31

完全理解Vue的渲染watcher、computed和user watcher

 3 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzA4Nzg0MDM5Nw%3D%3D&%3Bmid=2247486369&%3Bidx=1&%3Bsn=96634ed1ab84c24b0d4ca4839f9d6d74
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

vM3ABz.jpg!mobile

作者:Naice

https://segmentfault.com/a/1190000023196603

这篇文章将带大家全面理解 vuewatchercomputeduser watcher ,其实 computeduser watcher 都是基于 Watcher 来实现的,我们通过一个一个功能点去敲代码,让大家全面理解其中的实现原理和核心思想。所以这篇文章将实现以下这些功能点:

  • 实现数据响应式

  • 基于渲染 wather 实现首次数据渲染到界面上
  • 数据依赖收集和更新

  • 实现数据更新触发渲染 watcher 执行,从而更新ui界面
  • 基于 watcher 实现 computed
  • 基于 watcher 实现 user watcher

废话不要多说,先看下面的最终例子。

7NJ3UrI.gif!mobile

例子看完之后我们就直接开工了。

准备工作

首先我们准备了一个 index.html 文件和一个 vue.js 文件,先看看 index.html 的代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>全面理解vue的渲染watcher、computed和user atcher</title>
</head>
<body>
  <div id="root"></div>
  <script src="./vue.js"></script>
  <script>
    const root = document.querySelector('#root')
    var vue = new Vue({
      data() {
        return {
          name: '张三',
          age: 10
        }
      },
      render() {
        root.innerHTML = `${this.name}----${this.age}`
      }
    })
  </script>
</body>
</html>

index.html 里面分别有一个id是root的div节点,这是跟节点,然后在script标签里面,引入了 vue.js ,里面提供了Vue构造函数,然后就是实例化Vue,参数是一个对象,对象里面分别有data 和 render 函数。然后我们看看 vue.js 的代码:

function Vue (options) {
  this._init(options) // 初始化
  this.$mount() // 执行render函数
}
Vue.prototype._init = function (options) {
  const vm = this
  vm.$options = options // 把options挂载到this上
  if (options.data) {
    initState(vm) // 数据响应式
  }
  if (options.computed) {
    initComputed(vm) // 初始化计算属性
  }
  if (options.watch) {
    initWatch(vm) // 初始化watch
  }
}

vue.js 代码里面就是执行 this._init()this.$mount()this._init 的方法就是对我们的传进来的配置进行各种初始化,包括数据初始化 initState(vm) 、计算属性初始化 initComputed(vm) 、自定义watch初始化 initWatch(vm)this.$mount 方法把 render 函数渲染到页面中去、这些方法我们后面都写到,先让让大家了解整个代码结构。下面我们正式去填满我们上面写的这些方法。

实现数据响应式

要实现这些 watcher 首先去实现数据响应式,也就是要实现上面的 initState(vm) 这个函数。相信大家都很熟悉响应式这些代码,下面我直接贴上来。

function initState(vm) {
  let data = vm.$options.data; // 拿到配置的data属性值
  // 判断data 是函数还是别的类型
  data = vm._data = typeof data === 'function' ? data.call(vm, vm) : data || {};
  const keys = Object.keys(data);
  let i = keys.length;
  while(i--) {
    // 从this上读取的数据全部拦截到this._data到里面读取
    // 例如 this.name 等同于  this._data.name
    proxy(vm, '_data', keys[i]);
  }
  observe(data); // 数据观察
}

// 数据观察函数
function observe(data) {
  if (typeof data !== 'object' && data != null) {
    return;
  }
  return new Observer(data)
}

// 从this上读取的数据全部拦截到this._data到里面读取
// 例如 this.name 等同于  this._data.name
function proxy(vm, source, key) {
  Object.defineProperty(vm, key, {
    get() {
      return vm[source][key] // this.name 等同于  this._data.name
    },
    set(newValue) {
      return vm[source][key] = newValue
    }
  })
}

class Observer{
  constructor(value) {
    this.walk(value) // 给每一个属性都设置get set
  }
  walk(data) {
    let keys = Object.keys(data);
    for (let i = 0, len = keys.length; i < len; i++) {
      let key = keys[i]
      let value = data[key]
      defineReactive(data, key, value) // 给对象设置get set
    }
  }
}

function defineReactive(data, key, value) {
  Object.defineProperty(data, key, {
    get() {
      return value
    },
    set(newValue) {
      if (newValue == value) return
      observe(newValue) // 给新的值设置响应式
      value = newValue
    }
  })
  observe(value); // 递归给数据设置get set
}

重要的点都在注释里面,主要核心就是给递归给 data 里面的数据设置 getset ,然后设置数据代理,让 this.name 等同于 this._data.name 。设置完数据观察,我们就可以看到如下图的数据了。

