性能优化笔记

做优化我们需要先分析浏览器的工作原理,如果不清楚建议先参考下这篇——览器从用户输入url到看到界面,及交互过程中浏览器都做了什么。我们就能针对每一个环节找到优化点,甚至最终权衡所有环节和优化方法做出相对最优的优化。

资源加载型优化

资源打包与加载

  • 在打包构建阶段合理拆分chunk,代码去重、压缩等,根据业务分包,保证每个产品功能加载的资源尽量没有多余;
  • 合理使用icon-font代替png图标;
  • 小图片转码base64,从然后从业务代码中剥离,或者单独文件统一存储,避免业务变动而图片(不常变动的资源)也跟着重新打包,影响缓存;
  • http/1.x下使用雪碧图,合并多个图片减少请求;
  • 加载过程中资源压缩gzip、CDN缓存等;
  • 资源文件与页面分开部署到不同域名下,减少不必要cookie传输,浪费带宽和流量;
  • tab间共享信息用localStorage,避免使用cookie,cookie可能会在请求间被携带;

ReST (Representational State Transfer)

restful API (ReST的API,类似于wonderful、NBable) 一种基于HTTP协议的服务API接口的设计风格。REST是面向资源的,而资源是通过URI进行暴露,具有以下特点。

  1. 每一个URI代表1种资源;
  2. 客户端使用4个表示操作方式的方法对服务端资源进行操作(restful只是资源相关操作接口的设计风格,并不妨碍其他DATA OPTION等方法的使用):
    • GET用来获取资源
    • POST用来新建资源(也可以用于更新资源)
    • PUT用来更新资源
    • DELETE用来删除资源
  3. 通过操作资源的表现形式来操作资源;
  4. 客户端与服务端之间的交互在请求之间是无状态的,从客户端到服务端的每个请求都必须包含理解请求所必需的信息。使用Token令牌来做用户身份的校验与权限分级,而不是Cookie。
  5. 返回结果
    • 使用HTTP状态码。比如401表示用户身份认证失败,403表示你验证身份通过了,但这个资源你不能操作等。
    • 内容使用JSON
    • 如果出现错误,返回一个错误码。
  6. API迭代更新必须有版本的概念:v1,v2,v3。

因此RESTful的API简单明了,所见即所得,在OpenAPI、资源操作相关的业务中能让使用者更自由的组装自己的业务。除了这些特点和有点外restful风格的接口相比传统用post加格式化数据的方式处理所有操作来说更节省字符传送,节省流量。

预解析、预链接、预加载、预渲染

预解析

DNS(Domain Name System)预解析分两种,一种预解析文档中所有链接的域名;另一种预解析指定资源链接的域名,包括各种资源及文档等所有链接,当然解析过的就跳过了。一般浏览器有自己的DNS缓存列表,如chrome://net-internals/#dns,也是DNS解析的第一层

对于第一种可以通过给文档服务器添加HTTP响应Header或页面meta标签开启或关闭。如果浏览器支持DNS预解析,即使不启用该标签或Header浏览器依然会进行预解析。

X-DNS-Prefetch-Control: on // 启用 DNS 预解析。
X-DNS-Prefetch-Control: off // 关闭 DNS 预解析。这个和后面精确控制。
<meta http-euqiv="x-dns-prefetch-control" content="on">
<meta http-euqiv="x-dns-prefetch-control" content="off">

DNS请求延迟高但实际占用带宽极小,因此预先解析会带来较大体验提升,远超过带宽占用的收益。如果想控制的资源已知,且不多宜维护,我们可以指定对应资源链接单独解析

<link rel="dns-prefetch" href="https://game.alesir.com/">
<link rel="dns-prefetch" href="https://src.alesir.com/">
<link rel="dns-prefetch" href="https://api.alesir.com/">

详细参考DNS Prefetch

预链接

rel=preconnect 预链接不仅预解析DNS,还与配置url对应服务器建立TCP预链接和可选TLS协商(HTTPS中浏览器与服务器创建连接的第一步参考《览器从用户输入url到看到界面》)中HTTPS部分

<link rel="preconnect" href="https://api.alesir.com" crossorigin>

rel="preconnect"

note: 我们知道tcp连接是占用资源的,且统一host对应的链接数也是有限制的,我们在一定的时间没有使用预链接的链接,为了避免占用资源浏览器或服务器也会主动断开的。

预加载

rel=preload 资源预加载,指定的资源文件在页面文档文件加载完后渲染前开始异步加载,资源得到提前加载,对用户的操作响应更快捷。

<link rel="preload" href="https://src.alesir.com/js/main.js" as="script">
<!-- 
  as指定资源的MIME类型
  audio: 音频文件。
  document: 一个将要被嵌入到<frame>或<iframe>内部的HTML文档,201909 chrome测试未通过。
  fetch: 那些将要通过fetch和XHR请求来获取的资源,比如一个ArrayBuffer或JSON文件。
  font: 字体文件。
  image: 图片文件。
  object: 一个将会被嵌入到<embed>元素内的文件。
  script: JavaScript文件。
  style: 样式表。
  track: WebVTT文件。
  worker: 一个JavaScript的web worker或shared worker。
  video: 视频文件。 
