《浏览器工作原理与实践》是极客时间上的一个浏览器学习系列,在学习之后特在此做记录和总结。

一、事件循环

  消息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。

  

  从上图可以看出,改造可以分为下面三个步骤:

  (1)添加一个消息队列;

  (2)IO 线程中产生的新任务添加进消息队列尾部;

  (3)渲染主线程会循环地从消息队列头部中读取任务,执行任务。

1)处理其他进程发送过来的任务

  从图中可以看出,渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程,后续的步骤就和前面讲解的“处理其他线程发送的任务”一样了。

  

2)消息队列中的任务类型

  包含很多内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等。

  除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。

  以上这些事件都是在主线程中执行的,所以在编写 Web 应用时,还需要衡量这些事件所占用的时长,并想办法解决单个任务占用主线程过久的问题。

3)安全退出

  确定要退出当前页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志。

  如果设置了,那么就直接中断当前的所有任务,退出线程。

4)单线程的缺点

  (1)第一个问题是如何处理高优先级的任务。

  如果 DOM 发生变化,采用同步通知的方式,会影响当前任务的执行效率;如果采用异步方式,又会影响到监控的实时性。

  针对这种情况,微任务就应运而生了,下面来看看微任务是如何权衡效率和实时性的。

  通常把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。

  等宏任务中的主要功能都执行完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题。

  (2)第二个是如何解决单个任务执行时长过久的问题。

  针对这种情况,JavaScript 可以通过回调功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。

二、WebAPI

1)定时器

  在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。

  所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。

  当通过 JavaScript 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数 showName、当前发起时间和延迟执行时间。

  处理完消息队列中的一个任务之后,就开始执行延迟函数。该函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,再继续下一个循环过程。通过这样的方式,一个完整的定时器就实现了。

  使用定时器的注意事项:

  (1)如果当前任务执行时间过久,会影响定时器任务的执行。

  (2)如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒。

  (3)未激活的页面,setTimeout 执行最小间隔是 1000 毫秒。

  (4)延时执行时间有最大值。

  (5)使用 setTimeout 设置的回调函数中的 this 不符合直觉,方法中的 this 关键字将指向全局环境。

2)XMLHttpRequest

  XMLHttpRequest的工作过程可以参考下图:

  

  setTimeout 是直接将延迟任务添加到延迟队列中,而 XMLHttpRequest 发起请求,是由浏览器的其他进程或者线程去执行,然后再将执行结果利用 IPC 的方式通知渲染进程,之后渲染进程再将对应的消息添加到消息队列中。

3)requestAnimationFrame

  根据实际情况,动态调整消息队列的优先级。

  

  这张图展示了 Chromium 在不同的场景下,是如何调整消息队列优先级的。通过这种动态调度策略,就可以满足不同场景的核心诉求了,同时这也是 Chromium 当前所采用的任务调度策略。

  当显示器将一帧画面绘制完成后,并在准备读取下一帧之前,显示器会发出一个垂直同步信号(Vertical Synchronization)给 GPU,简称 VSync。

  具体地讲,当 GPU 接收到 VSync 信号后,会将 VSync 信号同步给浏览器进程,浏览器进程再将其同步到对应的渲染进程,渲染进程接收到 VSync 信号之后,就可以准备绘制新的一帧了,具体流程你可以参考下图:

  

  在合成完成之后,合成线程会提交给渲染主线程提交完成合成的消息,如果当前合成操作执行的非常快,比如从用户发出消息到完成合成操作只花了 8 毫秒,因为 VSync 同步周期是 16.66(1/60)毫秒,那么这个 VSync 时钟周期内就不需要再次生成新的页面了。那么从合成结束到下个 VSync 周期内,就进入了一个空闲时间阶段,那么就可以在这段空闲时间内执行一些不那么紧急的任务,比如 V8 的垃圾回收,或者通过 window.requestIdleCallback() 设置的回调任务等,都会在这段空闲时间内执行。

  CSS 动画是由渲染进程自动处理的,所以渲染进程会让 CSS 渲染每帧动画的过程与 VSync 的时钟保持一致, 这样就能保证 CSS 动画的高效率执行。

  但是 JavaScript 是由用户控制的,如果采用 setTimeout 来触发动画每帧的绘制,那么其绘制时机是很难和 VSync 时钟保持一致的,所以 JavaScript 中又引入了 window.requestAnimationFrame,用来和 VSync 的时钟周期同步,它的回调任务会在每一帧的开始执行。

