[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文件夹中中创建工程reactivityshared

导入相关的简单测试代码:

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>
posted @ 2025-06-11 14:04  Zhentiw  阅读(50)  评论(0)    收藏  举报