1. 从零搭建企业级 Monorepo 工程化模板:初始化、应用创建与共享 TypeScript 配置

1 阅读17分钟

导语

本文记录我在搭建企业级 HRMS 人事管理系统 Monorepo 模板时的学习笔记,涵盖第一步初始化基础结构、第二步创建第一个应用、第三步配置共享 TypeScript 配置。内容包含核心概念、操作步骤和避坑指南,适合刚开始接触 Monorepo 和 Vue3 + TS 工程化的开发者参考。

第一步:初始化 Monorepo 基础结构

1.1 初始化 Git

  • 作用:在当前目录创建一个空的 Git 仓库,生成 .git 文件夹,用于版本控制。

  • 解决问题:让项目能够被 Git 跟踪,后续可以提交代码、回滚、协作等。

  • 操作步骤:

    1. 在项目根目录执行 git init。

    2. 关联远程仓库,在终端中执行以下命令(以 Gitee 为例):

      git remote add origin https://gitee.com/xxxx/hrms-monorepo-2.git
      git push -u origin "master"
      
  • 我的理解:Git 是项目的地基,没有它后续的版本管理无从谈起,是搭建任何项目的第一步。

1.2 项目初始化(创建根 package.json)

  • 作用:在项目根目录生成 package.json 文件,用于声明项目名称、版本、脚本、依赖等元信息,是 Node.js 项目的核心配置文件。

  • 解决问题:没有 package.json,后续无法使用 pnpm 安装依赖、执行脚本命令;同时通过定制字段(如 private、packageManager、engines)可以约束项目属性和环境。

  • 操作步骤:

    1. 在项目根目录执行 pnpm init(会自动生成一个基础的 package.json)。

    2. 打开 package.json,将其内容修改为以下结构(可直接复制粘贴):

      {
        "name": "hrms-monorepo",
        "version": "1.0.0",
        "private": true,
        "description": "企业 HRMS 人事管理系统,采用 Monorepo 工程化架构,包含后台管理端、数据可视化大屏与公共工具包,配套完整代码规范、安全及无障碍校验体系,覆盖组织、员工、权限、薪资、考勤、审批等全量人事业务模块",
        "author": "咖啡无伴侣",
        "license": "UNLICENSED",
        "type": "module",
        "scripts": {
          "test": "echo "Error: no test specified" && exit 1"
        },
        "engines": {
          "node": ">= 22.14.0",
          "pnpm": ">=11.24.0"
        },
        "packageManager": "pnpm@11.24.0"
      }
      
    3. 修改说明:

      • 修改 description 为项目描述信息。
      • 添加 "private": true 防止项目被误发布到 npm。
      • 删除 main 字段:monorepo 多包项目的入口文件位于各子包中,根目录无需 main。
      • 添加 "type": "module":表示项目采用 ES Module 规范(使用 import 和 export),而不是 CommonJS 模式。
      • 删除 keywords 字段:根包不发布,无需提供搜索关键词。
      • 删除 devEngines 对象:该字段尚未被广泛支持,且已通过 engines 和 packageManager 约束版本,避免冗余。
      • 添加 engines 对象:配置 Node.js 和 pnpm 的版本要求。
  • 我的理解:package.json 是 Node.js 项目的配置文件,其中包含项目的基本信息与脚本配置。只有当项目中包含这个文件时,才能运行 npm、pnpm 等脚本工具。

1.3 配置 pnpm Workspace

  • 概念:pnpm workspace 允许在一个仓库中管理多个相互独立的包(packages),并通过 pnpm-workspace.yaml 声明哪些目录是包。这样 pnpm 能自动通过符号链接关联本地依赖,避免重复安装相同版本的包,节省磁盘空间。

  • 操作步骤:

    1. 在项目根目录创建 pnpm-workspace.yaml,内容如下:

      packages:
        - "apps/*"
        - "packages/*"
      
    2. 创建目录 apps(如果尚未存在)。

  • 我的理解:pnpm 通过 pnpm-workspace.yaml 文件来识别哪些目录是子项目,从而统一管理整个 monorepo 的依赖和脚本。

