背景
提到前端打包工具, 最熟悉和常用的就是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
这样的工具, 减少重复工作量和踩坑的过程