从 package.json 来聊聊如何管理一款优秀的 Npm 包

写在前边

其实原本只是想写一些有关于 Package.json 相关的内容,因为最近业务上刚好碰到这之中一些有趣的事情。

所以打算来和大伙进行分享,但是中途稍微构思了下思路。最近在关于频繁业务迭代下的 Npm 包管理方面其实也有一部分心得刚好也拿出来在这篇文章中和大伙互相交流下。

所以整篇文章大概会涉及到以下二个方面内容:

  • Package.json 中常见但是容易误解的字段解释,比如 等。

  • Package.json 中一些重要字段用途讲解,比如 browserexportsmodulemainversion等。

  • 频繁业务迭代背景下,如何语义化的迭代 NPM 包版本。

也许,你并不了解 Package.json

开始之前大家可以思考一个在平常不过的小问题:

Axios 大家或多或少都会使用过。它的一大特性即使支持双端(NodeJs 和 Web)端同时良好运行。不过你有想过在我们日常 build web 项目时,它是如何抹平环境差异呢?

换句话说,Axios 中的 Node 请求依赖的 http/https 模块。如果将它打包进入 web 代码一定会发生问题,但当你使用 Axios 进行 web 项目构建时并不会发生问题,这是为什么,你有考虑过这个问题吗?

经常使用 Axios 的小伙伴可以先按照自己的想法先思考思考。

首先,我们从 Package.json 作为文章切入点来聊聊 NPM 包中的声明文件。

mainmodule

关于 main 以及 module 字段对于大家来说应该是非常常见了。

每当我们通过 npm install xxx 安装某个包时,之后在项目中引入该包。

绝大多数情况下,针对于引入的包入口文件都是取决于这两个字段。

比如,我们以 vue 为例,在 vuepackage.json 中存在这样的声明:

// ...
"main": "dist/vue.runtime.common.js", 
"module": "dist/vue.runtime.esm.js",

它即表示当我们引入 vue 时,会根据不同的模块规范来进行不同的入口文件查找

简单来说,当你在项目中使用 const vue = require('vue') 时和使用 import vue from 'vue' 进行引入时实际上引入的是完全不同的两个文件。

前者由于是 CJS 的引入方式,所以会自动寻找对应 main 字段中的 node_modules/vue/dist/vue.runtime.common.js

而后者由于 ESM的方式则会自动寻找对应的 module 字段中的路径 node_modules/vue/dist/vue.runtime.esm.js

当然,vue 版本不同或者你使用的是 pnpm 这里示例的 node_modules 中的路径都不尽相同。

其次,偶尔有些情况下我们引入的包并不存在这两个字段。

  • 如果你是以 ESM 的方式引入该包时,首先会去寻找对应的 module 字段。如果 module 不存在的情况它会接下来去寻找对应的 main 字段。

  • 当然,如果 main 字段也不存在的话,默认是会寻找当前包中的 index.js 作为入口文件。

上述是一些这两个字段的基本使用情况,现在我们假设这样一种场景。

通常我们在 Web 项目中引入包的方式基本都是通过 ESM 的方式进行 import xxx from 'xxx' 的方式作为引入。

毫无疑问,通常它会按照上述的查找方式去寻找对应的 module -> main -> index.js 进行查找。

但如果此时存在另外一种特殊场景,虽然我们使用 ESM 的方式来引入了这个包。但是我们希望寻找该包的入口文件是根据 main 字段进行查找而非 module

这种情况下,我们可以利用构建工具来帮我我们来实现对应的效果。

比如假使我们使用 webpack 对于我们的 FE 项目进行构建,此时我们希望所有的包引入默认引入 main 字段的路径那么此时我们可以通过 resolve.mainFields 来进行处理。

当然在 Rollup 中也可以通过 @rollup/plugin-node-resolve 插件中的 mainFields 来实现这个功能。

当然 resolve.mainFields 默认会根据不同的构建环境来设置默认值。感兴趣的朋友可以自行去官网查阅对应的构建工具选项。

需要额外注意的是,根据使用的构建工具不同。比如我们以为 webpack 举例。

webpack 在 target: web 的情况下 mainFields 字段默认为 ['browser', 'module', 'main'] 。 这样就意味着假如你使用 webpack 构建你的 Web 项目,无论你使用 ESM 还是 CJS 语法本质上都是会优先查找 module 字段。

