Vite 开发实践 - 打包部署

22,964 阅读4分钟

前言

在前面我们讲了vite的环境配置与插件开发实践,下面再来讲下vite在打包以及部署中的相关实践。

打包生产环境

简单的应用直接npm run build就可以得到生产环境的代码,但是如果需要对打包做更精确的操作,还需要单独做一些配置,比如对多页应用和代码库的打包。

当然,这一点官方已经说的很清楚了,这里建议直接看 vite 官网就行了

多页应用的打包

vite 中多页应用打包很简单,因为是使用的rollup,所以改变一下rollup相关选项就行了:

import { defineConfig } from 'vite'
import path from 'path'

module.exports = defineConfig({
  build: {
    rollupOptions: {
      input: {
        main: path.resolve(__dirname, 'index.html'),
        nested: path.resolve(__dirname, 'index2.html')
      }
    }
  }
})

其余更细节的操作可以参考rollup的打包流程。

库模式的打包

vite 专门为我们提供了库模式打包的配置,一般来说只需要在build.lib的配置项中进行相应声明就可以了。

import { defineConfig } from 'vite'
import path from 'path'
export default defineConfig({
  build: {
    lib: {
      entry: path.resolve(__dirname, 'lib/main.js'),
      // umd 形式的命名空间
      name: 'MyLib',
      fileName: (format) => `my-lib.${format}.js`
    },
    rollupOptions: {
      // 确保外部化处理那些你不想打包进库的依赖
      external: ['vue'],
      output: {
        // 在 umd 构建模式下为这些外部化的依赖提供一个全局变量
        globals: {
          vue: 'Vue'
        }
      }
    }
  }
})

官方还建议我们在package.json中进行相关定义,当我们引入包时会匹配到下面的定义的内容:

{
  // 包名
  "name": "my-lib",
  // 代表只上传 dist 目录和 package.json 文件,如果不写则默认上传所有非 node_modules 文件
  "files": ["dist"],
  // 声明包内导出的内容,该字段是一个较新的语法,建议能写就写,但是相应的兼容写法都需要写上。
  "exports": {
    // . 代表该包只导出在了第一级,只能使用 import xx from 'my-lib'的方式引入包内容,不能使用 import xxx from 'my-lib/dist/my-lib.es.js'等方式引入
    ".": {
      // 通过 import 引入时会匹配到这里
      "import": "./dist/my-lib.es.js",
     // 通过 require 引入时会匹配到这里
      "require": "./dist/my-lib.umd.js",
      // ts 定义文件
      "types": "./dist/my-lib.d.ts"
    },
    // 代表导出了'my-lib/dist'目录下所有内容,可以 import xx from 'my-lib/dist/my-lib.es.js' 引入包内容
    "./dist/*": {
      "import": "./dist/my-lib.es.js",
      "require": "./dist/my-lib.umd.js",
      "types": "./dist/my-lib.d.ts"
    }
  }// 通过 require 引入时会匹配到这里,exports 的兼容写法
  "main": "./dist/my-lib.umd.js",
  // 通过 import 引入时会匹配到这里,exports 的兼容写法
  "module": "./dist/my-lib.es.js",
  // ts 定义文件,exports 的兼容写法
  "types": "./dist/my-lib.d.ts"
}

公共基础路径

vite 允许我们添加base配置项来管理资源的公共路径,由 JS 引入的资源 URL,CSS 中的 url() 引用以及 .html 文件中引用的资源在构建过程中都会自动调整,以适配此选项。

正常情况下在引入资源不是很多的情况下都不需要做其余的配置。但是当我们需要在 JS 中引入大量诸如图片等资源时,通过import一个个引入图片就显得过于繁琐了,所以往往都是通过引入url路径的形式直接引入的,比如下面这样:

// vite 通过 import 引入
import img1 from 'xxx1'
import img2 from 'xxx2'
import img3 from 'xxx3'
const config = {
    img1,
    img2,
    img3,
}

