0%

基本概念

Vue是一个实现了数据驱动的框架, 当数据改变的时候,需要通知到依赖这个数据的订阅者watcher。将数据和依赖数据的订阅者watcher联系起来的过程,称为依赖收集。

收集过程

一般来讲,数据的订阅者watcher常见的类型有如下3种:

  1. computed watcher
  2. user watcher
  3. render watcher

不同类型的watcher完成依赖收集是有顺序的,比如上面提到的3种类型watcher执行顺序就是:computed watcher -> user watcher -> render watcher。

下面按顺序来分析3种watcher完成依赖收集的过程:

computed watcher

在初始化数据的时候,如果组件中有使用computed计算属性,会执行initComputed方法,一个computed属性对应生成一个computed watcher。computed watcher是惰性的,不会马上执行watcher实例的get方法,watcher实例的get方法是依赖收集的一个关键方法,也就是说初始化computed的时候是没有做依赖收集的。

当后面使用到computed属性时,如template模版中第一次使用到计算属性,会执行computed对应watcher的get方法。

在这里插入对依赖管理器的介绍,帮助理解后续的过程。对于每一个被用到的响应式数据,都需要有一个dep实例来管理依赖这个数据的watcher。构造函数Dep有一个静态属性target,可以理解成一个全局变量,开始当前watcher的依赖收集的时候,就将当前依赖收集的watcher赋值给target。结束当前watcher的依赖收集的时候,将target赋值为watcher栈里的最上层的一个watcher。从上面的描述中可以看出,在做依赖收集的时候,是按watcher顺序执行来完成依赖收集的。

在get方法中会将当前的watcher压入watcher栈中,并将依赖管理器Dep的静态属性target赋值为当前watcher。然后执行当前watcher实例的getter方法,也就是computed属性对应的计算方法,里面涉及到对响应式数据的取值操作,就会执行数据的getter方法(为了跟前面的get方法区别开,使用getter)。

这里注意一下响应式数据,在执行initComputed之前,数据都是已经被数据劫持过的,使用的是Object.defineProperty方法。所以在对响应式数据取值的时候,才会执行数据的getter方法。

在getter方法中,除了取值还做了依赖收集的工作。判断Dep.target是否存在,如果存在就执行dep实例的depend方法,而dep.depend方法中调用的是当前watcher的addDep方法。

因为在前面的get方法中完成了computed watcher的压栈,所以这个时候的Dep.target是存在的,且值为computed watcher,会顺利执行到当前watcher的addDep方法。

在watcher.addDep方法中做了两件事,通过id来避免了重复收集:

  1. 判断当前的watcher是否有收集关于当前dep实例的信息,如果没有则收集到自己的属性中;
  2. 判断当前的dep实例是否有收集当前watcher的信息,如果没有则收集到自己的属性中。

watcher.addDep执行完成后,接着执行computed属性对应的计算方法,对使用到的响应式数据都完成依赖收集。完成依赖收集之后,将当前的watcher出栈。最后完成一个细节,遍历deps属性移除旧的订阅,更新为新的订阅。一些列操作完成之后,开始下一个watcher的依赖收集。

user watcher

在初始化数据的时候, 如果组件中有使用watch监听属性,会执行initWatch方法,一个watch属性对应生成一个user watcher。创建user watcher实例之后,会马上执行实例的get方法。

在get方法中也会将当前的watcher压入watcher栈中,并将依赖管理器Dep的静态属性target赋值为当前watcher。

执行当前watcher实例的getter方法,这个getter方法是在创建实例的时候执行parsePath方法返回的一个方法,会从Vue实例中获取到当前watch属性的值。因为watch的属性都是响应式数据,所以取值的时候会执行数据的getter方法。

在getter方法中,除了取值还做了依赖收集的工作。判断Dep.target是否存在,如果存在就执行dep实例的depend方法,而dep.depend方法中调用的是当前watcher的addDep方法。

因为在前面的get方法中完成了user watcher的压栈,所以这个时候的Dep.target是存在的,且值为user watcher,会顺利执行到当前watcher的addDep方法。

在watcher.addDep方法中做了两件事,通过id来避免了重复收集:

  1. 判断当前的watcher是否有收集关于当前dep实例的信息,如果没有则收集到自己的属性中;
  2. 判断当前的dep实例是否有收集当前watcher的信息,如果没有则收集到自己的属性中。

watcher.addDep执行完成后,将当前的watcher出栈。最后也是完成一个细节,遍历deps属性移除旧的订阅,更新为新的订阅。一些列操作完成之后,开始下一个watcher的依赖收集。

render watcher

初始化数据完成之后,对Vue实例进行挂载。将VNode渲染成DOM的方法作为创建watcher的第二个参数,创建了一个render watcher,一个组件实例对应一个render watcher。创建render watcher实例之后,会马上执行实例的get方法。

在get方法中也会将当前的watcher压入watcher栈中,并将依赖管理器Dep的静态属性target赋值为当前watcher。

