React18系列reconciler从0实现过程详解

 更新时间:2023年01月16日 15:39:38   作者:sunnyhuang519626  
这篇文章主要介绍了React18系列reconciler从0实现过程详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪

引言

本系列是讲述从0开始实现一个react18的基本版本。由于React源码通过Mono-repo 管理仓库,我们也是用pnpm提供的workspaces来管理我们的代码仓库,打包我们使用rollup进行打包。

仓库地址

我们这一次主要写有关调和(reconciler)和ReactDom,React将调和单独的抽出一个包,暴露出入口,通过不同的宿主环境去调用不同的api。

React-Dom包

这个包主要是提供浏览器环境的一些dom操作。主要是提供2个文件hostConfig.ts 以及root.ts。 想想我们在React18中,是通过如下方式调用的。所以我们需要提供一个方法createRoot方法,返回要给包含render函数的对象。

import ReactDOM from 'react-dom/client';
ReactDOM.createRoot(root).render(<App />)

createRoot

主要功能是2个,第一个是创建根fiberNode节点, 第二个创建更新(初始化主要是用于渲染),开始调度。

//createRoot.ts 文件
import {
  createContainer,
  updateContainer,
} from "../../react-reconciler/src/filerReconciler";
export function createRoot(container: Container) {
  const root = createContainer(container);
  return {
    render(element: ReactElementType) {
      updateContainer(element, root);
    },
  };
}

createRoot.js主要是调用的react-reconcilercreateContainer方法和updateContainer方法。我们之后看看这2个方法主要的作用

hostConfig.ts

主要是创建各种dom,已经dom的插入操作

export const createInstance = (type: string, props: any): Instance => {
  // TODO 处理props
  const element = document.createElement(type);
  return element;
};
export const appendInitialChild = (
  parent: Instance | Container,
  child: Instance
) => {
  parent.appendChild(child);
};
export const createTextInstance = (content: string) => {
  return document.createTextNode(content);
};
export const appendChildToContainer = appendInitialChild;

React-reconciler包

createContainer() 函数

从上面我们可以知道,首先调用的createContainerupdateContainer,我们把它写到filerReconciler.tscreateContainer接受传入的dom元素。

/**
 * ReactDOM.createRoot()中调用
 * 1. 创建fiberRootNode 和 hostRootFiber。并建立联系
 * @param {Container} container
 */
export function createContainer(container: Container) {
  const hostRootFiber = new FiberNode(HostRoot, {}, null);
  const fiberRootNode = new FiberRootNode(container, hostRootFiber);
  hostRootFiber.updateQueue = createUpdateQueue();
  return fiberRootNode;
}

可以看到我们在这里主要就是2个事情

调用了2个方法去创建2个不同的fiberNode,一个是hostRootFiber,一个是fiberRootNode

创建一个更新队列,并将其赋值给hostRootFiber

/**
 * 顶部节点
 */
export class FiberRootNode {
  container: Container; // 不同环境的不同的节点 在浏览器环境 就是 root节点
  current: FiberNode;
  finishedWork: FiberNode | null; // 递归完成后的hostRootFiber
  constructor(container: Container, hostRootFiber: FiberNode) {
    this.container = container;
    this.current = hostRootFiber;
    hostRootFiber.stateNode = this;
    this.finishedWork = null;
  }
}
export class FiberNode {
  constructor(tag: WorkTag, pendingProps: Props, key: Key) {
    this.tag = tag;
    this.pendingProps = pendingProps;
    this.key = key;
    this.stateNode = null; // dom引用
    this.type = null; // 组件本身  FunctionComponent () => {}
    // 树状结构
    this.return = null; // 指向父fiberNode
    this.sibling = null; // 兄弟节点
    this.child = null; // 子节点
    this.index = 0; // 兄弟节点的索引
    this.ref = null;
    // 工作单元
    this.pendingProps = pendingProps; // 等待更新的属性
    this.memoizedProps = null; // 正在工作的属性
    this.memoizedState = null;
    this.updateQueue = null;
    this.alternate = null; // 双缓存树指向(workInProgress 和 current切换)
    this.flags = NoFlags; // 副作用标识
    this.subtreeFlags = NoFlags; // 子树中的副作用
  }
}

