前言
继上一篇 如何使用链式属性表达式取值和赋值理论篇完结后,我把这些真正应用到了我的小程序优化项目当中,但是感觉效果比预期的还差点,遂在实践过程中不断地优化,总结出了更优雅更健壮性能更好的方案。
我们来一起看一下。
性能优化版
这个方法可以将一个链式属性表达式处理为一个字段数组。
/**
* 解析路径为字段数组
* @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;
while (i < len) {
const key = fileds[i];
if (i + 1 === len) { // 当前键是被更新路径的最后一个字段, 如 'obj.a.b'中的b则直接赋值
target[key] = value;
return;
}
// 创建对象或数组
// 下一个字段的形式决定当前字段对应的数据类型,例如,arr[0],0决定了arr字段是数组类型,如果字段为纯数字则判定为数组(忽略对象键为数字的情况),key不会为''
const curKeyDataType = isNaN(Number(fileds[i + 1])) ? 'object' : 'array';
let typeMutation = false;
const val = target[key];
if (val) {
const oriDataType = isArray(val) ? 'array' : 'object';
typeMutation = oriDataType !== curKeyDataType || !isPlainObjectOrArray(val);
}
// 如果路径值不存在,或者存在但是数据类型变了,则创建对应数据类型
if (!val || typeMutation) {
target[key] = curKeyDataType === 'object' ? {} : [];
// !更新 data 中不存在引用的属性或随意变更数据类型,理论上工具不会阻止这种行为,但是并不推荐,因为这种写法可能不利于维护
warn(`updated field "${path}" does not exist in the data or datatype is inconsistent, may not be easy to maintain.`);
}
target = target[key];
i++;
}
}
除性能优化外,还对功能进行了增强,旧方法不能更新没有父对象的数据, 例如 updateValByPath({obj:{}, 'obj.a.b'})
, b
属性没有父级对象 a
,会静默失败,并给出提示,优化后的方法则支持在没有父对象时为其创建新的对象或数组,b
属性可以成功赋值,obj
对象成功添加b
属性及其父对象a
。
注意一个细节,有一处代码 const oriDataType = Array.isArray(ref) ? 'array' : 'object';
用来判断是数组还是对象,这里起初想用constructor
属性,会比调用一个isArray
方法快一丢丢,但是constructor
属性不稳定,容易被更改,而且没有原型的对象(如Object.create(null)
创建的纯净对象)是没有constructor
属性的,所以用isArray
更好,为什么没有用Object.prototype.toString.call()
呢?因为该方法调用了两个函数,一个call
,一个toString
,再加上toString
方法要将传入参数转为字符串,性能消耗就更大了,所以,推荐能使用 typeof
、 constructor
、instanceof
判断类型的场景就不要用Object.prototype.toString.call()
,尤其在循环中,节省4倍左右的性能不香么,最好优先使用另外三种方式,来用toString
方法兜底。
关于constructor属性这部分有不理解的同学可以去看一下另一篇讲原型链的文章从prototype的设计初衷剖析JS原型和原型链,有收获后别忘了点个赞哦~
最后附上未改造前的旧方法:
// 链式赋值
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"`);
}
以及类型判断常用函数:
// typeUtil.ts
// 获取数据类型 如:[object Array]
export const getTypeString = (val: unknown): string => Object.prototype.toString.call(val);
// 获取原始数据类型 如:array
export const getRawType = (val: unknown) => getTypeString(val).slice(8, -1)
.toLowerCase();
export const isObject = (val: unknown): val is Record<any, any> => val !== null && typeof val === 'object';
export const { isArray } = Array;
/**
* 判断是否是普通对象
*
* 直接调用constructor属性判断对象,替代调用函数转成字符串,减少性能开销,
* 一般情况都可以通过constructor来判断,但是constructor属性不稳定,容易被更改,而且没有原型的对象(如Object.create(null)创建的纯净对象)是没有constructor属性的,
* 此时仍然需要使用toString()方法来判断。
*
* @param {unknown} val
* @returns {val is object}
*/
export const isPlainObject = (val: unknown): val is object => {
if (val?.constructor) return val.constructor === Object;
return getTypeString(val) === '[object Object]';
};
export const isPlainObjectOrArray = (val: unknown): boolean => isPlainObject(val) || isArray(val);
总结
以上文中所提到的一些优化的点,其实我们日常开发中很常见的,只是大家都没有那么关注,确实我们在写业务需求时往往不会涉及会造成很大性能差异的场景,没有太大必要死磕这些性能开销问题,对于不是那么复杂的项目来说,得到的收益微乎其微,但是重点是我们的开发习惯和思想,你是否会在某些场景下思考性能问题,而不是只是实现就行呢?
至少你需要考虑首屏代码是应该性能优先的吧,例如首屏被加载的逻辑包含大量循环,本来可以用优化for
循环或while
,那你非用 for in
,结果首屏仅处理数据格式化就消耗了大量性能,又或者你在首屏代码的循环中执行多次JSON.stringfy()
方法,也会导致主线程被占用过久,首屏加载慢,再比如你写了一个框架或者开源包,搞了一大堆性能差的方法上去,开发者引入了你的包虽然功能增强了但是却拖累了人家的运行性能,诸如此类场景都需要我们在开发时带入对js代码的运行性能的思考。
具体哪些写法性能好,哪些性能偏低,可以简单参考下js遍历方法性能对比,然后结合实际场景,有选择地去应用。
后续会写一篇js代码性能优化实践总结,尽可能全地去收拢实际开发中的有用的优化技巧,敬请期待。
感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,还请三连支持一下,点赞、关注、收藏,作者会持续与大家分享更多干货。