-->

preload

预渲染

rel=prerender 页面预渲染,在一些浏览器中已经实现了,比如chrome。在当前页面预判用户将可能访问的页面进行预渲染,加快新页面的首次展示,无论是新开Tab或在当前页跳转都会受益。

<link rel="prerender" href="https://note.alesir.com"></link>

Prerenders are unlike requests in most ways (for instance, they pass down fragments, and they don’t return data), but they do have referrers.

prerender与preload的区别在于prerender不仅加载指定文档资源文件,还加载页面中的所有资源文件,并执行js。浏览器创建类似于document.createFragments的内存片段(不像iframe那样直接渲染),但在预渲染的页面的所有请求在DevTool中看不到,只能通过Chrome任务管理器中可以看到这个进程(渲染完后立即关闭),或用charles等代理工具看到prerender网页的对应资源请求。不论是文档还是其他资源文件都会在请求header中加上purpose: prefetch

rel="prerender"

note: 谨慎使用prerender,因为他对网络带宽和CPU,内存等资源的消耗占用代价相比preload,dns-prefetch大得多,较多的prerender缓存的页面可能还会导致前面的预渲染页面失效,且超过一定时间不访问也会被浏览器清除已释放资源。另外预渲染的页面在初始化过程中有阻断js执行的弹窗,新开窗口等,或者有存在安全隐患的提示,证书、身份认证等提示将被浏览器取消预渲染。

缓存

HTTP资源缓存

HTTP缓存主要的就是缓存资源数据,页面在呈现到用户面前前都可以对资源缓存以更快的到达用户窗口。从用户的请求开始,浏览器可以对各资源缓存(DNS解析缓存,页面文档、脚本等各种资源缓存,有的浏览器还有会话恢复缓存)->浏览器后操作系统各模块的缓存(DNS缓存、网络请求缓存)->进入网络后就近路由器(DNS、资源缓存等)->区域局域网缓存、运营商缓存、CDN服务器缓存->网站服务器高速缓存->服务器资源IO或数据库RW。缓存有自己的更新机制,且一般只缓存GET请求及响应等长期不变的资源。

对于资源请求缓存可以通过HTTP Response Header Cache-Control配置,也可以通过网页<meta>标签配置

// 禁止缓存 
// 浏览器每次请求都会发到服务器,服务器也会返回完整响应
Cache-Control: no-store
// 强制确认缓存
// 浏览器将请求发送到服务器,服务器会验证请求中的xx,如已缓存且未过期则返回304,浏览器则直接取浏览器缓存中的对应资源作为响应。
Cache-Control: no-cache
// 缓存验证
// 
Cache-Control: must-revalidate
// 私有/共有缓存
// 默认为私有缓存,即中间人不能缓存只能缓存到浏览器私有缓存中,若指定为public则表示该响应可以被中间代理、CDN等缓存。
Cache-Control: private
Cache-Control: public
// 缓存通过max-age设置过期时间
Cache-Control: max-age=31536000 // 单位s
<!-- http 1.1 -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> 
<!-- http 1.0 -->
<meta http-equiv="Pragma" content="no-cache" />
<!-- 对应 Expire Header,效果同Cache-Control: max-age,兼容Full support -->
<meta http-equiv="Expires" content="0" />

HTML Cache

Cache API

Cache API目前主要为Fetch API提供的一套存储机制。Cache API更像一个实现request->response的存储API,缓存的过期,更新,匹配删除等只提供了API,具体机制需要自己实现(后面的Service Worder即是一个标准的实现,在那里也有实践例子)。

// 返回一个 Promise对象,resolve的结果是跟 Cache 对象匹配的第一个已经缓存的请求。
Cache.match(request, options)
// 同上,resolve的结果是跟Cache对象匹配的所有请求组成的数组。
Cache.matchAll(request, options)
// 这个API处理两件事:1.抓取这个URL;2.把返回的response对象添加到给定的Cache对象。
// 等同于调用 fetch(), 然后使用 Cache.put() 将response添加到cache中.
Cache.add(request)
// 抓取一个URL数组,检索并把返回的response对象添加到给定的Cache对象。
Cache.addAll(requests)
// 1.同时抓取一个请求及其响应;2.将其添加到给定的cache。
Cache.put(request, response)
// 搜索key值为request的Cache 条目。
// 1.如果找到,则删除该Cache 条目,并且返回一个resolve为true的Promise对象;
// 2.如果未找到,则返回一个resolve为false的Promise对象。
Cache.delete(request, options)
// 返回一个Promise对象,resolve的结果是Cache对象key值组成的数组
Cache.keys(request, options)

更多参考MDN Cache API

Service Worker

