重0到1实现一个简单的vue

381 阅读10分钟

本文着重讲解vue2.0的双向绑定,computed,watch,异步更新队列内容,不涉及模版渲染相关内容,但是为了demo能够体现这种功能功能,简单利用模版字符串和正则渲染数据。废话不多说,开始正文

demo结构

为了让大家了解相关功能,先看实例化结构,然后再一步步实现它

import MyVue from './src/index.js'
new MyVue({
  el: '#app',
  data() {
    return {
      age: 1
    }
  },
  mounted() {
    this.say()
  },
  methods: {
    say() {
      this.age = 99
    }
  },
  watch: {
    age: {
      handler(nv, ov) {
        console.log(nv, ov, 'change')
      }
    }
  },
  computed: {
    ageName() {
      return this.age + '我的名字'
    }
  },
  template: `
    <div>{{ageName}}</div>
  `
})

实例化

首先我们可以看出MyVue是构造函数,并且接收一个参数。所以第一步非常简单,创建一个构造函数并保存参数

// src/index.js
import { initMixin } from './init.js'

function MyVue(options) {
  this._init(options)
}

initMixin(MyVue)

export default MyVue
// src/init.js
import { initState } from './state.js'
export function initMixin(MyVue) { 
  MyVue.prototype._init = function(options) {
    const vm = this
    vm.$options = options
    // 初始化各种属性
    initState(vm)
  }
}

初始化函数(methods)

想想vue中method的几个特点

  1. 可以直接使用this调用,例如this.sayAge()
  2. function内部可以使用this调用data,例如this.age = 11

基于这2点我们可以理解为method里的所有函数都挂载在MyVue上,并且this指向MyVue

// src/state.js
export function initState(vm) {
  const opts = vm.$options
  if(opts.methods) initMethods(vm, opts.methods)
}

function initMethods(vm, methods) {
  // 循环methods对象
  for(let key in methods) {
    const fn = methods[key]
    // 判断是否是函数,不是的话抛出提示
    if(typeof fn !== 'function') {
      console.log(`${key} not function`)
    }
    // 把函数挂载到MyVue上,并且使函数的this指向MyVue
    vm[key] = fn !== 'function' ? () => {} : fn.bind(vm)
  }
}

到这里应该会有疑惑,函数的this绑定到MyVue后也没法通过this.xxx获取data里的数据啊,我们接着往下看~

响应式数据(observeData)

data初始化

首先声明一点,vue组件data必须是一个函数(new Vue可以是对象),不然可能会发生组件间数据引用问题,具体可以看官方文档解释。 然后我们也先想一下data的特性

  1. 可能是函数也可能是对象,data函数内可以通过this直接调用methods函数
  2. key不可以与methods中函数重名
  3. 在生命周期或者methods函数等地方都可以用this获取data里的数据
  4. 响应式

接下来先一步步实现如上特性

// src/state.js
import { observe } from './observe.js'
export function initState(vm) {
  const opts = vm.$options
  if(opts.methods) initMethods(vm, opts.methods)
  // 初始化data
  if(opts.data) initData(vm, opts.data)
}
function initData(vm, data) {
  const methods = vm.$options.methods
  // 判断data是否是function,function就利用call绑定this到MyVue并且求值,实现了特性1
  // vm._data里也保存一份data值,后续代理使用
  data = vm._data = typeof data === 'function' ? data.call(vm) : data || {}
  for(let key in data) {
    // 判断是否和methods重名
    if(methods && methods.hasOwnProperty(key)) {
      console.log(`data ${key}和methods函数重名了`)
    }
    // 代理
    proxy(vm, '_data', key)
  }
  // 响应式
  observe(data)
}

我们在vm实例里保存了一份_data数据,主要是为了做一层代理,通过Object.defineProperty使this.xxx可以访问到this._data.xxx的数据

// src/state.js
function proxy(vm, source, key) {
  // this.name -> this._data.name
  Object.defineProperty(vm, key, {
    configurable: true,
    enumerable: true,
    get() {
      // this = vm
      return this[source][key]
    },
    set(val) {
      this[source][key] = val
    }
  })
}

