背景
提到前端打包工具, 最熟悉和常用的就是webpack了, 但是对于开发纯js库来说, 其实有更好的打包工具可以使用: rollup
与webpack的区别
简单对比一下两个打包工具
webpack
webpack的核心概念是一切皆模块, 然后基于模块可以实现代码缓存, 代码分割, 按需加载等特性
但这样在开发简单的js库时候, 打包出来的未压缩包里反而会出现__webpack_require__这样的代码, 压缩过后的min.js代码中也有一些遗留的模块加载的代码, 导致体积会稍大
rollup
rollup是一款ES Modules打包工具, 我们经常使用的vue和react都是使用它打包的. 对比起 webpack, rollup的核心特点是: 代码更简洁, 不会存在模块引入的问题. 但不足的是如果需要引入其他资源类型的文件需要其他插件, 而这些插件质量是没有webpack质量高的. 所以作为纯js库开发来说, rollup更轻量快速, 但整体web-app开发还是需要选择大而全的webpack或者其他如vite和parcel等打包工具
开发环境搭建
rollup.config
纯js的rollup开发环境比较简单, 直接按照官方文档搭建都可以. 但是我们需要使用ts, 所以有些配置需要定制
同时可以看到, 我们这里输出了两种文件: umd类型和es类型, 其中umd类型是用于浏览器的, es类型是用于webpack等打包工具的, webpack会优先引入package.json里的module文件, 实现Tree-shaking
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| import resolve from 'rollup-plugin-node-resolve'; import commonjs from 'rollup-plugin-commonjs'; import sourceMaps from 'rollup-plugin-sourcemaps'; import camelCase from 'lodash.camelcase'; import typescript from 'rollup-plugin-typescript2'; import json from 'rollup-plugin-json';
import pkg from './package.json';
const libraryName = 'my-library';
export default { input: `src/${libraryName}.ts`, output: [ { file: pkg.main, name: camelCase(libraryName), format: 'umd', sourcemap: true }, { file: pkg.module, format: 'es', sourcemap: true }, ], external: [], watch: { include: 'src/**', }, plugins: [ json(), typescript({ useTsconfigDeclarationDir: true }), commonjs(), resolve({ browser: true, preferBuiltins: true, mainFields: ['browser'] }),
sourceMaps(), ], };
|
package.json
在script里加上build和start两个命令, 这里参考了其他库的用法, 在build的时候会直接使用typedoc生成一份docs文档
1 2 3 4 5 6
| { "scripts": { "build": "tsc --module commonjs && rollup -c rollup.config.ts && typedoc --out docs --target es6 --theme minimal --mode file src", "start": "rollup -c rollup.config.ts -w" } }
|
使用
在配置完成后, 使用npm run build就能打出生成环境包, 使用npm run start就能启动rollup监听自动编译, 性能上比webpack启动和打包会快很多, 而且打包代码的可读性也比webpack更强, 没有冗余部分, 可以更好的调试代码
优化压缩
打包之后, 发现umd格式的代码没有压缩. es模块代码不压缩比较正常, 因为如果是由webpack引入的, 往往会再次压缩打包, 不压缩更易于调试和修改. 但umd版本是可以提供浏览器直接使用的, 如果不压缩会造成体积过大, 所以需要修改rollup配置来实现不同的压缩策略, 而查了一遍文档后, 发现rollup的插件只能基于整体来配置, 而不能基于不同的output文件配置, 所以需要稍微修改一下配置结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| import resolve from 'rollup-plugin-node-resolve'; import commonjs from 'rollup-plugin-commonjs'; import sourceMaps from 'rollup-plugin-sourcemaps'; import camelCase from 'lodash.camelcase'; import typescript from 'rollup-plugin-typescript2'; import json from 'rollup-plugin-json';
import pkg from './package.json';
const libraryName = 'my-library';
const config = [ { input: `src/index.ts`, output: { file: pkg.main, name: camelCase(libraryName), format: 'umd', sourcemap: true, }, external: [], watch: { include: ['src/**'], }, plugins: [ json(), typescript({ useTsconfigDeclarationDir: true }), commonjs(), resolve({ browser: true, preferBuiltins: true, mainFields: ['browser'] }),
sourceMaps(), uglify(), ], }, { input: `src/index.ts`, output: { file: pkg.module, format: 'es', sourcemap: true, }, external: [], watch: { include: ['src/**'], }, plugins: [ json(), typescript({ useTsconfigDeclarationDir: true }), commonjs(), resolve({ browser: true, preferBuiltins: true, mainFields: ['browser'] }),
sourceMaps(), ], }, ];
export default config;
|
单元测试
开发一个库自然需要单元测试, 使用最常见jest就可以了, 对ts的支持程度也非常好, 不需要编译和打包就能直接测试, 也能生成覆盖率报告
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| { "scripts": { "test": "jest --coverage", "test:watch": "jest --coverage --watch" }, "jest": { "transform": { ".(ts|tsx)": "ts-jest" }, "testEnvironment": "node", "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", "moduleFileExtensions": ["ts", "tsx", "js"], "coveragePathIgnorePatterns": ["/node_modules/", "/test/"], "coverageThreshold": { "global": { "branches": 90, "functions": 95, "lines": 95, "statements": 95 } }, "collectCoverageFrom": ["src/*.{js,ts}"] } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import Lib from '../src/index';
describe('Lib test', () => { it('works if true is truthy', () => { expect(true).toBeTruthy(); });
it('Lib is instantiable', () => { expect(new Lib()).toBeInstanceOf(Lib); });
it('Lib test is 1', () => { expect(new Lib().test()).toEqual(1); }); });
|
Demo
单元测试跑完没有问题之后, 有些库的效果还需要在页面实际使用中才能看到效果, 我们也可以基于rollup打包一个简单的react-demo应用, 实现在页面上实时调试, 同时也加上自动更新和调试服务器等配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
| const isNpmStart = process.env.npm_lifecycle_event === 'start'; const isDev = isNpmStart;
if (isDev) { fs.rmdirSync(path.resolve('./node_modules/.cache'), { recursive: true }); }
const createDemoConfig = () => { const getIPAddress = () => { const interfaces = os.networkInterfaces(); let result = '0.0.0.0'; for (const devName in interfaces) { const alias = _.find(interfaces[devName], (alias) => alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal); if (alias) { result = alias.address; break; } } return result; }; return { input: `demo/index.tsx`, output: { file: `dist/lib-demo.js`, name: camelCase('lib-demo'), format: 'iife', sourcemap: true, sourcemapExcludeSources: false, exports: 'named', globals: { react: 'React', 'react-dom': 'ReactDOM', antd: 'antd', store: 'store', '@ant-design/icons': 'icons', }, }, external: ['react', 'react-dom', 'antd', '@ant-design/icons', 'store'], watch: { include: ['demo/**'], }, plugins: [ postcss({ extensions: ['.sass', '.css'], }), replace({ 'process.env.NODE_ENV': JSON.stringify('development'), 'process.env.BASE_PATH': JSON.stringify(basePath), }), json(), typescript({ include: ['demo/**/*.ts', 'demo/**/*.tsx'], rollupCommonJSResolveHack: true, useTsconfigDeclarationDir: true, tsconfigOverride: { include: ['src', 'demo'] }, }), babel({ babelrc: false, extensions: ['.js', '.jsx', '.ts', '.tsx'],
presets: ['@babel/preset-env'], exclude: ['node_modules/**'], }), resolve({ browser: true, preferBuiltins: true, mainFields: ['browser'] }), commonjs({ }),
sourceMaps(),
...(isNpmStart ? [ livereload({ watch: ['dist/'] }), serve({ contentBase: '../', host: getIPAddress(), port: 10001, open: true, openPage: '/lib/demo/index.html', }), ] : []), ], }; };
const config = libConfig.concat(isDev ? [createDemoConfig()] : []);
|
Demo的html配置, 为了减少打包速度和打包问题, 其他依赖库直接使用script引入页面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1" /> <script src="../node_modules/react/umd/react.development.js"></script> <script src="../node_modules/react-dom/umd/react-dom.development.js"></script> <script src="../node_modules/moment/min/moment.min.js"></script> <script src="../node_modules/antd/dist/antd.min.js"></script> <script src="../node_modules/@ant-design/icons/dist/index.umd.min.js"></script> <script src="../node_modules/store/dist/store.legacy.min.js"></script> <link type="text/css" rel="stylesheet" href="../node_modules/antd/dist/antd.css" /> <title>demo</title> </head> <body> <div id="root"></div> <script src="../dist/lib.umd.js"></script> <script src="../dist/lib-demo.js"></script> </body> </html>
|
Demo的index.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import _ from 'lodash'; import React, { useReducer, useEffect } from 'react'; import ReactDOM from 'react-dom'; import Root from './containers/root'; import { ErrorBoundary, LoadingProgress } from './components'; import { defaultState, reducer, Context } from './context-store/userList'; import Lib from '../src/index';
import './App.scss'; import './main.scss';
declare global { const lib: typeof Lib; }
const App: React.FC = () => { const [store, dispatch] = useReducer(reducer, defaultState); useEffect(() => { const sdk = Lib.getInstance(); }, []); return ( <ErrorBoundary> <LoadingProgress /> <Context.Provider value={{ state: store, dispatch }}> <Root /> </Context.Provider> </ErrorBoundary> ); };
ReactDOM.render(<App />, document.getElementById('root'));
|
小结
整套配置完成后, 就基本实现了一个简单的rollup+typescript的js库开发环境, 后续如果有其他需求继续在此基础上迭代即可, 也考虑使用最常用的配置开发一个create-rollup-ts-lib这样的工具, 减少重复工作量和踩坑的过程