3

Vue 3 内部原理讲解,深入理解 Vue 3,创建你自己的 Vue 3。
Deep Dive into Vue 3. Build Your Own Vue 3 From Scratch.

本文完整内容可见,从零开始创建你自己的Vue 3

第1章 Vue 3总览

你能学到什么

  • 了解 Vue 3 核心模块的功能
  • 了解 Vue 3 整体的运行过程

核心模块

Vue 3有三个核心模块,分别是:

  • 响应式(reactivity)模块
  • 编译器(compiler)模块
  • 渲染器(renderer)模块

响应式模块

reactivity 模块用来创建响应式对象,我们可以监听这些对象的修改,当执使用了这些响应式对象的代码执行时,他们就会被跟踪,当响应式对象的值发生了变化时,这些被追踪的代码会重新执行。

编译器模块

compiler 模块是用来处理模板的,它把模板编译成 render 函数,它可以发生在浏览器的运行阶段,但更多的是在Vue项目构建时进行编译。

渲染器模块

renderer 模块处理 VNode,把组件渲染到 web 页上。它包含三个阶段:

  1. Render 阶段,调用 render 函数并返回一个 VNode
  2. Mount 阶段,render 接收 VNode,然后进行 JavaScript DOM 操作来创建 web 页;
  3. Patch 阶段,render 接收新旧两个 VNode,比较二者的不同,然后进行页面的局部更新。

运行过程

  1. compiler 把 HTML 编译成 render 函数;
  2. reactivity 模块初始化 reactive 对象;
  3. renderer 模块的 Render 阶段,调用引用了 reactive 对象的 render 函数,这样就监听了响应式对象,render 函数返回 VNode
  4. renderer 模块的 Monut 阶段,用 VNode 生成真实的 DOM 并渲染到页面上;
  5. 如果 reactive 对象发生了变化,将再次调用 render 函数创建新的 VNode,这时将进入 renderer 模块的 Patch 阶段,更新页面的变化。

第2章 渲染机制

你能学到什么

  • 了解 Virtual DOM 存在的意义
  • 了解 render 函数存在的必要性
  • 了解 tempalterender 的使用场景

Virtual DOM

Virtual DOM 是什么

Virtual DOM 就是用 JavaScript 对象来描述真实的 DOM 节点。

例如,有这样一段 HTML:

<div id="div">
  <button @click="click">click</button>
</div>

用 Virtual DOM 来表示可以是这样的(为什么说可以是这样,因为这完全取决于你的设计):

const vDom = {
  tag: 'div',
  id: 'div',
  children: [{
    tag: 'button',
    onClick: this.click
  }]
}

为什么要用 Virtual DOM

  1. 可跨平台,Virtual DOM 使组件的渲染逻辑和真实 DOM 彻底解耦,因此你可以很方便的在不同环境使用它,例如,当你开发的不是面向浏览器的,而是 IOS 或 Android 或小程序,你可以利用编写自己的 render 函数,把 Virtual DOM 渲染成自己想要的东西,而不仅仅是 DOM。
  2. 可程序式修改,Virtual DOM 提供了一种可以通过编程的方式修改、检查、克隆 DOM 结构的能力,你可以在把 DOM 返回给渲染引擎之前,先利用基本的 JavaScript 来处理好。
  3. 提升性能,当页面中有大量的 DOM 节点操作时,如果涉及到了浏览器的回流和重绘,性能是十分糟糕的,就像第二条说的,在 DOM 返回给渲染引擎之前,我们可以先用 JavaScript 处理 Virtual DOM,最终才返回真实 DOM,极大减少回流和重绘次数。

render 函数

render 函数是什么

首先我们知道,当你在编写 Vue 组件或页面时,一般会提供一个 template 选项来写 HTML 内容。根据Vue 3 总览这一部分内容的介绍,Vue 会先走编译阶段,把 template 编译成 render 函数,所以说最终的 DOM 一定是从 render 函数输出的。因此 render 函数可以用来代替 template,它返回的内容就是 VNode。直接使用 render 反而可以省去 complier 过程。

为什么要提供 render 函数

Vue 中提供的 render 函数是非常有用的,因为有些情况用 template 来表达业务逻辑会一定程度受到限制,这种情况你需要一种比较灵活的编程方式来表达底层的逻辑。