ServiceWorker注册在指定源和路径下的事件驱动worker(和其他worker一样,运行在单独的线程,异步不阻塞,且不能访问DOM),通过拦截并修改API访问和资源请求,利用Cache API实现细粒度的资源和API访问缓存。本质上是web应用在浏览器上的代理服务器,旨在使得webapp提升离线的可用性和体验,也可以在网络可用的时候同步资源。常用在后台数据同步,因为可以在tab间共享,可以用来集中接收计算成本高的数据更新、处理消息通知等。

在网站中注册ServiceWorker

/*
 * Service Worker(后面简称SW)是一个注册在指定源和路径下的事件驱动worker,与主线JS不在一
 * 个线程,上下文也不同。
 * 常见错误:
 *    1. 出于安全考虑,SW只能在HTTPS环境下才能使用。
 *    2. SW是异步的,以Promise形式的返回,同步API:localStorage等不能在SW中使用
 * 注册成功后会下载安装对应的SW脚本,在SW脚本中,可注册拦截install事件并配置需要缓存资源的
 * 访问路径;当网站请求匹配到SW缓存的资源时,可以拦截fetch事件并随心所欲的修改返回资源内容。
 * register service worker, 可以在主文件中引入注册也可以在web worker中注册
*/
// SW可控范围的URL,default相对location.pathname
const SW_MAX_SCOPE = '/static'

if (!'serviceWorker' in navigator) {
  // 注册service worker脚本,ServiceWorkerContainer.register(scriptURL, options)
  // 在代码中我们直接用navigator.serviceWorker,它是[ServiceWorkerContainer](https://developer.mozilla.org/zh-CN/docs/Web/API/ServiceWorkerContainer/register)的一个实例
  // scriptURL指定service worker的配置文件(需要service worker做什么)的路径,相对于域名根路径 如:https://app.alesir.com/static/worker/sw.js 相对路径为/static/worker/sw.js
  // options中 scope指定serviceworker控制的文件的正则匹配(包括非文档域下的请求)
  navigator.serviceWorker.register(`/sw.js`, { scope: SW_MAX_SCOPE })
    .then(reg => {
      if(reg.installing) {
        console.log('Service worker installing')
      } else if(reg.waiting) {
        console.log('Service worker installed')
      } else if(reg.active) {
        console.log('Service worker active')
      }
    })
    .catch(error => {
      // registration failed
      console.log('Registration failed with ' + error)
    })
}

sw.js

const CACHE_VERSION = 'v1.0.0'
self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open(CACHE_VERSION).then(function(cache) {
      return cache.addAll([
        // 接受一个URL数组,检索它们,创建对应的request对象为key,并将生成的response对象添加到给定的缓存中。
        // 相对于注册时的scope,这里宽泛的匹配pathname,文档中其他允许跨域的资源也能拦截
        // 如 https://app.alesir.com/static/pages/index.html
        // https://www.alesir.com/static/images/img.jpeg
        '/static/',
        '/static/pages/index.html',
        '/static/styles/app.css',
        '/static/scripts/app.js',
        '/static/images/img.jpeg',
      ])
    })
  )
})

self.addEventListener('fetch', event => {
  event.respondWith(caches.match(event.request).then(response => {
      // caches.match()是否匹配都会resolve, 没有命中缓存的请求则response为undefined
      if (response !== undefined) {
        return response
      } else {
        return fetch(event.request).then(response => {
          // Fetch API中的response只能使用一次(使用后response.body.bodyUsed被标记,这里'使用'应该是作为请求体发送,或者作为响应体返回,请求和响应都是异步的,以方便管理及读取不易混乱),所以需要复制一份
          let responseClone = response.clone()
          // 没有命中可能是第一次请求,也可能cache.delete了,response后需要修改对应request的缓存
          caches.open(CACHE_VERSION).then(function (cache) {
            cache.put(event.request, responseClone)
          })
          return response
        }).catch(function () {
          // 只有网络错误、网络认证阻断或者主动abort,即AbortError、TypeError才会走到reject。即使非200的response都作为resolve处理
          return caches.match('/static/images/error.jpeg')
        })
    }
  }))
})

Service Worker API

fetch实例
Fetch API
Fetch API Standard

相关库:Workbox
相关架构模式:PWA(Progressive Web App,渐进式web应用, 相关实现App Shell)

利用持久存储API实现自己的缓存机制

利用浏览器localStorage/IndexedDB/WebSQL等存储js,css代码片段;结合webapck等构建工具打包,将每次更改后的代码diff出来作为浏览器上每次加载更新的部分,做到更大限度的缓存代码资源。其实每次更新功能,或修复bug,我们改动的代码只是一个js或者css等代码文件的一小部分,有的时候一次改动会带来很多文件的局部变更,但每次都需要下载完整的代码文件。代码对于浏览器来说实际还是字符资源,解析前我们对代码的更改和在本地开发写代码没什么区别。因此可以把代码按字符串来diff管理每次更新后的下载拼装,以节省带宽等资源。

实例: bowl

其他实现: 缓存已编译的WebAssembly模块

反请求限制

