Skip to content

基本介绍

核心设计思想

ue3.0 注重模块上的拆分 Vue3 的模块之间耦合度低,模块可以独立使用。拆分模块 通过构建工具 Tree-shaking 机制实现按需引入,减少用户打包后体积。组合式 API Vue3 允许自定义渲染器,扩展能力强。扩展更方便 使用 RFC 来确保改动和设计都是经过 Vuejs 核心团队探讨并得到确认的。也让用户可以了解每 一个功能采用或废弃的前因后果。

整体架构

Monorepo 管理项目

Monorepo 是管理项目代码的一个方式,指在一个项目仓库(repo)中管理多个模块/包 (package)。Vue3 源码采用 monorepo 方式进行管理,将模块拆分到 package 目录中。作为一 个个包来管理,这样职责划分更加明确。 一个仓库可维护多个模块,不用到处找仓库 方便版本管理和依赖管理,模块之间的引用,调用都非常方便

Vue3 采用 Typescript

复杂的框架项目开发,使用类型语言非常有利于代码的维护,在编码期间就可以帮我们做类型检查,避免错误。所以 TS 已经是主流框架的标配~

Vue2 早期采用 Flow 来进行类型检测(Vue2 中对 TS 支持并不友好),Vue3 源码采 用 Typescript 来进行理写。同时 Vue2.7 也采用 TS 进行重写。TS 能对代码提供良好的 类型检查,同时也支持复杂的类型推导。

Vue3响应式原理

reactive

核心响应式模块 reactive(让数据变成响应式,可以检测数据的变化)、effect(数据变化后可以让 effect 执行更新视图,组件、watch、computed 都是基于 effect 来实现)

html
<div id="app"></div>

<script type="module">
import { reactive,effect }from"/node_modules/@vue/reactivity/dist/reactivity.esm-browser.js";
    