这时如果log一下vm,可以直接看到data里的属性直接挂载在vm上了(proxy的原因),所以可以直接使用this.xxx获取this._data.xxx里的值,这时上一章的methods里函数可以直接用this访问数据也得了解释。 接下来到了这一章的重点,响应式

响应式

首先说明为了性能,vue的响应式实现区分了对象和数组。因为很多时候数组中的数据非常大,而Object.defineProperty只能通过遍历的形式为每个key添加响应式,这对性能的开销是非常大的。 所以在实现上我们需要区分数组还是对象,首先我们先实现对象的响应式,依旧使用Object.defineProperty劫持对象中的每一个属性

// src/observe.js
export function observe(data) {
  // data不是数组或对象的话不处理
  if(!Array.isArray(data) && data.constructor !== Object) {
    return 
  }
  let ob = new Observer(data)
  // 小细节,后面说
  return ob
}

class Observer{
  constructor(data) {
    // 数组,先不处理
    if(Array.isArray(data)) {
      this.observeArray(data)
    } else {
      // 对象就遍历
      this.walk(data)
    }
  }
  walk(obj) {
    const keys = Object.keys(obj)
    // 遍历对象
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
  observeArray() {

  }
}
// 响应式
function defineReactive(data, key) {
  let val = data[key]
  // 递归,因为data可能是嵌套的{a:{b:1,c:2}}
  let childOb = observe(val)
  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get() {
      // get劫持
      console.log(`get ${key} ${val}`)
      return val
    },
    set(newVal) {
      // set劫持,如果是相同的值,直接过滤
      if(newVal === val) return 
			console.log(`set ${key} ${newVal}`)
      val = newVal
    }
  })
}

这时我们写个demo验证一下get和set是不是执行了,修改一下initMixin和index.js,新增mounted验证是否劫持

import { initState } from './state.js'

export function initMixin(MyVue) { 
  MyVue.prototype._init = function(options) {
    const vm = this
    vm.$options = options
    initState(vm)
    // 新增这一段,执行mounted方法
    vm.$options.mounted.call(vm)
  }
}

// index.js
import MyVue from './src/index.js'
new MyVue({
  el: '#app',
  data() {
    return {
      age: 20
    }
  },
  mounted() {
    console.log(this.age)
    this.age = 21
  },
})
// 
// get age 20
// 20
// set age 21

log可以看出,我们的属性劫持成功。 其实后面我们该说到Dep和Watcher,但是如果直接说这部分又不太连贯,因为涉及到渲染Wachter,而且为了更好的演示效果,所以先到下一章,再回到这一部分后续。

字符串模版初始渲染(template)

这一部分因为没有去研究vue的模版渲染的核心原理,所以只是简单的用模版字符串和正则的方法简单实现了一下功能。先说一下实现步骤吧

  1. 初始化state后去执行$mounted
  2. 获取el(挂载节点)和template(模版字符串)
  3. 根据template获取render方法,把模版字符串中{{xxx}}转为data内数据
  4. 创建渲染Watcher,执行render方法,把dom节点塞入el内
  5. 执行mounted生命周期

获取render

修改一下initMixin

// src/init.js
import { initState } from './state.js'
import { compileToFunctions } from './compile.js'
import { Watcher } from './watcher.js'

export function initMixin(MyVue) { 
  MyVue.prototype._init = function(options) {
    const vm = this
    vm.$options = options
    initState(vm)
    if(vm.$options.el && vm.$options.template) {
      vm.$mounted(el)
    }
  }
  MyVue.prototype.$mounted = function(el) {
    const vm = this
    // 获取根节点
    el = vm.$el = query(el)
    let template = vm.$options.template
    // 根据template获取render方法
    const { render } = compileToFunctions(template, vm)
    // 保存在vm内,执行watcher后调用,也就是更新
    vm._render = render
    // 创建渲染watcher
    mountComponent(vm)
  }
}

