React 源码系列 | React Children 详解 | Children 中 key 内部生成原理

3,189 阅读7分钟

本文基于 React V16.8.6,本文代码地址

React 中一个元素可能有 0 个、1 个或者多个直接子元素,React 导出的 Children 中包含 5 个处理子元素的方法。

  • map 类似 array.map
  • forEach 类似 array.forEach
  • count 类似 array.length
  • toArray
  • only

React 内部处理 Children 的几个重要函数包括

  • mapChildren
  • traverseAllChildrenImpl
  • mapIntoWithKeyPrefixInternal
  • mapSingleChildIntoContext
  • getPooledTraverseContext
  • releaseTraverseContext

源码都在 packages/react/src/ReactChildren.js 中。

导出的语句

export {
  forEachChildren as forEach,
  mapChildren as map,
  countChildren as count,
  onlyChild as only,
  toArray,
};

Children API

map

类似 array.map,但有一下几个不同点:

  • 返回的结果一定是一个一维数组,多维数组会被自动摊平
  • 对返回的每个节点,如果 isValidElement(el) === true ,则会给它加上一个 key,如果元素本来就有 key,则会重新生成一个新的 key

map 的用法:第一个参数是要遍历的 children,第二个参数是遍历的函数,第三个是 context,执行遍历函数时的 this

如果 children == null,则直接返回了。

mapChildren

/**
 * Maps children that are typically specified as `props.children`.
 * 用来遍历 `props.children`
 *
 * @param {?*} children Children tree container.
 * @param {function(*, int)} func The map function.
 * @param {*} context Context for mapFunction.
 * @return {object} Object containing the ordered map of results.
 */
function mapChildren(children, func, context) {
  if (children == null) {
    return children;
  }
  // 遍历出来的元素会丢到 result 中最后返回出去
  const result = [];
  mapIntoWithKeyPrefixInternal(children, result, null, func, context);
  return result;
}

mapIntoWithKeyPrefixInternal

将 children 完全遍历,遍历的节点最终全部存到 array 中,是 ReactElement 的节点会更改 key 之后再放到 array 中。

function mapIntoWithKeyPrefixInternal(children, array, prefix, func, context) {
  // 这里是处理 key,不关心也没事
  let escapedPrefix = '';
  if (prefix != null) {
    escapedPrefix = escapeUserProvidedKey(prefix) + '/';
  }
  // getPooledTraverseContext 和 releaseTraverseContext 是配套的函数
  // 用处其实很简单,就是维护一个大小为 10 的对象重用池
  // 每次从这个池子里取一个对象去赋值,用完了就将对象上的属性置空然后丢回池子
  // 维护这个池子的用意就是提高性能,毕竟频繁创建销毁一个有很多属性的对象消耗性能
  const traverseContext = getPooledTraverseContext(
    array, // result 
    escapedPrefix, // ''
    func, // mapFunc
    context, // context
  );
  // 最核心的一句
  traverseAllChildren(children, mapSingleChildIntoContext, traverseContext);
  releaseTraverseContext(traverseContext);
}

getPooledTraverseContext

getPooledTraverseContextreleaseTraverseContext这两个函数是用来维护一个对象池,池子最大为10。Children 需要频繁的创建对象会导致性能问题,所以维护一个固定数量的对象池,每次从对象池拿一个对象进行复制,使用完将各个属性 reset。

const POOL_SIZE = 10;
const traverseContextPool = [];
// 返回一个传入参数构成的对象
// traverseContextPool 长度为 0 则自己构造一个对象出来,否则从 traverseContextPool pop 一个对象
// 再对这个对象的各个属性进行赋值
function getPooledTraverseContext(
  mapResult,
  keyPrefix,
  mapFunction,
  mapContext,
) {
  if (traverseContextPool.length) {
    const traverseContext = traverseContextPool.pop();
    traverseContext.result = mapResult;
    traverseContext.keyPrefix = keyPrefix;
    traverseContext.func = mapFunction;
    traverseContext.context = mapContext;
    traverseContext.count = 0;
    return traverseContext;
  } else {
    return { 
      result: mapResult,
      keyPrefix: keyPrefix,
      func: mapFunction,
      context: mapContext,
      count: 0,
    };
  }
}

releaseTraverseContext

getPooledTraverseContext 产生的对象加入数组中,对象池 >= 10 则不用管

function releaseTraverseContext(traverseContext) {
  traverseContext.result = null;
  traverseContext.keyPrefix = null;
  traverseContext.func = null;
  traverseContext.context = null;
  traverseContext.count = 0;
  if (traverseContextPool.length < POOL_SIZE) {
    traverseContextPool.push(traverseContext);
  }
}

traverseAllChildren

没太多好说的

