vue.js的设计和实现的解读1: 框架设计概览

971 阅读6分钟

前言

大家好,我是为梦执守。本人最近在阅读霍春阳著作的《vue.js的设计和实现》这本书,这本书写的非常的好,很多业界大佬也在强烈推荐,工作中使用到vue的同学可以购买正版进行阅读,以下内容是自己最近在学习过程中的一些总结笔记,有哪些理解错误的欢迎大家评论留言哦

第一章 权衡的艺术

建议阅读原版书籍了解具体实现。

1.1 什么是命令式和声明式

命令式

命令式框架,最大的特点就是关注过程,我们需要考虑每一步的实现,一步错就导致程序奔溃了

以jQuery为例:

$('#app').text('hello world').on('click', () => { alert('OK') })

以JavaScript为例:

const div = document.querySelector('#app')
div.innerText = 'hello world'
div.addEventListener('click', () => { alert('OK') })

这行代码就是典型的命令式框架写法,会关注过程的每一步实现,那么当实现的逻辑及项目复杂度剧增的时候,开发者的维护和开发心智巨大,那么有没有什么方法让我们不这么累呢?当然是有的,下面我们看看声明式框架的特点:

声明式

声明式框架,最大的特点就是关注结果,不用在像命令式一样关注每一步的实现,只需要关系最终结果就可以了

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

以上是这段类HTML的模板就是vue.js实现的方式。可以看到,我们不用在关注过程是怎么去实现的,只有在意最终输出的结果就行。

其实vue.js内部是通过命令式的方式帮我们实现了这个过程,用户通过声明式的方式进行交互,那么相对于用户的体验上来说,声明式优于命令式,性能及可维护性谁更具有优势呢?

1.2 性能和可维护性的权衡

关于性能,书中结论为:声明式代码的性能不优于命令式代码的性能,前面的代码例子为例,我们执行一次更新操作,如果直接修改的性能消耗定义为A,把找到差异的性能消耗定义为B,如下:

// 命令式,直接更改文本内容,性能最优
// 更新消耗性能 = A
div.textContent = 'hello world3' 
// 声明式
// 更新消耗性能 = B + A
<div @click="() => alert('OK')" >hello world3</div>

以上例子也说明最开始的结论是正确的,框架本身就是封装了命令式代码才实现了面向用户的声明式。说完了性能,那么vue.js为什么会选择声明式的设计方案呢?主要在于声明式的可维护性更强。

1.3 虚拟DOM的性能

前面我们说到,声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗,如何最小化找出差异的性能消耗,就可以让声明式代码的性能无限接近命令式代码的性能。而我们所说的虚拟DOM,就是为了最小化找出差异这一步性能消耗而出现的,所以我们应该明白采用虚拟DOM的更新技术的性能理论上是不可能比原生JavaScript操作DOM更高的(也只是理论上而已,大多数情况我们无法做到极致性能优化的命令式代码)

DOM运算和JavaScript层面的计算对比:

// 纯JavaScript计算
console.time("Array initialize");
var arr = [];
for(var i=0; i<100000; i++){
    const div = {tag:'div'}
    arr.push(div);
}
console.timeEnd("Array initialize"); 
// 耗时 Array initialize: 24.919ms
// DOM操作计算
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id='app'></div>
</body>
    <script>
        console.time("Array initialize");
        const app = document.querySelector("#app");
        for (let i = 0; i<100000; i++){
            const div = document.createElement('div');
            app.appendChild(div)
        };
        console.timeEnd("Array initialize"); 
        // 耗时 Array initialize: 42.591064453125 ms
    </script>
</html>

大家可以看见,循环10000次,JavaScript层面确实比DOM操作快得多,innerHTML创建页面的性能 = HTML字符串拼接的计算量 + innerHTML的DOM计算量

虚拟DOM创建页面的过程为:

  1. 创建JavaScript对象,这个对象可以理解为真实DOM的描述

  2. 递归地遍历虚拟DOM树并创建真实DOM

创建JavaScript对象的计算量 + 创建真实DOM的计算量

image.png

