集成webpack、typescript,构建打包体系(一)

1,091 阅读16分钟

上一节 # 基于pnpm搭建monorepo前端工程

我们前面已经详细讲过webpack、babel、typescript的基本配置,这里就不再赘述它们的API,如果有疑问的可以去看我的这篇文章前置章节和对应的官网。话不多讲,直接开干。。。

集成webpack

为了成功集成 webpack,让我们的组件库能够构建出产物,这里我们需要完成三个步骤,分别是:

  • 编写构建目标源码。因为文章的重点是工程化而非组件库的开发,代码预备部分我们不会实现组件的实际功能,只给出能够体现构建要点的 demo 代码
  • 准备 webpack.config.ts 配置文件。
  • package.json 中设置构建脚本。

我们先回顾一下上一章节所规划的 monorepo 目录结构,在集成 webpack 的过程中,我们会对它做非常多的拓展:

  1. 对于 packages 目录下的每一个组件包,我们制定了更细的源码组织规则:
  • 各种配置文件,如 package.jsonwebpack.config.ts,都放在模块根目录下。
  • src 目录下存放源码,其中 src/index.ts(js) 作为这个模块的总出口,所有需要暴露给外部供其他模块使用的方法、对象都要在这里声明导出。
  • dist 目录作为产物输出目录,当然如果没执行过构建命令,这个目录是不会生成的。
  1. packages 目录下新建统一出口包,命名为 @farmerui/ui。正如 element-plus 主包负责集合各个子包,并统一导出其中内容一般。
  2. 我们还没走到搭建 demo 文档的阶段,但又迫不及待地想看到组件的实际效果,为了满足这个需求,我们要在根目录下建立 demo 模块,这个模块是一个 Web 应用,用来展示组件,同时验证我们的monorepo 架构是否能立即响应子模块的更新修改。
  3. 关于 tsconfig,我们会在集成 TypeScript 的阶段进行说明。

添加各子包测试代码

1.公共方法代码添加@farmerui/shared 我们规划 @openxui/shared 作为公共工具方法包,将成为所有其他模块的依赖项。

// 模块源码目录
📦shared
 ┣ ...
 ┣ 📂src
 ┃ ┣ 📜hello.ts
 ┃ ┣ 📜index.ts
 ┃ ┗ 📜useLodash.ts
 ┣ ...

为 shared 包安装 lodash 相关依赖

pnpm --filter @farmerui/shared i -S lodash @types/lodash

给hello.ts文件添加测试代码

// packages/shared/src/hello.ts
export function hello(to: string = 'World') {
  const txt = `Hello ${to}!`;
  alert(txt);
  return txt;
}

useLodash.ts文件添加测试代码

// packages/shared/src/useLodash.ts
import lodash from 'lodash';
export function useLodash() {
  return lodash
}

index.ts文件添加测试代码

// packages/shared/src/index.ts
export * from './hello';
export * from './useLodash'

2.button组件代码添加@farmerui/button

我们先设置 button 组件的初始代码,为了演示 monorepo 工程中内部模块之间互相依赖的特性,我们假定按钮组件的用例为点击之后打印 Hello ${props.hello},那么 button 将依赖于先前定义的 shared 模块中的 hello 方法。我们先通过 pnpm workspace 命令声明内部模块关联:

pnpm --filter @farmerui/button i -S @farmerui/shared

button.tsx添加代码

import React from 'react';
import { hello } from '@farmerui/shared';
interface ButtonProps {
   onClick: () => void;
   size: number,
   content: string
}

const Button: React.FC<ButtonProps> = ({onClick, size, content}) => {
   return <>
      <button onClick={() => hello(content)}>{content}</button>
   </>
}
export default Button

index.ts组件出口添加代码

import Button from "./button";
export { Button }

3.input组件代码添加@farmerui/input

按照类似button组件的方式,实现处理一下 input 模块。我们假设 input 输入框组件实现的场景是监听内容变化,调用 hello 方法打印当前输入的内容。

pnpm --filter @farmerui/input i -S @farmerui/shared

input.tsx组件添加代码

import React from 'react';
import { hello } from '@farmerui/shared';
interface InputProps {
   onChange?: () => void;
   onBlur: () => void;
   InputType: 'text'
}
const Input: React.FC<InputProps> = ({onBlur, InputType}) => {
   return <>
      <input type={InputType} onBlur={(e) => onBlur(e.target.value)} onChange={(e) => hello(e.target.value)}></input>
   </>
}
export default Input

index.ts组件出口添加代码

import Input from "./input";
export { Input }

4.组件主包 @farmerui/ui @farmerui/ui 模块作为各个组件的统一出口,需要在 package.json 中正确声明与所有组件的依赖关系,之后每增加一个新组件,都应该在 @farmerui/ui 模块中补充导出代码。
package.json

{
  "name": "@farmerui/ui",
  "version": "0.0.0",
  "description": "",
  "keywords": ["react", "utils", "component library"],
  "author": "coderlwh",
  "license": "MIT",
  "homepage": "https://github.com/coderliweihong/farmer-ui/blob/main/README.md",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/coderliweihong/farmer-ui.git"
  },
  "bugs": {
    "url" : "https://github.com/coderliweihong/farmer-ui/issues"
  },
  "scripts": {
    "build": "echo build",
    "test": "echo test"
  },
  "main": "",
  "module": "",
  "types": "",
  "exports": {
    ".": {
      "require": "",
      "module": "",
      "types": ""
    }
  },
  "files": [
    "dist",
    "README.md"
  ],
  "peerDependencies": {
    "react": ">=18.2.0",
    "react-dom": ">=18.2.0"
  },
  "dependencies": {
    "@farmerui/button": "workspace:^",
    "@farmerui/input": "workspace:^",
    "@farmerui/shared": "workspace:^"
  },
  "devDependencies": {}
}

添加src/index.ts文件

// packages/ui/src/index.ts
export * from '@farmerui/button';
export * from '@farmerui/input';
export * from '@farmerui/shared';

安装子包依赖

pnpm --filter @farmerui/ui i

okey,我们子包文件中的测试代码添加完毕,接下来进入基本的构建配置环节。

基础构建配置

安装公共构建依赖

因为每个包都需要用到 webpack 、babel和 typeScript 进行构建,我们在之前的前置知识章节讲过,公共开发依赖统一安装在根目录下,是可以被各个子包正常使用的。 安装webpack相关依赖

pnpm i -wD webpack webpack-cli @types/webpack webpack webpack-dev-server webpack-merge cross-env html-webpack-plugin mini-css-extract-plugin

