「Vue源码学习(四)」立志写一篇人人都看的懂的computed,watch原理

16,517 阅读8分钟

前言

朋友们大家好,我是林三心,还是那句话:改变不了,那就适应它,源码的理解在当今前端市场越来越重要了,理解源码,可以使我们在开发中更快地捕捉到问题所在,今天讲到computed,watch的原理,个人建议朋友们先看这个系列的前几篇文章,或许能更好地理解本章内容,当然,我会尽我所能让大家能更好地理解computed,watch原理,我尽量讲的通俗易懂一些。你们不要嫌我啰嗦哦。
😂😂😂
「Vue源码学习」文章:

预计实现效果

20210615_220340.gif

知识前提

需要懂基本的npm命令,ES6语法,以及webpack基本打包

准备工作

1.创建一个文件夹

npm init

npm i @babel/core @babel/preset-env babel-loader clean-webpack-plugin html-webpack-plugin webpack webpack-cli webpack-dev-server -D

2.创建webpack.config.js文件

目的:配置热更新打包

// webpack.config.js

const path = require('path')
// 引入html-webpack-plugin插件
const HtmlWebpackPlugin = require('html-webpack-plugin');
// 引入clean-webpack-plugin
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
// 引入webpack插件
const webpack = require('webpack');
module.exports = {
    mode: 'development',
    devtool: 'eval',
    devServer: {
        contentBase: './dist',
        open: true,
        hot: true
    },
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, '../dist')
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                loader: 'babel-loader',
                exclude: /node_modules/
            },
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({ // 往dist里塞html并且把bundle搞进去
            template: './src/index.html'
        }),
        new CleanWebpackPlugin(), // 执行时间,在打包之前执行,改变输出文件后,下一次打包可以清除老文件
        new webpack.HotModuleReplacementPlugin() // 更新后不会刷新,保留后加的数据
    ]
}

3.package命令行修改

"scripts": {
    "dev": "webpack-dev-server --config ./webpack.config.js"
  },

4.创建.babelrc文件

// .babelrc

{
    "presets":["@babel/preset-env"]
}

5.创建src文件夹

目的:存放本章原理代码

6.最终目录

image.png

7.Vue实例

// src/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>林三心Vue源码</title>
</head>

<body>
    <div id="root"></div>
</body>
</html>
// src/index.js

import Vue from './init.js'