执行当前watcher实例的getter方法,也就是将VNode渲染成DOM的方法(创建watcher实例时传入的第二个参数),在创建DOM的时候,需要对响应式数据进行取值,这就会执行数据的getter方法。

在getter方法中,除了取值还做了依赖收集的工作。判断Dep.target是否存在,如果存在就执行dep实例的depend方法,而dep.depend方法中调用的是当前watcher的addDep方法。

因为在前面的get方法中完成了render watcher的压栈,所以这个时候的Dep.target是存在的,且值为user watcher,会顺利执行到当前watcher的addDep方法。

在watcher.addDep方法中做了两件事,通过id来避免了重复收集:

  1. 判断当前的watcher是否有收集关于当前dep实例的信息,如果没有则收集到自己的属性中;
  2. 判断当前的dep实例是否有收集当前watcher的信息,如果没有则收集到自己的属性中。

watcher.addDep执行完成后,将当前的watcher出栈。最后也是完成一个细节,遍历deps属性移除旧的订阅,更新为新的订阅。一些列操作完成之后,开始下一个watcher的依赖收集。

差异

这三种watcher的其实大同小异,主要差别在于两点:

  1. 实例的get方法执行时机不一样;
  2. 实例的getter方法不一样,可以根据需求自定义。

数组响应式的限制

Vue对数组响应式的处理可以分为两个方面来理解:

  1. 对数组项的以下7个操作方法做了重写,当数组使用下面方法改变了数组的值,Vue会派发更新通知依赖数组的watcher。
    • push
    • pop
    • shift
    • unshift
    • splice
    • sort
    • reverse
  2. 数组项的值如果是对象,会对对象属性进行数据劫持实现响应式,完全走的就是对普通对象属性进行依赖收集,派发更新那一套流程。

Vue中不能检测以下数组的变动:

  1. 利用索引直接设置一个数组项;
  2. 修改数组的长度。

Vue中的数据响应式,实际上是通过对对象属性进行的数据劫持,而且无论是数组还是对象,响应式都是在初始化的时候完成的。Vue希望开发者可以提前声明所有的响应式属性,可以让响应式更可控。

  1. 数组项并不是一个对象的属性,在Vue中是不具有响应式的,在对这个数组项直接赋值的时候,数组值会改变,但并不会触发数组的setter方法。

    ⚠️如果这个数组项的值是一个对象,那这个对象里面的属性值是响应式的。

  2. 修改数组的长度,数组值会改变,但并不会触发数组的setter方法。新数组中只有数组项里还存在的对象属性是响应式的。

与其说Vue不能检测上面两种方式的变化,不如说Vue不想检测,尤大给的原因是性能代价和获得的用户体验收益不成正比。其实也好理解,对数据项直接赋值和修改数组的长度这两种方式都太不可控了。

数组响应式的实现

在对数组类型的数据进行响应式处理之前,先往数组上绑定了数原型上有的一些方法和属性。这些方法和属性中有7个方法是被Vue做了重写的。因为这7个方法会改变数组本身的值,而Vue根本就没有对数组项这个维度进行响应式处理,所以不会触发数组的setter方法。

为了处理这种情况,使数组在用的这7种方法改变值的时候会触发依赖更新,Vue将这几个方法做了一个重写。

其中pop、shift、sort、reverse四个方法,因为没有添加数组项,所以重写的步骤很简单,就是执行方法、手动派发更新和返回方法的结果值。

而push、unshift和splice方法因为添加了数据项,所以重写的步骤多了一项,简单来讲就是执行方法、将新增的数据项进行响应式处理、手动派发更新和返回方法的结果值。

往目标数组中绑定这些方法和属性时,根据浏览器是否支持__proto__,分为了两种处理方法。

  1. 支持__proto__:调用protoAugment方法通过原型式继承的方式,将目标数组的原型指向改造后的数组的实例,这个实例中既有数组的所有属性和方法,又有重写了的7个方法,这种方式是将方法和属性绑定在目标的原型链上。
  2. 不支持__proto__:调用copyAugment方法,通过def函数,遍历改造后的数组实例,将方法和属性挂在到目标数组的属性上。

总结

出于对性能的考虑,没有直接用Object.defineProperty去监听数组,但是需要知道Object.defineProperty是具备这个能力的。Vue2.x通过对常见的7种方法进行了重写,来实现对数组项的监听。

浏览器中的DOM的设计是非常复杂的,当我们频繁的去做DOM更新的时候,会产生一定的性能问题。

而Virtual DOM就是一个用js对象去描述一个DOM节点,对Virtual DOM的操作代价会少很多。

这个设计在react中也有用到,在Vue中Virtual DOM是用VNode这么一个Class去描述的,借鉴了⼀个开源库 snabbdom 的实现,然后加⼊了⼀些 Vue.js 特⾊的东⻄。