function mountComponent(vm) {
  // 创建一个渲染函数,执行render并更新页面
  let updateComponent = () => {
    const tem = vm._render()
    vm.$el.innerHTML = tem
  }
  console.log('start mount')
  // 渲染watcher
  new Watcher(vm, updateComponent)
  console.log('mounted')
  // mounted生命周期
  vm.$options.mounted && vm.$options.mounted.call(vm)
}

// 容错,没有找到el节点就创建一个
function query(el) {
  let element = document.querySelector(el)
  if(!element) {
    console.error(`没有找到${el}节点`)
    element = document.createElement('div')
  }
  return element
}

其实compileToFunctions函数就是把template传入后,通过replaceAll替换{{}}内的内容

// src/compile.js
// <div>{{obj.name}}</div> -> <div>my name</div>
export function compileToFunctions(template, vm) {
  const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
  const render = function () {
    // {{xxx}} -> xxx -> vm.xxx
    return template.replaceAll(defaultTagRE, (match, key) => {
      const value = parseEx(vm, key)
      return value ? value : ''
    })
  }
  return {render}
}

// 获取a.b.c的值
function parseEx(vm, key) {
  const arr = key.split('.')
  let value = vm
  for(let i=0;i<arr.length;i++) {
    value = value[arr[i]]
  }
  return value
}

渲染Watcher

渲染Watcher可以简单的理解为一个立即执行updateComponent(执行_render)的构造函数

// src/watcher.js
let uid = 0
export class Watcher{
  constructor(vm, expOrFn) {
    this.vm = vm
    this.id = uid++
    this.getter = expOrFn
    this.value = this.get()
  }
  get() {
    const value = this.getter.call(this.vm, this.vm)
    return value
  }
}

然后修改index.js,查看效果,发现页面输出20,所以没毛病

import MyVue from './src/index.js'
new MyVue({
  el: '#app',
  data() {
    return {
      age: 20
    }
  },
  template: `
    <div>{{age}}</div>
  `
})

渲染更新(update)

渲染更新其实就是修改了某个data的值后,template自动更新。那么我们怎么知道什么时候修改了数据呢,又怎么知道哪里用到了这个数据呢? 刚刚在响应式这一小节里我们对data做了一层劫持,本质上来说什么时候修改了数据等于set,哪里用到了这个数据等于get,所以我们只需要在get和set中去收集和触发就可以完成渲染更新。 在这之前,先说明一下几个概念

  1. Dep:每个属性都有一个Dep,保存每个属性中关联的Watcher(目前只有渲染Watcher),属性值修改后调用Watcher.update执行回调
  2. Watcher:渲染Watcher(其实还有几种,后面说),保存关联的Dep,去重和后续计算属性使用,与Dep是多对多的关系
  3. Dep.target:Dep静态属性,保存进行中的Watcher,执行完后消除

实现Dep

首先实现一个Dep,用来保存Watcher,并且可以有一个唯一标识可以去重。接着维护一个Dep.target的静态属性保存进行中的Watcher,还有一个进行中的Watcher栈

// src/dep.js
let uid = 0
export class Dep{
  constructor() {
    // 唯一标识
    this.id = uid++
    // 保存Watcher
    this.subs = []
  }
  // 给Watcher添加dep,并且去重
  depend() {
    if(Dep.target) {
      Dep.target.addDep(this)
    }
  }
  // 收集watcher
  addSub(watcher) {
    this.subs.push(watcher)
  }
  // 遍历subs,执行update更新
  notify() {
    let len = this.subs.length
    while(len--) {
      this.subs[len].update()
    }
  }
}
// 运行中watcher
Dep.target = null
// watcher栈
const targetStack = []

export function pushTarget(watcher) {
  targetStack.push(watcher)
  Dep.target = watcher
}

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

修改Watcher

然后修改之前的渲染Watcher,这次需要新增几个点

  1. 在执行getter之前pushTarget,执行完毕popTarget,也就是在模版渲染的时候让Dep.target等于渲染Watcher,渲染完成后清除渲染Watcher
  2. 新增addDep函数,实现dep去重和存储
  3. 新增update函数,重新执行get(也就是之前的updateComponent)