function traverseAllChildren(children, callback, traverseContext) {
  if (children == null) {
    return 0;
  }
  return traverseAllChildrenImpl(children, '', callback, traverseContext);
}

traverseAllChildrenImpl

它的作用可以理解为

  • children 是可渲染节点,则调用 mapSingleChildIntoContext 把 children 推入 result 数组中
  • children 是数组,则再次对数组中的每个元素调用 traverseAllChildrenImpl,传入的 key 是最新拼接好的
  • children 是对象,则通过 children[Symbol.iterator] 获取到对象的迭代器 iterator, 将迭代的结果放到 traverseAllChildrenImpl 处理

函数核心作用就是通过把传入的 children 数组通过遍历摊平成单个节点,然后去执行 mapSingleChildIntoContext

这个函数比较复杂,函数签名是这样的

  • children 要处理的 children
  • nameSoFar 父级 key,会一层一层拼接传递,用 : 分隔
  • callback 如果当前层级是可渲染节点,undefinedboolean 会变成 nullstringnumber?typeofREACT_ELEMENT_TYPE 或者 REACT_PORTAL_TYPE,会调用 mapSingleChildIntoContext 处理
  • traverseContext 对象池中拿出来的一个对象

/**
 * @param {?*} children Children tree container. `Children.map` 的第一个参数,要处理的 children
 * @param {!string} nameSoFar Name of the key path so far.
 * @param {!function} callback Callback to invoke with each child found. map 时 callback 是
 * `mapSingleChildIntoContext`
 * @param {?*} traverseContext Used to pass information throughout the traversal
 * process. 对象池的一个对象
 * @return {!number} The number of children in this subtree.
 */
function traverseAllChildrenImpl(
  children,
  nameSoFar,
  callback,
  traverseContext,
) {
  // 这个函数核心作用就是通过把传入的 children 数组通过遍历摊平成单个节点
  // 然后去执行 mapSingleChildIntoContext

  // 开始判断 children 的类型
  const type = typeof children;

  if (type === 'undefined' || type === 'boolean') {
    // All of the above are perceived as null.
    children = null;
  }

  // 决定是否调用 callback
  // 是可渲染的节点则为 true
  let invokeCallback = false;

  // 判断是否调用,children === null、type 为可渲染的节点则 invokeCallback 为 true
  if (children === null) {
    invokeCallback = true;
  } else {
    switch (type) {
      case 'string':
      case 'number':
        invokeCallback = true;
        break;
      case 'object':
        switch (children.?typeof) {
          case REACT_ELEMENT_TYPE:
          case REACT_PORTAL_TYPE:
            invokeCallback = true;
        }
    }
  }
  // 如果 children 是可以渲染的节点的话,就直接调用 callback
  // callback 是 mapSingleChildIntoContext
  // 我们先去阅读下 mapSingleChildIntoContext 函数的源码
  if (invokeCallback) {
    callback(
      traverseContext,
      children,
      // If it's the only child, treat the name as if it was wrapped in an array
      // so that it's consistent if the number of children grows.
      // const SEPARATOR = '.';
      nameSoFar === '' ? SEPARATOR + getComponentKey(children, 0) : nameSoFar,
    );
    return 1;
  }

  // nextName 和 nextNamePrefix 都是在处理 key 的命名
  let child;
  let nextName;
  let subtreeCount = 0; // Count of children found in the current subtree.
  // const SUBSEPARATOR = ':';
  const nextNamePrefix =
    nameSoFar === '' ? SEPARATOR : nameSoFar + SUBSEPARATOR;

  // 节点是数组的话,就开始遍历数组,并且把数组中的每个元素再递归执行 traverseAllChildrenImpl
  // 这一步操作也用来摊平数组的
  // React.Children.map(this.props.children, c => [[c, c]])
  // c => [[c, c]] 会被摊平为 [c, c, c, c]
  // 这里如果看不明白的话过会在 mapSingleChildIntoContext 中肯定能看明白
  if (Array.isArray(children)) {
    for (let i = 0; i < children.length; i++) {
      child = children[i];
      nextName = nextNamePrefix + getComponentKey(child, i); // .$dasdsa:
      subtreeCount += traverseAllChildrenImpl(
        child,
        nextName, // 不同点是 nameSoFar 变了,它会在每一层不断拼接,用 : 分隔
        callback,
        traverseContext,
      );
    }
  }  else {
    // 不是数组的话,就看看 children 是否可以支持迭代
    // 通过 obj[Symbol.iterator] 的方式去取
    const iteratorFn = getIteratorFn(children);

    // ... 中间有部分 __DEV__ 下检测使用正确性的代码

    // 只有取出来对象是个函数类型才是正确的
    // 然后就是执行迭代器,重复上面 if 中的逻辑
    const iterator = iteratorFn.call(children);
    let step;
    let ii = 0;
    while (!(step = iterator.next()).done) {
      child = step.value;
      nextName = nextNamePrefix + getComponentKey(child, ii++);
      subtreeCount += traverseAllChildrenImpl(
        child,
        nextName,
        callback,
        traverseContext,
      );
    }
  }
  return subtreeCount;
}