const state = reactive({name:"jw",age:30 });
// effect 会默认执行一次
effect(()=>{
	app.innerHTML=姓名${state.name}年龄${state.age};
);
setTimeout(()=>
	state.age++;//数据变化后effect会再次重新执行
1000)

reactive 实现

1.判断传入的参数是否为对象类型

js
// 统一做判断,响应式对象必须是对象才可以
if(!isobject(target)){
	return target;
}

2.使用 WeakMap 缓存代理后的结果,对同一数据代理多次返回的还是同一个代理对象

js
// 用于记录我们的代理后的结果,可以复用
const reactiveMap = new WeakMap();

const exitsProxy = reactiveMap.get(target);
if(exitsProxy)return exitsProxy;
                                   
let proxy = new Proxy(target,mutableHandlers);
// 根据对象缓存代理后的结果
reactiveMap.set(target,proxy)

3.在代理数据的 get 方法写入 IS_REACTIV 判断,用于返回当前数据是否被代理过(用于应对 reactive(reactive(obj)) 这种情况),防止多次代理

js
// 获取当前数据是否被代理过
if(target[ReactiveFlags.IS_REACTIVE]){
	return target;
}
// 用于应对如下情况
let obj = {name:“jw",age:30};
const state1 = reactive(obj);
//如果 state1 被代理过后一定有 get 和 set 了
const state2 = reactive(state1);
console.log(state1=== state2) // true

4.使用 Reflect 书写代理对象更改逻辑

js
const person = {
    name: 'jw',
    get aliasName() {
        console.log(this) // 未使用 Reflect 时 这里的 this 指向 person 而不是 proxyPerson
        return this.name + 'handsome'
    }
}

let proxyPerson = new Proxy(person, {
    get(target, key, recevier) {
        // return target[key]
        // return recevier[key] // 报错 Maximum call stack size exceeded
        return Reflect.get(target, key, recevier)
    }
})
console.log(proxyPerson.aliasName)

5.实现 effect。定义一个全局变量用于保存当前执行的 effect ,并在执行 effect 的 回调函数(effect 的参数)时设置当前执行的 effect,effect 默认会自动执行一次

6.创建一个全局的 WeakMap(一种数据结构,可以防止内存泄漏。也就是 dep 依赖映射表)根据代理对象存放对应属性,在对应属性新建一个 Map 存放涉及该属性的 effect

7.进行依赖收集。这时就可以在代理对象的 get 方法里拿到代理对象和对应的属性以及与之对应的 effect (上一步通过保存的全局变量获取)进行依赖收集。

8.优化依赖收集。在每个 effect 中定义一个 _trackId 属性用于记录执行次数,对于多次出现的响应式数据保证只收集一次。

拿到上一次的依赖的与这次比较,如果不一致则会删掉上一次的依赖(尽量复用)替换成新的依赖,避免出现不需要触发 effect 执行的依赖却触发了 effect 重新执行

js
// 依赖收集时会记录在此 effect 使用用的响应式数据(即 state.flag 与 state.name),每个响应式数据发生变化时都会重新执行 effect
effect(()=>{
    // 这里出现了两次 state.flag 需要优化只需要收集一次 state.flag 依赖即可
	app.innerHTML = state.flag + state.flag + state.name;
)

effect(() =>
    // 这里 state.name 和 state.age 只使用了其一,收集依赖时也应该收集实际使用到的依赖
	app.innerHTML = state.flag ? state.name: state.age;
);

9.当监听到代理对象变化时会触发代理对象的 set 方法,这里就可以拿到对应的代理对象和键以及老值与新值,然后根据上一步保存的 dep 中查找对应代理对象的对应属性,再将对应属性身上保存的所有 effect 依次调用一下即可完成页面更新

10.进行一些优化。

(1.在 effect 中定义一个变量用于记录当前 effect 是否正在执行,然后在执行 effect 之前判断 当前 effect 是否正在执行,如果是则不执行。

(2.解决深层次数据变化时 effect 不执行。在 proxy 代理对象上获取值时会触发代理对象的 get 方法,此时判断该值是否为基础数据类型,如果不是则将该值用 reactive 方法递归代理(即懒代理,当取用的值不为基础数据类型时,会深层代理)

js
effect(()=>{
    // 当修改页面后又再次修改响应式数据,会导致死循环
    app.innerHTML = state.name;
    state.name = Math.random();
})


// 如果只代理响应式数据的第一层则深层数据变化时并不会触发 effect 执行
effect(()=>{
	app.innerHTML = state.address.n;
});

setTimeout(()=>{
	state.address.n= 602
},1000)

ref 实现

1.创建一个 RefImpl 类用于创建 ref 的响应式数据。

1)在类中定义一个 __v_isRef 属性用于区分某个数据是否是 ref 创建的响应式数据。

2)再定义一个 _value 属性用于保存用户传递进来的数据,重写类的 get 和 set 方法(set方法中会去重,即修改的最新值与保存的值不一致时才会修改 ref 的值)

3)如果传入的值不为基础数据类型时,会调用 reactive 方法包裹实现响应式

2.依赖收集。定义一个 dep 属性用于收集依赖,在 get 方法中将当前 effect 与 ref 值保存到 dep 中,然后在 set 方法拿出 dep 中的依赖 effect 依次执行

ts
class RefImpl {
  public _v_isRef = true // 增加ref标识
  public _value // 用来保存ref的值的
  public dep // 用于收集对应的effect
  constructor(public rawValue) {
    // 如果传入的值不为基础数据类型时,会调用 reactive 方法包裹实现响应式
    this._value = toReactive(rawValue);
  }
  get value() {
    // 收集依赖
    trackRefValue(this)
    return this._value
  }
  set value(newValue) {
    if (newValue !== this.rawValue) {
      this.rawValue = newValue // 更新值
      this._value = newValue
      // 拿出 dep 中的依赖 effect 依次执行
      triggerRefValue()
    }
  }
}

toRefs、proxyRef 实现

toRef、toRefs 实现

功能:将一个 reactive 数据的某个或多个值变成 Ref 数据

与 ref 实现较为类似

ts
// 以下为简化代码
class ObjectRefImpl {
  public _v_isRef = true // 增加ref标识
  constructor(public _object, public _key) {}
  get value() {
    return this._object[this._key]
  }
  set value(newWalue) {
    this._object[this._key] = newWalue
  }
}

export function toRef(object, key) {
  return new ObjectRefImpl(object, key)
}

// toRefs 实际上就是循环调用 toRef
export function toRefs(object) {
  const res = {}
  // 这里简化了代码,只考虑对象类型
  for (let key in object) {
    res[key] = toRef(object, key)
  }
  return res
}

proxyRef 实现

proxyRefs 用于应对 ref 数据自动解包,开发中很少用,是 Vue 内部在模板中将 ref 自动解包使用,即 ref 数据在模板中无需 .value

ts
let state = reactive({name:"jw",age: 30 });
let proxy = proxyRefs({...toRefs(state)});

effect(()=>{
    // 无需 .value 即可访问数据,相当于 proxy.name.value,proxy.sge.value 
	console.log(proxy.name,proxy.sge); // jw 30
})

用 Proxy 代理并在 get 方法中判断是否是 ref 数据,如果是则自动解包(即 .value)

ts
// 简单实现
export function proxyRefs(objectwithRef) {
  return new Proxy(objectwithRef, {
    get(target, key, receiver) {
      let r = Reflect.get(target, key, receiver)
      return r._v_isRef ? r.value : r //自动脱ref
    },
    set(target, key, value, receiver) {
      const oldValue = target[key]
      if (oldValue._v_isRef) {
        oldValue.value = value // 如果老值是ref需要给ref赋值
        return true
      } else {
        return Reflect.set(target, key, value, receiver)
      }
    }
  })
}

计算属性实现

computed 执行后的结果是一个 ref 。

计算属性具备收集能力的,可以收集对应的 effect,依依赖的值变化后会触发 effect 重新执行。

1.计算属性维护了一个 dirty 属性(表示是否执行过),默认就是 true,稍后运行过一次会将 dirty 变为 false,并且稍后依赖的值变化后会再次让 dirty 变为 true 2.计算属性也是一个 effect,依赖的属性会收集这个计算属性,当前值变化后,会让 computedEffect 里面dirty 变为 true

具体实现

1.定义两个变量 Dirty 与 NoDirty 。Dirty 是一个数字类型,表示脏值,意味着取值要运行计算属性。NoDirty 也是一个数字类型,表示不脏就用上一次的返回结果。并在 effect 属性上添加一个属性 _dirtyLevel 表示脏值。

ts
export enum DirtyLevels{
    Dirty = 4// 脏值,意味着取值要运行计算属性
    NoDirty = 0// 不脏就用上-次的返回结果
}

2.在运行 effect 时会将该 effect 的 _dirtyLevel 置为 0 表示不脏(便于实现 computed 缓存功能)。

3.定义一个方法 computed ,判断参数是否为函数(computed 参数可直接传递一个函数也可以传递 getter 和 setter)最后返回一个 ComputedRefImpl 类

ts
export function computed(getteroroptions) {
  let onlyGetter = isFunction(getteroroptions)
  let getter
  let setter
  if (onlyGetter) {
    getter = getteroroptions
    setter = () => {}
  } else {
    getter = getteroroptions.get
    setter = getteroroptions.set
  }
  return new ComputedRefImpl(getter,setter);// 计算属性 ref
}

4.在 ComputedRefImpl 类中通过 ReactiveEffect 类(创建 effect 的类)创建一个关联的 effect,再通过定义一个属性 _value 记录老值,并重写 getter (获取 computed 值时执行 effect) 和 setter。

5.依赖收集。定义一个 dep 属性用于将 computed 与相关的 effect 关联(依赖收集),然后在获取值时将对应 effect 存入 dep 中。循环取出 dep 中的 effect 依次执行并将该 effect 变脏(即用户修改了 computed 依赖的值时变脏,需要重新执行)。

ts
class ComputedRefImpl {
  // 记录当前 computed 的老值
  public _value
  // 关联当前 computed 对应的 effect
  public effect
  // 用于收集依赖
  public dep
  constructor(getter, public setter) {
    // 我们需要创建一个 effect 来关联当前计算属性的 dirty 属性
    this.effect = new ReactiveEffect(
      () => getter(this._value),
      () => {
    // 计算属性依赖的值变化了,我们应该触发渲染。即循环取出 dep 中的 effect 依次执行并将该 effect 变脏
      }
    )
  }
  get value() {
    // 只有当前 effect 是脏的时候才会重新执行,否则直接返回老值
    ifthis.effect.dirty){
    	// 默认取值一定是脏的,但是执行一次 run 后就不脏了
    	this._value = this.effect.run();
        // 在此进行依赖收集,将对应 effect 存入 dep 中
    }
    // 直接返回老值
    return this._value;
  }
  set value(v) {
    //这个就是ref的setter
    this.setter(v)
  }
}

watch

可以监听数据的变化。

watch 实现

1.定义一个 watch 方法,返回 dowatch 方法

ts
export function watch(sourcecb,options = {}){
    // watchEffect 也是基于 dowatch 来实现的
    return dowatch(source,cb,options);
}

2.先根据 deep (用户传递的是否深度监听参数)递归遍历 source 中的每一个数据。遍历时就会触发每个属性的 getter

ts
// depth 选项可能还处在测试阶段,在后续版本才可使用
// depth 表示深度监听到第几层,currentDepth 表示当前已经遍历的层数,每遍历一个数据就会往 seen 中保存一份,如果下次再遇到相同的直接返回保存的值,防止递归查询死循环(即一个对象中的值又指向对象本身)
function traverse(source, depth, currentDepth = 0, seen = new Set()) {
  if (!isobject(source)) {
    return source
  }
  if (depth) {
    if (currentDepth >= depth) {
      return source
    }
    currentDepth++ //根据deep属性来看是否是深度
  }

  if (seen.has(source)) {
    return source
  }
  seen.set(source)
  for (let key in source) {
    traverse(source[key], depth, currentDepth, seen)
  }
    return source
}

function dowatch(source, cb, options) {
    const reactiveGetter = source => traverse(source,options.deep === false?1:undefined)
}

3.根据上一步遍历的数据产生一个 getter 并传递给 ReactiveEffect (之前实现的类,只要访问监听的值就会重新执行回调),这样在遍历用户数据时就相当于是依赖收集了(递归遍历时就已经访问了所有数据),只要数据发生变化就会重新执行回调。

ts
function dowatch(source, cb, options) {
  // 递归遍历 source 中的每一个数据
  const reactiveGetter = source => traverse(source,options.deep === false?1:undefined)
  // 判断当前数据是否是响应式数据,是的话才会做监听
  let getter
  if(isReactive(source)){
     //产生一个可以给ReactiveEffect来使用的getter,需要对这个对象进行取值操作,会关联当前 reactiveEffect
  	getter = () => reactiveGetter(source)
  }else if(isRef(source)){
    // 如果是 ref 的值会将其转换为 getter 并解包以实现如下写法
    getter = () => source.value
  }else if(isFunction(source)){
    // 如果是函数类型直接赋值
    getter = source
  }
  // 保存老值
  let oldValue
  function job(){
      if(cb){
          // 当数据变化后会执行此回调,拿到新值并保存
          const newValue = effect.run();
          // 调用用户传递的回调并将新老值传递过去
          cb(newValue,oldvalue)
          oldValue = newValue
      }else{
		// watchEffect 实现逻辑
      }
  }
  const effect = new ReactiveEffect(getter, job)
  if(cb){
      // 用户如果传递了 inmediate 属性则立即执行一次。
      if(options.inmediate){
          job()
      }else{
          // 保存老值
          oldValue = effect.run()
      }
  }else{
      // watchEffect 实现逻辑
  }
}

4.其他一些补充

ts
if(isReactive(source)){
 	// 代码...
}else if(isRef(source)){
    // 如果是 ref 的值会将其转换为 getter 并解包
    getter = () => source.value
}

// 上面判断是否是 ref 的的逻辑是为了实现如下写法。即实现对 ref 数据的监听
const name = ref('xxx')
watch(name, (newVal, oldVal) => {
  console.log('1')
}
      
      
if(isReactive(source)){
    // 代码...
}else if(isRef(source)){
    // 代码...
}else if(isFunction(source)){
    // 如果是函数类型直接赋值
    getter = source
}

// 上面判断是否是函数的的逻辑是为了实现如下写法。即实现对 reactive 中某个值单独的监听
const state = reactive({age:30})
watch(()=>state,age, (newVal, oldVal) => {
  console.log('1')
}

watchEffect

watchEffect 就类似于一个 reactiveEffect 只要依赖的数据变化就重新执行

ts
const state = reactive({name:"jw",age:30,address:{n:1}})
watchEffect(()=>{
	console.log(state.name + state.age);
});

1.定义一个 watchEffect 方法,返回 dowatch 方法

ts
export function watchEffect(sourceoptions = {}){
    // 没有 watch 中的 cb(用户传递的回调函数)就是 watchEffect
    return dowatch(source,null,options);
}

2.在上一步实现 watch 的 inmediate 属性逻辑判断没有 cb 时,直接执行 effect 即可

ts
const effect = new ReactiveEffect(getter, job)
if(cb){
    // watch API 相关代码......
}else{
    effect.run() // 直接执行即可
}

3.在上一步实现 watch 的 job 方法逻辑判断没有 cb 时,直接执行 effect 即可

ts
const effect = new ReactiveEffect(getter, job)
function job(){
    if(cb){
        // watch API 相关代码......
    }else{
        effect.run() // 直接执行即可
    }
}

渲染器

使用

vue 中提供了两种渲染器(如何渲染虚拟DOM),render(借助 DOM API 渲染,vue 内置渲染器)、createRenderer(自定义渲染)。此功能在 vue 源码中的 runtime-dom 包中,主要提供一些浏览器端 API

ts
import{ createRenderer,render,h }from"/node_modules/@vue/runtime-dom/dist/runtime-dom.esm-browser.js";

// 使用内置渲染器

// 创建一个虚拟节点 h1 元素文本为 handsome jw
let ele = h("h1","handsome jw");
// 表示将此虚拟节点变为真实节点插入到 app 这个容器中
render(ele,app);



// 使用自定义渲染器。如 uniapp 开发小程序

let ele = h("h1","handsome jw");
const renderer = createRenderer({
    // 自定义如何创建元素
    createElement(type){
        return document.createElement("h1")
    },
    // 如何处理文本元素
    setElementText(eltext){
    	el.textContent = text;
    },
    // 如何插入节点
    insert(elcontainer){
    	container.appendChild(el);
    }
)}
// 将此虚拟节点变为真实节点插入到 app 这个容器中
renderer.render(ele,app);

具体实现

1.实现 nodeOps。实现对节点得增删改等操作。

ts
export const nodeops = {
    // 如果第三个元素不传递则等价于 appendChild
    insert(el, parent, anchor) {
        //appendchild parent.insertBefore(el,null)
        parent.insertBefore(el, anchor || null)
    },
    remove(el) {
        //移除dom元素
        const parent = el.parentNode;
        if(parent){
            parent.removeChild(el);
        }
    },
    createElement(type) {
        return document.createElement(type)
    },
    setElementText(el, text) {
        el.textContent = text
    }
}

2.实现 patchProp 。实现对节点属性的(class、style、事件、class、普通属性)操作

ts
export function patchProp(el, key, prevValue, nextValue) {
  // 处理 class
  if (key === 'class') return patchclass(el, nextValue)
  // 处理 style
  if (key === 'style') return patchStyle(el, prevValue, nextValue)
  // 处理事件以 on 开头,如 onClick
  if (/on[^a-z]/.test(key)) return patchEvent(el, key, nextValue)
  // 处理普通属性
  return patchAttr(el, key, nextValue)
}

// 处理 class
function patchclass(el, value) {
  if (value == null) {
    // 移除class
    el.removeAttribute('class')
  } else {
    el.className = value
  }
}

// 处理 style
function patchStyle(el, prevValue, nextvalue) {
  let style = el.style
  for (let key in nextvalue) {
    style[key] = nextvalue[key] // 新样式要全部生效
    if (prevValue) {
      for (let key in nextvalue) {
        // 看以前的属性,现在有没有,如果没有删除掉
        if (nextvalue && nextvalue[key] == null) style[key] = null
      }
    }
  }
}

// 处理事件
function patchEvent(el, name, nextValue) {
  // 将用户绑定的事件回调保存起来,下次回调更新后无需解绑事件再绑定,直接修改 .value 属性保存的函数即可
  function createInvoker(value) {
    const invoker = e => invoker.value(e)
    // 更改 invoker 中的 value 属性可以修改对应的调用函数
    invoker.value = value
    return invoker
  }
  // 给当前元素绑定缓存区,用于记录绑定过哪些事件
  const invokers = el._vei || (el._vei = {})
  const exisitingInvokers = invokers[name]
  const eventName = name.slice(2).toLowerCase()
  if (nextValue & exisitingInvokers) {
    // 事件换绑定 当缓存中存在同名事件如:之前是 onClick = fn1,更新后是 onClick = fn2 直接替换用户回调即可
    return (exisitingInvokers.value = nextValue)
  }
  if (nextValue) {
    // 创建一个调用函数,并且内部会执行 nextValue,并将用户回调缓存
    const invoker = (invokers[name] = createInvoker(nextValue))
    // 绑定事件
    return el.addEventListener(eventName, invoker)
  }
  if (exisitingInvokers) {
    // 现在没有,以前有
    el.removeEventListener(eventName, exisitingInvokers)
    invokers[name] = undefined
  }
}

// 处理普通属性
function patchAttr(el, key, value) {
  if (value === null) {
    el.removeAttribute(key)
  } else {
    el.setAttribute(key, value)
  }
}

3.将前两步组装的选项合并变为最终传递给渲染器的选项

ts
let ele = h("h1","handsome jw");
// 组装选项
const renderer = createRenderer(Object.assign({patchProp},node0ps))
renderer.render(ele,app);

虚拟DOM渲染

vue 内部就是按照上一个大步骤创建渲染器并渲染的。

ts
const render =(vnode,container)=> {
    // render0ptions 是上一个大步骤中的组装的选项 vnode 表示虚拟 DOM container 表示要将 vnode 渲染的容器
	return createRenderer((render0ptions).render(vnode,container);
}

// 用户使用代码
// 第一次的 vdom
let ele1 = h(
"h1",
{style:{color:'red'}},
"handsome jw"
)

// 第二次的 vdom
let ele2 = h(
"h2",
{style:{color:'#000'}},
"handsome jw"
)

render(ele1,app);

// 更新
setTimeout(()=>{
	render(ele2,app);
},3000);

实现

1.创建 createRenderer 方法,将外部传入的渲染选项重命名并返回一个 render 函数,此函数会采用外部传递进来的 API 将 vnode 渲染到指定容器中。此功能在 vue 源码中的 runtime-core 包中,此包完全不关心渲染时用的是哪些 API ,可以跨平台

ts
export function createRenderer({
        insert: hostInsert,
        remove: hostRemove,
        createElement: hostCreateElement,
        createText: hostCreateText,
        setText: hostSetText,
        setElementText: hostSetElementText,
        parentNode: hostParentNode,
        nextSibling: hostNextSibling,
        patchProp: hostPatchProp
    }) {
    const render = (vnode, container) => {
        // 这里会采用上面的 API 将 vnode 渲染到 container 中
        // ...实现逻辑...
    }
    return {
        render
    }
}

2.创建 patch 方法用于渲染和更新 vnode,并保存此次的 vnode 方便后续更新操作时比对差异

ts
export function createRenderer(renderoptions) {
  // ...其他逻辑...
    
  // 初次渲染走这里,更新也走这里
  const patch = (n1, n2, container) => {}

  // 多次调用 render 会进行虚拟节点的比较,再进行更新
  const render = (vnode, container) => {
    // 第一个参数表示旧的 vnode 第二个表示最新的 vnode
    patch(container._vnode || null, vnode, container)
    // 保存此次的 vnode 方便后续更新操作时比对差异
    container._vnode = vnode
  }
  return {
    render
  }
}

3.创建 patch 方法,多次调用时过滤相同的 vnode ,如果没有旧的 vnode 就执行初次渲染

ts
const patch = (n1, n2, container) => {
    // 两次渲染同一个元素直接跳过即可
    if (n1 === n2) return
    if (n1 === null) {
        // 初始化操作
        mountElement(n2, container)
    }
}

4.实现 mountElement 方法,根据外部提供的 API 将虚拟节点变为真实节点并插入到页面中

ts
const mountElement = (vnode, container) => {
    const mountChildren = (children, container) => {
        for (let i = 0; i < children.length; i++) {
            patch(null, children[i], container)
        }
    }
   // type:当前节点的类型,如 h1、div等,children:此节点的子节点,可能是文本,props:此节点上的属性,shapeFlas:子节点的类型,是一个数字,通过位运算得出
    const { type, children, props, shapeFla } = vnode
    // 根据类型创建真实元素
    let el = hostCreateElement(type)
    // 当此元素有属性时,循环所有属性并依次添加到元素上
    if (props) {
        for (let key in props) {
            hostPatchProp(el, key, null, props[key])
        }
    }
    // 判断子节点是否是单个文本
    if (shapeFla & ShapeFlags.TEXT_CHILDREN) {
        // 创建文本节点并插入
        hostSetElementText(el, children)
        // 当子节点有多个时递归遍历依次创建
    }else if(shapeFla & ShapeFlags.ARRAY_CHILDREN){
        mountElement(children,el)
    }
    // 将此元素插入到指定容器中
    hostInsert(el, container)
}

h 方法

h 方法是一个用于创建虚拟节点的函数,它是创建渲染函数的核心工具。用法如下

ts
// 1.两个参数第二个参数可能是属性,或者虚拟节点(v_isVnode 有此属性表示是虚拟节点)
const ele1 = h("div",{a:1}) // 表示创建一个 div 节点,节点属性为 a 其值为 1
const ele1 = h("div"h("a")) // 表示创建一个 div 节点,再为其创建一个子节点 a 标签
// 2.第二个参数是一个数组表示儿子
const ele1 = h("div",["a","b"]) // 表示创建一个 div 节点,其子节点为 a 和 b(都是文本)
// 3.其他情况就是属性
// 4.直接传递非对象的,文本
const ele1 = h("div"'abc') // 表示创建一个 div 节点,再为其创建一个子节点 abc(文本)
// 5.不能出现三个参数的时候第二个只能是属性
const ele1 = h("div",{a:1},'abc') // 表示创建一个 div 节点,节点属性为 a 其值为 1,再为其创建一个子节点 abc(文本)
// 6.如果超过三个参数,后面的都是儿子
const ele1 = h("div",{a:1},"a""b""c""d")// 表示创建一个 div 节点,节点属性为 a 其值为 1,再为其创建一个子节点 abcd (文本)

实现

1.创建一个 h 函数,分别根据参数的个数进行不同的创建方式对应虚拟节点。写法比较随意。

ts
export function h(type, propsorchildren?, children?) {
  let l = arguments.length
  if (l === 2) {
    //h(h1,虚拟节点|属性)
    if (isobject(propsorChildren) && !Array.isArray(propsorChildren)) {
      // 判断当前节点是否是虚拟节点,用 _v_isVnode 判断
      // h("div",h('a'))
      if (isVnode(propsorChildren)) return createVnode(type, null, [propsorchildren])
      // 属性
      return createVnode(type, propsorchildren)
    }
    // 儿子 是数组丨文本
    return createVnode(type, null, propsorchildren)
  } else {
    if (l > 3) {
      // 直接截取后面的参数作为子节点
      children = Array.from(arguments).slice(2)
    }
    if (l == 3 && isVnode(children)) {
      // 如果参数为 3 直接将 children 包装成数组作为子节点
      children = [children]
    }
    // 参数为 3 或 1 的情况
    return createVnode(type, propsorchildren, children)
  }
}

2.实现 createVnode 方法,返回虚拟节点,用法严格。vue 为了处理后面的优化,源码中编译后的结果全部采用了createVnode,靶向更新。至此就完成了页面初次渲染。

ts
function createVnode(type, props, children?) {
  // 判断当前节点类型是否是字符串,是的话就创建对应标签的虚拟节点,否则置为 0
  const shapeFlag = isString(type) ? ShapeFlags.ELEMENT : 0
  const vnode = {
    _v_isVnode: true, // 标识是否是虚拟节点
    type, // 节点类型
    props, // 属性
    children, // 子节点
    key: props?.key, //diff算法后面需要的key
    el: null, //虚拟节点需要对应的真实节点是谁
    shapeFlag
  }
  if (children) {
    if (Array.isArray(children)) {
      vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN
    } else {
      children = String(children)
      vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN
    }
  }
  return vnode
}

3.当页面更新后,需要对比节点的变化。

ts
let vnode1 = h('h1‘,{key:1},’hello')
let vnode2 = h('div', { style: { color: 'red' },key:2 }, 'world')

// 初次渲染
render(vnode1, app)

// 两个节点之间是有diff的看两个元素是否是同一个元素,如果是则可以复用
setTimeout(() => {
    // 更新
    render(vnode2, app)
}, 1000)

4.需要修改前几步中 render 方法中的 patch 方法,当新旧节点不同时直接删除旧节点,重新挂载新节点。

ts
// 判断当前新旧节点是否相同,主要是判断节点的 type(h1、div 等) 和 key 是否相同
function isSameVnode(n1,n2){
    return n1.type == n2.type && n1.key === n2.key
}

const patch = (n1, n2, container) => {
    // 其他逻辑代码......
    
    // 判断当前新旧节点是否相同
    if(n1 &!isSameVnode(n1,n2)){
        /*
        当新旧节点如下时
            let vnode1 = h('h1‘,{key:1},’hello')
            let vnode2 = h('div', { style: { color: 'red' },key:2 }, 'world')
        直接删除老的节点,重新渲染新节点即可
        */
        unmount(n1); // 删除老的节点
        n1 = null// 就会执行后续的 n2 的初始化
    }
    
    // 其他逻辑代码......
}

5.当新旧节点相同,属性不同时,需要比对差异进行更新

ts
const patch = (n1, n2, container) => {
    // 其他逻辑代码......
    
    // 判断当前新旧节点是否相同
    if(n1 &!isSameVnode(n1,n2)){
        // 其他逻辑代码......
    }
    if(n1 === null){
        // 其他逻辑代码......
    }else{
      /*
         当新旧节点相同,属性不同时
            let vnode1 = h('h1‘,{key:1},’hello')
            let vnode2 = h('h1', { style: { color: 'red' },key:2 }, 'world')
       */
        patchElement(n1,n2,container)
    }
    
    // 其他逻辑代码......
}

function patchElement(n1,n2,container){
    // 将老节点的 dom 赋值给新节点,并将此节点缓存
    let el = n2.el = n1.el;
    // 取出新旧节点的属性
    let oldProps = n1.props || {}
    let newProps = n2.props || {};
    // 循环处理节点上的所有属性
    patchProps(oldProps,newProps,el)
    // 处理节点上的子节点
    patchchildren(n1,n2,container)
}

function patchProps(oldProps,newProps,el){
    // 循环处理新节点上的所有属性,让其全部生效
    for (let key in newProps){
        // 对某一条属性进行更新操作
        hostPatchProp(el,key,oldProps[key],newProps[key])
    }
    // 循环处理老节点上的所有属性
    for (let key in oldProps){
        // 此属性如果在新节点没有则需要删除
        if(!(key in newProps)){
            hostPatchProp(el,key,oldProps[key],null)
        }
    }
}

6.比对节点的子节点差异,实现除新旧子节点都是数组(多个子节点,需要 diff) 情况外的渲染

子元素比较情况

新儿子旧儿子操作方式
文本数组删除老儿子,设置文本内容
文本文本更新文本即可
文本更新文本即可,与上面的类似
数组数组diff 算法
数组文本清空文本,进行挂载
数组进行挂载,与上面的类似
数组删除所有儿子
文本清空文本
无需处理
ts
function patchElement(n1,n2,container){
    // 其他逻辑......
    
    // 处理节点上的子节点
    patchchildren(n1,n2,container)
}

function patchchildren(n1,n2,el){
	// 有三种情况 儿子是文本、数组、null(没有子节点)
    const c1= n1.children;
    const c2= n2.children;
    // 旧节点的节点类型(文本、元素等)
    const prevShapeFlag = n1.shapeFlag;
    // 新节点的节点类型
    const shapeFlag = n2.shapeFlag;
    // 新的是文本,老的是数组
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
        if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
            // 循环老的子节点并将全部其删除
            unmountChildren(c1)
        }
        // 如果新的是文本,老的也是文本,直接替换成新的文本
        if (c1 !== c2) {
            hostSetElementText(el, c2)
        }
    } else {
        // 老的是数组,新的也是数组
        if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
            if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
                // 全量 diff 算法两个数组的比对
                // 放到下一个步骤实现
            } else {
                // 老的是数组,新的不是数组,循环老的子节点并将全部其删除
                unmountChildren(c1);
            }
        } else {
            // 老的是文本,新的是空
            if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
                // 直接删除文本即可
                hostSetElementText(el, '')
            }
            // 老的是文本,新的是数组,直接将数组元素依次挂载到容器中
            if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
                mountChildren(c2, el)
            }
        }
    }
}

