跳转到内容

Vue.js 3 的设计思路

声明式地描述 UI

用户在使用 Vue.js 的时候,只需要声明式地描述 UI,而不需要关心 DOM 的操作,Vue.js 会根据用户提供的声明式描述自动生成对应的 DOM。它设计需要考虑到编写前端页面时涉及到的几个方面:

  • DOM 元素,是 div 还是 a 标签
  • 属性,class 类名或者 id 、超链接的 href
  • 事件,比如点击事件 click、鼠标移入事件 mouseover
  • 层级结构,比如 div 包含 a 标签等是否有子元素

Vue.js 的相应解决方案是:

  • 和 HTML 一样,采取一致的方式描述 DOM 元素,如 <div></div>
  • 和 HTML 一样,采取一致的方式描述属性,如 <div id="div1"></div>
  • 使用 v-bind: 指令描述动态的属性和事件,如 <div :id="id"></div>
  • 使用 v-on@ 指令描述事件,如 <div @click="handleClick"></div>
  • 和 HTML 一样,使用标签嵌套描述层级结构,如 <div><a></a></div>

除了使用模版来声明 UI 之外,Vue.js 还支持使用 JavaScript 对象来描述 UI。

js
const vnode = {
  tag: 'div', // DOM 元素
   // 属性
  props: {
    id: 'div1'
  },
  // 事件
  on: {
    click: () => {
      console.log('div clicked');
    }
  },
  // 子元素
  children: [
    {
      tag: 'a',
      props: {
        href: 'https://www.baidu.com'
      },
      children: 'click me'
    }
  ]
};

与使用模版创建 UI 相比,使用 JavaScript 对象创建 UI 的好处是,描述 UI 更灵活。举个例子,根据一个变量 level 渲染对应的 <h> 标签,使用模版需要写多个标签,而使用 JavaScript 对象则只需要一个对象:

js
const level = 1;
const vnode = {
  tag: `h${level}`,
  children: 'Hello, world'
};
vue
<h1 v-if="level === 1">Hello, world</h1>
<h2 v-else-if="level === 2">Hello, world</h2>
<h3 v-else-if="level === 3">Hello, world</h3>
<h4 v-else-if="level === 4">Hello, world</h4>
<h5 v-else-if="level === 5">Hello, world</h5>
<h5 v-else="level === 6">Hello, world</h5>

通过对比可以看出,使用 JavaScript 对象描述 UI 更加灵活,而且可以和 JavaScript 代码无缝结合,比如可以很方便地使用 JavaScript 表达式。而这种方式就是虚拟 DOM,Vue.js 封装了一个 h 函数让使用者更轻松地创建虚拟 DOM。

js
const vnode = h('div', { id: 'div1' }, [
  h('a', { href: 'https://www.baidu.com' }, 'click me')
]);
js
const vnode = {
  tag: `div`,
  props: {
    id: 'div1'
  },
  children: [
    {
      tag: 'a',
      props: {
        href: 'https://www.baidu.com'
      },
      children: 'click me'
    }
  ]
};

初识渲染器

渲染器的作用是将虚拟 DOM 渲染为真实 DOM。渲染器是一个函数,接收两个参数:

  1. 虚拟 DOM
  2. 挂载 DOM 的容器

渲染器会根据虚拟 DOM 创建真实 DOM,然后将真实 DOM 插入到挂载 DOM 的容器中。

js
const vnode = {
  tag: 'div', // DOM 元素
   // 属性
  props: {
    id: 'div1'
  },
  // 事件
  on: {
    click: () => {
      console.log('div clicked');
    }
  },
  // 子元素
  children: [
    {
      tag: 'a',
      props: {
        href: 'https://www.baidu.com'
      },
      children: 'click me'
    }
  ]
};

renderer(vnode, document.body);
js
function renderer(vnode, container) {
  // 创建真实 DOM
  const el = document.createElement(vnode.tag);
  // 处理 props
  if (vnode.props) {
    for (const key in vnode.props) {
      const value = vnode.props[key];
      if (key.startsWith('on')) {
        el.addEventListener(key.slice(2).toLowerCase(), value);
      } else {
        el.setAttribute(key, value);
      }
    }
  }
  // 处理 children
  if (vnode.children) {
    if (typeof vnode.children === 'string') {
      el.textContent = vnode.children;
    } else {
      vnode.children.forEach(child => {
        renderer(child, el);
      });
    }
  }
  // 将真实 DOM 插入到容器中
  container.appendChild(el);
}

上方代码会把一个 div 包裹 a 标签挂载到 document.body 中,并且给 div 绑定了一个点击事件。来看看它是如何运作的。

  1. 创建真实 DOM,vnode.tag 属性作为真实 DOM 元素的标签名
  2. 处理 props
    • 如果是 on 开头,说明该属性是事件,截取后面部分作为事件名,调用 addEventListener 方法绑定事件
    • 否则,说明该属性是属性,设置属性
  3. 处理 children
    • 如果是字符串,则设置文本内容
    • 否则,递归调用渲染器函数,将当前元素作为挂载元素
  4. 将真实 DOM 插入到容器中

