阅读视图

发现新文章,点击刷新页面。

逐步搞懂 Vue 的 patchChildren,把 Diff 算法拆给你看

Vue 的 patchChildren一文看懂

在啃 Vue3 源码的时候,翻到 patchChildren 这一块直接卡住了。网上搜了一圈,要么上来就丢一堆概念,要么就是贴一整段源码说"自己看"。折腾了一段时间,终于把这块逻辑从头到尾捋顺了,索性写篇文章记录一下,也给正在啃源码的朋友搭把手。

说实话,Diff 算法听起来挺唬人,但拆开来看其实就是一件事——页面更新的时候,怎么用最小的代价把旧页面变成新页面。而 patchChildren 就是干这件事的核心函数。

这篇文章我会从最简单的版本开始,一步一步往上加功能,每一步都能跑通、能理解。跟着看完,你对 Vue 的子节点更新逻辑基本就能了然于胸了。


先搞清楚 patchChildren 是干嘛的

在讲代码之前,先说个前提。

Vue 更新页面的时候,不会直接操作真实 DOM。它会维护一份"虚拟 DOM"(就是用 JS 对象描述页面结构),然后对比新旧虚拟 DOM 的差异,最后只把有变化的部分更新到真实 DOM 上。

patchChildren 就是负责更新某个父元素下面所有子节点的函数。它接收三个参数:

  • n1:旧的虚拟 DOM 节点
  • n2:新的虚拟 DOM 节点
  • container:真实的 DOM 容器(就是页面上的那个父元素)

一句话概括它的职责:对比新旧子节点,该更新的更新,该新增的新增,该删的删。


第一版:最简粗暴的更新

我们先看一个最基础的版本,只考虑"新旧子节点数量一样"的情况:

function patchChildren(n1, n2, container) {
  // 新子节点是纯文本
  if (typeof n2.children === 'string') {
    // 文本更新逻辑,先不管
  }
  // 新子节点是数组(多个标签)
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children

    // 按下标一一对比,逐个更新
    for (let i = 0; i < oldChildren.length; i++) {
      patch(oldChildren[i], newChildren[i])
    }
  }
  else {
    // 新无子节点,清空逻辑,先不管
  }
}

逻辑很简单粗暴——旧节点有几个,新节点就有几个,按下标顺序挨个调用 patch 更新。

patch 是 Vue 里负责单个节点更新的函数:标签一样就改内容,标签不一样就销毁旧的创建新的。

这个版本能跑,但问题也很明显:如果新旧子节点数量不一样呢? 多出来的怎么办?少了的怎么办?


第二版:加上新增和删除

接下来我们把逻辑补全,处理子节点数量不一致的情况:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略文本处理
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children
    const oldLen = oldChildren.length
    const newLen = newChildren.length
    // 取较短的长度,算出能一一对应的部分
    const commonLength = Math.min(oldLen, newLen)

    // 第一步:能对上的,原地更新
    for (let i = 0; i < commonLength; i++) {
      patch(oldChildren[i], newChildren[i], container)
    }

    // 第二步:新节点更多 → 多出来的要挂载
    if (newLen > oldLen) {
      for (let i = commonLength; i < newLen; i++) {
        patch(null, newChildren[i], container)
      }
    }
    // 第三步:旧节点更多 → 多出来的要卸载
    else if (oldLen > newLen) {
      for (let i = commonLength; i < oldLen; i++) {
        unmount(oldChildren[i])
      }
    }
  }
  else {
    // 省略
  }
}

拆开来看这三步:

第一步,先把能一一对应的子节点更新了。比如旧的有 3 个,新的有 5 个,那前 3 个先挨个更新。

第二步,新的比旧的多,多出来的那些调用 patch(null, 新节点)。第一个参数传 null 意味着"没有旧节点",所以会直接创建新的真实 DOM 挂载到页面上。

第三步,旧的多新的少,多出来的旧节点调用 unmount 直接从页面删掉。

举个具体例子感受一下:

原来页面有 div1、div2,更新后要变成 div1、div2、div3、div4

  • 前 2 个原地更新
  • 后 2 个是全新的,新建挂载

反过来:

原来页面有 div1、div2、div3,更新后只要 div1

  • 第 1 个原地更新
  • 后 2 个旧节点直接删除

到这一步,基本的增删改都能处理了。但还有一个大问题——它只按下标顺序比对。如果子节点只是换了顺序(比如列表排序),它不会聪明地移动 DOM,而是全部删掉重建,性能很差。