browser 字段我们会在稍后详细讲述到。

当然,如果你的项目不依赖于任何构建工具作为纯 NodeJs 项目。那么其实仍然是会按照我们刚才所述的查找规则进行查找的。

browser

上述我们描述了关于 modulemain 字段的含义,本质上它们两个都是针对于导入 Npm 包时规定按照哪个字段的路径去查找入口文件的字段。

其实还有一个额外重要的 browser 字段,同样也是针对于特定环境下(浏览器环境)的入口文件查找方式。

关于 browser 有两种配置方式,通常我们使用最多的也就是第一种。配置 browser 为一个 String 类型的路径。

第一种含义

通常,通过配置 browser 为一个单一的字符串时它会替换 main 字段作为浏览器环境下的包入口文件。

比如:

"browser": "./lib/browser/main.js"

通常,我们在使用 webpack 构建我们的项目时候默认构建环境 [target] (webpack.js.org/configurati…) 配置会是web

上述我们提到过的 resolve.mainFields 字段其实在浏览器环境下 target:web 下默认值会是:

mainFields: ['browser', 'module', 'main'],

这也就意味着,假使某个 NPM 包中同时存在 mainmodulebrowser 三个字段。

当构建环境为 web 时(浏览器环境下),那么该包的入口文件会变为 browser 字段而而非其他两个字段。(前提是你没有修改resolve.mainFields配置)

第二种含义

当然,browser 字段还有一种不是很常用的用法。

将它配置为一个 Map 对象表示声明需要替换的路径或者文件。

单独来听描述也许会感觉稍微有点生涩,没关系,接下来我们来稍微解释一下这句话的含义:

假如我们在项目中存在这样一个 NPM 包 qingfeng

image.png

  • package.json
{
  "name": "qingfeng",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "browser": {
    "./src/server.js": "./src/client.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

可以看到 package.json 中,我们设置了 browser 字段为一个路径映射的对象。

之后,我们来看看 index.js 中的内容:

// 项目入口文件
import server from './src/server'
import client from './src/client'

if (typeof window === 'undefined') {
  console.log(server)
} else {
  console.log(client)
}

在入口文件中,我们根据运行环境做了简单的判断。

如果 window 不存在那么就 log server 模块的内容,如果存在则 log client 模块。

此时,我们来看看各个模块中的内容:

// src/server.js
import path from 'path'

const server = path.resolve()

export default server
// src/client.js
const isClient = 'client'

export default isClient

可以看到 Server.js 中的代码依赖了 path 模块,而该模块在我们日常的项目中(浏览器环境下)一定是不存在的,依赖于该模块进行 build 在浏览器中执行一定是会报错。

此时 browser 字段的作用就会体现出来了:当我们在构建工具中声明的代码环境为浏览器环境web下,并且browser字段为对象时。 此时寻找 Npm 包的入口文件仍然会按照 main或者module 去查找入口文件。

不过,会根据browser字段中的 Map 进行映射对应的路径。 比如我们 qingfeng 包中引入了

import server from './src/server'

在 browser 环境中进行构建时会将它的路径替换为

import server from './src/client'进行打包。

image.png

上述是 Webpack development 模式打包后的该模块,可以看到最终在 web 环境下两个条件 case 中的 log 是一模一样的模块。

当然,你也可以将对应模块的值设置为 false,表示禁止将 module 加入到构建的产物中。比如

  "browser": {
    "./src/server.js": false
  },

不过,如果你的 NPM 可以做到在 client 和 server 环境下可以无差异化的实现的话,那么其实这个字段是完全可以被忽略的。

exports

Node 在 v12.7.0 版本中引入了 exports 字段作为 package.json 中对于 main 字段的强大替代品。

在各个开源库的 package.json 中你也许会经常见到这字段,接下来我们来聊聊 exports 字段是如何被处理的。

首先我们需要明确的是,exports 在 node v12.7.0 后提供了对于入口文件的替代品字段,它的优先级是高于任何入口字段的(modulemainbrowser

简单来说,加入我们的 package.json 中同时定义了 exports 字段和 modulemainbrowser 字段。

那么,在 Node v12.7.0 以上版本相当于 modulemainbrowser 等社区字段都不会生效,仅仅 exports 字段会生效。

接下来,我们来看看 exports 字段究竟应该如何使用:

路径封装

首先 exports 字段可以对于包中导出的路径进行封装。

比如下面的代码:

{
  // 表示该包仅存在默认导出,默认导出为 ./index.js
  "exports": "./index.js"
}

// 上述的写法相当于
{
  "exports": {
    ".": "./index.js"
  }
}

当我们定义了该字段后,该 Npm 包中的所有子路径都会被封装。

换句话说,我们仅仅只能引入 index.js。比如我们引入了未在 exports 中定义的文件。

// Error
// 此时控制台会报错,找不到该模块(无法引入在 exports 未定义的子模块路径)
import qingfeng from 'qingfeng/src/server.js'

// correct
import qingfeng from 'qingfeng'

同时在使用 exports 关键字时,可以通过 . 的方式来定义主入口文件:

{
  "exports": {
    // . 表示引入包默认的导出文件路径, 比如 import qingfeng from 'qingfeng'
    // 这里的 . 即表示未携带任何路径的 qingfeng
    ".": "./index.js",
    // 同时额外定义一个可以被引入的子路径
    // 可以通过 import qingfengSub from 'qingfeng/submodule.js' 进行引入 /src/submodule.js 的文件
    "./submodule.js": "./src/submodule.js"
  }
}

条件导出

同样, exports 字段的强大不仅仅在于它对于包中子模块的封装。这个字段同时提供了一种根据特定条件映射到不同路径的方法。

比如,通常我们编写的 NPM 包支持被 ESM 和 CJS 两种方式同时引入,但是不同的引入方式是不同的文件。

在不使用 exports 字段时,我们可以通过 modulemain 来进行区分,比如:

"module": "./index-module.js",
"main": "./index-require.cjs"

exports 字段中同时为我们提供了该条件判断:

// package.json
{
  "exports": {
    "import": "./index-module.js",
    "require": "./index-require.cjs"
  },
  "type": "module"
}

// 相当于
{
  "exports": {
    "import": {
        ".":  "./index-module.js"
    },
    "require": {
        ".": "./index-require.cjs"
    }
  },
  "type": "module"
}

可以看到 exports 关键字中定义的 key 为 importrequire 分别表示两种不同的模块引入方式使用该包时引入的不同文件路径。

关于条件判断的 Key 值,除了上述的 importrequire 分别代表的 ESM 引入和 CJS 引入的方式,NodeJS 同样提供了以下的条件匹配:

  • "import"- 当包通过 ESM 或加载时匹配 import(),或者通过 ECMAScript 模块加载器的任何顶级导入或解析操作。

  • "require"- 当包通过 CJS 加载时,匹配require()

  • "default"- 始终匹配的默认选项。可以是 CommonJS 或 ES 模块文件。这种情况应始终排在最后。(他会匹配任意模块引入方式)

需要注意的是 exports 中的 key/value 顺序很重要,在发生条件匹配时较早的条目具有更高的优先级并优先于后面的条目。

当然上边我们提到的条件导出不仅仅适用于包的默认导出路径,同样也适用于子路径。比如:

{
  "exports": {
    ".": "./index.js",
    "./feature.js": {
      "node": "./feature-node.js",
      "default": "./feature.js"
    }
  }
}

嵌套条件

上边我们说到过 exports 的条件匹配,它支持不同的引入方式从而进行不同的条件导出。

同样 exports 还支持多层嵌套,支持在运行环境中嵌套不同的引入方式从而进行有条件的导出。

比如:

{
  "exports": {
    "node": {
      "import": "./feature-node.mjs",
      "require": "./feature-node.cjs"
    },
    "default": "./feature.mjs"
  }
}

上述的匹配条件就类似于 js 中的 if 语句,首先检查是否是 Node 环境下去运行。如果是则进入模块判断是 ESM 引入方式还是 CJS 方式。

如果不是,则进行往下匹配进入 default 匹配。

同样 NodeJs 中支持以下运行环境的:

  • "node"- **匹配任何 Node.js 环境。**可以是 CommonJS 或 ES 模块文件。在大多数情况下,不需要显式调用 Node.js 平台。

  • "node-addons"- 类似于"node"并匹配任何 Node.js 环境。此条件可用于提供使用本机 C++ 插件的入口点,而不是更通用且不依赖本机插件的入口点。

更多的 exports 关键字

当然,除了上述 Node 中支持的 exports key 的条件。比如上述我们提到的 importrequirenodedefault 等。

同样,exports 的 Key 也支持许多社区中的成熟关键字条件,比如:

  • "types"- typescipt 可以使用它来解析给定导出的类型定义文件
  • "deno"- 表示 Deno 平台的关键 key。
  • "browser"- 任何 Web 浏览器环境。
  • "development"- 可用于定义仅开发环境入口点,例如提供额外的调试上下文。
  • "production"- 可用于定义生产环境入口点。必须始终与 互斥"development"

最后,让我们以 Vue/Core 中的 exports 来为大家看看开源项目中的 exports 关键字用法:

  // ...
  "exports": {
  	".": {
          "import": {
            "node": "./index.mjs",
  		"default": "./dist/vue.runtime.esm-bundler.js"
             },
            "require": "./index.js",
            "types": "./dist/vue.d.ts"
  	},
  	"./server-renderer": {
            "import": "./server-renderer/index.mjs",
            "require": "./server-renderer/index.js"
  	},
  	"./compiler-sfc": {
            "import": "./compiler-sfc/index.mjs",
            "require": "./compiler-sfc/index.js"
  	},
  	"./dist/*": "./dist/*",
  	"./package.json": "./package.json",
  	"./macros": "./macros.d.ts",
  	"./macros-global": "./macros-global.d.ts",
  	"./ref-macros": "./ref-macros.d.ts"
    }
    // ...

相信上面这份 exports 配置对于大家来说已经小菜一碟了。

如果你仍然不是很明白上述的配置,那么一定请你翻回去认真读一读上边的内容~

自定义运行环境

上述针对于 exports 的字段的解释其实已经基本结束了,但是通过上边的描述我们清楚 Node 在查找模块时会根据 exports 中的不同环境来进行匹配对应包的导出路径。

不知道有没有好奇心重的小伙伴,上述所谓的 browserdevelopment 等运行环境究竟是如何被识别的呢

或者换一个问题,如果我们在 exports 中希望额外添加一个环境的引入路径,应该如何做呢?

比如,此时我们希望定义一个名为 qingfeng 的加载环境:

// qingfeng 所在的 NPM 包 package.json
{
  "name": "qingfeng",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "exports": {
    // 匹配 qingfeng 的运行环境,当匹配当前运行环境为 qingfeng 时并且引入方式为 ESM 加载时
    // 该包运行 ./hello.js
    "qingfeng": {
      "import": "./hello.js"
    }
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

// hello.js
const qingfeng = 'wanghaoyu'

export default qingfeng
// 项目入口文件 (注意是引入 qingfeng 这个包的项目)
import qingfeng from 'qingfeng';

console.log(qingfeng, 'qingfeng');

其实答案也非常简单,在运行 NodeJs 脚本时可以通过 --conditions标志添加自定义用户条件。

比如此时我们通过

node --conditions=hello src/index.js

来运行项目入口文件,随后 terminal 中打印出 wanghaoyu

这样也就达成了我们自定义的 qingfeng 运行环境。

频繁业务迭代下的 Npm 包版本应该如何管理

关于 Npm Version 相关的信息,不太清楚的同学可以查阅春哥的这篇semver:语义版本号标准 + npm的版本控制器

这个章节其实主要想和大家交流一些关于频繁业务迭代下 Npm 包版本仅可能语义化自己的看法。

设想目前的业务场景下,多人在同步开发一款 NPM 包。换句话说,假设我们有一款 NPM 包 latest 版本为 1.0.0。

此时,产品需要迭代需求 A,此时小王同学进行负责本次需求的迭代。开发完毕后发布 1.0.0-alpha.0 进行测试。

同时,产品又需要同步迭代需求 B,同一个项目也许有多个需求在同步迭代(我相信这在日常业务中绝不少见)。

OK,此时小张同学同时基于 latest 版本的包进行开发新功能,开发完毕后发布 1.0.0-alpha.1 。

此时,由于两位同学发布的 alpha 版本的包存在的一些 Bug ,所以就会造成以下现象:

  • 1.0.0-alpha.0: A 功能相关。
  • 1.0.0-alpha.1: B 功能相关。
  • 1.0.0-alpha.2: A 功能相关。
  • 1.0.0-alpha.3: B 功能相关。
  • ...

最终,我们在 Npm 上的版本号虽然是遵从了 semver 规范,但是通过频繁迭代的版本号完全无法关联相应版本的单一功能。

当然规避这个问题最佳途径是通过合理的产品规划进度以及相关关联版本生成 CHANGELOG 从而进行固定周期的包版本正常迭代。

但是在频繁业务迭代的背景下,这个也许对于团队来说是一种趋于理想化的状态。

那么面对上述这种情况,我们希望尽可能的在发布 NPM 包时,对于相应每个独立的功能可以拥有单独的版本迭代

为了解决上述的问题,并且达到相对独立的需求。我们设计了这样一种思路:

在我们进行开发一个新的独立功能时,往往都是会基于一个新的 feat/xxx-x 的独立分支来进行开发。

比如,此时我需要为我的 NPM 包新增一个多账号相关需求,那么我会基于现有的稳定分支派生出一个名为 feat/multi-account 的分支。

只要分支命名合理的话,通过分支名我们可以对于该分支实现的迭代功能一目了然对吧。

那么,我们能否将分支名和对应的 NPM 包版本进行关联呢,如果将分支和 NPM 包版本进行了关联,其实就很容易实现我们上述的需求:确定单一功能迭代下的包版本号语义化。

简单来说,它的步骤是这样的:

  • 当开发人员接到该包需要添加一个新的需求时,建立分支 `feat/multi-account 分支。

  • 开发人员在功能分支完成开发后,首次发布本次功能相关 NPM 包提交测试。

    • 发布时,首先会根据脚本读取当前分支名称:feat/multi-account

    • 将读取到的分支名 feat/multi-account 替换为 multi.account

    • 去远程 NPM 地址查找是否已经存在该包关联的 dist-tag,假设发布的包名为 vue。那么就相当于执行 npm view [email protected] version

      • 如果存在该 tag 相关版本,那么即表示已经进行过发布该功能相关的 tag 版本,此时仅仅需要根据远程版本号进行叠加即可。比如远程为 1.0.0-multi.account.0 此时本地即会生成 1.0.0-multi.account.1

      • 如果不存在该 tag 相关版本,那么表示该分支相关的功能是首次进行发布。那么首先会拉取远程最新的 latest 稳定版版本(假如稳定版为 1.0.0),之后根据稳定版版本会新建相关 dist-tag 进行发布,相当于会发布 1.0.0-multi.account.0

关于 Npm dist-tag 的相关内容,不太了解的同学可以查阅这里

本质上 dist-tag 你可以将它理解成为 git tag 类似,通常我们来用它来组织和标记和正式版不同版本的包。比如 vue 中

image.png

可以看到 vue 中除了 latest 正式版本,同样也存在 beta、legacy、csp 等等自定义的 dist-tag 相关版本包。

我们回归正文,通过上述的步骤其实已经可以将相关版本迭代和 dist-tag 进行强关联了。

比如我们开发的多账号功能,关于多账号功能的迭代版本:

1.0.0-multi.account.0
1.0.0-multi.account.1
1.0.0-multi.account.2
// ...

同时,如果有别的同学在同步开发一款收集用户行为的需求的话,其实也可以做到完全独立的语义化版本:

1.0.0-collect.user.0
1.0.0-collect.user.1
1.0.0-collect.user.2
// ...

上述随着业务迭代需求的增加的话不可避免的会带来存在很多个 tag 的包。这个问题其实解决起来也比较简单。

无论是我们通过 CI 关联分支删除即调用命令删除远程 tag 还是手动的方式,只要在相关 tag 测试稳定后,合并进入正式代码时删除掉对应的 dist-tag 即可解决 tag 数繁多的问题。

当然,本质上通过合理的产品迭代流程和计划完全是不存在上述的问题。上边的思路也只是针对于频繁业务迭代背景下的一个临时 Hack 方案。

这个方案中也许仍存在不少值得深思的优化点,也欢迎大家在评论区互相交流各自的看法。

写在结尾

文章的内容到这里就要画上句话了,感谢每一位可以看到结尾的小伙伴。

希望大家可以从文章中的内容有所收获,当然也欢迎每一位小伙伴在评论区留下自己的见解我们互相讨论。

使用 Webpack 验证一下 Vue 上边的内容!!!!!!!

猜你喜欢

转载自juejin.im/post/7126394898445500423