从vue3源码中你能学到的项目构建

1,060 阅读4分钟

前言

作为 vue 玩家,一路走来在 vue3 源码中收获了很多,无论是项目构建还是TS方面vue3源码都是最好的学习教程。

本课程打算作为系列课程,主要包括以下几个章节:

  • 从vue3源码构建中你能学到什么
  • 从vue3源码中你能学到的TS
  • 从Vue3源码中你能学到的工具类

感兴趣的话关注一波吧🤑

不是大佬,文章内容如有错误,欢迎留言。

Vue3 架构总览

Vue3 源码架构相比于 vue2 最大的变化就是 Vue3 中 使用了 monorepo 架构,很多UI库采用的也是这种架构。

从表象上来看 monorepo 架构是把项目中文件夹(功能模块)变成了包,每个包可以独立发布也可相互依赖。

以下是vue3源码中packages目录

image.png

我们点击 compiler-core 文件夹看看

image.png

可以看出是一个标准的包结构,再来看看package.json,如下所示(关键部分以给出注释):

{
  "name": "@vue/compiler-core", // 包的名称 以@开头说明是个范围包
  "version": "3.1.2",
  "description": "@vue/compiler-core",
  "main": "index.js",
  "module": "dist/compiler-core.esm-bundler.js",
  "types": "dist/compiler-core.d.ts",
  "files": [
    "index.js",
    "dist"
  ],
  "buildOptions": {  // 打包时需要的自定义属性
    "name": "VueCompilerCore",
    "compat": true,
    "formats": [
      "esm-bundler",
      "cjs"
    ]
  },
  "dependencies": {  // 包的依赖项
    "@vue/shared": "3.1.2",
    "@babel/parser": "^7.12.0",
    "@babel/types": "^7.12.0",
    "estree-walker": "^2.0.1",
    "source-map": "^0.6.1"
  }
}

这个时候你可能会有些疑问?为什么vue3要使用这种架构?以下纯属个人猜想:

  1. 更有利于团队合作

我们知道vue3中的模块非常多,同时每个模块都需要相对应的人员来开发,使用monorepo项目架构可以吧每个模块都作为一个个的包,这样不同的人员维护不同的包,逻辑上很清晰。

  1. 解决每个模块的依赖关系

比如 compiler-core 这个模块会使用到 shared 模块里面的方法,如果使用monorepo项目架构,我们可以直接在 compiler-core 中通过 import 的方式导入 shared,且不需要使用npm link 进行关联(yarn 的 workspaces已经帮我们做了)

  1. 分包发布

比如我们只是使用了vue中的某个模块,这个时候我们只需要下载对应的模块包即可,无需下载整个vue

如何搭建 monorepo 项目

业界上大概有三种搭建方式

  1. 使用 yarnworkspaces
  2. 使用 lerna
  3. 使用 pnpm 最新版 element-plus 就是采用这种架构

下面我们参考vue3源码架构使用 yarnworkspaces 进行项目搭建

  1. 新建文件夹monorepo-project 进入文件夹执行yarn init -y命令,生成 package.json文件

新增 private:trueworkspaces

{
  "name": "monorepo-project",
  "private": "true",
  "workspaces": [
    "packages/*"
  ],
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}

配置完这两个属性后其实我们的项目就变成了monorepo项目了

  1. packages 文件夹下新建两个文件夹module1module2作为我们的两个包,然后分别进入module1module2里面进行yarn init -y,这个时候的目录结构如下所示:

image.png

分别更改module1module2package.json中的 name 名称,把他都变成范围包

{
  "name": "@monorepo-project/module1",
  "version": "1.0.0",
  "main": "src/index.ts",
  "license": "MIT"
}
  1. 回到根目录执行 yarn install 命令

你会神奇的发现yarn自动给我们的module1``module2模块创建了软链

image.png

这个时候我们在module1中使用module2就可以直接import

import module2 from '@monorepo-project/module2'
console.log(module2);

如何让项目跑起来

现在根目录的 package.json 里面还十分的朴实,没有配置 scripts 脚本,现在我们需要配置个dev 让项目先跑起来。

{
  "name": "monorepo-project",
  "private": "true",
  "workspaces": [
    "packages/*"
  ],
 
  "scripts": {  //新增 dev 脚本
    "dev": "node ./scripts dev.js"
  },
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}

dev脚本其实运行的就是 script 文件夹下面的 dev.js 文件。首先我们看下vue3源码中dev.js 里面都干了什么事情(关键部分已给出注释)。


const execa = require('execa')
const { fuzzyMatchTarget } = require('./utils')
const args = require('minimist')(process.argv.slice(2))
const target = args._.length ? fuzzyMatchTarget(args._)[0] : 'vue' // 目标模块
const formats = args.formats || args.f
const sourceMap = args.sourcemap || args.s
const commit = execa.sync('git', ['rev-parse', 'HEAD']).stdout.slice(0, 7)

execa(
  'rollup', // 打包命令
  [
    '-wc', // 文件变化时就执行
    '--environment', // 设置环境变量
    [
      `COMMIT:${commit}`,
      `TARGET:${target}`,
      `FORMATS:${formats || 'global'}`,
      sourceMap ? `SOURCE_MAP:true` : ``
    ]
      .filter(Boolean)
      .join(',')
  ],
  {
    stdio: 'inherit' // 输出到主进程中
  }
)

可以看出dev.js中主要干的就是使用 execa 去执行 rollup 打包命令,那接下来我们就去瞅瞅 rollup.config.json 文件。

