Vue响应式原理简析

1,869 阅读4分钟

这是开课吧视频中的小一个 demo ,代码很简单但是思路很清晰~

其实是 Vue 1.x 版本核心原理的简单实现,虽然 2.x 版本有很多改进,但是这里用来分析 Vue 响应式的原理足够了。

demo

假设我们要自己实现一个 Vue 类,如下调用后可以初始化 dom 并实现数据视图响应式:

// index.html
<body>
  <div id="app">
    <p>{{ name }}</p>
    <p v-text="name"></p>
    <br>
    <p>{{ age }}</p>
    <br>
    <input type="text" v-model="name">
    <br>
    <div v-html="html"></div>
    <br>
    <button @click="changeName">呵呵</button>
  </div>
  <script>
    new Vue({
      el: '#app',
      data: {
        name: "我是初始化",
        age: 12,
        html: '<p>我是个p</p>'
      },
      mounted() {
        setTimeout(() => {
          this.name = '我是延迟一点五秒'
          this.html = '<p>哈啊哈哈哈哈哈哈</p>'
        }, 1500)
      },
      methods: {
        changeName() {
          this.age = '年年18岁'
          this.name = '岁岁平安'
        }
      }
    })
  </script>
</body>

观察者模式

首先实现一个观察者模式:

// 主题
class Dep {
  constructor() {
    this.watchers = []
  }

  addDep(watcher) {
    this.watchers.push(watcher)
  }

  notify() {
    this.watchers.forEach(watcher => watcher.update())
  }
}
// 观察者
class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm
    this.key = key
    this.cb = cb

    Dep.target = this
    this.vm[key]
    Dep.target = null
  }

  update() {
    this.cb.call(this.vm, this.vm[this.key])
  }
}

Dep 类的作用主要是进行依赖收集(addDep)和派发更新(notify)。

Watcher 类的作用主要是处触发 data 中属性关联的钩子(数据劫持中的get方法,下面介绍)。 new Watcher 的时候会将 Dep.target 赋值为 watcher 实例,并 this.vm[key] 触发 get 钩子添加到依赖中。 watcher 实例的 update 方法就是真正的更新 dom 的逻辑。

数据劫持和响应式

class Vue {
  constructor(options) {
    this.observe(options.data)
  }

  observe(data) {
    if (!data || !(typeof data === 'object')) return
    Object.entries(data).forEach(([key, value]) => {
      this.observe(value) // 递归遍历
      this.defineReactive(data, key, value)
    })
  }

  defineReactive(data, key, value) {
    const dep = new Dep()
    Object.defineProperty(data, key, {
      get() {
        Dep.target && dep.addDep(Dep.target)
        return value
      },
      set(newV) {
        value = newV
        dep.notify()
      }
    })
  }
}

这个 Vue 类在初始化时候深度遍历了 data ,并调用了 this.defineReactive(data, key, value) 。在 defineReactive 函数内部利用闭包技术保存了 value dep 等值,以供 data 变化时候进行读写。

你可能会问,这怎么就实现数据响应了呢?

答案是 Dep 类和 Watcher 类。 defineReactive 函数执行时创建了一个 dep 实例,并在 data 的属性被 get 的时候进行依赖收集,添加了一个了 watcher 实例。

defineReactive中 data 的每个属性都对应了一个 dep 实例,这个 dep 中保存着该属性相关的所有观察者(该属性可能在页面中多次引用,如上html模板中的name出现了多次)。每当该属性被修改,都会触发 dep.notify() 通知所有相关观察者实例调用 update方法。

现在关于响应式的原理大概清楚了,但是 Vue 是在什么时候创建 watcher 实例的呢?

dom 编译和 Watcher 初始化

我们的 dome 中,Vue 初始化的时候,#app 还是个充满 {{ }} 的真实dom,所以要进行编译。

这里的编译其实就是将 #app 中的真实 dom 取出来,将其中的 {{ }} 替换成真实数据并进行响应式绑定,以使其能够自动更新。

class Compile {
  constructor(el, vm) {
    this.$el = document.querySelector(el)
    this.$vm = vm

    const fragment = this.node2Fragment(this.$el)
    this.compile(fragment)
    this.$el.appendChild(fragment)
  }
  node2Fragment(el) {
    const fragment = document.createDocumentFragment()
    let child
    while (child = el.firstChild) {
      fragment.appendChild(child)
    }
    return fragment
  }
  compile(el) {
    Array.from(el.childNodes).forEach(node => {
      if (node.nodeType === 1) {
        // v-html @click等
        this.compileElement(node)
      } else if (this.isInter(node)) {
        // 是文本节点
        this.compileText(node)
      }
      node.children && this.compile(node)
    })
  }
  isInter(node) {
    return /\{\{\s*([^\s]+)\s*\}\}/.test(node.textContent)
  }
  compileText(node) {
    // this.isInter 函数中的正则捕获组会将匹配到的字符缓存到 RegExp.$1 中。这里匹配的是 {{}} 中的字符
    const key = RegExp.$1
    node.textContent = this.$vm[key]
    new Watcher(this.$vm, key, function(value) {
      node.textContent = value
    })
  ... 
}

Compile 类初始化时,首先调用 node2Fragment 函数将 #app 中的 dom 取出并挂载在一个片段下。这个片段就像一个虚拟的节点,在挂载到真实 dom 上的时候会消失,只保留子节点。

然后调用 compile 函数,递归遍历片段的子节点(注意这里用的childNodes,因为children不包含文本注释等)并判断是文本节点还是node节点,分别执行不同的处理。

如果this.isInter(node) 判断是文本节点,则执行 compileText 函数,将节点文本更新为 data 中对应的值。然后再实例化一个观察者,记录属性和属性相关的dom更新逻辑。watcher 初始化的过程中便会触发数据劫持,将 watcher 实例添加到相应的依赖中。

class Compile {
  
  compileText(node) {
    // this.isInter 函数中的正则捕获组会将匹配到的字符缓存到 RegExp.$1 中。这里匹配的是 {{}} 中的字符
    const key = RegExp.$1
    node.textContent = this.$vm[key]
    new Watcher(this.$vm, key, function(value) {
      node.textContent = value
    })
  }
}

总结

至此 Vue 的响应式原理已经基本厘清了,主要有两步:

  1. observe 对data进行属性劫持,并给每个属性初始化一个主题 dep 实例
  2. compile 将模板中的插槽替换成真实数据,并初始化一个观察者 watcher 实例

数据和视图通过观察者模式实现了响应式更新。

demo 中还有 v-model@click 等,有心人可以自己实现或者偷懒:传送门