三、宏任务和微任务

1)宏任务

  页面中的大部分任务都是在主线程上执行的,这些任务包括了:

  (1)渲染事件(如解析 DOM、计算布局、绘制);

  (2)用户交互事件(如鼠标点击、滚动页面、放大缩小等);

  (3)JavaScript 脚本执行事件;

  (4)网络请求完成、文件读写完成事件。

  为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。把这些消息队列中的任务称为宏任务。

  页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。

<!DOCTYPE html>
<html>
    <body>
        <div id='demo'>
            <ol>
                <li>test</li>
            </ol>
        </div>
    </body>
    <script type="text/javascript">
        function timerCallback2(){
          console.log(2)
        }
        function timerCallback(){
            console.log(1)
            setTimeout(timerCallback2,0)
        }
        setTimeout(timerCallback,0)
    </script>
</html>

  在这段代码中,目的是想通过 setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,中间也不要再插入其他的任务。

  但实际情况是不能控制的,比如在调用 setTimeout 来设置回调任务的间隙,消息队列中就有可能被插入很多系统级的任务。

  

  所以说宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如监听 DOM 变化的需求。

2)微任务

  微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。也就是说每个宏任务都关联了一个微任务队列。

  当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。

  在现代浏览器里面,产生微任务有两种方式。

  (1)第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。

  (2)第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

  通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。

  WHATWG 把执行微任务的时间点称为检查点。

  在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

  

  在 JavaScript 脚本的后续执行过程中,分别通过 Promise 和 removeChild 创建了两个微任务,并被添加到微任务列表中。接着 JavaScript 执行结束,准备退出全局执行上下文,这时候就到了检查点了,JavaScript 引擎会检查微任务列表,发现微任务列表中有微任务,那么接下来,依次执行这两个微任务。等微任务队列清空之后,就退出全局执行上下文。

  从上面分析可以得出如下几个结论:

  (1)微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。

  (2)微任务的执行时长会影响到当前宏任务的时长。

  (3)在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。

3)监听 DOM 变化方法

  MutationObserver 是用来监听 DOM 变化的一套方法。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变化、节点的增减、内容的变化等。

  相比较 Mutation Event,MutationObserver 的改进如下:

  (1)首先,MutationObserver 将响应函数改成异步调用,可以不用在每次 DOM 变化都触发异步调用,而是等多次 DOM 变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。

  (2)在每次 DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8 引擎就会按照顺序执行微任务了。

四、Promise

  如果你想要学习一门新技术,最好的方式是先了解这门技术是如何诞生的,以及它所解决的问题是什么。了解了这些后,你才能抓住这门技术的本质。

  如果嵌套了太多的回调函数就很容易使得自己陷入了回调地狱,并且代码看起来会很乱,原因如下。

  (1)第一是嵌套调用,下面的任务依赖上个任务的请求结果,并在上个任务的回调函数内部执行新的业务逻辑,这样当嵌套层次多了之后,代码的可读性就变得非常差了。

  (2)第二是任务的不确定性,执行每个任务都有两种可能的结果(成功或者失败),这种对每个任务都要进行一次额外的错误处理的方式,明显增加了代码的混乱程度。

  原因分析出来后,那么问题的解决思路就很清晰了:

  (1)第一是消灭嵌套调用;

  (2)第二是合并多个任务的错误处理。

1)消灭嵌套

  Promise 主要通过下面两步解决嵌套回调问题的。

  (1)首先,Promise 实现了回调函数的延时绑定。

  回调函数的延时绑定在代码上体现就是先创建 Promise 对象 x1,通过 Promise 的构造函数 executor 来执行业务逻辑;创建好 Promise 对象 x1 之后,再使用 x1.then 来设置回调函数。

  (2)其次,需要将回调函数 onResolve 的返回值穿透到最外层。

  因为根据 onResolve 函数的传入值来决定创建什么类型的 Promise 任务,创建好的 Promise 对象需要返回到最外层,这样就可以摆脱嵌套循环了。

  

2)合并错误处理

  无论哪个对象里面抛出异常,都可以通过最后一个对象 catch 来捕获异常,通过这种方式可以将所有 Promise 对象的错误合并到一个函数来处理,这样就解决了每个任务都需要单独处理异常的问题。

  之所以可以使用最后一个对象来捕获所有异常,是因为 Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被 onReject 函数处理或 catch 语句捕获为止。

3)Promise 与微任务

  Promise 之所以要使用微任务是由 Promise 回调函数延迟绑定技术导致的。

  下面用一个自定义的 Bromise 来实现Promise。

function Bromise(executor) {
    var onResolve_ = null
    var onReject_ = null
    //模拟实现resolve和then,暂不支持rejcet
    this.then = function (onResolve, onReject) {
        onResolve_ = onResolve
    };
    function resolve(value) {
        //setTimeout(()=>{
            onResolve_(value)
        //},0)
    }
    executor(resolve, null);
}

function executor(resolve, reject) {
    resolve(100)
}
//将Promise改成自定义的Bromsie
let demo = new Bromise(executor)
function onResolve(value){
    console.log(value)
}
demo.then(onResolve)

  执行这段代码,发现执行出错,输出的内容是:

Uncaught TypeError: onResolve_ is not a function

  之所以出现这个错误,是由于 Bromise 的延迟绑定导致的,在调用到 onResolve_ 函数的时候,Bromise.then 还没有执行,所以执行上述代码的时候,当然会报错。

  要让 resolve 中的 onResolve_ 函数延后执行,可以在 resolve 函数里面加上一个定时器,也就是取消代码中的注释。

  但是采用定时器的效率并不是太高,好在有微任务,所以 Promise 又把这个定时器改造成了微任务。

五、async/await

  使用 promise.then 仍然是相当复杂,虽然整个请求流程已经线性化了,但是代码里面包含了大量的 then 函数,使得代码依然不是太容易阅读。

  基于这个原因,ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,提供了在不阻塞主线程的情况下用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰。

1)生成器

  生成器(Generator)函数是一个带星号函数,而且可以暂停和恢复执行。

function* genDemo() {
    console.log("开始执行第一段")
    yield 'generator 2'

    console.log("开始执行第二段")
    yield 'generator 2'

    console.log("开始执行第三段")
    yield 'generator 2'

    console.log("执行结束")
    return 'generator 2'
}

console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')

  下面来看看生成器函数的具体使用方式:

  (1)在生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行。

  (2)外部函数可以通过 next 方法恢复函数的执行。

2)协程

  要搞懂函数为何能暂停和恢复,那首先要了解协程的概念。协程是一种比线程更加轻量级的存在。

  可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。

  如果从 A 协程启动 B 协程,就把 A 协程称为 B 协程的父协程。

  协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。

  结合上面那段代码的执行过程,画出了下面的“协程执行流程图”。

  

  (1)通过调用生成器函数 genDemo 来创建一个协程 gen,创建之后,gen 协程并没有立即执行。

  (2)要让 gen 协程执行,需要通过调用 gen.next。

  (3)当协程正在执行的时候,可以通过 yield 关键字来暂停 gen 协程的执行,并返回主要信息给父协程。

  (4)如果协程在执行期间,遇到了 return 关键字,那么 JavaScript 引擎会结束当前协程,并将 return 后面的内容返回给父协程。

  为了直观理解父协程和 gen 协程是如何切换调用栈的,可以参考下图:

  

  在 JavaScript 中,生成器就是协程的一种实现方式。

//foo函数
function* foo() {
    let response1 = yield fetch('https://www.geekbang.org')
    console.log('response1')
    console.log(response1)
    let response2 = yield fetch('https://www.geekbang.org/test')
    console.log('response2')
    console.log(response2)
}

