阅读 463

图解React源码 - fiber 树构造(基础准备)

这是我参与8月更文挑战的第7天,活动详情查看:8月更文挑战

在 React 运行时中, fiber树构造位于react-reconciler包.

在正式解读fiber树构造之前, 再次回顾一下reconciler 运作流程的 4 个阶段:

reactfiberworkloop.png

  1. 输入阶段: 衔接react-dom包, 承接fiber更新请求(可以参考React 应用的启动过程).
  2. 注册调度任务: 与调度中心(scheduler包)交互, 注册调度任务task, 等待任务回调(可以参考React 调度原理(scheduler)).
  3. 执行任务回调: 在内存中构造出fiber树DOM对象, 也是fiber 树构造的重点类容.
  4. 输出: 与渲染器(react-dom)交互, 渲染DOM节点.

fiber树构造处于上述第 3 个阶段, 可以通过不同的视角来理解fiber树构造React运行时中所处的位置:

  • scheduler调度中心的角度来看, 它是任务队列taskQueue中的一个具体的任务回调(task.callback).
  • React 工作循环的角度来看, 它属于fiber树构造循环.

由于fiber 树构造源码量比较大, 本系列根据React运行的内存状态, 分为 2 种情况来说明:

  1. 初次创建: 在React应用首次启动时, 界面还没有渲染, 此时并不会进入对比过程, 相当于直接构造一棵全新的树.
  2. 对比更新: React应用启动后, 界面已经渲染. 如果再次发生更新, 创建新fiber之前需要和旧fiber进行对比. 最后构造的 fiber 树有可能是全新的, 也可能是部分更新的.

无论是初次创建还是对比更新, 基础概念都是通用的, 本节将介绍这些基础知识, 为正式进入fiber树构造做准备.

ReactElement, Fiber, DOM 三者的关系

  1. ReactElement 对象(type 定义在shared 包中)

    • 所有采用jsx语法书写的节点, 都会被编译器转换, 最终会以React.createElement(...)的方式, 创建出来一个与之对应的ReactElement对象
  2. fiber 对象(type 类型的定义在ReactInternalTypes.js中)

    • fiber对象是通过ReactElement对象进行创建的, 多个fiber对象构成了一棵fiber树, fiber树是构造DOM树的数据模型, fiber树的任何改动, 最后都体现到DOM树.
  3. DOM 对象: 文档对象模型

    • DOM将文档解析为一个由节点和对象(包含属性和方法的对象)组成的结构集合, 也就是常说的DOM树.
    • JavaScript可以访问和操作存储在 DOM 中的内容, 也就是操作DOM对象, 进而触发 UI 渲染.

它们之间的关系反映了我们书写的 JSX 代码到 DOM 节点的转换过程:

code2dom.png

注意:

  • 开发人员能够控制的是JSX, 也就是ReactElement对象.
  • fiber树是通过ReactElement生成的, 如果脱离了ReactElement,fiber树也无从谈起. 所以是ReactElement树(不是严格的树结构, 为了方便也称为树)驱动fiber树.
  • fiber树DOM树的数据模型, fiber树驱动DOM树

开发人员通过编程只能控制ReactElement树的结构, ReactElement树驱动fiber树, fiber树再驱动DOM树, 最后展现到页面上. 所以fiber树的构造过程, 实际上就是ReactElement对象到fiber对象的转换过程.

全局变量

React 工作循环的角度来看, 整个构造过程被包裹在fiber树构造循环中(对应源码位于ReactFiberWorkLoop.js).

React运行时, ReactFiberWorkLoop.js闭包中的全局变量会随着fiber树构造循环的进行而变化, 现在查看其中重要的全局变量(源码链接):

// 当前React的执行栈(执行上下文)
let executionContext: ExecutionContext = NoContext;

// 当前root节点
let workInProgressRoot: FiberRoot | null = null;
// 正在处理中的fiber节点
let workInProgress: Fiber | null = null;
// 正在渲染的车道(复数)
let workInProgressRootRenderLanes: Lanes = NoLanes;