⚠️ 使用了虚拟DOM不一定会比直接渲染真实DOM快。举个🌰:一些很明显直接替换DOM的情况下,用虚拟DOM+diff算法,明显是会更慢的。所以严谨的说法是,在复杂视图情况下,使用虚拟DOM+diff算法可以找到DOM树变更的地方,复用之前的DOM,是可以减少DOM的操作使渲染速度更快的。

定义

在下次DOM更新循环结束之后执行延迟回调。

在修改数据之后立即使用这个方法,获取更新后的DOM。实际上在派发更新过程中,同步任务执行之后会执行nextTick方法,参数是一个watcher队列执行函数。目的是当本次事件循环中所有的数据变化完成后,异步批量执行watcher队列的回调来实现DOM更新。所以在修改数据之后立即使用这个方法,就相当于nextTick执行更新了DOM的回调之后,再来执行这个nextTick里面的回调函数。

用途

需要在视图更新之后,基于新的视图进⾏操作。

实现原理

nextTick接收一个函数作为参数,这个函数也可以理解成一个回调函数。nextTick内部其实存储回调函数的一个队列数组,当外面执行同步执行几个nextTick方法的时候,会将这几个nextTick方法的参数存储到回调函数队列中。然后在nextTick的内部会创建一个异步任务。

运行环境不同生成的异步任务的方式和结果也可能不同,具体的顺序如下:

  1. 判断是否支持Promise,如果支持就在Promise.then方法的回调中去遍历执行队列数组里的回调函数;
  2. 判断是否支持MutationObserver,如果支持就内部创建一个DOM元素,模拟修改DOM元素,以这种方式来执行监听DOM变化的回调函数,在这个回调函数中遍历执行队列数组里的回调函数;
  3. 判断是否支持setImmediate,如果支持就在setImmediate回调中遍历执行队列数组里的回调函数;
  4. 如果以上几种都不支持,setTimeout回调中遍历执行队列数组里的回调函数。

前面两种方式生成的异步任务是微任务,后面两种生成的异步任务是宏任务。Vue2.x不同的版本对于这个过程的实现可能会有细微差异,但是目的就是生成一个异步任务,这个任务执行的时间越早越好。

nextTick还有一个实现细节,就是当参数没传且运行环境支持Promise的时候,会返回一个Promise的实例,这个小功能可以按需使用。

作用

将VNode渲染成真实的DOM

核心概念

patch方法

patch可以理解成打补丁的意思,在patch方法中采用diff算法比较新旧节点,一边比较一边给真实的DOM打补丁。

diff算法

diff算法用来比较新旧节点,比较只会在同层级比较,不会跨层级比较,这个是相对于传统diff算法的一个很大提升。

过程分析
  • 判断两节点是否值得比较(sameVNode),值得比较则执行patchVNode方法。这个方法做了如下事情:

    1. 找到当前oldVNode对应的真实节点,称为el;

    2. 判断VNode和oldVNode是否指向同一个对象,如果是,直接返回;

    3. 如果两者都是文本节点且不相等,将el的文本节点设置为VNode的文本节点;

    4. 如果oldVNode有子节点而VNode没有,删除el的子节点;

    5. 如果oldVNode没有子节点而VNode有,则将VNode的子节点生成真实节点,添加到el;

    6. 如果两者都有子节点,则通过updateChildren函数比较子节点。由于updateChildren方法是diff中相对复杂的一部分,拎出来单独分析一下。

  • 不值得比较则用VNode替换oldVNode。具体流程如下:

    1. 找到当前oldVNode对应的真实节点以及该节点的父节点;
    2. 根据VNode生成新节点;
    3. 将新节点添加到父节点;
    4. 移除旧节点。
updateChildren方法

updateChildren方法用来给新旧VNode都有子节点的情况打补丁。可以将新旧VNode理解成两个数组,以旧数组为基础,通过删除、移动、插入的方式,将旧数组的值转换成新数组的值。

⚠️这里的值指的不是两个数组所在的存储地址,只是数组项的值。而且每次删除、移动、插入的时候都会操作真实DOM。

匹配过程

旧数组:[oldS,…,oldE]

新数组:[S, …, E]

其中oldS和oldE是待匹配旧节点的开始子节点和结束子节点,S和E是待匹配新节点的开始子节点和结束子节点。这里的oldS、oldE、S和E在diff过程中是会变的,可以理解成一个变量或者一个代号。因为在diff过程中,如果新旧子节点已经匹配过了,会从待匹配的新旧节点列表中移除。

