🐯 前端工程化小白 开发一个 webpack 插件的艰难历程

555 阅读9分钟

接着上一篇文章:# 🔥 AST、Babel、TSC - 啃着啃着就会了,现在来尝试实现开发插件

如何搭建Monorepo项目?

方案选取

目前在前端领域比较流行的 Monorepo 工具有Pnpm WorkspacesYarn Workspacesnpm WorkspacesRushTurborepoLernaYalc、和 Nx。 用得最多的是 Pnpm Workspace,也非常推荐大家使用这个工具来作为 Monorepo 项目的依赖管理工具,现在很多大厂也是在用这套方案。那么问题来了:Monorepo 与包管理工具(npm、yarn、pnpm) 之间是一种怎样的关系呢?

答:这些包管理工具与 monorepo 的关系在于:它们可以为 monorepo 提供依赖安装与依赖管理的支持,借助自身对 workspace 的支持,允许在 monorepo 中不同的子项目之间共享依赖性,并提供一种管理这些共享依赖项的方式,这可以简化依赖项管理和构建过程,并提高开发效率。

项目搭建

1.全局安装pnpm

npm i pnpm -g

2.初始化项目

  • (1)创建新的项目目录:aliasToModulation-plugin
  • (2)根目录终端运行 pnpm init 创建package.json 文件

image.png

  • (3)在根目录下新建一个文件夹packages,用来存放子包package-a package-sdk,再各自在相应的子目录下运行pnpn init

  • 在两个子包里面都创建一个 src 目录和 index.ts 文件

  • (4)配置 workspace:在根目录下新建一个pnpm-workspace.yaml,将 packages 下所有的目录都作为包进行管理

packages:
  - 'packages/*'

这个pnpm-workspace.yaml文件是用来配置一个pnpm工作区的。在pnpm中,工作区(workspace)允许你在同一个仓库中管理多个包(package),这些包可以相互依赖,并且你可以一次性安装、更新和发布这些包。

简而言之,这个配置文件告诉pnpm,所有的包都位于项目根目录下的packages文件夹中,这样pnpm就可以统一管理这些包了。

  • (5) 子包共享:

此时,pnpm-workspace.yaml工作空间下的每个子包都可以共享我们的公共依赖了。还有个问题是,兄弟模块之间如何共享呢?

答:子包之间可以通过 package.json 定义的 name 相互引用,并 install 到 node_modules 里面

image.png

package.json 中就会自动添加如下依赖,"workspace:" 只会解析本地 workspace 包含的 package

image.png

  • (6)由于 package-a需要进行打包构建,所以这里简单配置下 webpack:执行pnpm add --save-dev webpack webpack-cli webpack-dev-server

image.png

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "webpack serve --config ./webpack.config.js",
    "build": "webpack --config webpack.config.js"
  },
  • (7) 子包package-a目录下新建文件 webpack.config.js,用于打包TypeScript项目,配置内容如下:
const path = require("path");
module.exports = {
  entry: "./src/index.ts",
  // entry属性指定了入口文件的路径,这里是src/index.ts。
  output: {
    filename: "index.js",
    path: path.resolve(__dirname, "dist"),
  },
  // output属性指定了输出文件的名称和路径。
  // 这里将输出文件命名为index.js,路径为dist目录下。
  // __dirname是Node.js中的一个全局变量,表示当前执行脚本所在的目录。
  resolve: {
    extensions: [".ts", ".tsx", ".js"],
  },
  // resolve属性配置了模块解析规则。extensions指定了哪些文件扩展名可以被自动解析。
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: "ts-loader",
        exclude: /node_modules/,
      },
    ],
  },
  // module属性配置了模块的处理规则。
  // 这里使用了ts-loader来处理.ts和.tsx文件,
  mode: "production",
  // production模式会启用性能优化,
  // 而development模式则提供了详细的错误信息和源映射(source maps)。
};
};
  • (8)上面用到了ts-loader, 所以这里需要安装依赖:在根目录下执行pnpm install ts-loader -D

