普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月2日首页

整理「祖传」代码,就是在开发脚手架?

作者 codingWhat
2026年3月2日 16:26

前言

  • 每次起新项目,都要复制一整套「祖传」配置、改包名、删示例,稍不留神就漏掉埋点或兼容文件?
  • 团队里 Vue 和 React 混用,项目结构五花八门,新人上手全靠口口相传?
  • 你和我说这就是在开发脚手架?No,No,No,你这是在扒拉项目结构。

脚手架不是框架,而是用命令行把「创建项目、统一规范」自动化的工具。我们天天在用的 npmvue createcreate-react-app,背后都是同一套思路:用 Node.js 写一个 CLI,把最佳实践固化成一条命令。


一、脚手架是什么?一条命令里藏着四样东西

回想一下你敲的每一条脚手架命令,无非四部分:

vue create vue-test-app --force -r https://registry.npmmirror.com
部分 示例 说明
主命令 vue 对应一个可执行文件
子命令 create 具体做什么事
参数 vue-test-app 子命令的输入
选项 --force-r <url> 开关或带值的配置

所以脚手架就是一个「主命令 + 子命令 + 参数 + 选项」的命令行客户端

执行时发生了什么? 终端先根据主命令在 PATH 里找到可执行文件(例如全局的 vue.js),再用 Node 执行它(因为文件头有 #!/usr/bin/env node),脚本里解析子命令和选项后执行对应的逻辑,结束退出。
一句话:脚手架本质是操作系统的客户端,只不过这个「客户端」是一段用 Node 跑的 JS,通过命令行和你交互罢了。


二、为什么值得花费成本自己做一套?

vue-cli、create-react-app 解决的是「从零搭一个标准项目」。但日常团队中会沉淀出一堆自家的东西,比如H5 兼容、接口封装、埋点、公共组件、登录/权限等,甚至整块业务都会被复用。每次起项目都从零复制,既费时又容易出错。

依我看,自己做项目创建脚手架,最起码能带来三件收益:模板沉淀(把「我们团队该怎么起项目」固化成可选模板)、标准化(类型、名称、框架通过交互选择,减少人为差异)、可复用(新人一条命令就和团队站在同一起跑线)。


三、原理:三个问题搞懂脚手架执行的过程

回答三个问题,原理就通了:

  1. 为什么装的是 @vue/cli,敲的却是 vue
    package.json 里有个 bin 字段,例如 "bin": { "vue": "bin/vue.js" }。全局安装时,npm 会在可执行路径下创建一个叫 vue软链接,指向这个 js 文件,所以命令名可以和包名不一样。

  2. 全局安装时到底干了啥?
    把包下到全局 node_modules,再按 bin 配置在系统 PATH 能搜到的地方建好软链接,这样你在任意目录敲 vue 都能找到对应脚本。

  3. 为什么一个 .js 文件能直接当命令执行?
    因为第一行写了 shebang#!/usr/bin/env node。系统看到 #! 就知道要用后面的解释器来跑这个文件,于是用当前环境的 node 去执行。用 env node 而不是写死 /usr/bin/node,换机器、换环境也能用。


四、给我一首歌的时间,从 0 跑通一个最小 的CLI

四步:建项目、写入口、配 bin、本地 link。跑通后你就有了一条「真」命令,再往上加 init、install 只是扩展。

1. 初始化项目

mkdir my-cli && cd my-cli
npm init -y

2. 写入口并加上 shebang

创建 bin/cli.js。第一行 #!/usr/bin/env nodeshebang(希棒):以 #! 开头,告诉系统「用谁」来执行这个文件。这里用当前环境的 node,所以终端里直接敲 my-cli 就会用 Node 跑这段脚本,不用再写 node bin/cli.js

#!/usr/bin/env node

import { program } from 'commander';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(
  readFileSync(join(__dirname, '../package.json'), 'utf-8')
);

program
  .name('my-cli')
  .description('最小 CLI 示例')
  .version(pkg.version);

program
  .command('hello [name]')
  .description('打个招呼')
  .action((name) => {
    console.log('Hello,', name || 'World');
  });

program.parse();

3. 配置 package.json

补上 bintype: "module"(推荐用 ESM,大势所趋):

{
  "name": "my-cli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "my-cli": "bin/cli.js"
  },
  "dependencies": {
    "commander": "^11.0.0"
  }
}

4. 本地调试

npm install
npm link

在任意目录执行 my-cli --versionmy-cli hello 张三,能输出版本、能打招呼,就说明最小 CLI 已经跑通。后面要支持 init、install 等多条命令,无非是把脚手架拆成多包、抽象出命令基类,在入口里按「一条命令一个子类」挂上去,是不是很简单?


五、来看一个真实项目

拿我们组脚手架为例,采用 commander框架,实现了组内自定义Vue/React模板框架的生成功能。

目录