可以分为5个匹配方式来对比是否是sameVNode,来按顺序完成匹配:

  1. 旧首新首对比:匹配成功会将旧结束子节点移动到真实DOM中目前可供选择位置的最前,因为不能影响到已经patch好的子节点位置,然后移除待匹配的新旧节点列表中的开始子节点;

  2. 旧尾旧尾对比:匹配成功会将旧开始子节点移动到真实DOM中目前可供选择位置的最后,因为不能影响到已经patch好的子节点位置,然后移除待匹配的新旧节点列表中的结束子节点;

  3. 旧首新尾对比:匹配成功会将旧开始子节点移动到真实DOM中目前可供选择位置的最后,因为不能影响到已经patch好的子节点位置,然后移除待匹配的旧节点列表中的开始子节点和新节点列表中的结束子节点;

  4. 旧尾新首对比:匹配成功会将旧结束子节点移动到真实DOM中目前可供选择位置的最前,因为不能影响到已经patch好的子节点位置,然后移除待匹配的旧节点列表中的结束子节点和新节点列表中的开始子节点;

  5. 如果上面四种没有匹配成功会分成两种情况:

    • 如果新旧子节点都存在key,会根据旧节点的key生成一张hash表,用S的key与hash表做匹配,判断是否时为sameVNode。
      1. 匹配成功:将匹配成功的节点移动到真实DOM中目前可供选择位置的最前,因为不能影响到已经patch好的子节点位置,然后移除待匹配旧节点中已经匹配到S的key的节点和待匹配新节点中的S;
      2. 匹配失败:将S生成真实DOM节点,插入到目前可供选择位置的最前,也就是oldS的位置,然后然后移除待匹配新节点中的S。
    • 如果没有key,将直接将遍历待匹配旧节点列表。
      1. 匹配成功:将匹配成功的节点移动到真实DOM中目前可供选择位置的最前,因为不能影响到已经patch好的子节点位置,然后移除待匹配旧节点中已经匹配到S的节点和待匹配新节点中的S;
      2. 匹配失败:将S生成真实DOM节点,插入到目前可供选择位置的最前,也就是oldS的位置,然后然后移除待匹配新节点中的S。

    ⚠️ 常见场景解析:

    • v-for的时候设置key,就是为了如果首首、尾尾、首尾、尾首这四种匹配方式没匹配到的时候,可以使用key来更快地寻找可复用的节点,而不是只能用遍历的方式。

    • 不能用index索引来做key的原因也是这个,因为用index索引来做key,并不能找到想要复用的旧节点,甚至可能会导致一个子节点都复用不了,起到负面作用。

      举个🌰:一个数组长度是偶数的数组用index做key,被反序后,会给每一个子节点匹配到一个错误的不可复用的子节点,使diff的效率比没加key更低。

  6. 循环上面的过程,不断的将待匹配节点列表向内部收缩。当待匹配的新旧节点列表有一个先被清空的话,将执行下面判断。

    ⚠️ 这里说的待匹配列表只是为了帮助理解来提出的一个概念,并没有一个真实的变量来存储这个待匹配列表。真实在数组中描述待匹配列表,是通过移动开始节点和结束节点的指针位置来实现。

    1. 待匹配的旧节点列表被清空,也就是oldStartIdx > oldEndIdx,说明旧节点都被patch了,还有新节点没被处理到,批量新增待匹配的新节点;
    2. 待匹配的新节点列表被清空,也就是newStartIdx > newEndIdx,说明新节点都被patch了,还有多余的旧节点没被处理到,批量删除待匹配的旧节点。
目的

完成这一些复杂的匹配,就是为了更快更多地复用DOM的旧节点,因为DOM节点的创建开销是很大的。其实更快和更多是矛盾的,Vue找到了一个平衡点,比较只会在同层级进行, 不会跨层级。

调用时机

首次渲染

父子组件在加载的时候,生命周期执行的顺序为:

父beforeCreate->父created->父beforeMount->子beforeCreate->子created->子beforeMount->子mounted->父mounted。

下面从首次渲染的流程角度,来分析为什么是这个顺序。

首次渲染时,父组件render生成父组件的VDOM,然后会执行父组件的patch方法,目的是将VDOM渲染成DOM。

在执行父组件patch方法解析到子组件VNode的时候,子组件VNode是被父组件的render函数生成的组件类型的VNode。在生成组件类型的组件过程中,完成了构造子类构造函数、安装组件钩子函数和实例化VNode,生成的子组件是没有children的,这个时候不知道子组件的细节。

patch到子组件VNode时,会触发子组件生命周期中的init hook,在init hook中开始对子组件进行挂载,将子组件当作一个新的Vue实例,重新进行初始化、render生成VDOM,然后执行子组件的patch。

解析子组件VNode这个过程,以深度优先的算法,将所有的子孙组件挂载到根实例上。有子组件就先完成子组件的完整挂载过程,等子组件完成挂载后再回到父组件完成父组件的挂载。从这个逻辑来理解,上面的生命周期执行顺序就很好理解了。

首次渲染patch过程比较简单,就是简单的插入,因为旧节点为空。

完成patch之后 ,将更新后的VNode数据赋值给oldVNode。

数据更新的时候

数据更新触发渲染watcher更新的时候,会重新生成render函数,生成新的VNode,供patch方法为旧DOM打补丁使用。即使数据更新发生在很多组件上,每次执行渲染watcher更新,重新patch的时候关注的都只是当前组件。

