🔥从零手写vue2 - 虚拟节点以及createElement函数

300 阅读8分钟

本专栏是打算从零手写一个 vue2,并学习 vue2 中的一些核心理念。

目录结构尽量和 vue2 保持一致。

1. 渲染器的渲染流程

在讨论虚拟节点之前,我们先来了解一下浏览器渲染的流程。

当浏览器接收到一个 HTML 文件后,JavaScript 引擎与浏览器的渲染引擎随即开始运行。

从渲染引擎的角度来看,它首先会把 HTML 文件解析为一个 DOM 树。

与此同时,浏览器会识别并加载 CSS 样式,然后将其与 DOM 树合并,形成一个渲染树。

在有了渲染树之后,渲染引擎会计算所有元素的位置信息,最后通过绘制操作,在屏幕上呈现出最终的内容。

JavaScript 引擎和渲染引擎虽然处于两个独立的线程之中,然而 JavaScript 引擎却能够触发渲染引擎开始工作。

当我们借助脚本去更改元素的位置或者外观时,JavaScript 引擎会运用与 DOM 相关的 API 方法来操作 DOM 对象。

此时渲染引擎便开始运作,渲染引擎会触发回流或者重绘操作。

我们来了解下回流以及重绘的概念:

  • 回流:当我们对DOM的修改引发了元素尺寸的变化时,浏览器需要重新计算元素的大小和位置,最后将重新计算的结果绘制出来,这个过程称为回流。

  • 重绘:当我们对DOM的修改只单纯改变元素的颜色时,浏览器此时并不需要重新计算元素的大小和位置,而只要重新绘制新样式。这个过程称为重绘。

很显然,回流比起重绘更加消耗性能。

通过了解浏览器基本的渲染机制,我们不难联想到,当不断地通过 JavaScript 修改 DOM 时,很容易在不经意间触发渲染引擎的回流或者重绘,而这种操作所带来的性能开销是非常巨大的。

因此,为了降低性能开销,我们需要做的是尽可能地减少对 DOM 的操作。

虚拟节点就是在这种情况下孕育而生。

2. 缓冲层-虚拟DOM

虚拟 DOM (Virtual DOM 以下简称 VDOM)是为了解决频繁操作 DOM 所引发的性能问题而产生的产物。

VDOM是把页面的状态抽象成 JS 对象的形式呈现。

从本质上来说,它处于 JS 与真实 DOM 之间,起着中间层的作用。

当我们需要使用 JS 脚本进行大批量的 DOM 操作时,会优先在虚拟 VDOM 这个 JS 对象上进行操作。

最后,通过对比找出将要改动的部分,并将这些改动通知并更新到真实的 DOM 上。

尽管最终仍然是对真实的 DOM 进行操作,然而虚拟 DOM 能够将多个改动合并为一个批量操作。

这样做可以减少 DOM 重排的次数,进而缩短生成渲染树以及进行绘制所花费的时间。

我们来看一下一个真实的 DOM 具体包含了哪些内容。

image-1.png

浏览器将真实的 DOM 设计得极为复杂。

它不但包含了自身的属性描述,如大小、位置等定义,还囊括了 DOM 所拥有的浏览器事件等内容。

正是由于其如此复杂的结构,我们频繁地去操作 DOM 或多或少会给浏览器带来性能方面的问题。

而作为数据与真实 DOM 之间的一层缓冲,虚拟 DOM 只是用于映射到真实 DOM 进行渲染,所以并不需要包含操作 DOM 的方法。

它只需在对象中重点关注几个属性就可以了。

3. VNode

// 真实DOM
<div id="app"><span>Hello World</span></div>

// 真实DOM对应的JS对象(VNode)
{
  tag:'div',
  data:{
    id:'app'
  },
  children:[{
    tag:'span',
    children:[
      {
        tag:undefined,
        text:'Hello World'
      }
    ]
  }]
}

通过上面的例子我们可以看出来每一个 DOM节点 都可以使用一个 VNode 来表示

在 Vue内部,使用 VNode 这个构造函数去描述一个节点。

3.1 编写Vnode构造函数

保持和 vue源码一致,我们新建 my-vue2/core/vdom/vnode.js。

export default class VNode {
    tag;
    data;
    children;
    text;
    elm;
    constructor({ tag, data, children, text, elm }){
        this.tag = tag;
        this.data = data;
        this.children = children;
        this.text = text;
        this.elm = elm;
    }
}

Vnode 定义的属性大约有二十几个。

显然,使用 Vnode 对象要比真实 DOM 对象所描述的内容简单得多。

它只单纯用来描述节点的关键属性,例如标签名、数据、子节点等。

并没有保留与浏览器相关的 DOM 方法。

除此之外,Vnode 还会有其他的属性,用以扩展 Vue 的灵活性。