这就是为什么 Vue 需要引入 key


第三版:引入 key,实现 DOM 复用

用过 Vue 的都知道写 v-for 要加 :key,但很多人可能不太清楚它底层到底干了什么。看这段代码就明白了:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children

    // 遍历每一个新子节点
    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i]

      // 拿着新节点去旧节点里找 key 一样的
      for (let j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]

        if (newVNode.key === oldVNode.key) {
          // key 相同 → 是同一个元素,复用旧 DOM,只更新内容
          patch(oldVNode, newVNode, container)
          break // 找到了就别找了,处理下一个
        }
      }
    }
  }
}

key 就是每个节点的"身份证号"。身份证一样,就说明是同一个元素,只是内容变了,不需要删掉重建,直接在原来的 DOM 上改就行。

打个比方:

旧页面有 3 个人:甲(key=1)、乙(key=2)、丙(key=3) 新页面要变成:乙(key=2)、甲(key=1)、丁(key=4)

执行过程:

  1. 拿新人"乙"去旧人里找,找到 key=2 的乙 → 不换人,直接给旧乙换身衣服(更新数据)
  2. 拿新人"甲"去旧人里找,找到 key=1 的甲 → 同理原地更新
  3. 拿新人"丁"去旧人里找,找不到 → 这是新来的,需要另外处理(后面会说)

你看,甲和乙只是换了顺序,但因为 key 能对上,DOM 直接复用,不用销毁重建。这就是 key 的核心价值。

不过这个版本还有个问题——它能复用 DOM,但不会移动 DOM 的位置。也就是说,虽然旧乙的 DOM 被复用了,但它在页面上的物理位置没变,视觉上顺序还是错的。

所以我们需要进一步优化。


第四版:lastIndex 判断是否需要移动

这版加了一个关键变量 lastIndex,用来记录上一个被复用的节点在旧数组里的位置。通过比较当前位置和上次位置,就能判断出元素是不是"往前挪了":

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children
    let lastIndex = 0 // 记录旧节点中最大下标

    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i]

      for (let j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]

        if (newVNode.key === oldVNode.key) {
          patch(oldVNode, newVNode, container)

          if (j < lastIndex) {
            // 当前旧下标 < 上次最大下标
            // 说明这个元素往前挪了,需要移动 DOM
          } else {
            // 顺序正常,不用移动,更新最大下标
            lastIndex = j
          }
          break
        }
      }
    }
  }
}

这个 j < lastIndex 的判断是整段逻辑的灵魂,我用一个例子帮你理清:

旧 key 顺序:1、2、3 新 key 顺序:3、1、2

执行过程:

  1. 处理新 key=3:在旧数组里找到 j=2,2 >= lastIndex(0),顺序正常,不移动,lastIndex 更新为 2
  2. 处理新 key=1:在旧数组里找到 j=1,1 < lastIndex(2),说明这个元素本来在后面,现在跑到前面了 → 需要移动 DOM
  3. 处理新 key=2:在旧数组里找到 j=2,同样 2 < lastIndex(2) 不成立... 等等,这里 j=2 等于 lastIndex=2,所以不移动,lastIndex 更新为 2

嗯,你可能会问:判断出需要移动之后,具体怎么移?这就是下一版要解决的问题。


第五版:锚点精准插入,移动到正确位置

光知道"要移动"还不够,还得知道"移到哪"。这一版引入了锚点(anchor) 的概念:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children
    let lastIndex = 0

    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i]
      let find = false // 标记是否找到可复用的旧节点

      for (let j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]

        if (newVNode.key === oldVNode.key) {
          find = true
          patch(oldVNode, newVNode, container)

          if (j < lastIndex) {
            // 需要移动:找到新顺序里的前一个兄弟节点
            const prevVNode = newChildren[i - 1]
            if (prevVNode) {
              // 锚点 = 前一个节点的下一个兄弟元素
              const anchor = prevVNode.el.nextSibling
              // 把当前 DOM 插到锚点前面 = 放到前一个节点的后面
              insert(newVNode.el, container, anchor)
            }
          } else {
            lastIndex = j
          }
          break
        }
      }

      // find 为 false:旧节点里没找到 → 这是新增节点
      if (!find) {
        const prevVNode = newChildren[i - 1]
        let anchor = null

        if (prevVNode) {
          // 有前兄弟节点,插到它后面
          anchor = prevVNode.el.nextSibling
        } else {
          // 没有前兄弟,说明是第一个子元素,插到最前面
          anchor = container.firstChild
        }
        // 创建新 DOM 并挂载到锚点位置
        patch(null, newVNode, container, anchor)
      }
    }
  }
}

