跳到主要内容
版本:Next

前端多进程组件编译原理

编译

编译支持多种模式编译,支持单组件编译以及多组件编译,单组件编译又分为单次编译一个组件和单次编译多个组件两种模式

使用方法如下:

// 单次
npm run build Test
// 单次多个
npm run build Test,Http,xxx
// 打包多个组件
npm run build

实现原理如下:

并发构建 + 队列调度机制设计

为了更高效地打包多个组件,引入了“任务队列 + 并发进程池”机制,替代静态任务分组方式。该机制具备更优的 CPU 利用率与构建稳定性。

设计目标

目标描述
最大化 CPU 利用率按 CPU 核心动态并发,构建始终饱和运行,无资源浪费
动态任务调度每个组件作为独立任务,由调度器分发给空闲子进程,避免任务不均
更小任务粒度每个组件独立构建,调度更灵活,排查更精准

实现核心逻辑

  1. 初始化任务队列

    • 将所有 widgetName 推入队列
    • 支持 debug 模式或参数筛选组件名
  2. 创建并发工作池

    • 读取 os.availableParallelism() 作为并发线程数
    • 每个工作线程从队列中取出组件名,执行打包命令
  3. 进程事件监听

    • 子进程打包完成后,触发下一个队列任务
    • 若任务失败,报错退出
  4. 构建完成回调

    • 当所有任务完成后,进行版本打 tag、压缩产物(如 wwwroot.zip)

技术要点

  • 使用 spawn 启动子进程,注入 BUILD_WIDGET_NAME 环境变量
  • 支持平台兼容:Windows 下用 npm.cmd
  • 任务分发逻辑用纯 JS 实现,避免依赖队列中间件
  • ora 实时展示进度,用 chalk 美化终端输出

示例流程图

TaskQueue[WidgetA, WidgetB, WidgetC, ...]


┌──────────────┐
│ Process #1 │◄────┐
└──────────────┘ │
┌──────────────┐ │
│ Process #2 │◄────┘ ← 动态分发
└──────────────┘
...

构建脚本功能说明(buildWidgets.js)

该脚本用于批量构建 src/widgets 目录下的组件,支持单组件、多组件并行打包,并生成压缩包 wwwroot.zip,用于部署使用。


基本配置

  • baseDir:缓存目录 .cache/wwwroot
  • baseBuildFile:打包组件分组配置文件
  • hostZipPath:最终生成的压缩包路径

打包流程

  1. 初始化环境,清理旧的 .cachewwwroot.zip
  2. 解析命令行参数,支持逗号分隔的多个组件名
  3. 识别 widgets 目录中的组件名
  4. 根据 CPU 并发数将组件等分,按组并行打包
  5. 每个打包进程执行 npm run build-lib
  6. 所有组件打包完成后,clone assets 仓库到缓存目录
  7. 将打包产物写入 wwwroot 结构下,并生成 zip 包

附加功能

  • 支持 debug 模式替换资源路径
  • 使用 orachalk 美化控制台输出
  • 自动生成版本 tag 并清理临时文件 以下是源码实现
import tag from './tag.js'
import { removeSync, ensureFileSync } from 'fs-extra/esm'
import { spawn } from 'node:child_process'
import { globSync } from 'glob'
import path from 'path'
import fs from 'fs-extra/esm'
import { writeFileSync } from 'fs'
import slash from 'slash'
import crossSpawn from 'cross-spawn'
import { zip } from './ZipAFolder.js'
import os from 'os'
import chalk from 'chalk'
import cloneDeep from 'lodash/cloneDeep.js'
import ora from 'ora'

const assetsUrl =
'https://gitlab.syc-cms.com:8443/lmes-plugin/web/assets.git'
const baseDir = './node_modules/.cache/wwwroot'
const baseBuildFile = './node_modules/.cache/widgets.json'
const hostPath = slash(path.resolve(process.cwd(), baseDir))
const hostZipPath = slash(path.resolve(process.cwd(), './wwwroot.zip'))
let isDebugMode = false
// 编译进程总数量
let buildSumCount = 0
let startTime = Date.now()
let widgetNames = []
// 剩余未编译结束数据
let remainWidgetNames = []
let isMultipleBuild = false
let spinner = ora('编译中,请稍后...\n')

removeHostPackage()
buildWidgets()

