高观点下的 Vue.js 框架

本文是我在学习、使用 Vue.js 过程中的一些刍议,有些想法仅为我个人的推测,故难免有纰漏甚至偏颇。所谓高观点,只是希望 Standpoint 尽可能高一些,从设计层面看待 Vue.js 这样一个框架,而不陷入具体的 API 讲解或源代码分析中,另外,也是为文章标题赢得一些噱头而已。


Web 前端编程最本质的工作内容其实仅有两条:

  1. 处理数据
  2. 更新 DOM

这里的“更新”当然包括首次加载、新增和删除 DOM 。DOM 被更新了,剩下的渲染、绘制过程均由浏览器完成。一直以来,我们的编程工作也是围绕这两件事展开。然而事情的细枝末节太多,繁琐的业务场景让代码量日增,我们的编码方式本身也亟需改进。第一次大变革自然是 jQuery 这类聚焦于“更新 DOM”的库或框架,它们帮我们消除浏览器差异、提供更便利的 API 等。第二次大变革则是前端的全面 MVC 化。

前端 MVC 历经了 MVC --> MVP --> MVVM 的过程,这里不去述说它的历史。现在我们说到前端 MVC , 说的是它的变种:MVVM 。而 Vue.js ,不论官方将其描述得如何高大上,其本质仍只是 MVVM 框架。

现在来细想 MVVM ,其中 M 为模型(Model),在前端其存在形式只可能是 JavaScript 对象——不管是语言层面最直接的 JavaScript 对象,还是经过一番折腾后封装的 JavaScript 对象——似乎无大可为,只能依赖 JavaScript 解释器来做更多的事情。而对 JavaScript 这样一种动态弱类型语言,模型过分地“充血”似乎也不妥。第二个字母 V 为视图(View),它就是我们所更新的那个 “DOM”,这一切都交由浏览器处理。似乎只有在它们中间放个夹层才能做些大事,这便是 VM ,视图模型层。

至此,我们在“数据”和“DOM”间建立了桥梁。

1. 鸟瞰

统观 Vue.js 框架,它所放置的这个夹层,便是被叫作 “虚拟 DOM” 的东西。其它一切围绕它展开:

image.png

我想,Vue.js 所面临的第一个决策便是其所采用的编程范式是“命令式”还是“声明式”的。声明式显然在用户层面有其优势,用户代码只需要关注视图最终结果。不管结果存在几种状态,只需要通过某种方式(如 Vue.js 的模板)描述出来。实现声明式后,模型层的数据应当自动流经虚拟 DOM 进而进入真实 DOM 。

因此,框架中需要一个功能模块,用它来监听用户数据的变化,继而更新虚拟 DOM。这样一来,在引擎层面引入的该功能模块使得用户代码仅为处理数据,而不需要直接与虚拟 DOM 打交道。能够自动捕获用户数据变化并更新虚拟 DOM 的功能模块便是学习 Vue.js 的开发人员最为熟知的响应系统

image.png

响应系统是用户代码进入框架引擎的发端。 如果从一开始便是可响应的,后续引擎中引入的一切功能,便有了“自动”执行的可能。另一方面我们清醒地知道,程序并没有那么多所谓的“自动”,总归是要存在一个触发点的。而响应系统的触发,直接面向 JavaScript 语言层面的对象修改、删除或数组、集合的增删改等操作。这样一个大的观察者模式的被观察对象直接是语言层面最原始的东西,这可能让很多不熟悉语言规范的人感到陌生而神奇,此点后文详说。

有了虚拟 DOM 这个夹层后,很多事情便有了切入点,方便框架施展手脚。然而虚拟 DOM 是不能被浏览器识别并渲染成可视的界面的。在虚拟 DOM 与真实 DOM 之间必然要存在一层转换机制,这便是渲染器,渲染器本质是把虚拟 DOM 这种静态的描述,变成对真实 DOM 的挂载、卸载与更新等操作。

image.png

至此,一个完整的声明式的框架系统便可以工作了。然而我们更进一步来看,模型层的数据可以直接使用语言层面的 JavaScript 对象,框架没入侵我们的编码习惯,没有要求我们遵从特殊的约定写代码(这是响应系统的功劳)。而视图层面呢?从我们熟知的 DOM 变成了一个叫“虚拟 DOM”的东西。可我们还不知道虚拟 DOM 究竟是什么。现在有必要揭开虚拟 DOM 的面纱。