⚠️ 每个组件都有自己的渲染watcher。

数据更新时patch过程就会相对复杂,会更充分使用到diff算法的细节。oldVNode就是上次渲染时的VNode,VNode就是本次重新生成的VNode,通过diff算法,一边一边比较一边给真实的DOM打补丁。

完成patch之后 ,将更新后的VNode数据赋值给oldVNode。

基本介绍

render方法将Vue实例渲染成一个虚拟Node,在生成VNode的时候完成了渲染watcher的依赖的收集。

patch方法将VNode转换为真正的DOM节点。

前情提要

在Vue实例挂载的时候,执行render方法来生成VNode。而在开发的时候,开发者大多数情况并不是自己手写的render函数,而是写的template模版或者el。在mounted的方法中,会将template模版编译成render方法,如果是el会多一个步骤,得先从el中提取出template模版。

本集看点

render渲染VNode主要步骤
  1. 将children参数规范化:由于生成VNode的参数中children必须是VNode类型的,而下面几种情况下children不符合条件,所以在根据参数实例化VNode之前需要将children规范化成一个类型为VNode的Array。

    • render函数是编译生成的,理论上编译生成的children已经是VNode类型的,但是当组件是函数式组件时,返回的是一个数组而不是一个根结点,所以需要用Array.prototype.concat方法将整个children数组打平,让深度只有一层。

      ⚠️只有这一种情况,调用simpleNormalizeChildren方法来实现children规范化。

      1. render函数是用户手写的,当children只有一个基础类型节点的时候,会调用createTextVNode方法创建一个文本节点的VNode;
      2. render函数是手写的,当编译slot或者v-for的时候。

      ⚠️只有这两种情况,调用normalizeChildren方法来实现children规范化。

  2. 创建VNode实例

    为每一个html标签创建一个VNode,顺序是先子后父,从上到下。可以理解成按照标签闭合的顺序,依次创建VNode,一个template模版中的根标签生成的就是当前组件的VNode树,也称为VDOM。

    对tag进行判断,创建不同类型的VNode:

    • 如果是字符串类型且是内置的节点,直接创建普通VNode;
    • 如果是字符串类型且是已注册的组件名,则通过createComponent方法创建一个组件类型的VNode;
    • 如果是字符串类型,又不是上面两种情况,创建一个未知标签的VNode;
    • 如果是组件类型,则通过createComponent方法创建一个组件类型的VNode。
创建组件类型VNode主要步骤

render渲染VNode可能会生成3种类型的VNode:

  1. 普通类型VNode;
  2. 未知标签VNode;
  3. 组件类型VNode。

因为前面两种都比较简单,这里着重分析组件类型VNode。通过createComponent方法将组件渲染成VNode主要做了3个事情:

  1. 构造子类构造函数:开发者在写组件的时候,通常都是创建一个普通的对象,Vue内部使用Vue.extend将这个普通对象做了扩展,使这个对象可以像Vue实例一样可以完成初始化、挂载、渲染等一系列功能。

    Vue.extend的作用是构造一个Vue的子类,使用一种非常经典的原型继承的方式把一个纯对象转换成了一个继承于Vue的构造器Sub并返回,然后对Sub对象本身扩展一些属性,如扩展options、添加全局API,并且对配置做一些初始化工作。

    最后对这个Sub构造函数做了缓存,避免多次执行Vue.extend的时候对同一个子组件重复构造。在执行父组件patch方法解析到组件VNode的时候,会触发子组件生命周期中的init hook,在init hook中开始对子组件进行挂载,再走到子组件初始化逻辑。

  1. 安装组件钩子函数:将组件特有的几个钩子和Vue实例的生命周期钩子合并。

    在实例化Vue的时候,Vue的挂载是在初始化完成的时候,这个是一个同步的事件,有一个固定的地方可以执行这块逻辑,不需要钩子函数来回调Vue的挂载操作。

    而子组件的挂载时机,是在父组件patch的过程中。子组件为了更好的管理自己的生命周期,添加了init、prepatch、insert和destroy四个生命周期钩子,分别对应初始化、更新、挂载完成、销毁。

    在父组件VNode执行执行patch的时候会执行上面的钩子函数,这就可以实现组件的渲染。这里需要注意的一点是Vue实例原有的几种生命钩子是可以正常使用的,如mounted钩子函数会在insert钩子执行的时候被调用。

  1. 实例化VNode:实例化一个VNode,不过需要注意的是组件的VNode是没有children的。因为父组件render方法中,是看不到子组件内部结构的,只会为子组件生成一个组件VNode。

    父组件render方法生成VDOM之后,会执行到patch方法,在patch过程中会将子组件当作一个新的Vue实例,重新进行初始化、render生成VDOM,在这个子组件render方法生成的VDOM中,才会有children。

    也就是说render方法在解析子组件的时候,只会将子组件生成一个组件VNode,不会关心子组件是否有子组件,子组件是否有子组件,这个事情由子组件来关心。

简介

Node中的Event Loop和浏览器中的是完全不相同的东西。Node采用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv。libuv是一个事件驱动的跨平台抽象层,封装了不同操作系统的一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。

运行机制

V8引擎解析js脚本,解析后的代码调用Node API。libuv库负责Node API的执行,将不同的任务分配给不同的线程,形成一个事件循环,以异步的方式将任务的返回结果返回给V8引擎,再由V8引擎将结果返回给用户。

事件循环的阶段顺序

输入数据阶段 incoming data -> 轮询阶段 poll -> 检查阶段 check -> 关闭事件回调阶段 close callback -> 定时器检测阶段 timer -> I/O事件回调阶段 I/O callbacks -> 闲置阶段 idle,prepare -> 轮询阶段 poll …

六大阶段概述

  • 定时器检测阶段 timer:执行timer的回调,即setTimeout、setInterval里面的回调函数;
  • I/O事件回调阶段 I/O callbacks:执行上一轮循环中未被执行的一些I/O回调;
  • 闲置阶段 idle, prepare:仅系统内部使用;
  • 轮询阶段 poll:检索新的I/O事件,执行与I/O相关的回调;
  • 检查阶段 check:setImmediate()回调函数在这里执行;
  • 关闭事件回调阶段 close callback:一些关闭的回调函数,如socket.on(‘close’, …)。

⚠️每个阶段都有一个先进先出队列来执行回调。通常情况下,当事件循环进入给定的阶段后,将执行该阶段的任何操作,然后执行该阶段队列中的回调。当该队列执行完毕或达到最大回调限制时,事件循环将移动到下一阶段。

三大阶段详述

日常开发中绝大部分的异步任务都是在poll、check、timer这三个阶段,重点分析一下。

timer

timer阶段会执行setTimeout、setInterval里面的回调函数,并且是由poll阶段控制的。在Node中定时器指定的时间也不是准确时间,只能是尽快执行。

check

setImmediate()回调函数在这里执行。

poll

poll阶段是一个至关重要的阶段,执行逻辑相对复杂,具体流程如下。

在这一阶段中,系统会做两件事情:

  1. 回到timer阶段执行回调:设定了timer且poll队列为空,如果有timer超时,则会回到timer阶段;
  2. 执行I/O回调(没满足上面的条件就会走下面流程):
    • 如果poll队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制;
    • 如果poll队列为空时,也有两种情况:
      • 如果有setImmediate回调需要执行,poll阶段会停止并且进入到check阶段执行回调;
      • 如果没有setImmediate回调需要执行,会等待回调被加入队列中并立即执行回调。这里有个超时时间设置,防止一直等待下去。

分析差别

Node中的事件循环和浏览器的事件循环,差别就在于浏览器中事件循环中异步任务只分为了宏任务和微任务,他们执行的是同属于一个阶段的,简单理解为渲染之前的阶段。而Node中的不同的宏任务会有不同的执行阶段,且微任务的执行时机跟Node的版本还有关系。

Node中宏任务和微任务
宏任务 macro-task
  • setTimeout:timers阶段执行;
  • setInterval:timers阶段执行;
  • setImmediate:check阶段执行;
  • script 整体代码:执行同步代码,将不同类型的异步任务添加到任务队列;
  • I/O 操作:poll阶段执行。
微任务 micro-task
  • process.nextTick:与普通的微任务有区别,在微任务队列执行之前执行;
  • Promise.then;
版本差异总结

node11之前,每一个event loop阶段完成后都会先清空nextTick队列,再清空微任务队列。

node11之后,process.nextTick是微任务的一种,但还是执行顺序优先于Promise.then。在异步任务的执行方面,已经在向浏览器看齐,最大的改变是微任务的执行时机发生变化了。当执行完一个宏任务时,生成的微任务会在这个宏任务出队列的时候立即执行,而不是等到一个event loop阶段再去执行。

⚠️虽然node11之后,异步方法的执行方面已经在向浏览器看齐了,但是不同的宏任务还是位于不同的阶段去执行,这个跟浏览器还是很大差别的。

举几个🌰
  1. 微任务执行时机
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
setImmediate(() => {
console.log('timeout1')
Promise.resolve().then(() => console.log('promise resolve'))
process.nextTick(() => console.log('next tick1'))
});
setImmediate(() => {
console.log('timeout2')
process.nextTick(() => console.log('next tick2'))
});
setImmediate(() => console.log('timeout3'));
setImmediate(() => console.log('timeout4'));

// 执行结果:
// node11之前:timeout1 -> timeout2 -> timeout3 -> timeout4 -> next tick1 -> next tick2 -> promise resolve

// node11之后:timeout1 -> next tick1 -> promise resolve -> timeout2 -> next tick2 -> timeout3 -> timeout4

过程分析:

