Vue的设计思路

182 阅读6分钟

Vue3的设计思路

声明式地描述UI

在使用Vue3时开发页面是声明式地描述UI的。描述UI主要涉及以下内容:

  • DOM元素:例如使用的标签(div、span、a、img...)
  • 属性:如a标签的href属性,或id、class等通用属性
  • 事件:如click、keydown等
  • 元素的层级结构:DOM树的层级结构,既有子节点,又有父节点
  • 使用v-bind描述动态绑定的属性:如<div :id="dynamicId"></div>
  • 使用v-on来描述事件:如<div @click="handlerClick""></div>
    具体到事件,都有与之对应的描述方式,不需要写任何命令式的代码,这就是声明式的描述UI。

除了上面这种声明式的描述UI外,还可以用JavaScript对象来描述。

const title = {
    tag: 'h1',
    props: {
        onClick: handleClick
    },
    children: [{ tag: 'span' }]
}

对应到Vue模板,就是<h1 @click="handleClick""><span><span><h1>
这两种描述UI的方式有什么不同呢?答案就是使用JavaScript对象描述UI更加灵活。
而使用JavaScript描述UI就是所谓的虚拟DOM。正式这种灵活性,Vue3除了使用模板描述UI外还支持使用 虚拟DOM来描述UI。

import { h } from 'vue'
export default {
    render () {
        return h('h1', { onClick: handler })
    }
}

上面这个h函数的返回值就是一个虚拟DOM对象,它的作用是让我们编写虚拟DOM更加轻松。如果把上面的h函数调用改成JavaScript 对象,就需要写更多内容:

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

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

渲染器

现在已经了解什么是虚拟DOM了,那么虚拟DOM是如何变成真实DOM并渲染到浏览器页面中呢?答案渲染器。
这是编写的渲染器,将上面的虚拟DOM渲染为真实DOM:

function renderer (vNode, container) {
  // 使用vNode.tag作为标签名称创建DOM元素
  const el = document.createElement(vNode.tag)
  // 通过vNode.props将属性、事件添加到DOM元素上
  for (const key in vNode.props) {
    el.addEventListener(
      key.substr(2).toLowerCase(), // 事件名 onClick => click
      vNode.props[key] // 事件处理函数
    )
  }
  if (typeof vNode.children === 'string') {
    el.appendChild(document.createTextNode(vNode.children))
  } else if (Array.isArray(vNode.children)) {
    vNode.children.forEach(child => renderer(child, el))
  }
  container.appendChild(el)
}

渲染器函数的参数:

  • vNode:虚拟DOM对象
  • container:真实的DOM元素,作为挂载点,渲染器会把虚拟DOM渲染到该挂载点下

渲染器的执行过程主要分为三个步骤:

  • 创建元素
  • 为元素添加属性和事件
  • 处理children

组件的本质

到这里有一个问题,虚拟DOM处理能够描述真实DOM外,该能够描述组件。但是组件并不是真实的DOM元素, 那么是如何使用虚拟DOM来描述呢?
要明白这个问题首先需要明白组件是什么,组件就是一组DOM元素的封装,这组DOM就是组件要渲染的内容, 所以可以定义一个函数来代表组件,函数的返回值就代表组件需要渲染的内容。

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

可以看到上面的函数返回的是虚拟DOM,它代表组件想要渲染的内容。搞清楚组件的本质就可以定义用虚拟DOM来描述组件了。

const vNode = {
    tag: MyComponent
}

使用tag: MyComponent来描述组件,只不过此时的tag属性并不是标签名称,而是组件函数。为了渲染组件,当然需要渲染器 的支持。修改renderer函数:

// 将刚才的 renderer 改为 mountElement 函数 - 挂载DOM元素
function mountElement (vNode, container) {
  // 使用vNode.tag作为标签名称创建DOM元素
  const el = document.createElement(vNode.tag)
  // 通过vNode.props将属性、事件添加到DOM元素上
  for (const key in vNode.props) {
    el.addEventListener(
      key.substr(2).toLowerCase(), // 事件名 onClick => click
      vNode.props[key] // 事件处理函数
    )
  }
  if (typeof vNode.children === 'string') {
    el.appendChild(document.createTextNode(vNode.children))
  } else if (Array.isArray(vNode.children)) {
    vNode.children.forEach(child => mountElement(child, el))
  }
  container.appendChild(el)
}
// 挂载组件
function mountComponent (vNode, container) {
    const subTree = vNode.tag()
    renderer(subTree, container)
}
// 重新定义渲染器函数
function renderer (vNode, container) {
  if (typeof vNode.tag === 'string') {
    mountElement(vNode, container)
  } else if (typeof vNode.tag === 'function') {
    mountComponent(vNode, container)
  }
}

可以看到,先调用vNode.tag函数,返回值是虚拟DOM,即组件需要渲染的内容(这里称为subTree)。subTree也是 虚拟DOM,那么直接调用renderer函数完成渲染即可。

const MyComponent = {
  render () {
    return {
      tag: 'div',
      props: { onClick: handleClick },
      children: 'click me'
    }
  }
}

现在使用一个对象来描述组件,该组件有一个函数名字叫做render,它返回代表组件要渲染的内容(虚拟DOM)。
需要修改渲染器函数和挂载组件的函数:

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

function mountComponent (vNode, container) {
    const subTree = vNode.tag.render()
    renderer(subTree, container)
}

在mountComponent中,vNode.tag代表要渲染的组件,调用它的render函数得到组件要渲染的内容(虚拟DOM)。

模板的工作原理

无论手写虚拟DOM还是使用模板,都属于声明式的描述UI,并且Vue同时支持这两种描述UI的方式。那么模板是如何工作的呢? 这就需要提到Vue框架的另一个重要组成部分:编译器

编译器它的工作内容就是将模板编译为渲染函数.

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

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

function 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,这就是模板的工作原理。

各个模块组成的有机整体

以渲染器和编译器这两个关键的模块为例:
假设有模板<div id="foo" :class="cls"></div> 编译器会把这段代码编译成渲染函数:

render () {
  // 为了效果更加直观,没有使用h函数,而是直接采用了虚拟DOM对象
  // return h('div', { id: 'foo', class: cls })
  return {
    tag: 'div',
    props: {
      id: 'foo',
      class: 'cls'
    }
  }
}

前面说到渲染器的作用之一就是寻找并且只更新变化的内容,所以当cls的值发生变化时,渲染器会自行寻找变更点。但是 寻找的过程也需要用一些时间。
如果编译器分析动态内容,并在编译阶段把信息提取出来并直接交给渲染器,就不需要去需按照变更点了。
拿上面的模板来说,可以看出id时永远不会变化的,而class是用v-bind绑定的,是可能发生变化的。所以编译器能识别出 静态属性和动态属性,生成代码的时候可以附带这些信息:

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

生成的虚拟DOM对象多出了一个patchFlags属性,渲染器解析到这个标志 1 的时候就能知道,class属性会发生改变, 就相当于省去了寻找变更点的工作量,性能就会有所提升了。