手写lodash.get()方法

1,056 阅读2分钟

这是我面试字节碰到的一道题

题目:实现lodash.get()方法

function _get(obj, path, defaultValue) {
    // Write code here
}

const obj = { a: [{ b: 1 }] };
console.log(_get(obj, 'a[0].b', 3)) // 1
console.log(_get(obj, 'a[0].c', 3)) // 3

只针对示例来写是挺简单的,并且这也不是面试官所需要的。
先来看看lodash库中的get()方法如何实现的吧~

lodash库中的get()实现

src/get.ts 即可看到源码

// get.ts

import baseGet from './.internal/baseGet.js';

/**
 * Gets the value at `path` of `object`. If the resolved value is
 * `undefined`, the `defaultValue` is returned in its place.
 *
 * @since 3.7.0
 * @category Object
 * @param {Object} object The object to query.
 * @param {Array|string} path The path of the property to get.
 * @param {*} [defaultValue] The value returned for `undefined` resolved values.
 * @returns {*} Returns the resolved value.
 * @see has, hasIn, set, unset
 * @example
 *
 * const object = { 'a': [{ 'b': { 'c': 3 } }] }
 *
 * get(object, 'a[0].b.c')
 * // => 3
 *
 * get(object, ['a', '0', 'b', 'c'])
 * // => 3
 *
 * get(object, 'a.b.c', 'default')
 * // => 'default'
 */
function get(object, path, defaultValue) {
    const result = object == null ? undefined : baseGet(object, path);
    return result === undefined ? defaultValue : result;
}

export default get;

可以看到具体处理的 baseGet

// baseGet.ts

import castPath from './castPath.js'
import toKey from './toKey.js'

/**
 * The base implementation of `get` without support for default values.
 *
 * @private
 * @param {Object} object The object to query.
 * @param {Array|string} path The path of the property to get.
 * @returns {*} Returns the resolved value.
 */
function baseGet(object, path) {
  path = castPath(path, object)

  let index = 0
  const length = path.length

  while (object != null && index < length) {
    object = object[toKey(path[index++])]
  }
  return (index && index === length) ? object : undefined
}

export default baseGet

可以看到在 baseGet 中又引入了 castPathtoKey 两个方法,在实现这个方法时需要对path进行处理,所以 castPath 方法就是对path进行处理的,toKey 方法是将path转换为string字符串的,具体描述在这里toKey 方法不是关键,去看看castPath 方法。

// castPath.ts

import isKey from './isKey.js'
import stringToPath from './stringToPath.js'

/**
 * Casts `value` to a path array if it's not one.
 *
 * @private
 * @param {*} value The value to inspect.
 * @param {Object} [object] The object to query keys on.
 * @returns {Array} Returns the cast property path array.
 */
function castPath(value, object) {
  if (Array.isArray(value)) {
    return value
  }
  return isKey(value, object) ? [value] : stringToPath(value)
}

export default castPath

可以看到在 castPath 文件中,直接判断了path是不是一个数组,如果是则直接返回(这里是一个重要的点); 随后有一个 isKey 方法,这个方法是通过使用正则表达式去匹配path中的属性名的,相当于做个校验,具体描述在这里。还有一个 stringToPath 方法,这个方法做的是将path字符串转换为一个数组的,方便后期工作取出每一个key(这个文件也是一个重要的点)。

// stringToPath.ts

import memoizeCapped from './memoizeCapped.js'

const charCodeOfDot = '.'.charCodeAt(0)
const reEscapeChar = /\\(\\)?/g
const rePropName = RegExp(
  // Match anything that isn't a dot or bracket.
  '[^.[\\]]+' + '|' +
  // Or match property names within brackets.
  '\\[(?:' +
    // Match a non-string expression.
    '([^"\'][^[]*)' + '|' +
    // Or match strings (supports escaping characters).
    '(["\'])((?:(?!\\2)[^\\\\]|\\\\.)*?)\\2' +
  ')\\]'+ '|' +
  // Or match "" as the space between consecutive dots or empty brackets.
  '(?=(?:\\.|\\[\\])(?:\\.|\\[\\]|$))'
  , 'g')

/**
 * Converts `string` to a property path array.
 *
 * @private
 * @param {string} string The string to convert.
 * @returns {Array} Returns the property path array.
 */
const stringToPath = memoizeCapped((string) => {
  const result = []
  if (string.charCodeAt(0) === charCodeOfDot) {
    result.push('')
  }
  string.replace(rePropName, (match, expression, quote, subString) => {
    let key = match
    if (quote) {
      key = subString.replace(reEscapeChar, '$1')
    }
    else if (expression) {
      key = expression.trim()
    }
    result.push(key)
  })
  return result
})

export default stringToPath

我们可以看到在 stringToPath 方法中,有一行代码 key = substring.replace(reEscapeChar, '$1') ,这一行代码是什么意思?
首先需要知道的是在正则表达式中,使用括号()创建捕获组,可以将匹配到的子串进行捕获。每个捕获组都有一个编号,从左到右依次递增,从1开始。因此,$1 表示第一个捕获组, $2 表示第二个捕获组,如此我们便可以对捕获到的子串进行处理了。

基本上 get() 方法的过程就是这些了,来捋一捋大致思路:

  1. 首先判断传入的path是否是一个数组,如果是则直接返回,不进行下一步处理
  2. 如果不是一个数组,就会需要进行一些处理,这个处理实际上是对path进行处理,取出路径中的每一个key。具体的处理方式可以通过使用正则表达式或暴力循环。
  3. 取出每一个key后,再用这些key去获取对象中对应层级的值即可,如果通过指定的路径获取不到具体的值,返回指定的默认值defaultValue即可。

编码实现

基于上面的思路可以写出以下实现(当然只是一种简单实现,情况并没有完全考虑完)

function _get(obj, path, defaultValue) {
    let key = Array.isArray(path) ? path : path.replace(/(\[(\d)\])/g, '.$2').split('.');
    obj = obj[key[0]];
    if (obj && key.length > 1) {
        return _get(obj, key.slice(1), defaultValue);
    }
    return obj ? obj : defaultValue;
}

const obj = { a: [{ b: 1 }] };
console.log(_get(obj, "a[0].b", 3)); // 1
console.log(_get(obj, "a[0].c", 3)); // 3