To build a house,you need to put one brick on top of another
「不积跬步无以至千里」

大家好,我是柒八九。 今天,我们来讲讲在「前端架构」

要想在大项目中做到构建性能良好并且在架构方面具有扩展性是一件困难的事情。

所以,今天我们来通过一些例子来探讨如何在前端项目中如何做到在性能和架构方面做一个合理的配置和权衡处理。在讨论问题的同时,也会附带一些针对性的解决方案。让你在遇到一个类似问题时,不至于“抓耳挠腮”。

「前端架构」是一个广泛的话题,有许多不同的方面。该文章将侧重于组件的「代码结构」,针对其他的方面只是一带而过

并且,该篇文章所用的技术框架为React,但是不要过于担心,有些原则是通用的,放之四海而皆准

好了,话不多说,开始今天的话题。

你能所学到的知识点

  1. 组件思维 「推荐阅读指数」 ⭐️⭐️⭐️⭐️⭐️
  2. 何为自上而下构建组件 「推荐阅读指数」 ⭐️⭐️⭐️⭐️⭐️
  3. 何为单体组件 & 及其弊端 「推荐阅读指数」 ⭐️⭐️⭐️⭐️
  4. 自下而上的构建组件 「推荐阅读指数」 ⭐️⭐️⭐️⭐️⭐️
  5. 如何规避单体组件 「推荐阅读指数」 ⭐️⭐️⭐️⭐️⭐️


常见的心智模式

「心智模型」,是对事物的思考方式,在很大程度上影响了我们的决定。

在大型的代码库中,正是通过不断做出的各种决定导致了代码的整体结构。

当我们进行多人员协作时,最重要的就是「统一思想」,这样才可以劲往一处使。如若不然,每个人都有附带自己的思考去做同一件事,在一些问题上就会南辕北辙。

这就是为什么在团队协作的时候,需要制定一些符合团体「代码风格」或者借助prettier这样的工具进行格式制约。作为一个整体,我们有一个共同的「心智模式」,即统一思想,集中力量办一件事。

如果你曾经接手过「号称」当时由于时间紧,任务重而快速迭代开发的项目的时候,同时在开发的时候没有统一的代码组织方案,随着时间的推迟(不用很久,一个月足矣),所维护的代码就是各种问题,代码结构越来越乱,变量横飞,回调「贯穿」整颗元素树,运行时性能越来越差。

  • 悄悄的说一句,这不就是⛰吗!
  • 弱弱的问一下,你擅长雕花吗!
  • 同情的讲一下,你背的东西多吗!

如果,你想改变这种情况,那接下来的内容,你值得拥有。你会了解到如下内容:

  • 在使用像 React这样的 「基于组件」的框架开发前端应用程序时,最常见的心智模型是什么?
  • 它们是如何影响我们的组件结构的?
  • 它们中隐含着哪些权衡,我们可以将其明确化?

组件思维

React 是最流行的「基于组件」的前端框架。

React官网文档中有一篇Thinking in react,它阐述了在以 「React方式」构建前端应用程序时如何思考的心智模型。

它所阐述的主要原则,指导你在构建一个组件时需要考虑哪些方面。

  • 「组件的责任是什么」?好的组件API设计自然遵循单一责任原则Single Responsibility Principle,这对组合模式Composition Patterns很重要。我们很容易把简单的东西混为一谈。随着需求的新增和变更,「保持简单的东西往往是相当困难的」

  • 什么是其「状态的最小,但完整」的表示?我们的想法是,从「最小但完整」的状态开始,你可以从中推导出变化。这很灵活,很简单,而且可以避免常见的数据同步错误,比如更新一个状态而不更新另一个。

  • 「状态应该住在哪里」「状态管理」是一个广泛的话题,如果想了解可以参考React-全局状态管理的群魔乱舞,我们不在这里进行过多的赘述。但一般来说,如果一个状态可以被变成一个组件的本地状态,优先将其设置为组件本地state「组件内部对全局状态的依赖越多,它们的可重用性就越低」。提出这个问题对于确定哪些组件应该依赖哪些状态是很有用的。

