Frontend Development 33 min read

How to Transform Build‑Scripts into a Flexible, Plugin‑Based Architecture for Frontend Projects

This article walks through the step‑by‑step evolution of a unified build‑scripts scaffold—from simple start/build/test commands to a modular, plugin‑driven system that supports shared configurations, user overrides, webpack‑chain, and multi‑task builds across multiple frontend projects.

Taobao Frontend Technology
Taobao Frontend Technology
Taobao Frontend Technology
How to Transform Build‑Scripts into a Flexible, Plugin‑Based Architecture for Frontend Projects

一、写在前面

在 ICE、Rax 等项目研发中,我们或多或少都会接触到 build‑scripts 的使用。build‑scripts 是集团共建的统一构建脚手架解决方案,其除了提供基础的 start、build 和 test 命令外,还支持灵活的插件机制供开发者扩展构建配置。

本文尝试通过场景演进的方式,来由简至繁地讲解一下 build‑scripts 的架构演进过程,注意下文描述的演进过程意在讲清 build‑scripts 的设计原理及相关方法的作用,并不代表 build‑scripts 实际设计时的演进过程,如果文中存在理解错误的地方,还望指正。

二、架构演进

0. 构建场景

我们先来构建这样一个业务场景:假设我们团队内有一个前端项目 project‑a,项目使用 webpack 来进行构建打包。

项目 project‑a

<code>project-a
  |- /dist
  |   |- main.js
  |- /src
  |   |- say.js
  |   |- index.js
  |- /scripts
  |   |- build.js
  |- package.json
  |- package-lock.json
</code>

project‑a/src/say.js

<code>const sayFun = () => {
  console.log('hello world!');
};
module.exports = sayFun;
</code>

project‑a/src/index.js

<code>const say = require('./say');

say();
</code>

project‑a/scripts/build.js

<code>const path = require('path');
const webpack = require('webpack');
// 定义 webpack 配置
const config = {
  entry: './src/index',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, '../dist'),
  },
};
// 实例化 webpack
const compiler = webpack(config);
// 执行 webpack 编译
compiler.run((err, stats) => {
  compiler.close(() => {});
});
</code>

过段时间由于业务需求,我们新建了一个前端项目 project‑b。由于项目类型相同,项目 project‑b 想要复用项目 project‑a 的 webpack 构建配置,此时应该怎么办呢?

1. 拷贝配置

为了项目快速上线,我们可以先直接从项目 project‑a 拷贝一份 webpack 构建配置到项目 project‑b,再配置一下 package.json 中的 build 命令,项目 project‑b 即可“完美复用”。

项目 project‑b

<code>project-b
  |- /dist
  |   |- main.js
  |- /src
  |   |- say.js
  |   |- index.js
  |- /scripts
  |   |- build.js
  |- package.json
  |- package-lock.json
</code>

拷贝只能临时解决问题,并不是长期方案。如果构建配置需要在多个项目间复用,我们可以考虑将其封装为一个 npm 包来独立维护。

2. 封装 npm 包

我们新建一个 npm 包 build‑scripts ,提供统一的构建入口。

<code>build-scripts
  |- /bin
  |   |- build-scripts.js
  |- /lib (ts 构建目录,文件同 src)
  |- /src
  |   |- /commands
  |   |   |- build.ts
  |   |- tsconfig.json
  |   |- package.json
  |   |- package-lock.json
</code>

build‑scripts/bin/build‑scripts.js

<code>#!/usr/bin/env node
const program = require('commander');
const build = require('../lib/commands/build');
(async () => {
  // build 命令注册
  program.command('build').description('build project').action(build);
  // 判断是否有运行的命令,如果有则退出已执行命令
  const proc = program.runningCommand;
  if (proc) {
    proc.on('close', process.exit.bind(process));
    proc.on('error', () => { process.exit(1); });
  }
  // 命令行参数解析
  program.parse(process.argv);
  // 如果无子命令,展示 help 信息
  const subCmd = program.args[0];
  if (!subCmd) {
    program.help();
  }
})();
</code>

