前端那些事儿

05Vue源码剖析2

2020-07-11  本文已影响0人  LM林慕

Vue 源码剖析2

异步更新队列

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

概念解释

image.png

体验一下

Vue 中的具体实现

image.png

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

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

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 呢?

如果...

如果没有如果...

上一篇下一篇

猜你喜欢

热点阅读