1. Vue 2和Vue 3的项目结构比较
vue2的项目结构如下:
1.1 核心库
- Vue 2的核心库位于src/core目录,包含了Vue的核心功能和逻辑,如数据绑定、虚拟DOM、组件系统等。
- Vue 2的代码库采用了单体应用架构(Monolithic),所有核心功能都包含在一个巨大的src目录下,这使得代码的维护和理解变得相对困难。
1.2 运行时
- 运行时构建是Vue 2的一个独立部分,它包括运行时版本的Vue.js,用于在浏览器中运行的应用程序。
- 运行时构建不包含编译器,因此需要在构建时将Vue模板编译为渲染函数。
- 在
src/platforms/web/runtime
下。
1.3 编译器
用于将Vue模板编译成渲染函数。src/compiler
。
1.4 辅助工具和插件
Vue 2还包含了一些辅助工具和插件,如Vue Router、Vuex等,它们都是Vue生态系统的一部分。需要npm引入。
Vue 3的项目结构完全不一样,项目组织也完全不同:
可以看到,项目解构更扁平化了,并且各种功能都拆分到packages目录下了。
1.1 核心库
- Vue 3的核心库位于packages目录,它将核心功能划分为多个独立的包,每个包都有明确定义的职责
- 这种模块化的结构使得理解和维护代码变得更容易,同时也为未来的扩展和优化提供了更大的灵活性。
1.2 运行时
与Vue 2不同的是,Vue 3的运行时构建已经包括了编译器,这意味着它能够在客户端动态编译模板,减少了构建时的复杂性。
1.3 编译器
Vue 3的编译器成为了独立的包,位于packages/compiler-core
目录下,与核心库相分离。
1.4 辅助工具和插件
与Vue 2一样,Vue 3仍然支持众多辅助工具和插件,但它们通常也采用了更模块化的结构。
vue3项目结构的变化,采用了更好的模块化结构,使得项目代码更好维护和扩展。而且也比vue2代码更便于阅读。(vue2源码读起来层级很深,有时候会看了后面找不到前面的代码位置)。但是各自侧重点不同,传统的单体应用架构(Monolithic)组织架构,更适用于规模不大的小型项目。而后者更便于大型项目进行功能拆分。
2. Monorepo架构
目前几乎所有的大型前端开源项目,都采用该架构进行项目搭建了。比如 Google、Facebook、React、Vue3等等。 既然大家都用它,那它究竟有啥“过人之处”呢。
发展历程可用下面这个图来概括:
从初期的业务系统不复杂,只用一个仓库来管理项目,项目为单体应用架构 Monolithic。
到后来发现项目越来越大,业务越来越复杂,导致了一系列的问题:比如项目编译速度变慢(调试成本变大)、部署效率/频率低(非业务开发耗时增加)、很多公共的模块不需要每次都重新编译打包,技术债务越积越多。这时候迫切需要组件化/模块化开方来分割下这个项目。
后来大家都把公共的部分分割成一个独立的仓库,然后通过npm引入,这样的优势就是每个仓库都能独立进行各模块的编码、测试和发版,又能实现多项目共享代码,开方人员的关注点分离。这种管理模式称之为多仓多模块管理 Multirepo。
再随着时间的沉淀,模块数量也在飞速增长。Multirepo 这种方式虽然从业务逻辑上解耦了,但也同时增加了项目的工程管理难度。比如:
- 代码和配置很难共享:每个仓库都需要做一些重复的工程化能力配置(如 eslint/test/ci 等)且无法统一维护,当有工程上的升级时,没能同步更新到所有涉及模块,就会一直存在一个过渡态的情况,对工程的不断优化非常不利。
- 依赖的治理复杂:模块越来越多,涉及多模块同时改动的可能性急剧增加。如何保障底层组件升级后,其引用到的组件也能同步更新到位。这点很难做到,如果没及时升级,各工程的依赖版本不一致,往往会引发一些意想不到的问题。
- 存储和构建消耗增加:假如多个工程依赖 pkg-a,那么每个工程下 node_modules 都会重复安装 pkg-a,对本地磁盘内存和本地启动都是个很大的挑战,增加了开发时调试的困难。而且每个模块的发布都是相对独立的,当一次迭代修改较多模块时,总体发布时效就是每个发布流程的串联。对发布者来说是一个非常大的负担。
这时候就诞生了Monolithic单体应用架构,其实也就是上面两种方式的结合。
Pnpm的worksoace 实现monorepo
pnpm 内置了对单一存储库(也称为多包存储库、多项目存储库或单体存储库)的支持, 你可以创建一个 workspace 以将多个项目合并到一个仓库中。
一个 workspace 的根目录下必须有 pnpm-workspace.yaml
文件, 也可能会有 .npmrc
文件。
下面是一个pnpm-workspace.yaml
文件例子:
packages:
# all packages in direct subdirs of packages/
- 'packages/*'
# all packages in subdirs of components/
- 'components/'
# exclude packages that are inside test directories
- '!/test/**'
文件很简单,就是告诉pnpm,哪些目录需要被定义为Workspace目录,可以使用通配符。
pnpm天然支持monorepo架构。
Workspace 协议 (workspace:)
默认情况下,如果可用的 packages 与已声明的可用范围相匹配,pnpm 将从工作区链接这些 packages。 例如, 如果bar引用”foo”: “^1.0.0”并且foo@1.0.0存在工作区,那么pnpm会从工作区将foo@1.0.0链接到bar。 但是,如果 bar 的依赖项中有 “foo”: “2.0.0”,而 foo@2.0.0 在工作空间中并不存在,则将从 npm registry 安装 foo@2.0.0 。 这种行为带来了一些不确定性。
幸运的是,pnpm 支持 workspace 协议 workspace: 。 当使用此协议时,pnpm 将拒绝解析除本地 workspace 包含的 package 之外的任何内容。 因此,如果您设置为 “foo”: “workspace:2.0.0” 时,安装将会失败,因为 “foo@2.0.0” 不存在于此 workspace 中。
所以,workspace是安全的,能避免产生意料之外的包引入错误。
如何使用?
假如 workspace 中有两个包:
+ packages
+ foo
+ bar
如果需要引入这两个包,需要在子包的package.json的dependencies或者devDependencies里定义:
常规使用:”foo”: “workspace:*”
举个例子:
- 新建目录 monorepoTest,pnpm init进行初始化生成package.json,设置 private 为true,因为monorepo包通常不容许发布。
{ "name": "monorepo-test", "private": true, "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "dev": "node scripts/dev.js reactivity -f global" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "esbuild": "^0.19.5", "minimist": "^1.2.8" } }
-
新建 tsconfig.json, 配置 “@my-vue/*” 路径别名
{ "compilerOptions": { "outDir": "dist", // 输出的目录 "sourceMap": true, // 开启 sourcemap "target": "es2016", // 转译的目标语法 "module": "esnext", // 模块格式 "moduleResolution": "node", // 模块解析方式 "strict": false, // 关闭严格模式,就能使用 any 了 "resolveJsonModule": true, // 解析 json 模块 "esModuleInterop": true, // 允许通过 es6 语法引入 commonjs 模块 "jsx": "preserve", // jsx 不转义 "lib": ["esnext", "dom"], // 支持的类库 esnext及dom "baseUrl": ".", // 当前目录,即项目根目录作为基础目录 "paths": { // 路径别名配置 "@my-vue/*": ["packages/*/src"] // 当引入 @my-vue/时,去 packages/*/src中找 }, } }
- 新建文件pnpm-workspace.yaml,设置monorepo。
```ts
packages:
- ‘packages/*’ ```
- 紧接着就是创建子包了,新建文件夹packages,并且在其下新建,reactivity、shared、runtimeCore3个子包目录,并且分别 pnpm init 创建package.json 使其真正成为一个单独可发布的子包。
reactivity:
// package.json
{
"name": "@my-vue/reactivity",
"version": "1.0.0",
"description": "",
"main": "dist/reactivity.cjs.js",
"module": "dist/reactivity.esm-bundler.js",
"buildOptions": {
"name": "VueReactivity"
},
"dependencies": {
"runtime-core": "workspace:*"
},
"keywords": [],
"author": "",
"license": "ISC"
}
// src/index.js
import { isObject } from '@my-vue/shared'
import { printConsole } from 'runtime-core'
const obj = {name: 'Vue3'}
console.log(isObject(obj))
printConsole()
Shared:
// package.json
{
"name": "@my-vue/shared",
"version": "1.0.0",
"description": "",
"main": "dist/shared.cjs.js",
"module": "dist/shared.esm-bundler.js",
"keywords": [],
"author": "",
"license": "ISC"
}
// src/ index.js
/**
* 判断对象
*/
export const isObject = (value) =>{
return typeof value === 'object' && value !== null
}
/**
* 判断函数
*/
export const isFunction= (value) =>{
return typeof value === 'function'
}
/**
* 判断字符串
*/
export const isString = (value) => {
return typeof value === 'string'
}
runtimeCore:
// package.json
{
"name": "@my-vue/shared",
"version": "1.0.0",
"description": "",
"main": "dist/shared.cjs.js",
"module": "dist/shared.esm-bundler.js",
"keywords": [],
"author": "",
"license": "ISC"
}
// src/index.js
export const printConsole = () => {
console.log('runtimeCore')
}
主要逻辑在reactivity,
import { isObject } from '@my-vue/shared'
import { printConsole } from 'runtime-core'
这两种导入方式都是本地导入的,@my-vue/shared是通过tsconfig配置目录的,而runtime-core是通过workspace协议进行导入的。
- 打包脚本
// script/dev.js:
// 使用 minimist 解析命令行参数
const args = require('minimist')(process.argv.slice(2))
const path = require('path')
// 使用 esbuild 作为构建工具
const { build } = require('esbuild')
// 需要打包的模块。默认打包 reactivity 模块
const target = args._[0] || 'reactivity'
// 打包的格式。默认为 global,即打包成 IIFE 格式,在浏览器中使用
const format = args.f || 'global'
// 打包的入口文件。每个模块的 src/index.ts 作为该模块的入口文件
const entry = path.resolve(__dirname, ../packages/${target}/src/index.ts)
// 打包文件的输出格式
const outputFormat = format.startsWith('global') ? 'iife' : format === 'cjs' ? 'cjs' : 'esm'
// 文件输出路径。输出到模块目录下的 dist 目录下,并以各自的模块规范为后缀名作为区分
const outfile = path.resolve(__dirname, ../packages/${target}/dist/${target}.${format}.js)
// 读取模块的 package.json,它包含了一些打包时需要用到的配置信息
const pkg = require(path.resolve(__dirname, ../packages/${target}/package.json))
// buildOptions.name 是模块打包为 IIFE 格式时的全局变量名字
const pgkGlobalName = pkg?.buildOptions?.name
console.log('模块信息:\n', entry, '\n', format, '\n', outputFormat, '\n', outfile)
// 使用 esbuild 打包
build({
// 打包入口文件,是一个数组或者对象
entryPoints: [entry],
// external: [...Object.keys(pkg.dependencies || {})],
// 输入文件路径
outfile,
// 将依赖的文件递归的打包到一个文件中,默认不会进行打包
bundle: true,
// 开启 sourceMap
sourcemap: true,
// 打包文件的输出格式,值有三种:iife、cjs 和 esm
format: outputFormat,
// 如果输出格式为 IIFE,需要为其指定一个全局变量名字
globalName: pgkGlobalName,
// 默认情况下,esbuild 构建会生成用于浏览器的代码。如果打包的文件是在 node 环境运行,需要将平台设置为node
platform: format === 'cjs' ? 'node' : 'browser',
}).then(() => {
console.log('watching ...')
})
3. Vue源码
执行 npm run dev
,实际执行的是 node scripts/dev.js
script/dev.js:
关键代码如下:
// 。。。省略
const target = args._[0] || 'vue'
const format = args.f || 'global'
const pkg = require(`../packages/${target}/package.json`)
// resolve output
const outputFormat = format.startsWith('global')
? 'iife'
: format === 'cjs'
? 'cjs'
: 'esm'
// 。。。省略
const outfile = resolve(
__dirname,
`../packages/${target}/dist/${
target === 'vue-compat' ? `vue` : target
}.${postfix}.js`
)
// 。。。省略
esbuild
.context({
entryPoints: [resolve(__dirname, `../packages/${target}/src/index.ts`)],
outfile,
bundle: true,
external,
sourcemap: true,
format: outputFormat,
globalName: pkg.buildOptions?.name,
platform: format === 'cjs' ? 'node' : 'browser',
plugins,
define: {
__COMMIT__: `"dev"`,
__VERSION__: `"${pkg.version}"`,
__DEV__: `true`,
__TEST__: `false`,
__BROWSER__: String(
format !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches
),
__GLOBAL__: String(format === 'global'),
__ESM_BUNDLER__: String(format.includes('esm-bundler')),
__ESM_BROWSER__: String(format.includes('esm-browser')),
__NODE_JS__: String(format === 'cjs'),
__SSR__: String(format === 'cjs' || format.includes('esm-bundler')),
__COMPAT__: String(target === 'vue-compat'),
__FEATURE_SUSPENSE__: `true`,
__FEATURE_OPTIONS_API__: `true`,
__FEATURE_PROD_DEVTOOLS__: `false`
}
})
.then(ctx => ctx.watch())