整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          單點登錄系統實現

          單點登錄系統實現

          點登錄系統實現基于SpringBoot

          今天的干貨有點濕,里面夾雜著我的淚水。可能也只有代碼才能讓我暫時的平靜。通過本章內容你將學到單點登錄系統和傳統登錄系統的區別,單點登錄系統設計思路,Spring4 Java配置方式整合HttpClient,整合Redis ,HttpClient簡易教程。還在等什么?擼起袖子開始干吧!

          效果圖:8081端口是sso系統,其他兩個8082和8083端口模擬兩個系統。登錄成功后檢查Redis數據庫中是否有值

          技術:SpringBoot,SpringMVC,Spring,SpringData,Redis,HttpClient

          說明:本章的用戶登錄注冊的代碼部分已經在SpringBoot基礎入門中介紹過了,這里不會重復貼代碼。

          源碼: https://github.com/ITDragonBlog/daydayup/tree/master/SpringBoot-SSO

          SpringBoot基礎入門:http://www.cnblogs.com/itdragon/p/8047132.html

          單點登錄系統簡介

          在傳統的系統,或者是只有一個服務器的系統中。Session在一個服務器中,各個模塊都可以直接獲取,只需登錄一次就進入各個模塊。若在服務器集群或者是分布式系統架構中,每個服務器之間的Session并不是共享的,這會出現每個模塊都要登錄的情況。這時候需要通過單點登錄系統(Single Sign On)將用戶信息存在Redis數據庫中實現Session共享的效果。從而實現一次登錄就可以訪問所有相互信任的應用系統。

          單點登錄系統實現

          Maven項目核心配置文件 pom.xml 需要在原來的基礎上添加 httpclient和jedis jar包

           <dependency> <!-- http client version is 4.5.3 -->
          <groupId>org.apache.httpcomponents</groupId>
          <artifactId>httpclient</artifactId>
          </dependency>
          <dependency> <!-- redis java client version is 2.9.0 -->
          <groupId>redis.clients</groupId>
          <artifactId>jedis</artifactId>
          </dependency>

          Spring4 Java配置方式

          這里,我們需要整合httpclient用于各服務之間的通訊(也可以用okhttp)。同時還需要整合redis用于存儲用戶信息(Session共享)。

          在Spring3.x之前,一般在應用的基本配置用xml,比如數據源、資源文件等。業務開發用注解,比如Component,Service,Controller等。其實在Spring3.x的時候就已經提供了Java配置方式。現在的Spring4.x和SpringBoot都開始推薦使用Java配置方式配置bean。它可以使bean的結構更加的清晰。

          整合 HttpClient

          HttpClient 是 Apache Jakarta Common 下的子項目,用來提供高效的、最新的、功能豐富的支持 HTTP 協議的客戶端編程工具包,并且它支持 HTTP 協議最新的版本和建議。HttpClient4.5系列教程 : http://blog.csdn.net/column/details/httpclient.html

          首先在src/main/resources 目錄下創建 httpclient.properties 配置文件

          #設置整個連接池默認最大連接數
          http.defaultMaxPerRoute=100
          #設置整個連接池最大連接數
          http.maxTotal=300
          #設置請求超時
          http.connectTimeout=1000
          #設置從連接池中獲取到連接的最長時間
          http.connectionRequestTimeout=500
          #設置數據傳輸的最長時間
          http.socketTimeout=10000

          然后在 src/main/java/com/itdragon/config 目錄下創建 HttpclientSpringConfig.java 文件

          這里用到了四個很重要的注解

          @Configuration : 作用于類上,指明該類就相當于一個xml配置文件

          @Bean : 作用于方法上,指明該方法相當于xml配置中的

          @PropertySource : 指定讀取的配置文件,引入多個value={"xxx:xxx","xxx:xxx"},ignoreResourceNotFound=true 文件不存在時忽略

          @Value : 獲取配置文件的值,該注解還有很多語法知識,這里暫時不擴展開

          package com.itdragon.config;

          import java.util.concurrent.TimeUnit;
          import org.apache.http.client.config.RequestConfig;
          import org.apache.http.impl.client.CloseableHttpClient;
          import org.apache.http.impl.client.HttpClients;
          import org.apache.http.impl.client.IdleConnectionEvictor;
          import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
          import org.springframework.beans.factory.annotation.Autowired;
          import org.springframework.beans.factory.annotation.Value;
          import org.springframework.context.annotation.Bean;
          import org.springframework.context.annotation.Configuration;
          import org.springframework.context.annotation.PropertySource;
          import org.springframework.context.annotation.Scope;

          /**
          * @Configuration 作用于類上,相當于一個xml配置文件
          * @Bean 作用于方法上,相當于xml配置中的<bean>
          * @PropertySource 指定讀取的配置文件
          * @Value 獲取配置文件的值
          */
          @Configuration
          @PropertySource(value="classpath:httpclient.properties")
          public class HttpclientSpringConfig {

          @Value("${http.maxTotal}")
          private Integer httpMaxTotal;

          @Value("${http.defaultMaxPerRoute}")
          private Integer httpDefaultMaxPerRoute;

          @Value("${http.connectTimeout}")
          private Integer httpConnectTimeout;

          @Value("${http.connectionRequestTimeout}")
          private Integer httpConnectionRequestTimeout;

          @Value("${http.socketTimeout}")
          private Integer httpSocketTimeout;

          @Autowired
          private PoolingHttpClientConnectionManager manager;

          @Bean
          public PoolingHttpClientConnectionManager poolingHttpClientConnectionManager {
          PoolingHttpClientConnectionManager poolingHttpClientConnectionManager=new PoolingHttpClientConnectionManager;
          // 最大連接數
          poolingHttpClientConnectionManager.setMaxTotal(httpMaxTotal);
          // 每個主機的最大并發數
          poolingHttpClientConnectionManager.setDefaultMaxPerRoute(httpDefaultMaxPerRoute);
          return poolingHttpClientConnectionManager;
          }

          @Bean // 定期清理無效連接
          public IdleConnectionEvictor idleConnectionEvictor {
          return new IdleConnectionEvictor(manager, 1L, TimeUnit.HOURS);
          }

          @Bean // 定義HttpClient對象 注意該對象需要設置scope="prototype":多例對象
          @Scope("prototype")
          public CloseableHttpClient closeableHttpClient {
          return HttpClients.custom.setConnectionManager(this.manager).build;
          }

          @Bean // 請求配置
          public RequestConfig requestConfig {
          return RequestConfig.custom.setConnectTimeout(httpConnectTimeout) // 創建連接的最長時間
          .setConnectionRequestTimeout(httpConnectionRequestTimeout) // 從連接池中獲取到連接的最長時間
          .setSocketTimeout(httpSocketTimeout) // 數據傳輸的最長時間
          .build;
          }
          }

          整合 Redis

          SpringBoot官方其實提供了spring-boot-starter-redis pom 幫助我們快速開發,但我們也可以自定義配置,這樣可以更方便地掌控。Redis 系列教程 : http://www.cnblogs.com/itdragon/category/1122427.html

          首先在src/main/resources 目錄下創建 redis.properties 配置文件設置Redis主機的ip地址和端口號,和存入Redis數據庫中的key以及存活時間。這里為了方便測試,存活時間設置的比較小。這里的配置是單例Redis。

          redis.node.host=192.168.225.131
          redis.node.port=6379

          REDIS_USER_SESSION_KEY=REDIS_USER_SESSION
          SSO_SESSION_EXPIRE=30

          在src/main/java/com/itdragon/config 目錄下創建 RedisSpringConfig.java 文件

          package com.itdragon.config;

          import java.util.ArrayList;
          import java.util.List;
          import org.springframework.beans.factory.annotation.Value;
          import org.springframework.context.annotation.Bean;
          import org.springframework.context.annotation.Configuration;
          import org.springframework.context.annotation.PropertySource;
          import redis.clients.jedis.JedisPool;
          import redis.clients.jedis.JedisPoolConfig;
          import redis.clients.jedis.JedisShardInfo;
          import redis.clients.jedis.ShardedJedisPool;

          @Configuration
          @PropertySource(value="classpath:redis.properties")
          public class RedisSpringConfig {

          @Value("${redis.maxTotal}")
          private Integer redisMaxTotal;

          @Value("${redis.node.host}")
          private String redisNodeHost;

          @Value("${redis.node.port}")
          private Integer redisNodePort;

          private JedisPoolConfig jedisPoolConfig {
          JedisPoolConfig jedisPoolConfig=new JedisPoolConfig;
          jedisPoolConfig.setMaxTotal(redisMaxTotal);
          return jedisPoolConfig;
          }

          @Bean
          public JedisPool getJedisPool{ // 省略第一個參數則是采用 Protocol.DEFAULT_DATABASE
          JedisPool jedisPool=new JedisPool(jedisPoolConfig, redisNodeHost, redisNodePort);
          return jedisPool;
          }

          @Bean
          public ShardedJedisPool shardedJedisPool {
          List<JedisShardInfo> jedisShardInfos=new ArrayList<JedisShardInfo>;
          jedisShardInfos.add(new JedisShardInfo(redisNodeHost, redisNodePort));
          return new ShardedJedisPool(jedisPoolConfig, jedisShardInfos);
          }
          }

          Service 層

          在src/main/java/com/itdragon/service 目錄下創建 UserService.java 文件,它負責三件事情

          第一件事情:驗證用戶信息是否正確,并將登錄成功的用戶信息保存到Redis數據庫中。

          第二件事情:負責判斷用戶令牌是否過期,若沒有則刷新令牌存活時間。

          第三件事情:負責從Redis數據庫中刪除用戶信息。

          這里用到了一些工具類,不影響學習,可以從源碼中直接獲取。

          package com.itdragon.service;

          import java.util.UUID;
          import javax.servlet.http.HttpServletRequest;
          import javax.servlet.http.HttpServletResponse;
          import javax.transaction.Transactional;
          import org.springframework.beans.factory.annotation.Autowired;
          import org.springframework.beans.factory.annotation.Value;
          import org.springframework.context.annotation.PropertySource;
          import org.springframework.stereotype.Service;
          import org.springframework.util.StringUtils;
          import com.itdragon.pojo.ItdragonResult;
          import com.itdragon.pojo.User;
          import com.itdragon.repository.JedisClient;
          import com.itdragon.repository.UserRepository;
          import com.itdragon.utils.CookieUtils;
          import com.itdragon.utils.ItdragonUtils;
          import com.itdragon.utils.JsonUtils;

          @Service
          @Transactional
          @PropertySource(value="classpath:redis.properties")
          public class UserService {

          @Autowired
          private UserRepository userRepository;

          @Autowired
          private JedisClient jedisClient;

          @Value("${REDIS_USER_SESSION_KEY}")
          private String REDIS_USER_SESSION_KEY;

          @Value("${SSO_SESSION_EXPIRE}")
          private Integer SSO_SESSION_EXPIRE;

          public ItdragonResult userLogin(String account, String password,
          HttpServletRequest request, HttpServletResponse response) {
          // 判斷賬號密碼是否正確
          User user=userRepository.findByAccount(account);
          if (!ItdragonUtils.decryptPassword(user, password)) {
          return ItdragonResult.build(400, "賬號名或密碼錯誤");
          }
          // 生成token
          String token=UUID.randomUUID.toString;
          // 清空密碼和鹽避免泄漏
          String userPassword=user.getPassword;
          String userSalt=user.getSalt;
          user.setPassword;
          user.setSalt;
          // 把用戶信息寫入 redis
          jedisClient.set(REDIS_USER_SESSION_KEY + ":" + token, JsonUtils.objectToJson(user));
          // user 已經是持久化對象,被保存在session緩存當中,若user又重新修改屬性值,那么在提交事務時,此時 hibernate對象就會拿當前這個user對象和保存在session緩存中的user對象進行比較,如果兩個對象相同,則不會發送update語句,否則會發出update語句。
          user.setPassword(userPassword);
          user.setSalt(userSalt);
          // 設置 session 的過期時間
          jedisClient.expire(REDIS_USER_SESSION_KEY + ":" + token, SSO_SESSION_EXPIRE);
          // 添加寫 cookie 的邏輯,cookie 的有效期是關閉瀏覽器就失效。
          CookieUtils.setCookie(request, response, "USER_TOKEN", token);
          // 返回token
          return ItdragonResult.ok(token);
          }

          public void logout(String token) {
          jedisClient.del(REDIS_USER_SESSION_KEY + ":" + token);
          }

          public ItdragonResult queryUserByToken(String token) {
          // 根據token從redis中查詢用戶信息
          String json=jedisClient.get(REDIS_USER_SESSION_KEY + ":" + token);
          // 判斷是否為空
          if (StringUtils.isEmpty(json)) {
          return ItdragonResult.build(400, "此session已經過期,請重新登錄");
          }
          // 更新過期時間
          jedisClient.expire(REDIS_USER_SESSION_KEY + ":" + token, SSO_SESSION_EXPIRE);
          // 返回用戶信息
          return ItdragonResult.ok(JsonUtils.jsonToPojo(json, User.class));
          }
          }

          Controller 層

          負責跳轉登錄頁面跳轉

          package com.itdragon.controller;

          import org.springframework.stereotype.Controller;
          import org.springframework.ui.Model;
          import org.springframework.web.bind.annotation.RequestMapping;

          @Controller
          public class PageController {

          @RequestMapping("/login")
          public String showLogin(String redirect, Model model) {
          model.addAttribute("redirect", redirect);
          return "login";
          }

          }

          負責用戶的登錄,退出,獲取令牌的操作

          package com.itdragon.controller;

          import javax.servlet.http.HttpServletRequest;
          import javax.servlet.http.HttpServletResponse;
          import org.springframework.beans.factory.annotation.Autowired;
          import org.springframework.stereotype.Controller;
          import org.springframework.web.bind.annotation.PathVariable;
          import org.springframework.web.bind.annotation.RequestMapping;
          import org.springframework.web.bind.annotation.RequestMethod;
          import org.springframework.web.bind.annotation.ResponseBody;
          import com.itdragon.pojo.ItdragonResult;
          import com.itdragon.service.UserService;

          @Controller
          @RequestMapping("/user")
          public class UserController {

          @Autowired
          private UserService userService;

          @RequestMapping(value="/login", method=RequestMethod.POST)
          @ResponseBody
          public ItdragonResult userLogin(String username, String password,
          HttpServletRequest request, HttpServletResponse response) {
          try {
          ItdragonResult result=userService.userLogin(username, password, request, response);
          return result;
          } catch (Exception e) {
          e.printStackTrace;
          return ItdragonResult.build(500, "");
          }
          }

          @RequestMapping(value="/logout/{token}")
          public String logout(@PathVariable String token) {
          userService.logout(token); // 思路是從Redis中刪除key,實際情況請和業務邏輯結合
          return "index";
          }

          @RequestMapping("/token/{token}")
          @ResponseBody
          public Object getUserByToken(@PathVariable String token) {
          ItdragonResult result=;
          try {
          result=userService.queryUserByToken(token);
          } catch (Exception e) {
          e.printStackTrace;
          result=ItdragonResult.build(500, "");
          }
          return result;
          }
          }

          視圖層

          一個簡單的登錄頁面

          <%@ page language="java" contentType="text/html; charset=UTF-8"
          pageEncoding="UTF-8"%>
          <!doctype html>
          <html lang="zh">
          <head>
          <meta name="viewport" content="initial-scale=1.0, width=device-width, user-scalable=no" />
          <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
          <meta http-equiv="X-UA-Compatible" content="IE=edge,Chrome=1" />
          <meta http-equiv="X-UA-Compatible" content="IE=8" />
          <title>歡迎登錄</title>
          <link type="image/x-icon" href="images/favicon.ico" rel="shortcut icon">
          <link rel="stylesheet" href="static/css/main.css" />
          </head>
          <body>
          <div class="wrapper">
          <div class="container">
          <h1>Welcome</h1>
          <form method="post" onsubmit="return false;" class="form">
          <input type="text" value="itdragon" name="username" placeholder="Account"/>
          <input type="password" value="123456789" name="password" placeholder="Password"/>
          <button type="button" id="login-button">Login</button>
          </form>
          </div>
          <ul class="bg-bubbles">
          <li></li>
          <li></li>
          <li></li>
          <li></li>
          <li></li>
          <li></li>
          <li></li>
          <li></li>
          <li></li>
          <li></li>
          </ul>
          </div>
          <script type="text/javascript" src="static/js/jquery-1.10.1.min.js" ></script>
          <script type="text/javascript">
          var redirectUrl="${redirect}"; // 瀏覽器中返回的URL
          function doLogin {
          $.post("/user/login", $(".form").serialize,function(data){
          if (data.status==200) {
          if (redirectUrl=="") {
          location.href="http://localhost:8082";
          } else {
          location.href=redirectUrl;
          }
          } else {
          alert("登錄失敗,原因是:" + data.msg);
          }
          });
          }
          $(function{
          $("#login-button").click(function{
          doLogin;
          });
          });
          </script>
          </body>
          </html>

          HttpClient 基礎語法

          這里封裝了get,post請求的方法

          package com.itdragon.utils;

          import java.io.IOException;
          import java.net.URI;
          import java.util.ArrayList;
          import java.util.List;
          import java.util.Map;
          import org.apache.http.NameValuePair;
          import org.apache.http.client.entity.UrlEncodedFormEntity;
          import org.apache.http.client.methods.CloseableHttpResponse;
          import org.apache.http.client.methods.HttpGet;
          import org.apache.http.client.methods.HttpPost;
          import org.apache.http.client.utils.URIBuilder;
          import org.apache.http.entity.ContentType;
          import org.apache.http.entity.StringEntity;
          import org.apache.http.impl.client.CloseableHttpClient;
          import org.apache.http.impl.client.HttpClients;
          import org.apache.http.message.BasicNameValuePair;
          import org.apache.http.util.EntityUtils;

          public class HttpClientUtil {

          public static String doGet(String url) {// 無參數get請求
          return doGet(url, );
          }

          public static String doGet(String url, Map<String, String> param) {// 帶參數get請求
          CloseableHttpClient httpClient=HttpClients.createDefault;// 創建一個默認可關閉的Httpclient 對象
          String resultMsg="";// 設置返回值
          CloseableHttpResponse response=;// 定義HttpResponse 對象
          try {
          URIBuilder builder=new URIBuilder(url);// 創建URI,可以設置host,設置參數等
          if (param !=) {
          for (String key : param.keySet) {
          builder.addParameter(key, param.get(key));
          }
          }
          URI uri=builder.build;
          HttpGet httpGet=new HttpGet(uri);// 創建http GET請求
          response=httpClient.execute(httpGet); // 執行請求
          if (response.getStatusLine.getStatusCode==200) { // 判斷返回狀態為200則給返回值賦值
          resultMsg=EntityUtils.toString(response.getEntity, "UTF-8");
          }
          } catch (Exception e) {
          e.printStackTrace;
          } finally { // 不要忘記關閉
          try {
          if (response !=) {
          response.close;
          }
          httpClient.close;
          } catch (IOException e) {
          e.printStackTrace;
          }
          }
          return resultMsg;
          }

          public static String doPost(String url) { // 無參數post請求
          return doPost(url, );
          }

          public static String doPost(String url, Map<String, String> param) {// 帶參數post請求
          CloseableHttpClient httpClient=HttpClients.createDefault;// 創建一個默認可關閉的Httpclient 對象
          CloseableHttpResponse response=;
          String resultMsg="";
          try {
          HttpPost httpPost=new HttpPost(url); // 創建Http Post請求
          if (param !=) { // 創建參數列表
          List<NameValuePair> paramList=new ArrayList<NameValuePair>;
          for (String key : param.keySet) {
          paramList.add(new BasicNameValuePair(key, param.get(key)));
          }
          UrlEncodedFormEntity entity=new UrlEncodedFormEntity(paramList);// 模擬表單
          httpPost.setEntity(entity);
          }
          response=httpClient.execute(httpPost);// 執行http請求
          if (response.getStatusLine.getStatusCode==200) {
          resultMsg=EntityUtils.toString(response.getEntity, "utf-8");
          }
          } catch (Exception e) {
          e.printStackTrace;
          } finally {
          try {
          if (response !=) {
          response.close;
          }
          httpClient.close;
          } catch (IOException e) {
          e.printStackTrace;
          }
          }
          return resultMsg;
          }

          public static String doPostJson(String url, String json) {
          CloseableHttpClient httpClient=HttpClients.createDefault;
          CloseableHttpResponse response=;
          String resultString="";
          try {
          HttpPost httpPost=new HttpPost(url);
          StringEntity entity=new StringEntity(json, ContentType.APPLICATION_JSON);
          httpPost.setEntity(entity);
          response=httpClient.execute(httpPost);
          if (response.getStatusLine.getStatusCode==200) {
          resultString=EntityUtils.toString(response.getEntity, "utf-8");
          }
          } catch (Exception e) {
          e.printStackTrace;
          } finally {
          try {
          if (response !=) {
          response.close;
          }
          httpClient.close;
          } catch (IOException e) {
          e.printStackTrace;
          }
          }
          return resultString;
          }
          }

          Spring 自定義攔截器

          這里是另外一個項目 itdragon-service-test-sso 中的代碼,首先在src/main/resources/spring/springmvc.xml 中配置攔截器,設置那些請求需要攔截

           <!-- 攔截器配置 -->
          <mvc:interceptors>
          <mvc:interceptor>
          <mvc:mapping path="/github/**"/>
          <bean class="com.itdragon.interceptors.UserLoginHandlerInterceptor"/>
          </mvc:interceptor>
          </mvc:interceptors>

          然后在 src/main/java/com/itdragon/interceptors 目錄下創建 UserLoginHandlerInterceptor.java 文件

          package com.itdragon.interceptors;

          import javax.servlet.http.HttpServletRequest;
          import javax.servlet.http.HttpServletResponse;
          import org.springframework.beans.factory.annotation.Autowired;
          import org.springframework.util.StringUtils;
          import org.springframework.web.servlet.HandlerInterceptor;
          import org.springframework.web.servlet.ModelAndView;
          import com.itdragon.pojo.User;
          import com.itdragon.service.UserService;
          import com.itdragon.utils.CookieUtils;

          public class UserLoginHandlerInterceptor implements HandlerInterceptor {

          public static final String COOKIE_NAME="USER_TOKEN";

          @Autowired
          private UserService userService;

          @Override
          public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
          throws Exception {
          String token=CookieUtils.getCookieValue(request, COOKIE_NAME);
          User user=this.userService.getUserByToken(token);
          if (StringUtils.isEmpty(token) ||==user) {
          // 跳轉到登錄頁面,把用戶請求的url作為參數傳遞給登錄頁面。
          response.sendRedirect("http://localhost:8081/login?redirect=" + request.getRequestURL);
          // 返回false
          return false;
          }
          // 把用戶信息放入Request
          request.setAttribute("user", user);
          // 返回值決定handler是否執行。true:執行,false:不執行。
          return true;
          }

          @Override
          public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
          ModelAndView modelAndView) throws Exception {
          }

          @Override
          public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
          Exception ex) throws Exception {
          }
          }

          可能存在的問題

          SpringData 自動更新問題

          SpringData 是基于Hibernate的。當User 已經是持久化對象,被保存在session緩存當中。若User又重新修改屬性值,在提交事務時,此時hibernate對象就會拿當前這個User對象和保存在session緩存中的User對象進行比較,如果兩個對象相同,則不會發送update語句,否則,會發出update語句。

          筆者采用比較傻的方法,就是在提交事務之前把數據還原。各位如果有更好的辦法請告知,謝謝!

          參考博客:http://www.cnblogs.com/xiaoluo501395377/p/3380270.html

          檢查用戶信息是否保存

          登錄成功后,進入Redis客戶端查看用戶信息是否保存成功。同時為了方便測試,也可以刪除這個key。

          [root@localhost bin]# ./redis-cli -h 192.168.225.131 -p 6379
          192.168.225.131:6379>
          192.168.225.131:6379> keys *
          1) "REDIS_USER_SESSION:1d869ac0-3d22-4e22-bca0-37c8dfade9ad"
          192.168.225.131:6379> get REDIS_USER_SESSION:1d869ac0-3d22-4e22-bca0-37c8dfade9ad
          "{\"id\":3,\"account\":\"itdragon\",\"userName\":\"ITDragonGit\",\"plainPassword\":,\"password\":,\"salt\":,\"iphone\":\"12349857999\",\"email\":\"itdragon@git.com\",\"platform\":\"github\",\"createdDate\":\"2017-12-22 21:11:19\",\"updatedDate\":\"2017-12-22 21:11:19\"}"

          總結

          1 單點登錄系統通過將用戶信息放在Redis數據庫中實現共享Session效果。

          2 Java 配置方式使用四個注解 @Configuration @Bean @PropertySource @Value 。

          3 Spring 攔截器的設置。

          4 HttpClient 的使用。

          5 祝大家圣誕節快樂!

          源碼: https://github.com/ITDragonBlog/daydayup/tree/master/SpringBoot-SSO

          到這里,基于SpringBoot的單點登錄系統就結束了,有什么不對的地方請指出。

          輯導語:在合適的時機出現彈窗可以有效地吸引用戶的興趣,進而提高用戶的留存率和轉化率,本篇文章作者分享了構建獨立站彈窗的要點和8個有意思的案例,感興趣的一起來看一下,希望對你有幫助。

          內容提要:

          • 構建彈窗的要點:給誰看、目的、選擇時機、內容和設計需要注意的、測試、最終目的是什么。
          • 案例:挽留彈窗、點擊訂閱彈窗、選擇彈窗、注冊彈窗、復古彈窗等。

          一、如何做好一個彈窗

          彈窗是出現在用戶屏幕上自動彈出的窗口消息。

          在合適的時機、以恰當的風格呈現合適的內容,才能讓用戶更容易接受,并按照提示操作。

          那么,構建彈窗的時候具體要注意哪些要點呢?

          首先,要弄清彈窗是給誰看的。結合自己的數據或者使用的精細化運營工具,將用戶進行分類。分類方法例如:

          • 來源:廣告、社交網絡、搜索引擎、直接訪問、外部鏈接。
          • 用戶對產品的認知和認可程度:新到訪、已購用戶、回訪用戶。
          • 根據用戶的站內行為推測:性別、年齡段、對產品的關注點。

          上述分類也可結合,獲得對一類用戶更精準的認知。例如:這些用戶都從FB來,在本站買過2件以上的男士正裝襯衫。

          第二,彈窗的目的是什么?

          有了用戶分類,我們就有了針對不同類型客戶的不同運營目的。

          可以從用戶購買旅程入手——認知、興趣、考慮、行動、忠誠度。

          • 認知、興趣:要不要讓最核心、最受歡迎的產品跳出來?
          • 興趣、考慮:要不要跳出一個折扣專屬代碼?
          • 考慮、行動:要不要留下聯系方式,給一個代金券?
          • 行動、忠誠度:要不要獲得訂閱許可、通知新品和活動?

          第三,把握時機。

          時機不光是時間,也是地點,或者說是頁面。可以把用戶的購物旅程和界面畫成一個平面直角坐標圖:

          正如上面這個示意圖,我們可以大概推演出:

          • 哪些頁面承載著哪些用戶旅程作用;
          • 什么目的的彈窗放在哪個界面更合適;
          • 彈窗是否是達成目的的最佳手段?

          第四,就是草擬彈窗內容啦。有趣的內容使您的彈窗更具響應性。這里有幾個小建議:

          • 明確并按照上面第一、二、三點的方向創作;
          • 用戶付出了信息,能得到什么;
          • 包含一個引人注目的call to action;
          • 突出產品核心價值;
          • 來點兒小幽默。

          第五,設計要注意的:

          • 符合上述目的性:用字體、大小寫、字號等方式突出諸如折扣、活動等等關鍵信息;
          • 基礎色、風格等盡量和品牌調性統一;
          • 根據需要靈活展示核心產品。

          第六,測試。

          可以做大量的A/B測試,用數據說話,終止一切爭論!

          • 各種鏈接設置是否準確有效?
          • 語言和設計有細微差別,到底哪種更好?
          • 要不要來個大膽的創新(慎重考慮)?

          第七,想想上述第二條的彈窗目的是不是最終目的?

          后面還需要哪些動作配合呢?如果從CVR和LTV視角看整個獨立站運營的話,運營是貫穿于用戶的整個購物生命周期的。

          我們當然希望用戶買的越多越好,所以運營無止境啊。

          • 收集到郵箱后,根據用戶分類考慮郵件撰寫;
          • 讓用戶點擊訂閱;
          • 發出郵件,獲得回撈/忠誠用戶再次來訪后,用什么界面接應?
          • 要不要做新的彈窗——讓我們回到第二點。

          二、彈窗典型案例

          方法說的夠多啦,下面給您帶來8個有意思的彈窗,希望能給您一些靈感~讓我們一起來看看:

          1. Co-Schedule 的挽留彈窗

          以“注冊只需30秒”開頭,寓意注冊過程實用簡單。

          這樣,嫌麻煩的一些用戶可能會回心轉意。Co-Schedule的主營業務是營銷日歷軟件,向用戶提供解決方案。

          該消息以“終于可以將所有營銷活動放在一個地方了”結束。因為了解受眾是營銷人員,所以Co-Schedule通過信息暗示:選擇本產品對于營銷是非常有利的。

          這樣,信息的針對性更強。在彈窗中顯示產品的預覽圖像對于推廣產品很有用。

          在購買和訂閱服務之前,用戶可以預先看到他們會得到什么。顯示“安排演示”的按鈕重定向到演示預約,并提供了試用產品的機會。

          2. Frank Body 的點擊訂閱彈窗

          Frank Body 是一個專注于痤瘡問題的護膚品牌。

          他們的彈窗充分展示了如何讓一切元素與品牌風格一致:網站標簽以“Hi babe”開頭,語氣真誠,有一點小曖昧。

          彈窗標題是“脫了褲子”。幽默地讓用戶注意到首次購買時可以打9折。“給你的收件箱一點愛”是一個很聰明的文案。

          語言風格符合品牌調性,且達到了詢問授權的目的:訂閱就可以了解新產品和產品使用提示。品牌方可以有效地與客戶進行有效的溝通。此外,彈窗的風格與網站的整體設計相似,看起來很溫馨。

          3. 樂高的選擇彈窗

          樂高的這個彈窗提供了有關網站不同部分的信息,這對用戶很有用。

          它概括描述了人們可以在網站上看到的內容。對于樂高,這樣的選擇也很有用。

          “Continue”將用戶重定向到在線商城。

          “Start Playing”將用戶重定向到樂高的主頁和游戲區——從最開始就給訪問用戶進行一次很簡單的分類。

          這意味著樂高網站可以基于此提供更多有針對性的東西。彈出窗口的風格簡單明了,但適合品牌。

          此彈出窗口中使用了樂高標志和基礎色。此外,樂高角色顯示在游樂區部分,使其看起來更有趣。

          4. MeUndies 的注冊彈窗

          MeUndies 是一家內衣公司。

          他們在這一界面中使用了簡單明了的彈窗:“15% 折扣怎么樣?”獲得折扣的方式是用郵箱注冊。

          同時,MeUndies 把下一步也想好了—— “我想要城里最好的電子郵件,拜托”。這是一個號召語,表明品牌電子郵件的質量。

          在用戶勾選后,品牌也獲得了給用戶發郵件的授權。

          5. Old Navy 的注冊彈窗

          Old Navy 的彈窗以 20% 的折扣為標題,以“這是你掙來的”這樣的正向話語收尾。

          彈窗的描述部分繼續使用正向的形容詞和短語。這些有力的話語在人們的心目中創造了一個積極的形象。

          “NEW arrivals, BIG deals, EXCLUSIVE sales。”用大寫形容詞來增強沖擊力,提高用戶參與度。

          6. Pitviper 的復古風格彈窗

          墨鏡公司 Pitviper 的復古風格網站上顯示著一個老式窗口——它與網站主頁融為一體,甚至看起來都不像一個彈窗。

          除了非常注意品牌辨識度以外,這個彈窗運用幽默的語言。標題是“我們真的不應該這樣做”;

          在文案部分,他們又說:“通訊錄已經很滿了,但是可以擠擠把你加進去”。

          文案暗示了訂閱的用戶很多——也許并沒這么多,但是跟這個品牌一樣有幽默感的人應該不介意留個聯系方式吧?

          7. Pool Factory 的郵件注冊彈窗

          The Pool Factory 是一家銷售泳池相關產品的公司。

          他們的彈出式設計有類似于水花飛濺的效果,能夠很好地引起注意。標題簡單地說,“注冊并保存“,像水滴落到地面一樣干脆利落,沒有添加其他文字上的創意。

          因為設計已經別具一格,并且很有品牌識別度。

          8. Revolve 的郵件注冊彈窗

          設計師服裝品牌 Revolve 的電子郵件彈窗同樣以折扣優惠開頭。

          文案用“嘿,靚仔!”的問候語,代表對用戶的認可和友好態度。

          用戶可以選擇要接收男士、女士新品或者折扣信息,也可以選擇都接收。

          這既讓用戶體會到了品牌的用心,又覺得自己被好好關注到了。

          而品牌方的群發郵件也更精確——至少不會在性別上犯錯了。

          三、寫在最后

          我們經常聽到做獨立站的大家一聽到彈窗就是一臉鄙視覺得沒用,但不得不說有時候經驗真的會成為一種障礙——你擁抱新事物的障礙。

          多嘗試,少發愁,就比如“人人抱怨”的彈窗,用得好是寶,亂彈就真能把用戶全嚇跑。

          本文由 @李景巖 原創發布于人人都是產品經理。未經許可,禁止轉載。

          題圖來自 Unsplash,基于CC0協議。

          能每一個前端工程師都想要理解瀏覽器的工作原理。

          我們希望知道從在瀏覽器地址欄中輸入 url 到頁面展現的短短幾秒內瀏覽器究竟做了什么;

          我們希望了解平時常常聽說的各種代碼優化方案是究竟為什么能起到優化的作用;

          我們希望更細化的了解瀏覽器的渲染流程。

          瀏覽器的多進程架構

          一個好的程序常常被劃分為幾個相互獨立又彼此配合的模塊,瀏覽器也是如此,以 Chrome 為例,它由多個進程組成,每個進程都有自己核心的職責,它們相互配合完成瀏覽器的整體功能,每個進程中又包含多個線程,一個進程內的多個線程也會協同工作,配合完成所在進程的職責。

          對一些前端開發同學來說,進程和線程的概念可能會有些模糊,為了更好的理解瀏覽器的多進程架構,這里我們簡單討論一下進程和線程。

          進程(process)和線程(thread)


          進程就像是一個有邊界的生產廠間,而線程就像是廠間內的一個個員工,可以自己做自己的事情,也可以相互配合做同一件事情。

          當我們啟動一個應用,計算機會創建一個進程,操作系統會為進程分配一部分內存,應用的所有狀態都會保存在這塊內存中,應用也許還會創建多個線程來輔助工作,這些線程可以共享這部分內存中的數據。如果應用關閉,進程會被終結,操作系統會釋放相關內存。更生動的示意圖如下:

          一個進程還可以要求操作系統生成另一個進程來執行不同的任務,系統會為新的進程分配獨立的內存,兩個進程之間可以使用 IPC (Inter Process Communication)進行通信。很多應用都會采用這樣的設計,如果一個工作進程反應遲鈍,重啟這個進程不會影響應用其它進程的工作。

          如果對進程及線程的理解還存在疑惑,可以參考下述文章:

          http://www.ruanyifeng.com/blog/2013/04/processes_and_threads.html

          瀏覽器的架構

          有了上面的知識做鋪墊,我們可以更合理的討論瀏覽器的架構了,其實如果要開發一個瀏覽器,它可以是單進程多線程的應用,也可以是使用 IPC 通信的多進程應用。


          不同瀏覽器的架構模型

          不同瀏覽器采用了不同的架構模式,這里并不存在標準,本文以 Chrome 為例進行說明 :

          Chrome 采用多進程架構,其頂層存在一個 Browser process 用以協調瀏覽器的其它進程。


          Chrome 的不同進程

          具體說來,Chrome 的主要進程及其職責如下:

          Browser Process:

          • 負責包括地址欄,書簽欄,前進后退按鈕等部分的工作;
          • 負責處理瀏覽器的一些不可見的底層操作,比如網絡請求和文件訪問;

          Renderer Process:

          • 負責一個 tab 內關于網頁呈現的所有事情

          Plugin Process:

          • 負責控制一個網頁用到的所有插件,如 flash
          • GPU Process
          • 負責處理 GPU 相關的任務



          不同進程負責的瀏覽器區域示意圖

          Chrome 還為我們提供了「任務管理器」,供我們方便的查看當前瀏覽器中運行的所有進程及每個進程占用的系統資源,右鍵單擊還可以查看更多類別信息。

          通過「頁面右上角的三個點點點 — 更多工具 — 任務管理器」即可打開相關面板。

          Chrome 多進程架構的優缺點

          優點

          某一渲染進程出問題不會影響其他進程

          更為安全,在系統層面上限定了不同進程的權限

          缺點

          由于不同進程間的內存不共享,不同進程的內存常常需要包含相同的內容。

          為了節省內存,Chrome 限制了最多的進程數,最大進程數量由設備的內存和 CPU 能力決定,當達到這一限制時,新打開的 Tab 會共用之前同一個站點的渲染進程。

          測試了一下在 Chrome 中打開不斷打開知乎首頁,在 Mac i5 8g 上可以啟動四十多個渲染進程,之后新打開 tab 會合并到已有的渲染進程中。

          Chrome 把瀏覽器不同程序的功能看做服務,這些服務可以方便的分割為不同的進程或者合并為一個進程。以 Broswer Process 為例,如果 Chrome 運行在強大的硬件上,它會分割不同的服務到不同的進程,這樣 Chrome 整體的運行會更加穩定,但是如果 Chrome 運行在資源貧瘠的設備上,這些服務又會合并到同一個進程中運行,這樣可以節省內存,示意圖如下。

          iframe 的渲染 – Site Isolation

          在上面的進程圖中我們還可以看到一些進程下還存在著 Subframe,這就是 Site Isolation 機制作用的結果。

          Site Isolation 機制從 Chrome 67 開始默認啟用。這種機制允許在同一個 Tab 下的跨站 iframe 使用單獨的進程來渲染,這樣會更為安全。


          iframe 會采用不同的渲染進程

          Site Isolation 被大家看做里程碑式的功能, 其成功實現是多年工程努力的結果。Site Isolation 不是簡單的疊加多個進程。這種機制在底層改變了 iframe 之間通信的方法,Chrome 的其它功能都需要做對應的調整,比如說 devtools 需要相應的支持,甚至 Ctrl + F 也需要支持。關于 Site Isolation 的更多內容可參考下述鏈接:

          https://developers.google.com/web/updates/2018/07/site-isolation

          介紹完了瀏覽器的基本架構模式,接下來我們看看一個常見的導航過程對瀏覽器來說究竟發生了什么。

          導航過程發生了什么

          也許大多數人使用 Chrome 最多的場景就是在地址欄輸入關鍵字進行搜索或者輸入地址導航到某個網站,我們來看看瀏覽器是怎么看待這個過程的。

          我們知道瀏覽器 Tab 外的工作主要由 Browser Process 掌控,Browser Process 又對這些工作進一步劃分,使用不同線程進行處理:

          • UI thread : 控制瀏覽器上的按鈕及輸入框;
          • network thread: 處理網絡請求,從網上獲取數據;
          • storage thread: 控制文件等的訪問;



          瀏覽器主進程中的不同線程

          回到我們的問題,當我們在瀏覽器地址欄中輸入文字,并點擊回車獲得頁面內容的過程在瀏覽器看來可以分為以下幾步:

          1. 處理輸入

          UI thread 需要判斷用戶輸入的是 URL 還是 query;

          2. 開始導航

          當用戶點擊回車鍵,UI thread 通知 network thread 獲取網頁內容,并控制 tab 上的 spinner 展現,表示正在加載中。

          network thread 會執行 DNS 查詢,隨后為請求建立 TLS 連接。


          UI thread 通知 Network thread 加載相關信息

          如果 network thread 接收到了重定向請求頭如 301,network thread 會通知 UI thread 服務器要求重定向,之后,另外一個 URL 請求會被觸發。

          3. 讀取響應

          當請求響應返回的時候,network thread 會依據 Content-Type 及 MIME Type sniffing 判斷響應內容的格式。


          判斷響應內容的格式

          如果響應內容的格式是 HTML ,下一步將會把這些數據傳遞給 renderer process,如果是 zip 文件或者其它文件,會把相關數據傳輸給下載管理器。

          Safe Browsing 檢查也會在此時觸發,如果域名或者請求內容匹配到已知的惡意站點,network thread 會展示一個警告頁。此外 CORB 檢測也會觸發確保敏感數據不會被傳遞給渲染進程。


          4. 查找渲染進程

          當上述所有檢查完成,network thread 確信瀏覽器可以導航到請求網頁,network thread 會通知 UI thread 數據已經準備好,UI thread 會查找到一個 renderer process 進行網頁的渲染。


          收到 Network thread 返回的數據后,UI thread 查找相關的渲染進程

          由于網絡請求獲取響應需要時間,這里其實還存在著一個加速方案。當 UI thread 發送 URL 請求給 network thread 時,瀏覽器其實已經知道了將要導航到那個站點。UI thread 會并行的預先查找和啟動一個渲染進程,如果一切正常,當 network thread 接收到數據時,渲染進程已經準備就緒了,但是如果遇到重定向,準備好的渲染進程也許就不可用了,這時候就需要重啟一個新的渲染進程。

          5. 確認導航

          進過了上述過程,數據以及渲染進程都可用了, Browser Process 會給 renderer process 發送 IPC 消息來確認導航,一旦 Browser Process 收到 renderer process 的渲染確認消息,導航過程結束,頁面加載過程開始。

          此時,地址欄會更新,展示出新頁面的網頁信息。history tab 會更新,可通過返回鍵返回導航來的頁面,為了讓關閉 tab 或者窗口后便于恢復,這些信息會存放在硬盤中。


          6. 額外的步驟

          一旦導航被確認,renderer process 會使用相關的資源渲染頁面,下文中我們將重點介紹渲染流程。當 renderer process 渲染結束(渲染結束意味著該頁面內的所有的頁面,包括所有 iframe 都觸發了 onload 時),會發送 IPC 信號到 Browser process, UI thread 會停止展示 tab 中的 spinner。


          Renderer Process 發送 IPC 消息通知 browser process 頁面已經加載完成。

          當然上面的流程只是網頁首幀渲染完成,在此之后,客戶端依舊可下載額外的資源渲染出新的視圖。

          在這里我們可以明確一點,所有的 JS 代碼其實都由 renderer Process 控制的,所以在你瀏覽網頁內容的過程大部分時候不會涉及到其它的進程。不過也許你也曾經監聽過 beforeunload 事件,這個事件再次涉及到 Browser Process 和 renderer Process 的交互,當當前頁面關閉時(關閉 Tab ,刷新等等),Browser Process 需要通知 renderer Process 進行相關的檢查,對相關事件進行處理。


          瀏覽器進程發送 IPC 消息給渲染進程,通知要離開當前網站了

          如果導航由 renderer process 觸發(比如在用戶點擊某鏈接,或者 JS 執行 window.location="http://newsite.com" ) renderer process 會首先檢查是否有 beforeunload 事件處理器,導航請求由 renderer process 傳遞給 Browser process。

          如果導航到新的網站,會啟用一個新的 render process 來處理新頁面的渲染,老的進程會留下來處理類似 unload 等事件。

          關于頁面的生命周期,更多內容可參考 Page Lifecycle API 。


          瀏覽器進程發送 IPC 消息到新的渲染進程通知渲染新的頁面,同時通知舊的渲染進程卸載。

          除了上述流程,有些頁面還擁有 Service Worker (服務工作線程),Service Worker 讓開發者對本地緩存及判斷何時從網絡上獲取信息有了更多的控制權,如果 Service Worker 被設置為從本地 cache 中加載數據,那么就沒有必要從網上獲取更多數據了。

          值得注意的是 service worker 也是運行在渲染進程中的 JS 代碼,因此對于擁有 Service Worker 的頁面,上述流程有些許的不同。

          當有 Service Worker 被注冊時,其作用域會被保存,當有導航時,network thread 會在注冊過的 Service Worker 的作用域中檢查相關域名,如果存在對應的 Service worker,UI thread 會找到一個 renderer process 來處理相關代碼,Service Worker 可能會從 cache 中加載數據,從而終止對網絡的請求,也可能從網上請求新的數據。


          Service Worker 依據具體情形做處理。

          關于 Service Worker 的更多內容可參考:

          https://developers.google.com/web/fundamentals/primers/service-workers/lifecycle

          如果 Service Worker 最終決定通過網上獲取數據,Browser 進程 和 renderer 進程的交互其實會延后數據的請求時間 。Navigation Preload 是一種與 Service Worker 并行的加速加載資源的機制,服務端通過請求頭可以識別這類請求,而做出相應的處理。

          更多內容可參考:

          https://developers.google.com/web/updates/2017/02/navigation-preload

          渲染進程是如何工作的?

          渲染進程幾乎負責 Tab 內的所有事情,渲染進程的核心目的在于轉換 HTML CSS JS 為用戶可交互的 web 頁面。渲染進程中主要包含以下線程:


          渲染進程包含的線程

          1. 主線程 Main thread

          2. 工作線程 Worker thread

          3. 排版線程 Compositor thread

          4. 光柵線程 Raster thread

          后文我們將逐步介紹不同線程的職責,在此之前我們先看看渲染的流程。

          1. 構建 DOM

          當渲染進程接收到導航的確認信息,開始接受 HTML 數據時,主線程會解析文本字符串為 DOM。

          渲染 html 為 DOM 的方法由 HTML Standard 定義。

          2. 加載次級的資源

          網頁中常常包含諸如圖片,CSS,JS 等額外的資源,這些資源需要從網絡上或者 cache 中獲取。主進程可以在構建 DOM 的過程中會逐一請求它們,為了加速 preload scanner 會同時運行,如果在 html 中存在 <img><link> 等標簽,preload scanner 會把這些請求傳遞給 Browser process 中的 network thread 進行相關資源的下載。

          3.JS 的下載與執行

          當遇到 <script> 標簽時,渲染進程會停止解析 HTML,而去加載,解析和執行 JS 代碼,停止解析 html 的原因在于 JS 可能會改變 DOM 的結構(使用諸如 documwnt.write()等 API)。

          不過開發者其實也有多種方式來告知瀏覽器應對如何應對某個資源,比如說如果在<script> 標簽上添加了 async 或 defer 等屬性,瀏覽器會異步的加載和執行 JS 代碼,而不會阻塞渲染。更多的方法可參考 Resource Prioritization – Getting the Browser to Help You。

          4. 樣式計算

          僅僅渲染 DOM 還不足以獲知頁面的具體樣式,主進程還會基于 CSS 選擇器解析 CSS 獲取每一個節點的最終的計算樣式值。即使不提供任何 CSS,瀏覽器對每個元素也會有一個默認的樣式。


          渲染進程主線程計算每一個元素節點的最終樣式值

          5. 獲取布局

          想要渲染一個完整的頁面,除了獲知每個節點的具體樣式,還需要獲知每一個節點在頁面上的位置,布局其實是找到所有元素的幾何關系的過程。其具體過程如下:

          通過遍歷 DOM 及相關元素的計算樣式,主線程會構建出包含每個元素的坐標信息及盒子大小的布局樹。布局樹和 DOM 樹類似,但是其中只包含頁面可見的元素,如果一個元素設置了 display:none ,這個元素不會出現在布局樹上,偽元素雖然在 DOM 樹上不可見,但是在布局樹上是可見的。


          6. 繪制各元素

          即使知道了不同元素的位置及樣式信息,我們還需要知道不同元素的繪制先后順序才能正確繪制出整個頁面。在繪制階段,主線程會遍歷布局樹以創建繪制記錄。繪制記錄可以看做是記錄各元素繪制先后順序的筆記。


          主線程依據布局樹構建繪制記錄

          7. 合成幀

          熟悉 PS 等繪圖軟件的童鞋肯定對圖層這一概念不陌生,現代 Chrome 其實利用了這一概念來組合不同的層。

          復合是一種分割頁面為不同的層,并單獨柵格化,隨后組合為幀的技術。不同層的組合由 compositor 線程(合成器線程)完成。

          主線程會遍歷布局樹來創建層樹(layer tree),添加了 will-change CSS 屬性的元素,會被看做單獨的一層。


          主線程遍歷布局樹生成層樹

          你可能會想給每一個元素都添加上 will-change,不過組合過多的層也許會比在每一幀都柵格化頁面中的某些小部分更慢。為了更合理的使用層,可參考 堅持僅合成器的屬性和管理層計數 。

          一旦層樹被創建,渲染順序被確定,主線程會把這些信息通知給合成器線程,合成器線程會柵格化每一層。有的層的可以達到整個頁面的大小,因此,合成器線程將它們分成多個磁貼,并將每個磁貼發送到柵格線程,柵格線程會柵格化每一個磁貼并存儲在 GPU 顯存中。


          柵格線程會柵格化每一個磁貼并存儲在 GPU 顯存中

          一旦磁貼被光柵化,合成器線程會收集稱為繪制四邊形的磁貼信息以創建合成幀。

          合成幀隨后會通過 IPC 消息傳遞給瀏覽器進程,由于瀏覽器的 UI 改變或者其它拓展的渲染進程也可以添加合成幀,這些合成幀會被傳遞給 GPU 用以展示在屏幕上,如果滾動發生,合成器線程會創建另一個合成幀發送給 GPU。


          合成器線程會發送合成幀給 GPU 渲染

          合成器的優點在于,其工作無關主線程,合成器線程不需要等待樣式計算或者 JS 執行,這就是為什么合成器相關的動畫 最流暢,如果某個動畫涉及到布局或者繪制的調整,就會涉及到主線程的重新計算,自然會慢很多。

          瀏覽器對事件的處理

          瀏覽器通過對不同事件的處理來滿足各種交互需求,這一部分我們一起看看從瀏覽器的視角,事件是什么,在此我們先主要考慮鼠標事件。

          在瀏覽器的看來,用戶的所有手勢都是輸入,鼠標滾動,懸置,點擊等等都是。

          當用戶在屏幕上觸發諸如 touch 等手勢時,首先收到手勢信息的是 Browser process, 不過 Browser process 只會感知到在哪里發生了手勢,對 tab 內內容的處理是還是由渲染進程控制的。

          事件發生時,瀏覽器進程會發送事件類型及相應的坐標給渲染進程,渲染進程隨后找到事件對象并執行所有綁定在其上的相關事件處理函數。


          事件從瀏覽器進程傳送給渲染進程

          前文中,我們提到過合成器可以獨立于主線程之外通過合成柵格化層平滑的處理滾動。如果頁面中沒有綁定相關事件,組合器線程可以獨立于主線程創建組合幀。如果頁面綁定了相關事件處理器,主線程就不得不出來工作了。這時候合成器線程會怎么處理呢?

          這里涉及到一個專業名詞「理解非快速滾動區域(non-fast scrollable region)」由于執行 JS 是主線程的工作,當頁面合成時,合成器線程會標記頁面中綁定有事件處理器的區域為 non-fast scrollable region ,如果存在這個標注,合成器線程會把發生在此處的事件發送給主線程,如果事件不是發生在這些區域,合成器線程則會直接合成新的幀而不用等到主線程的響應。


          涉及 non-fast scrollable region 的事件,合成器線程會通知主線程進行相關處理。

          web 開發中常用的事件處理模式是事件委托,基于事件冒泡,我們常常在最頂層綁定事件:

          復制代碼

          document.body.addEventListener('touchstart', 
          event=> {
           if (event.target===area) {
           event.preventDefault();
           }
          }
          );
           
          

          上述做法很常見,但是如果從瀏覽器的角度看,整個頁面都成了 non-fast scrollable region 了。

          這意味著即使操作的是頁面無綁定事件處理器的區域,每次輸入時,合成器線程也需要和主線程通信并等待反饋,流暢的合成器獨立處理合成幀的模式就失效了。


          由于事件綁定在最頂部,整個頁面都成為了 non-fast scrollable region。

          為了防止這種情況,我們可以為事件處理器傳遞 passive: true 做為參數,這樣寫就能讓瀏覽器即監聽相關事件,又讓組合器線程在等等主線程響應前構建新的組合幀。

          復制代碼

          document.body.addEventListener('touchstart', 
          event=> {
           if (event.target===area) {
           event.preventDefault()
           }
           }, {passive: true}
          );
           
          

          不過上述寫法可能又會帶來另外一個問題,假設某個區域你只想要水平滾動,使用 passive: true 可以實現平滑滾動,但是垂直方向的滾動可能會先于event.preventDefault()發生,此時可以通過 event.cancelable 來防止這種情況。

          復制代碼

          document.body.addEventListener('pointermove', event=> {
           if (event.cancelable) {
           event.preventDefault(); // block the native scroll
           /*
           * do what you want the application to do here
           */
           } 
          }, {passive: true});
           
          

          也可以使用 css 屬性 touch-action 來完全消除事件處理器的影響,如:

          復制代碼

          #area { 
           touch-action: pan-x; 
          }
           
          

          查找到事件對象

          當組合器線程發送輸入事件給主線程時,主線程首先會進行命中測試(hit test)來查找對應的事件目標,命中測試會基于渲染過程中生成的繪制記錄( paint records )查找事件發生坐標下存在的元素。


          主線程依據繪制記錄查找事件相關元素。

          事件的優化

          一般我們屏幕的刷新速率為 60fps,但是某些事件的觸發量會不止這個值,出于優化的目的,Chrome 會合并連續的事件 (如 wheel, mousewheel, mousemove, pointermove, touchmove ),并延遲到下一幀渲染時候執行 。

          而如 keydown, keyup, mouseup, mousedown, touchstart, 和 touchend 等非連續性事件則會立即被觸發。


          Chrome 會合并連續事件到下一幀觸發。

          合并事件雖然能提示性能,但是如果你的應用是繪畫等,則很難繪制一條平滑的曲線了,此時可以使用 getCoalescedEvents API 來獲取組合的事件。示例代碼如下:

          復制代碼

          window.addEventListener('pointermove', event=> {
           const events=event.getCoalescedEvents();
           for (let event of events) {
           const x=event.pageX;
           const y=event.pageY;
           // draw a line using x and y coordinates.
           }
          });
           
          



          花了好久來整理上面的內容,整理的過程收獲還挺大的,也希望這篇筆記能對你有所啟發,如果有任何疑問,歡迎一起來討論。

          本文經作者授權轉載,原文鏈接為:

          https://zhuanlan.zhihu.com/p/47407398

          參考鏈接

          • https://developers.google.com/web/updates/2018/09/inside-browser-part1
          • https://developers.google.com/web/updates/2018/09/inside-browser-part2
          • https://developers.google.com/web/updates/2018/09/inside-browser-part3
          • https://developers.google.com/web/updates/2018/09/inside-browser-part4
          • https://www.html5rocks.com/zh/tutorials/internals/howbrowserswork/#Layered_representation

          主站蜘蛛池模板: 国产精品丝袜一区二区三区| 成人无号精品一区二区三区| 日本午夜精品一区二区三区电影 | 四虎一区二区成人免费影院网址| 性色av一区二区三区夜夜嗨 | 制服美女视频一区| 久久精品国产一区二区三区肥胖 | 日韩少妇无码一区二区三区| 2021国产精品视频一区| 人妻少妇精品视频一区二区三区| 国产色情一区二区三区在线播放| 福利一区福利二区| 一区二区三区电影在线观看| av一区二区三区人妻少妇| 一区二区三区在线观看| 偷拍激情视频一区二区三区| 亚洲日韩AV无码一区二区三区人 | 一区二区三区四区在线视频| 国产成人免费一区二区三区| 国产精品一区二区电影| 国产一区二区三区免费视频| 无码喷水一区二区浪潮AV | 国产亚洲自拍一区| 国产一区视频在线| 精品性影院一区二区三区内射| 日韩内射美女人妻一区二区三区| 91精品一区二区综合在线| 亚洲宅男精品一区在线观看| 日韩一区二区三区免费体验| 一区二区视频在线观看| 99久久无码一区人妻a黑| 夜夜高潮夜夜爽夜夜爱爱一区| 99无码人妻一区二区三区免费 | 亚洲A∨无码一区二区三区| 亚洲欧洲一区二区三区| 国产成人精品日本亚洲专一区 | 国产日韩精品一区二区在线观看 | 亚洲无线码在线一区观看| 日本亚洲成高清一区二区三区| 久久精品午夜一区二区福利 | 国产精品自拍一区|