mapSingleChildIntoContext

child 推入 traverseContext 的 result 数组中,child 如果是 ReactElement,则更改 key 了再推入。

只有当传入的 child 是可渲染节点才会调用。如果执行了 mapFunc 返回的是一个数组,则会将数组放到 mapIntoWithKeyPrefixInternal 继续处理。

/**
 * @param bookKeeping 就是我们从对象池子里取出来的东西,`traverseContext`
 * @param child 传入的节点,`children`
 * @param childKey 节点的 key,`nameSoFar`
 */
function mapSingleChildIntoContext(bookKeeping, child, childKey) {
  const {result, keyPrefix, func, context} = bookKeeping; // traverseContext
  // func 就是我们在 React.Children.map(this.props.children, c => c)
  // 中传入的第二个函数参数
  let mappedChild = func.call(context, child, bookKeeping.count++);
  // 判断函数返回值是否为数组
  // 因为可能会出现这种情况
  // React.Children.map(this.props.children, c => [c, c])
  // 对于 c => [c, c] 这种情况来说,每个子元素都会被返回出去两次
  // 也就是说假如有 2 个子元素 c1 c2,那么通过调用 React.Children.map(this.props.children, c => [c, c]) 后
  // 返回的应该是 4 个子元素,c1 c1 c2 c2
  if (Array.isArray(mappedChild)) {
    // 是数组的话就回到最先调用的函数中
    // 然后回到之前 traverseAllChildrenImpl 摊平数组的问题
    // 假如 c => [[c, c]],当执行这个函数时,返回值应该是 [c, c]
    // 然后 [c, c] 会被当成 children 传入
    // traverseAllChildrenImpl 内部逻辑判断是数组又会重新递归执行
    // 所以说即使你的函数是 c => [[[[c, c]]]]
    // 最后也会被递归摊平到 [c, c, c, c]
    mapIntoWithKeyPrefixInternal(mappedChild, result, childKey, c => c);
  } else if (mappedChild != null) {
    // 不是数组且返回值不为空,判断返回值是否为有效的 Element
    // 是的话就把这个元素 clone 一遍并且替换掉 key
    if (isValidElement(mappedChild)) {
      mappedChild = cloneAndReplaceKey(
        mappedChild,
        // Keep both the (mapped) and old keys if they differ, just as 
        // traverseAllChildren used to do for objects as children
        keyPrefix +
          (mappedChild.key && (!child || child.key !== mappedChild.key)
            ? escapeUserProvidedKey(mappedChild.key) + '/'
            : '') +
          childKey,
      );
    }
    result.push(mappedChild);
  }
}

map 测试

map 代码就是上面这些,写一个 demo 看看执行过程。

class Child extends Component {
  render() {
    console.log('this.props.children', this.props.children)
    const c = React.Children.map(this.props.children, c => {
      debugger
      return c
    })
    console.log('mappedChildren', c)
    return <div>{c}</div>
  }
}

export default class Children extends Component {
  render() {
    // return 的代码包含 2 种情况:children 是和不是数组
    return (
      <Child>
        <div>
          childrendasddadas
          <div>childrendasddadas</div>
          <div>childrendasddadas</div>
        </div>
        <div key="key2">childrendasddadas</div>
        <div key="key3">childrendasddadas</div>
        {[
          <div key="key4">childrendasddadas</div>,
          <div key="key5=">childrendasddadas</div>,
          <div key="key6:">childrendasddadas</div>,
        ]}
      </Child>
    )
  }
}

打印的结果如下

React.Children.map 就是把传进去的 this.props.children 全部摊平,最后返回的一定是一维数组,数组中的对象都会添加上 key 属性。对 mappedChildren key 的生成做分析如下。