安装sass相关依赖

pnpm i -wD css-loader postcss-loader postcss postcss-preset-env sass-loader sass mini-css-extract-plugin

安装babel相关依赖

pnpm install -wD babel-loader @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript @babel/plugin-transform-runtime @babel/runtime-corejs3

@babel/runtime包运行时加载,需安装到dependencies

pnpm i -wS @babel/runtime  

根目录添加babel.config.json文件

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-react", //支持编译react jsx语法
    "@babel/preset-typescript" //支持编译tsx、ts
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "absoluteRuntime": false,
        "corejs": 3
      }
    ]
  ]
}

子包构建配置文件添加

1.@farmer/shared包添加webpack.config.ts

import * as path from 'path';
import * as webpack from 'webpack';
const config: webpack.Configuration = {
    mode: 'development',
    entry: './src/index.ts',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].bundle.js',
        clean: true,
        library: {
            name: 'farmeruiShareds',
            type: 'umd'
        }
    },
    module: {
        rules: [
            {
                test: /.(ts|js)$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        rootMode: "upward" //该属性会告诉webpack一直向上级目录搜索babel的配置文件,这样可以将babel.config.json放到项目根目录下
                    }
                },
                exclude: /node_modules/,
            }
        ],
    },
    resolve: {
        extensions: ['.ts', '.js', '.json']
    },
    devtool: 'source-map'
};
export default config;

package.json添加脚本命令

//  packages/shared/package.json
...
"scripts": {
+ "build:dev": "webpack -c webpack.config.ts",
  "test": "echo test"
},
"main": "./dist/main.bundle.js",
"module": "./dist/main.bundle.js",
"types": "./dist/index.d.ts",
"exports": {
  ".": {
    "require": "./dist/main.bundle.js",
    "module": "./dist/main.bundle.js",
    "types": "./dist/index.d.ts"
  }
},
...

项目根目录运行打包命令

pnpm --filter @farmerui/shared run build:dev

CleanShot 2023-12-11 at 15.30.27.png 2.@farmer/button包添加webpack.config.ts

import * as path from 'path';
import * as webpack from 'webpack';
const config: webpack.Configuration = {
    mode: 'development',
    entry: './src/index.ts',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].bundle.js',
        clean: true,
        library: {
            name: 'fu-button',
            type: 'umd'
        }
    },
    module: {
        rules: [
            {
                test: /.(tsx|ts|js)$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        rootMode: "upward",
                    }
                },
                exclude: /node_modules/,
            }
        ],
    },
    resolve: {
        extensions: ['.tsx','.ts', '.js', '.json']
    },
    devtool: 'source-map',
    externals: [
        {
            'react': 'React',
            'react-dom': 'ReactDOM'
        },
        // 除了 @farmerui/shared,未来可能还会依赖其他内部模块,我们直接用正则表达式将 @farmerui 开头的依赖项一起处理掉
        /@farmerui.*/
    ]
};
export default config;

package.json文件添加脚本执行命令

//  packages/shared/package.json
...
"scripts": {
+ "build:dev": "webpack -c webpack.config.ts",
  "test": "echo test"
},
"main": "./dist/main.bundle.js",
"module": "./dist/main.bundle.js",
"types": "./dist/index.d.ts",
"exports": {
  ".": {
    "require": "./dist/main.bundle.js",
    "module": "./dist/main.bundle.js",
    "types": "./dist/index.d.ts"
  }
},
...

执行打包命令

pnpm --filter @farmerui/button run build:dev

CleanShot 2023-12-11 at 16.22.59@2x.png

3.@farmer/input包参照button组件添加配置,这里不再赘述 4.@farmer/ui包添加webpack.config.ts

import * as path from 'path';
import * as webpack from 'webpack';
const config: webpack.Configuration = {
    mode: 'development',
    entry: './src/index.ts',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].bundle.js',
        clean: true,
        library: {
            name: 'farmeruis',
            type: 'umd'
        }
    },
    module: {
        rules: [
            {
                test: /.(ts|js)$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        rootMode: "upward",
                    }
                },
                exclude: /node_modules/,
            }
        ],
    },
    resolve: {
        extensions: ['.ts', '.js', '.json']
    },
    devtool: 'source-map',
    externals: [
        {
            'react': 'React',
            'react-dom': 'ReactDOM'
        },
        // 除了 @farmerui/shared,未来可能还会依赖其他内部模块,我们直接用正则表达式将 @farmerui 开头的依赖项一起处理掉
        /@farmerui.*/
    ]
};
export default config;

package.json添加脚本命令

//  packages/shared/package.json
...
"scripts": {
+ "build:dev": "webpack -c webpack.config.ts",
  "test": "echo test"
},
"main": "./dist/main.bundle.js",
"module": "./dist/main.bundle.js",
"types": "./dist/index.d.ts",
"exports": {
  ".": {
    "require": "./dist/main.bundle.js",
    "module": "./dist/main.bundle.js",
    "types": "./dist/index.d.ts"
  }
},
...

到此为止,所有组件的构建都完成了,我们可以通过 路径过滤器 选中 packages 目录下所有包进行构建

pnpm --filter "./packages/**" run build:dev

由于 @openxui/ui 是组件库的统一出口包,它的 package.jsondependencies 字段中声明了所有其他模块,我们也可以用依赖过滤器 ...,构建 ui 以及其所有的依赖项,达到整体构建的效果。不过如果不能确保所有包都在 ui 中再进行一次导出,还是采用前者更好。

pnpm --filter @farmerui/ui... run build:dev

执行命令后,命令行输出如下:

Scope: 4 of 6 workspace projects
packages/shared build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.82 KiB [compared for emit] (name: main) 1 related asset
│ runtime modules 937 bytes 4 modules
│ built modules 395 bytes [built]
│   ./src/index.ts 53 bytes [built] [code generated]
│   ./src/hello.ts 223 bytes [built] [code generated]
│   ./src/useLodash.ts 77 bytes [built] [code generated]
│   external {"commonjs":"lodash","commonjs2":"lodash","amd":"lodash","root":"_"} 42 bytes [built] [code generated]
│ webpack 5.89.0 compiled successfully in 843 ms
└─ Done in 3s
packages/button build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.94 KiB [compared for emit] (name: main) 1 related asset
│ runtime modules 937 bytes 4 modules
│ built modules 538 bytes [built]
│   cacheable modules 454 bytes
│     ./src/index.ts 49 bytes [built] [code generated]
│     ./src/button.tsx 405 bytes [built] [code generated]
│   external "React" 42 bytes [built] [code generated]
│   external "@farmerui/shared" 42 bytes [built] [code generated]
│ webpack 5.89.0 compiled successfully in 690 ms
└─ Done in 2.7s
packages/input build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 6 KiB [compared for emit] (name: main) 1 related asset
│ runtime modules 937 bytes 4 modules
│ built modules 612 bytes [built]
│   cacheable modules 528 bytes
│     ./src/index.ts 46 bytes [built] [code generated]
│     ./src/input.tsx 482 bytes [built] [code generated]
│   external "React" 42 bytes [built] [code generated]
│   external "@farmerui/shared" 42 bytes [built] [code generated]
│ webpack 5.89.0 compiled successfully in 716 ms
└─ Done in 2.8s
packages/ui build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 6.89 KiB [emitted] (name: main) 1 related asset
│ runtime modules 937 bytes 4 modules
│ built modules 254 bytes [built]
│   ./src/index.ts 128 bytes [built] [code generated]
│   external "@farmerui/button" 42 bytes [built] [code generated]
│   external "@farmerui/input" 42 bytes [built] [code generated]
│   external "@farmerui/shared" 42 bytes [built] [code generated]
│ webpack 5.89.0 compiled successfully in 616 ms
└─ Done in 2.6s
 ~/Desktop/farmer-ui   main ±✚  pnpm --filter "./packages/**" run build:dev
Scope: 4 of 6 workspace projects
packages/shared build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.82 KiB [compared for emit] (name: main) 1 related asset
│ runtime modules 937 bytes 4 modules
│ built modules 395 bytes [built]
│   ./src/index.ts 53 bytes [built] [code generated]
│   ./src/hello.ts 223 bytes [built] [code generated]
│   ./src/useLodash.ts 77 bytes [built] [code generated]
│   external {"commonjs":"lodash","commonjs2":"lodash","amd":"lodash","root":"_"} 42 bytes [built] [code generated]
│ webpack 5.89.0 compiled successfully in 772 ms
└─ Done in 2.8s
packages/input build:dev$ webpack -c webpack.config.ts
└─ Running...
packages/button build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.94 KiB [compared for emit] (name: main) 1 related asset
│ asset main.bundle.js 6 KiB [compared for emit] (name: main) 1 related asset
└─ Running...
packages/button build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.94 KiB [compared for emit] (name: main) 1 related asset
│ runtime modules 937 bytes 4 moduleslt] [code generated]
└─ Running...
packages/button build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.94 KiB [compared for emit] (name: main) 1 related asset
│ built modules 612 bytes [built]uleslt] [code generated]
└─ Running...
packages/button build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.94 KiB [compared for emit] (name: main) 1 related asset
│   cacheable modules 528 bytesoduleslt] [code generated]
└─ Running...
packages/button build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.94 KiB [compared for emit] (name: main) 1 related asset
│     ./src/index.ts 46 bytes [built] [code generated]ed]
└─ Running...
packages/button build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.94 KiB [compared for emit] (name: main) 1 related asset
│     ./src/input.tsx 482 bytes [built] [code generated]]
└─ Running...
packages/button build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.94 KiB [compared for emit] (name: main) 1 related asset
│   external "React" 42 bytes [built] [code generated]ed]
└─ Running...
packages/button build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.94 KiB [compared for emit] (name: main) 1 related asset
│   external "@farmerui/shared" 42 bytes [built] [code generated]
└─ Running...
packages/button build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.94 KiB [compared for emit] (name: main) 1 related asset
│ webpack 5.89.0 compiled successfully in 720 msenerated]
└─ Running...
packages/button build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 5.94 KiB [compared for emit] (name: main) 1 related asset
└─ Done in 2.8ses 937 bytes 4 moduleslt] [code generated]
│ built modules 538 bytes [built]ilt] [code generated]
│   cacheable modules 454 bytes
│     ./src/index.ts 49 bytes [built] [code generated]
│     ./src/button.tsx 405 bytes [built] [code generated]
│   external "React" 42 bytes [built] [code generated]
│   external "@farmerui/shared" 42 bytes [built] [code generated]
│ webpack 5.89.0 compiled successfully in 685 ms
└─ Done in 2.8s
packages/ui build:dev$ webpack -c webpack.config.ts
│ asset main.bundle.js 6.89 KiB [compared for emit] (name: main) 1 related asset
│ runtime modules 937 bytes 4 modules
│ built modules 254 bytes [built]
│   ./src/index.ts 128 bytes [built] [code generated]
│   external "@farmerui/button" 42 bytes [built] [code generated]
│   external "@farmerui/input" 42 bytes [built] [code generated]
│   external "@farmerui/shared" 42 bytes [built] [code generated]
│ webpack 5.89.0 compiled successfully in 637 ms
└─ Done in 2.6s

观察命令行输出,可以发现整个打包执行顺序(shared -> button & input(并行) -> ui) 是符合依赖树的拓扑排序的。由于我们用 webpack.externals 外部化了依赖,这个特性现在对我们而言无关紧要,但在未来定制完善的打包体系,需要研究全量构建时,拓扑排序的特性就会变得非常关键。
我们在项目根目录下的package.json文件添加全局打包