例如,当你有一个需求是大量的文本输入框,这中需求你要写的标签并不多,但是却揉了大量的交互逻辑,你需要在模板上添加大量的逻辑代码(比如控制关联标签的显示),然而,你的 JavaScript 代码中也有大量的逻辑代码。

render 函数的存在可以让你在一个地方写业务逻辑,这时你就不用太多的去考虑标签的问题了。

render 函数使用方法

有一段 template 如下:

template: '<div id="foo" @click="onClick">hello</div>'

Vue 3 中用 render 函数实现如下:

import { h } from 'vue'

render() {
 return h('div', {
   id: 'foo',
   onClick: this.onClick
 }, 'hello')
}

由于这是纯粹的 JavaScript,所以如果你需要实现 template 中类似 v-ifv-for 这样的功能,直接通过三元表达式做到。

import { h } from 'vue'

render() {
 let nodeToReturn

 // v-if="ok"
 if(this.ok) {
   nodeToReturn = h('div', { 
     id: 'foo', 
     onClick: this.onClick 
   }, 'ok')
 } else {
   // v-for="item in list"
   const children = this.list.map(item => {
     return h('p', { 
       key: item.id 
     }, item.text)
   })

   nodeToReturn = h('div', {}, children)
 }
 return nodeToReturn
}

这就是 render 基本使用用法,就是 JavaScript 代码而已。

render 函数使用场景

一般来说我们用 tempate 可以满足大多数场景来,但是你一定了解过 slot 这个东西,如果只使用 tempate 你将无法操作 slot 中的内容,如果你需要程序式地修改传进来的 slot 内容,你就必须用到 render 函数了(这也是大多数使用 render 函数的场景)。

下面我们用一个例子来说明。

比如我们要实现这样一个组件:实现层级缩进效果,即类似 HTML 中嵌套的 UL 标签,看起来就像这样:

level 1
  level 1-1
  level 1-2
    level 1-2-1
    level 1-2-2

我们的模板是这样写的,实际上 Stack 组件就是给每一个 slot 都增加一个左边距:

<Stack size="10">
  <div>level 1</div>
  <Stack size="10">
    <div>level 1-1</div>
    <div>level 1-2</div>
    <Stack size="10">
      <div>level 1-2-1</div>
      <div>level 1-2-2</div>
    </Stack>
  </Stack>
</Stack>

现在我们只用 template 是无法实现这种效果的,众所周知,template 只能把默认的 slot 渲染出来,它不能程序式处理 slot 的值。

我们先用 template 来实现这个组件,stack.html

const Stack = {
  props: {
    size: [String, Number]
  },
  template: `
    <div class="stack">
      <slot></slot>
    </div>
  `
}

这样由于不能处理 slot 内容,那么它的表现效果如下,并没有层级缩进:

level 1
level 1-1
level 1-2
level 1-2-1
level 1-2-2

我们现在尝试用 render 函数实现 Stack 组件:

const { h } = Vue
const Stack = {
  props: {
    size: [String, Number]
  },
  render() {
    const slot = this.$slots.default
      ? this.$slots.default()
      : []

    return h('div', { class: 'stack' }, 
      // 这里给每一项 slot 增加一个缩进 class
      slot.map(child => {
      return h('div', { class: `ml${this.$props.size}` }, [ child ])
    }))
  }
}

render 函数中我们可以通过 this.$slots 拿到插槽内容,通过 JavaScript 把它处理成任何我们想要的东西,这里我们给每一项 slot 添加了一个 margin-left: 10px 缩进,看下效果:

level 1
  level 1-1
  level 1-2
    level 1-2-1
    level 1-2-2

完美,我们实现了一个用 template 几乎实现不了的功能。

原则:

  • 一般来说开发一些公共组件时才会用到 render
  • 当你发现用 JavaScript 才能更好的表达你的逻辑时,那么就用 render 函数
  • 日常开发的功能性组件使用 template,这样更高效,且 template 更容易被 complier 优化

结束语

你可能会想,为什么不直接编译成 VNode ,而要在中间加一层 render 呢?