1.4 总结

  1. 项目创建之初就要初始化 Git 仓库,便于团队开发协同。

  2. 修改根目录 package.json:

    • 添加 "private": true,防止根包被误发布到 npm。
    • 删除 main 字段:monorepo 的入口文件位于各子包中,根目录无需 main。
    • 删除 keywords 字段:根包不发布,无需关键词。
    • 删除 devEngines 字段:该字段尚未被广泛支持,且已通过 engines 和 packageManager 约束版本。
    • 添加 "type": "module":声明使用 ES Modules 规范(import/export)。
    • 添加 engines 字段:要求 Node 和 pnpm 的最低版本。
    • 添加 packageManager 字段:指定使用的包管理器及精确版本,便于配合 corepack 统一环境。

第二步:创建第一个应用(hrms-admin)

2.1 理解 monorepo 的工作原理

核心问题:为什么 vue 放在子项目,typescript 放在根目录?

在 Monorepo 中,依赖不是随意放置的,而是遵循职责分离和依赖隔离的原则。理解这一点,是掌握 Monorepo 工程化的关键。

1. 运行时依赖 vs 开发时依赖

  • 运行时依赖:应用在浏览器运行时必需的包,例如 vue、vue-router、pinia。

    • 特征:会被 import 到源码中,参与最终打包,应用离开它无法运行。
    • 放置位置:子项目的 dependencies。
  • 开发时依赖:只在开发、构建、检查阶段使用的工具,例如 typescript、eslint、prettier。

    • 特征:不参与最终打包,只辅助开发流程(类型检查、代码规范、格式化)。
    • 放置位置:根目录的 devDependencies,因为工具链应当全仓库统一,避免版本不一致。

重点:运行时依赖放在子项目,开发工具放在根目录。
原因:应用需要独立可控的框架版本,而工具链需要全仓库统一。

2. pnpm 的依赖隔离机制

pnpm 采用严格的依赖隔离,子项目只能访问自己 package.json 中显式声明的依赖。

  • import 语句:必须在子项目声明对应依赖,否则报错。
    例如 hrms-admin 中写 import { createApp } from 'vue',那么 vue 必须出现在 hrms-admin/package.json 的 dependencies 中。
  • 终端命令:如 tsc、vue-tsc,执行时会向上查找 node_modules/.bin。
    因此根目录安装的 typescript 能被所有子项目直接使用,无需子项目重复声明。

重点:import 看声明,命令看查找路径。
子项目必须声明自己要 import 的包;而命令工具可以从根目录继承。

3. pnpm 的符号链接与全局 Store

pnpm 安装依赖时,不会将依赖文件复制到每个子项目的 node_modules,而是使用符号链接和全局 Store。

  • 全局 Store:所有依赖包的实际文件存储在一个统一位置(如 ~/.pnpm-store)。同一个版本的依赖只会保存一份,不同项目共享。
  • 虚拟存储 .pnpm:在项目 node_modules/.pnpm 下创建硬链接,指向全局 Store,并构建依赖关系图。
  • 子项目 node_modules:只包含符号链接,指向 .pnpm 中的实际包。
    例如 hrms-admin/node_modules/vue 是一个链接,指向 .pnpm/vue@3.5.31/node_modules/vue。

重点:依赖只有一份真实文件,项目通过链接共享。
好处:节省磁盘空间、安装速度快、避免重复下载。

4. workspace 协议与跨包引用

pnpm 通过 pnpm-workspace.yaml 声明哪些目录是包,并支持 workspace 协议进行本地包引用。

  • 例如在 hrms-admin/package.json 中写:

    {
      "dependencies": {
        "@hrms/utils": "workspace:*"
      }
    }
    

    表示引用本地 packages/utils 包,pnpm 会自动链接它,无需发布到 npm。

  • 修改 packages/utils 后,依赖它的子项目立即生效,可以在一次 Git 提交中完成共享包和应用的联动更新。