这里我们先列举四个常用的属性:tag、data、children、text

在后续我们慢慢实现 vue功能的时候 会慢慢进行补充。

这里与vue2源码中不一致的是,在 constructor 中我们使用的是解析赋值,不然很有可能会因为参数顺序传递的不对导致存在问题。

3.1.1 tag

tag表示创建的虚拟节点的标签名称。

决定了最终会渲染成什么样的 DOM元素。

tag可以是 HTML 元素,比如字符串'span'、'div'

也可以是一个组件引用,同样可以是一个动态标签。

// 编译前(普通节点)
<div></div> 
// 编译后
VNode {
  tag:"div"
}
// 编译前(组件节点)
<CustomComponent></CustomComponent> 
// 编译后
VNode {
  tag:"CustomComponent"
}

3.1.2 data

data 参数通常是一个对象,包含了用于描述 VNode 的各种属性和配置信息。

data 参数可以由以下几种构成:

  • attrs:表示元素的上静态属性,如src、alt等。
  • staicClass:表示元素上的静态css类。
  • style:表示元素上的内联样式。
  • on:表示元素上的事件监听器。
  • slot:表示作用域插槽或普通插槽的位置。
  • props:表示传递组件的 props 数据。
  • directives:表示添加的自定义的行为,如 v-model、v-show等。
  • key:表示组件唯一标识。

3.1.3 children

children 参数是指定一个 VNode(虚拟节点)的子节点内容。

这个参数可以包含多种类型的数据,用于描述子节点的结构和内容。

3.1.4 text

我们知道并不是每个节点都有tag的,比如文字节点就没有tag。

在vue中,文字也代表一个vnode。

// 编译前
"我是" 
// 编译后
VNode {
  text:"我是"
}

3.1.4 elm

我们知道每个 vnode节点 都有对应的真实 DOM元素。

而这个 elm 就指向这个真实 DOM元素。

3.2 生成 VNode 的通用方法

上一节中,我们编写了 VNode 构造函数。

在 vue 源码中,为了避免重复调用生成 VNode,会有一些通用方法生成 VNode。

我们可以创建几个,方便后续调用。

3.2.1 createTextVNode

用于生成一个只有 text属性的文字节点。

需要注意文字节点也有对应的 DOM节点。

// 创建一个文字vnode
export const createTextVNode = (text) => {
  return new VNode({ text:String(text) })
}

4. createElement函数

经过上面的学习,我们知道虚拟 DOM就是一个JS对象。

只不过他有很多属性,所以创建一个虚拟 DOM也绝不是什么难事。

但是 vue 框架给我们提供了一个函数createElement。

4.1 createElement函数的优势

createElement函数的意义在于它提供了一种更方便、更简洁且更具可读性的方式来创建vnode,相比直接编写 VNode 具有以下好处:

4.1.1 直观的参数形式

使用createElement函数可以通过直观的参数来描述虚拟节点的属性。

相比之下,直接编写 VNode 对象时,需要手动构建一个包含多个属性的 JavaScript 对象,可能会导致代码较为冗长和复杂,降低了可读性和可维护性。

4.1.2 统一的创建方式

在项目中使用createElement函数可以确保虚拟节点的创建方式一致。

直接编写 VNode 对象可能会导致不同的开发者采用不同的方式来构建虚拟节点,从而降低了代码的一致性和可维护性。

4.1.3 动态属性和条件判断

createElement函数可以接收动态的参数,允许在运行时根据条件来决定虚拟节点的属性。例如,可以根据数据的变化动态地添加或修改属性,或者根据条件判断来决定是否创建某个子节点。

直接编写 VNode 对象时,要实现类似的动态行为可能需要更多的代码和逻辑处理,增加了代码的复杂性。

4.2 编写源码

新建my-vue2/core/vdom/create-element.js文件

import VNode from "./vnode"
import { isArray,isPrimitive } from "../util/index";

export function createElement(
    tag,
    data,
    children
){
    // 如果 data是数组或者是原始值
    // 则交换 children和 data的位置
    // 体现了vue的灵活性
    if (isArray(data) || isPrimitive(data)) {
        children = data
        data = undefined
    }
    return new VNode({
        tag,
        data,
        children
    })
}

可以看见这里只做了一件事,兼容第二个参数 data和 children。

源码中还有对组件的处理和一些兼容性处理,我们在后续的章节中会体现出来。

isArray和 isPrimitive用于判断是否是数组和原始值。而真实的 data不会出现这 2 种值。

5. 总结

为了避免重复操作真实 DOM 所带来的性能消耗,vue框架引入了虚拟 DOM。

虚拟 DOM本质上就是一个具有特有属性的一个 JS对象。

为了实现创建虚拟 DOM 的一致性,vue提供了一个方法 createElement 用来方便快捷的生成虚拟 DOM。