其实,它只是一个用来描述真实 DOM 的 JavaScript 对象。举个例子便可知,如真实 DOM 结构:

bar

对应的虚拟 DOM 为:

const vnodeDiv = {
    tag: 'div',
    props: {
        id: 'foo'
    },
    children: 'bar'
}

整个 Vue.js 框架居然是围绕这么一个简单的东西在运作,想起来有些微妙。

现在回到刚才的问题,视图层如何表示?当应用系统需求复杂后,这种 JavaScript 对象的表示方式显然过于丑陋(用过 ExtJS 等框架的开发者显然了解这种方式的痛苦所在)。Vue.js 框架的设计者想让开发者的使用体验回归到编写 HTML 的方式,这是一个很好的设想,如何实现呢?

image.png

经验非常自然地告诉我们,此处需要一个编译器,用它来架起 HTML 和虚拟 DOM 的桥梁,将用户写的 HTML 翻译成虚拟 DOM。值得注意的是,此处的 HTML 已不再是直接交给浏览器解析的 HTML,而只是借鉴了它的语法形式,本质上只是一些字符串而已。既然决定引入编译器这种复杂的结构,对于这个形如 HTML 的字符串,何不做做手脚让它更为丰富呢?于是框架设计者建立了一套模板系统,可以用它扩展 HTML 的语法。

image.png

现在还有两个小问题未解决,它们在框架架构上是小问题,在用户使用上却至关重要。第一个问题是如何让 HTML 灵动起来。例如如何重复书写 100 遍 div ,如何在特定条件下输出某 span ?HTML 并不是图灵完备的语言,而 JavaScript 却是,虚拟 DOM 刚好也是用 JavaScript 表示。那么,如果用一个 JavaScript 函数来输出虚拟 DOM,而编译器的编译目标不再是虚拟 DOM,改为这个函数,问题便可迎刃而解。

image.png

这便是渲染函数在框架中存在的位置及其存在的最根本意义。当虚拟 DOM 的存在形式从普通对象上升为函数,一切便“灵动”起来。函数中使用 for 或者 if 语句,可以解决我们刚才提出的问题。

第二个小问题。庞大的应用系统中,势必存在繁多的虚拟 DOM ,如何维持其秩序?最简单的方式当然是把这个出力不讨好的活交给开发者本人。Vue.js 框架层面只给开发者提供管理制度,具体的管理方式让其用户(即开发者)自行决定。这套管理制度被叫作组件系统。所谓组件系统,本质上是针对虚拟 DOM 进行的打包捆绑方式。我们已经知道了虚拟 DOM 的样貌,在此不妨也来看看组件的样貌(注意,这不是 Vue.js API 层面的组件形式):

const MyComponent = {
    name: 'foo',
    myProperty: 'bar',
    ...
    render () {
        return {
            tag: 'div',
            ...
        }
    }
}

可以看到,只要组件里包含一个渲染函数(示例中的 render),它便可以通过渲染函数成为一个或一组虚拟 DOM 。而组件对象本身则可以在渲染函数之外附加更多的属性或其它处理函数。这样一来,我们通过提供渲染函数把一组虚拟 DOM 进行打包,附加的属性则像这个包裹上贴的标签一样,描述了这组虚拟 DOM 的特征,让它们成为一个共同体,与其它组件合作构成整个应用系统。由于组件的组织方式交给了用户,我们经常可以看到卓越的程序员封装出卓越的组件库,低劣程序员写的组件却支离破碎。

至此,我们围绕着虚拟 DOM 完成了 Vue.js 框架的全部顶层设计。用户侧模型层的代码,围绕语言层面最普通的 JavaScript 对象展开,而视图层的代码,聚焦在一种类似 HTML 的模板中。

image.png

等等!也就是说虚拟 DOM 在 MVVM 框架中是不可或缺的吗?并非如此。响应系统完全可能直接工作于真实 DOM 之上,而编译器也可以直接编译出真实 DOM 。事实上确实也有其它框架在这么做。这中间夹杂了诸多性能、易用性各方面的问题,而 Vue.js 大概是一种稳妥折中的决策结果吧。

2. 响应系统

现在更近一步来思考响应系统。响应系统是网络上资料最为繁多的一部分。有诸多文章甚至以“深入 Vue 原理”之名,内容却只讲了 Object.defineProperty 的用法,再写个示例代码便结束。我这里自然不去讨论 JavaScript 的语法细节,只看响应系统的设计过程。