MrYvumr.png!mobile
console.log(vue.name) // 张三
console.log(vue.age) // 10

ps: 数组的数据观察大家自行去完善哈,这里重点讲的是watcher的实现。

首次渲染

数据观察搞定了之后,我们就可以把 render 函数渲染到我们的界面上了。在 Vue 里面我们有一个 this.$mount() 函数,所以要实现 Vue.prototype.$mount 函数:

// 挂载方法
Vue.prototype.$mount = function () {
  const vm = this
  new Watcher(vm, vm.$options.render, () => {}, true)
}

以上的代码终于牵扯到我们 Watcher 这个主角了,这里其实就是我们的渲染 wather ,这里的目的是通过 Watcher 来实现执行 render 函数,从而把数据插入到root节点里面去。下面看最简单的Watcher实现

let wid = 0
class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm // 把vm挂载到当前的this上
    if (typeof exprOrFn === 'function') {
      this.getter = exprOrFn // 把exprOrFn挂载到当前的this上,这里exprOrFn 等于 vm.$options.render
    }
    this.cb = cb // 把cb挂载到当前的this上
    this.options = options // 把options挂载到当前的this上
    this.id = wid++
    this.value = this.get() // 相当于运行 vm.$options.render()
  }
  get() {
    const vm = this.vm
    let value = this.getter.call(vm, vm) // 把this 指向到vm
    return value
  }
}

通过上面的一顿操作,终于在 render 中终于可以通过 this.name 读取到 data 的数据了,也可以插入到 root.innerHTML 中去。阶段性的工作我们完成了。如下图,完成的首次渲染:v:

BB7VFba.png!mobile

数据依赖收集和更新

首先数据收集,我们要有一个收集的地方,就是我们的 Dep 类,下面呢看看我们去怎么实现这个 Dep

// 依赖收集
let dId = 0
class Dep{
  constructor() {
    this.id = dId++ // 每次实例化都生成一个id
    this.subs = [] // 让这个dep实例收集watcher
  }
  depend() {
    // Dep.target 就是当前的watcher
    if (Dep.target) {
      Dep.target.addDep(this) // 让watcher,去存放dep,然后里面dep存放对应的watcher,两个是多对多的关系
    }
  }
  notify() {
    // 触发更新
    this.subs.forEach(watcher => watcher.update())
  }
  addSub(watcher) {
    this.subs.push(watcher)
  }
}

let stack = []
// push当前watcher到stack 中,并记录当前watcer
function pushTarget(watcher) {
  Dep.target = watcher
  stack.push(watcher)
}
// 运行完之后清空当前的watcher
function popTarget() {
  stack.pop()
  Dep.target = stack[stack.length - 1]
}

Dep 收集的类是实现了,但是我们怎么去收集了,就是我们数据观察的 get 里面实例化 Dep 然后让 Dep 收集当前的 watcher 。下面我们一步步来:

  • this.$mount()
    new Watcher(vm, vm.$options.render, () => {}, true)
    Watcher
    this.get()
    pushTarget(this)
    Dep.target = watcher
    watcher
    Dep.target
    
class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm
    if (typeof exprOrFn === 'function') {
      this.getter = exprOrFn
    }
    this.cb = cb
    this.options = options
    this.id = wid++
    this.id = wId++
+    this.deps = []
+    this.depsId = new Set() // dep 已经收集过相同的watcher 就不要重复收集了
    this.value = this.get()
  }
  get() {
    const vm = this.vm
+   pushTarget(this)
    let value = this.getter.call(vm, vm) // 执行函数
+   popTarget()
    return value
  }
+  addDep(dep) {
+    let id = dep.id
+    if (!this.depsId.has(id)) {
+      this.depsId.add(id)
+      this.deps.push(dep)
+      dep.addSub(this);
+    }
+  }
+  update(){
+    this.get()
+  }
}
  • Dep.target
    this.get()
    vm.$options.render
    render
    this.name
    Object.defineProperty·get
    
function defineReactive(data, key, value) {
  let dep = new Dep()
  Object.defineProperty(data, key, {
    get() {
+      if (Dep.target) { // 如果取值时有watcher
+        dep.depend() // 让watcher保存dep,并且让dep 保存watcher,双向保存
+      }
      return value
    },
    set(newValue) {
      if (newValue == value) return
      observe(newValue) // 给新的值设置响应式
      value = newValue
+      dep.notify() // 通知渲染watcher去更新
    }
  })
  // 递归给数据设置get set
  observe(value);
}
  • dep.depend()
    Dep.target.addDep(this)
    Dep.target
    watcher
    
