最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • [译] Inside Fiber: 深入了解React的新协调算法

    正文概述 掘金(金名的铭)   2021-06-15   653

    React是一个用于构建用户界面的JavaScript库。它的核心机制是跟踪组件的state变化并将更新后的state显示到屏幕上。在React中这个过程叫做协调(reconciliation)。我们调用setState方法,React会检查stateprops是否变化,然后重新渲染组件到UI上。

    React文档为这个机制提供了很好的高级概述:React元素的角色,生命周期方法和render方法,以及应用到组件childrendiffing算法。由render方法返回的元素组成的树通常被认为是”虚拟DOM“。这个术语早期有助于理解React,但是它也引起了困惑并且在React文档里已经不再使用它了。在这篇文章里我称其为React元素树。

    除了React元素树,还有一颗内部实例树(组件,DOM节点等等)用于保存状态。从版本16开始,React推出了内部树和管理内部树的算法的实现,称为Fiber。通过React如何以及为什么在Fiber中使用链表 。

    这是让你了解React内部架构系列的第一篇文章。在这篇文章中,我想提供关于这个算法的重要概念和数据结构的概述。一旦我们拥有足够的背景知识,我们就会探索该算法用于遍历和处理fiber树的主要函数。在这个系列接下来的文章中会展示React是如何使用这个算法进行首次渲染,处理stateprops的更新。在那之前,我们先了解调度器、协调过程和构建effects列表的机制的细节。

    我会教你一些相当高级的知识?我鼓励你阅读它来理解Concurrent React内部运作背后的魔法。如果你想为React贡献,这个系列的文章可以作为你很好的指南。我坚信逆向工程,所以会有很多版本16.6.0的源码链接。

    这确实要花费大量时间和精力,所以不要气馁即使你不能马上理解。花费时间是值得的。注意,你无需知道这些也能使用React,这篇文章是关于React内部是如何运作的。

    背景设定

    这是一个我准备贯穿整个系列的简单程序。在屏幕上我们有个简单增加数字的按钮: [译] Inside Fiber: 深入了解React的新协调算法

    这是实现:

    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的简单组件。一旦你点击按钮,组件的state在处理函数中被更新。这样就会导致span元素的文本更新。

    React协调过程中有很多活动,比如调用 生命周期方法 、更新refs。Fiber架构中这些活动都称为”work“。work的类型通常依赖于React元素的类型。举个例子,对于类组件,React需要创建一个实例,而函数组件则不必这样。正如你所知道的,React中有很多种类的元素,如类组件和函数组件,原生组件(DOM节点),portals等等。React元素的类型是由createElement函数的第一个参数确定的。这个函数通常用于render方法中用来创建一个元素。

    在研究这些活动和fiber主要算法前,我们先来熟悉React内部使用的数据结构。

    React元素到Fiber节点

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

    <button key="1" onClick={this.onClick}>Update counter</button>
    <span key="2">{this.state.count}</span>
    

    React元素

    一个模板经过JSX编译器编译后,就会得到一堆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元素。还有些属性typekeyprops来描述元素。这些值是通过React.createElement函数传递进来的。注意React如何让文本内容作为spanbuttonchildren。以及点击事件如何作为button元素的props的一部分。React元素上还有其他一些超出本文讨论范围的字段比如ref

    ClickCounterReact元素没有任何propsref

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

    Fiber节点

    协调过程中,每个从render返回的React元素会被合并一颗fiber树。每个React元素都有相应的fiber节点。与React元素不同的是,fiber节点不会再每次渲染是从新创建。它们是可变的数据结构,保存了组件state和DOM。

    我们之前讨论过React根据元素类型执行不同活动。在我们的实例程序中,对于类组件ClickCounter会调用生命周期方法和render方法,而对于span这样的原生组件(DOM节点)会执行DOM变化。所以每个React元素会被转换成相应类型的Fiber节点,这个节点描述了需要完成的work。

    你可以将Fiber理解为一种表示待做的一些work的数据结构,或者换句话说,一个work单元。Fiber架构也提供了一种方便的方式来追踪、调度、暂停和中止这些work。

    当一个React元素第一次转换成fiber节点时,React在createFiberFromTypeAndProps函数中使用元素中的数据来创建一个fiber。在更新中React会复用fiber节点,根据相应的React元素仅更新必要的属性。React也可能根据keyprop来移动节点,或者如果相应的的React不再从render方法中返回,那么就删除它。

    因为React为每个React元素创建了fiber节点并且我们有一颗由这些元素组成的树,所以我们将有一个由fiber节点组成的树。在我们例子中看起来像这样: [译] Inside Fiber: 深入了解React的新协调算法

    所有fiber节点都是通过fiber节点上的这几个属性形成链表:childsublingreturn。要了解为什么这样做的更多细节,请阅读我的文章React如何以及为什么在Fiber中使用链表,如果你还没读过。

    Current和work in process树

    首次渲染后,React中存在一颗保存了应用程序状态,用于渲染UI的fiber树。这颗树通常称为current。当React开始进行更新时,它创建一颗所谓的workInProgress树,这棵树保存着将来要刷新到屏幕上的状态。

    所有的work都是在workInProgress树的fibers上执行的。当React遍历current树,对于每个现存的fiber节点,React会创建一个代替(alternate)节点,这些代替节点组成workInProgress树。代替节点是由render方法返回的React元素的数据创建的。一旦更新都被处理了、所有相关联的work完成了,React就会有一颗准备刷新到屏幕上的代替树。一旦workInProgress树渲染到屏幕上,它就变成current树。

    React的核心原则之一就是连贯性。React总是一次性更新DOM,它不会显示部分结果。workInProgress树就像一份草稿,用户是看不见它的,所以React可以先处理所有组件,然后在将它们的变化更新到屏幕上。

    在源码中你会看到很多使用currentworkInProgress树节点的函数,这是其中一个函数的签名:

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

    每个fiber节点在alternate字段上保存了另一颗树上相应节点的引用。current树上节点指向workInProgress树上相应的节点,反之亦然。

    Side-effects(副作用)

    我们可以把React中的组件看成一个使用stateprops来得到UI页面的函数。其他的每个活动比如DOM变化或调用生命周期方法都应该被认为是副作用或作用。Effects在文档中也有提及。

    你可以看到大部分stateprops更新如何产生副作用。由于标记effects是一种work,除了更新外,fiber节点是一种方便跟踪effects的机制。每个fiber节点都可以关联它的effects。它们保存在effectTag字段上。

    因此,Fiber中的effects基本定义了更新被处理后实例需要完成的work。对于原生组件(DOM元素),work包含添加、更新或移除元素。对于类组件,React可能需要更新refs,调用componentDidMountcomponentDidUpdate生命周期方法。其他类型的fibers有相应的其他effects。

    Effects list

    React处理更新非常快,为了实现高性能它使用了一些有趣的技术。它们中的一个就是创建一个由包含effects的fiber节点组成的线性链表来实现快速迭代。 迭代线链列表比迭代一颗树快的多,而且无需在没有副作用的节点上浪费时间。

    这个链表的目标是标记含有DOM更新或其他effects的节点并把它们关联起来。这个链表是finishedWork树的子集,节点之间使用nextEffect属性进行连接,而不是 currentworkInProgress树中使用的 child属性。

    Dan Abramov为effects list描述了一种比喻。他喜欢将它想象成挂在圣诞树上的”圣诞灯“,”圣诞灯“将所有有副作用的节点绑到一起。形象点说,把下面fiber树种高亮的节点想象成有些work要做的节点。比如,我们的更新导致c2插入DOM中,d2c1改变了属性,b2触发了一个生命周期方法。effects list会把它们连接起来,如此,React在后面就可以跳过其他节点: [译] Inside Fiber: 深入了解React的新协调算法

    你可以看到有副作用的节点是如何连接到一起的。遍历节点时,React使用firstEffect指针找出list从哪开始。所以上面的图可以看成这样的线性链表: [译] Inside Fiber: 深入了解React的新协调算法

    Root of the fiber tree

    每个React程序都有一个或多个DOM元素作为容器。在我们的例子中它是ID为containerdiv元素。

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

    React为这些容器创建了一个fiber root对象。你可以使用DOM元素的引用来获取它。

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

    这个fiber root就是React保存一颗fiber树引用的地方。它保存在fiber root的current属性中。

    const hostRootFiberNode = fiberRoot.current
    

    fiber树开始于一个特殊类型的fiber节点,它就是HostRoot。它在内部创建,作为你最顶层组件的父级。HostRootfiber节点上有个指回FiberRootstateNode属性:

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

    你可以通过访问最顶层HostRootfiber节点到达fiber root,接着探索fiber树。 或者你可以从组件实例中获取一个fibe节点,就像这样:

    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
    }
    

    spanDOM元素的fiber节点:

    {
        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节点上有很多字段。在之前的部分中我已经描述过字段alternateeffectTagnextEffect的作用。现在来看看为什么需要其他字段。

    stateNode

    保存类组件实例,DOM节点,或其他与fiber节点关联的React元素类型。总的来说,我们可以说这个属性用于保存与fiber节点关联的本地状态。

    type

    定义与这个fiber关联的函数或类。对于类组件,它指向构造函数,对于DOM元素,它代表HTML标签。我经常使用这个字段来理解与一个fiber节点关联的元素是什么。

    tag

    定义the type of the fiber。它在协调算法中用于确定什么work要做。如前所述,React元素类型不同,work有所不同。createFiberFromTypeAndProps函数将React元素映射成相应的fiber节点类型。在我们的程序中,ClickCounter组件的tag属性是1,表示它是一个ClassComponentspan元素的是5表示它是一个HostComponent

    updateQueue

    一条state更新,回调和DOM更新的队列。

    memoizedState

    fiber中用于创建输出的state。当处理更新时,它表示当前渲染到屏幕上的state。

    memoizedProps

    在之前渲染中fiber用于创建输出的props。

    pendingProps

    React元素中从新数据中更新的props,需要传递给子组件或DOM元素。

    key

    一组子元素中的唯一标识符,帮助React从列表中找出哪些项目已变化、添加或者删除。它与React文档此处描述的”列表和keys“功能有关。

    你可以在这看到fiber节点完整的结构。我在上面的说明中删除了一堆字段。特别是我跳过了我在上篇文章中描述过了的组成树结构的childsiblingreturn 指针。还有一类字段像expirationTimechildExpirationTimemode,它们是给调度器用的。

    通用算法

    React在两个主要阶段中执行work:rendercommit

    render阶段中,React将更新应用到通过setStateReact.render调度的组件,并且找出什么需要被更新到UI。如果是首次渲染,React为每个从render方法中返回的元素创建新的fiber节点。在接下来的更新中,现存的React元素的fiber会被复用和更新。这个阶段的结果是由标记了副作用的fiber节点组成的树。 effects描述了在接下来的commit阶段需要完成的work。在这个阶段中,React拥有一颗标记了effects的fiber树,并将它们应用到实例上。它遍历effects链表执行DOM更新和其他用户可见的变化。

    render阶段中的work是可以异步执行的,理解这一点很重要。 React在可用时间内处理一个或多个fiber节点,然后停止运行并暂存完成的work,让步于其他事件。然后从它停止的地方继续。但有时,它可能需要放弃已完成的work,再次从顶部开始。正是因为这个阶段执行的work不会导致任何用户可见的变化,比如DOM更新,使得这些暂停成为可能。相反,后面的commit阶段总是同步的。 这是因为这个阶段执行的work会用户可见的变化,例如DOM更新。这就是为什么React需要一次性完成它们。

    调用生命周期方式是React执行的一类work。一些方法在render阶段调用,其他的在commit阶段调用。下列生命周期函数在render阶段中调用:

    • [UNSAFE_]componentWillMount (已废弃)
    • [UNSAFE_]componentWillReceiveProps (已废弃)
    • getDerivedStateFromProps
    • shouldComponentUpdate
    • [UNSAFE_]componentWillUpdate (已废弃)
    • render

    如你所见,一些在render阶段中执行的遗留的生命周期函数从版本16.3开始被标记为UNSAFE。现在再文档中它们被称为遗留的生命周期函数。它们将在16.x发行版中废弃,对应的没有UNSAFE前缀的将在17.0中移除。你可以在这读到更多关于这些变化和建议的迁移路线。

    你对这样做的原因感到好奇吗?

    好的,我们刚刚学习了render阶段不会产生像DOM更新这样的副作用,React可以异步处理组件更新(甚至可以在多个线程中运行)。然而,这些被标记为UNSAFE被误解和误用。开发人员往往在这些生命周期方法中放入带有副作用的代码,这在新的异步渲染方式中可能引起问题。尽管只有没有UNSAFE前缀的会被移除,它们在即将到来的Concurrent模式(你可以选择退出)中仍然可能引起问题。

    下列生命周期函数在commit阶段中执行:

    • getSnapshotBeforeUpdate
    • componentDidMount
    • componentDidUpdate
    • componentWillUnmount

    因为执行在同步的commit阶段,所以它们可以包含副作用和访问DOM。

    好的,现在我们了解了用于遍历树和执行work的算法的背景知识。让我们更深入些。

    Render阶段

    协调算法总是从renderRoot函数使用的最顶层HostRootfiber节点开始。然而,React会跳过已经处理了的fiber节点直到它遇到有未完成work的节点。例如,如果你在组件树深层调用setState,React将从顶层开始,但是会快速跳过父级直到它到达调用setState方法的组件。

    work循环的主要步骤

    所有fiber节点在work循环中处理。这是循环的同步部分实现的实现:

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

    在上面的代码中,nextUnitOfWork保存了来自workInProgress树中有work待完成的fiber节点的引用。当React遍历Fiber树时,它使用这个变量来知道是否存在其他有未完成work的fiber节点。当前节点处理完后,这个变量将包含树中下一个fiber节点的引用或者为null。在这种情况下(译注:nextUnitOfWork=null的情况)React退出work循环并准备提交变化。

    有四个主要函数用于遍历树,开始或结束work:

    • performUnitOfWork
    • beginWork
    • completeUnitOfWork
    • completeWork

    为了演示它们是如何使用的,看看下面遍历fiber树的动画。我在demo中使用这些函数的简化实现。每个函数都接收一个fiber节点来处理,随着React向下遍历树你会看到当前活动的fiber节点发生了变化。你可以在视频中清楚地看出算法是如何从一个分支到其他分支的。它在移动到父节点之前先完成子节点的work。

    [译] Inside Fiber: 深入了解React的新协调算法

    这是视频的链接,你可以暂停播放,查看当前节点和函数的状态。从概念上讲,你可以把”开始“看作”进入“一个组件,把”完成“看作“退出它”。当我说明这些函数是做说明的时候,你可以在这查看示例和实现。

    让我们从前面的两个函数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;
    }
    

    performUnitOfWork函数接收一个workInProgress树中的fiber节点,通过调用beginWork函数开始工作。这个函数将开启一个fiber节点所有执行的活动。为了演示,我们简单的打印出fiber的name表示work已经完成。beginWork函数总是返回一个指向循环中下一个待处理child的指针或者null

    如果存在下一个child,它将在workLoop函数中赋值给nextUnitOfWork变量。 但是,如果不存在child,React知道它已经到达分支的末尾,所以它可以完成当前节点。一旦节点完成,它需要执行兄弟节点的work然后返回父节点。这是在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完成work后,会检查是否有兄弟节点。如果有,React退出这个函数并返回指向兄弟节点的指针。它将赋值给nextUnitOfWork变量,React将从这个兄弟节点开始为这个分支执行work。在这个时候,React只完成了之前兄弟节点的work,理解这点很重要。它没有完成父节点的work。只有当所有以子节点开始的分支都完成后,它才完成父节点的work并回到父节点。

    你可以从实现中看出,performUnitOfWorkcompleteUnitOfWork主要起到迭代的作用,而主要活动是在beginWorkcompleteWork函数中进行的。在这个系列接下来的文章中,我们将学到当React进入beginWorkcompleteWork函数中时ClickCounter组件和span节点发生了什么。

    Commit阶段

    这个阶段开始于completeRoot函数.在这个阶段中React更新DOM,调用变更前后生命周期函数。

    当React进入这个阶段,它有两颗树和effects链表。一颗树代表当前渲染在屏幕上的状态。然后有颗在render阶段创建的alternate树。在源码中它被称为finishedWorkworkInProgress,代表需要被显示到屏幕上的状态。alternate树和current树类似,通过child和sibling指针连接。

    然后,还有一条effects链 —— finishedWork树节点的子集,通过nextEffect指针连接的。记住effect链是render阶段的运行结果。render阶段的目标就是确定哪些节点需要插入、更新或删除 ,以及需要调用哪些组件的生命周期方法。这就是在commit阶段遍历的节点集。

    commit阶段的主要函数是commitRoot。 大体上,它做了下面这些事:

    • 在带有Snapshoteffect标记的节点上调用 getSnapshotBeforeUpdate生命周期方法
    • 在带有Deletioneffect标记的节点上调用 componentWillUnmount生命周期方法
    • 执行所有DOM的插入、更新、删除
    • 设置finishedWork树作为current
    • 在带有Placementeffect标记的节点上调用 componentDidMount生命周期方法
    • 在带有Updateeffect标记的节点上调用 componentDidUpdate生命周期方法

    在调用变更前方法getSnapshotBeforeUpdate之后,React在树中提交所有副作用。分成两次完成。第一次执行所有DOM(host)的插入、更新、删除,和ref卸载。然后,React将finishedWork树分配给FiberRoot,标记workInProgress树作为current树。这是在commit阶段第一部分之后,第二个部分之前完成的。所以在componentWillUnmount中之前的树仍然是当前的。componentDidMount/Updatefinished树是当前的。在第二部分中React调用其他生命周期方法和ref回调。这些方法作为独立部分执行,因此所有的插入、更新和删除在整颗树中都已被调用。

    这是运行上述步骤的函数的大体结构:

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

    每个子函数都实现循环遍历effects list并检查effects的类别。当发现effect和该函数作用有关时会应用它。

    变更前生命周期方法

    例如,这是遍历effects树并检查节点是否有Snapshoteffect的代码:

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

    对于类组件,effect意味着调用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在commitDeletion函数中调用componentWillUnmount方法作为删除过程的一部分。

    变更后生命周期方法

    commitAllLifecycles是React调用剩下的componentDidUpdatecomponentDidMount生命周期方法的函数。

    终于结束了。在评论区中告诉我你觉得这篇文章怎么样或问我问题。查看这个系列的下一篇文章深入理解React中的state和props更新。我计划写更多的文章深入解释调度器,协调过程,以及effects list是如何创建的。我也计划创建个视频,使用这篇文章作基础展示如何调试程序。


    起源地下载网 » [译] Inside Fiber: 深入了解React的新协调算法

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元