webpack+gulp+typescript实现组件库打包

687 阅读6分钟

前言

组件库打包现在有很多方案,如果想着省事可以使用开箱即用的vue-cli以及vite的库模式,当然vue-cli针对性较强,只适用vue项目。vite可以支持vue和react,具体可以根据需求进行配置,使用方式也很简单,管网都有配置不做具体描述。使用这些工具总有一种知其然不知其所以然的感觉,并且在有特殊化的需求时有点束手束脚,对打包过程没有一个深入的了解。基于此我看了一下ant-design的打包流程,决定自己实现一个简易版打包工具,本文以react为例。

本次要实现的目标如下:

  • 导出 umd/commonjs/ES module等三种格式文件。
  • 导出类型定义。
  • 支持按需加载。
  • 打包发布npm

初始化项目

项目整体是使用pnpm进行管理, 执行 pnpm init 初始化项目

然后添加 pnpm-workspace.yaml文件 内容如下:

packages:
   - 'packages/**'
   - 'examples'

然后在根目录创建packages 目录和example目录

packages文件夹目录

此目录为组件开发及打包目录,主要打包配置都在这里。 接下来一点一点完成packages下的配置

packages项目初始化

  1. 执行同样的命令pnpm init初始化项目
  2. 安装webpack 和webpack-cli
  3. 安装typescript
  4. 添加打包入口文件index.js
  5. 添加webpack.config.js配置

安装webpack主要是为了打包输出umd格式的文件

commonjs 和esmodule的文件使用gulp打包生成

打包入口文件

打包入口index.js文件很简单

image.png

components文件夹就是所有组件存放的位置

image.png

目前只是一段测试代码

webpack配置

webpack.config.js

const path = require('path')
const config = [{
    mode: 'development', // 开发模式
    devtool: 'source-map',
    entry: {
        antd: './index.js'
    },
    output: {
        path: path.resolve('dist'),
        filename: '[name].js', //打包后的文件
        library: 'antd', //打包后名字
        libraryTarget: 'umd',
        clean: true
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js', 'jsx', '.json'],
    },
}]
module.exports = config

接下来尝试打包是否能成功, 在package.json中添加命令 "build": "webpack"

执行打包命令后

image.png image.png 打包成功!

编写组件升级配置

现在尝试使用react写一个简单的button组件,进行打包。

首先安装react 和 react-dom

components下新建button组件

image.png

components下的index中统一暴露出组件

export { default as Button } from './button'
export type { ButtonProps } from './button';
...继续放置其他组件

接下来就是尝试打包了,在打包之前我们还得增加个配置,就是babel-loader来处理react编写的组件

webpack.config.js

const path = require('path')
const config = [{
    ...
    module: {
        rules: [
            {
                test: /\.(j|t)sx?$/,
                exclude: /node_modules/,
                loader: 'babel-loader'
            },
        ]
    },
}]
module.exports = config

babel.config.js


module.exports = {
    presets: [
        [
            '@babel/preset-react' // 支持react jsx语法
        ],
        [
            '@babel/preset-env', // 根据browsers配置进行语法降级
            {
                modules: 'commonjs',
                targets: {
                    browsers: ['last 2 versions', 'Firefox ESR', '> 1%', 'ie >= 11']
                }
            }
        ]

    ],
    plugins: [
        [
            '@babel/plugin-transform-typescript',
            {
                isTSX: true
            }
        ],
        ['@babel/plugin-transform-runtime', {
            useESModules: true,
        },] //提取一些编译运行时帮助方法
    ]
}

需要安装 @babel/preset-react babel-loader @babel/plugin-transform-typescript @babel/core @babel/plugin-transform-runtime @babel/runtime @babel/preset-env

image.png 但是细心地你会发现打包出来的antd.js体积竟然有92.2KB!因为webpack把react和react-dom的代码一起打包进来了,接下来需要排除react相关代码

webpack.config.js

const path = require('path')
const config = [{
...
externals: {
        react: 'react',
        "react-dom": 'react-dom',

    },
...
}]
module.exports = config

然后重新打包

image.png 大小变成了5.5KB #### 引用组件查看效果

在根目录下创建examples目录初始化一个react项目,然后修改package.json 添加一个配置

