经过痛苦的一周,终于将 vue-cli 升级到 rsbuild了

1 阅读7分钟

技术是不断向前发展的,当年的小甜甜 vue-cli 也成了牛夫人,看着生产构建时间一点点从 4 分钟增长到了 10 几分钟,开发启动时间从 10s 到平均 2 分多钟,产品还在不断的提新功能,增加新的路由、功能,尽管已经抽象了一层基础组件 + 业务组件,但是多个产品线之间由于老是互相借鉴 UI,不同产品线上的 UI 于是也有了共享的需求, 导致存在大量的冗余业务组件,在前面的文章中笔者构思了一种代码库组织办法(# 代码共享方案-多仓库合并单仓库),经过拆分之后,满足了共享不同产品中业务代码需求,但是构建时间,以及开发启动时间依然像一根刺扎在我的心中,再加上客户反馈,在跳板机性能较差时会有接近 20s 的白频时间,于是我打算针对新的架构来一次彻底的改造。

目标

改造伊始,定下以下几个小目标:

  • 提升开发环境启动速度,加快生产环境构建速度
  • 加快 node_modules 依赖安装速度
  • 兼容 webpack 插件
  • 加快首页访问速度
  • vue 2.6 升级到 vue 2.7

实现路径

移除 vue-cli, 采用 rsbuild

image.png

最大的变量是构建工具的差别,关于前端构建工具的考察,我试用了 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。

加快首屏访问速度

  1. 动态加载
  2. 化整为零,利用浏览器并行加载大模块
  3. 控制预加载减少并发数量

改造实战开始

  • 移除 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

image.png

总结

升级的过程是痛苦的,需要随时盯着各种报错,要解决它们,需要的是耐心加细心。

ps:亲测下来,vue2.6 升级 2.7最后一个版本还是有一定风险的,并没有官方说的那么丝滑,动不动就会找不到 this,十分的头痛,并且编译时没有任何报错,在没有一定的测试资源的情况下,慎重升级。