临时文件。
waku 如何打包 rsc?
依赖部分。react、react-dom 和 react-server-dom-webpack 用的是 19.0.0-canary-e3ebcd54b-20240405 。
只看 build 阶段,dev 应该是走 vite 的 bundless,对 mako 借鉴意义不大。
第一步,analyzeEntries。分析依赖,拿到 clientEntryFiles、serverEntryFiles 和 serverModuleFiles。
方法是,先 pages、templates、routes、components 下的所有文件都是 serverModuleFiles,然后以 serverModuleFiles + src/entries.js 作为入口,启 Vite 构建加两个插件(rscAnalyzePlugin 和 rscManagedPlugin)进行分析。
rscAnalyzePlugin 里通过 transform hook 进行分析。注:use xxx 的声明不一定在最上方,比如这个例子。所以要遍历整个 ast.body,直到找到为止。rscAnalyzePlugin 里还会对 client file 的 code 做 hash,建立 fileHashMap,目的是要给后续 buildServerBundle 用。
/// <reference types="react/canary" />
'use client';
...
rscManagedPlugin 用于用户没有 src/main.js 和 src/entries.js 时构造虚拟的 mainFile 和 managedEntries。前者是渲染入口,后者是路由路口(?)。
构建完成后,会把拿到的 clientEntryFiles 和 serverEntryFiles 从 Set 转成 Object。比如 assets/rsc1-xxxxx: /path/to/client-file
。server 叫 rsf,这里的 f 应该是 function。
const clientEntryFiles = Object.fromEntries(
Array.from(clientFileSet).map((fname, i) => [
`${config.assetsDir}/rsc${i}-${fileHashMap.get(fname)}`,
fname,
]),
);
const serverEntryFiles = Object.fromEntries(
Array.from(serverFileSet).map((fname, i) => [
`${config.assetsDir}/rsf${i}`,
fname,
]),
);
脚手架项目在这一步拿到的结果如下。
clientEntryFiles {
'assets/rsc0-undefined': '/Users/chencheng/Documents/Code/github.com/dai-shi/waku/packages/waku/dist/client.js',
'assets/rsc1-98568e70c': '/private/tmp/sorrycc-RxbAph/waku-project/src/components/counter.tsx',
'assets/rsc2-4c28aedd1': '/private/tmp/sorrycc-RxbAph/waku-project/node_modules/.pnpm/waku@0.20.0_react-dom@18.3.0-canary-670811593-20240322_react-server-dom-webpack@18.3.0-canary_3kk2h6dtjy7uxdmdy7t4y73ynu/node_modules/waku/dist/router/client.js',
'assets/rsc3-323f488e7': '/private/tmp/sorrycc-RxbAph/waku-project/node_modules/.pnpm/waku@0.20.0_react-dom@18.3.0-canary-670811593-20240322_react-server-dom-webpack@18.3.0-canary_3kk2h6dtjy7uxdmdy7t4y73ynu/node_modules/waku/dist/client.js'
}
serverEntryFiles {}
serverModuleFiles {
'pages/_layout': '/private/tmp/sorrycc-RxbAph/waku-project/src/pages/_layout.tsx',
'pages/about': '/private/tmp/sorrycc-RxbAph/waku-project/src/pages/about.tsx',
'pages/index': '/private/tmp/sorrycc-RxbAph/waku-project/src/pages/index.tsx',
'components/counter': '/private/tmp/sorrycc-RxbAph/waku-project/src/components/counter.tsx',
'components/footer': '/private/tmp/sorrycc-RxbAph/waku-project/src/components/footer.tsx',
'components/header': '/private/tmp/sorrycc-RxbAph/waku-project/src/components/header.tsx'
}
第二步,buildServerBundle。启 Vite,入口文件是 entries、serverModuleFiles、clientEntryFiles、serverEntryFiles 和 SERVER_MODULE_MAP,多 entry。输出到 dist 目录,target 是 node18,配了 ssr 和 ssrEmitAssets。搭配 7 个 Vite 插件。
SERVER_MODULE_MAP 内容如下。
export const SERVER_MODULE_MAP = {
'rsdw-server': 'react-server-dom-webpack/server.edge',
'waku-server': 'waku/server',
} as const;
nonjsResolvePlugin。作用是对后缀为 .js 的文件,以此尝试解析 ['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs']
的后缀。
rscTransformPlugin。这里使用 react-server-dom-webpack/node-loader 提供的 resolve 和 load 方法来处理模块的 resolve 和 load 。同时 opts.isBuild 开启时,会修改部分源码,对前者产出的代码做 HACK。(HACK 代码警告!)
rscEnvPlugin。修改配置,添加一些 WASU_ 前缀的环境变量到 define 配置,让源码里可以使用他们。
rscPrivatePlugin。约定 private 目录下的文件不可以被引用。这个目录应该只在 server 上可以被使用,后面应该会有 copy 之类的功能吧。
rscEntriesPlugin。为 react-server-dom-webpack-server.edge.production.min.js 加 prepend 代码,为 entries 加 append 代码。
prepend 代码如下。
try {
globalThis.AsyncLocalStorage = (await import('node:async_hooks')).AsyncLocalStorage;
} catch (e) {}
append 代码如下,基于 id 返回模块内容。
export function loadModule(id) {
switch (id) {
${Object.entries(opts.moduleMap)
.map(([k, v]) => `case '${k}': return import('' + '${v}');`)
.join('\n')}
default: throw new Error('Cannot find module: ' + id);
}
}
脚手架项目的 moduleMap 如下。
{
'rsdw-server': './rsdw-server.js',
'waku-server': './waku-server.js',
'client/react': './ssr/react.js',
'client/rd-server': './ssr/rd-server.js',
'client/rsdw-client': './ssr/rsdw-client.js',
'client/waku-client': './ssr/waku-client.js',
'ssr/assets/rsc0-undefined.js': './ssr/assets/rsc0-undefined.js',
'ssr/assets/rsc1-98568e70c.js': './ssr/assets/rsc1-98568e70c.js',
'ssr/assets/rsc2-4c28aedd1.js': './ssr/assets/rsc2-4c28aedd1.js',
'ssr/assets/rsc3-323f488e7.js': './ssr/assets/rsc3-323f488e7.js'
}
rscServePlugin。有配部署 serve 时开启。增加一些配置,define 配置加环境变量,以及 externals 配置加 hono 等。
第三步,buildSsrBundle。For SSR (在 server 渲染 client 组件用于生成 HTML)。产物输出到 assets 目录。入口文件包含 mainJs、clientEntryFiles 和 CLIENT_MODULE_MAP(如下)。其他配置,ssr: true,target:node18。以及一些插件。
export const CLIENT_MODULE_MAP = {
react: 'react',
'rd-server': 'react-dom/server.edge',
'rsdw-client': 'react-server-dom-webpack/client.edge',
'waku-client': 'waku/client',
} as const;
rscIndexPlugin。构建一个 html,入口文件时 mainJs,包括 middleware 和 load(用于 build)。
第四步,buildClientBundle。和 buildSsrBundle 比较像,说下差异点。入口文件没有 CLIENT_MODULE_MAP,有配 preserveEntrySignatures: ‘exports-only’,没有配 target 和 ssr。产物输出到 public/assets 目录。
第五步,收尾。
1、给 entries 加上 export const buildConfig = … 信息,这个很大,但是跑在 server 应该问题不大,但是可能并不需要那么多信息吧。。(注:这里会所 Rsc 渲染,TODO:为啥?)
2、emitRscFiles。针对 isStatic 的 Page 生成 RSC 文件,存放于 public/RSC 目录,用 txt 后缀。静态生成的 Html 里会前置一段脚本存相关信息。
globalThis.__WAKU_PREFETCHED__ = {
'/RSC/caterpie.txt': Promise.resolve(new Response(new ReadableStream({start(c) {const d = (self.__FLIGHT_DATA ||= []);const t = new TextEncoder();const f = (s) => c.enqueue(typeof s === 'string' ? t.encode(s) : s);d.forEach(f);d.push = f;if (document.readyState === 'loading') {document.addEventListener('DOMContentLoaded', () => c.close());} else {c.close();}}}))),
};
renderRsc 怎么实现?
const { renderToReadableStream, decodeReply } = require('react-server-dom-webpack/server.edge');
const { runWithRenderStore } = require('waku/server');
runWithRenderStore(renderStore, async () => {
const elements = await renderEntries(input, {});
return renderToReadableStream(elements, bundlerConfig);
});
elements 的格式示例。
{
layout: {
'$$typeof': Symbol(react.element),
...
},
'togepi/page': {
'$$typeof': Symbol(react.element),
...
},
'/SHOULD_SKIP': [
['layout', []],
...
],
'/LOCATION': ['xxx', '']
}
Bundle Config 示例。
{ id, chunks: [id], name, async: true }
3、emitHtmlFiles。SSG,为所有路由生成 Html 文件,包含动态路由。动态路由支持通过 export getConfig 指定 staticPaths 。
export const getConfig = async () => {
const pokemonPaths = await getPokemonPaths();
return {
render: 'static',
staticPaths: pokemonPaths,
};
};