image.png

  • (9) 接着就是在各个子包下新建文件tsconfig.json,配置内容如下:
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@package-sdk/*": ["../package-sdk/src/*"]
    }
  },
  "include": ["src"],
  "references": [
    {
      "path": "../package-sdk"
    }
  ]
}

这里也顺带说明下 tsconfig 中常见的配置项 - 参考文章一份不可多得的 ts 学习指南

image.png

到这里,初始化项目搭建就完成了,文件目录如下:

.
├── packages
   ├── package-a
      ├── src
         └── index.ts
      ├── package.json
      ├── pnpm-lock.yaml
      ├── tsconfig.json
      └── webpack.config.js
   └── package-sdk
       ├── src
          └── index.ts
       ├── package.json
       └── tsconfig.json
├── package.json
├── pnpm-lock.yaml
└── pnpm-workspace.yaml

3.尝试打包项目

执行pnpm build 命令:

image.png

ok 打包没问题,现在去给 index.ts 填简单代码

//packages/package-a/src/index.ts
import { hello } from '../../package-sdk/src/index';
console.log(hello);
//packages/package-sdk/src/index.ts
export const hello = "Hello from package-sdk";

再次执行打包命令,然后去 dist 目录下,执行打包后的文件index.js

image.png

ok 输出没问题,现在环境搭建好了,接下来就是本文最重要的环节 -- 写插件

4. 确定需要实现的效果

//import { hello } from '../../package-sdk/src/index';
import { hello } from 'package-sdk/index';
console.log(hello);

想要实现这样的效果,需要具备的知识有:exports、references、paths、alias、composite。

在理解和使用 pnpm + monorepo 项目时,exports 和 references 是两个关键的概念,它们分别来自于 JavaScript/TypeScript 模块规范和 TypeScript 项目配置。下面我将详细解释这两个概念及其在 pnpm 和 monorepo 项目中的应用。

exports

exportspackage.json文件中的一个字段,用于定义模块可以导出的入口点。这主要用于指定当其他模块或包尝试引入当前包时,哪些文件或目录是可以被访问的。这个特效中构建复杂项目或库时非常有用,因为它允许开发者精确地控制包的公开接口,隐藏内部实现细节。如:

{
  "name": "package-sdk",
  "exports":{
    "./index":"./src/index.ts"
  },
}

如果不配置exports字段的话,可能会有以下影响:

  1. 默认导出行为
  • 对于CommonJS模块,如果不配置exports,则默认导出main字段指定的文件(如果main字段存在)。如果main字段也不存在,则可能默认导出index.js文件(如果该文件存在)。
  • 对于ES模块,如果不配置exports,则可以使用相对路径或绝对路径直接引用模块内部的文件。但是,这可能会暴露模块内部的实现细节,并且不受exports字段提供的封装和保护。
  1. 封装和保护
  • exports字段提供了一种封装模块内部实现的方式。通过仅导出特定的文件或路径,可以隐藏模块内部的其他文件和实现细节,从而避免不必要的暴露和潜在的安全问题。如果不配置exports,则模块的内部实现可能会更容易被外部访问和修改。
pathsreferences

pathscompilerOptions 的一个子配置,它允许你定义模块的路径别名,这样你就可以在代码中使用这些别名来引用模块,而不是硬编码实际的文件路径。这在大型项目中非常有用,特别是当你需要频繁引用某些模块或目录时。paths 配置的值是一个对象,其中的键是别名,值是一个数组,数组中的每个元素都是对应的解析路径。

paths 配置通常用于以下场景:

  1. 简化模块引用:你可以为常用的模块路径设置别名,例如将 @/components 指向项目的 src/components 目录。
  2. 支持模块的重构:当你需要移动模块到不同的位置时,只需要更新 tsconfig.json 中的路径别名,而不需要修改所有引用该模块的文件。
  3. 避免硬编码的路径:使用路径别名可以避免在代码中硬编码具体的文件路径。

referencestsconfig.json 的一个顶层配置,它用于定义项目引用。这个配置允许你将多个 tsconfig.json 文件组织在一起,形成一个项目结构,其中每个 tsconfig.json 文件可以定义一个项目的一部分。references 配置的值是一个对象数组,每个对象都包含一个 path 属性,指向另一个 tsconfig.json 文件的位置。参考文章 references

package-a下的 tsconfig.json 中使用 references 配置字段去指向package-sdk 依赖:

{
  "compilerOptions": {
    //"composite": true, //此时 package-a 项目没有被其他项目引用,所以不用设置
    "baseUrl": ".",
    "paths": {//实现别名的引用
      "@/*": ["./src/*"],
      "@package-sdk/*": ["../package-sdk/src/*"]
    }
  },
  "include": ["src/**/*.ts"],
  //"exclude": ["node_modules", "dist"],
  //这里不能用 exclude,因为 monorepo 中引用了其他子包会放到node_modules 里面
  "references": [
    {
      "path": "../package-sdk" 
      //此时 package-sdk 项目被引用了,所以在它的 tsconfig 那里需要配置"composite": true
    }
  ]
}