image.png

把里面的函数全部折叠后看出这个配置文件最后导出了一个 packageConfigs 配置,也是rollup的最终配置项,我们就从这个配置看起。

const packageConfigs = process.env.PROD_ONLY
  ? []
  : packageFormats.map(format => createConfig(format, outputConfigs[format]))

上面代码可以看出主要是使用 map 循环了 packageFormats 通过 createConfig 方法生成配置文件。

我们先来看看 packageFormste 是怎么来的。

// 如果通过命令行有设置就用命令行中的设置,否则使用package.json只中的formats配置 如果都没有 就使用默认
const packageFormats = inlineFormats || packageOptions.formats || defaultFormats

image.png

通过上面的代码,我们可以清楚的看到,其实最终的配置项是在每个模块的 package.jsonbuildOptions 配置了,下面我们来说说这些配置项都是什么意思:


  "buildOptions": {
    "name": "Vue", // 模块最总暴露出来的变量名称,打包为global时需要
    "formats": [ // 需要打包成的类型
      "esm-bundler", // es
      "esm-bundler-runtime",  // es
      "cjs",  //cjs
      "global",  // iife
      "global-runtime", // iife
      "esm-browser", // es
      "esm-browser-runtime" // es
    ]
  }

es 用于 ES6 语法,cjs 用于 node,iife 用于浏览器环境

createConfig 函数接受两个参数,分别是:

  1. format 支持的打包类型
  2. outputConfigs[format] 根据打包类型获取对应 rollup 的 output 部分配置

下面我们来看下 createConfig 函数返回的配置项信息

function createConfig(format,output,plugins=[]){

    ...
    
  return {
    input: resolve(entryFile), // 入口 src/index.ts
    external, // 一些外部依赖不打包
    plugins: [ // 插件配置
      json({
        namedExports: false
      }),
      tsPlugin,
      createReplacePlugin(
        isProductionBuild,
        isBundlerESMBuild,
        isBrowserESMBuild,
        // isBrowserBuild?
        (isGlobalBuild || isBrowserESMBuild || isBundlerESMBuild) &&
          !packageOptions.enableNonBrowserBranches,
        isGlobalBuild,
        isNodeBuild,
        isCompatBuild
      ),
      ...nodePlugins,
      ...plugins
    ],
    output, // 输入文件
    onwarn: (msg, warn) => {
      if (!/Circular/.test(msg)) {
        warn(msg)
      }
    },
    treeshake: {
      moduleSideEffects: false
    }
  }
}

至此 npm run dev 打包逻辑已经全部看完,下面我们来梳理下整个流程

image.png

npm run dev 只是打包的是 vue 文件夹,vue 文件也是对外输出vue的所有功能。应为在 dev.js 中执行 rollup 的时候配置了 --wc 即文件变化后就执行这个文件的打包,所以在开发环境中我们可以使用 npm run dev 进行代码的调试。

如果我们想每个模块都打包 就的使用 npm run build 命令。

打包所有模块

npm run buildnpm run dev要干的事情本质来说是一样的,只不过在 执行 build.js 的时候需要打包所有的模块,所以 targets 变量需要去读取而不是像 dev.js中的直接写死。

image.png

首先我们来看看 utils 中是如何获取 targets 的

const targets = (exports.targets = fs.readdirSync('packages').filter(f => {
// 过滤掉不是文件夹的文件
  if (!fs.statSync(`packages/${f}`).isDirectory()) {
    return false
  }
  const pkg = require(`../packages/${f}/package.json`)
  // 过滤掉 package 中 private=4 以及没有设置 buildOptions 属性的文件
  if (pkg.private && !pkg.buildOptions) {
    return false
  }
  return true
}))

最后 targets 返回的是一个需要打包的模块名

image.png

获得打包列表后,我们就可以进行循环并行打包了


async function buildAll(targets) {
  await runParallel(require('os').cpus().length - 7, targets, build)
}
//maxConcurrency 最大CPU核数 source 需要打包的模块数组 iteratorFn执行的打包命令
async function runParallel(maxConcurrency, source, iteratorFn) {
  const ret = []
  const executing = []
  for (const item of source) {
    const p = Promise.resolve().then(() => iteratorFn(item, source))
    ret.push(p)
    if (maxConcurrency <= source.length) {
      const e = p.then(() => {
        return executing.splice(executing.indexOf(e), 1)
      })
      executing.push(e)
      if (executing.length >= maxConcurrency) {
        await Promise.race(executing)
      }
    }
  }
  return Promise.all(ret)
}

我们再来看看 build 函数中的主要代码

async function build(target){
...

  await execa(
    'rollup',
    [
      '-c',
      '--environment',
      [
        `COMMIT:${commit}`,
        `NODE_ENV:${env}`,
        `TARGET:${target}`,
        formats ? `FORMATS:${formats}` : ``,
        buildTypes ? `TYPES:true` : ``,
        prodOnly ? `PROD_ONLY:true` : ``,
        sourceMap ? `SOURCE_MAP:true` : ``
      ]
        .filter(Boolean)
        .join(',')
    ],
    { stdio: 'inherit' }
  )

}
...

其实和 dev 中的逻辑一样,都是使用 execa 执行 rollup 命令去打包

下面我们来梳理下 npm run build执行的打包流程

image.png

最后

最新的 vue3 代码已经使用 pnpm 进行模块管理了

image.png