addDep(dep) {
  let id = dep.id
  if (!this.depsId.has(id)) {
    this.depsId.add(id)
    this.deps.push(dep) // 当前的watcher收集dep
    dep.addSub(this); // 当前的dep收集当前的watcer
  }
}

这里双向保存有点绕,大家可以好好去理解一下。下面我们看看收集后的 des 是怎么样子的。

NRvaYbr.png!mobile
  • this.name = '李四'
    Object.defineProperty.set
    dep.notify()
    watcer.update
    watcher
    vm.$options.render
    

有了依赖收集个数据更新,我们也在 index.html 增加修改 data 属性的定时方法:

// index.html
<button onClick="changeData()">改变name和age</button>
// -----
// .....省略代码
function changeData() {
  vue.name = '李四'
  vue.age = 20
}

运行效果如下图

b6bQbqE.gif!mobile

到这里我们 渲染watcher 就全部实现了。

实现computed

首先我们在 index.html 里面配置一个 computed,script 标签的代码就如下:

const root = document.querySelector('#root')
var vue = new Vue({
  data() {
    return {
      name: '张三',
      age: 10
    }
  },
  computed: {
    info() {
      return this.name + this.age
    }
  },
  render() {
    root.innerHTML = `${this.name}----${this.age}----${this.info}`
  }
})
function changeData() {
  vue.name = '李四'
  vue.age = 20
}

上面的代码,注意 computed 是在 render 里面使用了。

在vue.js中,之前写了下面这行代码。

if (options.computed) {
  // 初始化计算属性
  initComputed(vm)
}

我们现在就实现这个 initComputed ,代码如下

// 初始化computed
function initComputed(vm) {
  const computed = vm.$options.computed // 拿到computed配置
  const watchers = vm._computedWatchers = Object.create(null) // 给当前的vm挂载_computedWatchers属性,后面会用到
  // 循环computed每个属性
  for (const key in computed) {
    const userDef = computed[key]
    // 判断是函数还是对象
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    // 给每一个computed创建一个computed watcher 注意{ lazy: true }
    // 然后挂载到vm._computedWatchers对象上
    watchers[key] = new Watcher(vm, getter, () => {}, { lazy: true })
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    }
  }
}

大家都知道 computed 是有缓存的,所以创建 watcher 的时候,会传一个配置 { lazy: true } ,同时也可以区分这是 computed watcher ,然后到 watcer 里面接收到这个对象

class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm
    if (typeof exprOrFn === 'function') {
      this.getter = exprOrFn
    }
+    if (options) {
+      this.lazy = !!options.lazy // 为computed 设计的
+    } else {
+      this.lazy = false
+    }
+    this.dirty = this.lazy
    this.cb = cb
    this.options = options
    this.id = wId++
    this.deps = []
    this.depsId = new Set()
+    this.value = this.lazy ? undefined : this.get()
  }
  // 省略很多代码
}

从上面这句 this.value = this.lazy ? undefined : this.get() 代码可以看到, computed 创建 watcher 的时候是不会指向 this.get 的。只有在 render 函数里面有才执行。

现在在 render 函数通过 this.info 还不能读取到值,因为我们还没有挂载到vm上面,上面 defineComputed(vm, key, userDef) 这个函数功能就是 让computed 挂载到 vm 上面。下面我们实现一下。

// 设置comoputed的 set个set
function defineComputed(vm, key, userDef) {
  let getter = null
  // 判断是函数还是对象
  if (typeof userDef === 'function') {
    getter = createComputedGetter(key)
  } else {
    getter = userDef.get
  }
  Object.defineProperty(vm, key, {
    enumerable: true,
    configurable: true,
    get: getter,
    set: function() {} // 又偷懒,先不考虑set情况哈,自己去看源码实现一番也是可以的
  })
}
// 创建computed函数
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {// 给computed的属性添加订阅watchers
        watcher.evaluate()
      }
      // 把渲染watcher 添加到属性的订阅里面去,这很关键
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

上面代码有看到在 watcher 中调用了 watcher.evaluate()watcher.depend() ,然后去 watcher 里面实现这两个方法,下面直接看 watcher 的完整代码。

class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm
    if (typeof exprOrFn === 'function') {
      this.getter = exprOrFn
    }
    if (options) {
      this.lazy = !!options.lazy // 为computed 设计的
    } else {
      this.lazy = false
    }
    this.dirty = this.lazy
    this.cb = cb
    this.options = options
    this.id = wId++
    this.deps = []
    this.depsId = new Set() // dep 已经收集过相同的watcher 就不要重复收集了
    this.value = this.lazy ? undefined : this.get()
  }
  get() {
    const vm = this.vm
    pushTarget(this)
    // 执行函数
    let value = this.getter.call(vm, vm)
    popTarget()
    return value
  }
  addDep(dep) {
    let id = dep.id
    if (!this.depsId.has(id)) {
      this.depsId.add(id)
      this.deps.push(dep)
      dep.addSub(this);
    }
  }
  update(){
    if (this.lazy) {
      this.dirty = true
    } else {
      this.get()
    }
  }
  // 执行get,并且 this.dirty = false
