diff --git a/readme.md b/readme.md index 5dc79d93..e3b2e8b6 100644 --- a/readme.md +++ b/readme.md @@ -6,7 +6,7 @@ 前端界的好文精读,每周更新! -最新精读:192.精读《DOM diff 最长上升子序列》 +最新精读:193.精读《React Server Component》 素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2) @@ -155,6 +155,7 @@ - 190.精读《DOM diff 原理详解》 - 191.精读《高性能表格》 - 192.精读《DOM diff 最长上升子序列》 +- 193.精读《React Server Component》 ### 设计模式 diff --git "a/\345\211\215\346\262\277\346\212\200\346\234\257/193.\347\262\276\350\257\273\343\200\212React Server Component\343\200\213.md" "b/\345\211\215\346\262\277\346\212\200\346\234\257/193.\347\262\276\350\257\273\343\200\212React Server Component\343\200\213.md" new file mode 100644 index 00000000..08602d33 --- /dev/null +++ "b/\345\211\215\346\262\277\346\212\200\346\234\257/193.\347\262\276\350\257\273\343\200\212React Server Component\343\200\213.md" @@ -0,0 +1,324 @@ +截止目前,React Server Component 还在开发与研究中,因此不适合投入生产环境使用。但其概念非常有趣,值得技术人学习。 + +目前除了国内各种博客、知乎解读外,最一手的学习资料有下面两处: + +1. [Dan 的 Server Component 介绍视频](https://reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html) +2. [Server Component RFC 草案](https://github.com/josephsavona/rfcs/blob/server-components/text/0000-server-components.md) + +我会结合这些一手资料,与一些业界大牛的解读,系统的讲清楚 React Server Component 的概念,以及我对它的一些理解。 + +首先我们来看,为什么需要提出 Server Component 这个概念: + +Server Component 概念的提出,是为了解决 "用户体验、可维护性、性能" 这个不可能三角,所谓不可能三角就是,最多同时满足两条,而无法三条都同时满足。 + +简单解释一下,用户体验体现在页面更快的响应、可维护性体现在代码应该高内聚低耦合、性能体现在请求速度。 + +- 保障 **用户体验、可维护性**,用一个请求拉取全部数据,所有组件一次性渲染。但当模块不断增多,无用模块信息不敢随意删除,请求会越来越大,越来越冗余,导致瓶颈卡在取数这块,也就是 **性能不好**。 +- 保障 **用户体验、性能**,考虑并行取数,之后流程不变,那么以后业务逻辑新增或减少一个模块,我们就要同时修改并行取数公共逻辑与对应业务模块,**可维护性不好**。 +- 保障 **可维护性、性能**,可以每个模块独立取数,但在父级渲染完才渲染子元素的情况下,父子取数就变成了串行,页面加载被阻塞,**用户体验不好**。 + +一言蔽之,在前后端解耦的模式下,唯一连接的桥梁就是取数请求。要把用户体验做好,取数就要提前并行发起,而前端模块是独立维护的,所以在前端做取数聚合这件事,必然会破坏前端可维护性,而这并行这件事放在后端的话,会因为后端不能解析前端模块,导致给出的聚合信息滞后,久而久之变得冗余。 + +要解决这个问题,就必须加深前端与后端的联系,所以像 GraphQL 这种前后端约定方案是可行的,但因为其部署成本高,收益又仅在前端,所以难以在后端推广。 + +Server Component 是另一种方案,通过启动一个 Node 服务辅助前端,但做的不是 API 对接,而是运行前端同构 js 代码,直接解析前端渲染模块,从中自动提取请求并在 Node 端直接与服务器通信,因为服务端间通信成本极低、前端代码又不需要做调整,请求数据也是动态按需聚合的,因此同时解决了 "用户体验、可维护性、性能" 这三个问题。 + +其核心改进点如下图所示: + + + +如上图所示,这是前后端正常交互模式,可以看到,`Root` 与 `Child` 串行发了两个请求,因为网络耗时与串行都是严重阻塞部分,因此用红线标记。 + +Server Component 可以理解为下图,不仅减少了一次网络损耗,请求也变成了并行,请求返回结果也从纯数据变成了一个同时描述 UI DSL 与数据的特殊结构: + + + +到此,恭喜你已经理解了 Server Component 核心概念,如果你只想泛泛了解一下,读到这里就可以结束了。如果你还想深入了解其实现细节,请继续阅读。 + +## 概述 + +概括的说,Server Component 就是让组件拥有在服务端渲染的能力,从而解决不可能三角问题。也正因为这个特性,使得 Server Component 拥有几种让人眼前一亮的特性,都是纯客户端组件所不具备的: + +- **运行在服务端的组件只会返回 DSL 信息,而不包含其他任何依赖**,因此 Server Component 的所有依赖 npm 包都不会被打包到客户端。 +- **可以访问服务端任何 API**,也就是让组件拥有了 Nodejs 能拥有的能力,你理论上可以在前端组件里干任何服务端才能干的事情。 +- **Server Component 与 Client Component 无缝集成**,可以通过 Server Component 无缝调用 Client Component。 +- **Server Component 会按需返回信息**,在当前逻辑下,走不到的分支逻辑的所有引用都不会被客户端引入。比如 Server Component 虽然引用了一个巨大的 npm 包,但某个分支下没有用到这个包提供的函数,那客户端也不会下载这个巨大的 npm 包到本地。 +- **由于返回的不是 HTML,而是一个 DSL,所以服务端组件即便重新拉取,已经产生的 State 也会被维持住**。比如说 A 是 ServerComponent,其子元素 B 是 Client Component,此时对 B 组件做了状态修改比如输入一些文字,此时触发 A 重新拉取 DSL 后,B 已经输入的文字还会保留。 +- **可以无缝与 Suspense 结合**,并不会因为网络原因导致连 Suspense 的 loading 都不能及时展示。 +- **共享组件可以同时在服务端与客户端运行**。 + +### 三种组件 + +Server Component 将组件分为三种:Server Component、Client Component、Shared Component,分别以 `.server.js`、`.client.js`、`.js` 后缀结尾。 + +其中 `.client.js` 与普通组件一样,但 `.server.js` 与 `.js` 都可能在服务端运行,其中: + +- `.server.js` 必然在服务端执行。 +- `.js` 在哪执行要看谁调用它,如果是 `.server.js` 调用则在服务端执行,如果是 `.client.js` 调用则在客户端执行,因此其本质还要接收服务端组件的约束。 + +下面是 RFC 中展示的 Server Component 例子: + +```typescript +// Note.server.js - Server Component +import db from 'db.server'; +// (A1) We import from NoteEditor.client.js - a Client Component. +import NoteEditor from 'NoteEditor.client'; + +function Note(props) { + const {id, isEditing} = props; + // (B) Can directly access server data sources during render, e.g. databases + const note = db.posts.get(id); + + return ( +
+

{note.title}

+
{note.body}
+ {/* (A2) Dynamically render the editor only if necessary */} + {isEditing + ? + : null + } +
+ ); +} +``` + +可以看到,**这就是 Node 与 React 混合语法**。服务端组件有着苛刻的限制条件:**不能有状态,且 `props` 必须能被序列化**。 + +很容易理解,因为服务端组件要被传输到客户端,就必须经过序列化、反序列化的过程,JSX 是可以被序列化的,props 也必须遵循这个规则。另外服务端不能帮客户端存储状态,因此服务端组件不能用任何 `useState` 等状态相关 API。 + +但这两个问题都可以绕过去,即将状态转化为组件的 `props` 入参,由 `.client.js` 存储,见下图: + + + +或者利用 Server Component 与 Client Component 无缝集成的能力,将状态与无法序列化的 `props` 参数都放在 Client Component,由 Server Component 调用。 + +### 优点 + +#### 零客户端体积 + +这句话听起来有点夸张,但其实在 Server Component 限定条件下还真的是。看下面代码: + +```typescript +// NoteWithMarkdown.js +import marked from 'marked'; // 35.9K (11.2K gzipped) +import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped) + +function NoteWithMarkdown({text}) { + const html = sanitizeHtml(marked(text)); + return (/* render */); +} +``` + +`marked` 与 `sanitize-html` 都不会被下载到本地,所以如果只有这一个文件传输,客户端的理论增加体积就是 `render` 函数序列化后字符串大小,可能不到 1KB。 + +当然这背后也是限制换来的,首先这个组件没有状态,无法在客户端实时执行,而且在服务端运行也可能消耗额外计算资源,如果某些 npm 包计算复杂度较高的话。 + +这个好处可以理解为,`marked` 这个包仅在服务端读取到内存一次,以后只要后客户端想用,只需要在服务端执行 `marked` API 并把输出结果返回给客户端,而不需要客户端下载 `marked` 这个包了。 + +#### 拥有完整服务端能力 + +由于 Server Component 在服务端执行,因此可以执行 Nodejs 的任何代码。 + +```typescript +// Note.server.js - Server Component +import fs from 'react-fs'; + +function Note({id}) { + const note = JSON.parse(fs.readFile(`${id}.json`)); + return ; +} +``` + +我们可以把对请求的理解拔高一个层次,即 `request` 只是客户端发起的一个 Http 请求,其本质是访问一个资源,在服务端就是个 IO 行为。对于 IO,我们还可以通过 `file` 文件系统写入删除资源、`db` 通过 sql 语法直接访问数据库,或者 `request` 直接在服务器本地发出请求。 + +#### 运行时 Code Split + +我们都知道 webpack 可以通过静态分析,将没有使用到的 import 移出打包,而 Server Component 可以在运行时动态分析,将当前分支逻辑下没有用到的 import 移出打包: + +```typescript +// PhotoRenderer.js +import React from 'react'; + +// one of these will start loading *once rendered and streamed to the client*: +import OldPhotoRenderer from './OldPhotoRenderer.client.js'; +import NewPhotoRenderer from './NewPhotoRenderer.client.js'; + +function Photo(props) { + // Switch on feature flags, logged in/out, type of content, etc: + if (props.useNewPhotoRenderer) { + return ; + } else { + return ; + } +} +``` + +这是因为 Server Component 构建时会进行预打包,运行时就是一个动态的包分发器,完全可以通过当前运行状态比如 `props.xxx` 来区分当前运行到哪些分支逻辑,而没有运行到哪些分支逻辑,并且仅告诉客户端拉取当前运行到的分支逻辑的缺失包。 + +纯前端模式与之类似的写法是: + +```typescript +const OldPhotoRenderer = React.lazy(() => import('./OldPhotoRenderer.js')); +const NewPhotoRenderer = React.lazy(() => import('./NewPhotoRenderer.js')); +``` + +只是这种写法不够原生,且实际场景往往只有前端框架把路由自动包一层 Lazy Load,而普通代码里很少出现这种写法。 + +#### 无客户端往返的数据端取数 + +一般考虑到取数网络消耗,我们往往会将其处理成异步,然后在数据返回前展示 Loading: + +```typescript +// Note.js + +function Note(props) { + const [note, setNote] = useState(null); + useEffect(() => { + // NOTE: loads *after* rendering, triggering waterfalls in children + fetchNote(props.id).then(noteData => { + setNote(noteData); + }); + }, [props.id]); + if (note == null) { + return "Loading"; + } else { + return (/* render note here... */); + } +} +``` + +这是因为单页模式下,我们可以快速从 CDN 拿到这个 DOM 结构,但如果再等待取数,整体渲染就变慢了。而 Server Component 因为本身就在服务端执行,因此可以将拿 DOM 结构与取数同时进行: + +```typescript +// Note.server.js - Server Component + +function Note(props) { + // NOTE: loads *during* render, w low-latency data access on the server + const note = db.notes.get(props.id); + if (note == null) { + // handle missing note + } + return (/* render note here... */); +} +``` + +当然这个前提是网络消耗敏感的情况,如果本身就是一个慢 SQL 查询,耗时几秒的情况下,这样做反而适得其反。 + +#### 减少 Component 层次 + +看下面的例子: + +```js +// Note.server.js +// ...imports... + +function Note({id}) { + const note = db.notes.get(id); + return ; +} + +// NoteWithMarkdown.server.js +// ...imports... + +function NoteWithMarkdown({note}) { + const html = sanitizeHtml(marked(note.text)); + return
; +} + +// client sees: +
+ +
+``` + +虽然在组件层面抽象了 `Note` 与 `NoteWithMarkdown` 两个组件,但由于真正 DOM 内容实体只有一个简单的 `div`,所以在 Server Component 模式下,返回内容就会简化为这个 `div`,而无需包含那两个抽象的组件。 + +### 限制 + +Server Component 模式下有三种组件,分别是 Server Component、Client Component、Shared Component,其各自都有一些使用限制,如下: + +**Server Component**: + +- ❌ 不能用 `useState`、`useReducer` 等状态存储 API。 +- ❌ 不能用 `useEffect` 等生命周期 API。 +- ❌ 不能用 `window` 等仅浏览器支持的 API。 +- ❌ 不能用包含了上面情况的自定义 Hooks。 +- ✅ 可无缝访问服务端数据、API。 +- ✅ 可渲染其他 Server/Client Component + +**Client Component**: + +- ❌ 不能引用 Server Component。 + - ✅ 但可以在 Server Component 中出现 Client Component 调用 Server Component 的情况,比如 ``。 +- ❌ 不能调用服务端 API 获取数据。 +- ✅ 可以用一切 React 与浏览器完整能力。 + +**Shared Component**: + +- ❌ 不能用 `useState`、`useReducer` 等状态存储 API。 +- ❌ 不能用 `useEffect` 等生命周期 API。 +- ❌ 不能用 `window` 等仅浏览器支持的 API。 +- ❌ 不能用包含了上面情况的自定义 Hooks。 +- ❌ 不能引用 Server Component。 +- ❌ 不能调用服务端 API 获取数据。 +- ✅ 可以同时在服务器与客户端使用。 + +其实不难理解,因为 Shared Component 同时在服务器与客户端使用,因此兼具它们的劣势,带来的好处就是更强的复用性。 + +## 精读 + +要快速理解 Server Component,我觉得最好也是最快的方式,就是找到其与十年前 PHP + HTML 的区别。看下面代码: + +```php +$link = mysqli_connect('localhost', 'root', 'root'); +mysql_select_db('test', $link); +$result = mysql_query('select * from table'); + +while($row=mysql_fetch_assoc($result)){ + echo "".$row["id"].""; +} +``` + +其实 PHP 早就是一套 "Server Component" 方案了,在服务端直接访问 DB、并返回给客户端 DOM 片段。 + +React Server Component 在折腾了这么久后,可以发现,最大的区别是将返回的 HTML 片段改为了 DSL 结构,这其实是浏览器端有一个强大的 React 框架在背后撑腰的结果。而这个带来的好处除了可以让我们在服务端能继续写 React 语法,而不用退化到 "PHP 语法" 以外,更重要的是组件状态得以维持。 + +另一个重要不同是,PHP 无法解析现在前端生态下任何 npm 包,所以无从解析模块化的前端代码,所以虽然直觉上感觉 PHP 效率与 Server Component 并无区别,但背后的成本是得写另一套不依赖任何 npm 包、JSX 的语法来返回 HTML 片段,Server Component 大部分特性都无法享受到,而且代码也无法复用。 + +所以,本质上还是 HTML 太简单了,无法适应如今前端的复杂度,而普通后端框架虽然后端能力强大,但在前端能力上还停留在 20 年前(直接返回 DOM),唯有 Node 中间层方案作为桥梁,才能较好的衔接现代后端代码与现代前端代码。 + +### PHP 前后端模块化 + +其实在 PHP 时代,前后端都可以做模块化。后端模块化显而易见,因为可以将后端代码模块化的开发,最后打包至服务器运行。前端也可以在服务端模块化开发,只要我们将前后端代码剥离出来即可,下图青色是后端部分,红色是前端部分: + + + +但这有个问题,因为后端服务对浏览器来说是无状态的,所以后端模块化本身就符合其功能特征,但前端页面显示在用户浏览器,每次都通过路由跳转到新页面,显然不能最大程度发挥客户端持续运行的优势,我们希望在保持前端模块化的基础上,在浏览器端有一个持续运行的框架优化用户体验,因此 Server Component 其实做了下面的事情: + + + +这样做有两大好处: + +1. 兼顾了 PHP 模式下优势,即前后端代码无缝混合,带来一系列体验和能力增强。 +2. 前后端还是各自模块化编写,图中红色部分是随前端项目整体打包的,因此开发还是保留了模块化特点,且在浏览器上还保持了 React 现代框架运行,无论是单页还是数据驱动等特性都能继续使用。 + +## 总结 + +Server Component 还没有成熟,但其理念还是很靠谱的。 + +想要同时实现 "用户体验、可维护性、性能",重后端,或者重前端的方案都不可行,只有在前后端取得一种平衡才能达到。Server Component 表达了一种职业发展理念,即未来前后端还是会走向全栈,这种全栈是前后端同时做深,从而让程序开发达到纯前端或纯后端无法达到的高度。 + +2021 年国内开发环境依然比较落后,所谓全栈,往往指的是 “前后端都懂一点”,各端都做不深,难以孵化出 Server Component 这种概念。当然,这也是我们继续向世界学习的动力。 + +也许 PHP 与 Server Component 的区别,就是检验一个人是真全栈还是伪全栈的试金石,快去问问你的同事吧! + +> 讨论地址是:[精读《React Server Component》· Issue #311 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/311) + +**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** + +> 关注 **前端精读微信公众号** + + + +> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh))