《Vue.js 设计与实现》—— 03 Vue.js 3 的设计思路

news/2024/7/5 2:39:38

1. 声明式地描述 UI

Vue.js 3 是一个声明式的 UI 框架,即用户在使用 Vue.js 3 开发页面时是声明式地描述 UI 的。

编写前端页面涉及的内容如下:

  • DOM 元素:例如是 div 标签还是 a 标签
  • 属性:如 a 标签的 href 属性,再如 idclass 等通用属性
  • 事件:如 click、keydown 等
  • 元素的层级结构:DOM 树的层级结构,既有子节点,又有父节点

那么,如何声明式地描述上述内容呢?拿 Vue.js 3 来说,相应的解决方案是:

  • 使用与 HTML 标签一致的方式来描述 DOM 元素,例如描述一个 div 标签时可以使用 <div></div>
  • 使用与 HTML 标签一致的方式来描述属性,例如 <div id="app"></div>
  • 使用 :v-bind 来描述动态绑定的属性,例如 <div :id="dynamicId"></div>
  • 使用 @v-on 来描述事件,例如点击事件 <div @click="handler"></div>
  • 使用与 HTML 标签一致的方式来描述层级结构,例如一个具有 span 子节点的 div 标签 <div><span></span></div>

可以看到,在 Vue.js 中,哪怕是事件,都有与之对应的描述方式。用户不需要手写任何命令式代码,这就是所谓的声明式地描述 UI。

除上述使用模板来声明式地描述 UI 外,还可以用 JavaScript 对象来描述,如:

const title = {
  // 标签名称
  tag: 'h1',
  // 标签属性
  props: {
    onClick: handler
  },
  // 子节点
  children: [
    { tag: 'span' }
  ]
}

对应到 Vue.js 模板,就是:

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

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

let level = 3
const title = {
  tag: `h${level}`, // h3 标签
}

如果使用模板来描述,就不得不穷举:

<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>

使用 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
  }
}

render 函数看似返回的是一个 h 函数,其实 h 函数的返回值就是一个对象,其作用是让用户编写虚拟 DOM 变得更加轻松。如果把上面 h 函数调用的代码改成 JavaScript 对象,就需要写更多内容:

export default {
  render() {
    return {
      tag: 'h1',
      props: { onClick: handler }
    }
  }
}

如果还有子节点,那么需要编写的内容就更多了,所以 h 函数就是一个辅助创建虚拟 DOM 的工具函数,仅此而已。

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

2. 初识渲染器

虚拟 DOM 其实就是用 JavaScript 对象来描述真实的 DOM 结构。那么,虚拟 DOM 是如何变成真实 DOM 并渲染到浏览器页面中的呢?答案是渲染器。渲染器的作用就是把虚拟 DOM 渲染为真实 DOM,我们平时编写的 Vue.js 组件都是依赖渲染器来工作的。

假设有如下虚拟 DOM:

const vnode = {
  tag: 'div', // 标签名称
  props: { // 标签对应的属性和事件
    onClick: () => alert('hello')
  },
  children: 'click me' // 标签的子节点
}

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

// vnode -- 虚拟 DOM 对象
// container -- 真实的 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)
}

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

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

创建节点只是“前菜”,渲染器的精髓都在更新节点的阶段。假设对 vnode 做一些小小的修改:

const vnode = {
  tag: 'div',
  props: {
    onClick: () => alert('hello')
  },
  children: 'click again' // 从 click me 改成 click again
}

对于渲染器来说,它需要精确地找到 vnode 对象的变更点并且只更新变更的内容。就上例来说,渲染器应该只更新元素的文本内容,而不需要再走一遍完整的创建元素的流程(这些内容将在后面讲解)。事实上,渲染器的工作原理其实很简单,归根结底都是使用一些熟悉的 DOM 操作 API 来完成渲染工作。

3. 组件的本质