your-cli/
├── package.json
├── packages/
│   ├── cli/              # 入口、createCLI、注册命令
│   ├── command/          # 命令基类
│   ├── utils/            # 日志、inquirer、npm、Git
│   ├── init/             # 命令 init:模板 → 下载 → 安装
│   └── install/          # 命令 install:搜索 → 选 tag → clone → 装依赖 → 运行

Command 基类说明:项目中,子命令不直接调 Commander,而是继承基类,从而实现 commanddescriptionoptionsaction,基类在构造函数里统一完成了「注册命令 + 绑定 action」。这样新增命令 = 新子类 + 入口挂一行。核心逻辑如下:

// packages/command/lib/index.js(思路示例,已脱敏)
class Command {
  constructor(program) {
    if (!program) throw new Error('command instance must not be null!');
    this.program = program;
    const cmd = this.program.command(this.command);
    cmd.description(this.description);
    if (this.options?.length > 0) {
      this.options.forEach(opt => cmd.option(...opt));
    }
    cmd.action((...params) => this.action(...params));
  }

  get command() {
    throw new Error('command must be implemented');
  }
  get description() {
    throw new Error('description must be implemented');
  }
  get options() {
    return [];
  }
  async action() {
    throw new Error('action must be implemented');
  }
}
export default Command;

入口里只需:通过createCLI() 得到 program
createInitCommand(program)createInstallCommand(program)
最后 program.parse(process.argv)
通常createCLI() 里需要包含 name、version、--debug、Node 版本检查、未知命令提示等功能。


六、来看看init 命令干了啥

init 只做一件事:从模板创建项目。在项目里,InitCommand 的 action 拆成三步,对应三个文件:

步骤 做的事 对应模块
1 选择模板,生成安装信息 createTemplate.js
2 下载模板到缓存目录 downloadTemplate.js
3 拷贝到项目目录并渲染 installTemplate.js

第一步:createTemplate
入参是项目名 name 和命令行 opts--type--template--force)。
没传 type/template 就通过交互收集。
选定后用 getLatestVersion(template.npmName) 从 npm 拉最新版本,并返回 { type, name, template, targetPath },其中targetPath 即缓存目录(如 ~/.your-cli/addTemplate)。

第二步:downloadTemplate
在缓存目录下执行 npm install ${npmName}@${version},把模板包装进 node_modules。示例:

// 思路示例,已脱敏
import { execa } from 'execa';
import ora from 'ora';

async function downloadAddTemplate(targetPath, template) {
  const { npmName, version } = template;
  await execa('npm', ['install', `${npmName}@${version}`], { cwd: targetPath });
}

export default async function downloadTemplate(selectedTemplate) {
  const { targetPath, template } = selectedTemplate;
  ensureDirSync(targetPath);
  const spinner = ora('正在下载模板...').start();
  try {
    await downloadAddTemplate(targetPath, template);
    spinner.stop();
    log.success('下载模板成功');
  } catch (e) {
    spinner.stop();
    printErrorLog(e);
  }
}

第三步:installTemplate
目标目录是当前目录下的 name 文件夹;
从缓存的 node_modules/<npmName>/template 拷贝到目标目录,之后用 ejs 注入 name 后写回。
代码示例如下:

// 思路示例,已脱敏
import fse from 'fs-extra';
import { pathExistsSync } from 'path-exists';
import ejs from 'ejs';
import glob from 'glob';

export default async function installTemplate(selectedTemplate, opts) {
  const { force = false } = opts;
  const { targetPath, name, template } = selectedTemplate;
  const rootDir = process.cwd();
  const installDir = path.resolve(rootDir, name);

  if (pathExistsSync(installDir)) {
    if (!force) {
      log.error(`当前目录下已存在 ${installDir}`);
      return;
    }
    fse.removeSync(installDir);
  }
  fse.ensureDirSync(installDir);

  const originFile = path.resolve(targetPath, 'node_modules', template.npmName, 'template');
  const fileList = fse.readdirSync(originFile);
  fileList.forEach((file) => {
    fse.copySync(path.join(originFile, file), path.join(installDir, file));
  });

  const ejsData = { name, ...customData };
  glob('**', { cwd: installDir, nodir: true, ignore: template.ignore }, (err, files) => {
    files.forEach((file) => {
      const filePath = path.join(installDir, file);
      ejs.renderFile(filePath, ejsData, (err, result) => {
        if (!err) fse.writeFileSync(filePath, result);
      });
    });
  });
}

最终,InitCommand 的 action 就是三步串联:

// 思路示例,已脱敏
async action([name, opts]) {
  const selectedTemplate = await createTemplate(name, opts);
  await downloadTemplate(selectedTemplate);
  await installTemplate(selectedTemplate, opts);
}

ps:为何用 npm 管理模板? 不占服务器、自带版本、用 registry API 查 dist-tags.latest 即可拿到最新版的包。我们内部使用了自己部署的Verdaccio,也推荐给大家!
模板包约定:模板统一放在 template/下,支持多框架(React/Vue)。