{
  // ...
  "scripts": {
+   "build:ui": "pnpm --filter "./packages/**" run build:dev"
  },
}

我们已经能够打包umd产物,我们做lib库肯定想支持更多场景,比如esm和cmd,我们继续修改@farmerui/buttonpackage.jsonwebpack.config.ts文件代码,其余模块也参考该模块进行设置,不再详叙。
package.json添加配置

{
 ...
  "scripts": {
+   "build:dev": "cross-env NODE_ENV=development pnpm --filter @farmerui/button run build:ESM & cross-env NODE_ENV=development pnpm --filter @farmerui/button run build:UMD & cross-env NODE_ENV=development pnpm --filter @farmerui/button run build:CMD",
+  "build:prod": "cross-env NODE_ENV=production pnpm --filter @farmerui/button run build:ESM & cross-env NODE_ENV=production pnpm --filter @farmerui/button run build:UMD & cross-env NODE_ENV=production pnpm --filter @farmerui/button run build:CMD",
+   "build:ESM": "cross-env LIB_TYPE=module webpack -c webpack.config.ts",
+   "build:UMD": "cross-env LIB_TYPE=umd webpack -c webpack.config.ts",
+   "build:CMD": "cross-env LIB_TYPE=commonjs webpack -c webpack.config.ts",
    "test": "echo test"
  },
+ "main": "./lib/index.js",
+ "browser": "./dist/index.js",
+ "module": "./es/index.esm.js",
  "types": "",
  "exports": {
    ".": {
+     "require": "./lib/index.js",
+     "module": "./es/index.esm.js",
+     "default": "./dist/index.js"
    }
  },
  "files": [
    "dist",
    "lib",
    "es",
    "README.md"
  ],
  "peerDependencies": {
    "react": ">=18.2.0",
    "react-dom": ">=18.2.0"
  },
  "dependencies": {
    "@farmerui/shared": "workspace:^"
  }
}

```diff
//packages/button/webpack.config.ts
import * as path from 'path';
import * as webpack from 'webpack';
const isProd = process.env.NODE_ENV && process.env.NODE_ENV.toLowerCase()  === 'production';
+ const libType:string = process.env.LIB_TYPE && process.env.LIB_TYPE.toLowerCase() || '';
/**
 * generate library output config
 * @param type module type
 */
+ const generateLibOutputConfig = (type: string) => {
    switch (type) {
        case 'umd':
            return {
                path: path.resolve(__dirname, 'dist'),
                filename: 'index.js',
                library: {
                    name: 'FarmerUIShared',
                    type: 'umd',
                    export: 'default'
                },
                globalObject: 'globalThis',
                clean: true
            }
        case 'module':
            return {
                path: path.resolve(__dirname, 'es'),
                filename: 'index.esm.js',
                library: {
                    type: 'module'
                },
                chunkFormat: 'module',
                clean: true
            }
        case 'commonjs':
            return {
                path: path.resolve(__dirname, 'lib'),
                filename: 'index.js',
                library: {
                    name: 'FarmerUIShared',
                    type: 'commonjs'
                },
                clean: true
            }
        default:
            return {
                path: path.resolve(__dirname, 'dist'),
                filename: 'index.js',
                library: {
                    name: 'FarmerUIShared',
                    type: 'umd',
                    export: 'default'
                },
                globalObject: 'globalThis',
                clean: true
            }
    }
}
/**
 * generate library externalsType config
 * @param type module type
 */
+ const generateLibExternalsTypeConfig = (type: string) => {
    switch (type) {
        case 'umd':
            return 'umd';
        case 'module':
            return 'module';
        case 'commonjs':
            return 'commonjs';
        default:
            return 'umd';
    }
}
const config: webpack.Configuration = {
    mode: isProd ? 'production' : 'development',
    entry: './src/index.ts',
    // 由于输出 ESM 格式文件为 Webpack 实验特性,因此需要加上此配置。
+    experiments: {
        outputModule: libType === 'module'
    },
    output: generateLibOutputConfig(libType),
    module: {
        rules: [
            {
                test: /.(tsx|ts|js)$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        rootMode: "upward",
                    }
                },
                exclude: /node_modules/,
            }
        ],
    },
    resolve: {
        extensions: ['.tsx','.ts', '.js', '.json'],
    },
    devtool: 'source-map',
+    externalsType: generateLibExternalsTypeConfig(libType),
+    externals: [
        {
            'react': 'React',
            'react-dom': 'ReactDOM'
        },
        // 除了 @farmerui/shared,未来可能还会依赖其他内部模块,我们直接用正则表达式将 @farmerui 开头的依赖项一起处理掉
        /@farmerui.*/
    ]
};
export default config;

⚠️ 带有 { root, amd, commonjs, ... } 的对象只允许用于 libraryTarget: 'umd' 和 [externalsType: 'umd']。其他库的 target 不允许这样做。

运行整体打包命令

pnpm run build:ui

CleanShot 2023-12-14 at 14.54.47@2x.png

搭建demo应用演示组件效果

创建 demo 子模块来演示如何在 monorepo 项目里建立一个网站应用模块。我们先设置好 demo 模块的目录结构:

📦farmer-ui
 ┣ 📂...
 ┣ 📂demo
 ┃ ┣ 📂node_modules
 ┃ ┣ 📂dist
 ┃ ┣ 📂src
 ┃ ┃ ┣ 📂main.ts
 ┃ ┃ ┗ 📜index.tsx
 ┃ ┣ 📜index.html
 ┃ ┣ 📜webpack.config.ts
 ┃ ┗ 📜package.json

demo属于新建子模块,需要在 pnpm-workspace.yaml 中补充声明这个工作空间:

packages:
  # 根目录下的 docs 是一个独立的文档应用,应该被划分为一个模块
  - docs
  # packages 目录下的每一个目录都作为一个独立的模块
  - packages/*
+ # demo演示组件模块,划分为一个模块
+ - demo

添加package.json文件

{
  "name": "@farmer/demo",
  "private": true,
  "scripts": {
    "dev": "cross-env NODE_ENV=development webpack server"
  },
  "dependencies": {
    "@farmer/ui": "workspace:^"
  }
}

添加webpack.config.ts文件

import * as path from 'path';
import * as webpack from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import {Configuration as DevServerConfiguration} from 'webpack-dev-server';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
const isProd = process.env.NODE_ENV && process.env.NODE_ENV.toLowerCase()  === 'production';

const config: webpack.Configuration & { devServer?: DevServerConfiguration } = {
    mode: isProd ? 'production' : 'development',
    entry: './src/App.tsx',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].bundle.js',
        clean: true
    },
    module: {
        rules: [
            {
                test: /.(tsx|ts|js)$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        rootMode: "upward",
                    }
                },
                exclude: /node_modules/,
            },
            {
                test: /.s[ac]ss$/i,
                use: [
                    // 将 CSS 提取到单独的文件中,为每个包含 CSS 的 JS 文件创建一个 CSS 文件,并且支持 CSS 和 SourceMaps 的按需加载
                    MiniCssExtractPlugin.loader,
                    // 将 CSS 转化成 CommonJS 模块
                    {
                        loader: 'css-loader'
                    },
                    //处理css3不同浏览器兼容性
                    {
                        loader: 'postcss-loader',
                        options: {
                            postcssOptions: {
                                plugins: [
                                    'autoprefixer',
                                    'postcss-preset-env',
                                ],
                            },
                        },
                    },
                    // 将 Sass 编译成 CSS
                    'sass-loader',
                ],
                exclude: /node_modules/,
            },
        ],
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.js', '.json'],
        alias: {
            '@farmerui/ui': path.resolve(__dirname, '..','packages/ui/src'),
            '@farmerui/button': path.resolve(__dirname, '..','packages/button/src'),
            '@farmerui/input': path.resolve(__dirname, '..','packages/input/src'),
            '@farmerui/shared': path.resolve(__dirname, '..','packages/shared/src'),
            '@': path.resolve(__dirname, 'src/'),
        },
    },
    plugins: [
        new HtmlWebpackPlugin({
            title: 'Hello Demo',
            template: 'index.html'
        }),
        new MiniCssExtractPlugin({
            filename: 'css/[name].css'
        })
    ],
    devtool: 'source-map',
    devServer: {
        static: './dist',
        host: '0.0.0.0',
        port: 9527,
        open: false,
        hot: true
    },
    externals: [
        {
            'react': 'React',
            'react-dom': 'ReactDOM',
        }
    ]
};
export default config;

⚠️上面webpack中的reslove.alias需要将所有依赖映射到对应源码文件上,这里为了demo演示组件实时更新直接手动配置,也可参考babel-plugin-resolve-config-json插件来根据tsconfig.json中的paths来设置alias 添加页面入口模版文件index.html

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<div id="app"></div>
<script src="https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"></script>
</body>
</html>

我们webpack配置中是将react、react-dom外部化处理了,所以需要通过cdn方式来引入react依赖
添加src及其业务页面APP.tsx文件

APP.tsx

import * as React from "react";
import { createRoot } from "react-dom";
import { Button, Input } from "@farmerui/ui";
const root = createRoot(document.getElementById('app'));
root.render(<div>
    <Button content="按钮1"/>
    <Input onBlur={(value) => {alert(value)}}/>
</div>);

运行启动命令

pnpm --filter @farmerui/demo run start

CleanShot 2023-12-14 at 14.59.28@2x.png Dec-14-2023 15-04-02.gif

集成typesript

安装typescript相关依赖

pnpm i -wD typescript ts-node @types/node

tsconfig.json理解:

  • 每个 tsconfig.json 将一个文件集合声明为一个 ts project(如果称为项目则容易产生概念混淆,故叫做 ts project),通过 include 描述集合中包含的文件、exclude 字段声明了集合中需要排除的文件。注意,除了 node_modules 中的三方依赖,每个被引用的源码文件都要被包含进来。

  • compilerOptions 是编译选项,决定了 TypeScript 编译器在处理该 ts project 包含的文件时所采取的策略与行为。

{
  "compilerOptions": {
    // ts project的编译选项
  },
  "include": [
    // ts project包含哪些文件
  ],
  "exclude": [
    //include 包含的文件夹中需要排除哪些文件
  ]
}

include 与 exclude 字段通过 glob 语法进行文件匹配
在整个monorepo模式中,有多个子包,那我们是不是每个子包都划分为一个ts project呢,我们不会这么干,我们参考各大开源ui库会发现,大佬们都是按功能来划分ts project,也就是将类似功能的子包划分为一个ts project,将其中配置抽离到根目录来加以管理。element-plus UI库对于ts project的划分

CleanShot 2023-12-15 at 15.36.17@2x.png 对于ts编译选项 compilerOptions有很多重复性的配置,我们也有样学样将这些重复配置抽离到tsconfig.base.json中管理,根目录执行脚本添加

touch tsconfig.base.json

添加公共配置

{
  "compilerOptions": {
    // 项目的根目录
    "rootDir": ".",
    // 项目基础目录
    "baseUrl": ".",
    // tsc 编译产物输出目录
    "outDir": "dist",
    // 编译目标 js 的版本
    "target": "es5",
    //
    "module": "commonjs",
    // 模块解析策略
    "moduleResolution": "node",
    // 是否生成辅助 debug 的 .map.js 文件。
    "sourceMap": true,
    // 产物不消除注释
    "removeComments": false,
    // 严格模式类型检查,建议开启
    "strict": true,
    // 不允许有未使用的变量
    "noUnusedLocals": true,
    // 允许引入 .json 模块
    "resolveJsonModule": true,

    //esModuleInterop: true 配合允许从 commonjs 的依赖中直接按 import XX from 'xxx' 的方式导出 default 模块。
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,

    // 在使用 const enum 或隐式类型导入时受到 TypeScript 的警告
    "isolatedModules": true,
    // 检查类型时是否跳过类型声明文件,一般在上游依赖存在类型问题时置为 true"skipLibCheck": true,
    "noImplicitAny": true,
    "jsx": "react",
    "allowJs": true,
    // 引入 ES 的功能库
    "lib": [],
    // 默认引入的模块类型声明
    "types": [],
    // 路径别名设置
    "paths": {
      "@farmerui/*": ["packages/*/src"]
    }
  }
}

将node环境执行的脚本、配置文件划分为一个ts project,添加tsconfig.node.json,根目录执行脚本添加

touch tsconfig.node.json
// tsconfig.node.json
{
  // 继承基础配置
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    // 该 ts project 将被视作一个部分,通过项目引用(Project References)功能集成到一个 tsconfig.json 中
    "composite": true,
    // node 脚本没有 dom 环境,因此只集成 es5 库即可
    "lib": ["es5"],
    // 集成 Node.js 库函数的类型声明
    "types": ["node"],
    // 脚本有时会以 js 编写,因此允许 js
    "allowJs": true
  },
  "include": [
    // 目前项目中暂时只有配置文件,如 webpack.config.ts,以后会逐步增加
    "**/*.config.*",
  ],
  "exclude": [
    // 暂时先排除产物目录,packages/xxx/dist/x.config.js 或者 node_modules/pkg/x.config.js 不会被包含进来
    "**/dist",
    "**/lib",
    "**/es",
    "**/node_modules"
  ]
}

将我们所有lib子包划分为一个ts project,它们几乎都是组件库的实现代码,大多要求浏览器环境下特有的 API(例如 DOM API),且相互之间存在依赖关系。根目录执行命令

touch tsconfig.web.json
// tsconfig.web.json
{
  // 继承基础配置
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    // 组件库依赖浏览器的 DOM API
    "lib": ["es5", "DOM", "DOM.Iterable"],
    "types": ["node"],
  },
  "include": [
    "typings/env.d.ts",
    "packages/**/src"
  ],
}

到此,IDE 还是无法正常提供类型服务,我们最终还是要在根目录建立一个总的 tsconfig.json,通过 项目引用(Project References)功能 将多个 compilerOptions.composite = truets project 聚合在一起,这样 IDE 才能够识别。

根目录添加ts配置文件

