SSF0SSF0
首页
前端
  • Node
  • Go
  • Python
  • MySql
  • Bash
  • Git
  • Docker
  • VuePress
  • CI/CD
  • 服务器
  • 网站
  • 学习资料
  • 软件
  • 开源项目
Timeline
Github
标签
分类
首页
前端
  • Node
  • Go
  • Python
  • MySql
  • Bash
  • Git
  • Docker
  • VuePress
  • CI/CD
  • 服务器
  • 网站
  • 学习资料
  • 软件
  • 开源项目
Timeline
Github
标签
分类
  • HTML

    • html1
    • html2
  • CSS

    • Flex 布局常见问题与解决方案
  • JavaScript

    • 数据类型及引用问题
    • 处理 Blob 类型文件下载问题总结
    • localStorage 与 sessionStorage 区别
    • JavaScript 中的 script 标签问题详解
    • JavaScript 中的 this 指向问题详解
    • SessionStorage踩坑记录
    • 动态加载 JS 脚本方法对比
    • 浏览器页面关闭场景下的数据上报
    • HTTP流式接口详解与封装实践
    • JavaScript 中 this 绑定与解构:为什么 Pinia Store 可以解构,但 Class 实例方法不行?
  • es6

    • Promise
    • es6 模块导出方式全解析
    • reduce
    • forEach 中使用 async/await 的异步陷阱
  • Vue2

    • created VS mounted 发起异步请求
    • vue2-2
  • Vue3

    • Vite + Vue3 修改 element-plus 源码
    • Vue v-if 与 v-show
    • Vue3 ref 获取组件
    • Vue3 路由传参
    • 父子组件与组件里 Hooks 加载顺序
    • 第三方组件传入参数TS提示
    • Vue3 Props 在 Hooks 中的响应性处理
    • Vue Router 的两种历史模式及部署配置详解
    • 在Vue 3项目中顺利集成Tailwind CSS的完整指南
    • Vue Scoped CSS 深度解析
    • Vue 3 Teleport 组件完全指南:从原理到实战
    • Vant List 组件中双向绑定 loading 的注意事项
    • Vue 3 + Vite 中的路径别名与静态资源处理深度解析
    • 获取图片高度
    • 深入理解 Pinia:storeToRefs vs toRefs - 避开 TypeScript 类型推断的坑
  • webSocket

    • WebSocket 鉴权入门(实战版):握手票据 / 首帧鉴权 + 连接级 cid
  • Electron

    • 快速构建 electron + vue3 项目
  • TS

    • TS 泛型
    • 记录模板使用断言的问题
    • type 与 interface
  • WebPack

    • Webpack 介绍
  • Vite

    • Vite CLI 常见命令
    • vite 与 webpack 比较
    • Vite base 配置与 public 静态资源路径问题
  • 客户端数据库

    • IndexedDB 索引陷阱:字段重命名后索引返回旧数据
    • IndexedDB 事务陷阱与最佳实践:TransactionInactiveError 完全解析
  • UniApp

    • uni-app + Monorepo + 微信小程序热更新失效问题排查与结论总结
    • Monorepo + uni-app 开发中监听 / 热更新机制差异与解决方案
    • uni-app 运行与编译原理梳理
    • 为什么 uni-app 在 Monorepo 中必须做「环境区分」,而 Web 项目却不用?
  • 项目工程

    • 前端代码风格
    • Vue3 项目规范
    • npm 镜像问题
    • 包管理工具
    • 使用 engines 限制开发环境依赖
    • 打包与shell交互指定模式
    • Monorepo示例
    • PC 项目兼容 uni-app:基于 pnpm Monorepo 的工程化重构实践
    • Vue3 + Vite + pnpm Monorepo 迁移问题指南
    • 前端工程化:深入理解 npm 依赖管理机制
    • pnpm 修改依赖源码打包报错
    • 代码片段
  • 移动端

    • 浏览器跳转外部应用时请求状态码问题
  • 记录一下小程序
  • 控制浏览器正确保存网站账号密码的技巧
  • 信息泄露
  • 使用Gulp实现前端项目哈希化压缩打包
  • 前端缓存策略与文件哈希化的关联解析
  • 浏览器刷新方式与 Vue 生命周期、缓存行为解析
  • 浏览器消息铃声实现记录

前端工程化:深入理解 npm 依赖管理机制

从 dependencies vs peerDependencies 到 npm 包构建与分发,解密前端依赖管理的完整链路。

