Vue 是如何将一份模板转换为真实的 DOM 节点的,又是如何高效地更新这些节点的呢?我们接下来就将尝试通过深入研究 Vue 的内部渲染机制来解释这些问题。
你可能已经听说过“虚拟 DOM”的概念了,Vue 的渲染系统正是基于这个概念构建的。
虚拟 DOM (Virtual DOM,简称 VDOM) 是一种编程概念,意为将目标所需的 UI 通过数据结构“虚拟”地表示出来,保存在内存中,然后将真实的 DOM 与之保持同步。这个概念是由 React 率先开拓,随后被许多不同的框架采用,当然也包括 Vue。
与其说虚拟 DOM 是一种具体的技术,不如说是一种模式,所以并没有一个标准的实现。我们可以用一个简单的例子来说明:
const vnode = {
type: 'div',
props: {
id: 'hello'
},
children: [
/* 更多 vnode */
]
}
这里所说的 vnode 即一个纯 JavaScript 的对象 (一个“虚拟节点”),它代表着一个 <div> 元素。它包含我们创建实际元素所需的所有信息。它还包含更多的子节点,这使它成为虚拟 DOM 树的根节点。
一个运行时渲染器将会遍历整个虚拟 DOM 树,并据此构建真实的 DOM 树。这个过程被称为挂载 (mount)。
如果我们有两份虚拟 DOM 树,渲染器将会有比较地遍历它们,找出它们之间的区别,并应用这其中的变化到真实的 DOM 上。这个过程被称为更新 (patch),又被称为“比对”(diffing) 或“协调”(reconciliation)。
虚拟 DOM 带来的主要收益是它让开发者能够灵活、声明式地创建、检查和组合所需 UI 的结构,同时只需把具体的 DOM 操作留给渲染器去处理。
从高层面的视角看,Vue 组件挂载时会发生如下几件事:
Vue 模板会被预编译成虚拟 DOM 渲染函数。Vue 也提供了 API 使我们可以不使用模板编译,直接手写渲染函数。在处理高度动态的逻辑时,渲染函数相比于模板更加灵活,因为你可以完全地使用 JavaScript 来构造你想要的 vnode。
那么为什么 Vue 默认推荐使用模板呢?有以下几点原因:
模板更贴近实际的 HTML。这使得我们能够更方便地重用一些已有的 HTML 代码片段,能够带来更好的可访问性体验、能更方便地使用 CSS 应用样式,并且更容易使设计师理解和修改。
由于其确定的语法,更容易对模板做静态分析。这使得 Vue 的模板编译器能够应用许多编译时优化来提升虚拟 DOM 的性能表现 (下面我们将展开讨论)。
在实践中,模板对大多数的应用场景都是够用且高效的。渲染函数一般只会在需要处理高度动态渲染逻辑的可重用组件中使用。想了解渲染函数的更多使用细节可以去到渲染函数 & JSX 章节继续阅读。
虚拟 DOM 在 React 和大多数其他实现中都是纯运行时的:更新算法无法预知新的虚拟 DOM 树会是怎样,因此它总是需要遍历整棵树、比较每个 vnode 上 props 的区别来确保正确性。另外,即使一棵树的某个部分从未改变,还是会在每次重渲染时创建新的 vnode,带来了大量不必要的内存压力。这也是虚拟 DOM 最受诟病的地方之一:这种有点暴力的更新过程通过牺牲效率来换取声明式的写法和最终的正确性。
但实际上我们并不需要这样。在 Vue 中,框架同时控制着编译器和运行时。这使得我们可以为紧密耦合的模板渲染器应用许多编译时优化。编译器可以静态分析模板并在生成的代码中留下标记,使得运行时尽可能地走捷径。与此同时,我们仍旧保留了边界情况时用户想要使用底层渲染函数的能力。我们称这种混合解决方案为带编译时信息的虚拟 DOM。
下面,我们将讨论一些 Vue 编译器用来提高虚拟 DOM 运行时性能的主要优化:
在模板中常常有部分内容是不带任何动态绑定的:
<div>
<div>foo</div> <!-- 需提升 -->
<div>bar</div> <!-- 需提升 -->
<div>{{ dynamic }}</div>
</div>
foo 和 bar 这两个 div 是完全静态的,没有必要在重新渲染时再次创建和比对它们。Vue 编译器自动地会提升这部分 vnode 创建函数到这个模板的渲染函数之外,并在每次渲染时都使用这份相同的 vnode,渲染器知道新旧 vnode 在这部分是完全相同的,所以会完全跳过对它们的差异比对。
此外,当有足够多连续的静态元素时,它们还会再被压缩为一个“静态 vnode”,其中包含的是这些节点相应的纯 HTML 字符串。(示例)。这些静态节点会直接通过 innerHTML 来挂载。同时还会在初次挂载后缓存相应的 DOM 节点。如果这部分内容在应用中其他地方被重用,那么将会使用原生的 cloneNode() 方法来克隆新的 DOM 节点,这会非常高效。
对于单个有动态绑定的元素来说,我们可以在编译时推断出大量信息:
<!-- 仅含 class 绑定 -->
<div :class="{ active }"></div>
<!-- 仅含 id 和 value 绑定 -->
<input :id="id" :value="value">
<!-- 仅含文本子节点 -->
<div>{{ dynamic }}</div>
在为这些元素生成渲染函数时,Vue 在 vnode 创建调用中直接编码了每个元素所需的更新类型:
createElementVNode("div", {
class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */)
最后这个参数 2 就是一个更新类型标记 (patch flag)。一个元素可以有多个更新类型标记,会被合并成一个数字。运行时渲染器也将会使用位运算来检查这些标记,确定相应的更新操作:
if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) {
// 更新节点的 CSS class
}
位运算检查是非常快的。通过这样的更新类型标记,Vue 能够在更新带有动态绑定的元素时做最少的操作。
Vue 也为 vnode 的子节点标记了类型。举例来说,包含多个根节点的模板被表示为一个片段 (fragment),大多数情况下,我们可以确定其顺序是永远不变的,所以这部分信息就可以提供给运行时作为一个更新类型标记。
export function render() {
return (_openBlock(), _createElementBlock(_Fragment, null, [
/* children */
], 64 /* STABLE_FRAGMENT */))
}
运行时会完全跳过对这个根片段中子元素顺序的重新协调过程。
再来看看上面这个例子中生成的代码,你会发现所返回的虚拟 DOM 树是经一个特殊的 createElementBlock() 调用创建的:
export function render() {
return (_openBlock(), _createElementBlock(_Fragment, null, [
/* children */
], 64 /* STABLE_FRAGMENT */))
}
这里我们引入一个概念“区块”,内部结构是稳定的一个部分可被称之为一个区块。在这个用例中,整个模板只有一个区块,因为这里没有用到任何结构性指令 (比如 v-if 或者 v-for)。
每一个块都会追踪其所有带更新类型标记的后代节点 (不只是直接子节点),举例来说:
<div> <!-- root block -->
<div>...</div> <!-- 不会追踪 -->
<div :id="id"></div> <!-- 要追踪 -->
<div> <!-- 不会追踪 -->
<div>{{ bar }}</div> <!-- 要追踪 -->
</div>
</div>
编译的结果会被打平为一个数组,仅包含所有动态的后代节点:
div (block root)
- div 带有 :id 绑定
- div 带有 {{ bar }} 绑定
当这个组件需要重渲染时,只需要遍历这个打平的树而非整棵树。这也就是我们所说的树结构打平,这大大减少了我们在虚拟 DOM 协调时需要遍历的节点数量。模板中任何的静态部分都会被高效地略过。
v-if 和 v-for 指令会创建新的区块节点:
<div> <!-- 根区块 -->
<div>
<div v-if> <!-- if 区块 -->
...
<div>
</div>
</div>
一个子区块会在父区块的动态子节点数组中被追踪,这为他们的父区块保留了一个稳定的结构。
更新类型标记和树结构打平都大大提升了 Vue SSR 激活的性能表现:
静态站点生成SSG - Static Site Generation是一种在构建时生成静态HTML等文件资源的方法,其可以完全不需要服务端的运行,通过预先生成静态文件,实现快速的内容加载和高度的安全性。由于其生成的是纯静态资源,便可以利用CDN等方案以更低的成本和更高的效率来构建和发布网站,在博客、知识库、API文档等场景有着广泛应用。
在前段时间遇到了一个比较麻烦的问题,我们是主要做文档业务的团队,而由于对外的产品文档涉及到全球很多地域的用户,因此在CN以外地域的网站访问速度就成了比较大的问题。虽然我们有多区域部署的机房,但是每个地域机房的数据都是相互隔离的,而实际上很多产品并不会做很多特异化的定制,因此文档实际上是可以通用的,特别是提供了多语言文档支持的情况下,各地域共用一份文档也变得合理了起来。而即使对于CN和海外地区有着特异化的定制,但在海外本身的访问也会有比较大的局限,例如假设机房部署在US,那么在SG的访问速度同样也会成为一件棘手的事情。
那么问题来了,如果我们需要做到各地域访问的高效性,那么就必须要在各个地域的主要机房部署服务,而各个地域又存在数据隔离的要求,那么在这种情况下我们可能需要手动将文档复制到各个机房部署的服务上去,这必然就是一件很低效的事情,即使某个产品的文档不会经常更新,但是这种人工处理的方式依然是会耗费大量精力的,显然是不可取的。而且由于我们的业务是管理各个产品的文档,在加上在海外业务不断扩展的情况下,这类的反馈需求必然也会越来越多,那么解决这个问题就变成了比较重要的事情。
那么在这种情况下,我就忽然想到了我的博客站点的构建方式,为了方便我会将博客直接通过gh-pages分支部署在GitHub Pages上,而GitHub Pages本身是不支持服务端部署的,也就是说我的博客站全部都是静态资源。由此可以想到在业务中我们的文档站也可以用类似的方式来实现,也就是在发布文档的时候通过SSG编译的方式来生成静态资源,那么在全部的内容都是静态资源的情况下,我们就可以很轻松地基于CDN来实现跨地域访问的高效性。此外除了调度CDN的分发方式,我们还可以通过将静态资源发布到业务方申请的代码仓库中,然后业务方就可以自行部署服务与资源了,通过多机房部署同样可以解决跨地域访问的问题。
当然,因为要考虑到各种问题以及现有部署方式的兼容,在我们的业务中通过SSG来单独部署实现跨地域的高效访问并不太现实,最终大概率还是要走合规的各地域数据同步方案来保证数据的一致性与高效访问。但是在思考通过SSG来作为这个问题的解决方案时,我还是很好奇如何在React的基础上来实现SSG渲染的,毕竟我的博客就可以算是基于Mdx的SSG渲染。最开始我把这个问题想的特别复杂,但是在实现的时候发现只是实现基本原理的话还是很粗暴的解决方案,在渲染的时候并没有想象中要处理得那么精细,当然实际上要做完整的方案特别是要实现一个框架也不是那么容易的事情,对于数据的处理与渲染要做很多方面的考量。
在我们正式开始聊SSG的基本原理前,我们可以先来看一下通过SSG实现静态站点的特点:
那么同样的,通过SSG生成的静态资源站点也有一些局限性:
综上所述,SSG更适用于生成内容较为固定、不需要频繁更新、且对于数据延迟敏感较低的的项目,并且实际上我们可能也只是选取部分能力来优化首屏等场景,最终还是会落到CSR来实现服务能力。因此当我们要选择渲染方式的时候,还是要充分考虑到业务场景,由此来确定究竟是CSR - Client Side Render、SSR - Server Side Render、SSG - Static Site Generation更适合我们的业务场景,甚至在一些需要额外优化的场景下,ISR - Incremental Static Regeneration、DPR - Distributed Persistent Rendering、ESR - Edge Side Rendering等也可以考虑作为业务上的选择。
当然,回到最初我们提到的问题上,假如我们只是为了静态资源的同步,通过CDN来解决全球跨地域访问的问题,那么实际上并不是一定需要完全的SSG来解决问题。将CSR完全转变为SSR毕竟是一件改造范围比较大的事情,而我们的目标仅仅是一处生产、多处消费,因此我们可以转过来想一想实际上JSON文件也是属于静态资源的一种类型,我们可以直接在前端发起请求将JSON文件作为静态资源请求到浏览器并且借助SDK渲染即可,至于一些交互行为例如点赞等功能的速度问题我们也是可以接受的,文档站最的主要行为还是阅读文档。此外对于md文件我们同样可以如此处理,例如docsify就是通过动态请求,但是同样的对于搜索引擎来说这些需要执行Js来动态请求的内容并没有那么容易抓取,所以如果想比较好地实现这部分能力还是需要不断优化迭代。
那么接下来我们就从基本原理开始,优化组件编译的方式,进而基于模版渲染生成SSG,文中相关API的调用基于React的17.0.2版本实现,内容相关的DEMO地址为https://github.com/WindrunnerMax/webpack-simple-environment/tree/master/packages/react-render-ssg。
通常当我们使用React进行客户端渲染CSR时,只需要在入口的index.html文件中置入<div id="root"></div>的独立DOM节点,然后在引入的xxx.js文件中通过ReactDOM.render方法将React组件渲染到这个DOM节点上即可。将内容渲染完成之后,我们就会在某些生命周期或者Hooks中发起请求,用以动态请求数据并且渲染到页面上,此时便完成了组件的渲染流程。
那么在前边我们已经聊了比较多的SSG内容,那么可以明确对于渲染的主要内容而言我们需要将其离线化,因此在这里就需要先解决第一个问题,如何将数据离线化,而不是在浏览器渲染页面之后再动态获取。很明显在前边我们提到的将数据从数据库请求出来之后写入json文件就是个可选的方式,我们可以在代码构建的时候请求数据,在此时将其写入文件,在最后一并上传到CDN即可。
在我们的离线数据请求问题解决后,我们就需要来看渲染问题了,前边也提到了类似的问题,如果依旧按照之前的渲染思路,而仅仅是将数据请求的地址从服务端接口替换成了静态资源地址,那么我们就无法做到SEO以及更快的首屏体验。其实说到这里还有一个比较有趣的事情,当我们用SSR的时候,假如我们的组件是dynamic引用的,那么Next在输出HTML的时候会将数据打到HTML的<script />标签里,在这种情况下实际上首屏的效率还是不错的,并且Google进行索引的时候是能够正常将动态执行Js渲染后的数据抓取,对于我们来说也可以算作一种离线化的渲染方案。
那么这种方式虽然可行但是并不是很好的方案,我们依然需要继续解决问题,那么接下来我们需要正常地来渲染完整的HTML结构。在ReactDOM的Server API中存在存在两个相关的API,分别是renderToStaticMarkup与renderToString,这两个API都可以将React组件输出HTML标签的结构,只是区别是renderToStaticMarkup渲染的是不带data-reactid的纯HTML结构,当客户端进行React渲染时会完全重建DOM结构,因此可能会存在闪烁的情况,renderToString则渲染了带标记的HTML结构,React在客户端不会重新渲染DOM结构,那么在我们的场景下时需要通过renderToString来输出HTML结构的。
// packages/react-render-ssg/src/basic/index.ts
import ReactDOMServer from "react-dom/server";
const App = React.createElement(
React.Fragment,
null,
React.createElement("div", null, "React HTML Render"),
React.createElement(
"button",
{
onClick: () => alert("On Click"),
},
"Button"
)
);
const HTML = ReactDOMServer.renderToString(App);
// <div data-reactroot="">React HTML Render</div><button data-reactroot="">Button</button>
当前我们已经得到组件渲染过后的完整HTML结构,紧接着从输出的内容我们可以看出来一个问题,我们定义的onClick函数并没有在渲染过后的HTML结构中体现出来,此时在我们的HTML结构中只是一些完整的标签,并没有任何事件的处理。当然这也是很合理的情况,我们是用React框架实现的事件处理,其并不太可能直接完整地映射到输出的HTML中,特别是在复杂应用中我们还是需要通过React来做后续事件交互处理的,那么很显然我们依旧需要在客户端处理相关的事件。
那么在React中我们常用的处理客户端渲染函数就是ReactDOM.render,那么当前我们实际上已经处理好了HTML结构,而并不需要再次将内容完整地渲染出来,或者换句话说我们现在需要的是将事件挂在相关DOM上来处理交互行为,将React附加到在服务端环境中已经由React渲染的现有HTML上,由React来接管有关的DOM的处理。那么对于我们来说,我们需要将同样的React组件在客户端一并定义,然后将其输出到页面的Js中,也就是说这部分内容是需要在客户端中执行的。
// packages/react-render-ssg/src/basic/index.ts
const PRESET = `
const App = React.createElement(
React.Fragment,
null,
React.createElement("div", null, "React HTML Render"),
React.createElement(
"button",
{
onClick: () => alert("On Click"),
},
"Button"
)
);
const _default = App;
ReactDOM.hydrate(_default, document.getElementById("root"));
`;
await fs.writeFile(`dist/${jsPathName}`, PRESET);
实际上这部分代码都是在服务端生成的,我们此时并没有在客户端运行的内容,或者说这是我们的编译过程,还没有到达运行时,所以我们生成的一系列内容都是在服务端执行的,那么很明显我们是需要拼装HTML等静态资源文件的。因此在这里我们可以通过预先定义一个HTML文件的模版,然后将构建过程中产生的内容放到模版以及新生成的文件里,产生的所有内容都将随着构建一并上传到CDN上并分发。
<!-- packages/react-render-ssg/public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<!-- ... Meta -->
<title>Template</title>
<!-- INJECT STYLE -->
</head>
<body>
<div id="root">
<!-- INJECT HTML -->
</div>
<!-- ... React Library -->
<!-- INJECT SCRIPT -->
</body>
</html>
// packages/react-render-ssg/src/basic/index.ts
const template = await fs.readFile("./public/index.html", "utf-8");
await fs.mkdir("dist", { recursive: true });
const random = Math.random().toString(16).substring(7);
const jsPathName = `${random}.js`;
const html = template
.replace(/<!-- INJECT HTML -->/, HTML)
.replace(/<!-- INJECT SCRIPT -->/, `<script src="${jsPathName}"></script>`);
await fs.writeFile(`dist/${jsPathName}`, PRESET);
await fs.writeFile(`dist/index.html`, html);
至此我们完成了最基本的SSG构建流程,接下来就可以通过静态服务器访问资源了,在这部分DEMO可以直接通过ts-node构建以及anywhere预览静态资源地址。实际上当前很多开源的静态站点搭建框架例如VitePress、RsPress等等都是采用类似的原理,都是在服务端生成HTML、Js、CSS等等静态文件,然后在客户端由各自的框架重新接管DOM的行为,当然这些框架的集成度很高,对于相关库的复用程度也更高。而针对于更复杂的应用场景,还可以考虑Next、Gatsby等框架实现,这些框架在SSG的基础上还提供了更多的能力,对于更复杂的应用场景也有着更好的支持。
虽然在前边我们已经实现了最基本的SSG原理,但是很明显我们为了最简化地实现原理人工处理了很多方面的内容,例如在上述我们输出到Js文件的代码中是通过PRESET变量定义的纯字符串实现的代码,而且我们对于同一个组件定义了两遍,相当于在服务端和客户端分开定义了运行的代码,那么很明显这样的方式并不太合理,接下来我们就需要解决这个问题。
那么我们首先需要定义一个公共的App组件,在该组件的代码实现中与前边的基本原理中一致,这个组件会共享在服务端的HTML生成和客户端的React Hydrate,而且为了方便外部的模块导入组件,我们通常都是通过export default的方式默认导出整个组件。
// packages/react-render-ssg/src/rollup/app.tsx
import React from "react";
const App = () => (
<React.Fragment>
<div>React Render SSG</div>
<button onClick={() => alert("On Click")}>Button</button>
</React.Fragment>
);
export default App;
紧接着我们先来处理客户端的React Hydrate,在先前我们是通过人工维护的编辑的字符串来定义的,而实际上我们同样可以打包工具在Node端将组建编译出来,以此来输出Js代码文件。在这里我们选择使用Rollup来打包Hydrate内容,我们以app.tsx作为入口,将整个组件作为iife打包,然后将输出的内容写入APP_NAME,然后将实际的hydrate置入footer,就可以完成在客户端的React接管DOM执行了。
// packages/react-render-ssg/rollup.config.js
const APP_NAME = "ReactSSG";
const random = Math.random().toString(16).substring(7);
export default async () => {
return {
input: "./src/rollup/app.tsx",
output: {
name: APP_NAME,
file: `./dist/${random}.js`,
format: "iife",
globals: {
"react": "React",
"react-dom": "ReactDOM",
},
footer: `ReactDOM.hydrate(React.createElement(${APP_NAME}), document.getElementById("root"));`,
},
plugins: [
// ...
],
external: ["react", "react-dom"],
};
};
接下来我们来处理服务端的HTML文件生成与资源的引用,这里的逻辑与先前的基本原理中服务端生成逻辑差别并不大,只是多了通过终端调用Rollup打包的逻辑,同样也是将HTML输出,并且将Js文件引入到HTML中,这里需要特殊关注的是我们的Rollup打包时的输出文件路径是在这里由--file参数覆盖原本的rollup.config.js内置的配置。
// packages/react-render-ssg/src/rollup/index.ts
const exec = promisify(child.exec);
(async () => {
const HTML = ReactDOMServer.renderToString(React.createElement(App));
const template = await fs.readFile("./public/index.html", "utf-8");
const random = Math.random().toString(16).substring(7);
const path = "./dist/";
const { stdout } = await exec(`npx rollup -c --file=${path + random}.js`);
console.log("Client Compile Complete", stdout);
const jsFileName = `${random}.js`;
const html = template
.replace(/<!-- INJECT HTML -->/, HTML)
.replace(/<!-- INJECT SCRIPT -->/, `<script src="${jsFileName}"></script>`);
await fs.writeFile(`${path}index.html`, html);
})();
当前我们已经复用了组件的定义,并且通过Rollup打包了需要在客户端运行的Js文件,不需要再人工维护输出到客户端的内容。那么场景再复杂一些,假如此时我们的组件有着更加复杂的内容,例如引用了组件库来构建视图,以及引用了一些CSS样式预处理器来构建样式,那么我们的服务端输出HTML的程序就会变得更加复杂。
继续沿着前边的处理思路,我们在服务端的处理程序仅仅是需要将App组件的HTML内容渲染出来,那么假设此时我们的组件引用了@arco-design组件库,并且通常我们还需要引用其中的less文件或者css文件。
import "@arco-design/web-react/dist/css/arco.css";
import { Button } from "@arco-design/web-react";
// OR
import "@arco-design/web-react/es/Button/style/index";
import { Button } from "@arco-design/web-react/es/Button";
那么需要关注的是,当前我们运行组件的时候是在服务端环境中,那么在Node环境中显然我们是不认识.less文件以及.css文件的,实际上先不说这些样式文件,import语法本身在Node环境中也是不支持的,只不过我们通常是使用ts-node来执行整个运行程序,暂时这点不需要关注,那么对于样式文件我们在这里实际上是不需要的,所以我们就需要配置Node环境来处理这些样式文件的引用。
require.extensions[".css"] = () => undefined;
require.extensions[".less"] = () => undefined;
但是即使这样问题显然没有结束,熟悉arco-design的打包同学可能会清楚,当我们引入的样式文件是Button/style/index时,实际上是引入了一个js文件而不是.less文件,如果需要明确引入.less文件的话是需要明确Button/style/index.less文件指向的。那么此时如果我们是引入的.less文件,那么并不会出现什么问题,但是此时我们引用的是.js文件,而这个.js文件中内部的引用方式是import,因为此时我们是通过es而不是lib部分明确引用的,即使在tsconfig中配置了相关解析方式为commonjs也是没有用的。
{
"ts-node": {
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true
}
}
}
因此我们可以看到,如果仅仅用ts-node来解析或者说执行服务端的数据生成是不够的,会导致我们平时实现组件的时候有着诸多限制,例如我们不能随便引用es的实现而需要借助包本身的package.json声明的内容来引入内容,如果包不能处理commonjs的引用那么还会束手无策。那么在这种情况下我们还是需要引入打包工具来打包commonjs的代码,然后再通过Node来执行输出HTML。通过打包工具,我们能够做的事情就很多了,在这里我们将资源文件例如.less、.svg都通过null-loader加载,且相关的配置输出都以commonjs为基准,此时我们输出的文件为node-side-entry.js。
// packages/react-render-ssg/rspack.server.ts
const config: Configuration = {
context: __dirname,
entry: {
index: "./src/rspack/app.tsx",
},
externals: externals,
externalsType: "commonjs",
externalsPresets: {
node: true,
},
// ...
module: {
rules: [
{ test: /\.svg$/, use: "null-loader" },
{ test: /\.less$/, use: "null-loader" },
],
},
devtool: false,
output: {
iife: false,
libraryTarget: "commonjs",
publicPath: isDev ? "" : "./",
path: path.resolve(__dirname, ".temp"),
filename: "node-side-entry.js",
},
};
当前我们已经得到了可以在Node环境中运行的组件,那么紧接着,考虑到输出SSG时我们通常都需要预置静态数据,例如我们要渲染文档的话就需要首先在数据库中将相关数据表达查询出来,然后作为静态数据传入到组件中,然后在预输出的HTML中将内容直接渲染出来,那么此时我们的App组件的定义就需要多一个getStaticProps函数声明,并且我们还引用了一些样式文件。
// packages/react-render-ssg/src/rspack/app.tsx
import "./app.less";
import { Button } from "@arco-design/web-react";
import React from "react";
const App: React.FC<{ name: string }> = props => (
<React.Fragment>
<div>React Render SSG With {props.name}</div>
<Button style={{ marginTop: 10 }} type="primary" onClick={() => alert("On Click")}>
Button
</Button>
</React.Fragment>
);
export const getStaticProps = () => {
return Promise.resolve({
name: "Static Props",
});
};
export default App;
/* packages/react-render-ssg/src/rspack/app.less */
body {
padding: 20px;
}
同样的,我们也需要为客户端运行的Js文件打包,只不过在这里由于我们需要处理预置的静态数据,我们在打包的时候同样就需要预先生成模版代码,当我们在服务端执行打包功能的时候,就需要将从数据库查询或者从文件读取的数据放置于生成的模版文件中,然后以该文件为入口去再打包客户端执行的React Hydrate能力。在这里因为希望将模版文件看起来更加清晰,我们使用了JSON.parse来处理预置数据,实际上这里只需要将占位预留好,数据在编译的时候经过stringify直接写入到模版文件中即可。
// packages/react-render-ssg/src/rspack/entry.tsx
/* eslint-disable @typescript-eslint/no-var-requires */
const Index = require(`<index placeholder>`);
const props = JSON.parse(`<props placeholder>`);
ReactDOM.hydrate(React.createElement(Index.default, { ...props }), document.getElementById("root"));
在模版文件生成好之后,我们就需要以这个文件作为入口调度客户端资源文件的打包了,这里由于我们还引用了组件库,输出的内容自然不光是Js文件,还需要将CSS文件一并输出,并且我们还需要配置一些通过参数名可以控制的文件名生成、externals等等。这里需要注意的是,此处我们不需要使用html-plugin将HTML文件输出,这部分调度我们会在最后统一处理。
// packages/react-render-ssg/rspack.config.ts
const args = process.argv.slice(2);
const map = args.reduce((acc, arg) => {
const [key, value] = arg.split("=");
acc[key] = value || "";
return acc;
}, {} as Record<string, string>);
const outputFileName = map["--output-filename"];
const config: Configuration = {
context: __dirname,
entry: {
index: "./.temp/client-side-entry.tsx",
},
externals: {
"react": "React",
"react-dom": "ReactDOM",
},
// ...
builtins: {
// ...
pluginImport: [
{
libraryName: "@arco-design/web-react",
customName: "@arco-design/web-react/es/{{ member }}",
style: true,
},
{
libraryName: "@arco-design/web-react/icon",
customName: "@arco-design/web-react/icon/react-icon/{{ member }}",
style: false,
},
],
},
// ...
output: {
chunkLoading: "jsonp",
chunkFormat: "array-push",
publicPath: isDev ? "" : "./",
path: path.resolve(__dirname, "dist"),
filename: isDev
? "[name].bundle.js"
: outputFileName
? outputFileName + ".js"
: "[name].[contenthash].js",
// ...
},
};
那么此时我们就需要调度所有文件的打包过程了,首先我们需要创建需要的输出和临时文件夹,然后启动服务端commonjs打包的流程,输出node-side-entry.js文件,并且读取其中定义的App组件以及预设数据读取方法,紧接着我们需要创建客户端入口的模版文件,并且通过调度预设数据读取方法将数据写入到入口模版文件中,此时我们就可以通过打包的commonjs组件执行并且输出HTML了,并且客户端运行的React Hydrate代码也可以在这里一并打包出来,最后将各类资源文件的引入一并在HTML中替换并且写入到输出文件中就可以了。至此当我们打包完成输出文件后,就可以使用静态资源服务器启动SSG的页面预览了。
const appPath = path.resolve(__dirname, "./app.tsx");
const entryPath = path.resolve(__dirname, "./entry.tsx");
require.extensions[".less"] = () => undefined;
(async () => {
const distPath = path.resolve("./dist");
const tempPath = path.resolve("./.temp");
await fs.mkdir(distPath, { recursive: true });
await fs.mkdir(tempPath, { recursive: true });
const { stdout: serverStdout } = await exec(`npx rspack -c ./rspack.server.ts`);
console.log("Server Compile", serverStdout);
const nodeSideAppPath = path.resolve(tempPath, "node-side-entry.js");
const nodeSideApp = require(nodeSideAppPath);
const App = nodeSideApp.default;
const getStaticProps = nodeSideApp.getStaticProps;
let defaultProps = {};
if (getStaticProps) {
defaultProps = await getStaticProps();
}
const entry = await fs.readFile(entryPath, "utf-8");
const tempEntry = entry
.replace("<props placeholder>", JSON.stringify(defaultProps))
.replace("<index placeholder>", appPath);
await fs.writeFile(path.resolve(tempPath, "client-side-entry.tsx"), tempEntry);
const HTML = ReactDOMServer.renderToString(React.createElement(App, defaultProps));
const template = await fs.readFile("./public/index.html", "utf-8");
const random = Math.random().toString(16).substring(7);
const { stdout: clientStdout } = await exec(`npx rspack build -- --output-filename=${random}`);
console.log("Client Compile", clientStdout);
const jsFileName = `${random}.js`;
const html = template
.replace(/<!-- INJECT HTML -->/, HTML)
.replace(/<!-- INJECT STYLE -->/, `<link rel="stylesheet" href="${random}.css">`)
.replace(/<!-- INJECT SCRIPT -->/, `<script src="${jsFileName}"></script>`);
await fs.writeFile(path.resolve(distPath, "index.html"), html);
})();
作者:WindrunnerMax
链接:https://juejin.cn/post/7375426024705769482
来源:稀土掘金
近一位同学在学习 vue3 源码的时候,把 vue 3 的大部分核心逻辑都整理到了脑图之中:
整理的内容非常详细。应该会对所有还在学习 vue3 源码
在了解 Vue3 框架设计之前,我们需要做两件事情,而这两件事情也是今天的主要内容。
那么准备好了?
我们开始吧!
针对于目前的前端开发而言,主要存在两种 编程范式:
这两种 范式 一般是相对来去说的。
那么首先我们先来说什么叫做 命令式。
具体例子:
张三的妈妈让张三去买酱油。
那么张三怎么做的呢?
张三拿起钱
打开门
下了楼
到商店
拿钱买酱油
回到家
以上的流程详细的描述了,张三在买酱油的过程中,每一步都做了什么。那么这样一种:详细描述做事过程 的方式就可以被叫做 命令式。
那么如果把这样的方式放到具体的代码实现之中,又应该怎么做呢?
我们来看以下这样的一个事情:
在指定的 div 中展示 “hello world”
那么如果想要完成这样的事情,通过命令式的方式我们如何实现呢?
我们知道命令式的核心在于:关注过程。
所以,以上事情通过命令式实现则可得出以下逻辑与代码:
// 1. 获取到指定的 div
const divEle = document.querySelector('#app')
// 2. 为该 div 设置 innerHTML 为 hello world
divEle.innerHTML = 'hello world'
该代码虽然只有两步,但是它清楚的描述了:完成这件事情,所需要经历的过程
那么假如我们所做的事情,变得更加复杂了,则整个过程也会变得更加复杂。
比如:
为指定的 div 的子元素 div 的子元素 p 标签,展示变量 msg
那么通过命令式完成以上功能,则会得出如下逻辑与代码:
// 1. 获取到第一层的 div
const divEle = document.querySelector('#app')
// 2. 获取到它的子 div
const subDivEle = divEle.querySelector('div')
// 3. 获取第三层的 p
const subPEle = subDivEle.querySelector('p')
// 4. 定义变量 msg
const msg = 'hello world'
// 5. 为该 p 元素设置 innerHTML 为 hello world
subPEle.innerHTML = msg
那么通过以上例子,相信大家可以对命令式的概念有了一个基础的认识。
最后做一个总结,什么叫做命令式呢?
命令式是:关注过程 的一种编程范式,他描述了完成一个功能的 详细逻辑与步骤。
当了解完命令式之后,那么接下来我们就来看 声明式 编程。
针对于声明式而言,大家其实都是非常熟悉的了。
比如以下代码,就是一个典型的 声明式 :
<div>{{ msg }}</div>
对于这个代码,大家是不是感觉有些熟悉?
没错,这就是 Vue 中非常常见的双大括号语法。所以当我们在写 Vue 模板语法 的时候,其实一直写的就是 声明式 编程。
那么声明式编程具体指的是什么意思呢?
还是以刚才的例子为例:
张三的妈妈让张三去买酱油。
那么张三怎么做的呢?
张三拿起钱
打开门
下了楼
到商店
拿钱买酱油
回到家
在这个例子中,我们说:张三所做的事情就是命令式。那么张三妈妈所做的事情就是 声明式。
在这样一个事情中,张三妈妈只是发布了一个声明,她并不关心张三如何去买的酱油,只关心最后的结果。
所以说,所谓声明式指的是:不关注过程,只关注结果 的范式。
同样,如果我们通过代码来进行表示的话,以下例子:
为指定的 div 的子元素 div 的子元素 p 标签,展示变量 msg
将会得出如下代码:
<div id="app">
<div>
<p>{{ msg }}</p>
</div>
</div>
在这样的代码中,我们完全不关心 msg 是怎么被渲染到 p 标签中的,我们所关心的只是:在 p 标签中,渲染指定文本而已。
最后做一个总结,什么叫做声明式呢?
声明式是:关注结果 的一种编程范式,他 并不关心 完成一个功能的 详细逻辑与步骤。(注意:这并不意味着声明式不需要过程!声明式只是把过程进行了隐藏而已!)
那么在我们讲解完成 命令式 和 声明式 之后,很多同学肯定会对这两种编程范式进行一个对比。
是命令式好呢?还是声明式好呢?
那么想要弄清楚这个问题,那么我们首先就需要先搞清楚,评价一种编程范式好还是不好的标准是什么?
通常情况下,我们评价一个编程范式通常会从两个方面入手:
那么接下来我们就通过这两个方面,来分析一下命令式和声明式。
性能一直是我们在进行项目开发时特别关注的方向,那么我们通常如何来表述一个功能的性能好坏呢?
我们来看一个例子:
为指定 div 设置文本为 “hello world”
那么针对于这个需求而言,最简单的代码就是:
div.innerText = "hello world" // 耗时为:1
你应该找不到比这个更简单的代码实现了。
那么此时我们把这个操作的 耗时 比作 :1 。(PS:耗时越少,性能越强)
然后我们来看声明式,声明式的代码为:
<div>{{ msg }}</div> <!-- 耗时为:1 + n -->
<!-- 将 msg 修改为 hello world -->
那么:已知修改text最简单的方式是innerText ,所以说无论声明式的代码是如何实现的文本切换,那么它的耗时一定是 > 1 的,我们把它比作 1 + n(对比的性能消耗)。
所以,由以上举例可知:命令式的性能 > 声明式的性能
可维护性代表的维度非常多,但是通常情况下,所谓的可维护性指的是:对代码可以方便的 阅读、修改、删除、增加 。
那么想要达到这个目的,说白了就是:代码的逻辑要足够简单,让人一看就懂。
那么明确了这个概念,我们来看下命令式和声明式在同一段业务下的代码逻辑:
// 命令式
// 1. 获取到第一层的 div
const divEle = document.querySelector('#app')
// 2. 获取到它的子 div
const subDivEle = divEle.querySelector('div')
// 3. 获取第三层的 p
const subPEle = subDivEle.querySelector('p')
// 4. 定义变量 msg
const msg = 'hello world'
// 5. 为该 p 元素设置 innerHTML 为 hello world
subPEle.innerHTML = msg
// 声明式
<div id="app">
<div>
<p>{{ msg }}</p>
</div>
</div>
对于以上代码而言,声明式 的代码明显更加利于阅读,所以也更加利于维护。
所以,由以上举例可知:**命令式的可维护性 < 声明式的可维护性 **
由以上分析可知两点内容:
那么双方各有优劣,我们在日常开发中应该使用哪种范式呢?
想要搞明白这点,那么我们还需要搞明白更多的知识。
企业应用的设计原则,想要描述起来比较复杂,为什么呢?
因为对于 不同的企业类型(大厂、中小厂、人员外包、项目外包),不同的项目类型(前台、中台、后台)来说,对应的企业应用设计原则上可能会存在一些差异。
所以我们这里所做的描述,会抛弃一些细微的差异,仅抓住核心的重点来进行阐述。
无论什么类型的企业,也无论它们在开发什么类型的项目,那么最关注的点无非就是两个:
项目成本非常好理解,它决定了一个公司完成“这件事”所付出的代价,从而直接决定了这个项目是否是可以盈利的(大厂的烧钱项目例外)。
那么既然项目成本如此重要,大家可以思考一下,决定项目成本的又是什么?
没错!就是你的 开发周期。
开发周期越长,所付出的人员成本就会越高,从而导致项目成本变得越高。
通过我们前面的分析可知,声明式的开发范式在 可维护性 上,是 大于 命令式的。
而可维护性从一定程度上就决定了,它会使项目的:开发周期变短、升级变得更容易 从而大量节约开发成本。
所以这也是为什么 Vue 会变得越来越受欢迎的原因。
决定开发者开发体验的核心要素,主要是在开发时和阅读时的难度,这个被叫做:心智负担。
心智负担可以作为衡量开发难易度的一个标准,心智负担高则证明开发的难度较高,心智负担低则表示开发的难度较低,开发更加舒服。
那么根据我们之前所说,声明式的开发难度明显低于命令式的开发难度。
所以对于开发体验而言,声明式的开发体验更好,也就是 心智负担更低。
Vue 作者尤雨溪在一次演讲中说道:框架的设计过程其实是一个不断取舍的过程 。
这代表的是什么意思呢?
想要搞明白这个,那么再来明确一下之前说过的概念:
当我们明确好了这样的一个问题之后,那么我们接下来来思考一个问题:框架的开发与设计原则是什么呢?
我们知道对于 Vue 而言,当我们使用它的是通过 声明式 的方式进行使用,但是对于 Vue 内部而言,是通过 命令式 来进行的实现。
所以我们可以理解为:Vue 封装了命令式的逻辑,而对外暴露出了声明式的接口
那么既然如此,我们明知 命令式的性能 > 声明式的性能 。那么 Vue 为什么还要选择声明式的方案呢?
其实原因非常的简单,那就是因为:命令式的可维护性 < 声明式的可维护性 。
为指定的 div 的子元素 div 的子元素 p 标签,展示变量 msg
以这个例子为例。
对于开发者而言,不需要关注实现过程,只需要关注最终的结果即可。
而对于 Vue 而言,他所需要做的就是:封装命令式逻辑,同时 尽可能的减少性能的损耗!它需要在 性能 与 可维护性 之间,找到一个平衡。从而找到一个 可维护性更好,性能相对更优 的一个点。
所以对于 Vue 而言,它的设计原则就是:在保证可维护性的基础上,尽可能的减少性能的损耗。
那么回到我们的标题:为什么说框架的设计过程其实是一个不断取舍的过程?
答案也就呼之欲出了,因为:
我们需要在可维护性和性能之间,找到一个平衡点。在保证可维护性的基础上,尽可能的减少性能的损耗。
所以框架的设计过程其实是一个不断在 可维护性和性能 之间进行取舍的过程
在 Vue 3 的 源代码 中存在一个 runtime-core 的文件夹,该文件夹内存放的就是 运行时 的核心代码逻辑。
runtime-core 中对外暴露了一个函数,叫做 渲染函数render
我们可以通过 render 代替 template 来完成 DOM 的渲染:
有些同学可能看不懂当前代码是什么意思,没有关系,这不重要,后面我们会详细去讲。
<head>
<meta charset="UTF-8">
<title>Document</title>
<script src="https://unpkg.com/vue@3.2.36/dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
</body>
<script>
const { render, h } = Vue
// 生成 VNode
const vnode = h('div', {
class: 'test'
}, 'hello render')
// 承载的容器
const container = document.querySelector('#app')
// 渲染函数
render(vnode, container)
</script>
我们知道,在 Vue 的项目中,我们可以通过 tempalte 渲染 DOM 节点,如下:
<template>
<div class="test">hello render</div>
</template>
但是对于 render 的例子而言,我们并没有使用 tempalte,而是通过了一个名字叫做 render 的函数,返回了一个不知道是什么的东西,为什么也可以渲染出 DOM 呢?
带着这样的问题,我们来看:
我们知道在上面的代码中,存在一个核心函数:渲染函数 render,那么这个 render 在这里到底做了什么事情呢?
我们通过一段代码实例来去看下:
假设有一天你们领导跟你说:
我希望根据如下数据:
渲染出这样一个 div:
{
type: 'div',
props: {
class: test
},
children: 'hello render'
}
<div class="test">hello render</div>
那么针对这样的一个需求你会如何进行实现呢?大家可以在这里先思考一下,尝试进行一下实现,然后我们再继续往下看..........
那么接下来我们根据这个需求来实现以下代码:
<script>
const VNode = {
type: 'div',
props: {
class: 'test'
},
children: 'hello render'
}
// 创建 render 渲染函数
function render(vnode) {
// 根据 type 生成 element
const ele = document.createElement(vnode.type)
// 把 props 中的 class 赋值给 ele 的 className
ele.className = vnode.props.class
// 把 children 赋值给 ele 的 innerText
ele.innerText = vnode.children
// 把 ele 作为子节点插入 body 中
document.body.appendChild(ele)
}
render(VNode)
</script>
在这样的一个代码中,我们成功的通过一个 render 函数渲染出了对应的 DOM,和前面的 render 示例 类似,它们都是渲染了一个 vnode,你觉得这样的代码真是 妙极了!
但是你的领导用了一段时间你的 render 之后,却说:天天这样写也太麻烦了,每次都得写一个复杂的 vnode,能不能让我直接写 HTML 标签结构的方式 你来进行渲染呢?
你想了想之后,说:如果是这样的话,那就不是以上 运行时 的代码可以解决的了!
没错!我们刚刚所编写的这样的一个“框架”,就是 运行时 的代码框架。
那么最后,我们做一个总结:运行时可以利用render 把 vnode 渲染成真实 dom 节点。
在刚才,我们明确了,如果只靠 运行时,那么是没有办法通过 HTML 标签结构的方式 的方式来进行渲染解析的。
那么想要实现这一点,我们就需要借助另外一个东西,也就是 编译时。
Vue 中的编译时,更准确的说法应该是 编译器 的意思。它的代码主要存在于 compiler-core 模块下。
我们来看如下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<script src="https://unpkg.com/vue@3.2.36/dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
</body>
<script>
const { compile, createApp } = Vue
// 创建一个 html 结构
const html = `
<div class="test">hello compiler</div>
`
// 利用 compile 函数,生成 render 函数
const renderFn = compile(html)
// 创建实例
const app = createApp({
// 利用 render 函数进行渲染
render: renderFn
})
// 挂载
app.mount('#app')
</script>
</html>
对于编译器而言,它的主要作用就是:把 template 中的 html 编译成 render 函数。然后再利用 运行时 通过 render 挂载对应的 DOM。
那么最后,我们做一个总结:编译时可以把html 的节点,编译成 render函数
前面两小节我们已经分别了解了 运行时 和 编译时,同时我们也知道了:vue 是一个 运行时+编译时 的框架!
vue 通过 compiler 解析 html 模板,生成 render 函数,然后通过 runtime 解析 render,从而挂载真实 dom。
那么看到这里可能有些同学就会有疑惑了,既然 compiler 可以直接解析 html 模板,那么为什么还要生成 render 函数,然后再去进行渲染呢?为什么不直接利用 compiler 进行渲染呢?
即:为什么 vue 要设计成一个 运行时+编译时的框架呢?
那么想要理清楚这个问题,我们就需要知道 dom 渲染是如何进行的。
对于 dom 渲染而言,可以被分为两部分:
那么什么是初次渲染呢?
当初始 div 的 innerHTML 为空时,
<div id="app"></div>
我们在该 div 中渲染如下节点:
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
那么这样的一次渲染,就是 初始渲染。在这样的一次渲染中,我们会生成一个 ul 标签,同时生成三个 li 标签,并且把他们挂载到 div 中。
那么此时如果 ul 标签的内容发生了变化:
<ul>
<li>3</li>
<li>1</li>
<li>2</li>
</ul>
li - 3 上升到了第一位,那么此时大家可以想一下:我们期望浏览器如何来更新这次渲染呢?
浏览器更新这次渲染无非有两种方式:
那么大家觉得这两种方式哪一种方式更好呢?那么我们来分析一下:
那么根据以上分析,我们知道了:
那么这两种方式,哪一种更快呢?我们来实验一下:
const length = 10000
// 增加一万个dom节点,耗时 3.992919921875 ms
console.time('element')
for (let i = 0; i < length; i++) {
const newEle = document.createElement('div')
document.body.appendChild(newEle)
}
console.timeEnd('element')
// 增加一万个 js 对象,耗时 0.402099609375 ms
console.time('js')
const divList = []
for (let i = 0; i < length; i++) {
const newEle = {
type: 'div'
}
divList.push(newEle)
}
console.timeEnd('js')
从结果可以看出,dom 的操作要比 js 的操作耗时多得多,即:dom** 操作比 js 更加耗费性能**。
那么根据这样的一个结论,回到我们刚才所说的场景中:
首先对于第一种方式而言:它的好处在于不需要进行任何的比对,仅需要执行 6 次(删除 3 次,重新渲染 3 次)dom 处理即可。
对于第二种方式而言:在逻辑上相对比较复杂。他需要分成两步来做:
对比 旧节点 和 新节点 之间的差异
根据差异,删除一个 旧节点,增加一个 新节点
根据结论可知:方式一会比方式二更加消耗性能(即:性能更差)。
那么得出这样的结论之后,我们回过头去再来看最初的问题:为什么 vue 要设计成一个 运行时+编译时的框架呢?
答:
在 vue 的源码中,会大量的涉及到一个概念,那就 副作用。
所以我们需要先了解一下副作用代表的是什么意思。
副作用指的是:当我们 对数据进行 setter 或 getter 操作时,所产生的一系列后果。
那么具体是什么意思呢?我们分别来说一下:
setter 所表示的是 赋值 操作,比如说,当我们执行如下代码时 :
msg = '你好,世界'
这时 msg 就触发了一次 setter 的行为。
那么假如说,msg 是一个响应性数据,那么这样的一次数据改变,就会影响到对应的视图改变。
那么我们就可以说:msg 的 setter 行为,触发了一次副作用,导致视图跟随发生了变化。
getter 所表示的是 取值 操作,比如说,当我们执行如下代码时:
element.innerText = msg
此时对于变量 msg 而言,就触发了一次 getter 操作,那么这样的一次取值操作,同样会导致 element 的 innerText 发生改变。
所以我们可以说:msg 的 getter 行为触发了一次副作用,导致 element 的 innterText 发生了变化。
那么明确好了副作用的基本概念之后,那么大家想一想:副作用可能会有多个吗?
答案是:可以的。
举个简单的例子:
<template>
<div>
<p>姓名:{{ obj.name }}</p>
<p>年龄:{{ obj.age }}</p>
</div>
</template>
<script>
const obj = ref({
name: '张三',
age: 30
})
obj.value = {
name: '李四',
age: 18
}
</script>
在这样的一个代码中 obj.value 触发了一次 setter 行为,但是会导致两个 p 标签的内容发生改变,也就是产生了两次副作用。
根据本小节我们知道了:
根据前面的学习我们已经知道了:
那么了解了这些内容之后,下来 vue3 的一个基本框架设计:
对于 vue3 而言,核心大致可以分为三大模块:
我们以以下基本结构来描述一下三者之间的基本关系:
<template>
<div>{{ proxyTarget.name }}</div>
</template>
<script>
import { reactive } from 'vue'
export default {
setup() {
const target = {
name: '张三'
}
const proxyTarget = reactive(target)
return {
proxyTarget
}
}
}
</script>
在以上代码中:
以上就是 reactivity、runtime、compiler 三者之间的运行关系。
*请认真填写需求信息,我们会在24小时内与您取得联系。