// src/watcher.js
import { pushTarget, popTarget } from './dep.js'
let uid = 0
export class Watcher{
  constructor(vm, expOrFn, userCb, options = {}) {
    this.vm = vm
    this.id = uid++
    this.getter = expOrFn
    this.userCb = userCb
    // 存dep
    this.deps = []
    // 存depId
    this.depIds = new Set()
    this.value = this.get()
  }
  get() {
    // Dep.target = 渲染Watcher
    pushTarget(this)
    // 渲染(render) -> 读取data(get) -> 渲染Watcher收集Dep -> Dep收集渲染Watcher
    // 如果data修改了(set) -> dep.notify -> 之前dep已经收集了渲染Watcher -> update(_render) 
    const value = this.getter.call(this.vm, this.vm)
    // pop Dep.target
    popTarget()
    return value
  }
  addDep(dep) {
    const id = dep.id
    // 去重
    if(!this.depIds.has(id)) {
      this.deps.push(dep)
      this.depIds.add(id)
      // dep保存Watcher
      dep.addSub(this)
    }
  }
  // 重新执行get
  update() {
  	this.value = this.get()
  }
}

对象劫持

最后修改的defineReactive方法,实现模版渲染期间使用到的数据收集渲染Watcher,修改数据时触发渲染Watcher,其实就是get时收集Watcher,set时遍历Dep下Watcher然后执行get

// src/dep.js
import { Dep } from "./dep"

export function observe(data) {
  if(!Array.isArray(data) && data.constructor !== Object) {
    return 
  }
  let ob = new Observer(data)
  return ob
}

class Observer{
  constructor(data) {
    if(Array.isArray(data)) {
      this.observeArray(data)
    } else {
      this.walk(data)
    }
  }
  walk(obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
  observeArray() {

  }
}
function defineReactive(data, key) {
  let val = data[key]
  const dep = new Dep()
  let childOb = observe(val)
  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get() {
      // 存在Watcher,给Watcher添加dep,给dep添加Watcher
      if(Dep.target) {
        dep.depend()
      }
      return val
    },
    set(newVal) {
      if(newVal === val) return 
      val = newVal
      // 有新的值触发渲染
      dep.notify()
    }
  })
}

到这一步,我们已经完成了对象劫持,实现了对象的响应式,接下来我们实现数组的劫持。

数组劫持

如果此时data里有一个数组,然后给数组push新数据的话,并没有进行更新,因为我们目前为止只劫持了对象里的属性。 之前我们也说过,劫持每一个值的话对性能影响非常大。那么换一种思路,我们重写数组类型中会修改原数组的方法(比如push...),然后取出新增的数据做响应式处理,再去触发更新。但是在这之前我们要先收集对应的Watcher,才能触发更新。怎么去收集呢? 假设存在如下的data

{
	ages: [1, [2], [3, [4, 5]]]
}

如果我们执行了ages.push(8),那么是不是可以理解为ages变了。所以说,数组里的所有Watcher其实和ages这个key的Watcher完全是一致的,我们只需要不断的重写每一层数组,和收集每一层数据对应的的Watcher,得到类似这种映射关系

ages -> 渲染Watcher
[1, [2], [3, [4, 5]]] -> 重写,dep保存渲染Watcher
[2] -> 重写,dep保存渲染Watcher
[3, [4, 5]] -> 重写,dep保存渲染Watcher
[4, 5] -> 重写,dep保存渲染Watcher

思路说完了,我们修改一下Observer方法

// src/dep.js
import { Dep } from "./dep.js"
// 重写数组原型方法
import { arrayMethods } from './array.js'