分析原因,浏览器限制同host的并发请求数,为什么是同host?

我们知道TCP协议只支持0-65535个端口建立链接,目前普世的HTTP1.x同时只支持一个TCP一个HTTP请求。65536个端口除了保留的0-1024还有几万个,浏览器客户端的设备完全没压力呀!我们还知道同源策略这些安全措施是浏览器实现的,实际跨域请求资源返回了,是被浏览器拦截的,同样这样的老好人表现在方方面面。为了保护服务器TCP/IP协议栈资源不被迅速耗尽而限制用户向同一host的并行链接,即使HTTP1.1的keep-alive也只是串行复用TCP链接。HTTP2.0正式为了解决这些问题而来。当然,除了HTTP协议我们还有其他应用层通信协议,Web Socket等。

对于这个问题:

  1. 我们可以将各资源分拆或代理到不同的host,比如:图片资源在img.alesir.com,js在script.alesir.com;不同产品功能在不同的api host,如api-prod.alesir.com处理产品请求; api-base.alesir.com处理基础服务的请求。或者将多个子域如api-a. api-b. …都代理到api.的host,当网页中需要并行请求多个api时动态的分配不同的host来请求。
  2. 我们还可以打包的时候内联css、js到HTML文件中,以减少请求数,因为构建资源请求接受的工作相对解析资源本身是很耗时。即使这样也不是一本万利,后面渲染章节我们再分析这做的适应场景。

HTTP 2.0

为什么要有HTTP2,超文本传输协议(HTTP)是一个非常成功的协议。 但是HTTP/1.1 是针对90年代的情况而不是现代web应用的性能而设计的,导致它的一些特点已经对现代应用程序的性能产生负面影响。特别是,HTTP/1.0只允许在一个连接上建立一个当前未完成的请求。HTTP/1.1管道只部分处理了请求并发和报头阻塞的问题。因此客户端需要发起多次请求通过数次连接服务器来减少延迟。此外,HTTP/1.1的报头字段经常重复和冗长。在产生更多或更大的网络数据包时,可能导致小的初始TCP堵塞窗口被快速填充。这可能在多个请求建立在一个新的TCP连接时导致过度的延迟。那这些问题都交给了HTTP2,毕竟是上一版本的升级,协议通过定义一个优化的基础连接的HTTP语义映射来解决这些问题。它允许在同一连接上交错地建立请求和响应消息,并使用高效率编码的HTTP报头字段。它还允许请求的优先级,让更多的重要的请求更快速的完成,进一步提升了性能。最终协议设计为对网络更友好,因为它相对HTTP/1.x减少了TCP连接。这意味着与其他流更少的竞争以及更长时间的连接,从而更有效地利用可用的网络容量。另外,这种封装也通过使用二进制消息帧使信息处理更具扩展性。

全文请看超文本传输协议版本2

Web Socket

WebSocket建立在客户端和服务端间的交互式通信(这里的客户端就是用户浏览器),服务器和浏览器间可以相互主动发送和接受消息,而不需要轮询请求。

客户端

let wsServer = 'ws://{{ origin }}'
let websocket = new WebSocket(wsServer)
// window.addEventListener('DOMContentLoaded', function(){
websocket.onopen = function(evt) {
    initDefaultEvent()
    console.log('[synctest start success]')
}
websocket.onclose = function(evt) {
    console.log('[synctest closed please check the server or refresh this page]')
}
websocket.onmessage = function(evt) {
    excuteCommand(parseCommand(evt.data))
}
websocket.onerror = function(evt) {
    console.log('[synctest closed please check the server or refresh this page]')
}

客户端完整代码syncTest实例

服务端

import { WebSocketServer as server } from 'websocket'
import utils from './utils.js'

const MAXSIZE = 1024 * 1024 * 100

export function ({server, config, plugins, host, post}) {
    // ... init

    var wsServer = new WebSocketServer({
        httpServer: server,
        maxReceivedFrameSize: maxsize,
        maxReceivedMessageSize: MAXSIZE,
        autoAcceptConnections: false
    })

    wsServer.on('request', function(request) {
        if (!originIsAllowed(request.origin)) return request.reject()
        var connection = request.accept(acceptProtocols, request.origin);
        connection.on('message', function(message) {
            var store = clientStore.store[appname]
            for(let o in store){
                if(sameOrigin && o !== request.origin) continue
                store[o].forEach(function(sockCli){
                  // do something
                  msgIsText ? sockCli.sendUTF(data, cb) : sockCli.sendBytes(data, cb)
                })
            }
        });
        connection.on('close', function(reasonCode, description) {
            clientStore.del(connection.__appname, request.origin, connection);
            //connection.unmount();
        });
    });
};

服务端完整代码:servermoc websocket实例,客户端syncTest是servermock的一个插件,代码有些老旧,但不影响websocket的实现。

GRPC websocket/http2

