编程开源技术交流,分享技术与知识

网站首页 > 开源技术 正文

大曝光!大佬发布一篇《为什么这样写—classnames》引起业内热议

wxchong 2024-07-07 00:12:14 开源技术 11 ℃ 0 评论

简介

classnames 能帮助我们快速且灵活的构建出一个className字符串。

其实同类的库还有 Classcat,clsx

这篇文章主要看看这个简单的库中,有多少能挖掘的细节。

核心逻辑

利用 arguments 的特性——所有参数存放在一个类数组里,循环遍历每个参数。

不同的参数类型对应不同的动作。

  • 数字/字符串 ? 保留 truthy 的值
  • 数组 ? 再次调用 classnames() 做递归
  • 对象 ? 保留所有值为 truthy 的值,忽略其余

上面保留下来的值都会存入一个数组中。

最后会把这个数组转换成字符串作为最终结果返回。


一些问题

为什么不能 if (module && module.exports)

因为这样会引用报错

需要这样写

if (typeof module !== 'undefined' && module.exports) {
	...
}

为什么要检查hasOwnProperty

检查一个对象中的属性是不是他自己的,而不是他继承来的,很容易理解。

例如有人在 Object.prototype 上挂载了自己的属性

但为什么要从别处引用 hasOwnProperty ?

var hasOwn = {}.hasOwnProperty;

...

if (hasOwn.call(arg, key) && arg[key]) {
		classes.push(key);
}

因为这个对象有可能没有 hasOwnProperty

Why use Object.prototype.hasOwnProperty.call(myObj, prop) instead of myObj.hasOwnProperty(prop)?

为什么支持数字?

很奇怪的功能,毕竟没人会把数字当作class名。也确实有人提了一个issue询问此事。

最初版本中确实是不支持数字的,直到这个issue。

这里就说明了为什么加上对数字的支持,仅仅因为readme中写的是"falsy keys won't be included"。

但类似数字 1 这种非 falsy 值却会被忽略。加入对数字的支持就是为了解决这个问题。

再说加上这个支持也不难,也没有额外的副作用,就一直留着了。

但现在有个问题是:真的不能拿数字当作class名么?

答案是肯定的。

首先在 html 中,是可以给class一个数字的,不会报错。

在 CSS 中虽然允许类名中存在数字,但不允许以数字开头。

必须使用转译符 \ 。

最终代码这样写


.\36\36\36 {
	color: red;
}

为什么要返回undefined?

为什么不接受这个PR

考虑下面一种情况

const classname = classnames({'foo': false})

return (
	<div className={classname}>Hello,world</div>
)

结果为

<div class>Hello,world</div>

class 属性依然存在,因为这时 classnames 返回的是 ''

而如果返回的是 undefined ,则结果会变成。

<div>Hello,world</div>

这样的 html 没有了多余的属性,看起来更加简洁了

那要不要提个 PR 改进一下呢?只要在最终返回结果的时候过滤一下就行了,很简单。

但真的要这样改么?

作者最终给出的回复是:不要改。主要原因在于:要尽量避免预期外的行为

而对于classnames这样一个已经被大量使用的库,稳定性和安全性是优先考虑的。这样一个 breaking change 显然是不可接受的。

classnames() 会从始终返回一个字符串,变成可能返回一个字符串。

如果真的出现 undefined 就将它强制转换为字符串,也就是 'undefined' ,是一个更好的方式

如果真的不想要一个空字符串,也可以自己把 classnames 包裹一下


const cx = args => classnames(...args) || undefined;

为什么要检查 toString()

为什么接受这个PR

Ability to handle object with own .toString() method by resetko · Pull Request #170 · JedWatson/classnames

在最新版本的 classnames 中的我们可以看见这样的代码

if (argType === 'object') {

		// 这一层判断是做什么的?
		if (arg.toString === Object.prototype.toString) {
				for (var key in arg) {
						if (hasOwn.call(arg, key) && arg[key]) {
							classes.push(key);
						}
				}
		} else {
					classes.push(arg.toString());
		}
}

显然这里的意思是:如果传入的对象身上有一个自定义的 toString() 则自动调用 toString()

但为什么要这样写?有什么用?

想象这样一个场景:我在使用一个可以生成类名的库,类似于这样

class MyClassName {
    constructor(mainName) {
      this.name = mainName;
    }

    el(element) {
      this.name = this.name + '_' + element;
      return this
    }

    toString(){
      return this.name;
    }
  }

const className = new MyClassName('hbc');

return (
    <button className={className.el('btn')}>
      hello,world
    </button>
);

这样的代码结果是

<button class="hbc_btn">hello,world</button>

我并没有显式的调用 toString() ,但我却拿到了预期的结果。

那是因为 React 在这里做了一次隐式转换

// `setAttribute` with objects becomes only `[object]` in IE8/9,
// ('' + value) makes it output the correct toString()-value
attributeValue = '' + value;

JS 会自动调用 toString() 以求得到一个字符串的结果

所以在React中如果传一个对象到 className 会得到什么?

const obj = {}

return (
	<div className={obj}>Hello,world</div>
)

得到

<div class="[object Object]">Hello,world</div>

不会报错

但最初的 classnames 并没有考虑这一点。如果使用 classnames 的话,必须显式的调用 toString() 。

很多的 UI 库都依赖于 classnames ,比如 ant-design、Semantic UI...

所以,当你期望像使用 React 原生组件那样,使用这类 UI 库组件的时候,你就得不到想要的结果。

className with object containing toString() method isn't working as expected · Issue #2599 · Semantic-Org/Semantic-UI-React

所以,这样的改变能让各类使用了 classnames 的UI库,让其组件的行为能和 React 原生组件保持一致。


为什么需要dedupe.js

为什么接受这个PR


Allow replacing of previous class names · Issue #18 · JedWatson/classnames

想象这样一个场景

classNames('foo', 
	{ 
		foo: false,
		bar: true 
	}
)

输出的结果会是 'foo bar' ,对象中的 foo: false 没有覆盖掉前面的 'foo' 。

想要实现这个功能,必须采用新的思路,但新思路的运行速度没法像原来一样快。最初版本的 dedupe.js 比原始函数慢10倍

而这种性能上的差异对于 classnames 来说是不可接受的。

我们的工具函数需要一个新功能,但想实现新功能需要重构这个函数,而且还会降低性能。

我们又不想影响原本的函数,该如何是好?

解决方法其实很简单,写一个新的函数就行了。

classnames 加入了一个新的导出选项,如果有去重需要的使用者,自行导出 dedupe.js 就行了。这样就不会影响原始函数的性能表现,还能给新用户提供去重功能。


import classNames from 'classnames/dedupe';

性能优化

性能优化主要集中在 dedupe.js 中,毕竟这个函数是最慢的

最初的 dedupe.js 是这样的

function () {
	'use strict';

	function _parseArray (resultSet, array) {
		var length = array.length;

		for (var i = 0; i < length; ++i) {
			_parse(resultSet, array[i]);
		}
	}

	function _parseObject (resultSet, object) {
		for (var k in object) {
			if (object.hasOwnProperty(k)) {
				if (object[k]) {
					resultSet[k] = true;
				} else {
					delete resultSet[k]
				}
			}
		}
	}

	function _parseNumber (resultSet, num) {
		resultSet[num] = true;
	}

	var SPACE = /\s+/;
	function _parseString (resultSet, str) {
		var array = str.split(SPACE);
		var length = array.length;

		for (var i = 0; i < length; ++i) {
			resultSet[array[i]] = true;
		}
	}
const argType = typeof arg

	function _parse (resultSet, arg) {
		// 'foo bar'
		if ('string' === argType) {
			_parseString(resultSet, arg)

		// ['foo', 'bar', ...]
		} else if (Array.isArray(arg)) {
			_parseArray(resultSet, arg)

		// { 'foo': true, ... }
		} else if ('object' === typeof arg) {
			_parseObject(resultSet, arg)

		// '130'
		} else if ('number' === typeof arg) {
			_parseNumber(resultSet, arg)
		}
	}

	function _classNames () {
		function add () {}
		add.prototype = Object.create(null);
		const obj = new add();
		var classes = '';
		var argLength = arguments.length;

		for (var i = 0; i < argLength; ++i) {
			_parse(classSet, arguments[i]);
		}

		for (var k in classSet) {
			if (classSet.hasOwnProperty(k) && classSet[k]) {
				classes += ' ' + k;
			}
		}

		return classes.substr(1);
	}

	return _classNames;
}

整体思路和现在一致,都是用一个对象当作缓存,再遍历每个参数,值为 truthy 则对应的键设置为 true

  • 删除值为 falsy 的键
if ('object' === typeof arg) {
	for (var k in arg) {
		if (arg.hasOwnProperty(k)) {
			result[k] = arg[k];
		} else {
			
			// 删掉不需要的值,这样在最后遍历缓存对象的时候就不用判断了
			// 遍历次数也能少一些
			delete result[k];
		}
	}
}

