开发npm包的一些注意事项

本文整理了开发单个npm工具包的一些注意事项,包括环境搭建、构建目标、类型提示和外部依赖等。

<!--more-->

1. 项目及开发环境

先创建一个目录,以及初始的package.json文件

mkdir tools
cd tools
npm init -y

然后你就会得到一个最基础的项目

{
  "name": "tools",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

记得选一个好名字,npm使用字符串作为包的名字,因此确保你选择的包名是唯一的,不会与现有的包发生冲突。你可以在npm官方网站上搜索你打算使用的包名,以确保它还没有被使用。最简单的方式是使用命名空间,即@xxx/packagename形式的包。

这个包作为一个完整的项目工程,并且会托管在开源仓库中,因此也需要具备基础的代码质量和检测工具,方便使用者可以直接阅读项目源码

1.1. eslint

ESLint是一个静态代码分析工具,用于检查和识别JavaScript代码中的潜在问题、错误和风格问题,它可以配置一组规则,并根据这些规则对代码进行静态分析和报告。

具体的使用可以参考官方文档

npm init @eslint/config -- --config semistandard

# 安装完成后就可以校验某个文件了,借助WebStrom等IDE工具,也可以快速检测项目源码文件夹中的所有文件
npx eslint yourfile.js

初始化eslint后,在项目根目录下会出现.eslintrc.{js,yml,json}配置文件,几个重要的字段包括

  1. root:指定配置文件的根目录。可以是相对路径或绝对路径。
  2. env:指定代码执行的环境。例如,设置为"browser": true表示在浏览器环境下执行。
  3. extends:继承其他配置文件的规则。可以是预设的规则集如eslint:recommendedplugin:react/recommended,也可以是其他如团队、项目级别的自定义配置文件。
  4. rules:指定规则配置。可以是预定义的规则名称,也可以是自定义规则。规则可以设置为以下几种级别:
    • "off":关闭规则。
    • "warn":警告级别,不会导致构建失败。
    • "error":错误级别,会导致构建失败。
  5. plugins:指定使用的插件。可以是预设的插件,也可以是自定义插件。插件是为了扩展ESLint的功能,提供额外的规则或处理特定类型的文件。插件通常由社区开发,并根据特定的编程语言、框架或库提供规则
  6. globals:指定全局变量。用于定义在代码中使用但是未声明的全局变量,常见的比如一些全局的环境变量import.meta.env.xxx
  7. overrides:指定针对特定文件的配置覆盖项。可以根据文件路径、文件名等进行匹配,并设置特定的规则。

1.2. prettier

ESLint负责代码质量和风格的检查,而Prettier负责代码格式化的任务,Prettier它专注于代码的格式化和美化,可以自动调整代码的缩进、换行、引号等,使代码具有一致的风格。

Prettier可以通过插件的方式集成到ESLint中,以实现代码格式化的一致性。这样在代码检查过程中,ESLint会调用Prettier来格式化代码,并根据ESLint的规则来检查代码质量和风格

npm i prettier -D
# 配置文件
touch .prettierrc

配置文件一般包括缩进、单双引号等字段

{
  "printWidth": 100,
  "tabWidth": 2,
  "useTabs": false,
  "semi": false,
  "singleQuote": true,
  "quoteProps": "as-needed",
  "jsxSingleQuote": false,
  "trailingComma": "es5",
  "bracketSpacing": true,
  "jsxBracketSameLine": false,
  "arrowParens": "always",
  "requirePragma": false,
  "insertPragma": false,
  "proseWrap": "preserve",
  "htmlWhitespaceSensitivity": "ignore",
  "endOfLine": "auto"
}

配置好之后,可以通过命令prettier来格式化代码,不过更常规的操作是配置IDE,通过快捷键或自动保存时执行格式化操作的任务。

1.3. husky

不论是eslint还是prettier,都依赖于开发者手动操作检测。为了避免不规范的代码提交到了git仓库中,需要借助git hooks,在commit或push前自动执行检测操作。

husky可以让我们向项目中方便添加git hooks。

npm install husky -D

在项目根目录下创建.husky文件夹,并在其中创建pre-commit文件。在该文件中指定需要在提交前运行的脚本,例如:

#!/bin/sh
echo "pre commit hooks"
# 某些提交前的任务,比如eslint检测等
lint-staged

添加钩子任务之后,可能会导致每次提交会多花一点时间,但可以让整个项目的代码变得更规范,还是很有必要的。

此外,提交记录也可以按照固定的格式,方便查看变更记录。可以通过commitizen来实现

# 安装时间可能会有点久
npm install -g commitizen

npm install -g cz-conventional-changelog

echo '{ "path": "cz-conventional-changelog" }' > ~/.czrc

# 切换到项目,使用cz 替换commit命令 
git cz
# 依次选择后续步骤

建议使用全局设置

2. 构建目标

构建工具包和构建业务代码的最大区别是:对应构建产物的目标对象不同。

对于业务代码而言,构建产物是直接部署到服务器上并让浏览器直接访问的,资源文件会直接通过script引用

  <script src='https://xxx.cdn.com/fe/index-abcdef.js'></script>

因此打包出来的代码一般是IIFE的

(function(e){
  // ...
})({'1':xx,'2':xx,...})

当然,随着现代浏览器对es module规范的逐渐支持,有的打包工具也支持直接打包浏览器规范的esmodule产物

import {xxx} from './vendor-ea058dad.js'
...

也就是说,业务代码的构建产物是面向浏览器的

对于工具包而言,构建产物主要是发布到npm仓库,供第三方通过npm install的方式安装到node_modules中,比如

import {ref} from 'vue'

也就是说,工具包的构建产物是面向开发者的。

这就存在一个很重要的顺序问题,先有工具包,其他开发者才能使用它。而在构建这个工具包的时候,我们无法预知这个包会在什么情况下使用。

所以,构建工具包,就需要从第三方开发者的角度考虑以下几个问题

  • 使用的模块规范是什么,esm、commonjs、umd还是iffe

  • 使用的语言是什么,typescript还是javascript,是否需要类型提示

  • 项目是运行在浏览器还是Node环境下的,是否需要支持同构

  • 代码是否已经完全构建,某些构建工具(如vue-cli中的babel-loader)默认会忽略所有 node_modules 中的文件以加快构建速度,就可能导致最终的构建产物包含es6的代码,从而出现浏览兼容错误

接下来看看JavaScript常用的几种模块规范

2.1. esm

ESM(ES Modules)适用于现代浏览器和支持ES模块的环境,也可以在Node.js中使用(需要开启配置)

使用importexport语法进行模块导入和导出。支持静态分析和按需加载,可以实现更好的模块封装和代码优化。

借助静态分析的特性,esm模块还可以实现tree shaking的功能,将未被使用的依赖自动移除,优化构建速度和产物体积。

2.2. CommonJS

CommonJS使用require()module.exports(或exports)语法进行模块导入和导出。在Node.js中是同步加载模块的,适用于服务器端和命令行应用。

主要用于Node.js环境,也可在一些构建工具(如Webpack、Browserify)中使用

2.3. UMD

UMD(Universal Module Definition)用于同时支持浏览器环境和Node.js环境的情况,以及在不同的模块规范之间进行兼容性处理

UMD模块规范兼容了CommonJS和AMD(异步模块定义)的语法,以及全局变量的导出方式,可以在不同的环境中使用

2.4. IIFE

IIFE(Immediately Invoked Function Expression)使用立即执行的函数表达式将模块代码封装在函数作用域内,防止变量冲突和全局污染。通过全局变量或参数传递来导出模块。

适用于老旧浏览器或不支持模块系统的环境,以及一些独立的库或插件。

2.5. 声明构建目标

目前,ESM已经成为现代前端开发的主流模块规范,因此对于一个工具包而言,必须要支持esm的构建产物

此外,由于还有大量项目运行在不支持esm的Node版本(NodeJS V13以下),因此,CommonJS的构建产物也需要实现。

由于前端工程化的推荐,直接运行在浏览器端的模块加载器requirejsseajs等已经逐步淘汰、因此已经不太需要再构建umd类型的模块了。

至于IIFE,除了需要直接提供一整个文件,然后直接通过script标签引入的场景(快速创建一个html文件写demo)之外,也已经不太需要了。

得到了我们的构建目标,还需要再package.json中进行声明

  • main: 用于CommonJS规范的入口文件,
  • module: 用于ESM规范的入口文件
"main": "dist/xxx.cjs.js",
"module": "dist/xxx.esm.js",

当第三方开发者的引用这个包的时候,就会根据工具或者环境自动加载main或者module入口文件的内容。

2.6. rollup构建

确认了构建目标之后,还需要选择合适的工具将源代码打包成符合要求的输出文件。目前比较主流的还是webpack打包业务产物、rollup打包工具包(当然vite借助rollup也可以输出业务产物了)

首先安装rollup

npm install --save-dev rollup

编写配置文件rollup.config.js,类似于下面的配置

Copy code
export default {
  input: 'src/index.js', // 输入文件路径
  output: [
    {
      file: 'dist/xxx.cjs.js', // 输出文件路径
      format: 'cjs', // 输出模块的格式,例如:'cjs', 'es', 'umd'
    }
     {
      file: 'dist/xxx.esm.js',
      format: 'es',
    }
  ],
  plugins: [
    // 插件配置,例如babel、commonjs等
  ]
};

然后就可以通过rollup -c命令进行构建,在实际开发中,还需要根据需求进行各种配置和插件的使用,这里不在赘述。

3. 类型提示

即使开发的是纯javascript项目,我们也更希望有一些智能的提示,来获得更好的代码体验。

因此,不论第三方开发者是否需要,一个工具包都应该尽量提供类型声明

npm i typescript -D

然后在项目根目录创建基础的tsconfig.json配置文件

{
  "compilerOptions": {
    "module": "es6",
    "strict": true,
    "declaration": true, // 需要输出声明文件
    "esModuleInterop": true
  },
  "include": ["src"]
}

TypeScript本身提供了编译器(tsc)可以将TypeScript代码编译为JavaScript代码,如果是一些简单的工具库,直接使用tsc就可以完成构建的任务,不用再单独配置rollup

"build:esm": "npx tsc -m es2015 --outDir dist/esm",
"build:cjs": "npx tsc -m commonjs --outDir dist/cjs",
"build:umd": "npx tsc -m umd --outDir dist/umd"

得到声明文件之后,也需要在package.json中声明

  • types:指定了TypeScript声明文件(.d.ts)的路径。当其他开发者使用你的模块时,TypeScript编译器将使用types字段指定的声明文件提供类型检查和自动补全的支持。
"types": "dist/xxx.d.ts"

4. 管理外部依赖

业务代码可以依赖第三方包、我们开发的工具包当然也可以依赖第三方包,这也是npm生态繁荣的一大理由。

但是在打包的时候,我们不应该将第三方包的代码一起构建,下面将阐述具体的原因

首先是版本冲突的问题,比如业务项目依赖了Vue、你的工具包也依赖了Vue版本,如果工具包打包了一份版本不同的Vue,就会导致不可预期的错误和行为,用户也很难来解决一个项目里面有多个不同版本的包的问题,最终的结果只能是将这个冲突的包移除依赖列表(然后加入黑名单

其次,包的大小也是决定用户是否愿意安装这个包的原因之一,过大的包会占据更多的下载时间和磁盘空间,以及最终打包的业务代码体积变大。

因此,在打包时不应该讲第三方包的代码一起进行构建,要达到这个目的,可以配置rollup.config.js中的external选项

export default {
  input: 'src/index.js', // 输入文件路径
  output: [],
  external: ['vue', 'vue-router'], // 外部依赖的列表
};

这样,在引入外部依赖的地方,还是会保留

import Vue from 'vue'

类似的代码,而不会替换成vue整个项目的代码。

在业务代码安装工具包时,会安装工具包package.json中声明的依赖列表,然后工具包就可以从node_modules自动找到对应的包,换言之,我们将工具包依赖的交给了业务方。

这会带来一个新的问题:如何保证业务方按照到工具包期望的依赖版本呢?比如业务方安装的是React14版本,而工具包只能在React16版本下工作时,强行安装就会导致node_modules里面出现两个不同大版本的Vue。

这个可以通过package.json中的peerDependencies指定包的对等依赖项来实现,即该包的用户也应该安装的其他包来声明你的包对于业务环境所需的依赖关系,帮助用户了解并安装正确的依赖版本,以确保你的包与宿主环境协同工作。

  1. 首先通过在package.json中使用peerDependencies字段,你可以明确告知用户在使用你的包时需要安装的特定依赖项及其版本范围。
  2. 当用户安装你的包时,npm会检查宿主环境中已安装的依赖,如果发现与你声明的peerDependencies存在版本冲突,npm会提醒用户解决这些冲突,以确保正确的依赖版本被安装。
{
  "name": "xxx",
  "version": "1.0.0",
  "peerDependencies": {
    "react": "^16.0.0",
    "react-dom": "^16.0.0"
  }
}

5. 其他

此外还有一些需要注意的点

不要在包中编写具有副作用的代码,一是ESM模块无法进行tree shaking、二是副作用的代码往往无法考虑全部的边界情况,如SSR需要这个包同时运行在浏览器和Node中,如果依赖了端特定的接口,就会出问题。更好的处理办法是将副作用的代码封装在初始化函数中、或者提供相关的

覆盖率足够的测试用例、以及及时更新的文档,也是必不可少的