前端组件设计原则

高级前端进阶

共 10832字,需浏览 22分钟

 · 2020-08-31


译者:@没有好名字了
译文:https://github.com/lightningminers/article/issues/36,https://juejin.im/post/5c49cff56fb9a049bd42a90f
作者:@Andrew Dinihan
原文:https://engineering.carsguide.com.au/front-end-component-design-principles-55c5963998c9

前言

我在最近的工作中开始使用 Vue 进行开发,但是我在上一家公司积累了三年以上 React 开发经验。虽然在两种不同的前端框架之间进行切换确实需要学习很多,但是二者之间在很多基础概念、设计思路上是相通的。其中之一就是组件设计,包括组件层次结构设计以及组件各自的职责划分。

组件是大多数现代前端框架的基本概念之一,在 React 和 Vue 以及 Ember 和 Mithril 等框架中均有所体现。组件通常是由标记语言、逻辑和样式组成的集合。它们被创建的目的就是作为可复用的模块去构建我们的应用程序。

类似于传统 OOP 语言中 class 的设计,在设计组件的时候需要考虑到很多方面,以便它们可以很好的复用,组合,分离和低耦合,但是功能可以比较稳定的实现,即使是在超出实际测试用例范围的情况下。这样的设计说起来容易做起来却很难,因为现实中我们往往没有足够的时间按照最优的方式去做。

方法

在本文中,我想介绍一些组件相关的设计概念,在进行前端开发时应该考虑这些概念。我认为最好的方法是给每个概念一个简洁精炼的名字,然后逐一解释每个概念是什么以及为什么重要,对于比较抽象概念的会举一些例子来帮助理解。

以下这个列表并不是不全面也不完整,但我注意到的只有 8 件事情值得一提,对于那些已经可以编写基本组件但想要提高他们的技术设计技能的人来说。所以这是列表:
以下列举的这个列表仅仅是是我注意到的 8 个方面,当然组件设计还有其他一些方面。在此我只是列举出来我认为值得一提的。

对于已经掌握基本的组件设计并且想要提高自身的组件设计能力的开发者,我认为以下 8 项是我认为值得去注意的,当然这并不是组件设计的全部。

  • 层次结构和 UML 类图

  • 扁平化、面向数据的 state/props

  • 更加纯粹的 State 变化

  • 低耦合

  • 辅助代码分离

  • 提炼精华

  • 及时模块化

  • 集中/统一的状态管理

请注意,代码示例可能有一些小问题或有点人为设计。但是它们并不复杂,只是想通过这些例子来帮助更好的理解概念。

层次结构和类图

应用内的组件共同形成组件树, 而在设计过程中将组件树可视化展示可以帮助你全面了解应用程序的布局。一个比较好的展示这些的办法就是组件图。

UML 中有一个在 OOP 类设计中经常使用的类型,称为 UML 类图。类图中显示了类属性、方法、访问修饰符、类与其他类的关系等。虽然 OOP 类设计和前端组件设计差异很大,但是通过图解辅助设计的方法值得参考。对于前端组件,该图表可以显示:

  • State

  • Props

  • Methods

  • 与其他组件的关系( Relationship to other components )

因此,让我们看一下下面这个基础表组件的组件层次图,该组件的渲染对象是一个数组。该组件的功能包括显示总行数、标题行和一些数据行,以及在单击其单元格标题格时对该列进行排序。在它的 props 中,它将传递列列表(具有属性名称和该属性的人类可读版本),然后传递数据数组。我们可以添加一个可选的’on row click’功能来进行测试。

虽然这样的事情可能看起来有点多,但是它具有许多优点,并且在大型应用程序开发设计中所需要的。这样会带来的一个比较重要的问题是它会需要你在开始 codeing 之前就需要考虑到具体细节的实现,例如每个组件需要什么类型的数据,需要实现哪些方法,所需的状态属性等等。

一旦你对如何构建一个组件(或一组组件)的整体有大概的思路,就会很容易认为当自己真正开始编码实现时,它会如自己所期望的按部就班的完成,但事实上往往会出现一些预料之外的事情, 当然你肯定不希望因此去重构之前的某些部分,或者忍受初始设想中的缺点并因此扰乱你的代码思路。而这些类图的以下优点可以帮助你有效的规避以上问题,优点如下:

  • 一个易于理解的组件组成和关联视图

  • 一个易于理解的应用程序 UI 层次结构的概述

  • 一个结构数据层次及其流动方式的视图

  • 一个组件功能职责的快照

  • 便于使用图表软件创建

顺带一提,上图并不是基于某些官方标准,比如 UML 类图,它是我基本上创建的一套表达规则。例如,在 props 、方法的参数和返回值的数据类型定义声明都是基于 Typescript 语法。我还没有找到书写前端组件类图的官方标准,可能是由于前端 Javascript 开发的相对较新且生态系统不够完善所致,但如果有人知道主流标准,请在回复中告诉我!

