Vite SSR 功能是一个底层 API,为库和框架作者准备,支持在 Node.js 中运行相同应用程序的前端框架(例如 React、Preact、Vue 和 Svelte),将其预渲染成 HTML,最后在客户端进行 hydration 处理。
启动 SSR 构建
在开发 Web 应用过程中,Vite 提供了 dev 模式细粒度的编译文件以缩短 HMR 的时间,和 prod 模式使用 Rollup 编译项目。在 SSR 模式下也不例外,Vite 也会提供两种方式去启动。以 vite 官方的 Vue.js 例子进行介绍两种模式下 Vite 的处理。
dev
使用
在 dev 下,我们需要 SSR 提供和非 SSR 模式一致的极速的 HMR 体验。
下面例子是一个启动 vite SSR dev Node.js 端的用例。主要用到了 ssrLoadModule
SSR 运行的 API。
import express from 'express'
import { createServer } from 'vite'
const app = express()
const vite = createServer({
root,
server: {
middlewareMode: true,
watch: {
// During tests we edit the files too fast and sometimes chokidar
// misses change events, so enforce polling for consistency
usePolling: true,
interval: 100
},
hmr: {
port: hmrPort
}
},
appType: 'custom'
})
app.use(vite.middlewares)
app.use('*', async (req, res) => {
try {
let template = fs.readFileSync(resolve('index.html'), 'utf-8')
template = await vite.transformIndexHtml(url, template)
const render = (await vite.ssrLoadModule('/src/entry-server.js')).render
const [appHtml, preloadLinks] = await render(url, manifest)
const html = template
.replace(`<!--preload-links-->`, preloadLinks)
.replace(`<!--app-html-->`, appHtml)
res
.status(200)
.set({ 'Content-Type': 'text/html' })
.end(html)
} catch (e) {
res.status(500).end(e.stack)
}
})
ssrLoadModule
这个 API 的作用是在 Node.js 环境下加载模块及其依赖,并且在 Vite 同一个 Node.js 环境下执行,并且让这个模块经过 Vite 所有插件 SSR 模式转换,让同一份代码在 SSR 模式下也支持 Vite 提供的语法糖。
下面介绍下这个方法的 happy path。
- ensureEntryFromUrl,解析加载的 URL,并在模块图上创建这个模块。
- ssrTransform,将所有的 esm
import,export都转换成__vite_ssr_*__函数。 - 执行模块。使用
AsyncFunction提供__vite_ssr_*__相关函数,对原来的 import 语句将会执行ssrLoadModule继续加载,对 export 语句则在ssrModule对象上添加返回对象的引用。
❓ 循环依赖问题
- 因为模块对象在加载之前就已经注册到模块图上了,如果这个模块也正在初始化就会直接返回这个模块
ssrModule引用。 - 如果加载中的模块还是被再次调用
ssrLoadModule去加载,也是直接从模块图上取这个模块ssrModule引用,避免模块二次加载。
vite 在加载模块的时候,避免模块进行二次加载,循环依赖获取到的是这个模块没有初始化完成的ssrModule引用。
❓ 为什么快
SSR dev 处理逻辑和纯 dev 处理逻辑是一致的。SSR 还是根据运行时需要加载的模块进行实时编译然后放到 Node.js 环境下执行,纯 dev 环境根据浏览器加载模块进行请求 vite 然后实时编译返回到浏览器执行。他们都还是保持着 vite 细颗粒度的更新和编译模块,所以在 hmr 的场景下还是很快。
prod
使用
在 prod 下,我们追求的是极致的运行速度。
下面列子是一个启动 vite SSR prod Node.js 端的用例。主要依赖的是两个渲染的结果,一个是 server 端 Node.js 运行的代码(./dist/server/entry-server.js),一个是浏览器运行的代码(dist/client)作为静态资源进行代理。
import express from 'express'
const app = express()
const indexProd = isProd
? fs.readFileSync(resolve('dist/client/index.html'), 'utf-8')
: ''
app.use((await import('compression')).default())
app.use(
'/',
(await import('serve-static')).default(resolve('dist/client'), {
index: false
})
)
app.use('*', async (req, res) => {
try {
const template = indexProd
const render = (await import('./dist/server/entry-server.js')).render
const [appHtml, preloadLinks] = await render(url, manifest)
const html = template
.replace(`<!--preload-links-->`, preloadLinks)
.replace(`<!--app-html-->`, appHtml)
res
.status(200)
.set({ 'Content-Type': 'text/html' })
.end(html)
} catch (e) {
console.log(e.stack)
res.status(500).end(e.stack)
}
})
render
export async function render (url, manifest) {
const { app, router } = createApp()
// set the router to the desired URL before rendering
await router.push(url)
await router.isReady()
// passing SSR context object which will be available via useSSRContext()
// @vitejs/plugin-vue injects code into a component's setup() that registers
// itself on ctx.modules. After the render, ctx.modules would contain all the
// components that have been instantiated during this render call.
const ctx = {}
const html = await renderToString(app, ctx)
// the SSR manifest generated by Vite contains module -> chunk/asset mapping
// which we can then use to determine what files need to be preloaded for this
// request.
const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
return [html, preloadLinks]
}
function renderPreloadLinks (modules, manifest) {
let links = ''
const seen = new Set()
modules.forEach(id => {
const files = manifest[id]
if (files) {
files.forEach(file => {
if (!seen.has(file)) {
seen.add(file)
const filename = basename(file)
if (manifest[filename]) {
for (const depFile of manifest[filename]) {
links += renderPreloadLink(depFile)
seen.add(depFile)
}
}
links += renderPreloadLink(file)
}
})
}
})
return links
}
function renderPreloadLink (file) {
if (file.endsWith('.js')) {
return `<link rel="modulepreload" crossorigin href="${file}">`
} else if (file.endsWith('.css')) {
return `<link rel="stylesheet" href="${file}">`
} else if (file.endsWith('.woff')) {
return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
} else if (file.endsWith('.woff2')) {
return ` <link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
} else if (file.endsWith('.gif')) {
return ` <link rel="preload" href="${file}" as="image" type="image/gif">`
} else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) {
return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">`
} else if (file.endsWith('.png')) {
return ` <link rel="preload" href="${file}" as="image" type="image/png">`
} else {
return ''
}
}
构建客户端代码
$ vite build --ssrManifest --outDir dist/client
在构建客户端代码的时候需要额外生成ssrManifest,ssrManifest的作用是告知 Node.js 端渲染这个模块下所有的依赖。在 rollup 构建项目的时候添加一个插件,在generateBundle 下把 bundled 的依赖保存下来。
构建 Node.js 端代码
$ vite build --ssr src/entry-server.js --outDir dist/server
Vite 需要对 SSR 应用需要使用同一份代码构建在 Node.js 和浏览器运行的代码。以 Vue.js 插件为例,当 Vue 编译器接受到ssr: true参数后,就会将模版生成的代码从生成浏览器运行代码 转换成 拼接字符串。
<div>Hello World!</div>
<script>
// 浏览器运行代码 (生成 vnode)
import {
openBlock as _openBlock,
createElementBlock as _createElementBlock
} from 'vue'
export function render (_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock('div', null, 'Hello World!')
}
</script>
<script>
// -> nodejs运行代码 (拼接html字符串)
import { mergeProps as _mergeProps } from 'vue'
import { ssrRenderAttrs as _ssrRenderAttrs } from 'vue/server-renderer'
export function ssrRender (
_ctx,
_push,
_parent,
_attrs,
$props,
$setup,
$data,
$options
) {
const _cssVars = { style: { color: _ctx.color } }
_push(
`<div${_ssrRenderAttrs(
_mergeProps(_attrs, _cssVars)
)} scope-id>Hello World!</div>`
)
}
</script>
根据插件的 resolveId, load 和 transform hooks 提供 SSR flag来区分生成代码运行平台。
interface Plugin {
...
resolveId?: ObjectHook<
(
this: PluginContext,
source: string,
importer: string | undefined,
options: {
custom?: CustomPluginOptions
ssr?: boolean
/**
* @internal
*/
scan?: boolean
isEntry: boolean
}
) => Promise<ResolveIdResult> | ResolveIdResult
>
load?: ObjectHook<
(
this: PluginContext,
id: string,
options?: { ssr?: boolean }
) => Promise<LoadResult> | LoadResult
>
transform?: ObjectHook<
(
this: TransformPluginContext,
code: string,
id: string,
options?: { ssr?: boolean }
) => Promise<TransformResult> | TransformResult
>
...
}
以 @vitejs/plugin-vue 的插件使用为例,通过插件的 flag 确定当前的需要转换生成的运行平台代码。
resolved = options.compiler.compileScript(descriptor, {
...options.script,
id: descriptor.id,
isProd: options.isProduction,
inlineTemplate: isUseInlineTemplate(descriptor, !options.devServer),
reactivityTransform: options.reactivityTransform !== false,
templateOptions: resolveTemplateCompilerOptions(descriptor, options, ssr),
sourceMap: options.sourceMap
})
插件使用 Vite 提供的 SSR Flag,然后再把 SSR Flag 透传到框架的编译器完成 SSR 阶段和非 SSR 阶段的区分。
因为分别打包浏览器运行的代码和 Node.js 直接运行的代码,省去了 dev 模式下按需编译的过程,在各个环境下拿到的都是编译好的代码,在 prod 模式下直接不用启动 vite。