//执行foo函数的代码
let gen = foo()
function getGenPromise(gen) {
    return gen.next().value
}
getGenPromise(gen).then((response) => {
    console.log('response1')
    console.log(response)
    return getGenPromise(gen)
}).then((response) => {
    console.log('response2')
    console.log(response)
})

  foo 函数是一个生成器函数,在 foo 函数里面实现了用同步代码形式来实现异步操作。

  不过通常,把执行生成器的代码封装成一个函数,并把这个执行生成器代码的函数称为执行器(可参考著名的 co 框架)。

3)async/await

  async/await 技术背后的秘密就是 Promise 和生成器应用,往低层说就是微任务和协程应用。

  根据 MDN 定义,async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。

async function foo() {
    console.log(1)
    let a = await 100
    console.log(a)
    console.log(2)
}
console.log(0)
foo()
console.log(3)

  先站在协程的视角来看看这段代码的整体执行流程图:

  

  当执行到await 100时,会默认创建一个 Promise 对象。

let promise_ = new Promise((resolve,reject){
  resolve(100)
})

  然后 JavaScript 引擎会暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时会将 promise_ 对象返回给父协程。

  接下来继续执行父协程的流程,打印出 3。随后父协程将执行结束,在结束之前,会进入微任务的检查点,然后执行微任务队列。

  最后触发 promise_.then 中的回调函数,将主线程的控制权交给 foo 函数的协程,并同时将 value 值传给该协程,并继续执行后续打印语句。

六、渲染流水线

1)含有 CSS

  先结合下面代码来看看最简单的渲染流程:

<html>
<head>
    <link href="theme.css" rel="stylesheet">
</head>
<body>
    <div>geekbang com</div>
</body>
</html>

  

  请求 HTML 数据和构建 DOM 中间有一段空闲时间,这个空闲时间有可能成为页面渲染的瓶颈。

  在 DOM 构建结束之后、theme.css 文件还未下载完成的这段时间内,渲染流水线无事可做,因为下一步是合成布局树,而合成布局树需要 CSSOM 和 DOM,所以这里需要等待 CSS 加载结束并解析成 CSSOM。

  CSSOM 体现在 DOM 中就是document.styleSheets,和 DOM 一样,CSSOM 也具有两个作用。

  (1)第一个是提供给 JavaScript 操作样式表的能力。

  (2)第二个是为布局树的合成提供基础的样式信息。

2)含有JavaScript和CSS

  这段代码是我在开头代码的基础之上做了一点小修改,在 body 标签内部加了一个简单的 JavaScript。

<html>
<head>
    <link href="theme.css" rel="stylesheet">
</head>
<body>
    <div>geekbang com</div>
    <script>
        console.log('time.geekbang.org')
    </script>
    <div>geekbang com</div>
</body>
</html>

  

  在执行 JavaScript 脚本之前,如果页面中包含了外部 CSS 文件的引用,或者通过 style 标签内置了 CSS 内容,那么渲染引擎还需要将这些内容转换为 CSSOM,因为 JavaScript 有修改 CSSOM 的能力,所以在执行 JavaScript 之前,还需要依赖 CSSOM。也就是说 CSS 在部分情况下也会阻塞 DOM 的生成。

3)白屏

  从发起 URL 请求开始,到首次显示页面的内容,在视觉上经历的三个阶段。

  (1)第一个阶段,等请求发出去之后,到提交数据阶段,这时页面展示出来的还是之前页面的内容。

  (2)第二个阶段,提交数据之后渲染进程会创建一个空白页面,通常把这段时间称为解析白屏,并等待 CSS 文件和 JavaScript 文件的加载完成,生成 CSSOM 和 DOM,然后合成布局树,最后还要经过一系列的步骤准备首次渲染。

  (3)第三个阶段,等首次渲染完成之后,就开始进入完整页面的生成阶段了,然后页面会一点点被绘制出来。

  要想缩短白屏时长,可以有以下策略:

  (1)通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了。

  (2)还可以尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件。

  (3)将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 sync 或者 defer。

  (4)对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件。

七、页面性能

  页面优化,其实就是要让页面更快地显示和响应。

  通常一个页面有三个阶段:加载阶段、交互阶段和关闭阶段。

  (1)加载阶段,是指从发出请求到渲染出完整页面的过程,影响到这个阶段的主要因素有网络和 JavaScript 脚本。

  (2)交互阶段,主要是从页面加载完成到用户交互的整合过程,影响到这个阶段的主要因素是 JavaScript 脚本。

  (3)关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作。