重点:workspace 协议让本地包像 npm 包一样被引用,实现跨包即时联动,这是 Monorepo 的核心优势。

5. 总结:Monorepo 依赖管理的黄金法则

对比维度示例放置位置原因
运行时依赖vue、vue-router、pinia子项目 dependencies谁用谁声明,依赖隔离
应用级构建工具vite、@vitejs/plugin-vue子项目 devDependencies不同应用构建需求可能不同
通用开发工具typescript、eslint、prettier根目录 devDependencies全仓库统一版本,避免冲突

理解这些原则,你就知道了在 Monorepo 中每个依赖应该放在哪里,以及 pnpm 如何通过隔离 + 链接实现高效、安全的依赖管理。

2.2 创建 hrms-admin 应用的核心知识点

1. 子包 package.json 的关键字段

字段作用关键点
name定义包名使用作用域命名,如 @hrms/admin,区分不同子包
private防止包被发布设为 true,仅用于内部应用
type声明模块规范"module" 使 import/export 语法生效
scripts定义常用命令dev 启动开发服务器,build 先类型检查再构建,preview 预览产物
dependencies运行时依赖如 vue,浏览器运行必需
devDependencies开发/构建依赖如 vite、@vitejs/plugin-vue、typescript、vue-tsc,不参与最终打包

重点:Monorepo 中谁用谁声明。子包必须声明自己 import 的依赖;通用开发工具(如 typescript)可提升到根目录,但 vite 和 Vue 插件通常留在子包,因为它们与应用强绑定。

2. index.html 的作用

  • Vite 的入口文件,浏览器直接加载。
  • 包含 <div id="app">,Vue 应用挂载点。
  • 通过 <script type="module" src="/src/main.ts"> 引入入口模块。
  • type="module" :让浏览器按 ES Module 加载脚本,支持 import 语法,并具有延迟执行和作用域隔离特性。

3. src/main.ts 的挂载流程

import { createApp } from "vue";
import App from "./App.vue";

const app = createApp(App);
app.mount("#app");
  • createApp(App):创建 Vue 应用实例。
  • .mount('#app'):将应用挂载到 index.html 中 id 为 app 的元素。
  • 这是 Vue3 启动应用的标准写法。

4. App.vue 中的响应式基础

<script setup lang="ts">
import { ref } from "vue";
const msg = ref("Hello HRMS");
function changeMsg() {
  msg.value = "Hello Vue3 + TypeScript";
}
</script>

<template>
  <h1>{{ msg }}</h1>
  <button @click="changeMsg">点击我改变文字</button>
</template>
  • 使用 <script setup lang="ts"> 语法糖,更简洁。
  • ref 创建响应式数据,模板中自动解包(直接写 msg,无需 .value)。
  • @click 绑定事件,修改 ref 值会触发视图更新。

5. vite.config.mts 的作用

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  plugins: [vue()],
});
  • defineConfig 用于获得类型提示。
  • 必须从 @vitejs/plugin-vue 导入插件,不能从 'vue' 导入(否则报错)。
  • 插件让 Vite 能编译 .vue 文件。

6. tsconfig.json 的核心作用(理解即可,不深究所有字段)

  • 告诉 TypeScript 如何检查类型。
  • "strict": true:开启严格模式,是 TS 的核心价值。
  • "include" :指定要检查的文件(如 src/**/*)。
  • tsconfig.node.json 专门用于 Node 环境下的配置文件(如 vite.config.mts)。

重点:现在不需要背所有字段,遇到类型错误再反查即可。

7. pnpm 安装与依赖链接

  • 执行 pnpm install 后,pnpm 会:

    • 识别 workspace 中的子包。
    • 将依赖下载到全局 store。
    • 在子包 node_modules 中创建符号链接,指向全局 store 中的真实文件。
  • 同一个依赖版本只存储一份,多个项目共享,节省磁盘空间。

  • 若出现 Ignored build scripts 提示(如 esbuild),执行 pnpm approve-builds 批准构建脚本。

8. 总结表格

