Skip to content

Vue2 响应式原理最小实现

这篇文档不追求完整还原 Vue2 源码,而是聚焦面试里最常被问到的那条主线: 模板是怎么和 Watcher、Dep 建立关系的,以及数据修改后为什么能自动更新视图。

一、核心要点速览

💡 核心考点

  • 模板不会直接绑定 Watcher:真正参与依赖收集的是模板编译后的 render 函数。
  • Watcher 是入口:组件初始化时会创建 Render Watcher,并主动执行一次渲染。
  • getter 完成收集render 访问数据时触发 getter,当前 Watcher 会被收集到属性对应的 Dep 中。
  • setter 负责通知:当数据变化时,setter 调用 dep.notify(),触发 Watcher 重新执行渲染。

二、整体流程图

下面这张图专门对应本文的“最小实现版”代码,用来串起从模板到视图更新的完整链路:

Vue2 响应式最小实现流程图

可以先记住这两条主线:

  • 初始化链路template -> render -> watcher.get() -> getter -> dep.depend()
  • 更新链路setter -> dep.notify() -> watcher.update() -> render -> _update()

三、核心实现代码(精简版)

js
/* ================= Dep(依赖收集器) ================= */
class Dep {
  constructor() {
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }

  notify() {
    this.subs.forEach(w => w.update())
  }
}

Dep.target = null

/* ================= 响应式核心 ================= */
function defineReactive(obj, key, val) {
  const dep = new Dep()

  Object.defineProperty(obj, key, {
    get() {
      dep.depend()
      return val
    },
    set(newVal) {
      val = newVal
      dep.notify()
    }
  })
}

/* ================= Watcher ================= */
class Watcher {
  constructor(vm, exprOrFn, cb, options = {}) {
    this.vm = vm
    this.cb = cb
    this.lazy = !!options.lazy
    this.dirty = this.lazy

    // 支持函数或字符串
    if (typeof exprOrFn === 'function') {
      this.getter = exprOrFn
    } else {
      this.getter = () => vm[exprOrFn]
    }

    this.value = this.lazy ? undefined : this.get()
  }

  get() {
    Dep.target = this
    const value = this.getter.call(this.vm)
    Dep.target = null
    return value
  }

  update() {
    if (this.lazy) {
      this.dirty = true
    } else {
      const oldValue = this.value
      this.value = this.get()
      if (this.cb) {
        this.cb(this.value, oldValue)
      }
    }
  }

  evaluate() {
    this.dirty = false
    this.value = this.get()
    return this.value
  }
}

/* ================= Vue(迷你版) ================= */
class Vue {
  constructor(options) {
    this.$data = options.data
    this._watchers = []
    this._computedWatchers = {}

    // 1️⃣ 数据响应式
    Object.keys(this.$data).forEach(key => {
      defineReactive(this.$data, key, this.$data[key])

      // 代理到 this.xxx
      Object.defineProperty(this, key, {
        get: () => this.$data[key],
        set: v => (this.$data[key] = v)
      })
    })

    // 2️⃣ computed
    if (options.computed) {
      this._initComputed(options.computed)
    }

    // 3️⃣ watch
    if (options.watch) {
      this._initWatch(options.watch)
    }

    // 4️⃣ 渲染 Watcher
    this.$render = options.render
    new Watcher(this, () => {
      const vnode = this.$render?.call(this)
      this._update(vnode)
    })
  }

  _update(vnode) {
    console.log('🖥️ 更新视图:', vnode)
  }

  /* ---------- watch ---------- */
  $watch(expr, cb) {
    const watcher = new Watcher(this, expr, cb)
    this._watchers.push(watcher)
  }

  _initWatch(watch) {
    Object.keys(watch).forEach(key => {
      this.$watch(key, watch[key])
    })
  }

  /* ---------- computed ---------- */
  _initComputed(computed) {
    Object.keys(computed).forEach(key => {
      const getter = computed[key]

      const watcher = new Watcher(this, getter, null, {
        lazy: true
      })

      this._computedWatchers[key] = watcher

      Object.defineProperty(this, key, {
        get: () => {
          if (watcher.dirty) {
            watcher.evaluate()
          }
          return watcher.value
        }
      })
    })
  }
}

四、模板如何转成 Render