这样渲染器初步就完成了,还有一些改动和优化需求,如修改了 vnode 对象的 children 后,如何快速查找到更新的内容并针对更新,而不是全部重新渲染重走一遍流程。

组件的本质

虚拟 DOM 的本质是描述真实 DOM 的 JavaScript 对象,渲染器是把虚拟 DOM 渲染为真实 DOM 的函数,而组件是一组 DOM 元素的封装,这组 DOMM 元素就是组件要渲染的内容。

此时可以定义一个函数来代表组件,该函数的返回值就是组件要渲染的内容。判断 vnode.tag 是否是函数,如果不是,说明是普通标签,调用 mountElement 函数(和前面的渲染器函数一致)直接创建真实 DOM;反之,则说明该节点是一个组件,递归调用 mountComponent 函数获取组件要渲染的内容,将组件渲染为真实 DOM。

js
const myComponent = () => {
  return {
    tag: 'div',
    children: 'hello world'
  }
}
js
const vnode = {
  tag: myComponent, // 组件
}
js
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];
      if (key.startsWith('on')) {
        el.addEventListener(key.slice(2).toLowerCase(), value);
      } else {
        el.setAttribute(key, value);
      }
    }
  }
  // 处理 children
  if (vnode.children) {
    if (typeof vnode.children === 'string') {
      el.textContent = vnode.children;
    } else {
      vnode.children.forEach(child => {
        renderer(child, el);
      });
    }
  }
  // 将真实 DOM 插入到容器中
  container.appendChild(el);
}
js
function mountComponent(vnode, container) {
  const subtree = vnode.tag();
  renderer(subtree, container);
}

模版的工作原理

无论是手写虚拟 DOM 渲染函数还是使用模板,都是声明式代码,Vue.js 同时支持这两种方式。使用模板会通过编译器编译为渲染函数,然后用渲染器把渲染函数返回的虚拟 DOM 转为真实 DOM。

vue
<template>
  <div @click="handleClick">hello world</div>
</template>
js
export default {
  data() { /* ... */},
  methods: {
    handleClick() { /* ... */ }
  },
  render() {
    return {
      tag: 'div',
      props: {
        onClick: this.handleClick
      },
      children: 'hello world'
    }
  }
  /**
   * 或者
   */
  render() {
    eturn h('div', { onClick: this.handleClick }, 'hello world');
  }
}

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

综上所述,组件模板依赖编译器编译成渲染函数返回虚拟 DOM,虚拟 DOM 通过渲染器渲染为真实 DOM。Vue.js 是一个有机整体,各个模块相互协作,共同完成工作。从编译器和渲染器两个关键模块出发,看看 Vue.js 是如何配合工作并实现性能优化的。

vue
<div id="foo" :class="cls">hello world</div>
js
render() {
  return {
    tag: 'div',
    props: {
      id: 'foo',
      class: cls
    },
    children: 'hello world',
    patchFlags: 1 // 动态属性
  }
  /* 等价于 */
  // return h('div', { id: 'foo', class: cls }, 'hello world');
}

在上方代码中,类名 cls 是一个变量,可能会发生变化,前文也提到过,渲染器越快找到需要更新的节点,性能越好。所以,Vue.js 在编译阶段把动态内容提取出来交给渲染器,这样渲染器就不需要花费时间寻找变更点,从而提升性能。

总结

Vue.js 3 是由编译器、渲染器等模块组成的有机整体,各模块相互协作,共同实现高效、灵活的前端开发体验。采用声明式编程,让用户通过模板或 JavaScript 对象描述 UI,无需手动操作 DOM。

  • 声明式 UI 描述:通过模板或虚拟 DOM 对象(如使用 h 函数创建)来声明 UI,包括 DOM 元素、属性、事件和层级结构等。
  • 渲染器渲染虚拟 DOM:渲染器接收虚拟 DOM 和挂载容器两个参数,将虚拟 DOM 转换为真实 DOM 并插入容器。渲染器会处理元素创建、属性设置、事件绑定和子元素递归渲染等操作。
  • 组件封装与渲染:组件是一组 DOM 元素的封装,通过函数定义组件,返回要渲染的虚拟 DOM。渲染器根据 vnode.tag 是否为函数判断是否为组件,进而调用相应函数进行渲染。
  • 模板编译:模板会被编译器编译成渲染函数,渲染函数返回虚拟 DOM,再由渲染器渲染为真实 DOM。编译阶段会提取动态内容,为渲染器提供性能优化依据,使其能快速找到更新点。