【Vuejs】664- 从Vue源码角度深挖Watch、Computed_html

作者:Naice

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

  • 实现数据响应式
  • 基于渲染​​wather​​实现首次数据渲染到界面上
  • 数据依赖收集和更新
  • 实现数据更新触发渲染​​watcher​​执行,从而更新ui界面
  • 基于​​watcher​​实现​​computed​
  • 基于​​watcher​​实现​​user watcher​

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

【Vuejs】664- 从Vue源码角度深挖Watch、Computed_数据_02

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

准备工作

首先我们准备了一个​​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​​​里面的数据设置​​get​​​和​​set​​​,然后设置数据代理,让 ​​this.name​​​ 等同于 ​​this._data.name​​。设置完数据观察,我们就可以看到如下图的数据了。

【Vuejs】664- 从Vue源码角度深挖Watch、Computed_数据_03

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​​中去。阶段性的工作我们完成了。如下图,完成的首次渲染✌️

【Vuejs】664- 从Vue源码角度深挖Watch、Computed_数据_04

数据依赖收集和更新

首先数据收集,我们要有一个收集的地方,就是我们的​​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​​。下面我们一步步来:

  • 1、在上面​​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()
+ }
}
  • 2、知道​​Dep.target​​是怎么来之后,然后上面代码运行了​​this.get()​​,相当于运行了​​vm.$options.render​​,在​​render​​里面回执行​​this.name​​,这时候会触发​​Object.defineProperty·get​​方法,我们在里面就可以做些依赖收集(dep.depend)了,如下代码
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);
}
  • 3、调用的​​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​​是怎么样子的。

【Vuejs】664- 从Vue源码角度深挖Watch、Computed_html_05

  • 4、数据更新,调用​​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
}

运行效果如下图

【Vuejs】664- 从Vue源码角度深挖Watch、Computed_html_06

到这里我们​​渲染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()
+ }
+ }
}

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

  • 1、首先在​​render​​函数里面会读取​​this.info​​,这个会触发​​createComputedGetter(key)​​中的​​computedGetter(key)​​;
  • 2、然后会判断​​watcher.dirty​​,执行​​watcher.evaluate()​​;
  • 3、进到​​watcher.evaluate()​​,才真想执行​​this.get​​方法,这时候会执行​​pushTarget(this)​​把当前的​​computed watcher​​ push到stack里面去,并且把​​Dep.target 设置成当前的​​computed watcher`;
  • 4、然后运行​​this.getter.call(vm, vm)​​ 相当于运行​​computed​​的​​info: function() { return this.name + this.age }​​,这个方法;
  • 5、​​info​​函数里面会读取到​​this.name​​,这时候就会触发数据响应式​​Object.defineProperty.get​​的方法,这里​​name​​会进行依赖收集,把​​watcer​​收集到对应的​​dep​​上面;并且返回​​name = '张三'​​的值,​​age​​收集同理;
  • 6、依赖收集完毕之后执行​​popTarget()​​,把当前的​​computed watcher​​从栈清除,返回计算后的值('张三+10'),并且​​this.dirty = false​​;
  • 7、​​watcher.evaluate()​​执行完毕之后,就会判断​​Dep.target​​ 是不是​​true​​,如果有就代表还有​​渲染watcher​​,就执行​​watcher.depend()​​,然后让​​watcher​​里面的​​deps​​都收集​​渲染watcher​​,这就是双向保存的优势。
  • 8、此时​​name​​都收集了​​computed watcher​​ 和 ​​渲染watcher​​。那么设置​​name​​的时候都会去更新执行​​watcher.update()​
  • 9、如果是​​computed watcher​​的话不会重新执行一遍只会把​​this.dirty​​ 设置成 ​​true​​,如果数据变化的时候再执行​​watcher.evaluate()​​进行​​info​​更新,没有变化的的话​​this.dirty​​ 就是​​false​​,不会执行​​info​​方法。这就是computed缓存机制。

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

【Vuejs】664- 从Vue源码角度深挖Watch、Computed_初始化_07

这里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
}
}

最后看看效果

【Vuejs】664- 从Vue源码角度深挖Watch、Computed_数据_02

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

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



【Vuejs】664- 从Vue源码角度深挖Watch、Computed_初始化_09