[Monorepo] monorepo工程基本格式
monorepo工程基本格式
pnpm-workspace.yaml
指定工程管理目录
packages:
- "packages/**"
这里配置之后,我们在安装第三方包的时候,就需要指定安装参数,如果是全局安装,就需要指定-w或者--workspace-root
安装全局第三方包
注意:为了ts转换方便,这里使用了rollup-plugin-typescript2包
pnpm add rollup typescript @rollup/plugin-node-resolve @rollup/plugin-commonjs tslib rollup-plugin-typescript2 @rollup/plugin-json @types/node rollup-plugin-clear @rollup/plugin-terser rollup-plugin-generate-html-template chalk execa minimist @microsoft/api-extractor npm-run-all -D --workspace-root
其中:
execa
:Execa 是一个 Node.js 库,可以替代 Node.js 的原生 child_process 模块,用于执行外部命令
minimist
: 是一个轻量级的库,专门用于解析命令行参数和选项,使得开发者能够轻松地构建命令行工具。
api-extractor
:是辅助打包 TypeScript 类型系统的工具,它可以将所有类型定义从一个入口获取到,最后汇总到一个.d.ts
文件内部
全局tsconfig.json配置
{
"compilerOptions": {
"lib":["ESNext", "DOM"],
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"outDir": "dist",
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"rootDir": ".", /* 指定输出文件目录(用于输出),用于控制输出目录结构 */
"baseUrl": ".", /* 解析非相对模块的基地址,默认是当前目录 */
"paths": { /* 路径映射,相对于baseUrl */
"@vue/*": ["packages/*/src"]
}
}
}
简单的测试子工程
在packages
文件夹中中创建工程reactivity
,shared
导入相关的简单测试代码:
shared
工程
其实就是把之前utils.ts
中的代码放入进来
index.ts
// 自定义守卫是指通过 `{形参} is {类型}` 的语法结构,
// 来给返回布尔值的条件函数赋予类型守卫的能力
// 类型收窄只能在同一的函数中,如果在不同的函数中就不起作用。
// 如果判断val is object,下面的val.then会报错,object上没有then方法
export const isObject = (val: unknown): val is Record<any, any> => {
return val !== null && typeof val === "object";
};
export const isArray = Array.isArray;
export const isString = (val: unknown): val is string => {
return typeof val === "string";
};
export const isPromise = <T = any>(val: unknown): val is Promise<T> => {
return isObject(val) && isFunction(val.then) && isFunction(val.catch);
};
export const isSymbol = (val: unknown): val is symbol =>
typeof val === "symbol";
export const extend = Object.assign;
// 通过Object.is比较可以避免出现一些特殊情况
// 比如NaN和NaN是相等的,+0和-0是不相等的
export const hasChanged = (value: any, oldValue: any): boolean =>
!Object.is(value, oldValue);
// 判断一个 key 是否是一个合法的整数类型的字符串
export const isIntegerKey = (key: unknown) =>
isString(key) && // 检查 key 是否是字符串
key !== "NaN" && // 确保 key 不是字符串 'NaN'
key[0] !== "-" && // 确保 key 不是负数(即 key 的第一个字符不是 '-')
"" + parseInt(key, 10) === key; // 确保 key 是一个可以被转换为整数的合法字符串
// hasOwnProperty 检查对象自身是否拥有某个属性,而不是从其原型链继承来的属性
// key is keyof typeof val 表示 key 是 val 的一个键, TS方法的谓语动词,
// 目的是为了在调用方法的时候也能够进行类型收窄。
// 因为TS的类型推断是基于值的,而不是基于变量的,
// 在调用方法的时候,TS无法推断出方法的返回值,所以我们需要使用谓语动词来告诉TS方法的返回值的类型。
const hasOwnProperty = Object.prototype.hasOwnProperty;
export const hasOwn = (
val: object, // 第一个参数 val 是一个对象
key: string | symbol // 第二个参数 key 是一个字符串或 symbol,表示属性的键
): key is keyof typeof val => hasOwnProperty.call(val, key);
// 判断是否为函数
export const isFunction = (val: unknown) => typeof val === "function";
// 空函数
export const NOOP = () => {};
// 以on开头的正则
const onRE = /^on[^a-z]/;
// 判断字符串是否以on开头
export const isOn = (key: string) => onRE.test(key);
monorepo工程引用
安装工作空间中的一个包到工作空间的另外一个包:
pnpm add <包名B> --workspace -- filter <包名A>
上面的命令表示将B包安装到A包里面,也就是说B包成为了A包的一个依赖。
我们这里的例子中,将shared安装到reactivity中
pnpm add @vue/shared --workspace -- filter @vue/reactivity
这样,就在reactivity工程包中,直接引入了工作空间的另外一个包shared,我们要引入相关内容的时候,可以如下:
import { isArray, isIntegerKey } from "@vue/shared";
package.json文件
reactivity/package.json
{
"name": "@vue/reactivity",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"module": "dist/reactivity.esm-bundler.js",
"types": "dist/reactivity.d.ts",
"unpkg": "dist/reactivity.global.js",
"jsdelivr": "dist/reactivity.global.js",
"buildOptions": {
"name": "VueReactivity",
"formats": [
"esm-bundler",
"esm-browser",
"cjs",
"global"
]
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
注意:main现在在测试环境下,如果打包之后可以直接指定js文件
module
: esm文件
types
:类型声明文件
unpkg,jsdelivr
:浏览器可以直接引入的文件,这种文件内容一般是iife或者umd,需要指定函数返回的变量名
buildOptions
:指定变量名和打包文件格式名
shared/package.json
{
"name": "@vue/shared",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"module": "dist/shared.esm-bundler.js",
"types": "dist/shared.d.ts",
"buildOptions": {
"formats": [
"esm-bundler",
"cjs"
]
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
其中,测试环境下,在main属性下,直接写的src/index.ts
路径,我们也可以自己构造一下,比如在reactivity工程根目录下直接创建index.js
'use strict'
if (process.env.NODE_ENV === 'production') {
module.exports = require('./dist/reactivity.cjs.prod.js')
} else {
module.exports = require('./dist/reactivity.cjs.js')
}
package.json
的属性main可以改成index.js
shared工程同理:
'use strict'
if (process.env.NODE_ENV === 'production') {
module.exports = require('./dist/shared.cjs.prod.js')
} else {
module.exports = require('./dist/shared.cjs.js')
}
package.json
的属性main可以改成index.js
全局工程打包文件rollup.config.mjs
import { createRequire } from "module";
import { fileURLToPath } from "url";
import path from "path";
import json from "@rollup/plugin-json";
import ts from "rollup-plugin-typescript2";
import terser from "@rollup/plugin-terser";
// 获取require方法
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);
const resolve = (p) => path.resolve(packageDir, p);
// 获取package.json文件
const pkg = require(resolve(`package.json`));
// 获取package.json文件中自定义属性buildOptions
const packageOptions = pkg.buildOptions || {};
// 获取package.json文件名
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: {
name: name,
file: resolve(`dist/${name}.global.js`),
format: `iife`,
},
};
const defaultFormats = ["esm-bundler", "cjs"];
// 获取rollup传递过来的环境变量process.env.FORMATS
const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(',');
// packageOptions.formats需要在package.json中定义
// 优先查看是否有命令行传递的参数
// 然后查看使用每个包里自定义的formats,
// 如果没有使用defaultFormats
const packageFormats = inlineFormats || packageOptions.formats || defaultFormats
const packageConfigs = packageFormats.map((format) =>
createConfig(format, outputConfigs[format])
);
export default packageConfigs;
function createConfig(format, output, plugins = []) {
// 是否输出声明文件
const shouldEmitDeclarations = !!pkg.types;
const isBundlerESMBuild = /esm-bundler/.test(format)
const isBrowserESMBuild = /esm-browser/.test(format)
const isNodeBuild = format === 'cjs'
// 如果format包含global说明是iife导出,设置导出变量名字
const isGlobalBuild = /global/.test(format)
if (isGlobalBuild) {
output.name = packageOptions.name
}
const minifyPlugin =
format === "global" && format === "esm-browser" ? [terser()] : [];
// nodejs相关的插件处理
const nodePlugins =
packageOptions.enableNonBrowserBranches && format !== "cjs"
? [
require("@rollup/plugin-node-resolve").nodeResolve({
extensions: [".js", "jsx", "ts", "tsx"],
// preferBuiltins: true,
}),
require("@rollup/plugin-commonjs")({
sourceMap: false,
}),
]
: [];
// 处理ts相关插件处理
const tsPlugin = ts({
tsconfig: path.resolve(__dirname, "tsconfig.json"),
tsconfigOverride: {
compilerOptions: {
target: format === "cjs" ? "es2019" : "es2015",
sourceMap: true,
declarationMap: shouldEmitDeclarations,
declaration: shouldEmitDeclarations,
declarationDir: "types"
},
},
});
const external =
isGlobalBuild || isBrowserESMBuild
? packageOptions.enableNonBrowserBranches
? // externalize postcss for @vue/compiler-sfc
// because @rollup/plugin-commonjs cannot bundle it properly
['postcss']
: // normal browser builds - non-browser only imports are tree-shaken,
// they are only listed here to suppress warnings.
['source-map', '@babel/parser', 'estree-walker']
: // Node / esm-bundler builds. Externalize everything.
[
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...['path', 'url'] // for @vue/compiler-sfc
]
return {
input: resolve("src/index.ts"),
external,
plugins: [
json({
namedExports: false,
}),
tsPlugin,
...minifyPlugin,
...nodePlugins,
...plugins,
],
output,
onwarn: (msg, warn) => {
if (!/Circular/.test(msg)) {
warn(msg);
}
},
treeshake: {
moduleSideEffects: false,
},
};
}
打包编译
根目录下新建scripts
目录,并新建build.mjs
用于打包编译执行。由于执行的步骤较多,基本分为下面几块
packages
下的所有子包- 获取到子包之后就可以执行
build
操作,借助 execa来执行rollup
命令 - 同步编译多个包时,为了不影响编译性能,控制并发,默认并发数4
- 通过
api-extractor
实现.d.ts文件整合 - 如果不想同时编译多个包,也可以命令行自定义打包并指定其格式
build.mjs
import { createRequire } from "module";
import fs from "fs";
import { rm } from "fs/promises";
import path from "path";
import { execa } from "execa";
import chalk from "chalk";
// 获取require方法
const require = createRequire(import.meta.url);
// 获取packages下的所有子包
const allTargets = fs.readdirSync("packages").filter((f) => {
// 过滤掉非目录文件
if (!fs.statSync(`packages/${f}`).isDirectory()) {
return false;
}
const pkg = require(`../packages/${f}/package.json`);
// 过滤掉私有包和不带编译配置的包
if (pkg.private && !pkg.buildOptions) {
return false;
}
return true;
});
// 方便单独打包可以传递命令行参数
const args = require('minimist')(process.argv.slice(2))
// 如果没有传递命令行参数,就是全部工程
const targets = args._.length ? args._ : allTargets
const formats = args.formats || args.f
// 获取到子包之后就可以执行build操作,这里我们借助 execa包 来执行rollup命令
const build = async function (target) {
const pkgDir = path.resolve(`packages/${target}`);
const pkg = require(`${pkgDir}/package.json`);
// 编译前移除之前生成的产物
await rm(`${pkgDir}/dist`, { recursive: true, force: true });
// -c 指使用配置文件 默认为rollup.config.js
// --environment 向配置文件传递环境变量 配置文件通过process.env.获取
await execa(
"rollup",
[
"-c",
"--environment", // 传递环境变量
[
`TARGET:${target}`,
formats ? `FORMATS:${formats}` : `` // 使用命令行参数
]
.filter(Boolean).join(",")],
{ stdio: "inherit" }
);
// 执行完rollup生成声明文件后
// package.json中定义此字段时执行,通过api-extractor整合.d.ts文件
if (pkg.types) {
console.log(
chalk.bold(chalk.yellow(`Rolling up type definitions for ${target}...`))
);
// 执行API Extractor操作 重新生成声明文件
const { Extractor, ExtractorConfig } = require("@microsoft/api-extractor");
const extractorConfigPath = path.resolve(pkgDir, `api-extractor.json`);
const extractorConfig =
ExtractorConfig.loadFileAndPrepare(extractorConfigPath);
const extractorResult = Extractor.invoke(extractorConfig, {
localBuild: true,
showVerboseMessages: true,
});
if (extractorResult.succeeded) {
console.log(`API Extractor completed successfully`);
process.exitCode = 0;
} else {
console.error(
`API Extractor completed with ${extractorResult.errorCount} errors` +
` and ${extractorResult.warningCount} warnings`
);
process.exitCode = 1;
}
// 删除ts生成的声明文件
await rm(`${pkgDir}/dist/packages`, { recursive: true, force: true });
}
};
// 同步编译多个包时,为了不影响编译性能,我们需要控制并发的个数,这里我们暂定并发数为4
const maxConcurrency = 4; // 并发编译个数
const buildAll = async function () {
const ret = [];
const executing = [];
for (const item of targets) {
// 依次对子包执行build()操作
const p = Promise.resolve().then(() => build(item));
ret.push(p);
if (maxConcurrency <= targets.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);
};
// 执行编译操作
buildAll();
@microsoft/api-extractor
@microsoft/api-extractor
这个包我们主要用来整合.d.ts
声明文件的,你可以把这个库理解成ts声明文件处理的打包工具,最终形成的.d.ts
由多个文件,汇总成一个文件
这里需要单独说明一下这个包的使用,本地安装@microsoft/api-extractor
之后,可以在执行命令
npx api-extractor init
在根目录生成api-extractor
全局json配置文件,不过生成的json文件一堆注释,我们可以直接用vue的
api-extractor.json
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"apiReport": {
"enabled": true,
"reportFolder": "<projectFolder>/temp/"
},
"docModel": {
"enabled": true
},
"dtsRollup": {
"enabled": true
},
"tsdocMetadata": {
"enabled": false
},
"messages": {
"compilerMessageReporting": {
"default": {
"logLevel": "warning"
}
},
"extractorMessageReporting": {
"default": {
"logLevel": "warning",
"addToApiReportFile": true
},
"ae-missing-release-tag": {
"logLevel": "none"
}
},
"tsdocMessageReporting": {
"default": {
"logLevel": "warning"
},
"tsdoc-undefined-tag": {
"logLevel": "none"
}
}
}
}
由于每一个子项目,都需要单独的整合,因此,在每个子项目的根目录下,创建api-extractor.json
文件
{
"extends": "../../api-extractor.json",
"mainEntryPointFilePath": "./dist/packages/<unscopedPackageName>/src/index.d.ts",
"dtsRollup": {
"publicTrimmedFilePath": "./dist/<unscopedPackageName>.d.ts"
}
}
然后就是在build.mjs
中的代码处理了,代码的基本原理其实就是整合生成.d.ts
文件,然后删除原来形成的.d.ts
文件
const build = async function (target) {
// 代码省略
await execa(// 相关配置省略);
// 执行完rollup生成声明文件后
// package.json中定义此字段时执行,通过api-extractor整合.d.ts文件
if (pkg.types) {
console.log(
chalk.bold(chalk.yellow(`Rolling up type definitions for ${target}...`))
);
// 执行API Extractor操作 重新生成声明文件
const { Extractor, ExtractorConfig } = require("@microsoft/api-extractor");
const extractorConfigPath = path.resolve(pkgDir, `api-extractor.json`);
const extractorConfig =
ExtractorConfig.loadFileAndPrepare(extractorConfigPath);
const extractorResult = Extractor.invoke(extractorConfig, {
localBuild: true,
showVerboseMessages: true,
});
if (extractorResult.succeeded) {
console.log(`API Extractor completed successfully`);
process.exitCode = 0;
} else {
console.error(
`API Extractor completed with ${extractorResult.errorCount} errors` +
` and ${extractorResult.warningCount} warnings`
);
process.exitCode = 1;
}
// 删除ts生成的声明文件
await rm(`${pkgDir}/dist/packages`, { recursive: true, force: true });
}
};
命令行自定义打包并指定其格式
这里也需要单独说明一下,主要是利用了第三方包:minimist
,并且读取命令行参数,主要的代码如下:
// 方便单独打包可以传递命令行参数
const args = require('minimist')(process.argv.slice(2))
// 如果没有传递命令行参数,就是全部工程
const targets = args._.length ? args._ : allTargets
const formats = args.formats || args.f
其中formats参数,会通过命令行命令传递进去,因此,我们可以执行命令
pnpm run build reactivity --formats global
package.json中脚本
"scripts": {
"build": "node scripts/build.mjs"
},
// 运行
pnpm build
渲染器界面测试
PS: 引入渲染器之后,界面进行测试:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app"></div>
</body>
<script src="./runtime-core.global.js"></script>
<script >
const {options, createRenderer} = VueRuntimeCore;
const oldVNode = {
type: "div",
children: [
{ type: "p", children: "1", key: 1 },
{ type: "p", children: "2", key: 2 },
{ type: "p", children: "3", key: 3 },
{ type: "p", children: "4", key: 4 },
{ type: "p", children: "6", key: 6 },
{ type: "p", children: "5", key: 5 },
],
};
const newVNode = {
type: "div",
children: [
{ type: "p", children: "1", key: 1 },
{ type: "p", children: "3", key: 3 },
{ type: "p", children: "4", key: 4 },
{ type: "p", children: "2", key: 2 },
{ type: "p", children: "7", key: 7 },
{ type: "p", children: "5", key: 5 },
],
};
const nodeOps = options;
const renderer = createRenderer(nodeOps);
// 首次挂载
renderer.render(oldVNode, document.getElementById("app"));
// 1秒后更新
setTimeout(() => {
renderer.render(newVNode, document.getElementById("app"));
}, 1000);
</script>
</html>