B站手写mini-vue教程笔记

1,161 阅读10分钟

说明

这两天看了B站手写 mini-vue视频教程,然后记下了这篇学习笔记,所有的笔记和源码都放在了我的这个仓库下。教程总共分3个视频,所以我的大标题也有三个:P1,P2,P3。然后我又按过程推进分成了8个逐步递进的版本,相应的源码分别在v1~v88个文件夹内。B站的视频没有贴源码,如果你看到这篇文章想直接看源码,可以直接看v8文件夹内的源码。

P1 reactivity响应式实现

要实现响应式,我们不妨先看看最简陋的情景,然后循序渐进,逐步实现最终的响应式,先看v1版本的代码:

v1

let a = 10
let b = a + 10
console.log(b)

a = 20
b = a + 10
console.log(b)

简单的两个变量,当a变更时,命令式的通过一个赋值表达式更新b的值。确实很简陋对吧,现在升级下,把重复出现的赋值表达式封装成函数就有了v2版本。

v2

let a = 10
let b
function update() {
    b = a + 10
    console.log(b)
}

update()
b = 20
update()

好像依然简陋,能不能不要每次变量变更都手动调用update()呢?可以,我们用@vue/reactivity模块,看v3版本。

v3

const { reactive, effect } = require('@vue/reactivity')

let a = reactive({
  value: 10
})
let b
effect(() => {
  b = a.value + 10
  console.log(b)
})
a.value = 20

使用了@vue/reactivity模块的reactiveeffect方法,这样每次响应式数据发生变更时,都会自动执行副作用函数(相当于v2版本中的update方法)。接下来我们来自己实现这个响应式系统。

v4 初步实现Dep和effectWatch

Vue的响应式最核心的点就是收集依赖触发依赖。简单来说,当触发响应式数据的getter的时候收集依赖(这里可以简单理解为把v2版本中的update方法保存起来),当触发响应式数据的setter的时候触发依赖通知更新(这里可以简单理解为调用v2版本的update方法)。下面我们就来实现Dep类和副作用方法effectWatch

// 源码实现
export class Dep {
  constructor(val) {
    this.effects = new Set()
    this._val = val
  }

  get value() {
    this.depend()
    return this._val
  }

  set value (val) {
    this._val = val
    this.notice()
  }

  // 收集依赖
  depend() {
    if (currentEffect) {
      this.effects.add(currentEffect)
    }
  }

  // 触发依赖
  notice() {
    this.effects.forEach(effect => {
      effect()
    })
  }
}

let currentEffect
export function effectWatch(effect) {
  currentEffect = effect
  effect()
  currentEffect = null
}
// 测试用例
let dep = new Dep(10)
let b
effectWatch(() => {
  b = dep.value + 10
  console.log(b)
})
dep.value = 20

我们从测试用例代码看起:

  1. let dep = new Dep(10)这句代码创建了一个Dep实例dep。这相当于创建了一个响应式数据,此后dep.value触发getter即get value()方法收集依赖;dep.value =的赋值语句触发setter即set value()方法触发依赖通知更新。
  2. effectWatch(() => {})这句代码,先把内部的effect回调函数() => {}保存在全局变量currentEffect上;然后执行effect回调函数;然后再把currentEffect重置为null。这个先暂存再清空的操作,咋一看是不是有点脱裤子放屁的感觉?别急,原因在effect回调函数里。
  3. b = dep.value + 10这句代码中dep.value会触发get value()方法,get value()方法又接着调用depend()方法收集依赖,depend()方法体内判断全局的currentEffect为true(因为刚才我们已经把effect暂存到currentEffect上了),所以会把effect收集为依赖。当effect执行完毕,重新把currentEffect置为null,方便下一次依赖收集的条件判断。这就解释了第2步的困惑。
  4. 紧接着console.log(b)这句,算得结果,打印20
  5. dep.value = 20这句代码触发set value()方法,接着调用notice()方法,notice()方法内部遍历依赖effects执行其中的所有依赖,于是再次调用effect()打印30。注意,这次调用effect不是再次触发了get value()吗,不会造成重复收集依赖吗?这就是为什么我们使用Set数据结构来收集依赖,就是为了防止重复收集。

至此,调用过程分析清楚了,看起来很像Vue3的refAPI了,接下来我们实现reactiveAPI。

v5 实现reactive