export function observe(data) {
  if (!Array.isArray(data) && data.constructor !== Object) {
    return
  }
  // 如果存在__ob__属性,就证明已经是响应式,直接返回实例就可以
  let ob
  if (data.hasOwnProperty('__ob__') && data.__ob__ instanceof Observer) {
    ob = data.__ob__
  } else {
    ob = new Observer(data)
  }
  return ob
}
class Observer {
  constructor(data) {
    // 只有数组使用到了这个dep
    this.dep = new Dep()
    // 给data添加不可枚举的__ob__属性,标记为响应式
    this.insetOb(data)
    // 如果是数组类型,重写数组的原型
    if (Array.isArray(data)) {
      // 当前data原型链继承
      data.__proto__ = arrayMethods
      this.observeArray(data)
    } else {
      this.walk(data)
    }
  }
  // 
  walk(obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
  // 递归,不断重写数组的原型方法
  observeArray(data) {
    for(let i=0;i<data.length;i++) {
      observe(data[i])
    }
  }
  insetOb(data) {
    Object.defineProperty(data, '__ob__', {
      configurable: true,
      enumerable: false,
      value: this
    })
  }
}
function defineReactive(data, key) {
  let val = data[key]
  const dep = new Dep()
  // 值的Observer实例
  let childOb = observe(val)
  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get() {
      // 存在Watcher,给Watcher添加dep,给dep添加Watcher
      if (Dep.target) {
        dep.depend()
        // 如果存在值的Observer实例,也收集key对应的Watcher
        if(childOb) {
          childOb.dep.depend()
          // [1, [2, [3]]],可能存在无限个嵌套数组,就无限收集Watcher
          if(Array.isArray(val)) {
            dependArray(val)
          }
        }
      }
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      dep.notify()
    }
  })
}

function dependArray(val) {
  for(let i=0;i<val.length;i++) {
    let e = val[i]
    e && e.__ob__ && e.__ob__.dep.depend()
    if(Array.isArray(e)) {
      dependArray(e)
    }
  }
}
// src/array.js
// 数组的原型
const arrayProto = Array.prototype
// 继承数组原型
export const arrayMethods = Object.create(arrayProto)

// 会修改原数据的数组方法
const list = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

list.forEach(method => {
  // 原数组方法
  const original = arrayProto[method]
  Object.defineProperty(arrayMethods, method, {
    enumerable: true,
    configurable: true,
    value(...args) {
      console.log(this, args)
      // 原型数组方法取值
      const value = original.apply(this, args)
      // Observer,this代表数据本身,比如{a:[1,2]},this代表a,__ob__在Observer中添加过,代表响应式
      const ob = this.__ob__
      let inset
      // 截取新的数据
      switch(method) {
        case 'push':
        case 'unshift':
          inset = args
          break;
        case 'splice':
          inset = args.slice(2)
          break;
      }
      // 新数据响应式处理
      if(inset) ob.observeArray(inset)
      // 触发更新
      ob.dep.notify()
      // 返回值
      return value
    }
  })
}) 

写到这一步,我们就完成了响应式数据的全部内容,写个demo验证一下

// index.js
import MyVue from './src/index.js'
new MyVue({
  el: '#app',
  data() {
    return {
      age: 20,
      names: ['我', ['zm']]
    }
  },
  mounted() {
    setTimeout(() => {
      this.age = 21
      this.names[1].push('yg')
    }, 1000)
  },
  template: `
    <div>{{age}}</div>
    <div>{{names}}</div>
  `
})
// 先显示20,我,zm
// 1s后显示21,我,zm,yg
// 完工

但是这时我们可以在Watcher的update方法里加个log(1),可以发现1s后控制台有先后log了2次1,因为我们先修改了age的数据,发生了一次update,然后又在names中push了一次数据,又发生了一次update。这显然跟vue不一样。接下来我们实现一个更新队列,让同步的update只执行一次

更新队列(queue)

主要就是利用事件循环机制,利用任务队列包裹更新行为,使其达到异步效果,其实并不是真的异步,只是把更新的顺序放到了同步赋值之后。废话不多说,开始操作。 首先先改一下Watcher,将收集更新和真正触发更新区分开来

// src/watcher.js
import { queueWatcher } from './queue.js'
class Watcher{
  // 之前老代码就不写了
	...
  // 收集更新队列
  update() {
    queueWatcher(this)
  }
  // 真正触发更新
  run() {
    this.value = this.get()
  }
}

然后我们需要创建一个Watcher队列用来保存异步任务未执行的这段时间内所有的Watcher并且去重,然后用异步任务执行后遍历执行run更新

