技术是不断向前发展的,当年的小甜甜 vue-cli 也成了牛夫人,看着生产构建时间一点点从 4 分钟增长到了 10 几分钟,开发启动时间从 10s 到平均 2 分多钟,产品还在不断的提新功能,增加新的路由、功能,尽管已经抽象了一层基础组件 + 业务组件,但是多个产品线之间由于老是互相借鉴 UI,不同产品线上的 UI 于是也有了共享的需求, 导致存在大量的冗余业务组件,在前面的文章中笔者构思了一种代码库组织办法(# 代码共享方案-多仓库合并单仓库),经过拆分之后,满足了共享不同产品中业务代码需求,但是构建时间,以及开发启动时间依然像一根刺扎在我的心中,再加上客户反馈,在跳板机性能较差时会有接近 20s 的白频时间,于是我打算针对新的架构来一次彻底的改造。
目标
改造伊始,定下以下几个小目标:
- 提升开发环境启动速度,加快生产环境构建速度
- 加快 node_modules 依赖安装速度
- 兼容 webpack 插件
- 加快首页访问速度
- vue 2.6 升级到 vue 2.7
实现路径
移除 vue-cli, 采用 rsbuild
最大的变量是构建工具的差别,关于前端构建工具的考察,我试用了 vite、rsbuild,跟着官方文档升级成 vite 之后,发现很多报错很诡异,解决起来费时费力(vite 与 rsbuild 的对比),为了更好的兼容现有的 webpack 第三方插件以及自定义插件,我选择了rsbuid 1.x(这里特别说明一下,rsbuild2.x 不兼容 webpack 配置)
升级 node,卸载 node-sass, 改用 dart-sass
众所周知,最快的方式是通过 pnpm 下载依赖,我们新开的工程也都采用 pnpm,但是这个老坑试了多次,没有成功,根本原因是 node 版本过低, 另外由于项目中使用的是node-sass,dddd,这个库已经不维护了,日常install 贼慢,安装依赖大部分时间都是卡在 node-sass 安装编译过程中,所以本次主要就是针对以上痛点进行改造,首先升级 node, 其次卸载 node-sass, 改用 dart-sass。
加快首屏访问速度
- 动态加载
- 化整为零,利用浏览器并行加载大模块
- 控制预加载减少并发数量
改造实战开始
- 移除 vue-cli 相关依赖
- "@vue/cli-plugin-babel": "~4.2.0",
- "@vue/cli-plugin-e2e-nightwatch": "~4.2.0",
- "@vue/cli-plugin-eslint": "~4.2.0",
- "@vue/cli-plugin-pwa": "~4.2.0",
- "@vue/cli-plugin-router": "~4.2.0",
- "@vue/cli-plugin-typescript": "~4.2.0",
- "@vue/cli-plugin-unit-mocha": "~4.2.0",
- "@vue/cli-plugin-vuex": "~4.2.0",
- "@vue/cli-service": "~4.2.0",
- "vue-cli-plugin-axios": "~0.0.4",
- "vue-cli-plugin-element": "^1.0.1"
- 安装 rsbuild 框架依赖
+ "@rsbuild/core": "^1.5.11",
+ "@rsbuild/plugin-babel": "^1.0.6",
+ "@rsbuild/plugin-node-polyfill": "^1.4.2",
+ "@rsbuild/plugin-sass": "^1.4.0",
+ "@rsbuild/plugin-vue2": "^1.0.4",
+ "@rsbuild/plugin-vue2-jsx": "^1.0.4",
+ "@rsdoctor/rspack-plugin": "^1.3.9",
- 升级 vue2.6 到 vue2.7 - "vue": "^2.6.11", + "vue": "^2.7.0", - "vue-router": "^3.1.5", + "vue-router": "^3.6.0"
- 升级 dart-sass
- "node-sass": "^4.13.1",
+ "sass": "^1.93.1",
- 替换 index.html
<%= BASE_URL %>替换成 <%= assetPrefix %>
<!DOCTYPE html>
<html lang="en">
<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">
<link rel="icon" href="<%= assetPrefix %>/favicon.ico" />
</head>
<body>
<noscript>
<!-- <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> -->
</noscript>
<!-- built files will be auto injected -->
</body>
</html>
- 修改package.json scripts
{
"script": {
"serve": "rsbuild serve --open",
"build": "rsbuild build",
"test": "rsbuild test:unit --require tests/unit/setup.js --reporter mochawesome",
"test:unit": "rsbuild test:unit --watch --require tests/unit/setup.js",
"test:e2e": "rsbuild test:e2e",
"lint": "rsbuild lint --fix",
"start": "rsbuild serve --mode development --open",
}
}
vue-cli-service 统统替换成 rsbuild
- 启动项目
nvm use 21
pnpm serve
此时,项目是起不来滴,会报一堆错误。
- 新增配置文件
/rsbuild.config.ts
import { defineConfig, RsbuildConfig, loadEnv, rspack, RsbuildPlugin } from '@rsbuild/core'
import { pluginVue2 } from '@rsbuild/plugin-vue2'
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill'
import { pluginSass } from '@rsbuild/plugin-sass'
import { globSync } from 'glob'
import { pluginBabel } from '@rsbuild/plugin-babel'
import { pluginVue2Jsx } from '@rsbuild/plugin-vue2-jsx'
import CompressionPlugin from 'compression-webpack-plugin'
import skeletonRsbuildPlugin from './skeleton-rsbuild-plugin'
import postcssPxtorem from 'postcss-pxtorem'
import { merge } from 'lodash'
import path from 'path'
import os from 'os'
import tag from './vue.tag'
import util from './vue.util'
import { createRequire } from 'module';
import virtualPlugin from './rsbuild.virtuals'
const require = createRequire(import.meta.url);
/** 更新变量 */
export function modifyProcessEnv (): RsbuildPlugin {
return {
name: 'modifiy-process-env',
setup (api) {
api.modifyRsbuildConfig(config => {
// 是否运行在子仓库下
const isRunningSubmodule = path.basename(__dirname) === path.basename(process.cwd())
// 动态设置项目名称
process.env.VUE_APP_NAME = require(config.root + '/package.json').name
process.env.VUE_APP_IS_SUBMODULE = String(isRunningSubmodule)
// 动态设置 mock 开关
process.env.VUE_APP_MOCK = String(process.argv.toString().includes('mock'))
// 当前安装包打包信息
process.env.VUE_APP_TAG = JSON.stringify(tag)
// 计算当前 infra 到项目根目录的相对位置
process.env.VUE_APP_RELATED_TO_ROOT = util.relateToRoot(process.cwd(), __dirname)
const { publicVars, rawPublicVars } = loadEnv({ prefixes: ['VUE_APP_'] })
// 混入变量到前端
config.source = merge(config.source, {
define: {
...publicVars,
// 兼容原有的 process.env.xxx 用法
'process.env': JSON.stringify(rawPublicVars)
}
})
})
}
}
}
const rsbuildConfig: RsbuildConfig = {
html: {
template: './public/index.html',
mountId: 'app',
tags: [
...skeletonRsbuildPlugin()
]
},
resolve: {
alias: {
'&': path.join(__dirname, 'src')
},
// 修复 minor 版本级别重复依赖
dedupe: [
'elliptic', 'sha.js', 'tslib', 'parse-asn1', 'string_decoder', 'pbkdf2',
'ripemd160', 'hash-base', 'safe-buffer', 'browserify-rsa', 'isarray'
]
},
plugins: [
pluginBabel({
include: /\.(?:jsx|tsx)$/,
exclude: [/[\\/]node_modules[\\/]/]
}),
pluginVue2(),
pluginVue2Jsx(),
pluginNodePolyfill(),
pluginSass({
sassLoaderOptions (config) {
const alias: any = rsbuildConfig.resolve?.alias || {}
const paths = []
for (const key in alias) {
const urls = new util.MakeScssSource([alias[key] as string]).make()
paths.push(urls.map((url: string) => globSync(url.replace(/\\/g, '/')).map(p => p.replace(alias[key], key).replace(/\\/g, '/'))).flat())
}
// 忽略@important, 除法警告
config.sassOptions!.silenceDeprecations = [
'import',
'slash-div'
]
// 全局注入资源
const additionalData = paths.flat().map(url => {
return `@import '${url}';`
})
// dart scss 方法全局引入
const preUse = [
'@use "sass:math";',
'@use "sass:list";',
'@use "sass:color";',
'@use "sass:map";'
]
config.additionalData = preUse.concat(additionalData).join(os.EOL)
return config
}
}),
virtualPlugin(),
modifyProcessEnv()
],
dev: {
assetPrefix: '/',
watchFiles: [
{
type: 'reload-server',
paths: ['src/**/rsbuilld.config.ts']
}
]
},
server: {
proxy: [
{
context (pathname) {
return /^.*\/api\/v2\//.test(pathname)
},
target: process.env.VUE_APP_HOST || 'http://172.24.12.227',
changeOrigin: true
}
],
port: 8081
},
source: {
// 指定入口文件
entry: {
index: './src/main.ts'
},
decorators: {
version: 'legacy'
}
},
output: {
assetPrefix: 'auto',
sourceMap: false
},
tools: {
// webpack 兼容
rspack (config, { env }) {
const productionGzipExtensions = ['js', 'css']
if (env === 'production') {
config.plugins.push(
new CompressionPlugin({
algorithm: 'gzip',
test: new RegExp(`\\.(${productionGzipExtensions.join('|')})$`),
threshold: 10240,
minRatio: 0.8
})
)
}
config.plugins.push(
new rspack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/
})
)
},
bundlerChain: config => {
// 加载 txt 纯文本
config.module
.rule('raw')
.test(/\.txt$/)
.use('raw-loader')
.loader('raw-loader')
.end()
},
postcss: (opts, { addPlugins }) => {
addPlugins(
postcssPxtorem({
rootValue: 10,
propList: ['*'], // 可以将 px 转换为 rem 的属性
selectorBlackList: [
/^html$/, // 如果是 regexp,它将检查选择器是否匹配 regexp,这里表示 html 标签不会被转换
'.px-', // 如果是字符串,它将检查选择器是否包含字符串,这里表示 .px- 开头的都不会转换
'el-time-'
], // px 不会被转换为 rem 的 选择器
minPixelValue: 2 // 设置要替换的最小像素值(2px会被转rem)。 默认 0
})
)
}
},
performance: {
removeConsole: true,
// 大 chunk 分包加载,控制每个包体积小于244kb,方便 http 请求在一个周期内获取所有资源
chunkSplit: {
strategy: 'split-by-experience',
forceSplitting: {
libs: /[\\/]node_modules[\\/](moment|axios|lodash|element-ui|json5)[\\/]/,
xlsx: /[\\/]node_modules[\\/](xlsx)[\\/]/
},
},
},
}
export default defineConfig(rsbuildConfig)
上面用到的首屏骨架屏,实现如下
/skeleton.tpl.html
<!DOCTYPE html>
<html lang="en">
<head>
<style>
.skeleton {
display: flex;
padding: 0;
width: 100%;
height: 100vh;
justify-content: center;
align-items: center;
margin-top: -100px;
gap: 15px;
}
.skeleton-item {
width: 20px;
height: 100px;
list-style: none;
background-color: #4668db;
}
.skeleton-item:nth-child(odd) {
height: 100px;
animation: skeleton-odd 1s ease infinite
}
.skeleton-item:nth-child(even) {
height: 50px;
animation: skeleton-even 1s ease infinite
}
@keyframes skeleton-odd {
0%,
100% {
height: 100px;
}
50% {
height: 50px;
margin-bottom: 10px;
opacity: 0.5;
}
}
@keyframes skeleton-even {
0%,
100% {
height: 50px;
opacity: 0.5;
}
50% {
height: 100px;
opacity: 1;
margin-bottom: -10px;
}
}
</style>
</head>
<body>
<ul class="skeleton">
<li class="skeleton-item"></li>
<li class="skeleton-item"></li>
<li class="skeleton-item"></li>
<li class="skeleton-item"></li>
<li class="skeleton-item"></li>
</ul>
</body>
</html>
/skeleton-rsbuild-plugin.ts
import { HtmlTagDescriptor } from "@rsbuild/core";
import path from "path";
import fs from "fs";
import { minify } from 'html-minifier'
import * as cheerio from 'cheerio'
const skeletonRsbuildPlugin = (): HtmlTagDescriptor[] => {
const url = path.resolve(__dirname, './skeleton.tpl.html')
const tpl = fs.readFileSync(url, 'utf8');
const minified = minify(tpl, {
collapseWhitespace: true,
minifyCSS: true
})
const $t = cheerio.load(minified)
return [
{
tag: "div",
attrs: {
id: "app",
},
children: $t("body").html()?.trim(),
},
{
tag: "style",
attrs: {
type: "text/css",
},
children: $t("style").html()?.trim(),
},
];
};
export default skeletonRsbuildPlugin;
由于在导出scss文件变量为js使用的时候,scss 变量值 hash 化了,导致颜色值无法正常使用,此处使用虚拟文件动态生成,类似这个样子
$success-color: #39A63E;
:export {
success-color: $success-color;
}
// 期望
{
'success-color': '#39A63Ef'
}
// 实际得到的是
// js 中
{
'success-color': 'frontend-home-bg-xddasdf'
}
// 编译后的 cs
.frontend-home-bg-xddasdf {
color: frontend-home-bg-xddasdf
}
所以生成虚拟文件代替 scss export,同时我引入了动态生成 ts, 方便在构建以后生成 ts 类型提示
/rsbuild.virtuals.ts
/**
* 虚拟文件
*/
import path from 'path'
import fs from 'fs'
import os from 'os'
import * as sass from 'sass'
import { rspack, RsbuildPlugin } from '@rsbuild/core'
import swc from '@swc/core'
const virtualPlugin = (): RsbuildPlugin => ({
name: 'example',
setup(api) {
api.modifyRspackConfig((config) => {
const virtuals = createVirtuals()
outputDtsFromVirtuals(virtuals)
config.plugins.push(new rspack.experiments.VirtualModulesPlugin(virtuals))
})
}
})
function createVirtuals () {
const cssContent = sass.compileString(
[
'src/assets/sass/variable.scss',
'src/assets/sass/variable.export.scss'
].map(p => fs.readFileSync(path.join(__dirname, p))).join(os.EOL)
).css.match(/:export\s*{([\s\S]*?)}/)?.[1] || ''
const jsVariables = cssContent
.split(os.EOL)
.map(line => line.trim())
.filter(line => line)
.map(line => {
const [key, value] = line.split(':').map(item => item.trim().replace(';', ''));
return ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`;
})
.join(',' + os.EOL);
/** 虚拟动态文件 */
const virtuals = {
/** 根据平台加载对应路由文件 */
'&/utils/virtual-router.ts': [
`import { VueRouter, RouteConfig } from 'vue-router/types/router'`,
process.env.VUE_APP_NAME === 'front-infra'
? "import router from '&/router/index.ts'"
: `import router from '&/../${process.env.VUE_APP_RELATED_TO_ROOT}/src/router/index.ts'`,
'export const layoutRouters = (): RouteConfig[] => router.options.routes[0].children',
'export const routers: VueRouter = router',
].join(os.EOL),
/** 修复 rsbuild 平台下 sass :export 加载 hash 化 */
'&/assets/sass/virtual-variable.scss.ts': `
const vars = {${os.EOL}${jsVariables}${os.EOL} };${os.EOL}
export default vars
`
}
return virtuals
}
/** 将虚拟文件的 ts 类型写入 virtual.d.ts */
async function outputDtsFromVirtuals (virtuals: Record<string, string>) {
const mergedContent: Promise<string>[] = Object.keys(virtuals).map(async (filename) => {
const result = await generateDtsFromCode(virtuals[filename])
const types = JSON.parse((result as any).output).__swc_isolated_declarations__.replaceAll('declare', '')
const declared = `declare module "${filename.replace(/.ts$/, '')}" {${os.EOL}${types}}`
return declared
})
const contents = await Promise.all(mergedContent)
const outputDtsPath = path.join(__dirname, 'src/virtual.d.ts')
const outputDir = path.dirname(outputDtsPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputDtsPath, contents.join(os.EOL), 'utf8');
}
/** 根据字符串生成 ts */
async function generateDtsFromCode(code: string) {
try {
// 编译代码并生成 d.ts
const result = await swc.transform(code, {
jsc: {
parser: {
// 默认解析 TypeScript,若输入是纯 JS 可改为 'ecmascript'
syntax: 'typescript',
// 支持装饰器(可选,根据你的代码调整)
decorators: false,
},
target: 'es2016',
experimental: {
emitIsolatedDts: true
}
},
// 禁用源码映射(生成 d.ts 时无需)
sourceMaps: false,
// 不生成输出文件,仅返回内存中的结果
outputPath: undefined
});
// 返回生成的 d.ts 内容(result.output 是数组,第一个元素是 d.ts 内容)
return result;
} catch (error) {
console.error('生成 d.ts 失败:', error);
throw error;
}
}
export default virtualPlugin
/vue.tag.js
方便在生产环境下,在控制台查看当前版本
import path from 'path'
import { readFileSync } from 'fs';
import GitRevisionPlugin from 'git-revision-webpack-plugin';
import os from 'os'
import child_process from 'child_process'
const gitRevisionPlugin = new GitRevisionPlugin({ branch: true })
const tsconfig = readFileSync(path.join('.', `/tsconfig.json`), { encoding: 'utf8' })
const paths = JSON.parse(tsconfig).compilerOptions.paths
const workspaces = Object.values(paths)
.flat().map(p => path.join(process.cwd(), p.replace('src/*', '')))
const wukongHash = child_process.execSync(`cd $(git rev-parse --show-superproject-working-tree) && git rev-parse HEAD`)
const versionMap = workspaces.reduce((acc, p) => {
const project = path.basename(p)
const output = child_process.execSync(`cd ${p} && git rev-parse HEAD`)
acc[project] = output.toString().trim()
return acc
}, {
wukong: wukongHash.toString().trim()
})
const meta = {
hash: versionMap,
branch: gitRevisionPlugin.branch(),
buildTime: new Date().toLocaleString(),
buildHost: os.hostname()
}
export default meta
/vue.util.js
import SpritesmithPlugin from 'webpack-spritesmith'
import { join, relative } from 'path'
// 精灵图配置
const createSprite = (resolve, { spritesheetName = 'basic-ico', root = '&' } = {}) => new SpritesmithPlugin({
src: {
cwd: resolve('src/assets/sprites/ico'),
glob: '*.png'
},
target: {
image: resolve('src/assets/sprites/sprite.png'),
css: [
[
resolve('src/assets/sprites/index.scss'),
{
format: 'handlebars_based_template',
// 命名空间
spritesheetName
}
]
]
},
customTemplates: {
handlebars_based_template: resolve('src/assets/sprites/scss.template.handlebars')
},
apiOptions: {
cssImageRef: `~${root}/assets/sprites/sprite.png`
}
})
class MakeScssSource {
varScss = [
'assets/sass/variable.scss'
]
commonScss = [
'assets/sass/mixin/**/*.scss'
]
coverScss = [
'assets/sass/common.scss',
'assets/sprites/index.scss'
]
allScss = [this.varScss, this.commonScss, this.coverScss]
constructor (paths = []) {
// 优先顺序:公共文件 < 可变文件 < 项目文件
this.paths = paths.sort((a, b) => a.length - b.length).reverse()
}
make () {
const paths = this.paths
const loop = (files, result = []) => {
files.forEach(file => {
paths.forEach((path) => {
const cssFile = join(path, '/', file)
result.push(cssFile)
})
})
return result
}
const res = this.allScss.map((arr) => loop(arr))
return res.flat()
}
}
// 计算 infra 目录到项目根目录的层级 e.g. ../../../..
function relateToRoot (root = '', current = '') {
return relative(current, root).replace(/\\/g, '/')
}
export default {
relateToRoot,
createSprite,
MakeScssSource,
addSassResource: (config) => {
const root = process.cwd()
const oneOfsMap = config.module.rule('scss').oneOfs.store
// 根据 alias 找到对应层级的文件
const aliasStore = config.resolve.alias.store
const vue$ = 'vue$'
const paths = Array.from(aliasStore.keys())
.filter(v => v !== vue$)
.map(alias => {
return aliasStore.get(alias).replace(root + '\\', '')
})
const resources = new MakeScssSource(paths).make()
oneOfsMap.forEach(item => {
item
.use('sass-resources-loader')
.loader('sass-resources-loader')
.options({
// 添加公共ui 代码
resources
})
.end()
})
}
}
接下来就舒服了,全局替换 /deep/ -> ::v-deep,(注意 deep 后面加个空格) 跟着启动报错,修复 sass 报错就可以了,nth -> list.nth,mix -> color.mix...
启动项目
yarn start
解决 vscode vue office 插件报错
随着版本升级以后,vscode 中 vue tempalte 里面会有大量的爆红,虽然不影响编译,但是很难看,在这里可以选择 2.x 版本的 vue official
总结
升级的过程是痛苦的,需要随时盯着各种报错,要解决它们,需要的是耐心加细心。
ps:亲测下来,vue2.6 升级 2.7最后一个版本还是有一定风险的,并没有官方说的那么丝滑,动不动就会找不到 this,十分的头痛,并且编译时没有任何报错,在没有一定的测试资源的情况下,慎重升级。