「一个组件最好只做一件事」。如果它最终成长起来,它应该被分解成更小的子组件。

这里原则是简单的、经过实践检验的,而且它们对驯服复杂性很有效。它们构成了创建组件时最常见的心智模型的基础。

「但简单并不意味着容易」。在实践中,在有多个团队和开发人员的大型项目中,这一点说起来容易做起来难。

这就引出了我们要探讨的两个问题。

  • 是什么情况阻碍了这些简单原则的应用?
  • 我们怎样才能尽可能地减轻这些情况?

下面我们将看到为什么随着时间的推移,「保持简单性」在实践中并不总是那么直接。

成功的项目往往来自于「对基本原则的坚持」,而且是持续的坚持。并且不犯太多代价高昂的错误。


自上而下Top down自下而上Bottom up

组件是React等现代框架的「核心抽象单位」。有两种主要的方式来考虑创建它们。

你可以「自上而下」「自下而上」地构建。

  • 在比较简单的项目中, 「自上而下」比较容易
  • 而在比较大的项目中, 「自下而上」比较容易


自上而下的构建组件

上面总结隐含着一种「权衡」

  • 对较简单的项目采取自上而下的方法
  • 对大型项目采取 较慢的、可扩展的自下而上的方法

「自上而下通常是最直观和最直接的方法」。这也是从事功能开发的开发人员在构建组件时最常见的心智模式。

「自上而下的方法是什么样子的?」 当开始页面结构设计时,常见的建议是:“在用户界面周围画上方框,这些将成为你的组件”。

这构成了我们最终创建的顶层组件的基础。采用这种方法,我们通常以创建一个粗略的组件来开始构建页面。

假设,我们现在接到了一个「用户管理系统」的需求。从页面设计的角度,我们来看看需要哪些组件。

在设计中,它有一个侧边栏导航。我们在侧边栏周围画一个方框,意味着要创建一个组件。

按照这种自上而下的方法,我们可以规划它需要什么props,以及它如何渲染。假设我们从后端获得导航的列表数据。按照自上而下的模式,我们可以构建一个类似下面的伪代码的初始设计。

//从某个地方调用接口获得列表数据
//然后转换为一个列表,传递给导航组件
constnavItems=[
{label:'首页',to:'/home'},
{label:'信息展示',to:'/dashboards'},
{label:'页面设置',to:'/settings'},
]
...

到目前为止,使用自上而下的方法相当直接和直观。我们的目的是「使事情变得简单和可重复使用」,消费者只需要传入他们想要呈现的数据信息,剩余的事情都由SideNavigation为他们代劳。

还有一些需要注意的事情,在自上而下的模式中是常见的。

  1. 我们从最初确定的「顶层边界」开始设计,通过画方框的方式来敲定我们需要的组件。

  2. 它是一个「单一的抽象」,处理所有与侧面导航栏有关的事情。

  3. 它的API通常是 「自上而下」的,即消费者通过顶部传递它需要工作的数据,它负责处理框架渲染的所有相关事宜。
    很多时候,我们的组件直接从后端获取数据,所以这也符合将数据 「向下」传递到组件中进行渲染的模式。

对于较小的项目,这种方法能够简单快速的构建页面。「但是」,针对大型项目来讲,这种自上而下的数据流向就会出现问题。


自上而下模式的弊端

「自上而下的思维模式」倾向于一开始就把自己固定在一个特定的抽象逻辑上,以解决眼前的问题。它是直观的。它常常被认为是构建组件的「最直接的方法」

这里有一个比较常见的场景。在一个正在快速迭代的项目中。你已经通过画方框的方式来界定出你组件的范围并将其交付到页面中。但是,新需求出现了,需要你针对导航组件进行修改。

这时,事情就会迅速开始变得棘手。如果处理不当的话,无形中会构建出许多,代码臃肿,职责范围过于单一的「野组件」

