阅读视图

发现新文章,点击刷新页面。

如何实现一个「万能」的通用打印组件?

在我们组开发的业务系统中,存在文书种类多、格式不一的场景,但又要求保持一致的打印体验,怎么办呢?难道每次加一种新文书就写一套打印逻辑?不存在的。用「配置 + 动态模板 + iframe 打印」的思路,可以搭出一套一个组件打天下的通用打印方案。


一、先想清楚:我们要解决什么问题?

  • 多种文书:不同业务对应不同的文书模板,字段、布局、样式都不一样。
  • 统一入口:希望小伙伴调用时只关心「打开打印、传文书类型和业务单号」,不用关心具体模板和接口。
  • 可编辑再打:部分文书需要在预览里编辑或填充后再打印,而不是纯静态展示。
  • 打印体验:要能控制打印样式(页眉页脚、分页、字体),并且不把整页 UI 一起打出去。

二、整体架构:三层拆解

可以把通用打印拆成三层,逻辑会非常清晰:

  1. 主组件:包含组件状态提示、调用 iframe 执行打印等功能;
  2. 配置层:文书类型与文书模版要一一对应;
  3. 模板层:每种文书一个 Vue 模板组件,负责展示、编辑字段,同时提供方法给壳层拿去保存/打印。

三、配置层:文书类型与模板的映射

用一份配置集中维护,后续扩展新文书主要就是:加一条配置 + 加一个模板组件。

export const DOC_TYPE = {
  FORM_A: 'FORM_A',  // 例如:某登记表
  FORM_B: 'FORM_B',  // 例如:某告知书
  // ...
};

export const documentTemplates = {
  [DOC_TYPE.FORM_A]: {
    title: '某登记表',
  },
  [DOC_TYPE.FORM_B]: {
    title: '某告知书',
  },
};

export function getTemplateConfig(docType) {
  const config = documentTemplates[docType];
  if (!config) {
    console.warn(`未找到文书类型 ${docType} 的模板配置`);
    return null;
  }
  return config;
}

主组件里使用 getTemplateConfig(docType) 拿配置,这样「加新文书」对主组件来说就是多一个配置键和对应的模板组件啦。


四、壳层:动态组件 + 打印流程

主组件只认「当前 docType 对应哪个模板组件」,用 component :is 动态渲染,这样无需在壳里写一长串 if/else 或 v-if。

4.1 模板区域与动态组件

<!-- 打印区域:唯一 id 便于后面克隆到 iframe -->
<div id="commonPrintArea" class="print-area">
  <component
    :is="templateComponent"
    ref="templateRef"
    :data="printData"
    :numb="numb"
    :template-config="templateConfig"
  />
</div>
computed: {
  templateComponent() {
    const componentMap = {
      FORM_A: 'FormATemplate',
      FORM_B: 'FormBTemplate',
      // 新文书:加一行即可
    };
    return componentMap[this.docType] || null;
  },
},

printData 由你在 init/loadCommonData 里请求接口或直接使用外部传入的数据;templateConfig 来自 getTemplateConfig(this.docType)

4.2 从模板组件拿数据:约定 getData()

打印或保存前,主组件需要拿到当前模板里用户可能改过的内容,所以约定:每个模板组件暴露 getData()方法,返回要落库/打印的纯数据。

// 主组件 methods
getTemplateData() {
  const templateComponent = this.$refs.templateRef;
  if (!templateComponent || typeof templateComponent.getData !== 'function') {
    return null;
  }
  return templateComponent.getData();
},

async handlePrint() {
  const templateData = this.getTemplateData();
  if (!templateData) return;

  const saved = await this.savePrintRecord(templateData);
  if (!saved) return;

  this.executePrint();
  this.$emit('print-success', { docType: this.docType, numb: this.numb, printData: templateData });
}

这样无论是「先保存再打」还是「仅打印」,数据源都统一来自模板的 getData()


五、模板层:可编辑字段与 getData()

模板里会有大量「看起来像下划线填空」的格子,既要可编辑又要打印时样式干净,我们的做法是,用一个可编辑字段的子组件包一层,再在模板里用 v-model 绑定 editableData对象,最后 getData() 直接返回这个对象。

5.1 可编辑字段的子组件(EditableField组件)

用 HTML5的contenteditable属性做内联编辑,通过 v-model和父组件同步;输入法期间用 compositionstart/end 防抖。

<template>
  <span
    ref="editableElement"
    :class="['editable-field', customClass]"
    :contenteditable="editable"
    :data-placeholder="placeholder"
    @blur="handleBlur"
    @input="handleInput"
    @compositionstart="isComposing = true"
    @compositionend="isComposing = false; handleInput($event)"
  />
</template>