touch tsconfig.json
// tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "moduleResolution": "node",
    // 将每个文件作为单独的模块(与“ts.transpileModule”类似)。
    "isolatedModules": true,
    "useDefineForClassFields": true,
  },
  "files": [],
  "references": [
    // 聚合 ts project
    { "path": "./tsconfig.web.json" },
    { "path": "./tsconfig.node.json" }
  ],
}

每个引用的 path 属性可以指向包含 tsconfig.json 文件的目录,或指向配置文件本身(可以有任何名称)。

当您引用一个项目时,新的事情会发生:

  • 从引用的项目导入模块将改为加载其输出声明文件 ( .d.ts )
  • 如果引用的项目生成 outFile ,则输出文件 .d.ts 文件的声明将在此项目中可见
  • 如果需要,构建模式(见下文)将自动构建引用的项目

通过分成多个项目,您可以极大地提高类型检查和编译的速度,减少使用编辑器时的内存使用量,并改善程序逻辑分组的执行。

引用的项目必须启用新的 composite 设置。需要此设置来确保 TypeScript 可以快速确定在哪里找到引用项目的输出。启用 composite 标志会改变一些事情:

  • rootDir 设置,如果未显式设置,则默认为包含 tsconfig 文件的目录
  • 所有实现文件必须与 include 模式匹配或在 files 数组中列出。如果违反此限制, tsc 将通知您哪些文件未指定
  • declaration 必须打开

@farmerui/Demo中由于时应用运行时,与其他的lib没什么太大关系,我们给它添加自己的tsconfig.json文件

cd demo && touch tsconfig.json
{
  // 集成基础配置
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "baseUrl": ".",
    // Web 应用需要 DOM 环境
    "lib": ["es5", "DOM", "DOM.Iterable"],
    // Web 应用不需要 node 相关方法
    "types": [],
    // baseUrl 改变了,基础配置中的 paths 也需要一并重写
    "paths": {
      "@/*": ["src/*"],
      "@farmerui/*": ["../packages/*/src"]
    }
  },
  "include": [
    // demo 应用会引用其他子模块的源码,因此都要包含到 include 中
    "../packages/*/src",
    "src"
  ]
}

我们去代码编辑器中查看typescript是否还报错,之前我们在input.tsx文件中故意留了几个ts报错

CleanShot 2023-12-15 at 16.52.40@2x.png

第1行报错:Cannot resolve definitions for module 'react'

CleanShot 2023-12-15 at 16.55.09@2x.png 这是告诉我们react缺少声明文件,由于demo中用到了react-dom,我们在这里就一起安装了 fix:

pnpm i -wD @types/react @types/react-dom

安装之后,vscode可能有延时,需要cmd + shift + p,选择Developer: Reload Window reload vscode

CleanShot 2023-12-15 at 16.59.58@2x.png 第5行错误:很明显这是个语法错误,我们在定义接口时,只给函数形参类型,没给形参的name这样是不会通过ts检查的

CleanShot 2023-12-15 at 17.05.12@2x.png fix:

interface InputProps {
	onChange?: () => void;
+	onBlur?: (args0: string) => void;
	InputType?: 'text'
}

第10行错误:Cannot invoke an object which is possibly 'undefined'.这是因为我们在接口定义时onBlur不是必传的参数,所以在真正调用时可能时undefined,所以ts会检查报错。 CleanShot 2023-12-15 at 17.08.32@2x.png fix:

<input type={InputType} onBlur={(e) => onBlur?.(e.target.value)} onChange={(e) => hello(e.target.value)}></input>

页面上报错终于干净了,很喜欢这种感觉 CleanShot 2023-12-15 at 17.13.53@2x.png 我们利用es7?语法来选择调用这个有可能存在undefined的方法就可以了。

ts类型检查 众所周知,babel在编译ts时是直接将ts删除了,所以才会有如此高代码编译性能,所以babel是不能进行check的,我们的ts想要check,需要依赖ts的tsc来做,tsc是安装typescript就可以获得的,我们将@farmerui/shared中的hello.ts文件修改为错误的ts

/**
 * [hello description]
 * @param    {number = 'World'} to [description]
 * @author   codefarmer
 * @version  [1.0.0]
 * @datetime 2023-12-19T09:25:41+0800
 */
+ export function hello(to: number = 'World') {//这块to变量应该是string类型,我们给了number类型,这是错误的,如果不进行类型检查,那么babel也会编译成功
    const txt = `Hello ${to}!`;
    alert(txt);
    return txt;
}

我们执行ts的check

tsc -p tsconfig.web.json --noEmit --composite false

其中:

  • --noEmit 使构建产物不被输出,
  • --composite false 使得 buildInfo 文件不被输出 CleanShot 2023-12-19 at 09.29.29@2x.png 可以看到,所有引用hello方法的地方都给检查出来了,我们把类型检查相关的命令在根目录 package.json 中声明。并在组件的统一构建指令加上了限制,要求类型检查必须通过才能执行构建。
...
"scripts": {
+    "ts:check": "tsc -p tsconfig.web.json --noEmit --composite false",
+    "build:ui": "pnpm run ts:check && pnpm --filter \"./packages/**\" run build:dev"
  },
...

生成d.ts类型声明产物 我们提供libui库提供给其他开发者使用时,缺少 d.ts 类型声明文件。这会导致用户在引用我们的包时,无法获得类型提示信息。 生成d.ts文件也非常的简单,一行命令搞定,在上面check的命令基础上添加两个编译参数: --declaration 和 --emitDeclarationOnly 选项就可以为所有的包生成 d.ts 文件。 注意需要删除--noEmit参数

tsc -p tsconfig.web.json --declaration --emitDeclarationOnly --composite false

CleanShot 2023-12-19 at 10.01.13@2x.png 默认就会生成到根目录下的dist文件中 所有的产物都会被生成到 outDir 字段指定的根目录下的 dist。这和我们的需求有所不合,我们希望对应产物能在每个子模块自己的 dist 目录下
幸运的是,d.ts 产物目录的内部结构与 packages 的结构是一致的,我们可以很容易实现移动产物的脚本。在根目录下建立 scripts 目录,专门用于存放构建相关的脚本,记得在 tsconfig.node.json 里面补充这个新的脚本目录。

// tsconfig.node.json
{
  // ...
  "include": [
    "**/*.config.*",
+   "scripts"
  ],
}

scripts/mvdts.ts

import { join } from 'node:path';
import { readdir, cp } from 'node:fs/promises';

/** 以根目录为基础解析路径 */
const fromRoot = (...paths: string[]) => join(__dirname, '..', ...paths);

/** 包的 d.ts 产物目录 */
const PKGS_DTS_DIR = fromRoot('dist/packages');

