# 如何制作一个组件?论组件化思想

# 写在前面

先进的 web 开发通过前后端分离的技术拆分为了 web 后端开发与 web 前端开发,值得指出的是,web 前端开发早已不是传统意义上的开发模式了,转而变成了 web 客户端开发, 有过客户端开发经验的同学应该知道这两者的区别,客户端开发关注的是:

  • 应用的生命周期
  • 组件化
  • 开发模式与打包方法

组件化是客户端开发最重要的内容,设计一套复用度高、扩展性好的组件系统,可以 显著提高开发效率, 并且可以 减少后期的维护成本。

# 一个笔记组件的设计案例

img

就一我们正在使用的笔记 app 为例,上图展示的笔记的阅读与书写区域,如何将这个区域抽象为一个组件呢?让我们一步一步来分析。

# 最简 API

我们为该组件取个名字(取名很重要),就叫 Note 吧。不管是在阅读状态还是编辑状态,该组件都要展示笔记的内容,因为笔记对象应该通过组件的接口传入进来,因为我们为该组件设计第一个 API:

属性 说明 类型 是否必填 默认值
data 笔记对象数据 object null

接下来我们简单使用一下这个组件:

const note = {
  title: '如何制作一个组件.md',
  content: ''
}

function F() {
  return <Note data={note} />
}

这样,一个最简 API 的笔记组件就搞定了,它的接口非常简单,只需要提供一个 data 属性,就可以展示出笔记内容,并且可以点击编辑进入书写状态。

一般而言,如果没有更多的需求的话,我们的笔记组件设计到这里就可以了。 在设计组件时,务必遵循最小化原则,即尽可能少抛出接口。 因为使用组件的用户可能很多,一旦组件作者不小心跑出了一个不合理的接口,以后想要修改就几乎不可能了(只能通过标记果实的方法提醒用户,但这种做法往往是无奈之举)。

# 满足数据获取的多种情况

现在,组件的使用者已经可以通过很简洁的 API 使用这个笔记组件了,但是新的问题来了: 有的组件使用者只拿到了笔记的 id,想要通过直接传入 id 的方式使用组件。

此时,作为组件作者,我们评估了这个需求是合理的,于是我们扩展了笔记组件的 API:

属性 说明 类型 是否必填 默认值
data 笔记对象数据 object null
dataId 笔记对象id string null

现在可以通过传入 id 的方式来使用组件了:

const noteId = '123'

function F() {
  return <Note dataId={noteId} />
}

请注意,API 中的两个属性都是非必填的,因为不知道用户会传入哪个属性,为了程序的严谨性,组件内部应当校验两个参数都不传的情况,并通过抛出错误告诉调用者。

这是组件设计的一个技巧,通过支持多种数据源使得调用更加简单。但是这种设计也有其弊端所在,如果这种兼容性的扩展过多会使得组件的内部逻辑变得复杂,也会使得 API 变得难于理解,因此, 对于兼容性的 API 扩展要谨慎,不可过量。

# 兼容不同模式

组件的使用一如既往的优雅、简单,但是现在又有用户提出新的需求: 因为该组件是支持阅读与编辑两种模式,在使用时,对于他人的笔记是不可编辑的,能否在指定场景下只支持一种阅读模式?

笔记组件由于内部支持了两种模式,既支持阅读,又支持编辑,因此调用者只想使用一种模式也是合理的,于是我们继续扩展组件 API:

属性 说明 类型 是否必填 默认值
mode 模式,数组的第一项作为初始模式,该参数不可为空数组 array ['write', 'read']

现在对于只想使用阅读模式的用户,可以这么调用:

const note = {}
const mode = ['read']

function F() {
	return <Note data={note} mode={mode} />
}

在设计api时,我们在满足需求的前提下,支持了更多情况。首先,使用者也可能只使用编辑模式,因为mode参数是支持随意组合各种模式的,因此这种情况也能满足。另外,如果组件以后扩展了更多模式,该api仍然能满足需求,只需要为mode数组增加更多的模式项即可。

这里有一个更佳的设计是,当使用多个模式时,确定哪个模式作为初始模式也是有必要的,因此,将mode数组的第一项作为多模式下的初始模式,既满足了需求,又达到了api设计最小化的原则。

现在,我们对用户的需求进行了扩展,不仅支持只使用阅读模式,还支持各种模式任意组合和初始模式,但是这还不够,组件的设计者应当针对需求想到更长远的情况,针对这个例子,我们还可以为组件扩展一个模式改变的事件,让调用者可以捕捉到笔记组件从阅读 -> 编辑或编辑 -> 阅读(随着模式的扩展,这种组合会更多)切换的时机:

事件 说明 回调参数
modeChange 模式切换时触发 (from: string, to: string) from表示切换前的模式,to表示切换后的模式

调用者可能在捕捉到模式切换事件时,做一些特定的工作:

function handleModeChange(from, to) {
  // ...
}

function F() {
  return <Note onModeChange={handleModeChange}  />
}

# 更多的支持

在编辑器中编辑笔记时 html 或 markdown 类型的, 笔记组件支持将组件导出为一个 PDF 文档。因此,设计时我们可以将组件的一些能力抽象为 API,再次扩展组件的 API:

方法 说明 参数
exportPDF 导出笔记为PDF文件 -
toggleFullscreen 切换全屏显示 (value: boolean) 是否全屏展示