// 通过把图片放到 public 目录中直接引入 https://cn.vitejs.dev/guide/assets.html#the-public-directory
const config = {
    img1: 'xx1',
    img2: 'xx2',
    img3: 'xx3',
}

但由于只能够通过import的形式引入时vite才会自动适配base选项,所以我们还需要在代码中对添加的base进行动态的适配。值得庆幸的是,vite 为我们提供了在运行时获取base字段的能力,所以我们只需要封装一下就行了:

const isHttp = (url: string) => /^https?:/.test(url)

function addBase(url: string) {
    // import.meta.env.BASE_URL 是 vite 提供的注入变量
    return isHttp(url) ? url : `/${`${import.meta.env.BASE_URL}/${url}`.split('/').filter(Boolean).join('/')}`
}

const config = {
    img1: addBase('xx1'),
    img2: addBase('xx2'),
    img3: addBase('xx3'),
}

但是这还不是终极方案,如果条件允许,其实最合适的方案是使用URL api,其实 vite 本身也推荐通过URLapi 来获取资源文件,使用这种方案也不需要将资源文件放入public目录中,但是需要有几点注意:

  • 需要将所有资源文件放入单独的目录中。
  • 需要提前写好对应放有资源文件的路径模板字符串(需要进行路径的预解析)。
  • import.meta.url解析的路径位置。
// import.meta.url 是当前文件名,所以 import.meta.url 的获取建议直接放到 src 下的一级目录中
export function addBase(path: string): string {
  return new URL(`./assets/${path}`, import.meta.url).href
}

使用:

const config = {
    // 这里只需要给出文件名,不要写完整路径
    img1: addBase('xx1.jpg'),
    img2: addBase('xx2.jpg'),
    img3: addBase('xx3.jpg'),
}

支持 runtimePublicPath 功能

什么是 runtimePublicPath

简单来说,publicPath(也就是上面的base配置)功能可以帮助我们根据自己的选择进行资源部署,配置了此选项后代码中的所有的资源引用都会被分发到该路径下(比如配置 CDN),而 runtimePublicPath 则是让我们可以在运行时根据不同的状态而修改给用户展示的资源。 image.png

如果你之前深入用过webpack,或许知道webpack自身为我们提供了变量__webpack_public_path__来实现运行时获取publicPath(也就是 vite 的base)的能力,也就是说通过在程序入口赋值为诸如window.publicPath的变量,就可以动态地在运行时改变publicPath了。

而就目前而言,vite官方并没有为我们提供类似的方法。但好在vite同时兼容rollup的所有接口,所以我们还可以在插件层面上使用打包拦截 + 变量替换的方法实现该功能。

实现 runtimePublicPath 插件

下面的代码参考自 vite-plugin-dynamic-publicpath

vite 中有两个部分都需要有 runtimePublicPath 的功能,一个是资源预加载时的路径,一个是资源import时的路径。

我们可以使用 rollup 提供了两个 Api:renderDynamicImportgenerateBundlerenderDynamicImport用于拦截import语句,添加动态引入的路径变量,generateBundle则用于生成新的资源映射关系并替换掉 vite 原有的预加载路径,生成新的预加载路径变量。

import path from 'path'
import { parse as parseImports, ImportSpecifier } from 'es-module-lexer'
import { normalizePath, Plugin } from 'vite'

interface Options {
  /**
   * @default: window.__dynamicImportHandler__
   */
  // 动态引入的变量
  dynamicImportHandler?: string
  /**
   * @default: window.__dynamicImportPreload__
   */
  // 动态预加载的变量
  dynamicImportPreload?: string
  /**
   * @description 该值和打包后的生成文件夹对应,请同步修改
   * @default assets
   */
  assetsBase?: string
}