问题的起源

在 Monorepo 项目中,我遇到了这样的困惑:

// element-plus/package.json
{
  "dependencies": {
    "lodash-unified": "^1.0.0",
    "@vueuse/shared": "^10.0.0"
  }
}

疑问:

  1. element-plus 配置的这些依赖,是打包在 element-plus 里面的吗?
  2. 我安装 element-plus 时,是 element-plus 自己去安装这些依赖吗?
  3. 为什么我的项目需要配置 optimizeDeps.include 才能找到这些依赖?
  4. dependencies 和 peerDependencies 到底有什么区别?
  5. peerDependencies 指定 vue,是不是用父项目的?这个声明只是看与父项目的版本差异是否巨大?
  6. element-plus 的依赖我在 node_modules 根目录找不到,但 pnpm-lock.yaml 里可以看到,这是为什么?

带着这些问题,我深入研究了 npm 依赖管理的完整机制。


一、dependencies vs peerDependencies

1.1 核心区别

配置项dependenciespeerDependencies
含义"我需要这个依赖,请自动安装""我需要这个依赖,但让父项目提供"
安装行为每个包独立安装,可能有多个版本使用父项目的版本,全局唯一
适用场景工具库(lodash、dayjs、axios)框架、插件(vue、react、pinia)

1.2 为什么需要 peerDependencies?

场景:element-plus 需要 vue

❌ 错误方式:使用 dependencies

// element-plus/package.json
{
  "dependencies": {
    "vue": "^3.5.0"
  }
}

// 项目里面 apps/uni/package.json
{
  "dependencies": {
    "vue": "^3.5.13",
    "@zkzq/hooks": "workspace:*"
  }
}

结果:两个 vue 实例!

node_modules/
├── [email protected]                    # apps/uni 的
└── element-plus/
    └── node_modules/
        └── [email protected]              # element-plus 自己的

问题:

  • ❌ Vue 响应式系统失效(ref 在一个实例创建,但在另一个实例使用)
  • ❌ 运行时报错:[Vue warn]: Invalid VNode type
  • ❌ 打包体积增大(两份 vue)

✅ 正确方式:使用 peerDependencies

// element-plus/package.json
{
  "peerDependencies": {
    "vue": "^3.5.0"  // 告诉父项目:"我需要 vue,但你来装"
  }
}

// apps/uni/package.json
{
  "dependencies": {
    "vue": "^3.5.13",
    "@zkzq/hooks": "workspace:*"
  }
}

结果:只有一个 vue 实例!

node_modules/
├── [email protected]                    # 唯一的 vue
└── element-plus/  (symlink,共享父项目的 vue)

优势:

  • ✅ 只有一个 vue 实例
  • ✅ 响应式系统正常工作
  • ✅ 打包体积最小

1.3 类比理解

dependencies(自己带工具):

你是木匠(element-plus),需要锤子(vue)

"我自己带一把锤子"

结果:
- 你带了锤子 3.5.0
- 工地(apps/uni)有锤子 3.5.13
- 两把锤子可能用错 → 出问题 ❌

peerDependencies(用工地的工具):

你是木匠(element-plus),需要锤子(vue)

"我需要锤子,但用工地的那把"

结果:
- 检查工地有没有锤子
- 有一把 3.5.13
- 大家都用这把 → 不会出问题 ✅

1.4 选择规则

依赖类型使用场景应该用什么
框架库vue, react, angularpeerDependencies(必须单例)
状态管理pinia, vuex, reduxpeerDependencies(必须单例)
路由vue-router, react-routerpeerDependencies(必须单例)
UI 组件库element-plus, ant-designpeerDependencies(避免重复打包)
工具库lodash, dayjs, axiosdependencies(可以多个版本)

二、npm 包的构建与分发机制

2.1 核心问题:element-plus 的依赖打包在哪里?

element-plus 发布到 npm 时的结构

element-plus/
├── package.json
├── dist/
│   ├── index.mjs          # ESM 格式(已构建)
│   ├── index.cjs          # CommonJS 格式
│   └── index.d.ts         # TypeScript 类型
└── es/
    └── components/
        └── button/
            └── index.mjs

dist/index.mjs 的内容(简化示例)

// element-plus/dist/index.mjs
// ❌ 注意:lodash-unified 没有被打包进来!
import { isString } from "lodash-unified"; // ← 仍然是 import 语句
import { useVModel } from "@vueuse/shared"; // ← 仍然是 import 语句