关于组件,有三个问题:

  • 什么是组件?
  • 组件和虚拟 DOM 有什么关系?
  • 渲染器如何渲染组件?

其实虚拟 DOM 除了能够描述真实 DOM 之外,还能够描述组件。例如使用 { tag: 'div' } 来描述 <div> 标签,但是组件并不是真实的 DOM 元素,如何使用虚拟 DOM 来描述呢?

组件的本质是一组 DOM 元素的封装,这组 DOM 元素就是组件要渲染的内容,因此可以定义一个函数来代表组件,而函数的返回值就代表组件要渲染的内容:

const MyComponent = function () {
  return {
    tag: 'div',
    props: {
      onClick: () => alert('hello')
    },
    children: 'click me'
  }
}

可以看到,组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。可以让虚拟 DOM 对象中的 tag 属性来存储组件函数:

const vnode = {
    tag: MyComponent
}

就像 tag: 'div' 用来描述 <div> 标签一样,tag: MyComponent 用来描述组件,只不过此时的 tag 属性不是标签名称,而是组件函数。为了能够渲染组件,需要渲染器的支持。

前面封装的 renderer 函数仅能够渲染非组件(标签)元素,因此可以改名为 mountElement,内容不变,然后修改 renderer 函数为:

function renderer(vnode, container) {
  if (typeof vnode.tag === 'string') {
    // 说明 vnode 描述的是标签元素
    mountElement(vnode, container)
  } else if (typeof vnode.tag === 'function') {
    // 说明 vnode 描述的是组件
    mountComponent(vnode, container)
  }
}

mountComponent 函数实现如下:

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

组件一定非得是函数吗?其实它完全可以是一个 JavaScript 对象,例如:

// MyComponent 是一个对象
const MyComponent = {
  render() { // render 函数的返回对象表示组件要渲染的内容
    return {
      tag: 'div',
      props: {
        onClick: () => alert('hello')
      },
      children: 'click me'
    }
  }
}

为了完成组件的渲染,需要修改 renderer 渲染器以及 mountComponent 函数。

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

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

接着,修改 mountComponent 函数:

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

4. 模板的工作原理

无论是手写虚拟 DOM(渲染函数)还是使用模板,都属于声明式地描述 UI,并且 Vue.js 同时支持这两种描述 UI 的方式。那么模板是如何工作的呢?答案是编译器。编译器和渲染器一样,只是一段程序而已,不过它们的工作内容不同。编译器的作用是将模板编译为渲染函数,例如:

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

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

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

以 .vue 文件为例,一个 .vue 文件就是一个组件,如:

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

<script>
export default {
  data() {/* ... */},
  methods: {
    handler: () => {/* ... */}
  }
}
</script>

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

export default {
  data() {/* ... */},
  methods: {
    handler: () => {/* ... */}
  },
  render() {
    return h('div', { onClick: handler }, 'click me')
  }
}

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

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

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

下面以编译器和渲染器这两个非常关键的模块为例,看看它们是如何配合工作,并实现性能提升的。假设有如下模板:

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

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

render() {
  // 下面的代码等价于:return h('div', { id: 'foo', class: cls })
  return {
    tag: 'div',
    props: {
      id: 'foo',
      class: cls // cls 是一个变量,可能会发生变化
    }
  }
}

渲染器的作用之一就是寻找并且只更新变化的内容,所以当变量 cls 的值发生变化时,渲染器会自行寻找变更点。对于渲染器来说,这个“寻找”的过程需要花费一些力气。

从编译器的视角来看,它能否知道哪些内容会发生变化呢?如果编译器有能力分析动态内容,并在编译阶段把这些信息提取出来,然后直接交给渲染器,这样渲染器不就不需要花费大力气去寻找变更点了吗?这是个好想法并且能够实现。

Vue.js 的模板是有特点的,以上面的代码为例,我们可以清楚的知道 id="foo" 是永远不会变化的,而 :class="cls" 是一个 v-bind 绑定,是可能发生变化的。所以编译器能识别出哪些是静态属性,哪些是动态属性,在生成代码的时候完全可以附带这些信息:

render() {
  return {
    tag: 'div',
    props: {
      id: 'foo',
      class: cls
    },
    patchFlags: 1 // 假设数字 1 代表 class 是动态的
  }
}

渲染器直接通过 patchFlags 判断变更,不需要自己“寻找”,性能自然就提升了。

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

更多文章可关注:GopherBlog、GopherBlog副站


http://lihuaxi.xjx100.cn/news/1126344.html

相关文章

JQuery 详细教程

文章目录 一、JQuery 对象1.1 安装和使用1.2 JQuery包装集对象 二、JQuery 选择器2.1 基础选择器2.2 层次选择器2.3 表单选择器 三、JQuery Dom 操作3.1 操作元素3.1.1 操作属性3.1.2 操作样式3.1.3 操作内容 3.2 添加元素3.3 删除元素3.4 遍历元素 四、JQuery 事件4.1 ready 加…

MySQL笔记(四) 函数、变量、存储过程、游标、索引、存储引擎、数据库维护、指定字符集、锁机制

MySQL笔记&#xff08;四&#xff09; 文章目录 MySQL笔记&#xff08;四&#xff09;函数文本处理函数日期和时间处理函数数值处理函数类型转换函数流程控制函数自定义函数基本语法 局部变量全局变量聚集函数 aggregate functionDISTINCT 存储过程为什么要使用使用创建 删除建…

顺序表和链表的各种代码实现

一、线性表 在日常生活中&#xff0c;线性表的例子比比皆是。例如&#xff0c;26个英文字母的字母表&#xff08;A,B,C,……&#xff0c;Z&#xff09;是一个线性表&#xff0c;表中的数据元素式单个字母。在稍复杂的线性表中&#xff0c;一个数据元素可以包含若干个数据项。例…

进程与线程——嵌入式(驱动)软开基础(四)

1 异步IO和同步IO区别? 答案:如果是同步IO,当一个IO操作执行时,应用程序必须等待,直到此IO执行完。相反,异步IO操作在后台运行,IO操作和应用程序可以同时运行,提高系统性能,提高IO流量。 解读:在同步文件IO中,线程启动一个IO操作然后就立即进入等待状态,直到IO操…

如何在IVD行业运用IPD?

IVD&#xff08;体外诊断&#xff0c;In Vitro Diagnostic&#xff09;是指对人体样本&#xff08;血液、体液、组织&#xff09;进行定性或定量的检测&#xff0c;进而判断疾病或机体功能的诊断方法。IVD目前已经成为疾病预防、诊断治疗必不可少的医学手段&#xff0c;约80%左…

Java --- redis7之缓存预热+雪崩+穿透+击穿

目录 一、缓存预热 二、缓存雪崩 三、缓存穿透 3.1、解决方案 3.1.1、空对象缓存或者缺省值 3.1.2、Goolge布隆过滤器Guava解决缓存穿透 四、缓存击穿 4.1、危害 4.2、解决方案 4.3、模拟百亿补贴活动案例 一、缓存预热 场景&#xff1a;MySQL有N条新记录&#xff…

大数据Doris(十八):Properties配置项和关于ENGINE

文章目录 Properties配置项和关于ENGINE 一、Properties配置项 二、关于ENGINE Properties配置项和关于ENGINE 一、Properties配置项 在创建表时,可以指定properties设置表属性,目前支持以下属性: replica

商用密码产品认证中的随机数(二)

商用密码产品认证中的随机数&#xff08;二&#xff09; 1 随机数相关规范概述2 随机数生成2.1 随机数发生概述2.2 编程语言自带的标准库2.3 操作系统提供的随机数2.4 第三方库2.5 使用符合密码行业要求的随机数发生器2.6 自行设计的随机数发生器 1 随机数相关规范概述 现行密…