规范化构建npm包

2,023 阅读10分钟

背景

开发一个npm包的目的是为了将好的代码封装起来,提供给别的开发者使用,从而避免重复造轮子以及解决代码的共享拷贝问题。

然而一个npm包要让他人使用起来方便,需要一套完整的流程规范,让我们的包在代码规范、构建、测试以及兼容性等方面都做到充分考量,从而减少bug,提高包的质量和易用性。

项目搭建流程

初始化项目

完善基本信息

首先选定一个文件夹,在 shell 终端通过 npm init 初始化你所要开发的npm包,填写好相应的包名、描述信息、git仓库地址等,对于默认信息或者暂时不确定的信息都可以通过回车确定。所有信息都填写完成后,我们就可以生成项目的package.json

Aspose.Words.f900bcd8-f54d-4b8a-b474-fa32354db149.001.png

然后我们通过 git init 初始化项目的本地仓库git信息,

并建立和已有远端git仓库的联系:

git remote add origin git@github.com:yourName/yourRepository.git

这时候我们可以通过vscode等编辑器打开我们刚生成的项目

包管理器的选择

目前有三种主流的包管理器:npm、yarn以及pnpm

这三者在下载、升级相关包的性能上的对比如下(摘自pnpm官网benchmark页面):

Aspose.Words.f900bcd8-f54d-4b8a-b474-fa32354db149.002.png

从图上来说,yarn和pnpm具有更快的下载速度。

pnpm官网可知,pnpm主打的是更快的下载速度和更小的磁盘空间占用,而且通过非扁平化的node_modules结构,解决了Phantom dependencies 幽灵依赖等问题,因此我觉得他的实力是不容小觑的,是值得一试的。

而对我而言,我更倾向于yarn,因为yarn具有更简洁的输出信息和更语义化的命令(还有就是没怎么使用过pnpm)

项目目录规范

Aspose.Words.f900bcd8-f54d-4b8a-b474-fa32354db149.003.png

  • 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",
  ]
}

更完整的tsconfig配置

然后我们就可以配置好项目的 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信息

Aspose.Words.f900bcd8-f54d-4b8a-b474-fa32354db149.004.png

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 命令来试一试

Aspose.Words.f900bcd8-f54d-4b8a-b474-fa32354db149.005.png

此时的输出文件内容:

Aspose.Words.f900bcd8-f54d-4b8a-b474-fa32354db149.006.png

可以看到输出的内容十分的简洁,并且机遇树摇优化,未引入的部分并没有被包含进源码

接下来是一份完整的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 的某个模块的内容,例如:

Aspose.Words.f900bcd8-f54d-4b8a-b474-fa32354db149.007.png

它是通过在项目的根目录下,直接添加相应的文件夹实现的,比如上述的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

Aspose.Words.f900bcd8-f54d-4b8a-b474-fa32354db149.008.png

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目录添加代码并测试发布啦!