/** 包的目录 */
const PKGS_DIR = fromRoot('packages');

/** 单个包的 d.ts 产物相对目录 */
const PKG_DTS_RELATIVE_DIR = 'dist';

/** 包的代码入口相对目录 */
const PKG_ENTRY_RELATIVE_DIR = 'src';

async function main() {
    const pkgs = await match();
    const tasks = pkgs.map(resolve);
    await Promise.all(tasks);
}

/** 寻找所有需要移动 dts 的包 */
async function match() {
    const res = await readdir(PKGS_DTS_DIR, { withFileTypes: true });
    return res.filter((item) => item.isDirectory()).map((item) => item.name);
}

/**
 * 处理单个包的 dts 移动
 * @param pkgName 包名
 */
async function resolve(pkgName: string) {
    try {
        const sourceDir = join(PKGS_DTS_DIR, pkgName, PKG_ENTRY_RELATIVE_DIR);
        const targetDir = join(PKGS_DIR, pkgName, PKG_DTS_RELATIVE_DIR);
        const sourceFiles = await readdir(sourceDir);
        const cpTasks = sourceFiles.map((file) => {
            const source = join(sourceDir, file);
            const target = join(targetDir, file);
            console.log(`[${pkgName}]: moving: ${source} => ${target}`);
            return cp(source, target, {
                force: true,
                recursive: true,
            })
        })
        await Promise.all(cpTasks);
        console.log(`[${pkgName}]: moved successfully!`);
    } catch (e) {
        console.log(`[${pkgName}]: failed to move!`);
    }
}

main().catch((e) => {
    console.error(e);
    process.exit(1);
})

ts 脚本不能直接执行,要借助额外的工具,例如:tsxts-node。个人比较倾向于用 tsx,相较而言免配置,问题少。此外,由于 tsc 不具备清空输出目录的功能,为了避免混淆输出产物,我们可以选择安装工具 rimraf 来负责清空产物目录。

pnpm i -wD tsx@3.14.0 rimraf
  • ⚠️这块指定了tsx的版本,因为最新版4.6.2运行后一直报错下边贴下报错信息,希望大佬告诉我怎么修改
//tsx@4.6.2 运行tsx ./scripts/mvdts.ts
/Users/liweihong/Desktop/farmer-ui/node_modules/.pnpm/tsx@4.6.2/node_modules/tsx/dist/index-5d1f01e6.cjs:16
Did you specify these with the most recent transformation maps first?`);let s=kt(i,e,"",0);for(let a=A.length-1;a>=0;a--)s=Bt(A[a],[s]);return s}function kt(n,e,A,i){const{resolvedSources:s,sourcesContent:a}=n,c=i+1,g=s.map((h,C)=>{const u={importer:A,depth:c,source:h||"",content:void 0},r=e(u.source,u),{source:b,content:y}=u;if(r)return kt(new st(r,b),e,b,c);const w=y!==void 0?y:a?a[C]:null;return ir(b,w)});return Bt(n,g)}class ar{constructor(e,A){const i=A.decodedMappings?me(e):ut(e);this.version=i.version,this.file=i.file,this.mappings=i.mappings,this.names=i.names,this.sourceRoot=i.sourceRoot,this.sources=i.sources,A.excludeContent||(this.sourcesContent=i.sourcesContent)}toString(){return JSON.stringify(this)}}function wt(n,e,A){const i=typeof A=="object"?A:{excludeContent:!!A,decodedMappings:!1},s=or(n,e);return new ar(nr(s),i)}const cr=(n,e,A)=>{const i=[],s={code:e};for(const a of A){const c=a(n,s.code);c&&(Object.assign(s,c),i.unshift(c.map))}return{...s,map:wt(i,()=>null)}},Qr=async(n,e,A)=>{const i=[],s={code:e};for(const a of A){const c=await a(n,s.code);c&&(Object.assign(s,c),i.unshift(c.map))}return{...s,map:wt(i,()=>null)}},lr=Object.freeze({target:`node${process.versions.node}`,loader:"default"}),It={...lr,sourcemap:!0,sourcesContent:!1,minifyWhitespace:!0,keepNames:!0},pt=n=>{const e=n.sourcefile;if(e){const A=P.extname(e);A?(A===".cts"||A===".mts")&&(n.sourcefile=`${e.slice(0,-3)}ts`):n.sourcefile+=".js"}return A=>(A.map&&(n.sourcefile!==e&&(A.map=A.map.replace(JSON.stringify(n.sourcefile),JSON.stringify(e))),A.map=JSON.parse(A.map)),A)},bt=n=>{const[e]=n.errors;let A=`[esbuild Error]: ${e.text}`;if(e.location){const{file:i,line:s,column:a}=e.location;A+=`
                                                                                   
TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))
    at bt (/Users/liweihong/Desktop/farmer-ui/node_modules/.pnpm/tsx@4.6.2/node_modules/tsx/dist/index-5d1f01e6.cjs:16:1598)
    at /Users/liweihong/Desktop/farmer-ui/node_modules/.pnpm/tsx@4.6.2/node_modules/tsx/dist/index-5d1f01e6.cjs:17:420
    at cr (/Users/liweihong/Desktop/farmer-ui/node_modules/.pnpm/tsx@4.6.2/node_modules/tsx/dist/index-5d1f01e6.cjs:16:913)
    at Object.ur [as transformSync] (/Users/liweihong/Desktop/farmer-ui/node_modules/.pnpm/tsx@4.6.2/node_modules/tsx/dist/index-5d1f01e6.cjs:17:345)
    at Object.S (/Users/liweihong/Desktop/farmer-ui/node_modules/.pnpm/tsx@4.6.2/node_modules/tsx/dist/cjs/index.cjs:1:1230)
    at Module.load (node:internal/modules/cjs/loader:1103:32)
    at Module._load (node:internal/modules/cjs/loader:942:12)
    at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:168:29)
    at ModuleJob.run (node:internal/modules/esm/module_job:193:25)

修改根目录下的package.json文件

"scripts": {
+    "clean:type": "rimraf ./dist",
+    "type:node": "tsc -p tsconfig.node.json --noEmit --composite false",
+    "type:web": "pnpm run clean:type && tsc -p tsconfig.web.json --composite false  --declaration --emitDeclarationOnly",
+    "mv-type": "tsx ./scripts/mvdts.ts",
+    "build:ui": "pnpm run type:web && pnpm --filter \"./packages/**\" run build:dev  && pnpm run mv-type"
  },