七、React/Vue 模板来源有哪些?

「从模板创建项目」时,React/Vue 通常有两种来源,可以同时提供:

来源 命令 场景
npm 模板 init 团队标准化:把 React/Vue 模板打成 npm 包,init 时选模板一步到位
Git 仓库 install 选取目标仓库和 tag 后 clone、装依赖、可选运行

init 做「内部」创建,install 做「任意仓库」拉取,两条能力互补。


八、总结

  • 架构commander框架。
  • 项目模板:npm 托管 + registry API 查版本。
  • 交互:Inquirer进行选择与输入,validate 做必填;ora 做 loading,log做成功/失败统一提示日志 ,debug模式采用log.verbose等。
昨天以前首页

前端组件库开发实践:从零到发布

作者 codingWhat
2026年2月27日 23:53

为什么要搞自己的组件库?

先说说一些开发的名场面,看看你有没有中招:

  • 新开一个项目,复制粘贴上个项目的 components 文件夹,改改名字接着用;
  • 三个项目里同一套「表格 + 表单」各写各的,改一处要改三处;
  • 用现成 UI 库吧,按需加载配到怀疑人生,不用吧,自己从零写又太慢;
  • 产品说「这里要跟 XX 项目长得一样」,你发现那边早改版了,根本对不齐。

把共用的表格、表单、图表、布局、指令、工具函数收拢成一个库,统一维护、按需引用,多项目复用同一套体验和逻辑——这就是自研或二次封装组件库要解决的事。


一、先搭骨架:工程长什么样?

组件库不是 SPA,而是一个「被别的项目 import 的包」。举个实际项目为例:

my-ui-kit/
├── src/
│   ├── main.ts              # 库的唯一起点:样式、组件、指令、Composables 都从这里出去
│   ├── components/          # 按业务或功能分子目录Form、Table、Menu…
│   ├── directives/          # 自定义指令:复制、防抖、长按、拖拽等
│   ├── modules/             # 公共模块,比如 i18n、主题
│   ├── utils/               # 纯工具函数
│   └── vite-plugin.ts       # 可选:给使用方用的「按需解析」插件
├── playground/              # 本地调试用的示例项目,改完库立刻能看效果
├── dist/                    # 构建产物,不提交,发布时只发这个
├── locales/                 # 若有多语言,可随库一起发布
├── vite.config.ts
├── package.json
└── tsconfig.json

入口文件 main.ts 干三件事

  1. 样式:引入 Reset、UnoCSS/Tailwind 等,保证打出来的 dist/style.css 一份就够。
  2. 统一导出:组件、指令、Composables(如 useFormuseMenu)全部 export,方便 使用方 按需 import。
  3. 插件形态:导出一个带 install 的对象,使用方可以 app.use(MyUiKit) 一把梭全局注册。

入口示例:

// 样式最先
import '@unocss/reset/tailwind-compat.css'
import 'virtual:uno.css'

// 公共模块:i18n、指令安装函数等
import * as I18n from './modules/i18n'
export { I18n as I18nModule }
import { setupDirectives } from './directives'
export { setupDirectives }

// 组件:表格、表单、图表、菜单等
import Table from './components/Table/Table.vue'
import Form from './components/Form/Form.vue'
// ... 其余组件

// 指令
import XxxDirectiveCopy from './directives/modules/copy'
import XxxDirectiveDebounce from './directives/modules/debounce'
// ...

// 全局注册插件
export const globalPlugin = {
  install(app) {
    app.component('XxxTable', Table)
    app.component('XxxForm', Form)
    // ...
  }
}

// 具名导出:按需引用 + 友好 tree-shaking
export default globalPlugin
export { Table as XxxTable, Form as XxxForm, useForm, useMenu /* ... */ }
export { XxxDirectiveCopy, XxxDirectiveDebounce, /* ... */ }

这样使用方既可以「全量 + 全局注册」,也可以「只 import 用到的组件和 hooks」。


二、配 Vite 库模式:打出「可被 import 的包」

骨架有了,下一步是让 Vite 把项目打成库,而不是打成一个能跑的网页。

2.1 基础 lib 配置

vite.config.ts 里加上 build.lib

import { resolve } from 'path'
import pkg from './package.json'

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/main.ts'),
      name: pkg.name,   // UMD 时挂到 window 上的名字
      formats: ['es', 'umd'],
      fileName: (format) => `${pkg.name}.${format}.js`,
    },
  },
})
  • es:给 Vite/Webpack/Rollup 用,支持 tree-shaking;
  • umd:给 script 标签或老环境兜底。

一执行 vite builddist/ 里就会出现 my-ui-kit.es.jsmy-ui-kit.umd.js,别的项目就能 import 了。

2.2 依赖别打包进来:external 是亲兄弟

