阅读视图

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

使用 pnpm + Workspaces 构建 Monorepo 的完整指南

一、核心概念:pnpm Workspace

pnpm 内置了对 Monorepo(单一代码仓库)的原生支持,通过 Workspace(工作区) 机制实现。Workspace 允许你在一个仓库中管理多个相互关联但独立的项目(包),并智能地处理它们之间的依赖关系。

二、将普通仓库转变为 Monorepo 的步骤

步骤 1:初始化项目结构

# 创建项目根目录
mkdir my-monorepo
cd my-monorepo

# 初始化根目录 package.json
pnpm init

步骤 2:配置根目录 package.json

修改根目录的 package.json,关键配置如下:

{
  "name": "my-monorepo",
  "version": "1.0.0",
  "private": true,  // 必须设置为 true,避免误发布到 npm
  "scripts": {
    "dev": "pnpm -r run dev",      // -r 表示递归执行所有子包
    "build": "pnpm -r run build",
    "test": "pnpm -r run test"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

重要说明

  • "private": true是必须的,确保整个 Monorepo 不会被意外发布
  • 可以移除 maintest等字段,因为根目录通常不包含业务代码

步骤 3:创建 pnpm-workspace.yaml 配置文件

在根目录创建 pnpm-workspace.yaml文件,这是 pnpm Workspace 的核心配置文件:

# pnpm-workspace.yaml
packages:
  # packages 目录下的所有直接子目录
  - 'packages/*'
  
  # apps 目录下的所有直接子目录
  - 'apps/*'
  
  # components 目录下的所有层级子目录
  - 'components/**'
  
  # 排除包含 test 的目录
  - '!**/test/**'

配置说明

  • packages/*:匹配 packages目录下的所有一级子目录
  • apps/*:匹配 apps目录下的所有一级子目录
  • components/**:匹配 components目录下的所有层级子目录
  • !**/test/**:排除所有包含 test的目录

pnpm 中的两种配置方式

方式一:使用 package.jsonworkspaces字段

// package.json
{
  "name": "my-monorepo",
  "version": "1.0.0",
  "private": true,
  "workspaces": [
    "packages/*",
    "apps/*"
  ],
  "scripts": {
    "dev": "pnpm -r run dev"
  }
}

方式二:使用 pnpm-workspace.yaml文件

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
  - '!**/test/**'  # 排除包含 test 的目录

两种方式的优先级与兼容性

  1. pnpm 的读取顺序

    • 优先读取 pnpm-workspace.yaml
    • 如果不存在,则读取 package.json中的 workspaces字段
    • 如果两者都存在,pnpm-workspace.yaml优先级更高
  2. 推荐使用 pnpm-workspace.yaml的原因

    • 更丰富的配置选项:支持排除模式(!**/test/**
    • 更好的可读性:YAML 格式更适合复杂配置
    • 工具兼容性:明确标识为 pnpm 工作区
    • 未来扩展性:pnpm 的新功能会优先在 YAML 配置中支持

步骤 4:创建子项目结构

典型的 Monorepo 目录结构如下:

my-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── pnpm-lock.yaml
├── packages/
│   ├── shared-utils/     # 共享工具库
│   │   ├── package.json
│   │   └── src/
│   ├── ui-components/    # UI 组件库
│   │   ├── package.json
│   │   └── src/
│   └── core-lib/         # 核心库
│       ├── package.json
│       └── src/
├── apps/
│   ├── web-app/          # 前端应用
│   │   ├── package.json
│   │   └── src/
│   └── mobile-app/       # 移动应用
│       ├── package.json
│       └── src/
└── docs/                 # 文档

步骤 5:配置子项目的 package.json

每个子项目都需要有自己的 package.json,关键配置如下: 示例:共享工具库 (packages/shared-utils/package.json)

{
  "name": "@my-monorepo/shared-utils",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "dependencies": {
    "lodash": "^4.17.21"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}

示例:前端应用 (apps/web-app/package.json)

{
  "name": "web-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@my-monorepo/shared-utils": "workspace:*",  // 关键:引用本地包
    "@my-monorepo/ui-components": "workspace:*",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "vite": "^5.0.0"
  }
}

三、关键配置详解

1. workspace:* 协议

这是 pnpm Workspace 的核心特性,用于声明对本地其他包的依赖:

{
  "dependencies": {
    "@my-monorepo/shared-utils": "workspace:*",      // 使用最新版本
    "@my-monorepo/ui-components": "workspace:^1.0.0", // 指定版本范围
    "@my-monorepo/core-lib": "workspace:../packages/core-lib" // 相对路径
  }
}

作用

  • 建立本地包之间的软链接,无需发布到 npm
  • 修改本地包时,依赖它的项目能立即看到变化
  • 确保所有包使用同一份依赖,避免重复安装

2. 依赖安装与管理

在根目录安装全局依赖(所有包共享)

# 安装到根目录,所有包共享
pnpm add typescript -w
# 或
pnpm add typescript --workspace-root

为特定包安装依赖

# 为 web-app 安装 react
pnpm add react --filter web-app
# 或
pnpm add react -F web-app

# 为多个包安装依赖
pnpm add axios --filter "web-app" --filter "mobile-app"

安装本地包依赖

# 在 web-app 中安装 shared-utils
pnpm add @my-monorepo/shared-utils --filter web-app

3. 脚本执行

在所有包中执行相同脚本

# 递归执行所有包的 build 脚本
pnpm -r run build

# 递归执行所有包的 test 脚本
pnpm -r run test

在特定包中执行脚本

# 仅在 web-app 中执行 dev 脚本
pnpm --filter web-app run dev

# 使用包名(package.json 中的 name)
pnpm -F @my-monorepo/shared-utils run build

四、完整示例:Vue 项目 Monorepo

项目结构

vue-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── packages/
│   ├── ui-lib/          # UI 组件库
│   │   ├── package.json
│   │   ├── src/
│   │   └── vite.config.ts
│   └── utils/           # 工具函数库
│       ├── package.json
│       └── src/
└── apps/
    ├── admin/           # 后台管理系统
    │   ├── package.json
    │   └── src/
    └── portal/          # 门户网站
        ├── package.json
        └── src/

pnpm-workspace.yaml

packages:
  - 'packages/*'
  - 'apps/*'

根目录 package.json

{
  "name": "vue-monorepo",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "pnpm -r run dev",
    "build": "pnpm -r run build",
    "lint": "pnpm -r run lint"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}

包间依赖示例 (apps/admin/package.json)

{
  "name": "admin",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "vue": "^3.3.0",
    "@vue-monorepo/ui-lib": "workspace:*",
    "@vue-monorepo/utils": "workspace:*"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.0.0",
    "vite": "^5.0.0"
  }
}

五、最佳实践与注意事项

1. 命名规范

  • 根项目:使用项目总名称,如 my-monorepo
  • 子包:使用作用域名称,如 @my-monorepo/ui-components
  • 应用:使用描述性名称,如 web-appadmin-console

2. 依赖管理

  • 公共依赖(如 TypeScript、ESLint)安装在根目录
  • 业务依赖安装在各自包中
  • 使用 pnpm-lock.yaml确保依赖一致性

3. 版本控制

  • 提交 pnpm-lock.yaml到版本控制系统
  • 考虑使用 Changesets 或 Lerna 进行版本管理和发布

4. 性能优化

  • pnpm 使用硬链接和符号链接,节省磁盘空间
  • 所有包共享同一份依赖,安装速度快
  • 支持过滤命令,只构建需要的包

5. CI/CD 集成

# GitHub Actions 示例
name: CI
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      - uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: 'pnpm'
      - run: pnpm install
      - run: pnpm -r run build
      - run: pnpm -r run test

六、常见问题解决

1. 幽灵依赖问题

确保所有依赖都在 package.json中明确声明,避免直接引用 node_modules中的未声明包。

2. 循环依赖检测

使用 pnpm why <package-name>检查依赖关系,避免包之间的循环依赖。

3. 包找不到错误

如果出现 no matches found错误,检查:

  • 包名是否正确(包括作用域)
  • 包是否在 pnpm-workspace.yaml配置的目录中
  • 包是否有正确的 name字段

4. 跨包类型引用

对于 TypeScript 项目,配置 tsconfig.json中的 paths

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@my-monorepo/*": ["packages/*/src"]
    }
  }
}

