ssr框架6.0正式发布,更完美的vite体验

1,812 阅读8分钟

SSRv6.0 + Vite

框架仓库地址:github.com/zhangyuang/… 原文地址:doc.ssr-fc.com/docs/featur…

本章介绍 ssr 框架 6.0 版本全新的 Vite SSR 体验

快速开始

React/Vue3 场景中我们都已经以最小化成本的方式接入 Vite。在 Vue2 场景中,由于 vite-plugin-vue2 的限制,我们暂时无法使用 vite ssr

$ npm init ssr-app my-ssr-project # 如需要使用 Vite 这里可以选择创建 React/Vue3 类型的应用
$ cd my-ssr-project && yarn
$ npx ssr start --vite # 等价于 npm run start:vite
$ npx ssr build --vite # 等价于 npm run build:vite
$ npm run prod:vite # 生产环境启动

也可以在 config.ts 中显式的添加 isVite 选项来固定启动模式

// 添加后无需 ssr start/build 新增 --vite 参数
import type { UserConfig } from 'ssr-types'

const userConfig: UserConfig = {
    isVite: true
}

export { userConfig }

完成上述步骤即可使用 Vite 作为构建工具体验极快的开发速度

框架背景

关于ssr 框架的介绍请查看文章

前言

5.x 版本的 ssr 框架中,当时出于开发时间限制以及改动成本的关系,我们采用的方案是服务端 bundleWebpack 编译,客户端文件走 Vite 服务的 vite ssr 这样的架构模式,来保证最小化代码的改动。现在看来当时的决定是非常正确的,确实 vite ssr 在那个时候有不少不成熟的地方也在这段期间中被 Vite 官方团队逐一的解决,并且本人也在这段开发期间深度阅读了 vite ssrloadModule 这块的源码对它的运行机制有了更深刻的了解。也在这个过程中尝试对 Vite 做了一些微小的贡献。在这里感谢 Vite 团队的付出和及时的响应。

现状

那么,在 6.0 版本的 ssr 框架中,我们做到了 All in Vite,也就是提供了全套只使用 Vite 作为开发工具的开发链路来体验极快的启动速度。同时我们分离了 WebpackVite。也就是说开发者可以任意的选择喜欢的工具。

开发者可以选择 ssr/csr + 本地开发 Webpack/Vite + 生产环境构建 Webpack/Vite + Midway/Nest.js 这样的任意组合方式。同时我们在新增功能的保持对代码行数的克制,使得框架总代码量在没有刻意优化的情况下仍然保持在 6000行 左右单前端框架场景在 2500 行左右。

那么先让我们用动图看看分别用 Webpack/Vite 启动的速度差别吧。如果图片动不了,请使用 Chrome 浏览器打开网页

Webpack 启动

启动时间 = 初始化模块加载 + 编译服务端/客户端 bundle 时间 + Midway 启动时间

image.png

Vite 启动

启动时间 = 更少的初始化模块加载 + Midway 启动时间

image.png

应用结构

对于应用结构这里简单画了一个示意图如下。

具体的 vite ssr 结构如下图

在服务端我们用 ssrLoadModule 这个 API 来转换模块。客户端以中间件的形式让 Vite 接管请求。与 Webpack SSR 架构类似。在服务端和客户端我们有两套不同的 vite.config 配置,所以我们不会将 vite.config.js 直接暴露出来。而是通过框架统一的配置项抛出配置。

开发建议

由于 Vite/Rollup 没有 Webpack-Chain 这样的模块来生成配置,目前只能用一些比较笨的方式来 Merge 用户自定义配置。所以容易造成用户配置覆盖框架默认配置的情况。所以目前框架只会开放少量配置让用户自定义配置。在之后我们会不断完善这一块。

正如上文所说的,开发者有多种开发构建组合方式。只要不使用只能够在特定平台运行的代码例如 import.meta.env/module.hot 这些代码,那么你的代码在 Vite/Webpack 模式下都能够本地运行,生产环境构建成功。所以不建议开发者使用只能在特定工具下运行成功的代码以及配置。框架将会在之后将不同的工具的配置进行打平,抛出一个共同使用的配置项供开发者使用。

在生产环境构建上尽管我们也支持了 Vite 并且能够运行成功, 但目前看来 Vite 底层使用的 Rollup 更适合用来构建库,用来构建应用确实有些别扭(也有可能是本人使用的姿势不对还在探索当中)。目前会有一些小瑕疵问题还未解决,不过不用担心在之后的版本中我们将会不断优化这一块。

综上所述,我们已经迈出了最困难的一步,接下来的做法就是抹平 Vite/Webpack 在本框架中的使用差异,配置差异,构建差异。特别是在 UI 框架使用这一块,我们之后将会集成一些配置,使得可以在 Vite 场景下无缝使用各大流行 UI 库例如 antd, vant 等等而无需做任何额外的配置。也欢迎各位开发者的深度使用以及问题反馈

踩坑记录

以下记录开发 vite ssr 时遇见的问题,给其他框架开发者作为参考

干掉 CommonJS

ssrLoadModule 方法传入的文件中只能够使用 es6 module 语法,不能够出现 require/modulecommonjs 关键字,如必须使用,可使用 createRequire 方法。

原因是因为 ssrLoadModule 采用 new Function 的形式执行入口文件。

const AsyncFunction = async function () {}.constructor as typeof Function
const initModule = new AsyncFunction(
  `global`,
  ssrModuleExportsKey,
  ssrImportMetaKey,
  ssrImportKey,
  ssrDynamicImportKey,
  ssrExportAllKey,
  result.code + `\n//# sourceURL=${mod.url}`
)
await initModule(
  context.global,
  ssrModule,
  ssrImportMeta,
  ssrImport,
  ssrDynamicImport,
  ssrExportAll
)

函数内部只能够访问 new Function 传入的变量,并且这些变量都是被 Vite 替换过一遍的。换句话说 import Vue from 'vue' 实际执行的是 const Vue = await __vite_ssr_import__('vue')。操作都会被 Vite 定义的函数接管。new Function 中没有传入 require 所以自然在代码内部无法识别 require 关键字。

也就是说形如下面的代码是无法直接运行的

const getConfig = () => require(resolve(process.cwd(), './config'))

第三方模块必须显示添加到项目的 dependencies

上面讲到了 Vite 使用 new Function 的形式来执行入口文件,对于入口文件中依赖的第三方模块或者是自身引用的相对路径模块 Vite 都有不同的处理方式。对于第三方模块一般是直接使用原生的 const module = return import(file) 的形式读取。

const ssrImport = async (dep: string) => {
    if (dep[0] !== '.' && dep[0] !== '/') {
      // 原生的 import 方法处理第三方模块
      return nodeRequire(
        dep,
        mod.file,
        server.config.root,
        !!server.config.resolve.preserveSymlinks
      )
    }
    // 处理非第三方文件,会调用 vite 自身的 transform 逻辑进行代码转换以及 new Function 代码执行
    // xxx 省略
  }

这里有一个巨坑,就是 ssrLoadModule 里面执行的入口文件中依赖的第三方模块必须显示列在 dependencies,否则 Vite 这块的处理会有问题。举个🌰,当我们在 server-entry 中引用了 semver 这个只提供了 CommonJS 格式的模块

import semver from 'semver'

如果你没有把它列在 dependencies 中将会被解析成

// error
const __vite_ssr_import_0__ = await __vite_ssr_import__("/node_modules/semver/index.js?v=cea99eb4");
// true
const __vite_ssr_import_0__ = await __vite_ssr_import__("semver");

这样的错误路径在 __vite_ssr_import__ 中不会被当作第三方模块进行处理,会继续以 new Function 的形式解析,导致错误。

这个问题我在 Vite 的源码中横跳了一天才发现。因为 ssr-server-utils/serialize-javascriptssr-core-vue3 给依赖了,所以应用本身并没有列出它们在 dependencies 中,导致本地 link 能够正常运行,但是正式 publish 后死活也无法运行。

分成两个 vite 配置

在官方的 ssr-vue 示例中前端服务端公用了一个 vite.config 文件,对于简单的应用来说这足够了,但对于大型应用来说这块最好是沿用 Webpack 场景下的思路,将服务端客户端的配置文件分离,优势在于我们可以通过 define __isBrowser__ 这样的关键字在业务代码中区分当前环境。

尽管 Vite 也提供了 import.meta.env.SSR 这样的关键字来区分环境,但依赖只能够在在特定工具下运行的代码不是一个好的方案。特别是当我们做构建时要对服务端,客户端的代码做不同的构建配置

缺少 manifest-plugin 以及 MagicComment

对于 Rollup 来说,官方原生没有提供 manifest-plugin 这样的插件来提供源文件与构建后的 hash 文件映射关系,需要开发者自己寻找第三方插件或者自行编写。

其次 Rollup 缺少 import(/* webpackChunkName */) 这样的 MagicComment 来定义 chunkName, 只能够通过 manualChunks 以及自行编写插件来实现对应的功能。对于开发者来说是一种挑战。

对于上面的两个问题尽管 Vite 官方提供了 ssr-manifest 这样的插件来生成一些映射关系做资源预加载,但在一些场景下仍然不够用。特别是当我们组建渲染返回的结果是 stream 时,这样的字符串动态替换插值的方式就用不了了。

升级步骤

对于之前使用 5.x 的开发者来说要如何进行升级呢?

我们在业务代码层面和应用配置层面没有任何破坏性变更所有的开发习惯跟之前一样。唯一的区别在于开发者不再需要 5.0 版本的 vite.config 文件

  • 更新所有 ssr-* 相关依赖到 ^6.0.0 或者直接 npm init ssr-app 创建最新的模版对比, 以 midway-vue3 为例
"dependencies": {
    "ssr-core-vue3": "^5.0.0",
    "serialize-javascript": "^6.0.0",
    "ssr-server-utils": "^6.0.0",
    "ssr-types": "^5.0.0",
    "swiper": "6.7.5",
    "vue": "^3.0.0",
    "vue-router": "^4.0.0",
    "vuex": "^4.0.0"
},
"devDependencies": {
  "ssr": "^5.0.0",
  "ssr-plugin-midway": "^5.0.0",
  "ssr-plugin-vue3": "^5.0.0",
  "typescript": "^4.0.0"
},
  • 删除原有的 vite.config.js 文件,如之前没有创建,则不需要删除
  • 服务端静态资源文件夹新增 build/client 文件夹
// midway config.default.js
config.static = {
    prefix: '/',
    dir: [join(appInfo.appDir, './build'), join(appInfo.appDir, './public'), join(appInfo.appDir, './build/client')]
}
// nestjs main.ts
app.useStaticAssets(join(getCwd(), './build/client'))
  • package.json 新增 ssr build --vite 相关脚本, dependencies 中显式添加
"dependencies": {
  "serialize-javascript": "^6.0.0",
  "ssr-server-utils": "^6.0.0",
},
"scripts": {
  "prod:vite": "ssr build --vite && cross-env BUILD_TOOL=vite egg-scripts start --port=3000 --title=midway-server-my_midway_project --framework=@midwayjs/web",
  "stop": "egg-scripts stop --title=midway-server-my_midway_project",
  "start:vite": "ssr start --vite",
  "build:vite": "ssr build --vite"
}