文件作用关键点
package.json声明依赖和脚本type: "module", private: true
index.html浏览器入口<div id="app">, type="module"
src/main.ts应用启动createApp(App).mount('#app')
src/App.vue根组件<script setup>, ref, @click
vite.config.mtsVite 配置导入 @vitejs/plugin-vue
tsconfig.jsonTS 检查配置strict: true, include
pnpm install依赖安装全局 store + 符号链接,去重节省空间

第三步:共享 TypeScript 配置

前置知识:TypeScript 是什么?

TypeScript 是 JavaScript 的超集,它在 JavaScript 的基础上添加了静态类型系统。简单说,TypeScript 允许你在写代码时声明变量、参数、返回值的类型,编译器会在开发阶段检查类型错误,提前发现潜在问题,而不是等到运行时才报错。它最终会被编译成纯 JavaScript 运行在浏览器或 Node.js 中。

为什么需要 TypeScript?

  • 提升代码可读性和可维护性,类型即文档。
  • 减少低级错误(如拼写错误、类型不匹配、空值访问)。
  • 更好的编辑器支持(智能提示、重构、跳转)。
  • 适合大型项目和团队协作。

3.1 创建共享配置包 packages/typescript-config

1. 在 packages/typescript-config 下新建 package.json 文件

{
  "name": "@hrms/typescript-config",
  "version": "0.0.1",
  "private": true,
  "type": "module",
  "exports": {
    "./base.json": "./base.json",
    "./vue.json": "./vue.json",
    "./node.json": "./node.json"
  }
}

2. 标量字段说明

字段值作用
name@hrms/typescript-config包名,@hrms 是作用域,用于在 monorepo 中引用该配置包
version0.0.1包版本,目前为初始版本
privatetrue防止误发布到 npm,仅内部使用
typemodule声明包使用 ES Module 规范

3. exports 字段说明

exports 定义了导出映射,允许使用者通过 @hrms/typescript-config/子路径 的方式导入配置文件。

json

"exports": {
  "./base.json": "./base.json",  // 导入 @hrms/typescript-config/base.json 时,返回包内根目录的 base.json
  "./vue.json": "./vue.json",    // 导入 @hrms/typescript-config/vue.json 时,返回包内根目录的 vue.json
  "./node.json": "./node.json"   // 导入 @hrms/typescript-config/node.json 时,返回包内根目录的 node.json
}

3.2 子项目继承与路径别名配置

1. base.json 文件

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,
    "noEmit": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  }
}

字段说明

字段值作用
$schemahttps://json.schemastore.org/tsconfig指定 JSON Schema 地址,让编辑器(如 VS Code)提供智能提示和校验
compilerOptions对象TypeScript 编译选项的容器,所有具体配置都写在这个对象内
targetES2020指定编译后 JavaScript 的语法版本,确保代码能在支持 ES2020 的环境中运行
moduleESNext指定模块系统标准,使用最新的 ES 模块语法(即 import/export)
moduleResolutionbundler模块解析策略,告诉 TS 使用打包器(如 Vite、Webpack)的方式解析模块路径,支持 exports 字段和省略扩展名
stricttrue开启所有严格类型检查选项,是 TS 类型安全的核心保障
jsxpreserve保留 JSX 语法,不进行转换,交给后续工具(如 Vite)处理。在 Vue 项目中用于支持 TSX
resolveJsonModuletrue允许导入 .json 文件,并自动推断其类型
isolatedModulestrue确保每个文件可以独立编译,兼容 Babel 等单文件转译工具,防止某些不安全语法
esModuleInteroptrue允许用 ESM 默认导入 CommonJS 模块,避免 import react from 'react' 这类写法报错
lib["ES2020", "DOM", "DOM.Iterable"]指定编译时可用的全局类型库,包含 ES2020 API、DOM 接口和 DOM 可迭代对象类型
skipLibChecktrue跳过对所有 .d.ts 声明文件的类型检查,加快编译速度,避免第三方库类型冲突
noEmittrue不生成输出文件,仅做类型检查(通常配合 vue-tsc --noEmit 使用)
noUnusedLocalstrue报告未使用的局部变量错误,保持代码整洁
noUnusedParameterstrue报告未使用的函数参数错误
noFallthroughCasesInSwitchtrue禁止 switch 语句中 case 出现贯穿(fallthrough),必须显式 break 或 return

