大数据|程序员该如何构建面向未来的前端架构!

大数据|程序员该如何构建面向未来的前端架构!
文章图片

【CSDN 编者按】本文深入探讨基于组件的前端架构是如何随着复杂度的增加而变得臃肿甚至难以维护,以及如何规避这种情况。
作者 | REM译者 | 无阻我飞扬
出品 | CSDN(ID:CSDNnews)
构建高性能且易于更改的前端架构很难实现规模化。本文将探索解决这种复杂情况的主要方法,即众多开发人员和团队共同完成前端项目开发,并且可以快速而平滑地集成到前端项目中。
我们还将研究有效的方法来避免面对这种复杂情况时不知所措。无论是在问题出现之前还是之后,你会发现自己在被要求增加或改变一个功能时,会想“哦,天啦撸,这怎么会变得如此复杂?” 。
前端架构是一个涉及面很广的话题。本文将聚焦于组件代码结构,它能建立弹性的前端架构,从而轻松适应变化。
本文案例代码是基于React(用于构建用户界面的JavaScript库),其基本原理可以应用于任何基于组件的框架。
我们从编写代码之前开始谈起,看看代码结构是如何受到影响的。
大数据|程序员该如何构建面向未来的前端架构!
文章图片

常规心智模型的影响
我们的心智模型会影响我们的决定,进而影响代码库的整体结构。
在组建团队时,明确开发人员的心智模型很重要,最好大家都有共同的心智模型,但是每个个体又都会有自己潜在的心智模型。
这就是为什么团队在研发过程中需要共享样式指南和诸如prettier(前端代码格式化工具)之类的工具。作为一个团队,要有一个共同的模式,明确事情应该怎么保持一致性,大家要做哪些事情,应该怎么做。
这样就让研发工作变得更加轻松。具有共同心智模型的研发团队能够避免随着时间的推移,由于每个人都按自己的模式开发,从而让代码库沦为不可维护。
假如曾经经历过一个快速开发的项目,开发人员都急于发布程序,你可能已经发现,如果没有适当的指导方针,事情很快就会失控,随着时间的推移,代码的增加和代码运行性能的下降,前端会变得越来越慢。
在接下来的几节中,我们将探讨回答以下问题:

  • 在使用基于组件的模型框架(如使用React)开发前端应用程序时,最常规的心智模型是什么?
  • 它们如何影响构建组件的方式?
  • 这些心智模型中隐含着程序员怎样的权衡,我们可以明确这些导致复杂度快速上升的权衡有哪些?
大数据|程序员该如何构建面向未来的前端架构!
文章图片

组件思维
React是最流行的基于组件的前端框架。“React思维”通常是前端框架开发人员读的第一篇文章。
它阐述了在以 "React方式 "构建前端应用程序时应如何思考的关键心智模型。这是一篇很好的文章,因为其中的建议也适用于任何基于组件的框架。
它所列出的主要原则让你在需要构建组件时提出以下问题。
  • 该组件的责任是什么?好的组件API设计自然遵循单一责任原则,这对于组合模式非常重要。把简单的事情组合起来是很容易的,随着需求的不断增加和变化,组件保持简单往往是非常困难的,我们将在本文的后面部分进行探讨。
  • 什么是其状态的绝对最小但完整的表示?这个想法是最好从最小但完整的状态真实来源开始,可以从中得出变化,这样灵活、简单,可以规避常见的数据同步错误,比如更新一个状态而不更新另一个状态。
  • 状态应该定义在哪里?状态管理是一个广泛的话题,超出了本文探讨的范畴。但一般来说,如果一个状态可以成为一个组件的本地状态,那么它就应该是定义在本地的。组件在内部对全局状态依赖越多,它们的复用性就越低。提出这个问题有助于确定什么组件应该依赖于什么状态。
