3.Vue.js3的设计思路

2024/8/21 Vue

​ 框架设计讲究全局视角的把控,一个项目就算再大,也是存在一条核心思路的,并围绕核心展开。

# 声明式地描述 UI

无论是手写虚拟 DOM(渲染函数)还是使用模板,都属于声明式地描述 UI,并且 Vue.js 同时支持这两种描述 UI 的方式。

# 模版

​ Vue.js 3 是一个声明式的 UI 框架,用户在使用 Vue.js 3 开发页面时是声明式地描述 UI 的。在 Vue.js 中,哪怕是事件,都有与之对应的描述方式。用户不需要手写任何命令式代码,这就是所谓的声明式地描述 UI。

比如:

  1. 使用与 HTML 标签一致的方式来描述 DOM 元素,例如描述一个 div 都是使用 <div></div>
  2. 使用与 HTML 标签一致的方式来描述属性,例如 <div id="root"></div>
  3. 使用 :v-bind 来描述动态绑定的属性,例如 <div :class="cls"></div>
  4. 使用 @v-on 来描述事件,例如 <div @click="handler"></div>
  5. 使用与 HTML 标签一致的方式来描述层级结构,例如<div><span></span></div>

# 虚拟 DOM

​ 除了可以使用模板来声明式地描述 UI 之外,我们还可以用 JavaScript 对象来描述:

 const title = {
   // 标签名称
   tag: 'h1',
   // 标签属性
   props: {
     onClick: handler
   },
   // 子节点
   children: [
     { tag: 'span' }
   ]
 }
1
2
3
4
5
6
7
8
9
10
11
12

​ 以上 JavaScript 对象,对应到 Vue.js 模板,其实就是:

 <h1 @click="handler"><span></span></h1>
1

​ 使用 JavaScript对象描述 UI 更加灵活。比如,我们要表示一个标题,根据标题级别的不同,会分别采用 h1~h6 这几个标签,如果用 JavaScript 对象来描述,我们只需要使用一个变量来代表 h 标签即可:

 // h 标签的级别
 let level = 3
 const title = {
   tag: `h${level}`, // h3 标签
 }
1
2
3
4
5

​ 可以看到,当变量 level 值改变,对应的标签名字也会在 h1 和 h6 之间变化。但是如果使用模板来描述,就不得不穷举:

 <h1 v-if="level === 1"></h1>
 <h2 v-else-if="level === 2"></h2>
 <h3 v-else-if="level === 3"></h3>
 <h4 v-else-if="level === 4"></h4>
 <h5 v-else-if="level === 5"></h5>
 <h6 v-else-if="level === 6"></h6>
1
2
3
4
5
6

​ 这远没有 JavaScript 对象灵活。使用 JavaScript 对象来描述 UI 的方式,其实就是所谓的虚拟 DOM。正是因为虚拟 DOM 的这种灵活性,Vue.js 3 除了支持使用模板描述 UI 外,还支持使用虚拟 DOM 描述 UI。

​ 其实我们在 Vue.js 组件中手写的渲染函数就是使用虚拟 DOM 来描述 UI 的:

 import { h } from 'vue'

 export default {
   render() {
     return h('h1', { onClick: handler }) // 虚拟 DOM
   }
 }
1
2
3
4
5
6
7

​ h() 函数就是一个辅助创建虚拟 DOM 的工具函数,其返回值是一个对象,h() 函数的作用就是把 DOM 描述成 JavaScript 对象(虚拟 DOM ),让我们编写虚拟 DOM 变得更加轻松。

​ 如果把上面 h() 函数调用的代码改成 JavaScript 对象,就需要写更多内容(如果还有子节点,那么需要编写的内容就更多了):

 export default {
   render() {
     return {
       tag: 'h1',
       props: { onClick: handler }
     }
   }
 }
1
2
3
4
5
6
7
8

