前言
在前面我们讲了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 本身也推荐通过URL
api 来获取资源文件,使用这种方案也不需要将资源文件放入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 则是让我们可以在运行时根据不同的状态而修改给用户展示的资源。
如果你之前深入用过webpack
,或许知道webpack
自身为我们提供了变量__webpack_public_path__
来实现运行时获取publicPath
(也就是 vite 的base
)的能力,也就是说通过在程序入口赋值为诸如window.publicPath
的变量,就可以动态地在运行时改变publicPath
了。
而就目前而言,vite
官方并没有为我们提供类似的方法。但好在vite
同时兼容rollup
的所有接口,所以我们还可以在插件层面上使用打包拦截 + 变量替换的方法实现该功能。
实现 runtimePublicPath 插件
下面的代码参考自 vite-plugin-dynamic-publicpath
vite 中有两个部分都需要有 runtimePublicPath 的功能,一个是资源预加载时的路径,一个是资源import
时的路径。
我们可以使用 rollup 提供了两个 Api:renderDynamicImport
和generateBundle
。renderDynamicImport
用于拦截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' },
},
}
下面是整体的处理思路:
添加持续集成服务
这小节的内容可以直接参考这里
持续集成服务可以帮我们方便地进行项目的部署等操作,相比于手动打包来说还是非常节省时间的,我这里使用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 上部署项目,所以要修改一下vite
的base
配置用于路由匹配:
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 了:
总结
本文从基本的 vite 打包开始,到介绍打包中遇到的一些坑和具体的实践解决方式,最后又简单介绍了一下目前比较通用的自动化部署服务。总的来说 vite 确实给了我们极致的开发体验,但在生产环境中或许还是有着一些小缺陷,期待后续官方能够提供更加优雅的方式来解决它们。