组件库会用到 Vue、Element Plus、ECharts、vue-router 等——这些必须由使用方项目提供,不能打进你的 bundle,否则会出现「两份 Vue」「包体积爆炸」等问题。

rollupOptions 里把它们 external 掉,并告诉 UMD:「这些模块对应的是哪个全局变量」:

rollupOptions: {
  external: [
    'vue',
    'element-plus',
    'vue-router',
    'echarts',
    'vue-echarts',
    '@vueuse/core',
    'vue-i18n',
    // 若库里会 import 自己的子路径(如 locales),也要写进来,避免被打进 bundle
    'my-ui-kit/locales/zh-cn.json',
    'my-ui-kit/locales/en.json',
  ],
  output: {
    globals: {
      vue: 'Vue',
      'element-plus': 'ElementPlus',
      'vue-router': 'VueRouter',
      echarts: 'echarts',
      'vue-echarts': 'VueEcharts',
      'vue-i18n': 'VueI18n',
    },
    exports: 'named',  // 具名导出,方便 tree-shaking
  },
}

这样打出来的库又小又干净,运行时和业务项目共用同一套依赖,快去试试吧!


三、配 package.json:告诉 npm「入口在哪、发布什么」

3.1 主入口与类型

{
  "name": "my-ui-kit",
  "version": "1.0.0",
  "private": false,
  "main": "./dist/my-ui-kit.es.js",
  "module": "./dist/my-ui-kit.es.js",
  "types": "./dist/index.d.ts",
  "type": "module"
}
  • main / module:分别给 require 和 import 用(若只发 ESM,可都指到 es 产物)。
  • types:TS 声明入口。

3.2 使用 exports 一把梭

exports 可以精细控制「主入口、样式、多语言、Vite 插件」等子路径:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/my-ui-kit.es.js",
      "require": "./dist/my-ui-kit.umd.js"
    },
    "./style": "./dist/style.css",
    "./dist/style.css": "./dist/style.css",
    "./locales/*": "./dist/locales/*",
    "./vite": {
      "types": "./dist/vite-plugin.d.ts",
      "import": "./dist/vite-plugin.js",
      "require": "./dist/vite-plugin.cjs"
    }
  }
}

这样使用方可以:

  • import X from 'my-ui-kit' → 主包;
  • import 'my-ui-kit/style' → 样式;
  • import zh from 'my-ui-kit/locales/zh-cn.json' → 多语言;
  • import { XxxComponentsResolver } from 'my-ui-kit/vite' → 按需解析插件(若你提供了)。

3.3 只发 dist,依赖交给使用方

{
  "files": ["dist"],
  "peerDependencies": {
    "vue": "^3.3.11",
    "element-plus": "^2.4.4"
  }
}
  • files:只把 dist 发上去,源码、playground、测试都不发。
  • peerDependencies:声明「我依赖这些,但请您自己装嘞」,避免重复安装、版本打架等问题。

四、TypeScript 类型:让用你库的人也有提示

源码是 .ts / .vue,构建出来是 .js,使用方在 TS 项目里要类型提示和类型检查,就得有一份 .d.tsvite-plugin-dts 会在 build 时根据源码自动生成声明文件:

import dts from 'vite-plugin-dts'

export default defineConfig({
  plugins: [
    dts({
      rollupTypes: true,  // 把零散的 .d.ts 滚成少量文件,发布更清爽
    }),
  ],
  build: { /* ... */ },
})

构建完 dist/ 里会有 index.d.ts 等,使用方装你的包就能自动获得类型补全。


五、样式与静态资源:别漏了 CSS 和 locales

  • 样式:在 main.ts 最上面引入 UnoCSS/Reset 等,构建后会生成 dist/style.css。在 package.jsonexports 里暴露 ./style,使用方 import 'my-ui-kit/style' 即可。
  • 多语言 / 静态资源:若库内用到了 locales/zh-cn.json 等,构建时要把它们拷到 dist,否则发布后引用会 404。用 rollup-plugin-copywriteBundle 阶段拷贝:
import copy from 'rollup-plugin-copy'

plugins: [
  copy({
    targets: [{ src: './locales', dest: 'dist/' }],
    hook: 'writeBundle',
  }),
]

记得在 exports 里加上 "./locales/*": "./dist/locales/*",使用方才能正确引用。


六、构建脚本与体积分析

构建和包信息都齐了,接下来把日常用的脚本配好,顺带加个体积分析,避免悄悄打进不该打的东西。

  • 先类型检查再构建:避免带着类型错误发布。例如 "build": "run-p type-check \"build-only\" --"build-only 里只跑 vite build
  • 体积分析:可以用 rollup-plugin-visualizer,构建时生成依赖占比图:
import { visualizer } from 'rollup-plugin-visualizer'

const isAnalysis = process.env.ANALYSIS === 'true'
plugins: [
  visualizer({ open: isAnalysis }),
]