// 包含所有子节点的优先级, 是workInProgressRootRenderLanes的超集
// 大多数情况下: 在工作循环整体层面会使用workInProgressRootRenderLanes, 在begin/complete阶段层面会使用 subtreeRenderLanes
let subtreeRenderLanes: Lanes = NoLanes;
// 一个栈结构: 专门存储当前节点的 subtreeRenderLanes
const subtreeRenderLanesCursor: StackCursor<Lanes> = createCursor(NoLanes);

// fiber构造完后, root节点的状态: completed, errored, suspended等
let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
// 重大错误
let workInProgressRootFatalError: mixed = null;
// 整个render期间所使用到的所有lanes
let workInProgressRootIncludedLanes: Lanes = NoLanes;
// 在render期间被跳过(由于优先级不够)的lanes: 只包括未处理的updates, 不包括被复用的fiber节点
let workInProgressRootSkippedLanes: Lanes = NoLanes;
// 在render期间被修改过的lanes
let workInProgressRootUpdatedLanes: Lanes = NoLanes;

// 防止无限循环和嵌套更新
const NESTED_UPDATE_LIMIT = 50;
let nestedUpdateCount: number = 0;
let rootWithNestedUpdates: FiberRoot | null = null;

const NESTED_PASSIVE_UPDATE_LIMIT = 50;
let nestedPassiveUpdateCount: number = 0;

// 发起更新的时间
let currentEventTime: number = NoTimestamp;
let currentEventWipLanes: Lanes = NoLanes;
let currentEventPendingLanes: Lanes = NoLanes;
复制代码

在源码中, 大部分变量都带有英文注释(读者可自行查阅), 此处只列举了fiber树构造循环中最核心的变量

执行上下文

在全局变量中有executionContext, 代表渲染期间执行栈(或叫做执行上下文), 它也是一个二进制表示的变量, 通过位运算进行操作(参考React 算法之位运算). 在源码中一共定义了 8 种执行执行栈:

type ExecutionContext = number;
export const NoContext = /*             */ 0b0000000;
const BatchedContext = /*               */ 0b0000001;
const EventContext = /*                 */ 0b0000010;
const DiscreteEventContext = /*         */ 0b0000100;
const LegacyUnbatchedContext = /*       */ 0b0001000;
const RenderContext = /*                */ 0b0010000;
const CommitContext = /*                */ 0b0100000;
复制代码

上文回顾了reconciler 运作流程的 4 个阶段, 这 4 个阶段只是一个整体划分. 如果具体到每一次更新, 是有差异的. 比如说: Legacy模式下的所有更新, 不会经过调度中心(第 2 阶段),而是直接进入fiber树构造(第 3 阶段).

事实上正是executionContext在操控reconciler 运作流程(源码体现在scheduleUpdateOnFiber 函数).

export function scheduleUpdateOnFiber(
  fiber: Fiber,
  lane: Lane,
  eventTime: number,
) {
  if (lane === SyncLane) {
    // legacy或blocking模式
    if (
      (executionContext & LegacyUnbatchedContext) !== NoContext &&
      (executionContext & (RenderContext | CommitContext)) === NoContext
    ) {
      performSyncWorkOnRoot(root);
    } else {
      // 后续的更新
      // 进入第2阶段, 注册调度任务
      ensureRootIsScheduled(root, eventTime);
      if (executionContext === NoContext) {
        // 如果执行上下文为空, 会取消调度任务, 手动执行回调
        // 进入第3阶段, 进行fiber树构造
        flushSyncCallbackQueue();
      }
    }
  } else {
    // concurrent模式
    // 无论是否初次更新, 都正常进入第2阶段, 注册调度任务
    ensureRootIsScheduled(root, eventTime);
  }
}
复制代码

在 render 过程中, 每一个阶段都会改变executionContext(render 之前, 会设置executionContext |= RenderContext; commit 之前, 会设置executionContext |= CommitContext), 假设在render过程中再次发起更新(如在UNSAFE_componentWillReceiveProps生命周期中调用setState)则可通过executionContext来判断当前的render状态.

双缓冲技术(double buffering)