​ 一个组件要渲染的内容是通过渲染函数来描述的,也就是上面代码中的 render 函数,Vue.js 会根据组件的 render 函数的返回值拿到虚拟 DOM,然后就可以把组件的内容渲染出来了。

虚拟 DOM 是如何渲染成真实 DOM 的

render 渲染函数的返回值就是虚拟 DOM 对象,renderer 渲染器函数就是将虚拟 DOM 变成真实 DOM。

虚拟 DOM 其实就是用来描述真实 DOM 的普通 JavaScript 对象,渲染器会把这个对象渲染为真实 DOM 元素。

# 初识渲染器

​ **渲染器的作用就是把虚拟 DOM 渲染为真实 DOM。**我们平时编写的 Vue.js 组件都是依赖渲染器来工作的。

渲染器核心原理

脚本式,偏向解决问题的步骤:第一步做什么,第二步做什么,笑第三步做什么。

声明式,直接描述你要做什么就好,别的都不用管,让声明式框架帮你去做。

​ 渲染器的核心原理,就是使用原生 JavaScript ,把虚拟 DOM 对象解析为真实 DOM 标签的过程。

假设我们有如下虚拟 DOM:

 const vnode = {
   // 用来描述标签名称
   tag: 'div', 
   // 用来描述标签的属性、事件等内容
   props: { 
     onClick: () => alert('hello')
   },
   // 用来描述标签的子节点
   children: 'click me' 
 }
1
2
3
4
5
6
7
8
9
10

实际上,你完全可以自己设计虚拟 DOM 的结构,例如可以使用 tagName 代替tag,因为它本身就是一个 JavaScript 对象,并没有特殊含义。