这是因为 VNode 本身包含的信息比较多,手写太麻烦,也许你写着写着不自觉就封装成了一个 helper 函数,h 函数就是这样的,它把公用、灵活、复杂的逻辑封装成函数,并交给运行时,使用这样的函数将大大降低你的编写成本。

知道了为什么要有 render 后,才需要去设计实现它,其实主要是实现 h 函数。

第3章 渲染器原理及其实现

你能学到什么

  • 了解 Vue 3 中的 VNode
  • 了解 render 的具体渲染原理
  • 了解 diff 算法的作用
  • 实现 Vue 3 中渲染器功能

编译器和渲染器 API初探

Complier 和 Renderer

Vue 3总览章节中,我们已经初步认识了编译器(complier)和渲染器( renderer)的作用。

  • 编译器是用来处理模板的,它把模板编译成 render 函数
  • 渲染器处理 VNode,把组件渲染到 web 页上

我们有这样一段 HTML:

<div id="div">
  <button @click="click">click</button>
</div>

编译器会先把它处理成 render 函数,类似下面的代码:

import { h } from 'vue'

render() {
  return h('div', {
    id: 'div',
  }, [
    h('button', {
      onClick: this.click
    }, 'click')
  ])
}

渲染器通过 render 函数获取对应的 VNode,类似这样:

const vDom = {
  tag: 'div',
  id: 'div',
  children: [{
    tag: 'button',
    onClick: this.click,
    text: 'click'
  }]
}

编译器(Complier)真实场景

上面是一个很简单的例子,实际上,Vue 3中的编译器做了很多的优化工作,比如判断你的节点是静态的还是动态的、缓存事件的绑定等等。所以如果你的组件用 template 实现的话,反而会被 Vue 优化。

我们通过 Vue 3在线模板编译系统 生成一段真实代码:

import { createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue"

export function render(_ctx, _cache) {
  return (_openBlock(), _createBlock("div", { id: "div" }, [
    _createVNode("button", { onClick: _ctx.click }, "click", 8 /* PROPS */, ["onClick"])
  ]))
}

// Check the console for the AST

可以看到和我们手写的 render 函数还是有比较大的差异。

设计 VNode

render 函数返回结果就是 h 函数执行的结果,因此 h 函数的输出为 VNode

所以需要先设计一下我们的 VNode

用 VNode 描述 HTML

一个 html 标签有它的标签名、属性、事件、样式、子节点等诸多信息,这些内容都需要在 VNode 中体现。

<div id="div">
  div text
  <p>p text</p>
</div>
const elementVNode = {
  tag: 'div',
  props: {
    id: 'div'
  },
  text: 'div text',
  children: [{
    tag: 'p',
    props: null,
    text: 'p text'
  }]
}

上面的代码显示了 DOM 变成 VNode 的表现形式,VNode 各属性解释:

  • tag :表示 DOM 元素的标签名,如 divspan
  • props:表示 DOM 元素上的属性,如idclass
  • children:表示 DOM 元素的子节点
  • text:表示 DOM 元素的文本节点

这样设计 VNode 完全没有问题(实际上 Vue 2 就是这样设计的),但是 Vue 3 设计的 VNode 并不包含 text 属性,而是直接用 children 代替,因为 text 本质也是 DOM 的子节点。

在保证语义讲得通的情况下尽可能复用属性,可以使 VNode 对象更加轻量

基于此我们把刚才的 VNode 修改成如下形式:

const elementVNode = {
  tag: 'div',
  props: {
    id: 'div'
  },
  children: [{
    tag: null,
    props: null,
    children: 'div text'
  }, {
    tag: 'p',
    props: null,
    children: 'p text'
  }]
}

用 VNode 描述抽象内容

什么是抽象内容呢?组件就属于抽象内容,比如下面这一段模板内容:

<div>
  <MyComponent></MyComponent>
</div>

MyComponent 是一个组件,我们预期渲染出 MyComponent 组件所有的内容,而不是一个 MyComponent 标签,这用 VNode 如何表示呢?

上一段内容我们其实已经通过 tag 是否为 null 来区分元素节点和文本节点了,那这里我们可以通过 tag 是否是字符串判断是标签还是组件呢?

const elementVNode = {
  tag: 'div',
  props: null,
  children: [{
    tag: MyComponent,
    props: null
  }]
}

理论上是可以的,Vue 2 中就是通过 tag 来判断的,具体过程如下,可以在这里看源码

  1. VNode.tag 如果不是字符串,则创建组件类型的 VNode
  2. VNode.tag 是字符串

    1. 若是内置的 htmlsvg 标签,则创建正常的 VNode
    2. 若是属于某个组件的 id,则创建组件类型的 VNode
    3. 未知或没有命名空间的组件,直接创建 VNode

以上这些判断都是在挂载(或 patch)阶段进行的,换句话说,一个 VNode 表示的内容需要在代码运行阶段才知道。这就带来了两个难题:无法从 AOT 的层面优化、开发者无法手动优化。

如果可以提前知道 VNode 类型,那么就可以对其进行优化,所以这里我们可以定义好一套用来判断 VNode 类型的规则,随便是用 FLAG = 1 这样的数字表示还是其它方法。

区分 VNode 类型

这里我们给 VNode 增加一个字段 shapeFlag(这是为了和 Vue 3 保持一致),它是一个枚举类型变量,具体如下:

export const enum ShapeFlags {
  // html 或 svg 标签
  ELEMENT = 1,
  // 函数式组件
  FUNCTIONAL_COMPONENT = 1 << 1,
  // 普通有状态组件
  STATEFUL_COMPONENT = 1 << 2,
  // 子节点是纯文本
  TEXT_CHILDREN = 1 << 3,
  // 子节点是数组
  ARRAY_CHILDREN = 1 << 4,
  // 子节点是 slots
  SLOTS_CHILDREN = 1 << 5,
  // Portal
  PORTAL = 1 << 6,
  // Suspense
  SUSPENSE = 1 << 7,
  // 需要被keepAlive的有状态组件
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
  // 已经被keepAlive的有状态组件
  COMPONENT_KEPT_ALIVE = 1 << 9,
  // 有状态组件和函数式组件都是“组件”,用 COMPONENT 表示
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}

现在我们可以修改我们的 VNode 如下:

const elementVNode = {
  shapeFlag: ShapeFlags.ELEMENT,
  tag: 'div',
  props: null,
  children: [{
    shapeFlag: ShapeFlags.COMPONENT,
    tag: MyComponent,
    props: null
  }]
}

shapeFlag 如何用来判断 VNode 类型呢?按位运算即可。

const isComponent = vnode.shapeFlag & ShapeFlags.COMPONENT

熟悉一下按位运算。

  • a & b:对于每一个比特位,只有两个操作数相应的比特位都是1时,结果才为1,否则为0。
  • a | b:对于每一个比特位,当两个操作数相应的比特位至少有一个1时,结果为1,否则为0。

我们把 ShapeFlags 对应的值列出来,如下:

ShapeFlags 操作 bitmap
ELEMENT 0000000001
FUNCTIONAL_COMPONENT 1 << 1 0000000010
STATEFUL_COMPONENT 1 << 2 0000000100
TEXT_CHILDREN 1 << 3 0000001000
ARRAY_CHILDREN 1 << 4 0000010000
SLOTS_CHILDREN 1 << 5 0000100000
PORTAL 1 << 6 0001000000
SUSPENSE 1 << 7 0010000000
COMPONENT_SHOULD_KEEP_ALIVE 1 << 8 0100000000
COMPONENT_KEPT_ALIVE 1 << 9 1000000000
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT

根据上表展示的基本 flags 值可以很容易地得出下表:

ShapeFlags bitmap
COMPONENT 00000001 10

区分 children 的类型

上面我们已经看到了 children 可以是数组或纯文本,但真实场景可能是:

  • null
  • 纯文本
  • 数组

这里我们可以增加一个 ChildrenShapeFlags 的变量表示 children 的类型,但是基于之前的设计原则,我们完全可以用 ShapeFlags 来表示,那么同一个 ShapeFlags 如何既用来表示 VNode 的类型,又用来表示其 children 的类型呢?

仍然是按位运算,我们通过 JavaScript 代码判断 children 类型,然后和当前 VNode 进行按位或运算即可。

我们增加如下函数用来专门处理子节点类型,这和 Vue 3 中的处理一致:

function normalizeChildren(vnode, children) {
  let type = 0
  if (children == null) {
    children = null
  } else if (Array.isArray(children)) {
    type = ShapeFlags.ARRAY_CHILDREN
  } else if (typeof children === 'string') {
    children = String(children)
    type = ShapeFlags.TEXT_CHILDREN
  }
  vnode.shapeFlag |= type
}

这样我们就可以直接通过 shapeFlag 同时判断 VNode 及其 children 类型了。

为什么 children 也需要标识呢?原因只有一个:为了 patch 过程的优化

定义 VNode

至此,我们可以定义 VNode 结构如下:

export interface VNodeProps {
  [key: string]: any
}
export interface VNode {
  // _isVNode 是 VNode 对象
  _isVNode: true
  // el VNode 对应的真实 DOM
  el: Element | null
  shapeFlag: ShapeFlags.ELEMENT,
  tag: | string | Component | null,
  props: VNodeProps | null,
  children: string | Array<VNode>
}

实际上,Vue 3 中对 VNode 的定义要复杂的多,这里就不去细看了。

生成 VNode 的 h 函数

基本的 h 函数

首先我们实现一个最简单的 h 函数,可以是这样的,接收三个参数:

  • tag 标签名
  • props DOM 上的属性
  • children 子节点

我们新建一个文件 h.ts,内容如下:

function h(tag, props, children){
  return {
    tag,
    props,
    children
  }
}

我们用如下的 VNode 来表示 <div class="red"><span>hello</span></div>

import { h } from './h'
const vdom = h('div', {
  class: 'red'
}, [
  h('span', null, 'hello')
])

看一下实际输出内容:

const vdom = {
  "tag": "div",
  "props": {
    "class": "red"
  },
  "children": [
    {
      "tag": "span",
      "props": null,
      "children": "hello"
    }
  ]
}

基本符合预期,但是这里有同学可能又要问了:“这个 vdom 和写的 h 函数没什么不同,为什么不直接写 VNode?”

这是因为我们现在的 h 函数所做的仅仅就是返回传入的参数,实际上根据我们对 VNode 的定义,还缺少一些字段,不过你也可以直接写 VNode,但这样会增加大量的额外工作。

完整的 h 函数

现在我们补全 h 函数,添加 _isVNodeelshapeFlag 字段。

function h(tag, props = null, children = null) {
  return {
    _isVNode: true,
    el: null,
    shapeFlag: null,
    tag,
    props,
    children
  }
}

这里的 _isVNode 永远为 trueel 不是在创建 VNode 的时候赋值,所以不用处理,我们主要处理 shapeFlag,实际上 shapeFlag 有 10 种类型,我们这里只实现一个最简单的判断:

function h(tag, props = null, children = null) {
  let shapeFlag = null
  // 这里为了简化,直接这样判断
  if (typeof tag === 'string') {
    shapeFlag = ShapeFlags.ELEMENT
  } else if(typeof tag === 'object'){
    shapeFlag = ShapeFlags.STATEFUL_COMPONENT
  } else if(typeof tag === 'function'){
    shapeFlag = ShapeFlags.FUNCTIONAL_COMPONENT
  }

  return {
    _isVNode: true,
    el: null,
    shapeFlag,
    tag,
    props,
    children
  }
}

现在我们需要处理一下 children 的类型了,VNode 章节中我们讲过其判断逻辑,那么 h 函数现在完整逻辑如下:

function h(tag, props = null, children = null) {
  let shapeFlag = null
  // 这里为了简化,直接这样判断
  if (typeof tag === 'string') {
    shapeFlag = ShapeFlags.ELEMENT
  } else if(typeof tag === 'object'){
    shapeFlag = ShapeFlags.STATEFUL_COMPONENT
  } else if(typeof tag === 'function'){
    shapeFlag = ShapeFlags.FUNCTIONAL_COMPONENT
  }

  const vnode = {
    _isVNode: true,
    el: null,
    shapeFlag,
    tag,
    props,
    children
  }
  normalizeChildren(vnode, vnode.children)
  return vnode
}

function normalizeChildren(vnode, children) {
  let type = 0
  if (children == null) {
    children = null
  } else if (Array.isArray(children)) {
    type = ShapeFlags.ARRAY_CHILDREN
  } else if (typeof children === 'object') {
    type = ShapeFlags.SLOTS_CHILDREN
  } else if (typeof children === 'string') {
    children = String(children)
    type = ShapeFlags.TEXT_CHILDREN
  }
  vnode.shapeFlag |= type
}

现在我们重新写一个测试代码看一下 h 函数输入结果:

import { h } from './h'
const MyComponent = {
  render() {}
}
const vdom = h('div', {
  class: 'red'
}, [
  h('p', null, 'hello'),
  h('p', null, null),
  h(MyComponent)
])

console.log(vdom);
// vdom:
// {
//   _isVNode: true,
//   el: null,
//   shapeFlag: 17,
//   tag: 'div',
//   props: { class: 'red' },
//   children: [
//     {
//       _isVNode: true,
//       el: null,
//       shapeFlag: 9,
//       tag: 'p',
//       props: null,
//       children: 'hello'
//     },
//     {
//       _isVNode: true,
//       el: null,
//       shapeFlag: 1,
//       tag: 'p',
//       props: null,
//       children: null
//     },
//     {
//       _isVNode: true,
//       el: null,
//       shapeFlag: 4,
//       tag: [Object],
//       props: null,
//       children: null
//     }
//   ]
// }

至此已经完成了 h 函数的基本设计,可以得到想要的 VNode 了,下一步就是把 VNode 渲染到页面上。

渲染 VNode 的 mount 函数

得到 VNode 之后,我们需要把它渲染到页面上,这就是渲染器的 Mount 阶段。

mount 函数基本原理

首先,新建一个 render.ts 文件,用来处理挂载相关代码。

mount 函数应该是这样,接收一个 VNode 作为参数,并把生成的 DOM 放进指定的容器 container 中,实现如下:

function mount(vnode, container) {
  const el = document.createElement(vnode.tag)
  contianer.appendChild(el);
}

这就是挂载所做的核心事情,不过这里我们还缺少具体要实现的内容:

  1. 根据不同 shapeFlag 生成不同 DOM
  2. 设置 DOM 的属性
  3. DOM 子节点的处理
  4. 生成 DOM 后需将其赋值给 vnode.el

解决 VNode 的类型问题

这里我们需要先了解一下普通有状态组件和函数式组件分别是什么,以下仅做理解用。

  • 普通有状态组件
const MyComponent = {
  render() {
    return h('div', null, 'stateful component')
  }
}
  • 函数式组件
function MyFunctionalComponent() {
  return h('div', null, 'function component')
}

我们根据 vnode.shapeFlag 的值来对各种类型 VNode 进行渲染操作。

function mount(vnode, container) {
  if (vnode.tag === null) {
    mountTextElement(vnode, container)
  } else if (vnode.shapeFlag & ShapeFlags.ELEMENT) {
    mountElement(vnode, container)
  } else if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
    mountStatefulComponent(vnode, container)
  } else if (vnode.shapeFlag & ShapeFlags.FUNCTIONAL_COMPONENT) {
    mountFunctionalComponent(vnode, container)
  }
}

