# 设计思路

# 声明式地描述 UI

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

前端页面涉及到的内容

  • DOM 元素:例如是 div 标签还是 a 标签。
  • 属性:如 a 标签的 href 属性,再如 id、class 等通用属性。
  • 事件:如 click、keydown 等。
  • 元素的层级结构:DOM 树的层级结构,既有子节点,又有父节点。 声明式地描述 UI

用户不需要手写任何命令式代码

  • 使用与 HTML 标签一致的方式来描述 DOM 元素,例如描述一个div 标签时可以使用<div></div>;
  • 使用与 HTML 标签一致的方式来描述属性,例如 <div id="app"></div>;
  • 使用 : 或 v-bind 来描述动态绑定的属性,例如<div :id="dynamicId"></div>;
  • 使用 @ 或 v-on 来描述事件,例如 <div @click="handleClick"></div>;
  • 使用模板语法来描述元素的层级结构,例如 <div><span></span></div>。 除了上面使用模版语法描述元素的层级结构,我们还可以用 JavaScript 对象来描述,例如:
const title = {
    // 标签名称
    tag: 'h1',
    // 标签属性
    props: {
        onClick: handler
    },
    // 子节点
    children: [{
        tag: 'span'
    }]
}
// 对应的Vuejs 模版就是
// <h1 :click="handler"><span></span></div>

使用 JavaScript 对象描述 UI 更加灵活

假如我们要表示一个标题,根据标题级别的不同,会分别采用 h1~h6 这几个标签,如果用 JavaScript 对象来描述,我们只需要使用一个变量来代表 h 标签即可

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

当变量 level 值改变,对应的标签名字也会在 h1 和h6 之间变化。但是如果使用模板来描述,就不得不穷举:

<template>
  <h1 v-if="level === 1">标题1</h1>
  <h2 v-else-if="level === 2">标题2</h2>
  <h3 v-else-if="level === 3">标题3</h3>
  <h4 v-else-if="level === 4">标题4</h4>
  <h5 v-else-if="level === 5">标题5</h5>
  <h6 v-else>标题6</h6>
</template>

这远没有 JavaScript 对象灵活。而使用 JavaScript 对象来描述 UI的方式,其实就是所谓的虚拟 DOM。

Vue3 除了支持使用模板描述 UI 外,还支持使用虚拟 DOM 描述 UI。

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

Vue.js 会根据组件的 render 函数的返回值拿到虚拟 DOM,然后就可以把组件的内容渲染出来了。

# 初识渲染器

虚拟 DOM,它其实就是用 JavaScript 对象来描述真实的 DOM 结构。虚拟DOM 是通过渲染器来渲染成真实的 DOM 的。

渲染器的作用就是把虚拟 DOM 渲染为真实 DOM

渲染器

假设我们有如下虚拟 DOM

const vnode = {
    tag: 'div', //用来描述标签名称
    // props 是一个对象,用来描述 <div> 标签的属性、事件等内容。可以看到,我们希望给 div 绑定一个点击事件。
    props: {
        onClick: () => alert('hello')
    },
    // 用来描述标签的子节点。在上面的代码中,children是一个字符串值,意思是 div 标签有一个文本子节点:
    children: 'click me'
}

编写一个 渲染器 , 把上面的虚拟 DOM 渲染为真实的 DOM

/**
 * @param {Object} vnode 虚拟 DOM
 * @param {Element} container 挂载点
 */
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 的实现思路,总体来说分为三步

  • 创建元素:把 vnode.tag 作为标签名称来创建 DOM 元素。
  • 添加属性:遍历 vnode.props,将属性、事件添加到 DOM 元素。如果 key 以on 字符开头,说明它是一个事件,把字符 on 截取掉后再调用toLowerCase 函数将事件名称小写化,最终得到合法的事件名称,例如 onClick 会变成 click,最后调用addEventListener 绑定事件处理函数。
  • 处理 children:如果 children 是一个数组,就递归地调用 renderer 继续渲染,注意,此时我们要把刚刚创建的元素作为 挂载点(父节点);如果 children 是字符串,则使用 createTextNode 函数创建一个文本节点,并将其添加到新创建 的元素内。

# 组件的本质

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

// 定义一个组件 返回一个虚拟 DOM
const MyComponent = function() {
    return {
        tag: 'div',
        props: {
            onClick: () => alert('hello')
        },
        children: 'click me'
    }
}

//虚拟Dom
const vnode = {
    tag: MyComponent, // 组件函数
    props: {},
}

为了能够渲染组件,需要渲染器的支持。修改前面提到的 renderer 函数。

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

//这里我们称之为 subtree。既然 subtree 也是虚拟 DOM,
function mountComponent(vnode, container) {
    // 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
    const subtree = vnode.tag()
    // 递归地调用 renderer 渲染 subtree
    renderer(subtree, container)
}

# 模板的工作原理

编译器的作用其实就是将模板编译为渲染函数,例如给出如下模板

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

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

 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 渲染页面的流程