1)加载阶段

  把这些能阻塞网页首次渲染的资源称为关键资源,例如JavaScript、首次请求的 HTML 资源文件、CSS 文件。

  基于关键资源,继续细化出来三个影响页面首次渲染的核心因素。

  (1)第一个是关键资源个数。

  (2)第二个是关键资源大小。

  (3)第三个是请求关键资源需要多少个 RTT。

  RTT(Round Trip Time) 就是这里的往返时延。它是网络中一个重要的性能指标,表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时长。

  总的优化原则就是减少关键资源个数,降低关键资源大小,降低关键资源的 RTT 次数。

  (1)将 JavaScript 和 CSS 改成内联的形式。如果 JavaScript 代码没有 DOM 或者 CSSOM 的操作,则可以改成 async 或者 defer 属性;同样对于 CSS,如果不是在构建页面之前加载的,则可以添加媒体取消阻止显现的标志。

  (2)压缩 CSS 和 JavaScript 资源,移除 HTML、CSS、JavaScript 文件中一些注释内容,也可以通过取消 CSS 或者 JavaScript 中关键资源的方式。

  (3)通过减少关键资源的个数和减少关键资源的大小搭配来实现。除此之外,还可以使用 CDN 来减少每次 RTT 时长。

2)交互阶段

  谈交互阶段的优化,其实就是在谈渲染进程渲染帧的速度,因为在交互阶段,帧的渲染速度决定了交互的流畅度。

  先来看看交互阶段的渲染流水线(如下图)。

  

  大部分情况下,生成一个新的帧都是由 JavaScript 通过修改 DOM 或者 CSSOM 来触发的。还有另外一部分帧是由 CSS 来触发的。

  一个大的原则就是让单个帧的生成速度变快。所以,下面就来分析下在交互阶段渲染流水线中有哪些因素影响了帧的生成速度以及如何去优化。

  (1)减少 JavaScript 脚本执行时间,不要一次霸占太久主线程。

  一种策略是将一次执行的函数分解为多个任务,另一种是把一些和 DOM 操作无关且耗时的任务放到 Web Workers 中去执行。

  (2)避免强制同步布局。

  通过 DOM 接口执行添加元素或者删除元素等操作后,是需要重新计算样式和布局的,不过正常情况下这些操作都是在另外的任务中异步完成的,这样做是为了避免当前的任务占用太长的主线程时间。

  

  执行 JavaScript 添加元素是在一个任务中执行的,重新计算样式布局是在另外一个任务中执行。

  所谓强制同步布局,是指 JavaScript 强制将计算样式和布局操作提前到当前的任务中。

function foo() {
    let main_div = document.getElementById("mian_div")
    let new_node = document.createElement("li")
    let textnode = document.createTextNode("time.geekbang")
    new_node.appendChild(textnode);
    document.getElementById("mian_div").appendChild(new_node);
    //由于要获取到offsetHeight,
    //但是此时的offsetHeight还是老的数据,
    //所以需要立即执行布局操作
    console.log(main_div.offsetHeight)
}

  

  为了避免强制同步布局,可以调整策略,在修改 DOM 之前查询相关值。

function foo() {
    let main_div = document.getElementById("mian_div")
    //为了避免强制同步布局,在修改DOM之前查询相关值
    console.log(main_div.offsetHeight)
    let new_node = document.createElement("li")
    let textnode = document.createTextNode("time.geekbang")
    new_node.appendChild(textnode);
    document.getElementById("mian_div").appendChild(new_node);   
}

  (3)避免布局抖动。

  所谓布局抖动,是指在一次 JavaScript 执行过程中,多次执行强制布局和抖动操作。