export function ElButton(props) {
  if (isString(props.type)) {
    // ...
  }
}

关键发现:element-plus 的 dist/ 文件保留了 import 语句,依赖没有被打包进去!


2.2 为什么不把依赖打包进去?

策略对比

策略 1:打包所有依赖(Bundle)❌

// 如果打包了所有依赖,dist/index.mjs 会变成:
function isString(value) {
  // lodash-unified 的代码内联
  return typeof value === "string";
}

export function ElButton(props) {
  if (isString(props.type)) {
    // ...
  }
}

问题:

  • ❌ 文件巨大(element-plus.mjs 可能 5MB+)
  • ❌ 如果你的项目也用 lodash-unified,会打包两份
  • ❌ 无法 tree-shaking(按需加载失效)

策略 2:保留 import 语句(External)✅

// element-plus 实际采用的方式
import { isString } from "lodash-unified"; // ← 保留 import

export function ElButton(props) {
  if (isString(props.type)) {
    // ...
  }
}

优势:

  • ✅ element-plus 包很小(只有自己的代码)
  • ✅ 依赖由用户项目统一管理
  • ✅ 支持 tree-shaking(只打包用到的代码)
  • ✅ 多个库共享依赖时,只会打包一份

2.3 npm 包的两种构建策略对比

对比项Library(库)Application(应用)
典型代表element-plus, lodash, vue你的项目
构建目标给别人用给用户访问
依赖处理External(保留 import)Bundle(完全打包)
发布到 npm✅ 是❌ 否
是否包含依赖❌ 否(在 dependencies 声明)✅ 是(打包到 dist/)

三、依赖的完整生命周期

3.1 阶段 1:安装时(pnpm install)

cd apps/uni
pnpm add element-plus

pnpm 做的事:

1. 下载 element-plus 包(包含 dist/ 构建产物)
   ↓
2. 读取 element-plus/package.json 的 dependencies:
   {
     "lodash-unified": "^1.0.0",
     "@vueuse/shared": "^10.0.0",
     "@ctrl/tinycolor": "^3.0.0"
   }
   ↓
3. 递归下载并安装这些依赖
   ↓
4. 所有依赖都安装到 node_modules/
   node_modules/
   ├── element-plus/
   │   └── dist/index.mjs  (已构建,但保留了 import)
   ├── lodash-unified/
   ├── @vueuse/shared/
   └── @ctrl/tinycolor/

此时:element-plus 和它的所有依赖都在 node_modules 中,但还没有打包到你的项目。


3.2 阶段 2:开发时(pnpm dev)

// apps/uni/src/main.ts
import { ElButton } from "element-plus";

Vite 做的事:

1. 读取你的代码:import { ElButton } from 'element-plus'
   ↓
2. 找到 node_modules/element-plus/dist/index.mjs
   ↓
3. 读取 element-plus 的代码:
   import { isString } from 'lodash-unified';
   ↓
4. 继续找到 node_modules/lodash-unified/
   ↓
5. 预构建(optimizeDeps):
   - 普通 Vite 项目:✅ 自动预构建 lodash-unified
   - uni-app 项目:❌ 不会预构建 → 报错
   ↓
6. 开发服务器提供所有模块

3.3 阶段 3:生产构建时(pnpm build)

pnpm build

Vite + Rollup 做的事:

1. 分析依赖树
   main.ts
     → element-plus/dist/index.mjs
       → lodash-unified/dist/index.mjs
       → @vueuse/shared/dist/index.mjs
   ↓
2. 把所有代码打包到一起
   dist/assets/index-abc123.js
   (包含:你的代码 + element-plus + lodash-unified + ...)
   ↓
3. Tree-shaking:只打包用到的代码
   - 只用了 ElButton → 不打包 ElTable
   - lodash-unified 只用了 isString → 只打包 isString
   ↓
4. ✅ 最终产物:优化后的 .js 文件

最终 dist/ 目录:

dist/
├── index.html
└── assets/
    └── index-abc123.js  # 包含所有依赖的完整代码

此时:所有依赖都被打包进去了,node_modules 不需要部署。


3.4 完整流程图

npm 包发布者(element-plus)                用户项目(apps/uni)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

源码阶段
├─ src/button.vue
├─ src/table.vue
└─ dependencies:
   lodash-unified

构建阶段(vite build)
├─ 编译 Vue 组件
├─ 保留 import 语句 (External)
└─ 生成 dist/index.mjs

发布到 npm
npm publish