// 源码实现
let targetMap = new Map()
export function reactive(raw) {
  // target是对象,key是对象的key
  // targetMap是以对象为key,depsMap为值的Map
  // depsMap是以对象的key为key,dep实例为值的Map
  const getDep = (target, key) => {
    // 先以对象为key获取targetMap上存储的depsMap
    let depsMap = targetMap.get(target)
    // 如果获取不到,需要初始化一个depsMap存储到targetMap上
    if (!targetMap.get(target)) {
      depsMap = new Map()
      targetMap.set(target, depsMap)
    }
    // 以对象的key为key,获取depsMap上存储的dep实例
    let dep = depsMap.get(key)
    // 如果获取不到,需要初始化dep实例存储到depsMap上
    if (!depsMap.get(key)) {
      dep = new Dep()
      depsMap.set(key, dep)
    }
    return dep
  }

  return new Proxy(raw, {
    get(target, key) {
      const dep = getDep(target, key)
      // 在getter中 收集依赖
      dep.depend()
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      const dep = getDep(target, key)
      const result = Reflect.set(target, key, value)
      // 在setter中 通知更新
      dep.notice()
      return result
    }
  })
}
// 测试用例
import { reactive, effectWatch } from './core/reactivity/index.js'

const user = reactive({
  age: 18
})
effectWatch(() => {
  const double = user.age * 2
  console.log(double)
})
user.age++

在vue3中reactive使用Proxy来代理对象实现响应式。首先明确代码中的几个概念:

  • target: 被代理的对象
  • key: 被代理的对象上的属性key
  • targetMap: 一个全局的Map数据结构,用来保存被代理对象上的依赖集合,其key是被代理的对象target,值是depsMap
  • depsMap: 一个Map数据结构,每个被代理的对象都有一个depsMap,用来保存其依赖dep实例,其key是对象的key,值是dep实例。

调用reactive()会返回一个被代理的响应式对象。当触发对象上某个key的getter时,先获取这个key对应的dep实例,然后调用dep.depend()收集依赖。当触发对象上某个key的setter时,先获取这个key对应的dep实例,然后调用dep.notice触发依赖通知更新。

上面**获取dep实例*的过程(getDep())是:先通过target获取到全局targetMap上存储的depsMap,如果没有,就先创建depsMap在存储。然后通过key获取到depsMap上保存的dep实例,如果没有就先创建dep实例再存储。

至此,reactive也基本分析完了。

P2 setup&render

v6 初步实现setup与render

到目前为止,我们实现的响应式都是通过console.log来打印数据,怎么实现视图与数据的绑定呢?接下来我们来实现vue3的setup与render。

<div id="#app"></div>
<script src="./index.js" type="module"></script>
// index.js
import App from './App.js'
import { createApp } from './core/index.js'
createApp(App).mount(document.getElementById('app'))
// App.js
import { reactive } from './core/reactivity/index.js'
export default {
  render(context) {
    const div = document.createElement('div')
    div.innerText = context.state.count
    return div
  },

  setup() {
    const state = reactive({
      count: 0
    })
    window.state = state
    return {
      state
    }
  }
}
// core/index.js
import { effectWatch } from './reactivity/index.js'

export function createApp(rootComponent) {
  return {
    mount(rootContainer) {
      const context = rootComponent.setup()
      effectWatch(() => {
        const element = rootComponent.render(context)
        rootContainer.innerHTML = ''
        rootContainer.appendChild(element)
      })
    }
  }
}

上面的代码已经和vue3的初始代码很相似了。首先是App.js中定义了setup和render,render函数可以看成是我们实际App.vue中的模板部分,先不考虑vdom的实现,我们这里直接简单的返回一个div,div的文本由setup函数返回的响应式数据提供上下文,这样App.render(App.setup())就返回了一个文本节点为响应式对象的div,后面我们会消费这个div。

接着我们来看createApp函数,与vue3的API保持一致,createApp(App)会返回一个有mount方法的对象,调用mount方法,会首先通过调用rootComponent.setup()获取到响应式对象上下文,然后调用rootComponent.render(context)获取到我们前面说过的创建的div,最后把div追加到根节点上。

当然上一步需要被包裹在effectWatch中才能触发依赖收集,并在响应式数据发生变更时更新视图。此时我们在浏览器控制台输入state.count++可以看到界面的数字会从0开始递增。

到目前为止我们简单地完成了响应式数据与视图的绑定,但是现在是直接销毁根容器内的dom并重新创建的,开销比较大,下面我们会实现vdom。

v7实现虚拟dom的h函数和mountElement函数