function foo() {
    let time_li = document.getElementById("time_li")
    for (let i = 0; i < 100; i++) {
        let main_div = document.getElementById("mian_div")
        let new_node = document.createElement("li")
        let textnode = document.createTextNode("time.geekbang")
        new_node.appendChild(textnode);
        new_node.offsetHeight = time_li.offsetHeight;
        document.getElementById("mian_div").appendChild(new_node);
    }
}

  

  (4)合理利用 CSS 合成动画。

  合成动画是直接在合成线程上执行的,这和在主线程上执行的布局、绘制等操作不同,如果主线程被 JavaScript 或者一些布局任务占用,CSS 动画依然能继续执行。

  另外,如果能提前知道对某个元素执行动画操作,那就最好将其标记为 will-change,这是告诉渲染引擎需要将该元素单独生成一个图层。

  (5)避免频繁的垃圾回收。

  要尽量避免产生那些临时垃圾数据。尽可能优化储存结构,尽可能避免小颗粒对象的产生。

3)虚拟DOM

  虚拟 DOM 解决的事情。

  (1)将页面改变的内容应用到虚拟 DOM 上,而不是直接应用到 DOM 上。

  (2)变化被应用到虚拟 DOM 上时,虚拟 DOM 并不急着去渲染页面,而仅仅是调整虚拟 DOM 的内部状态,这样操作虚拟 DOM 的代价就变得非常轻了。

  (3)在虚拟 DOM 收集到足够的改变时,再把这些变化一次性应用到真实的 DOM 上。

  把虚拟 DOM 看成是 DOM 的一个 buffer,和图形显示一样,它会在完成一次完整的操作之后,再把结果应用到 DOM 上,这样就能减少一些不必要的更新,同时还能保证 DOM 的稳定输出。

  

八、WebComponent

  WebComponent能提供给开发者组件化开发的能力。

  对内高内聚,对外低耦合。对内各个元素彼此紧密结合、相互依赖,对外和其他组件的联系最少且接口简单。

  WebComponent提供了对局部视图的封装能力,可以让 DOM、CSSOM 和 JavaScript 运行在局部环境中,这样就使得局部的 CSS 和 DOM 不会影响到全局。

1)使用

  WebComponent 是一套技术的组合,具体涉及到了 Custom elements(自定义元素)、Shadow DOM(影子 DOM)和HTML templates(HTML 模板)。

<!DOCTYPE html>
<html>
<body>
    <!--
            一:定义模板
            二:定义内部CSS样式
            三:定义JavaScript行为
    -->
    <template id="geekbang-t">
        <style>
            p {
                background-color: brown;
                color: cornsilk
            }


            div {
                width: 200px;
                background-color: bisque;
                border: 3px solid chocolate;
                border-radius: 10px;
            }
        </style>
        <div>
            <p>time.geekbang.org</p>
            <p>time1.geekbang.org</p>
        </div>
        <script>
            function foo() {
                console.log('inner log')
            }
        </script>
    </template>
    <script>
        class GeekBang extends HTMLElement {
            constructor() {
                super()
                //获取组件模板
                const content = document.querySelector('#geekbang-t').content
                //创建影子DOM节点
                const shadowDOM = this.attachShadow({ mode: 'open' })
                //将模板添加到影子DOM上
                shadowDOM.appendChild(content.cloneNode(true))
            }
        }
        customElements.define('geek-bang', GeekBang)
    </script>

    <geek-bang></geek-bang>
    <div>
        <p>time.geekbang.org</p>
        <p>time1.geekbang.org</p>
    </div>
    <geek-bang></geek-bang>
</body>
</html>

  要使用 WebComponent,通常要实现下面三个步骤。

  (1)首先,使用 template 属性来创建模板。

  (2)其次,需要创建一个 GeekBang 的类。查找模板内容;创建影子 DOM;再将模板添加到影子 DOM 上。

  (3)最后,可以像正常使用 HTML 元素一样使用该元素。

2)影子 DOM

  WebComponent的核心就是影子 DOM。

  (1)影子 DOM 中的元素对于整个网页是不可见的;

  (2)影子 DOM 的 CSS 不会影响到整个网页的 CSSOM,影子 DOM 内部的 CSS 只对内部的元素起作用。

  

  从图中可以看出,使用了两次 geek-bang 属性,那么就会生成两个影子 DOM,并且每个影子 DOM 都有一个 shadow root 的根节点。

  可以将要展示的样式或者元素添加到影子 DOM 的根节点上,每个影子 DOM 都可以看成是一个独立的 DOM,它有自己的样式、自己的属性,内部样式不会影响到外部样式,外部样式也不会影响到内部样式。

