【Vue3.5】原来Vue的源码是这样设计目录结构和打包流程的😦

388 阅读9分钟

大家好,不出意外的话,这篇文章是我在摸鱼的时候完成的(嘿嘿

不知道大家是不是跟我一样,平时就摸摸鱼、刷刷博客、看看技术书籍,零零散散的对Vue的实现原理有一些了解,但是却没有更进一步的去调试源码,或是拿到一堆源码却不知从何入手(可能就我比较菜吧)。本系列文章就从0开始,一起系统的来学习一下Vue最新3.5版本的源码吧😎

本篇文章会从vue3.5源码的目录入手,分析目录结构以及分析vue源码的构建过程的方式 。

下载源码

先到github上把源码给下载下来。点这里👉Vue.js

如果网络问题无法下载的话可以先跳过这步,后续我们分析源码会直接调试vue cli创建的vue项目,当然还是建议下载下来自己研究研究,后续也会分析下载的vue源码和vue cli创建的vue项目中的源码有何不同。

我这里直接下载的zip压缩包,到本地解压:

image.png

本地编辑器打开:

image.png

源码到手~(截图没有截完全)

目录结构

vue项目采用的是monorepo的架构,也就是一个git仓库管理了很多个子模块,子模块间可以共享模块代码,方便管理,目前许多组件库也是使用这个架构来设计项目。

所以我们要关注的各个模块代码就在packages目录下。

packages

可以看到有这些包:

image.png

直接让ai来解释各个包的作用(真方便

包名作用
compiler-core核心编译器逻辑,不依赖于具体平台,负责将模板字符串解析为AST(抽象语法树)
compiler-dom基于compiler-core,针对浏览器环境的特定编译逻辑,如DOM特定的优化和指令处理
compiler-sfc单文件组件(.vue文件)的编译器,负责解析和处理单文件组件中的模板、脚本和样式部分
compiler-ssr服务端渲染(SSR)的编译器,用于将Vue组件编译成可以在服务器上执行的JavaScript代码
reactivity提供响应式系统的核心功能,包括对象的属性追踪和依赖收集机制
runtime-coreVue运行时的核心逻辑,不依赖于具体平台,提供了创建和管理组件实例的基础功能
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:

image.png

这里存放的都是命令相关的代码,可以先移步到根目录下的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)。

至此,整个基本的构建流程就跑通啦,来整理一下:

  1. 执行run方法,确定构建的目标(要构建哪些包,默认packages下的那些包)
  2. 调用buildAll方法,执行runParallel方法,传入电脑cpu数量、构建目标和构建方法进行并行构建
  3. runParallel方法使用promise,将构建任务放到微任务队列中异步执行,控制同时执行的构建任务数量始终不超过cpu的数量
  4. 依次执行所有构建任务,调用build方法使用rollup进行构建
  5. 等待所有构建任务执行完毕,若都构建成功,runParallel的promise.all方法返回成功的promise,逐级返回到run方法中,检查构建的文件大小
  6. 如果需要打包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目录,并将产物放到其中。

那我们来打包一下看看,打开终端

  1. 执行pnpm install 安装依赖
  2. 执行pnpm run build 打包

执行完后就可以看到,packages下每个包里面都多了一个dist目录,而里面则有不同环境会用到的打包后的文件:

image.png

如果想单独使用vue中某个包的功能,也可以直接使用这些文件

总结

本文分析了Vue项目的目录结构以及Vue项目的构建流程。Vue源码采用了monorepo的架构,在一个git仓库中管理了多个项目,也就是在packages目录下的各个包。Vue源码中主要有以下几类包:

  • compiler 开头的包存放编译器相关的代码
  • runtime 开头的包存放运行时相关的代码
  • reactivity 包有关响应式实现
  • server-renderer 包有关SSR(服务端渲染)实现
  • vue 包是入口包,也是直接提供给用户使用的包

有关编译器、运行时、响应式、服务端渲染等等概念会在以后进行分析,而当在Vue项目中,执行pnpm run build打包指令后,会去执行Scripts目录下的build.js文件,会做以下事情:

  1. 执行run方法,确定构建的目标(要构建哪些包,默认packages下的那些包)
  2. 调用buildAll方法,执行runParallel方法,传入电脑cpu数量、构建目标和构建方法进行并行构建
  3. runParallel方法使用promise,将构建任务放到微任务队列中异步执行,控制同时执行的构建任务数量始终不超过cpu的数量
  4. 依次执行所有构建任务,调用build方法使用rollup进行构建
  5. 等待所有构建任务执行完毕,若都构建成功,runParallel的promise.all方法返回成功的promise,逐级返回到run方法中,检查构建的文件大小
  6. 如果需要打包ts类型文件,再执行pnpm run build-dts方法构建ts文件

打包完成后,会在每个包下创建一个dist目录,将打包产物放入其中。

下篇文章就介绍调试Vue源码的方式吧