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

上一节 # 基于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 作为公共工具方法包,将成为所有其他模块的依赖项。

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

为 shared 包安装 lodash 相关依赖

scss 复制代码
pnpm --filter @farmerui/shared i -S lodash @types/lodash

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

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

useLodash.ts文件添加测试代码

javascript 复制代码
// packages/shared/src/useLodash.ts
import lodash from 'lodash';
export function useLodash() {
  return lodash
}

index.ts文件添加测试代码

javascript 复制代码
// 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 命令声明内部模块关联:

scss 复制代码
pnpm --filter @farmerui/button i -S @farmerui/shared

button.tsx添加代码

typescript 复制代码
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组件出口添加代码

css 复制代码
import Button from "./button";
export { Button }

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

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

scss 复制代码
pnpm --filter @farmerui/input i -S @farmerui/shared

input.tsx组件添加代码

typescript 复制代码
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组件出口添加代码

css 复制代码
import Input from "./input";
export { Input }

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

package.json

perl 复制代码
{
  "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文件

javascript 复制代码
// packages/ui/src/index.ts
export * from '@farmerui/button';
export * from '@farmerui/input';
export * from '@farmerui/shared';

安装子包依赖

css 复制代码
pnpm --filter @farmerui/ui i

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

基础构建配置

安装公共构建依赖

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

sql 复制代码
pnpm i -wD webpack webpack-cli @types/webpack webpack webpack-dev-server webpack-merge cross-env html-webpack-plugin mini-css-extract-plugin

安装sass相关依赖

css 复制代码
pnpm i -wD css-loader postcss-loader postcss postcss-preset-env sass-loader sass mini-css-extract-plugin

安装babel相关依赖

bash 复制代码
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

css 复制代码
pnpm i -wS @babel/runtime  

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

perl 复制代码
{
  "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

javascript 复制代码
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添加脚本命令

diff 复制代码
//  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"
  }
},
...

项目根目录运行打包命令

css 复制代码
pnpm --filter @farmerui/shared run build:dev

2.@farmer/button包添加webpack.config.ts

javascript 复制代码
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文件添加脚本执行命令

diff 复制代码
//  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"
  }
},
...

执行打包命令

css 复制代码
pnpm --filter @farmerui/button run build:dev

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

javascript 复制代码
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添加脚本命令

diff 复制代码
//  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 目录下所有包进行构建

arduino 复制代码
pnpm --filter "./packages/**" run build:dev

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

css 复制代码
pnpm --filter @farmerui/ui... run build:dev

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

ini 复制代码
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文件添加全局打包

diff 复制代码
{
  // ...
  "scripts": {
+   "build:ui": "pnpm --filter "./packages/**" run build:dev"
  },
}

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

diff 复制代码
{
 ...
  "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 不允许这样做。

运行整体打包命令

arduino 复制代码
pnpm run build:ui

搭建demo应用演示组件效果

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

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

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

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

添加package.json文件

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

添加webpack.config.ts文件

javascript 复制代码
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

xml 复制代码
<!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

javascript 复制代码
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>);

运行启动命令

css 复制代码
pnpm --filter @farmerui/demo run start

集成typesript

安装typescript相关依赖

css 复制代码
pnpm i -wD typescript ts-node @types/node

tsconfig.json理解:

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

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

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

includeexclude 字段通过 glob 语法进行文件匹配

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

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

bash 复制代码
touch tsconfig.base.json

添加公共配置

ruby 复制代码
{
  "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,根目录执行脚本添加

bash 复制代码
touch tsconfig.node.json
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),且相互之间存在依赖关系。根目录执行命令

bash 复制代码
touch tsconfig.web.json
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配置文件

bash 复制代码
touch tsconfig.json
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文件

bash 复制代码
cd demo && touch tsconfig.json
perl 复制代码
{
  // 集成基础配置
  "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报错

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

这是告诉我们react缺少声明文件,由于demo中用到了react-dom,我们在这里就一起安装了 fix:

bash 复制代码
pnpm i -wD @types/react @types/react-dom

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

第5行错误:很明显这是个语法错误,我们在定义接口时,只给函数形参类型,没给形参的name这样是不会通过ts检查的

fix:

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

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

diff 复制代码
<input type={InputType} onBlur={(e) => onBlur?.(e.target.value)} onChange={(e) => hello(e.target.value)}></input>

页面上报错终于干净了,很喜欢这种感觉 我们利用es?语法来选择调用这个有可能存在undefined的方法就可以了。 通过以上几个小问题我们可以感受到ts带来的好处,代码越多,好处越多。说教无意,只有折断的骨头才是最好的课本,相信大家已经被生产运行时这种低级失误的bug折磨的很舒爽了吧。选择ts吧!她真的很棒!

打包体系构建

一、设计打包体系

经过我们的基础打包配置后,会发现大量配置代码的重复,这对于最终包结果可能没有影响,但是确增加我们开发时候的心智负担。 1.公共配置提取

根据我们之前的webpack基础配置章节我们可以了解到,其实每个工程的配置99%是相同的,即使存在着外部依赖(externals),包名(output.library.name) 这样的不同配置,仔细思考也会发现它们都能够从各自的 package.json 中自动获取:

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

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 文件。
  • 在根目录下有一个集中的脚本,一口气完成整个构建任务。
  • 在全量构建脚本中,首先获取所有的子包工作目录。
  • 遍历上一步获取到的子包列表,获取每个子包的文件目录、package.json 等信息,在循环中结合子包信息动态拼接出每个包构建配置。
  • 最后调用构建工具 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 本身的命令,就足以实现"获取所有待构建的子包"、"对子包进行拓扑排序"、"遍历子包执行脚本"。

bash 复制代码
pnpm --filter ./packages/** run build

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

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

arduino 复制代码
import { generateConfig } from '@farmerui/build'
export default generateConfig(/** ... */);

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

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

相关推荐
2401_879103687 分钟前
24.11.10 css
前端·css
ComPDFKit1 小时前
使用 PDF API 合并 PDF 文件
前端·javascript·macos
yqcoder1 小时前
react 中 memo 模块作用
前端·javascript·react.js
优雅永不过时·2 小时前
Three.js 原生 实现 react-three-fiber drei 的 磨砂反射的效果
前端·javascript·react.js·webgl·threejs·three
神夜大侠4 小时前
VUE 实现公告无缝循环滚动
前端·javascript·vue.js
明辉光焱4 小时前
【Electron】Electron Forge如何支持Element plus?
前端·javascript·vue.js·electron·node.js
柯南二号5 小时前
HarmonyOS ArkTS 下拉列表组件
前端·javascript·数据库·harmonyos·arkts
wyy72935 小时前
v-html 富文本中图片使用element-ui image-viewer组件实现预览,并且阻止滚动条
前端·ui·html
前端郭德纲5 小时前
ES6的Iterator 和 for...of 循环
前端·ecmascript·es6
王解5 小时前
【模块化大作战】Webpack如何搞定CommonJS与ES6混战(3)
前端·webpack·es6