Banner 图来自 ant.design

聊到封装,其实无论是组件还是函数,还是模块,都应当遵循单一职责这个大原则,行话叫高内聚,低耦合。

举个例子:你封装了一个通用列表组件,它服务了 N 个页面。某天接到一个需求:希望在查看某个页面下的用户列表时,如果当前登录用户为管理员,那就展示用户条目底部的操作区。

那这个需求应当在这个通用列表组件中被满足吗?比如提供一个 isAdmin ?

显然不应该。因为这个服务了 N 个页面的通用列表的核心能力只是:渲染列表。之外的需求如果都往里塞,它内部的代码会越来越臃肿,且难以跨项目复用。反过来说之所以它能够服务这么多页面,就是因为它的能力够通用。

类似上面那个需求,对外提供的能力应当是操作区的渲染方法,翻译成组件 props 可以是: actions

总结一下:如果给封装体增加的某个特性只能够服务少部分上层,那就不做。或去想是否可以把该特性再抽象,最终去对外提供一个可以满足很多类似特性的能力。


不过随后你可能会遇到这种场景:不止一个页面需要用到用户列表了。

那这个时候你就可以去基于 通用列表 + actions 去封装一个用户列表组件了。

更进一步地:

  • 如果管理员操作用户是一个不限场景的需求,那你应当考虑把 isAdmin 变成一个可随处获取到的全局状态,这样使用到你的用户列表的地方就不至于都要手动传递 isAdmin
  • 如果存在少部分场景下不希望管理员操作用户,那你应当让你的用户列表默认管理员可编辑,再对外提供一个 editablereadonly,让调用方去增量配置

总结一下(其实总结本身也是抽象):尽量服务好你的上层,让他们尽可能地少做事情,做得少就错得少。


上述场景的例子其实比较简单,相信很多人在使用过一些优秀的开源组件后都能通过经验得出上述结论。不过也有些时候会面临一些难以抉择的场景:比如某特性的实现是应当由「上层」去获取还是「下层」去通知?

还是拿那个用户列表组件来举例子,需求是:通过点击页面中的某个按钮触发滚动来快捷定位到任意用户条目的位置。

首先,这个需求肯定得依赖列表条目的 dom 节点信息,直觉上的做法是给列表中的每个条目分配一个唯一 dom id,然后点击按钮的时候去通过 id 获取 dom 再滚过去。

不过细想一下,对于你的用户列表来说,这个 id 信息是必要的吗?是为了它的核心功能(渲染用户列表)服务的吗?显然不是。所以这里又有个老生常谈的原则是:如无必要,勿增实体。此时你在这个用户列表里埋下的 id,也可能会成为另一个不需要滚动定位页面的坑,比如和那个页面的其他 dom id 发生了冲突。

建议的方式是:

  1. 你的用户 Item 组件对外提供一个 ref,它提供一个包含了节点 dom 的索引 Map
  2. 你的用户 List 组件通过收集 Items 的 ref 对外提供一个包含了所有上述 Map 的集合 ref
  3. 最外层通过 List ref 拿到 Map 集合以备滚动按钮去调用

看起来更复杂了啊。。搞来搞去传了三层还占用了更多的内存。没办法,这个例子只是为了讲类似问题的处理思路,应该还有更恰当的例子。

其实内存占用问题可以考虑将「大内存」的数据封装成方法提供出去,在特地的时机再去获取也未尝不可。不过传来传去的问题在此类场景下就是不可避免,它实际上本身就是单一职责的代价。而单一职责的好处显然就是:

  • 内部不需要关心外部的状态
  • 外部也不需要关心内部的实现
  • 以上各自的拓展性都会更强

总结一下:封装体之间只通过规范的接口来交流,各自把对方的除了 API 之外的信息都当作黑盒子来寻求一个更符合各自利益的方式合作。


不过凡事都有个例外,比如说你现在又想把那个可以随意滚动到列表项的按钮封装成一个通用的组件,那此时对于该组件来说滚动至目标就是它的核心能力。但列表组件五花八门,这时候你就无法去要求每种场景下的列表都按照上述范式去实现,那么一个合理的 API 可能是:

  • 提供列表容器 id 以及列表项的 className
  • 提供目标项在所处列表容器的 index

所以可以发现很多时候设计也取决于你的视角,在不同的场景下去分别聚焦每个封装单元,让它们都尽可能地多做自己的事来让合作方少做事。


当然了,确实也会存在一些不太好划分组件职责的场景,还是继续拿用户列表组件举例:希望在滚动列表的过程中,列表条目底部的操作区可以「吸底」。

这个需求看起来你可以:

  1. 在用户 Item 组件里实现:每个 Item 都监听滚动,在滚到特定的位置时自己管理自己的「吸底」
  2. 在用户 List 组件里实现:统一监听整个列表的滚动,在滚到特定的位置时去通知相应的 Item 来「吸底」

第一种更符合「组件自治」原则,状态处理起来会很方便,实现也会比较简单。

第二种其实性能上会好些,然后还有个关键的因素最终使我选择了它。就是从需求本身考虑:滚动吸底本身是属于只有在列表状态下才会发生的事情,那我的用户 Item 组件为啥要去关心它呢?尤其是在用户 Item 组件被单独拿出去展示的时候,它显然不应该持续去监听滚动事件。

所以这部分的结论是:当无法确定某个功能该在某一层实现的时候,可以分别想想在不同地方实现的成本和收益,择优取之。


最后还有个值得分享的经验是:

底层实现有时候不必循规蹈矩,能解放业务层才是关键。如果底层为了守规矩反而破坏了业务层的规矩才是得不偿失的添乱行为,行话叫过度设计。

其实这也是所谓封装的原则之一,从函数到模块到组件到页面再到工程的设计都应当秉持着为更上一层服务的原则,尽可能地把下一层的脏话累活揽到自己的作用域下,屏蔽到角落里。

说到这,突然想到一个词:金玉其外 败絮其中……

这部分具体的例子感兴趣地可以看看我之前的这篇文章:JS 真的可以为所欲为之绕过 ContextProvider 给 useRequest 注入全局配置


 评论