我们需要编写一个渲染器,把上面这段虚拟 DOM 渲染为真实 DOM:

 function renderer(vnode, container) {
   // 使用 vnode.tag 作为标签名称创建 DOM 元素
   const el = document.createElement(vnode.tag)
   // 遍历 vnode.props,将属性、事件添加到 DOM 元素
   for (const key in vnode.props) {
     if (/^on/.test(key)) {
       // 如果 key 以 on 开头,说明它是事件
       el.addEventListener(
         key.substr(2).toLowerCase(), // 事件名称 onClick ---> click
         vnode.props[key] // 事件处理函数
       )
     }
   }
   // 处理 children
   if (typeof vnode.children === 'string') {
     // 如果 children 是字符串,说明它是元素的文本子节点
     el.appendChild(document.createTextNode(vnode.children))
   } else if (Array.isArray(vnode.children)) {
     // 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
     vnode.children.forEach(child => renderer(child, el))
   }
   // 将元素添加到挂载点下
   container.appendChild(el)
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

接下来,我们可以调用 renderer 函数:

 renderer(vnode, document.body) // body 作为挂载点
1

vnode:虚拟 DOM 对象。

container:一个真实 DOM 元素,作为挂载点,渲染器会把虚拟 DOM 渲染到该挂载点下。

在浏览器中运行这段代码,会渲染出“click me”文本,点击该文本,会弹出alert('hello')

浏览器中运行这段代码

  • 现在我们回过头来分析渲染器 renderer 的实现思路,总体来说,创建节点分为三步:

    • 1、创建元素:把 vnode.tag 作为标签名称来创建 DOM 元素。

    • 2、为元素添加属性和事件:遍历 vnode.props 对象,如果 key 以 on 字符开头,说明它是一个事件,把字符 on 截取掉后再调用 toLowerCase 函数将事件名称小写化,最终得到合法的事件名称,例如 onClick 会变成 click,最后调用addEventListener 绑定事件处理函数。

    • 3、处理 children:如果 children 是一个数组,就递归地调用 renderer 继续渲染,注意,此时我们要把刚刚创建的元素作为挂载点(父节点);如果 children是字符串,则使用 createTextNode 函数创建一个文本节点,并将其添加到新创建的元素内。

  • 渲染器的精髓在于更新节点的阶段:

    • 假设我们对 vnode 做一些小修改,对于渲染器来说,它需要精确地找到 vnode 对象的变更点并且只更新变更的内容,渲染器应该只更新元素的文本内容,而不需要再走一遍完整的创建元素的流程。

      const vnode = {
        tag: 'div',
        props: {
          onClick: () => console.log('Hello world.')
        },
        children: 'Click again' // 从 Click me 变成 Click again
      }
      
      1
      2
      3
      4
      5
      6
      7
    • 渲染器的作用之一就是寻找并且只更新变化的内容

​ 归根结底,渲染器的工作原理,其实都是使用一些我们熟悉的 DOM 操作 API 来完成渲染工作。

# 组件的本质

​ 虚拟 DOM 除了能够描述真实 DOM 之外,还能够描述组件。组件就是一组 DOM 元素的封装,这组 DOM 元素就是组件要渲染的内容。

# 用函数结构描述

​ 我们可以定义一个函数来代表组件,而函数的返回值就代表组件要渲染的内容:

 const MyComponent = function () {
   return {
     tag: 'div',
     props: {
       onClick: () => alert('hello')
     },
     children: 'click me'
   }
 }
1
2
3
4
5
6
7
8
9

可以看到,组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。

​ 搞清楚了组件的本质,我们就可以定义用虚拟 DOM 来描述组件了。我们可以让虚拟 DOM 对象中的 tag 属性来存储组件函数:

 const vnode = {
   tag: MyComponent // 描述组件,此时的tag属性不是标签名称,而是组件函数
 }
1
2
3

就像 { tag: 'div' } 来描述 div 标签一样,使用 { tag: myComponent } 来描述组件,只不过 tag 的值不再是一个标签名称,而是一个组件函数。

​ 为了能够渲染组件,需要渲染器的支持,我们需要修改前面提到的 renderer() 函数:

 function renderer(vnode, container) {
   if (typeof vnode.tag === 'string') {
     // 说明 vnode 描述的是标签元素
     mountElement(vnode, container)
   } else if (typeof vnode.tag === 'function') {
     // 说明 vnode 描述的是组件
     mountComponent(vnode, container)
   }
 }
1
2
3
4
5
6
7
8
9

如果 vnode.tag 的类型是字符串,说明它描述的是普通标签元素,此时调用 mountElement 函数完成渲染;

如果 vnode.tag 的类型是函数(或对象),则说明它描述的是组件,此时调用 mountComponent 函数完成渲染。

​ 其中 mountElement 函数与上文中 renderer 函数的内容一致:

 function mountElement(vnode, container) {
   // 使用 vnode.tag 作为标签名称创建 DOM 元素
   const el = document.createElement(vnode.tag)
   // 遍历 vnode.props,将属性、事件添加到 DOM 元素
   for (const key in vnode.props) {
     if (/^on/.test(key)) {
       // 如果 key 以字符串 on 开头,说明它是事件
       el.addEventListener(
         key.substr(2).toLowerCase(), // 事件名称 onClick--->click
         vnode.props[key] // 事件处理函数
       )
     }
   }
   // 处理 children
   if (typeof vnode.children === 'string') {
     // 如果 children 是字符串,说明它是元素的文本子节点
     el.appendChild(document.createTextNode(vnode.children))
   } else if (Array.isArray(vnode.children)) {
     // 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
     vnode.children.forEach(child => renderer(child, el))
   }
   // 将元素添加到挂载点下
   container.appendChild(el)
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

​ 再来看 mountComponent 函数是如何实现的:

 function mountComponent(vnode, container) {
   // 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
   const subtree = vnode.tag()
   // 递归地调用 renderer 渲染 subtree
   renderer(subtree, container)
 }
1
2
3
4
5
6

首先调用 vnode.tag 函数,它其实就是组件函数本身,其返回值是虚拟DOM,即组件要渲染的内容,这里我们称之为 subtree。

既然 subtree 也是虚拟 DOM,那么直接调用renderer 函数完成渲染即可。

这样子,我们的 renderer() 函数也支持组件的渲染了。

# 用对象结构描述

​ 组件不一定得是函数,我们也可以用一个 JavaScript 对象来表达组件,例如:

 // MyComponent 是一个对象
 const MyComponent = {
   render() {
     return {
       tag: 'div',
       props: {
         onClick: () => alert('hello')
       },
       children: 'click me'
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12

该对象有一个 render 函数,其返回值代表组件要渲染的内容,是一个虚拟 DOM。

​ 为了完成这种组件的渲染,我们需要修改渲染器 renderer() 函数以及 mountComponent() 函数。

​ 首先,修改渲染器的判断条件:

 function renderer(vnode, container) {
   if (typeof vnode.tag === 'string') {
     mountElement(vnode, container)
   } else if (typeof vnode.tag === 'object') { // 如果是对象,说明 vnode 描述的是组件
     mountComponent(vnode, container)
   }
 }
1
2
3
4
5
6
7

现在我们使用对象而不是函数来表达组件,因此要将 typeof vnode.tag=== 'function' 修改为 typeof vnode.tag === 'object'。

​ 接着,修改 mountComponent 函数:

 function mountComponent(vnode, container) {
   // vnode.tag 是组件对象,调用它的render函数得到组件要渲染的内容(虚拟 DOM)
   const subtree = vnode.tag.render()
   // 递归地调用 renderer 渲染 subtree
   renderer(subtree, container)
 }
1
2
3
4
5
6

vnode.tag 是表达组件的对象,调用该对象的 render 函数得到组件要渲染的内容,也就是虚拟 DOM。

​ 其实 Vue.js 中的有状态组件就是使用对象结构来表达的。

有状态组件 无状态组件
指具有自己的状态(数据)的组件。通常使用对象结构(即 Vue 实例)来表达。这些组件可以使用 Vue 实例的数据、计算属性、生命周期钩子等功能,从而能够在组件内部保存和处理数据,并随着数据的改变而更新界面。 指没有自己的状态(数据)的组件。比如子组件通过 props 属性来获取父组件的属性,并且在函数中通过 this.$emit() 向父组件传递数据,而子组件本身并不保存数据和状态。无状态组件其实就是函数结构。

​ 使用渲染函数渲染的是虚拟 DOM,执行 render 函数,会将虚拟 DOM 对象中的标签、属性、子标签分别处理。通过标签创建元素,通过属性创建事件、样式等,通过子标签处理 DOM 层级关系。如果渲染的是组件,则实现步骤是:执行组件中返回 DOM 对象的函数,再执行解析渲染页面的 render 方法。

# 模板的工作原理

​ 将模板转换成虚拟 DOM 的工作是由 Vue.js 框架中的另一个重要组件部分:编译器,来实现的。编译器和渲染器一样,只是一段程序而已,不过它们的工作内容不同,编译器的作用其实就是将模板编译为渲染函数(然后添加为组件的属性)。

​ 例如,给出如下模板:

 <div @click="handler">
   click me
 </div>
1
2
3

​ 对于编译器来说,模板就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数:

 render() {
   return h('div', { onClick: handler }, 'click me')
 }
1
2
3

​ 以我们熟悉的 .vue 文件为例,一个 .vue 文件就是一个组件,如下所示:

 <template>
   <div @click="handler">
     click me
   </div>
 </template>

 <script>
 export default {
   data() {/* ... */},
   methods: {
     handler: () => {/* ... */}
   }
 }
 </script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 其中 <template></template> 标签里的内容就是模板内容,编译器会把模板内容编译成渲染函数并添加到 <script> 标签块的组件对象上。所以最终在浏览器里运行的代码就是:

 export default {
   data() {/* ... */},
   methods: {
     handler: () => {/* ... */}
   },
   render() {
     return h('div', { onClick: handler }, 'click me')
   }
 }
1
2
3
4
5
6
7
8
9

​ 我们可以整理一下这个过程:

1、编译器将 template 解析为 render 渲染函数。

2、渲染函数会返回虚拟 DOM 对象。

3、渲染器负责把虚拟 DOM 渲染为真实 DOM。

​ 无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM,这就是模板的工作原理,也是Vue.js 渲染页面的流程。

编译器将模板转换为虚拟 DOM,然后渲染器将虚拟 DOM 转化为真实 DOM。

组件可以是不同格式,比如函数、对象等约定格式,是对一组 DOM 元素的封装,渲染器就是按照一定的格式把组件的数据集转成真实 DOM。

# Vue.js 是各个模块组成的有机整体

​ 组件的实现依赖于渲染器,模板的编译依赖于编译器,并且编译后生成的代码是根据渲染器和虚拟 DOM 的设计决定的。因此,Vue.js 的各个模块之间是互相关联、互相制约的,共同构成一个有机整体。

假设我们有如下模版:

 <div id="foo" :class="cls"></div>
1

编译器会把这段代码编译成渲染函数:

01 render() {
02   // 为了效果更加直观,这里没有使用 h 函数,而是直接采用了虚拟 DOM 对象
03   // 下面的代码等价于:
04   // return h('div', { id: 'foo', class: cls })
05   return {
06     tag: 'div',
07     props: {
08       id: 'foo',
09       class: cls
10     }
11   }
12 }
1
2
3
4
5
6
7
8
9
10
11
12

这段代码中的 cls 是一个变量,它随时可能发生变化。渲染器的作用之一就是寻找并且只更新变化的内容,当变量 cls 的值发生变化时,渲染器会自行寻找变更点,但对于渲染器来说,这个“寻找”的过程需要花费一些力气。而从编译器的角度看,可以很容易的看出模板中会出现变化的点。

​ 编译器能识别出哪些是静态属性,哪些是动态属性,在生成代码的时候完全可以附带这些信息:

01 render() {
02   return {
03     tag: 'div',
04     props: {
05       id: 'foo',
06       class: cls
07     },
08     patchFlags: 1 // 假设数字 1 代表 class 是动态的
09   }
10 }
1
2
3
4
5
6
7
8
9
10

在生成的虚拟 DOM 对象中多出了一个 patchFlags 属性,用于描述某个属性是动态的,可能会发生变化的。这样,渲染器就省去了寻找变更点的工作量,性能自然就提升了。

​ 可见,编译器和渲染器之间是存在信息交流的,它们互相配合使得性能进一步提升,而它们之间交流的媒介就是虚拟DOM 对象。

# 总结

  • Vue.js 是一个声明式的框架。声明式的好处在于,它直接描述结果,用户不需要关注过程。
  • Vue.js 采用模板的方式来描述 UI,但它同样支持使用虚拟DOM 来描述 UI。虚拟 DOM 要比模板更加灵活,但模板要比虚拟DOM 更加直观。
  • 渲染器的作用是把虚拟DOM 对象渲染为真实 DOM 元素。它的工作原理是,递归地遍历虚拟DOM 对象,并调用原生 DOM API 来完成真实 DOM 的创建。渲染器的精髓在于后续的更新,它会通过 Diff 算法找出变更点,并且只会更新需要更新的内容。
  • 组件其实就是一组虚拟 DOM 元素的封装,它可以是一个返回虚拟 DOM 的函数,也可以是一个对象,但这个对象下必须要有一个函数用来产出组件要渲染的虚拟 DOM。
  • 渲染器在渲染组件时,会先获取组件要渲染的内容,即执行组件的渲染函数并得到其返回值,我们称之为 subtree,最后再递归地调用渲染器将subtree 渲染出来即可。
  • 编译器的作用是把模板编译为渲染函数。
  • 编译器、渲染器都是 Vue.js 的核心组成部分,它们共同构成一个有机的整体,不同模块之间互相配合,进一步提升框架性能。
上次更新: 2024/9/10 02:14:23