// /core/h.js
export function h(tag, props, children) {
  return {
    tag,
    props,
    children
  }
}
// /core/renderer/index.js
export function mountElement(vnode, container) {
  const { tag, props, children } = vnode

  // 根据tag创建element
  const el = document.createElement(tag)

  if (props) {
    // 如果有props,则遍历props,设置attribute
    for (const key in props) {
      const val = props[key]
      el.setAttribute(key, val)
    }
  }

  if (Array.isArray(children)) {
    // 1. 如果children是数组,则递归
    children.forEach(v => {
      mountElement(v, el)
    })
  } else {
    // 2. 否则,创建文本节点并插入
    const textNode = document.createTextNode(children)
    el.appendChild(textNode)
  }

  container.appendChild(el)
}
// App.js
// ...
render(context) {
  return h('div', null, context.state.count)
},
// ...
// /core/index.js
// ...
effectWatch(() => {
  rootContainer.innerHTML = ''
  const subTree = rootComponent.render(context)
  mountElement(subTree, rootContainer)
})
// ...

h函数接收三个参数,分别是标签名tag、属性集合对象props、子元素children,最终返回的vdom,其实就是一个对象,用对象来表示真实的对象。

这样render函数内最终返回的就是由h()函数生成的vdom,然后在createApp函数内,我们再调用mountElement函数把vdom生成真实的dom插入到根容器中,这个函数中需要注意的点是:如果children是一个数组,需要遍历这个数组,并递归调用mountElement来把vdom中的所有children都生成对应的真实dom。

v7实现虚拟dom的h函数和mountElement函数

// /core/h.js
export function h(tag, props, children) {
  return {
    tag,
    props,
    children
  }
}
// /core/renderer/index.js
export function mountElement(vnode, container) {
  const { tag, props, children } = vnode

  // 根据tag创建element
  const el = document.createElement(tag)

  if (props) {
    // 如果有props,则遍历props,设置attribute
    for (const key in props) {
      const val = props[key]
      el.setAttribute(key, val)
    }
  }

  if (Array.isArray(children)) {
    // 1. 如果children是数组,则递归
    children.forEach(v => {
      mountElement(v, el)
    })
  } else {
    // 2. 否则,创建文本节点并插入
    const textNode = document.createTextNode(children)
    el.appendChild(textNode)
  }

  container.appendChild(el)
}
// App.js
// ...
render(context) {
  return h('div', null, context.state.count)
},
// ...
// /core/index.js
// ...
effectWatch(() => {
  rootContainer.innerHTML = ''
  const subTree = rootComponent.render(context)
  mountElement(subTree, rootContainer)
})
// ...

h函数接收三个参数,分别是标签名tag、属性集合对象props、子元素children,最终返回的vdom,其实就是一个对象,用对象来表示真实的对象。

这样render函数内最终返回的就是由h()函数生成的vdom,然后在createApp函数内,我们再调用mountElement函数把vdom生成真实的dom插入到根容器中,这个函数中需要注意的点是:如果children是一个数组,需要遍历这个数组,并递归调用mountElement来把vdom中的所有children都生成对应的真实dom。

P3 虚拟dom&diff

v8 实现虚拟dom的diff算法

v7版本还有一个缺点,我们看core/index.js里的mountElement(subTree, rootContainer)这句代码。当数据发生变更时,rootContainer下的dom是被全量替换的,即使实际发生变更的可能只是其中某个节点或某个属性。在控制台Element面板中也可以看到整个节点销毁到生成一闪而过的现象。要解决这个问题就要用到diff算法了。

这个过程是:

  1. createAppmount()阶段,通过维护一个标志位来判断是否是初始化,是就执行mountElement(),不是就执行diff()。需要注意:不管是不是初始化,都把这一次的VNode保存下来,作为一下次diff()操作的老的VNode。
  2. 接着执行diff()函数,这个函数依然是从tag,props,children三个维度来判断。过程不想说了,都写在代码注释里了,代码也不想贴了,github里都有。

结语

本来写这篇笔记的目的是想写成一篇教程的,这从我分不同文件夹记录源码可以看出。后来发现,这个过程用文字表达出来太繁琐了,成本太高。而且我发现这个过程必须得自己去认真看了视频理解消化后,自己敲一遍才有可能真的掌握,所以这篇就全当是记了一篇笔记了。

最后是软广时间,mini-vue的作者现在推出了mini-vue的视频教程,这个教程会带着大家从环境搭建开始,一行行代码把整个仓库的代码都敲一遍,你最终也将可以手写一遍mini-vue。我报名了这个课程,感觉讲的很好。扫描下面的图片二维码可以了解课程详情。

微信图片_20220225140328.jpg