diff 算法

1.紧接上一步当新旧节点都是数组(多个节点),就需要 diff 算法来计算差异了。

ts
function patchchildren(n1,n2,el){
	// 其他逻辑代码......
    const c1 = n1.children
    const c2 = n2.children
    // 老的是数组,新的也是数组
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
            // 全量 diff 算法两个数组的比对
            patchKeyedChildren(c1,c2,el)
        }
        // 其他逻辑代码......
    }
}

2.实现 patchKeyedChildren 方法。先从头开始比,再从尾部开始比,同层比较(新旧节点同一层级)

ts
// 1.减少比对范围,从头开始比,确定新旧子节点不一样的范围
function patchKeyedChildren(n1,n2,el){
    let i = 0 // 开始比对的索引
    let e1 = c1.length - 1 // 第一个数组的尾部索引
    let e2 = c2.length - 1 // 第二个数组的尾部索引
    while (i <= e1 && i <= e2) {
        // 有任何一方循环结束了就要终止比较
        const n1 = c1[i]
        const n2 = c2[i]
        if (isSameVnode(n1, n2)) {
            patch(n1, n2, el) // 更新当前节点的属性和儿子(递归比较子节点)
        } else {
            break
        }
        i++
    }
}

// 用户代码
// 旧节点
let vnode1 = h('h1', [
  h('div', { key: 'a' }, 'a'),
  h('div', { key: 'b' }, 'b'),
  h('div', { key: 'c' }, 'c')
])
// 新节点
let vnode2 = h('h1', [
  h('div', { key: 'a' ,style:{color:'red'}}, 'a'),
  h('div', { key: 'b' }, 'b'),
  h('div', { key: 'd' }, 'd'),
  h('div', { key: 'e' }, 'e')
])

3.从尾部开始对比

ts
// 用户代码(尾部有相同节点)
// 旧节点
let vnode1 = h('h1', [
  h('div', { key: 'a' }, 'a'),
  h('div', { key: 'b' }, 'b'),
  h('div', { key: 'c' }, 'c')
])
// 新节点
let vnode2 = h('h1', [
  h('div', { key: 'd' ,style:{color:'red'}}, 'd'),
  h('div', { key: 'e' }, 'e'),
  h('div', { key: 'b' }, 'b'),
  h('div', { key: 'c' }, 'c')
])
ts
function patchKeyedChildren(n1,n2,el){
    let i = 0 // 开始比对的索引
    let e1 = c1.length - 1 // 第一个数组的尾部索引
    let e2 = c2.length - 1 // 第二个数组的尾部索引

    while (i <= e1 && i <= e2) {
        // 上一步从头开始比较逻辑代码......
    }
    
    // 从尾部开始比较
    while (i <= e1 && i <= e2) {
        const n1 = c1[e1]
        const n2 = c2[e2]
        if (isSameVnode(n1, n2)) {
            patch(n1, n2, el) // 更新当前节点的属性和儿子(递归比较子节点)
        } else {
            break
        }
        e1--
        e2--
    }
}

4.比对不变的节点。新节点比老节点多,依次从尾部开始插入新节点

ts
function patchKeyedChildren(n1, n2, el) {
    let i = 0 // 开始比对的索引
    let e1 = c1.length - 1 // 第一个数组的尾部索引
    let e2 = c2.length - 1 // 第二个数组的尾部索引
    while (i <= e1 && i <= e2) {
        // 上一步从头开始比较逻辑代码......
    }

    // 从尾部开始比较
    while (i <= e1 && i <= e2) {
        // 上一步从尾部开始比较逻辑代码......
    }
    // 新的节点多
    if (i > el) {
        // 有插入的部分
        if (i <= e2) {
            // 看一下当前下-个元素是否存在
            letnextPos = e2 + 1
            let anchor = c2[nextPos]?.el
            // 有的话,从尾部开始插入新节点
            while (i <= e2) patch(null, c2[i], el, anchor)
            i++
        }
    }
}

5.比对不变的节点。老节点比新节点多,将元素一个个删除

ts
function patchKeyedChildren(n1, n2, el) {
  let i = 0 // 开始比对的索引
  let e1 = c1.length - 1 // 第一个数组的尾部索引
  let e2 = c2.length - 1 // 第二个数组的尾部索引
  while (i <= e1 && i <= e2) {
    // 上一步从头开始比较逻辑代码......
  }

  // 从尾部开始比较
  while (i <= e1 && i <= e2) {
    // 上一步从尾部开始比较逻辑代码......
  }

  if (i > el) {
    // 新的节点多处理逻辑代码......
  } else if (i > e2) {
    // 旧的节点多处理逻辑代码......
    if (i <= e1) {
      while (i <= e1) unmount(c1[i]) // 将元素一个个删除
      i++
    }
  }
}

6.乱序比对。拿老的节点到新的节点中寻找,有就更新或复用没有就删除,最后还有剩余的新节点就新增。

以上步骤确认不变化的节点,并对这部分进行移除或插入。这步将对比新老节点发生变化时做处理。

ts
function patchKeyedChildren(n1, n2, el) {
    let i = 0 // 开始比对的索引
    let e1 = c1.length - 1 // 第一个数组的尾部索引
    let e2 = c2.length - 1 // 第二个数组的尾部索引
    while (i <= e1 && i <= e2) {
        // 上一步从头开始比较逻辑代码......
    }

    // 从尾部开始比较
    while (i <= e1 && i <= e2) {
        // 上一步从尾部开始比较逻辑代码......
    }

    if (i > el) {
        // 新的节点多处理逻辑代码......
    } else if (i > e2) {
        // 旧的节点多处理逻辑代码......
    } else {
        let s1 = i
        let s2 = i
        // 根据新节点列表做一个映射表用于快速查找,看老的是否在新的里面还有,没有就删除,有的话就更新
        const keyToNewIndexMap = newMap()
        let toBePatched = e2 - s2 + 1 // 要倒序插入的个数
        for (leti = s2; i <= e2; i++) {
            const vnode = c2[i]
            }
        // 遍历老节点
        for (leti = s1; i <= e1; i++) {
            const vnode = c1[i]
            const newIndex = keyToNewIndexMap.get(vnode.key) // 通过 key 找到对应的索引
            if (newIndex == undefined) {
                // 如果新的里面找不到则说明老的有的要删除掉
                unmount(vnode)
            } else {
                // 比较前后节点的差异,更新属性和儿子
                patch(vnode, c2[newIndex], el) //服用
            }
        }
        // 调整节点顺序
        // 按照新的队列倒序插入 insertBefore 通过参照物往前面插入
        // 插入的过程中,可能新的元素的多,需要创建

        for (leti = toBePatched - 1; i >= 0; i--) {
            let newIndex = s2 + i // 找下-个元素作为参照物,来进行插入
            let anchor = c2[newIndex + 1]?.el
            let vnode = c2[newIndex]
            // 当不存在此节点时,表示新加入的节点需要创建
            if (!vnode.el) {
                // 新列表中新增的元素
                patch(null, vnode, el, anchor) // 创建这个节点并插入
            } else {
                hostInsert(vnode.el, el, anchor) // 倒序插入节点
            }
        }
    }
}

优化

以上步骤性能较差(倒序插入,但某些节点其实是不需要动的),还需优化,利用贪心算法 + 二分查找(最长递增子序列)

记录新的元素索引映射老的索引(元素的位置),再根据最长递增子序列算法求出哪些元素是不用移动的(优化点)。

1.加入最长递增子序列算法,当当前节点索引出现在最长递增子序列中则跳过插入,以提高性能。

ts
function patchKeyedChildren(n1, n2, el) {
  // 前一个步骤的其他逻辑代码......

  if (i > el) {
    // 前一个步骤的其他逻辑代码......
  } else if (i > e2) {
    // 前一个步骤的其他逻辑代码......
  } else {
    // 前一个步骤的其他逻辑代码......
    // 根据要倒序插入的个数创建对应的数组,用新索引映射老的索引,再利用算法求出哪些元素是不用移动的
    let nnewIndexTo0ldMapIndex = new Array(toBePatched).fill(0) // [0,0,0,0]
    for (leti = s1; i <= e1; i++) {
      if (newIndex == undefined) {
        // 前一个步骤的其他逻辑代码......
      } else {
        // 每次 patch 比对后记录索引,i 可能出现是 0 的情况,为了保证 0 是没有比对过的元素,直接 i+1
        newIndexTo0ldMapIndex[newIndex - s2] = i + 1
        // 比较前后节点的差异,更新属性和儿子
        patch(vnode, c2[newIndex], el)
      }
    }
    // 获取最长递增子序列
    let increasingSeqq = getSequence(newIndexToOldMapIndex)
    let j = increasingSeq.length - 1

    for (leti = toBePatched - 1; i >= 0; i--) {
      // 前一个步骤的其他逻辑代码......
      if (!vnode.el) {
        // 前一个步骤的其他逻辑代码......
      } else {
        // 如果当前索引在最长递增子序列中,则不需要移动
        if (i == increasingSeq[j]) {
          j--
        } else {
          hostInsert(vnode.el, el, anchor) // 倒序插入节点
        }
      }
    }
  }
}

2.实现核心最长递增子序列算法函数 getSequence。利用贪心算法 + 二分查找求出哪些元素无需移动。

