path.js源码

exports.parse函数源码:#L239

这个parse函数样例入参:

parse("a.b.['c'].d")

最后返回值是一个数组(带有一个get函数的数组): ["a", "b", "c", "d"] 源码:

exports.parse = function (path) {
  var hit = pathCache.get(path)
  if (!hit) {
    hit = parsePath(path)
    if (hit) {
      hit.get = exports.compileGetter(hit)
      pathCache.put(path, hit)
    }
  }
  return hit
}

parsePath关键代码,返回值是一个数组

parsePath github:L140

源码:

function parsePath (path) {
  var keys = []
  var index = -1
  var mode = 'beforePath'
  var c, newChar, key, type, transition, action, typeMap

  var actions = {
    push: function() {
      if (key === undefined) {
        return
      }
      keys.push(key)
      key = undefined
    },
    append: function() {
      if (key === undefined) {
        key = newChar
      } else {
        key += newChar
      }
    }
  }

  function maybeUnescapeQuote () {
    var nextChar = path[index + 1]
    if ((mode === 'inSingleQuote' && nextChar === "'") ||
        (mode === 'inDoubleQuote' && nextChar === '"')) {
      index++
      newChar = nextChar
      actions.append()
      return true
    }
  }

  while (mode) {
    index++
    c = path[index]

    if (c === '\\' && maybeUnescapeQuote()) {
      continue
    }

    type = getPathCharType(c)
    typeMap = pathStateMachine[mode]
    transition = typeMap[type] || typeMap['else'] || 'error'

    if (transition === 'error') {
      return // parse error
    }

    mode = transition[0]
    action = actions[transition[1]] || noop
    newChar = transition[2] === undefined
      ? c
      : transition[2]
    action()

    if (mode === 'afterPath') {
      return keys
    }
  }
}

核心逻辑是在 github:L10的状态机变量pathStateMachine。 通过一个while循环,来执行掉这个pathStateMachine状态机。 每一个pathStateMachine的子属性里头的孙子属性,都是一个数组。最大是3位的数组。index从0到2,分别代表"下一步mode,也称状态", "action", "替换原先key的字符串"。

其中action在github:L146指定了两种,push和append。push类似于在最后parse出来的结果数组(暂称为rst)里头,增加一个key;append是目前这个key增加字符。 举例说明,假设目前rst已经是["abc", "d"],目前action是append,然后当前字符是e,也即将e添加到rst[1]的字符串后面行程["abc", "de"];如果当前action是push,那么就是["abc", "d", "e"]

index为0的——“下一步mode,也称状态”,这个是通过getPathCharType这个函数+前面提到的孙子属性的数组,结合得到下一步的“状态”。

譬如pathStateMachine.beforePath,其原始值如下:

  { 'beforePath': {
    'ws': ['beforePath'],
    'ident': ['inIdent', 'append'],
    '[': ['beforeElement'],
    'eof': ['afterPath']
  }}

parsePath的状态机pathStateMatchine遍历的举例说明,辅助理解。

举个例子:解析 "user.addresses[0]['street']" 的过程简化如下:

字符 状态转移 动作 keys
u beforePath → inIdent append key="u"
s e r inIdent → inIdent append key="user"
. inIdent → beforeIdent push ["user"]
a..s beforeIdent → inIdent × n append key="addresses"
[ inIdent → beforeElement push ["user","addresses"]
0 beforeElement → afterZero append key="0"
] afterZero → inPath push ["user","addresses","0"]
[ inPath → beforeElement -
' beforeElement → inSingleQuote append '' key=""
s..t inSingleQuote → inSingleQuote append key="street"
' inSingleQuote → afterElement - (不把引号加进去)
] afterElement → inPath push ["user","addresses","0","street"]
EOF inPath → afterPath - 返回结果 ✅

hit.get是一个compileGetter返回的函数。

为何给返回值的这个数组(hit)增加这么一个get函数

能快速方便使用,从而从一个入参object里头获取到对应的属性值(多层path)。 例如:

var parsed = path. parse ( 'user.name.first' )
var obj = { user : { name : { first : 'Tom' } } }
parsed. get (obj) // → 'Tom'

exports.get函数源码:#L258

使用了exports.parse里头的hit.get这个挂载的函数,再封装一层就是exports.get函数

exports.get = function (obj, path) {
  path = exports.parse(path)
  if (path) {
    return path.get(obj)
  }
}

exports.set函数源码:#L273

exports.set = function (obj, path, val) {
  if (typeof path === 'string') {
    path = exports.parse(path)
  }
  if (!path || !_.isObject(obj)) {
    return false
  }
  var last, key
  for (var i = 0, l = path.length - 1; i < l; i++) {
    last = obj
    key = path[i]
    obj = obj[key]
    if (!_.isObject(obj)) {
      obj = {}
      last.$add(key, obj)
    }
  }
  key = path[i] 
  if (key in obj) {
    obj[key] = val
  } else {
    obj.$add(key, val)
  }
  return true
}

核心是用了x.$add(xx, xx)函数,将属性添加上去,并有响应式

需要注意L290行的关键代码key = path[i],取得就是最后一个key。

仔细看L281行的for循环,一开始l赋值的就是length - 1,然后后面是i < l这个判断,所以for循环是会跑到倒数第二个key之后就结束,从而 L290行那一行取到的是最后一个key。 一般上的for循环会赋值l = length,然后在i < l。但L281行那里却是l = length - 1

exports.parse为何不能直接eval,而要先parsePath,然后再compileGetter获取?

  1. 代码注入风险。通过parsePath再compileGetter合起来,实现了白名单式代码。
  2. parsePath之后还能给别的用。譬如set相关、watch相关等等。如果只有eval ,你无法拿到['user', 'address', 'city'] 这样的分段信息。
  3. parsePath 的状态机会对路径做完整的语法校验:
  • 引号不闭合 → 返回undefined (如'a["b' )
  • 非法字符 → 返回undefined (如'a@b' )
  • 数字索引格式错误 → 返回undefined
  • 而eval 遇到错误会 抛出异常 ,调用方必须 try-catch,而且错误信息是 JS 引擎的原始报错,不友好。

if (+key === key >>> 0)这段代码小技巧。

属于快速判断这个key是>=0的正整数。 在这个具体逻辑里,是用来判断是合法的数组下标值。

关键原理是>>>这个操作。类似还有个>>操作

>>> 是 JS 无符号右移运算符(zero-fill right shift)。 移位运算在 JS 里有一条硬性规则:

运算前,先把操作数转为 32 位有符号整数(ToInt32),再执行移位;无符号右移的结果类型是 32 位无符号整数。

>>> 0 就是不移位,只利用这个类型转换机制。

转换流程拆解:

  1. 先对 key 执行抽象操作 ToNumber(key) → 得到数字
  2. 再执行 ToInt32:把数字截断为32 位有符号二进制补码
    • 范围:[-2³¹, 2³¹−1],也就是 -2147483648 ~ 2147483647
    • 小数直接舍弃小数部分;超出范围则只保留低 32bit;NaN/Infinity 变成 0
  3. 然后执行右移 0 位:比特不变
  4. 但 >>> 是无符号移位:输出时,把这 32 位比特解释成无符号数
    • 最高位不再是符号位,而是普通数值位
    • 结果范围:[0, 4294967295](2³² −1)

>>操作

有符号右移 0 位,转成 32 位有符号整数,结果可负,范围 -2147483648 ~ 2147483647

(-1) >> 0 // -1
(-1) >>> 0 // 4294967295