脚本里加一条:"analysis": "cross-env ANALYSIS=true npm run build-only",需要时跑一下,可以心里有数。


七、本地联调:不发布也能在业务项目里用

在真正发 npm 之前,先用 link 在业务项目里跑一跑,确保装包、引用、样式、类型都没问题。

在组件库目录:

pnpm build
pnpm link --global

在业务项目目录:

pnpm link --global my-ui-kit

之后业务项目里的 import ... from 'my-ui-kit' 会直接指向你本地的 dist。改完库再执行一次 pnpm build,刷新页面就能看到效果——再也不用手动拷 dist 了,但是吧,这个地方还是会有缓存问题,没辙。


八、按需引入:既省心又省体积

全量 app.use(MyUiKit) 会把所有组件都打进 bundle;更推荐按需引用,让打包器帮你 tree-shake 掉没用的。

8.1 使用方自己按需 import

import { XxxTable, XxxForm, useForm } from 'my-ui-kit'
import 'my-ui-kit/style'

只要库是 ES Module + 具名导出,未用到的组件会被自然摇掉。

8.2 自动按需:unplugin-vue-components + 自定义 Resolver

如果希望使用方不用手写 import,直接在模板里写 <XxxTable /> 就自动从库里拉对应组件,可以给 unplugin-vue-components 提供一个自定义 Resolver。做法是:在库里导出一份「组件名 → 从哪个包、用什么名字引入」的规则。

例如在库的 src/vite-plugin.ts 里(包名、前缀已泛化):

import { ComponentResolver } from 'unplugin-vue-components/types'

// 可选:Composables 自动从库里引入,避免使用方到处写 import
export const XxxAutoImports = {
  'my-ui-kit': ['useForm', 'useMenu', 'useDrag']
}

export const XxxComponentsResolver = [
  {
    type: 'component',
    resolve: (componentName) => {
      if (componentName.startsWith('Xxx'))
        return { name: componentName, from: 'my-ui-kit' }
    },
  },
  {
    type: 'directive',
    resolve: (name) => {
      const map = {
        Copy: { importName: 'XxxDirectiveCopy' },
        Debounce: { importName: 'XxxDirectiveDebounce' },
        Draggable: { importName: 'XxxDirectiveDraggable' },
        WaterMarker: { importName: 'XxxDirectiveWaterMarker' },
        // ...
      }
      const item = map[name]
      if (!item) return
      return { name: item.importName, from: 'my-ui-kit' }
    },
  },
]

使用方在 vite.config.ts 里:

import Components from 'unplugin-vue-components/vite'
import { XxxComponentsResolver } from 'my-ui-kit/vite'

export default defineConfig({
  plugins: [
    Components({
      resolvers: [XxxComponentsResolver],
    }),
  ],
})

这样模板里用到的 Xxx* 组件和指令都会自动按需从 my-ui-kit 引入。注意:样式通常还是整份引入一次 my-ui-kit/style 即可。


九、发布到 npm

类型、构建、package.json、本地 link 都验证过了,就可以发版了:

  1. 版本号npm version patch(或改 package.json 里的 version)。
  2. 登录npm login(私有 registry 就按你们流程来)。
  3. 发布npm publish。若是 scoped 包且首次发,记得 npm publish --access public

发完,别人就能 pnpm add my-ui-kit ,然后愉快地使用啦。

总结:

把业务中积累的「components」 抽成公共包,从「复制粘贴」升级成「库模式构建 + 类型 + 规范发布 + 按需使用」,在多项目里复用同一套组件和指令就会稳很多。组件再也不是拖累,而是资源宝库!
后面还可以加上单元测试、文档站(如 VitePress)、Changelog 和语义化版本等等,一步步做成团队级的组件库产品。有坑一起踩,与掘友们共勉!

uniapp 多地区、多平台、多环境打包方案

作者 codingWhat
2026年2月27日 12:21

前言:本文基于真实的线上项目,介绍如何用一套代码支持多个地区多种平台(微信/支付宝小程序、H5 等)、多种部署环境(开发/测试/生产)的构建与打包,并给出具体代码与配置说明。

开始之前,大家先想象这样一个场景:同一套业务,要同时给 A 省、B 省、C 省上线微信小程序和支付宝小程序,每个省的标题、首页内容、支付方式、后端网关地址都不一样,还要区分 dev / qa / prod 多套环境。如果每一套都复制一份代码维护,成本会很快失控。那么我这篇文章讲的,就是怎样通过「一套代码 + 一组脚本」把这些组合全部收拢起来,统一维护。


一、需求与目标

维度 说明 示例
地区(DISTRICT) 不同省份/地区,不同应用标题、页面配置、manifest 新疆、安徽、江苏、吉林…
平台(UNI_PLATFORM) 不同运行端 微信小程序 mp-weixin、支付宝 mp-alipay、H5
环境(DEPLOY) 不同 API 与部署目标 dev、qa02、release_anhui、xinjiang_prod…