这里有两块新逻辑,我分开说。

移动 DOM 的具体操作

当判断出 j < lastIndex 需要移动时:

  1. 先找到当前节点在新顺序里的前一个兄弟节点 prevVNode
  2. 拿到前一个兄弟节点的真实 DOM 的下一个兄弟元素作为锚点 anchor
  3. 调用 insert 把当前 DOM 插到锚点前面

说白了就是:我要站到前一个兄弟的后面。通过"前一个兄弟的下一个元素"作为锚点,就能精确定位。

新增节点的处理

注意这里多了一个 find 变量。内层循环跑完如果 find 还是 false,说明这个新节点在旧节点里完全找不到同 key 的,那就是个全新元素。

新增的时候同样需要锚点来决定插在哪:

  • 有前兄弟节点 → 插到前兄弟后面
  • 没有前兄弟(自己是第一个) → 插到容器最前面

patch(null, newVNode, container, anchor) 里第一个参数传 null,代表没有旧节点,走的是挂载逻辑,会创建新的真实 DOM。

顺便说一下,patch 函数本身也做了对应改造来支持锚点:

function patch(n1, n2, container, anchor) {
  if (typeof n2.type === 'string') {
    if (!n1) {
      // 全新节点,挂载时带上锚点
      mountElement(n2, container, anchor)
    } else {
      // 有旧节点,走更新逻辑
      patchElement(n1, n2)
    }
  }
  // ...其他类型省略
}

mountElement 内部调用 insert(el, container, anchor),不传锚点就默认追加到最后,传了就插到锚点前面。


第六版:补齐最后一块拼图——删除多余旧节点

前面处理了复用、移动、新增,还差一个:旧的节点里有些在新列表里已经不存在了,需要删掉

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children
    let lastIndex = 0

    // ...前面复用、移动、新增的逻辑(和上一版一样)
    for (let i = 0; i < newChildren.length; i++) {
      // ...(同上,省略)
    }

    // ========== 新增:遍历旧节点,清理不需要的 ==========
    for (let i = 0; i < oldChildren.length; i++) {
      const oldVNode = oldChildren[i]
      // 拿旧节点的 key 去新列表里找
      const has = newChildren.find(vnode => vnode.key === oldVNode.key)

      if (!has) {
        // 新列表里找不到这个 key → 这个旧节点不需要了,删掉
        unmount(oldVNode)
      }
    }
  }
}

逻辑很直白:遍历所有旧节点,拿着它的 key 去新列表里找,找不到就说明新页面已经不需要它了,直接 unmount 删掉。

再举个完整的例子把所有逻辑串起来:

旧 key:1、2、3 新 key:3、1、4

执行过程:

  1. key=3:旧里找到,复用 DOM,顺序正常不移动
  2. key=1:旧里找到,j < lastIndex,触发移动
  3. key=4:旧里找不到,find=false,判定为新增,创建并插入
  4. 清理阶段:遍历旧节点 1、2、3
    • key=1:新里有 → 保留
    • key=2:新里没有 → unmount 删除
    • key=3:新里有 → 保留

最终结果:key=2 被清理,key=4 被新增,key=1 和 key=3 被复用并移动到正确位置。整个更新过程没有多余的 DOM 创建和销毁。


回顾一下完整流程

到这里,patchChildren 的核心逻辑就完整了。我用一张流程图帮你把所有分支串起来:

patchChildren 被调用
  │
  ├─ 新子节点是文本 → 走文本更新逻辑
  │
  ├─ 新子节点是数组 → 进入核心 Diff
  │   │
  │   ├─ 遍历新节点,用 key 去旧节点里匹配
  │   │   │
  │   │   ├─ 找到了(find=true)
  │   │   │   ├─ 复用旧 DOM,patch 更新内容
  │   │   │   ├─ j < lastIndex → 移动 DOM 到正确位置
  │   │   │   └─ j >= lastIndex → 不移动,更新 lastIndex
  │   │   │
  │   │   └─ 没找到(find=false)→ 新增节点,锚点精准插入
  │   │
  │   └─ 遍历旧节点,清理新列表中不存在的 → unmount 删除
  │
  └─ 新无子节点 → 清空容器