九、安全沙箱

  在渲染进程和操作系统之间建一道墙,即便渲染进程由于存在漏洞被黑客攻击,但由于这道墙,黑客就获取不到渲染进程之外的任何操作权限。将渲染进程和操作系统隔离的这道墙就是安全沙箱。

  浏览器中的安全沙箱是利用操作系统提供的安全技术,让渲染进程在执行过程中无法访问或者修改操作系统中的数据,在渲染进程需要访问系统资源的时候,得通过浏览器内核来实现,然后将访问的结果通过 IPC 转发给渲染进程。

  了解了被安全沙箱保护的进程会有一系列的受限操作之后,接下来就可以分析渲染进程和浏览器内核各自都有哪些职责,如下图:

  

1)持久存储

  文件内容的读写都是在浏览器内核中完成的:

  (1)存储 Cookie 数据的读写。通常浏览器内核会维护一个存放所有 Cookie 的 Cookie 数据库,然后当渲染进程通过 JavaScript 来读取 Cookie 时,渲染进程会通过 IPC 将读取 Cookie 的信息发送给浏览器内核,浏览器内核读取 Cookie 之后再将内容返回给渲染进程。

  (2)一些缓存文件的读写也是由浏览器内核实现的,比如网络文件缓存的读取。

2)网络访问

  同样有了安全沙箱的保护,在渲染进程内部也是不能直接访问网络的,如果要访问网络,则需要通过浏览器内核。

  不过浏览器内核在处理 URL 请求之前,会检查渲染进程是否有权限请求该 URL,比如检查 XMLHttpRequest 或者 Fetch 是否是跨站点请求,或者检测 HTTPS 的站点中是否包含了 HTTP 的请求。

3)用户交互

  由于渲染进程不能直接访问窗口句柄,所以渲染进程需要完成以下两点大的改变。

  (1)第一点,渲染进程需要渲染出位图。为了向用户显示渲染进程渲染出来的位图,渲染进程需要将生成好的位图发送到浏览器内核,然后浏览器内核将位图复制到屏幕上。

  (2)第二点,操作系统没有将用户输入事件直接传递给渲染进程,而是将这些事件传递给浏览器内核。然后浏览器内核再根据当前浏览器界面的状态来判断如何调度这些事件,如果当前焦点位于浏览器地址栏中,则输入事件会在浏览器内核内部处理;如果当前焦点在页面的区域内,则浏览器内核会将输入事件转发给渲染进程。

  之所以这样设计,就是为了限制渲染进程有监控到用户输入事件的能力,所以所有的键盘鼠标事件都是由浏览器内核来接收的,然后浏览器内核再通过 IPC 将这些事件发送给渲染进程。

十、Chrome性能工具

1)Audits

  性能指标的分数是由六项指标决定的,它们分别是:

  (1)首次绘制 (First Paint)。

  如果 FP 时间过久,那就是页面的 HTML 文件可能由于网络原因导致加载时间过久,可利用网络面板做性能分析。

  (2)首次有效绘制 (First Meaningfull Paint),由于 FMP 计算复杂,所以现在不建议使用该指标了;

  (3)首屏时间 (Speed Index),即 LCP;

  如果 FMP 和 LCP 消耗时间过久,那么有可能是加载关键资源花的时间过久,也有可能是 JavaScript 执行过程中所花的时间过久,所以可以针对具体的情况来具体分析。

  (4)首次 CPU 空闲时间 (First CPU Idle),也称为 First Interactive,对大部分用户输入做出响应即可;

  要缩短首次 CPU 空闲时长,就需要尽可能快地加载完关键资源,尽可能快地渲染出来首屏内容。

  (5)完全可交互时间 (Time to Interactive),简称 TTI,页面的内容已经完全显示出来了,所有的 JavaScript 事件已经注册完成,页面能够对用户的交互做出快速响应,通常满足响应速度在 50 毫秒以内。

  如果要解决 TTI 时间过久的问题,可以推迟执行一些和生成页面无关的 JavaScript 工作。

  (6)最大估计输入延时 (Max Potential First Input Delay),估计 Web 页面在加载最繁忙的阶段,窗口中响应用户输入所需的时间。

  为了改善该指标,可以使用 Web Worker 来执行一些计算,从而释放主线程。另一个有用的措施是重构 CSS 选择器,以确保它们执行较少的计算。

  

  在渲染进程确认要渲染当前的请求后,渲染进程会创建一个空白页面,把创建空白页面的这个时间点称为 First Paint,简称 FP。

  上图中,bundle.js 是关键资源,因此需要完成加载之后,渲染进程才能执行该脚本,然后脚本会修改 DOM,引发重绘和重排等一系列操作,当页面中绘制了第一个像素时,把这个时间点称为 First Content Paint,简称 FCP。

  接下来继续执行 JavaScript 脚本,当首屏内容完全绘制完成时,把这个时间点称为 Largest Content Paint,简称 LCP。

  接下来 JavaScript 脚本执行结束,渲染进程判断该页面的 DOM 生成完毕,于是触发 DOMContentLoad 事件。等所有资源都加载结束之后,再触发 onload 事件。

