九十行代码手摸手带你透彻理解 Vue 响应式

667 阅读2分钟

本文通过不到百行代码,实现了一个极小的响应式Vue,在此过程中,希望读者能了解到

  • Vue 实例 、响应式数据 、watcher 、dep 四者之间的关系
  • dep 何时、如何收集 watcher

Vue 类

首先我们创建一个 Vue 类,要求传入的 options(即组件)有最基础的几个属性

class Vue {
  constructor(options) {
    const { data, render, methods, el } = options
    this.$options = options
    this._methods = methods || {}
    this._render = render
    this.$el = document.querySelector(el) // 选择要挂载的元素
  }
}

描述组件

一般来说,Vue 单文件的单文件组件会将 template 编译成 render 函数,然后挂在组件对象上(引入 .vue 文件打印一下就知道了),这个 render 函数是一个返回 vnode 的函数,Vue 再根据 vnode 渲染,我们这里就直接写一个 极其简单的描述 Dom 的 element 对象返回就好了(vnode 由 element 生成,是 diff 中的一个单元),有 tag、attrs、children、events 四个属性

const ComponentA = {
  el:'#container1',
  render() {
    // <div :class="color"><span>{{text}}</span></div>
    return {
      tag: 'div', // 元素标签
      attrs: { class: 'red' }, // 属性
      events: { click:()=>console.log(111) }, // 事件
      // 子元素
      children: [{ tag: 'span', children: ['i am a'] }]
    }
  }
}

创建真实 Dom

我们再实现一个相对应的生成真实 Dom 的函数

class Vue {
  // ...
  _createDom({ tag = 'div', attrs = {}, children = [], events = {} } = {}) {
    const dom = document.createElement(tag)
    for (let [key, value] of Object.entries(attrs)) {
      dom.setAttribute(key, value)
    }
    for (let [event, callback] of Object.entries(events)) {
      dom.addEventListener(event, callback.bind(this))
    }
    for (let child of children) {
      if (typeof child === 'object' && child !== null)
        dom.appendChild(this._createDom(child))
      else dom.insertAdjacentHTML('beforeend', child)
    }
    return dom
  }
}

然后简单地挂载一下

class Vue {
  constructor({ data, render }) {
    //...
+   this._mounted()
  }
+ _mounted() {
+   this.$el.replaceChildren(this._createDom(this._render()))
+ }
}

执行 const vm1 = new Vue({render,el:'#container1' }),成功挂载

data 代理

之后我们处理一下 data 方法,我们知道,访问 data 和 method 时是使用 this.xxx 而不是 this.methods.xxx,实际上是 Vue 做了一层代理

class Vue {
  constructor(options) {
    // ...
    this._data = data?.apply(this) ?? {}
    proxy(this, '_methods')
    proxy(this, '_data')
    this._mounted()
  }
}
function proxy(target, sourceKey) {
  const data = target[sourceKey]
  for (let key of Object.keys(data)) {
    Object.defineProperty(target, key, {
      get() {
        return this[sourceKey][key]
      },
      set(val) {
        this[sourceKey][key] = val
      }
    })
  }
}

现在我们试一下,直接在 render 中访问 data 和 method 吧

const ComponentA = {
  el: '#container1',
  data() {
    return {
      color: 'red',
      text: 'i am a'
    }
  },
  methods: {
    console() {
      console.log(this)
    }
  },
  render() {
    // <div :class="color"><span>{{text}}</span></div>
    with (this) {
      return {
        tag: 'div', // 元素标签
        attrs: { class: color }, // 属性
        events: { click: console }, // 事件
        children: [{ tag: 'span', children: [text] }]
      }
    }
  }
}

Vue 在生成 render 时会在外层包一个 with(this){} ,变量访问时候就会访问到 this 上,就不用写 this 了

const vm1 = new Vue(ComponentA)

点击之后,打印 vue 实例

image.png

观察者模式

Vue 官网这张图很清晰地描述了整个过程:

