服务端渲染(Server Side Rendering, SSR),就是指从服务端渲染出前端页面的内容。在以往早期的时候,前后端还没有分离,网页内容完全是由服务端渲染的,例如用 PHP 做后端生成好网页内容之后返回给前端负责显示,我了解到还有 JSP (Java Server Pages), ASP.NET (Active Server Pages Network Enabled Technologies) 等。但是随着前端越来越复杂,前后端开始分离,逐渐出现了各种前端框架,如 React (2013), Vue (2014), Angular (2016), Svelte (2016) 等等。前后端开始分工明确,客户端渲染(Client Side Rendering, CSR)大行其道,这种模式下面所有页面内容都由客户端动态渲染而来。
纯的客户端渲染主要有两个问题:
拿到前端页面代码之后,一般页面往往需要从后端获取数据才能展示页面,导致白屏时间长。
SEO 不友好,搜索引擎拿到页面之后往往不会继续请求数据,这样抓取到的页面可能会是只带 JS 文件的空白 HTML。
这样 SSR 又逐渐流行起来,以弥补 CSR 的缺点。SSR 主要的方法是前端框架实现一套服务端渲染的 API,支持对组件进行脱水(hydrate)和水合(de-hydrate)。服务端(Node.js)负责将前端组件进行脱水得到 HTML 内容,这样浏览器请求之后的页面是有内容的,不用再重复请求数据;浏览器拿到数据和 HTML 之后进行水合,继续以前端组件的形式完成渲染,支持前端组件的生命周期和对应的 API。这样 CSR 和 SSR 的优点就结合了起来,使得开发体验更好的同时首屏得以保证,当然需要维护额外的 Node.js 服务器,以及构建工具需要对服务端渲染做一些支持,这是额外的成本投入,这也是我这次要讲的内容~
另外 clientPlugin 在后处理 HTML 文件的时候,会加入一些元信息(manifest),后续可供使用:
compiler.hooks.processAssets.tapPromise( { name: CLIENT_PLUGIN_NAME, stage: compiler.STAGE.PROCESS_ASSETS_MODIFY_GENERATED_HTML, }, async (bundles, manifest) => { // ... for (const [chunkName, chunk] of compiler.outputChunk.entries()) { // ... // Append root.js to all html outputs if (chunkName.endsWith('.html')) { // add manifest to all pages chunk.contents = chunk.contents.toString().replace( '</body>', ` <script id="${__SPEEDY_DATA_ID__}" type="application/json"> ${serializeState<SpeedyData>({ __CONTEXT__: { basename: ssrOptions.baseUrl, matchedPage, pages: ssrOptions.pages, isModule, assetsMeta, routeData: {}, routeModules: {}, getServerDataMeta: {}, }, __SSR__: false, })} </script> </body> `.trim() } }
再来看 serverPlugin 的核心代码:
// packages/universal/src/bundler/react/plugins/virtual-entry/server.tsx import React from 'react'; import { Server } from '@speedy-js/universal/components'; import type { ServerSideRenderContext } from '@speedy-js/universal/interface'; // @ts-ignore, __ENTRY_PATH__ 要被替换为实际的路径才能实现封装 import Entry from '__ENTRY_PATH__';
const relativeEntryPath = path.relative(root, entryPath); if (!entryExportedNames?.includes('default')) { thrownewError(`The component should be exported by default, file: ${relativeEntryPath}`); }
const relativePrefetchPath = prefetchPath && path.relative(root, prefetchPath); if (hasPrefetch) { if (isSSG && isSSR) { thrownewError( `Cannot export both 'getStaticProps' and 'default' at the same time in prefetch entry, please remove one of them, file: ${relativePrefetchPath}` ); } if (!isSSG && !isSSR && prefetchExportedNames) { thrownewError( `Should export one of 'getStaticProps' or 'default' in prefetch entry, file: ${relativePrefetchPath}` ); } }
const entryHelper = new EntryHelper(serverVirtualEntryCode);
entryHelper.replace(__ENTRY_PATH__, `./${relativeEntryPath}`); if (hasPrefetch) { entryHelper.addReExport({ exportName: isSSR ? 'default as getServerSideProps' : 'getStaticProps', filepath: `./${relativePrefetchPath}`, }); }
if (url.searchParams.has('csr')) { return res.end(template); }
// should not cache html files in browser // https://nextjs.org/docs/going-to-production#caching res.setHeader('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate');
if (framework === 'react') { // Renderer 内部会根据当前请求页面是 SSR 还是 SSG 去进行不同的渲染逻辑;可以直接根据构建产物的 export 名称来判断 const renderer = new Renderer({ baseUrl, dist: this.dist, matchedPage, name: matchedPage.name, template, pages, root, dev, pageMeta: this.manifest.entryMeta[matchedPage.name], assetsMeta: this.manifest.assetsMeta ?? {}, isModule: this.manifest.format === 'esm', }); // 借鉴了 Remix 的思想,将所有请求转化为 fetch API req/res 来进行处理,renderer 只需考虑 fetch API 即可 const response = await withFetchAPI(req, res, async (request) => { return renderer.render(request); }); return response; } thrownewError(`universal server for framework ${framework} has not been implemented`); } }; }
const { searchParams } = this.url; if (searchParams.get('__data')) { if (!this.matchedPage) { returnnew Response('No matched SSR page for data', { status: 404 }); } const { getServerSideProps } = exportsMap[this.matchedPage.name].server; if (!getServerSideProps) { returnnew Response('No matched SSR export getServerSideProps for data', { status: 404 }); } const response = new Response(); const data = await getServerSideProps({ request, response, pathname: this.pathname ?? '/', query: searchParams, }); returnnew Response(JSON.stringify(data), { ...response, headers: { ...Object.fromEntries(response.headers.entries()), 'Content-Type': 'application/json', }, }); }
if (this.matchedPage?.name) { if (prefetchTypeMap[this.matchedPage.name] === PREFETCH_TYPE.SSR) { if (framework === 'react') { returnthis.handleSSRRequest(); } thrownewError(`universal server for framework ${framework} has not been implemented`); } } // 其他情况的话是静态资源,使用 CF Worker 的 KV 存储处理,构建产物需要上传到 KV 存储 returnthis.handleAssets(); } }
// 给的是 fetchAPI Request,我们要返回 fetchAPI Response // TODO: proxy the web socket connection when developing exportfunctioncreateCloudflareWorkerHandler(options: CloudflareWorkerAdapterOptions): (event: FetchEvent) => void{ return(event) => { const server = new CloudflareWorkerServer({ ...options, event, }); event.respondWith(server.handleRequest()); }; }
[site] # These two settings are necessary to save the assets to CF KV storage # https://developers.cloudflare.com/workers/cli-wrangler/commands#kvkey # https://developers.cloudflare.com/workers/cli-wrangler/configuration#site bucket = ".universal" entry-point = "."