export function viteDynamicPublicPathPlugin(options?: Options): Plugin {
  const defaultOptions: Options = {
    dynamicImportHandler: 'window.__dynamicImportHandler__',
    dynamicImportPreload: 'window.__dynamicImportPreload__',
    assetsBase: 'assets',
  }

  // eslint-disable-next-line no-param-reassign
  options = { ...defaultOptions, ...options }

  const { dynamicImportHandler, dynamicImportPreload, assetsBase } = options

  return {
    name: 'vite-dynamic-public-path-plugin',
    enforce: 'post',
    apply: 'build',
    // 拦截 import
    renderDynamicImport({ format }) {
      // 看 es 就行了
      if (format === 'es') {
        // 在 import 时添加动态变量
        return {
          left: `import("__PUBLIC_PATH_MARKER__" + (${dynamicImportHandler} || function(importer) { return importer; })(`,
          right: ') + "__PUBLIC_PATH_MARKER__" )',
        }
      } else if (format === 'system') {
        return {
          left: `module.import((${dynamicImportHandler} || function(importer) { return importer; })(`,
          right: '))',
        }
      }
      return null
    },
    // 生成打包后的代码
    generateBundle({ format }, bundle) {
      if (format !== 'es') {
        return
      }
      // vite 中预加载的标记,这个是 vite 内部的,固定值
      const preloadMarker = '__VITE_PRELOAD__'
      const preloadMarkerRE = new RegExp(`"${preloadMarker}"`, 'g')
      // eslint-disable-next-line guard-for-in
      for (const file in bundle) {
        const chunk = bundle[file]
        if (chunk.type === 'chunk' && chunk.code.indexOf(preloadMarker) > -1) {
          const code = chunk.code.replace(/"__PUBLIC_PATH_MARKER__"/g, '""')
          let imports: ImportSpecifier[]
          try {
            // 拿到解析所有 imports,过滤拿到所有动态导入的 import
            imports = parseImports(code)[0].filter((i) => i.d > -1)
          } catch (e: any) {
            this.error(e, e.idx)
          }
          if (imports?.length) {
            // 所有动态导入
            for (let index = 0; index < imports.length; index++) {
              const { s: start, e: end } = imports[index]
              // 路径
              const url = code.slice(start, end)
              // 加上资源目录前缀
              const normalizedFile = path.posix.join(
                path.posix.dirname(chunk.fileName),
                url.slice(1, -1)
              )
              const importerResult = url.match(/\(['"](.+)['"]\)/)
              if (Array.isArray(importerResult) && importerResult.length > 1) {
                // 与当前某个 bundle 对应的 assetKey 值,因为我们实际上只是改变了一下映射,资源内容还是一样的
                const assetKey = normalizePath(
                  path.join(`${assetsBase}`, importerResult[1])
                )
                // 多生成一份相对应 bundle,否则生成文件时找不到文件映射关系会少生成文件
                // eslint-disable-next-line no-param-reassign
                bundle[normalizedFile] = bundle[assetKey]
              }
            }
          }

          chunk.code = code
            // 替换 vite 中预加载的静态标记,改为我们的动态函数值
            .replace(
              preloadMarkerRE,
              `(${dynamicImportPreload} || function(importer) { return importer; })((${preloadMarker}))`
            )
        }
      }
    },
  }
}

export default viteDynamicPublicPathPlugin

然后,只需要我们在项目入口中加入下面几行代码就可以实现 runtimePublicPath 功能了:

// main.ts
// Your dynamic cdn,可以在应用开始时手动设置
window.publicPath = 'cdn.xxx.com'
const runtimePublicPath = window.resourceBaseUrl || window.publicPath || ''
// import 路径
window.__dynamicImportHandler__ = function(importer) {
    return dynamicCdn + importer;
}
// 预加载路径
window.__dynamicImportPreload__ = function(preloads) {
    return preloads.map(preload => dynamicCdn + preload);
}

资源文件的处理

在上面我们实际只能解决js文件的引入问题,但通过import引入的相关图片等资源文件却还是无法正常获取,因为 vite 中图片等资源文件的引入是通过在解析完成后返回 url 路径来拿的,像下面这样:

// import img from './xxx.jpg' 时返回如下
export default '/xxx.jpg'

我们在之前实际只解决了import img from './xxx.jpg'的路径问题,但最终资源的 url 还是错的(应该是和window.publicPath绑定),这时候就需要我们对项目中的资源文件拦截后再做单独处理了。当然,这同样需要使用 vite 提供的插件功能:

// 下面是对 svg 格式资源的处理
import { Plugin } from 'vite'

const svgRegex = /\.svg$/

function svgPublicPathPlugin(): Plugin {
  return {
    name: 'vite-svg-plugin-path-plugin',
    enforce: 'pre',
    apply: 'build',
    transform(code, id) {
      // 拿到文件源码,再进行字符串替换
      if (svgRegex.test(id)) {
        // 引入 svg 的 url
        const url = code.match(/".*"/gi)?.[0] || ''
        return `const importer = ${url}
// 手动添加前缀
const prefix = window.publicPath || '/'
const url = prefix + importer.slice(1)
export default url`
      }
    },
  }
}

export default svgPublicPathPlugin

这样,我们就能正常引入资源文件了。

至于 CSS 中的资源文件,由于无法处理 js 中的变量,我对其只是简单地进行了资源内联,通过postcss-url插件资源全部变为 base64 编码打入资源包中,或许也可以考虑使用 CSS 变量来解决,但由于我之前并没有在 CSS 中引入过多资源,所以这里就没有往这条路走了,有兴趣的同学可以试一试。

下面是postcss.config.js文件的配置:

module.exports = {
  plugins: {
    'postcss-url': { filter: 'node_modules/**/*', url: 'inline' },
  },
}

下面是整体的处理思路: image.png

添加持续集成服务

这小节的内容可以直接参考这里

持续集成服务可以帮我们方便地进行项目的部署等操作,相比于手动打包来说还是非常节省时间的,我这里使用Github Actions部署Github Pages来简单做一个演示(当然工作中也可以自行选择 ci、cd 工具链)。

Github Actions服务相比其他 ci、cd 工具来说简单很多,我们在项目中创建.github/workflows/deploy.yaml文件,写入下面的代码:

name: Build and Deploy
# 监听 main 分支的推送,我这边是把 master 分支修改为了 main 分支
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
jobs:
  # job 名
  build-and-deploy:
    # 运行环境
    runs-on: ubuntu-latest
    # 运行步骤
    steps:
      # 获取源码
      - name: Checkout
        uses: actions/checkout@v2.3.1
      # 下载依赖
      - name: Install dependencies and Build
        run: yarn && yarn build
      # 发布
      - name: Deploy
        uses: JamesIves/github-pages-deploy-action@4.1.4
        with:
          # 发布在 gh-pages 分支,会自动创建
          branch: gh-pages
          # 将打包后的 dist 目录放到 gh-pages 分支
          folder: dist

具体的Github Actions的配置见文档

除此之外,由于我们要在 github 上部署项目,所以要修改一下vitebase配置用于路由匹配:

import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
  // 部署的前缀,这里的匹配方式是 username.github.io/repository 的形式
  base: 'https://col0ring.github.io/vite-react-start-template/',
  // ...
})

然后就可以把项目推送到github了,github会找到我们的配置文件,然后开启 ci、cd 流程。

流程跑完后,进行下面的选择,就可以访问到我们部署的 github pages 了: image.png

总结

本文从基本的 vite 打包开始,到介绍打包中遇到的一些坑和具体的实践解决方式,最后又简单介绍了一下目前比较通用的自动化部署服务。总的来说 vite 确实给了我们极致的开发体验,但在生产环境中或许还是有着一些小缺陷,期待后续官方能够提供更加优雅的方式来解决它们。

参考