1. pnpm add element-plus
━━━━━━━━━━━━━━━━━━━━━━
下载 element-plus
下载 lodash-unified
安装到 node_modules/

2. pnpm dev (开发)
━━━━━━━━━━━━━━━━━━
预构建依赖
启动开发服务器

3. pnpm build (生产)
━━━━━━━━━━━━━━━━━━
打包所有依赖
Tree-shaking
生成 dist/assets/

四、Vite 的依赖预构建机制

4.1 什么是依赖预构建?

官方定义(来自 Vite 文档):

Vite 会将以 CommonJS 或 UMD 发布的依赖项转换为 ESM,并将有许多内部模块的 ESM 依赖项转换为单个模块。

作用:

  1. 格式转换:CommonJS → ESM(浏览器可用)
  2. 性能优化:合并多个模块为一个(减少网络请求)
  3. 缓存优化:预构建结果缓存在 node_modules/.vite/

4.2 普通 Vite 项目 vs uni-app 项目

普通 Vite 项目(全量扫描)

// apps/web/vite.config.ts
export default defineConfig({
  plugins: [vue()]
});

行为:

你的代码 import element-plus
  ↓
Vite 扫描 element-plus/package.json → dependencies
  ↓
发现 lodash-unified、@vueuse/shared、@ctrl/tinycolor
  ↓
递归扫描这些包的 dependencies
  ↓
全部预构建 → ✅ 不会报错

uni-app 项目(选择性扫描)

// apps/uni/vite.config.ts
export default defineConfig({
  plugins: [uni()] // @dcloudio/vite-plugin-uni
});

行为:

你的代码 import element-plus
  ↓
uni() 插件只预构建 element-plus 本身
  ↓
❌ 不会扫描 element-plus 的 dependencies
  ↓
lodash-unified 等子依赖没有被预构建
  ↓
构建时找不到 → 报错

uni-app 为什么这样设计?

  • 目标环境复杂(H5、微信小程序、支付宝小程序、App)
  • 小程序有代码包大小限制(2MB 主包、20MB 分包)
  • 需要更严格的依赖控制

4.3 解决方案:optimizeDeps.include

// apps/uni/vite.config.ts
export default defineConfig({
  plugins: [uni()],
  optimizeDeps: {
    include: [
      "element-plus", // 你直接 import 的
      "lodash-unified", // element-plus 间接依赖的
      "@vueuse/shared", // element-plus 间接依赖的
      "@ctrl/tinycolor" // element-plus 间接依赖的
    ]
  }
});

作用:告诉 Vite "这些包也要预构建,即使我没有直接 import"。


五、Monorepo 中的特殊场景

5.1 workspace 包的源码引用

你的项目采用源码直接引用模式:

// packages/hooks/package.json
{
  "main": "index.ts", // ← 指向源码 .ts 文件
  "types": "index.ts"
}

与 npm 包的区别:

对比项npm 包(element-plus)workspace 包(packages/hooks)
main 指向"./dist/index.mjs""./index.ts"
分发形式构建产物源码
是否预构建是(在开发服务器启动时)否(实时编译)
依赖处理dependenciespeerDependencies(框架类)

5.2 workspace 包的依赖问题

场景:packages/hooks 缺少 vue 依赖声明

// packages/hooks/package.json
{
  "dependencies": {
    "@zkzq/services": "workspace:*"
    // ❌ 缺少 vue 依赖声明
  }
}
// packages/hooks/src/useTable.ts
import { ref } from "vue"; // ❌ TypeScript: 找不到模块 'vue'

问题链:

1. apps/uni 引用 @zkzq/hooks
   ↓
2. hooks/src/useTable.ts 中 import { ref } from 'vue'
   ↓
3. uni-app 构建时在 hooks 的依赖树中找 vue
   ↓
4. hooks 没有声明 vue(无论是 dependencies 还是 peerDependencies)
   ↓
5. ❌ 报错:"Cannot find module 'vue'"

解决方案:使用 peerDependencies

// packages/hooks/package.json
{
  "peerDependencies": {
    "vue": "^3.5.0"  // ← 告诉父项目:"我需要 vue,但让你提供"
  },
  "dependencies": {
    "@zkzq/services": "workspace:*"
  }
}

// apps/uni/package.json
{
  "dependencies": {
    "vue": "^3.5.13",          // ← 提供 vue
    "@zkzq/hooks": "workspace:*"
  }
}

效果:

1. apps/uni 引用 @zkzq/hooks
   ↓
