Vite SSR 原理解析

856 阅读4分钟

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

在构建客户端代码的时候需要额外生成ssrManifestssrManifest的作用是告知 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, loadtransform 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。