原文:大型 web 前端架构设计 - 面向形象编程入门 | AlloyTeam
作者:曾探
面向形象编程,是构建一个大型零碎十分重要的参考准则。
但对于许多前端同学来说,对面向形象编程的了解说不上很粗浅。大部分同学的习惯是 拿到需要单和设计稿之后就开始编写 UI 界面,UI 里哪个按钮须要调哪些办法,接下来再编写这些办法,很少去思考复用性。当某天产生需要变更时,才发现目前的代码很难适应这些变更,只能重写。日复一日,如此循环。
面向具体实现编程:
当第一次看到“将形象和具体实现离开”这句话的时候,可能很难明确它表白的是什么意思。什么是形象,什么又是具体实现?为了了解这段话,咱们耐下性子,先看一个假想的小例子,回顾下什么是面向具体实现编程
假如咱们正在开发一个相似“模仿人生”的程序,并且发明了小明,为了让他的每一天都有法则的生存上来,于是给他的外围程序里设置了如下逻辑:
1、8 点起床
2、9 点吃面包
3、17 点打篮球
过了一个月,小明厌倦了变化无穷的反复生存,某天早上起来之后他忽然想吃薯片,而不是面包。等到黄昏的时候他想去踢足球,而不是持续打篮球,于是咱们只好批改源代码:
1、8 点起床
2、9 点吃面包 -> 9 点吃薯片
3、17 点打篮球 -> 17 点踢足球
又过了一段时间,小明心愿周 3 和周 5 踢足球,星期天打羽毛球,这时候为了满足需要,咱们的程序里可能会被加进很多 if
、else
语句。
为了满足需要的变换,跟事实世界很类似,咱们须要深刻外围源代码,做大量改变。当初再想想本人的代码里,是不是有很多似曾相识的场景?
这就是一个面向具体实现编程的例子,在这里,吃面包、吃薯片、打篮球、踢足球这些动作都属于具体实现,映射到程序中,它们就是一个模块、一个类,或者一个函数,蕴含着一些具体的代码,去负责某件具体的事件。
一旦咱们想在代码中更改这些实现,必然须要被迫深刻和批改外围源代码。当需要产生变更时,一方面,如果外围代码中存在各种各样的大量具体实现,想去全副重写这些具体实现的工作量是微小的,另一方面,批改代码总是会带来未知的危险,当模块间的分割千头万绪时,批改任何一个模块都得小心翼翼,否则很可能产生改好 1 个 bug,多出 3 个 bug 的状况。
抽取出独特个性
形象的意思是:从一些事物中抽取出独特的、本质性的特色。
如果咱们总是针对具体实现去编写代码,就像下面的例子,要么写死 9 点吃面包,要么写死 9 点吃薯片。这样一来,在业务倒退和零碎迭代过程中,零碎就会变得生硬和批改艰难。产品需要总是多变的,咱们须要在多变的环境里,尽量让外围源代码保持稳定和不必批改。
办法就是须要抽取出 9 点吃面包
和 9 点吃薯片
的通用个性,这里能够用 9 点吃早餐
来示意这个通用个性。同理,咱们抽取出 17 点打篮球
和 17 点踢足球
的通用个性,用 17 点做静止
来代替它们。而后让这段外围源代码去依赖这些“形象进去的通用个性”,而不再是依赖到底是“吃面包”还是“吃早餐”这种“具体实现”。
咱们将这段代码写成:
1、8 点起床
2、9 点吃早餐
3、17 点做静止
这样一来,这段外围源代码就变得绝对稳固多了,不论当前小明早上想吃什么,都无需再改变这段代码,只有在前期,由外层程序将“吃早餐”还是“吃薯片”注入进来即可。
实在示例
方才是一个虚构的例子,当初看一段实在的代码,这段代码仍然很简略,但能够很好的阐明形象的益处。
在某段外围业务代码里,须要利用 localstorge 贮存一些用户的操作信息,代码很快就写好了:
import 'localstorge' from 'localstorge';
class User{save(){localstorge.save('xxx');
}
}
const user = new User();
user.save();
这段代码原本工作的很好,然而有一天,咱们发现用户信息相干数据量太大, 超过了 localstorge 的贮存容量。这时候咱们想到了 Indexdb,仿佛用 Indexdb 来存储会更加正当一些。
当初咱们须要将 localstorge 换成 Indexdb,于是不得不深刻 User 类,将调用 localstorge 的中央批改为调用 Indexdb。仿佛又回到了相熟的场景,咱们发现程序里,在许多外围业务逻辑深处,不只一个,而是有成千盈百个中央调用了 localstorge,这个简略的批改都成了劫难。
所以,咱们仍然须要提取出 localstorge 和 Indexdb 的独特形象局部,很显然,localstorge 和 Indexdb 的独特形象局部,就是都会向它的消费者提供一个 save 办法。作为它的消费者,也就是业务中的这些外围逻辑代码,并不关怀它到底是 localstorge 还是 Indexdb,这件事件齐全能够等到程序前期再由更外层的其余代码来决定。
咱们能够申明一个领有 save 办法的接口:
interface DB{save(): void;
}
而后让外围业务模块 User 仅仅依赖这个接口:
import DB from 'DB';
class User{
constructor(private db: DB){ }
save(){this.db.save('xxx');
}
}
接着让 Localstorge 和 Indexdb 别离实现 DB 接口:
class Localstorge implements DB{save(str:string){...//do something}
}
class Indexdb implements DB{save(str:string){...//do something}
}
const user = new User(new Localstorage() );
//or
const user = new User(new Indexdb() );
userInfo.save();
这样一来,User 模块从依赖 Localstorge 或者 Indexdb 这些具体实现,变成了依赖 DB 接口,User 模块成了一个稳固的模块,不论当前咱们到底是用 Localstorage 还是用 Indexdb,User 模块都不会被迫随之进行改变。
让批改远离外围源代码
可能有些同学会有疑难,尽管咱们不必再批改 User 模块,但还是须要去抉择到底是用 Localstorage 还是用 Indexdb,咱们总得在某个中央改变代码把,这和去改变 User 模块的代码有什么区别呢?
实际上,咱们说的面向形象编程,通常是针对外围业务模块而言的。User 模块是属于咱们的外围业务逻辑,咱们心愿它是尽量稳固的。不想仅仅因为抉择应用 Localstorage 还是 Indexdb 这种事件就得去改变 User 模块。因为 User 模块这些外围业务逻辑一旦被不小心改坏了,就会影响到千千万万个依赖它的外层模块。
如果 User 模块当初依赖的是 DB 接口,那它被改变的可能性就变小了很多。不论当前的本地存储怎么倒退,只有它们还是对外提供的是 save 性能,那 User 模块就不会因为本地存储的变动而产生扭转。
绝对具体行为而言,接口总是绝对稳固的,因为接口一旦要批改,意味着具体实现也要随之批改。而反之当具体行为被批改时,接口通常是不必改变的。
至于抉择到底是用 Localstorage 还是用 Indexdb 这件事件放在那里做,有很多种实现形式,通常咱们会把它放在更容易被批改的中央,也就是远离外围业务逻辑的外层模块,举几个例子:
- 在 main 函数或者其余外层模块中生成 Localstorage 或者 Indexdb 对象,在 User 对象被创立时作为参数传给 User
- 用工厂办法创立 Localstorage 或者 Indexdb
- 用依赖注入的容器来绑定 DB 接口和它具体实现之间的映射
内层、外层和单向依赖关系
将零碎分层,就像建筑师会将大厦分为很多层,每层有特有的设计和性能,这是构建大型零碎架构的根底。除了过期的 MVC 分层架构形式外,目前罕用的分层形式有洋葱架构(整洁架构)、DDD(畛域驱动设计)架构、六边形架构(端口 - 适配器架构)等,这里不会具体介绍每个分层模式,但不论是洋葱架构、DDD 架构、还是六边形架构,它们的层与层之间,都会被绝对而动静地区分为外层和内层。
后面咱们也提过好几次内层和外层的概念(大部分书里称为高层和低层),那么在理论业务中,哪些模块会对应内层,而哪些模块应该被放在外层,到底由什么法则来决定呢?
先察看下天然届,地球围绕着太阳转,咱们认为太阳是内层,地球是外层。眼睛接管光线后通过大脑成像,咱们认为大脑是内层,眼睛是外层。当然这里的内层和外层不是由物理地位决定的,而是基于模块的稳定性,即越稳固越难批改的模块应该被放在越内层,而越易变越可能产生批改的模块应该被放在越外层。就像用积木搭建房子时,咱们须要把最坚硬的积木搭在上面。
这样的规定设置是很有意义的,因为一个成熟的分层零碎都会严格遵守单向依赖关系。
咱们看上面这个图:
假如零碎中被分为了 A、B、C、D 这 4 层,那么 A 是绝对的最内层,外层顺次是 B、C、D。在一个严格单向依赖的零碎中,依赖关系总是只能从外层指向内层。
这是因为,如果最内层的 A 模块被批改,则依赖 A 模块的 B、C、D 模块都会别离受到牵连。在动态类型语言中,这些模块因为 A 模块的改变都要从新进行编译,而如果它们援用了 A 模块的某个变量或者调用了 A 模块中的某个办法,那么它们很可能因为 A 模块的批改而须要随之批改。所以咱们心愿 A 模块是最稳固的,它最好永远不要产生批改。
但如果外层的模块被批改呢?比方 D 模块被批改之后,因为它处在最外层,没有其余模块依赖它,它影响的仅仅是本人而已,A、B、C 模块都不须要放心它们收到任何影响,所以,当外层模块被批改时,对系统产生的破坏性绝对是比拟小的。
如果从一开始就把容易变动,常常跟着产品需要变更的模块放在凑近内层,那意味着咱们常常会因为这些模块的改变,不得不去跟着调整或者测试零碎中依赖它的其余模块。
能够构想一下,造物者兴许也是基于单向依赖准则来设置宇宙和自然界的,比方行星依赖恒星,没有地球并不会对太阳造成太大影响,而如果失去了太阳,地球天然也不存在。眼睛依赖大脑,大脑坏了眼睛天然失去了作用,但眼睛坏了大脑的其余性能还能应用。看起来地球只是太阳的一个插件,而眼睛只是大脑的一个插件。
回到具体的业务开发,外围业务逻辑个别是绝对稳固的,而越靠近用户输入输出的中央(越靠近产品经理和设计师,比方 UI 界面),则越不稳固。比方开发一个股票交易软件,股票交易的外围规定是很少发生变化的,但零碎的界面长成什么样子很容易发生变化。所以咱们通常会把外围业务逻辑放在内层,而把靠近用户输入输出的模块放在外层。
在腾讯文档业务中,外围业务逻辑指的就是将用户输出数据通过肯定的规定进行计算,转换成文档数据。这些转换规则和具体计算过程是腾讯文档的外围业务逻辑,它们是十分稳固的,从微软 office 到谷歌文档到腾讯文档,30 多年了也没有太多变动,它们理当被放在零碎的内层。另一方面,不论这些外围业务逻辑跑在浏览器、终端或者是 node 端,它们也都不应该变动。而网络层、存储层,离线层、用户界面这些是易变的,在终端环境里,终端用户界面层和 web 层的实现就齐全不一样。在 node 端,存储层或者能够间接从零碎中剔除掉,因为在 node 端,咱们只须要利用外围业务逻辑模块对函数进行一些计算。同理,在单元测试或者集成测试的时候,离线层和存储层可能都是不须要的。在这些易变的状况下,咱们须要把非核心业务逻辑都放在外层,不便它们被随时批改或替换。
所以,恪守单向依赖准则能极大进步零碎稳定性,缩小需要变更时对系统的破坏性。咱们在设计各个模块的时候,要将相当多的工夫花在设计层级、模块的切分,以及层级、模块之间的依赖关系上,咱们常说“分而治之”,“分”就是指层级、模块、类等如何切分,“治”就是指如何将分好的层级、模块、类正当的分割起来。这些设计比具体的编码细节工作要更加重要。
依赖反转准则
依赖反转准则的核心思想是:内层模块不应该依赖外层模块, 它们都应该依赖于形象。
只管咱们会花很多工夫去思考哪些模块别离放到内层和外层,尽量保障它们处于单向依赖关系。但在理论开发中,总还是有不少内层模块须要依赖外层模块的场景。
比方在 Localstorge 和 Indexdb 的例子里,User 模块作为内层的外围业务逻辑,却依赖了外层易变的 Localstorage 和 Indexdb 模块,导致 User 模块变得不稳固。
import 'localstorge' from 'localstorge';
class User{save(){localstorge.save('xxx');
}
}
const user = new User();
user.save();
为了解决 User 模块的稳定性问题,咱们引入了 DB 形象接口,这个接口是绝对稳固的,User 模块改为去依赖 DB 形象接口,从而让 User 变成一个稳固的模块。
Interface DB{save(): void;
}
而后让外围业务模块 User 仅仅依赖这个接口:
import DB from 'DB';
class User{
constructor(private db: DB){ }
save(){this.db.save('xxx');
}
}
接着让 Localstorge 和 Indexdb 别离实现 DB 接口:
class Localstorge implements DB{save(str:string){...//do something}
}
依赖关系变成:
User -> DB <- Localstorge
当初,User 模块不再显式的依赖 Localstorge,而是依赖稳固的 DB 接口,DB 到底是什么,会在程序前期,由其余外层模块将 Localstorge 或者 Indexdb 注入进来,这里的依赖关系看起来被反转了,这种形式被称为“依赖反转”。
找到变动,并将其形象和封装进去
咱们的主题“面向形象编程”,很多时候其实就是指的“面向接口编程”,面向形象编程站在零碎设计的更宏观角度,领导咱们如何构建一个涣散的低耦合零碎,而面向接口编程则通知咱们具体实现办法。依赖倒置准则通知咱们如何通过“面向接口编程”,让依赖关系总是从外到内,指向零碎中更稳固的模块。
知易行难,面向形象编程尽管概念上不难理解,但在实在施行中却总是不太容易。哪些模块应该被形象,哪些依赖应该被倒转,零碎中引入多少形象层是正当的,这些问题都没有标准答案。
咱们在接到一个需要,对其进行模块设计时,要先剖析这个模块当前有没有可能随着需要变更被替换,或是被大范畴批改重构?当咱们发现可能会存在变动之后,就须要将这些变动封装起来,让依赖它的模块去依赖这些形象。
比方下面例子中的 Localstorge 和 Indexdb,有教训的程序会很容易想到它们是有可能须要被相互替换的,所以它们最好一开始就被设计为形象的。
同理,咱们的数据库也可能产生变动,兴许明天应用的是 mysql,但明年可能会替换为 oracle,那么咱们的应用程序里就不应该强依赖 mysql 或者 oracle,而是要让它们依赖 mysql 和 oracle 的公共形象。
再比方,咱们常常会在程序中应用 ajax 来传输用户输出数据,但有一天可能会想将 ajax 替换为 websocket 的申请,那么外围业务逻辑也应该去依赖 ajax 和 websocket 的公共形象。
封装变动与设计模式
实际上常见的 23 种设计模块,都是从封装变动的角度被总结进去的。拿创立型模式来说,要创立一个对象,是一种形象行为,而具体创立什么对象则是能够变动的,创立型模式的目标就是封装创建对象的变动。而结构型模式封装的是对象之间的组合关系。行为型模式封装的是对象的行为变动。
比方工厂模式,通过将创建对象的变动封装在工厂里,让外围业务不须要依赖具体的实现类,也不须要理解过多的实现细节。当创立的对象有变动的时候,咱们只需改变工厂的实现就能够,对外围业务逻辑没有造成影响。
比方模块办法模式,封装的是执行流程程序,子类会继承父类的模版函数,并依照父类设置好的流程规定执行上来,具体的函数实现细节,则由子类本人来负责实现。
通过封装变动的形式,能够把零碎中稳固不变的局部和容易变动的局部隔离开来。在零碎的演变过程中,只须要替换或者批改那些容易变动的局部,如果这些局部是曾经封装好的,替换起来也绝对容易。这能够最大水平地保障程序的稳定性。
防止适度形象
尽管形象进步了程序的扩展性和灵活性,但形象也引入了额定的间接层,带来了额定的复杂度。原本一个模块依赖另外一个模块,这种依赖关系是最简略间接的,但咱们在两头每减少了一个形象层,就意味着须要始终关注和保护这个形象层。这些形象层被退出零碎中,必然会减少零碎的档次和复杂度。
如果咱们判断某些模块绝对稳固,很长时间内都不会发生变化,那么没必要一开始就让它们成为形象。
比方 java 中的 String 类,它十分稳固,所以并没有对 String 做什么形象。
比方一些工具办法,相似 utils.getCookie(),我很难设想 5 年内有什么货色会代替 cookie,所以我更喜爱间接写 getCookie。
比方腾讯文档 excel 的数据 model,它属于内核中的内核,像整个身材中的骨骼和经脉,曾经融入到了各个应用逻辑中,它被替换的可能性十分小,难度也十分大,不亚于重写一个腾讯文档 excel,所以也没有必要对 model 做适度形象。
结语
面向形象编程有 2 个最大益处。
一方面,面向形象编程能够将零碎中常常变动的局部封装在形象里,放弃外围模块的稳固。
另一方面,面向形象编程能够让外围模块开发者从非核心模块的实现细节中解放出来,将这些非核心模块的实现细节留在前期或者留给其他人。
这篇文章探讨的理论次要并重第一点,即封装变动。封装变动是构建一个低耦合涣散零碎的要害。
这篇文章,作为面向形象编程的入门,心愿能帮忙一些同学意识面向形象编程的益处,以及把握一些根底的面向形象编程的办法。
团队继续招人中,欢送分割
AlloyTeam 欢送优良的小伙伴退出。
简历投递: alloyteam@qq.com
详情可点击 腾讯 AlloyTeam 招募 Web 前端工程师(社招)