2. hooks 的 peerDependencies 要求 vue
   ↓
3. uni-app 构建时在 apps/uni 的依赖树中找 vue
   ↓
4. ✅ 找到了 [email protected]
   ↓
5. hooks 使用 uni 提供的 vue → 只有一个 vue 实例

六、常见问题解答

Q1: 为什么删除 node_modules 重新安装能解决问题?

原因:

  1. Vite 的预构建缓存在 node_modules/.vite/,可能出现旧缓存
  2. pnpm 的 symlink 可能在某些情况下创建失败
  3. 重新安装会重新解析依赖树,重新创建 symlink

更好的方法:

# 只清除 Vite 缓存
rm -rf node_modules/.vite
pnpm dev

Q2: 为什么 Web 项目正常,uni-app 报错?

根本原因:@dcloudio/vite-plugin-uni 关闭了全量依赖扫描。

项目类型依赖扫描策略子依赖预构建
Web(标准 Vite)全量扫描✅ 自动预构建
uni-app选择性扫描❌ 不会自动预构建

解决方法:使用 optimizeDeps.include 手动指定子依赖。


Q3: dependencies 到底会不会把依赖打包进去?

答案:看发布者是谁。

npm 包发布者(element-plus):

  • ❌ 不会打包依赖到 npm 包(dist/)
  • ✅ 依赖保留为 import 语句
  • ✅ 在 dependencies 中声明,让用户自动安装

用户项目(apps/uni):

  • ✅ 会打包所有依赖到最终产物(dist/assets/)
  • ✅ 包含 element-plus + lodash-unified + 所有依赖
  • ❌ 部署时不需要 node_modules

Q4: peerDependencies 未满足会怎么样?

pnpm 会发出警告:

pnpm install

 WARN  Issues with peer dependencies found
.
└─┬ @zkzq/hooks
  └── ✕ missing peer vue@^3.5.0

后果:

  • ⚠️ TypeScript 类型检查可能报错
  • ⚠️ 运行时可能找不到模块
  • ⚠️ 如果有多个版本,可能出现实例冲突

Q5: peerDependencies 指定 vue,是不是用父项目的?这个声明只是看与父项目的版本差异是否巨大?

第一个问题:是否使用父项目的依赖?

✅ 是的,peerDependencies 会使用父项目的依赖。

// packages/hooks/package.json
{
  "peerDependencies": {
    "vue": "^3.5.0"
  }
}

// apps/uni/package.json
{
  "dependencies": {
    "vue": "^3.5.13"
  }
}

结果:

  • ✅ @zkzq/hooks 会使用 apps/uni 安装的 [email protected]
  • ✅ 不会重复安装 vue
  • ✅ 全局只有一个 vue 实例

第二个问题:不是"差异是否巨大",而是严格的版本范围匹配

❌ 错误理解:"只是看版本差异是否巨大"

✅ 正确理解:"检查父项目版本是否满足指定的版本范围(semver)"

pnpm install 时的检查逻辑

1. 读取 @zkzq/hooks 的 peerDependencies: { "vue": "^3.5.0" }
   ↓
2. 检查父项目(apps/uni)是否安装了 vue
   ↓
3. 检查父项目的 vue 版本是否满足 "^3.5.0" 这个范围
   ↓
4. 如果满足 → ✅ 不报警告
   如果不满足 → ⚠️ 发出警告

版本范围的含义(semver)

^3.5.0 的含义:

^3.5.0  表示:>= 3.5.0  且  < 4.0.0

✅ 满足的版本:
- 3.5.0
- 3.5.13
- 3.6.0
- 3.99.99

❌ 不满足的版本:
- 3.4.9   (小于 3.5.0)
- 4.0.0   (主版本号变化)
- 2.7.0   (主版本号变化)

其他常见范围符号

写法含义示例
^3.5.0>= 3.5.0 且 < 4.0.03.5.x, 3.6.x ✅
4.0.0 ❌
~3.5.0>= 3.5.0 且 < 3.6.03.5.x ✅
3.6.0 ❌
>=3.5.0>= 3.5.0(无上限)3.5.0, 4.0.0 ✅
3.5.0精确匹配只有 3.5.0 ✅
^2.0.0 || ^3.0.02.x 或 3.x2.7.0, 3.5.0 ✅

版本不匹配的实际后果

场景 1:版本满足要求 ✅

// packages/hooks → peerDependencies: { "vue": "^3.5.0" }
// apps/uni → dependencies: { "vue": "3.6.2" }
pnpm install
# ✅ 无警告,3.6.2 满足 ^3.5.0

场景 2:版本不满足要求 ❌

// packages/hooks → peerDependencies: { "vue": "^3.5.0" }
// apps/uni → dependencies: { "vue": "3.4.0" }
pnpm install

 WARN  Issues with peer dependencies found
.
└─┬ @zkzq/hooks
  └── ✕ unmet peer vue@^3.5.0: found 3.4.0

后果:

  • ⚠️ 可能存在 API 不兼容(vue 3.4 缺少 3.5 的新特性)
  • ❌ 运行时可能报错

场景 3:版本相差"巨大" ❌

// packages/hooks → peerDependencies: { "vue": "^3.5.0" }
// apps/uni → dependencies: { "vue": "2.7.16" }
pnpm install

 ERR  Issues with peer dependencies found
.
└─┬ @zkzq/hooks
  └── ✕ unmet peer vue@^3.5.0: found 2.7.16

后果:

  • ❌ API 完全不兼容(Vue 2 vs Vue 3)
  • ❌ 运行时直接报错

为什么这样设计?

不是"巨大"的模糊概念,而是 API 兼容性

Vue 的版本规则(semver):

主版本号.次版本号.补丁号
   X   .   Y   .   Z

X 变化 → 破坏性变更(API 不兼容)
Y 变化 → 新增功能(向后兼容)
Z 变化 → Bug 修复(向后兼容)

举例:

peerDependencies父项目版本是否兼容原因
^3.5.03.5.13✅ 完全兼容只是 bug 修复
^3.5.03.6.0✅ 兼容新增功能,旧 API 仍可用
^3.5.03.4.9⚠️ 可能不兼容hooks 可能用了 3.5 的新特性
^3.5.04.0.0❌ 不兼容主版本变化,破坏性更新
^3.5.02.7.0❌ 完全不兼容Vue 2 vs Vue 3

总结

你的理解实际情况
"使用父项目的依赖"✅ 完全正确
"只是看版本差异是否巨大"❌ 不准确
正确理解检查父项目版本是否满足指定的版本范围(semver)

核心要点:

  1. ✅ peerDependencies 确实会使用父项目的依赖
  2. ✅ 版本号(如 ^3.5.0)是一个精确的范围要求,不是模糊的"巨大"概念
  3. ✅ 检查的是 API 兼容性,而不是"差异大小"
  4. ✅ 如果不满足范围,会发出警告,可能导致运行时错误

Q6: element-plus 的依赖我在 node_modules 根目录找不到,但 pnpm-lock.yaml 里可以看到,这是为什么?

你观察到的现象

# 在 node_modules 根目录执行
ls node_modules/
# 输出:
element-plus/
vue/
pinia/
@zkzq/
# ❌ 没有看到 lodash-unified、@vueuse/shared

# 但在 pnpm-lock.yaml 里可以看到
pnpm-lock.yaml:
  element-plus:
    dependencies:
      lodash-unified: 1.0.3
      @vueuse/shared: 10.7.0
      @ctrl/tinycolor: 3.6.1

疑问:这些依赖到底装没装?装在哪里了?


pnpm 的特殊依赖管理机制

npm/yarn 的方式(扁平化)
node_modules/
├── element-plus/        # 你安装的
├── lodash-unified/      # element-plus 的依赖(提升到根目录)
├── @vueuse/shared/      # element-plus 的依赖(提升到根目录)
├── @ctrl/tinycolor/     # element-plus 的依赖(提升到根目录)
└── vue/

特点:

  • ✅ 所有依赖都在根目录,容易看到
  • ❌ 幽灵依赖(你没声明也能用)
  • ❌ 依赖重复(多个版本会有冗余)

pnpm 的方式(严格隔离)

Monorepo 项目的结构(重要纠正):

zkzq/                          # ← Monorepo 根目录
├── node_modules/              # ← .pnpm 在根目录的 node_modules
│   ├── element-plus/          # symlink
│   ├── vue/                   # symlink
│   ├── pinia/                 # symlink
│   └── .pnpm/                 # ← 真正的依赖存储位置(根目录)
│       ├── [email protected]/
│       │   └── node_modules/
│       │       ├── element-plus/
│       │       ├── lodash-unified/       # ← lodash-unified 在这里!
│       │       ├── @vueuse/shared/       # ← @vueuse/shared 在这里!
│       │       └── @ctrl/tinycolor/
│       ├── [email protected]/
│       └── [email protected]/
├── apps/
│   ├── web/
│   └── uni/
└── packages/
    ├── hooks/
    └── core/

关键点:

  • ✅ Monorepo 中,.pnpm 在根目录的 node_modules/.pnpm/(不是每个子项目都有)
  • ✅ node_modules/element-plus 是一个 symlink(符号链接)
  • ✅ 真实文件在 node_modules/.pnpm/[email protected]/node_modules/element-plus
  • ✅ lodash-unified 等依赖也在 .pnpm/[email protected]/node_modules/ 下
  • ✅ 严格隔离,你的代码不能直接 import 'lodash-unified'(除非你显式声明)

为什么这样设计?

npm/yarn 的问题:幽灵依赖
// 你的 package.json
{
  "dependencies": {
    "element-plus": "^2.11.2"
    // ❌ 没有声明 lodash-unified
  }
}
// 你的代码
import { isString } from "lodash-unified"; // ❌ 居然能用!

// 因为 npm/yarn 把 lodash-unified 提升到了 node_modules 根目录

问题:

  • ❌ 你没声明依赖,却能用(幽灵依赖)
  • ❌ element-plus 哪天不用 lodash-unified 了,你的代码就崩了
  • ❌ 依赖关系不清晰

pnpm 的解决方案:严格隔离
你的代码只能访问:
  node_modules/
  ├── element-plus/  ✅ 你声明了,可以用
  ├── vue/           ✅ 你声明了,可以用
  └── .pnpm/         ❌ 隐藏目录,不能直接访问

你不能访问:
  node_modules/.pnpm/[email protected]/node_modules/lodash-unified/
  ↑ 这是 element-plus 的私有依赖,你没声明,不能用

如果你尝试:

// 你的代码
import { isString } from "lodash-unified";

结果:

✘ [ERROR] Could not resolve "lodash-unified"

正确做法:

# 如果你真的需要用,必须显式声明
pnpm add lodash-unified

pnpm-lock.yaml 的作用

# pnpm-lock.yaml(简化)
lockfileVersion: "6.0"

dependencies:
  element-plus:
    specifier: ^2.11.2
    version: 2.11.2([email protected])

packages:
  /@ctrl/[email protected]:
    resolution: { integrity: sha512-xxx }

  /[email protected]:
    resolution: { integrity: sha512-xxx }
    dependencies:
      "@ctrl/tinycolor": 3.6.1
      "@vueuse/core": 10.7.0
      lodash-unified: 1.0.3 # ← 记录了子依赖

作用:

  1. 锁定完整的依赖树:记录了所有包(包括子依赖)的确切版本
  2. 确保一致性:团队成员、CI/CD 安装的版本完全一致
  3. 记录依赖关系:哪个包依赖了哪个包的哪个版本

验证方法

# 1. 查看根目录(只有直接依赖)
ls node_modules/

# 2. 查看 .pnpm 目录(所有真实依赖)
ls node_modules/.pnpm/

# 3. 查看 element-plus 的依赖
ls node_modules/.pnpm/[email protected]*/node_modules/

# 4. 查看 symlink
ls -la node_modules/element-plus
# 输出:element-plus -> .pnpm/[email protected][email protected]/node_modules/element-plus

# 5. 查看全局 store
pnpm store path
# 输出:~/.pnpm-store/v3

总结

你的问题的答案:

  1. ✅ 这些依赖确实被安装了,在 node_modules/.pnpm/ 隐藏目录里
  2. ✅ pnpm-lock.yaml 记录了完整的依赖树,包括子依赖的确切版本
  3. ✅ 根目录看不到,是因为 pnpm 采用严格隔离机制,子依赖只能被声明它们的包访问
  4. ✅ 这是设计行为,避免幽灵依赖,确保依赖关系清晰
  5. ✅ Monorepo 中,.pnpm 在根目录的 node_modules/.pnpm/,所有子项目共享

pnpm vs npm/yarn 对比表:

对比项npm/yarnpnpm
依赖位置全部扁平化到根目录隔离在 .pnpm/
根目录可见✅ 所有依赖都可见❌ 只有直接依赖可见
幽灵依赖❌ 可以用未声明的包✅ 不能用未声明的包
磁盘占用每个项目独立存储全局硬链接共享
安装速度较慢很快
Monorepo每个子项目有 node_modules根目录统一管理

七、最佳实践总结

7.1 依赖声明规范

npm 包发布(packages/hooks、packages/core)

{
  "peerDependencies": {
    "vue": "^3.5.0", // 框架类必须用 peer
    "pinia": "^2.0.0 || ^3.0.0" // 状态管理必须用 peer
  },
  "dependencies": {
    "dayjs": "^1.11.0", // 工具库用 dependencies
    "@zkzq/utils": "workspace:*" // workspace 包用 dependencies
  }
}

应用层(apps/web、apps/uni)

{
  "dependencies": {
    "vue": "^3.5.13", // 提供 peerDependencies 要求的依赖
    "pinia": "^2.1.7",
    "element-plus": "^2.11.2", // UI 库可以在应用层
    "@zkzq/hooks": "workspace:*"
  }
}

7.2 uni-app 项目配置

// apps/uni/vite.config.ts
export default defineConfig({
  plugins: [uni()],
  resolve: {
    dedupe: ["vue", "pinia"] // 防止多实例
  },
  optimizeDeps: {
    include: [
      "vue",
      "pinia",
      // 如果使用了 npm 包,手动添加其子依赖
      "element-plus",
      "lodash-unified",
      "@vueuse/shared"
    ],
    exclude: [
      // workspace 包排除预构建(使用源码)
      "@zkzq/core",
      "@zkzq/hooks",
      "@zkzq/store"
    ]
  }
});

7.3 依赖选择原则

依赖类型使用场景应该用什么
框架vue, reactpeerDependencies
状态管理pinia, reduxpeerDependencies
路由vue-routerpeerDependencies
UI 组件库element-pluspeerDependencies(库)/ dependencies(应用)
工具库lodash, dayjsdependencies
workspace 包@zkzq/*dependencies (workspace:*)

7.4 Monorepo 架构分层

packages/core (跨端业务逻辑)
  ✅ 可依赖:dayjs、lodash、axios(纯 JS)
  ❌ 不可依赖:element-plus、uni-ui(平台专属)

apps/web (Web 应用)
  ✅ 可依赖:element-plus、echarts

apps/uni (uni-app 应用)
  ✅ 可依赖:@dcloudio/uni-ui、uqrcodejs

八、总结

8.1 关键概念回顾

  1. dependencies:

    • "我需要这个依赖,请自动安装"
    • npm 包不会把依赖打包进去,只是声明"用户需要安装这些"
    • 用户项目 build 时才会把所有依赖打包到 dist/
  2. peerDependencies:

    • "我需要这个依赖,但让父项目提供"
    • 确保全局只有一个版本(框架、插件必须用)
    • 避免多实例问题
  3. npm 包的分发:

    • 发布的是构建产物(dist/),但保留 import 语句
    • 依赖不打包,在 dependencies 中声明
    • 用户项目负责最终打包
  4. Vite 预构建:

    • 普通项目:全量扫描,自动预构建所有依赖
    • uni-app:选择性扫描,需要手动 optimizeDeps.include

8.2 核心规律

npm 包构建流程:
  源码 → 编译 → dist/(保留 import)→ npm publish

用户项目使用流程:
  pnpm install → 安装所有依赖到 node_modules
  → pnpm dev → Vite 预构建(开发模式)
  → pnpm build → 打包所有依赖到 dist/(生产模式)

最终产物:
  dist/assets/index.js  (包含你的代码 + 所有依赖)

8.3 实战建议

  1. Monorepo 中的 workspace 包:

    • 框架类依赖用 peerDependencies
    • 保持源码引用(不要构建)
    • 让应用层提供框架依赖
  2. uni-app 项目:

    • 避免在共享包中使用平台专属库
    • 手动配置 optimizeDeps.include
    • 使用 uni-app 兼容的库(uqrcodejs、uni-ui)
  3. 遇到依赖问题:

    • 优先清除 Vite 缓存:rm -rf node_modules/.vite
    • 检查 peerDependencies 是否满足
    • 检查是否需要手动 include 子依赖

参考资料

  • npm dependencies vs peerDependencies
  • Vite 依赖预构建官方文档
  • pnpm workspace 文档
  • uni-app Vite 配置文档

本文基于实际 Monorepo 项目开发经验总结,涵盖了从依赖管理到构建打包的完整链路。希望能帮助你深入理解前端工程化中的依赖管理机制。

最后更新时间:
贡献者: 何风顺
上一页
Vue3 + Vite + pnpm Monorepo 迁移问题指南
下一页
pnpm 修改依赖源码打包报错