前言
这篇文章主要是想总结一下vue项目结构的环境搭建,方便后续可以在这个基础上总结一些比较常用功能的原理。vue3整体采用了Monorepo 的架构,所以我们很有必要先了解一下pnpm这个包管理工具。
pnpm介绍
pnpm全称为 Profermance Node Package Manager,从名字上看是一个高性能的包管理工具,那么到底性能高在哪里了呢?接着往下看!叭~ 当然还是先把官网地址双手奉上。
背景
阶段一
首先先从背景说起,在早期管理依赖的方式是有缺陷的。比如说以下例子,我有一个模块 A,在这个模块中引入了 B 和 C 两个依赖,而这两个依赖又对 D 有一个共同的依赖,依赖树结构如下:
css
-A
-B
-D
-C
-D
这里就出现了不太合理的地方,依赖 B 和 C 都对 D 有一个依赖关系但是D 依赖却分别在 BC 依赖中各自保存了一份,这就有点说不过去了,因为上述依赖树会造成磁盘空间的浪费,比较好的解决方法就是只保存一份 D 依赖,让 B 和 C 引用同一个地方的依赖,由此出现了扁平化依赖。
阶段二
扁平化就是把所有的依赖全部拍平统一放在一个目录下,即node_modules 文件夹,所有的依赖在引用其他依赖时都会去node_modules这一目录下去查找,也就是我们现在经常用的一种模式,依赖树结构如下:
css
node_modules
-B
-C
-D
这样一来就解决了依赖重复安装的问题,但随之而来又带来了新的问题,那就是著名的幽灵依赖(Phantom dependances)。
幽灵依赖是指一个项目中安装的一个依赖包 A 中依赖了其他包,由于依赖是被拍平的,所以这时在项目中也可以直接访问到 A 和A的其他依赖包,这样一来当 A 依赖的包被删除时 A 中的其他依赖包也会被删除从而导致项目中原先使用其他依赖的地方出现报错,找不到该依赖,这就是npm依赖扁平化的缺点。
阶段三
这时候 pnpm 闪亮登场了,可以说 pnpm 不仅提升了包管理能力也提高了安装效率。针对幽灵依赖这一问题,pnpm 是这样做的:
首先当你执行 pnpm i
命令的时候会默认在node_modules文件夹在生成一个.pnpm 文件夹并将当前项目中引用的依赖当做快捷方式一并存储(与.pnpm文件夹同级),所有依赖扁平化的真正实体文件其实都保存在.pnpm 文件中。这里其实涉及到两个概念:软链接(Symbolic Link )和硬链接(Hard Link)

可以看到箭头指向的标识就是软链接的标志,并且在.pnpm 文件夹中也能找到对应的实体文件。
其中软链接就可以理解为是一个文件的快捷方式,只是复制了文件或目录的路径。当使用一个依赖时会先在node_modules文件夹下查找,找到之后再软链接到.pnpm文件夹下,.pnpm文件夹中的每个依赖还会有在.pnpm-store 文件夹中对应的硬链接,.pnpm 文件夹中的依赖是直接安装到我们的磁盘上的,.pnpm-store 文件夹可以通过pnpm store path
命令找到。
这也就解释了为什么 pnpm 安装效率更高,它会先去.pnpm-store文件中读取,没有的话才会进行下载。 总的来说就是node_modules访问.pnpm文件是软链接的形式,.pnpm文件中才是硬链接的形式。
css
node_modules
.pnpm .pnpm-store
-A HL --> -A
node_modules
-B SL
-B HL --> -B
-A SL
上面就是 pnpm 安装后的依赖结构,只要不删除依赖的硬链接依赖将永远存在除非手动进行删除。
通过增加.pnpm 文件夹以及软链接和硬链接的方式使我们不能直接访问 node_modules 文件夹下的依赖,从而巧妙的解决了幽灵依赖的问题。
搭建开发环境
首先第一步需要先使用pnpm init
命令初始化package.json文件(注意声明"type":"module"字段),然后新建一个 packages 文件夹用于管理所有的Monorepo项目,并在根目录新建 pnpm-workspace.yaml 文件来告诉 pnpm 该文件夹下的文件都是 Monorepor 项目。pnpm-workspace.yaml 文件内容如下:
yaml
packages:
- 'packages/*' // 匹配packages下的所有文件
然后安装后续需要使用到的一些依赖:
- typescript
- esbuild (开发环境下性能更好)
- minimist (解析命令行中的参数,后面会说到)
这时候使用 pnpm 安装就会有一个错误提示,比如执行pnpm install vue

这是因为生成 pnpm-workspace.yaml 文件后使用 pnpm 全局安装依赖需要加上-w
参数来明确告诉 pnpm 到底要安装到哪里,因为你可能是在其他目录下执行安装命令。
接着执行pnpm install typescript esbuild minimist -D -w
命令来安装表示这些依赖都是所有项目都要用到的依赖,然后执行tsc --init
命令初始化生成 tsconfig.json 文件,并设置相应的配置:
ts
{
"compilerOptions": {
"outDir": "dist",
"module": "nodenext",
"target": "es2016",
"types": [],
"sourceMap": true,
"strict": true,
"resolveJsonModule": true, // 允许导入 json 文件
"esModuleInterop": true, // 允许通过 es6使用 CommonJS 模块
"jsx": "preserve", // 保留 jsx 语法,不进行编译
"baseUrl": ".",
}
}
接下来需要一个帮我们打包 packages 下的模块并最终生成 js 文件的地方即入口文件,可以在根目录下新建一个 scripts 文件夹并在里面新建一个 dev.js 文件,这时候就可以在 package.json 文件中配置 scripts 属性:
json
"scripts": {
// node 路径 要打包的名字 -f 输出代码的格式
"dev": "node scripts/dev.js myReactive -f esm"
},
这行代码的意思是当执行npm run dev
命令的时候会使用 node 运行 dev.js 这个脚本,后面是要打包的模块名称,比如说要打包 reactive 响应式的代码,-f 是指输出代码的格式,默认是 iife(立即执行函数),还有可选项就是 esm 和 cjs。 下面就可以开始在 dev.js 中进行编码:
js
import minimist from 'minimist'
// node中的命令参数通过 process.argv 获取
console.log(process.argv) // ['xxx路径','xxx路径','myReactive',-f','esm']
// 去除前两个参数,只需要获取到传递过来的配置参数即可
const argv = minimist(process.argv.slice(2))
console.log(argv) // { _: [ 'myReactive' ], f: 'esm' }
const target = argv._[0]
const format = argv.f || 'iife'
console.log(target, format) // myReactive esm
这就是 minimist 这个库的一个作用,通过上面的代码已经获取到了运行脚本文件时传递的配置参数,接下来就可以进行打包操作了。 首先新建这样统一的目录级别:

分别初始化两个文件的依赖文件并做相关修改:
json
// myReactive
{
"name": "@myVue/myReactive",
"version": "1.0.0",
"module": "dist/myReactive.esm-bundler.js",
"unpkg": "dist/myReactive.global.js"
}
// myShared
{
"name": "@myVue/myShared",
"version": "1.0.0",
"module": "dist/myShared.esm-bundler.js",
}
module 和 unpkg 两个字段表示提供了两种引入包的方式,一种是通过 esm 的方式一种是通过全局变量的方式。
然后在分别在 js 文件里面写点东西
js
// myShare
export function isObject(obj:any){
return obj!==null && typeof obj==='object'
}
// myReactive
import {isObject} from '../../myShared/src'
isObject(123)
可以看到 myReactive 文件中引入isObject 方法的路径是一个相对路径,这样其实并不友好,如果我们想要用@myVue/myShared 的方式引入呢(对应 myShared 中的 packages.json 中的 name 属性),首先需要先配置一下 tsconfig.json 文件
json
{
...,
"paths": {
"@myVue/*": ["packages/*/src"]
}
}
//这样就可以使用了,ctrl+鼠标左键该路径即可直接跳转到该方法
// myReactive
import {isObject} from '@myVue/myShared'
isObject(123)
既然 myReactive 模块依赖 myShared 中的方法,那么就可以将 myShared 来作为一个本地依赖安装到myReactive 模块中,所以可以执行pnpm i @myVue/myShared --workspace --filter @myVue/myReactive
命令来进行安装,其中--workspace 表示作为本地依赖,--filter 表示只给某个模块安装。
回到 dev.js 文件中添加打包代码:
js
import {resolve,dirname} from 'path'
import { fileURLToPath } from 'url'
import esbuild from 'esbuild'
import { createRequire } from 'module'
// 先获取文件的绝对路径 是以 file:格式开头
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename) // 获取当前文件所在文件夹路径
const require=createRequire(import.meta.url)
// node 中 esm 模块不能使用 __dirname 这个变量,需要使用 dirname 方法获取当前文件所在文件夹路径
const entry=resolve(__dirname,`../packages/${target}/src/index.ts`) // 入口文件
// 获取目标文件中package.json 配置的信息 esm 中不能使用 require所以需要自定义
const pkg=require(`../packages/${target}/package.json`)
esbuild.context({
entryPoints: [entry],
outfile: resolve(__dirname,`../packages/${target}/dist/${target}.js`),
bundle: true, // myReactive 依赖了 myShared 会打包到一起
platform: 'browser', // 打包的目标平台
format,
globalName: pkg.buildOptions.globalName, // 看文章最后有说到
sourcemap: true,
}).then(ctx=>{
console.log('打包成功')
return ctx.watch() // 监听入口文件持续进行打包处理
})
此时执行npm run dev
命令就会生成 dist 文件夹
当然我们也可以设置代码的输出格式,比如想输出为 iife 的格式,只需改为`node scripts/dev.js myReactive -f iife`或不写-f 参数,iife格式的代码长这个样子:

其中 MyVue 这个全局变量也可以在package.json 文件中进行配置,即:
json
// myReactive/package.json
{
...,
"buildOptions": {
"globalName": "MyVue"
}
}
上述代码中的globalName: pkg.buildOptions.globalName
就是取自这里。
后面就开始基于这个环境来实现vue中的一些功能。