build‑scripts/src/commands/build.ts

<code>const path = require('path');
const webpack = require('webpack');
module.exports = async () => {
  const rootDir = process.cwd();
  // 定义 webpack 配置
  const config = {
    entry: path.resolve(rootDir, './src/index'),
    module: {
      rules: [
        {
          test: /\.ts?$/,
          use: 'ts-loader',
          exclude: /node_modules/,
        },
      ],
    },
    resolve: { extensions: ['.ts', '.js'] },
    output: { filename: 'main.js', path: path.resolve(rootDir, './dist') },
  };
  // 实例化 webpack
  const compiler = webpack(config);
  // 执行 webpack 编译
  compiler.run((err, stats) => {
    compiler.close(() => {});
  });
};
</code>

项目 project‑a 和 project‑b 只需把 build 脚本改为调用

build‑scripts build

,并在

devDependencies

中加入

build‑scripts

,即可共享统一的构建逻辑。

3. 添加用户配置

为满足不同项目的自定义需求,我们在项目根目录新增

build.json

,用于覆盖默认的 entry 与 outputDir。

project‑c/build.json

<code>{
  "entry": "./src/index1",
  "outputDir": "./build"
}
</code>

在 build‑scripts/src/commands/build.ts 中读取并合并用户配置:

<code>let userConfig = {};
try {
  userConfig = require(path.resolve(rootDir, './build.json'));
} catch (error) {
  console.log('Config error: build.json is not exist.');
  return;
}
if (!userConfig.entry || typeof userConfig.entry !== 'string') {
  console.log('Config error: userConfig.entry is not valid.');
  return;
}
if (!userConfig.outputDir || typeof userConfig.outputDir !== 'string') {
  console.log('Config error: userConfig.outputDir is not valid.');
  return;
}
const config = {
  entry: path.resolve(rootDir, userConfig.entry),
  ...
  output: { filename: 'main.js', path: path.resolve(rootDir, userConfig.outputDir) },
};
</code>

此方式仍存在配置校验分散、缺少默认值兜底等问题。

4. 添加插件机制

为解决更复杂的自定义需求,引入插件机制。用户在

build.json

中声明插件列表,插件在执行时接收完整的 webpack 配置并自行修改。

project‑c/build.json

<code>{
  "entry": "./src/index1",
  "outputDir": "./build",
  "plugins": ["build-plugin-xml"]
}
</code>

build‑scripts/core/ConfigManager.ts (关键片段)

<code>private async runPlugins() {
  for (const plugin of this.userConfig.plugins) {
    const pluginPath = require.resolve(plugin, { paths: [process.cwd()] });
    const pluginFn = require(pluginPath);
    await pluginFn({
      setConfig: this.setConfig,
      registerUserConfig: this.registerUserConfig,
      onGetWebpackConfig: this.onGetWebpackConfig,
    });
  }
}
</code>

build‑plugin‑xml/index.js

<code>module.exports = async (webpackConfig) => {
  if (!webpackConfig.module) webpackConfig.module = {};
  if (!webpackConfig.module.rules) webpackConfig.module.rules = [];
  webpackConfig.module.rules.push({
    test: /\.xml$/i,
    use: require.resolve('xml-loader'),
  });
};
</code>

5. 引入 webpack‑chain

使用

webpack‑chain

将配置改写为链式 API,简化插件内部的对象操作。

build‑scripts/src/configs/build.ts

<code>const Config = require('webpack-chain');
const path = require('path');
const rootDir = process.cwd();
const buildConfig = new Config();
buildConfig.entry('index').add('./src/index');
buildConfig.module
  .rule('ts')
  .test(/\.ts?$/)
  .use('ts-loader')
  .loader(require.resolve('ts-loader'));
buildConfig.resolve.extensions.add('.ts').add('.js');
buildConfig.output.filename('main.js');
buildConfig.output.path(path.resolve(rootDir, './dist'));
module.exports = buildConfig;
</code>

