前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
  • Vue

    • 从源码解读Vue生命周期
    • 组件的本质
    • 有状态组件的设计
    • 设计 VNode
    • 辅助创建 VNode 的 h 函数
    • 自定义渲染器和异步渲染
    • 渲染器之挂载
    • 渲染器的核心 Diff 算法
    • 渲染器之patch
    • 图解 Vue 响应式原理
    • 图解 Vue 异步更新
    • 剖析 Vue 内部运行机制
    • vue响应式原理模拟
    • vue状态管理之vuex
    • 理解Vue的设计思想及实现Vue
    • diff算法深入
    • vue router vuex原理分析
    • Vue3初探响应式原理.
    • vue2源码分析
    • vue组件化实践
  • React

  • Webpack

  • Node

  • Javascript

  • 综合

文章学习导航
面试总览

30 秒速记

  • VNode 描述类型、属性、子节点与身份
  • 元素、文本、组件和片段需要不同信息
  • 类型标志方便分派,不是业务必需字段
  • 宿主节点引用连接描述与实际结果
  • 键负责身份,不能替代内容更新

设计虚拟节点,就是设计一份渲染器能看懂的界面数据协议。 元素要记录标签和属性,文本要保存内容,组件则要指向组件定义与输入,片段还涉及多个宿主节点。类型标志可以让更新路径快速分派,实际节点引用帮助复用已经创建的结果。字段名和位运算方式可以变化,核心是信息足够、语义清楚,并且能覆盖挂载、更新和卸载。

版本校准: 本文出现 Object.defineProperty、Dep、Watcher 和双端 Diff 时,主要描述 Vue 2 实现;Vue 3 使用 Proxy、effect 与新的渲染器路径。Vue 2 已于 2023-12-31 结束维护,新项目应以 Vue 3 为基线,旧项目参考 Vue 2 EOL 官方说明 制定迁移与安全策略。

上一章讲述了组件的本质,知道了一个组件的产出是 VNode,渲染器(Renderer)的渲染目标也是 VNode。可见 VNode 在框架设计的整个环节中都非常重要,甚至设计 VNode 本身就是在设计框架,VNode 的设计还会对后续算法的性能产生影响。本章我们就着手对 VNode 进行一定的设计,尝试用 VNode 描述各类渲染内容。

# 用 VNode 描述真实 DOM

一个 html 标签有它的名字、属性、事件、样式、子节点等诸多信息,这些内容都需要在 VNode 中体现,我们可以用如下对象来描述一个红色背景的正方形 div 元素:

const elementVNode = {
  tag: 'div',
  data: {
    style: {
      width: '100px',
      height: '100px',
      backgroundColor: 'red'
    }
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

我们使用 tag 属性来存储标签的名字,用 data 属性来存储该标签的附加信息,比如 style、class、事件等,通常我们把一个 VNode 对象的 data 属性称为 VNodeData。

为了描述子节点,我们需要给 VNode 对象添加 children 属性,如下 VNode 对象用来描述一个有子节点的 div 元素:

const elementVNode = {
  tag: 'div',
  data: null,
  children: {
    tag: 'span',
    data: null
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

若有多个子节点,则可以把 children 属性设计为一个数组:

const elementVNode = {
  tag: 'div',
  data: null,
  children: [
    {
      tag: 'h1',
      data: null
    },
    {
      tag: 'p',
      data: null
    }
  ]
}
@前端进阶之旅: 代码已经复制到剪贴板

除了标签元素之外,DOM 中还有文本节点,我们可以用如下 VNode 对象来描述一个文本节点:

const textVNode = {
  tag: null,
  data: null,
  children: '文本内容'
}
@前端进阶之旅: 代码已经复制到剪贴板

如上,由于文本节点没有标签名字,所以它的 tag 属性值为 null。由于文本节点也无需用额外的 VNodeData 来描述附加属性,所以其 data 属性值也是 null。

唯一需要注意的是我们使用 children 属性来存储一个文本节点的文本内容。有的同学可能会问:“可不可以新建一个属性 text 来存储文本内容呢?”

const textVNode = {
  tag: null,
  data: null,
  children: null,
  text: '文本内容'
}
@前端进阶之旅: 代码已经复制到剪贴板

这完全没有问题,这取决于你如何设计,但是尽可能的在保证语义能够说得通的情况下复用属性,会使 VNode 对象更加轻量,所以我们采取使用 children 属性来存储文本内容的方案。

如下是一个以文本节点作为子节点的 div 标签的 VNode 对象:

const elementVNode = {
  tag: 'div',
  data: null,
  children: {
    tag: null,
    data: null,
    children: '文本内容'
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

# 用 VNode 描述抽象内容

什么是抽象内容呢?组件就属于抽象内容,比如你在 模板 或 jsx 中使用了一个组件,如下:

<div>
  <MyComponent />
</div>
@前端进阶之旅: 代码已经复制到剪贴板

你的意图并不是要在页面中渲染一个名为 MyComponent 的标签元素,而是要渲染 MyComponent 组件所产出的内容。

但我们仍然需要使用 VNode 来描述 <MyComponent/>,并给此类用来描述组件的 VNode 添加一个标识,以便在挂载的时候有办法区分一个 VNode 到底是普通的 html 标签还是组件。

我们可以使用如下 VNode 对象来描述上面的模板:

const elementVNode = {
  tag: 'div',
  data: null,
  children: {
    tag: MyComponent,
    data: null
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

如上,用来描述组件的 VNode 其 tag 属性值引用的就是组件类(或函数)本身,而不是标签名称字符串。所以理论上:我们可以通过检查 tag 属性值是否是字符串来确定一个 VNode 是否是普通标签。

除了组件之外,还有两种抽象的内容需要描述,即 Fragment 和 Portal。我们先来了解一下什么是 Fragment 以及它所解决的问题。

Fragment 的寓意是要渲染一个片段,假设我们有如下模板:

<template>
  <table>
    <tr>
      <Columns />
    </tr>
  </table>
</template>
@前端进阶之旅: 代码已经复制到剪贴板

组件 Columns 会返回多个 <td> 元素:

<template>
  <td></td>
  <td></td>
  <td></td>
</template>
@前端进阶之旅: 代码已经复制到剪贴板

大家思考一个问题,如上模板的 VNode 如何表示?如果模板中只有一个 td 标签,即只有一个根元素,这很容易表示:

const elementVNode = {
  tag: 'td',
  data: null
}
@前端进阶之旅: 代码已经复制到剪贴板

但是模板中不仅仅只有一个 td 标签,而是有多个 td 标签,即多个根元素,这如何表示?此时我们就需要引入一个抽象元素,也就是我们要介绍的 Fragment。

const Fragment = Symbol()
const fragmentVNode = {
  // tag 属性值是一个唯一标识
  tag: Fragment,
  data: null,
  children: [
    {
      tag: 'td',
      data: null
    },
    {
      tag: 'td',
      data: null
    },
    {
      tag: 'td',
      data: null
    }
  ]
}
@前端进阶之旅: 代码已经复制到剪贴板

如上,我们把所有 td 标签都作为 fragmentVNode 的子节点,根元素并不是一个实实在在的真实 DOM,而是一个抽象的标识,即 Fragment。

当渲染器在渲染 VNode 时,如果发现该 VNode 的类型是 Fragment,就只需要把该 VNode 的子节点渲染到页面。

TIP

在上面的代码中 fragmentVNode.tag 属性的值是一个通过 Symbol 创建的唯一标识,但实际上我们更倾向于给 VNode 对象添加一个 flags 属性,用来代表该 VNode 的类型,这在本章的后面会详细说明。

再来看看 Portal,什么是 Portal 呢?

一句话:它允许你把内容渲染到任何地方。其应用场景是,假设你要实现一个蒙层组件 <Overlay/>,要求是该组件的 z-index 的层级最高,这样无论在哪里使用都希望它能够遮住全部内容,你可能会将其用在任何你需要蒙层的地方。

<template>
  <div id="box" style="z-index: -1;">
    <Overlay />
  </div>
</template>
@前端进阶之旅: 代码已经复制到剪贴板

如上,不幸的事情发生了,在没有 Portal 的情况下,上面的 <Overlay/> 组件的内容只能渲染到 id="box" 的 div 标签下,这就会导致蒙层的层级失效甚至布局都可能会受到影响。

其实解决办法也很简单,假如 <Overlay/> 组件要渲染的内容不受 DOM 层级关系限制,即可以渲染到任何位置,该问题将迎刃而解。

使用 Portal 可以这样编写 <Overlay/> 组件的模板:

<template>
  <Portal target="#app-root">
    <div class="overlay"></div>
  </Portal>
</template>
@前端进阶之旅: 代码已经复制到剪贴板

其最终效果是,无论你在何处使用 <Overlay/> 组件,它都会把内容渲染到 id="app-root" 的元素下。由此可知,所谓 Portal 就是把子节点渲染到给定的目标,我们可以使用如下

基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
  • Vue

    • 从源码解读Vue生命周期
    • 组件的本质
    • 有状态组件的设计
    • 设计 VNode
      • 用 VNode 描述真实 DOM
      • 用 VNode 描述抽象内容
      • VNode 的种类
      • 使用 flags 作为 VNode 的标识
      • 枚举值 VNodeFlags
      • children 和 ChildrenFlags
      • VNodeData
    • 辅助创建 VNode 的 h 函数
    • 自定义渲染器和异步渲染
    • 渲染器之挂载
    • 渲染器的核心 Diff 算法
    • 渲染器之patch
    • 图解 Vue 响应式原理
    • 图解 Vue 异步更新
    • 剖析 Vue 内部运行机制
    • vue响应式原理模拟
    • vue状态管理之vuex
    • 理解Vue的设计思想及实现Vue
    • diff算法深入
    • vue router vuex原理分析
    • Vue3初探响应式原理.
    • vue2源码分析
    • vue组件化实践
  • React

  • Webpack

  • Node

  • Javascript

  • 综合

完整面试题地址:
作者:程序员poetry
扫码关注作者公众号:「前端进阶之旅」 每天分享技术干货
前端进阶之旅公众号二维码

Vue 设计VNode|原理篇

文章学习导航
面试总览

30 秒速记

  • VNode 描述类型、属性、子节点与身份
  • 元素、文本、组件和片段需要不同信息
  • 类型标志方便分派,不是业务必需字段
  • 宿主节点引用连接描述与实际结果
  • 键负责身份,不能替代内容更新

设计虚拟节点,就是设计一份渲染器能看懂的界面数据协议。 元素要记录标签和属性,文本要保存内容,组件则要指向组件定义与输入,片段还涉及多个宿主节点。类型标志可以让更新路径快速分派,实际节点引用帮助复用已经创建的结果。字段名和位运算方式可以变化,核心是信息足够、语义清楚,并且能覆盖挂载、更新和卸载。

版本校准: 本文出现 Object.defineProperty、Dep、Watcher 和双端 Diff 时,主要描述 Vue 2 实现;Vue 3 使用 Proxy、effect 与新的渲染器路径。Vue 2 已于 2023-12-31 结束维护,新项目应以 Vue 3 为基线,旧项目参考 Vue 2 EOL 官方说明 制定迁移与安全策略。

上一章讲述了组件的本质,知道了一个组件的产出是 VNode,渲染器(Renderer)的渲染目标也是 VNode。可见 VNode 在框架设计的整个环节中都非常重要,甚至设计 VNode 本身就是在设计框架,VNode 的设计还会对后续算法的性能产生影响。本章我们就着手对 VNode 进行一定的设计,尝试用 VNode 描述各类渲染内容。

# 用 VNode 描述真实 DOM

一个 html 标签有它的名字、属性、事件、样式、子节点等诸多信息,这些内容都需要在 VNode 中体现,我们可以用如下对象来描述一个红色背景的正方形 div 元素:

const elementVNode = {
  tag: 'div',
  data: {
    style: {
      width: '100px',
      height: '100px',
      backgroundColor: 'red'
    }
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

我们使用 tag 属性来存储标签的名字,用 data 属性来存储该标签的附加信息,比如 style、class、事件等,通常我们把一个 VNode 对象的 data 属性称为 VNodeData。

为了描述子节点,我们需要给 VNode 对象添加 children 属性,如下 VNode 对象用来描述一个有子节点的 div 元素:

const elementVNode = {
  tag: 'div',
  data: null,
  children: {
    tag: 'span',
    data: null
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

若有多个子节点,则可以把 children 属性设计为一个数组:

const elementVNode = {
  tag: 'div',
  data: null,
  children: [
    {
      tag: 'h1',
      data: null
    },
    {
      tag: 'p',
      data: null
    }
  ]
}
@前端进阶之旅: 代码已经复制到剪贴板

除了标签元素之外,DOM 中还有文本节点,我们可以用如下 VNode 对象来描述一个文本节点:

const textVNode = {
  tag: null,
  data: null,
  children: '文本内容'
}
@前端进阶之旅: 代码已经复制到剪贴板

如上,由于文本节点没有标签名字,所以它的 tag 属性值为 null。由于文本节点也无需用额外的 VNodeData 来描述附加属性,所以其 data 属性值也是 null。

唯一需要注意的是我们使用 children 属性来存储一个文本节点的文本内容。有的同学可能会问:“可不可以新建一个属性 text 来存储文本内容呢?”

const textVNode = {
  tag: null,
  data: null,
  children: null,
  text: '文本内容'
}
@前端进阶之旅: 代码已经复制到剪贴板

这完全没有问题,这取决于你如何设计,但是尽可能的在保证语义能够说得通的情况下复用属性,会使 VNode 对象更加轻量,所以我们采取使用 children 属性来存储文本内容的方案。

如下是一个以文本节点作为子节点的 div 标签的 VNode 对象:

const elementVNode = {
  tag: 'div',
  data: null,
  children: {
    tag: null,
    data: null,
    children: '文本内容'
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

# 用 VNode 描述抽象内容

什么是抽象内容呢?组件就属于抽象内容,比如你在 模板 或 jsx 中使用了一个组件,如下:

<div>
  <MyComponent />
</div>
@前端进阶之旅: 代码已经复制到剪贴板

你的意图并不是要在页面中渲染一个名为 MyComponent 的标签元素,而是要渲染 MyComponent 组件所产出的内容。

但我们仍然需要使用 VNode 来描述 <MyComponent/>,并给此类用来描述组件的 VNode 添加一个标识,以便在挂载的时候有办法区分一个 VNode 到底是普通的 html 标签还是组件。

我们可以使用如下 VNode 对象来描述上面的模板:

const elementVNode = {
  tag: 'div',
  data: null,
  children: {
    tag: MyComponent,
    data: null
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

如上,用来描述组件的 VNode 其 tag 属性值引用的就是组件类(或函数)本身,而不是标签名称字符串。所以理论上:我们可以通过检查 tag 属性值是否是字符串来确定一个 VNode 是否是普通标签。

除了组件之外,还有两种抽象的内容需要描述,即 Fragment 和 Portal。我们先来了解一下什么是 Fragment 以及它所解决的问题。

Fragment 的寓意是要渲染一个片段,假设我们有如下模板:

<template>
  <table>
    <tr>
      <Columns />
    </tr>
  </table>
</template>
@前端进阶之旅: 代码已经复制到剪贴板

组件 Columns 会返回多个 <td> 元素:

<template>
  <td></td>
  <td></td>
  <td></td>
</template>
@前端进阶之旅: 代码已经复制到剪贴板

大家思考一个问题,如上模板的 VNode 如何表示?如果模板中只有一个 td 标签,即只有一个根元素,这很容易表示:

const elementVNode = {
  tag: 'td',
  data: null
}
@前端进阶之旅: 代码已经复制到剪贴板

但是模板中不仅仅只有一个 td 标签,而是有多个 td 标签,即多个根元素,这如何表示?此时我们就需要引入一个抽象元素,也就是我们要介绍的 Fragment。

const Fragment = Symbol()
const fragmentVNode = {
  // tag 属性值是一个唯一标识
  tag: Fragment,
  data: null,
  children: [
    {
      tag: 'td',
      data: null
    },
    {
      tag: 'td',
      data: null
    },
    {
      tag: 'td',
      data: null
    }
  ]
}
@前端进阶之旅: 代码已经复制到剪贴板

如上,我们把所有 td 标签都作为 fragmentVNode 的子节点,根元素并不是一个实实在在的真实 DOM,而是一个抽象的标识,即 Fragment。

当渲染器在渲染 VNode 时,如果发现该 VNode 的类型是 Fragment,就只需要把该 VNode 的子节点渲染到页面。

TIP

在上面的代码中 fragmentVNode.tag 属性的值是一个通过 Symbol 创建的唯一标识,但实际上我们更倾向于给 VNode 对象添加一个 flags 属性,用来代表该 VNode 的类型,这在本章的后面会详细说明。

再来看看 Portal,什么是 Portal 呢?

一句话:它允许你把内容渲染到任何地方。其应用场景是,假设你要实现一个蒙层组件 <Overlay/>,要求是该组件的 z-index 的层级最高,这样无论在哪里使用都希望它能够遮住全部内容,你可能会将其用在任何你需要蒙层的地方。

<template>
  <div id="box" style="z-index: -1;">
    <Overlay />
  </div>
</template>
@前端进阶之旅: 代码已经复制到剪贴板

如上,不幸的事情发生了,在没有 Portal 的情况下,上面的 <Overlay/> 组件的内容只能渲染到 id="box" 的 div 标签下,这就会导致蒙层的层级失效甚至布局都可能会受到影响。

其实解决办法也很简单,假如 <Overlay/> 组件要渲染的内容不受 DOM 层级关系限制,即可以渲染到任何位置,该问题将迎刃而解。

使用 Portal 可以这样编写 <Overlay/> 组件的模板:

<template>
  <Portal target="#app-root">
    <div class="overlay"></div>
  </Portal>
</template>
@前端进阶之旅: 代码已经复制到剪贴板

其最终效果是,无论你在何处使用 <Overlay/> 组件,它都会把内容渲染到 id="app-root" 的元素下。由此可知,所谓 Portal 就是把子节点渲染到给定的目标,我们可以使用如下 VNode 对象来描述上面这段模板:

const Portal = Symbol()
const portalVNode = {
  tag: Portal,
  data: {
    target: '#app-root'
  },
  children: {
    tag: 'div',
    data: {
      class: 'overlay'
    }
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

Portal 类型的 VNode 与 Fragment 类型的 VNode 类似,都需要一个唯一的标识,来区分其类型,目的是告诉渲染器如何渲染该 VNode。

# VNode 的种类

当 VNode 描述不同的事物时,其属性的值也各不相同。比如一个 VNode 对象是 html 标签的描述,那么其 tag 属性值就是一个字符串,即标签的名字;如果是组件的描述,那么其 tag 属性值则引用组件类(或函数)本身;如果是文本节点的描述,那么其 tag 属性值为 null。

最终我们发现,不同类型的 VNode 拥有不同的设计,这些差异积少成多,所以我们完全可以将它们分门别类。

总的来说,我们可以把 VNode 分成五类,分别是:html/svg 元素、组件、纯文本、Fragment 以及 Portal:

如上图所示,我们可以把组件细分为 有状态组件 和 函数式组件。同时有状态组件还可以细分为三部分:普通的有状态组件、需要被 keepAlive 的有状态组件 以及 已经被 keepAlive 的有状态组件 。

但无论是普通的有状态组件还是 keepAlive 相关的有状态组件,它们都是有状态组件。所以我们在设计 VNode 时可以将它们作为一类看待。

# 使用 flags 作为 VNode 的标识

既然 VNode 有类别之分,我们就有必要使用一个唯一的标识,来标明某一个 VNode 属于哪一类。同时给 VNode 添加 flags 也是 Virtual DOM 算法的优化手段之一。

比如在 Vue2 中区分 VNode 是 html 元素还是组件亦或是普通文本,是这样做的:

← 有状态组件的设计辅助创建 VNode 的 h 函数 →

fe