在看了解GRPC前我们先来看RPC是什么。
RPC(Remote Procedure Call)—远程过程(方法、属性…)调用协议,它采用客户端/服务器模式,客户端通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。一次远程调用过程大概如下:

  1. 客户端调用句柄,包装请求参数
  2. 调用本地系统内核发送网络消息
  3. 消息传送到远程服务器
  4. 服务器句柄得到消息并取得客户端请求参数
  5. 执行远程程序或方法
  6. 将执行结果返回服务器句柄
  7. 调用服务器内核,请求返回结果
  8. 消息传回客户端
  9. 客户句柄由内核接收消息
  10. 客户接收句柄返回的数据

这个过程类似于服务器给客户端的一个调用服务端功能的SDK,至于底层通过HTTP或者WebSocket或者其他基于TCP、UDP的应用协议和服务器通信我们不用自己实现,服务端是什么语言我们也不用理会,只要实现我们客户端能使用的语言的SDK就行。

GRPC(Google RPC) 是Google实现的一个高性能,平台中立、语言中立、开源的RPC框架。GRPC基于 HTTP 2.0标准设计,底层默认封装了自家开源成熟的结构数据序列化机制(protocol buffers,也可以换成JSON等),这些特性使得其在使用上更灵活多用,也意味着HTTP2.0有的优势他都有,在移动设备上表现更好,更省电和节省空间占用。当然对于我们来说,GRPC-Node,GRPC-Web目前已相对稳定。

GRPC 跨平台 多语言的支持

相关链接

渲染性能优化

以基于chromium/webkit项目(两个项目都使用webkit渲染引擎或衍生版blink)的浏览器为例,我们来跟着一次渲染过程淘一下有哪些可以优化的点

HTNL解析构建DOM树

从HTML文件开始

  • 浏览器接收完HTML文本文件后开始扫描HTML文本,匹配出文本中可能的资源加载链接,并交由资源管理器进程按序加载资源和管理备用。(HTTP1.x协议下内联css或js等资源可以减少请求带来一些好处,但接收到HTML文件和后面构建DOM树的时间都会受到影响。这里需要权衡一下,如果是比较简单的页面或者首页我们可以考虑内联css和js,反之其他的不推荐内联,具体后面优化检测工具章节再看怎么针对当前项目做选择取舍)
    • 同时根据文档类型声明,HTML文本数据交由Render进程的html解析器解析
      • 开始构建dom树,先构建根节点document节点->DOCTYPE节点然后html节点元素(不排除其间可能有其他任意节点,这些html标签外的节点可以被选择器选择、a标签加入window.links、img->window.images、iframe->window.frames等,注释也会生成comment节点,因此html中的注释在production mode要清除),然后深度遍历HTML标签
      • 当遍历到script标签没有指定异步async或delay属性则停止解析等待脚本资源加载解压等完毕然后交由javascript引擎解析执行(所以要把同步外链js方文档后面,阻碍dom构建是因为javascript可以操作dom,防止数据不一致,比如append或appendChild,向父元素追加一个子元素,而html解析器不暂停依然向父元素插入元素,这个js追加的元素的位置将不可控)
        • 另外还可以通过脚本创建影子DOM(在渲染果中包含在DOM树中不可直接遍历的DOM节点,可以通过shadow DOM API创建,也有canvas,input,video等这些标签内部实际也是shadow DOM)
      • 当遇到其他外链资源,交由资源加载器对应的loader加载资源,不阻碍HTML的解析和DOM树的构建。

RenderStyle & RenderObject

  • 加载完后的css由css解析器解析生成CSSOM和内部规则表示,以便之后构建RenderObject渲染对象(因为js可以通过操作CSSOM改变样式规则,css的加载和解析会阻碍js的执行,因此在script最好放到文档末尾,或异步加载。head中内联<script>var a = 1;<script>即使只有简单的一个声明赋值操作,在css没有加载解析完也会暂停DOM树的构建)。
  • 样式规则针对DOM可视节点(display不为none的节点)进行规则匹配(选择器优先级,先后顺序匹配覆盖,生成RenderStyle对象)。
  • 渲染引擎为DOM可视节点创建包含各自renderStyle的RenderObject实例。RenderObject的layout方法根据renderStyle样式规则中的框模型进行布局计算,为了布局,可能还会为一些内联元素创建匿名RenderBlock。这样每个RenderObject对象都知道自己关联的DOM对象和渲染布局信息。

