just-last 版本演进全解:从 3.1.0 到 3.2.1 的模块规范与发布修复实战

原创2026-10-14 12:22:331,759 阅读
文章标签:开发工具

just-last 版本演进全解:从 3.1.0 到 3.2.1 的模块规范与发布修复实战

导读

just-last 是 just 零依赖工具库中用于“返回数组最后一个元素”的最小模块,其 CHANGELOG.md 记录了 3.1.0 → 3.2.1 四个版本的关键演变:从 package.json 的 exports 字段修复(#467、#483)、导出顺序调整(#488),到 Node 模块文件由 .js 重命名为 .cjs。本文将结合仓库内真实源码、类型声明、构建脚本与测试用例,逐条解读每条变更的动机与落地效果,帮助你理解一个“只做一件事”的 npm 微包是如何在 CJS/ESM 双格式与版本管理中逐步走向规范的——读完你既能掌握 just-last 的完整用法,也能看懂其发布历史的底层细节。

一、just-last 是什么:功能与用法

just-last 的实现极简且无任何运行时依赖。核心逻辑位于 index.cjs:

module.exports = last;

function last(arr) {
  if (!Array.isArray(arr)) {
    throw new Error('expected an array');
  }
  return arr[arr.length - 1];
}

关键行为(以 README.md 与源码注释为准):

输入 输出 说明
last([1, 2, 3, 4, 5]) 5 普通数值数组,取最后一个成员
last([{a: 1}, {b: 1}, {c: 1}]) {c: 1} 返回最后一个对象成员(引用)
last([true, false, [true, false]]) [true, false] 元素本身可以是数组
last([]) undefined 空数组按索引取值自然得到 undefined
last() / last(null) / last(undefined) / last({}) 抛错 非数组参数会抛出 Error('expected an array')

安装方式:

npm install just-last
yarn add just-last

用法上,Node 的 ESM 环境可 import last from 'just-last';CommonJS 环境则 const last = require('just-last')。注意一个细节:README 中列出的示例 last()、last([])、last(null)、last(undefined) 均显示返回 undefined,而 index.cjs 源码对非数组入参实际是抛错——这属于文档与源码的表述差异,运行时行为请以源码为准:只有数组(含空数组)会返回元素或 undefined,非数组一律抛错。

二、CHANGELOG 版本脉络总览

packages/array-last/CHANGELOG.md 全文按时间倒序记录了 4 条变更,可归纳为两大主题:package.json/exports 规范化与模块文件重命名:

版本 变更类型 变更内容 关联 Issue
3.1.0 Minor package.json 更新,修复模块解析问题 #467、#483
3.1.1 Patch 重排 exports,将 default 导出置于最后 #488
3.2.0 Minor Node 模块文件由 .js 重命名为 .cjs —
3.2.1 Patch 延续 .js → .cjs 重命名收尾(发布层面修复) —

整体节奏清晰:3.1.x 解决“包在 Node 里如何被正确解析”的问题,3.2.x 解决“Node 模块文件后缀与格式语义”的问题。下面逐条展开。

三、3.1.0:package.json 更新(#467、#483)

3.1.0 是一次 Minor 升级,变更为“package.json updates to fix #467 and #483”。虽然仓库无法直接查看这两个 issue 的正文,但从当前 package.json 的最终形态可以推断修复方向:它引入了 Node 官方推荐的 exports 字段,按条件区分模块格式:

{
  "name": "just-last",
  "version": "3.2.1",
  "type": "module",
  "exports": {
    ".": {
      "types": "./index.d.ts",
      "require": "./index.cjs",
      "import": "./index.mjs"
    },
    "./package.json": "./package.json"
  },
  "main": "index.cjs",
  "types": "index.d.ts"
}

该配置的语义:

  • "type": "module":声明本包内部 .js 文件按 ESM 解释(这也是 3.2.x 必须把 Node 入口改成 .cjs 的原因之一,见后文);
  • exports["."].types:为 TypeScript 与打包器提供类型入口 index.d.ts;
  • exports["."].require:CommonJS 场景解析到 index.cjs;
  • exports["."].import:ESM 场景解析到 index.mjs;
  • exports["./package.json"]:允许外部读取包的元数据而不被 exports 限制拦截;
  • 顶层 main / types:作为 exports 未覆盖环境(如旧版 Node)的回退。

从源码结构看,3.1.0 的“package.json updates”正是为双格式解析补齐了条件导出与类型声明,从依赖 main 单一入口走向 exports 精确分发。

四、3.1.1:exports 重排——default 置于最后(#488)

3.1.1 是 Patch 级修复,变更记录为“fix: reorder exports to set default last #488”。从变更说明与当前产物推断,该次修复调整了 exports(或 index.mjs 的导出列表)中条目的排列顺序,核心原则是将 default 导出放到最后。

在 Node 的条件导出机制里,exports 对象中的键按书写顺序参与匹配,顺序错误可能导致某类环境命中错误的分支。而在 index.mjs 中,模块同样采用了“具名绑定在前、default 置后”的规范写法:

var arrayLast = last;
export {arrayLast as default};

这种“default 放最后”的约定在混用具名导出与默认导出的模块里尤为常见,可避免解析器或打包器对默认导出的优先级产生歧义。3.1.1 以 Patch 形式修复,说明它不改变 API 行为,只修正模块分发层的解析顺序。

五、3.2.0 / 3.2.1:Node 模块由 .js 重命名为 .cjs

3.2.0(Minor)与 3.2.1(Patch)的变更记录都是“Rename node module .js -> .cjs”,这对应仓库中 CommonJS 入口文件名为 index.cjs(而非历史上惯用的 index.js)。

这次重命名的直接动因是 package.json 中的 "type": "module":在 type=module 的包中,.js 后缀会被 Node 强制按 ESM 解析,而 module.exports = last 的写法是 CommonJS 语法,二者冲突会导致 require('just-last') 直接报错。将 Node 入口改为 .cjs 后缀,Node 会无视 type 字段、明确按 CommonJS 解析,从而保证 require 与 import 两条路径都能正常工作。

当前仓库中三种格式的分工非常清晰:

  • index.cjs——CommonJS 主入口,手工维护,module.exports = last;
  • index.mjs——ESM 入口,由 Rollup 从 index.cjs 自动构建产出;
  • index.d.ts——类型声明,供 TS 与编辑器使用。

3.2.1 作为同主题的第二次发布(Patch),从版本语义看是 3.2.0 重命名发布的收尾修复,确保重命名后的包在新旧 Node 环境中均能被正确加载——这正是“重命名一个文件后缀”这种看似简单、实则需要跨版本验证的发布场景。

六、构建链路:index.cjs 如何变成 index.mjs

just-last 采用“手写 CJS、Rollup 转 ESM”的单向构建模式。构建入口为 rollup.config.js:

const createRollupConfig = require('../../config/createRollupConfig');

module.exports = createRollupConfig(__dirname);

真正配置位于 config/createRollupConfig.js,仓库内所有包共享:

const cjs = require('@rollup/plugin-commonjs');
const {resolve} = require('path');

module.exports = function createRollupConfig(path) {
  return {
    input: resolve(path, './index.cjs'),
    output: {
      file: resolve(path, './index.mjs'),
      format: 'esm',
    },
    plugins: [cjs()],
  };
};

即:以包目录下的 index.cjs 为输入,经 @rollup/plugin-commonjs 转换后输出为 index.mjs(ESM 格式)。在包内执行 npm run build(即 rollup -c)即可完成产物生成,仓库根目录则通过 package.json 中的 "build": "lerna run build --stream $@" 批量构建所有包。

这也解释了为何 index.cjs 与 index.mjs 的注释块高度一致——后者是前者经 Rollup 自动生成的,二者语义等同,只是模块格式不同。

七、类型声明与测试验证

类型声明:精确到字面量的重载

index.d.ts 通过两个重载在“空数组/动态数组”与“元组”场景下给出精确类型:

export default function last<T>(arr: readonly [...any, T]): T;
export default function last<T>(arr: T[]): T;
  • 第一个重载利用 readonly [...any, T] 元组解构,能对 as const 等静态元组提取最后一个元素的字面量类型(如 last([1,2,3,4,5] as const) 推导为 5);
  • 第二个重载作为兜底,对一般 T[] 数组返回 T;
  • 空数组 [] 因不满足“至少含一个元素”的元组约束,会回退到第二个重载,返回 undefined。

该类型行为在 index.tests.ts 中有完整验证:const test7: 5 = last([1, 2, 3, 4, 5] as const) 通过编译,而 last({})、last(undefined)、last(null)、last() 均以 @ts-expect-error 标注为“应当报错”。

运行测试:三种行为三组用例

运行时测试位于 test/array-last/index.cjs,基于 tape 断言框架(经 test/util/test.js 封装为带包名前缀的测试函数):

  1. 非空数组返回最后一个元素:覆盖数值数组、嵌套数组、对象数组、混合类型数组(['a', 1, true, /r/g]),断言 last(arr) === arr[arr.length - 1];
  2. 空数组返回 undefined:last([]) 期望 undefined;
  3. 非数组参数抛错:对 {}、undefined、null、无参调用四种情况断言 t.throws(...)。

仓库根目录通过 package.json 的 "test": "npm run lint && tape ./test/**/index.cjs | tap-spec && npm run test-types" 串联了 lint、运行时测试与 tsc 类型检查("test-types": "tsc"),可见每个微包的行为都被测试与类型双重约束。

八、版本管理机制:lerna independent + changesets

just-last 的版本演进并非手工维护,而是由仓库级工具链驱动。lerna.json 声明了独立版本模式:

{
  "lerna": "3.20.2",
  "version": "independent"
}

"version": "independent" 意味着每个包独立打版本,因此 just-last 可以单独走 3.1.0 → 3.1.1 → 3.2.0 → 3.2.1 的节奏,而不受其他工具包影响。变更记录(CHANGELOG)由 @changesets/cli(见根 package.json 的 dependencies)自动生成,其中 Minor/Patch 的分类即来自 changesets 的语义化版本约定:

  • Minor(3.1.0、3.2.0):新增或改变对外行为,如引入 exports 字段、重命名模块后缀;
  • Patch(3.1.1、3.2.1):不改变 API 的修复,如 exports 顺序重排、重命名后的发布收尾。

理解这套机制后回看 CHANGELOG,会发现每条记录背后都是“改动 → 语义化定级 → 自动生成变更说明 → 独立发布”的标准化流程。

九、总结与实践建议

综合源码与变更记录,可以得出 just-last 版本演进的完整逻辑链:

  1. 3.1.0 引入 exports 条件导出与类型入口,解决双格式解析(#467、#483);
  2. 3.1.1 修正导出顺序,将 default 置后,消除解析歧义(#488);
  3. 3.2.0 / 3.2.1 将 Node 入口从 index.js 重命名为 index.cjs,适配 "type": "module" 语义,确保 require 路径稳定可用。

对于其他 npm 微包的维护者,以下实践可直接借鉴:

  • 包声明了 "type": "module" 后,CommonJS 入口务必使用 .cjs 后缀,否则 require 会解析失败;
  • 使用 exports 条件导出(types / require / import)替代单一 main,并保持键顺序稳定,default 分支放在最后;
  • ESM 产物建议由 Rollup 从 CJS 源码单向生成,避免两份手写实现漂移(参照 config/createRollupConfig.js);
  • 每个行为变更都配套 tape 运行测试与 tsc 类型测试,并让 CHANGELOG 由 changesets 自动产出,保证发布历史可审计(参照 test/array-last/index.cjs 与 index.tests.ts)。

如果要在自己的项目中使用 just-last,只需 npm install just-last,随后 import last from 'just-last'(ESM)或 const last = require('just-last')(CJS),并牢记:只接受数组入参,空数组返回 undefined,非数组抛错。

登录后查看全文
just