// src/queue.js
import { nextTick } from "./nexttick.js"

// Watcher队列
let queue = []
// Watcher id集合
let has = {}

// 循环执行Watcher的更新方法
function flushSchedulerQueue() {
  this.queue = queue.slice()
  for(let i=0;i<queue.length;i++) {
    const watcher = queue[i]
    watcher.run()
  }
  // 执行完成后清空
  queue = []
  has = {}
}

export function queueWatcher(watcher) {
  const id = watcher.id
  // 判断当前id是否存在,可能(this.name = 1;this.name = 2;this.age=2)那么就存在3次,我们只保存一次Watcher就可以(渲染watcher id是同一个)
  if(!has[id]) {
    has[id] = true
    queue.push(watcher)
    // 异步,详细可以看看事件循环机制
    nextTick(flushSchedulerQueue)
  }
}
// src/nexttick.js
// 存储flushSchedulerQueue
const cbs = []
let pendding = false

function flushCallbacks() {
  pendding = false
  // 遍历更新方法,执行所有更新任务
  for(let i = 0;i<cbs.length;i++) {
    cbs[i]()
  }
}

// 优雅降级,让flushCallbacks在同步代码之后执行
let timeFunc
if(typeof Promise !== 'undefined') {
  const p = Promise.resolve()
  timeFunc = () => {
    p.then(flushCallbacks)
  }
} else if(typeof MutationObserver !== 'undefined') {
  let counter = 1
  // 在指定的DOM发生变化时被调用
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true // 观查字符数据变化
  })
  timerFunc = () => {
    // 字符变化后调用flushCallbacks
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
} else if (typeof setImmediate !== 'undefined') {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

export function nextTick(fn) {
  cbs.push(fn)
  // 同步的update会多次执行nextTick,但是我们只执行一次,等异步队列清空之后再重新执行
  if(!pendding) {
    pendding = true
    timeFunc()
  }
}

到这里我们就完成异步更新队列,同步任务下执行多次赋值,只会渲染一次

初始化计算属性(computed)

在实现computed之前我们还是先思考一下computed的一些特性

  1. 可以直接使用this访问data的数据
  2. 可以在其它函数里用this访问computed属性
  3. 写法上存在2种,一种是函数写法,一种是对象写法
  4. 依赖的值没有变化的话,会直接取缓存的值,不会重新计算
  5. 懒计算,只有在用到之后才去求值

老步骤,先修改initState里的方法,获取computed属性并执行初始化方法

// src/state.js
export function initState(vm) {
  const opts = vm.$options
  if(opts.methods) initMethods(vm, opts.methods)
  if(opts.data) initData(vm, opts.data)
  if(opts.computed) initComputed(vm, opts.computed)
}

然后我们遍历computed获取计算属性,处理计算属性中几种写法的差异,并创建computed Watcher

// src/state.js
function initComputed(vm, computed) {
  // 保存watcher
  const watchers = vm._computedWatchers = {}
  for(let key in computed) {
    const userDef = computed[key]
    // 可能是function,也可能是对象,是对象获取get作为表达式
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    // 创建computed Watcher,lazy作为标识。因为使用computed都是在其余函数或者模版中,使用到的时候再求值和获取关联的进行中的Watcher
    // 保存每一个watcher
    watchers[key] = new Watcher(vm, getter, () => {}, {lazy: true})
    // 做一层劫持,用来获取关联的Watcher,也是在这里将computed属性挂载到实例上
    if(!(key in vm)) {
      defineComputed(vm, key, userDef)
    }
  }
}

劫持computed属性

我们先实现defineComputed,主要就是利用Object.defineProperty给实例添加computed属性,然后在get内判断依赖值是否更新,如果更新才重新求值,没更新就直接获取缓存值。

// src/state.js
function defineComputed(vm, key, userDef) {
  const isFn = typeof userDef === 'function'
  // 挂载到实例
  Object.defineProperty(vm, key, {
    configurable: true,
    enumerable: true,
    get() {
      // 获取computed Watcher
      const watcher = vm._computedWatchers[key]
      // 如果是脏值就执行Watcher get,获取重新求值并给依赖项dep添加computed Watcher
      if(watcher.dirty) {
        watcher.evaluate()
      }
      // 计算属性的依赖关联渲染Watcher
      if(Dep.target) {
        watcher.depend()
      }
      // dirty:true,新值,dirty:false,上一次的值(缓存值)
      return watcher.value
    },
    // 取对象的set或者空函数
    set: isFn ? () => {} : (userDef.set || function() {})
  })
}

这里比较难理解的应该是watcher.depend这一段,为什么要关联渲染Watcher? 因为我们可能是在模版渲染的时候读取了computed属性,这时Dep.target为渲染Watcher,然后在执行evaluate的时候我们会对计算属性求值(具体代码看后面一点),然后此时Dep.target为computed Watcher,给依赖项添加computed Watcher和给computed Watcher添加依赖项的dep,执行完毕popTarget后,Dep.target归还给渲染Watcher,然后我们再给computed Watcher收集到的依赖项Dep添加渲染Watcher,这时依赖项如果修改了值,那么遍历dep的update时也会执行渲染Watcher,达到了更新template的效果。接下来我们就来修改一下watcher达到这一效果。

computed Watcher

首先我们需要watcher在拿到lazy的时候先不执行get,并初始化一个判断依赖项是否有更新的属性(dirty),初始值也是true

// src/watcher.js
export class Watcher{
  constructor(vm, expOrFn, userCb, options = {}) {
    this.vm = vm
    this.id = uid++
    // 计算属性,并初始化一个判断依赖项是否有更新的属性
    this.dirty = this.lazy = options.lazy
    this.userCb = userCb
    this.deps = []
    this.depIds = new Set()
    // lazy,先不求值
    this.value = this.lazy ? undefined : this.get()
  }
}

然后实现evaluate,也就是简单的求值,然后把dirty赋值为false,代表已经是最新值,如果依赖没变就不求值

// src/watcher.js
evaluate() {
    // 求值,获取依赖 -> 例如a(){return this.a+this.b},执行get求值后,a和b的dep收集computed Watcher,computed Watcher的deps收集a和b的dep
  this.value = this.get()
  // 求值之后如果依赖不更新就不会重新求值
  this.dirty = false
}

然后再遍历computed Watcher收集到的依赖项的deps,给它们添加渲染Watcher

// src/watcher.js
depend() {
  // 遍历computed Watcher收集到的dep,也就是依赖项的dep,其实这时候的Dep.target为渲染Watcher,所以就是给依赖项添加渲染Watcher
  for(let i = 0;i < this.deps.length; i++) {
    this.deps[i].depend()
  }
}

最重要一点什么时候把dirty重新赋值为true?依赖项更新的时候对啊,依赖项更新的时候会走set,然后走到update,是计算属性的话就把dirty赋值为true,然后在读取到computed属性的时候computed watcher的dirty就是true并重新求值。所以我们只需要在update中判断是不是computed Watcher就可以啦。

// src/watcher.js
update() {
  // computed watcher
  if(this.lazy) {
    // 需要重新计算
    this.dirty = true
  } else {
    // 异步更新
    queueWatcher(this)
  }
}

附上一张画的图吧 Untitled-2021-11-10-1443.png

初始化监听属性(watch)

终于快写完了。。。 老规矩,想一想watch的特性

  1. 可以直接使用this访问data的数据
  2. 写法上存在4种,函数写法,对象写法,数组写法,字符串写法
  3. 表达式的值发生变化后再执行回调函数,并返回新老值
  4. 存在immediate,deep参数

老步骤,先修改initState里的方法,获取watch属性并执行初始化方法

// src/state.js
export function initState(vm) {
  const opts = vm.$options
  if(opts.methods) initMethods(vm, opts.methods)
  if(opts.data) initData(vm, opts.data)
  if(opts.computed) initComputed(vm, opts.computed)
  if(opts.watch) initWatcher(vm, opts.watch)
}
// src/state.js
function initWatcher(vm, watch) {
  // 遍历
  for(let key in watch) {
    const handler = watch[key]
    // 是数组,循环创建user Watcher
    if(Array.isArray(handler)) {
      let i = handler.length
      while(i--) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      // 直接创建user Watcher
      createWatcher(vm, key, handler)
    }
  }
}

消除差异

在创建user Watcher之前,我们已经消除了数组创建方式的差异,而且我们需要把回调参数传入watcher,函数就是回调,所以我们只需要再消除字符串和对象之间的差异就可以了

// src/state.js
function createWatcher(vm, key, handler) {
  let options = {}
  // 对象,获取回调和参数
  if(handler.constructor === Object) {
    options = handler
    handler = handler.handler
  }
  // 字符串,直接获取methods的方法
  if(typeof handler === 'string') {
    handler = vm[handler]
  }
  // user Watcher标识
  options.user = true
  // 创建watcher
  const watcher = new Watcher(vm, key, handler, options)
  // 立即执行
  if(options.immediate) {
    handler.call(vm, watcher.value)
  }
}

user Watcher

这时我们把key当作Watcher的第二个参数,因为watch的功能是监听某个项的变化,所以当我们初始化时,需要给对应项的Dep添加user Watcher,跟之前的概念一样,求值即可 改造一下watcher

// src/watcher.js
export class Watcher{
  constructor(vm, expOrFn, userCb, options = {}) {
    this.vm = vm
    this.id = uid++
    // computed Watcher和渲染Watcher的expOrFn都是函数
    // user Watcher是key,对key求值,给对应data的Dep添加user Watcher
    if(typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
    // user watcher标识
    this.user = options.user
    // 计算属性
    this.dirty = this.lazy = options.lazy
    this.userCb = userCb
    this.deps = []
    this.depIds = new Set()
    this.value = this.lazy ? undefined : this.get()
  }
}

// 可能是'a.b.c',在执行getter的时候会call(vm, vm),所以等于vm[a][b][c]
// 求值,走data的劫持的get,所以添加了user Watcher
function parsePath(path) {
  const arr = path.split('.')
  return (obj) => {
    for(let i=0;i<arr.length;i++) {
      obj = obj[arr[i]]
    }
    return obj
  }
}

实现一下deep参数,其实就是在get里对取到的值再不断取其子属性的值的过程(不断给其关联user Watcher)

// src/watcher.js
get() {
  pushTarget(this)
  const value = this.getter.call(this.vm, this.vm)
  if(this.deep) {
    traverse(value)
  }
  popTarget()
  return value
}
function traverse(val) {
  const depIds = new Set()
  // 递归
  function deep(value, seen) {
    // 不是对象或者数组直接return
    if(!Array.isArray(value) && value.constructor !== Object) {
      return 
    }
    // 是否是响应式,如果是添加dep.id,避免重复添加watcher
    if(value.__ob__) {
      const id = value.__ob__.dep.id
      if(seen.has(id)) {
        return 
      }
      seen.add(id)
    }
    // 数组或对象不断取值
    if(Array.isArray(value)) {
      let i = value.length
      while (i--) deep(value[i], seen)
    } else {
      let keys = Object.keys(value)
      let i = keys.length
      while (i--) deep(value[keys[i]], seen)
    }
  }
  deep(val, depIds)
}

最后我们处理什么时候执行回调函数,user Watcher update的时候会走异步队列,最后走到run真正求值的地方,然后判断新老值是否判断,有变化执行回调。

// 真正触发更新
  run() {
    // 老值
    const oldVal = this.value
    // 新值
    const newVal = this.get()
    this.value = newVal
    // 引用类型的新老值是相等的,指向同一个地址
    if(oldVal !== newVal || (newVal !== undefined && (Array.isArray(newVal) || newVal.constructor === Object))) {
      // user Watcher返回新老值
      if(this.user) {
        this.userCb.call(this.vm, newVal, oldVal)
      } else {
        this.userCb.call(this.vm)
      }
    }
  }

完成

参考资料

文章内容源码:github.com/chenerhong/…
掘金文章:juejin.cn/post/693534…
vue源码
vue文档:cn.vuejs.org/v2/guide/