this.props.children 自身是一个数组,在第一次调用 traverseAllChildrenImpl 时,nextName.0,第一个 child 执行 traverseAllChildrenImpl 时,invokeCallback 为 true,nameSoFar.0,再执行 mapSingleChildIntoContext 走到 cloneAndReplaceKey ,新 key 生成为 .0(因为 (mappedChild.key && (!child || child.key !== mappedChild.key) 为 false,keyPrefix 为空字符串)。

第二个和第三个 child 的 key 加上了 .$,在 traverseAllChildrenImpl 中,遍历到第二个和第三个下标时 nextName = nextNamePrefix + getComponentKey(child, i);nextNamePrefix.i 是 2、3,getComponentKey 执行,由于它有自己的 key,所以 escape 后变成 . + $key2 => .$key2.$key3 同理。

function escape(key) {
  const escapeRegex = /[=:]/g;
  const escaperLookup = {
    '=': '=0', // 替换 =
    ':': '=2', // 替换 :
  };
  const escapedString = ('' + key).replace(escapeRegex, function(match) {
    return escaperLookup[match];
  });

  return '$' + escapedString; // 返回的字符串前面加上 $
}

第四、五、六个是嵌套在数组里面的,同上面,this.props.children 遍历到这个数组的时候索引为 3。传给下一轮 traverseAllChildrenImplnameSoFar.3child 为数组,下一 轮traverseAllChildrenImpl ,children 是一个数组,对其进行遍历,nextNamePrefix.3:,由下面这句计算出来。

const nextNamePrefix = nameSoFar === '' ? SEPARATOR : nameSoFar + SUBSEPARATOR;

getComponentKey(child, i),由于数组中的每个元素有自己的 key,所以返回的是 $key4$key5=0$key6=2,拼接出来就是 .3:$key4.3:$key5=0.3:$key6=2,这里第五、六个的 =: 被 escape 处理成了 =0=2

上面例子代码 debugger 时的调用栈:

下面贴一张 map 的流程图。

forEach

类似 array.forEach

map 的不同之处是传给 getPooledTraverseContext 的参数 result 为 null,因为 forEach 只需要遍历,不需要返回一个数组。另外 traverseAllChildren 它的第二个参数变成了 forEachSingleChild

它没有 map 那么复杂。

forEachChildren

调用 traverseAllChildren 让每个 child 都被放到 forEachSingleChild 中执行

/**
 * Iterates through children that are typically specified as `props.children`.
 * The provided forEachFunc(child, index) will be called for each
 * leaf child.
 *
 * @param {?*} children Children tree container. `this.props.children`
 * @param {function(*, int)} forEachFunc 遍历函数
 * @param {*} forEachContext Context for forEachContext. 遍历函数的上下文
 */
function forEachChildren(children, forEachFunc, forEachContext) {
  if (children == null) {
    return children;
  }
  const traverseContext = getPooledTraverseContext(
    null,
    null,
    forEachFunc,
    forEachContext,
  );
  traverseAllChildren(children, forEachSingleChild, traverseContext);
  releaseTraverseContext(traverseContext);
}

forEachSingleChild

children 中的每个元素放到 func 中执行

/**
 * 把 `children` 中的每个元素放到 `func` 中执行
 *
 * @param bookKeeping traverseContext
 * @param child 单个可 render child
 * @param name 这里没有用到
 */
function forEachSingleChild(bookKeeping, child, name) {
  const {func, context} = bookKeeping;
  func.call(context, child, bookKeeping.count++);
}

count

计算 children 的个数,计算的是摊平后数组元素的个数

countChildren

traverseAllChildren 有一个返回值 subtreeCount,表示子节点的个数,traverseAllChildren 遍历所有 child 之后,subtreeCount 会统计出结果。

/**
 * 计算 children 的个数,计算的是摊平后数组元素的个数
 * Count the number of children that are typically specified as
 * `props.children`.
 *
 * @param {?*} children Children tree container.
 * @return {number} The number of children.
 */
function countChildren(children) {
  return traverseAllChildren(children, () => null, null);
}

toArray

mapChildren(children, child => child, context)

/**
 * 是 `mapChildren(children, child => child, context)` 版本
 * Flatten a children object (typically specified as `props.children`) and
 * return an array with appropriately re-keyed children.
 */
function toArray(children) {
  const result = [];
  mapIntoWithKeyPrefixInternal(children, result, null, child => child);
  return result;
}

only

如果参数是一个 ReactElement,则直接返回它,否则报错,用在测试中,正式代码没什么用。

/**
 * Returns the first child in a collection of children and verifies that there
 * is only one child in the collection.
 * The current implementation of this function assumes that a single child gets
 * passed without a wrapper, but the purpose of this helper function is to
 * abstract away the particular structure of children.
 *
 * @param {?object} children Child collection structure.
 * @return {ReactElement} The first and only `ReactElement` contained in the
 * structure.
 */
function onlyChild(children) {
  invariant(
    isValidElement(children),
    'React.Children.only expected to receive a single React element child.',
  );
  return children;
}

function isValidElement(object) {
  return (
    typeof object === 'object' &&
    object !== null &&
    object.?typeof === REACT_ELEMENT_TYPE
  );
}

导出的函数中,map 是最复杂的,把每个函数的意义和签名都读懂之后我对整体有了比较深的认识。看一看流程图,整个过程就清楚了。


广告时间

欢迎关注,每日进步!!!