/**
* 编译组件
*/
function buildWidgets() {
const isWin = process.platform === 'win32'
const argv = process.argv || []
// 当debug模式下,将本地206环境下的图片地址映射到代码中,可以将.env环境进行设置VITE_STATIC_URL
if (argv.includes('debug')) {
isDebugMode = true
argv.splice(argv.indexOf('debug'), 1)
}
// 支持传入多个组件名称进行打包,npm run build QualityManagement,ProcessManagement
const widgetName = argv[argv.length - 1]

const widgetsPath = globSync(`./src/widgets/*/index.ts`)
widgetNames = widgetsPath.map((file) => {
const parts = isWin
? path.resolve(file).split('\\')
: path.resolve(file).split('/')
return parts[parts.length - 2]
})
const originWidgets = [...widgetNames]
let remainWidgetNames = [...widgetNames]
if (widgetName.includes(',')) {
isMultipleBuild = true
widgetName.split(',').forEach((name) => {
if (!widgetNames.includes(name)) {
console.error(chalk.red(`组件${name}不存在`))
process.exit(1)
}
})
widgetNames = widgetName.split(',')
remainWidgetNames = cloneDeep(widgetNames)
} else {
if (!widgetNames.includes(widgetName)) {
isMultipleBuild = true
} else {
isMultipleBuild = false
}
}

try {
fs.removeSync('./dist')
} catch (error) {
console.error('dist不存在,继续执行打包任务')
}
buildSumCount = widgetNames.length

function buildWidget(currentBuildWidgets) {
const isWidgetArray = Array.isArray(currentBuildWidgets)
// 打包一个组件
if (!isWidgetArray || (widgetName && originWidgets.includes(widgetName))) {
runBuild(null, !isWidgetArray ? currentBuildWidgets : widgetName)
} else {
// 打包多组件,按CPU默认并行度打包
const buildWidgets = divideArray(currentBuildWidgets)
const slashPath = slash(path.resolve(process.cwd(), baseBuildFile))
ensureFileSync(slashPath)
writeFileSync(slashPath, JSON.stringify(buildWidgets, null, 2))
for (let index = 0; index < Object.keys(buildWidgets).length; index++) {
const widgets = buildWidgets[index]
if (widgets.length) {
runBuild(index)
}
}
}
}
async function build(buildWidgetName) {
if (!buildWidgetName) {
const cpus = os.availableParallelism()
const currentBuildWidgets = widgetNames.splice(0, cpus)
buildWidget(currentBuildWidgets)
} else {
buildWidget(buildWidgetName)
}
}
function start() {
spinner.start()
build()
}
start()

/**
* 运行编译
* @param {*} nodeIndex 设置打包组件起点
*/
function runBuild(nodeIndex, widgetName) {
// return new Promise((resolve,reject) => {

const cmdParams = ['run', 'build-lib']
cmdParams.push(isDebugMode ? 'development' : 'production')
const run = spawn(
process.platform === 'win32' ? 'npm.cmd' : 'npm',
cmdParams,
{
// stdio: 'inherit',
shell: true,
env: {
// 编译组件索引映射
...process.env,
NODE_INDEX: nodeIndex,
BUILD_WIDGET_NAME: widgetName,
},
}
)

run.on('close', async (code) => {
// 添加打包hash
remainWidgetNames.shift()
if (
(!widgetNames.length && !remainWidgetNames.length) ||
!isMultipleBuild
) {
spinner.stop()
await tag()
if (isMultipleBuild) {
console.log(
chalk.green(`\n已经编译完所有组件,正在添加版本tag,请稍后...\n`)
)
console.log(chalk.green(`\n开始打包wwwroot.zip包\n`))
getHostPackage()
} else {
console.log(
chalk.green(`\n编译总时间: ${(Date.now() - startTime) / 1000}秒\n`)
)
}
} else {
const name = widgetNames.shift()
if (name && widgetNames.length) {
spinner.text = `正在编译组件: ${chalk.green(
name
)},剩余组件数量:${chalk.green(widgetNames.length)}\n`
}

if (name && isMultipleBuild) {
build(name)
}
}
})
// })
}
}
/**
* 获取等分的组件数据
* @param {*} widgets
* @param {*} cpus
* @returns
*/
function divideArray(widgets) {
// 当打包时,操作电脑可能会卡
const cpus = os.availableParallelism()
let result = {}
let dataPerKey = Math.floor(widgets.length / cpus)
let remainingData = widgets.length
for (let i = 0; i < cpus; i++) {
let currentDataCount = Math.min(dataPerKey, remainingData)
result[i] = widgets.splice(0, currentDataCount)
remainingData -= currentDataCount
}
if (widgets.length) {
widgets.forEach((widgetName, index) => {
result[index].push(widgetName)
})
}
return result
}
/**
* 获取host包 zip包
* @param {*}
*/
function getHostPackage() {
const resourcesPath = slash(
path.resolve(process.cwd(), `${baseDir}/resources`)
)

const widgetsPath = slash(path.resolve(process.cwd(), `${baseDir}/widgets`))
const currentDistPath = slash(path.resolve(process.cwd(), './dist'))
const isResources = fs.pathExistsSync(resourcesPath)
const isWidgets = fs.pathExistsSync(widgetsPath)
if (!isResources) {
fs.mkdirpSync(resourcesPath)
}
if (!isWidgets) {
fs.mkdirpSync(widgetsPath)
}

const git = crossSpawn.sync('git', ['clone', assetsUrl, '-b', 'develop'], {
stdio: 'inherit',
cwd: resourcesPath,
shell: true,
})

if (git.status === 0) {
fs.removeSync(slash(path.resolve(resourcesPath, './assets/.git')), {
recursive: true,
})
const dirs = globSync(slash(path.resolve(currentDistPath, './**/*.js')))
dirs.forEach((dir) => {
const widgetName = slash(dir).replace(currentDistPath, '.')
const widgetPath = slash(path.resolve(widgetsPath, widgetName))

fs.copySync(slash(dir), widgetPath)
})
zipDir(hostPath, hostZipPath)
.then(() => {
console.log(chalk.green(`${hostZipPath} 压缩成功`))
fs.removeSync(hostPath, {
recursive: true,
})
console.log(
chalk.green(`\n编译总时间: ${(Date.now() - startTime) / 1000}秒\n`)
)
})
.catch((error) => {
console.log(error)
})
}
}
/**
* 压缩zip包
* @param {*} dir
* @param {*} zipPath
* @returns
*/
function zipDir(dir, zipPath) {
return new Promise(async (resolve, reject) => {
try {
await zip(slash(dir), slash(zipPath))
resolve()
} catch (error) {
reject(error)
}
})
}
/**
* 删除host包
* @param {*}
*/
function removeHostPackage() {
if (fs.pathExistsSync(hostPath)) {
fs.removeSync(hostPath, {
recursive: true,
})
}
if (fs.pathExistsSync(hostZipPath)) {
fs.removeSync(hostZipPath, {
recursive: true,
})
}
}

打包效果如下:

alt text

alt text

alt text