function mountTextElement(vnode, container) {
  ...
}

function mountElement(vnode, container) {
  ...
}

function mountStatefulComponent(vnode, container) {
  ...
}

function mountFunctionalComponent(vnode, container) {
  ...
}
渲染文本节点
function mountTextElement(vnode, container) {
  const el = document.createTextNode(vnode.children)
  container.appendChild(el)
}
渲染标签节点
function mountElement(vnode, container) {
  const el = document.createElement(vnode.tag)
  container.appendChild(el)
}
渲染普通有状态组件

普通有状态组件就是一个对象,通过 render 返回其 VNode, 因此其渲染方法如下:

function mountStatefulComponent(vnode, container) {
  const instance = vnode.tag
  instance.$vnode = instance.render()
  mount(instance.$vnode, container)
  instance.$el = vnode.el = instance.$vnode.el
}
渲染函数式组件

函数式组件的 tag 为一个函数,返回值为 VNode,因此其渲染方法如下:

function mountFunctionalComponent(vnode, container){
  const $vnode = vnode.tag()
  mount($vnode, container)
  vnode.el = $vnode.el
}

设置 DOM 属性

这里为了简化,这里我们假设 props 的每一项都是 DOM 的 attribute,所以我们可以这样做:

function mountElement(vnode, container) {
  const el = document.createElement(vnode.tag)
  if(vnode.props){
    for(const key in vnode.props){
      const value = vnode.props[key]
      el.setAttribute(key, value)
    }
  }
  container.appendChild(el);
}