RenderLayer

  • 渲染引擎参考DOM树结构创建RenderObject树(如上非一一对应),然后根据元素层次创建RenderLayer,再根据RenderObject树创建RenderLayer树(同一层可能有很多RenderObject,因此是一个一对多关系,RenderLayer节点实际对应的还是一个RenderObject节点,RenderLayer的父节点则对应该RenderObject的父节点,且这对父子RenderObject不在同一RenderLayer,又因为RenderObject与DOM元素非一一对应(如:为布局创建的匿名RenderBlock。shadow DOM实际创建了DOM节点,但被public/private控制用户JS DOM访问,后面工具章节会讲怎么在浏览器devTool中查看shadow DOM),因此RenderLayer的父节点不一定是DOM树上对应的父节点)。创建RenderLayer的条件:
    • DOM树的根节点document对应的RenderObject:RenderView
    • document的根元素对应的RenderObject:RenderBlock
    • 显示指定位置:如position: absolute/fixed 或float: left/right等的RenderObject
    • 有透明效果的RenderObject: 如opacity、filter、alpha等
    • 有溢出:如overflow
    • canvas/video/audio等复合节点及影子DOM对应的RenderObject
  • RenderObject绘制过程
    • 绘制所有块的边框和背景
    • 绘制浮动元素
    • 绘制前景(内容、轮廓)(内联元素的以上结构都在这一步绘制)
    • RenderLayer的绘制过程(反射层->背景层->负z层->层的子女的背景-浮动元素-前景内容轮廓->层轮廓->子女层->滤镜)

知道了创建RenderLayer的条件我们就要加以利用,后面章节中会了解到,渲染的元素是按层绘制的,在元素发生变化的时候,更容易锁定范围。就像家里瓷砖有一个区域玻璃被敲坏了,如果是一个整块,那整块玻璃都得花钱更换,但如果是很多小块组成的,拿只需要更换敲坏的那几块。当然这还不够具体,RenderLayer还可能重叠,实际还能避免更多的浪费。