接下来,我们看看createUpdateQueue里面的执行逻辑。执行了一个函数,返回了一个对象。所以现在hostRootFiberupdateQueue指向了这个指针

/**
 * 初始化updateQueue
 * @returns {UpdateQueue<Action>}
 */
export const createUpdateQueue = <State>() => {
  return {
    shared: {
      pending: null,
    },
  } as UpdateQueue<State>;
};

我们从上面createRoot执行完后,返回了一个render函数,我们接下来看看render后的执行过程,是怎么渲染到页面的。

render() 调用

createRoot执行后,创建了一个rootFiberNode, 并返回了render调用,主要是执行了updateContainer用于去渲染初始化的工作。

updateContainer接受2个参数,第一个参数是传入的ReactElement(), 第二个参数是fiberRootNode

主要是做3件事情:

  • 创建一个更新事件
  • 把更新事件推进队列中
  • 调用调度,开始更新
/**
 * ReactDOM.createRoot().render 中调用更新
 * 1. 创建update, 并将其推到enqueueUpdate中
 */
export function updateContainer(
  element: ReactElementType | null,
  root: FiberRootNode
) {
  const hostRootFiber = root.current;
  const update = createUpdate<ReactElementType | null>(element);
  enqueueUpdate(
    hostRootFiber.updateQueue as UpdateQueue<ReactElementType | null>,
    update
  );
  // 插入更新后,进入调度
  scheduleUpdateOnFiber(hostRootFiber);
  return element;
}

创建更新createUpdate

实际上就是创建一个对象,由于初始化的时候传入的是ReactElementType(), 所以返回的是App对应的ReactElement对象

/**
 * 创建更新
 * @param {Action<State>} action
 * @returns {Update<State>}
 */
export const createUpdate = (action) => {
  return {
    action,
  };
};

将更新推进队列enqueueUpdate

接受2个参数,第一个参数是我们创建一个更新队列的引用,第二个是新增的队列

/**
 * 更新update
 * @param {UpdateQueue<Action>} updateQueue
 * @param {Update<Action>} update
 */
export const enqueueUpdate = <State>(
  updateQueue: UpdateQueue<State>,
  update: Update<State>
) => {
  updateQueue.shared.pending = update;
};

执行到这一步骤,我们得到了更新队列,其实是一个ReactElement组件 及我们调用render传入的jsx对象。

开始调用scheduleUpdateOnFiber

接受FiberNode开始执行我们的渲染工作, 一开始渲染传入的是hostFiberNode 之后其他更新传递的是对应的fiberNode

export function scheduleUpdateOnFiber(fiber: FiberNode) {
  // todo 调度功能
  let root = markUpdateFromFiberToRoot(fiber);
  renderRoot(root);
}

wookLoop

执行完上面的操作后,接下来进入的调和阶段。开始我们要明白一个关键词:

workInProgress: 表示当前正在调和的fiber节点,之后简称wip

beginWork: 主要是根据当前fiberNode创建下一级fiberNode,在update时标记placement(新增、移动)ChildDeletion(删除)

completeWork: 在mount时构建Dom Tree, 初始化属性,在Update时标记Update(属性更新),最终执行flags冒泡

flags冒泡我们下一节讲。

从上面我们可以看到调用了scheduleUpdateOnFiber方法,开始从根部渲染页面。scheduleUpdateOnFiber主要是执行了2个方法:

markUpdateFromFiberToRoot: 由于我们更新的节点可能不是hostfiberNode, 这个方法就是不管传入的是那个节点,返回我们的根节点rootFiberNode

// 从当前触发更新的fiber向上遍历到根节点fiber
function markUpdateFromFiberToRoot(fiber: FiberNode) {
  let node = fiber;
  let parent = node.return;
  while (parent !== null) {
    node = parent;
    parent = node.return;
  }
  if (node.tag === HostRoot) {
    return node.stateNode;
  }
  return null;
}

renderRoot: 这里是我们wookLoop的入口,也是调和完成后,将生成的fiberNode树,赋值给finishedWork,并挂在根节点上,进入commit的入口。

function renderRoot(root: FiberRootNode) {
  // 初始化,将workInProgress 指向第一个fiberNode
  prepareFreshStack(root);
  do {
    try {
      workLoop();
      break;
    } catch (e) {
      if (__DEV__) {
        console.warn("workLoop发生错误", e);
      }
      workInProgress = null;
    }
  } while (true);
  const finishedWork = root.current.alternate;
  root.finishedWork = finishedWork;
  // wip fiberNode树  树中的flags执行对应的操作
  commitRoot(root);
}

prepareFreshStack函数: 用于初始化当前节点的wip, 并创建alternate 的双缓存的建立。 由于我们开始的时候传入的hostFiberNode, 经过createWorkInProgress后,创建了一个新的fiberNode 并通过alternate相互指向。并赋值给wip

let workInProgress: FiberNode | null = null;
function prepareFreshStack(root: FiberRootNode) {
  workInProgress = createWorkInProgress(root.current, {});
}
export const createWorkInProgress = (
  current: FiberNode,
  pendingProps: Props
): FiberNode => {
  let wip = current.alternate;
  if (wip === null) {
    //mount
    wip = new FiberNode(current.tag, pendingProps, current.key);
    wip.stateNode = current.stateNode;
    wip.alternate = current;
    current.alternate = wip;
  } else {
    //update
    wip.pendingProps = pendingProps;
    // 清掉副作用(上一次更新遗留下来的)
    wip.flags = NoFlags;
    wip.subtreeFlags = NoFlags;
  }
  wip.type = current.type;
  wip.updateQueue = current.updateQueue;
  wip.child = current.child;
  wip.memoizedProps = current.memoizedProps;
  wip.memoizedState = current.memoizedState;
  return wip;
};

接下来我们来分析一下workLoop中到底是如何生成fiberNode树的。它本身函数执行很简单。就是不停的根据wip进行单个fiberNode的处理。 此时wip指向的hostRootFiber。开始执行performUnitOfWork进行递归操作,其中递:beginWork,归:completeWork。React通过DFS,首先找到对应的叶子节点。

function workLoop() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}
function performUnitOfWork(fiber: FiberNode): void {
  const next = beginWork(fiber); // next 是fiber的子fiber 或者 是null
  // 工作完成,需要将pendingProps 复制给 已经渲染的props
  fiber.memoizedProps = fiber.pendingProps;
  if (next === null) {
    // 没有子fiber
    completeUnitOfWork(fiber);
  } else {
    workInProgress = next;
  }
}

beginWork开始

主要是向下进行遍历,创建不同的fiberNode。由于我们传入的是HostRoot,所以会走到updateHostRoot分支

/**
 * 递归中的递阶段
 * 比较 然后返回子fiberNode 或者null
 */
export const beginWork = (wip: FiberNode) => {
  switch (wip.tag) {
    case HostRoot:
      return updateHostRoot(wip);
    case HostComponent:
      return updateHostComponent(wip);
    case HostText:
      // 文本节点没有子节点,所以没有流程
      return null;
    default:
      if (__DEV__) {
        console.warn("beginWork未实现的类型");
      }
      break;
  }
  return null;
};

updateHostRoot

这个方法主要是2个部分:

  • 根据我们之前创建的更新队列获取到最新的值
  • 创建子fiber
/**
	processUpdateQueue: 是根据不同的类型(函数和其他)生成memoizedState
*/
function updateHostRoot(wip: FiberNode) {
  const baseState = wip.memoizedState;
  const updateQueue = wip.updateQueue as UpdateQueue<ElementType>;
  // 这里获取之前的更新队列
  const pending = updateQueue.shared.pending;
  updateQueue.shared.pending = null;
  const { memoizedState } = processUpdateQueue(baseState, pending); // 最新状态
  wip.memoizedState = memoizedState; // 其实就是传入的element
  const nextChildren = wip.memoizedState; // 就是我们传入的ReactElement 对象
  reconcileChildren(wip, nextChildren);
  return wip.child;
}

reconcileChildren

调和子节点, 根据是否生成过,分别调用不同的方法。通过上面我们知道传入的hostFiber, 此时是存在alternate属性的,所以会走到reconcilerChildFibers分支。