“React思维”这篇文章中还有一些智慧:
一个组件最好只做一件事,如果它变得庞大复杂了,就应该将它拆解为更小的子组件。
这里概述的原则是简单的,经过实践检验的,对于抑制组件复杂性很有效。它们构成了创建组件时最常规心智模型的基础。
不过,简单并不意味着容易。在有多个团队和开发人员的大型项目实践中,这一点说起来容易做起来难。
成功的项目往往来自于对基本原则的坚持,而且是持续的坚持,不要犯太多代价高昂的错误。
这就引出了我们将要探讨的两个问题。
  1. 是什么情况阻止了这些简单原则的应用?
  2. 如何才能尽可能地缓解这些情况?
下面将了解为什么随着时间的推移,保持简单性在实践中并不总是那么容易。
大数据|程序员该如何构建面向未来的前端架构!
文章图片

自上而下 vs. 自下而上
组件是React等现代框架中的核心抽象单元,可以考虑有两种主要的方法创建它们。以下是React中不得不说的内容:
可以自上而下或自下而上地构建。也就是说,可以从构建体系结构中更高层次的组件开始。在比较简单的项目中,通常自上而下更容易,而在较大的项目中,在构建时就编写测试代码,自下而上更容易。
更可靠的建议。乍一看,这听起来很简单,就像读到“单一责任很好”一样,很容易达成一致并继续向前。
但是,自上而下和自下而上的心智模型之间的区别,比表面上看起来要重要得多。应用于大规模研发时,当某一种心智模型作为构建组件的隐含方式被广泛分享时,这两种思维模式都会导致截然不同的结果。
自上而下的构建方式
在上面的引述中隐含着这样一种权衡,对于比较简单的项目,采取自上而下的方法,而对于大型的项目,则采取较慢、更具可扩展性的自下而上的方法,这样项目更容易取得进展。
自上而下通常是最直观、最直接的方法。根据我的经验,这也是从事功能开发的开发人员在构建组件时最常见的心智模型。
自上而下的方法是什么样的?当给出一个要构建的设计时,常规的建议是 "在UI周围绘制框,这些框将成为你的组件"。
这构成了最终创建的顶层组件的基础。采用这种方法,通常先创建一个粗颗粒度的组件,开始的时候似乎有一个正确的界限。
假设有一个新的管理仪表盘的设计,继续看设计需要构建哪些组件。
在设计中,它有一个新的侧边导航。在侧边导航周围画一个方框,并创建一个Story(在软件开发和项目管理中用日常语言或商务用语表达开发需求),告诉开发人员要创建新的组件。
遵循自上而下的方法,可能会考虑它需要什么属性,以及如何渲染。假设从后端API获得导航项的列表,按照自上而下的模型,看到类似下面伪代码的初始设计也就不足为奇了:
// get list from API call somewhere up here // and then transform into a list we pass to our nav component const navItems = [ { label: 'Home', to: '/home' }, { label: 'Dashboards', to: '/dashboards' }, { label: 'Settings', to: '/settings' }, ] ...

到目前为止,自上而下的方法看起来相当直接易懂。我们的目的是让事情变得简单和可复用,用户只需要传递想要渲染的项目,交由SideNavigation来处理。
自上而下方法中常见的一些注意事项:
  1. 从最初确定为所需组件的顶层边界开始构建,从设计中画出的框开始;
  2. 它是一个单一的抽象,处理与侧边导航栏相关的所有事情;
  3. 它的API通常是“自上而下”的,即用户通过顶层向下传递它所需要的数据,并在后台处理一切事务。
通常情况下,组件直接从后端数据源渲染数据,这也符合将数据“向下”传递到组件中进行渲染的模型。
对于较小的项目来说,这种方法没有什么不对,但是对于众多开发人员试图快速发布的大型代码库来说,会看到自上而下的心智模型如何在大规模项目上很快出现问题。
自上而下错在哪了
自上而下的思维模式倾向于一开始就把自己固定在一个特定的抽象上,以解决眼前的问题。
这是直观易懂的。它常常被认为是构建组件最直接的方法,它也经常优化API,以实现组件最初的易用性。
这里有一个比较常见的场景。你所在的团队正在进行一个快速开发的项目,已经画出了框并创建了Story,完成了新组件合并,此时,一个新的需求出现了,要求更新侧边导航组件。
这个时候,事情可能会开始变得非常棘手。这是一种常见的情况,可能会导致创建大型单片组件。
开发人员拿起story进行更改,在现场准备编码,处在已经确定的抽象和API的项目背景下。
他们面临的选择:
  1. 考量一下这是否是正确的抽象。如果不是,在执行故事大纲之前先主动分解来撤销它;
  2. 增加一个附加属性。在一个简单的条件后面添加新的功能以检查该属性,编写一些测试传递给新属性,新功能通过测试并运行正常,这么做的好处在于完成的很快。
正如桑迪-梅茨所说:
现有代码发挥着强大的影响力。它存在的本身就证明了它的正确性和必要性。我们知道代码代表了所付出的努力,非常有动力去维持这种努力的价值。不幸的是,可悲的事实是,代码越复杂,越难以理解,也就是说,在创建代码方面投入越深,就越感到继续保留这些代码的压力("沉没成本谬论")
沉没成本谬论之所以存在,是因为人们天生就更热衷于规避损失。当再加上时间考量,要么是来自最后期限,要么只是简单的“1个story point”(story point是一个度量单位,用于表示完成一个产品待办项或者其他任何某项工作所需的所有工作量的估算结果),开发人员选择A的可能性不大。
从规模上看,正是这些快速做出的小决定迅速累积起来,开始增加组件的复杂性。
不幸的是,我们现在已经违背了 "用React思考 "中概述的一个基本原则。简单的事情往往不会简单化,与其它选择相比,引导我们走向简单并不容易做到。
警告
让我们将这种常见的场景应用到简单的导航侧栏例子中。
第一个设计变更需求出现了。需要增加对导航项的要求,使其具有图标以及不同大小的文本,并使其中一些成为链接而不是SPA页面过渡。
在实践中,UI拥有大量的视觉状态。我们还希望有分隔符,在新选项卡中打开链接,被点击过的链接的默认状态,等等诸如此类。
因为把导航项列表作为数组传递给侧栏组件,对于这些新的要求,需要在这些对象上添加一些附加属性,以区分新类型的导航项目及其不同的状态。
所以现在的类型可能看起来像是这样,类型对应于它是链接还是常规导航项:{id, to, label, icon, size, type, separator, isSelected}等等。
然后在内部,不得不检查类型,并基于此渲染导航项。像这样的小变化已经开始有那么点意思了。
这里的问题是,具有这样的API的自上而下组件,必须通过添加到API来响应需求的变化,并基于传入的内容在内部逻辑分叉。
从小事到大事的发展
几周后,要求提供一个新的功能,需要能够点击一个导航项目,转换到该项目下的一个嵌套子导航,并有一个返回按钮回到主导航列表,还希望管理员能够通过拖放对导航项进行重新排序。
现在需要有嵌套列表的概念,并将子列表与父列表关联起来,确认有些导航项目是否可以拖动。
一些需求发生了变化,可以看到事情是如何开始变得复杂的。
一开始是一个相对简单的组件,有一个简单的API,经过几次快速的迭代,迅速发展成为其他组件。比方说,开发人员及时设法使事情顺利进行。
至此,下一个需要使用或改编这个组件的开发人员或团队要面对的是一个需要复杂配置的单片组件,而且(说实话)很可能根本就没有什么好的开发说明文档。
最初的意图是“只传递列表,剩下的就由组件来处理”,但现在却事与愿违,对组件进行更改既缓慢又有风险。
此时,一个常见的场景是考虑废弃所有东西,从头开始重写组件。现在我们已经了解了第一轮迭代中需要解决的问题和用例。
单片组件的逐渐增长
除了第一次,一切都应该自上而下地构建。
正如我们所看到的,单片组件是试图做太多事情的组件。它们通过属性接收太多数据或配置选项,管理太多状态,输出太多UI。
它们通常从简单的组件开始,通过上述更常见的复杂性的逐渐增长,随着时间的推移,最终一个单片组件要做得太多了。
一开始只是一个简单的组件,在实现新的功能时,经过几次迭代过程(甚至在同一个sprint中,sprint是项目开发过程中最小迭代周期),就会变成一个庞大的单片组件。
当团队在快速开发的情况下,使用同一个代码库,如果多个组件发生这种情况,前端很快就会变得更难更改,用户的终端速度也会变得更慢。
下面是单片组件可能导致前端突然崩溃的一些其它方式。
  • 它们通过过早的抽象而产生。还有一个微妙的陷阱导致了单片组件的出现。这与作为软件开发者早期被灌输的一些常见模型有关,特别是对DRY(不要重复自己)的坚持。