插件也改为链式写法:

<code>module.exports = async (webpackConfig) => {
  webpackConfig.module
    .rule('xml')
    .before('ts')
    .test(/\.xml$/i)
    .use('xml-loader')
    .loader(require.resolve('xml-loader'));
};
</code>

6. 插件化默认构建配置

将默认构建配置抽离为插件

build‑plugin‑base

,通过

registerTask

注册任务。

build‑plugin‑base/index.js

<code>const Config = require('webpack-chain');
const path = require('path');
const rootDir = process.cwd();
module.exports = async ({ registerTask, registerUserConfig }) => {
  const buildConfig = new Config();
  buildConfig.entry('index').add('./src/index');
  buildConfig.module
    .rule('ts')
    .test(/\.ts?$/)
    .use('ts-loader')
    .loader(require.resolve('ts-loader'));
  buildConfig.resolve.extensions.add('.ts').add('.js');
  buildConfig.output.filename('main.js');
  buildConfig.output.path(path.resolve(rootDir, './dist'));
  registerTask('base', buildConfig);
  registerUserConfig([
    {
      name: 'entry',
      validation: async (value) => typeof value === 'string',
      configWebpack: async (defaultConfig, value) => {
        defaultConfig.entry('index').clear().add(path.resolve(rootDir, value));
      },
    },
    {
      name: 'outputDir',
      validation: async (value) => typeof value === 'string',
      configWebpack: async (defaultConfig, value) => {
        defaultConfig.output.path(path.resolve(rootDir, value));
      },
    },
  ]);
};
</code>

对应插件

build‑plugin‑xml

通过任务名称绑定:

<code>module.exports = async ({ onGetWebpackConfig }) => {
  onGetWebpackConfig('base', (webpackConfig) => {
    webpackConfig.module
      .rule('xml')
      .before('ts')
      .test(/\.xml$/i)
      .use('xml-loader')
      .loader(require.resolve('xml-loader'));
  });
};
</code>

7. 添加多任务机制

为支持单项目多构建产物,引入任务列表。

ConfigManager

维护

configArr

,每个任务拥有独立的

WebpackChain

实例和修改函数。

关键 API:

<code>public registerTask(name, chainConfig) {
  if (this.configArr.find(v => v.name === name)) {
    throw new Error(`[Error] config '${name}' already exists!`);
  }
  this.configArr.push({ name, chainConfig, modifyFunctions: [] });
}
public onGetWebpackConfig(name, fn) {
  const task = this.configArr.find(v => v.name === name);
  if (!task) throw new Error(`[Error] config '${name}' does not exist!`);
  task.modifyFunctions.push(fn);
}
</code>

在 build‑scripts/src/commands/build.ts 中执行所有任务:

<code>const compiler = webpack(
  manager.configArr.map(task => task.chainConfig.toConfig())
);
compiler.run((err, stats) => {
  compiler.close(() => {});
});
</code>

三、写在最后

通过上述场景演进,build‑scripts 展示了一个具备灵活插件机制、支持用户配置、可通过 webpack‑chain 简化配置、并能执行多任务的前端构建解决方案。该思路同样适用于任何需要跨项目共享与扩展配置的场景。

注:示例代码可在仓库 build‑scripts‑demo 查看,完整源码请参考 build‑scripts 仓库。

参考资料

[1] 官方文档: https://github.com/neutrinojs/webpack-chain

[2] build‑scripts‑demo: https://github.com/CavsZhouyou/build-scripts-demo

[3] build‑scripts: https://github.com/ice-lab/build-scripts

configuration managementPlugin ArchitectureWebpackbuild-scriptsfrontend tooling
Taobao Frontend Technology
Written by

Taobao Frontend Technology

The frontend landscape is constantly evolving, with rapid innovations across familiar languages. Like us, your understanding of the frontend is continually refreshed. Join us on Taobao, a vibrant, all‑encompassing platform, to uncover limitless potential.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.