运行pnpm run build:ui CleanShot 2023-12-19 at 14.03.19@2x.png 修改各子包的types入口配置,以@farmerui/button下的package.json为例:

+ "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "require": "./lib/index.js",
      "module": "./es/index.esm.js",
      "default": "./dist/index.js",
+     "types": "./dist/index.d.ts"
    }
  },

通过以上几个小问题我们可以感受到ts带来的好处,代码越多,好处越多。说教无意,只有折断的骨头才是最好的课本,相信大家已经被生产运行时这种低级失误的bug折磨的很舒爽了吧。选择ts吧!她真的很棒!

打包体系构建

一、设计打包体系

经过我们的基础打包配置后,会发现大量配置代码的重复,这对于最终包结果可能没有影响,但是确增加我们开发时候的心智负担。 1.公共配置提取
根据我们之前的webpack基础配置章节我们可以了解到,其实每个工程的配置99%是相同的,即使存在着外部依赖(externals),包名(output.library.name) 这样的不同配置,仔细思考也会发现它们都能够从各自的 package.json 中自动获取:

  • 包名、全局变量名可以根据 name 字段生成,为什么手动维护呢?
  • 需要被外部化处理的依赖项也在 peerDependencies 和 dependencies 中,为什么手动维护呢?

2.构建全量产物
目前我们构建出的 umdes 以及 d.ts 类型声明产物,足以让通过包管理器(npm / pnpm)集成组件的用户正常使用,我们称这种产物为常规产物常规产物适用于构建场景,必须配合包管理器使用。

但是这些产物如果直接通过 <script src="xxxx"></script> 的方式引入,是无法正常工作的。这是因为我们的 umd 产物经过了依赖外部化处理,直接引用会缺少大量依赖。这种场景需要取消依赖的外部化处理,构建出全量产物。 例如 antd 产物中的 antd.jsreact 产物中的 react(.runtime).js全量产物适用于非构建场景,不必配合包管理器使用。

全量产物的使用场景其实是不容忽视的,一方面,许多新手用户需要这样一个快速上手的途径(可以暂时不折腾构建工具);另一方面,在线演示的沙盒环境也正需要这样的产物。

在全量产物的基础上,还可以进一步扩展:

  • 全量产物体积可能太大了,我们需要压缩混淆后的 .min.js 版本。
  • .min.js 版本体积虽小但是调试困难,我们需要提供 sourcemap 文件(sourcemap 推荐阅读:sourcemap 这么讲,我彻底理解了关于sourcemap,这篇文章就够了)。 这时我们回过头来想想,webpack 对于这些各式各样的打包需求都能支持,但是它们各自对应了不同的打包配置,webpack 无法在一次构建中生成全部类型的产物。 这就更需要我们在 webpack 的基础上增强构建能力,演化出自己的打包体系。

3.痛点:
(1)如果我们按照之前多仓库单项目的模式配置工程化的配置文件,就会造成大量配置项冗余
(2)每个子项目单独的配置项文件不易于维护和拓展
(3)构建子包全量产物场景的支持必不可少
(4)提高构建的自动化程度,进一步降低维护构建配置的心智负担。
根据上面我们综合分析,总结出以下定制打包体系的理由:

  • 促进 Clean Code,消除大量的重复代码。
  • 集中维护构建配置,避免分散管理造成的多地多次修改的困扰。
  • 提高构建的自动化程度,进一步降低维护构建配置的心智负担。
  • 增强构建能力,支持同时生成不同类型的产物。

4.打包体系初步分析:
我们可以去github上观察monorepo模式的开源项目,你会发现每个包的打包都五花八门的,并没有说是统一的,所谓八仙过海,各显神通。我们以pixijs这个2D渲染库为案例进行分析,总结出适合我们的打包方案。pixijs虽然基于vite打包,但不影响我们分析它打包的整个思路。

  • 每个子包中移除了专属的 rollup.config 或者 vite.config 文件。
  • 在根目录下有一个集中的脚本,一口气完成整个构建任务。

CleanShot 2023-12-09 at 15.33.55@2x.png

  • 在全量构建脚本中,首先获取所有的子包工作目录。

CleanShot 2023-12-09 at 15.36.46@2x.png

  • 遍历上一步获取到的子包列表,获取每个子包的文件目录、package.json 等信息,在循环中结合子包信息动态拼接出每个包构建配置。

CleanShot 2023-12-09 at 15.37.57@2x.png

  • 最后调用构建工具 API / CLI 读取配置,执行构建。
erDiagram
"开始构建" ||--o{ getPkgs : "获取待构建的子包"
getPkgs ||--o{ forEach : "循环待构建的子包"
forEach ||--o{ nextPkg : "获取下一个待构建的子包"
nextPkg ||--o{ getPkgInfo : "获取待构建的子包信息"
getPkgInfo ||--o{ generateConfig : "生成待构建的子包配置"
generateConfig ||--o{ build : "构建子包"
build ||--o{ "endForEach?" : ""
"endForEach?" ||--o{ forEach : "N"
"endForEach?" ||--o{ "结束?": "Y"
  • getPkgs - 获取待构建的子包。
  • forEachnextPkgendForEach? - 控制子包列表的遍历。
  • getPkgInfo - 获取子包 package.json 等信息。
  • generateConfig - 生成子包的构建配置。
  • build - 执行构建。

我们的组件库并不计划编写一个集中脚本去编排所有流程,我们打算充分利用包管理器和构建工具的能力。pnpm 本身的命令,就足以实现“获取所有待构建的子包”、“对子包进行拓扑排序”、“遍历子包执行脚本”。

pnpm --filter ./packages/** run build

因此,上述例子中的获取子包 getPkgs,循环控制 forEach 系列,我们都不打算自己实现,而是借助 pnpm 本身的能力。

另外,我们也不计划废除每个子包中的 webpack.config,去调用 webpack 的 API 单独实现 build 构建方法。而是计划在 webpack.config 中调用公共的 generateConfig 方法直接生成完善的打包配置,通过 webpack build 的 CLI 命令去读取配置,启动构建进程。 届时,webpack.config 配置会大幅简化,变成类似下面的形式。

import { generateConfig } from '@farmerui/build'
export default generateConfig(/** ... */);

这样一来,我们就只需要考虑如何实现生成打包配置的 generateConfig 方法。当然,generateConfig 的实现也依赖于 getPkgInfo 对各自子包 package.json 信息的获取

不知不觉写了3w8千字了,篇幅有点长了,打包体系构建实践放到下一篇内容吧,期待与你相遇,与你共同成长!