目标:通过一条 npm 脚本即可确定「地区 + 平台 + 环境」,打出对应产物,无需改代码。


二、整体思路

npm script (cross-env 注入环境变量)
    ↓
process.env.UNI_PLATFORM / DISTRICT / DEPLOY / NODE_ENV
    ↓
vue.config.js 加载时先执行 preBuild.js(类似于webpack自定义plugin的效果)
    ↓
preBuild.js 根据上述变量生成 CLIENT_TYPE、APP_TITLE、API_BASE_URL 等
    ↓
动态生成 pages.json、manifest.json(按地区合并)
    ↓
Webpack 通过 EnvironmentPlugin 将变量注入业务代码
    ↓
构建产物中 process.env.XXX 被替换为常量

核心有以下三点:

  1. 环境变量驱动:用 UNI_PLATFORMDISTRICTDEPLOY 等控制整条构建链。
  2. 构建前预处理:在 Webpack 之前跑 preBuild.js,统一把「平台/地区/环境」转成业务需要的 CLIENT_TYPEAPI_BASE_URLAPP_TITLE 等。
  3. 配置与代码注入:用 Webpack 官方 EnvironmentPluginprocess.env 中的键注入到前端代码,保证运行时代码能拿到当前构建的「地区/平台/环境」。

三、具体实现步骤与代码说明

3.1 用 cross-env 在 npm script 中传参

不同操作系统下设置环境变量方式不同,使用 cross-env 可统一写法。

安装:

npm i -D cross-env

package.json 中的脚本示例:

{
  "scripts": {
    "build:mp-weixin:anhui:release_anhui": "cross-env NODE_ENV=production UNI_PLATFORM=mp-weixin DISTRICT=anhui DEPLOY=release_anhui vue-cli-service uni-build",
    "dev:mp-weixin:anhui:release_anhui": "cross-env NODE_ENV=development UNI_PLATFORM=mp-weixin DISTRICT=anhui DEPLOY=dev_anhui vue-cli-service uni-build --watch"
  }
}

含义简述:

  • cross-env KEY=value 会在当前 Node 进程中设置 process.env.KEY = value
  • 后面执行的 vue-cli-service uni-build 与它加载的 vue.config.jspreBuild.js 都运行在同一进程,因此能直接读到这些环境变量。
  • 命名约定:dev/build + 平台 + 地区 + 环境,便于一眼看出打的是哪一套。

关于 --watch

  • 开发脚本(如 dev:mp-weixin:...)末尾加上 --watch,表示 开启监听模式:当代码或配置文件变更时,会自动重新编译对应平台/地区/环境的产物,无需每次手动重新执行命令,适合本地联调和反复修改。

常见变量:

变量 含义 示例
NODE_ENV 开发/生产 development / production
UNI_PLATFORM 平台 mp-weixin、mp-alipay、h5
DISTRICT 地区 anhui、xinjiang、jiangsu
DEPLOY 部署环境 dev、qa、release_anhui
CUSTOM_TAB 可选,自定义 tab 配置 如 pay、order_search

3.2 统一配置:preBuild.config.js

把「平台 → 客户端类型」「地区 → 应用标题」「部署环境 → API 地址」等做成映射表,便于维护和扩展。

config/preBuild.config.js:

// 平台与客户端类型(用于接口等)
const CLIENT_TYPE_MAP = {
  h5: 1,
  'mp-weixin':2,
  'mp-alipay':3,
  'mp-baidu': 4,
  'mp-toutiao': 5,
  'mp-qq': 6
}

// 地区与应用标题等展示配置
const DISTRICT_CONFIG_MAP = {
  jilin: { APP_TITLE: 'xxx' },
  xinjiang: { APP_TITLE: 'xxx' },
  // ... 其他地区
}

// 部署环境与 API 根地址(示例域名为占位)
const API_BASE_URL_MAP = {
  dev: 'https://api-dev.example.com/mobile-app/api',
  qa: 'https://api-qa02.example.com/mobile-app/api',
  release_regionA: 'https://api-release-regionA.example.com/mobile-app/api',
  // ... 其他环境
}

module.exports = {
  CLIENT_TYPE_MAP,
  DISTRICT_CONFIG_MAP,
  API_BASE_URL_MAP
}

后续新增地区或环境时,只需在此处增补配置,无需改构建脚本逻辑。


3.3 构建前预处理:preBuild.js

在 Webpack 读取 vue.config.js 之前,需要把「平台/地区/环境」转成业务和配置生成器使用的变量。因此把 preBuild.js 放在 vue.config.js 最前面执行。

build/preBuild.js:

/**
 * 设置自定义的 process.env.X 需同时在 vue.config.js 的
 * configureWebpack.plugins 里加入 EnvironmentPlugin 对应 key
 */
const { CLIENT_TYPE_MAP, DISTRICT_CONFIG_MAP, API_BASE_URL_MAP } = require('../config/preBuild.config')

// 版本号等固定值(示例)
process.env.VERSION = '1.0.0'

// 由「平台」得到客户端类型
process.env.CLIENT_TYPE = CLIENT_TYPE_MAP[process.env.UNI_PLATFORM]

// 由「地区」得到应用标题(缺省用 jilin)
process.env.APP_TITLE = DISTRICT_CONFIG_MAP[process.env.DISTRICT || 'xuzhou'].APP_TITLE

// 由「部署环境」得到 API 根地址(缺省用 dev)
process.env.API_BASE_URL = API_BASE_URL_MAP[process.env.DEPLOY || 'dev']

// 可选:自定义 tab 等,未传则空字符串
process.env.CUSTOM_TAB = process.env.CUSTOM_TAB || ''

// 便于排查:构建时打印当前维度
console.log('------------------------------')
console.log('NODE_ENV:', process.env.NODE_ENV)
console.log('DEPLOY:', process.env.DEPLOY)
console.log('UNI_PLATFORM:', process.env.UNI_PLATFORM)
console.log('DISTRICT:', process.env.DISTRICT)
console.log('CLIENT_TYPE:', process.env.CLIENT_TYPE)
console.log('API_BASE_URL:', process.env.API_BASE_URL)
console.log('------------------------------')

// 根据 DISTRICT 等动态生成 pages.json、manifest.json
require('./pages.json.js')
require('./manifest.json.js')

要点:

  • 只读不写:从 process.env 读取由 cross-env 注入的 UNI_PLATFORMDISTRICTDEPLOY
  • 派生变量:写入 CLIENT_TYPEAPP_TITLEAPI_BASE_URLCUSTOM_TAB 等,供后续 Webpack 和动态配置使用。
  • 执行顺序:本文件在 vue.config.js 的顶部被 require,因此先于 Webpack 配置执行;下面的 pages.json.jsmanifest.json.js 会用到当前的 process.env.DISTRICT 等。

3.4 在 vue.config.js 中接入 preBuild 与 EnvironmentPlugin

vue.config.js:

const webpack = require('webpack')
const path = require('path')

// 必须最先执行:注入 CLIENT_TYPE、API_BASE_URL 等,并生成 pages.json / manifest.json
require('./build/preBuild.js')

module.exports = {
  configureWebpack: {
    resolve: {
      alias: {
        // 按地区做目录别名,业务里 import 来自 @district 即当前地区配置
        '@district': path.join(__dirname, 'src/district', process.env.DISTRICT)
      }
    },
    plugins: [
      // 将 process.env 中列出的 key 在编译时注入到业务代码中
      // 业务代码中 process.env.UNI_PLATFORM 等会被替换为构建时的常量
      new webpack.EnvironmentPlugin([
        'UNI_PLATFORM',
        'CLIENT_TYPE',
        'VERSION',
        'DISTRICT',
        'API_BASE_URL',
        'CUSTOM_TAB'
      ])
    ]
  }
}

说明:

  • require('./build/preBuild.js'):保证在任何 webpack 配置使用 process.env.DISTRICTprocess.env.API_BASE_URL 等之前,这些变量就已经就绪。
  • @district 别名:指向 src/district/${DISTRICT},便于按地区维护配置(如 src/district/anhui/config.js),业务侧统一从 @district 引用。
  • EnvironmentPlugin:Webpack 内置插件,会把数组中列出的 process.env.XXX编译时替换为当前构建时的值,因此业务里写 process.env.UNI_PLATFORMprocess.env.DISTRICTprocess.env.API_BASE_URL 等即可,无需再传参。

3.5 按地区动态生成 pages.json(build/pages.json.js)

不同地区可能需要不同的页面列表、tabBar 等,因此把「基础配置」与「地区配置」合并后再写入 src/pages.json

build/pages.json.js:

const fs = require('fs')
const path = require('path')
const lodash = require('lodash')
const standardPages = require('../config/pages.js')

let districtPage = {}
let customPages = []
const district = process.env.DISTRICT

// 若存在该地区的 pages 配置则合并
try {
  districtPage = require(`../config/districts/${district}/pages.js`)
} catch (e) {
  console.log(`不存在【${district}】地区差异化 page.js,略过`)
}

// 若有 CUSTOM_TAB,可再合并 custom.xxx.pages.js
process.env.CUSTOM_TAB.split(',').forEach((type) => {
  // ... 按 type 加载 config/custom.${type}.pages.js 与 config/districts/${district}/ 下同名文件
})

const pageJSON = lodash.mergeWith({}, standardPages, districtPage, ...customPages, (objValue, srcValue, key) => {
  if (Array.isArray(objValue) && key === 'list') {
    return srcValue  // 如 tabBar.list 用地区配置覆盖,而不是数组合并
  }
})

