面试造火箭,日常拧螺丝
这句戏谑多多少少反映了大家对于自己日常开发的不满。不过拧螺丝有没有技巧呢?作为资深螺丝刀的笔者在这里想要分享给大家一些自己的心得,哪怕只有一点点能帮到大家也是好的。
一、组件的设计
在 React docs 里,它是这么定义 Component 功能的:
Components let you split the UI into independent, reusable pieces, and think about each piece in isolation.
而我们这一章也几乎都是围绕着这句话展开的。
-
设计组件不如设计数据
我觉得 React 的开发团队是 Functional Programming 的死忠粉(?) ,从上到下都透露出一种将 FP 进行到底的决心。尽管 React 在不支持 hooks 的时候大多还是依赖 class 模式设计组件,但是 hooks 出现之后可以说 FC 变成了几乎是 one and only。因为它几乎无所不能。
所以对于一块界面,设计实现这个界面的组件及其结构,其实是在设计它背后的数据结构是什么样的。所有组件无非就是 F(data) => DOMElement ,只是我们需要关心中间的映射逻辑而已。
那么如何从一个设计稿出发,构建出数据结构呢?我往往是从两点入手:空间关系和因果关系。
先看空间关系,会比较简单。因为就排版形式来说,往往比较固定。不同的板块之间,展示的数据、支持的操作不一样,自然而然的就可以划分出数据的边界。
而因果关系就比较棘手。因为 toB 系统中,很有可能背后的数据、逻辑自变量很多。界面上展示出的一个值往往依赖 3~4 个其他值,那么这时候可以借助一些工具来帮忙梳理思路。因为组件的数据之间有依赖关系,很难光靠脑补补全整个依赖图,如果贸然开始开发风险还是比较高的。
我在这里分享给大家一个整理思路的小工具 Sketch Systems 。这个工具可以通过简洁的表达式描述出来若干个变量之间的依赖关系,以及状态变化的过程。对于一些复杂场景来说十分好用。
-
忠于职守:一个组件做好一件事
做好一件事很难,我们不要指望让一个组件能做很多很多事。不知道大家看别人(?)的代码的时候有没有发现过写了数十个成员方法的组件?又或者是数了一下 useState 的数量,发现竟然有十五个还多?
尤其是针对 FC 组件来说,如果 useState 的数量太多,代码可读性大大下降,很有可能搞清楚 state 的变化逻辑就要花个 10 分钟还不止。而我在这种情况下,通常都会选择把逻辑相关的 state, handler 还有 memo 住的值,都放在一个自定义的 hook 里面。这样至少在 FC 这个渲染的层级里面,组件会用到的或者提供的所有功能是一目了然的。而且有时候会有额外的收益,比如说别的组件需要再复用一些逻辑的时候,通用化一下这个 hook 就事半功倍。
那么到底怎么才算是 “一件事” 呢?或者说到底怎么抽象才是最纯粹的呢?在 toB 系统中最常见的列表页,往往都是代码复杂度最高的地方。带入用户的视角,列表页最主要的操作就是获取列表数据,不管是输入了复杂的筛选条件,还是做了排序、分组,这个页面最核心的事情就是收集用户的选项去请求对应的数据。
而至于选项是从哪里收集来的、获取回来的数据该如何展示,我们都应该交给别的组件去做。所以我们不难发现,如果想要更好的组织组件关系,有些组件可能起到的展示界面的作用并不大,而是作为控制 React 渲染流的一个节点,在这个节点中,我们可以单纯关心其内部的数据结构及流转过程。
所以思路模糊的时候,先把用户再界面要做的事情写下来,再去找句子里的那个谓语,就会豁然开朗。(也适用于英语阅读)
-
HTML+JS+CSS 三剑客
曾几何时,这三项一度是前端简历里的标配关键字。但渐渐的,HTML 消失了,CSS 也消失了。
我在这里想要强调的是, JS 在现代页面的渲染方案里虽然占据了很大的成分,但是 HTML 与 CSS 也是得力的助手。不要忘了纵使 React 再强大,最后的产物依然是 DOM 元素。所以如何使用 HTML 与 CSS 进行布局,可以说是决定了使用 React 进行渲染的下限。因为一个不成熟的 HTML 布局或者 CSS 选择器错误,很有可能严重影响产出的质量。
这里结合之前说的从空间关系来划分组件范围这一点来进行讨论。有的时候不可避免的,会出现有些流程的入口放置在了关联度并不那么高的区域里。比如有些表格的外观自定义按钮放在了卡片的标题行右侧,或者导出按键固定在了表格底部某个区域。这些功能性组件大多需要很多的 props 来进行控制,作为子组件放到表格里,或者放到卡片里感觉不那么合适,props 也要找方法传递下去。
这时候其实不妨换个思路,虽然从布局上来看,确实是某个框架组件的子组件,但是实际上我们的 HTML 元素允许我们以很灵活的方式进行排版。比如这些固定位置的按钮、入口,可以通过绝对布局,让它的层级不那么深。通过调整 CSS 样式,让它们符合设计稿,而且在使用中也不会引发任何问题。回归到自己的 JSX 代码组织上,层级也更扁平,对于变量的追踪也更容易。
二、实现中的技巧
-
Context :穿透层级的绝佳助手
Context 作为官方提供的层级穿透工具,不可谓不灵活。比较常见的场景就是像 antd 这类组件库中的 ConfigProvider,处理大量需要穿透的变量,诸如多语言信息、风格样式参数等。并且非常适合要处理拖拽、虚拟渲染这类不太依赖 Flux 数据流,而更需要 PubSub 模式的场景。
使用过程当中有一个问题比较明显:如果是 class 组件,一次使用多个 context 的时候,可能会麻烦一些。比如在 render 过程中可能需要写大量的 JSX 与渲染嵌套的代码,导致可读性降低。这时候比较好的解决方案就是通过装饰器来进行 context 的消费,这样不仅在 props 里可以直接拿到多个需要的 context 的值(不然需要用 provider 子元素或者 this.context),而且从形式上看也会比较简洁。
另一个需要关注的问题就是 context 引起渲染的频率。因为 context 的 value 在传入它的 provider 时,很有可能是通过字面量声明的对象。这样就会导致父组件渲染时,即使有些值是没有变化的,也会因为这些字面量是重新生成的,而引起子组件不必要的渲染。
-
FC 与 Class 都有合适的场景
接着上一部分里 context 的问题来说,虽然书写 FC 现在已经可以覆盖绝大多数场景了,但是 Class 组件还是有它排上用场的地方。
比如 Class 组件管理 state 的方式就可以很方便的解决上面我们会遇到的,可能会引起潜在的大量重复渲染的问题。如果我们使用 this.state 作为 provider 的 value 而不是字面量生成的对象,那么我们就可以保证如果 state 不发生更新,下面消费内容的子组件接收的引用也是不变的。
而且还有一种情况,不知道大家在开发中是否遇到过。就是有的组件可能展示的成分偏少一点,更多的是向它的子组件传递各种 handler 来应对一些范围比较广、比较扁平的状态变化。那么这时候如果是 FC 你可能会直接写一个箭头函数放到 props 里。
但这样意味着每次重新渲染时生成一个新的函数引用,即便子组件是 PureComponent 也没有什么意义,总是会重新渲染。当然你会说可以用 useCallback 进行包裹就好,这没错,但如果是逻辑比较长的函数或者涉及到了三个以上的依赖,对于后期的一些维护会造成阻碍(总要考虑后来接触到这段代码的其他同学)。
所以对于这种编排性质的组件来说,反而使用 Class 组件会比较方便一些。因为这些 handler 可以一开始就通过成员属性定义的方式绑定 this 的同时,固定下来他们的引用,比一点点理顺 callback 闭包住的变量可容易多了。
-
自定义 Hook
还记得 React 官方文档里面那个自定义 hook 的例子吗?它在里面展示了如何把一段需要和 BOM API 交互产生副作用的逻辑封装到了一个自定义函数中。其实这就是 hooks 的意义了,它的作用就是把逻辑相关,但使用位置不同的代码片段整合到一起。
例如我们可以把某个事件的监听逻辑放在自定义 hook 的 useEffect 中,再把这个事件的 emitter 放到它的返回值里让使用者可以安插在合适的地方,这样我们就通过函数式的方式实现了一个发布订阅功能的聚合。
这种思路对于 React 组件的编写是很有用的。因为大多数情况下,我们的设计思路是遵循 Flux 单向数据流的方式来设计组件,但往往一些用户交互其实是响应式的。如果硬往单向数据流上去套的话,不可避免的就需要将一些用户行为序列化,然后再对每个序列化的结果做出反应。这样的话就太麻烦了。
但如果我们通过 hooks 能够快速的构建自己的发布订阅逻辑,一过性的行为处理就可以被聚合在这一个 hook 函数里面,不仅保证了代码逻辑可以在一个集中的地方进行阅读,也更方便做适合更多场景的抽象。
三、需要规避的问题
-
早设计,但不要早优化
你有没有写过一个函数,本来是为了让它的功能变得更通用些,但是改着改着最后面目全非,到最后再也用不上了?这就是犯了过早优化的错误。
有时候我们在开发需求的一期、二期的时候,功能还比较单一,觉得什么东西都应该朝着通用化来思考。但殊不知后面几期开发下来,新加的这些兼容性逻辑,不仅没有给到你便宜,还让你的维护成本陡然上升。
所以设计的结果我们可以反复推敲,因为画一画图、写一写伪码都是成本很低的工作,实在不行很轻易的就可以推倒重来。而实际去实现代码的成本是涉及到方方面面的,不论是依赖的选择,还是项目结构的安排,这些一旦成型,想要再去重构都是比较困难的。
不过早优化,在初期做到就事论事,就需求开发功能,可以让你的代码仓库一开始有一个相对松散的耦合关系。产品稳定或者发展目标清晰之后,针对该发力的地方有的放矢,也会更容易一些。
-
避免无意中冗余的数据
有许多新同学在用 React 的过程中,有意无意的会把数据在不同的地方冗余出来。比如有些值是父组件传递下来的,在子组件就建了一个 state 把这个值存住了。有些时候这可能无伤大雅,不会引发任何 bug,但是这个习惯非常危险。
正如前面提到的,整个渲染的过程就是一个数据到界面的映射。现在这份数据在不同的地方有了不同的引用,这会导致开发者分辨不出来现在的界面到底是从哪份数据映射而来。况且对于一些交互频繁的界面,这些从上到下流入子组件的数据走到这个冗余的地方就中断了,一定会引发未知的错误。
如果数据体量大,我们完全可以用 useStore 来集中管理它们。这样我们对数据的操作和读取,都是集中管理的。不得不说,笔者觉得还是大道至简,类 redux 的数据管理方案有着它独特的魅力。
-
不存在所谓的“多余”的计算
这个问题可能并不适用于所有人,而是笔者之前走过的一些弯路。之前在设计组件的时候,总是试图在 state 中存放一些已经经过完整处理的值,然后在 render 方法中直接使用。例如有些数据需要做一些计算操作,与其存放原始的数据,我总是更愿意放置已经格式化过的。
之前会这么做的原因是因为觉得这些为了展示而做的计算,做得越少越好。把它们提前计算好,放到 state 中直接取用,显然会节省一些计算成本。但后来我的观念转变了。因为越来越多的现实需求证明,这些“提前”计算带来的成本上的收益太低了,而且还为系统的扩展带来了阻碍。
所以与其去做这些 ROI 很低的优化,不如去找到那些多余的重新渲染的原因是什么。减少渲染的次数大多数情况下,比降低渲染的复杂度要有效得多。
四、结语
希望上面这些内容可以帮到所有在一线与业务需求搏斗的同学。不管你此时正享受其中还是对它们深恶痛绝,少走一分钟的弯路就可以早一分钟下班(笑)。