"dependencies": {
    ...
    "@jasen/antd": "workspace:*", // @jasen/antd 为组件库中package.json 定义的包名
    ...

注意:依赖需要使用pnpm安装

App.js

import { Button } from '@jasen/antd'
function App() {
  return (
    <div>
      <Button>我是一个按钮</Button>
    </div>
  );
}

export default App;

效果如下:

image.png 按钮成功展示,接下来添加样式

webpack.config.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const config = [{
module: {
        rules: [
            ...
             {
                test: /\.less$/,
                use: [
                    MiniCssExtractPlugin.loader, // 抽离css
                    {
                        loader: 'css-loader', 
                        options: {
                            sourceMap: true
                        }
                    },
                    {
                        loader: 'postcss-loader', //加厂商前缀
                        options: {
                            postcssOptions: {
                                plugins: ['autoprefixer'],
                            },
                            sourceMap: true
                        }
                    },
                    {
                        loader: 'less-loader',
                        options: {
                            lessOptions: {
                                javascriptEnabled: true
                            },
                            sourceMap: true
                        }
                    }
                ]
            },
         ]
    },
    plugins: [
        new MiniCssExtractPlugin({
            filename: '[name].css'
        })
    ]

重新打包后在examples项目中入口文件引入css(组件库打包代码会同步到example下的项目中,不用重新安装)

image.png image.png

目前为止已经简单实现了umd格式的输出,接下来完成输出es和commonjs

安装并配置gulp

在组件库项目根目录下新建 gulpfile.js 配置如下

const gulp = require('gulp')
const path = require('path')
const rimraf = require('rimraf')
const ts = require('gulp-typescript')
const babel = require('gulp-babel')
const merge2 = require('merge2')
const through2 = require('through2');
const transformLess = require('./transformLess');

const { compilerOptions } = require('./tsconfig.json')
const tsconfig = {
	noUnusedParameters: true, // 不能有未使用的变量
	noUnusedLocals: true, // 不能有未使用的本地变量
	strictNullChecks: true, //严格的null检查
	target: 'es6',// 编译目标
	moduleResolution: 'node', // 模块的查找规则
	declaration: true, //是否生成声明文件
	allowSyntheticDefaultImports: true, //允许默认导入
	...compilerOptions
}

const babelconfig = require('./babel.config')

const source = [
	'components/**/*.tsx',
	'components/**/*.ts',
	'typings/**/*.d.ts'
];


function getProjectPath(filePath) {
	return path.join(process.cwd(), filePath)
}

const base = getProjectPath('components')
const libDir = getProjectPath('lib')
const esDir = getProjectPath('es')


gulp.task('compile-es', (done) => {
	compile(false).on('finish', done)
})

gulp.task('compile-lib', (done) => {
	compile().on('finish', done)
})

function compile(modules) {
	const targetDir = modules === false ? esDir : libDir
	rimraf.sync(targetDir)

	// =============================== LESS ===============================
	let less = gulp
		.src(['components/**/*.less'])
		.pipe(
			through2.obj(function (file, encoding, next) {
				const cloneFile = file.clone(); //复制一份打包后可以保留less源文件
				const content = file.contents.toString().replace(/^\uFEFF/, '');

				cloneFile.contents = Buffer.from(content);

				const cloneCssFile = cloneFile.clone();

				this.push(cloneFile);

				
				if (file.path.match(/(\/|\\)style(\/|\\)index\.less$/)) {
					transformLess(cloneCssFile.contents.toString(), cloneCssFile.path)
						.then(css => {
							cloneCssFile.contents = Buffer.from(css);
							cloneCssFile.path = cloneCssFile.path.replace(/\.less$/, '.css');
							this.push(cloneCssFile);
							next();
						})
						.catch(e => {
							console.error(e);
						});
				} else {
					next();
				}
			})
		)
		.pipe(gulp.dest(modules === false ? esDir : libDir));


	const { js, dts } = gulp.src(source, { base }).pipe(ts(tsconfig))

	const dtsStream = dts.pipe(gulp.dest(targetDir))

	let jsStream = js
	if (modules === undefined) {
		jsStream = js.pipe(babel(babelconfig))
	}
	jsStream.pipe(gulp.dest(targetDir))
	return merge2([less, jsStream, dtsStream])
}

gulp.task('compile', gulp.parallel('compile-es', 'compile-lib'))

新建 两个 task 'compile-es'和 'compile-lib' 顾名思义,就是最终打包生成es和lib格式文件

两个任务统一调用compile方法,通过参数区分不同的任务

compile方法中首先处理的是less文件,通过through2插件对文件流进行操作。其中,处理less文件的功能在transformLess内。transformLess其实也很简单,就是利用less、postcss 等插件处理less文件输出css文件流

transformLess.js

const less = require('less');
const path = require('path');
const postcss = require('postcss');
const autoprefixer = require('autoprefixer');

function transformLess(lessContent, lessFilePath, config = {}) {
  const { cwd = process.cwd() } = config;
  const resolvedLessFile = path.resolve(cwd, lessFilePath);

  const lessOpts = {
    paths: [path.dirname(resolvedLessFile)],
    filename: resolvedLessFile,
    javascriptEnabled: true,
  };
  return less
    .render(lessContent, lessOpts)
    .then(result => postcss([autoprefixer]).process(result.css, { from: undefined }))
    .then(r => r.css);
}

module.exports = transformLess;

以上配置是参考antd-tools进行简化

package.json中添加命令

"compile": "gulp compile"

安装相应插件进行打包

pnpm install gulp rimraf gulp-typescript gulp-babel merge2 through2 postcss autoprefixer -D

打包结果如下:

image.png

es输出:

image.png

commonjs输出:

image.png

接下来修改下package.json

image.png

回到examples项目log一下button组件

image.png 加载的是lib内的组件

组件库package.json再添加一个配置

image.png 再回到examples项目log一下button组件

image.png 这次加载的是es内的组件。

验证treeshaking功能

为了能区分button组件是否被打包,给button上打上标记

image.png

  • 重新打包后查看打包后的源代码

image.png

  • 把Button组件注释后再次打包

搜索不到button上标记,button组件并未打包进来

image.png

接下来换成commonjs模式再走一遍同样的流程 1.把组件库package.json文件修改下

image.png 这样就可以加载lib下面的包了

image.png

  • 同样,把Button组件注释后再次打包

image.png 可以看到 页面上没有显示button,但是代码里还是将button组件打包进去了

按需加载

按需加载需要借助babel-plugin-import 插件

现在导出的组件只能支持js文件的按需加载,但是css文件还需要手动导入

  1. 在项目入口全量导入 dist下的antd.css (缺点:全量导入导致代码体积增加,许多不需要的组件css也一起导入)
import React from 'react';
import ReactDOM from 'react-dom/client';

import App from './App';
import '@jasen/antd/dist/antd.css'

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
    <React.StrictMode>
        <App />
    </React.StrictMode>
);

  1. 在使用组件时手动导入(缺点:需要手动导入,相对麻烦)
import { Button } from '@jasen/antd'
import '@jasen/antd/es/button/style/index.css'
function App() {
  return (
    <div>
      <Button type='primary'>我是一个按钮</Button>
    </div>
  );
}
export default App;

借助babel-plugin-import实现自动引入样式文件

注意这是examples项目内的babel配置

const path = require('path')
module.exports = {
    presets: [
        ["@babel/preset-react", {
            "runtime": "automatic" // 添加运行时帮助方法,可以在写组件时不引入react
        }],
        [
            '@babel/preset-env',
            {
                modules: 'auto',
                targets: {
                    browsers: ['last 2 versions', 'Firefox ESR', '> 1%', 'ie >= 11']
                }
            }
        ]

    ],
    plugins: [
        [
            '@babel/plugin-transform-typescript', // 支持typescript
            {
                isTSX: true
            }
        ],
        [
            "import",
            {
                "libraryName": "@jasen/antd", customStyleName: function (p, f) {
                    return path.join('@jasen/antd/lib', p, 'style/index.css') //自定义css路径
                }
            }
        ]

    ]
}

看下最终生成的代码

image.png

image.png 样式文件成功插入到页面中

一个基本组件库打包功能至此已经完成啦,一个完善的组件库构建工具还有许多功能需要考虑:eslint,文档,单元测试,样式主题等一些列功能。

发布npm

发布之前先完善下package.json配置 ,添加files字段配置上传npm的文件白名单目录,以及license开源协议等

{
  "name": "@jasen/antd",
  "version": "1.0.2",
  "description": "",
  "scripts": {
    "build": "webpack",
    "compile": "gulp compile"
  },
  "keywords": [],
  "author": "",
  "license": "MIT",
  "unpkg": "dist/antd.js",
  "main": "lib/index.js",
  "module": "es/index.js",
  "typings": "lib/index.d.ts",
  "files": [
    "dist",
    "lib",
    "es"
  ],
  "devDependencies": {...},
  "dependencies": {...}
 }
  1. 注册npm账号
  2. 终端登录npm执行npm login
  3. 输入注册的用户名密码登
  4. 执行npm publish