node11之前,在check阶段执行setImmediate的时候遇到的微任务都会先放入微任务队列,等check阶段所有的setImmediate执行完成之后,在进入关闭事件回调阶段 close callback之前,会讲所有的微任务清空。

node11之后,在check阶段执行setImmediate的时候遇到的微任务都在当前的宏任务执行完成之后,马上清空该宏任务生成的微任务。等微任务清空后,再去执行下一个宏任务。

  1. setTimeout 和 setImmediate
1
2
3
4
5
6
7
setTimeout(function timeout () {
console.log('timeout');
},0);
setImmediate(function immediate () {
console.log('immediate');
});
// 执行结果:结果不固定

对于以上代码来说,setTimeout可能执行在前,也可能执行在后。首先科普一下,setTimeout(fn, 0) === setTimeout(fn, 1),这个是源码决定的。

因为进入事件循环也是需要时间的,如果在进入时间循环的准备阶段花费了大于1ms的时间,那么此时就成了一个timer超时且poll队列为空的状态,会回到timer阶段执行setTimeout回调。

如果进入时间循环的准备阶段花费了小于1ms的时间,不满足timer超时且poll队列为空的状态,就会还是处于poll阶段执行I/O回调。由于poll队列为空,且有setImmediate回调,就直接跳转到check阶段执行immediate回调函数。

  1. 异步I/O回调中的setTimeout 和 setImmediate
1
2
3
4
5
6
7
8
9
10
11
const fs =require('fs')
fs.readFile('./reptileServer.js', 'utf-8', (err, res) => {
if (err) throw err
setTimeout(function timeout () {
console.log('timeout');
},0);
setImmediate(function immediate () {
console.log('immediate');
});
})
// 执行结果:immediate -> timeout

这个🌰跟上面看似只有细微差别,实际上会有完全不同的执行结果,会稳定先执行setImmediate回调。因为在I/O回调生成setTimeout和setImmediate宏任务时,poll队列不为空,所以不管timer是否超时都不会进入到timers阶段。等队列为空时,会直接到check阶段执行setImmediate回调。

在index.js中export同级js文件中暴露的数据:

1
2
3
4
5
6
7
8
9
// index.js 
const hooks = {}
const context = require.context('./', false, /\.js$/)
const keys = context.keys().filter(item => item !== './index.js')
keys.forEach(filePath => {
const file = context(filePath).default
Object.assign(hooks, { [file.name]: file })
})
export default hooks

使用方式:

1
2
import hooks from 'xxx/xxx/index.js' 
const { xxx, xxx, xxx } = hooks

1.配置文件中使用插件

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const CopyWebpackPlugin = require('./plugins/CopyWebpackPlugin.js')
module.exports = {
...
plugins: [
new CopyWebpackPlugin({
from: 'public',
to: '',
/**
* '**'可以匹配任意数量的字符,包括/
* 因为ignore是作为参数传给globby,所以规则在globby中定义
*/
ignore: '**/index.html'
}),
],
...
}

2.plugin文件

CopyWebpackPlugin.js:将静态文件打包到dist目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
const globby = require('globby')
const path = require('path')
const fs = require('fs')
const util = require('util')
const webpack = require('webpack')

// 将文件资源转化成webpack compilation可以识别的格式
const { RawSource } = webpack.sources
// 将读取文件函数基于Promise再次封装
const readFilePromise = util.promisify(fs.readFile)