+  evaluate() {
+    this.value = this.get()
+    this.dirty = false
+  }
  // 所有的属性收集当前的watcer
+  depend() {
+    let i = this.deps.length
+    while(i--) {
+      this.deps[i].depend()
+    }
+  }
}

代码都实现王完成之后,我们说下流程,

  • render
    this.info
    createComputedGetter(key)
    computedGetter(key)
    
  • 2、然后会判断 watcher.dirty ,执行 watcher.evaluate()
  • watcher.evaluate()
    this.get
    pushTarget(this)
    computed watcher
    Dep.target 设置成当前的
    
  • this.getter.call(vm, vm)
    computed
    info: function() { return this.name + this.age }
    
  • info
    this.name
    Object.defineProperty.get
    name
    watcer
    dep
    name = '张三'
    age
    
  • popTarget()
    computed watcher
    this.dirty = false
    
  • watcher.evaluate()
    Dep.target
    true
    渲染watcher
    watcher.depend()
    watcher
    deps
    渲染watcher
    
  • name
    computed watcher
    渲染watcher
    name
    watcher.update()
    
  • computed watcher
    this.dirty
    true
    watcher.evaluate()
    info
    this.dirty
    false
    info
    

实现了之后我们看看实现效果:

muQnu23.gif!mobile

这里conputed的对象set配置没有实现,大家可以自己看看源码

watch实现

先在script标签配置watch配置如下代码:

const root = document.querySelector('#root')
var vue = new Vue({
  data() {
    return {
      name: '张三',
      age: 10
    }
  },
  computed: {
    info() {
      return this.name + this.age
    }
  },
  watch: {
    name(oldValue, newValue) {
      console.log(oldValue, newValue)
    }
  },
  render() {
    root.innerHTML = `${this.name}----${this.age}----${this.info}`
  }
})
function changeData() {
  vue.name = '李四'
  vue.age = 20
}

知道了 computed 实现之后, 自定义watch 实现很简单,下面直接实现 initWatch

function initWatch(vm) {
  let watch = vm.$options.watch
  for (let key in watch) {
    const handler = watch[key]
    new Watcher(vm, key, handler, { user: true })
  }
}

然后修改一下Watcher,直接看Wacher的完整代码。

let wId = 0
class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm
    if (typeof exprOrFn === 'function') {
      this.getter = exprOrFn
    } else {
+      this.getter = parsePath(exprOrFn) // user watcher 
    }
    if (options) {
      this.lazy = !!options.lazy // 为computed 设计的
+      this.user = !!options.user // 为user wather设计的
    } else {
+      this.user = this.lazy = false
    }
    this.dirty = this.lazy
    this.cb = cb
    this.options = options
    this.id = wId++
    this.deps = []
    this.depsId = new Set() // dep 已经收集过相同的watcher 就不要重复收集了
    this.value = this.lazy ? undefined : this.get()
  }
  get() {
    const vm = this.vm
    pushTarget(this)
    // 执行函数
    let value = this.getter.call(vm, vm)
    popTarget()
    return value
  }
  addDep(dep) {
    let id = dep.id
    if (!this.depsId.has(id)) {
      this.depsId.add(id)
      this.deps.push(dep)
      dep.addSub(this);
    }
  }
  update(){
    if (this.lazy) {
      this.dirty = true
    } else {
+      this.run()
    }
  }
  // 执行get,并且 this.dirty = false
  evaluate() {
    this.value = this.get()
    this.dirty = false
  }
  // 所有的属性收集当前的watcer
  depend() {
    let i = this.deps.length
    while(i--) {
      this.deps[i].depend()
    }
  }
+  run () {
+    const value = this.get()
+    const oldValue = this.value
+    this.value = value
    // 执行cb
+    if (this.user) {
+      try{
+        this.cb.call(this.vm, value, oldValue)
+      } catch(error) {
+        console.error(error)
+      }
+    } else {
+      this.cb && this.cb.call(this.vm, oldValue, value)
+    }
+  }
}
function parsePath (path) {
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

最后看看效果 7NJ3UrI.gif!mobile

当然很多配置没有实现,比如说 options.immediate 或者 options.deep 等配置都没有实现。篇幅太长了。自己也懒~~~ 完结撒花

详细代码:https://github.com/naihe138/write-vue

mQzEb2b.jpg!mobile

》》面试官都在用的题库,快来看看《《


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK