背景
开发一个npm包的目的是为了将好的代码封装起来,提供给别的开发者使用,从而避免重复造轮子以及解决代码的共享拷贝问题。
然而一个npm包要让他人使用起来方便,需要一套完整的流程规范,让我们的包在代码规范、构建、测试以及兼容性等方面都做到充分考量,从而减少bug,提高包的质量和易用性。
项目搭建流程
初始化项目
完善基本信息
首先选定一个文件夹,在 shell 终端通过 npm init
初始化你所要开发的npm包,填写好相应的包名、描述信息、git仓库地址等,对于默认信息或者暂时不确定的信息都可以通过回车确定。所有信息都填写完成后,我们就可以生成项目的package.json
然后我们通过 git init
初始化项目的本地仓库git信息,
并建立和已有远端git仓库的联系:
git remote add origin git@github.com:yourName/yourRepository.git
这时候我们可以通过vscode等编辑器打开我们刚生成的项目
包管理器的选择
目前有三种主流的包管理器:npm、yarn以及pnpm
这三者在下载、升级相关包的性能上的对比如下(摘自pnpm官网benchmark页面):
从图上来说,yarn和pnpm具有更快的下载速度。
从pnpm官网可知,pnpm主打的是更快的下载速度和更小的磁盘空间占用,而且通过非扁平化的node_modules结构,解决了Phantom dependencies 幽灵依赖等问题,因此我觉得他的实力是不容小觑的,是值得一试的。
而对我而言,我更倾向于yarn,因为yarn具有更简洁的输出信息和更语义化的命令(还有就是没怎么使用过pnpm)
项目目录规范
- lib:代码构建输出目录,用于npm发包
- src:项目源码目录,存放源代码
- test:测试目录,用于对源码进行各种单元测试
- .gitignore:git忽略文件
- README.md:项目的说明书,通常在这里面介绍项目的指令怎么用,指令有哪些选项等,以及其他信息
代码规范配置
初始完成项目、并且选定好包管理器后,我们就可以对项目进行各项规范配置了
TS支持
几乎所有的主流项目都是基于typescript书写构建的,它具有更好的类型提示,以及编译时的代码检测,从而增强我们的代码健壮性,这也是提高包质量的关键方法之一
首先安装typescript
yarn add typescript -D
然后配置项目的tsconfig.json
{
"compilerOptions": {
"target": "es5", // 编译后的es版本
"module": "esnext", // 前端模块化规范
"allowJs": true, // 允许引入js文件
"strict": true, // 开启严格模式
"baseUrl": "./",
"moduleResolution": "node", // 模块解析方式,按node的方式递归查找node_modulse
"outDir": "lib",
"sourceMap": true,
"noImplicitAny": false,
"noImplicitThis": true,
"suppressImplicitAnyIndexErrors": true,
"lib": [
"ES2015",
"DOM",
"es5",
"es2015.promise",
"es2015",
"es2017",
"esnext"
]
},
"include": [
"src/**/*",
"index.ts"
],
"exclude": [
"node_modules",
]
}
然后我们就可以配置好项目的 npm script :
"scripts": {
"dev": "tsc -w"
},
同时为了清理掉上一次构建的产物,我们可以安装 rimraf 这个包来兼容不同平台的文件删除功能,因此我们当前的 npm scripts 变成了:
"scripts": {
"clean": "rimraf dist",
"dev": "npm run clean && tsc -w",
},
此时我们运行 yarn dev ,在src目录下书写的ts文件都能够实时编译,这种方法不太优雅,适用于小型包,后续会采用rollup进行构建,丰富的插件机制以及hook,可以对输出代码做更精细化的掌控。
ESlint + Prettier 格式化代码
写代码就像写字一样,不能乱涂乱画,代码工整、语义化,有利于提高代码的可读性以及形成良好的书写习惯
依赖安装
yarn add -D eslint@7.32.0 @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier eslint-plugin-prettier prettier
对于这里为何选择 7.32.0 版本的eslint,是经过实践而来的,经验证,7.32.0版本比较好用,8.0以上移除了一些API,产生eslint加载失败,导致VSCode的eslint实时检查不生效
配置.eslintrc与.eslintignore
.eslintrc
{
"root": true,
"parser": "@typescript-eslint/parser", //定义ESLint的解析器
"plugins": [
"prettier",
"@typescript-eslint"
],//定义了该eslint文件所依赖的插件,
"extends": [
"prettier"
],
"rules": {
"no-var": "error",
"prettier/prettier": "error"
}
}
.eslintignore
/\*
!/src
!/docs
!/\*.js
这里有一个细节,就是通过通配符 /* ,将所有的文件忽略(不进行eslint检查),然后通过白名单,选择需要检查的目录,这样做可以尽可能少的书写匹配规则
配置.prettierrc与.prettierignore
.prettierrc
{
"useTabs": false,
"printWidth": 120,
"singleQuote": true,
"trailingComma": "es5",
"arrowParens": "always"
}
.prettierignore
node_modules
lib
Husky配置
代码提交前先对代码进行格式化,从而保证提交到仓库里的代码是美观整洁的
依赖安装
yarn add husky lint-staged -D
配置package.json
"lint-staged": {
"src/**/*.{js,ts}": [
"prettier --write",
"eslint --fix"
]
},
配置脚本
npx husky install
添加钩子pre-commit
npx husky add .husky/pre-commit 'echo \"git commit trigger husky pre-commit hook\" && yarn lint-staged'
这样在 git commit 之前就能使用lint-staged去检查相应的文件,并执行相应的命令来修复我们的代码
CommitLint
代码提交前,对commit信息进行规范化校验,从而保证每次提交的信息符合标准
依赖安装
yarn add @commitlint/cli @commitlint/config-conventional -D
配置commitlint.config.js
在项目根目录下添加 commitlint.config.js 配置文件,配置commit规范
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'update', 'fix', 'refactor', 'optimize', 'style', 'docs', 'chore', 'build', 'test'],
],
'type-case': [0],
'type-empty': [2, 'never'], //type必填
// 'scope-empty': [2, 'never'], //scope必填
// 'scope-case': [0],
'subject-full-stop': [0, 'never'],
'subject-case': [0, 'never'],
'header-max-length': [0, 'always', 72],
},
};
添加钩子commit-msg
npx husky add .husky/commit-msg 'yarn commitlint -e $HUSKY_GIT_PARAMS'
经过以上配置后,就可以对commit信息进行检查,符合规则的commit才会成功保存,
比如以下的 commit message 为 'test' 他是不符合规范的,因此我们需要重新提交正确的commit信息
Rollup构建
rollup.config配置
对于小型项目,我们可以通过 tsconfig.json 配合 tsc 直接对源码进行编译,输出为es module或者cjs,但是有时候我们需要同时输出好几种格式的代码,自己去配置一系列的配套脚本无异于是重复造轮子
Rollup 是一个 JavaScript 模块打包工具,可以将多个小的代码片段编译为完整的库和应用。与传统的 CommonJS 和 AMD 这一类非标准化的解决方案不同,Rollup 使用的是 ES6 版本 Javascript 中的模块标准
Rollup是基于es module的,因此它更适用于es模块,对于普通的cjs模块,它的优势并不是很大,对于他的详细介绍,可以去官网查询
接下来主要描述一下该如何在项目里对Rollup进行配置,首先需要在项目的根目录下新建一个rollup.config.js文件,这个文件会在执行rollup相关脚本的时候被读取
因此对于一个简单的rollup.config.js配置如下:
const path = require('path');
module.exports = {
input: path.resolve(__dirname, './src/index.js'),
output: {
file: path.resolve(__dirname, './lib/index.js'),
format: 'es',
},
};
同时我们需要在 npm script 中添加一条新的命令,用于构建源码:
"build": "rimraf lib && cross-env NODE_ENV=production rollup -c"
同时需要安装 cross-env 用于声明兼容各平台的环境变量:
yarn add cross-env -D
然后安装 Rollup 依赖:
yarn add rollup@^2.79.1 -D
然后我们在 src 目录下添加 index.js 并书写一下内容:
import { add } from './func';
console.log(add(1, 1));
并在同级目录下添加 func.js 文件,并书写以下内容:
export const add = (...args) => args.reduce((a, b) => a + b, 0);
export const sub = (...args) => args.reduce((a, b) => a - b, args[0] * 2 || 0);
这时候我们可以执行 yarn build 命令来试一试
此时的输出文件内容:
可以看到输出的内容十分的简洁,并且机遇树摇优化,未引入的部分并没有被包含进源码
接下来是一份完整的rollup配置,它包含了对ts文件的解析、babel转换、声明文件生成等:
import fs from 'fs';
import path from 'path';
import shelljs from 'shelljs';
import ts from 'rollup-plugin-typescript2';
// 将json 文件转换为ES6 模块
import json from '@rollup/plugin-json';
// 在node_模块中查找并绑定第三方依赖项(将第三方依赖打进包里)
import resolve from '@rollup/plugin-node-resolve';
// 将CommonJS模块转换为ES6
import commonjs from '@rollup/plugin-commonjs';
// rollup babel插件 兼容新特性
import babel from 'rollup-plugin-babel';
// 优化代码
import { terser } from 'rollup-plugin-terser';
// 热更新服务
// import livereload from 'rollup-plugin-livereload';
import dts from 'rollup-plugin-dts';
// import eslint from '@rollup/plugin-eslint'
import pkg from './package.json';
// 判断是是否为生产环境
// 开发环境or生产环境
const isPro = function () {
return process.env.NODE_ENV === 'production';
};
const SRC_DIR = './src';
const GIT_IGNORE = '.gitignore';
const extensions = ['.jsx', '.ts', '.tsx'];
const generateConfig = (input, output, plugins = []) => {
return {
input,
output,
plugins: [
resolve(), //快速查找外部模块
commonjs(), //将CommonJS转换为ES6模块
json(), //将json转换为ES6模块
//ts编译插件
ts({
tsconfig: path.resolve(__dirname, './tsconfig.json'),
extensions,
}),
babel({
runtimeHelpers: true,
exclude: ['node_modules/**', 'src/plugins/**.js'],
}),
// !isPro() &&
// livereload({
// watch: ['dist', 'examples', 'src/**/*'],
// verbose: false, // 关闭冗长的重新编译成功后的控制台输出
// }),
isPro() && terser(),
...plugins,
],
};
};
const configList = [
generateConfig(path.resolve('./src/index.ts'), [
// {
// file: pkg.unpkg,
// format: 'umd',
// name: pkg.jsname,
// sourcemap: true,
// },
{
file: pkg.module,
format: 'esm',
sourcemap: true,
},
{
file: pkg.main,
format: 'cjs',
sourcemap: true,
},
]),
{
// 生成 .d.ts 类型声明文件
input: path.resolve('./src/index.ts'),
output: {
file: pkg.types,
format: 'es',
},
plugins: [
dts(),
// del({
// targets: ['./lib/src'],
// hook: 'buildEnd',
// }),
// {
// name: 'move-dts',
// buildEnd() {
// // console.log('test');
// },
// },
],
},
];
const files = shelljs.ls(`${SRC_DIR}/**/*.@(js|ts)`).filter((path) => typeof path === 'string');
files.forEach((file) => {
const filename = path.basename(file).replace(/\.\w+$/, '');
if (filename === 'index') return;
configList.unshift(
generateConfig(
path.resolve(file),
[
// {
// file: pkg.unpkg,
// format: 'umd',
// name: pkg.jsname,
// sourcemap: true,
// },
{
file: `${path.resolve('.', filename, 'index')}.esm.js`,
format: 'esm',
sourcemap: true,
},
{
file: `${path.resolve('.', filename, 'index')}.js`,
format: 'cjs',
sourcemap: true,
},
],
[
{
name: 'add-gitignore',
buildEnd() {
const gitIgnoreList = fs.readFileSync(GIT_IGNORE).toString().split('\n');
if (!gitIgnoreList.includes(filename)) {
fs.writeFileSync(GIT_IGNORE, gitIgnoreList.concat(filename).join('\n'));
}
},
},
]
),
{
// 生成 .d.ts 类型声明文件
input: path.resolve(file),
output: {
file: `${path.resolve('.', filename, 'index')}.d.ts`,
format: 'es',
},
plugins: [dts()],
}
);
});
export default configList;
配合这个配置,还需要安装相应的插件:
yarn add -D @rollup/plugin-commonjs @rollup/plugin-json @rollup/plugin-node-resolve rollup-plugin-babel rollup-plugin-dts rollup-plugin-livereload rollup-plugin-terser rollup-plugin-typescript2 @babel/core @babel/preset-env @babel/preset-typescript shelljs
并在项目的根目录下配置 .babelrc 配置文件
{
"presets": ["@babel/preset-env", "@babel/preset-typescript"]
}
改写 npm script 中的dev命令:
"dev": "cross-env NODE_ENV=dev rollup -c -w"
补充(或修改) package.json 里面的 main、module、types字段(这里将入口统一为了 src/index.ts ):
"main": "./lib/index.cjs.js",
"module": "./lib/index.esm.js",
"types": "./lib/index.d.ts",
为了进一步演示,改写:
- src/index.js => src/index.ts
- src/func.js => src/func.ts
并将 src/index.ts 的内容改为:
export * from './func';
再次执行 yarn build
可以看到在lib目录下输出了构建好的文件,并且在根目录下会有一个 func 文件夹(稍后阐述)
插件
对于插件的使用及自定义,详见:Plugin Development
这里想说的是,我们可以自定义插件,并且选择适当的生命周期去做一些特定的事情,比如以上的rollup.config.js中有一段代码:
{
name: 'add-gitignore',
buildEnd() {
const gitIgnoreList = fs.readFileSync(GIT_IGNORE).toString().split('\n');
if (!gitIgnoreList.includes(filename)) {
fs.writeFileSync(GIT_IGNORE, gitIgnoreList.concat(filename).join('\n'));
}
},
},
这段代码会在代码构建完成后执行,用于动态的向 .gitignore 添加忽略内容,至于原因,下一节会解释
现在就可以往src目录下添加源代码了,并且需要一个index.ts作为默认的统一入口(也可以自行配置)
并且通过 yarn dev
命令实时调试包源码内容
模块化加载
众所周知,我们在使用loadsh的时候可以直接引入 lodash 的某个模块的内容,例如:
它是通过在项目的根目录下,直接添加相应的文件夹实现的,比如上述的at模块则在 package.json 的统计目录下有一个 at 文件夹,里面包含相应的源码
因此,我们的项目也想实现这一个功能则可以借鉴这种思路,所以我在上述的rollup.config.js文件中,将 src 目录下的各个模块都进行了单独的打包,将其构建到了外层根目录,并且动态的在 .gitignore 文件中添加了要忽略的模块,至此,便可以实现上述功能
当然,这种方法其实是一种兜底策略,通过这种方式加载的内容,在不支持es module的情况下,默认采用的是cjs模块
所以为了兼容es module的情况,在项目的 package.json 字段中配置了一个 exports 字段:
"exports": {
"./*": {
"import": "./*/index.esm.js",
"require": "./*/index.js"
}
},
经过这样的配置之后,我们就可以在支持 es module 的情况下默认读取特定文件夹下的以 .esm.js 结尾的文件,从而达到按需加载、tree shaking的目的
单元测试
当前主流的测试框架有mocha和jest等,这里我主要选取了mocha进行测试,
在test目录下创建与src目录下模块相对应的test文件
单测书写
通常我们需要对源码中的每个模块进行测试,比如我们的源码里有func这个模块,它包含一个add和一个sub函数
以下是func.test.js:
import assert from 'assert';
import { add, sub } from '../lib/index.esm';
describe('Func', function () {
it('add 应该可以进行数字的加法操作', function () {
const n1 = add(1, 2);
const n2 = add(1, 2, 3);
assert.deepEqual(n1, 3);
assert.deepEqual(n2, 6);
window.localStorage.setItem('TEST', 123);
console.log(window.localStorage);
});
it('sub 应该可以进行数字的减法操作', function () {
const n1 = sub(3, 2);
const n2 = sub(3, 2, 1);
assert.deepEqual(n1, 1);
assert.deepEqual(n2, 0);
});
});
依赖安装
yarn add -D esm mocha@^8.0.0
添加 npm script :
"test": "mocha -r esm test/*.test.js"
执行 yarn test
:
Dom测试如何模拟
对于有的项目,需要做Dom相关测试,比如测试cookie、localstorage等,但是我们的项目一般基于node环境,无法进行测试,因此我们需要对浏览器的相关API进行mock
.mocharc.js
在项目根目录下添加 .mocharc.js,mocha在测试之前会读取这个配置文件,以下是配置文件内容:
module.exports = {
require: ['test/hooks.js', 'mock-local-storage'],
};
其中 'mock-local-storage' 是注入对 localStorage 的mock
然后我们需要在 test文件夹下添加一个hooks.js文件,文件内容如下:
import { JSDOM } from 'jsdom';
// 模拟DOM
const { window } = new JSDOM(
`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mocha Test</title>
</head>
<body>
<p>hello world!</p>
</body>
</html>
`,
{
url: 'https://localhost/',
referrer: 'https://localhost/',
contentType: 'text/html',
includeNodeLocations: true,
}
);
const document = window.document;
const _document = global.document;
const _window = global.window;
export const mochaHooks = {
beforeAll() {
document.cookie = '';
// 添加全局document对象
global.document = document;
// 添加全局window对象
global.window = {
...window,
localStorage: global.localStorage, // mock-local-storage
addEventListener: () => {}
};
},
afterAll() {
_document ? (global.document = _document) : delete global.document;
_window ? (global.window = _window) : delete global.window;
},
};
这里主要是通过 jsdom 配置Dom的相关操作以及配置cookie和localstorage等mock,如果有需要mock别的api(比如上图的addEventListener),都可以自行添加
这里主要涉及到两个生命周期:beforeAll、afterAll,在beforAll里面我们需要注册所需的api,在afterAll里面卸载所有api避免污染
当mocha在测试之前会触发beforeAll这个hook,执行完测试之后会触发afterAll这个hook
依赖安装
yarn add -D jsdom mock-local-storage
以上都配置完成之后,我们就可以在测试文件的相应方法里对dom进行模拟测试
目录规范
对于test文件放到一个单独test目录(本文主推),还是将模块的test文件与模块放到一起,有两种声音
对于第一种,便于查看所有测试文件;对于第二种,便于知道某个模块是否已经书写了测试文件
各有各的好处,视项目而定
本地代码调试
在项目的根目录下执行:
npm link
在需要测试的地方执行(其中packageName是你所开发的包名,比如此处为 demo-project ):
npm link ${packageName}
至此,就可以在需要的地方引入源码进行测试
发包
Standard Version的使用
结合 Standard Version 对代码进行打tag、生成项目的changelog、以及自动升级包版本等
这里可以自定一个 npm script 并且在执行发包命令前(npm publish),先执行standard-version命令,从而达到生成相关规范的版本记录的目的
需要注意的是,通过standard-version会自动commit,但不会推到远端仓库
发布
第一次发包:
npm adduser
否则:
npm login
然后:
npm publish
现在就可以向项目的src目录添加代码并测试发布啦!