Skip to content

前端代码的依赖关系

brokun edited this page Apr 25, 2024 · 2 revisions

前端的工程正在越来越复杂,从 Gmail 成为工程典范,到 Office online 再到人手 WebIDE,我们在工程中关注的东西也越来越宏观,从闭包、数据流再到分层、模块、微前端。传统软件工程中的很多概念在前端一次次被实践和创新。掌握方法不如掌握原理,理解原理不如理解思想,今天想跟大家聊一下的是最基础的概念,我们从依赖概念开始,去看一看前端的工程实践。

前言

为什么会有工程,因为代码越来越多,项目越来越大,我们把应对这种情况的方法都归为工程方法。为什么代码多项目大会成为问题,为什么需要应对这种情况?因为参与工程的是人,人脑的短期记忆只能支持7个左右的对象,所以当一个系统的信息量变大的时候,我们就需要一些方法来有效的处理这些信息,所以对工程方法发思考,实际上是对人的认知规律的思考。如果大家读过《Coders at Work》,可能会发现这些大佬对于工程方法多半是不在意的,在人的思维能够支持的空间内,工程方法并不重要。 对于一般人面对一堆杂乱的信息,我们会无从下手;面对一些有规律的、整齐的信息时,我们就能高效检索。我们本来就是靠总结规律,推理演绎来认识世界,同样的信息,在不同的组织方式下,我们会有不同的接受程度,而能够让我们高效接收的信息,往往呈现一些符合认知规律的特点,比如分类、分层。这些结构帮助我们能够从不同的角度对信息做出判断,形成习惯,带有预期,从而更好的掌握他们,使用他们。 人脑是有限的,而工程方法让我们的大脑有效的应对信息量的问题,这也是为什么扩展性成为永恒的主题。不论是数据流的控制还是模块化、微前端化,都是为了便于参与工程的人理解设计思路,从而降低维护成本,对抗遗忘规律。当我们的脑力不再耗费在信息的整理上时,我们也就能更加深入一个领域,去探索他的深度,让推理的链条变得更长。

依赖的实质

程序的运行是靠执行一条条语句来完成的,我们将多条语句聚合成方法,然后方法之间再产生调用,这个过程里依赖就产生了,调用一个方法也就依赖了一个方法。实际上,一条条执行的语句也是有依赖的,他们的执行顺序本身就是他们的依赖条件。现代的软件体系里,我们一般把调用看过依赖产生的根源,从这个角度看,依赖是不可避免的,除非代码不参与执行,否则他一定会产生依赖关系。 通过调用产生的依赖,一般会体现在代码里,我们使用的语言一般会有明确的依赖声明语句,比如import,但实际的程序里,调用一个程序并不尽然要通过这种语言及工程工具约定的形式,往大了讲我们会说前端代码是依赖后端接口的,但是我们无法import后端接口,因为语言和工程体系并不互通,所以我们通过文本化的http protocol来调用;往小了讲,当我们在使用redux等数据流方案时,调用方法也要通过文本约定的action来完成,这些依赖并没有体现在代码里,也不对打包产生影响,却实实在在的影响了使用方的代码,让我们在写代码的时候,需要通过查阅 api 文档,或者查看类型定义来完成调用,这也是一种依赖。

