Vue.js3 框架设计

200 阅读6分钟

前言

本文是《Vue.js设计与实现》第一篇 框架设计概览 学习摘要笔记。

权衡的艺术

命令式和声明式

视图层框架通常分为命令式和声明式,命令式更加关注过程,而声明式更加关注结果。

命令式代码示例:

const div = document.querySelector('#app') // 获取 div
div.innerText = 'hello world' // 设置文本内容
div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件

声明式代码示例:

<div @click="() => alert('ok')">hello world</div>

性能与可维护性

innerHTML、虚拟DOM、原生 JavaScript 在更新页面时的性能 image.png

声明式的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗。 虚拟 DOM 的意义就在于使找出差异的性能消耗最小化。

运行时和编译时

代码运行的时候才开始编译,而这会产生一定 的性能开销,因此我们也可以在构建的时候就执行 Compiler 程序将 用户提供的内容编译好,等到运行时就无须编译了,这对性能是非常友好的。

纯运行时的框架。没有编译的过程,没办法分析用户提供的内容。

加入编译步骤,我们可以分析用户提供的内容,看看哪些内容未来可能会改变,哪些内容永远不会改变,这样我们就可以在编译的时候提取这些信 息,然后将其传递给 Render 函数,Render 函数得到这些信息之 后,就可以做进一步的优化。

纯编译时的框架,那么它也可以分析用户提供的内容。由于不需要任何运行时, 而是直接编译成可执行的 JavaScript 代码,因此性能可能会更好,但是 这种做法有损灵活性,即用户提供的内容必须编译后才能用。

实际上,在这三个方向上业内都有探索,其中 Svelte 就是纯编译时的框架,但是它的真实性能可能达不到理论高度。Vue.js 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化。等Vue.js设计与实现到后面讲解 Vue.js 3 的编译优化相关内容时,你会看到 Vue.js 3 在保留运行时的情况下,其性能甚至不输纯编译时的框架。

框架设计的核心要素

提升用户的开发体验

rollup.js 配置

// rollup.config.js
const config = {
    input: 'input.js',
    output: {
        file: 'output.js',
        format: 'iife' //指定模块格式  cjs => Node.js 环境 esm => ESM 格式
    }
}
export default config

错误处理

为用户提供统一的错误处理接口

// utils.js
let handleError = null
export default {
    foo(fn) {
        callWithErrorHandling(fn)
    }, 
    // 用户可以调用该函数注册统一的错误处理函数
    registerErrorHandler(fn) {
        handleError = fn 
    } 
}
function callWithErrorHandling(fn) {
    try {
        fn && fn()
    } catch (e) {
        // 将捕获到的错误传递给用户的错误处理程序 
        handleError(e) 
    } 
}

在Vue.js注册统一的错误处理函数:

import App from 'App.vue'
const app = createApp(App)
app.config.errorHandler = () => { 
    // 错误处理程序 
}

声明式地描述 UI

虚拟 DOM 描述 UI:

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

h 函数的返回值就是一个对象,其作用是让我们编写虚拟 DOM 变得更加轻松。

把上面 h 函数调用的代码改成 JavaScript 对象:

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

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

简单渲染器

const vnode = {
    tag: 'div',
    props: {
        onClick: () => alert('hello')
    },
    children: 'click me'
}
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 
                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, document.body)

渲染器的精髓都在更新节点的阶段。

组件的本质

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

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

组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。

让虚拟 DOM 对象中的 tag 属性来存储组件函数:

const vnode = {
    tag: MyComponent 
}

修改 renderer 函数支持渲染组件

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

其中 mountElement 函数与上文中 renderer 函数一致

mountComponent 实现:

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

模板的工作原理

编译器的作用其实就是将模板编译为渲染函数


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

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

编译器会把模板内容 编译成渲染函数并添加到 <script> 标签块的组件对象上

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

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

组件的实现依赖于渲染器,模板的编译依赖于编译器,并且编译后生成的代码是根据渲染器和虚拟 DOM 的设计决定。

总结

  1. Vue.js 是一个声明式的框架。声明式的好处在于,它直接描述结果,用 户不需要关注过程。Vue.js 采用模板的方式来描述 UI,但它同样支持 使用虚拟 DOM 来描述 UI。虚拟 DOM 要比模板更加灵活,但模板要 比虚拟 DOM 更加直观。
  2. 渲染器的作用是把虚拟 DOM 对象渲染为真实 DOM 元素。它的工作原理是,递归地遍历虚拟 DOM 对象,并调用原生 DOM API 来完成真实 DOM 的创建。渲染器的精髓在于后续的更新,它会通过 Diff 算法找出变更点,并且只会更新需要更新的内容。后面我们会专门讲解渲染器的相关知识。
  3. 组件的本质其实就是一组虚拟 DOM 元素的封装,它可以是一个返回虚拟 DOM 的函数,也可以是一个对象,但这个对象下必须要有一个函数用来产出组件要渲染的虚拟 DOM。渲染器在渲染组件时,会先获取组件要渲染的内容,即执行组件的渲染函数并得到其返回值,我们称之为 subtree,最后再递归地调用渲染器将 subtree 渲染出来即可。
  4. Vue.js 的模板会被编译器编译为渲染函数,编译器、渲染器都是 Vue.js 的 核心组成部分,它们共同构成一个有机的整体,不同模块之间互相配合,进一步提升框架性能。