// 插件都是一个类
class CopyWebpackPlugin {
constructor (options) {
// 从构造函数参数中获取webpack.config.js中的配置
// options: '{ from: 'public', to: '', ignore: '**/index.html' }'
this.options = options
}

/**
* apply函数中根据需求在合适的生命周期注册回调函数
* 函数接收的参数是一个Compiler实例,Compiler扩展自Tapable。
*
* Tapable实现了发布订阅模式:
* 1.实例属性hooks是一个对象,key为事件名称, value可以指定该事件数组的执行方式(同步并行/异步并行/...);
* 2.使用tap/tapAsync/tapPromise往hooks里的事件注册回调;
* 3.使用call/callAsync/promise触发hooks里的事件。
*
* 在webpack中Tapable创建了各种钩子,插件将自己的方法注册到对应的钩子上,
* 相当于往实例的hooks里的事件注册回调,交给webpcak,
* webpack编译时,不同的生命周期触发不同的事件。
*/
apply (compiler) {
/**
* thisCompilation钩子:
* 生命周期:初始化compilation时调用,在触发compilation事件之前调用
* 事件数组执行方式:SyncHook 串行同步,出没出错都往下执行
* 回调参数:compilation,compilationParams
*/
compiler.hooks.thisCompilation.tap('CopyWebpackPlugin', (compilation) => {
/**
* Compilation模块会被Compiler用来创建新的编译(或新的构建)
* compilation实例的additionalAssets钩子:
* 生命周期:可以为compilation创建额外asset
* 事件数组执行方式:AsyncSeriesHook 串行异步
* cb: 调用表示任务完成
*/
compilation.hooks.additionalAssets.tapAsync('CopyWebpackPlugin', async callback => {
const { from, to = '', ignore } = this.options || {}

// 筛选需要拷贝的所有文件的绝对路径
const absoluteFromPath = path.resolve(compiler.options.context, from)
/**
* globby函数第一个参数是匹配的绝对路径,第二个参数是配置对象。
* 下面配置了ignore属性,设置可以忽略的文件
*/
const paths = await globby(absoluteFromPath, { ignore })
console.log(absoluteFromPath)

// 判断文件分类,先简单分为三类:js、css、images
const judgeType = (path) => {
let middle = ''
if (/\.js$/.test(path)) {
middle = 'js'
} else if (/\.css$/.test(path)) {
middle = 'css'
} else if (/\w(\.gif|\.jpeg|\.png|\.jpg|\.bmp)/i.test(path)) {
middle = 'image'
}
return middle
}

try {
const files = await Promise.all(
// 遍历文件内容
paths.map(async absolutePath => {
// 获取文件内容
const source = await readFilePromise(absolutePath)
// 文件名称:webpack.config.js中配置的to + 文件分类 + 获取path的最后一部分
const baseName = path.basename(absolutePath)
const fileName = path.join(to, judgeType(absolutePath), baseName )
// 将资源转成compilation可识别的格式
const rawSource = new RawSource(source)
// 输出文件
compilation.emitAsset(fileName, rawSource)
})
)
// 成功回调
callback()
} catch {
// 抛出异常
callback(new Error('[CopyWebpackPlugin] loading error'))
}
})
})
}
}

module.exports = CopyWebpackPlugin

3.调试技巧

  1. Package.json: 配置执行脚本
1
2
3
4
5
"scripts": {
...
"debug": "node --inspect-brk ./node_modules/webpack/bin/webpack.js --mode development"
...
},
  1. CopyWebpackPlugin.js: 在需要断点的地方添加debugger
1
2
3
4
5
compiler.hooks.thisCompilation.tap('CopyWebpackPlugin', (compilation) => {
debugger
console.log(compilation)
...
})
  1. 控制台执行
1
npm run debug
  1. 打开网页: https://nodejs.org/en/docs/inspector

img

  1. 开始调试

img

配置 webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
module: {
rules: [
{
test: /\.json$/,
use: ['loader1.js', 'loader2.js','loader3.js']
}
]
},
resolveLoader: {
// 寻找loader所在位置
modules: ['node_modules', path.resolve(__dirname, 'loaders/')]
},

编写 loader 文件

loader1.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 执行顺序:webpack当前loader链中的pitch方法同步代码执行完成之后,
* 再从右往左(从下往上)执行loader函数。
* 注意:异步回调使用this.async,异步回调执行完之后才会下一个loader.
*/
module.exports = function (content) {
console.log('1')
return content
}

/**
* pitch方法不是必须的。
* 执行顺序: webpack会从左往右(从上往下)执行loader链中的每一个pitch方法。
* 注意:如果picth方法中有异步代码,webpack执行的时候不会等待,
* 会将loader链中的pitch方法中同步代码执行完再来执行异步代码。
*/
module.exports.pitch = () => {
console.log('pitch1')
}

loader2.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* content: 对于第一个执行的loader为资源的内容,非第一个执行的loader为上一个loader的执行结果。
* map: 可选参数,sourceMap
* mate: 可选参数,传递给下一个loader数据(在这个例子中下一个loader是loader1)
*/
module.exports = function (content, map, meta) {
// 接收pitch传递过来data
// console.log(this.data.customStr) // 传递给loader函数的字符串

// 接收loader3传递过来的meta数据
// console.log(meta) // { preLoader: 'loader3' }

const callback = this.async()
setTimeout(() => {
console.log('2')
callback(null, content, map)
}, 1000)
}

/**
* remainingRequest:当前loader之后的资源请求字符串;
* previousRequest:当前loader之前经历的loader列表以'!'连接的字符串;
* data: 用于与当前loader函数传递数据
*/
module.exports.pitch = (remainingRequest, precedingRequest, data) => {
/**
* precedingRequest: /Users/xxx/loaders/loader3.js!/Users/xxx/testLoader.json
* precedingRequest /Users/xxx/loaders/loader1.js
*/

// 传递给loader函数data数据
// data.customStr = "传递给loader函数的字符串"

console.log('pitch2')
setTimeout(() => {
console.log('async pitch2')
}, 1000)
}

loader3.js

1
2
3
4
5
6
7
8
9
module.exports = function (content, map, meta) {
console.log('3')
// 给loader2传递meta数据
this.callback(null, content, map, { preLoader: 'loader3' })
}

module.exports.pitch = () => {
console.log('pitch3')
}

打包执行结果

1
2
3
4
5
6
7
pitch1
pitch2
pitch3
3
async pitch2
2
1