扁平的,面向数据的 state/props

在 state 和 props 频繁被 watch 和 update 的情况下,如果你有使用嵌套数据,那么你的性能可能会受到影响,尤其是在以下场景中,例如一些因为浅对于而触发的重新渲染;在涉及 immutability 的库中,比如 React,你必须创建状态的副本而不是像在 Vue 中那样直接更改它们,并且使用嵌套数据这样做可能会创建笨拙,丑陋的代码。

//Flat, data-oriented state/props
const state = {
  clients: {
    allClients,
    firstClient,
    lastClient: {
      name: 'John',
      phone: 'Doe',
      address: {
        number: 5,
        street: 'Estin',
        suburb: 'Parrama',
        city: 'Sydney'
      }
    }
  }
}

// 倘若我们需要去修改 address number时需要怎么办?
const test = {
  clients: {
    ...state.clients,
    lastClient: {
      ...state.clients.lastClient,
      address: {
        ...state.clients.lastClient.address,
        number: 10
      }
    }
  }
}


即使使用展开运算符,这种写法也并不够优雅。扁平 props 也可以很好地清除组件正在使用的数据值。如果你传给组件一个对象但是你并不能清楚的知道对象内部的属性值,所以找出实际需要的数据值是来自组件具体的属性值则是额外的工作。但如果 props 足够扁平化,那么起码会方便使用和维护。

// 我们无法得知 customer 这个对象里面拥有什么属性
// 这个组件需要使用这个对象所有的属性值或者只是需要其中的一部分?
// 如果我想要将这个组件在别处使用,我应该传入什么样的对象

<listItem customer={customer}/>

// 下面的这个组件接收的属性就一目了然

<listItem phone={customer.phone} name={customer.name} iNumber={customer.iNumber}  />


state / props 还应该只包含组件渲染所需的数据。You shouldn’t store entire components in the state/props and render straight from there.

(此外,对于数据繁重的应用程序,数据规范化可以带来巨大的好处,除了扁平化之外,你可能还需要考虑一些别的优化方法)。

更加纯粹的 State 变化

对 state 的更改通常应该响应某种事件,例如用户单击按钮或 API 的响应。此外它们不应该因为别的 state 的变化而做出响应,因为 state 之间这种关联可能会导致难以理解和维护的组件行为。state 变化应该没有副作用。

如果你滥用watch而不是有限考虑以上原则,那么在 Vue 的使用中就可能由此引发的问题。我们来看一个基本的 Vue 示例。我正在研究一个从 API 获取一些数据并将其呈现给表的组件,其中排序,过滤等功能都是后端完成的,因此前端需要做的就是 watch 所有搜索参数,并在其变化时触发 API 调用。其中一个需要 watch 的值是“zone”,这是一个过滤器。当更改时,我们想要使用过滤后的值重新获取服务端数据。watcher 如下:

//State change purity
zone:{
  handler() {
    // 重置页码
    if(this.pagination.page > 1){
        this.pagination.page = 1
        return;
    }
    this.getDataFromApi()
  }
}


你会发现一些奇怪的东西。如果他们超出了结果的第一页,我们重置页码然后结束?这似乎不对,如果它们不在第一页上,我们应该重置分页并触发 API 调用,对吧?为什么我们只在第 1 页上重新获取数据?实际上原因是这样,让我们来看下完整的 watch:

watch: {
  pagination() {
    this.getDataFromApi()
  }
},
zone: {
  handler() {
    // 重置页码
    if(this.pagination.page > 1) {
        this.pagination.page = 1
        return;
    }
    this.getDataFromApi()
  }
}


当分页改变时,应用首先会通过 pagination 的处理函数重新获取数据。因此,如果我们改变了分页,我们并不需要去关注数据更新这段逻辑。

让我们一下来考虑以下流程:如果当前页面超出了第 1 页并且更改了 zone,而这个变化会触发另一个状态(pagination)发生变化,进而触发 pagination 的观察者重新请求数据。这样并不是预料之中的行为,而且产生的代码也不够直观。

解决方案是改变页码这个行为的事件处理函数(不是观察者,用户更改页面的实际处理函数)应该更改页面值并触发 API 调用请求数据。这也将消除对观察者的需求。通过这样的设置,直接从其他地方改变分页状态也不会导致重新获取数据的副作用。

虽然这个例子非常简单,但不难看出将更复杂的状态更改关联在一起会产生令人难以理解的代码,这些代码不仅不可扩展并且是调试的噩梦。

松耦合

组件的核心思想是它们是可复用的,为此要求它们必须具有功能性和完整性。“耦合”是指实体彼此依赖的术语。松散耦合的实体应该能够独立运行,而不依赖于其他模块。就前端组件而言,耦合的主要部分是组件的功能依赖于其父级及其传递的 props 的多少,以及内部使用的子组件(当然还有引用的部分,如第三方模块或用户脚本)。