注意事项:

  1. 所有被引用的项目都应该设置 "composite": true 在它们的 compilerOptions 中,这样它们才能被其他项目引用。
  2. 引用的项目会生成 .d.ts 声明文件,这些文件会被依赖它的项目使用,以了解类型信息。

总的来说,pathsreferences 是 TypeScript 项目配置中两个互补的机制,它们共同支持了模块化开发、项目分割和代码复用。

alias

在 webpack.config.js 中配置:

  resolve: {
    extensions: [".ts", ".tsx", ".js"],
    alias: {
      "@sdk": path.resolve(__dirname, "../package-sdk/src"),
    },
  },

执行打包pnpm build

image.png

打包成功

image.png

输出正常

ok 插件要执行的逻辑明朗了,就是提取 tsconfig 中的 path 字段,然后注入 compiler 的 alias 配置中,那我们该如何实现呢?

编写插件

梳理下前面实现的逻辑:

在 tsconfig.json 中配置 paths 别名,中 webpack.config.js 中自动注入 alias 里面

image.png

核心代码实现:

const path = require("path");
const fs = require("fs");

class TypescriptAliasPlugin {
  constructor(options) {
    // 获取 tsconfig.json 所在的根目录:tsconfigRoot
    // this.xxx=xxx;
  }

  apply(compiler) {
  //先确定在哪个钩子函数注册事件:这里应该用 afterResolvers.tap
      try{
          //获取 tsconfig 文件路径:利用 options 中拿到的tsconfigRoot,和"tsconfig.json"拼接起来 
          //读取 tsconfig 内容,并由 json 格式转为对象
          //开一个 alias 空对象,并遍历 tsconfig 中的 paths 对象
              //按照转换逻辑,将 paths 中的内容匹配添加到 alias 中
          //将 alias 合并到 compiler.options.resolve 中
      }catch{
          //打印 error 报错信息
      }
  }
}

module.exports = TypescriptAliasPlugin;

具体代码我放到代码仓库:github.com/Mzz2022/ali…

编译输出

image.png

打包构建

image.png

尾声

总算是将这个插件完成了,虽然编写插件的逻辑不是很难,但难就难在需要掌握比较全面的知识点,像 alias、paths、references、webpack.config.js 这些,不然搞起来真的毫无头绪。

然后就是对 hook 钩子的选择,选择注册事件的时机也很重要,这就需要对 webpack 构建流程有个完整的理解体系,少走很多弯路。

踩坑的历程太难受了,为了不给大伙传输负面情绪,这篇文章就把大部分坑点给过滤掉了(自己承受就好了),感兴趣的也可以去尝试尝试,经历过就会发现:对前端工程化有了更为全面的理解......