在全局变量中有workInProgress, 还有不少以workInProgress来命名的变量. workInProgress的应用实际上就是React的双缓冲技术(double buffering).

在上文我们梳理了ReactElement, Fiber, DOM三者的关系, fiber树的构造过程, 就是把ReactElement转换成fiber树的过程. 在这个过程中, 内存里会同时存在 2 棵fiber树:

  • 其一: 代表当前界面的fiber树(已经被展示出来, 挂载到fiberRoot.current上). 如果是初次构造(初始化渲染), 页面还没有渲染, 此时界面对应的 fiber 树为空(fiberRoot.current = null).
  • 其二: 正在构造的fiber树(即将展示出来, 挂载到HostRootFiber.alternate上, 正在构造的节点称为workInProgress). 当构造完成之后, 重新渲染页面, 最后切换fiberRoot.current = workInProgress, 使得fiberRoot.current重新指向代表当前界面的fiber树.

此处涉及到 2 个全局对象fiberRootHostRootFiber, 在React 应用的启动过程中有详细的说明.

用图来表述double buffering的概念如下:

  1. 构造过程中, fiberRoot.current指向当前界面对应的fiber树.

fibertreecreate1-progress.png

  1. 构造完成并渲染, 切换fiberRoot.current指针, 使其继续指向当前界面对应的fiber树(原来代表界面的 fiber 树, 变成了内存中).

fibertreecreate2-complete.png