new Vue() 时:

  1. 使数据响应式,就是用 Object.defineProperty 设置每个 key 的 get 和 set 函数,利用闭包让每个键都有一个 dep 用于收集 watcher
  2. 创建一个 watcher 对应这个 Vue 实例的 render
  3. 在当前 watcher 环境下执行 render,当访问到响应式数据时,触发 get 函数,dep 就可以收集到当前 watcher
  4. 之后是挂载等操作...

当我们设置响应式数据时

  1. 触发 set 函数,dep 通知 watcher 更新
  2. watcher 执行当时传入的更新回调(对于 Vue 实例来说就是 update )
  3. 然后进入更新的操作...

这里需要理清楚,

  • 一个 Vue 实例对应一个 watcher,任务是负责渲染;一个 computed 也会对应一个 watcher,任务是负责更新 computed 缓存

  • 一个 key 对应一个 dep

  • 一个响应式数据与它所属的 Vue 实例没有任何关系,能被收集是因为 render 上访问到了这个数据,比如 Vuex store 上的属性,它在多个 Vue render 中被访问,收集到了多个对应的 watcher,数据更新时,就会同时通知多个组件更新。

我们实现这两个重要类

class Watcher {
  constructor(vm, get) {
    this.vm = vm
    this.get = get 
    Dep.target = this
    get.call(vm)
    Dep.target = null
  }
  update() {
    this.get.call(this.vm)
  }
}

class Dep {
  static target = null
  constructor() {
    this.watchers = new Set()
  }
  add(watcher) {
    if (!this.watchers.has(watcher)) this.watchers.add(watcher)
  }
  notify() {
    for (let watcher of this.watchers) {
      watcher.update()
    }
  }
}

Dep 实例上有个 Set 收集 watchers,调用 notify 时通知 watchers 更新,

Watcher 构造函数中,首先改变 Dep.target 为 this,然后执行传入的 get(Vue 就是 render 函数),如果执行过程中有访问到响应式数据,这个数据上的 dep 就会将 Dep.target 收集进 watchers 数组。

数据响应式

用 Object.defineProperty 递归地为每个键设置 get 函数并通过闭包私有 Dep 实例,在 watcher 调用环境下,收集调用者。

function reactive(obj) {
  if (typeof obj !== 'object' || obj === null) return
  for (let key of Object.keys(obj)) {
    const dep = new Dep()
    let value = obj[key]
    Object.defineProperty(obj, key, {
      configurable: true,
      enumerable: true,
      get: function reactiveGetter() {
        if (Dep.target) dep.add(Dep.target) 
        return value
      },
      set: function reactiveSetter(newValue) {
        if (newValue === value) {
          return
        }
        value = newValue
        dep.notify()
      }
    })
    reactive(value)
  }
}

最后,我们将 data 变为响应式并用 watcher 来初次渲染

class Vue {
  constructor(options) {
    const { data, render, methods, el } = options
    this.$options = options
    this._methods = methods || {}
    this._render = render
    this.$el = document.querySelector(el) // 选择要挂载的元素
    this._data = data.apply(this)
+   reactive(this._data)
    proxy(this, '_methods')
    proxy(this, '_data')
-   this._mounted()
+   this._watcher = new Watcher(this,this._mounted)
  }
// ...
}

响应式就实现了!是不是很简单呢?

测试一下

const Counter = {
  el: '#container1',
  data() {
    return {
      color: 'red',
      count: 0
    }
  },
  methods: {
    toggle() {
      this.color = this.color === 'red' ? 'blue' : 'red'
    },
    add() {
      this.count++
    }
  },
  render() {
    /* <div>
          <div :class="color" @click="add">{{count}}</div>
          <button @click="toggle">toggle</button>
       </div> 
    */
    with (this) {
      return {
        tag: 'div', // 元素标签
        children: [
          { tag: 'div',attrs: { class: color } , events: { click: add }, children: [count] },
          { tag: 'button', events: { click: toggle }, children: ['toggle'] }
        ]
      }
    }
  }
}