在其对现有组件的抽象思路和API有一个简单了解前提下,需求继任者在需求变更的裹挟下,在开始coding之前,它可能会有如下的心理路程。

  • 思考这是否是「正确的抽象」。如果不是,在处理新需求的过程中,就可以通过代码重构对其进行改造处理。

  • 增加一个额外的属性。在一个简单的条件后面添加新的功能(React中的条件渲染),只需要判定特定的属性,来处理新增需求的变更。它的好处就是,快。没错,就是快。

现有的抽象原则产生了强大的影响。它的存在证明了它是正确和必要的。

代码封装代表着所付出的努力,而我们非常热衷于去保护这种「既有」的努力成果。
「不幸的是」,可悲的事实是,代码越复杂,越难以理解,也就是说,在代码中倾注的付出越多,我们就更愿意去维护现有逻辑。 — 「沉没成本谬论」

沉没成本谬论之所以存在,是因为我们天生对避免损失比较敏感。

在规模的加持下,每次较小的决定都会导致我们的组件变得更加复杂。当组件变的臃肿&复杂的时候,我们已经违背了React中构建组件的基本原则之一 — 简单性(一个组件最好只做一件事)

让我们把这种常见的情况应用到我们简单的导航组件上。

第一个需求变更出现了。需要处理导航项,使其具有图标、不同大小的文本,并使其中的一些项能够外链到非本系统。

在实践中,UI拥有大量的「视觉状态」。我们还想拥有像分隔符、一些默认被选中状态等东西。

所以我们现在的类型可能看起来像这样,type对应于它是一个链接还是一个普通的导航项。

{id,to,label,icon,size,type,separator,isSelected}

然后在里面,我们将不得不检查type属性,并根据它来渲染导航项。

这里的问题是,具有这样的API的自上而下的组件,必须通过「增加API来响应需求的变化」,并根据传入的内容在内部进行逻辑的「分叉处理」

因为我们把导航项的列表作为一个数组传递给侧边栏组件,对于这些新的要求,我们需要在这些对象上添加一些额外的属性,以区分新类型的导航项和它们的各种状态。

「冰冻三尺非一日之寒」

几周后,有人要求提供一个新的功能,要求在点击一个导航项目,并过渡到该项目下的子导航,并有一个返回按钮回到主导航列表。并且还希望管理员能够通过拖放来重新排列导航项。

需求的不断变更,事情变得愈发不可控制。

一开始是一个相对简单的组件,有一个简单的API,但在几个快速迭代的过程中,很快就会发展成其他东西。

基于此时的现状,下一个需要使用或改编这个组件的开发者或团队要面对的是「一个需要复杂配置的单体组件,而且很可能根本没有相关使用文档」

我们最初的意图是 「只要把列表传下去,剩下的就由组件来处理」,但在这一点上,我们的意图又起了「反作用」,这个组件既慢又有风险,难以修改。

在这一点上,一个常见的情况是考虑扔掉一切,「从头开始重写这个组件」


单体组件Monolithic Components的健康增长

除了第一次,一切都应该自上而下地构建

正如我们所看到的,「单体组件是那些试图做太多事情的组件」。它们通过props接收过多的数据或配置选项,管理过多的状态,并输出过多的用户界面。

它们通常从简单的组件开始,通过需求的不断变更和新增,随着时间的推移,最终做了太多的事情。

「一开始只是一个简单的组件,在几个迭代过程并追加新功能后,就会变成一个单体组件」

当这种情况发生在多个组件上时,并且多人同时在同一个代码库中开发,代码很快就会变得更难改变,页面也会变的更慢。

以下是单体组件可能导致性能问题或者代码臃肿的原因。

过早的抽象化

「这是另外一个导致单体组件出现的原因」。 这与作为软件开发者早期被灌输的一些常见开发模式有关。特别是对 DRYDon’t Repeat Yourself的原则。

事实上,DRY在早期就已经深入人心,而我们在组成组件的地方看到了少量的重复。我们很容易想到 “这东西重复得很厉害,如果能把它抽象成一个单一的组件就好了”,于是我们就「匆忙地进行了过早的抽象」

一切都是权衡的结果,但「从没有抽象中恢复过来比从错误的抽象中恢复过来要容易得多」。 我们会在下面继续介绍,这里做一个剧透,「从一个自下而上的模型开始,我们可以有机地达成这些抽象,使我们能够避免过早地创建它们」

阻碍跨团队的代码重用

你经常会发现另一个团队已经实施了或正在进行与你的团队所需要的东西类似的工作。

在大多数情况下,它可以做你想要的90%的事情,但你想要一些轻微的变化。或者你只是想重新使用它的某一部分功能,而不需要把整个东西都搬过来。

如果它是一个”全有或全无”的单体组件,那么就很难复用现有的逻辑。与重构或者直接修改别人组件或者库的方式相比,在你自己的组件中重新实现相关逻辑或者利用条件判断来进行逻辑复用,显的更加安全。 但是,如果此处变更涉及多个组件,那就需要对多个组件进行相同的处理。

增加包的大小

我们怎样才能只允许在「正确的时间」加载、解析和运行需要的代码?

有一些组件是更重要的,要先给用户看。对于大型应用来说,一个关键的性能策略是根据优先级在页面渲染阶段通过异步操作加载代码。

同时,我们还可以在进行刷新操作时候,对用于实际看到的组件进行「服务端渲染」处理。

「单体组件」阻止了这些努力的发生,因为你必须把所有的东西作为一个大块的组件来加载

如果独立的组件的话,这些组件就可被优化,并且只在用户「真正需要」的时候加载。「消费者只需支付他们实际使用的性能价格」

运行时性能不佳

React这样的框架,有一个简单的state->UI的功能模型,是令人难以置信的生产力。但是,为了查看虚拟DOM中的变化而进行的「调和操作」在页面规模比较大的情况下是很昂贵的。「单体组件很难保证在状态发生变化时只重新渲染最少的东西」

在像React这样的拥有虚拟DOM的框架中,要实现更好的渲染性能,最简单的方法之一就是

将根据「状态变化的进行归类」,同属一类的组件变化,无论是渲染时机还是代码存放位置,都进行统一处理,对于不隶属于同类变更的组件进行隔离处理。

因此,当状态发生变化时,你只需重新渲染严格意义上需要的部分。

在单体组件和一般的自上而下的方法中,找到这种分割是很困难的,容易出错,而且常常导致过度使用memo()


自下而上的构建组件

与自上而下的方法相比,自下而上的方法往往不那么直观,而且最初可能会比较慢。

当你试图需求快速迭代时,这是一个不直观的方法,因为在实践中不是每个组件都需要可重用。

然而,创建API可以重用的组件,即使它们不是重用的,通常会导致更多的可读、可测试、可改变和可删除的组件结构。

关于事情应该被分解到什么程度,没有一个正确的答案。管理这个问题的关键是使用单一责任原则Single Responsibility Principle作为指导准则。

「自下而上的心智模式与自上而下有什么不同」

回到原来的示例。采用自下而上的方法,我们仍然有可能创建一个顶层的,但我们如何建立它才是最重要的。

我们确定了顶层的,但不同的是我们的工作并不是从这里开始。

它开始于对构成整体功能的所有底层元素信息的收录工作,并构建那些可以被组合在一起的小块。这样一来,它在开始时就显的不那么重要了。

「总的复杂性分布在许多较小的单一责任组件Responsibility Components中,而不是一个单一的单体组件」

自下而上的方法是什么样子的?

让我们回到导航的例子。下面是一个简单情况下可能出现的例子。


<NavItemto="/home">首页</NavItem>
<NavItemto="/settings">设置页面</NavItem>
</SideNavigation>

在简单的情况下,没有什么了不起。支持嵌套组的API会是什么样子?