但对一个对象做删除操作会降低这个对象的性能

hidden class

在JS引擎中,引擎使用了hidden class优化对象,使其能快速的访问属性

JavaScript 引擎基础:Shapes 和 Inline Caches

在 JS 中,不同的对象可能拥有相同的形状


const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };

!s3-us-west-2.amazonaws.com/secure.noti…

如果为每个对象都完整的储存他们的属性名和属性值,那无疑是浪费空间的。

为此,引擎将对象的形状单独储存

!s3-us-west-2.amazonaws.com/secure.noti…

shape 包含除 [[Value]] 之外的所有属性名和其余特性。用对象值的偏移量 Offest 代替 [[Value]] 。这样引擎就知道去哪里查找具体值了。

每个具有相同形状的对象都指向这个 Shape 事例。每个对象只需要存储对这个对象来说唯一的那些值。

!s3-us-west-2.amazonaws.com/secure.noti…

当我们为对象添加新的属性时

引擎会生成一个新的 Shape ,而对象也会转而指向这个新的 Shape

!s3-us-west-2.amazonaws.com/secure.noti…

每个 Shape 都与之前的 Shape 相连,这样,新的 Shape 就只需要保存新的属性值即可。旧属性可以沿着 Shape 链往回找就行了。

delete 操作可能会导致引擎改变对象的结构,降级为哈希表储存方式。应尽量避免使用,就算没有改变对象结构,也会让引擎调用 check 方法,检查是否应该将对象结构降级。同样会影响性能。

Slow delete of object properties in JS in V8

V8 是怎么跑起来的 -- V8 中的对象表示

所以现在我们知道不应该使用 delete 了。所以目前最新的版本是这样的。


function _parseObject (resultSet, object) {
	for (var k in object) {
		if (hasOwn.call(object, k)) {
			resultSet[k] = !!object[k];
		}
	}
}

这里其实就是一次 parseArray()

var argLength = arguments.length;

for (var i = 0; i < argLength; ++i) {
	_parse(classSet, arguments[i]);
}

所以改为

_parseArray(classSet, arguments);

用字符串拼接还是数组?

我们最终返回的其实就是一个字符串,完全可以用字符串拼接代替数组的操作

use [].join over concatenation by dcousens · Pull Request #50 · JedWatson/classnames

这个PR中,作者做了性能上的测试,结果是:

两者各有优劣

作者拿不准主意,又找来 @jdalton ,他也拿不准主意,于是又找来一个大佬 @mraleph。

经过一番讨论,认为用 join() 和字符串拼接在性能上的差别取决于你的应用场景

两者的差别其实不大,但使用 join() 更符合直觉,可读性上也更好一些。

于是最终决定还是使用 join()


(其实在最开始的版本中就是用的 join 因为这确实更符合程序猿的习惯)

  • 缓存 argType
  • 更安全的 hasOwnProperty 检查
  • 参数泄漏
  • 不要将 arguments 直接传到另一个函数中,这会导致参数泄漏,让JS引擎放弃对函数的优化。
  • 所以需要自行构建一个数组出来

  • var len = arguments.length;
    var args = Array(len);
    for (var i = 0; i < len; i++) {
    	args[i] = arguments[i];
    }
    
    var classSet = {};
    _parseArray(classSet, args);

    跳过 hasOwnProperty 检查

    hasOwnProperty 无非就是检查对象的上的属性到底是自己的还是,继承来的。那如果我们在开始创建对象的时候就不继承任何东西,那也就不用再做检查了

    Is creating JS object with Object.create(null) the same as {}?


    var classSet = Object.create(null);

    这里还可以继续优化

    要注意的是,classnames 是一个递归函数,所以使用 new 创建一个继承自 Object.create(null) 的实例,要比一遍又一遍的调用 Object.create(null)

    最后

    最近的 classnames 已经很少更新了,让人怀疑这个仓库是不是已经 "死" 了

    Is this project dead? · Issue #220 · JedWatson/classnames

    事实是,确实已经有后起之秀了,比如 classcat , clsx ,其中 clsx 的速度比 classnames 快差不多三倍,体积也更小。

    那现在如果你是 antd 的作者,你可以一键将 antd 中所有依赖的 classnames 换成 clsx ,你换么?


    为什么这样写——classnames
    原文

    链接:https://juejin.cn/post/7276716177120280637


    Tags:

    本文暂时没有评论,来添加一个吧(●'◡'●)

    欢迎 发表评论:

    最近发表
    标签列表