Vue2 最终不会直接执行模板字符串,而是先把模板编译成 render 函数。 也就是说,真正参与依赖收集的不是模板本身,而是下面这个 render 函数里的取值动作。

1. 模板写法

html
<div>{{ msg }}</div>

2. 等价的 Render 思想

js
function render() {
  return `<div>${this.msg}</div>`
}

这里最关键的是 this.msg。 因为一旦访问它,就会触发 getter,而 getter 内部正是 Dep 收集 Watcher 的入口。


五、运行示例

结合上面的最小实现代码,可以这样模拟一次完整的初始化与更新过程:

js
const vm = new Vue({
  data: {
    count: 0,
    price: 10
  },

  computed: {
    total() {
      return this.count * this.price
    }
  },

  watch: {
    count(newVal, oldVal) {
      console.log('👀 watch count:', oldVal, '→', newVal)
    }
  },

  render() {
    return `count: ${this.count}, total: ${this.total}`
  }
})




setTimeout(() => {
  vm.count++ 
  // 👉 watch 回调触发
  // 👉 computed total 重新计算
  // 👉 视图更新
}, 1000)

这个示例里,第一次会触发初始化渲染;1 秒后修改 msg,就会走一遍完整的更新链路。


六、执行流程拆解

1. 初始化阶段:Watcher 是怎么被收集进去的?

text
new Vue()
-> new Watcher()
-> watcher.get()
-> Dep.target = watcher
-> 执行 render()
-> 访问 this.msg
-> 触发 getter
-> dep.depend()
-> dep 收集 watcher
-> Dep.target = null
-> _update() 渲染视图

这一阶段的重点是:

  • 先有当前 Watcherwatcher.get() 执行前,先把 Dep.target 指向自己。
  • 再访问响应式数据render() 内部读取 this.msg,触发 getter
  • 最后完成依赖收集getter 内部调用 dep.depend(),把当前 Watcher 存入 dep.subs

所以本质上不是“模板绑定 Watcher”,而是“Render 取值时,getter 自动把当前 Watcher 收集进去”。


2. 更新阶段:数据修改后为什么能更新视图?

text
vm.msg = 'world'
-> 触发 setter
-> dep.notify()
-> watcher.update()
-> watcher.get()
-> 重新执行 render()
-> 再次触发 getter
-> 重新生成 vnode
-> _update() 更新视图

这一阶段的重点是:

  • setter 负责广播变化:属性一改,dep.notify() 就会执行。
  • Watcher 负责重新渲染:收到通知后重新执行 get()
  • Render 再跑一遍:于是新的数据被读到,新的视图结果被计算出来。

七、角色分工

1. Dep 是干什么的?

  • 每个响应式属性都会对应一个 Dep
  • 它本质上是一个依赖列表,内部通过 subs 保存所有相关 Watcher。
  • 当属性变化时,它负责统一通知这些 Watcher。

2. Watcher 是干什么的?

  • Watcher 是“执行者”。
  • 对于渲染场景,它负责重新执行 render,让视图拿到最新数据。
  • 对于计算属性、用户 watch 等场景,本质上也都是类似的观察者机制。

3. defineReactive 做了什么?

  • 它通过 Object.defineProperty 给属性加上 getter / setter
  • getter 负责依赖收集。
  • setter 负责派发更新。

八、面试高频回答

1. 模板如何和 Watcher 建立关系?

答: 不是模板直接绑定 Watcher,而是模板先编译成 render 函数。Render Watcher 执行 render 时,会访问模板里用到的数据,触发这些数据的 getter,从而在 getter 里把当前 Watcher 收集到对应的 Dep 中。

2. 为什么修改数据后视图会自动更新?

答: 因为属性被 setter 劫持了。数据变化时会触发 settersetter 再调用 dep.notify() 通知相关 Watcher。Watcher 收到通知后重新执行 render,最后调用 _update() 更新视图。

3. 一句话概括整条链路?

答: render 取值时收集依赖,setter 修改时通知依赖,watcher 再次渲染并更新视图。


九、最终总结

text
初始化:
模板 -> render -> watcher.get() -> getter -> dep 收集 watcher -> _update()

更新:
setter -> dep.notify() -> watcher.update() -> render -> _update()

最需要记住的一句话是:

Vue2 没有“模板直接绑定 Watcher”,一切都是通过 getter 劫持配合 render 执行时的自动依赖收集完成的。

最近更新