# Code Splitting 是什么以及为什么

在以前,为了减少 HTTP 请求,通常我们会把所有的代码都打包成一个单独的 JS 文件。但是如果这个 JS 文件的体积太大的话,就会让整个请求体体积过大,从而降低请求响应的速度,那就得不偿失了。

这时,我们不妨把所有代码分成一块一块,需要某块代码的时候再去加载它;还可以利用浏览器的缓存,下次用到它的时候,直接从缓存中读取。很显然,这种方式可以加快我们网页的加载速度。

所以说,Code Splitting 其实就是把代码分成很多很多块(chunk)。

# 怎么做

代码切割的主要方式有两种:

  • 分离业务代码和第三方库(vendor)
  • 按需加载(利用 import() 语法)

之所以把业务代码和第三方代码分离出来,是因为业务的需求是源源不断的,因此业务代码更新频率更高;相反,第三方库更新迭代相对较慢,有时还会锁版本,所以可以充分利用浏览器的缓存来加载这些第三方库。

而按需加载的使用场景,比如说「访问某个路由的时候再去加载相应的组件」,用户不一定一访问所有的路由,所以没必要把所有路由对应的组件都在开始的时候加载完毕。更典型的例子是「某些用户他们的权限只能访问特定的页面」,所以更没必要把他们没有权限的组件加载进来。

# 准备工作

我用 React 写了一个 demo (opens new window) ,他在页面输出一句Hello world。

接下来,看看第一次打包情况:

可以看到,当前只有一个 chunk,也就是 app.js,它是一个 entry chunk。因为我们的 webpack 配置是这样子的:

// webpack.config.js
module.exports = {
  entry: {
    app: '../src/index.tsx' // entry chunk
  }
}

app.js 包含了我们的第三方库 react 和 react-dom,以及我们的业务代码 src。

接下来我们把它们分离开来。

# 分离 Vendor

最简单的方法就是:增加一个 entry

// webpack.config.js
module.exports = {
  entry: {
    app: '../src/index.tsx',
    vendor: ['react', 'react-dom']
  }
}

来分析一下打包:

虽然 vendor.js 这个 chunk 包含了我们想要的 react 和 react-dom,但是 app.js 却没有忽略他们。

这是因为,每个 entry 都有自己的依赖,我们想要把 react 和 re-dom 等第三方依赖提取出来,就需要找出它们相同的依赖,就像这样:

img

如果想要提取公共模块的话,就需要用到 optimization.splitChunks。

# optimization.splitChunks

现在我们修改 webpack 配置:

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: { test: /[\\/]node_modules[\\/]/, priority: -10 },
      },
    },
  },
}

其中 splitChunks 中的配置具体可以参考 webpack 官网 (opens new window) ,我这里采用的配置是所有针对所有模块进行拆分,同时将 node_modules 中的依赖放到 vendors.js 里面,你也可以进行修改,只对异步模块进行拆分。

我们看下打包的结果:

我们可以看到,app.js 里面的 react 和 react-dom 已经拆分到了 vendors 中。

# Dynamic Import

由于产品经理加了新的需求,我们的 demo 新增了路由。

同时我们的打包:

我们新增的 react-router 自动打包到了 vendors 中,但是我们的主包 app.js 却将所有路由文件都打包到一个文件中,这不符合我们的按需加载的想法。

# React.lazy()

webpack 可以针对两种语法进行拆分:

  • ESM 的 import() 语法
  • webpack.ensure

我们使用 React 官方的 React.lazy ,它是基于 webpack.ensure ,我们修改路由配置:

import React, { FC, lazy } from 'react'
import { Redirect, Route, Switch } from 'react-router'

const Home = lazy(() => import('./home/home'))
const Person = lazy(() => import('./person/person'))
const School = lazy(() => import('./school/school'))

const Root: FC = () => {
  return (
    <Switch>
      <Route path='/' exact render={() => <Redirect to='/home' />} />
      <Route path='/home' component={Home} />
      <Route path='/person' component={Person} />
      <Route path='/school' component={School} />
    </Switch>
  )
}

export default Root

在修改 webpack 配置文件

module.exports = {
  output: {
    path: '../dist',
    filename: '[name].[chunkhash].bundle.js',
    chunkFilename: '[name].[chunkhash].bundle.js',
  }
}

为每一个 chunk 添加了 hash,利于以后做缓存。

如果你使用了 babel,需要安装 babel-plugin-syntax-dynamic-import (opens new window) 来解析 import() 语法,修改 .babelrc:

{
  "plugins": ["syntax-dynamic-import"]
}

看一下打包情况:

可以看到,除了主包 app.js 以外,已经额外分离出了三个单独的 chunk,分别对应了我们的三个路由组件。

但是引发了额外的问题,那便是之前在主包已经拆分好的 vendor,在 chunk 中失效了,某一些依赖是多个 chunk 公用的,这时候这些依赖理应在 vendor.js 中,而不应该是每一个 chunk 都有自己的依赖。

但其实问题不大,原因在于 webpack 在抽取公用模块的时候,会对被抽取的模块大小进行判断,模式最小被抽取的大小是 30kb,当然我们修改已达到最小细粒度的复用,这完全靠调用方自己把控。

这里我们把最小大小修改为 0,即所有模块都会被抽取,我们看一下打包后的样子:

# 分离业务公共模块

不单只是第三方依赖,通常我们在写业务代码的时候,也会抽离一些代码放到公共模块中。

细心的读者应该可以看到上图 3,4,5 chunk 里面都包含了 Button,如果类似的公共组件一多起来,就会产生很多重复的代码,所以我们也应该将这些重复代码打包到一个公共的模块里面去。

实现方式和上面一致:

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: 20,
          minSize: 0,
        },
        default: { minChunks: 2, priority: 10, reuseExistingChunk: true },
      },
    },
  },
}

这样,当 webpack 打包的时候,在所有异步 chunk 中引入次数大于等于 2 的模块,webpack 就会把它打包到 default.js chunk 中。(由于 demo 中我们的公用组件大小太小,所以我对公用 chunk 大小修改 0 以方便观察)。

最后我们打包的结果是:

Perfect,这就是我们想要的效果。

ps:由于有一个 chunk 太小导致图中没有显示出来,实际上图中一共有 6 个子 chunk。

# 总结

你的 Code Splitting = webpack bundle analyzer + optimization.splitChunks + 你的分析

我们做代码切割的目的,就是为了充分利用浏览器的缓存,以及首屏的极限优化达到按需加载的效果。