2)Performance

  Performance 可以记录站点在运行过程中的性能数据,有了这些性能数据,就可以回放整个页面的执行过程,这样就方便定位和诊断每个时间段内页面的运行情况

  下图区域 1,设置该区域中的“Network”来限制网络加载速度,设置“CPU”来限制 CPU 的运算速度。区域 2 和区域 3有两个按钮,黑色按钮是用来记录交互阶段性能数据的,带箭头的圆圈形按钮用来记录加载阶段的性能数据。

  

  无论采用哪种方式录制,最终所生成的报告页都是一样的。

  

  (1)概览面板

  引入了时间线,Performance 就会将几个关键指标,诸如页面帧速 (FPS)、CPU 资源消耗、网络请求流量、V8 内存使用量 (堆内存) 等,按照时间顺序做成图表的形式展现出来。

  除了以上指标以外,概览面板还展示加载过程中的几个关键时间节点,如 FP、LCP、DOMContentLoaded、Onload 等事件产生的时间点。这些关键时间点体现在了几条不同颜色的竖线上。

  (2)性能面板

  记录了非常多的性能指标项,比如 Main 指标记录渲染主线程的任务执行过程,Compositor 指标记录了合成线程的任务执行过程,GPU 指标记录了 GPU 进程主线程的任务执行过程。

  通过概览面板来定位问题的时间节点,然后再使用性能面板分析该时间节点内的性能数据。比如概览面板中的 FPS 图表中出现了红色块,那么点击该红色块,性能面板就定位到该红色块的时间节点内。

  

  (3)解读性能面板的各项指标

  先看最为重要的 Main 指标,它记录了渲染进程的主线程的任务执行记录。

  

  光栅化线程池 (Raster),用来让 GPU 执行光栅化的任务。因为光栅化线程池和 GPU 进程中的任务执行也会影响到页面的性能,所以性能面板也添加了这两个指标,分别是 Raster 指标和 GPU 指标。

  渲染进程中除了有主线程、合成线程、光栅化线程池之外,还维护了一个 IO 线程。

  除此之外,性能面板还添加了其他一些比较重要的性能指标。

  a、第一个是 Network 指标,网络记录展示了页面中的每个网络请求所消耗的时长,并以瀑布流的形式展现。

  b、第二个是 Timings 指标,用来记录一些关键的时间节点在何时产生的数据信息,诸如 FP、FCP、LCP 等。

  c、第三个是 Frames 指标,帧记录就是用来记录渲染进程生成所有帧信息,包括了渲染出每帧的时长、每帧的图层构造等信息。

  d、第四个是 Interactions 指标,用来记录用户交互操作,比如点击鼠标、输入文字等交互信息。

  (4)详情面板

  通过上面的图形只能得到一个大致的信息,如果想要查看这些记录的详细信息,就需要引入详情面板了。

 

 posted on 2020-08-16 14:10  咖啡机(K.F.J)  阅读(795)  评论(0编辑  收藏  举报