<script>
export default {
  name: 'EditableField',
  props: ['value', 'editable', 'placeholder', 'customClass', 'maxlength'],
  data() {
    return { isComposing: false, innerValue: '' };
  },
  watch: {
    value: {
      immediate: true,
      handler(newVal) {
        if (!this.isComposing && newVal !== this.innerValue) {
          this.innerValue = newVal || '';
          if (this.$refs.editableElement) this.$refs.editableElement.innerText = this.innerValue;
        }
      },
    },
  },
  methods: {
    handleBlur(e) {
      const text = e.target.innerText.trim();
      this.innerValue = text;
      this.$emit('input', text);
    },
    handleInput(e) {
      if (this.isComposing) return;
      let text = e.target.innerText;
      if (this.maxlength && text.length > this.maxlength) {
        text = text.substring(0, this.maxlength);
        this.$refs.editableElement.innerText = text;
      }
      this.innerValue = text;
      this.$emit('input', text);
    },
  },
};
</script>

模板里用法示例:

<editable-field v-model="editableData.name" placeholder="请输入" custom-class="inline-underline-field" />

打印样式里对 .editable-field.inline-underline-field 等做「无边框、无背景、保下划线」的覆盖,即可做到「屏幕可编辑、纸上像填空」。


六、iframe 打印:只打「这一块」且样式可控

直接 window.print() 会连侧边栏、导航、按钮一起打。我们的做法是:把要打印的那块 DOM 克隆到隐藏的 iframe 里,在 iframe 里注入完整打印样式,再对 iframe 执行 print()

6.1 克隆 + 处理特殊节点(如复选框)

克隆时注意:像 Element UI 的 checkbox,在 iframe 里可能不会按「勾选状态」渲染,所以克隆后先把这类控件转成「勾选用 ☑ / 未勾选用 ☐」的纯文本,再塞进 iframe,这样打印出来稳定一致。

processCheckboxes(container) {
  container.querySelectorAll('.el-checkbox').forEach((el) => {
    const input = el.querySelector('input[type="checkbox"]');
    const isChecked = input && input.checked;
    const checkmark = document.createElement('span');
    checkmark.textContent = isChecked ? '☑' : '☐';
    // 若有 .el-checkbox__label,可把 label 文本和 checkmark 拼成新节点替换 el
    el.parentNode.replaceChild(checkmark, el);
  });
}

6.2 创建 iframe 并写入 HTML + 样式

executePrint() {
  const printArea = document.getElementById('commonPrintArea');
  if (!printArea) return;

  const cloned = printArea.cloneNode(true);
  this.processCheckboxes(cloned);

  const iframe = document.createElement('iframe');
  iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:none';
  document.body.appendChild(iframe);

  const printStyles = this.getPrintStyles(); // 见下一小节

  const doc = iframe.contentWindow.document;
  doc.open();
  doc.write(`
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8">
        <title>${this.templateConfig.title}</title>
        <style>
          * { margin: 0; padding: 0; box-sizing: border-box; }
          body { font-family: "Microsoft YaHei", Arial, sans-serif; line-height: 1.5; color: #000; background: #fff; }
          ${printStyles}
        </style>
      </head>
      <body>${cloned.innerHTML}</body>
    </html>
  `);
  doc.close();

  iframe.onload = () => {
    iframe.contentWindow.focus();
    setTimeout(() => {
      iframe.contentWindow.print();
      setTimeout(() => document.body.removeChild(iframe), 500);
    }, 100);
  };
}

这样只有 iframe 里的 body 被打印,且样式完全由你注入的 printStyles 控制。


七、打印样式:基础 + 按文书类型扩展

拆成「基础样式(所有文书共用)」和「按 docType 的扩展样式」,主组件里根据 docType 拼成最终样式字符串。

getPrintStyles() {
  const baseStyles = `
    @page { margin: 0; size: A4; }
    body { margin: 10mm 10mm 15mm 10mm; font-family: "仿宋", serif; }
    .form-table { width: 100%; border-collapse: collapse; border: 2px solid #000; }
    .form-table th, .form-table td { border: 1px solid #000; padding: 6px 8px; }
    .form-table tr { page-break-inside: avoid; }
    .editable-field { border: none !important; background: transparent !important; box-shadow: none !important; }
    .inline-underline-field { border-bottom: 1px solid #333 !important; min-height: 1.2em; }
  `;
  const docTypeStyles = this.getDocTypeSpecificStyles(); // 从 styleMap[docType] 取
  return `${baseStyles}\n${docTypeStyles}`;
}

新增文书时,如需单独调表格列宽、标题字号等,在 getDocTypeSpecificStyles() 的 styleMap 里加一条即可,主组件逻辑不用改。


结尾:按这套思路实现后,业务侧只需要「传 docType + 外部数据) + 监听事件」,就能接住多种文书、可编辑、可保存的通用打印能力啦;后续加新文书也不会再在主组件里堆逻辑,维护成本也会低很多。

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

前言

  • 每次起新项目,都要复制一整套「祖传」配置、改包名、删示例,稍不留神就漏掉埋点或兼容文件?
  • 团队里 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等。

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

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

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

  • 新开一个项目,复制粘贴上个项目的 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 多地区、多平台、多环境打包方案

前言:本文基于真实的线上项目,介绍如何用一套代码支持多个地区多种平台(微信/支付宝小程序、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 下环境变量写法不一致的问题。

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

❌