事实上,DRY早就根深蒂固了,我们在组成组件的地方看到了少量的重复现象,很容易想当然地认为“这个重复的东西很多,如果把它抽象成一个单一的组件就好了”,于是仓促地进行了过早的抽象。
一切都是一种权衡,但从没有抽象中恢复要比从错误的抽象中恢复要容易得多。正如我们将在下面进一步讨论的那样,从一个自下而上的模型开始,可以逐渐地得出这些抽象,避免过早地创建它们。
  • 它们防止跨团队复用代码。经常会发现另一个团队已经实现了或正在开发与你的团队所需要的类似的东西。
在大多数情况下,它能做到90%你想要的,但你想要一些轻微的变化,或者只是想重复使用它功能的特定部分,而不需要把整个东西都拿过来。
如果像那样是一个整体的 "全有或全无 "组件,那么软件复用将更加困难,重新实现并将其放入到自己的安全包通常会变得更容易,而不是承担重构或分解其他人软件包的风险,从而导致多个重复的组件都有轻微的变化,并面临相同的问题。
  • 它们使代码膨胀。怎样才能只允许在正确的时间加载、解析和运行需要的代码?
有一些更重要的组件需要优先展示。对于大型应用程序来说,一个关键的性能策略是根据优先级在 "phases "中协调异步加载的代码。
除了让组件能够选择在服务器上的渲染与否之外(因为理想情况下,只对那些真正会被用户在第一时间看到的组件尽可能快地执行服务器端的渲染。),这里的想法是在可能的情况下延迟渲染。
单片组件阻止了这些意图的发生,因为必须将所有内容作为一个大块组件加载,而不是拥有可以优化的独立组件,这些组件只在用户真正需要的时候加载,用户只需付出实际使用的性能代价。
  • 它们导致运行时性能低下。像React这样的框架,有一个简单的状态—〉UI功能模型,其效率令人难以置信。但是,为了查看虚拟DOM中发生了什么变化而进行的协调过程在大规模开发中代价是相当昂贵的。单片组件很难保证在状态发生变化时只重新渲染最少的内容。
在像React这样的虚拟DOM框架中,实现更好渲染性能的最简单方法之一是将更改的组件与已经更改的组件分开。
因此,当状态更改时,只需要重新渲染严格意义上必要的内容。如果使用像Relay这样的声明式数据获取框架,那么这种技术会变得越来越重要,它可以规避数据更新时对子树进行代价昂贵的重新渲染。
一般来说,在单片组件和自上而下的方法中,很难找到这种分割,容易出错,而且常常导致过度使用memo()。
自下而上的构建方式
与自上而下的方法相比,自下而上的方法通常不那么直观,而且最初可能会比较慢。它会产生多个较小的组件,这些组件的API是可复用的,而不是庞大的单片组件。
【大数据|程序员该如何构建面向未来的前端架构!】当试图快速发布软件时,这是一种不直观的方法,因为在实践中并非每个组件都需要可复用。
然而,创建API可以复用的组件(即使它们不能复用)通常会形成更可读、可测试、可更改和可删除的组件结构。
关于事情应该被分解到什么程度,没有一个标准的答案。解决这个问题的关键是使用单一责任原则作为一般准则。
自下而上与自上而下的心智模型有何不同?
回到上面的例子。使用自下而上的方法,仍然有可能创造一个顶层的,但如何构建它才是最重要的。
确定了顶层的,但不同之处在于工作并不从那里开始。
而是首先对构成整体功能的所有底层元素进行分类,并构建那些可以被组合在一起的小块组件,这样一来,在开始的时候就不那么直观了。
总的复杂性分布在许多较小的单一责任组件中,而不是在单个的单片组件中。
自下而上的方法是什么样子的?
回到侧边导航的例子。下面是一个简单案例代码示例:
Home Settings