2. node.json 文件

在 Monorepo 共享 TypeScript 配置中,node.json 没有继承 base.json 是有意为之,主要基于以下原因:

  • 运行环境不同

    • base.json 面向浏览器 + Vue 应用,包含 "lib": ["ES2020", "DOM", "DOM.Iterable"] 和 "jsx": "preserve" 等浏览器/前端相关选项。
    • node.json 用于 Node.js 环境(例如 vite.config.mts),它不需要 DOM 类型,也不应该包含 JSX 配置。如果继承 base.json,就会把这些不必要的选项带入 Node 配置,可能引发类型错误或混淆。
  • noEmit 与项目引用的冲突

    • base.json 中设置了 "noEmit": true,目的是让 vue-tsc --noEmit 只做类型检查,不输出文件。

    • 但 TypeScript 的项目引用(Project References) 要求被引用的项目(如 tsconfig.node.json)必须设置 "composite": true,且不能同时设置 "noEmit": true,否则会报错:

      Referenced project may not disable emit.
      
    • 因此 node.json 必须覆盖 noEmit 为 false。如果它继承了 base.json,就需要显式覆盖,容易遗漏且逻辑混乱。

  • 独立性更清晰

    将 node.json 独立定义,可以让每个配置文件职责单一:

    • base.json:前端通用基础配置(浏览器、Vue、严格模式等)
    • vue.json:继承 base.json 并添加 Vue 相关类型(如 vite/client)
    • node.json:专门针对 Node 工具链配置,不继承,避免耦合
  • 代码块

    {
      "$schema": "https://json.schemastore.org/tsconfig",
      "compilerOptions": {
        "composite": true,
        "target": "ES2020",
        "module": "ESNext",
        "moduleResolution": "bundler",
        "lib": ["ES2020"],
        "skipLibCheck": true,
        "allowSyntheticDefaultImports": true,
        "strict": true,
        "noEmit": false
      }
    }
    
  • 字段说明

    字段值作用
    skipLibChecktrue跳过对所有 .d.ts 声明文件的类型检查,加速编译,避免第三方库类型冲突
    allowSyntheticDefaultImportstrue允许从没有默认导出的模块中使用默认导入(例如 import path from 'path'),方便在 Node 环境中使用 CommonJS 模块
    lib["ES2020"]指定编译时可用的全局类型库。这里只引入 ES2020,不包含 DOM,避免 Node 环境下误用浏览器 API
    noEmitfalse允许输出文件。由于项目引用(references)要求被引用项目不能禁用 emit,这里设置为 false 以满足构建规则(实际 vue-tsc --noEmit 检查时不会真正输出)

3. vue.json

json

{
  "extends": "./base.json",
  "compilerOptions": {
    "types": ["vite/client"]
  }
}
  • 字段说明

    字段值作用
    extends./base.json继承同目录下的基础 TypeScript 配置,避免重复定义通用编译选项
    types["vite/client"]引入 Vite 客户端类型声明,使 import.meta.env、静态资源导入(如 .vue、.svg)等获得类型支持
  • 补充说明

    • extends:允许当前配置文件复用另一个配置文件的设置,同时可以覆盖或增加特定选项。这里 vue.json 继承 base.json 的所有浏览器相关配置,只需额外声明 Vite 客户端类型即可。

    • types:默认情况下 TypeScript 会加载 node_modules/@types 下的所有类型包。但通过 types 字段可以显式限制要包含的类型声明包,避免无关类型干扰。vite/client 是 Vite 提供的类型定义文件,包含:

      • import.meta.env 的环境变量类型
      • 对 .vue、.svg、.css 等资源的模块声明
      • Vite 特有的 HMR API 类型

3.3 避坑指南(常见错误与解决方案)

坑 1:baseUrl 已弃用警告

