05Vue源码剖析2

作者: LM林慕 | 来源:发表于2020-07-11 12:53 被阅读0次

    Vue 源码剖析2

    异步更新队列

    Vue 高效的秘诀是一套批量、异步的更新策略

    概念解释

    image.png
    • 事件循环 Event Loop:浏览器为了协调事件处理、脚本执行、网络请求和渲染等任务而制定的工作机制。

    • 宏任务 Task:代表一个个离散的、独立的工作单元。浏览器完成一个宏任务,在下一个宏任务执行开始前,会对页面进行重新渲染。主要包括创建文档对象、解析 HTML、执行主线 JS 代码以及各种事件如页面加载、输入、网络事件和定时器等。

    • 微任务:微任务是更小的任务,是在当前宏任务执行结束后立即执行的任务。如果存在微任务,浏览器会清空微任务之后再重新渲染。微任务的例子有 Promise 回调函数、DOM 变化等。

    体验一下

    Vue 中的具体实现

    image.png
    • 异步:只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。

    • 批量:如果同一个 Watcher 被多次触发,只会被推入到队列中一次。去重对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环 tick 中,Vue 刷新队列执行实际工作。

    • 异步策略:Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 或 setTmmediate,如果执行环境都不支持,则会采用 setTimeout 代替。

    update() core\observer\watcher.js

    dep.notify() 之后 watcher 执行更新,执行入队操作

    queueWatcher(watcher) core\observer\scheduler.js

    执行 watcher 入队操作

    nextTick(flushSchedulerQueue) core\util\next-tick.js

    nextTick 按照特定异步策略执行队列操作

    测试代码:03-timerFunc.html

    watcher 中 update 执行三次,但 run 仅执行一次,且数值变化对 dom 的影响也不是立竿见影的。

    可以研究下相关 API:vm.$nextTick(cb)

    $nextTick 把传入回调函数放入 callbacks 队尾

    $nextTick 原理执行顺序:

    Promise==>MutationObserver==>SetImmediate==>setTimeout

    虚拟 DOM

    概念

    虚拟 DOM(Vitual DOM)是对 DOM 的 JS 抽象表示,他们是 JS 对象,能够描述 DOM 结构和关系。应用的各种状态变化会作用于虚拟 DOM,最终映射到 DOM 上。

    image.png

    体验虚拟 DOM

    Vue 中虚拟 dom 基于 snabbdom 实现,安装 snabbdom 并体验

    <!DOCTYPE html>
    <html lang="en">
    
    <head></head>
    
    <body>
      <div id="app"></div>
      <!--安装并引⼊snabbdom-->
      <script src="../../node_modules/snabbdom/dist/snabbdom.js"></script>
      <script>
        // 之前编写的响应式函数
        function defineReactive(obj, key, val) {
          Object.defineProperty(obj, key, {
            get() {
              return val
            },
            set(newVal) {
              val = newVal
              // 通知更新
              update()
            }
          })
        }
        // 导⼊patch的⼯⼚init,h是产⽣vnode的⼯⼚
        const { init, h } = snabbdom
        // 获取patch函数
        const patch = init([])
        // 上次vnode,由patch()返回
        let vnode;
        // 更新函数,将数据操作转换为dom操作,返回新vnode
        function update() {
          if (!vnode) {
            // 初始化,没有上次vnode,传⼊宿主元素和vnode
            vnode = patch(app, render())
          }
          else {
            // 更新,传⼊新旧vnode对⽐并做更新
            vnode = patch(vnode, render())
          }
        }
        // 渲染函数,返回vnode描述dom结构
        function render() {
          return h('div', obj.foo)
        }
        // 数据
        const obj = {}
        // 定义响应式
        defineReactive(obj, 'foo', '')
        // 赋⼀个⽇期作为初始值
        obj.foo = new Date().toLocaleTimeString()
        // 定时改变数据,更新函数会重新执⾏
        setInterval(() => {
          obj.foo = new Date().toLocaleTimeString()
        }, 1000);
      </script>
    </body>
    
    </html>
    

    优点

    1. 虚拟 DOM 轻量、快速:当它们发生变化时通过新旧虚拟 DOM 比对可以得到最小 DOM 操作量,配合异步更新策略减少刷新频率,从而提升性能
    patch(vnode, h('div', obj.foo))
    
    1. 跨平台:将虚拟 dom 更新转换为不同运行时特殊操作实现跨平台
    <script src="../../node_modules/snabbdom/dist/snabbdom-style.js"></script>
    <script>
        // 增加style模块
        const patch = init([snabbdom_style.default])
        function render() {
          // 添加节点样式描述
          return h('div', {style: {color: 'red' } }, obj.foo)
        }
    </script>
    
    1. 兼容性:还可以加入兼容性代码增强操作的兼容性

    必要性

    vue 1.0 中有细粒度的数据变化侦测,它是不需要虚拟 DOM 的,但是细粒度造成了大量开销,这对于大型项目来说是不可接受的。因此,vue 2.0 选择了中等粒度的解决方案,每⼀个组件⼀个 watcher 实例,这样状态变化时只能通知到组件,再通过引入虚拟 DOM 去进行比对和渲染。

    整体流程

    mountComponent() core/instance/lifecycle.js

    渲染、更新组件

    // 定义更新函数
    const updateComponent = () => {
      // 实际调⽤是在lifeCycleMixin中定义的_update和renderMixin中定义的_render
      vm._update(vm._render(), hydrating)
    }
    

    _render core/instance/render.js

    生成虚拟 dom

    _update core\instance\lifecycle.js

    update 负责更新 dom,转换 vnode 为 dom

    patch() platforms/web/runtime/index.js

    patch是在平台特有代码中指定的

    Vue.prototype.__patch__ = inBrowser ? patch : noop
    

    测试代码,examples\test\04-vdom.html

    patch

    patch 获取

    patch 是 createPatchFunction 的返回值,传递 nodeOps 和 modules 是 web 平台特别实现

    export const patch: Function = createPatchFunction({ nodeOps, modules })
    

    platforms\web\runtime\node-ops.js

    定义各种原生 dom 基础操作方法

    platforms\web\runtime\modules\index.js

    modules 定义了属性更新实现

    watcher.run() => componentUpdate() => render() => update() => patch()

    patch 实现

    patch core\vdom\patch.js

    首先进行树级别比较,可能有三种情况:增删改。

    • new VNode 不存在就删;

    • old VNode 不存在就增;

    • 都存在就执行 diff 执行更新

    image.png

    patchVnode

    比较两个 VNode,包括三种类型操作:属性更新、文本更新、子节点更新

    具体规则如下:

    1. 新老节点均有 children 子节点,则对子节点进行 diff 操作,调用 updateChildren

    2. 如果新节点有子节点点而老节点没有子节点,先清空老节点的文本内容,然后为其新增子节点

    3. 当新节点没有子节点而老节点有子节点的时候,则移除该节点的所有子节点

    4. 当新老节点都无子节点的时候,只是文本的替换

    测试,04-vdom.html

    image.png
    // patchVnode过程分解
    // 1.div#demo updateChildren
    // 2.h1 updateChildren
    // 3.text ⽂本相同跳过
    // 4.p updateChildren
    // 5.text setTextContent
    

    updateChildren

    updateChildren 主要作用是用⼀种较高效的方式比对新旧两个 VNode 的 children 得出最小操作补丁。执行⼀个双循环是传统方式,Vue 中针对 web 场景特点做了特别的算法优化,我们看图说话:

    image.png

    在新老两组 VNode 节点的左右头尾两侧都有⼀个变量标记,在遍历过程中这几个变量都会向中间靠拢。

    当oldStartIdx > oldEndIdx或者newStartIdx > newEndIdx时结束循环。

    下面是遍历规则:

    首先,oldStartVnode、oldEndVnode与newStartVnode、newEndVnode 两两交叉比较,共有4种比较方法。

    当 oldStartVnode 和 newStartVnode 或者 oldEndVnode 和 newEndVnode 满足 sameVnode,直接将该 VNode 节点进行 patchVnode 即可,不需再遍历就完成了⼀次循环。如下图:

    image.png

    如果 oldStartVnode 与 newEndVnode 满足 sameVnode。说明 oldStartVnode 已经跑到了 oldEndVnode 后面去了,进行 patchVnode 的同时还需要将真实 DOM 节点移动到 oldEndVnode 的后面。

    image.png

    如果 oldEndVnode 与 newStartVnode 满足 sameVnode,说明 oldEndVnode 跑到了 oldStartVnode 的前面,进行 patchVnode 的同时要将 oldEndVnode 对应 DOM 移动到 oldStartVnode 对应 DOM 的前面。

    image.png

    如果以上情况均不符合,则在 old VNode 中找与 newStartVnode 相同的节点,若存在执行 patchVnode,同时将 elmToMove 移动到 oldStartIdx 对应的 DOM 的前面。

    image.png

    当然也有可能 newStartVnode 在 old VNode 节点中找不到⼀致的 sameVnode,这个时候会调用 createElm 创建⼀个新的 DOM 节点。

    image.png

    至此循环结束,但是我们还需要处理剩下的节点。

    当结束时 oldStartIdx > oldEndIdx,这个时候旧的 VNode 节点已经遍历完了,但是新的节点还没有。说明了新的 VNode 节点实际上比老的 VNode 节点多,需要将剩下的 VNode 对应的 DOM 插入到真实 DOM 中,此时调用 addVnodes(批量调用 createElm 接口)。

    image.png

    但是,当结束时 newStartIdx > newEndIdx 时,说明新的 VNode 节点已经遍历完了,但是老的节点还有剩余,需要从文档中将老的节点删除。

    image.png

    总结&&思考

    const app = new Vue({
      el: '#demo',
      data: { foo: 'ready~~' },
      mounted () {
        // 批量、异步
        // 每次赋值,watcher入队
        // $nextTick()把传入回调函数放入callbacks队尾
        this.foo = Math.random()
        console.log('1:' + this.foo);
    
        this.foo = Math.random()
        console.log('2:' + this.foo);
    
        this.foo = Math.random()
        console.log('3:' + this.foo);
    
        // 异步行为,此时内容没变
        console.log('p1.innerHTML:' + p1.innerHTML) // ready~~
    
        // [callbacks, fn]
        // Promise.resolve().then(() => {
        //     console.log('promise, p1.innerHTML:' + p1.innerHTML)
        // })
    
        this.$nextTick(() => {
          // 这里才是最新的值
          console.log('p1.innerHTML:' + p1.innerHTML)
        })
      }
    });
    

    面试官:在 Vue 里面执行 mounted 里面的内容,输出结果如何?

    面试官:如果把 $nextTick 放在中间位置呢?

    面试官:如果把 $nextTick 放在最上面位置呢?

    面试官:如果再加上 Promise 呢?

    如果...

    如果没有如果...

    相关文章

      网友评论

        本文标题:05Vue源码剖析2

        本文链接:https://www.haomeiwen.com/subject/gcsbcktx.html