zoukankan      html  css  js  c++  java
  • 【React】383- React Fiber:深入理解 React reconciliation 算法

    作者:Maxim Koretskyi 

    译文:Leiy 

    https://indepth.dev/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/

    React 是一个用于构建用户交互界面的 JavaScript 库,其核心机制就是跟踪组件的状态变化,并将更新的状态映射到到新的界面。在 React 中,我们将此过程称之为协调。我们调用setState方法来改变状态,而框架本身会去检查state或 props是否已经更改来决定是否重新渲染组件。

    React 的官方文档对协调机制进行了良好的抽象描述:React 元素、生命周期、 render 方法,以及应用于组件子元素的diffing算法综合起到的作用,就是协调。

    render方法返回的不可变的 React 元素通常称为虚拟 DOM。这个术语有助于早期向人们解释 React,但它也引起了混乱,并且不再用于 React 文档。在本文中,我将坚持称它为 React 元素的树。

    除了 React 元素的树之外,框架总是在内部维护一个实例来持有状态(如组件、 DOM 节点等)。从版本 16 开始, React 推出了内部实例树的新的实现方法,以及被称之为Fiber的算法。

    下文中,我们将结合 ClickCounter 组件展开说明。我们有一个按钮,点击它将会使屏幕上渲染的数字加1

    640?wx_fmt=gif

    代码如下:

    class ClickCounter extends React.Component {
        constructor(props) {
            super(props);
            this.state = {count: 0};
            this.handleClick = this.handleClick.bind(this);
        }
    
        handleClick() {
            this.setState((state) => {
                return {count: state.count + 1};
            });
        }
    
    
        render() {
            return [
                <button key="1" onClick={this.handleClick}>Update counter</button>,
                <span key="2">{this.state.count}</span>
            ]
        }
    }
    

    这个组件很简单,从render() 方法中返回两个子元素buttonspan

    单击button按钮时,组件将更新处理程序,进而使span元素的文本进行更新。

    React 在协调(reconciliation) 期间执行各种活动。例如,以下是 React 在我们的ClickCounter组件中的第一次渲染和状态更新之后执行的高级操作:

    • 更新ClickCounter组件中stateconut属性。

    • 检索并比较ClickCounter的子组件及其props

    • 更新span元素的props

    协调(reconciliation) 期间执行了其他活动,包括调用生命周期方法或更新refs所有这些活动在 Fiber 架构中统称为 work。 work类型通常取决于 React 元素的类型。

    例如,对于class组件,React 需要创建实例,而functional组件则不需要执行此操作。正如我们所了解的,React 中有许多元素类型,例如classfunctional组件,host组件(DOM节点)等。React 元素的类型由createElement函数的第一个参数决定,此函数通常用于创建元素的render方法中。

    在我们开始探索活动细节和主要的fiber算法之前,让我们先熟悉 React 内部使用的数据结构。

    React 中的每个组件都有一个UI表示,我们可以称之为从render方法返回的一个视图或模板。这是ClickCounter组件的模板:640?wx_fmt=png

    React Elements

    如果模板通过JSX编译器处理,就会得到一堆 React 元素。这是从React组件的render方法返回的,并不是HTML。由于我们并不需要使用JSX因此我们的ClickCounter组件的render方法可以像这样重写:

    class ClickCounter {
        ...
        render() {
            return [
                React.createElement(
                    'button',
                    {
                        key: '1',
                        onClick: this.onClick
                    },
                    'Update counter'
                ),
                React.createElement(
                    'span',
                    {
                        key: '2'
                    },
                    this.state.count
                )
            ]
        }
    }
    

    render方法中调用的React.createElement会产生两个如下的数据结构:

    [
        {
            $$typeof: Symbol(react.element),
            type: 'button',
            key: "1",
            props: {
                children: 'Update counter',
                onClick: () => { ... }
            }
        },
        {
            $$typeof: Symbol(react.element),
            type: 'span',
            key: "2",
            props: {
                children: 0
            }
        }
    ]
    

    您可以看到 React 将属性添加到$$typeof这些对象中,以将它们唯一地标识为React 元素。然后我们有描述元素的属性typekey、和props。这些值取自传递给react.createElement函数的内容。

    注意 React 如何将文本内容表示为spanbutton节点的子节点,以及click处理程序如何成为button元素的props的一部分,以及 React 元素上的其他字段,比如ref字段,超出了本文的范围。

    ClickCounter的 React 元素没有任何propskey

    {
        $$typeof: Symbol(react.element),
        key: null,
        props: {},
        ref: null,
        type: ClickCounter
    }
    

    Fiber nodes

    协调(reconciliation) 过程中,render方法返回的每个 React 元素的数据将被合并到Fiber节点树中,每个 React 元素都有一个对应的Fiber节点。

    与 React 元素不同,Fiber不是在每此渲染上都重新创建的,它们是保存组件状态和DOM的可变数据结构。

    我们之前讨论过,根据 React 元素的类型,框架需要执行不同的活动。在我们的示例中,对于类组件ClickCounter,它调用生命周期方法方法和render方法,而对于span host 组件(dom节点),它执行DOM修改。因此,每个 React 元素都被转换成相应类型的Fiber节点,用于描述需要完成的工作。

    您可以将Fiber视为一种数据结构,它表示一些要做的工作,或者一个工作单元。Fiber的架构还提供了一种方便的方式来跟踪、调度、暂停和中止工作。

    当react元素第一次转换为Fiber节点时,React 使用元素中的数据在createFiberFromTypeAndProps函数中创建一个Fiber。在随后的更新中,React 重用这个Fiber节点,并使用来自相应的 React 元素的数据更新必要的属性。

    如果不再从render方法返回相应的 React 元素,React 可能还需要根据key属性来移动或删除层级结构中的节点。

    检查ChildReconciler函数,查看所有活动的列表以及 React 为现有Fiber节点执行的相应函数。

    因为 React 为每个 React 元素创建一个Fiber,而且我们有一个这些元素组成的树,所以我们可以得到一个Fiber节点树。对于我们的示例,如下所示:

    640?wx_fmt=png

    所有fiber节点通过链接列表进行连接:childsiblingreturn

    Current 树以及 workInProgress 树

    在第一次呈现之后,React 最终得到一个Fiber树,它反映了用于渲染UI的应用程序的状态。这棵树通常被称为current树。当 React 开始处理更新时,它会构建一个所谓的workInProgress树,反映要刷新到屏幕的未来状态。

    所有的工作都是在工作进度workInProgress树的fibler上进行的。当 React 遍历当前树时,它为每个现有的fiber节点创建一个备用节点,该节点构成workInProgress树。此节点是使用render方法返回的 React 元素中的数据创建的。

    一旦处理了更新并完成了所有相关工作,React 将有一个备用树准备刷新到屏幕上。在屏幕上呈现此工作进度树后,它将成为current树。

    React 的核心原则之一是一致性。 React总是一次性更新DOM(它不会显示部分结果)。workInProgress树用作用户看不到的"草稿",因此 React 可以先处理所有组件,然后将其更改刷新到屏幕上。

    在源代码中,您将看到许多函数从current树和workInProgress树中获取fiber节点。下面是这样一个函数:

    function updateHostComponent(current, workInProgress, renderExpirationTime) {...}
    

    每个fiber节点都保存对备用字段中另一棵树的对应节点的引用。current树中的节点指向WorkInProgress树中的节点,反之亦然。

    副作用

    我们可以把 React 中的一个组件看作是一个使用stateprops来计算UI呈现的函数,任何其他活动,比如改变DOM或调用生命周期方法,都应该被认为是一种副作用,或者简单地说,是一种效果。https://reactjs.org/docs/hooks-overview.html#%EF%B8%8F-effect-hook中也提到了影响:

    你之前可能已经在 React 组件中执行过数据获取、订阅或者手动修改过 DOM。我们统一把这些操作称为“副作用”,或者简称为“作用”。(因为它们会影响其他组件,并且在渲染期间无法完成。)

    您可以看到大多数stateprops更新将如何导致副作用。由于"作用"work的一种,所以除了更新之外,fiber节点是跟踪"作用"的一种方便机制。每个fiber节点都可以具有与其相关联的效果。它们在effectTag字段中编码。

    因此,fiber中的"作用"基本上定义了在处理更新后实例需要完成的工作:

    • 对于host宿主组件(dom元素),包括添加、更新或删除元素。

    • 对于类组件,React 可能需要更新refs并调用componentDidMountcomponentDiddUpdate生命周期方法。

    • 还有其他与其他fiber相对应的效应。

    副作用列表

    React 进程更新非常快,为了达到这个性能水平,它使用了一些有趣的技术。其中之一是建立一个具有快速迭代效果的fiber节点线性列表。迭代线性列表比树快得多,不需要花时间在没有副作用的节点上。

    此列表的目标是标记具有DOM更新或与其相关联的其他作用的节点。此列表是finishedWork树的子集,使用nextEfect属性而不是current树和workInProgress树中使用的子属性进行链接。

    这里有一个作用列表的类比,把它想象成一棵圣诞树,"圣诞灯"把所有有效的节点绑在一起。为了将其可视化,让我们想象一下下面的fiber节点树,其中突出显示的节点有一些工作要做,例如,我们的更新导致C2插入到DOM中,D2C1更改属性,B2触发生命周期方法。效果列表将它们链接在一起,以便 React 可以稍后跳过其他节点:

    640?wx_fmt=png

    可以看到具有副作用的节点是如何链接在一起的。当遍历节点时,React 使用firstEffect指针来确定列表的起始位置。所以上面的图表可以表示为这样的线性列表:

    640?wx_fmt=png如您所见,React 按照从子到父的顺序应用副作用。

    Fiber 的根节点

    每个 React 应用程序都有一个或多个充当容器的DOM元素。在我们的例子中它是带有idcontainerdiv元素。

    const domContainer = document.querySelector('#container');
    ReactDOM.render(React.createElement(ClickCounter), domContainer);
    

    React为每个容器创建一个fiber根对象。您可以使用对DOM元素的引用来访问它:

    const fiberRoot = query('#container')._reactRootContainer._internalRoot
    

    这个Fiber根节点是 React 保存对fibler树的引用的地方。它存储在fiber根对象的currrent属性中:

    const hostRootFiberNode = fiberRoot.current
    

    这个Fiber树以一个特殊类型的Fiber节点HostRoot 开始。它在内部创建的,并充当最顶层组件的父级。HostRoot节点可通过stateNode属性返回到FiberRoot

    fiberRoot.current.stateNode === fiberRoot; // true
    

    您可以通过fiber根访问最顶层的hostRoot fiber节点来浏览fiber树。或者可以从组件实例中获取单个fiber节点,如下所示:

    compInstance._reactInternalFiber
    

    Fiber 节点结构

    现在让我们看看为ClickCounter组件创建的fiber节点的结构:

    {
        stateNode: new ClickCounter,
        type: ClickCounter,
        alternate: null,
        key: null,
        updateQueue: null,
        memoizedState: {count: 0},
        pendingProps: {},
        memoizedProps: {},
        tag: 1,
        effectTag: 0,
        nextEffect: null
    }
    

    以及span DOM 元素:

    {
        stateNode: new HTMLSpanElement,
        type: "span",
        alternate: null,
        key: "2",
        updateQueue: null,
        memoizedState: null,
        pendingProps: {children: 0},
        memoizedProps: {children: 0},
        tag: 5,
        effectTag: 0,
        nextEffect: null
    }
    

    fiber节点上有很多字段。在前面的我已经描述了字段alternateeffectTagnextEfect的用途。现在让我们看看为什么我们需要其他的字段。

    stateNode

    保存组件的类实例、DOM节点或与Fiber节点关联的其他 React 元素类型的引用。总的来说,我们可以认为该属性用于保持与一个Fiber节点相关联的局部状态。

    type

    定义与此fiber关联的函数或类。

    对于类组件,它指向构造函数;对于DOM元素,它指定HTML标记。(使用这个字段来了解fiber节点与什么元素相关。)

    tag

    定义fiber的类型,它在reconciliation(协调)算法中确定需要做什么工作。

    如前所述,工作取决于 React 元素的类型。函数createFiberFromTypeAndProps将 React 元素映射到相应的fiber节点类型。在我们的案例中,ClickCounter组件的tag1,表示classComponent;而对于span元素,属性tag5,表示hostComponent

    updateQueue

    状态更新、回调和DOM更新的队列。

    memoizedState

    用于创建输出的fiber的状态,处理更新时,它会反映当前在屏幕上呈现的状态。

    memoizedProps

    在前一次渲染期间用于创建输出的fiberprops

    pendingProps

    已从 React 元素中的新数据更新并且需要应用于子组件或DOM元素的props

    key

    唯一标识符,当具有一组子元素的时候,可帮助 React 确定哪些项发生了更改、添加或删除。

    在上文中省略了一些字段:特别是数据结构指针childsiblingreturn。以及一类特定于调度器的字段,如expirationTimechildExpirationTimemode

    通用算法

    React 在两个主要阶段执行工作:rendercommit

    在第一个render阶段,React 通过setUpdateReact.render计划性的更新组件,并确定需要在UI中更新的内容。

    如果是初始渲染,React 会为render方法返回的每个元素创建一个新的Fiber节点。在后续更新中,现有 React 元素的Fiber节点将被重复使用和更新。这一阶段是为了得到标记了副作用的Fiber节点树。

    副作用描述了在下一个commit阶段需要完成的工作。在当前阶段,React 持有标记了副作用的Fiber树并将其应用于实例。它遍历副作用列表、执行 DOM更新和用户可见的其他更改。

    我们需要重点理解的是,第一个render阶段的工作是可以异步执行的。React 可以根据可用时间片来处理一个或多个Fiber节点,然后停下来暂存已完成的工作,并转而去处理某些事件,接着它再从它停止的地方继续执行。但有时候,它可能需要丢弃完成的工作并再次从顶部开始。

    由于在此阶段执行的工作不会导致任何用户可见的更改(如 DOM 更新),因此暂停行为才有了意义。

    与之相反的是,后续commit阶段始终是同步的。这是因为在此阶段执行的工作会导致用户可见的变化,例如DOM更新。这就是为什么 React 需要在一次单一过程中完成这些更新。

    React 要做的一种工作就是调用生命周期方法。一些方法是在render阶段调用的,而另一些方法则是在commit阶段调用。

    这是在第一个render阶段调用的生命周期列表:

    • [UNSAFE_] componentWillMount(弃用)

    • [UNSAFE_] componentWillReceiveProps(弃用)

    • getDerivedStateFromProps

    • shouldComponentUpdate

    • [UNSAFE_] componentWillUpdate(弃用)

    render

    正如你所看到的,从版本 16.3 开始,在render阶段执行的一些保留的生命周期方法被标记为UNSAFE,它们现在在文档中被称为遗留生命周期。它们将在未来的16.x 发布版本中弃用,而没有UNSAFE前缀的方法将在17.0中移除。

    那么这么做的目的是什么呢?

    好吧,我们刚刚了解到,因为render阶段不会产生像DOM更新这样的副作用,所以 React 可以异步处理组件的异步更新(甚至可能在多个线程中执行)。

    但是,标有UNSAFE的生命周期经常被误解和滥用。开发人员倾向于将带有副作用的代码放在这些方法中,这可能会导致新的异步渲染方法出现问题。虽然只有没有UNSAFE 前缀的对应方法将被删除,但它们仍可能在即将出现的并发模式(您可以选择退出)中引起问题。

    接下来罗列的生命周期方法是在第二个 commit 阶段执行的:

    • getSnapshotBeforeUpdate

    • componentDidMount

    • componentDidUpdate

    • componentWillUnmount

    因为这些方法都在同步的commit阶段执行,他们可能会包含副作用,并对DOM进行一些操作。

    至此,我们已经有了充分的背景知识,下面我们可以看下用来遍历树和执行一些工作的通用算法。

    Render 阶段

    协调算法始终使用renderRoot函数从最顶层的HostRoot节点开始。不过,React 会略过已经处理过的Fiber节点,直到找到未完成工作的节点。例如,如果在组件树中的深层组件中调用setState方法,则 React 将从顶部开始,但会快速跳过各个父项,直到它到达调用了setState方法的组件。

    工作循环的主要步骤

    所有的Fiber节点都会在工作循环中进行处理。如下是该循环的同步部分的实现:

    function workLoop(isYieldy) {
      if (!isYieldy) {
        while (nextUnitOfWork !== null) {
          nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
        }
      } else {...}
    }
    

    在上面的代码中,nextUnitOfWork持有workInProgress树中的Fiber 节点的引用,这个树有一些工作要做:当 React 遍历Fiber树时,它会使用这个变量来知晓是否有任何其他Fiber节点具有未完成的工作。处理过当前Fiber后,变量将持有树中下一个Fiber节点的引用或null。在这种情况下,React 退出工作循环并准备好提交更改。

    遍历树、初始化或完成工作主要用到4个函数:

    • performUnitOfWork

    • beginWork

    • completeUnitOfWork

    • completeWork

    为了演示他们的使用方法,我们可以看看如下展示的遍历Fiber树的动画。我已经在演示中使用了这些函数的简化实现。每个函数都需要对一个Fiber节点进行处理,当 React 从树上下来时,您可以看到当前活动的Fiber节点发生了变化。从GIF中我们可以清楚地看到算法如何从一个分支转到另一个分支。它首先完成子节点的工作,然后才转移到父节点进行处理。

    640?wx_fmt=gif

    注意,垂直方向的连线表示同层关系,而折线连接表示父子关系,例如,b1 没有子节点,而 b2 有一个子节点 c1。

    从概念上讲,你可以将开始视为进入一个组件,并将完成视为离开它。

    我们首先开始研究performUnitOfWorkbeginWork这两个函数:

    function performUnitOfWork(workInProgress) {
        let next = beginWork(workInProgress);
        if (next === null) {
            next = completeUnitOfWork(workInProgress);
        }
        return next;
    }
    
    function beginWork(workInProgress) {
        console.log('work performed for ' + workInProgress.name);
        return workInProgress.child;
    }
    

    函数performUnitOfWorkworkInProgress树接收一个Fiber节点,并通过调用beginWork函数启动工作,这个函数将启动所有Fiber执行工作所需要的活动。出于演示的目的,我们只logFiber节点的名称来表示工作已经完成。函数beginWork始终返回指向要在循环中处理的下一个子节点的指针或null

    如果有下一个子节点,它将被赋值给workLoop函数中的变量nextUnitOfWork。但是,如果没有子节点,React 知道它到达了分支的末尾,因此它可以完成当前节点。一旦节点完成,它将需要为同层的其他节点执行工作,并在完成后回溯到父节点。

    这是completeUnitOfWork函数执行的代码:

    function completeUnitOfWork(workInProgress) {
        while (true) {
            let returnFiber = workInProgress.return;
            let siblingFiber = workInProgress.sibling;
    
            nextUnitOfWork = completeWork(workInProgress);
    
            if (siblingFiber !== null) {
                // If there is a sibling, return it
                // to perform work for this sibling
                return siblingFiber;
            } else if (returnFiber !== null) {
                // If there's no more work in this returnFiber,
                // continue the loop to complete the parent.
                workInProgress = returnFiber;
                continue;
            } else {
                // We've reached the root.
                return null;
            }
        }
    }
    
    function completeWork(workInProgress) {
        console.log('work completed for ' + workInProgress.name);
        return null;
    }
    

    你可以看到函数的核心就是一个大的while的循环。当workInProgress节点没有子节点时,React 会进入此函数。完成当前 Fiber 节点的工作后,它就会检查是否有同层节点。

    如果找的到,React 退出该函数并返回指向该同层节点的指针。它将被赋值给 nextUnitOfWork变量,React将从这个节点开始执行分支的工作。

    我们需要着重理解的是,在当前节点上,React 只完成了前面的同层节点的工作。它尚未完成父节点的工作。只有在完成以子节点开始的所有分支后,才能完成父节点和回溯的工作。

    从实现中可以看出,performUnitOfWorkcompleteUnitOfWork主要用于迭代目的,而主要活动则在beginWorkcompleteWork函数中进行。

    commit 阶段

    这一阶段从函数completeRoot开始。在这个阶段,React 更新DOM并调用变更生命周期之前及之后方法的地方。

    当 React 进入这个阶段时,它有2棵树和副作用列表。第一个树表示当前在屏幕上渲染的状态,然后在render阶段会构建一个备用树。它在源代码中称为finishedWorkworkInProgress,表示需要映射到屏幕上的状态。此备用树会用类似的方法通过childsibling指针链接到current树。

    然后,有一个副作用列表(它是finishedWork树的节点子集),通过nextEffect 指针进行链接。需要记住的是,副作用列表是运行render阶段的结果。渲染的重点就是确定需要插入、更新或删除的节点,以及哪些组件需要调用其生命周期方法。这就是副作用列表告诉我们的内容,它页正是在 commit 阶段迭代的节点集合。

    出于调试目的,可以通过Fiber根的属性current访问current树。可以通过 current树中HostFiber节点的alternate属性访问finishedWork树。

    commit阶段运行的主要函数是commitRoot。它执行如下下操作:

    • 在标记为Snapshot副作用的节点上调用getSnapshotBeforeUpdate生命周期。

    • 在标记为Deletion副作用的节点上调用componentWillUnmount生命周期。

    • 执行所有DOM插入、更新、删除操作。

    • finishedWork树设置为current

    • 在标记为Placement副作用的节点上调用componentDidMount生命周期。

    • 在标记为Update副作用的节点上调用componentDidUpdate生命周期。

    在调用变更前方法getSnapshotBeforeUpdate之后,React 会在树中提交所有副作用,这会通过两波操作来完成。

    第一波执行所有 DOM(宿主)插入、更新、删除和 ref 卸载。然后 React 将finishedWork树赋值给FiberRoot,将 workInProgress树标记为current树。这是在提交阶段的第一波之后、第二波之前完成的,因此在componentWillUnmount中前一个树仍然是current,在componentDidMount/Update期间已完成工作是current

    在第二波,React 调用所有其他生命周期方法和引用回调。这些方法单独传递执行,从而保证整个树中的所有放置、更新和删除能够被触发执行。

    以下是运行上述步骤的函数的要点:

    function commitRoot(root, finishedWork) {
        commitBeforeMutationLifecycles()
        commitAllHostEffects();
        root.current = finishedWork;
        commitAllLifeCycles();
    }
    

    这些子函数中都实现了一个循环,该循环遍历副作用列表并检查副作用的类型。当它找到与函数目的相关的副作用时,就会执行。

    更新前的生命周期方法

    例如,这是在副作用树上遍历并检查节点是否具有Snapshot副作用的代码:

    function commitBeforeMutationLifecycles() {
        while (nextEffect !== null) {
            const effectTag = nextEffect.effectTag;
            if (effectTag & Snapshot) {
                const current = nextEffect.alternate;
                commitBeforeMutationLifeCycles(current, nextEffect);
            }
            nextEffect = nextEffect.nextEffect;
        }
    }
    

    对于一个类组件,这一副作用意味着会调用getSnapshotBeforeUpdate生命周期方法。

    DOM 更新

    commitAllHostEffects是 React 执行DOM更新的函数。该函数基本上定义了节点需要完成的操作类型,并执行这些操作:

    function commitAllHostEffects() {
        switch (primaryEffectTag) {
            case Placement: {
                commitPlacement(nextEffect);
                ...
            }
            case PlacementAndUpdate: {
                commitPlacement(nextEffect);
                commitWork(current, nextEffect);
                ...
            }
            case Update: {
                commitWork(current, nextEffect);
                ...
            }
            case Deletion: {
                commitDeletion(nextEffect);
                ...
            }
        }
    }
    

    有趣的是,React 调用componentWillUnmount方法作为commitDeletion函数中删除过程的一部分。

    更新后的生命周期方法

    commitAllLifecycles是 React 调用所有剩余生命周期方法的函数。在 React 的当前实现中,唯一会调用的变更方法就是componentDidUpdate

    最后,文章有点长,希望您您耐心的看完,中秋小长假已经过去了,要收收心尽快进入工作状态哦。
    原创系列推荐



    4. 
    5. 
    6. 
    7. 

    640?wx_fmt=png

    回复“加群”与大佬们一起交流学习~

    640?wx_fmt=png
    点这,与大家一起分享本文吧~
    个人博客:http://www.pingan8787.com 微信公众号【前端自习课】和千万网友一起,每日清晨,享受一篇前端优秀文章。 目前已连续推送文章 600+ 天,愿每个人的初心都能一直坚持下去!
  • 相关阅读:
    客户端技术的一点思考(数据存储用SQLite, XMPP通讯用Gloox, Web交互用LibCurl, 数据打包用Protocol Buffer, socket通讯用boost asio)
    自绘LISTVIEW的滚动条(Delphi实现)
    文字滚屏控件(SliderPanel)
    自动注册服务NET Core扩展IServiceCollection
    Three.js基础
    Cordova+Asp.net Mvc+GIS
    Netty
    TagHelper
    jQuery、实例大全
    React和Angular
  • 原文地址:https://www.cnblogs.com/pingan8787/p/11838056.html
Copyright © 2011-2022 走看看