总结成一句话:能复用就复用,该移动就移动,多了就新增,少了就删除。

这就是 Vue 简易版 Diff 子节点更新的全部核心逻辑。当然,Vue3 实际源码里用的是更高效的快速 Diff 算法(基于最长递增子序列),但核心思想是一脉相承的。搞懂了这个简易版,再看源码里的完整实现会轻松很多。


最后说两句

啃源码这件事,说实话一开始挺痛苦的,尤其是 Diff 这块,变量多、嵌套深,很容易看着看着就迷失了。但如果你能像我这样,从最简单的版本开始,一步一步往上加功能,每一步都搞清楚"为什么要这样写",其实也没那么难。

希望这篇文章能帮到正在啃 Vue 源码的你。如果觉得有帮助,欢迎点赞收藏,有问题也欢迎在评论区交流。


参考:Vue.js 设计与实现 —— 霍春阳

我把Vue2响应式源码从头到尾啃了一遍,这是整理笔记

Vue 2 响应式源码精读:从 initState 到 defineReactive

之前看 Vue 源码的时候,状态初始化这块一直是一知半解的状态,后来硬着头皮一行行啃下来,发现其实逻辑很清晰。这篇就把 initState、initProps、initData、proxy、observe、Observer、defineReactive 这几个核心函数串起来讲,争取让读完的人都能在脑子里画出整条链路。


一、initState —— 所有状态的"总调度"

initState 这个函数做的事情说白了就是:把 Vue 实例上的 props、methods、data、computed、watch 统统初始化一遍,变成响应式数据。

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options

  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)

  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }

  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

拆开看:

  • vm._watchers = [] —— 先准备一个数组,后面所有 Watcher(computed、watch、渲染 watcher)都会塞进去
  • const opts = vm.$options —— 就是你 new Vue({ ... }) 传进来的配置对象,取出来方便后面用
  • 后面就是按顺序依次初始化:props → methods → data → computed → watch

这个顺序不是随便排的。 props 先初始化,所以 data 里能访问 props;methods 第二,所以 data 里能调 methods;computed 第四,所以它能依赖 data 和 props;watch 最后,所以它能监听前面所有的数据。谁在前谁在后,是有依赖关系的。

data 那块有个细节:如果用户没写 data,Vue 会给一个空对象 {} 并调 observe,保证根实例一定有响应式数据。


二、initProps —— 处理父组件传进来的数据

initProps 要干的事情:拿到父组件传的值 → 校验类型和默认值 → 变成响应式 → 代理到 this 上。

function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent

  if (!isRoot) {
    toggleObserving(false)
  }

  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm)

    // 省略了开发环境警告逻辑...

    defineReactive(props, key, value, () => {
      if (vm.$parent && !isUpdatingChildComponent) {
        warn(`Avoid mutating a prop directly...`)
      }
    })

    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }

  toggleObserving(true)
}

几个关键点:

1. propsData vs propsOptions

  • propsData 是父组件实际传过来的值,比如 <Child msg="hello"/> 中的 { msg: 'hello' }
  • propsOptions 是子组件声明的 props 配置,props: { msg: { type: String } }

2. toggleObserving(false) 是干嘛的?

非根组件会先关掉响应式转换开关。因为 props 的值来自父组件,父组件那边已经做过响应式处理了,子组件不需要再 observe 一遍,避免重复。

3. validateProp

这个函数负责校验:取父组件传入的值,没传就用默认值,检查类型对不对,执行自定义校验函数,最后返回合法值。

4. defineReactive 里的第四个参数

defineReactive(props, key, value, () => {
  if (vm.$parent && !isUpdatingChildComponent) {
    warn(`Avoid mutating a prop directly...`)
  }
})

这个箭头函数是自定义 setter,当你在子组件里直接改 props(this.msg = 'xxx')的时候会触发警告。这就是为什么 Vue 一直强调"不要在子组件里直接修改 props"——源码层面就给你拦着了。

5. proxy(vm, '_props', key)

让你能直接写 this.msg 而不是 this._props.msg,后面会单独讲 proxy 函数。


三、initData —— 处理组件自身的数据

