大家好,我卡颂。
最近Next.js v14
发布,发布会的各种梗图刷爆了国外前端社区。
Next.js
的诸多特性(比如Server Action
、App Router
),都是在RSC
(React Server Component
)基础上衍生出的。
从名字可以看出,RSC
是React
的特性。那么,该怎么理解RSC
和Next.js
的关系呢?
欢迎围观朋友圈、加入人类高质量前端交流群,带飞
React团队的宿愿
对于前端框架的开发范式,有三个重要衡量因素:
-
用户体验
-
维护成本
-
性能
但是,通常很难做到三者兼顾(具体原因本文不细究,感兴趣的同学可以看data-fetching-with-react-server-components。
简单来说,在前端开发中,IO瓶颈 是影响内容渲染速度的重要因素(可以简单理解为,前端需要等待请求返回数据后,再根据数据渲染内容,这期间延迟的时间就是IO瓶颈)。
但是,前端框架能够掌控的范围局限在前端,所以无法对IO瓶颈做出极致优化,只能在三个因素中做出取舍(比如考虑用户体验与性能时,代码维护成本就高)。
React
团队为了同时兼顾三者,需要对服务端拥有更多掌控。这就是RSC
诞生的初衷。
但是,大部分React
的受众只是把React
当作前端view
库,并不会直接使用RSC
相关功能,所以React
团队选择和Next.js
团队合作,落地RSC
。
此时我们发现,React
有三类受众:
-
普通前端开发者,用稳定的
React
做业务开发 -
其他合作团队(比如
Next.js
团队),React
团队为他们提供API
支持 -
喜欢尝鲜的开发者/团队,愿意尝试那些可能出现在未来版本中的特性(通常还不稳定)
React
团队针对这三类受众,制定了三条版本迭代路径:
-
Latest
路径 -
Canary
路径 -
Experimental
路径
我们正常通过npm i react
下载的React
包就是Latest路径的打包产物。
通过npm update react@canary
可以替换为canary
包,RSC
相关的功能就属于canary
包。
同理,通过npm update react@xperimental
可以替换experimental
包。
脱离Next.js使用RSC
在Next.js
的App Router
模式,所有组件默认为服务端组件(即在服务端render
的组件),只有当组件所在文件顶部标记了'use client'
指令时,该组件是客户端组件(即在前端render
的组件)。
比如下面就是个客户端组件:
js
'use client'
import {useState} from 'react';
function Cpn() {
const [num, update] = useState(0);
// ...省略
}
实际上,这并不是Next.js
自己的定义,而是RSC
中的规范。在React
文档中,我们可以看到'use client'
与'use server'
规范的定义,其中:
-
'use client'
用于标记客户端组件(在服务端,默认所有组件都是服务端组件,所以客户端组件需要专门标记) -
'use server'
用于标记前端的某个函数为Server Action
(可以在前端执行的服务端逻辑)
既然是规范,那就需要落地。在Next.js
中,规范的落地都被收敛到Next.js
框架内部实现了。如果要脱离Next.js
使用RSC
,就需要我们自己落地规范。
RSC
规范的落地包括三部分:
-
服务端编译时
-
服务端运行时
-
客户端运行时
这三者都被收敛到react-server-dom-webpack包中。
接下来我们简单讲下这三部分的作用。
服务端编译时
通过react-server-dom-webpack/plugin
名字中的webpack
、plugin
字样能看出,这是个webpack
插件,配置类似如下:
js
const ReactServerWebpackPlugin = require("react-server-dom-webpack/plugin");
const config = {
// ...省略其他配置
plugins: [
new ReactServerWebpackPlugin({ isServer: false }),
],
}
他的作用是识别项目中的'use client'
指令,作用有些类似于全自动React.lazy。
使用过React.lazy
特性的同学会知道,当我们通过React.lazy
懒加载组件时,dynamic import
的组件会被打包工具(比如webpack
)打包成独立的chunk
。当前端需要该组件时,会通过Jsonp
请求chunk
文件。
比如下面代码中的./Cpn.jsx
组件由于懒加载,会被打包成独立的chunk
:
js
import React from 'react';
const LayCpn = React.lazy(() => import('./Cpn.jsx'));
function App(props) {
return <LayCpn {...props} />;
}
与React.lazy
类似,当我们在组件所在文件的顶部标记'use client'
时,并在服务端组件的子孙组件中使用到该组件,该组件代码也会打包成独立的chunk
。由于这个过程是全自动的,所以可以称为全自动React.lazy。
服务端运行时
上面讲到的编译产物都是客户端组件对应chunk,所以他们是不会在服务端运行时使用的。
服务端运行时的作用类似SSR
,都是给定JSX
输入,经过render
后获得输出。比如,给定如下输入:
js
function App() {
return <div>hello</div>;
}
对于SSR
,会获得字符串'<div>hello</div>'
的输出。
对于RSC
规范,将输入传给react-server-dom-webpack/server
导出的renderToPipeableStream
方法,会获得如下序列化数据:
js
0:"$L1"
1:["$","div",null,{"children":"hello"}]
再让我们看一个稍微复杂点的例子:
我们有个组件Cpn
,由于他包含客户端状态(使用了useState
),所以只能作为客户端组件(顶部标记'use client'
):
js
'use client'
import {useState} from 'react';
function Cpn() {
const [num, update] = useState(0);
// ...省略
}
现在,我们的服务端组件App
返回值中包含了Cpn
:
js
function App() {
return <div><Cpn/></div>;
}
经由renderToPipeableStream
方法,会获得如下序列化数据:
js
0:"$L1"
2:I["./src/app/Test.jsx",["client0","client0.chunk.js"],"Test"]
1:["$","div",null,{"children":["$","$L2",null,{}]}]
可以发现,序列化数据中并不包含具体的客户端组件代码,而是组件代码对应的文件(client0.chunk.js
),这个文件就是我们在服务端编译时 打包产生的chunk
文件。
客户端运行时
当服务端运行时 产生的序列化数据 传递给前端时,react-server-dom-webpack
又出场了,这次使用的是react-server-dom-webpack/client
。
这个包提供了几个方法,用于将从不同数据源获取的序列化数据 转换为合法的React Element,比如:
-
createFromFetch
:通过fetch
方法获取序列化数据
-
createFromReadableStream
:通过可读流获取序列化数据
对于上述序列化数据:
js
0:"$L1"
2:I["./src/app/Test.jsx",["client0","client0.chunk.js"],"Test"]
1:["$","div",null,{"children":["$","$L2",null,{}]}]
经由react-server-dom-webpack/client
中方法的转换,会得到一个React.lazy
组件,这样前端的React
就能正常render
这个组件了。
总结
RSC
规范属于React
特性,来自于React Canary
。规范的落地可以通过react-server-dom-webpack
包实现。
整个工作流程包括三个阶段:
-
服务端编译时,对应
react-server-dom-webpack/plugin
-
服务端运行时,对应
react-server-dom-webpack/server
-
客户端运行时,对应
react-server-dom-webpack/client
在Next.js
中,RSC
规范的落地被集成到框架内部,做到了开箱即用的RSC
,并在此基础上衍生出更完善的功能(App Router
)。