大家好,不出意外的话,这篇文章是我在摸鱼的时候完成的(嘿嘿
不知道大家是不是跟我一样,平时就摸摸鱼、刷刷博客、看看技术书籍,零零散散的对Vue的实现原理有一些了解,但是却没有更进一步的去调试源码,或是拿到一堆源码却不知从何入手(可能就我比较菜吧)。本系列文章就从0开始,一起系统的来学习一下Vue最新3.5版本的源码吧😎
本篇文章会从vue3.5源码的目录入手,分析目录结构以及分析vue源码的构建过程的方式 。
下载源码
先到github上把源码给下载下来。点这里👉Vue.js
如果网络问题无法下载的话可以先跳过这步,后续我们分析源码会直接调试vue cli创建的vue项目,当然还是建议下载下来自己研究研究,后续也会分析下载的vue源码和vue cli创建的vue项目中的源码有何不同。
我这里直接下载的zip压缩包,到本地解压:
本地编辑器打开:
源码到手~(截图没有截完全)
目录结构
vue项目采用的是monorepo的架构,也就是一个git仓库管理了很多个子模块,子模块间可以共享模块代码,方便管理,目前许多组件库也是使用这个架构来设计项目。
所以我们要关注的各个模块代码就在packages目录下。
packages
可以看到有这些包:
直接让ai来解释各个包的作用(真方便
| 包名 | 作用 |
|---|---|
compiler-core | 核心编译器逻辑,不依赖于具体平台,负责将模板字符串解析为AST(抽象语法树) |
compiler-dom | 基于compiler-core,针对浏览器环境的特定编译逻辑,如DOM特定的优化和指令处理 |
compiler-sfc | 单文件组件(.vue文件)的编译器,负责解析和处理单文件组件中的模板、脚本和样式部分 |
compiler-ssr | 服务端渲染(SSR)的编译器,用于将Vue组件编译成可以在服务器上执行的JavaScript代码 |
reactivity | 提供响应式系统的核心功能,包括对象的属性追踪和依赖收集机制 |
runtime-core | Vue运行时的核心逻辑,不依赖于具体平台,提供了创建和管理组件实例的基础功能 |
runtime-dom | 基于runtime-core,针对浏览器环境的特定运行时逻辑,如DOM操作和事件处理 |
runtime-test | 用于测试目的的运行时环境,通常在编写单元测试时使用 |
server-renderer | 服务端渲染(SSR)的运行时库,与compiler-ssr配合使用,用于在服务器上渲染Vue应用 |
shared | 包含多个包共享的工具函数和常量,如类型检查、字符串处理等 |
vue | 主要的入口包,整合了上述各个包的功能,提供完整的Vue开发体验 |
vue-compat | 兼容包,用于支持旧版本Vue的API,帮助开发者平滑过渡到新版本 |
可以看到,这些包能分为以下几类:
- compiler 开头的包存放编译器相关的代码
- runtime 开头的包存放运行时相关的代码
- reactivity 包有关响应式实现
- server-renderer 包有关SSR(服务端渲染)实现
- vue 包是入口包,也是直接提供给用户使用的包
涉及核心功能的、比较重要的包应该就是这些,剩下shared包和vue-compat包暂时还不去看他们
至于说编译器、运行时、响应式、服务端渲染等等名词,可能大家平时刷博客也会有大致了解,后面我们也会详细整理和介绍,先大概留个印象~
scripts
还有一个值得关注的目录是scripts:
这里存放的都是命令相关的代码,可以先移步到根目录下的package.json文件中看:
// package.json
{
"private": true,
"version": "3.5.13",
"packageManager": "pnpm@9.14.2",
"type": "module",
"scripts": {
"dev": "node scripts/dev.js",
"build": "node scripts/build.js",
"build-dts": "tsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js",
......
}
}
作为前端er都知道,package文件是整个项目的配置文件之一。如上,在其中的script对象中,就定义了dev、build等命令(我们熟悉的pnpm run dev),而命令后面具体执行的,就是scripts目录下的各个文件。既然我们还要分析vue源码项目是如何构建的,那也离不开这个目录下的代码。
这里命令非常多,对应的执行文件也很多,我们这里就重点看打包相关的命令buid吧~对应去到scripts目录下的build.js文件
"build": "node scripts/build.js"
打包过程
当在命令行输入pnpm run build时,就会执行scripts/build.js这个文件进行打包,而整个文件实际是执行了一个run方法:
// scripts/build.js
run()
async function run() {
....
const resolvedTargets = targets.length
? fuzzyMatchTarget(targets, buildAllMatching)
: allTargets
await buildAll(resolvedTargets)
....
}
这里省略了一些不太重要的代码,核心就是执行了这两段:
- 判断是否指定了构建目标,如果没有则确定目标为packages目录下的要构建的包(帮你们看了,allTargets指的就是用fs读取的、上面说的packages目录下的这些包)
- 对刚刚确定的目标文件进行构建
所以重点就是buildAll方法,进来看看
// scripts/build.js
async function buildAll(targets) {
await runParallel(cpus().length, targets, build)
}
方法内部只是执行了runParallel方法,从名字也可以看得出来这个方法进行了并行构建。此外还传入了cpus().length(node内置方法,获取cpu的数量)、构建目标targets和构建时执行的具体方法build。
那么是如何进行并行构建的呢?进来runParallel方法看看
// scripts/build.js
async function runParallel(maxConcurrency, source, iteratorFn) {
const ret = []
const executing = []
for (const item of source) {
const p = Promise.resolve().then(() => iteratorFn(item))
ret.push(p)
if (maxConcurrency <= source.length) {
const e = p.then(() => {
executing.splice(executing.indexOf(e), 1)
})
executing.push(e)
if (executing.length >= maxConcurrency) {
await Promise.race(executing)
}
}
}
return Promise.all(ret)
}
这可是把promise玩成花了~来分析一下:
方法的核心逻辑是用for...of来遍历所有构建对象,用Promise.resolve()返回一个promise对象,在这个对象的then后调用具体的构建方法(这里的iteratorFn就是先前buildAll方法传入的build函数)。这样的写法实际上是用promise来包裹构建的实现,将构建的执行步骤放到微任务队列中执行,随后将构建promise任务存放到ret中,方法最后返回promise.all(ret)来确保所有的构建任务都执行完成,返回执行的结果。
将构建任务放到ret中后,还有一段判断的逻辑:如果当前的cpu数量少于构建的任务数量,则将任务同步压入executing中,并在任务执行完后从executing中删除任务(也就是说executing存放着当前正在执行的那些构建任务,构建完后就移除)。且如果当前正在执行的任务数量已经大于电脑cpu的数量了,就调用await方法先阻塞住代码的执行,调用Promise.race方法等待一个正在执行的任务执行完成,然后再继续往executing中添加构建任务,继续构建。
这样就能够确保当前执行的构建任务数始终不大于电脑cpu的数量,让电脑有多少个cpu就同时构建多少个任务,充分利用cpu来进行并行构建。
所以runParallel这个方法是用来控制并行构建的过程的,而具体的构建实现则在iteratorFn(buildAll传入的build方法)中,我们来看看具体的构建实现:
// scripts/build.js
async function build(target) {
const pkgBase = privatePackages.includes(target)
? `packages-private`
: `packages`
const pkgDir = path.resolve(`${pkgBase}/${target}`)
...
// if building a specific format, do not remove dist.
if (!formats && existsSync(`${pkgDir}/dist`)) {
fs.rmSync(`${pkgDir}/dist`, { recursive: true })
}
...
await exec(
'rollup',
[
...
[
...
`TARGET:${target}`,
...
]
...
],
...
)
}
同样省略了一些代码,省略的代码都是排除需要忽略的包、传入配置项、传入构建环境等。
可以看到,具体的构建实现只是将传入的target拼接成构建的路径,然后调用path.resolve方法加载出来,并执行rollup命令,使用rollup构建工具进行打包(可以看出来目前vue的底层构建工具还是使用的rollup)。
至此,整个基本的构建流程就跑通啦,来整理一下:
- 执行run方法,确定构建的目标(要构建哪些包,默认packages下的那些包)
- 调用buildAll方法,执行runParallel方法,传入电脑cpu数量、构建目标和构建方法进行并行构建
- runParallel方法使用promise,将构建任务放到微任务队列中异步执行,控制同时执行的构建任务数量始终不超过cpu的数量
- 依次执行所有构建任务,调用build方法使用rollup进行构建
- 等待所有构建任务执行完毕,若都构建成功,runParallel的promise.all方法返回成功的promise,逐级返回到run方法中,检查构建的文件大小
- 如果需要打包ts类型文件,再执行pnpm run build-dts方法构建ts文件
最后的两步在先前run方法里省略的一些代码,当然省略的还有缓存相关的代码,这些也不复杂,因为本部分主要是分析构建的主流程,要是大家感兴趣可以再自己看看源码~
构建产物
可以再关注一下打包产物的输出路径,看看build方法中的这行:
// scripts/build.js/build
if (!formats && existsSync(`${pkgDir}/dist`)) {
fs.rmSync(`${pkgDir}/dist`, { recursive: true })
}
这里代码是构建之前,如果已经存在dist打包目录,则删除这个目录再进行构建,也就是说,构建后会在要构建的包中新建一个dist目录,并将产物放到其中。
那我们来打包一下看看,打开终端
- 执行pnpm install 安装依赖
- 执行pnpm run build 打包
执行完后就可以看到,packages下每个包里面都多了一个dist目录,而里面则有不同环境会用到的打包后的文件:
如果想单独使用vue中某个包的功能,也可以直接使用这些文件
总结
本文分析了Vue项目的目录结构以及Vue项目的构建流程。Vue源码采用了monorepo的架构,在一个git仓库中管理了多个项目,也就是在packages目录下的各个包。Vue源码中主要有以下几类包:
- compiler 开头的包存放编译器相关的代码
- runtime 开头的包存放运行时相关的代码
- reactivity 包有关响应式实现
- server-renderer 包有关SSR(服务端渲染)实现
- vue 包是入口包,也是直接提供给用户使用的包
有关编译器、运行时、响应式、服务端渲染等等概念会在以后进行分析,而当在Vue项目中,执行pnpm run build打包指令后,会去执行Scripts目录下的build.js文件,会做以下事情:
- 执行run方法,确定构建的目标(要构建哪些包,默认packages下的那些包)
- 调用buildAll方法,执行runParallel方法,传入电脑cpu数量、构建目标和构建方法进行并行构建
- runParallel方法使用promise,将构建任务放到微任务队列中异步执行,控制同时执行的构建任务数量始终不超过cpu的数量
- 依次执行所有构建任务,调用build方法使用rollup进行构建
- 等待所有构建任务执行完毕,若都构建成功,runParallel的promise.all方法返回成功的promise,逐级返回到run方法中,检查构建的文件大小
- 如果需要打包ts类型文件,再执行pnpm run build-dts方法构建ts文件
打包完成后,会在每个包下创建一个dist目录,将打包产物放入其中。
下篇文章就介绍调试Vue源码的方式吧