ts
function getSequence(arr) {
  const result = [0]
  const len = arr.length // 数组长度
  const p = result.slice(0) // 存放当前节点的上一个节点的索引的,用于回溯
  // 双指针,用于二分查找,查找性能更高
  let start
  let end
  let middle
  for (leti = 0; i < len; i++) {
    const arrI = arr[i]
    if (arrI !== 0) {
      // 为了 vue3 而处理掉数组中 0 的情况
      // 拿出结果集对应的的最后一项,和我当前的这一项来作比对
      let resultLastIndex = result[result.length - 1]
      // 这是处理比较简单的情况,数组就是依次递增的。如果当前项比结果集最后一项大,则直接放入结果集即可
      if (arr[resultLastIndex] < arrI) {
        result.push(i) // 直接将当前的索引i放入到结果集即可
        p[i] = result[result.length - 1] // 记录前一个节点的索引
        continue
      }
    }
    start = 0
    end = result.length - 1
    // 二分查找,用于当数组数字乱序的情况
    while (start < end) {
      // 取整,取中间值
      middle = ((start + end) / 2) | 0
      if (arr[result[middle]] < arrI) {
        start = middle + 1
      } else {
        end = middle
      }
    }
    if (arrI < arr[result[start]]) {
      p[i] = result[start - 1] // 记录前一个节点的索引
      result[start] = i
    }

    // p为前驱节点的列表,需要根据最后一个节点做追溯
    let l = result.length
    let last = result[l - 1] // 取出最后-项
    while (l-- > 0) {
      result[l] = last
      last = p[last] // 在数组中找到最后-个
    }
  }
  return result
}

至此 vue3 中的全量 diff(递归diff)已经完成。但 vue3 中分为两种:全量 diff(递归 diff)以及快速diff(靶向更新)-> 基于模板编译实现(即在编译时就给那些静态节点当上标记(静态标记),到时候运行时只需要对动态节点进行 diff 即可,进一步提高性能)

Fragment

1.创建 Fragment 类型,并在 patch 时添加 Fragment 情况的处理函数 processFragment

ts
// 创建 Fragment 类型,方便后续对比
export const Fragment = Symbol("Fragment")

// 比对(patch 函数)时处理 Fragment 
switch (type) {
  case Text:
    processText(n1, n2, container)
    break
  case Fragment:
    processFragment(n1, n2, container)
    break
  default:
    processElement(n1, n2, container, anchor) // 对元素处理
}

2.实现 processFragment

ts
const processFragment = (n1, n2, container) => {
    // 没有老节点时直接将 Fragment 包裹的子节点渲染到指定容器钟
    if (n1 == null) {
        mountChildren(n2.children, container)
    } else {
        // 如果有老节点进行 diff 比较
        patchchildren(n1, n2, container)
    }
}

3.处理卸载的情况

ts
const unmount = vnode => {
    // 当 Fragment 需要卸载时,直接循环遍历 Fragment 包裹的子节点依次卸载
    if (vnode.type === Fragment) {
        unmountChildren(vnode.children)
    }else{
        hostRemove(vnode.el)
    }
}

组件渲染

使用

ts
// 这就是一个组件
const VueComponent = {
  data() {
    return { name: 'jw', age: 30 }
  },
  render() {
    //this ==组件的实例内部不会通过类来产生实例了
    return h(Fragment, [h(Text, 'my name is ' + this.name), h('a', this.age)])
  }
}
// 组件两个虚拟节点组成 h(VueComponent) = vnode 产生的是组件内的虚拟节点
// render 函数返回的虚拟节点,这个虚拟节点才是最终要渲染的内容 = subTree
render(h(VueComponent), app)

实现

1.创建 Vnode 时打上组件标识,并针对其做不同处理

ts
export function createVnode(type, props, children?) {
    const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT // 元素
    : isobject(type)
    // 如果传入的是一个对象(即 h 函数第一个参数)则为组件
    ? ShapeFlags.STATEFUL_COMPONENT // 组件
    : 0
    }

// path 函数
const patch = (n1, n2, container, anchor = null) => {
    // 其他逻辑......
    if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(n1, n2, container, anchor) // 对元素处理
    } else if (shapeFlag & ShapeFlags.COMPONENT) {
        //对组件的处理,vue3中函数式组件,已经废弃了,没有性能节约
        processComponent(n1, n2, container, anchor)
    }
}

2.组件挂载与更新逻辑

ts
const processComponent = (n1, n2, container, anchor) => {
    if (n1 === null) {
        // 组件的挂载
        mountComponent(n2, container, anchor)
    } else {
        //组件的更新
    }
}

3.实现组件挂载 mountComponent

ts
const mountComponent = (vnode, container, anchor) => {
    // 组件可以基于自己的状态重新渲染,组件就类似 effect
    // 组件创建时获取组件状态以及渲染函数
    const { data = () => {}, render } = vnode.type
    const state = reactive(data()) // 将组件的状态变成响应式

    // 创建组件实例
    const instance = {
        state,
        vnode,
        // 该组件的子节点
        subTree: null,
        // 是否挂载完成
        isMounted: false,
        // 组件更新方法
        update: null
    }
    // 组件更新方法,数据更新后会重新执行
    const componentUpdateFn = () => {
        if (!instance.isMounted) {
            // 组件初次挂载
            // 指定 render 函数的 this 为 state (用户在可以使用 this 访问数据),
            const subTree = render.call(state, state)
            // 创建子节点
            patch(null, subTree, container, anchor)
            instance.isMounted = true
            instance.subTree = subTree
        } else {
            // 组件更新
            const subTree = render.call(state, state)
            // diff 比较前后差异进行更新
            patch(instance.subTree, subTree, container, anchor)
            instance.subTree = subTree
        }
    }

    // 创建一个 effect,当状态发生改变的时候,重新执行 effect
    const effect = new ReactiveEffect(componentUpdateFn, () => update())
    const update = (instance.update = () => effect.run())
    // 组件挂载时后立即执行一次
    update()
}

异步更新

4.借助 gueueJob 批处理优化,即将多次更新合并为一次统一更新。

ts
const mountComponent = (vnode, container, anchor) => {
  // 其他逻辑......
    // 使用 gueueJob 函数包裹 update 实现批处理操作
  const effect = new ReactiveEffect(componentUpdateFn, () => gueueJob(update))
}

5.实现 gueueJob 方法。声明一个队列,多次执行 updata 时只会入队一次,再开启一个微任务将更新操作放入微任务中执行(不会立即执行)。

ts
const queue = [] // 缓存当前要执行的队列
let isFlushing = false // 是否正在执行队列
const resolvePromise = Promise.resolve()

// 如果同时在一个组件中更新多个状态 job 肯定是同一个
function gueueJob(job) {
  if (!queue.includes(job)) {
    queue.push(job) // 让任务入队列
  }
  if (!isFlushing) {
    // 同时开启一个异步任务
    // 通过 js 事件环的机制(先走宏任务再执行微任务),延迟更新操作,将更新操作放在微任务中
    isFlushing = true
    resolveProise.then(() => {
      isFlushing = false
      const copy = queue.slice(0) // 先拷贝再执行
      queue.length = 0
      copy.forEach(job => job())
      copy.length = 0
    })
  }
}

props及attrs实现

使用

ts
// 属性分两种:props(响应式的,需要组件显式声明,未显式声明的属性会放入 attrs 中,且会绑定到组件根容器之上)、attrs(开发环境是响应式的,但生产环境是非响应式的)
// 就相当于:<VueComponent :a=1 :b=2 name='jw' :age='30'/>
render(h(VueComponent, { a: 1, b: 2, name: 'jw', age: 30 }), app)

6.修改上方第三步中的代码(mountComponent 函数),添加 props、attrs 等属性,并初始化组件 props

ts
const mountComponent = (vnode, container, anchor) => {
    const { data = () => {}, render,props:propsoptions={} } = vnode.type
    const instance = {
        // ...其他属性...
        props:{},
        attrs:{},
        propsoptions,
        component:null
    }
    // 根据 propsoptions 来区分出 props,attrs
    vnode.component = instance
    // 初始化组件 props
    initProps(instance, vnode.props)
}

7.实现 initProps。将组件中声明接收的 props 赋值给 props,其余的赋值给 attrs,并将 props 变为响应式数据。

ts
function initProps(instance, rawProps) {
    const props = {}
    const attrs = {}
    const propsoptions = instance.propsoptions || {} // 组件中定义的
    if (rawProps) {
        // 遍历组件 props 将组件中声明接收的 props 赋值给 props,其余的赋值给 attrs
        for (let key in rawProps) {
            if (key in propsoptions) {
                props[key] = value
            } else {
                attrs[key] = value
            }
        }
    }
    instance.attrs = attrs
    // 将 props 变为响应式(浅层)
    instance.props = shalloReactive(props)
}

组件中的代理对象

1.修改前面实现 mountComponent 函数的步骤中的代码。通过 proxy 创建一个代理对象,让用户能够通过 proxy 更方便的访问属性。

ts
render(proxy) {
    // 用户可以通过 proxy 快速访问 props 
    // a 为该组件未声明接受的 props ,age 为组件通过 props 选项声明接受的属性
  return h('div', [h(Text, 'my name is ' + proxy.$attrs.a), h('a', proxy.age)])
}
ts
const mountComponent = (vnode, container, anchor) => {
    // 其他逻辑代码......
    const instance = {
        state,
        vnode,
        // 其他属性......
        // 用来代理 props attrs,data 让用户更方便的使用
        proxy: null
    }
    
    // 做一个映射
    const publicProperty = {
        $attrs: instance => instance.attrs
        // ...其他属性:$slots 等
    }
    
    instance.proxy = new Proxy(instance, {
        // 由此可见开发时 data 和 props 属性中的名字不要重名
        get(target, key) {
            const { state, props } = target
            // 查看 state 中是否有 key
            if (state && hasOwn(state, key)) {
                return state[key]
                // 查看 props 中是否有 key
            } else if (props && hasOwn(props, key)) {
                return props[key]
            }
            const getter = publicProperty[key] // 通过不同的策略来访问对应的方法
            if (getter) {
                return getter(target)
            }
        },
        set(target, key, value) {
            const { state, props } = target
            // 查看 state 中是否有 key
            if (state && hasOwn(state, key)) {
                state[key] = value
                // 查看 props 中是否有 key
            } else if (props && hasOwn(props, key)) {
                // 我们用户可以修改属性中的嵌套属性(内部不会报错)但是不合法
                console.warn('props are readonly')
                return false
            }
            return true
        }
    })

    
    // 组件更新方法,数据更新后会重新执行
    const componentUpdateFn = () => {
        if (!instance.isMounted) {
            // 将代理对象传入,用户就能够通过 proxy 更方便的访问属性
            const subTree = render.call(instance.proxy, instance.proxy)
            // 其他逻辑代码......
        } else {
            // 将代理对象传入,用户就能够通过 proxy 更方便的访问属性
            const subTree = render.call(instance.proxy, instance.proxy)
            // 其他逻辑代码......
        }
    }
    // 其他逻辑代码......
}

通过属性进行组件更新

组件更新分为三种情况:组件内 data 、组件 props、组件插槽。前面的步骤已经实现第一种情况。接下来实现第二种。

用户代码

ts
// VueComponent 组件中渲染了一个按钮和 RenderComponent 组件并传递了 address 属性,当 VueComponent 组件发生改变即传递的 props 发生改变那么 RenderComponent 也需要更新
const RenderComponent = {
    props: {
        address: String
    },
    render() {
        return h(Text, this.address)
    }
}

const VueComponent = {
    data() {
        return { flag: true }
    },
    render() {
        return h(Fragment,null, [
            h('button', { onClick: () => (this.flag = !this.flag) }, '点我啊'),
            h(RenderComponent, { address: this.flag ? '北京' : '上海' })
        ])
    }
}

1.回到组件渲染实现的第一步的 processComponent 函数中添加 updateComponent 方法实现组件更新。

ts
const processComponent = (n1, n2, container, anchor) => {
    if (n1 === null) {
        // 组件的挂载
        mountComponent(n2, container, anchor)
    } else {
        // 组件的更新
        updateComponent(n1,n2)
    }
}

2.实现 updateComponent。比较新老属性变化并将其更新,多余的删除,由于 props 在前面的步骤已经被包装为响应式数据,此时比较更新后,组件也会更新。

ts
function updateComponent(n1, n2) {
    const instance = (n2.component = n1.component) // 复用组件的实例
    // 获取老新属性
    const { props: prevProps } = n1
    const { props: nextProps } = n2
    // 比较老新属性差异
    updataProps(instance, prevProps, nextProps)
}

function hasPropsChange(prevProps, nextProps) {
    const nKeys = Object.keys(nextProps)
    // 先看属性个数是否发生变化
    if (nKeys.length !== Object.keys(prevProps).length) return true
    // 再遍历依次比较属性值是否发生变化
    for (let i = 0; i < nKeys.length; i++) {
        const key = nKeys[i]
        if (nextProps[key] !== prevProps[key]) {
            return true
        }
    }
    return false
}

function updataProps(instance, prevProps, nextProps) {
    // 比较属性是否发生变化
    if (hasPropsChange(prevProps, nextProp)) {
        //看属性是否存在变化
        for (let key in nextProps) {
            // 用新的覆盖掉所有老的
            instance.props[key] = nextProps[key]
        }
        for (let key in instance.props) {
            // 删除老的多于的
            if (!(key in nextProps)) {
                delete instance.props[key]
            }
        }
    }
}

setup 函数

每个组件只会执行一次,可以书写 compositionApi

ts
// 基本使用
// 平时开发时一般很少直接写 render 函数,一般通过模板书写内容,再通过构建工具将模板转换成 render 函数进行渲染
const VueComponent = {
    setup(props, { emit, attrs, expose, slots }) {
        const a = ref(1)
        // 提供渲染逻辑
        setTimeout(() => {
            a.value = 2
        }, 1000)
        return {
            // setup 可以返回函数亦可以返回对象
            a: a.value
        }
		// 可返回函数
        // return () => {
        //   return h('div', a.value)
        // }
    },
    // 当 setup 返回函数时,该组件以内容以 setup 返回内容渲染优先级高于此处组件的 render
    render(proxy) {
        return h('div', proxy.a)
    }
}

1.修改 setupComponent 函数。结构出组件传递的 setup 函数,并根据其返回值渲染组件