initData 的流程:拿到 data → 处理函数/对象 → 挂载到 vm._data → 校验重名 → 代理到 this → observe 变响应式。

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}

  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object...',
      vm
    )
  }

  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length

  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(`Method "${key}" has already been defined as a data property.`, vm)
      }
    }
    if (props && hasOwn(props, key)) {
      warn(`The data property "${key}" is already declared as a prop.`, vm)
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }

  observe(data, true /* asRootData */)
}

几个要注意的地方:

1. 组件的 data 为什么必须是函数?

data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {}

这行就是答案。组件会被复用创建多个实例,如果 data 是对象,所有实例共享同一块内存,一个改了全跟着变。用函数的话每次 getData 都返回新对象,实例之间数据隔离。

2. 校验很严格

遍历 data 的每个 key,检查三件事:

  • 不能和 methods 重名(否则 this.xxx 不知道是取数据还是调方法)
  • 不能和 props 重名(props 优先级更高,重名会被覆盖)
  • 不能是 $_ 开头的保留字(Vue 内部属性用的)

3. 最后一步 observe(data, true)

把整个 data 对象递归地变成响应式,这是响应式的入口,后面会细讲。

对比一下 initProps 和 initData:

initProps initData
数据存哪 vm._props vm._data
怎么访问 this.xxx(代理) this.xxx(代理)
响应式方式 defineReactive 逐个属性 observe 整体递归
数据来源 父组件传入 组件自己定义
能不能改 子组件不能改 可以改

四、proxy —— this.xxx 背后的"中间商"

这个函数特别短,但特别关键。它做的事情就一件:让你写 this.xxx 的时候,实际去访问 this._data.xxxthis._props.xxx

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

逻辑很直白:

  1. 先定义一个公用的属性描述符模板 sharedPropertyDefinition,不用每次都 new 一个,省内存
  2. 动态设置 getter:读 this.msg → 实际读 this._data.msg(或 this._props.msg
  3. 动态设置 setter:写 this.msg = 'hi' → 实际写 this._data.msg = 'hi'
  4. Object.defineProperty 把这个属性挂到 Vue 实例上

所以 this.xxx 本身不存任何数据,它就是一个"门把手",拧开之后通向 _data_props

Vue 这么设计有几个好处:

  • 写法简洁,不用到处写 this._data.xxx
  • 真实数据藏在内部,外部只暴露代理接口,内部怎么优化不影响用户代码
  • 不管是 data、props 还是 computed,用户都只需要 this.xxx 一种写法

五、observe —— 响应式的"门卫"

observe 是响应式系统的入口函数,负责判断一个值需不需要、能不能变成响应式。

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }

  let ob: Observer | void

  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }

  if (asRootData && ob) {
    ob.vmCount++
  }

  return ob
}

分三步看:

第一步:过滤掉不需要处理的值

不是对象或者数组?直接 return。是 VNode(虚拟 DOM)?也 return。简单类型(string、number、boolean)不需要劫持。

第二步:检查是不是已经处理过了

__ob__ 是 Vue 给响应式对象加的隐藏标记。如果对象上已经有 __ob__,说明已经被 observe 过了,直接复用,不重复创建。这是个重要的性能优化。

第三步:满足五个条件才创建 Observer

shouldObserve &&              // 响应式开关是开着的
!isServerRendering() &&       // 不是服务端渲染
(Array.isArray(value) || isPlainObject(value)) && // 是对象或数组
Object.isExtensible(value) && // 没被 Object.freeze() 冻结
!value._isVue                 // 不是 Vue 实例本身

五个条件全满足,才会 new Observer(value),真正给数据穿上响应式外套。

最后 ob.vmCount++ 是给根数据打标记,后面组件销毁的时候会用到,跟内存回收有关。


六、Observer —— 真正给数据装监控的"工程师"

observe 只是门卫,Observer 才是干活的人。

export class Observer {
  value: any
  dep: Dep
  vmCount: number

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0

    def(value, '__ob__', this)

