【译】React及React Fiber基本的设计理念
前言本文主要是对收集到的一些官方或者其他平台的文章进行翻译,中间可能穿插一些个人的理解,如有错误疏漏之处,还望批评指正。笔者并未研究过源码,只是希望本文成为那些inspire你的东西的一部分,从而在今后一起去探讨和研究React Fiber。 注:绝大多数情况下,以下的第一人称不代表译者,而是对应文章的作者,请注意区分。 React basic基础的理论概念??这篇文章是我的一次尝试,希望能够形式化的介绍关于react本身的一些理念模型。目的在于基于演绎推理的方式,描述那些给我们灵感让我们进行这样的设计的源泉。 ??当然,这里的一些设想是具有争议的,实际的设计也许也会有bug或者疏漏。但是,这也是一个好的开始让我们去形式化地谈论这些。同时,如果你有更好的想法,也欢迎pr。以下让我们沿着这个思路,从简单到复杂的去思考这一系列问题,不必担心,这里没有太多具体的框架细节。 ??实际的关于React的实现是充满务实主义的,渐进式的,算法优化的,新老代码交替的,各种调试工具以及任何你能想到的让他变成更加有用的东西。当然,这些东西也像版本迭代一样,它们的存在是短暂的,如果它们足够有用,我们就会不断的更新他们。再次声明,实际的实现是非常非常复杂的。 转换??React最核心的前提是,UI仅仅是数据->数据的映射。相同的输入意味着相同输出。非常简单的纯函数。 function NameBox(name) { return { fontWeight: 'bold',labelContent: name }; } 'Sebastian Markb?ge' -> { fontWeight: 'bold',labelContent: 'Sebastian Markb?ge' }; 抽象??但是,并不是所有的UI都能这样做,因为,有些UI是非常复杂的。所以,很重要的一点是,UI能够被抽象成许许多多可复用的小块,同时不暴露这些小块的内部实现细节。就像在一个函数中调用另一个函数一样。 function FancyUserBox(user) { return { borderStyle: '1px solid blue',childContent: [ 'Name: ',NameBox(user.firstName + ' ' + user.lastName) ] }; } { firstName: 'Sebastian',lastName: 'Markb?ge' } -> { borderStyle: '1px solid blue',childContent: [ 'Name: ',{ fontWeight: 'bold',labelContent: 'Sebastian Markb?ge' } ] }; 组合??为了实现可复用这一特性,仅仅只是简单复用叶子节点,每次都为它们创建一个新的容器是远远不够的。同时我们需要在容器(container)这一层面构建抽象,并且组合其它抽象。在我看来,组合就是将两个甚至多个抽象变成一个新的抽象。 function FancyBox(children) { return { borderStyle: '1px solid blue',children: children }; } function UserBox(user) { return FancyBox([ 'Name: ',NameBox(user.firstName + ' ' + user.lastName) ]); } 状态??UI并不仅仅是简单的服务或者说业务中的逻辑状态。事实上,对于一个特定的投影而言,很多状态是具体的,但是对于其他投影,可能不是这样。例如,如果你正在文本框中输入,这些输入的字符可以被复制到另外的tab或者移动设备上(当然你不想复制也没问题,主要是为了和下一句的例子进行区分)。但是,诸如滚动条的位置这样的数据,你几乎从来不会想把它在多个投影中复制(因为在这台设备上比如滚动条位置是200,但是在其他设备上滚动到200的内容通常来说肯定是不同的)。 ??我们更趋向于将我们的数据模型变为不可变的。我们在最顶端将所有能更新状态的函数串起来,把它们当作一个原子(说成事务可能更容易明白)来对待。 function FancyNameBox(user,likes,onClick) { return FancyBox([ 'Name: ',NameBox(user.firstName + ' ' + user.lastName),'Likes: ',LikeBox(likes),LikeButton(onClick) ]); } // Implementation Details var likes = 0; function addOneMoreLike() { likes++; rerender(); } // Init FancyNameBox( { firstName: 'Sebastian',lastName: 'Markb?ge' },addOneMoreLike ); 注意:这个例子通过副作用去更新状态。我对于此实际的理念模型是在每次的更新过程中返回下一个阶段的状态。当然,不这样做看起来要更简单一点,但是在以后我们最终还是会选择改变这个例子采用的方式(因为副作用的缺点太多了)。 缓存??我们知道,对于纯函数而言,一次又一次相同的调用是非常浪费时间和空间的。我们可以对这些函数建立缓存的版本,追踪最近一次调用的输入和输出。下一次就可以直接返回结果,不用再次计算。 function memoize(fn) { var cachedArg; var cachedResult; return function(arg) { if (cachedArg === arg) { return cachedResult; } cachedArg = arg; cachedResult = fn(arg); return cachedResult; }; } var MemoizedNameBox = memoize(NameBox); function NameAndAgeBox(user,currentTime) { return FancyBox([ 'Name: ',MemoizedNameBox(user.firstName + ' ' + user.lastName),'Age in milliseconds: ',currentTime - user.dateOfBirth ]); } 列表/集合??大多数UI都是通过很多个列表组成,通过列表中的每个元素产生不同的值(比如 ??为了管理每个列表元素的状态,我们可以创建一个Map来管理每个特定的列表元素。 function UserList(users,likesPerUser,updateUserLikes) { return users.map(user => FancyNameBox( user,likesPerUser.get(user.id),() => updateUserLikes(user.id,likesPerUser.get(user.id) + 1) )); } var likesPerUser = new Map(); function updateUserLikes(id,likeCount) { likesPerUser.set(id,likeCount); rerender(); } UserList(data.users,updateUserLikes); 注意:现在我们有多个不同的输入传递给FancyNameBox。那会破坏我们上一节提到的缓存策略,因为我们一次只能记忆一个值。(因为上面的memoize函数的形参只有一个) 续延??不幸的是,在UI中有太多的list相互嵌套,我们不得不用大量的模板代码去显式的管理它们。 ??我们可以通过延迟执行将一部分的模板代码移到我们的主要逻辑之外。例如,通过利用currying(可以通过 ??这并没有减少模板代码,但是至少将它们移动到了核心逻辑之外。 function FancyUserList(users) { return FancyBox( UserList.bind(null,users) ); } const box = FancyUserList(data.users); const resolvedChildren = box.children(likesPerUser,updateUserLikes); const resolvedBox = { ...box,children: resolvedChildren }; 译注:这里当然可以采用 function FancyUserList(users) { return FancyBox( UserList(users,updateUserLikes) ); } ??但是这样扩展起来就很麻烦,想增加,删除我们都需要去改 function FancyUserList2(users) { return FancyBox( UserList(users,likesPerUser2,updateUserLikes2) ); } 当然,你肯定会想到,直接给 状态映射??我们很早就知道,一旦我们看见相同的部分,我们能够使用组合去避免一次又一次重复的去实现相同的部分。我们可以将提取出来那部分逻辑移动并传递给更低等级或者说更低层级的函数,这些函数就是我们经常复用的那些函数。 function FancyBoxWithState( children,stateMap,updateState ) { return FancyBox( children.map(child => child.continuation( stateMap.get(child.key),updateState )) ); } function UserList(users) { return users.map(user => { continuation: FancyNameBox.bind(null,user),key: user.id }); } function FancyUserList(users) { return FancyBoxWithState.bind(null,UserList(users) ); } const continuation = FancyUserList(data.users); continuation(likesPerUser,updateUserLikes); 缓存映射??想在缓存列表中缓存多个元素是比较困难的,你必须弄清楚一些在平衡缓存与频率之间做得很好的缓存算法,然而这些算法是非常复杂的。 ??幸运的是,在同一区域的UI通常是比较稳定的,不会变化的。 ??在这里我们依然可以采用像刚刚那种缓存 function memoize(fn) { return function(arg,memoizationCache) { if (memoizationCache.arg === arg) { return memoizationCache.result; } const result = fn(arg); memoizationCache.arg = arg; memoizationCache.result = result; return result; }; } function FancyBoxWithState( children,updateState,memoizationCache ) { return FancyBox( children.map(child => child.continuation( stateMap.get(child.key),memoizationCache.get(child.key) )) ); } const MemoizedFancyNameBox = memoize(FancyNameBox); 代数哲学??你会发现,这有点像PITA(一种类似肉夹馍的食物),通过几个不同层次的抽象,将你需要的东西(值/参数)一点一点的加进去。有时这也提供了一种快捷的方式,能在不借助第三方的条件下在两个抽象之间传递数据。在 ??有时候数据之间的依赖并不像抽象树那样整齐一致。例如,在布局算法中,在完整的确定所有字节点的位置之前,你需要知道各个子节点矩形区域的大小。 Now,this example is a bit "out there". I'll use Algebraic Effects as proposed for ECMAScript. If you're familiar with functional programming,they're avoiding the intermediate ceremony imposed by monads. 译注:FP理解不深,所以上面段就不翻译了,以免误导 function ThemeBorderColorRequest() { } function FancyBox(children) { const color = raise new ThemeBorderColorRequest(); return { borderWidth: '1px',borderColor: color,children: children }; } function BlueTheme(children) { return try { children(); } catch effect ThemeBorderColorRequest -> [,continuation] { continuation('blue'); } } function App(data) { return BlueTheme( FancyUserList.bind(null,data.users) ); } React Fiber体系结构译注:为了比较形象的阐释,故这里将React Stack vs Fiber的视频贴在这,而不是放在阅读更多里面。由于在youtube上,为了方便查看,这里录制了一张gif(有点大,18M,下载时请耐心等待)。 简介??React Fiber是一个正在进行中的对React核心算法的重写。它是过去两年React团队研究成果的一个顶峰。 ??React Fiber的目标是提升对在动画,布局以及手势方面的友好度。它最重要的特性叫做"增量式/渐进式"渲染:即,将渲染工作分割为多个小块进行,并在各个帧之间传播。 ??其它关键的特性包括,1.拥有了暂停,中止以及当有更新来临的时候重新恢复工作的能力。2.不同的能力对于不同类型的更新分配不同的优先级。3.新的并发原语。 关于本文档??在Fiber中引入了几个新的概念,这些概念仅仅只看代码是很难真的体会的。本文档最初只是我在React项目组时的收集,收集一些我整理Fiber的实现的时候的笔记。随着笔记的增多,我意识到这可能对其他人来说也是一个有益的资源。(译注:本文档的作者acdlite是Facebook开发组的一名成员,并不属于React框架的开发组(这里指实际工作中,而不是gh上的team)。React团队的leader,旧的核心算法及新的核心算法的提出者是sebmarkbage) ??我将尝试尽可能用简单的语言来描述,避免一些不必要的术语。在必要时也会给出一些资源的链接。 ??请注意我并不是React团队的一员,也不具备足够的权威。所以这并不是一份官方文档。我已经邀请了React团队的成员来对本文档的准确性进行review。 ??Fiber是一项还在进行中的工作,在它完成前都很可能进行重改。所以本文档也是如此,随着时间很可能发生变化。欢迎任何的建议。 ??我的目标是,在阅读本文档后,在Fiber完成的时候,顺着它的实现你能更好的理解它。甚至最终回馈React(译注:意思是fix bug,pr新特性,解决issue等等)。 准备??在继续阅读前,我强烈建议你确保自己对以下内容已经非常熟悉: ??React Components,Elements,and Instances - "组件"通常来说是一个范围很大的术语。牢固的掌握这些术语是至关重要的。 ??Reconciliation - 对React的协调/调度算法的一个高度概括。 ??React基础理论概念 - 对React中的一些概念模型的抽象描述,第一次读的时候可能不太能体会。没关系,以后终会明白的。 ??React设计原则 - 请注意其中的scheduling这一小节,非常好的解释了React Fiber。 回顾??如果你还没准备好的话,请重新阅读上面的"准备"一节。在我们探索之前,让我们来了解几个概念。 什么是协调(reconciliation)??reconciliation:是一种算法,React使用它去区分两棵树,从而决定到底哪一部分需要改变。 ??update:数据的变化会导致渲染,通常这是 ??React API的核心理念是思考/决定/调度怎样去update,就好像它会导致整个app重新渲染一样。它让开发者能够声明式地去思考,而不用去担心如何高效的将app从一个状态过渡到另一个状态(A到B,B到C,C再到A等等)。 ??事实上,每次变化都重新渲染整个app的方式只能工作在非常小的app上。在现实世界真正的app中,这在性能上花费的代价太大了。React已经在这方面做了优化,在保持好性能的前提下创造出app重新渲染之后的样子。绝大部分的优化都属于reconciliation这个过程的一部分。 ??Reconciliation是一个隐藏在被广为熟知的称作"virtual DOM"的背后的算法。概括起来就是:当你渲染一个React应用的时候,就产生了一棵描述这个应用的节点树,并存储在内存中。接下来这棵树会被刷新,然后翻译到具体的某个环境中。例如,在浏览器环境,它被翻译成一系列的DOM操作。当app有更新的时候(通常是通过 ??虽然Fiber是一个对reconciler完全的重写,但是React文档中对核心算法的概括描述仍然是适用的。几个关键点为:
Reconciliation vs rendering??DOM只是React能够渲染的东西之一,除此之外,主要还有通过React Native产生的IOS和Android的原生控件。(这就是为什么说"virtual DOM"属于用词不当) ??React能支持这么多的渲染目标的是因为React本身的设计所导致的,协调(reconciliation)和渲染是两个不同的,分离的阶段。协调器(reconciler)做的是计算树的哪部分在变化的工作,而渲染器(renderer)做的则是利用协调器产生的结果去更新我们的应用的工作。(译注:即不同平台/环境下去更新界面的手段/方式是不同的,所以不能一概而论,但是计算树的差异的过程却是通用的。) ??这种分离意味着React DOM以及React Native既能共享同一个由React提供的协调器的逻辑,又能够利用它们各自的渲染器去完成渲染。 ??Fiber重写了协调器。它并不关心渲染,尽管渲染器需要相应作出一些改变(并且利用)这个新的算法的某些东西。 调度??调度(scheduling):是一个决定什么时候该做某个任务的过程。 ??任务(work):任何需要执行的计算都属于任务。任务通常是由一次更新所导致的。(如 ??React的设计原则这篇文档在这一点上阐释的非常不错,所以我在这引用一小段:
译注:将来即指Fiber,帧是Fiber里引入的一个概念,因为用到了requestAnimationFrame。Fiber栈就是用来协调对帧的操作(Fiber栈也是Fiber里的概念,是一个对函数调用栈的模拟。)。延迟更新是相对递归遍历而言的,即暂时中断递归,转去遍历另外的节点。可参考演讲视频,或者观察一下这个gif(有点大,20M)以及将帧划分的图片
几个关键点在于:
译注:完整的优先级可以参考源码中的定义
??React目前并没有非常好地利用调度,一次更新将会导致整个子树立即被重新渲染。改进React的核心算法从而更好的利用调度是隐藏在Fiber背后的理念驱动。 ??现在我们要准备深入Fiber的实现了。下一节会比我们到目前为止讨论的要更有专业性一点。在你继续阅读前请确保之前的内容你基本了解了。 Fiber是什么??我们即将讨论React Fiber的核心体系结构。Fiber比起应用开发者通常的认知而言,是一个更加的低得多的抽象层次。如果你发现自己很难去理解它,不要灰心。继续尝试,最后一定会拨开云雾见光明。(当你最后理解它的理解,请向我建议如何改进这一小节) ??我们开始吧~ ??我们对Fiber已经确立的目标是,激活React,让它具备调度的能力。具体地来说,我们需要能够:
??要想做到其中的任何一条,我们首先需要一种方式,把工作/任务分解成许许多多的小单元(units)。从某种意义上来说,那就是fiber。一个fiber代表了任务的单位。 ??为了进一步理解,让我们回到之前提到的把React组件当作数据的函数这一概念,通常表示为: ?? ??由此可见,渲染一个React应用与在一个函数类调用另一个函数是类似的(译注:一个组件的render函数里面会调用另一个组件的render函数)。这个类比在思考fiber的时候是很有用的。 ??通常,计算机对一个程序的执行/调用情况的跟踪的方式是通过调用栈(call stack)。当一个函数被执行的时候,一个新的栈帧(stack frame)被压入栈中。那个栈帧就代表了在那个函数里被执行的任务。(译注:听着可能有点不顺畅,不过无论什么语言,调试的时候观察过call stack的同学应该都清楚) ??当我们处理UI的时候,问题在于如果一次有太多的任务要执行,将会导致动画丢帧以及卡顿。更重要的是,那些任务当中的一部分也许是没有必要执行的,如果新的一次更新对其中一部分进行了废弃的话。这就是UI组件和函数分解之间有区别的地方,因为通常组件比函数有更多具体的需要关心的东西。 ??较新的浏览器(以及React Native)实现了帮助解决这些具体问题的API:requestIdleCallback会让一个低优先级的函数在空闲期被调用。而requestAnimationFrame会让一个高优先级的函数在下一个动画帧被调用。问题在于,为了使用这些API,你需要将渲染工作划分为增量式的单元。如果你只依赖调用栈的话,那么直到调用栈为空之前它都会一直在工作。 ??那么,如果我们能够自定义调用栈的行为,对优化渲染UI来说是不是就更好了呢?如果我们能任意地中断调用栈并且手动操作栈帧,是不是也会更好呢? ??这就是React Fiber的目标。Fiber是对于栈的重写,特别是对于React组件来说。你可以把一个单一的fiber想象成一个虚拟的栈帧。 ??重写栈的优点是,你能够在内存中保留栈帧(这个链接挺有趣的,值得一看),并且在任何时候通过任意方式执行。这对我们完成调度来说是至关重要的。 ??除了调度外,手动地处理栈帧,也许能够让我们拥有一些潜在的特性,例如并发以及错误边界处理。我们会在后面的小节讨论这些。 Fiber的结构??注意:随着我们对实现的细节关注得越具体,也许会发现更多的可能性。如果你发现错误或者太旧的信息,请给我们提pr。 ??在具体的术语中,一个fiber是一个js对象,它包含着一个组件,以及这个组件的输入及输出。 ??一个fiber与一个栈帧相对应,但同时也与一个组件的实例相对应。 ??这里列出一些属于fiber的重要的属性(注意并没有完全的列举全): type和key??fiber的type属性和key属性对React元素来讲提供的是相同的功能。(事实上,当一个fiber从一个元素中被创建的时候,这两个属性都是复制过来的(译注:可参考源码)) ??一个fiber的type描述了与它相对应的组件,对于函数或者类组件而言,type就是函数或者类组件本身(译注:源码中对type的描述为"与这个fiber相对应的函数/组件/模块")。对于宿主组件而言(div,span等等),type就是字符串("div","span")。(译注:这一点其实和之前的React是一样的,没有区别,如果你用react-devtools调试过的话应该会注意到) ??从概念上来讲,type是一个函数(就像 v = f(d)),这个函数的执行被栈帧所追踪。 ??和type一起的key,被用在协调(reconciliation)过程中,决定这个fiber是否能被重用。(译注:源码中的描述为"这个child唯一的标识符") child和sibling??这两个属性指向其它的fiber,描述一个fiber的递归树结构。(译注:源码中的描述为"单向链表树结构") ??child属性对应的fiber是与一个组件的render方法的返回值相对应的。所以,在下面的例子中: function Parent() { return <Child /> } ??Parent的child属性就与Child相对应。 ??sibling属性解释了这样的案例,即在render方法中返回多个子节点(一个在Fiber中的新特性)。(译注:而且也可以返回一个字符串。相信都是大家期盼已久的,再也不用套一个div了。另外一个大的特性是error boundaries) function Parent() { return [<Child1 />,<Child2 />] } ??子fiber形成了一个单链表,单链表的头节点是数组中的第一个元素。所以在上面的例子中,Parent的child属性是Child1,Child1的sibling属性是Child2。 ??回到我们与函数的类比上,你可以把一个子fiber想象成一个尾调用函数。 return??return属性的值也是一个fiber,指向处理完当前fiber之后的返回值。在概念上与栈帧的返回地址类似。 ??如果一个fiber有多个子fiber,每一个子fiber的return属性都执行父fiber。所以在我们上一节的例子中,Child1和Child2的return属性的值都是Parent。 pendingProps和memoizedProps??从概念上来说,props就是一个函数的arguments。一个fiber的pendingProps在它最初被调用的时候就被设置了。memoizedProps在执行的结尾被设置。(译注:应该就类似与对纯函数进行cache) ??当将要到来的pendingProps和memoizedProps相等的时候,就标志着这个fiber以前的输出能够被重用了,这样就能避免不必要的任务执行。 pendingWorkPriority??pendingWorkPriority的值代表了这个任务的优先级。ReactPriorityLevel列出了不同的优先级以及它们代表的含义。 ??NoWork优先级的值是0,优先级数字越大表示优先级越低(即0是最高的优先级)。例如,你可以利用下面的函数去检查一个fiber的优先级是否至少达到了某个指定的优先级。 function matchesPriority(fiber,priority) { return fiber.pendingWorkPriority !== 0 && fiber.pendingWorkPriority <= priority } ??这个函数仅仅只是为了说明使用,并不是真正的React Fiber代码库中的一部分。 ??调度器使用priority属性去搜索下一个要执行的任务单元。我们将在futrue一节讨论这个算法。 alternate??flush:刷新一个fiber就是将它的输出渲染到屏幕上。 ??work-in-progress:代表一个还未完成的fiber,从概念上来说,类似于一个还未return的栈帧。 ??在任何时候,一个组件的实例最多有2个fiber与它相关联:当前的刷新后的fiber以及正在运行中(work-in-progress)的fiber。 ??当前的fiber的备胎(alternate)就是正在运行的fiber,正在运行的fiber的备胎也是当前的fiber。(译注:可参考源码) ??一个fiber的备胎是用一个叫做cloneFiber的函数惰式创建的,而不是总是创建一个新的对象。如果fiber的备胎存在的话,cloneFiber会尝试重用这个fiber的备胎,从而达到最小化分配内存的目的。 ??虽然你应该把alternate属性当作一种实现细节,但是在源码中你会经常看到它,所以放到这里讨论它是有价值的。 output??host component:代表一个React应用程序的叶子节点。不同的渲染环境下是不同的(例如,在浏览器应用里面,它们是 ??从概念上来说,一个fiber的输出(output)是一个函数的返回值。 ??每一个fiber最终都有一个输出,但是只有在宿主环境的叶子节点中才会创建输出。然后输出被翻译/转移到真正的dom树中。 ??输出就是最终传给渲染器的东西,以便渲染器能够在渲染环境中刷新,从而反映出那些变化。如何创建和更新输出是渲染器的职责。 将来的可能??到目前为止我们就谈这么多了。但是本文档还远远没有完成。未来我可能将描述一些在更新的生命周期中频繁使用的算法。它们包括:
更多推荐React-Future Fiber Principles: Contributing To Fiber React 15.5 and 16 Umbrella Fiber Simplify coroutines by making yields stateless Fiber Umbrella for remaining features / bugs React Perf Scenarios Fiber Compute the Host Diff During Reconciliation fiber-debugger Why,What,and How of React Fiber with Dan Abramov and Andrew Clark Pete Hunt: The Past,Present and Future of React Dan Codes 另外之前收集过一些dan发在twitter上的东西,你可以进入链接然后ctrl+f搜索fiber。 ------------------------------------------------------2017-4-16日更新--------------------------------------------------------------- That @reactiflux Q&A from @acdlite,关于这个更多的可以看discord里的讨论 之前提到acdlite并非React项目组的成员,纠正下,准确度说应该是写那篇文章的时候还不是,但是后面加入了React团队。可参考这条tweet中的描述。另外其中也提到当时是作为一个旁观者的角度去写的那篇文章,经过在React项目组参与fiber的开发,文章里的很多东西也需要更新了,它后面会抽时间更新的,到时如果我没忘的话应该也会更新翻译的。 (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |