编程崽

登录

一叶在编程苦海沉沦的扁舟之上,我是那只激情自射的崽

花式引入组件和资源-打包时拆包减少js体积

花式引入组件和资源-打包时拆包减少js体积

注:以下方式部分也适用于 React

开发时,经常会有以下问题和需求:

  • 页面文件打包后是 app.js 文件,如果页面过多,会导致这个文件过大,首次加载消耗时间。
  • npm 安装的依赖,比如 UI 框架、js 插件等等,打包后是 chunk-vendors.js 文件,如果这些依赖过多,或某个依赖体积较大,也会导致这个文件过大。
  • 权限系统,并不是所有用户都有权查看所有页面,不用每位用户都得到完整路由和路由对应页面的文件资源
  • ...

以上痛点,我也经常遇到,虽然使用按需引入,可仍然没什么效果,下面根据我最近的经验,整理一下解决办法。

分页面打包成js,打开页面时异步引入

这个算是最常见的处理方式了,需要修改 router 路由配置文件。

正常写 router 路由配置文件时,写法如下:

js 复制代码
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router);

// 直接引入所有页面文件
import Index from '@/views/Index'
import Home from '@/views/Home'
import User from '@/views/User'

const router = new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'index',
      component: Index,
    },
    {
      path: '/home',
      name: 'home',
      component: Home,
    },
    {
      path: '/user',
      name: 'user',
      component: User,
    },
  ]
});

export default router;

修改为一下方式,这样一来,除了 Index 首页的其他页面,都会单独打包为独立的 js 资源文件,只有在用户即将进入页面时,才会当场加载那个页面的 js 资源文件,加载完成后才会跳页进入。

js 复制代码
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router);

// 只引入首页资源文件(也可以和其他页面一样使用下面的方式)
import Index from '@/views/Index'

const router = new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'index',
      component: Index,
    },
    {
      path: '/home',
      name: 'home',
      component: import('@/views/Home'), // 异步引入
    },
    {
      path: '/user',
      name: 'user',
      component: import('@/views/User'), // 异步引入
    },
  ]
});

export default router;

解决的问题

  • 页面过多导致的 app.js 文件过大,使初次进入页面也不用加载所有页面资源,减少加载时间,缩短首屏渲染完成时间。

缺点

  • 用户每次进入的页面,如果之前从未打开过,那么没有缓存文件,会自动的当场从服务器加载文件,导致页面没有立即跳转,稍微影响用户体验(如果网速快,则影响很小)。

npm 安装的依赖按需引入

最常见的就是 element-ui,提供按需引入的功能,也就是在需要使用该插件的某个 vue 组件中,解构引入用注册:

js 复制代码
<template>
  <div>
    <span>其他页面 dom</span>
    <Button type="primary">按钮</Button>
  </div>
</template>
<script>
// 解构引入,使用哪个引入那个
import { Button } from 'element-ui'
export default {
  components: {
    Button,
  },
}
</script>

解决的问题

  • 依赖的插件,每个插件都不用全量加载,只打包插件中我们使用的那几个方法和资源,减小 chunk-vendors.js 的体积。

缺点

  • 使用麻烦,每个需要使用 element-ui 的组件,都需单独引入,即使把用到的进行全局注册,也是稍微有点麻烦。
  • 效果不明显。

npm 安装的依赖,直接用静态资源的方式引入依赖

这种方式比较直接,就是直接在 index.html 中,引入我们需要的依赖的资源文件。

当然是需要判断操作的,当我们是本地启动服务开发时,不用关系文件体积大小,可以仍然照常开发。

当是 build 打包时,才需要进行以下配置,下面的配置,也包含判断操作的逻辑。

所以,我们需要做的有三件事:

  • 判断当前操作,是打包还是启动开发服务。

  • 打包时,index.html 使用资源路径,来引入我们项目需要的依赖,资源对象自动绑定到 window 对象上。

  • 打包时,需要配置 webpack 的文件复制功能,把 index.html 通过路径引入的资源文件,复制到对应目录。

  • 打包时,配置 webpack 的 externals,让项目中使用这些 index.html 通过路径引入的资源时,从 window 中查找并返回给项目,而不是再从 node_modules 中取。

判断当前操作,package.json 文件中:

json 复制代码
{
  "scripts": {
    // 给 serve 和 build,分别使用 npm config 功能传入变量,告诉后面当前正在执行的操作
    "serve": "vue-cli-service serve --dev",
    "build": "vue-cli-service build --build",
  }
}

配置使用资源路径来引入依赖,index.html 文件中:

html 复制代码
<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>首页</title>
    <!--
      这里注意判断一下环境,这里我使用 VUE_APP_IS_BUILD 字段判断,这个字段后面有配置
      只有打包时,才用这种资源路径的方式引入插件 js 文件
      如果是起服务的开发模式,则仍然使用默认方式,方便开发时调试
    -->
    <% if (process.env.VUE_APP_IS_BUILD === 'true') { %>
      <script src="./vue.min.js"></script>
      <script src="./element-ui.js"></script>
    <% } %>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but this Page doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

配置webpack,vue.config.js 文件中:

js 复制代码
const path = require('path')
const CopyWebpackPlugin = require('copy-webpack-plugin') // 引入复制插件

function resolve(dir) {
  return path.join(__dirname, dir)
}

// 得出是否是 build 打包模式
process.env.VUE_APP_IS_BUILD = process.env.npm_config_build === 'true' ? 'true' : 'false'

// 配置 externals
const externals = {}
if (process.env.VUE_APP_IS_BUILD === 'true') {
  // 是 build 打包构建,那么 index.html 是手动引入的 vue 和 element-ui 的资源文件,那么就需要配置一下
  // 让组件中,引入 vue 时,自动给它 window.Vue,引入 element-ui 时,给它 window.ELEMENT
  // 如果没有配置,或者配置的资源不存在,才会再从 node_modules 中取
  externals['vue'] = 'Vue'
  externals['element-ui'] = 'ELEMENT'
}

// 配置插件
const plugins = []
if (process.env.VUE_APP_IS_BUILD === 'true') {
  // 是 build 打包构建,那么 index.html 是手动引入的 vue 和 element-ui 的资源文件,那么就需要配置一下
  // 需要把这两个文件,复制到对应的目录,让 index.html 能成功找到并引入文件
  let copy = new CopyWebpackPlugin([
    { from: resolve('node_modules/vue/dist/vue.min.js'), to: resolve('dist/vue.min.js') },
    { from: resolve('node_modules/element-ui/lib/index.js'), to: resolve('dist/element-ui.js')},
  ])
  plugins.push(copy)
}

module.exports = {
  configureWebpack: {
    externals,
    plugins,
  },
}

注意上面的例子中,打包时的 vue 也是使用资源路径的引入方式,这是因为,由于我们是使用资源路径的方式引用 element-ui,而 element-ui 要求必须在它之前先引入 vue,否则会报错,所以,我们不得不把 vue 也使用这种方式引入了。

解决的问题

  • 打包后 app.jschunk-vendors.js 体积问题,能大幅减小他们。
  • 反正资源已经全量引入了,项目中可以全局全量注册,不用每个页面再单独解构引入想用的组件了。

缺点

  • 配置稍微麻烦点,但配置我都已经写在上面了
  • 如果更换了这些资源的版本,但文件名没变,这样用户打开是,可能会访问到缓存文件,不过这种可能性比较小,一个项目的依赖,一般是不会换版本的,而且我们也可以再编辑一下配置,给引入的资源文件加上版本号,这里没有做这个。

异步添加路由

顾名思义,这种方式需要使用 vue-router 的 addRoute 的方式。

使用场景,一般是用于权限系统,即当用户输入 url 即将进入项目时,此时进行拦截,开始获取接口得到当前用户能访问的页面列表,添加这些路由后再放行。

如果用户有权访问,那么当前打开的项目,就有这个页面,可以进入;如果没有权限,那当前根本也就没有这个路由,自动走路由错误的处理。

router 的配置文件:

js 复制代码
import Vue from 'vue'
import Router from 'vue-router'

import Index from '@/views/Index'
import NotFound from '@/views/NotFound'

Vue.use(Router)

const router = new Router({
  isAdded: false, // 用于标记是否已经 addRoute 添加过路由了
  routes: [
    {
      path: '/',
      name: 'index',
      component: Index,
    },
    {
      path: '/404',
      name: '404',
      component: NotFound,
    },
    // 先不添加 * 无路由的重定向
    // 因为遇到没有的路由时,需要先去调接口加载路由们
  ]
})

router.beforeEach(async (to, from, next) => {

  // 已经二次添加过路由 或 访问 404 页面
  if (router.options.isAdded || to.path === '/404') {
    return next()
  }

  try {
    // 调接口获取用户路由列表
    let list = await getRouterList()
    
    // 遍历组装新路由
    let routes = [] // 组装好的新路由们
    for (let routeItem of list) {
      routes.push({
        path: routeItem.path,
        name: routeItem.name,
        component: loadPage(routeItem.filePath),
      })
    }

    // 此时添加 空路由 的重定向
    routes.push({
      path: '*',
      redirect: '/404',
    })

    // addRoute 添加新路由
    routes.forEach(route => router.addRoute(route))
    // 标记改为添加路由成功
    router.options.isAdded = true
    // 最终去跳转
    next(to)

  } catch(err) {
    // 出错
    next('/404')
    throw err
  }
})

// 获取路由接口,路由路径、名称、路由对应的页面文件相对路径,都由接口返回
function getRouterList() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([
        {
          path: '/home',
          name: 'home',
          filePath: 'Home',
        },
        {
          path: '/abc',
          name: 'abc',
          filePath: 'Abc',
        },
      ])
    }, 1000);
  })
}

// 异步加载页面文件资源
function loadPage(file) {
  // 异步引入,当需要打开页面时,才引入对应页面的静态资源
  return () => import('@/views/modules/' + file + '.vue')
}

export default router

注意里面的 loadPage 方法中的 import() 方法,需要讲一下。

import() 方法

前面虽然也使用这个方法,但都是直接引入指定的确定文件,而这里我们需要按规则引入某些目录下的文件。

上面方法中,使它的方式如下:

js 复制代码
import('@/views/modules/' + file + '.vue')

当 webpack 编译到此处,发现 import 中是 路径 + 变量 的模式,会自动把他们转为正则,然后按照正则匹配目录下文件,把所有符合这个路径的文件全部编译打包为单文件 js,当我们使用时,真的去引用规则中某个文件时,就可以引入了。

注意这里不能直接以 import(file) 的形式使用,因为这么写的话,路径完全是个 file 变量,按照规则,webpack 需要把整个硬盘中的文件都打包,它认为这是个错误的行为,会直接报错。

以不同方式使用 import() 的几种效果

下面上来自上面 router 的配置文件 的内容的摘抄,下面我使用不同的语法来使用 import(),它也会有几种不同的执行效果。

1. 默认的推荐写法,添加路由后,用户打开哪个页面,才会加载某个页面的 js 资源文件。

js 复制代码
// ...
for (let routeItem of list) {
  routes.push({
    path: routeItem.path,
    name: routeItem.name,
    component: loadPage(routeItem.filePath),
  })
}
// ...

// 异步加载页面文件资源
function loadPage(file) {
  // 异步引入,当需要打开页面时,才引入对应页面的静态资源
  return () => import('@/views/modules/' + file + '.vue')
}

2. 同步引入,在添加路由时,直接把新页面的 js 资源们全部顺序引入。

js 复制代码
// ...
for (let routeItem of list) {
  routes.push({
    path: routeItem.path,
    name: routeItem.name,
		component: await loadPage(routeItem.filePath),
  })
}
// ...

// 同步加载页面文件资源
async function loadPage(file) {
  // 执行此行,立即同步引入对应页面的所有静态资源
  return (await import('@/views/modules/' + file + '.vue')).default
}

3. 还有一种极不推荐的写法,直接在编译时,就把所有匹配到的文件 js 资源,打包进 app.js 中

js 复制代码
// ...
for (let routeItem of list) {
  routes.push({
    path: routeItem.path,
    name: routeItem.name,
    component: loadPage(routeItem.filePath),
  })
}
// ...

// 异步加载页面文件资源
function loadPage(file) {
  // 编译时,把下匹配到的所有页面资源,直接打包至 app.js 中,相当于就差配置一个路由了
  return require('@/views/modules/' + file + '.vue').default
}

新增:使用 webpack 配置

其实早该用这个方法了,只是这个配置实在不太容易理解,我也不敢确保我写的是正确的。

下面是一种比较暴力的配置方法,使用 webpack 自带插件 optimization

官方教程:https://webpack.docschina.org/plugins/split-chunks-plugin/#optimizationsplitchunks

下面是用法,主要是 minSizemaxSize 两个字段。

js 复制代码
module.exports = {
  configureWebpack: config => {
    config.optimization.splitChunks = {
      chunks: 'all',
      minSize: 1024 * 100, // 生成 chunk 的最小体积(以 bytes 字节为单位)
      maxSize: 1024 * 800, // 尝试将大于 maxSize 个 bytes 字节的 chunk 分割成较小的部分,这些较小的部分在体积上至少为 minSize
      automaticNameDelimiter:'-',
      cacheGroups: {
        vendor: {
        test: /[\\/]node_modules[\\/]/,
        name(module) {
          const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
          return `chunk.${packageName.replace('@', '')}`;
        },
        priority: 10,
        },
      }
    }
  },
}