实际上,Vue 3 中 props 是一个扁平化的结构,它同时包含了 propertyattributeevent listener等,每一项都需要单独处理,如下:

props: {
  id: 'div',
  class: 'red',
  key: 'key1',
  onClick: this.onClick
}

简单解释 propertyattribute 的区别就是:attribute 是 DOM 自带的属性,如:idclassproperty 是自定义的属性名,如:keydata-xxx

渲染子节点

我们知道 children 可以是字符串或数组,因此实现方法如下:

function mountElement(vnode, container) {
  const el = document.createElement(vnode.tag)

  // props
  if(vnode.props){
    for(const key in vnode.props){
      const value = vnode.props[key]
      el.setAttribute(key, value)
    }
  }

  // children
  if(vnode.children){
    if(typeof vnode.children === 'string'){
      el.textContent = vnode.children
    }else{
      vnode.children.forEach(child => {
        mount(child, el)
      })
    }
  }

  container.appendChild(el);
}

关联 VNode 及其 DOM

这个只需要增加一行代码即可,其它函数类似:

function mountTextElement(vnode, container) {
  const el = document.createTextNode(vnode.children)
  vnode.el = el // (*)
  container.appendChild(el)
}

完整实现

现在我们实现了渲染器 mount 所有的功能,完整代码如下:

function mount(vnode, container) {
  if (vnode.tag === null) {
    mountTextElement(vnode, container)
  } else if (vnode.shapeFlag & ShapeFlags.ELEMENT) {
    mountElement(vnode, container)
  } else if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
    mountStatefulComponent(vnode, container)
  } else if (vnode.shapeFlag & ShapeFlags.FUNCTIONAL_COMPONENT) {
    mountFunctionalComponent(vnode, container)
  }
}

function mountTextElement(vnode, container) {
  const el = document.createTextNode(vnode.children)
  vnode.el = el
  container.appendChild(el)
}

function mountElement(vnode, container) {
  const el = document.createElement(vnode.tag)

  // props
  if(vnode.props){
    for(const key in vnode.props){
      const value = vnode.props[key]
      el.setAttribute(key, value)
    }
  }

  // children
  if(vnode.children){
    if(typeof vnode.children === 'string'){
      el.textContent = vnode.children
    }else{
      vnode.children.forEach(child => {
        mount(child, el)
      })
    }
  }

  vnode.el = el
  container.appendChild(el)
}

function mountStatefulComponent(vnode, container) {
  const instance = vnode.tag
  instance.$vnode = instance.render()
  mount(instance.$vnode, container)
  instance.$el = vnode.el = instance.$vnode.el
}

function mountFunctionalComponent(vnode, container){
  const $vnode = vnode.tag()
  mount($vnode, container)
  vnode.el = $vnode.el
}

完整示例

现在我们可以检验一下写的是否正确,新建 vdom.html,添加如下代码,并在浏览器中打开:

import { h } from './h'
import { mount } from './render'

const MyComponent = {
  render() {
    return h('div', null, 'stateful component')
  }
}
function MyFunctionalComponent() {
  return h('div', null, 'function component')
}

const vdom = h('div', {
  class: 'red'
}, [
  h('p', null, 'text children'),
  h('p', null, null),
  h(MyComponent),
  h(MyFunctionalComponent)
])

console.log(vdom);
mount(vdom, document.querySelector("#app"))

浏览器渲染结果,所有内容均正常显示。

至此,我们已经了解了 Vue 3 的基本渲染原理,并实现了一个简易版本的渲染器。

未完待续~(由于内容太多,后续将不在本文继续增加)

第4章 Vue 3响应式原理及实现

你能学到什么

  • 了解 reactive 设计理念
  • 开发独立的响应式库

详见 Vue 3响应式原理及实现

本文完整内容可见,build-your-own-vue-next


natee
138 声望114 粉丝