ts
export function setupComponent(instance) {
  const { vnode } = instance
  //赋值属性
  initProps(instance, vnode.props)
  //赋值代理对象
  instance.proxy = new Proxy(instance, handler)
  const { data = () => {}, render, setup } = vnode.type

  if (setup) {
    const setupcontext = {
      // 这里组合 setup 的上下文,即用户使用时 setup 第二个参数,里面放着:attrs、slots、emit、expose 等
    }

    const setupResult = setup(instance.props, setupcontext)
    if (isFunction(setupResult)) {
      // 如果 setup 返回一个函数,则认为这个函数是 render 函数
      instance.render = setupResult
    } else {
      // 否则,认为 setup 返回的就是组件的 state,并通过 proxyRefs 方法将数据自动解包(自动 .value)
      instance.setupState = proxyRefs(setupResult)
    }
  }

  if (!isFunction(data)) {
    console.warn('data option must be a function')
  } else {
    //data 中可以拿到props
    instance.data = reactive(data.call(instance.proxy))
  }
  // 如果该组件实例上没有 render 函数(setup 可能返回函数取代组件 render 函数),才会取用组件的 render 函数
  if (!instance.render) {
    instance.render = render
  }
}

插槽

基本使用

ts
const RenderComponent = {
    render(proxy) {
        // proxy.$slots.footer 传参表示作用域插槽
        return h(Fragment, [proxy.$slots.footer('fff'), proxy.$slots.header('hhh')])
    }
}

const VueComponent = {
    setup(props, { emit, attrs, expose, slots }) {
        return proxy => {
            return h(RenderComponent, null, {
                header: t => h('header', 'header' + t),
                footer: t => h('footer', 'footer' + t)
            })
        }
    }
}

1.修改 createVnode 方法(上方 h 方法实现的第二步),添加组件插槽标识

ts
function createVnode(type, props, children?) {
    // 其他逻辑......
    if (children) {
        if (Array.isArray(children)) {
            vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN
        }else if(isobject(children)){
            vnode.shapeFlag |= ShapeFlags.SLOTS_CHILDREN;// 添加插槽标识
        }else {
            children = String(children)
            vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN
        }
    }
}

2.修改 mountComponent 方法(上方组件中的代理对象步骤),在组件实例中添加 slots 属性用于保存组件插槽

ts
const mountComponent = (vnode, container, anchor) => {
    // 其他逻辑代码......
    const instance = {
        // 在组件实例中添加 slots 属性用于保存组件插槽
        slots:{}
        // 其他属性......
    }
    // 其他逻辑代码......
}

3.修改 setupComponent 方法(上方 setup 函数实现第一个步骤),添加 initSlots 方法

ts
export function setupComponent(instance) {
    const { vnode } = instance
    initProps(instance, vnode.props)
    // 添加 initSlots 方法
    initSlots(instance, vnode.children)
    instance.proxy = new Proxy(instance, handler)
    // 其他逻辑代码......
}

4.实现 initSlots 方法。判断当前虚拟节点上是否有插槽标识,如果有就将 children 赋值给 instance.slots

ts
export function initslots(instance, children) {
    // 判断当前虚拟节点上是否有插槽标识,如果有就将 children 赋值给 instance.slots。组件的儿子就是插槽
    if (instance.Vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
        instance.slots = children
    } else {
        instance.slots = {}
    }
}

5.修改 mountComponent 方法(上方组件中的代理对象步骤)。添加 $slots 映射,方便用户直接通过 $slots 快速访问组件插槽

ts
const mountComponent = (vnode, container, anchor) => {
    // 其他逻辑代码......
    const publicProperty = {
        $attrs: instance => instance.attrs
        $slots:instance => instance.slots
    }
    // 其他逻辑代码......
}

6.修改 mountComponent 方法(上方组件中的代理对象步骤)。修改 setupcontext (也就是用户使用 setup 函数的第二个参数)添加 slots 供用户快速访问。

ts
export function setupComponent(instance) {
    // 其他逻辑代码......
    if (setup) {
        // 这里组合 setup 的上下文,即用户使用时 setup 第二个参数,里面放着:attrs、slots、emit、expose 等
        const setupcontext = {
            slots: instance.slots,
            attrs: instance.attrs
        }
        const setupResult = setup(instance.props, setupcontext)
        }
}

emit

用户代码。写入了一个自定义事件 onMyEvent 并在组件中通过 emit 触发

ts
const VueComponent = {
  setup(props, { emit }) {
    return proxy => {
      return h('button', { onClick: () => emit('myEvent', 100) },"按钮")
    }
  }
}
// 给组件绑定事件<div@myEvent>
render(h(VueComponent, { onMyEvent: value => alert(value) }), app)

1.修改 mountComponent 方法(上方组件中的代理对象步骤)。在 setupcontext 中添加 emit ,先获取用户传递的函数名,再去组件实例上找到该函数执行。

ts
export function setupComponent(instance) {
    // 其他逻辑代码......
    if (setup) {
        const setupcontext = {
            slots: instance.slots,
            attrs: instance.attrs,
            emit(event, ...payload) {
                // 先拼接 “on” 再将函数名首字母大写,最终就会得到 “onMyEvent”,再去组件实例上找到该函数执行
                const eventNamee = `on${event[0].toUpperCase() + event.slice(1)}`
                const handler = instance.vnode.props[eventName]
                handler && handler(...payload)
            }
        }
        const setupResult = setup(instance.props, setupcontext)
        }
}

组件生命周期

使用

ts
// 父初始化 => 子初始化 => 父完成
// 父更新 => 子更新 => 子完成 => 父完成
const my = {
    props: {
        value: String
    },
    setup(props) {
        console.log('子 setup')
        onBeforeMount(() => {
            console.log('子 beforemount')
        })
        onMounted(() => {
            console.log('子 mounted')
        })
        onBeforeUpdate(() => {
            console.log('子 beforeupdate')
        })
        onUpdated(() => {
            cnsole.log('子 updated')
        })
        return () => {
            return h('div', props.value)
        }
    }
}
const VueComponent = {
    setup() {
        console.log('setup')
        onBeforeMount(() => {
            console.log('beforemount')
        })
        onMounted(() => {
            console.log('mounted')
        })
        onBeforeUpdate(() => {
            console.log('beforeupdate')
        })
        onUpdated(() => {
            cnsole.log('updated')
        })
        const val = ref('a')
        setTimeout(() => {
            val.value = 'b'
        }, 1000)
        return () => {
            return h(my, { value: val.value })
        }
    }
}

1.先列举出所有生命周期钩子名称,再通过 createHook (函数柯里化)创建对应钩子函数。

ts
// 列举生命周期钩子名称
export const enum Lifecycle {
  BEFORE_MOUNT = 'bm',
  MOUNTED = 'm',
  BEFORE_UPDATE = 'bu',
  UPDATED = 'u'
}

function createHook(type) {}

// 创建生命周期钩子
export const onBaforeMount = createHook(LifeCycles.BEFORE_MOUNT)
export const onMounted = createHook(LifeCycles.MouNTED)
export const onBeforeUpdate = createHook(LifeCycles.BEFORE_UPDATE)
export const onUpdated = createHook(LifeCycles.UPDATED)

2.实现 getCurrentInstance 、setCurrentInstance 、unsetCurrentInstance 方法

ts
// 定义一个全局变量,用来存储当前实例
export let currentInstance = null
// 获取当前组件实例
export const getCurrentInstance = () => {
    return currentInstance
}
// 设置当前组件实例
export const setCurrentInstance = instance => {
    currentInstance = instance
}
// 重置
export const unsetCurrentInstance = () => {
  currentInstance = null
}

3.修改 setupComponent 方法(上方 实现 setup 函数的步骤)。在执行组件 setup 函数之前保存当前组件实例,这样在 setup 中就可以获取到当前组件实例

ts
function setupComponent(instance) {
    // 其他逻辑代码......
    // 在执行组件 setup 函数之前将当前组件实例(instance)记录下来
    setCurrentInstance(instance)
    const setupResult = setup(instance.props, setupcontext)
    // 执行完之后再重置
    unsetCurrentInstance()
}

4.实现 createHook 方法。此时可以获取到组件实例让其和钩子产生关联。将用户写入的对应生命周期保存至当前组件实例上。

ts
function createHook(type) {
    // 获取全局保存的组件实例,利用闭包将当前的实例存到了此钩子上
    return (hook, target = currentInstance) => {
        if (target) {
            // 确保当前钩子是在组件中运行的,因为只有这样才能获取当前组件实例
            // 看当前钩子是否在当前组件实例上存放过
            const hooks = target[type] || (target[type] = [])
            // 让currentInstance 存到这个函数内容
            const wrapHook = () => {
                // 在钩子执行前,对实例进行校正处理,即重新设置一下当前组件实例,以解决用户在生命周期钩子中调用 getCurrentInstance 获取组件实例时获取错误问题
                setCurrentInstance(target)
                // 确保生命周期钩子内的 this 指向为当前组件实例
                hook.call(target);
                // 钩子执行完毕后,再重置
                unsetCurrentInstance()
            }
            // 存入数组中,用户可能会在组件中写入多个相同的钩子,所以需要用数组保存
            hooks.push(wrapHook(hook))
        }
    }
}

5.修改 mountComponent (上方组件渲染实现步骤三),分别在组件创建、更新阶段获取当前组件实例上的对应生命周期钩子依次遍历执行。

ts
const mountComponent = (vnode, container, anchor) => {
    // 其他逻辑代码......
    // 组件更新方法,数据更新后会重新执行
    const componentUpdateFn = () => {
        if (!instance.isMounted) {
            // 组件初次挂载后从实例上获取生命周期钩子(用户写入的钩子)
            const { bm, m } = instance
            if (bm) {
                // 获取用户组件中写入的所有 onBaforeMount 钩子遍历依次执行
                invokeArray(bm)
            }
            // 其他逻辑代码......
        } else {
            const { next, bu, u } = instance
            if (bu) {
                // 获取用户组件中写入的所有 onBeforeUpdate 钩子遍历依次执行
                invokeArray(bu)
            }
            // 组件更新。diff 比较前后差异进行更新逻辑代码......
            if (u) {
                // 获取用户组件中写入的所有 onBeforeUpdate 钩子遍历依次执行
                invokeArray(u)
            }
        }
    }
}

ref

用户可通过 ref 获取组件实例或对应 dom 元素

ts
// 用户代码
const My = {
    props: {
        value: String
    },
    setup(props) {
        return () => {
            return h('div', props.value)
        }
    }
}
const VueComponent = {
    setup(props) {
        // 1.如集 ref 放到组件上,指代的是组件实例,如果当前组件有 expose 则是 expose
        // 2.如果放到 dom 元素上指代的就是 dom 元素
        const comp = ref(null)
        onMounted(() => {
            console.log(comp.value) // 这个获取的是组件的实例
        })
        return () => {
            return h(My, { ref: comp })
        }
    }
}

1.修改 createVnode (上方 h 方法实现第二个步骤),在 vnode 上添加 ref 属性

ts
function createVnode(type, props, children?) {
  // 其他逻辑代码......
  const vnode = {
    // 其他属性
    // 在 vnode 上添加 ref 属性
    ref: props?.ref,
  }
  // 其他逻辑代码......
}

2.修改 patch 方法(上方虚拟 DOM 渲染实现的第三步),在页面更新完成时将 ref 设置到 vnode 上。

ts
const patch = (n1, n2, container) => {
  // 在页面渲染的时候从当前 vnode 中获取 ref 属性
  const { ref } = n2
  // 在页面更新完成的时候,将 ref 设置到 vnode 上
  if (ref !== nul) {
    setRef(ref, n2)
  }
}
function setRef(rawRef, vnode) {
  // 如果当前 vnode 是一个组件,则会再判断组件上是否有 exposed 属性(组件暴露的属性),如果有则取 exposed 属性的值,如果没有则取组件实例
  // 如果不是组件则直接取 el 属性,也就是 DOM 元素
  let value =
    vnode.shapeFLag & ShapeFlags.STATEFUL_COMPONENT
      ? vnode.component.exposed || vnode.component.proxy
      : vnode.el
  if (isRef(rawRef)) {
    rawRef.value = value
  }
}

函数组件

用户代码

ts
// 函数式组件,不建议使用,因为此类组件对比普通组件没有什么性能优化且是无状态的
function functionalComponent(props) {
  return h('div', props.a + props.b)
}
render(h(functionalComponent, { a: 1, b: 2 }), app)

1.修改 createVnode (上方组件渲染实现的第一个步骤),添加对函数组件的判断

ts
function createVnode(type, props, children?) {
    const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT
    : isobject(type)
    ? ShapeFlags.STATEFUL_COMPONENT
    ? ShapeFlags.FUNCTIONAL_COMPONEN // 函数式组件
    : 0
}

2.修改 mountComponent (上方组件渲染实现步骤三),在组件初次挂载以及更新时加入对函数式组件的判断,如果是函数式组件直接调用执行并将属性传递过去。

ts
const mountComponent = (vnode, container, anchor) => {
    // 其他逻辑......
    const instance = {
        state,
        vnode,
        // 该组件的子节点
        subTree: null,
        // 是否挂载完成
        isMounted: false,
        // 组件更新方法
        update: null
    }
    // 组件更新方法,数据更新后会重新执行
    const componentUpdateFn = () => {
        if (!instance.isMounted) {
            // 组件初次挂载
            const subTree = renderComponent(instance)
            // 其他逻辑......
            } else {
                // 组件更新
                const subTree = renderComponent(instance)
                // 其他逻辑......
                }
    }

    // 其他逻辑
}

function renderComponent(instance) {
    const { render, vnode, proxy, props, attrs } = instance
    if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) return render.call(proxy, proxy)
    // 函数组件,直接调用执行
    return vnode.type(attrs)
}

provide、inject实现

用户代码。

ts
const P1 = {
    setup(props) {
        // provide 传递的如果是响应式或者普通值,那么后代组件 inject 接收的就是响应式或者普通值
        // 自定定义的 provide 在当前组件内 inject 是获取不到的
        const name = ref('jw')
        provide('name', name)
        return ()=>h(P2)
    }
}

const P2 = {
    render() {
        return h(P3)
    }
}