总结

通过 pnpm + Workspaces 构建 Monorepo 的主要优势包括:

  1. 依赖共享:所有包共享同一份依赖,节省磁盘空间和安装时间
  2. 原子提交:跨包变更可以一次性提交,保持一致性
  3. 本地链接:使用 workspace:*协议,本地开发无需发布到 npm
  4. 精细控制:支持按包过滤的命令执行
  5. 统一管理:集中的 CI/CD 和代码质量检查

这种架构特别适合中大型项目、微前端架构、组件库开发等场景,能显著提升开发效率和代码复用性。

Vue生命周期详解:从创建到销毁的全过程

在学习 Vue 的过程中,我逐渐意识到一个问题:**为什么同样是写数据、改数据,有些代码只能写在某些地方?为什么定时器、请求、事件绑定如果位置不对,就会出 bug?**答案几乎都指向同一个核心概念——Vue 生命周期

Vue 生命周期并不是抽象的“理论名词”,而是 Vue 在实例从创建到销毁的整个过程中,在关键时间点自动帮我们调用的一组函数。这些函数有固定的名字、固定的调用时机,而我们能做的,就是在合适的钩子中写合适的代码。


一、生命周期的本质:Vue 在“关键时刻”帮你调用函数

从第一个示例可以看到,生命周期也被称为生命周期回调函数、生命周期函数或生命周期钩子。它的本质并不复杂:

Vue 在内部流程的某些关键阶段,自动调用我们提前写好的函数1.引出生命周期

有几个非常重要的特性需要先明确:

  • 生命周期函数的名字不能随便改
  • 生命周期函数内部的代码由开发者决定
  • 生命周期函数中的 this 永远指向当前的 Vue 实例(vm)

也正因为 this 指向 vm,我们才能在生命周期中自由访问 datamethodscomputed 等内容。


二、mounted:最常用、也最容易被误用的生命周期

在第一个示例中,页面上有一段非常直观的动画效果:文字透明度不断降低,又循环恢复。这个效果并不是通过操作 DOM 实现的,而是通过修改响应式数据 opacity 来完成的1.引出生命周期。

关键代码写在了 mounted() 中:

mounted() {
  setInterval(() => {
    this.opacity -= 0.01;
    if (this.opacity <= 0) {
      this.opacity = 1;
    }
  }, 16)
}

之所以把定时器写在 mounted,原因非常明确:

  • mounted 之前,模板还没有真正渲染成 DOM
  • 只有在 mounted 执行时,真实 DOM 已经挂载完成
  • 所有依赖页面结构、DOM 或视图更新的逻辑,都应该放在这里

需要特别注意的是:
mounted 只会在“初次挂载”时调用一次,后续数据变化引起的重新渲染,并不会再次触发 mounted,而是走更新相关的生命周期。


三、为什么要学 beforeDestroy?——生命周期的“收尾阶段”

第二个示例在第一个基础上进一步完善了逻辑,引出了一个非常现实的问题:
**定时器什么时候清?Vue 实例销毁后会发生什么?**1.总结生命周期

在这个例子中,点击“点我停止”按钮,会调用:

this.$destroy();

这行代码会手动销毁当前 Vue 实例,而在销毁之前,Vue 会调用 beforeDestroy()

beforeDestroy() {
  clearInterval(this.timer);
}

这正是生命周期的收尾价值所在

在实际开发中:

  • mounted 负责“初始化”
  • beforeDestroy 负责“清理善后”

例如:

  • 清除定时器
  • 解绑自定义事件
  • 取消消息订阅
  • 关闭 WebSocket 连接

如果不在销毁前清理这些内容,就会导致内存泄漏,甚至逻辑混乱。

另外,示例中还明确指出:

  • Vue 实例销毁后,开发者工具中将不再显示该实例
  • Vue 的自定义事件会失效
  • 原生 DOM 事件依然存在
  • beforeDestroy 中修改数据是没有意义的,因为更新流程已经不会再触发了1.总结生命周期

四、完整生命周期流程:从 beforeCreate 到 destroyed

第三个示例是对 Vue 生命周期最系统的一次演示,它几乎覆盖了所有常见的生命周期钩子,并通过 console.log 清晰地展示了调用顺序2.分析生命周期。

1️⃣ beforeCreate

这是 Vue 实例刚被创建时调用的钩子:

  • data 尚未初始化
  • methods 尚未初始化
  • 访问 this.n 得到的是 undefined

这说明:
beforeCreate 几乎不适合写业务代码


2️⃣ created

在这个阶段:

  • data 已经变成响应式数据
  • methods 已经可以正常调用
  • 但 DOM 还没有生成

如果你只关心数据、而不依赖页面结构,created 是一个可以使用的阶段。


3️⃣ beforeMount

此时:

  • 模板已经编译完成
  • 虚拟 DOM 已经生成
  • 但还没有挂载到页面上

打印 this.$el 会发现,它还不是最终呈现在页面中的 DOM。


4️⃣ mounted(最重要)

这是生命周期中使用频率最高的一个钩子:

  • 虚拟 DOM 已转为真实 DOM
  • 页面已经完成首次渲染
  • $el 就是页面中真实存在的 DOM

几乎所有涉及 DOM、视图、第三方库初始化的逻辑,都应该写在这里。


5️⃣ beforeUpdate / updated

当数据发生变化时:

  • beforeUpdate 在视图更新之前触发
  • updated 在视图更新之后触发

它们常用于:

  • 调试
  • 对比更新前后的状态
  • 少量特殊场景下的 DOM 同步操作

但在实际业务中,不建议滥用更新钩子


6️⃣ beforeDestroy / destroyed

这是 Vue 实例生命周期的终点:

  • beforeDestroy:还能访问 data 和 methods
  • destroyed:实例已经彻底不可用

一般来说,真正有价值的是 beforeDestroy,而 destroyed 很少使用。


五、从“记生命周期”到“会用生命周期”

通过这三段代码可以清楚地看到,生命周期并不是让人死记硬背的流程图,而是一套明确解决实际问题的时间节点机制

  • 初始化逻辑 → mounted
  • 数据准备 → created
  • DOM 操作 → mounted
  • 更新监听 → updated
  • 清理资源 → beforeDestroy

当我真正理解这些钩子的调用时机和设计目的之后,很多之前“写着写着就出 bug”的问题,都会自然消失。

Vue 生命周期的学习重点,从来不是“有多少个钩子”,而是——
在对的时间,做对的事。

六、总结

Vue生命周期钩子为开发者提供了在特定阶段介入实例生命周期的能力。通过合理使用这些钩子,我们可以:

  • 在正确时机执行初始化操作
  • 有效管理资源,防止内存泄漏
  • 优化应用性能
  • 更好地理解和控制应用状态

掌握生命周期不仅有助于编写更健壮的Vue应用,也是深入理解Vue响应式系统工作原理的关键。建议开发者在实际项目中多实践、多观察,逐步形成对生命周期各阶段的直观感受。

Vue3 插槽的本质

Vue 插槽的本质:一个关于函数传递的故事

核心观点

向组件传递插槽,其本质就是向子组件传递一个特殊的对象。这个对象包含了多个函数,每个函数对应一个插槽,而函数的返回值就是该插槽要渲染的虚拟节点 (VNode)。

  • 具名插槽:对象中以插槽名命名的属性,如 header
  • 默认插槽:对象中名为 default 的属性。
  • 作用域插槽:一个接收参数的函数,子组件调用它时传入的数据,就是父组件可以访问的 scope 数据。

因此,父组件定义插槽,是在“声明”这些函数;子组件使用插槽,是在“调用”这些函数。


1. 父组件:声明插槽函数 (模板语法如何编译)

我们在父组件中使用 <template> 语法来定义插槽,这是一种非常直观的“语法糖”。

父组件模板 (Parent.vue)

<template>
  <ChildComponent>
    <!-- 默认插槽 -->
    <p>这是默认插槽的内容</p>

    <!-- 具名插槽 "header" -->
    <template #header>
      <h1>我是 Header</h1>
    </template>

    <!-- 作用域插槽 "item" -->
    <template #item="scope">
      <div>
        来自子组件的数据: {{ scope.text }} (ID: {{ scope.id }})
      </div>
    </template>
  </ChildComponent>
</template>

在编译时,Vue 会将上述模板转换成类似下面这样的渲染函数调用,并生成你描述的那个 slots 对象:

编译后的概念

// Vue 编译器将模板转换为类似下面的代码
import { createVNode } from 'vue'
import ChildComponent from './ChildComponent.js'

createVNode(ChildComponent, null, {
    // 这就是传递给子组件的 slots 对象
    default: () => [createVNode("p", null, "这是默认插槽的内容")],
    header: () => [createVNode("h1", null, "我是 Header")],
    item: (scope) => [ // 这是一个接收 scope 参数的函数
        createVNode("div", null, `来自子组件的数据: ${scope.text} (ID: ${scope.id})`)
    ]
})

2. 子组件:调用插槽函数

子组件通过 setup 函数的第二个参数 { slots } 接收到这个对象。然后,在需要渲染插槽的地方,直接调用这些函数即可。

子组件

import { createElementVNode } from "vue"
export default {
    setup(_, { slots }) {
        // 正如你所说,使用插槽就是调用函数
        return () => {
           // 准备一些数据,用于传递给作用域插槽
           const scopeData = { text: "这是来自子组件的数据", id: 1 };
           // 调用 slots 对象上的函数来获取 VNode 数组作为子节点
           return createElementVNode("div", { class: "container" }, [
                // 1. 调用 header 函数,渲染具名插槽
                slots.header ? slots.header() : createElementVNode("p", null, "Header 的默认内容"),
                // 2. 调用 default 函数,渲染默认插槽
                slots.default ? slots.default() : createElementVNode("p", null, "Default 的默认内容"),
                // 3. 调用 item 函数并传入数据,渲染作用域插槽
                slots.item ? slots.item(scopeData) : null,
           ]);
        }
    }
}

总结

将插槽理解为“函数传递”是掌握其高级用法和渲染原理的关键。它解释了为什么作用域插槽能够从子组件获取数据,以及为什么我们可以用 JSX 或渲染函数灵活地创建和传递插槽。

❌