紧密耦合的组件往往更不容易被复用,当它们作为特定父组件的子项时,就很难正常工作,当父组件的一个子组件或一系列子组件只能在该父组件才能够正常发挥作用时,就会使得代码写的很冗余。因为父子组件别过度的关联在一起了。

在设计组件时,你应该考虑到更加通用的使用场景,而不仅仅只是为了满足最开始某个特定场景的需求。虽然一般来说组件最初都是出于特定目的进行设计,但没关系,如果在设计它们站在更高的角度去看待,那么很多组件将具有更好的适用性。

让我们看一个简单的 React 示例,你想在写出一个带有一个 logo 的链接列表,通过连接可以访问特定的网站。最开始的设计可能是并没有跟内容合理的进行解耦。下面是最初的版本:

const Links = ()=>(
  <div className="links-container">
    <div class="links-list">
      <a href="/">
        Home
      a>

      <a href="/shop">
        Products
      a>
      <a href="/help">
        Help
      a>
    div>
    <div className="links-logo">
      <img src="/default/logo.png"/>
    div>
  div>
)


虽然这这样会满足预期的使用场景,但却很难被复用。如果你想要更改链接地址该怎么办?你必须重新复制一份相同代码,并且手动去替换链接地址。而且, 如果你要去实现一个用户可以更改连接的功能,那么意味着不可能将代码写“死”,也不能期望用户去手动修改代码,那么让我们来看一下复用性更高的组件应该如何设计:

const DEFAULT_LINKS = [
  {route"/"text"Home"},
  {route"/shop"text"Products"},
  {route"/help"text"Help"}
]
const DEFAULT_LOGO = "/default/logo.png"

const Links = ({links = DEFAULT_LINKS,logoPath = DEFAULT_LOGO }) => (
  <div className="links-container">
    <div class="links-list">
       // 将数组依次渲染为超链接
       links.map((link) => <a href={link.route}> {link.text}a>
)
    div>
    <div className="links-logo">
      <img src={logoPath}/>
    div>
  div>
)


在这里我们可以看到,虽然它的原始链接和 logo 具有默认值,但我们可以通过 props 传入的值去覆盖掉默认值。让我们来看一下它在实际中的使用:

const adminLinks = {
  links: [
    {route: "/", text: "Home"},
    {route: "/metrics", text: "Site metrics"},
    {route: "/admin", text: "Admin panel"}
  ],
  logoPath: "/admin/logo.png"
}


并不需要重新编写新的组件!如果我们解决上文中用户可以自定义链接的使用场景,可以考虑动态构建链接数组。此外,虽然在这个具体的例子中没有解决,但我们仍然可以注意到这个组件没有与任何特定的父/子组件建立密切关联。它可以在任何需要的地方呈现。改进后的组件明显比最初版本具有更好的复用性。

如果不是要设计需要服务于特定的一次性场景的组件,那么设计组件的最终目标是让它与父组件松散耦合,呈现更好的复用性,而不是受限于特定的上下文环境。

辅助代码分离

这个可能不那么的偏理论,但我仍然认为这很重要。与你的代码库打交道是软件工程的一部分,有时一些基本的组织原则可以使事情变得更加顺畅。在长时间与代码相处的过程中,即使改变一个很小的习惯也可以产生很大的不同。其中一个有效的原则就是将辅助代码分离出来放在特定的地方,这样你在处理组件时就不必考虑这些。以下列举一些方面:

  • 配置代码

  • 假数据

  • 大量非技术说明文档

因为在尝试处理组件的核心代码时,你不希望看到与技术无关的一些说明(因为会多滚动几下鼠标滚轮甚至打断思路)。在处理组件时,你希望它们尽可能通用且可重用。查看与组件当前上下文相关的特定信息可能会使得设计出来的组件不易与具体业务解耦。

提炼精华

虽然这样做起来可能具有挑战性,但开发组件的一个好方法是使它们包含渲染它们所需的最小 Javascript。一些无关紧要的东西,比如数据获取,数据整理或事件处理逻辑,理想情况下应该将通用的部分移入外部 js 或或者放在共同的祖先中。

单独从组件分的“视图”部分来看,即你看到的内容(html 和 样式)。其中的 Javascript 仅用于帮助渲染视图,可能还有一些针对特定组件的逻辑(例如在其他地方使用时)。除此之外的任何事情,例如 API 调用,数值的格式化(例如货币或时间)或跨组件复用的数据,都可以移动外部的 js 文件中。让我们看一下 Vue 中的一个简单示例,使用嵌套列表组件。我们可以先看下下面这个有问题的版本。

这是第一个层级:

// 组件父级