const P3 = {
    setup(props) {
        // 后代组件接收
        const name = inject('name')
        // 可以接收父代组件没有传递的值并且给一个默认值
        const age = inject('age', 30)
        return ()=>h('div', [name.value, age])
    }
}
render(h(P1), app)

1.修改 patch 方法(上面组件渲染实现的第一个步骤)。在组件创建时构建组件父子关系。即新增 parentComponen 参数用于记录父组件。

ts
const patch = (n1, n2, container, anchor = null,parentComponen=null) => {
    // 其他逻辑......
    switch (type) {
        case Fragment:
            // 将 parentComponen 传递下去
            processFragment(n1, n2, container,parentComponen)
            break
        default:
            if (shapeFlag & ShapeFlags.ELEMENT) {
                // 将 parentComponen 传递下去
                processElement(n1, n2, container, anchor,parentComponen)
            } else if (shapeFlag & ShapeFlags.COMPONENT) {
                // 将 parentComponen 传递下去
                processComponent(n1, n2, container, anchor,parentComponen)
            }
    }
}

2.将父组件记录在当前组件实例之上,并保存父组件的 provides 值,这样后续再查找时只需向上查找一级即可,无需像 vue2 时循环查找提升性能。

ts
const instance = {
    // 记录当前组件的父组件
    parent,
    // 如果当前组件存在父组件则会保存父组件的 provides 值
    provides:parent ? parent.provides :Object.create(null),
}

3.实现 provide 以及 inject。

provide:先获取当前组件上保存的 provides 属性和父组件的是否一致(是否是同一个对象),如果是的话拷贝一份再将新属性设置上去。

inject:查询父组件上保存的 provides 属性,如果存在就直接返回,如果不存在就返回用户传递的默认值

ts
export function provide(key, value) {
    // 获取当前组件实例,确保在组件内部调用。
    // 此处 currentInstance 引用的是 vue 内部定义的用于存储当前组件实例的全局变量
    if (!currentInstance) return
    // 获取父组件的provide
    const parentProvidee = currentInstance.parent?.provides
    // 获取当前组件上的 provides
    let provides = currentInstance.provides
    if (parentProvidee === provides) {
        // 如果父组件的 provides 和当前组件的 provides 是同一个对象,则需要拷贝一个新的对象,否则子组件一改父组件也改了
        // 如果在子组件上新增了 provdes 需要拷贝一份全新的
        provides = currentInstance.provides = Object.create(null)
    }
    // 将用户设置的值保存到 provides 上
    provides[key] = value
}

export function inject(key, defaultValue) {
    // 确保在组件内部调用
    if (!currentInstance) return
    // 获取父组件的 provides
    const provides = currentInstance.parent?.provides
    // 获取父组件的 provides 上是否有对应的值,有的话就返回,没有的话就返回用户传递的默认值
    if (provides && key in provides) return provides[key]
    return defaultValue
}

内置组件

Teleport

用户代码

ts
// 会在根容器之外渲染 Teleport 包裹的内容
render(h(Teleport, { to: '#root' }, [123, 'abc']), app)

1.添加 Teleport 组件以及判断方法

ts
// Teleport 组件
export const Teleport = {
  isTeleport: true
}
// 判断是否是 Teleport 组件
export const isTeleport = value => value._isTeleport

2.修改 createVnode(上方组件渲染实现的第一个步骤),在创建组件时添加对 Teleport 的判断

ts
function createVnode(type, props, children?) {
    const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT
    : isTeleport(type) // Teleport
    ? ShapeFlags.TELEPORTI
    : isobject(type)
    ? ShapeFlags.STATEFUL_COMPONENT
    : isFunction(type)
    ? ShapeFlags.FUNCTIONAL_COMPONENT
    : 0
}

3.修改 patch 方法(上面组件渲染实现的第一个步骤)。在组件创建时如果当前是 teleport 组件,则调用该组件上的 process 方法

ts
const patch = (n1, n2, container, anchor = null, parentComponen = null) => {
    // 其他逻辑......
    if (shapeFlag & ShapeFlags.TELEPOR) {
        // 当是 teleport 组件时,直接调用该组件的 process 方法
        type.process(n1, n2, container, anchor, parentcomponent, {
            mountchildren,
            patchchildren,
            move(vnode, container, anchor) {
                // 此方法可以将组件或者dom元素移动到指定的位置
                hostInsert(vnode.component ? vnode.component.subTree.el : vnode.el, container, anchor)
            }
        })
    }
    // 其他逻辑......
}

4.实现 process 方法。在 Teleport 组件添加 process 方法,在初始化阶段(无老 vnode)获取 to 属性对应的节点将其挂载其上即可,更新阶段直接调用上一步传递的工具方法比对子节点更新,并且如果更新后 to 属性变化还需将子节点移动到新的容器之中。

ts
// Teleport 组件
export const Teleport = {
    isTeleport: true,
    process(n1, n2, container, anchor, parentcomponent, internals) {
        let { mountchildren, patchchildren, move } = internals
        // 初始化阶段
        if (!n1) {
            // 获取用户传递的 to 属性,获取到对应的 DOM 节点
            const target = (n2.target = document.querySelector(n2.props.to))
            // 如果存在此节点就直接将 Teleport 子节点挂载到这个节点上
            if (target) {
                mountchildren(n2.children, target, parentComponent)
            }
        } else {
            // 后续更新阶段。直接调用上一步传递的 patchchildren 方法比对子节点更新即可
            patchchildren(n1, n2, n2.target, parentcomponent)
            // 还需判断更新后 to 属性是否发生变化,如果变化还需将子节点全部移动到新的容器中
            if (n2.props.to !== n1.props.to) {
                const nextTarget = document.querySelector(n2.props.to)
                n2.children.forEach(child => move(child, nextTarget, anchor))
            }
        }
    }
}

5.组件卸载时加入对 Teleport 的判断,并调用 Teleport 组件的 remove 方法。

ts
const unmount = vnode => {
    const { shapeFlag } = vnode
    if (vnode.type === Fragment) {
        // 其他逻辑......
    } else if (shapeFlag & ShapeFlags.TELEPORT) {
        // 当 Teleport 组件卸载时调用组件的 remove 函数
        vnode.type.remove(vnode, unmountChildren)
    } else {
        // 其他逻辑......
    }
}

6.实现 remove 方法。直接调用上一步传递过来的工具方法,遍历子节点并卸载。

ts
// Teleport 组件
export const Teleport = {
    isTeleport: true,
    process(n1, n2, container, anchor, parentcomponent, internals) {
        // 其他逻辑......
    },
    remove(vnode, unmountchildren) {
        // 直接调用上一步传递过来的工具方法,遍历子节点并卸载
        const { shapeFlag, children } = vnode
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
            unmountchildren(children)
        }
    }
}

Transition

1.使用函数组件创建 Transition 组件,以及核心实现组件 BaseTranstionImpl

ts
export function Transition(props, { slots }) {
    // 函数式组件的功能比较少,为了方便函数式组件处理了属性
    // 处理属性后传递给 状态组件 setup
    return h(BaseTranstionImpl, resolveTransitionProps(props), slots)
}

const BaseTranstionImpl = {
    // 真正的组件 只需要渲染难的时候调用封装后的钩子即可
    props: {
        onBeforeEnter: Function,
        onEnter: Function,
        onLeave: Function
    },
    setup(props, slots) {}
}

2.实现 resolveTransitionProps 函数用于处理用户传递的 props。

​ 1):接收用户传递的 neme 、以及类名和动画钩子。

​ 2):添加 onBeforeEnter 钩子逻辑。给当前组件对应的真实 dom 添加动画执行前的类名。

​ 3):添加 onEnter 钩子逻辑。先实现 done 方法(用户可以在此钩子的第二个参数获取此函数,用户 可通过调用它来主动结束动画),在其中移除对应类名。在下一帧(确保)删除进入动画前的类名并添 加进入动画时的类名样式,然后清除动画类名。

​ 4):添加 onLeave 钩子逻辑。和上一步类似。

ts
function nextFrame(fn) {
    // 调用两次是因为确保在所有浏览器下都能在下一帧执行
    // 有的浏览器 requestAnimationFrame 是在帧的末尾执行,实际上还是这一帧
    requestAnimationFrame(() => {
        requestAnimationFrame(fn)
    })
}

function resolveTransitionProps({
    // 用户可以修改动画类名前缀
    name = 'v',
    // 接收用户传递的动画类名对应的样式
    enterFromClass = `${name}-enter-from`,
    enterActiveClass = `${name}-enter-active`,
    enterToclass = `${name}-enter-to`,
    leaveFromClass = `${name}-leave-from`,
    leaveActiveClass = `${name}-leave-active`,
    leaveToclass = `${name}-leave-to`,
    // 接收用户传递的钩子函数
    onBeforeEnter,
    onEnter,
    onLeave
}) {
    return {
        onBeforeEnter(el) {
            // 在动画开始前执行用户传递的钩子函数
            onBeforeEnter && onBeforeEnter(el)
            // 给对应真实 DOM 添加类名
            el.classList.add(enterFromClass)
            el.classList.add(enterActiveClass)
        },
        onEnter(el, done) {
            // 用户可以在使用时调用 done 函数来决定何时结束动画(移除类名)
            const resolve = () => {
                el.classList.remove(enterToclass)
                el.classList.remove(enterActiveClass)
                done && done()
            }
            // 在动画执行后调用用户传递的钩子函数
            onEnter && onEnter(el, resolve)
            // 在下一帧移除对应的类名和添加进入动画的类名
            nextFrame(() => {
                el.classList.remove(enterFromClass)
                el.classList.add(enterToclass)
                if (!onEnter || onEnter.length <= 1) {
                    // 如果用户没有传递 onEnter 钩子,或者没有调用 done 则监听动画结束钩子移除类名结束动画
                    el.addEventListener('transitionEnd', resolve)
                }
            })
            // 添加后,在移除,而不是马上移除
        },
        onLeave(el, done) {
            // 移除样式
            const resolve = () => {
                el.classList.remove(leaveActiveClass)
                el.classList.remove(leaveToclass)
                done && done()
            }
            // 在执行离开动画时调用用户传递的钩子函数
            onLeave && onLeave(el, resolve)
            // 先添加样式
            el.classList.add(leaveFromClass)
            // 立即让浏览器绘制一次(保证样式先生效)
            // 调用获取浏览器高度等 API 时,浏览器为了其值的正确性会重新绘制一次页面,重新计算其值。
            // 这里正是利用了此点特性,保证样式生效后再添加过渡样式,保证产生过渡效果
            document.body.offsetHeight
            // 待样式生效后再添加过渡样式,保证产生过渡效果
            el.classList.add(leaveActiveClass)
            // 在下一帧移除对应的类名和添加进入动画的类名
            nextFrame(() => {
                el.classList.remove(leaveFromClass)
                el.classList.add(leaveToclass)
                if (!onLeave || onLeave.length <= 1) {
                    // 如果用户没有传递 onLeave 钩子,或者没有调用 done 则监听动画结束钩子移除类名结束动画
                    el.addEventListener('transitionend', resolve)
                }
            })
        }
    }
}

3.完善 BaseTranstionImpl 组件。将上一步处理好的 props 放入 Transition 组件的虚拟节点上。

ts
const BaseTranstionImpl = {
    // 真正的组件 只需要渲染难的时候调用封装后的钩子即可
    props: {
        onBeforeEnter: Function,
        onEnter: Function,
        onLeave: Function
    },
    setup(props, slots) {
        return () => {
            const vnode = slots.defalut && slots.defalut()
            if (!vnode) return
            // 将上一步处理好的 props 钩子函数放入 Transition 组件的虚拟节点上
            vnode.transition = {
                beforeEnter: props.onBeforeEnter,
                enter: props.onEnter,
                leave: props.onLeave
            }
            return vnode
        }
    }
}

4.修改 mountElement 方法(上面渲染器虚拟 DOM 渲染实现的第四步)。在组件挂载前后分别调用上一步保存的 transition 属性上的钩子函数。

ts
function mountElement(vnode, container, anchor, parentcomponent) {
    // 其他逻辑......
    // 从当前 vnode 中获取上一步保存的 transition
    const { transition } = vnode
    if (transition) {
        // 在组件挂载前调用 transition 的 beforeEnter 钩子并将真实 DOM 传入
        transition.beforeEnter(el)
    }
    // 组件挂载
    hostInsert(el, container, anchor)
    if (transition) {
        // 在组件挂载后调用 transition 的 enter 钩子并将真实 DOM 传入
        transition.enter(el)
    }
}

5.在组件卸载时,调用上面保存在 vnode 上的 leave 钩子。

ts
// 组件挂载
function unmount(vnode) {
    const { transition, el } = vnode
    const performRemove = () => hostRemove(vnode.el)
    if (transition) {
        // 在组件卸载时调用前面保存在 vnode 上的 leave 钩子
        transition.leave(el, performRemove)
    } else {
        performRemovel()
    }
}

keepalive

1.修改 mountComponent 方法(上方组件渲染实现的第三步)组件实例,添加 ctx 属性,并将 KeepAlive 组件会用到一些 DOM API 存入其中

ts
const mountComponent = (vnode, container, anchor) => {
    // 其他逻辑代码......

    // 创建组件实例
    const instance = {
        // 其他属性......
        // 如果是 keepAlive 组件,就将 dom api 放入到这个属性上
        ctx: {}
    }

    if (isKeepAlive(vnode)) {
        instance.ctx.renderer = {
            // 放入创建 dom 元素的 API,KeepAlive 组件内部需要创建一个 div 来缓存 dom
            createElement: hostCreateElement,
            // 移动元素,KeepAlive 组件用于把之前渲染的 dom 放入到容器中
            move(ynode, container) {
                hostInsert(vnode.component.subTree.el, container)
            },
            // 如果组件切换需要将现在容器中的元素移除
            unmount
        }
    }
}

2.实现 KeepAlive 组件:

1)在组件上定义一个 _isKeepAlive 属性用于标记 KeepAlive 组件。

2)再创建一个映射表用于缓存组件,并在初次渲染以及更新后将 KeepAlive 组件的子节点(KeepAlive 组件默认插槽,即需要缓存的组件)缓存起来。

3)KeepAlive 组件更新时判断子组件是否被缓存过,如果缓存过无需重新创建组件实例,直接复用缓存即可,如果没有则保存此组件名。

4)给此组件 vnode 上打上标记,用于组件卸载时不走普通组件卸载逻辑需要走 KeepAlive 失活钩子。

ts
const KeepAlive = {
    // 标记当前是 KeepAlive 组件
    _isKeepAlive: true,
    setup(props, { slots }) {
        // 用来记录哪些组件缓存过(记录组件名)
        const keys = new Set()
        // 组件缓存表(记录组件名以及对应组件)
        const cache = newMap()

        let pendingCachekey = null
        const instance = getCurrentInstance()

        function cacheSubTr() {
            // 得在组件加载完毕和更新后再缓存到映射表中
            // 缓存组件的虚拟节点,里面有组件的 dom 元素
            cache.set(pendingCachekey, instance.subTree)
        }
        onMounted(cacheSubTr)
        onUpdated(cacheSubTr)
        return () => {
            // 每次更新时,获取默认插槽的 vnode
            const vnode = slots.default()
            const comp = vnode.type
            const key = vnode.key == null ? comp : vnode.key
            const cacheVNode = cache.get(key)
            if (cacheVNode) {
                // 如果此组件已经缓存过,无需重新创建组件实例,直接复用缓存即可
                vnode.component = cacheVNode.component
                // 标记此组件已经被缓存过,方便在后续组件更新逻辑中不走组件创建逻辑
                vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
            } else {
                // 初次渲染时记录缓存过的组件名
                keys.add(key)
            }
            // 给此组件打上标记,组件卸载时不走普通组件卸载逻辑需要走 KeepAlive 失活钩子
            vnode.shapeFLag |= ShapeFLags.COMPONENT_SHOULD_KEEP_ALIVE
            return vnode
        }
    }
}
// 判断 vnode 是否是 KeepAlive 组件
const isKeepAlive = value => value.type._isKeepAlive

3.在 KeepAlive 组件实例之上添加钩子方法。

1)activate:拿出上第一步中添加的 API ,将子组件移动到指定容器中。

2)deactivate:创建一个空的 div 将子组件内容移动到此空容器中。

ts
const KeepAlive = {
    setup(props, { slots }) {
        // 激活时执行,拿出上第一步中添加的 API ,将子组件移动到指定容器中
        const { move, createElement } = instance.ctx.renderer
        instance.ctx.activate = function (vnode, container, anchor) {
            move(vnode, container, anchor)
        }
        // 失活的时候执行,创建一个空的 div 将子组件移动到此空容器中
        const storageContent = createElement('div')
        instance.ctx.deactivate = function (vnode) {
            move(vnode, storageContent, null) // 将dom元素临时移动到这个div中但是没有被销毁
        }
        // 其他逻辑......
    }
}

4.修改 processComponent 方法(上面组件渲染通过属性进行组件更新的第一步)。判断当 KeepAlive 组件更新时不要直接走普通组件的挂载逻辑,而是调用上面存在 KeepAlive 组件 vnode 中的 activate 钩子,即激活钩子。

ts
const processComponent = (n1, n2, container, anchor, parentcomponent) => {
    if (n1 === null) {
        if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
            // 需要走 keepAlive 中的激活方法
            parentcomponent.ctx.activate(n2, container, anchor)
        } else {
            mountComponent(n2, container, anchor)
        }
    } else {
        updateComponent(n1, n2)
    }
}

5.当组件卸载时调用 keepAlive 组件上的 deactivate 钩子,即失活钩子

ts
// 组件挂载
function unmount(vnode, parentComponent) {
    if (shapeFlag & ShapeFLags.COMPONENT_SHOULD_KEEP_ALIVE) {
        // 调用 keepAlive 组件上的 deactivate 钩子
        parentComponent.ctx.deactivate(vnode)
    }
    // 其他组件卸载逻辑......
}

异步组件

使用

ts
// 异步加载组件
const MyComponent = defineAsyncComponent(() => {
    // 异步组件
    loader: () => {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve({
                    render: () => {
                        return h('div', 'hello')
                    }
                })
            }, 3000)
        })
    },
    // 超时时间
    timeout:1000,
    // 错误组件
    errorComponent: () => {
       return h('div', 'error')
    }
})

render(h(MyComponent),app)

1.异步组件本质和图片懒加载十分类似。定义一个状态用于标记当前组件是否加载完毕,当组件加载完成后渲染该组件,否则则渲染一个空容器。

ts
function defineAsyncComponent(loader) {
    // defineAsyncComponent 函数最终返回一个组件
    // loader 表示用户传递的异步组件,需要一个 Promise
    return {
        setup() {
            // 定义当前组件是否加载完成
            const loaded = ref(false)
            // 用于保存异步加载的组件
            let Comp = null
            // 等待 loader 加载完成
            loader().then(comp => {
                // 保存组件
                Comp = comp
                loaded.value = true
            })
            // 当组件加载完成后渲染该组件,否则则渲染一个空容器
            return () => (loaded.value ? h(Comp) : h('div'))
        }
    }
}

2.加入其他功能。定义一个错误状态,当加载组件出错或加载超时则会将此状态置为 true 并展示错误组件

ts
function defineAsyncComponent(options) {
    if (isFunction(options)) {
        // 如果传递的是一个函数则将函数作为 loader 属性
        options = { loader: options }
    }
    return {
        setup() {
            const { loader, errorComponent, timeout } = options
            // 定义当前组件是否加载完成
            const loaded = ref(false)
            // 是否出错
            const error = ref(false)
            // 用于保存异步加载的组件
            let Comp = null
            // 等待 loader 加载完成
            loader()
                .then(comp => {
                // 保存组件
                Comp = comp
                loaded.value = true
            })
                .catch(err => {
                error.value = true
            })
            if (timeout) {
                // 如果配置了超时时间则设置超时处理
                setTimeout(() => {
                    error.value = true
                }, timeout)
            }
            // 空节点
            const placeholder = h('div')
            return () => {
                // 如果加载完成则渲染异步加载的组件
                if (loaded.value) return h(Comp)
                // 如果出错则渲染 errorComponent 组件
                if (error.value && errorComponent) return h(errorComponent)
                // 如果都没加载完成则渲染空节点
                return placeholder
            }
        }
    }
}

编译优化

靶向更新

vue3 在编译阶段(模板转化成 render 函数)就会给所有节点打上标记(是否是动态节点,动态节点上那些属性是动态的)这样运行时就知道 dom 树上有哪些节点的哪些属性是动态的,并且会将这些动态节点放入一个队列当中,当更新后,不会进行整棵 dom 树的递归比对,会直接对比新旧队列(之前存放动态节点的队列)提升性能。

这是用户代码

vue
<div>
    <h1>Hello Jiang</h1>
    <span> {{name}}</span>
</div>

经过编译后(模板转化成 render 函数)变成如下代码

ts
// _openBlock 表示创建一个块,用于保存其下所有子节点中的动态节点
const vnode =
      (_openBlock(),
       _createElementBlock('div', null, [
          _createElementVNode('h1', null, 'Hello Jiang'),
          // 这行末尾的 1 是动态标识,此处表示此节点有一个动态的 text 文本
          _createElementVNode('span', null, _toDisplayString(_ctx.name), 1 /*TEXT*/)
      ]))

1.实现 _openBlock、_createElementBlock 方法。

openBlock 方法实际上就只是创建了一个空数组,待会用于保存其中的所有动态节点。

createElementBlock 在创建 vnode 之前会先判断当前节点是否包含动态属性,如果有会将其保存至 openBlock 创建的数组中。

ts
let currentBlock = null
export function openBlock() {
    currentBlock = [] // 用于收集动态节点的
}
export function closeBlock() {
    currentBlock = null
}
export function setupBlock(vnode) {
    // 将有动态属性的节点用收集 currentBlock 保存起来,并保存到根节点(当前块的节点)的 vnode 中
    vnode.dynamicChildren = currentBlock
    closeBlock()
    return vnode
}
// block 有收集虚拟节点的功能
export function createElementBlock(type, props, children, patchFlag?) {
    return setupBlock(createVnode(type, props, children, patchFlag))
}

2.修改 patchElement 方法(上方渲染器 h 方法实现的第五个步骤)。当页面更新后,比对新元素时,会直接比对动态文本更新。如果当前 vnode 上存在动态节点,则直接调用 patchBlockchildren 循环比对动态节点是否发生变化。

ts
function patchElement(n1, n2, container) {
    const { patchFlag, dynamicChildren } = n2
    if (patchFlag) {
        // 只要当前 vnode 之上的 patchFlag 是 1 (表示此节点有动态文本)直接查看其文本是否一致,不一致直接更新文本
        // 无需再比对其他属性
        if (patchFlag & PatchFlags.TEXT)
            if (n1.children !== n2.children) {
                // 只要文本是动态的只比较文本
                hostSetElementText(el, n2.children)
            }
    }
    // 查看当前 vnode 上是否存在动态子节点(上方创建 vnode 时添加的属性)
    if (dynamicChildren) {
        // 线性比对,直接比对动态节点,无需再比对其他节点
        patchBlockchildren(n1, n2, el, anchor, parentComponent)
    } else {
        // 全量diff
        patchchildren(n1, n2, el, anchor, parentcomponent)
    }
    // 其他比对逻辑......
}

function patchBlockchildren(n1, n2, el, anchor, parentComponent) {
    // 循环比对动态子节点
    for (let i = 0; i < n2.dynamicChildren.length; i++) {
        patch(n1.dynamicChildren[i], n2.dynamicChildren[i], el, anchor, parentComponent)
    }
}

BlockTree

有些指令或操作会改变 DOM 结构,再像上面的直接收集动态节点可能会出错。如使用 v-if 时就需要创建多个个 openBlock 分别保存动态节点,还有 v-for 时也会创建多个 Block 但父 Block 不会收集此上的动态节点,因为 v-for 渲染出来的 DOM 结构不稳定(实际渲染出的 DOM 数量可能会多也可能会少),除非 v-for 渲染的数量是确定的父 Block 才会收集此上的动态节点。

vue
<div>
    <p v-if="flag">
        <span>{{ a }}</span>
    </p>
    <div v-else>
        <span>{{ a }}</span>
    </div>
</div>

静态提升

vue 会在编译阶段将用户代码中不会发生变化的属性单独提取出来并打上标记,节点对比时会根据此标记直接跳过此节点的比对,而且此后每次重新渲染就无需再重新创建对应属性以及 DOM,提升性能。

用户代码

vue
<div>
    <span>hello</span>
    <span a="1"b="2">{{name}}</span>
    <a><span>{{age}}</span></a>
</div>

编译后的代码

ts
// 将不会发生变化的节点或属性单独提取出来,每次 render 就直接使用即可,无需重新创建
const _hoisted_1 = /*#_PURE_*/ _createElementVNode('span', null, 'hello', -1)
const _hoisted_2 = {
  a: '1',
  b: '2'
}

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock('div', null, [
      _hoisted_1,
      _createElementVNode('span', _hoisted_2, _toDisplayString(_ctx.name), 1 /*TEXT */),
      _createElementVNode('a', null, [
        _createElementVNode('span', null, _toDisplayString(_ctx.age), 1 /*TEXT */)
      ])
    ])
  )
}

预字符串化

编译时如果发现大量重复一模一样的静态节点,会将这些节点保存为一个字符串一次性创建并单独提取出来,每次更新时无需再重新创建 DOM。

用户代码

vue
<span></span>
<!-- 此处省略 18 个一模一样的 <span></span> -->
<span></span>

编译后

ts
const _hoisted_1 = /*#_PURE_*/ _createStaticVNode('<span></span><span></span><span>')
const _hoisted_21 = [_hoisted_1]
export function render(_ctx, _cache, $props, $setup, $data, $options) {
    return _openBlock(), _createElementBlock('div', null, _hoisted_21)
}

函数缓存

vue 编译时会将函数缓存起来,render 重新执行时直接从缓存中读取,而无需重新创建函数。

用户代码

vue
<div @click="e=>v=e.target.value"></div>

编译后

ts
export function render(_ctx, _cache, $props, $setup, $data, $options) {
    return (
        _openBlock(),
        _createElementBlock('div', {
            onClick: _cache[0] || (_cache[0] = e => (_ctx.v = e.target.value))
        })
    )
}

模板编译

vue 在编译时会将用户写的模板代码转换成 render 函数。大致分为三步:

1)将用户写的模板代码转换成 ast 语法树(用对象的形式描述代码结构)

2)遍历 ast 语法树并对其进行优化(当标记,patchFlag 即给每个节点打上对应副作用标识,是组件还是普通节点等)

3)根据优化后的 ast 语法树将其转换成字符串(render 函数字符串)。

转化成 ast

ts
// 解析 template 转化成 ast 抽象语法树。本质就是用一棵树来描述用户书写的代码
parse("<div>内容</div>")

1.创建 parse 方法用于解析用户 template 字符串。先根据原始用户书写的 template 字符串生成一个上下文,用于记录解析的位置,再创建根节点并调用 parseChilren 递归处理子节点。

ts
function createParserContext(content) {
    return {
        // 原始用户书写的 template 字符串
        originalSource: content,
        // 当前解析到的字符串,解析一段删一段所以会不断减少
        source: content,
        // 当前解析到的行
        line: 1,
        // 当前解析到的列
        column: 1,
        // 当前解析到的偏移量
        offset: 0
    }
}

function parseChilren(context) {}

// 创建根节点
function createRoot(children) {
    return {
        type: NodeTypes.ROOT,
        children
    }
}

function parse(template) {
    // 根据 template 记录解析位置的上下文
    const context = createParserContext(template)
    // 递归解析子节点
    return createRoot(parseChilren(context))
}

2.实现 parseChilren 。循环遍历字符串需要根据不同情况做不同处理。

ts
function parseChilren(context) {
    const nodes = []
    // 一直解析到 source 为空才结束解析
    while (context.source) {
        // 现在解析的内容
        const c = context.source 
        let node
        if (c.startsWith('{{')) {
            // {{}
            node = '表达式'
        } else if (c[0] === '<') {
            // <div>
            node = parseElement(context)
        } else {
            // 文本
            node = parseText(context)
        }
        nodes.push(node)
    }
    return nodes
}