new Vue(Counter)

1.gif

成功!

最后,我还想补充一个 store 的例子以加深你对响应式的理解

class Vue {
  constructor(options) {
-    const { data, render, methods, el } = options
+    const { data, render, methods, el, store } = options
+    this.$store = store
      // ...
  }
const store = { count: 0 }
reactive(store)
function createCounter(el) {
  return {
    store,
    el,
    render() {
      with (this) {
        return {
          tag: 'button',
          events: { click: () => $store.count++ },
          children: [$store.count]
        }
      }
    }
  }
}
new Vue(createCounter('#container1'))
new Vue(createCounter('#container2'))
new Vue(createCounter('#container3'))

2.gif

store.count 收集了三个 Vue 实例的 watcher,当 count 改变时,三个组件都会触发更新

全部代码

class Vue {
  constructor(options) {
    const { data, render, methods, el, store } = options
    this.$store = store
    this.$options = options
    this._methods = methods || {}
    this._render = render
    this.$el = document.querySelector(el) // 选择要挂载的元素
    this._data = data?.apply(this) ?? {}
    reactive(this._data)
    proxy(this, '_methods')
    proxy(this, '_data')
    this._watcher = new Watcher(this, this._mounted)
  }
  _mounted() {
    this.$el.replaceChildren(this._createDom(this._render()))
  }
  _createDom({ tag = 'div', attrs = {}, children = [], events = {} } = {}) {
    const dom = document.createElement(tag)
    for (let [key, value] of Object.entries(attrs)) {
      dom.setAttribute(key, value)
    }
    for (let [event, callback] of Object.entries(events)) {
      dom.addEventListener(event, callback.bind(this))
    }
    for (let child of children) {
      if (typeof child === 'object' && child !== null)
        dom.appendChild(this._createDom(child))
      else dom.insertAdjacentHTML('beforeend', child)
    }
    return dom
  }
}

function proxy(target, sourceKey) {
  const data = target[sourceKey]
  for (let key of Object.keys(data)) {
    Object.defineProperty(target, key, {
      get() {
        return this[sourceKey][key]
      },
      set(val) {
        this[sourceKey][key] = val
      }
    })
  }
}

class Watcher {
  constructor(vm, get) {
    this.vm = vm
    this.get = get
    Dep.target = this
    get.call(vm)
    Dep.target = null
  }
  update() {
    this.get.call(this.vm)
  }
}

class Dep {
  static target = null
  constructor() {
    this.watchers = new Set()
  }
  add(watcher) {
    if (!this.watchers.has(watcher)) this.watchers.add(watcher)
  }
  notify() {
    for (let watcher of this.watchers) {
      watcher.update()
    }
  }
}

function reactive(obj) {
  if (typeof obj !== 'object' || obj === null) return
  for (let key of Object.keys(obj)) {
    const dep = new Dep()
    let value = obj[key]
    Object.defineProperty(obj, key, {
      configurable: true,
      enumerable: true,
      get: function reactiveGetter() {
        if (Dep.target) dep.add(Dep.target)
        return value
      },
      set: function reactiveSetter(newValue) {
        if (newValue === value) {
          return
        }
        value = newValue
        dep.notify()
      }
    })
    reactive(value)
  }
}
<!DOCTYPE html>
<html lang="en">
  <head>
    <style>
      .red {
        color: red;
      }
      .blue {
        color: blue;
      }
    </style>
  </head>
  <body>
    <div id="container1"></div>
    <div id="container2"></div>
    <div id="container3"></div>
  </body>
</html>

小结

  • Vue 通过 watcher 执行第一次 render 函数,在此过程中访问到的响应式数据的 key 上的 dep 将这个 watcher 收集。
  • 若响应式数据更新,dep 通知 watcher 去 render

感谢阅读,欢迎在评论区留言~