Waku 调研

临时文件。

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,
  };
};