3.实现 parseText 方法。实现对普通文本的解析。遍历当前字符串看是否包含元素标签或表达式符号,如果有则取找到最近的词法位置并截取,这就是普通文本,否则则整段字符串都为文本,再根据首尾索引截取字符串并将其从 source 中移除,最后再将记录当前解析成果(这段字符串对应的文本类型,以及对应内容)

ts
function parseText(context) {
    let tokens = ['<', '{{'] // 找当前离着最近的词法
    let endIndex = context.source.length //先假设找不到
    for (let i = 0; i < tokens.length; i++) {
        const index = context.source.indexof(tokens[i], 1)
        if (index !== -1 && endIndex > index) {
            endIndex = index
        }
    }
    // 根据首尾字符串索引截取字符串并将其从 source 中移除
    let content = parseTextData(context, endIndex)
    return {
        type: NodeTypes.TEXT,
        content
    }
}

// 从 source 中移除
function advanceBy(context, endIndex) {
    let c = context.source
    context.source = c.slice(endIndex)
}

// 截取字符串
function parseTextData(context, endIndex) {
    const content = context.source.slice(0, endIndex)
    advanceBy(context, endIndex)
    return content
}

4.实现 parseElement 方法。使用正则匹配标签,并将当前标签保存起来(之后继续遍历发现存在不成对出现的标签则报错)并删除结尾标签,随后依次递归子元素依次解析。

ts
function parseElement(context) {
    const ele = parseTag(context)
    // 调用上面实现的 parseChilren 递归解析子元素
    const children = parseChilren(context)
    // 判断解析完后是否还存在闭合标签,如果存在则再次解析将其删除
    if (context.source.startsWith('</')) {
        parseTag(context)
    }
    ele.children = children
    return ele
}

function parseTag(context) {
    // 匹配标签
    const match = /^<\/?([a-z][\t\r\n/>]*)/.exec(context.source)
    const tag = match[1]
    // 删除已经解析完成的字符串
    advanceBy(context, match[0].length)
    // 删除标签中的空格
    advanceSpaces(context)
    // 查看当前是否是自闭合标签
    const isSelfclosing = context.source.startsWith('/>')
    // 如果是自闭合标签,则删除 /> 否则删除一个
    advanceBy(context, isSelfClosing ? 2 : 1)
    // 返回解析成果
    return {
        // 元素类型
        type: NodeTypes.ELEMENT,
        tag,
        // 记录当前标签是否是自闭合标签
        isSelfclosing
    }
}

4.在上一步的基础上解析标签上的属性。循环遍历字符找出 ">" 之前的字符,再使用正则匹配标签上类似 "a=1" 的字符,再分别取出 "=" 两边的字符作为标签属性名和对应的属性值,将这些信息记录并返回即可。

ts
// 先在上一个步骤的基础上进一步处理标签属性
function parseTag(context) {
    // 其他逻辑......
    // 在删除完标签空格后解析标签上的属性
    let props = parseAtrributes(context)

    return {
        // 其他信息
        // 记录标签上的属性
        props
    }
}

function parseAttributeValue(context) {
    let quote = context.source[0]
    // 查看当前字符是单引号还是双引号
    const isQuoted = quote === '"' || quote === "'"
    let content
    if (isQuoted) {
        // 删除左边引号
        advanceBy(context, 1)
        // 查找右边引号的位置
        const endIndex = context.source.indexOf(quote, 1)
        // 解析引号之间的属性值
        content = parseTextData(context, endIndex)
        // 删除右边引号
        advanceBy(context, 1)
    } else {
        // 如果属性值并未用引号包裹,则匹配结束符 ">"
        content = context.source.match(/([^\t\r\n/>])+/)[1]
        // 删除已经解析过的字符
        advanceBy(context, content.length)
        // 删除空格
        advanceSpaces(context)
    }
    return content
}

function parseAttribute(context) {
    // 使用正则匹配属性
    let match = /^[^\t\r\n\f/>][^\t\r\n\f/>=]*/.exec(context.source)
    // 拿到属性名
    const name = match[0]
    // 删除已经解析过的字符
    advanceBy(context, name.length)
    let value
    // 匹配属性值
    if (/^[\t\r\n\f ]*=/.test(context.source)) {
        // 删除属性值之前的多余空格
        advanceSpaces(context)
        // 删除 "="
        advanceBy(context, 1)
        // 删除属性值之后的多余空格
        advanceSpaces(context)
        // 解析属性值
        value = parseAttributeValue(context)
    }

    return {
        type: NodeTypes.ATTRIBUTE,
        value: {
            type: NodeTypes.TEXT,
            content: value,
        }
    }
}

function parseAtrributes(context) {
    const props = []
    // 当前字符不是 ">" 时并且 source 不为空,继续解析属性
    while (context.source.length > 0 && !context.source.startsWith('>')) {
        props.push(parseAttribute(context))
        // 删除空格
        advanceSpaces(context)
    }
    return props
}

代码转换

进阶上一个大步骤,将用户代码转换成 ast ,此步骤继续进一步处理 ast ,在每个节点上添加更多细节,如 openblock 等,根据节点记录对应需要使用何种方法创建。方便下一步代码生成。

1.先创建转换上下文,用于记录当前遍历的节点信息以及处理函数。再根据上一个大步骤中转化的 ast 进行递归遍历。根据不同节点执行不同的转化逻辑。

ts
// 转化元素
function transformElement(node, contex) {
    if (NodeTypes.ELEMENT == node.type) {
        console.log('处理元素', node)
        // 返回一个函数用于延迟处理
        return function () {
            console.log('处理元素后触发')
        }
    }
}
// 转化文本
function transformText(node, contex) {
    if (NodeTypes.ELEMENT == node.type || node.type === NodeTypes.ROOT) {
        console.log(node, '元素中含有文本')
        // 返回一个函数用于延迟处理
        return function () {
            console.log('文本处理后触发')
        }
    }
}
// 转化表达式
function transformExpression(node, contex) {
    if (NodeTypes.INTERPOLATION == node.type) {
        node.content.content = `_ctx.${node.content.content}`
    }
}

function createTransformContext(root) {
    const context = {
        // 当前遍历的节点
        currentNode: root,
        // 父节点
        parent: null,
        // 转化时需要的工具函数
        transformNode: [transformElement, transformText, transformExpression],
        // 用于记录调用方法的次数,如 reateElementVnode 调用了几次,如果多次调用则可以将此函数放到 render 函数之外,不用每次都重新创建,提升性能
        helpers: new Map(),
        // 记录调用方法的次数函数
        helper(name) {
            let count = context.helpers.get(name) || 0
            context.helpers.set(name, count + 1)
            return name
        }
    }
    return context
}

function traverseNode(node, context) {
    context.currentNode = node
    const transforms = context.transformNode
    // 保存转化元素、文本、表达式的方法返回函数用于延迟处理
    const exits = []
    // 依次拿出转化元素、文本、表达式的方法执行
    for (let i = 0; i < transforms.length; i++) {
        transforms[i](node, context)
        exit && exits.push(exit)
    }
    // 根据当前节点类型递归遍历子节点
    switch (node.type) {
        case NodeTypes.ROOT:
        case NodeTypes.ELEMENT:
            for (let i = 0; i < node.children.length; i++) {
                context.parent = node
                traverseNode(node.children[i], context)
            }
            // 对表达式的处理
        case NodeTypes.INTERPOLATION:
            context.helper('oDisplayString')
    }
    // 重置当前节点。因为 traverseNode 会将 node 变成子节点
    context.currentNode = node
    // 依次拿出转化元素、文本、表达式的方法返回函数,并依次执行
    let i = exits.length
    if (i > 0) {
        while (i--) {
            exits[i]()
        }
    }
}

function transform(ast) {
    // 创建上下文
    const context = createTransformContext()
    // 递归遍历语法树,构建字符串
    traverseNode(ast, context)
}

function compile(template) {
    const ast = parse(template)
    // 代码转化
    transform(ast)
}

2.实现 transformText 方法。用于转化文本元素。会先遍历当前节点,如果当前节点中包含文本则会向后继续遍历查找其他文本,最后将所有文本合并,并打上对应标记。

ts
// 转化文本
function transformText(node, contex) {
    if (NodeTypes.ELEMENT == node.type || node.type === NodeTypes.ROOT) {
        // 等待子节点全部处理后,再赋值给父元素
        return function () {
            const children = node.children
            let container = null
            // 拿到子节点遍历,看是否还是文本如果是则还要继续遍历后续节点,再看是否是文本,如果是则保存至
            // container 中,将其合并。用以应对<div>a{{b}} c</div> 的情况
            for (let i = 0; i < children.length; i++) {
                let child = children[i]
                if (isText(child)) {
                    for (let j = i + 1; j < children.length; j++) {
                        const next = children[j]
                        if (isText(next)) {
                            if (!container) {
                                container = children[i] = {
                                    // 定义合并后的节点类型
                                    type: NodeTypes.COMPOUND_EXPRESSION,
                                    children: [child]
                                }
                            }
                            // 合并文本
                            container.children.push('+', next)
                            // 删除已经遍历的字符
                            children.splice(j, 1)
                            j--
                        } else {
                            // 如果不是文本则制空容器跳出循环
                            container = null
                            break
                        }
                    }
                }
            }
        }
    }
}

3.实现 transformElement 方法。遍历节点上的所有属性,将所有属性合并为一个,并最终打上标记,和记录节点信息。

ts
// 转化元素
function transformElement(node, contex) {
    if (NodeTypes.ELEMENT == node.type) {
        console.log('处理元素', node)
        // 返回一个函数用于延迟处理
        return function () {
            // 获取 ast 上元素的属性、标签名、子节点
            let { tag, props, children } = node
            let vnodeTag = tag
            // 用于存放标签上的多个属性
            let properties = []
            // 遍历节点上的属性并以对象的方式存入 properties
            for (let i = 0; i < props.length; i++) {
                properties.push({ key: props[i].name, value: props[i].value.content })
            }
            // 将 properties 里的多个对象(即标签上的多个属性)转化成一个对象保存
            const propsExpression = properties.length > 0 ? createobjectExpression(properties) : null
            let vnodeChildren = children.length == 1 ? children[0] : children
            node.codegenNode = createVnodeCall(context, vnodeTag, propsExpression, vnodechildren)
        }
    }
}

4.根据子元素个数做不同处理,当子元素有多个时用 createElementBlock 包裹,一个则需要删掉 createElementVnode 换成 createElementBlock 包裹。

ts
function createRootCodegenNode(ast: { children }, context) {
    if (children.length == 1) {
        let child = children[0]
        // 如果子节点是元素节点且只有一个子节点
        if (child.type === NodeTypes.ELEMENT) {
            ast.codegenNode = child.codegenNode
            // 删掉 createElementVnode 换成 createElementBlock 包裹
            context.removeHeLper(CREATE_ELEMENT_VNODE)
            context.helper(CREATE_ELEMENT_BLOCK)
            // 并在外层用 openblock 包裹
            context.helper(OPEN_BLOCK)
        } else {
            // 如果子节点不是元素节点,则不做处理
            ast.codegenNode = child
        }
    } else {
        // 如果子节点有多个,则用 createElementBlock 包裹起来,并且在外层用 Fragment 包裹
        ast.codegenNode = createVnodeCall(context, context.helper(Fragment), undefined, children)
        context.helper(CREATE_ELEMENT_BLOCK)
        context.helper(OPEN_BLOCK)
    }
}
function transform(ast) {
    // 创建上下文
    const context = createTransformContext()
    // 递归遍历语法树,构建字符串
    traverseNode(ast, context)

    // 对根节点进行处理,当子节点有多个时,需要将其用 createElementBlock 包裹起来
    // 一个则需要删掉 createElementVnode 换成 createElementBlock 包裹即可
    createRootCodegenNode(ast, context)
}

代码生成

紧接上一个大步骤,根据处理后的 ast 拼接 render 函数字符串。

ts
function compile(template) {
    const ast = parse(template)
    // 进行代码的转化
    transform(ast)
    // 根据 ast 生成代码字符串,即 render 函数
    return generate(ast)
}

function generate(ast) {
    // 生成上下文,获取工具方法
    const context: { push; indent; deindent; newLine } = createCodegencontext(ast)
    // 生成 render 函数
    genFunctionPreamble(ast, context)
    // 生成 render 函数结尾代码字符串
    indent()
    push(`return`)
    deindent()
    push(`}`)
    // 返回最终的代码字符串
    return context.code
}

function genFunctionPreamble(ast, { push, indent, deindent, newLine }) {
    if (ast.helpers.length > 0) {
        // 生成引入 vue 函数代码字符串
        push(`const {${ast.helpers.map(item => `${helperNameMap[item]}:${helper(item)}`)}} = Vue`)
    }
    // 换行
    newLine()
    // 生成 render 函数开头字符串
    push(`return function render(_ctx){`)
    // 生成 render 函数主体内容字符串
    genNode(ast.codegenNode, context)
}

function genNode(node, { push, indent, deindent, newLine, helper }) {
    switch (node.type) {
        case NodeTypes.TEXT:
            // 如果是纯文本内容直接存入即可
            push(JSON.stringify(node.content))
            break
        case NodeTypes.INTERPOLATION:
            // 如果是插值表达式,需要在表达式外拼接上 _toDisplayString 方法
            push(`${helper(TO_DISPLAY_STRING)}(`)
            // 拼接上插值表达式的内容
            break
        case NodeTypes.VNODE_CALL:
            // 如果是元素节点,需要先拼接上 openblock 再拼接 _createElementVNode 方法
            push(helper(OPEN_BLOCK))
            push(helper(CEATE_ELEMENT_VNODE))
            // 最后再根据节点上的属性子元素等拼接上对应的代码字符串
            break
    }
}
function createCodegencontext(ast) {
    const context = {
        // 目前生成的代码
        code: '',
        // 缩进等级
        level: 0,
        helper(name) {
            // 找到对应的函数包裹
            // 生成的代码都需要以 _ 开头
            return '_' + helperNameMap[name]
        },
        // 添加生成的代码
        push(code) {
            context.code += code
        },
        // 处理代码换行缩进
        indent() {
            newLine(++context.level)
        },
        // 处理代码换行缩进
        deindent() {
            newLine(--context.level)
        }
    }
    return context
}