优先级 {#lanes}

在全局变量中有不少变量都以 Lanes 命名(如workInProgressRootRenderLanes,subtreeRenderLanes其作用见上文注释), 它们都与优先级相关.

在前文React 中的优先级管理中, 我们介绍了React中有 3 套优先级体系, 并了解了它们之间的关联. 现在fiber树构造过程中, 将要深入分析车道模型Lane的具体应用.

在整个react-reconciler包中, Lane的应用可以分为 3 个方面:

update优先级(update.lane) {#update-lane}

React 应用中的高频对象一文中, 介绍过update对象, 它是一个环形链表. 对于单个update对象来讲, update.lane代表它的优先级, 称之为update优先级.

观察其构造函数(源码链接),其优先级是由外界传入.

export function createUpdate(eventTime: number, lane: Lane): Update<*> {
  const update: Update<*> = {
    eventTime,
    lane,
    tag: UpdateState,
    payload: null,
    callback: null,
    next: null,
  };
  return update;
}
复制代码

React体系中, 有 2 种情况会创建update对象:

  1. 应用初始化: 在react-reconciler包中的updateContainer函数中(源码)
    export function updateContainer(
      element: ReactNodeList,
      container: OpaqueRoot,
      parentComponent: ?React$Component<any, any>,
      callback: ?Function,
    ): Lane {
      const current = container.current;
      const eventTime = requestEventTime();
      const lane = requestUpdateLane(current); // 根据当前时间, 创建一个update优先级
      const update = createUpdate(eventTime, lane); // lane被用于创建update对象
      update.payload = { element };
      enqueueUpdate(current, update);
      scheduleUpdateOnFiber(current, lane, eventTime);
      return lane;
    }
    复制代码
  2. 发起组件更新: 假设在 class 组件中调用setState(源码)
const classComponentUpdater = {
  isMounted,
  enqueueSetState(inst, payload, callback) {
    const fiber = getInstance(inst);
    const eventTime = requestEventTime(); // 根据当前时间, 创建一个update优先级
    const lane = requestUpdateLane(fiber); // lane被用于创建update对象
    const update = createUpdate(eventTime, lane);
    update.payload = payload;
    enqueueUpdate(fiber, update);
    scheduleUpdateOnFiber(fiber, lane, eventTime);
  },
};
复制代码

可以看到, 无论是应用初始化或者发起组件更新, 创建update.lane的逻辑都是一样的, 都是根据当前时间, 创建一个 update 优先级.

requestUpdateLane:

export function requestUpdateLane(fiber: Fiber): Lane {
  // Special cases
  const mode = fiber.mode;
  if ((mode & BlockingMode) === NoMode) {
    // legacy 模式
    return (SyncLane: Lane);
  } else if ((mode & ConcurrentMode) === NoMode) {
    // blocking模式
    return getCurrentPriorityLevel() === ImmediateSchedulerPriority
      ? (SyncLane: Lane)
      : (SyncBatchedLane: Lane);
  }
  // concurrent模式
  if (currentEventWipLanes === NoLanes) {
    currentEventWipLanes = workInProgressRootIncludedLanes;
  }
  const isTransition = requestCurrentTransition() !== NoTransition;
  if (isTransition) {
    // 特殊情况, 处于suspense过程中
    if (currentEventPendingLanes !== NoLanes) {
      currentEventPendingLanes =
        mostRecentlyUpdatedRoot !== null
          ? mostRecentlyUpdatedRoot.pendingLanes
          : NoLanes;
    }
    return findTransitionLane(currentEventWipLanes, currentEventPendingLanes);
  }
  // 正常情况, 获取调度优先级
  const schedulerPriority = getCurrentPriorityLevel();
  let lane;
  if (
    (executionContext & DiscreteEventContext) !== NoContext &&
    schedulerPriority === UserBlockingSchedulerPriority
  ) {
    // executionContext 存在输入事件. 且调度优先级是用户阻塞性质
    lane = findUpdateLane(InputDiscreteLanePriority, currentEventWipLanes);
  } else {
    // 调度优先级转换为车道模型
    const schedulerLanePriority = schedulerPriorityToLanePriority(
      schedulerPriority,
    );
    lane = findUpdateLane(schedulerLanePriority, currentEventWipLanes);
  }
  return lane;
}
复制代码

可以看到requestUpdateLane的作用是返回一个合适的 update 优先级.

  1. legacy 模式: 返回SyncLane
  2. blocking 模式: 返回SyncLane
  3. concurrent 模式:
    • 正常情况下, 根据当前的调度优先级来生成一个lane.
    • 特殊情况下(处于 suspense 过程中), 会优先选择TransitionLanes通道中的空闲通道(如果所有TransitionLanes通道都被占用, 就取最高优先级. 源码).

最后通过scheduleUpdateOnFiber(current, lane, eventTime);函数, 把update.lane正式带入到了输入阶段.

scheduleUpdateOnFiber输入阶段的必经函数, 在本系列的文章中已经多次提到, 此处以update.lane的视角分析:

export function scheduleUpdateOnFiber(
  fiber: Fiber,
  lane: Lane,
  eventTime: number,
) {
  if (lane === SyncLane) {
    // legacy或blocking模式
    if (
      (executionContext & LegacyUnbatchedContext) !== NoContext &&
      (executionContext & (RenderContext | CommitContext)) === NoContext
    ) {
      performSyncWorkOnRoot(root);
    } else {
      ensureRootIsScheduled(root, eventTime); // 注册回调任务
      if (executionContext === NoContext) {
        flushSyncCallbackQueue(); // 取消schedule调度 ,主动刷新回调队列,
      }
    }
  } else {
    // concurrent模式
    ensureRootIsScheduled(root, eventTime);
  }
}
复制代码

lane === SyncLane也就是 legacy 或 blocking 模式中, 注册完回调任务之后(ensureRootIsScheduled(root, eventTime)), 如果执行上下文为空, 会取消 schedule 调度, 主动刷新回调队列flushSyncCallbackQueue().

这里包含了一个热点问题(setState到底是同步还是异步)的标准答案:

  • 如果逻辑进入flushSyncCallbackQueue(executionContext === NoContext), 则会主动取消调度, 并刷新回调, 立即进入fiber树构造过程. 当执行setState下一行代码时, fiber树已经重新渲染了, 故setState体现为同步.
  • 正常情况下, 不会取消schedule调度. 由于schedule调度是通过MessageChannel触发(宏任务), 故体现为异步.

渲染优先级(renderLanes)

这是一个全局概念, 每一次render之前, 首先要确定本次render的优先级. 具体对应到源码如下:

// ...省略无关代码
function performSyncWorkOnRoot(root) {
  let lanes;
  let exitStatus;
  // 获取本次`render`的优先级
  lanes = getNextLanes(root, lanes);
  exitStatus = renderRootSync(root, lanes);
}
// ...省略无关代码
function performConcurrentWorkOnRoot(root) {
  // 获取本次`render`的优先级
  let lanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );
  if (lanes === NoLanes) {
    return null;
  }
  let exitStatus = renderRootConcurrent(root, lanes);
}
复制代码

可以看到, 无论是Legacy还是Concurrent模式, 在正式render之前, 都会调用getNextLanes获取一个优先级(源码链接).

// ...省略部分代码
export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
  // 1. check是否有等待中的lanes
  const pendingLanes = root.pendingLanes;
  if (pendingLanes === NoLanes) {
    return_highestLanePriority = NoLanePriority;
    return NoLanes;
  }
  let nextLanes = NoLanes;
  let nextLanePriority = NoLanePriority;
  const expiredLanes = root.expiredLanes;
  const suspendedLanes = root.suspendedLanes;
  const pingedLanes = root.pingedLanes;
  // 2. check是否有已过期的lanes
  if (expiredLanes !== NoLanes) {
    nextLanes = expiredLanes;
    nextLanePriority = return_highestLanePriority = SyncLanePriority;
  } else {
    const nonIdlePendingLanes = pendingLanes & NonIdleLanes;
    if (nonIdlePendingLanes !== NoLanes) {
      // 非Idle任务 ...
    } else {
      // Idle任务 ...
    }
  }
  if (nextLanes === NoLanes) {
    return NoLanes;
  }
  return nextLanes;
}
复制代码

getNextLanes会根据fiberRoot对象上的属性(expiredLanes, suspendedLanes, pingedLanes等), 确定出当前最紧急的lanes.

此处返回的lanes会作为全局渲染的优先级, 用于fiber树构造过程中. 针对fiber对象update对象, 只要它们的优先级(如: fiber.lanesupdate.lane)比渲染优先级低, 都将会被忽略.

以下源码展示了无论是renderRootSyncrenderRootConcurrent在调用render之前, 都会通过getNextLanes获取全局渲染优先级, 并且在fiber树构造过程中使用.

function performSyncWorkOnRoot(root) {
  let lanes;
  let exitStatus;
  if (
    root === workInProgressRoot &&
    includesSomeLane(root.expiredLanes, workInProgressRootRenderLanes)
  ) {
    // 渲染优先级
    lanes = getNextLanes(root, lanes);
    exitStatus = renderRootSync(root, lanes);
  } else {
    // 渲染优先级
    lanes = getNextLanes(root, NoLanes);
    exitStatus = renderRootSync(root, lanes);
  }
  // ...
}

function performConcurrentWorkOnRoot(root) {
  // ...

  let lanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );
  // 渲染优先级
  let exitStatus = renderRootConcurrent(root, lanes);

  // ...
}
复制代码

fiber优先级(fiber.lanes)

React 应用中的高频对象一文中, 介绍过fiber对象的数据结构. 其中有 2 个属性与优先级相关:

  1. fiber.lanes: 代表本节点的优先级
  2. fiber.childLanes: 代表子节点的优先级 从FiberNode的构造函数中可以看出, fiber.lanesfiber.childLanes的初始值都为NoLanes, 在fiber树构造过程中, 使用全局的渲染优先级(renderLanes)和fiber.lanes判断fiber节点是否更新(源码地址).
    • 如果全局的渲染优先级renderLanes不包括fiber.lanes, 证明该fiber节点没有更新, 可以复用.
    • 如果不能复用, 进入创建阶段.
function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  const updateLanes = workInProgress.lanes;
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;
    if (
      oldProps !== newProps ||
      hasLegacyContextChanged() ||
      // Force a re-render if the implementation changed due to hot reload:
      (__DEV__ ? workInProgress.type !== current.type : false)
    ) {
      didReceiveUpdate = true;
    } else if (!includesSomeLane(renderLanes, updateLanes)) {
      didReceiveUpdate = false;
      // 本`fiber`节点的没有更新, 可以复用, 进入bailout逻辑
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }
  // 不能复用, 创建新的fiber节点
  workInProgress.lanes = NoLanes; // 重置优先级为 NoLanes
  switch (workInProgress.tag) {
    case ClassComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);

      return updateClassComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        // 正常情况想渲染优先级会被用于fiber树的构造过程
        renderLanes,
      );
    }
  }
}
复制代码

