封装对象属性值获取方法

259 阅读12分钟

一、为什么要封装对象属性值获取方法?

对象属性值获取方法(下面简称get方法),我很早就知道lodash中有这样一个方法,但是一直觉得它很鸡肋。

1.get方法的应用场景

get方法主要是用于对象深层取值的场景,这种场景实际工作中非常常见,例如下面的这个对象,我想取得其中属性d的值,使用JavaScript的原生语法应该这样写obj.a[1].c.d

const obj = {
  a:[
    {
      b:1
    },
    {
      c:{
        d:2
      }
    } 
  ]
}

obj.a[1].c.d

但是这种原生的写法存在一个严重的问题,如果进行对象深度取值的这个路径上有任何一个点存在异常数据就会报错。

例如向下面的这种情况,属性cnull我们在获取属性d的值的时候就会报错。而类似的这种数据异常的情况在实际开发中几乎是无法避免的。

const obj = {
  a:[
    {
      b:1
    },
    {
      c:null
    } 
  ]
}

obj.a[1].c.d //Error: Cannot read properties of null (reading 'd')

此时就需要有一种方法来解决这个问题,get方法也就应运而生了。同样的条件下,如果使用get方法获取d属性,就不会报错而是会返回null或者默认值。

const obj = {
  a:[
    {
      b:1
    },
    {
      c:null
    } 
  ]
}

obj.a[1].c.d //Error: Cannot read properties of null (reading 'd')
get(obj , 'a[1].c.d')// undefined
get(obj , 'a[1].c.d' , 1)// 1

但是为什么我说get方法鸡肋呢,因为可选链的出现。JavaScript在后来推出了可选链操作符,它可以实现与get方法相似的效果。

const obj = {
  a:[
    {
      b:1
    },
    {
      c:null
    } 
  ]
}

obj.a[1].c.d //Error: Cannot read properties of null (reading 'd')

get(obj , 'a[1].c.d')// undefined
get(obj , 'a[1].c.d' , 1)// 1

//通过可选链读取d属性的值
obj?.a?.[1]?.c?.d // undefined

2.我为什么要封装get方法

既然我认为get方法很鸡肋,为什么我又要封装它呢?这主要是因为两件事。

其一,是我参与了一个项目,这个项目中没办法用可选链这样的比较新的语法,在这种情况下我发现get方法几乎是我必不可少的一个工具。虽然JavaScript有了可选链,但是它在某些情况下还是有兼容性问题的,这就给get方法留下了一片生存的土壤。

其二,是我在封装数组去重的方法的时候,发现get方法可以帮助我增强数组去重的功能。数组去重方法中有一个参数iteratee ,它是一个函数可以对数组中的元素进行一个映射。如果加入了get方法,我们可以让iteratee 不只是一个函数,也可以让它是一个属性名或者路径数组,这样就可以大大增强iteratee参数的实用性。

二、怎样封装对象属性值获取方法?

1.lodash中的实现

我还是将lodash中的代码进行了一个简单的归纳,整理出了下面的这个函数:

function lodashGet(object, path, defaultValue) {
  // 准备工作
  let result;
  if (typeof object != "object" || object == null) result = undefined;

  //获取路径数组
  const reIsDeepProp = /.|[(?:[^[]]*|(["'])(?:(?!\1)[^\]|\.)*?\1)]/,
    reIsPlainProp = /^\w*$/,
    rePropName =
      /[^.[]]+|[(?:(-?\d+(?:.\d+)?)|(["'])((?:(?!\2)[^\]|\.)*?)\2)]|(?=(?:.|[])(?:.|[]|$))/g;
  let pathArr = [];
  if (Array.isArray(path)) {
    pathArr = path;
  } else if (
    reIsPlainProp.test(path) ||
    !reIsDeepProp.test(path) ||
    path in Object(object)
  ) {
    pathArr = [path];
  } else {
    if (path.charCodeAt(0) === 46 /*. */) {
      pathArr.push("");
    }
    path.replace(rePropName, function (match, number, quote, subString) {
      pathArr.push(
        quote ? subString : number || match
      );
    });
  }

  // 遍历路径数组,获取值
  let index = 0,
    length = pathArr.length;
  while (object != null && index < length) {
    object = object[pathArr[index++]];
  }

  result = index && index == length ? object : undefined;
  return result === undefined ? defaultValue : result;
}

基本原理

在分析代码内容之前,我想先谈一谈get方法实现的基本原理。

原理很简单,就是准备一个路径数组,之后遍历数组的内容,根据每一个路径节点依次从对象中获取到对应的值,最终就可以获取到目标值。

const obj = {
  a:[
    {
      b:1
    },
    {
      c:{
        d:2
      }
    } 
  ]
}

// 路径数组
const pathArr = ['a' , 2 , 'c' , 'd']
let result = obj

// 遍历路径数组获取对象中的值
for(let i = 0 ; i < pathArr.length ; i++) {
  result = reuslt[pathArr[i]] 
}

console.log(result) // 2

参数准备

下面开始分析代码内容,第一步就是参数准备。get方法共有三个参数:

function lodashGet(object, path, defaultValue) {
  // 准备工作
  let result = defaultValue;
  if (typeof object != "object" || object == null) return result;
}

获取路径数组-概述

path参数是要获取的属性的路径,但是它未必是一个路径数组,因此我们就需要将path转化为路径数组。

path有“两种类型三种情况”:

  • 类型一,数组类型
    • 情况一,此时path就是一个路径数组不需要进行转换,例如['a' , 2 , 'c' , 'd']
  • 类型二,字符串类型
    • 情况二,path是一个属性名,那么路径数组就是[path],例如'id'
    • 情况三,path是一个路径链,那么就需要通过正则将路径链转化为路径数组,例如'a[1].c.d'

我们可以将这“两种类型三种情况”转换为如下的代码。那么此时就出现了两个难点:

  1. 如何判断path是字符串属性名还是字符串路径链
  2. 如何将字符串路径链转换为路径数组?
let pathArr = []; // 路径数组

if (/** 情况一,path为路径数组 */ Array.isArray(path)) {
  pathArr = path;
} else if (
  /** 情况二,path是属性名 */
) {
  pathArr = [path];
} else {/** 情况三,path是路径链 */
  // 将字符串路径链转换为路径数组 ...
}

获取路径数组-区分属性名和路径链

lodash中使用以下的两个正则来区分字符串属性名和字符串路径链。其中reIsDeepProp用于检测path是否为路径链,reIsPlainProp则用来检测path是否为属性名。

const reIsDeepProp = /.|[(?:[^[]]*|(["'])(?:(?!\1)[^\]|\.)*?\1)]/,
  reIsPlainProp = /^\w*$/,

这里的正则写的很复杂,我尝试分析一下:

首先分析reIsDeepProp,它的最外层是由|[1] 连接的两种情况:.[(?:[^[]]*|(["'])(?:(?!\1)[^\]|\.)*?\1)]

  • 情况 1 .,它表示匹配一个点符号 .[2]
  • 情况 2 [(?:[^[]]*|(["'])(?:(?!\1)[^\]|\.)*?\1)] ,它大概表示匹配一段被方括号包裹的字符串。方括号里面的内容为(?:[^[]]*|(["'])(?:(?!\1)[^\]|\.)*?\1),它是一个不记名原子组[3][4] 其中的内容是[^[]]*|(["'])(?:(?!\1)[^\]|\.)*?\1,它又被|选择符分为了两种情况:[^[]]*(["'])(?:(?!\1)[^\]|\.)*?\1
    • 情况 2.1 [^[]]* ,这部分正则是一个原子表[5] 加一个*修饰符[6]。原子表内以^开头表示原子表进行排除匹配[7]。因此原子表的含义是匹配除[]以外的其它字符零个或多个。
    • 情况 2.2 (["'])(?:(?!\1)[^\]|\.)*?\1 ,这部分正则又可以分为三部分。
      • 第一部分(["'])表示匹配一个双引号或单引号。
      • 第二部分(?:(?!\1)[^\]|\.)*? 是一个不记名原子组加上一个*?修饰符[8],不记名原子组中包括断言[9](?!\1)和要匹配的内容[^\]|\.(?!\1)本身是一个负向前瞻断言[10],其中的\1[11]表示对1号原子组(也就是(["']))的复用,所以这个断言就表示前面不能有引号。[^\]|\.则表示匹配不为反斜杠的字符或者`.`字符。所以整个第二部分的含义就是匹配“前面没有有引号的不为反斜杠的字符或者加任意字符(例如 `\w`都是反斜杠加任意字符)字符零个或多个(尽可能少的匹配)”。
      • 第三部分\1是对前面原子组(也就是(["'])的复用,表示配一个双引号或单引号。

分析完了reIsDeepProp正则的内容,可以简单的总结一下,该正则其实主要匹配了以下的几种情况:

  1. 匹配点符号,即/./ ,对应了路径参数为a.b.c的情况。
  2. 匹配一个中括号内,不含中括号的内容,对应了路径参数为a[0]或者a['b']的情况

目前还没有搞清楚(["'])(?:(?!\1)[^\]|\.)*?\1这一段正则的具体作用,希望有懂的大神能给我解答一下。

然后分析reIsPlainProp/^\w*$/,它就非常简单了表示匹配一段由字母数字下划线组成的字符串,并且它的前面和后面不能有其它字符。例如_namename1name是会被匹配到的,[name]3.14.11则不会被匹配。

注释

  1. |是正则中的选择符,表示多种可能的情况。
  2. 由于. 在正则中表示任意的字符,所以在它的前面要加上转义符``。所以.代表任意字符,.代表点字符。
  3. 原子组,正则中被圆括号()包裹起来的内容就是原子组,简单来说就是将部分正则划分为一组,主要作用是方便后续对这部分内容进行提取复用。
  4. 不记名原子组,内部以?:开头的原子组是不记名原子组 ,不记名原子组无法被复用(即反向引用,详见注释11)。如果我们不希望一个原子组被复用,就可以将它设置为不记名原子组。这样设置的原因是很多时候正则中的原子组会有很多,可能还会存在原子组的嵌套的情况,这会给原子组的复用造成困难,因此才需要将一些不复用的原子组设置为不记名原子组。
  5. 原子表,正则中被方括号[]包裹起来的内容就是原子组。与原子组不同的是原子表只用来匹配一个字符,它相当于创建一个索引表 , 只要符合索引表中的某一个条件的字符就会被匹配到。
  6. *,它是一个量词表示匹配前面元素的零次或多次,例如/1*/就表示匹配零个1到多个1的情况 """1""1111" 都会被它匹配到。
  7. 原子表内以^开头表示原子表进行排除匹配,此时不符合原子表中条件的字符将会被匹配到,例如/[123]/表示匹配字符1、2、3中的其中一个,/[^123]/则表示匹配一个不是1、2、3的字符。
  8. *?*的禁止贪婪形式(或者成为懒惰形式)。*是贪婪量词,它会尽可能多的匹配字符,例如有一个字符串"<p>content1</p><p>content2</p>",如果使用正则表达式<p>.*</p>来匹配,它会匹配从第一个<p>开始到最后一个</p>结束的整个字符串,即<p>content1</p><p>content2</p>*?是非贪婪量词,它会尽可能少的匹配字符,如果使用<p>.*?</p>来匹配上面的字符串,则之后匹配到<p>content1</p>
  9. 断言,类似于是正则表达式中的一种条件声明,它不会去匹配字符串,而是会声明一种规则,帮助其它正则来匹配字符串。例如,前面必需有什么,前面不能有什么等等。
  10. 负向前瞻断言,又称为否定查找,用于声明前面(后面)没有什么。例如,/\d+(?!.)/ 表示匹配后面没有点的数字,用它匹配"3.141"结果为141/(?!.)\d+/则表示匹配前面没有点的数字,用它匹配"3.141"结果为3
  11. 反向引用,简单来说就是引用之前的原子组所匹配的内容。\1就代表引用第一个原子组,\2就代表引用第二个原子组。

现在我们就可以利用这两个正则来区分属性名和路径链了:

let pathArr = []; // 路径数组

const reIsDeepProp = /.|[(?:[^[]]*|(["'])(?:(?!\1)[^\]|\.)*?\1)]/,
  reIsPlainProp = /^\w*$/,

if (/** 情况一,path为路径数组 */ Array.isArray(path)) {
  pathArr = path;
} else if (
/** 情况二,path是属性名 */
  reIsPlainProp.test(path) ||
  !reIsDeepProp.test(path) ||
  path in object
) {
  pathArr = [path];
} else {/** 情况三,path是路径链 */
// 将字符串路径链转换为路径数组 ...
}

获取路径数组-路径链转换为路径数组

将路径链转换为路径数组,简单来说就是要将路径链字符串中的属性名(或者数组索引)提取出来,这个提取的过程当然利用正则。主要使用了下面的这个正则:

const rePropName = /[^.[]]+|[(?:(-?\d+(?:.\d+)?)|(["'])((?:(?!\2)[^\]|\.)*?)\2)]|(?=(?:.|[])(?:.|[]|$))/g

这个正则我们也来简单分析一下:

这正则的最外层被选择符分为了三种可能的情况,分别是[^.[]]+[(?:(-?\d+(?:.\d+)?)|(["'])((?:(?!\2)[^\]|\.)*?)\2)](?=(?:.|[])(?:.|[]|$))

  • 情况1 [^.[]]+ ,这部分正则由一个反向原子表加一个+ 量词组成 ,表示匹配至少一个不为点或方括号的字符。
  • 情况2 [(?:(-?\d+(?:.\d+)?)|(["'])((?:(?!\2)[^\]|\.)*?)\2)] ,这部分正则表示匹配一段以方括号开头结尾和字符串(类似于"[...]"),方括号内的内容通过(-?\d+(?:.\d+)?)|(["'])((?:(?!\2)[^\]|\.)*?)\2来匹配,它又被选择符分为了两种情况:(-?\d+(?:.\d+)?)(["'])((?:(?!\2)[^\]|\.)*?)\2
    • 情况 2.1 (-?\d+(?:.\d+)?)-? 表示匹配可能存在的负号,\d+表示匹配一个或多个数字,(?:.\d+)? 表示匹配可能存在的点加一个或多个数字的结构(其实就是小数部分)。所以这整个的意思就是可以匹配整数和小数,例如-13.1415926
    • 情况 2.2 (["'])((?:(?!\2)[^\]|\.)*?)\2(["'])表匹配一个引号(单引号或双引号),((?:(?!\2)[^\]|\.)*?) ,这个结构很熟悉,前面分析过,它表示匹配“前面没有有引号的不为反斜杠的字符或者加任意字符(例如 \w``\都是反斜杠加任意字符)字符零个或多个(尽可能少的匹配),\2是对前面原子组的反向引用,也表示匹配一个引号。所以这部分其实是匹配了一个带引号的字符串,例如"name"'id'或者"\prop",它根本的目的应该就是通过原子组((?:(?!\2)[^\]|\.)*?)将引号中间的内容提取出来。
  • 情况3 (?=(?:.|[])(?:.|[]|$)),这部分是一个正向前瞻断言,它不会匹配字符串,而是会匹配一个位置,这个位置之后的字符必须能够匹配给定的条件,这个条件就是(?:.|[])(?:.|[]|$),所以这部分正则的含义是表示匹配一个位置,这个位置后面的字符串必需为点或一对方括号 + 点或一对方括号方括号或字符串结束位置。这部分正则的目的是为了匹配...[]等情况。以..为例,第一个点之前的位置符合断言的条件,因为这个位置后面跟着两个点,第二个点前面的位置也符合断言的条件,因为这个位置后面跟着点加字符串结束位置。因此通过这个断言就可以将.. 转化为['','']这样一个路径数组。

转化路径链的代码如下:

let pathArr = []; // 路径数组

const rePropName = /[^.[]]+|[(?:(-?\d+(?:.\d+)?)|(["'])((?:(?!\2)[^\]|\.)*?)\2)]|(?=(?:.|[])(?:.|[]|$))/g

// 将字符串路径链转换为路径数组 
if (path.charCodeAt(0) === 46 /*. */) {
  pathArr.push("");
}
path.replace(rePropName, function (match, number, quote, subString) {
  pathArr.push(
    quote ? subString : number || match
  );
});

这部分代码分为两个部分,第一部分是检查path参数的第一个字符是否为点(例如path.a...a.b),如果是则给路径数组中添加一个空字符串。这部分乍一看不知道它要干什么,我猜测lodash的开发者应该是认为以点开头的路径字符串,点的前面就相当于有一个属性,这个属性是空字符串。

if (path.charCodeAt(0) === 46 /*. */) {
  pathArr.push("");
}

第二部分则是利用正则来提取路径中的属性名,这里巧妙的使用了replace方法来实现这一功能,replace方法的第二个参数可以是一个函数,这个函数可以在每次正则匹配到内容时被调用,这样就可以方便我们将匹配到的内容进行处理放到路径数组中。

我介绍一下函数的四个参数:

  1. match就是每次匹配到的内容。
  2. number是第一个原子组(-?\d+(?:.\d+)?)捕获的内容,这个内容是方括号内的数字。例如如果patha[0],则当match[0]时,number就为0
  3. quote是第二个原子组(["'])捕获的内容,这个内容是方括号内的一个引号。例如如果patha['b'],则当match['b']时,quote就为'
  4. subString是第三个原子组((?:(?!\2)[^\]|\.)*?)的内容,这个内容是方括号内的引号内的内容。例如如果patha['b'],则当match['b']时,quote'subString就为b
path.replace(rePropName, function (match, number, quote, subString) {
  pathArr.push(
    quote ? subString : number || match
  );
});

所以完整的获取路径数组的代码如下:

//获取路径数组
const reIsDeepProp = /.|[(?:[^[]]*|(["'])(?:(?!\1)[^\]|\.)*?\1)]/,
  reIsPlainProp = /^\w*$/,
  rePropName =
    /[^.[]]+|[(?:(-?\d+(?:.\d+)?)|(["'])((?:(?!\2)[^\]|\.)*?)\2)]|(?=(?:.|[])(?:.|[]|$))/g;
let pathArr = [];
if (Array.isArray(path)) {
  pathArr = path;
} else if (
  reIsPlainProp.test(path) ||
  !reIsDeepProp.test(path) ||
  path in Object(object)
) {
  pathArr = [path];
} else {
  if (path.charCodeAt(0) === 46 /*. */) {
    pathArr.push("");
  }
  path.replace(rePropName, function (match, number, quote, subString) {
    pathArr.push(
      quote ? subString : number || match
    );
  });
}

使用路径数组从对象中取值

取值的方式在基本原理部分已经讲解过了,因此这部分也就不再详细介绍了,具体的代码如下:

  // 遍历路径数组,获取值
  let index = 0,
    length = pathArr.length;
  while (object != null && index < length) {
    object = object[pathArr[index++]];
  }

  result = index && index == length ? object : undefined;

2.radash中的实现

radash中get方法的实现代码如下,radash中的实现非常简单,我觉得我根本不用介绍了。并且它除了无法直接接受一个路径外,可以完美替代lodash中的get

/**
 * 用一个字符串从一个数组或对象中动态的获取一个嵌套的值
 * @param {*} value
 * @param {*} path
 * @param {*} defaultValue
 */
function radashGet(value, path, defaultValue) {
  const segments = path.split(/[.[]]/g);
  let current = value;
  for (const key of segments) {
    if (current === null) return defaultValue;
    if (current === undefined) return defaultValue;
    const dequoted = key.replace(/['"]/g, "");
    if (dequoted.trim() === "") continue;
    current = current[dequoted];
  }
  if (current === undefined) return defaultValue;
  return current;
}

3.我的实现

我的实现也就非常简单了,由于radash中的实现十分的优秀,我准备就直接照搬过来,并且在它的基础上,进行两个优化:

  1. path参数可以直接传一个路径数组
  2. path中可以兼容数组的find方法,因为我实际的开发中从对象中取数据的时候是经常会用到find的,例如:obj.datas.find(item=>item.id == 100)。因此我想设计一个规则,如果path中出现.f(key=value)的形式,就表示使用find方法从一个对象数组中查找一个对象,查找的条件就是key等于value

最后我实现的方法如下:

/**
 * 用一个路径字符串或路径数组从一个数组或对象中动态的获取一个嵌套的值
 * @param {Object|Array} value 要检索的对象或数组
 * @param {string|Array} path 路径字符串或路径数组
 * @param {*} defaultValue 默认值
 * @returns 获取到的值或默认值
 */
function get(value, path, defaultValue) {
  // 校验参数value的类型
  if (typeof value !== "object" || value === null) return defaultValue;

  // 处理路径
  let segments;
  if (Array.isArray(path)) {
    segments = path;
  } else if (typeof path === "string") {
    segments = path.split(/[.[]]/g);
  } else {
    return defaultValue;
  }


  // 获取值
  let current = value;
  const reIsArrayFind = /f((?<key>.+)=(?<value>.+))/;
  for (const key of segments) {
    if (current == null) return defaultValue;
    const dequoted = key.replace(/['"]/g, "");
    if (dequoted.trim() === "") continue;

    // 兼容数组的find方法
    if (reIsArrayFind.test(dequoted)) {
      const { key, value } = reIsArrayFind.exec(dequoted).groups;
      current = current.find(item => item[key] == value);
      continue;
    }

    current = current[dequoted];
  }
  if (current === undefined) return defaultValue;
  return current;
}

// 测试用例
const obj = {
  a: 1,
  b: {
    c: 2,
    d: [
      { e: 3, f: 1 },
      { e: 4, f: 2 },
      { e: 5, f: 5 },
    ],
  },
};
console.log(get(obj, "b.d.e",'default value')); //default value
console.log(get(obj, "b.d.f",'default value')); //default value
console.log(get(obj, "b.d.f(e=3)",'default value')); //{e: 3, f: 1} 
console.log(get(obj, "b.d.f(e=4).f",'default value')); // 2