- 前端
- UI组件
【免费下载链接】classnames
A simple javascript utility for conditionally joining classNames together
导读
本文以 HISTORY.md(classnames 官方 Changelog)为骨架,逐版本剖析这个"简单但被亿万次执行"的 JavaScript 工具在类型检测、性能优化、模块系统、TypeScript 类型、去重(dedupe)与绑定(bind)变体等维度的演进脉络。读完本文,你将理解classNames('foo', { bar: true }, ['baz'])背后每一行代码的由来,掌握classnames、classnames/dedupe、classnames/bind三个入口的适用场景与实现差异,并能通过仓库中的 tests/、benchmarks/ 与 package.json 复现各版本的验证与基准手段。
classnames 的核心哲学是"按条件拼接 className":字符串参数直接保留,对象参数只输出值为真值的键,数组参数递归展开,其余假值全部忽略(详见 README.md)。Changelog 记录的正是这条主线的每次加固与扩展。
版本演进总览
下表汇总了 HISTORY.md 记录的 v2.5.1(当前版本,见 package.json 的version字段)及之前的全部正式版本:
| 版本 | 发布日期 | 核心变更 |
|---|---|---|
| v2.5.1 | 2023-12-29 | 移除 package 中的workspaces字段 |
| v2.5.0 | 2023-12-27 | 恢复向函数传入 TypeScriptinterface的能力;新增exports字段 |
| v2.4.0 | 2023-12-26 | 主函数改用字符串拼接(string concatenation)提升性能 |
| v2.3.3 | 2023-12-21 | 修复默认导出;修复只读数组的类型定义;README 示例改为函数式组件 |
| v2.3.2 | 2022-09-13 | 修复require用法下的 TypeScript 类型;修复 vm 环境中toString显示为[Object object]的问题 |
| v2.3.1 | 2021-04-03 | 修复 bind/dedupe 的 TypeScript 类型导出;修复 Mapping 值类型;移除类型中不存在的具名导出 |
| v2.3.0 | 2021-04-01 | 首次加入 TypeScript 类型;对参数中的自定义.toString()方法提供一致支持 |
| v2.2.6 | 2018-06-08 | 修复 ES module 环境下的兼容性问题 |
| v2.2.5 | 2016-05-02 | 进一步优化dedupe变体性能 |
| v2.2.4 | 2016-04-25 | dedupe变体性能约提升 2 倍 |
| v2.2.3 | 2016-01-05 | bind变体改用[].join(' '),与 2.2.2 主脚本保持一致 |
| v2.2.2 | 2016-01-04 | 主函数从字符串拼接切换到[].join(' '),获得小幅性能提升 |
| v2.2.1 | 2015-11-26 | AMD 模块增加 deps 参数,修复 Dojo loader 下的问题 |
| v2.2.0 | 2015-10-18 | 新增bind变体,用于 css-modules 等抽象 |
| v2.1.5 | 2015-09-30 | 回退上次发布中dedupe.js误用的Object.keys |
| v2.1.4 | 2015-09-30 | 新增基准用例;更安全的hasOwnProperty检查;AMD 模块具名化 |
| v2.1.3 | 2015-07-02 | UMD 包装同时支持 AMD 与 CommonJS |
| v2.1.2 | 2015-05-28 | 添加正式的 UMD 包装 |
| v2.1.1 | 2015-05-06 | 通过类型缓存获得小幅性能提升;改进基准与结果输出 |
| v2.1.0 | 2015-05-05 | 新增dedupe版本:更慢(约 10x)但能保证"后出现的假值覆盖此前类名" |
| v2.0.0 | 2015-05-03 | 切换到Array.isArray类型检测(现代浏览器更快);IE8 需引入 polyfill |
| v1.2.2 | 2015-04-28 | 更新 license 注释以简化部分构建场景 |
| v1.2.1 | 2015-04-22 | 为 requireJS 增加安全导出;澄清 Bower 用法 |
| v1.2.0 | 2015-03-17 | 全面支持数组参数,包括嵌套数组 |
在此之前的历史变更,HISTORY.md 明确指向 git history 查阅。
参数处理能力的奠基与增强
v1.2.0:数组参数的全面支持
v1.2.0(2015-03-17)为 classnames 带来了"全面的数组参数支持,包括嵌套数组"。这一能力至今仍体现在 index.js 的parseValue中:
if (Array.isArray(arg)) { return classNames.apply(null, arg); }数组通过递归调用classNames自身展开,因此天然支持任意深度嵌套,且与字符串、对象参数无缝混用。仓库 tests/index.js 用一组用例锁定了这一契约:
classNames(['a', 'b'])→'a b'classNames('c', ['a', 'b'])→'c a b'classNames(['a', ['b', 'c']])→'a b c'classNames(['a', ['b', ['c', {d: true}]]])→'a b c d'(深层递归)classNames('a', [])→'a'(空数组安全)
v2.0.0:改用Array.isArray类型检测
v2.0.0(2015-05-03)是一次面向现代浏览器的性能优化:类型检测从"字符串形态的判断"切换到Array.isArray,在现代浏览器中明显更快;代价是 IE8 及以下不再原生支持,需要按 README 指引引入 polyfill。
这一决策直接决定了当前源码中parseValue的分支顺序(index.js):
function parseValue (arg) { if (typeof arg === 'string') return arg; if (typeof arg !== 'object') return ''; if (Array.isArray(arg)) return classNames.apply(null, arg); // ...自定义 toString 检查与对象键遍历 }先判断typeof、再用Array.isArray,把最便宜的分支放在最前面,是"性能优先"哲学在代码层面的直观体现。
v2.2.2 / v2.2.3 / v2.4.0:拼接方式的两次反转
性能优化在 Changelog 中反复出现,且方向有过一次反转:
- v2.2.2(2016-01-04):主函数从字符串拼接切换到
[].join(' '),换取小幅性能提升; - v2.2.3(2016-01-05):
bind变体跟进,也改用[].join(' ')保持与主脚本一致; - v2.4.0(2023-12-26):又改回字符串拼接(string concatenation)以进一步提升性能。
当前 index.js 正是 v2.4.0 的产物——用一个classes变量累积,配合appendClass以空格分隔追加:
export default function classNames () { let classes = ''; for (let i = 0; i < arguments.length; i++) { const arg = arguments[i]; if (arg) { classes = appendClass(classes, parseValue(arg)); } } return classes; } function appendClass (value, newClass) { if (!newClass) return value; return value ? (value + ' ' + newClass) : newClass; }注意外层if (arg)会先拦截所有假值参数(null、undefined、false、0、''、NaN),这是"假值忽略"语义的第一道闸门;对象内部的假值键则交由parseValue中的if (arg[key])过滤。仓库 benchmarks/benchmarks.js 中定义了 strings、object、strings+object、mix、arrays 五组基准场景,可通过npm run bench(执行node ./benchmarks/run.js)在本地对比当前实现与 npm 已发布版本(classnames-npm)的性能差异。
dedupe 变体:用约 2 倍性能换来的去重语义
v2.1.0:dedupe 的诞生
v2.1.0(2015-05-05)引入了备选的dedupe版本,Changelog 明确记录其代价:较慢(约 10x),但保证"如果一个类被加入、随后在后续参数中被一个假值覆盖,则它不会出现在结果中"。README 中给出的示例为:
const classNames = require('classnames/dedupe'); classNames('foo', 'foo', 'bar'); // => 'foo bar'(去重) classNames('foo', { foo: false, bar: true }); // => 'bar'(假值覆盖)这与主版本的行为形成鲜明对比:主版本只拼接不查重,classNames('foo', 'foo')会输出'foo foo'。
v2.2.4 / v2.2.5:性能的两次加速
dedupe 问世后性能被持续优化:
- v2.2.4(2016-04-25):性能提升约 2 倍;
- v2.2.5(2016-05-02):在此基础上进一步提升。
README 对 dedupe 的定位描述也从"约 10x 慢"更新为"约 5x 慢",并将其作为 opt-in 方案提供。这说明性能是该项目持续投入的方向,但去重语义本身从未妥协。
v2.1.5:一次针对正确性的回退
v2.1.5(2015-09-30)回退了上一版本中dedupe.js误用的Object.keys用法——这说明去重实现里对象键的枚举路径非常敏感,需要小心维护。
当前实现:StorageObject 去重方案
从当前 dedupe.js 源码看,去重的核心是不继承 Object 原型的StorageObject:
// Don't inherit from Object so we can skip hasOwnProperty check later. function StorageObject () {} StorageObject.prototype = Object.create(null);这样遍历classSet时不需要hasOwnProperty守卫。其流程分三段:
appendArray递归处理所有参数;appendString用str.split(/\s+/)把每个字符串参数拆成单词,逐个classSet[word] = true(这是去重的根本机制——同名类只是重复赋值为true);appendObject对对象参数按hasOwn检查后写入classSet[k] = !!object[k],且注释说明故意置为 false 而非删除键,以避免改变对象结构带来的性能损耗。
最后统一遍历classSet,只输出值为真的键。正因为每次写入都是"覆盖"而非"追加",才实现了"后续假值抹掉此前类名"的语义。仓库 tests/dedupe.js 中的用例精确覆盖了这一行为:
dedupe('foo foo', 0, null, undefined, false, 'b', { 'foo': false }); // => 'b' dedupe('foo', 'foobar', 'bar', { foo: false }); // => 'foobar bar' dedupe('foo', '-moz-foo-bar', 'bar', { foo: false }); // => '-moz-foo-bar bar'第三行用例尤其值得注意:它验证了去重按单词边界拆分(foo不会误伤foobar、-moz-foo-bar),这是SPACE = /\s+/拆分方案的意义所在。
bind 变体:为 css-modules 设计的映射绑定
v2.2.0:bind 的引入
v2.2.0(2015-10-18)新增bind变体,用于 css-modules 及类似"将抽象类名映射到真实输出 className"的场景。README 中的用法是:
const classNames = require('classnames/bind'); const styles = { foo: 'abc', bar: 'def', baz: 'xyz' }; const cx = classNames.bind(styles); cx('foo', ['bar'], { baz: true }); // => 'abc def xyz'实现机制:借助 Function.prototype.bind 的 this 传递
当前 bind.js 的实现比 README 示例更完整——它不仅支持字符串参数映射,也支持对象参数映射:
function parseValue (arg) { if (typeof arg === 'string') { return this && this[arg] || arg; // 字符串键查表 } // ... if (Array.isArray(arg)) { return classNames.apply(this, arg); // 递归时透传 this } // ... for (const key in arg) { if (hasOwn.call(arg, key) && arg[key]) { classes = appendClass(classes, this && this[key] || key); // 对象键查表 } } }要点有二:
this透传:数组递归分支使用classNames.apply(this, arg)而非主版本的apply(null, arg),保证深层嵌套数组仍能命中映射表;- 键未命中时回退为原键:
this && this[key] || key表示映射表中不存在的类名直接原样输出——对应 tests/bind.js 中的用例classNamesBound({ x: true, ... })输出中包含未映射的x z。
bind.d.ts 把绑定表类型定义为Record<string, string>,并将函数签名标注为classNames(this: Binding | undefined, ...args: ArgumentArray): string,从类型层面说明该变体依赖this上下文。
模块体系演进:UMD → AMD → ES Module
模块支持是早期版本迭代的重点:
- v1.2.1(2015-04-22):为 requireJS 增加安全导出,并澄清 Bower 用法;
- v1.2.2(2015-04-28):更新 license 注释以简化某些构建场景;
- v2.1.2(2015-05-28):添加正式的 UMD 包装(Universal Module Definition),使同一份代码可同时作为全局变量、CommonJS 与 AMD 模块使用;
- v2.1.3(2015-07-02):UMD 包装升级,在同一个包里同时支持 AMD 与 CommonJS;
- v2.1.4(2015-09-30):AMD 模块具名化,允许这样调用:
define(["classnames"], function (classNames) { var style = classNames("foo", "bar"); // ... });- v2.2.1(2015-11-26):AMD 模块增加 deps 参数,修复了 Dojo loader 下的加载问题;
- v2.2.6(2018-06-08):修复在 ES module 环境中的兼容性问题。
如今仓库已完全采用 ESM 风格:package.json声明"type": "module",源码使用export default function classNames()(index.js),并同时保留main: "./index.js"、types: "./index.d.ts"与exports条件导出。README 同时支持 Node.js、Browserify、webpack 及<script>全局变量、RequireJS 等多种消费方式。
TypeScript 类型与自定义 toString:2021 年的能力跃迁
v2.3.0:类型与 toString 双里程碑
v2.3.0(2021-04-01)同时完成两件事:
- 首次引入 TypeScript 类型。当前 index.d.ts 定义了完整的类型体系:
export type Value = string | boolean | undefined | null; export type Mapping = Record<string, any>; export interface ArgumentArray extends Array<Argument> {} export interface ReadonlyArgumentArray extends ReadonlyArray<Argument> {} export type Argument = Value | Mapping | ArgumentArray | ReadonlyArgumentArray; export default function classNames(...args: ArgumentArray): string;Value表示被直接忽略的假值标量,Mapping表示Record<string, any>形态的对象参数,Argument递归地组合了值、映射与(只读)数组。仓库通过 tsd(devDependencies 中的tsd: ^0.31.2)做类型断言测试,对应tests/index.test-d.ts、tests/bind.test-d.ts、tests/dedupe.test-d.ts三个类型测试文件,可用npm run check-types运行。
- 为自定义
.toString()方法提供一致支持。这条语义在 index.js 中体现为:
if (arg.toString !== Object.prototype.toString && !arg.toString.toString().includes('[native code]')) { return arg.toString(); }即:对象若覆盖了toString且不是原生实现,则直接采用其toString()返回值作为类名,不再遍历键。tests/index.js 与 tests/bind.js 均有用例验证"自有 toString"与"继承的 toString"(如class Class2 extends Class1 {}的实例)两种路径。
v2.3.1 / v2.3.2 / v2.3.3:类型与边界的持续修补
- v2.3.1(2021-04-03):修复 bind/dedupe 的 TypeScript 类型导出;修复
Mapping的 Value 类型;移除类型中不存在的具名导出(避免类型与实际导出不一致); - v2.3.2(2022-09-13):修复
require用法下的 TypeScript 类型问题;修复vm 环境中toString显示为[Object object]的问题——即跨上下文对象(如node:vm创建的 Realm 中产生的对象)此前会被Object.prototype.toString误判,当前 tests/index.js 中有一段对应的 vm 回归测试:
const context = { classNames, output: undefined }; vm.createContext(context); const code = 'output = classNames({ a: true, b: true });'; vm.runInContext(code, context); assert.equal(context.output, 'a b');- v2.3.3(2021-12-21):修复默认导出;修复只读数组的类型支持(对应
index.d.ts中的ReadonlyArgumentArray);并将 README 示例从类组件改写为函数式组件。
现代打包与发布配置:exports 与 workspaces
- v2.5.0(2023-12-27):向 package.json 添加
exports字段,为"."、"./index.js"、"./bind"、"./bind.js"、"./dedupe"、"./dedupe.js"及"./package.json"分别提供types与default条件,是"exports 优先 + 双入口后缀兼容"的标准现代包配置;同时恢复向函数传入 TypeScriptinterface的能力——这与Mapping = Record<string, any>的宽松定义有关,any值允许interface形状的对象传入而不再报类型错误; - v2.5.1(2023-12-29):从 package 中移除
workspaces字段,简化仓库自身的 npm 工作区配置,避免对消费者安装造成干扰。
质量保障体系:测试、基准与类型检查
Changelog 中反复出现的"性能提升""新增基准用例"背后,是仓库完备的验证设施(可在本地运行复现):
| 命令 | 作用 | 对应文件 |
|---|---|---|
npm test | 运行node --test ./tests/*.js,执行三个运行时测试套件 | tests/index.js、tests/bind.js、tests/dedupe.js |
npm run check-types | 通过 tsd 运行类型断言测试 | tests/index.test-d.ts、tests/bind.test-d.ts、tests/dedupe.test-d.ts |
npm run bench | 运行node ./benchmarks/run.js,对比本地与 npm 发布版的基准 | benchmarks/benchmarks.js |
npm run bench-browser | 用 rollup 打包后经 http-server 在浏览器中跑基准 | benchmarks/runInBrowser.js |
benchmarks 通过classnames-local(指向本地源码)与classnames-npm(指向 npm 最新发布版)双基准对照,并在版本不一致时打印警告(benchmarks/benchmarks.js),确保每次变更都有可量化的性能回归检测——这正是 Changelog 中那些"2x""10x"等表述的数据来源。
结语:一条"稳定与性能优先"的演进主线
回顾 HISTORY.md 的全部条目,可以清晰看到 classnames 的演进遵循三条主线:
- 性能优先:从
Array.isArray检测(v2.0.0)到[].join(' ')(v2.2.2),再到回归字符串拼接(v2.4.0),每一次改动都以基准测试为依据; - 能力按需扩展:数组展开(v1.2.0)、dedupe 去重(v2.1.0)、bind 绑定(v2.2.0)、自定义 toString 支持(v2.3.0)、TypeScript 类型(v2.3.0 起),每一步都通过 tests/ 中的用例固化语义;
- 兼容性谨慎维护:UMD/AMD/ESM 的逐步完善(v2.1.2~v2.2.6)、vm 跨上下文修复(v2.3.2)、现代
exports配置(v2.5.0)与工作区精简(v2.5.1),始终在"追求现代标准"与"不破坏既有用户"之间寻找平衡。
对使用者而言,这份 Changelog 也是选型依据:默认版本追求极致性能,适合绝大多数拼接场景;dedupe变体适用于"后写覆盖先写"的去重需求;bind变体则是 css-modules 用户的专属入口。三者共用的参数模型与测试保障,使其成为值得反复研读的小而美开源范本。
- 前端
- UI组件
【免费下载链接】classnames
A simple javascript utility for conditionally joining classNames together
相关推荐
react-sketchapp 版本演进全解析:从 CHANGELOG 看 3.x 核心能力与源码实现
react sketchapp 版本演进全解析:从 CHANGELOG 看 3.x 核心能力与源码实现 导读 本文以 react sketchapp 仓库根目录
开发工具前端Xournal++ 版本演进全解析:从 CHANGELOG 看 1.0.14 到 1.3.7 的核心功能迭代与工程演进
Xournal++ 版本演进全解析:从 CHANGELOG 看 1.0.14 到 1.3.7 的核心功能迭代与工程演进 Xournal++(xournalpp)
桌面应用Cadence 版本演进全解析:从 CHANGELOG 看核心能力迭代与升级运维实践
Cadence 版本演进全解析:从 CHANGELOG 看核心能力迭代与升级运维实践 Cadence 是一个分布式、可扩展、持久且高可用的编排引擎,用于以可扩展
后端任务调度工作流自动化微服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考