CPU渲染和GPU加速渲染:

  • 软件渲染(CPU计算):根据层次把每一层(RenderLayer)的元素叠加绘制到一张位图(优:当更新区域小时,只需计算该区域重叠的元素,RenderLayer渲染引擎更容易锁定变更的元素的影响范围,进而对受影响的层重型绘制。这时我们会想,要是把这些层缓存起来,每次变动不就只需要重新计算有变动这层的布局和绘制,及其他重叠的层就像贴贴画一样直接冲内存中取出再按像素复合贴到最后那张位图上不是更快吗?是的是更快了,但设备的CPU和内存资源是很宝贵的,都用来处理和存储这些不知道什么时候会变的层的数据,那不是很浪费吗?用户的其他程序也会受到影响)

  • 硬件渲染(GPU计算):引入合成层(Composite Layer),对其进行必要的数据缓存(这里避开了上面的疑问,没有对所有的RenderLayer做缓存,Composite Layer根据需要重新整合了交互中容易变更的RenderLayer,而且使用GPU的计算和显存,不抢占CPU和内存去响应和处理用户其他操作和程序,除了耗电外,使用户画面交互的流畅体验提升了不少,因此我们还是要合理利用。)。渲染引擎根据以下规则决定哪些RenderLayer合成一个合成层

    • 设置了css3d(translate3d/translateZ)、透视(opacity)、剪裁(background-clip/position:clip/ https://www.w3.org/TR/css-masking/#the-clip-path ) 、或者滤镜(filter)的RenderLayer(对应到DOM元素)
    • css过渡(transition)或动画(animation)的RenderLayer,优化只在动画过程中GPU加速(创建合成层)
    • vedio节点所在的RenderLayer
    • z-index比自己小的兄弟节点是合成层

知道了创建Composite Layer的条件,我们也知道了怎么优化网页中的问题,比如:

  • height/width/top/left等这些属性做的过渡或动画会很卡,我们可以加上css3d的属性使其分离并创建对应合成层,为了减少布局计算还可以直接用transform变化代替宽高定位等属性,提高性能。
  • 根据使用场景把交互中容易变更的区域加上以上相应触发创建合成层的属性,使其与不易变动的元素隔离,减少计算加快相应,进而提升流畅度和响应性能。

减少重绘,布局等

  • 到这里DOM树构建完触发DOMContentLoaded事件,所有资源都加载完后触发onload事件。(因此,我们一些逻辑不需要用到图片、视屏等资源的操作可以在DOMContentLoaded后开始处理,比如可以安全的操作DOM增删改查了,为了避免图片加载对布局的影响,给图片或异步内容区域给出默认宽高等。)

  • 以上过程中间或之后js可能对DOM节点CRUD,同样CSSOM对样式CRUD,每次更改都可能引起文档部分或整体重新渲染,我们能做的就是尽量避免这种变更,无法避免的就尽量减少变更区域及其带来的影响。我们就来看下哪些操作会导致重绘和重新布局:

    • 所有可视节点的可视属性操作都可能引起重绘,如内容、背景、宽高、内外填充、边框、颜色等CSSOM的操作;
    • 在DOM中增删一个DOM可视节点、更改DOM可视节点的盒模型(从外到内,margin、padding、border、width/height/content)都有可能触发重新布局
    • 通过CSSOM的ele.offsetTop等相对取值属性、ele.getComputedStyle()getBoundingClientRectele.getClientRects()等需要触发layout计算出实时结果的方法。

思考:在没有打开DevTool的时候浏览器会为网页缓存这些实时计算值吗?

计算型优化

浏览器为计算型应用煞费苦心,准备了一批用于优化此类问题的技术解决方案: webWorker/SharedWorker、OffscreenCanvas、WebAssembly… 及一些辅助元素Atomics、ArrayBuffer/ShareArrayBuffer、TypedArray、DataView,及一些其他应用容器TextEncode/Decode、Blob、ImageBitmap…

webWorker

通过使用Web Workers,Web应用程序可以在独立于主线程的后台线程中,运行一个脚本操作。这样做的好处是可以在独立线程中执行费时的处理任务,从而允许主线程(通常是UI线程)不会因此被阻塞/放慢。

worker中不能直接操作和窗口密切相关的API,如DOM及相关操作、事件等方法,但可以直接使用大部分WebAPI,如:Fetch、Chche、FileReader、ImageData、IndexedDB、Worker、WebSocket、XHR等,JS语言自身API不受影响,更多。 workers和主线程间的数据传递通过postMessage()发送和注册onmessage事件接收响应。window和worker间传递的数据是序列化后复制传递,不是共享,所以循环变量会导致序列化错误,无法复制。我们先来看常用的Worker。

专用worker

// main.js 主线程
if (window.Worker) {
  // 创建一个专用(Dedicate)worker
  let denoiseWorker = new Worker('/static/worker/denoise.js')
  // 将需要降噪处理的图片数据传给woker处理
  denoiseWorker.postMessage(imageData.data)
  // 接收降噪处理worker返回结果数据,并渲染到结果页面
  // 这只是简单的例子 当有多个worker和详细时我们需要管理标记这些消息,
  // 以便正确的发送和接收,参考安全笔记中postMessage跨域资源共享部分
  denoiseWorker.addEventListener('message', e => {
    showDenoisedImage(e.data)
  }, false)
  // 不想使用了就立即终止worker所有操作
  denoiseWorker.terminate()
}
// /static/worker/denoise.js worker线程
// 接收主线消息
// self.addEventListener self.postMessage
addEventListener('message', function (e) {
  console.log('message received')
  // 传回结果给主线程
  postMessage(denoise(e.data))
}, false)
// 处理错误
addEventListener('error', function (e) {
  // 和子窗口或元素不一样,worker的事件不会冒泡
  e.preventDefault()
}, false)
// 主动关闭worker
close()
// 其他操作,如创建子worker,与主线创建方式一致
// 引入一个或多个脚本如下
importScripts('script1.js', 'scriptn.js')

其他worker

通常我们使用的worker是专用(Dedicated) worker,除此之外还有Shared Workers、及缓存优化章节提到的Service Workers等。Shared Workers 可被不同的窗体的多个脚本运行,例如IFrames等,只要这些workers处于同一主域。共享worker 比专用 worker 稍微复杂一点 — 脚本必须通过活动端口进行通讯。详情请见SharedWorker

OffscreenCanvas

提供一个可以脱屏渲染的canvas,不仅在window环境中,worker环境也能能使用。

<canvas id="canvas"></canvas>
<canvas id="canvas2"></canvas>
// main.js
// 场景一 web worker
let canvas = document.getElementById('canvas')
let offscreen = canvas.transferControlToOffscreen()

let worker = new Worker('./worker/offscreencanvas.js')
// 第二个参数是一个可选的Transferable对象的数组,用于传递所有权
worker.postMessage(offscreen, [offscreen])

// 场景二 脱屏渲染
let cotext = document.getElementById('canvas2').getContext('bitmaprender')
let offSC = new OffscreenCanvas(512, 512)
let gl = offscreen.getContext('webgl')
// gl.draw() 脱屏绘制完后 transfer到canvas2画布,有点像docuemntFragment()
cotext.transferImageBitmap(offSC.transferToImageBitmap())
// ./worker/offscreencanvas.js
self.addEventListener('message', e => {
  let canvas = e.data
  let gl = canvas.getContext('webgl')
  // gl.draw() 绘制完然后提交到主窗口canvas画布
  gl.commit()
}, false)

webAssembly

WebAssembly是一种高性能的可以在浏览器中运行的新型代码,旨在为webapp提供高效、可移植、接近原生速度运行的能力。WebAssembly是一门低阶语言,和其他语言一样可以直接编写调试代码,不仅如此,WebAssembly更是为了诸如C/C++等底阶语言提供一个高效的编译目标或者说是其他语言编译到js引擎(浏览器、Nodejs)的一个可执行码,Javascript可以直接调用WebAssembly模块(wasm module。相应的,也可以在wasm module中调用js)进而获得其带来的巨大性能优势和新特性,可以把它想象成一个高效地生成高性能函数的Javascript特性。WebAssembly代码编译后生成以.wasm为扩展名的二进制文件,可以和WebAssembly代码互相转换,参考。另外,WebAssembly被限制运行在一个安全的沙箱执行环境中。和其他网页代码一样,它遵循浏览器的同源策略和授权策略;WebAssembly的设计原则是与其他网络技术和谐共处并保持向后兼容。更多

实例:C/C++移植到WebAssembly

编译工具:

C/C++移植到WebAssembly

note: 目前有许多开源的glue的实现来连接WebAssembly和DOM,以后还可能直接在wasm module中直接操作DOM

逻辑结构型优化

概要

有时候我们未为了方便,会把一些东西放在自己触手能即的地方,即使我们一开始所有东西的按计划有规划的摆放,但随着时间的推移,东西越来越多这样的摆放显得格外杂乱,甚至有的东西现在也不需要了还在那,这些东西杂乱和多到超出了当处还算超前的规划。这和我们维护一个项目一样,业务在发展,业务方向,产品形态也在发生变化,项目架构太超很多东西长期用不上显得浪费,刚刚够用则不易扩展跟不上业务发展需要,所以除了适当超前的架构设计,我们还要适时的根据业务去改善项目架构。我们相信没有最好的的架构,只有更适合当前业务+适当超前预判的架构。

除了项目架构需要适时重构外,我们还要适时的重构业务代码,一个成熟的业务可能我们在一开始就能制定出哪些可以抽象,哪些可以预置、预留等。但一个不熟悉或全新的业务或产品,我们也可能没有足够通用的解决方案,或者出于人力物力等资源的限制,或者决策者指定或实验探索性业务形态,使得我们从开始的一段时间无法得到业务的发展方向,进而不能做出一些抽象和预置预判,使得业务代码在往后的维护中出现很多重复,不易维护的的代码。那我们什么时候去做业务重构呢?1.平时发现后抽象了一部分就替换一部分(中评:可能开发业务的人力资源比较紧张,导致改了其他业务的代码但测试验证不够充分);2.根据业务发展一年到两年做一次业务重构(中评:虽然更改更彻底,测试更充分,但需要一次投入大量人力);3.平时开发中随着业务发展抽象出来业务组件、模块、公共方法等用版本去维护好以保持兼容和后面整体升级做准备,当前和后面的业务根据需要保留原版本或更换新的版本组件或模块,然后根据业务发展一到两年整体重构,并将旧版本的没有使用的组件、模块等删除。(好评:虽然代码量会增加,但稳定性和业务发展都有很大的提升和保障。PS:代码量增加的问题可以通过分包等方式解决)

项目架构重构

除了项目基础框架包含的路由设计、数据存储管理、视图、及他们之间的关系和生命周期,项目架构还要考虑构建部署、目录结构、模块管理、打包加载卸载、作用域命名空间管理、业务发展方向,业务代码管理等。项目架构旨在让业务代码人员更聚焦在业务的发展和实现,而不用花太多时间在系统怎么接收和响应用户的操作的,数据怎么存储和管理的…

参考我们之前的几次重构

业务代码重构

业务代码除了自身业务逻辑外,就是要充分利用好项目框架提供的便利

  • 提炼业务组件
  • 提炼公共方法
  • 按功能、产品等模块化、代码分包
  • 充分利用项目基础框架的生命周期钩子,如在created启动一些异步的工作,在mounted后操作DOM相关数据,在destroy前或后处理一些定时任务或变量的销毁
  • 充分利用WebAPI特性和事件实现业务功能

页面生命周期及visibility与hidden

利用页面可见性API根据业务需要,对不必要的定时任务、请求、轮播、视频等加以控制,减少资源浪费提高用户体验。比如网站有一个定时消息轮训,此时用户从当前页A打开了新的tab页B,那用户在浏览B的时候,页A处于hidden状态,他的消息轮训会占用不必要的资源:

  1. 浪费带宽,CPU,内存等资源。
  2. 会占用当前页B的请求限制,我们知道浏览器对同一host下并行的请求数有限制。

解决办法:A页hidden的时候暂停定时任务,可以由B页面通过其他方式同步消息如

  • localStorage + storage事件;
  • postMessage等;
  • 也可以不同步,当用户回到A页面后还会受到visibilitychange事件,再开启定时任务

note: 这里的可见性受page visibility API控制,不受CSSdisplay影响。iframe子窗口的可见性状态与父文档相同。更多

检测工具

善用Chrome DevTool Performace

其他技术

比如针对移动端还有React-Native WeexFluter;服务侧的SSR BFF的方案放在单独篇幅探索;代码层面根据不同的js引擎的特性书写代码等(如V8: The V8 Engine and JavaScript Optimization Tips)。

附录

附录A 名词统一

URL各段解析: https://www.alesir.com/activity.html?channel=1#buyByYear

  • https://www.alesir.com 源origin,其实这里包含端口的,https下port被隐藏了
  • https 协议名,相应的还有http、ws、wss等
  • https:// schema,相应的还有http:// ws:// wss:// data: file://
  • www.alesir.com hostname,IP或域名,不含端口,包含三级域名www,二级域名alesir,顶级域名com
  • www.alesir.com:8080 host,IP或域名,含端口,其他同上
  • 443 端口,https协议下默认port 443被隐藏了
  • /activity.html 请求路径path
  • ?channel=1 查询字符串search
  • #buyByYear hash