根据当前传入的returnFiberhostFiberNode以及currentFiber为null,newChild为ReactElementType。我们可以判断接下来会走到reconcileSingleElement的执行。其中placeSingleChild是打标记使用的,我们暂时先不研究。

/**
	wip: 当前正在执行的父fiberNode
	children: 即将要生成的子fiberNode
*/
function reconcileChildren(wip: FiberNode, children?: ReactElementType) {
  const current = wip.alternate;
  if (current !== null) {
    // update
    wip.child = reconcilerChildFibers(wip, current?.child, children);
  } else {
    // mount
    wip.child = mountChildFibers(wip, null, children);
  }
}
function reconcilerChildFibers(
    returnFiber: FiberNode,
    currentFiber: FiberNode | null,
    newChild?: ReactElementType | string | number
  ) {
    // 判断当前fiber的类型
    if (typeof newChild === "object" && newChild !== null) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE:
          return placeSingleChild(
            reconcileSingleElement(returnFiber, currentFiber, newChild)
          );
        default:
          if (__DEV__) {
            console.warn("未实现的reconcile类型", newChild);
          }
          break;
      }
    }
    // Todo 多节点的情况 ul > li * 3
    // HostText
    if (typeof newChild === "string" || typeof newChild === "number") {
      return placeSingleChild(
        reconcileSingleTextNode(returnFiber, currentFiber, newChild)
      );
    }
    if (__DEV__) {
      console.warn("未实现的reconcile类型", newChild);
    }
    return null;
  };
}

reconcileSingleElement

从名字我们可以看出是通过ReactElement 创建单一的fiberNode。通过reconcileSingleElement我们就可以得出了一个新的子节点,然后通过return指向父fiber。此时的fiberNode树如下图。

  /**
   * 根据reactElement对象创建fiber并返回
   */
  function reconcileSingleElement(
    returnFiber: FiberNode,
    _currentFiber: FiberNode | null,
    element: ReactElementType
  ) {
    const fiber = createFiberFromElement(element);
    fiber.return = returnFiber;
    return fiber;
  }
export function createFiberFromElement(element: ReactElementType): FiberNode {
  const { type, key, props } = element;
  let fiberTag: WorkTag = FunctionComponent;
  if (typeof type === "string") {
    // <div/>  type : 'div'
    fiberTag = HostComponent;
  } else if (typeof type !== "function" && __DEV__) {
    console.log("未定义的type类型", element);
  }
  const fiber = new FiberNode(fiberTag, props, key);
  fiber.type = type;
  return fiber;
}

调用完后,此时回到了reconcileChildren函数的这一句代码执行,指定wip的child指向。此时函数执行完毕。

// 省略无关代码
function reconcileChildren(wip: FiberNode, children?: ReactElementType) {
    wip.child = reconcilerChildFibers(wip, current?.child, children);
}

执行完后返回updateHostRoot函数调用reconcileChildren的地方。然后返回wip的child。

  function updateHostRoot(wip) {
      const baseState = wip.memoizedState;
      reconcileChildren(wip, nextChildren);
      return wip.child;
  }

执行完updateHostRoot函数后,返回调用它的beginWork中。beginWork也同样返回了当前wip的child节点。

export const beginWork = (wip: FiberNode) => {
  switch (wip.tag) {
    case HostRoot:
      return updateHostRoot(wip);
  }
}

执行完后,我们最后又回到了最开始调用beginWork的地方。进行接下来的操作,主要是将已经渲染过的属性赋值。然后将wip赋值给下一个刚刚生成的子节点。以便于开始下一次的递归中调用。

  function performUnitOfWork(fiber) {
      const next = beginWork(fiber); // next 是fiber的子fiber 或者 是null
      // 工作完成,需要将pendingProps 复制给 已经渲染的props
      fiber.memoizedProps = fiber.pendingProps;
      if (next === null) {
          // 没有子fiber
          completeUnitOfWork(fiber);
      }
      else {
          workInProgress = next;
      }
  }

由于workInProgress不等于null, 说明还有子节点。继续进行workLoop调用。又开始了新的一轮。直到我们到达了叶子节点。

  function workLoop() {
      while (workInProgress !== null) {
          performUnitOfWork(workInProgress);
      }
  }

例子

例如,如下例子,当遍历到hcc文本节点后,由于我们节点是没有调和流程的。所以执行到beginWork后,返回了一个null。正式结束了递归调用中的“递" 过程。此时的fiberNode树如下图所示。

const jsx = <div><span>hcc</span></div>
const root = document.querySelector('#root')
ReactDOM.createRoot(root).render(jsx)

completeWork开始

从上面的beginWork操作后,此时我们wip在文本节点hcc的节点位置.

completeUnitOfWork

接下来执行performUnitOfWork中的completeUnitOfWork的逻辑部分,我们看看completeUnitOfWork的逻辑部分。 我们传入的最底部的叶子节点。首先会对当前节点进行completeWork的方法调用。

function completeUnitOfWork(fiber) {
  let node = fiber;
  do {
      completeWork(node);
      const sibling = node.sibling;
      if (sibling !== null) {
          workInProgress = sibling;
          return;
      }
      node = node.return;
      workInProgress = node;
  } while (node !== null);
}

completeWork

首次我们会接受到一个最底部的子fiberNode,由于是第一次mount,所以当前的fiber下不会存在alternate属性的,所以会走到构建Dom的流程。

/**
 * 递归中的归
 */
export const completeWork = (wip: FiberNode) => {
  const newProps = wip.pendingProps;
  const current = wip.alternate;
  switch (wip.tag) {
    case HostComponent:
      if (current !== null && wip.stateNode) {
        //update
      } else {
        // 1. 构建DOM
        const instance = createInstance(wip.type, newProps);
        // 2. 将DOM插入到DOM树中
        appendAllChildren(instance, wip);
        wip.stateNode = instance;
      }
      bubbleProperties(wip);
      return null;
    case HostText:
      if (current !== null && wip.stateNode) {
        //update
      } else {
        // 1. 构建DOM
        const instance = createTextInstance(newProps.content);
        // 2. 将DOM插入到DOM树中
        wip.stateNode = instance;
      }
      bubbleProperties(wip);
      return null;
    case HostRoot:
      bubbleProperties(wip);
      return null;
    default:
      if (__DEV__) {
        console.warn("未实现的completeWork");
      }
      break;
  }
};
// 根据逻辑判断,走到下面的逻辑判断,传入了文本
// 1. 构建DOM 
const instance = createTextInstance(newProps.content); 
// 2. 将DOM插入到DOM树中 
wip.stateNode = instance;

经过completeWork后,我们给当前的wip添加了stateNode属性,用于指向生成的Dom节点。 执行完completeWork后,继续返回到completeUnitOfWork中,查找sibling节点,目前我们demo中没有,所以会向上找到当前节点的return指向。继续执行completeWork工作,此时的结构变成了如下图:

由于我们wip目前是HostComponent, 所以走到了如下的completeWork的逻辑。这里 根据type创建不同的Dom元素,和之前一样,绑定到对应的stateNode属性上。我们可以看到除了这2个,还执行了一个函数appendAllChildren。我们去看看这个函数的作用是什么

// 1. 构建DOM
const instance = createInstance(wip.type);
// 2. 将DOM插入到DOM树中
appendAllChildren(instance, wip);
wip.stateNode = instance;

appendAllChildren

接受2个参数,第一个是刚刚通过wip的type生成的对应的dom, 另外一个是wip本身。 它的作用就是把我们上一步产生的dom节点,插入到刚刚产生的父dom节点上,形成一个局部的小dom树。

它本身存在一个复杂的遍历过程,因为fiberNode的层级和DOM元素的层级可能不是一一对应的。

/**
 * 在parent的节点下,插入wip
 * @param {FiberNode} parent
 * @param {FiberNode} wip
 */
function appendAllChildren(parent: Container, wip: FiberNode) {
  let node = wip.child;
  while (node !== null) {
    if (node?.tag === HostComponent || node?.tag === HostText) {
      appendInitialChild(parent, node?.stateNode);
    } else if (node.child !== null) {
      node.child.return = node;
      // 继续向下查找
      node = node.child;
      continue;
    }
    if (node === wip) {
      return;
    }
    while (node.sibling === null) {
      if (node.return === null || node.return === wip) {
        return;
      }
      // 向上找
      node = node?.return;
    }
    node.sibling.return = node.return;
    node = node.sibling;
  }
}

我们用这个图来说明一下流程

  • 当前的”归“到了div对应的fiberNode。我们获取到node是第一个子元素的span, 执行appendInitialChild方法,把对应的stateNode的dom节点插入parent中。
  • 接下来执行由于node.sibling不为空,所以会将node 复制给第二个span。然后继续执行appendInitialChild。以此执行到第三个span节点。
  • 第三个span节点对应的sibling为空,所以开始向上查找到node.return === wip结束函数调用。
  • 此时三个span产生的dom,都已经插入到parent(div dom)中。

回到completeUnitOfWork

经过上述操作后,我们继续回到completeUnitOfWork的调用,继续向上归并。到上述例子的div节点。直到我们遍历到hostFiberNode, 它是没有return属性的,所以返回null,结束了completeUnitOfWork的执行。回到了最开始的workLoop。此时的workInProgress等于null, 结束循环。

  function workLoop() {
      while (workInProgress !== null) {
          performUnitOfWork(workInProgress);
      }
  }

回到renderRoot

执行完workLoop, 就回到了renderRoot的部分。此时我们已经得到了完整的fiberNode树,以及相应的dom元素。此时对应的结果如下图:

那么生成的fiberNode树是如何渲染的界面上的,我们下一章的commit章节介绍,如何打标签和渲染,更多关于React18系列reconciler实现的资料请关注脚本之家其它相关文章!

相关文章

  • React如何避免子组件无效刷新

    React如何避免子组件无效刷新

    这篇文章主要介绍了React几种避免子组件无效刷新的解决方案,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2024-03-03
  • React中的setState使用细节和原理解析(最新推荐)

    React中的setState使用细节和原理解析(最新推荐)

    这篇文章主要介绍了React中的setState使用细节和原理解析(最新推荐),前面我们有使用过setState的基本使用, 接下来我们对setState使用进行详细的介绍,需要的朋友可以参考下
    2022-12-12
  • React和Vue的props验证示例详解

    React和Vue的props验证示例详解

    这篇文章主要介绍了React和Vue的props验证,本文通过示例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2022-08-08
  • react-router browserHistory刷新页面404问题解决方法

    react-router browserHistory刷新页面404问题解决方法

    本篇文章主要介绍了react-router browserHistory刷新页面404问题解决方法,非常具有实用价值,需要的朋友可以参考下
    2017-12-12
  • react PropTypes校验传递的值操作示例

    react PropTypes校验传递的值操作示例

    这篇文章主要介绍了react PropTypes校验传递的值操作,结合实例形式分析了react PropTypes针对传递的值进行校验操作相关实现技巧,需要的朋友可以参考下
    2020-04-04
  • 详解如何在React中有效地监听键盘事件

    详解如何在React中有效地监听键盘事件

    React是一种流行的JavaScript库,用于构建用户界面,它提供了一种简单而灵活的方式来创建交互式的Web应用程序,在React中,我们经常需要监听用户的键盘事件,以便根据用户的输入做出相应的反应,本文将向您介绍如何在React中有效地监听键盘事件,并展示一些常见的应用场景
    2023-11-11
  • React实现登录表单的示例代码

    React实现登录表单的示例代码

    这篇文章主要介绍了React实现登录表单的示例代码,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2021-04-04
  • react hooks实现防抖节流的方法小结

    react hooks实现防抖节流的方法小结

    这篇文章主要介绍了react hooks实现防抖节流的几种方法,文中通过代码示例给大家讲解的非常详细,对大家的学习或工作有一定的帮助,需要的朋友可以参考下
    2024-04-04
  • React传递参数的几种方式

    React传递参数的几种方式

    本文详细的介绍了React传递参数的几种方式,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2021-06-06
  • 详解在React里使用

    详解在React里使用"Vuex"

    本篇文章主要介绍了详解在React里使用"Vuex",小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2018-04-04

最新评论