vue3 源码学习(1)—— monorepo 环境搭建

323 阅读10分钟

前言

vue3 使用 pnpm 搭建 Monorepo 开发环境。所以,本文的开发环境也采用这种方式。

Monorepo 是什么?

Monorepo 是一种新的仓库管理方式。

你可以将其理解为把多个项目放在同一个代码仓库里进行管理。它区别于以往的 multirepo(即每个项目对应一个代码仓库),其主要优势在于简易的版本管理便利的代码复用

而且,这种管理方式便于通过 Tree-shaking(一种优化机制),来帮助我们删除项目中用不到的代码或者永远不会执行的代码,从而减少打包后的项目体积。

pnpm 是什么?

pnpm 本质上就是一个包管理工具,和 npm/yarn 没有区别,主要优势在于:

  • 包安装速度极快
  • 磁盘空间利用效率高

关于上述两点,感兴趣的同学,可自行搜索进行详细的了解,小编就不做过多的介绍了。下面这张图,是对 vue3 各模块的简要说明,简单了解一下,对阅读源码有一定的帮助。

vue3.png

vue3模块简要说明vue3 模块简要说明

Monorepo 环境搭建

全局安装 pnpm

npm install -g pnpm

初始化项目

创建一个项目文件夹(例如,mini-vue3),然后切换至目录下,并初始化。

pnpm init

配置 pnpm-workspace.yaml

pnpm-workspace.yaml 定义了 工作区 的根,它能够使您在工作区中包含或排除目录。 注意,默认情况下,包含所有子目录。

在根目录下创建 pnpm-workspace.yaml 文件,做如下配置。

packages:
  - 'packages/*'

目的是,将 packages 目录下所有的目录都作为单独的包进行管理。

安装依赖包

pnpm add -D -w typescript esbuild minimist execa rollup rollup-plugin-typescript2 @rollup/plugin-json @rollup/plugin-node-resolve @rollup/plugin-commonjs

-w:monorepo 默认将依赖安装到具体的 package 中。使用 -w 参数,是告诉 pnpm 将依赖安装到 workspace-root,也就是项目的根目录中。

依赖包说明
esbuild开发阶段,所用的构建工具
minimist用于解析命令行中的参数
execa用于生产阶段开启子进程
rollup生产阶段,所用的构建工具
rollup-plugin-typescript2rollup 用来编译 ts 的插件
@rollup/plugin-jsonrollup 默认采用 esm 方式解析模块,该插件能够将 json 解析为 esm 供 rollup 进行处理
@rollup/plugin-node-resolverollup 默认采用 esm 方式解析模块,该插件可以解析安装在 node_modules 下的第三方模块
@rollup/plugin-commonjs将 commonjs 模块转为 esm 模块
typescriptTypeScript 是一种给 JavaScript 添加特性的语言扩展,是 JavaScript 的一个超集

注意,这里安装了 esbuildrollup 两个构建工具,分别用于 scripts/dev.jsscripts/build.js,这两个脚本文件。

修改根目录 package.json

初始化完成后,我们会在根目录下的得到一个 package.json 文件。这里,我们需要修改一下它的脚本命令,也就是 scripts

{
  "name": "mini-vue3",
  "private": true,
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "node scripts/dev.js reactivity -f global",
    "build": "node scripts/build.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@rollup/plugin-commonjs": "^23.0.5",
    "@rollup/plugin-json": "^5.0.2",
    "@rollup/plugin-node-resolve": "^15.0.1",
    "esbuild": "^0.16.7",
    "execa": "^6.1.0",
    "minimist": "^1.2.7",
    "rollup": "^3.7.4",
    "rollup-plugin-typescript2": "^0.34.1",
    "typescript": "^4.9.4"
  }
}

初始化 Typescript

pnpm tsc --init

该命令会在项目的根目录生成一个 tsconfig.json 文件,下面是对它的一些配置。详细配置,可查看文档

{
  "compilerOptions": {
    "outDir": "dist", // 输出目录   
    "sourceMap": true,
    "target": "es2016", // js 语言版本
    "module": "esnext", // 指定生成的模块代码
    "moduleResolution": "node", // 模块解析方式                  
    "strict": false, // 关闭严格模式
    "resolveJsonModule": true, // 解析json
    "esModuleInterop": true, // 允许通过es6语法引入commonJs
    "jsx": "preserve", // jsx 不转译 
    "lib": [
      "esnext",
      "dom"
    ],
    "baseUrl": ".",
    "paths": {
       // 针对路径的别名配置,当通过 @vue/* 引入文件模块时,就会去 packages/*/src 中找文件
      "@vue/*": [
        "packages/*/src"
      ]
    }
  }
}

