构建面向未来的前端架构

2,294 阅读26分钟

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等现代框架的核心抽象单位。有两种主要的方式来考虑创建它们。

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

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

自上而下的构建组件

上面总结隐含着一种权衡

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

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

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

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

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

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

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

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

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

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

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

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

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

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


自上而下模式的弊端

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

冰冻三尺非一日之寒

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

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

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

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

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

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


{单体组件|Monolithic Components}的健康增长

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

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

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

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

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

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

过早的抽象化

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

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

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

阻碍跨团队的代码重用

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

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

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

增加包的大小

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

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

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

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

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

运行时性能不佳

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

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

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

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

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


自下而上的构建组件

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

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

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

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

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

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

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

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

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

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

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

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

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

<SideNavigation>
        <Section>
            <NavItem to="/home">首页</NavItem>
            <NavItem to="/projects">项目</NavItem>
            <Separator />
            <NavItem to="/settings">设置页面</NavItem>
            <LinkItem to="/foo">Foo</NavItem>
        </Section>
        <NestedGroup>
            <NestedSection title="项目目录">
                <NavItem to="/project-1">项目 1</NavItem>
                <NavItem to="/project-2">项目 2</NavItem>
                <NavItem to="/project-3">项目 3</NavItem>
                <LinkItem to="/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
<Button isLoading={loading} />

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

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

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

同样有趣的是,<Button /> 本身可以由更小的基础单元组成。有时,一个特定的抽象概念下面有许多不同行为的子元素,这些元素可以被显性化。

例如,我们可以把它进一步分解成即适用于按钮和或者像Link组件这样的东西,它们可以组合成像LinkButton这样的东西。

组件扩展

即使在使用组合模式自下而上地构建页面时。你仍然希望输出具有可消耗API的专门组件,但由较小的基础单元构建而成。为了灵活起见,你也可以从你的组件中公开那些构成专门组件的较小的模块。

理想情况下,你的组件只做一件事。因此,在预制抽象的情况下,消费者根据它们需要实现的操作,用他们自己的功能对其进行包装扩展。另外,他们也可以只用一些构成现有抽象的基础单元来构建他们所需要的东西。

利用storybook驱动的发展

通常有大量的离散状态最终会在我们的组件中得到管理。组件状态库变得越来越流行是有原因的。

当我们用storybook孤立地构建我们的UI组件时,我们可以采用他们思维背后的模型,并为组件可能处于的每一种状态新增描述信息。

像这样提前做可以避免你在开发中对一些实现细节进行遗忘。

下面是一些比较常见措施,如何建立 {弹性组件|Resilient Components}

根据组件的实际作用为其命名

这又回到了单一责任原则。在名称有意义的情况下,不要害怕长的名字

也很容易把一个组件的名字命名得比它实际做的事情稍微通用一些。当组件被命名为比它们实际做的事情更通用时,它向其他开发者表明,它是处理与X有关的一切的抽象。

因此,当新的需求出现时,它自然而然地成为进行改变的出发点。

避免包含实施细节的props名称

尤其是UI风格的 叶子组件。尽量避免添加像isSomething这样的props,因为有些东西是与内部状态或特定领域相关的。然后让该组件在该props被传入时做一些不同的事情。

如果你需要这样做,如果props的名字能反映出它的那个组件的上下文中的实际作用,那就更清楚了。

举个例子,如果isSomethingprops最终控制的是padding之类的东西,那么props名称就应该反映出这一点,而不是让组件知道一些看似无关的东西。

谨慎对待通过props进行的配置

这又回到了控制反转上。

<SideNavigation navItems={items}/>这样的组件,如果你知道你只有一种类型的子组件(而且你知道这肯定不会改变!),就可以很好地解决这个问题,因为它们也可以安全地被类型化

但正如我们所看到的,这种模式很难在不同的团队和需求快速迭代开发人员之间进行推广。

因为你经常会想要扩展组件,使其拥有不同的,或额外的子类型。这意味着你会在这些配置选项中添加更多的东西,或者props,并添加分叉逻辑

与其让消费者安排和传递对象,一个更灵活的方法是把内部的子组件也导出,让消费者组成和传递组件。

避免在渲染方法中定义组件

有时候,在一个组件中拥有 辅助组件是很常见的。这些组件最终会在每次渲染时被重新加载,并可能导致一些奇怪的错误。

此外,有多个内部的renderXrenderY方法往往是一种不好的举措。这些通常是一个组件变得单一化的标志,这些都是需要被进行分解处理的点。


分解单体组件

如果可能的话,要经常和尽早地进行重构。识别可能发生变化的组件并积极分解它们是一个很好的策略。

当你发现组件变得过于复杂的情况下,通常有两个选择。

  1. 重写逻辑并逐步迁移到新的组件上
  2. 渐进式地分解组件逻辑

React这样的框架中,组件实际上只是伪装的函数。针对组件的重构,也就是针对函数逻辑的分发和梳理。

下面是一些比较常见的方式可供参考。(后期,我们可以单写一篇关于组件重构的文章)


内容回顾

断断续续,我们讲了很多概念和思路,让我们在最后做一个简短的梳理。

心智模型影响着我们在设计和构建前端组件时做出的许多微观决定

将这些明确化是有用的,因为它们积累得相当快。这些决定的积累最终决定了我们后续工作的方向,在遇到新的需求时,心智模型决定着,我们是对现有工作进行减枝处理还是分叉处理,又或者采用新的架构,对其进行优化扩展。

在构建组件时,自上而下和自下而上的做法会导致项目的最终结果不同

在构建组件时,一个自上而下的心智模型通常是最直观的。当涉及到分解用户界面时,最常见的模型是在功能区域周围画上方框,然后成为你的组件。这种功能分解的过程是自上而下的,通常会直接导致创建具有特定抽象性的专门组件。需求会改变。而在几个迭代过程中,这些组件很容易迅速变成单体组件。

自上而下的设计和构建会导致单一的组件

一个充满单体组件的代码库会导致一个缓慢的、对变化没有弹性的前端架构。单体组件之所以不好,是因为。

  • 需求变更和维护成本很高
  • 需求变更是有风险的
  • 很难跨团队利用现有的工作
  • 性能很差
  • 在采用面向未来的技术和架构时,它们会无形中新增阻力,而这些技术和架构对于扩展前端应用很重要,比如有效的代码拆分、跨团队的代码重用、加载阶段、渲染性能等。

避免创建单体组件

React 在设计组件时更有效地采用了自下而上的模式。这更有效地让你避免过早的抽象。这样,我们就可以在合适的时候进行抽象。这样的构建方式为组件组合模式的实现提供了更多的可能性。


后记

分享是一种态度

参考资料:

全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。