组件设计时,我们可以将可预见范围内的组件能力设计为 API,需要注意的是,方法的参数与返回值也是 API 的一部分,应当谨慎设计。

除了扩展组件的能力外,我们还可以扩展组件的视图。注意到阅读按钮右侧的工具栏了吗?我们假设这部分的视图不属于笔记组件,是通过 API 扩展而渲染出来的,这就是组件的 子视图设计, 在 web 前端的组件化中,称为 插槽。 我们可以为笔记组件扩展一个工具栏的插槽:

插槽 说明 参数
toolbar 工具栏子视图 { data }

当调用者想要扩展笔记组件的工具栏时,可以这么使用:

const note = {}

function F() {
  return <Note data={note}>
  	<MyToolbar />
  </Note>
}

这样,调用者就可以根据自己的需求,在工具栏渲染自己想要的内容。

# 组件设计四要素

上述案例讲述了组件设计的整个流程,通过分析用户的需求(或未来可能出现的需求),一步一步地设计出了一个复用度高、扩展性好的组件。如果你是一个组件设计的新手,你应该如何去思考、去设计一个优良的组件呢?

# 先设计,后实现

我们通篇在讨论组件的设计,但是实际操作时,很多朋友会通过边实现边设计的方式来完成一个组件的制作,这是不合理的,因为自身能力与眼界的限制,实现可能会干扰你的设计,对于以下两个景点矛盾,希望读者选择后者,以追求合理性为重。

  1. 这样实现比较方便,不如将这个参数抛出让用户传进来吧!
  2. 这样设计比较合理,虽然实现难度可能会比较高,但我可以通过文档学习、求问他人的方式来实现它,或者直接让他人来实现。

提出问题比解决问题更难。 设计难于实现,你应当花 70% 的时间来设计而不是用来实现,有的设计者甚至不参与实现,设计者与实现者的身份也是随时转换的,善于思考的实现者本身就是设计者。

# 组件设计四要素

  • 属性
  • 方法
  • 事件
  • 子视图(插槽)

上述的案例基本涵盖了这四个要素,这四要素共同组成了组件的api。需要注意的,除了基本的四要素外,我们还需要注意这些也是组件api的一部分:

  • 属性的类型、是否必填、默认值(属性类型确定后不再变化)
  • 方法的参数、返回值(需要考虑变化的情况)
  • 事件回调函数的参数
  • 插槽可获取到的局部参数

在设计时,应当小心谨慎面对每一个 API 的要素,哪一个环节出现了设计缺陷,对于调用者都是如鲠在喉。

# 终极思考:面向对象

尽管我们通过一系列的理论讲述了组件设计的方法,但是对于初学者来说,仍然难以设计出一个优良的组件,设计一个优良的组件需要大量的经验,初学者往往考虑不全面,或因对需求的不了解,无法预知未来的变化。

尽管如此,初学者仍要耐心学习组件的设计,不积硅步无以至千里,经过一段时间的积累,我总结了一个设计组件的终极思维,将 面向对象 的思想用于组件设计,将会事半功倍。

在开发领域,学会思考比埋头干活重要。我们将这个理论用于组件设计中,如何通过面向对象的思维来设计一个组件呢?

虽然我们强调使用面向对象的思维来设计组件,但仿佛面向对象思维比组件设计更高深,我们当然不会推荐大家用更加晦涩的理论来指导组件的设计,这里,我们将面向对象拟人化,提取出一个 自然世界联想法 的思考方法。

下面我们就用这个方法来设计一个「快递小哥」组件:

首先快递小哥有他的基本信息,这是该组件的 属性 ,基本信息包含他的任职单位、工作年限、姓名、联系方式等。此外,快递小哥有一些特定的行为,例如送快递、接受包裹等,我们可以将这部分抽取为组件的方法,比如我们调用快递小哥的接收包裹方法,该方法有两个参数,第一个参数是我要寄的东西即包裹,第二个参数是快递单,描述了寄送相关信息。除了基本信息和一些行为外,快递小哥组件还有一些特定的事件,当我们的包裹到了时,他会打电话给我们,这里,组件抛出一个 快递到达 的事件,时间的参数是快递单和包裹,快递单描述了包裹的送达信息,包裹是快递单中描述的接收人的东西。最后,快递小哥组件有没有子视图呢?有,快递小哥组件除了被我们普通用户调用外,还会被快递公司所调用,不同的快递公司会以不同的方式来包装快递小哥(例如通过不同服饰不同 logo 等),因此,快递公司在调用该组件时,会将快递小哥的服装传入一个名为 装束 的子视图中,这样不同的快递小哥就有不同的装束了。

你可以使用自然世界联想法来思考一切关于面向对象与组件化相关的问题,只要计算机世界仍然是构建的,我们就仍然可以按照自然世界的规则来感知计算机世界。

二进制世界从来不是冰冷、无情的,每一个二进制串都融入了编码人的思维模式、价值观。

# 最后

重新回到开篇的问题,为什么说当今的web前端开发已变成web客户端开发呢?因为组件化是所有客户端开发的核心概念,只要这个端大部分的时间在做组件抽象的工作,我们就可以认为自己在从事客户端开发。

最后,组件化不是银弹,不能为你解决任何实际问题,它只是一种思维方式。