到这里,整体看下来,虚拟DOM的性能其实要差很多,我们再来看看它们在更新页面时的性能,使用innerHTML更新页面的过程是重新构建HTML字符串,再重新设置DOM元素的innerHTML属性,就是只是修改一个文字内容,也需要重新设置innerHTML属性,而重新设置innerHTML属性等价于销毁所有旧的DOM元素,再全量创建新的DOM元素(这其实就不是我们想要的呢,对不对);虚拟DOM是通过找到变化的元素更新它。

image.png

那么当更新的数量级很大的时候,虚拟DOM的优势就完全凸显出来了,页面结构很复杂庞大时,虚拟DOM对比更新的节点(常说的Diff阶段,会消耗部分性能)只会对对应的节点进行更新,而innerHTML是全量销毁在创建,中间的性能差距将是巨大的。

image.png

最后,从性能、心智负担、可维护性方面来看一下

image.png

1.4 运行时和编译时

框架设计可以选择:纯运行时、运行时 + 编译时、纯编译时

纯运行时:给用户提给一个Render函数,用户根据提供规则达到预期的内容(AngularJS)

function Render(obj,root){
    const el = document.createElement(obj.tag)
    if(typeof obj.children === 'string'){
      const text = document.createTextNode(obj.children)
      el.appendChild(text)
    }else{
      // 数组,递归调用Render函数,使用el作为root参数
      obj.children.forEach((child)=>Render(child,el))
    }
    // 将元素添加到root
    root.appendChild(el)
}

提供的规则为一个树型结构的对象:

const obj = {
    tag:'div',
    children:[
      { tag:'span',children:'hello world'}
    ]
}
// 渲染到body下,运行代码查看预期结构
Render(obj,document.body)

那么用户不按照指定的规则将得不到预期的结果

运行时 + 编译时:将用户的HTML标签Compiler函数编译成树型结构数据后返还给用户(Vue.js3框架)

<div>
    <span> hello world </span>
</div>

            ||
            || 编译
            ||
            
const obj = {
    tag:'div',
    children:[
      { tag:'span',children:'hello world'}
    ]
}

最后用户调用Render函数输出预期结果

纯编译时:提供一个Compiler函数将用户的HTML标签直接编译成预期的结果(Svelte框架)

<div>
    <span> hello world </span>
</div>

            ||
            || 编译
            ||

const div = document.createElement("div");
const span = document.createElement("span");
span.innerText = "hello world";
div.appendChild(span);
document.body.appendChild(div);

第二章 框架设计的核心要素

建议阅读原版书籍了解具体实现。

2.1 提升用户的开发体验

衡量一个框架是否足够优秀的指标之一就是看它的而开发体验如何。提供友好的警告信息至关重要,这有助于开发者快速定位问题,因为大多数情况下“框架要比开发者更清楚问题出在哪里,因此在框架层面抛出有意义的警告信息是非常有必要的。但是是不是越详细的提示信息越好呢?答案肯定是否定的

2.2 控制框架代码的体积

框架的大小也是衡量框架的标准之一

if (__DEV__) {
    warn(
      `App has already been mounted.\n` +
        `If you want to remount the same app, move your app creation logic ` +
        `into a factory function and create fresh app instances for each ` +
        `mount - e.g. \`const createMyApp = () => createApp(App)\``
    )
}

源码中大量存在这样的代码,该变量是一个布尔值,构建生产环境资源时为false,构建开发环境时为true,这样做到了在开发环境中为用户提供友好的警告信息的同时,不会增加生产环境代码的体积

2.3 框架要做到良好的Tree-Shaking

什么是Tree-Shaking呢?在前端领域,这个概念因rollup.js而普及。简单来说,Tree-Shaking指的是消除哪些永远不会被执行的代码,也就是排查 dead code,现在无论是rollup.js还是webpack,都支持Tree-Shaking。想实现Tree-Shaking必须满足: 模块必须是ESM(ES Module),Tree-Shaking依赖ESM的静态结构:

|—— demo
|   —— package.json
|   —— input.js
|   —— utils.js