创建模块

根目录下,创建 packages 目录,在其下创建两个文件夹,分别为 reactivity 响应式模块 和 shared 工具库模块,而后分别对其进行初始化操作。

注意package.jsonbuildOptions,是自定义属性

  1. buildOptions 中的 name 属性,用来给打包的模块指定一个全局变量名
  2. buildOptions 中的 formats 属性,用来指定模块能够打包的代码格式,例如,下面四种代码格式:
  • esm-bundler,在构建工具中使用的代码格式。
  • esm-browser,是浏览器的原生模块化的方式,在浏览器中可以直接使用type="module"的方式直接导入模块。
  • cjs,commonjs 规范,在 node 中使用的代码格式。
  • global,立即执行函数的格式,可以直接通过 script 标签来导入。
  1. 特定的环境,有特定的入口文件。

例如,若是我们在 webpack 中导入了 @vue/reactivity 模块,那么它就会引入module 指定的文件。

reactivity

  1. 切换到 reactivity 目录下,执行初始化操作。
pnpm init
  1. 修改 package.json
{
  "name": "@vue/reactivity",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "module": "dist/reactivity.esm-bundler.js",
  "types": "dist/reactivity.d.ts",
  "unpkg": "dist/reactivity.global.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "buildOptions": {
    "name": "VueReactivity",
    "formats": [
      "esm-bundler",
      "esm-browser",
      "cjs",
      "global"
    ]
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
  1. reactivity 下,新建 src / index.ts,编写代码。
import { isString } from '@vue/shared';

function build(value) {
    if (isString(value)) console.log('测试完成');
}

build('123');
  1. 在 reactivity 模块中安装 shared 模块。
pnpm add @vue/shared@workspace --filter @vue/reactivity

当一个模块依赖了另一个模块时,需要安装。这行命令的意思,就是将本地workspace内的@vue/shared模块,安装到 @vue/reactivity模块中。

shared

  1. 切换到 shared 目录下,执行初始化操作。
pnpm init
  1. 修改 package.json
{
  "name": "@vue/shared",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "module": "dist/shared.esm-bundler.js",
  "types": "dist/shared.d.ts",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "buildOptions": {
    "formats": [
      "esm-bundler",
      "cjs"
    ]
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
  1. shared 下,新建 src / index.ts,编写代码。
export const isString = val => typeof val === 'string'; // 字符串类型判断

到这里,环境就算搭建好了。接下来,我们需要编写打包用的脚本。

编写构建脚本

vue3 源码中有许多脚本文件,它们都放在 scripts 目录中。这里我们只介绍两个脚本: dev.jsbuild.js

两者的主要区别是,dev.js 使用 esbuild 构建工具进行打包,而 build.js 使用rollup 构建工具进行打包。

另外,为了便于理解,本文所展示的脚本代码,同 vue3 中的脚本代码相比,做出了一些删减。

dev 脚本

执行pnpm run dev,运行根目录package.json中配置的打包命令。

node scripts/dev.js reactivity -f global

其中,reactivityglobal 都是传入 scripts/dev.js 中的参数,目的是为说明,对那个模块打包,代码格式是什么样的。当然,你可以不设置参数,就像下面这样:

node scripts/dev.js

这样的话,就需要对整个 vue 进行打包,这需要设置默认值来实现。

代码分析

  1. 把需要的模块都加载进来,放在最顶部。
const path = require('path');
const esbuild = require('esbuild');
  1. 解析脚本命令行,确定打包模块的名称和代码格式。
const args = require('minimist')(process.argv.slice(2)); // 解析脚本命令行参数
const target = args._[0] || 'reactivity'; // 需要打包的-模块
const format = args.f || 'global'; // 打包格式

minimist 能够解析命令行中的参数,并将它们放在一个普通对象中返回。例如,node scripts/dev.js reactivity -f global 会被解析成下面这样:

{ _: [ 'reactivity' ], f: 'global' }
  1. 加载目标模块的 package.json
// 获取模块的 package.json 的数据
const pkg = require(path.resolve(__dirname, `../packages/${target}/package.json`));

由于,我们要打包的目标模块的信息,都在其package.json中。所以,要用 require 来加载它 ,以便在后续的代码中使用。

  1. 定义打包所需的参数
// 打包的代码格式
const outputFormat = format.startsWith('global') ? 'iife' : format === 'cjs' ? 'cjs' : 'esm';

// 打包完成后的文件及其位置
const outfile = path.resolve(__dirname, `../packages/${target}/dist/${target}.${format}.js`);

// 打包的入口文件
const entryFile = path.resolve(__dirname, `../packages/${target}/src/index.ts`)

// 打包文件,在项目中的路径
const relativeOutfile = path.relative(process.cwd(), outfile);

relativeOutfile 这条路径,不是展示文件在你电脑中的详细位置,而是其在项目中的位置。它可以让你通过监听,快速定位当前打包好的文件。

  1. 使用 esbuild 进行打包
esbuild.build({
    entryPoints: [entryFile], // 打包入口文件
    outfile, // 打包后的文件
    bundle: true, // 把所有包打包到一起
    format: outputFormat, // 代码格式
    globalName: pkg?.buildOptions.name, // 打包后的全局变量名
    platform: format === 'cjs' ? 'node' : 'browser', // 平台
    sourcemap: true, // 生成映射文件
    watch: { // 监听
        onRebuild(err) {
           if (!error) console.log(`rebuilt: ${relativeOutfile}`);
        }
    }
  }).then(() => {
    console.log(`watching: ${relativeOutfile}`);
  });

build 脚本

执行pnpm run build,运行根目录package.json中配置的打包命令。

node scripts/build.js

这里,我们不再对单个模块打包,而是打包所有模块。

代码分析

  1. 把需要的模块都加载进来,放在最顶部。
const fs = require('fs');
const execa = require('execa'); // 用于开启子进程,使用 rollup 打包
  1. 解析脚本命令行,确定打包的环境、代码格式和映射。
const args = require('minimist')(process.argv.slice(2)); // 解析命令行参数
const formats = args.formats || args.f; // 代码格式
const devOnly = args.devOnly || args.d; // 用于控制打包环境
const sourceMap = args.sourcemap || args.s; // 是否生成映射文件
  1. 获取所有模块
// 所有模块
const targets = fs.readdirSync('packages').filter(f => { 
    // 不是文件夹,就过滤掉
    if (!fs.statSync(`packages/${f}`).isDirectory()) {
        return false;
    }
    // 文件夹(模块)下的 package.json,若是其 private 为真,但 buildOptions 不存在,就过滤掉
    const pkg = require(`../packages/${f}/package.json`);
    if (pkg.private && !pkg.buildOptions) {
        return false;
    }
    
    return true;
});
  1. 并行打包

将打包核心函数 build 和 所有的模块 targets(是一个数组) 传入 runParallel 函数,然后用 for of 循环遍历 targets,每次循环都创建一个 Promise,并添加到新数组 ret 中。

遍历完成后,将 ret 传入 Promise.all,并返回。

runParallel(targets, build);

// 通过 Promise.all 实现并行打包
async function runParallel(source, iteratorFn) {
    const ret = [];
    for (const item of source) {
        const p = Promise.resolve().then(() => iteratorFn(item, source));
        ret.push(p);
    }
    return Promise.all(ret);
}
// 打包
async function build(target) {
    const env = devOnly ? 'development' : 'production';

    // -c 表示使用配置文件 rollup.config.js 打包
    // –environment 设置需要传递到文件中的环境变量,例如,NODE_ENV、TARGET
    // 这些变量可以在 js 文件中,通过 process.env 读取,例如,process.env.TARGET
    await execa(
        'rollup', // 执行命令
        [
            '-c', 
            '--environment',
            [
                `NODE_ENV:${env}`, // 这个数组里面全是 环境变量
                `TARGET:${target}`, 
                formats ? `FORMATS:${formats}` : ``, 
                sourceMap ? `SOURCE_MAP:true` : ``
            ].filter(Boolean).join(',') // filter(Boolean) 会排除假值
        ],
        { stdio: 'inherit' } // 子进程打包的信息 共享 给父进程
    );
}

build.js 中的代码,主要是获取打包的相关参数,然后通过 rollup 进行打包。下面,我们要接着编写 rollup.config.mjs 中的代码。

rollup.config.mjs

根目录下,新建 rollup 配置文件 rollup.config.mjs。注意,配置文件的扩展名为 .mjs,而不是 .js

代码分析

  1. 引入所有需要的模块,放到最顶部
import path from 'path';
import ts from 'rollup-plugin-typescript2';
import json from '@rollup/plugin-json';
import commonJS from '@rollup/plugin-commonjs';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';

上面这些模块的作用,在上文都已介绍过,这里不再叙述。需要注意的是,fileURLToPathcreateRequire 这两个函数。

由于,本人将配置文件的扩展名改为了 .mjs (Node 13+,需要改),它导致原本在 rollup.config.js 中,可以直接使用的 require__dirname,不能正常使用(rollup.js 文档中有说明原因)。

可是仍要用到它们,为此,就只能根据 vue3 源码,对其重新定义。就像下面这样。

const require = createRequire(import.meta.url);
const __dirname = fileURLToPath(new URL('.', import.meta.url));
  1. 获取对应模块中的相关信息
// 获取 packages 目录
const packagesDir = path.resolve(__dirname, 'packages');

// 获取要打包的模块
const packageDir = path.resolve(packagesDir, process.env.TARGET);

// 获取对应打包目录下的文件(这里用来取 package.json 文件)
const resolve = p => path.resolve(packageDir, p);

// 获取 package.json
const pkg = require(resolve(`package.json`));

// 获取在 package.json 中,自定义的属性 buildOptions
const packageOptions = pkg.buildOptions || {};

// 获取文件名
const name = packageOptions.filename || path.basename(packageDir);
  1. 一个关于代码格式的映射表,用于确定打包的代码格式和文件名
const outputConfigs = {
    'esm-bundler': {
        file: resolve(`dist/${name}.esm-bundler.js`), // 打包后的文件
        format: `es`, // 采用的代码格式
    },
    'esm-browser': {
        file: resolve(`dist/${name}.esm-browser.js`),
        format: `es`,
    },
    cjs: {
        file: resolve(`dist/${name}.cjs.js`),
        format: `cjs`,
    },
    global: {
        file: resolve(`dist/${name}.global.js`),
        format: `iife`, // 立即执行函数
    },
};
  1. 代码格式
const defaultFormats = ['esm-bundler', 'cjs']; // 默认代码格式
const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(','); // 环境变量中的代码格式
const packageFormats = inlineFormats || packageOptions.formats || defaultFormats;
  1. 生成配置
// 获取 roullup 配置
const packageConfigs = packageFormats.map(format => createConfig(format, outputConfigs[format]));

// 导出配置
export default packageConfigs;

// 生成 roullup 配置
function createConfig(format, output) {
    // 如果是全局模式,则需要配置全局变量名,以便在浏览器中引入时,能够通过变量名来使用
    const isGlobalBuild = /global/.test(format);
    if (isGlobalBuild) {
        output.name = packageOptions.name;
    }

    output.sourcemap = true; // 生成映射文件

    return {
        input: resolve('src/index.ts'), // 入口
        output, // 出口
        plugins: [
            // 插件是自上而下执行
            json(),
            ts({
                tsconfig: path.resolve(__dirname, 'tsconfig.json'),
            }),
            commonJS({
                sourceMap: false,
            }),
            nodeResolve(),
        ],
    };
}

完整代码

每个文件各自的完整代码。

dev.js 脚本

const path = require('path');
const esbuild = require('esbuild');

// minimist 解析命令行参数
// 例如: node scripts/dev.js reactivity -f global ——> { _: [ 'reactivity' ], f: 'global' }
const args = require('minimist')(process.argv.slice(2));
const target = args._[0] || 'reactivity'; // 需要打包的-文件夹名
const format = args.f || 'global'; // 打包格式
const pkg = require(path.resolve(__dirname, `../packages/${target}/package.json`));

// iife 立即执行函数 (function () {})()
// cjs node模块 module.exports
// esm 浏览器中的esModule模块 import
const outputFormat = format.startsWith('global') ? 'iife' : format === 'cjs' ? 'cjs' : 'esm';
const outfile = path.resolve(__dirname, `../packages/${target}/dist/${target}.${format}.js`);
const entryFile = path.resolve(__dirname, `../packages/${target}/src/index.ts`)
const relativeOutfile = path.relative(process.cwd(), outfile);

esbuild.build({
    entryPoints: [entryFile], // 打包入口文件
    outfile, // 打包后的文件
    bundle: true, // 把所有包打包到一起
    format: outputFormat, // 代码格式
    globalName: pkg?.buildOptions.name, // 打包后的全局变量名
    platform: format === 'cjs' ? 'node' : 'browser', // 平台
    sourcemap: true, // 生成映射文件
    watch: { // 监听
        onRebuild(err) {
          if (!error) console.log(`rebuilt: ${relativeOutfile}`);
        }
    }
  }).then(() => {
    console.log(`watching: ${relativeOutfile}`);
  })

build.js 脚本

const fs = require('fs');
const execa = require('execa'); // 用于开启子进程

const args = require('minimist')(process.argv.slice(2)); // 解析命令行参数
const formats = args.formats || args.f; // 代码格式
const devOnly = args.devOnly || args.d; // 用于控制打包环境
const sourceMap = args.sourcemap || args.s; // 是否生成映射文件

// 所有模块
const targets = fs.readdirSync('packages').filter(f => { 
    // 不是文件夹,就过滤掉
    if (!fs.statSync(`packages/${f}`).isDirectory()) {
        return false;
    }
    // 文件夹(模块)下的 package.json,若是其 private 为真,但 buildOptions 不存在,就过滤掉
    const pkg = require(`../packages/${f}/package.json`);
    if (pkg.private && !pkg.buildOptions) {
        return false;
    }
    
    return true;
});

runParallel(targets, build);

// 通过 Promise.all 实现并行打包
async function runParallel(source, iteratorFn) {
    const ret = [];
    for (const item of source) {
        const p = Promise.resolve().then(() => iteratorFn(item, source));
        ret.push(p);
    }
    return Promise.all(ret);
}
// 打包
async function build(target) {
    const env = devOnly ? 'development' : 'production';

    // -c 表示使用配置文件 rollup.config.js 打包
    // –environment 设置需要传递到文件中的环境变量,例如,NODE_ENV、TARGET
    // 这些变量可以在 js 文件中,通过 process.env 读取,例如,process.env.TARGET
    await execa(
        'rollup', // 执行命令
        [
            '-c', 
            '--environment',
            [
                `NODE_ENV:${env}`, // 这个数组里面全是 环境变量
                `TARGET:${target}`, 
                formats ? `FORMATS:${formats}` : ``, 
                sourceMap ? `SOURCE_MAP:true` : ``
            ].filter(Boolean).join(',') // filter(Boolean) 会排除假值
        ],
        { stdio: 'inherit' } // 子进程打包的信息 共享 给父进程
    );
}

rollup.config.mjs

import path from 'path';
import ts from 'rollup-plugin-typescript2';
import json from '@rollup/plugin-json';
import commonJS from '@rollup/plugin-commonjs';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';

const require = createRequire(import.meta.url);
const __dirname = fileURLToPath(new URL('.', import.meta.url));

// 获取 packages 目录
const packagesDir = path.resolve(__dirname, 'packages');

// 获取要打包的模块
const packageDir = path.resolve(packagesDir, process.env.TARGET);

// 获取对应打包目录下的文件(这里用来取 package.json 文件)
const resolve = p => path.resolve(packageDir, p);

// 获取 package.json
const pkg = require(resolve(`package.json`));

// 获取在 package.json 中,自定义的属性 buildOptions
const packageOptions = pkg.buildOptions || {};

// 获取文件名
const name = packageOptions.filename || path.basename(packageDir);

// 一个关于代码格式的映射表,用于确定打包的代码格式和文件名
const outputConfigs = {
    'esm-bundler': {
        file: resolve(`dist/${name}.esm-bundler.js`), // 打包后的文件
        format: `es`, // 采用的代码格式
    },
    'esm-browser': {
        file: resolve(`dist/${name}.esm-browser.js`),
        format: `es`,
    },
    cjs: {
        file: resolve(`dist/${name}.cjs.js`),
        format: `cjs`,
    },
    global: {
        file: resolve(`dist/${name}.global.js`),
        format: `iife`, // 立即执行函数
    },
};

// 获取 formats,代码打包格式
const defaultFormats = ['esm-bundler', 'cjs']; // 默认 formats
const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(','); // 环境变量中的 fromats
const packageFormats = inlineFormats || packageOptions.formats || defaultFormats;

// 循环调用 createConfig 处理 formats 中的所有代码格式
const packageConfigs = packageFormats.map(format => createConfig(format, outputConfigs[format]));

// 导出配置
export default packageConfigs;

function createConfig(format, output) {
    // 如果是全局模式,则需要配置全局变量名
    const isGlobalBuild = /global/.test(format);
    if (isGlobalBuild) {
        output.name = packageOptions.name;
    }

    output.sourcemap = true; // 生成映射文件

    // 生成roullup 配置
    return {
        input: resolve('src/index.ts'), // 入口
        output, // 出口
        plugins: [
            // 插件是自上而下执行
            json(),
            ts({
                tsconfig: path.resolve(__dirname, 'tsconfig.json'),
            }),
            commonJS({
                sourceMap: false,
            }),
            nodeResolve(),
        ],
    };
}

测试

打包

切换到根目录下,执行打包命令:

pnpm run dev

由于是指定对 reactivity 模块打包,当运行命令后,会在其目录下出现一个 dist 文件夹,打包后的代码块就放在这里。

截屏2022-08-15 16.46.14.png

引入

dist 文件夹下,新建 index.html,引入 dist / reactivity.global.js,然后按 F12 打开控制台。

截屏2022-08-15 16.56.36.png

最后

搭建时最好按着上述流程走,否则可能在第一次尝试时,出现错误。