现象:
TypeScript 编译时提示:

text

选项"baseUrl"已弃用,并将停止在 TypeScript 7.0 中运行。

原因:
TypeScript 6.0 开始弃用 baseUrl,因为 paths 已经可以独立工作,不再需要基准目录。

解决方案:
删除 baseUrl,将 paths 中的路径改为相对于 tsconfig.json 文件的相对路径。

json

// 错误写法
{
  "baseUrl": ".",
  "paths": {
    "@/*": ["src/*"]
  }
}

// 正确写法
{
  "paths": {
    "@/*": ["./src/*"]
  }
}

预防:
配置路径别名时,不要依赖 baseUrl,直接使用以 ./ 或 ../ 开头的相对路径。

坑 2:项目引用要求 composite 和 noEmit 冲突

现象:
执行 vue-tsc --noEmit 时报错:

text

Referenced project 'tsconfig.node.json' may not disable emit.

原因:
TypeScript 项目引用(references)要求被引用项目必须设置 "composite": true,并且不能同时设置 "noEmit": true。而 base.json 中为了类型检查设置了 "noEmit": true,被继承后导致冲突。

解决方案:
在子项目的 tsconfig.node.json 中显式覆盖 "noEmit": false,并确保 "composite": true 存在。

json

{
  "extends": "@hrms/typescript-config/node.json",
  "compilerOptions": {
    "composite": true,
    "noEmit": false
  },
  "include": ["vite.config.mts"]
}

同时建议在共享配置 node.json 中直接设置 "noEmit": false,避免每个子项目都要重复覆盖。

预防:
在设计共享配置时,node.json 最好不继承 base.json,而是独立定义,从根本上避免环境差异带来的冲突。

坑 3:路径别名在 Vite 中无效

现象:
TypeScript 能识别 @/ 别名,但 Vite 构建或开发服务器报错找不到模块。

原因:
TypeScript 的 paths 只影响类型检查,不会影响构建工具。Vite 需要单独配置 resolve.alias。

解决方案:
在 vite.config.mts 中添加与 tsconfig.json 一致的别名配置。

ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

预防:
修改路径别名时,记得同步更新 TypeScript 和 Vite 两处配置,保持一致性。

坑 4:忘记安装 @types/node 导致 Node 模块类型缺失

现象:
在 vite.config.mts 中使用 import { fileURLToPath } from 'node:url' 时,TypeScript 报错找不到模块。

原因:
node:url 是 Node.js 内置模块,但 TypeScript 需要对应的类型声明包 @types/node 才能识别。

解决方案:
在根目录安装 @types/node 作为开发依赖(因为多个子包可能都需要)。

bash

pnpm add -D -w @types/node

预防:
在 Monorepo 中,如果某个配置文件运行在 Node 环境,提前安装 @types/node 并放在根目录,统一管理。

坑 5:共享配置包 exports 未正确导出导致 extends 失败

现象:
子项目 tsconfig.json 中写 "extends": "@hrms/typescript-config/vue.json" 时,TypeScript 报错找不到模块。

原因:
共享配置包的 package.json 中没有正确配置 exports 字段,或者路径映射错误。

解决方案:
确保共享包的 package.json 包含:

json

{
  "name": "@hrms/typescript-config",
  "exports": {
    "./base.json": "./base.json",
    "./node.json": "./node.json",
    "./vue.json": "./vue.json"
  }
}

并且子项目的 devDependencies 中通过 workspace:* 引用了该包。

预防:
创建共享配置包后,先在子项目测试 extends 是否能正确解析,再继续其他配置。


写在最后

以上就是我搭建 Monorepo 模板前三步的完整记录,涵盖基础结构初始化、第一个 Vue3 应用创建、共享 TypeScript 配置包的设计与坑点。后续我还会继续完善 ESLint、Prettier、Husky 等工程化配置,并逐步引入 Turborepo 和 CI/CD,感兴趣的朋友可以持续关注。

如果本文对你有帮助,欢迎点赞、收藏、评论,也欢迎指出不足之处,一起交流进步。