设计思路

有必要先弄清楚根本目标:响应系统是为了实现用户模型层数据到视图层界面的声明式编程。 思考 Vue.js 的工作方式,或者说 Web 前端开发的工作方式:首先拿到用户数据,然后使用数据挂载页面,这是初次加载的情况。后续界面操作进行过程中,数据产生变动,拿到最新的用户数据,更新或卸载页面。

产生响应的重点,显然是后续数据更新的过程。Vue.js 的设计者并没有其它更高明的办法,他使用的是我们很容易想到的观察者模式——观察数据,通知视图。观察者 Observer 时刻关心响应式数据的变化,一有变化便通知更新视图(通过渲染器完成)。其接口中应该包含用于调用渲染器以更新视图的方法,这里遵从 Vue3 的命名,叫它 effect 函数。 唯一特殊的一点是, 注册观察者的过程似乎不需要用户来执行,是否可以在挂载页面或者说是首次执行 effect 时进行注册呢?答案是肯定的。effect 函数内部势必会去获取用来渲染视图的数据,如果在获取数据的时候注册观察者,后面修改数据的时候便可以通知观察者,即执行 effect 函数。此时我们引入一个存放 effect 函数的桶:

image.png

示例代码如下,请先忽略代码可能产生的 BUG,仅关心其所蕴含的调用关系:

const bucket = new Set()
function effect () {
    let data = getData()  // 获取数据,用于后续更新视图
    updateView(data)
}
function getData() {
    arr.push(effect)  // 注册观察者,即 effect 函数
    return data
}
function setData(val) {
    // 更新数据时通知观察者,即重新执行 effect 函数
    bucket.forEach(effect => {
        effect()
    })
    data = val
}

现在聚焦“读取数据”和“更新数据”操作。可以对其进行封装。使用代理模式,从模型层面为原始数据提供代理对象:

class DemoProxy {
    constructor (data) {
        this.raw = data
    },
    getData () {
        // ...
    },
    setData (val) {
        // ...
    }
}
let data = new DemoProxy(data)
data.setData('123')

而 JavaScript 在语言层面,提供了一个函数对象 Proxy 用于代理(ECMAScript 2015 开始提供,在此之前可以使用 Object.defineProperty 实现类似效果),使用它可以实现语言层面的代理,从而用户侧代码不需要调用形如 data.foo.setData('bar') 的函数,而是可以直接进行赋值,如 data.foo = 'bar'。 只是有一点要保持清醒,代理后的 data 并非用户一开始提供的那个 data , 对它的任何操作都可能被进行了拦截。另外也可以看出,即便 JavaScript 语言层面不提供 Object.definePropertyProxy ,框架仍然可能实现响应系统,只是用户侧代码相对约束会多一些。

由此可见, 响应系统在设计层面极其简单:代理模式 + 观察者模式。 代理普通 JavaScript 对象,调用 effect 函数从而触发对象读取操作,在读取操作的代理方法中注册 effect 函数作为观察者,之后当对象产生更新操作时,执行 effect 函数从而促使后续视图层重新渲染。

技术实现

使用 Proxy 是无法处理原始值的。如 String、Number、Symbol、null 等,它们只传值而不传引用。此特殊情况下,Vue.js 框架引入了特殊的函数 ref 。该函数的原理是给原始值包裹一层对象。当然用户可以自己来包裹对象,但使用 ref 的方式更为统一,更为要紧的是让框架知道用户的意图是将原始值转为可响应的代理对象,如此框架便可以在特定的场合帮助用户自动脱去这层包裹,让代码看起来更为优雅。阅读 Refs 相关的一组 API 文档可知,它还会额外处理响应丢失的问题。不管怎么说,引入额外的函数始终会让响应系统的工作模式显得不一致,但现阶段受技术限制,想必未有其它更好的解决办法。

Proxy 在代理对象、数组、Map、Set 时, 主要通过拦截相应内置方法实现,foo.bar 操作对应着内置方法 [[Get]] ,API 层面叫作 get ,相应的还有 sethasdeleteProperty 等等。通过查阅 JavaScript 语言规范我们可以知道所有语法细节并处理所有可能产生响应变化的操作。这是一个非常细碎的工作,框架应该已经帮我们处理过了,但面对庞杂的语言细节,也许在某个角落还存在某个特殊的 BUG 为框架所未及。但要相信,Vue.js 框架确实已经努力考虑周全了,比如这些情况:

effect 函数使用 for...in 访问对象时,对对象属性的增删改操作要被响应;当使用 findmapforEach等方法访问数组时,对数组元素的操作要能够被响应;当获取从原型链中继承的属性时,要正确处理响应等等。

边界情况

响应系统中有很多边界情况要考虑。比如分支切换问题:

let foo = obj.isFoo ? obj.foo : 'bar'

分支切换可能使 effect 函数被遗留在桶中。又如 effect 函数的嵌套问题,可以使用 stack 结构解决。另外还有无限递归、循环调用等问题。

另外有一种特殊情况,当用户执行了修改数据的操作但数据却没有变化:

let foo = 'foo'
foo = 'foo'
foo = 'fo' + 'o'

此时是不应该再触发响应的。这些边界情况的处理都属于框架细节,此文不深入讨论,但我们应该感受到,一个功能完善的框架要考虑的问题确实很多。

API 设计

基于响应系统的设计,框架在 API 层面设计实现了计算属性与 watch 函数。watch 的目的是给用户一个介入响应系统的控制点,其实质为对 effect 函数的二次封装。另外由于响应存在响应深度的问题,API 提供了深响应和浅响应两种方式。相应的,对于只读数据提供深只读和浅只读。

3. 渲染器

虽然“鸟瞰”一章节中,我在画图时把“响应系统”指向了“虚拟 DOM”,事实上响应系统中的 effect 函数执行时,会使用渲染器进行页面渲染,而渲染器中所使用的虚拟 DOM 是由响应系统提供数据。严格来说,渲染器是由响应系统直接调用的。 由于其在引擎层面被自动触发执行,渲染器通常不为普通开发者所知。

设计思路

渲染器的输入为虚拟 DOM,输出为真实 DOM。虚拟 DOM 英文写做 virtual DOM ,后文将用 vdom 简写它。相应的,vdom 中的节点被称作 virtual node ,简写做 vnode 。

我们可以使用 class 表示一个渲染器,不同的作用空间下 new 出多个渲染器。也可以使用 function 直接创建返回一个渲染器,多次函数调用则返回多个渲染器(当然也可以返回单例,这取决于设计者对渲染器作用范围的设定)。为了方便描述,我们选用函数来表示。

函数输入虚拟 DOM 的某节点 ,输出真实 DOM ,因此形如:

function createRenderer (vnode) {
    let div = document.createElement('div')
    ...
    return div   // 也可能生成一组 dom ,视 vnode 而定
}

进一步想,输出的真实 DOM 是需要挂载到某个容器上的,也就是某个父节点。如果 createRenderer 能够接管挂载操作,同时获得父节点的信息,似乎可以做更多工作。同时,用户侧代码也更为简洁。因此,我们把渲染器设计为:

function createRenderer (vnode, container) {
    ...
}

至此,我们需要约定一下在本文中对 DOM 操作时的四类情况及叫法:

  • 挂载( mount ):将新生成的节点增加到空的容器节点中;
  • 卸载( unmount ):将容器节点下的节点删除;
  • 更新( patch ):相同节点类型时,更新节点内容;
  • 移动( move ):将容器节点下的兄弟节点交换位置。

细节处理

写个函数生成 DOM 并非难事,Vue.js 之所以要将渲染器作为独立模块,是因为其处理的技术细节太多。我们先把细碎的点罗列一下,再专注于核心问题。如果不感兴趣,可以跳过此节。

一个相对简单的情况:vnode 中包含子节点,此时可以用递归迭代的方式处理。

第二个问题是:DOM 的 property 和 HTML 的 Attribute 基本存在对应关系,但却不尽相同。例如 HTML 的 class 属性,在 DOM 中叫作 className (因为 class 是 JavaScript 关键字)。而有些 DOM 中的属性在 HTML 中不存在,有些 HTML 中的属性在 DOM 中不存在。Vue.js 框架使用 in 操作符先检测某属性在 DOM 中是否存在,如不存在再使用 setAttribute 处理。

第三个问题是:某些属性是枚举值(如 type),只接受特定的取值范围,而某些属性是只读的,不能后续修改。另外,值为 bool 型时,HTML 中的设置方式是不同的,如

你可能感兴趣的:(高观点下的 Vue.js 框架)