import func
fetch('/func)
store.dispatch({type:'func'})

我们关注的依赖并不是代码里的具体写法,而是实际意义上的心智依赖,一个存在多个执行步骤的程序必定会在内部生成依赖关系,因为他们之间存在着约定,这种约定有时候会被具象化的定义出来,简单的则会暂存在头脑中,这些约定就是依赖的实质。

依赖的形式

最简单的依赖

最简单的依赖关系就是 A 依赖 B,当我们从一个文件中引入另一个文件的方法,就产生了这样的依赖关系。问题是什么样的场景下我们会去建立这样的依赖关系。

提取公共

最原始的场景是,当一个程序的步骤变多,我们将其中重复的部分独立起来,成为可以被调用的过程,这个过程里形成了依赖,这个时候的依赖关系,就是 A 依赖 B 形式。

这是一个非常常见的过程,常见到我们几乎不需要思考就会去做,很多时候我们这样去处理的内在驱动里就是 DRY原则。这种依赖的建立,也实际上简化了程序的信息量,让我们更容易读懂程序,这个过程实际上就是在提取公共部分。

分解局部

也有些场景下,我们虽然会建立这种依赖关系,却并不出于提取公共,比如把页面里的弹窗分离到一个独立的组件里。这些操作的实质又是在做什么呢?我们看上图形成 A 和 B 两个部分的代码,实际上他们并不是对等的,依赖的产生是单向的,在这里 B 实际是 A 的局部,他们跟 A 关系始终是一个总体和局部的关系。所以这种关系还会出现在我们分解局部的过程里,这在代码里比提取公共部分还要常见。

页面到组件的过程,一般就是分解局部和提取公共的过程。下图中Item是提取了公共,其他的是分解了局部。

依赖与控制

当一个依赖关系是靠着分解局部来建立的,那应该意味着被分解出来的局部,代替了这个局部原有的所有细节,而通过提取公共应当是恰好这个局部被反复使用。这个过程里,整体应该控制局部,再有局部去控制更小粒度的局部,从而达到思维方式上的层层递进。 但我们实际的代码并不是这样,例如在页面分解到组件的过程中,我们经常会产生layout这样一个概念,上图有可能被分解为如下形式

所有的布局细节都被layout屏蔽,但是App直接依赖了展示在 layout 中的细节——它直接管理的Content的内容Item。我想对应的代码大家一定也不陌生

const App = () => {
  return (
    <Layout>
      <Item />
      <Item />
      <Item />
    </Layout>
  )
}

对于Layout而言,本来从整体局部的结构上,处于被自己控制层次内的Item变成了从上下文中获得的Children,我们在设计上,将属于Layout的这部分依赖,交给了上层控制,这样的操作获得了什么呢?Layout更高的复用性,这是一种处于中间层的复用性。

这并不是一种很少见的设计,我们在组件上暴露的各种 Prop,尤其是组件类型的 Prop;我们设计的函数的参数,尤其是函数类型的参数;我们设计的 Class 中的属性,尤其是 Class 类型的属性。推动我们产生这样设计的原因,是我们需要提取一个依赖关系中处于中间层次的单元,这些可以被上层控制的形式的设计,往往就是为了追求更高的灵活度和复用性。而这种设计的本质,就是控制反转

Wiki 上对于控制反转的定义相对狭义,主要是在面向对象的体系内,对于对象的创建这件事情的反转,我觉得这里的推广是适用的,局部通过控制反转的方式,交出了更多的控制权,从而获得更高的灵活度和复用性,也让我们可以分解和复用处于中间层次的依赖。

控制反转时的依赖

这个时候,我们从更加细微的视角来看他们形成的依赖关系,就会发现,局部在交出控制权的同时一定会对这部分的提出自己的约束,组件的 children 只是恰好跟框架的定义重合所以被省略了,但是当我们将组件通过 prop 传递,或者这种反转在 function 或者 class 中发生时,这种约束往往是显性的,他们最终也将形成如下的依赖结构。在实际运行时,B 所以来的约定会成为实体,所以 B 是逻辑结构中的中间层。

如果熟悉 SOLID 原则的话,我们会发现,对于依赖反转原则“高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口”,我们满足了后半句。

依赖与扩展性

假设这样一个场景,我们页面需要的 layout 不再是单一的,而是有多个,在不同的路由下做不同的展示,我们该如何设计?

最简单的我们可以有如上图的依赖结构,将所有可能的 layout 全部作为 App 的依赖出现,然后在 App 内根据路由等上下文信息,做出判断决定使用哪个 layout。但是这里我们可以明显的发现其扩展性的问题,也就是当我需要增加一种 layout 时,需要既增加 layout 的代码,也修改 App 的代码,如果熟悉 SOLID 原则的话,这就违反了开闭原则。那么如何才能让 layout 的扩展变得容易,让 App 不再跟随 layout 变化呢? 我们需要将 App 内关于 layout 处理的逻辑分离出来,然后你就会发现,分离出来的逻辑实际上只跟 interface 以及上下文有关。实际上,我们只要将 interface 实体化,让他在提供约束的同时,能够收集这些约束的实现即可。

具体的实现可以是,提供一个manager作为注册中心,各个 layout 将自己的信息注册到 manager中,然后 App 只依赖 manager 就可以拿到当前需要用的 Layout。我们再对照一下 umi 中路由的实现,你就会发现 umi 是将 layout 跟路由对应的信息先写成配置,然后被 umi 中的 manager 读取,然后生效在 App 中,由于框架对工程结构的约定,umi 省略了手动将 Layout 注册的代码,实际上,这也是各种不同的扩展实现的核心差异,如何收集符合约束的抽象实现。

除了注册式、配置式以外,我们还可以使用依赖注入容器等方式做采集,写法各不相同,但是都为了完成同样的事情。在这个时候,我们也终于可以写出一个常见的,满足扩展性的依赖关系

大部分情况下,我们的代码就是对前面几种依赖形式的组合,像乐高一样把整个项目越堆越大,其中一般依赖形式,和一定程度控制反转,是代码编写过程中,相对无意识就会使用,因为他们的驱动力来自于解决当前的问题,而通过控制完成完成扩展性设计,则往往需要有意识的设计,因为他解决的问题是面向未来的维护,而当前的代码并没有减少,甚至还要增加。

耦合、内聚

实际上耦合性的概念与依赖是息息相关的,是指一程序中,模块及模块之间信息或参数依赖的程度。所以有依赖就会有耦合,但是这里要注意,耦合是一个主谓对等的概念,而依赖是有方向的,所以很多时候,我们说 A 耦合 B ,不如 A 依赖 B 来得精确。 耦合有多种表现形式,耦合程度由高到低分别是内容耦合、公共耦合、控制耦合、标记耦合、数据耦合、非直接耦合,我们前面提到的一些依赖产生的方式,往往也会匹配到一种耦合形式上,如提取公共,往往对应着公共耦合。 耦合性往往是评价模块间关系的,所以我们追求的低耦合,实际上是让模块间的依赖尽量少,而这里的少又是实质上的,而不是形式上的,他应该是指的暴露出来的约定的描述能力,而不是约定的个数,所以当两个模块间的产生依赖时,我们应该让这种联系变简单,当它是类型约束的时候,也应该是最简单的类型约束,这也就对应到了 SOLID 中的接口隔离原则

在模块的尺度上,我们可以进一步的讨论内聚,高内聚和低耦合是伴生的,实际上一个程序总有紧耦合和松耦合的部分,当我们把紧耦合的部分都收缩在一起成为模块时,这些模块相互间的依赖就变少了,低耦合就出现了,这个时候,我们分割出来的模块,就是高内聚的。 内聚也有多种表现形式,内聚程度由低到高分别是偶然内聚、逻辑内聚、时间性内聚、程序内聚、联系内聚/信息内聚/通信内聚、依序内聚/顺序内聚、功能内聚。内聚是耦合的另一面,这里不再学究的介绍不同的内聚形式,因为有些我也不是很认可...

前端常见场景

路由

其实在前面的例子里,我们已经举了路由的例子,这里再发散一下,对于路由而言,他要操作的不仅仅是layout的动态性,而是layout``content的组合,甚至还有嵌套。

如果看代码的话,我们可能并没有显式的在页面中引用什么,但实际上,这是框架帮我们完成了,而我们编写页面级别的组件时,也不免的会用到来自 Route 定义的 Prop,这实际上依然是一种依赖。

无状态

自从函数式大行其道,无状态也就越来越受欢迎了,我们为什么会喜欢无状态?一个无状态的函数,可以保证在被不同的依赖方调用时,有着稳定的表现。从依赖的角度看,一个被依赖项是无状态的,那么当这个依赖被抽取公共的时候,他形成公共耦合的副作用就很小,实际的例子如工具函数,我们不会因为提取工具函数而产生负担,但是如果我们提取的公共依赖是有状态的,那他在运行时的表现就会有很多的不确定性,这种不确定性是我们所不喜欢的,也是公共耦合的风险所在。

事件

事件模型其实也是一种依赖管理的常见手段。例如在实现链各个互相绑定的模块时,因为依赖方向只能有一个,假设 A 依赖 B,那么为了让 B 可以影响到 A,就可以 B 中实现事件。这里实际上可以认为 A/B 都依赖了事件的抽象定义,只是这个定义被合并在了 B 中。

总结

依赖是必然的,在代码中实体不变的情况下,我们可以通过不同的依赖关系形式来组织他们,而调整这些依赖关系的目的,应该是为了让这些实体聚合成不同的模块,达到模块内的高内聚和模块间的低耦合,从而提高接触工程的人信息接收的效率,更好的维护和扩展工程。