SSR 场景下代码分割的最佳策略

1,411 阅读4分钟

SSR 场景下代码分割的最佳策略

Serverless 场景下最优秀的 SSR 框架

什么是代码分割

根据页面组件的粒度(也可以是单独的 component) 来进行单独的chunk打包,使得我们访问首页时只会加载首页的组件,切换路由时才加载详情页的组件。

前言

本篇文章是关于同构场景的 SSR (React SSR, Vue SSR) 技术栈下,如何进行路由分割来进行按需加载的实现方案。同时也是Serverless场景下最优秀的 SSR 框架所采用的方案。 分别部署在阿里云腾讯云的在线体验地址。
为什么要写本篇文章呢,因为目前市面上关于这块的解决方案绝大多数都是错误的或者是复杂的。就算是 react-loadable 或者是 @loadable/component 这些官方推荐的库的标准做法在我看来都是极其复杂且没必要的。 虽然纯客户端spa应用想做 路由分割 是非常简单的,特别是在 vue-router 里面你甚至不需要引入任何第三方库只是单纯修改组件的引入方式即可实现按需加载。但是一旦涉及到服务端渲染这个问题就会变的复杂起来。 下面的内容需要你尽可能掌握以下知识点

  • 配置过 SSR 场景下代码打包的webpack配置,知道客户端代码打包和服务端代码打包的区别。

如何快速创建项目在本地体验

$ npm init ssr-app my-ssr-project # 默认创建 Serverless SPA 项目, MPA 支持中
$ cd my-ssr-project

官方推荐策略

如果你使用过 SSR 技术栈。那么你应该知道我们的前端组件代码将会分别被打包为客户端 bundle 和 服务端 bundle

  • 服务端 bundle:在服务端运行,通过框架提供的 API 例如 React-dom 提供的 renderToNodeStream 来将组件编译为html字符串或者流在返回给浏览器。由于服务端运行环境存在 node_modules 所以打包的时候一般会开启 externals 选项,来排除第三方的依赖,运行时动态从node_modules 中加载,使得服务端bundle的体积很小。
  • 客户端 bundle:在客户端运行,复用服务端生成的 dom 来激活dom为其添加上事件响应,跟传统 spa 打包出来的 bundle 区别在于复用 dom 还是在客户端重新创建 dom

这是 react-loadable 官方给出的 SSR 场景下的解决方案,这里只截图了一部分。全文大概小几万字了。而且配置非常复杂,不但要引入官方提供的高阶组件,同时还要配套对应的babel插件,webpack 插件一起使用。 我简单总结一下官方的策略的实现目的 1、服务端 bundle 分块 2、客户端 bundle 分块 3、在运行的过程中通过高阶组件以及相关插件获取 chunk 之间的依赖关系进行动态的模块加载,以及静态资源在页面的注入 那么要完成这些需求的首要条件就是要求服务端 bundle 在本地开发的时候也需要落地磁盘。诸如 easy-webpack 等解决方案在本地开发时服务端 bundle 存在内存通过memory-fs读取的方式是无法做到 loadable 的。

更优秀的策略

但是仔细想想,我们一定需要按照官方的的做法来吗。上面提到 服务端 bundle由于开启了 externals 选项,它的体积是很小的。那么将其分块进行动态加载的性能收益其实是很低的。我们主要的目的是客户端 bundle 的分块,因为客户端的网络环境是不确定且比较恶劣的。

根据环境决定是否分块

我们的第一个改善方式,无需将服务端 bundle 分块。只需要将 客户端 bundle 分块。我们通过webpack的 definePlugin 注入变量来标识当前是客户端打包还是服务端打包,

config.plugin('define').use(webpack.DefinePlugin, [{
  __isBrowser__: false
}])

由于我们框架使用的是约定式路由。在生成路由表的时候可以通过文件夹名称来确定chunkName。当然使用声明式路由的也可以通过简单的约定来定义 chunkName 完整代码参考:github.com/ykfe/ssr/bl…

routes = routes.replace(/"component":("(.+?)")/g, (global, m1, m2) => {
        const currentWebpackChunkName = re.exec(routes)![2]
        return `"component":  __isBrowser__ ? require('react-loadable')({
        loader: () => import(/* webpackChunkName: "${currentWebpackChunkName}" */ '${m2.replace(/\^/g, '"')}'),
        loading: function Loading () {
          return require('React').createElement('div')
        }
      }) : require('${m2.replace(/\^/g, '"')}').default`
      })

执行后生成的路由表如下

执行npm run build构建命令后生成的资源如下。 可以看到服务端bundle仍然只有一个 Page.server.js 大小为 15.8kb,客户端bundle分成了 page.chunk index.chunk detail.chunk 由 page.chunk 在运行时动态加载 index.chunk 和 detail.chunk。同样 css 文件也进行了分块

预加载首页路由

由于 loadable 等方案是需要异步加载组件的。但是在 SSR 场景下,我们需要通过 hydrate api 来判断服务端生成的 dom 与客户端首次 render 生成的 dom结构是否匹配。如果不匹配则会报错。这种情况下如果我们使用 loadable 客户端的组件则需要在异步加载完成后的dom结构才是匹配的。所以这时候我们要预加载首页需要用到的组件。为此我编写了一个 preLoad 方法

import { FeRouteItem } from 'ssr-types'

const pathToRegexp = require('path-to-regexp')

const preloadComponent = async (Routes: FeRouteItem[]) => {
  const pathName = location.pathname
  for (const route of Routes) {
    const { component, path } = route
    let activeComponent = component
    if (activeComponent.preload && pathToRegexp(path).test(pathName)) {
      activeComponent = (await activeComponent.preload()).default
    }
    route.component = activeComponent
  }
  return Routes
}

export { preloadComponent }

根据当前的 pathname 来匹配路由表找到首屏需要的组件进行提前的 preload。这样我们运行的时候就不会报 dom 不匹配的错误了。

预加载 css 文件

使用 loadable 带来的一个问题就是 异步组件的 css 文件也是在js中动态加载的。这就会导致出现了非常影响用户体验的闪屏现象。官方的策略仍需要用户通过 webpack 来收集模块之间依赖关系自行注入。由于我们使用的是约定式路由,我们可以很轻松的知道首屏对应的css chunk 的文件名来在页面中进行注入

  if (dynamic) {
    cssOrder.push(`${routeItem.webpackChunkName}.css`)
  }

至此,我们的一个 SSR 场景下的代码分割方案就完成了。完全不需要做任何的 webpack 改造和高阶组件的包裹。比官方的方案简单好用非常多。