栈帧管理

React源码中, 每一次执行fiber树构造(也就是调用performSyncWorkOnRoot或者performConcurrentWorkOnRoot函数)的过程, 都需要一些全局变量来保存状态. 在上文中已经介绍最核心的全局变量.

如果从单个变量来看, 它们就是一个个的全局变量. 如果将这些全局变量组合起来, 它们代表了当前fiber树构造的活动记录. 通过这一组全局变量, 可以还原fiber树构造过程(比如时间切片的实现过程(参考React 调度原理), fiber树构造过程被打断之后需要还原进度, 全靠这一组全局变量). 所以每次fiber树构造是一个独立的过程, 需要独立的一组全局变量, 在React内部把这一个独立的过程封装为一个栈帧stack(简单来说就是每次构造都需要独立的空间. 对于栈帧的深入理解, 请读者自行参考其他资料).

所以在进行fiber树构造之前, 如果不需要恢复上一次构造进度, 都会刷新栈帧(源码在prepareFreshStack 函数)

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
  const prevExecutionContext = executionContext;
  executionContext |= RenderContext;
  const prevDispatcher = pushDispatcher();
  // 如果fiberRoot变动, 或者update.lane变动, 都会刷新栈帧, 丢弃上一次渲染进度
  if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
    resetRenderTimer();
    // 刷新栈帧
    prepareFreshStack(root, lanes);
    startWorkOnPendingInteractions(root, lanes);
  }
}

/**
刷新栈帧: 重置 FiberRoot上的全局属性 和 `fiber树构造`循环过程中的全局变量
*/
function prepareFreshStack(root: FiberRoot, lanes: Lanes) {
  // 重置FiberRoot对象上的属性
  root.finishedWork = null;
  root.finishedLanes = NoLanes;
  const timeoutHandle = root.timeoutHandle;
  if (timeoutHandle !== noTimeout) {
    root.timeoutHandle = noTimeout;
    cancelTimeout(timeoutHandle);
  }
  if (workInProgress !== null) {
    let interruptedWork = workInProgress.return;
    while (interruptedWork !== null) {
      unwindInterruptedWork(interruptedWork);
      interruptedWork = interruptedWork.return;
    }
  }
  // 重置全局变量
  workInProgressRoot = root;
  workInProgress = createWorkInProgress(root.current, null); // 给HostRootFiber对象创建一个alternate, 并将其设置成全局 workInProgress
  workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes;
  workInProgressRootExitStatus = RootIncomplete;
  workInProgressRootFatalError = null;
  workInProgressRootSkippedLanes = NoLanes;
  workInProgressRootUpdatedLanes = NoLanes;
  workInProgressRootPingedLanes = NoLanes;
}
复制代码

注意其中的createWorkInProgress(root.current, null), 其参数root.currentHostRootFiber, 作用是给HostRootFiber创建一个alternate副本.workInProgress指针指向这个副本(即workInProgress = HostRootFiber.alternate), 在上文double buffering中分析过, HostRootFiber.alternate正在构造的fiber树的根节点.

总结

本节是fiber树构造的准备篇, 首先在宏观上从不同的视角(任务调度循环, fiber树构造循环)介绍了fiber树构造React体系中所处的位置, 然后深入react-reconciler包分析fiber树构造过程中需要使用到的全局变量, 并解读了双缓冲技术优先级(车道模型)的使用, 最后解释栈帧管理的实现细节. 有了这些基础知识, fiber树构造的具体实现过程会更加简单清晰.

写在最后

本文属于图解react源码系列中的运行核心板块, 本系列近 20 篇文章,真的是为了搞懂React源码, 进而提升架构和编码能力.

目前图解部分初稿已经全部完成, 将在8月全部更新, 如文章有表述错误, 会在github第一时间修正.

文章分类
前端
文章标签