fs.writeFileSync(path.join(__dirname, '../src/pages.json'), JSON.stringify(pageJSON, null, 2), { encoding: 'utf8' })
console.log('page.json 构建完成')

思路:对基础 pages + 地区 pages + 自定义 tab 配置 做 merge,对 list 类数组采用覆盖策略,避免 tab 等被意外合并。


3.6 按地区动态生成 manifest.json(build/manifest.json.js)

小程序与 H5 的 appid、描述等可能按地区不同,同样采用「标准 manifest + 地区 manifest」合并。

build/manifest.json.js:

const fs = require('fs')
const path = require('path')
const lodash = require('lodash')

const standardManifest = {
  name: 'app',
  appid: 'xxx',
  'mp-weixin': { /* ... */ },
  'mp-alipay': { /* ... */ }
  // ...
}

let districtManifest = {}
try {
  districtManifest = require(`../config/districts/${process.env.DISTRICT}/manifest.json.js`)
} catch (e) {
  console.log('该地区不存在差异化 manifest.json.js')
}

const merged = lodash.merge({}, standardManifest, districtManifest)
fs.writeFileSync(path.join(__dirname, '../src/manifest.json'), JSON.stringify(merged, null, 4), { encoding: 'utf8' })
console.log('manifest.json 生成完成')

地区目录示例:config/districts/anhui/manifest.json.jsconfig/districts/xinjiang/pages.js 等,按需添加。


3.7 业务代码中如何使用

构建时环境变量已被注入,业务中直接读 process.env 即可。

按平台分支:

// 例如仅微信小程序展示某模块
if (process.env.UNI_PLATFORM === 'mp-weixin') {
  // 微信逻辑
}

// 计算属性中
moreModuleGroups: vm => process.env.UNI_PLATFORM === 'mp-weixin'
  ? xxxx
  : xxxx

按地区使用配置:

// 使用别名 @district,实际指向 src/district/${DISTRICT}
import banner from '@district/banner.jpg'

// 或直接使用注入的常量
const district = process.env.DISTRICT
const apiBase = process.env.API_BASE_URL

请求 API:

// 封装请求时用 process.env.API_BASE_URL 作为 baseURL
axios.create({ baseURL: process.env.API_BASE_URL })

四、目录与脚本约定小结

角色 路径/命令 作用
环境变量注入 cross-env UNI_PLATFORM=... DISTRICT=... DEPLOY=... 在 npm script 中传入维度
映射配置 config/preBuild.config.js 平台/地区/环境 → 客户端类型、标题、API
构建前逻辑 build/preBuild.js 设置 CLIENT_TYPE、API_BASE_URL 等并生成 pages/manifest
Webpack 入口 vue.config.js require preBuild、配置 @district、EnvironmentPlugin
动态页面配置 build/pages.json.js 合并基础 + 地区 pages,写回 src/pages.json
动态 manifest build/manifest.json.js 合并基础 + 地区 manifest,写回 src/manifest.json
地区前端配置 src/district/<DISTRICT>/config.js 业务通过 @district 引用
地区构建配置 config/districts/<DISTRICT>/pages.jsmanifest.json.js 仅该地区生效的页面与 manifest

五、新增地区/环境/平台时的操作清单

  1. 新增地区

    • config/preBuild.config.jsDISTRICT_CONFIG_MAP 中增加 APP_TITLE 等。
    • 如需差异化页面:在 config/districts/<新地区>/ 下增加 pages.js
    • 如需差异化 manifest:在 config/districts/<新地区>/ 下增加 manifest.json.js
    • src/district/ 下新增同名目录及 config.js(可参考现有地区)。
  2. 新增部署环境

    • config/preBuild.config.jsAPI_BASE_URL_MAP 中增加 DEPLOY → API 地址。
    • package.json 的 scripts 中增加对应 dev/build:平台:地区:环境 命令。
  3. 新增平台

    • CLIENT_TYPE_MAP 中增加平台与客户端类型。
    • 若 uniapp 支持该平台,只需在 script 中增加 UNI_PLATFORM=新平台 的 dev/build 脚本即可。

六、注意事项

  • EnvironmentPlugin 与 preBuild 一致:凡在 preBuild.js 里新加的 process.env.XXX,若要在业务代码中使用,需在 vue.config.jsEnvironmentPlugin 数组中增加 'XXX'
  • DISTRICT / DEPLOY 默认值:preBuild 中使用了 process.env.DISTRICT || 'xuzhou'process.env.DEPLOY || 'dev',未传时会有默认地区与环境,可按需修改。
  • 跨平台兼容:使用 cross-env 可避免在 Windows 与 Mac/Linux 下环境变量写法不一致的问题。

按上述方案,即可用「地区 + 平台 + 环境」三维度通过一条命令完成打包,并实现配置集中、扩展清晰,欢迎大家学习指正!

❌
❌