const root = document.querySelector('#root')
var vue = new Vue({
    data() {
        return {
            name: '林三心',
            age: 18
        }
    },
    render() {
        root.innerHTML = `${this.name}今年${this.age}岁了
    }
})

8. 如何调试

npm run dev
然后将index.html在谷歌浏览器里打开(live server)

Watcher是什么?Watcher的种类有哪些?

大家要注意,这里说的是Watcher,要跟vue里使用的watch属性区分一下哦

1.什么是Watcher呢?

举个例子,请看下面代码:

// 例子代码,与本章代码无关

<div>{{name}}</div>

data() {
        return {
            name: '林三心'
        }
    },
    computed: {
        info () {
            return this.name
        }
    },
    watch: {
        name(newVal) {
            console.log(newVal)
        }
    }

上方代码可知,name变量被三处地方所依赖,分别是html里,computed里,watch里。只要name一改变,html里就会重新渲染,computed里就会重新计算,watch里就会重新执行。那么是谁去通知这三个地方name修改了呢?那就是Watcher

2.Watcher的种类有哪些呢?

上面所说的三处地方就刚刚好代表了三种Watcher,分别是:

  • 渲染Watcher:变量修改时,负责通知HTML里的重新渲染
  • computed Watcher:变量修改时,负责通知computed里依赖此变量的computed属性变量的修改
  • user Watcher:变量修改时,负责通知watch属性里所对应的变量函数的执行

实现数据响应式

任何类型的Watcher都是基于数据响应式的,也就是说,要想实现Watcher,就需要先实现数据响应式,而数据响应式的原理就是通过Object.defineProperty去劫持变量的getset属性

这里的响应式只做了简单的响应式处理,如果想看详细的,请移步「Vue源码学习(一)」你不知道的-数据响应式原理,也就是此系列的第一篇。

1.初始化Vue

// src/init.js

import initState from './initState.js'
import initComputed from './initComputed.js'
import initWatch from './initWatch'
import Watcher from './Watcher.js'

export default function Vue(options) {

    // 初始化函数
    this._init(options)
}

Vue.prototype._init = function (options) {
    const vm = this
    vm.$options = options
    if (options.data) {
        // 初始化数据
        initState(vm)
    }
}

2.什么是Dep?

Dep是什么呢?举个例子,还是之前的例子代码:

// 例子代码,与本章代码无关

<div>{{name}}</div>

data() {
        return {
            name: '林三心'
        }
    },
    computed: {
        info () {
            return this.name
        }
    },
    watch: {
        name(newVal) {
            console.log(newVal)
        }
    }

这里name变量被三个地方所依赖,三个地方代表了三种Watcher,那么name会直接自己管这三个Watcher吗?答案是不会的,name会实例一个Dep,来帮自己管这几个Wacther,类似于管家,当name更改的时候,会通知dep,而dep则会带着主人的命令去通知这些Wacther去完成自己该做的事

3.响应式实现

// src/initState.js

import { Dep } from "./Dep"

export default 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--) {
        // 变量代理
        // 这样做的好处就是操作data里的变量时,只需要this.xxx而不用this.data.xxx
        proxy(vm, '_data', keys[i])
    }
    observe(data)
}

class Observer {
    constructor(value) {
        this.walk(value)
    }

    walk(data) {
        let keys = Object.keys(data)
        // 遍历data的key,并进行响应式判断处理
        for (let i = 0; i < keys.length; i++) {
            defineReactive(data, keys[i], data[keys[i]])
        }
    }
}

function defineReactive(data, key, value) {
    // 每个对象都有自己dep
    const dep = new Dep()
    Object.defineProperty(data, key, {
        get() {
            if (Dep.target) {
                // 如果Dep.target指向某个Watcher,则把此Watcher收入此dep的队列里
                dep.depend()
            }
            return value
        },
        set(newVal) {
            // 设置值时,如果相等则返回
            if (newVal === value) return
            value = newVal
            // 新设置的值也需要响应式判断处理
            observe(newVal)

            // 通知dep里的所有Wacther进行传达更新
            dep.notify()
        }
    })

    // 递归,因为可能对象里有对象
    observe(value)
}

function observe(data) {
    // 只有当data为数组或者对象时才进行响应式处理
    if (typeof data === 'object' && data !== null) {
        return new Observer(data)
    }
}

// 代理函数
function proxy(vm, source, key) {
    Object.defineProperty(vm, key, {
        get() {
            return vm[source][key]
        },
        set(newVal) {
            return vm[source][key] = newVal
        }
    })
}
// src/Dep.js
let dId = 0

export class Dep {
    constructor() {
        // 每个dep的id都是独一无二的
        this.id = dId++
        // 用来存储Watcher的数组
        this.subs = []
    }

    depend() {
        if (Dep.target) {
            // 此时Dep.target指向的是某个Wacther,Wacther也要把此dep给收集起来
            Dep.target.addDep(this)
        }
    }

    notify() {
        // 通知subs里的每个Wacther都去通知更新
        const tempSubs = this.subs.slice()
        tempSubs.reverse().forEach(watcher => watcher.update())
    }

    addSub(watcher) {
        // 将Watcher收进subs里
        this.subs.push(watcher)
    }
}

let stack = []
export function pushTarget(watcher) {
    // 改变target的指向
    Dep.target = watcher
    stack.push(watcher)
}

export function popTarget() {
    stack.pop()
    Dep.target = stack[stack.length - 1]
}

4.Watcher为何也要反过来收集Dep?

上面说到了,dep是name的管家,他的职责是:name更新时,dep会带着主人的命令去通知subs里的Watcher去做该做的事,那么,dep收集Watcher很合理。那为什么watcher也需要反过来收集dep呢?这是因为computed属性里的变量没有自己的dep,也就是他没有自己的管家,看以下例子:

这里先说一个知识点:如果html里不依赖name这个变量,那么无论name再怎么变,他都不会主动去刷新视图,因为html没引用他(说专业点就是:namedep里没有渲染Watcher),注意,这里说的是不会主动,但这并不代表他不会被动去更新。什么情况下他会被动去更新呢?那就是computed有依赖他的属性变量。

// 例子代码,与本章代码无关

<div>{{person}}</div>

computed: {
    person {
        return `名称:${this.name}`
        }
    }

这里的person事依赖于name的,但是person是没有自己的dep的(因为他是computed属性变量),而name是有的。好了,继续看,请注意,此例子html里只有person的引用没有name的引用,所以name一改变,按理说虽然person跟着变了,但是html不会重新渲染,因为name虽然有dep,有更新视图的能力,但是奈何人家html不引用他啊!person想要自己去更新视图,但他却没这个能力啊,毕竟他没有dep这个管家!这个时候computed Watcher里收集的namedep就派上用场了,可以借助这些dep去更新视图,达到更新html里的person的效果。具体会在下面computed里实现。

5.逻辑有点绕

这里逻辑确实有点绕,因为dep和watcher互相采集,大家在调试过程中可以自己console.log一下depsubs看看,这样会更能看清逻辑。这里可以看到,dep收集watcher,而watcher也会反过来收集dep。 此时输出了两个dep,因为有nameage,一个变量有一个dep所以总共两个dep,由于这两个变量都被html所依赖,所以每个depsubs里都收集了渲染Watcher,反过来,渲染Watcher也要收集这两个dep,如图:

image.png

实现Watcher

// src/Watcher.js

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

首次渲染(渲染Watcher)

上面说过,只要把render函数传进Wacther,那么此Watcher渲染Watcher渲染Watcher的作用是:首次渲染,并且HTML模板所依赖的变量改变时也会重新渲染。

export default function Vue(options) {

    // 初始化函数
    this._init(options)

    // 渲染函数
    this.$mount()
}

Vue.prototype.$mount = function() {
    const vm = this
    // 创建渲染Watcher
    // 这里第二个参数传render函数进去,则此Watcher为渲染Watcher
    // 因为在此例子里render为渲染dom的函数
    new Watcher(vm, vm.$options.render, () => {}, true)
}

此时在终端里运行npm run dev,并live server打开index.html文件,看到以下效果,则证明首次渲染成功:

image.png

更新数据

现在的数据是死的,那我们如何改变呢?

// src/index.html
<body>
    <div id="root"></div>
    <button id="btn1">改变name</button>
    <button id="btn2">改变age</button>
</body>

// src/index.js
const root = document.querySelector('#root')
var vue = new Vue({
    data() {
        return {
            name: '林三心',
            age: 18
        }
    },
    render() {
        root.innerHTML = `${this.name}今年${this.age}岁了`
    }
})

document.getElementById('btn1').onclick = () => {
    vue.name = 'sunshine_Lin'
}
document.getElementById('btn2').onclick = () => {
    vue.age = 20
}

由本章之前内容代码可知,当data里的变量被改变时,会触发Object.definePropertyset属性,直接改变数据层的的数据,但是问题来了,数据是修改了,那视图该怎么更新呢?这时候dep就排上用场了,dep会触发notify方法,通知渲染Watcher去更新视图(此时dep里只有一个Watcher,后续会更多),效果如图:

845bd71610cbe1c567506c62e64b2245 (1).gif

实现computed

1.代码实现

修改一下代码:

// src/index.js
const root = document.querySelector('#root')
var vue = new Vue({
    data() {
        return {
            name: '林三心',
            age: 18
        }
    },
    computed: { // 新增
        info() {
            return this.name + this.age
        }

    },
    render() {
        root.innerHTML = `${this.name}今年${this.age}岁了-----${this.info}` // 新增info
    }
})
// src/init.js

import initState from './initState.js'
import initComputed from './initComputed.js'

Vue.prototype._init = function (options) {
    const vm = this
    vm.$options = options
    if (options.data) {
        // 初始化数据
        initState(vm)
    }
    if (options.computed) { // 新增
        // 初始化computed
        initComputed(vm)
    }
}

我们需要在这个initComputed方法里实现computed的逻辑

// src/initComputed.js

import { Dep } from "./Dep"
import Watcher from "./Watcher"

export default 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,然后到watcher里面接收到这个对象

// src/Watcher.js


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上面。下面我们实现一下。

// src/initComputed.js


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多了evaluatedepend两个方法,让我们去实现一下吧,以下是此时Watcher的完整代码:


import { pushTarget, popTarget } from './Dep'

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()
    }
  }
}

2.流程讲解

  • 1.首次渲染会执行render函数,render函数里会读取info变量,这个会触发createComputedGetter(key)中的computedGetter(key)
  • 2.然后会判断dirty这个变量,看是否需要重新计算,如需重新计算则执行watcher.evaluate
  • 3.在watcher.evaluate方法中,执行了this.get方法,这时候会执行pushTarget(this)把当前的computed watcher push到stack里面去,并且把Dep.target 设置成当前的computed watcher
  • 4.运行this.getter.call(vm, vm),也就是运行了info() {return this.name + this.age}这个函数
  • 5.执行info函数后,函数里会读取nameage两个变量,那么就会触发两次Object.defineProperty.get的方法,那么nameage两者的dep都会把此computed Watcher收集起来
  • 6.依赖收集完毕之后执行popTarget(),把当前的computed watcher从栈清除,返回计算后的值('林三心' + '18'),并且this.dirty = false
  • 7.watcher.evaluate()执行完毕之后,就会判断Dep.target 是不是true,如果有就代表还有渲染watcher,就执行watcher.depend(),然后让watcher里面的deps都收集渲染watcher,这就是双向保存的优势。
  • 8.此时nameage都收集了computed watcher渲染watcher。那么设置name的时候都会去更新执行watcher.update(),age也同理
  • 9.如果是computed watcher的话不会重新执行一遍只会把this.dirty 设置成 true,如果数据变化的时候再执行watcher.evaluate()进行info更新,没有变化的的话this.dirty 就是false,不会执行info方法。这就是computed缓存机制。 看看此时的效果:

38980e0ca8c5f6ee438aa4981c01ac21.gif

watch的实现

修改一下代码:

// src/index.js

const root = document.querySelector('#root')
var vue = new Vue({
    data() {
        return {
            name: '林三心',
            age: 18
        }
    },
    computed: {
        info() {
            return this.name + this.age
        }

    },
    watch: {
        name(oldValue, newValue) {
            console.log('触发watch', oldValue, newValue)
        },
        age(oldValue, newValue) {
            console.log('触发watch', oldValue, newValue)
        }
    },
    render() {
        root.innerHTML = `${this.name}今年${this.age}岁了-----${this.info}`
    }
})
// src/init.js

Vue.prototype._init = function (options) {
    const vm = this
    vm.$options = options
    if (options.data) {
        // 初始化数据
        initState(vm)
    }
    if (options.computed) {
        // 初始化computed
        initComputed(vm)
    }
    if (options.watch) {
        // 初始化watch
        initWatch(vm)
    }
}

实现一下initWatch:

// src/initWatch.js

import Watcher from './Watcher'

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

修改一下Watcher.js的代码

// src/Watcher.js

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

最后来看看效果:

6ff71566df6adac8414a885725a61f8c.gif

结语

加油,各位!!!点赞,点起来