在这个简单的例子中没什么可说的。支持嵌套组的API是什么样子的?
Home ProjectsSettings Foo Project 1 Project 2 Project 3 See documentation

自下而上方法的结果是直观的。它需要做更多的前期工作,因为更简单的API的复杂性被封装在各个组件中,但这正是它成为一种更具易用性和可变性长期方法的原因。
与自上而下的方法相比,它的优势有很多:
  1. 使用组件的不同团队只为实际导入和使用的组件付诸努力;
  2. 也可以轻松地进行代码拆分和异步加载那些对用户来说不具有直接优先级的元素;
  3. 渲染性能更好且更易于管理,因为只有因更新而更改的子树需要重新渲染;
  4. 可以创建和优化在导航中具有特定责任的单个组件。从代码结构的角度来看,它的可扩展性更强,因为每个组件都可以单独工作和优化。
有什么问题?
自下而上的方法一开始会比较慢,但从长远来看会更快,因为它的适应性更强。可以更容易地避免仓促过早的抽象,随着时间的推移,驾驭变化的浪潮,直到正确的抽象变得明晰,这是防止单片组件扩散的最佳方法。
如果是像侧边导航这样在整个代码库中使用的共享组件,自下而上的构建往往需要团队花费更多的精力来组装这些部件,但正如我们所看到的,在具有许多共享组件的大型项目中,这是一种值得做出的权衡。
自下而上方法的强大之处在于,模型以“我可以将哪些简单的基本单元组合在一起以实现我想要的东西 "的前提开始,而不是从脑海中已经存在的特定抽象开始。
敏捷软件开发最重要的经验之一是迭代的价值;这适用于所有级别的软件开发,包括架构”
从长远来看,自下而上的方法可以更好地进行迭代。
接下来,回顾一下一些有用的原则,记住这些原则可以让自下而上的构建方式变得更容易:
避免使用单片组件策略
  • 平衡单一责任与DRY
自下而上的思维通常意味着接受组合模型。这往往意味着在开发上可能会有一些重复。
DRY是开发人员学习的第一件事,对代码进行DRY的感觉很好,但是把所有东西都DRY之前,最好还是等一等,看看是否有必要。
但是这种方法可以让开发人员随着项目的增长和需求的变化而“驾驭复杂的浪潮”,并且允许在有必要的时候更容易地使用抽象的东西。
  • 反转控制
理解这一原则的一个简单例子是callbacks 和Promises(异步编程的解决方案)之间的区别。
使用回调函数,不一定知道该函数的去向,它将被调用多少次,或者用什么来调用。
Promises将控制权反转回用户,这样就可以开始编写逻辑,并假装值已经存在。
// may not know what onLoaded will do with the callback we pass it onLoaded((stuff) => { doSomething(stuff); })// control stays with us to start composing logic as if the // value was already there onLoaded.then(stuff => { doSomething(stuff); })

在React的中,这是通过组件API设计实现的。
可以通过子元素来展示“slots”,或者渲染样式属性,以保持用户方的反转控制。
在这方面,有时用户会有一种对反转控制的厌恶,因为有一种不得不做更多工作的感觉。但这既是意味着放弃可以预测未来的想法,也是为了选择赋予用户以灵活性。
// A "top down" approach to a simple button API

第二个例子更灵活地满足不断变化的需求,性能也更高,因为不再需要成为Button包的依赖项。
可以在这里看到自上而下和自下而上的细微差别。在第一个例子中,传递数据并让组件处理。在第二个例子中,需要做更多的工作,但最终这是一种更灵活、更高效的方法。
有趣的是,