了解更多Tree-Shaking,来这里

2.4 框架应该输出怎样的构建产物

不同类型的构建产物是为了满足不同的需求,为了用户能够通过<script>标签直接应用并使用,需要输出IIFE(立即调用的函数表达式)格式的资源;为了让用户可以直接通过<script type='module'>引用并使用,需要输出ESM格式的资源,EMS格式的资源有两种: 用于浏览器的 ems-browser.js 和用于打包工具的 ems-bundler.js。

2.5 特殊开关及良好的TypeScript类型支持

框架会提供多种能力或者功能,有时出于灵活性和兼容性考虑,对于同样的任务,框架提供了两种解决方案,例如vue.js中选项对象式API和组合式API都能用来完成页面的开发,两者虽然不互斥,但是从框架设计的角度看,这完全是基于兼容性考虑的。有时用户明确知道自己仅会使用组合式API,而不会使用选项用户就可以通过特殊开关关闭对应的特性,这样打包的时候,关于实现关闭功能的代码会被Tree-Shaking机制排除。

vue.js团队花费很多精力做类型推导,从而做到更好的类型支持外,还要考虑对TSX的支持;但是需要说明的是TS编写代码和对TS类型支持友好是两回事

vue.js3的设计思路

建议阅读原版书籍了解具体实现。

3.1 声明式地描述UI

大家思考一个问题: 编写一个前端页面需要涉及哪些内容?

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

接着思考第二个问题,从框架的设计者角度触发,如何声明式的描述上述的内容呢?

以vue.js3为例: 

使用与HTML标签一致的方式来描述DOM元素和属性:
<div id='app'></div>
使用: 或 v-bind来描述动态绑定的属性: 
<div :id='dynamicId'></div>
使用 @ 或 v-on来描述事件,如点击事件: 
<div @click='handleClick'></div>
使用与HTML标签一致的方式来描述层级结构:
<div><span></span></div>

以上方式都属于使用模板来声明式的描述UI,下面再以JavaScript对象来描述UI:

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对象更具有灵活性,使用JavaScript对象来描述UI的方式其实就是虚拟DOM

vue.js3支持使用模板描述UI,也支持使用虚拟DOM描述UI:

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

h函数其实只是一个语法糖,底层实现的返回值还是一个JavaScript对象形式的虚拟DOM结构:

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

那么现在通过一系列的努力,咱们已经拿到虚拟DOM的数据结构了,怎么把组件的内容渲染出来呢?上面的render函数就是来渲染UI的,称之为渲染器

3.2 初识渲染器

渲染器的主要作用就是把虚拟DOM渲染成真是DOM:

虚拟DOM
h('div', 'hellow') --->  渲染器 ---> 真实DOM

如下例子:

// 虚拟DOM
const vnode = {
    tag: 'div', // tag 描述标签名称, 如tag: 'div'描述一个<div>标签
    props: {    // props 为一个对象, 用来描述<div>等标签的属性, 事件等内容
        onClick : () => alert('hello')
    },
    children : '点我' // children用来描述标签的子节点
}

再来编写一个渲染器:

// renderer 和 render 不是一个概念, 前者是名词(渲染器), 后者是动词(渲染)
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(
                // 将事件明 onClick ===> click
                key.substr(2).toLowerCase(),
                // 事件处理函数
                vnode.props[key]
            )
        }
    }
    // 处理children
    if (typeof vnode.children === 'string') {
        // 说明元素是文本子节点
        el.appendChild(document.createTextNode(vnode.children))
    } else if (Array.isArray(vnode.children)) {
        // 递归调用 render 函数渲染子节点, 使用当前元素el作为挂载点
        vnode.children.forEach(child => render(child, el))
    }

    // 将元素添加到挂载点下
    container.appendChild(el)
}

远行上面实现的渲染器,页面展示预期的结果

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id='app'></div>
</body>
    <script>
        const vnode = {
            tag: 'div', // tag 描述标签名称, 如tag: 'div'描述一个<div>标签
            props: {    // props 为一个对象, 用来描述<div>等标签的属性, 事件等内容
                onClick : () => alert('hello')
            },
            children : '点我' // children 用来描述标签的子节点
        }


        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(
                        // 将事件明 onClick ===> click
                        key.substr(2).toLowerCase(),
                        // 事件处理函数
                        vnode.props[key]
                    )
                }
            }
            // 处理children
            if (typeof vnode.children === 'string') {
                // 说明元素是文本子节点
                el.appendChild(document.createTextNode(vnode.children))
            } else if (Array.isArray(vnode.children)) {
                // 递归调用 render 函数渲染子节点, 使用当前元素 el 作为挂载点
                vnode.children.forEach(child => render(child, el))
            }

            // 将元素添加到挂载点下
            container.appendChild(el)
        }

        renderer(vnode, document.querySelector("#app"))
    </script>
</html>

image.png

根据上面的步骤我们实现了一个简单的渲染器渲染页面,至少从上面的实现来看,我们揭开了渲染器的神秘面纱,我们实现了针对单个标签元素的渲染,那么怎么渲染组件呢?

3.3 组件的本质

上文说到 { tag: 'div' } 用来描述<div>标签,但是组件是什么?一个<div>标签是一个组件,多个div标签组合而成的也是一个组件,组件就是一组DOM元素的封装,如下:

// 一个返回一个DOM结构函数
const MyCompontent = function () {
    return {
        tag: 'div',
        props: {
            onClick : () => alert('hello')
        },
        children : 'click me'
    }
}
// 使用虚拟DOM对象表示
const vnode = {
    tag: MyCompontent
}

将我们实现的渲染器调整一下:

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

function mountCompontent(vnode, container) {
    // 调用组件函数,获取组件要渲染的内容即虚拟DOM
    const subtree = vnode.tag()
    // 递归调用 renderer 渲染 subtree
    renderer(subtree, 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(
                // 将事件明 onClick ===> click
                key.substr(2).toLowerCase(),
                // 事件处理函数
                vnode.props[key]
            )
        }
    }
    // 处理children
    if (typeof vnode.children === 'string') {
        // 说明元素是文本子节点
        el.appendChild(document.createTextNode(vnode.children))
    } else if (Array.isArray(vnode.children)) {
        // 递归调用 render 函数渲染子节点, 使用当前元素 el 作为挂载点
        vnode.children.forEach(child => render(child, el))
    }

    // 将元素添加到挂载点下
    container.appendChild(el)
}

从上面的实现,细心的同学应该发现了,我们的组件就只能是函数形式的吗?能不能是对象呢?

// 一个对象的 render 函数方法返回一个DOM结构
const MyCompontent = {
    render () {  
        return {
            tag: 'div',
            props: {
                onClick : () => alert('hello')
            },
            children : 'click me'
        }
    }
}

修改renderer渲染器

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

修改mountCompontent函数

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

花费很小的代价就完成了修改,那么还有没有其他形式的表达形式呢?

不管是虚拟DOM还是使用模板,都属于声明式的描述UI,前面我们说到虚拟DOM是如何渲染成真实DOM的,那么模板又是如何工作的呢?下面介绍一下编译器模板的工作原理

3.4 模板的工作原理

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

<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渲染页面的流程。各个部分相互关联,互相制约,共同构成了一个有机整体

总结

通过上面的梳理,框架的设计概览这块的内容大致就总结完了,通过学习明白是什么是命令式框架和声明式框架,虚拟DOM及最终选择虚拟DOM作为框架数据结构的来龙去脉,什么是运行时和编译时,以及框架设计的核心要素,然后通过简单的例子实现了最简单的渲染器和编译器,明白了模板的工作原理,明白了vue.js3的设计思路。

写这篇文章之前,心里想简单对书中内容做个总结学习,但是写的过程中发现很多东西都是需要自己去梳理准备的,这样的方式让自己对这些技术点的认识更加的深入,后面自己学会坚持对学到的技术点通过文章表述的形式输出,希望大家能够提出宝贵的建议哦

路虽远行则将至,事虽难做则必成!