“框架设计里到处都体现了权衡的艺术。”
学习了Vue.js 3的框架设计,快速过了一些章节,后面需要再读一遍。
本书基于Vue.js 3,从规范出发,以源码为基础,循序渐进地讲解Vue.js中各个功能模块的实现,细致剖析框架设计原理。从全局的角度对框架的设计拥有清晰的认知,否则很容易被细节困住,看不清全貌。
第 1 章 权衡的艺术
- 命令式和声明式
命令式框架的一大特点就是关注过程。声明式框架更加关注结果。Vue.js 的内部实现一定是命令式的,而暴露给用户的却更加声明式。声明式代码的性能不优于命令式代码的性能。声明式代码的可维护性更强。
在采用声明式提升可维护性的同时,性能就会有一定的损失,而框架设计者要做的就是:在保持可维护性的同时让性能损失最小化。
声明式代码的更新性能消耗 = 找出差异的性能消耗+ 直接修改的性能消耗,虚拟 DOM,就是为了最小化找出差异这一步的性能消耗而出现的。
虚拟 DOM 创建页面的过程分为两步:第一步是创建 JavaScript 对象,这个对象可以理解为真实 DOM 的描述;第二步是递归地遍历虚拟 DOM 树并创建真实 DOM。对于虚拟 DOM 来说,无论页面多大,都只会更新变化的内容,而对于 innerHTML 来说,页面越大,就意味着更新时的性能消耗越大。 - 运行时和编译时
首先是纯运行时的框架。由于它没有编译的过程,因此我们没办法分析用户提供的内容,但是如果加入编译步骤,可能就大不一样了,我们可以分析用户提供的内容,看看哪些内容未来可能会改变,哪些内容永远不会改变,这样我们就可以在编译的时候提取这些信息,然后将其传递给 Render 函数,Render 函数得到这些信息之后,就可以做进一步的优化了。然而,假如我们设计的框架是纯编译时的,那么它也可以分析用户提供的内容。由于不需要任何运行时,而是直接编译成可执行的 JavaScript 代码,因此性能可能会更好,但是这种做法有损灵活性,即用户提供的内容必须编译后才能用。
Vue.js 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化。它的过程被分为两步:先把 template 模板转化为 render 函数,也就是 编译时;再利用 render 函数,把 虚拟 DOM 转化为 真实 DOM,也就是 运行时。第 2 章 框架设计的核心要素
- 1 提升用户的开发体验
在框架设计和开发过程中,提供友好的警告信息至关重要。浏览器允许我们编写自定义的formatter,从而自定义输出形式。在 Vue.js 3 的源码中,你可以搜索到名为 initCustomFormatter 的函数,该函数就是用来在开发环境下初始化自定义 formatter 的。以 Chrome为例,我们可以打开 DevTools 的设置,然后勾选“Console”→“Enable custom formatters”选项,启用自定义formatter后,当我们再次在控制台打印一个ref数据时,输出的内容就会变得非常直观。总的来说,initCustomFormatter是一个非常有用的功能,它可以帮助开发者在开发Vue.js应用时获得更清晰的数据展示,从而提升开发体验。 - 2 控制框架代码的体积
在实现同样功能的情况下,当然是用的代码越少越好,这样体积就会越小,最后浏览器加载资源的时间也就越少。
每一个 warn 函数的调用都会配合__DEV__
常量的检查,Vue.js 使用 rollup.js 对项目进行构建,这里的__DEV__
常量实际上是通过 rollup.js 的插件配置来预定义的,在输出资源的时候,会输出两个版本,其中一个用于开发环境,如 vue.global.js,另一个用于生产环境,如 vue.global.prod.js,通过文件名我们也能够区分。做到了在开发环境中为用户提供友好的警告信息的同时,不会增加生产环境代码的体积。 - 3 框架要做到良好的 Tree-Shaking
Tree-Shaking 指的就是消除那些永远不会被执行的代码,也就是排除 dead code,现在无论是 rollup.js 还是 webpack,都支持 Tree-Shaking。实现 Tree-Shaking,必须满足一个条件,即模块必须是 ESM(ES Module)。如果一个函数调用会产生副作用,那么就不能将其移除。副作用就是,当调用函数的时候会对外部产生影响,因为静态地分析 JavaScript 代码很困难,所以像 rollup.js 这类工具都会提供一个机制,让我们能明确地告诉 rollup.js:“放心吧,这段代码不会产生副作用,你可以移除它。/*#__PURE__*/
是一种特殊的注释,主要用于指示构建工具可以安全地移除某些代码,因为它不会产生副作用。在编写框架的时候需要合理使用/*#__PURE__*/
注释。该注释不仅仅作用于函数,它可以应用于任何语句上。该注释也不是只有 rollup.js 才能识别,webpack 以及压缩工具(如 terser)都能识别它。 - 4 框架应该输出怎样的构建产物
Vue.js 的构建产物除了有环境上的区分之外,还会根据使用场景的不同而输出其他形式的产物。
IIFE 的全称是 Immediately Invoked Function Expression,即“立即调用的函数表达式”,在 rollup.js 中,我们可以通过配置 format: ‘iife’ 来输出这种形式的资源,使用<script>
标签直接引入 vue.global.js 文件后,全局变量 Vue 就是可用的了。
现在主流浏览器对原生 ESM 的支持都不错,所以用户除了能够使用<script>
标签引用 IIFE 格式的资源外,还可以直接引入 ESM 格式的资源,例如 Vue.js 3 还会输出 vue.esm-browser.js 文件,用户可以直接用<script type="module">
标签引入。Vue.js 还会输出一个 vue.esm-bundler.js 文件,其中 -browser 变成了 -bundler。带有 -bundler 字样的 ESM 资源是给 rollup.js 或 webpack 等打包工具使用的,而带有 -browser 字样的 ESM 资源是直接给<script type="module">
使用的。
无论是 rollup.js 还是 webpack,在寻找资源时,如果 package.json 中存在 module 字段,那么会优先使用 module 字段指向的资源来代替 main 字段指向的资源。ESM 格式的资源有两种:用于浏览器的 esm-browser.js 和用于打包工具的 esm-bundler.js。它们的区别在于对预定义常量__DEV__
的处理,前者直接将__DEV__
常量替换为字面量 true 或 false,后者则将__DEV__
常量替换为process.env.NODE_ENV !== 'production'
语句。
当进行服务端渲染时,Vue.js 的代码是在 Node.js 环境中运行的,而非浏览器环境。在 Node.js 环境中,资源的模块格式应该是 CommonJS,简称 cjs。为了能够输出 cjs 模块的资源,我们可以通过修改 rollup.config.js 的配置 format: ‘cjs’ 来实现。 - 5 特性开关
对于用户关闭的特性,我们可以利用 Tree-Shaking 机制让其不包含在最终的资源中。 该机制为框架设计带来了灵活性,可以通过特性开关任意为框架添加新的特性,而不用担心资源体积变大。同时,当框架升级时,我们也可以通过特性开关来支持遗留 API,这样新用户可以选择不使用遗留 API,从而使最终打包的资源体积最小化。
例如:__VUE_OPTIONS_API__
是一个特性开关,是一个全局的编译时标志,用于判断是否启用 Options API。Options API 是 Vue.js 的一种编程模式,它允许开发者通过一个包含各种选项(如 data、methods、computed 等)的对象来定义组件。在 Vue 3 中,推荐使用 Composition API 来编写代码,开发者可以选择禁用 Options API,以减小最终构建的大小。 - 6 错误处理
框架错误处理机制的好坏直接决定了用户应用程序的健壮性,还决定了用户开发时处理错误的心智负担。
用户可以使用registerErrorHandler 函数注册错误处理程序,然后在 callWithErrorHandling 函数内部捕获错误后,把错误传递给用户注册的错误处理程序。这时错误处理的能力完全由用户控制,用户既可以选择忽略错误,也可以调用上报程序将错误上报给监控系统。在 Vue.js 中,我们也可以注册统一的错误处理函数app.config.errorHandler 。 - 7 良好的 TypeScript 类型支持
TypeScript 是由微软开源的编程语言,简称 TS,它是 JavaScript 的超集,能够为 JavaScript 提供类型支持。使用 TS 的好处有很多,如代码即文档、编辑器自动提示、一定程度上能够避免低级 bug、代码的可维护性更强等。第 3 章 Vue.js 3 的设计思路
Vue.js 采用模板的方式来描述 UI,但它同样支持使用虚拟 DOM 来描述 UI。虚拟 DOM 要比模板更加灵活,但模板要比虚拟 DOM 更加直观。
渲染器的作用是,把虚拟 DOM 对象渲染为真实 DOM 元素。它的工作原理是,递归地遍历虚拟 DOM 对象,并调用原生 DOM API 来完成真实 DOM 的创建。渲染器的精髓在于后续的更新,它会通过 Diff 算法找出变更点,并且只会更新需要更新的内容。
渲染器是 函数 createRenderer 的返回值,是一个对象。被叫做 renderer。 renderer 对象中有一个方法 render,这个 render ,就是我们常说的渲染函数。渲染函数接收两个参数 VNode 和 container。其中 VNode 表示 虚拟 DOM,本质上是一个 JS 对象。container 是一个容器,表示被挂载的位置。而 render 函数的作用,就是: 把 vnode 挂载到 container 上。第 4 章 响应系统的作用与实现
一个响应式数据最基本的实现依赖于对“读取”和“设置”操作的拦截,从而在副作用函数与响应式数据之间建立联系。当“读取”操作发生时,我们将当前执行的副作用函数存储到“桶”中;当“设置”操作发生时,再将副作用函数从“桶”里取出并执行。这就是响应系统的根本实现原理。
WeakMap 是弱引用的,它不影响垃圾回收器的工作。当用户代码对一个对象没有引用关系时,WeakMap 不会阻止垃圾回收器回收该对象。
响应系统的可调度性,所谓可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。想要实现一个调度系统,需要基于 Set 构建出一个基本的队列数组 jobQueue,利用 Promise 的异步特性,来控制执行的顺序。
计算属性,即 computed,计算属性实际上是一个懒执行的副作用函数,我们通过lazy 选项使得副作用函数可以懒执行。当计算属性依赖的响应式数据发生变化时,会通过 scheduler 将dirty 标记设置为 true,代表“脏”。这样,下次读取计算属性的值时,我们会重新计算真正的值。
watch本质上利用了副作用函数重新执行时的可调度性。一个 watch 本身会创建一个 effect,当这个 effect 依赖的响应式数据发生变化时,会执行该 effect 的调度器函数,即 scheduler。这里的 scheduler 可以理解为“回调”,所以我们只需要在 scheduler 中执行用户通过 watch 函数注册的回调函数即可。立即执行回调的 watch,通过添加新的immediate 选项来实现;通过 flush 选项来指定回调函数具体的执行时机,本质上是利用了调用器和异步的微任务队列。
过期的副作用函数,它会导致竞态问题。为了解决这个问题,Vue.js 为 watch 的回调函数设计了第三个参数,即onInvalidate。它是一个函数,用来注册过期回调。每当 watch 的回调函数执行之前,会优先执行用户通过 onInvalidate 注册的过期回调。这样,用户就有机会在过期回调中将上一次的副作用标记为“过期”,从而解决竞态问题。第 5 章 非原始值的响应式方案
Vue.js 3 的响应式数据是基于 Proxy 实现的,Proxy 可以为其他对象创建一个代理对象。使用 Proxy 可以创建一个代理对象。它能够实现对其他对象的代理,也就是说,Proxy 只能代理对象,无法代理非对象值,例如字符串、布尔值等。代理,指的是对一个对象基本语义的代理,它允许我们拦截并重新定义对一个对象的基本操作。访问器属性的 this 指向问题,这需要使用 Reflect.* 方法并指定正确的 receiver 来解决。代理对象的本质,就是查阅规范并找到可拦截的基本操作的方法。有一些操作并不是基本操作,而是复合操作,这需要我们查阅规范了解它们都依赖哪些基本操作,从而通过基本操作的拦截方法间接地处理复合操作。
一个对象是函数还是其他对象,是由部署在该对象上的内部方法和内部槽决定的。
浅响应(或只读)代表仅代理一个对象的第一层属性,即只有对象的第一层属性值是响应(或只读)的。深响应(或只读)则恰恰相反,为了实现深响应(或只读),我们需要在返回属性值之前,对值做一层包装,将其包装为响应式(或只读)数据后再返回。
在 ECMAScript 规范中,JavaScript 中有两种对象,其中一种叫作常规对象,另一种叫作异质对象。数组是一个异质对象,因为数组对象部署的内部方法[[DefineOwnProperty]]
不同于常规对象。
集合类型指 Set、Map、WeakSet 以及WeakMap。集合类型不同于普通对象,它有特定的数据操作方法。当使用 Proxy 代理集合类型的数据时要格外注意。集合类型的遍历,即 forEach 方法。集合的 forEach 方法与对象的for…in 遍历类似,最大的不同体现在,当使用for…in遍历对象时,我们只关心对象的键是否变化,而不关心值;但使用forEach 遍历集合时,我们既关心键的变化,也关心值的变化。第 6 章 原始值的响应式方案
原始值指的是 Boolean、Number、BigInt、String、Symbol、undefined 和 null 等类型的值。在 JavaScript 中,原始值是按值传递的,而非按引用传递。
由于 Proxy 的代理目标必须是非原始值,所以我们能够想到的唯一办法是,使用一个非原始值去“包裹”原始值,使用 Object.defineProperty 为包裹对象 wrapper 定义了一个不可枚举且不可写的属性__v_isRef
,它的值为 true,代表这个对象是一个 ref,而非普通对象。这样我们就可以通过检查__v_isRef
属性来判断一个数据是否是 ref 了。ref 除了能够用于原始值的响应式方案之外,还能用来解决响应丢失问题。解决问题的思路是,将响应式数据转换成类似于 ref 结构的数据。
自动脱 ref,指的是属性的访问行为,即如果读取的属性是一个 ref,则直接将该 ref 对应的 value 属性值返回。在 Vue.js 中,reactive 函数也有自动脱 ref 的能力。有了自动脱 ref 的能力后,用户在模板中使用响应式数据时,将不再需要关心哪些是 ref,哪些不是 ref。自动脱 ref 能力是 Vue.js 响应式系统的一部分,它的主要目的是减轻用户的心智负担,让用户在使用响应式数据时,不需要关心一个值是否是 ref。
在 Vue.js 中,如果一个值是 ref,那么它的 value 属性会被代理,使得我们可以通过该属性读取或修改其值。然而,在大多数情况下,用户并不需要知道一个值是否是 ref,他们只需要知道这个值是否是响应式的。因此,Vue.js 提供了自动脱 ref 的能力,使得在模板中,我们可以通过普通的属性访问方式读取 ref 的值,而不需要使用 value 属性。第 7 章 渲染器的设计
渲染器的作用是把虚拟 DOM 渲染为特定平台上的真实元素,我们用英文 renderer来表达渲染器。虚拟 DOM 通常用英文 virtual DOM 来表达,有时会简写成 vdom 或 vnode。渲染器会执行挂载和打补丁操作,对于新的元素,渲染器会将它挂载到容器内;对于新旧 vnode 都存在的情况,渲染器则会执行打补丁操作,即对比新旧 vnode,只更新变化的内容。vnode 本身是 一个普通的 JavaScript 对象,代表了渲染的内容。对象中通过 type 表示渲染的 DOM。比如 type === div:则表示 div 标签、type === Framgnet 则表示渲染片段(vue 3 新增)。
在浏览器端渲染时,利用 DOM API 完成 DOM 操作:比如,如果渲染 DOM 那么就使用 createElement,如果要删除 DOM 那么就使用 removeChild。渲染器不能与宿主环境(浏览器)产生强耦合:因为 vue 不光有浏览器渲染,还包括了 服务端 渲染,所以如果在渲染器中绑定了宿主环境,那么就不好实现服务端渲染了。第 8 章 挂载与更新
对于子节点,只需要递归地调用 patch 函数完成挂载即可。Vue.js 对 class 属性做了增强,它允许我们为 class 指定不同类型的值,为元素设置 class 的方式中el.className的性能最优。
unmount 函数是以一个 vnode 的维度来完成卸载的,它会根据 vnode.el 属性取得该虚拟节点对应的真实 DOM,然后调用原生 DOM API 完成 DOM 元素的卸载。当 unmount 函数执行时,我们有机会检测虚拟节点 vnode 的类型。如果该虚拟节点描述的是组件,则我们也有机会调用组件相关的生命周期函数。
在虚拟节点中把 vnode.props 对象中以字符串 on 开头的属性当作事件对待。vnode.children 属性只能有如下三种类型,字符串类型:代表元素具有文本子节点;数组类型:代表元素具有一组子节点; null:代表元素没有子节点。渲染器渲染 Fragment的方式类似于渲染普通标签,不同的是,Fragment 本身并不会渲染任何 DOM 元素。所以,只需要渲染一个 Fragment 的所有子节点即可。第 9 章 简单 Diff 算法
当新旧 vnode 的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作 Diff 算法。Diff 算法用来计算两组子节点的差异,并试图最大程度地复用 DOM 元素。第 10 章 双端 Diff 算法
简单 Diff 算法利用虚拟节点的 key 属性,尽可能地复用 DOM 元素,并通过移动 DOM的方式来完成更新,从而减少不断地创建和销毁 DOM 元素带来的性能开销。但是,简单 Diff 算法仍然存在很多缺陷。简单 Diff 算法的问题在于,它对 DOM 的移动操作并不是最优的。
双端 Diff 算法指的是,在新旧两组子节点的四个端点之间分别进行比较,并试图找到可复用的节点。相比简单 Diff 算法,双端 Diff 算法的优势在于,对于同样的更新场景,执行的 DOM 移动操作次数更少。第 11 章 快速 Diff 算法
快速 Diff 算法最早应用于ivi 和 inferno 这两个框架,Vue.js 3 借鉴并扩展了它。它借鉴了文本 Diff 中的预处理思路,先处理新旧两组子节点中相同的前置节点和相同的后置节点。当前置节点和后置节点全部处理完毕后,如果无法简单地通过挂载新节点或者卸载已经不存在的节点来完成更新,则需要根据节点的索引关系,构造出一个最长递增子序列。最长递增子序列所指向的节点即为不需要移动的节点。第 12 章 组件的实现原理
使用虚拟节点的 vnode.type 属性来存储组件对象,渲染器根据虚拟节点的该属性的类型来判断它是否是组件。如果是组件,则渲染器会使用mountComponent 和 patchComponent 来完成组件的挂载和更新。在组件挂载阶段,会为组件创建一个用于渲染其内容的副作用函数。该副作用函数会与组件自身的响应式数据建立响应联系。当组件自身的响应式数据发生变化时,会触发渲染副作用函数重新执行,即重新渲染。但由于默认情况下重新渲染是同步执行的,这导致无法对任务去重,因此我们在创建渲染副作用函数时,指定了自定义的调用器。该调度器的作用是,当组件自身的响应式数据发生变化时,将渲染副作用函数缓冲到微任务队列中。有了缓冲队列,我们即可实现对渲染任务的去重,从而避免无用的重新渲染所导致的额外性能开销。
组件实例本质上是一个对象,包含了组件运行过程中的状态,例如组件是否挂载、组件自身的响应式数据,以及组件所渲染的内容(即subtree)等。渲染上下文(renderContext),它实际上是组件实例的代理对象。在渲染函数内访问组件实例所暴露的数据都是通过该代理对象实现的。
setup 函数是为了组合式 API 而生的, setup 函数的返回值可以是两种类型,如果返回函数,则将该函数作为组件的渲染函数;如果返回数据对象,则将该对象暴露到渲染上下文中。
组件的插槽,它借鉴了 Web Component 中<slot>
标签的概念。插槽内容会被编译为插槽函数,插槽函数的返回值就是向槽位填充的内容。<slot>
标签则会被编译为插槽函数的调用,通过执行对应的插槽函数,得到外部向槽位填充的内容(即虚拟DOM),最后将该内容渲染到槽位中。第 13 章 异步组件与函数式组件
在异步组件中,“异步”二字指的是,以异步的方式加载并渲染一个组件。而函数式组件允许使用一个普通函数定义组件,并使用该函数的返回值作为组件要渲染的内容。函数式组件的特点是:无状态、编写简单且直观。在 Vue.js 2 中,相比有状态组件来说,函数式组件具有明显的性能优势。但在Vue.js 3 中,函数式组件与有状态组件的性能差距不大,都非常好。
Vue.js 3 提供了 defineAsyncComponent 函数,用来定义异步组件。函数式组件没有自身状态,也没有生命周期的概念。所以,在初始化函数式组件时,需要选择性地复用有状态组件的初始化逻辑。第 14 章 内建组件和模块
KeepAlive 组件的作用类似于 HTTP 中的持久链接。它可以避免组件实例不断地被销毁和重建。
Teleport是Vue 3的新特性之一,它能够将模板渲染至指定DOM节点,而不受父级style、v-show等属性影响,但data、prop数据依旧能够共用;类似于React的Portal。
Teleport的实现原理:
1)Teleport组件并非普通组件,它有特殊的选项 __isTeleport 和 process。
2)在process方法内直接判断是挂载还是更新,对相关逻辑进行处理和分发。
3)通过resolveTarget选中目标节点,完成渲染后,挂载到对应的DOM元素下。
Teleport的优点:
1)Teleport组件可以跨越DOM层级完成渲染,这在很多场景下非常有用。
2)可以避免渲染器逻辑代码“膨胀”。
3)可以利用TreeShaking机制在最终的bundle中删除Teleport相关的代码,使得最终构建包的体积变小。
Transition 组件的实现原理与为原生 DOM 添加过渡效果的原理类似,我们将过渡相关的钩子函数定义到虚拟节点的 vnode.transition 对象中。渲染器在执行挂载和卸载操作时,会优先检查该虚拟节点是否需要进行过渡,如果需要,则会在合适的时机执行vnode.transition 对象中定义的过渡相关钩子函数。第 15 章 编译器核心技术概览
Vue.js的模板编译器用于把模板编译为渲染函数。它的工作流程大致分为三个步骤。
(1) 分析模板,将其解析为模板 AST。
(2) 将模板 AST 转换为用于描述渲染函数的 JavaScript AST。
(3) 根据 JavaScript AST 生成渲染函数代码。
AST 是树型数据结构,为了访问 AST 中的节点,我们采用深度优先的方式对 AST 进行遍历。第 16 章 解析器
文本模式指的是解析器在工作时所进入的一些特殊状态,在不同的特殊状态下,解析器对文本的解析行为会有所不同。具体来说,当解析器遇到一些特殊标签时,会切换模式,从而影响其对文本的解析行为。文本模式指的是解析器在工作时所进入的一些特殊状态,如 RCDATA 模式、CDATA 模式、RAWTEXT 模式,以及初始的 DATA 模式等。在不同模式下,解析器对文本的解析行为会有所不同。解析文本节点本身并不复杂,它的复杂点在于,我们需要对解析后的文本内容进行 HTML 实体的解码工作。WHATWG 规范中也定义了解码 HTML 实体过程中的状态迁移流程。HTML 实体类型有两种,分别是命名字符引用和数字字符引用。第 17 章 编译优化
编译优化指的是编译器将模板编译为渲染函数的过程中,尽可能多地提取关键信息,并以此指导生成最优代码的过程。编译优化的策略与具体实现是由框架的设计思路所决定的,但优化的方向基本一致,即尽可能地区分动态内容和静态内容,并针对不同的内容采用不同的优化策略。
Vue.js 3 的编译器会充分分析模板,提取关键信息并将其附着到对应的虚拟节点上。在运行时阶段,渲染器通过这些关键信息执行“快捷路径”,从而提升性能。Vue.js 3 会为动态节点打上补丁标志,即 patchFlag。除了 Block 树以及补丁标志之外,Vue.js 3 在编译优化方面还做了其他努力,具体如下:静态提升:能够减少更新时创建虚拟 DOM 带来的性能开销和内存占用。 预字符串化:在静态提升的基础上,对静态节点进行字符串化。这样做能够减少创建虚拟节点产生的性能开销以及内存占用。 缓存内联事件处理函数:避免造成不必要的组件更新。v-once 指令:缓存全部或部分虚拟节点,能够避免组件更新时重新创建虚拟 DOM 带来的性能开销,也可以避免无用的 Diff 操作。第 18 章 同构渲染
Vue.js 的两种渲染方式,即客户端渲染(client-side rendering,CSR),以及服务端渲染(server-side rendering,SSR)。Vue.js 作为现代前端框架,不仅能够独立地进行 CSR 或 SSR,还能够将两者结合,形成所谓的同构渲染(isomorphic rendering)。
传统的服务端渲染的用户体验非常差,任何一个微小的操作都可能导致页面刷新。与 SSR 在服务端完成模板和数据的融合不同,CSR 是在浏览器中完成模板与数据的融合,并渲染出最终的 HTML 页面。CSR 不仅仅会产生白屏问题,它对 SEO(搜索引擎优化)也不友好。由于 SSR 是在服务端完成页面渲染的,所以它需要消耗更多服务端资源。CSR 则能够减少对服务端资源的消耗。
同构渲染分为首次渲染(即首次访问或刷新页面)以及非首次渲染。实际上,同构渲染中的首次渲染与 SSR 的工作流程是一致的。也就是说,当首次访问或者刷新页面时,整个页面的内容是在服务端完成渲染的,浏览器最终得到的是渲染好的 HTML 页面。但是该页面是纯静态的,这意味着用户还不能与页面进行任何交互,因为整个应用程序的脚本还没有加载和执行。另外,该静态的 HTML 页面中也会包含<link>、<script>
等标签。
同构渲染所产生的 HTML 页面与 SSR 所产生的 HTML 页面有一点最大的不同,即前者会包含当前页面所需要的初始化数据。直白地说,服务器通过 API 请求的数据会被序列化为字符串,并拼接到静态的 HTML 字符串中,最后一并发送给浏览器。这么做实际上是为了后续的激活操作,假设浏览器已经接收到初次渲染的静态 HTML 页面,接下来浏览器会解析并渲染该页面。在解析过程中,浏览器会发现 HTML 代码中存在<link> 和 <script>
标签,于是会从 CDN 或服务器获取相应的资源,这一步与 CSR 一致。当 JavaScript 资源加载完毕后,会进行激活操作,这里的激活就是我们在 Vue.js 中常说的 “hydration”。激活包含两部分工作内容。Vue.js 在当前页面已经渲染的 DOM 元素以及 Vue.js 组件所渲染的虚拟 DOM 之间建立联系。Vue.js 从 HTML 页面中提取由服务端序列化后发送过来的数据,用以初始化整个 Vue.js 应用程序。激活完成后,整个应用程序已经完全被 Vue.js 接管为 CSR 应用程序了。后续操作都会按照 CSR 应用程序的流程来执行。当然,如果刷新页面,仍然会进行服务端渲染,然后再进行激活,如此往复。
同构渲染的“同构”一词的含义是,同样一套代码既可以在服务端运行,也可以在客户端运行。编写同构的组件代码时要额外注意。具体可以总结为以下几点。
注意组件的生命周期。beforeUpdate、updated、beforeMount、mounted、beforeUnmount、unmounted 等生命周期钩子函数不会在服务端执行。
使用跨平台的 API。
特定端的实现。无论在客户端还是在服务端,都应该保证功能的一致性。
避免交叉请求引起的状态污染。
仅在客户端渲染组件中的部分内容。这需要我们自行封装<ClientOnly>
组件,被该组件包裹的内容仅在客户端才会被渲染。