<Section>
<NavItemto="/home">首页</NavItem>
<NavItemto="/projects">项目</NavItem>
<Separator/>
<NavItemto="/settings">设置页面</NavItem>
<LinkItemto="/foo">Foo</NavItem>
</Section>

<NestedGroup>
<NestedSectiontitle="项目目录">
<NavItemto="/project-1">项目1</NavItem>
<NavItemto="/project-2">项目2</NavItem>
<NavItemto="/project-3">项目3</NavItem>
<LinkItemto="/foo.com">介绍文档</LinkItem>
</NestedSection>
</NestedGroup>

</SideNavigation>

自下而上的方法的最终结果是直观的。它需要更多的前期努力,因为更简单的API的复杂性被封装在各个组件中。但这也使得它成为一种能够进行页面自由组装的优势。

与我们自上而下的方法相比,好处很多。

  1. 使用该组件的不同团队只需对他们 「实际导入和使用的组件」进行维护
  2. 可以很容易地用 「代码分割」「异步加载」那些对用户来说不是优先显示的元素
  3. 「渲染性能更好,更容易管理」,因为只有因更新而改变的子树需要重新渲染
  4. 从代码结构的角度来看,它也更具有 「可扩展性」,因为每个组件都可以被单独处理和优化。

自下而上最初比较慢,但从长远来看会更快,因为它的扩展性更强。你可以更容易地避免仓促的抽象,这是防止单体组件泛滥的最好方法。

假设我们在组装现有的页面,在采用自下而上的构建方式下,时间和精力往往耗费在「零件组装」上。但是从后期的可维护性来讲,这是一个值得做的事。

自下而上方法的力量在于,你的页面构建以「我可以将哪些简单的基础原件组合在一起以实现我想要的东西」为前提,而不是一开始就考虑到某个特定的抽象。

敏捷软件开发最重要的经验之一是「迭代的价值」

「自下而上的方法可以让你在长期内更好地进行迭代」


避免单体组件的策略

平衡单一责任与DRY的关系

自下而上的思考往往意味着接受组合模式Composition Patterns。这就势必会导致在代码结构上重复。

DRY是我们作为开发者学习的第一件事,而且将「代码DRY化」是一件令人心情愉悦的事情。但是,「在使所有的东西都成为DRY之前,等待并看看是否需要它往往是更好的选择」

但这种方法可以让你随着项目的发展和需求的变化而能够轻松驾驭复杂的逻辑,并能够进行有意义的抽象处理。

控制反转Inversion of Control

理解这一原则的一个简单例子是callbackpromise之间的区别。

对于callback,你不一定知道这个函数会去哪里,会被调用多少次,或者用什么调用。

promise将控制权转回给消费者,所以你可以开始组成你的逻辑,假装value已经在那里了。


//可能不知道onLoaded会对我们传递给它的回调做什么
onLoaded((stuff)=>{
doSomething(stuff);
})

//组件留在我们身边,开始组成逻辑,就像值已经在那里了
onLoaded.then(stuff=>{
doSomething(stuff);
})

React的技术背景下,我们可以看到这是通过组件API设计实现的。

我们可以通过children暴露slot(槽),或者通过renderProps来保持消费者对内容的控制权。

有时,人们对这方面的控制权反感,因为人们觉得消费者必须做更多的工作。但控制反转可以避免需要过多的牵扯以后的各种情况,也赋予了消费者控制逻辑的灵活性。

//以"自上而下"的方式处理一个简单的按钮API


//通过控制反转
//给予消费者进行自我逻辑的拼接处理
<Buttonbefore={loading" />LoadingSpinner/>:null}/>

第二个例子既能更灵活地应对不断变化的需求,又能更有效地执行,因为不再需要成为Button组件内的一个「依赖项」

你可以在这里看到自上而下与自下而上的微妙差别。在第一个例子中,我们传递数据并让组件处理它。在第二个例子中,我们必须做更多的工作,但最终它是一个更灵活、更有性能的方法。

同样有趣的是,

本文由 mdnice 多平台发布