前言
继上一篇 如何使用链式属性表达式取值和赋值理论篇完结后,我把这些真正应用到了我的小程序优化项目当中,但是感觉效果比预期的还差点,遂在实践过程中不断地优化,总结出了更优雅更健壮性能更好的方案。
我们来一起看一下。
性能优化版
该方法可以将一个链式属性表达式处理为一个字段数组。
/**
* 解析路径为字段数组
* @example
* // returns ['arr', '0', 'a', 'b']
* getPathFileds('arr[0].a.b');
* @param {string} path 解析路径
* @returns {string[]}
*/
export function getPathFileds(path: string): string[] {
// 取缓存解析过的路径
if (filedsCacheMap.has(path)) return filedsCacheMap.get(path);
const segments: string[] | number[] = path.split('.'); // 分割字段片段, 如 a[5].b[4][0].c
let fileds = segments; // 保存字段名
// 处理包含数组的情况,例如 a[5].b[4][0].c 路径,要把b[4][0]这样的格式处理成[b, 4, 0]
if (path.includes('[')) {
fileds = [];
let i = 0;
const len = segments.length;
while (i < len) {
const segment = segments[i];
if (segment.includes('[')) {
const arrFileds = segment.split(/[\[\]]/); // ["b", "4", "", "0", ""]
// for循环比push(...arrFileds)更快,而且加入判断非必要不push会更快
for (let i = 0, len = arrFileds.length; i < len; i++) {
if (arrFileds[i] !== '') fileds[fileds.length] = arrFileds[i]; // 比 fileds.push(arrFileds[i])更快
}
// fileds.push(...arrFileds); // push(...arr)比concat效率更高,push直接操作原数组,concat会创建新数组
} else { // 如果是被'.'分割完的字段直接push
fileds.push(segment);
}
i++;
}
}
filedsCacheMap.set(path, fileds); // 缓存解析过的路径
return fileds;
}
复制代码
原写法:
// 解析路径为字段数组
function parsePath(path: string) {
const segments: string[] = path.split('.'); // 分割字段片段
let fileds: Array<number | string> = []; // 保存字段名
if (path.includes('[')) { // 如果包含数组下标,收集数组索引 类似arr[0]这样的格式
for (const segment of segments) {
if (/^(\w+)(\[(\w+)\])+/.test(segment)) { // 匹配 类似 arr[0][1] 这种格式
const arrIndexs: number[] = [];
for (const item of segment.matchAll(/(\w*)\[(\w+)\]/g)) {
if (item[1]) fileds.push(item[1]); // 第一个匹配的括号,即数组字段名
arrIndexs.push(~~item[2]); // 第二个匹配的括号,即数组索引
}
fileds.push(...arrIndexs);
} else { // 如果是被'.'分割完的字段直接push
fileds.push(segment);
}
}
} else { // 无数组值时无需遍历,提高性能
fileds = segments;
}
return fileds;
}
复制代码
该版本做了如下优化:
-
while
替代for of
循环,while比for of更快。更多性能对比案例,可以看我的另一篇文章JS遍历13种循环方法性能大比拼:for/while/for in/for of/map/foreach...
-
/^(\w+)(\[(\w+)\])+/.test(segment)
,正则判断替换为includes
方法,相对于includes
,正则性能极慢。 -
matchAll
方法需要进行正则匹配,尤其是贪婪模式,性能消耗更大,此处使用split
方法直接将字段分为数组,省掉了一个循环和push操作。这里提醒一下,能用原生方法的情况尽量不要使用正则,即使你深谙正则优化之道,但百密也终有一疏
-
push(...arr)
替换为优化for循环内使用length
属性直接给数组赋值,首先...
扩展运算符实际上内部做的是遍历迭代器的操作,有一定性能消耗,另外直接使用数组下标赋值在多数场景下是比push更快的。 -
加入了缓存,已经处理过的路径直接返回上次结果。
综上,该方法经过一系列细节优化之后,除了带来了3-5倍的性能提升外,还让本人心情愉悦了一下午🐶,把一件事情做到极致的感觉真的很爽。
链式取值
自然,取值方法也进行了优化
/**
* 链式取值
*
* @export
* @param {object} target
* @param {string} path
* @returns {*}
*/
function getValByPath(target: object, path: string): any {
// 比 !(/[\\.\\[]/.test(path)) 性能高约15倍,比 !(path.includes('.') || path.includes('[')) 高约6倍
if (!path.includes('.') && !path.includes('[')) return target[path];
const fileds = getPathFileds(path);
// const val = fileds.reduce((pre, cur) => pre?.[cur], target);
// while 比 reduce快(2-3倍)
let i = 0;
let val = target;
const len = fileds.length;
while (i < len) {
val = val?.[fileds[i]];
i++;
}
return val;
}
复制代码
优化了几点:
-
正则判断链式属性改成了
includes
方法另外在性能测试时发现了一个有趣的现象,在判断不包含 ‘
.
’ 或者‘[
’时,不加括号竟然比加括号写法快了大约6倍,即!path.includes('.') && !path.includes('[')
远比!(path.includes('.') || path.includes('['))
这种写法要快,这里大家可以留意一下。 -
reduce 方法使用while循环代替,原因毫无疑问,
while
必然 比reduce
快,虽然代码行数多了一些,但是对于写工具框架的场景性能往往是更需要注重的点。感兴趣的同学可以去看下,循环方法性能对比。
链式更新值
/**
* 链式更新值
*
* @param {*} target
* @param {string} path
* @param {*} value
*/
export function updateValByPath(target: any, path: string, value: any): void {
// 替换正则写法
if (!path.includes('.') && !path.includes('[')) return target[path] = value;
const fileds = getPathFileds(path);
let i = 0;
const len = fileds.length;
// 替换reduce写法
while (i < len) {
const key = fileds[i];
let ref = target[key];
if (i + 1 === len) { // 当前键是被更新路径的最后一个字段, 如 'obj.a.b'中的b则直接赋值
target[key] = value;
} else { // 引用类型值
// 下一个字段的形式决定当前字段对应的数据类型,例如,arr[0],0决定了arr字段是数组类型,如果字段为纯数字则判定为数组(忽略对象键为数字的情况),此处key不会为''
const curKeyDataType = isNaN(Number(fileds[i + 1])) ? Object : Array;
// 如果路径值不存在,或者存在但是数据结构不一致,则创建对应空对象
// 使用 constructor 判断类型是因为它远比Object.prototype.toString.call()更快
if (!ref || (ref && ref.constructor !== curKeyDataType)) {
ref = curKeyDataType === Object ? {} : [];
// 不存在时可以继续创建了,但是依旧给出不易维护提示
warn(`updated field "${path}" does not exist in the data or datatype is inconsistent, may not be easy to maintain.`);
}
}
target = ref;
i++;
}
}
复制代码
除性能优化外,还对功能进行了增强,旧方法不能更新没有父对象的数据, 例如 updateValByPath({obj:{}, 'obj.a.b'})
, b属性没有父级对象,会静默失败,并给出提示,优化后的方法则支持在没有父对象时为其创建新的对象或数组,b属性可以成功赋值,obj对象成功添加b属性及其父对象a。
附上旧方法:
// 链式赋值
function updateValByPath(target: object, path: string, value: any): void {
if (!(/[\\.\\[]/.test(path))) return target[path] = value; // 如果没有 . 或 [ 符号说明非链式,直接赋值
const fileds = getPathFileds(path);
// cosnt obj = {a: {b: {c: 6}}};
// 获取值引用 ,例如更新obj对象的c值,需要获取{c: 6}对象的引用,即obj.a.d = {c: 6},拿到引用后 ref.c = 8,就 {c: 6} 更新成 {c: 8} 了
const ref = fileds.slice(0, -1).reduce((pre, cur) => pre[cur], target); // 只遍历到倒数第二个字段,因为这个字段就是被修改对象的引用
if (ref) return ref[`${fileds.at(-1)}`] = value; // 拿到引用后,更新最后一个字段
// 如果引用对象不存在,提醒开发者不要更新不存在的属性
console.warn(`updated property "${path}" is not registered in data, you will not be able to get the value synchronously through "this.data"`);
}
复制代码
另外注意,能使用 typeof 、 constructor 、instanceof 判断类型的场景就不要用Object.prototype.toString.call()
,尤其在长循环中,节省4倍左右的性能不香么。
总结
以上几点优化的地方,其实我们日常开发中很常见的,只是大家都没有那么关注,确实我们在写业务需求时往往不会涉及会造成很大性能差异的场景,也没有太大必要死磕这些性能开销问题,对于不是那么复杂的项目来说,得到的收益微乎其微,但是这其实不是重点,重点是你的开发习惯和思想,你是否会在某些场景下思考性能问题,而不是只是实现就行。
至少你需要考虑首屏代码是应该性能优先的吧,例如首屏被加载的逻辑包含大量循环,本来可以用优化for循环或while,那你非用 for in
,结果首屏加载很慢,再比如你在首屏代码中执行了大量的 JSON.stringfy()
方法,也会导致主线程被占用过久,首屏加载慢,再比如你写了一个框架或者开源包,搞一大堆性能差的方法上去,开发者引入了你的包虽然功能增强了但是却拖累了人家的运行性能,诸如此类场景都需要我们在开发时带入对js代码的运行性能的思考。
感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,还请三连支持一下,点赞、关注、收藏,作者会持续与发家分享更多干货。