    if (Array.isArray(value)) {
      const augment = hasProto ? protoAugment : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

构造函数做了这些事:

1. this.dep = new Dep()

每个被监控的对象都有一个 Dep(依赖管理器),可以理解成一个"通讯录",记录哪些 Watcher 用了这个对象的数据。数据变了就翻通讯录通知。

2. def(value, '__ob__', this)

给数据打上 __ob__ 标记,值就是 Observer 实例本身。用了 def 函数(后面讲),所以这个属性是不可枚举的,for...in 遍历不到,不会污染用户数据。

3. 对象和数组走不同路线

这是 Vue 响应式里最容易考的点:

  • 对象:调 walk,遍历所有属性,逐个调 defineReactive 给每个属性加 getter/setter
  • 数组:重写原型上的 7 个变异方法(pushpopshiftunshiftsplicesortreverse),然后 observeArray 递归处理数组里的每一项

为什么数组要特殊处理?因为 Object.defineProperty 劫持不到数组下标的赋值操作(arr[0] = xxx 不会触发 setter),所以 Vue 只能通过重写那几个会修改数组的方法来"曲线救国"。

这也解释了两个经典面试题:

  • 为什么对象新增属性不响应? 因为 walk 只在初始化时遍历一次,后面加的属性没经过 defineReactive,没有 getter/setter。用 Vue.setthis.$set 就行。
  • 为什么数组下标赋值不响应? 因为 Observer 没有劫持数组下标,只有那 7 个重写方法能触发更新。用 spliceVue.set 替代。

七、def —— 一个极简的工具函数

顺带提一下 def,因为上面用到了:

export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

就是对 Object.defineProperty 的封装,默认不可枚举。Vue 内部用它来给对象加隐藏属性(比如 __ob__),不会出现在 for...inObject.keys() 里。


八、defineReactive —— 响应式的核心加工厂

最后也是最核心的一个函数。defineReactive 的使命:给对象的某个属性劫持 get 和 set,实现"读的时候收集依赖,写的时候派发更新"。

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val)

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,

    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },

    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}

这段代码值得拆细了看。

Getter:读数据的时候发生了什么

get: function reactiveGetter () {
  const value = getter ? getter.call(obj) : val
  if (Dep.target) {
    dep.depend()
    if (childOb) {
      childOb.dep.depend()
      if (Array.isArray(value)) {
        dependArray(value)
      }
    }
  }
  return value
}

当你渲染模板、执行 computed 或 watch 的时候,会读到 this.xxx,就会触发这个 getter。

关键在 Dep.target。它指向当前正在执行的 Watcher(可能是渲染 Watcher、computed Watcher 或 watch Watcher)。如果 Dep.target 存在,说明"有人正在用这个数据",就调 dep.depend() 把这个 Watcher 记录下来。

如果值本身是对象或数组,还要递归地对子对象也收集依赖(childOb.dep.depend()),数组还要额外处理(dependArray)。

一句话:getter 负责"记住谁在用我"。

Setter:改数据的时候发生了什么

set: function reactiveSetter (newVal) {
  const value = getter ? getter.call(obj) : val
  if (newVal === value || (newVal !== newVal && value !== value)) {
    return
  }
  if (process.env.NODE_ENV !== 'production' && customSetter) {
    customSetter()
  }
  if (setter) {
    setter.call(obj, newVal)
  } else {
    val = newVal
  }
  childOb = !shallow && observe(newVal)
  dep.notify()
}

当你执行 this.xxx = 新值,触发 setter:

  1. 先拿旧值,跟新值比一下,一样就直接 returnNaN !== NaN 的特殊情况也处理了),这是性能优化
  2. 开发环境下如果有 customSetter 就调一下(比如 initProps 里传的那个"不要直接改 props"的警告)
  3. 赋新值
  4. 新值如果是对象/数组,也要 observe,保证新数据也是响应式的
  5. dep.notify() —— 遍历之前收集的 Watcher 列表,逐个通知更新

一句话:setter 负责"通知所有用我的人,我变了"。

整个响应式闭环

画个简单的流程:

vue-reactive-flowchart.png


整条链路串起来

到这里,Vue 2 响应式初始化的完整链路就清楚了:

new Vue()
  → initState()
    → initProps()  → validateProp + defineReactive + proxy
    → initMethods()
    → initData()   → getData + 校验 + proxy + observe
    → initComputed()
    → initWatch()

proxy: this.xxx → this._data.xxx / this._props.xxx

observe: 判断要不要响应式 → new Observer()
  Observer:
    对象 → walk → defineReactive(给每个属性加 getter/setter)
    数组 → 重写 7 个变异方法 + observeArray 递归

defineReactive:
  get → dep.depend()(收集依赖)
  set → dep.notify()(派发更新)

每个函数各司其职,代码量不大但设计得很精巧。建议感兴趣的话对着源码自己走一遍,比看任何文章都管用。

❌