UI逻辑分离
在一些小项目中,UI和逻辑经常是被写在一起的。比如一些我们常见的vue页面。在这些比较小的月抛性项目里算不上特别大的问题。但当项目变大后,UI和逻辑不分离带来的问题也随之放大.
主要问题有这几个:
UI和逻辑无法单独被复用。
一个需求的逻辑层通常会对应N个UI层,比如一份查找替换的逻辑层,要对应 pc、mobile、pad 等多个UI层。逻辑层如果耦合了某个端的 UI,逻辑层就无法直接复用在其他端.
同理,如果 UI 层耦合了逻辑层,那这些 UI 组件也将无法再复用在其他项目中.
UI层和逻辑层的需求迭代频率不一致.
通常来说,当核心业务逻辑确定之后,UI的迭代频率比逻辑层要快一些,当然在某些阶段可能也会有逻辑层迭代频率比UI层快的情况。
总而言之,UI和逻辑的迭代频率是很难保持一致的。我们在修改逻辑层或者修改UI层的时候,都不希望会影响另外一部分代码。但如果它们是耦合在一起的,不管修改逻辑还是UI,都需要修改同一个模块,以至于有可能影响到对方的代码.
UI和逻辑的运行环境不一样
逻辑层的运行环境通常比UI更严苛一些。
UI经常会含有一些宿主环境相关的代码,比如浏览器下的 window 对象等等,如果UI被耦合在逻辑里,那么这些逻辑代码就不方便运行在一些没有浏览器window对象的环境。比如这时候我们无法顺利在node、worker,或者单元测试中使用这些逻辑代码。
逻辑层和渲染层的自动化测试都不方便
逻辑层的单元测试里要去 mock 各种环境相关的对象,UI层的 e2e等测试里也要避免被逻辑层的代码影响。
基于以上原因, UI和逻辑分离是非常必要的。在腾讯文档业务中,逻辑层一般都是指核心业务逻辑,如协同处理、格式转化、函数计算等等,而UI只是这些业务逻辑的一种展示形式。在许多没有UI层的场景下,比如 node-ssr、开放平台 api,自动化测试这些场景下, 腾讯文档的逻辑层也需要能独立运行下去。
当逻辑和UI分开之后,我们再考虑如何将逻辑和UI联系起来,根据不同的业务场景, 我们尝试过一些不同的方式:
如事件、中介者、扩展点、依赖注入等等。
测试环境和生产环境分离
有时候我们会将测试环境和生产环境的代码写在一起。比如这段代码:
1 | if (process.env.NODE_ENV === 'test) { |
这是某个业务中的一段真实代码,我们来看看它有哪些严重缺陷:
业务逻辑中用if、else来处理测试环境和生产环境中data数据的分歧,目前这里代码看起来还不算很混乱,但如果后面我们增加了开发环境、系统测试、或者其他更多环境呢?这些条件分支有可能继续膨胀下去,而且它们之间的分歧也可能远不止对mock 数据的处理,这种写法是严重违反 开放-封闭原则 的,当许多业务逻辑中混杂了各种环境的判断代码,这些代码很有可能在将来都成为大泥球。
各种环境下的不同行为有可能互相影响,既然它们的代码都在一起,就很难避免当我们想修改测试环境的行为时,却修改了生产环境的行为,最终引起严重 bug,我在很多年前,也写过这样的代码,最终造成过一个比较严重的生产环境bug。
所以,我们希望业务开发者不再关心业务逻辑到底处在什么环境,从而保持业务逻辑代码的稳定。
比如这段代码里开发者只关心 use(data)
,而不关心 data
到底是来自测试环境,还是来自生产环境,我们要将环境的分歧从业务逻辑中提取出去,放到更容易维护和修改的地方。
简单来看,我们可以提供一个工厂方法来创建 mockData
或者真实 data
:
1 | class DataFactory{ |
核心业务逻辑代码就变得稳定起来:
1 | use( new DataFactory().create() ); |
用工厂方法可以将分歧从核心业务逻辑中提取出来,然后在工厂方法内部去解决这些分歧,这种方法的坏处是,如果系统的差异比较多,我们可以需要创建很多工厂方法,维护这些工厂方法虽然比直接修改业务逻辑要方便,但还是要付出不少成本。
还有一种方式是,我们给每个环境一个单独的 main 入口函数,通过构建工具让不同环境进入不同的 main 函数入口。然后将不同环境的分歧都找出来,分别放到不同的 service 多态实例里,让不同环境的 main 函数来连接它对应的 service 多态实例。
1 | class App(data){ |
借助依赖注入等技术,我们还可以更方便的将不同多态实例注入给核心业务逻辑。
- 注解:如果不同环境下的行为差异较大,看起来不能抽象多态行为,这时候我们可以把不同环境下的行为分别包装成command 或者 task 来让它们拥有多态特性。
用标志参数来控制业务流程状态
函数的参数越少越好,参数越少函数的职责就越清晰和简单。
最理想的情况是函数不带参数,当然大部分情况下,我们会将程序中通用的部分抽成函数,有分歧的部分通过参数传递进函数,来增强代码的复用性。这时候函数拥有参数是正常的。
但有一种参数希望尽量不要出现在程序中,就是标志参数,如:
1 | function render(flag: boolean){ |
一旦函数里加入了标志参数,那么这个函数肯定是不符合单一职责原则的,在这个例子,renderA和renderB这两个职责就耦合在一起了。如果标志参数是个枚举,这段代码的可维护性就变得更差了。
项目的启动流程和业务逻辑混在一起
原本业务逻辑只需要负责它自身的业务职责就可以,某段业务逻辑可以只对外界提供输入和输出,成为一段无副作用的纯函数逻辑。如果业务逻辑里耦合了系统的启动流程,因为启动流程的多变性和隐蔽性,这些业务逻辑函数也会变得很不稳定,而且可能产生很强的副作用。
比如在我刚接手腾讯文档项目时,系统的启动流程就是很隐蔽的分布在各个业务模块中。
伪代码大概是:
1 | function init() { |
开始初始化系统:
1 | init(); |
我们看看这一大段代码是如何来初始化整个系统的:
- 初始化编辑器(initEditor)
- 500ms后初始化网络层(initNetWork)
- 初始化离线层和工具栏(initOffline、initToolbar)
- 如果工具栏初始化成功, 则初始化分享和权限组件(initFeatureComponent)
- 如果分享和权限组件初始化功能,则系统初始化成功
腾讯文档大致要初始化这些模块:编辑器、工具栏、一系列组件(分享、权限)、网络层、离线层等等,而这些初始化过程全部是埋藏在各个业务逻辑细节里的。当时我们作为开发者,被这些代码折磨了不短的时间。
- 很难一眼看出系统的初始化过程中到底做了什么事情,因为这些逻辑散落在各个文件里。
- 当某个模块无法正常启动和工作的时候,我们要深入业务逻辑去排查和修改代码,有几次
bug没修改,还改出了其他bug。 - 代码的调试过程也很麻烦,需要在不同文件中间连续跳转。
- 这些代码很难更新迭代,当我们想调整一些模块的启动顺序时,也要深入业务代码去进行
修改。
其实这也是一个比较常见的问题,不仅曾经出现在腾讯文档代码中。近2年的代码 cr 评审和 readability 的一些练习题,涉及到的项目都多多少少会有启动流程和业务逻辑混在一起的问题。
通常来说,业务逻辑函数只专注于自身逻辑的输入和输出,它不关心谁会来调用自己,也不负责当自己逻辑执行完之后,还需要跳往其他哪个模块。按照这种方式,如果业务逻辑只专注于自身的业务逻辑,复用性和稳定性都得到很大的提升。
项目的启动应该统一放在系统的 main 函数中,main 函数本身不负责具体业务逻辑,它专门负责初始化系统中所要使用的模块,以及协调和监督这些模块的运行。
我们新建一个main函数:
1 | // 伪代码: |
将启动流程都集中在main函数之后,有这些优点:
- 很容易一眼看出系统的初始化过程中到底做了什么事情
- 当我们想修改启动流程、或是调整模块启动顺序的时候,比如将启动工具栏的时机放在启动编辑器前面,只需要改动 main 函数就可以,不用去修改业务逻辑代码
- 很容易对各个模块的启动成功情况和启动耗时等信息进行统一监控和上报
当然这段伪代码只是一个示例,腾讯文档在 ark 框架里提供了统一启动流程管理机制,主要有这些特点:
- 给每个品类&终端&租户提供 main 入口
- 用 task+job 机制封装常用的启动流程
- 提供统一生命周期+自定义生命周期机制,结合 task+job 来初始化系统
- 结合腾讯文档统一上报库进行上报
条件判断中含有不稳定的逻辑因果关系
这是一个严重并且常见,但又很容易被忽视的问题。
在业务代码中,不稳定的逻辑因果关系有可能导致许多隐藏bug,特别是因为业务需求变化而产生迭代的时候。如果我们写出了这种代码,等待的就是有天被 bug 找上门。
”不稳定的逻辑因果关系“这句话是我编出来的,它指的是一些条件判断语句看起来在当前是成立的,但某天这些条件判断语句可能会因为业务需求的变化,而变得不成立。
先举一个我们项目中遇到的例子:
某天有同学发现了一个登录的bug,用户总是出现登录无效的情况。排查代码发现判断用户是否登录的代码是这样写的:
1 | // 伪代码: |
原来程序里认为,一旦 cookie 中含有 UId,用户就算登录了。这个逻辑在当时是没有问题的,因为登录之后确实会在 cookie 里种下UId,注销登录之后会清理 cookie 里的 UId,cookie 里有没有 UId,和用户是否已登录,看起来就是一对一的关系。
但这个逻辑显然是不稳定的,后来有一天我们增加了企业微信登录,企业微信登录之后并不会在 cookie 里种下 UId,当用户用企业微信账号登录的时候,程序会认为用户没有登录,就会导致这个 bug 的产生。更惨的是,程序有上百个类似的条件语句来作为是否登录的判断,我们发现为了修复这个bug,得批量修改程序上百个地方。
第二个例子也是来自项目中的,虽然它暂时不是一个线上bug,但它给程序增加了一些维护上的困难,是一种违反开放-封闭原则的场景。
程序中有这样一个逻辑,当用户属于 owner 或者 manager 角色时,可以拥有可编辑和可复制的权限。
1 | if (isOwner || isManager ){ |
这个逻辑在一开始也是没有问题的,确实我们当时的产品需求就是只给 owner 或者 manager 角色赋予可编辑和可复制的权限。
但是在某天,产品同学突然增加了一个需求,增加了一些新的角色,比如高级管理员等等,这些角色也应该拥有可编辑和可复制的权限。
这时候我们要搜寻所有代码中成千上万个类似的条件语句,并且在 if 语句加上对高级管理员的判断,这是一种霰弹式的修改,给维护带来很大的成本。
第三个例子我们很熟悉,来自某一次 readability 的练习题,也许大家都写过这种代码(至少我以前写过):
1 | if (document.getElementId(‘login’).style.className === 'hasLogin'){ |
这个逻辑是指,当html里含有某个id为login的dom节点,并且它的样式等于hasLogin,就认为当前用户已登录。这个逻辑显然也是非常不稳定的,首先dom节点因为非常接近设计师,这个login节点和它的hasLogin样式可能因为将来的某个需求被去掉或者替换掉。其次,这段代码无法脱离浏览器存在,如果我们想让这个逻辑运行在node环境,或者运行在单元测试里,还得专门对这个逻辑进行兼容处理。
这三个例子都反映了一个问题:不稳定的因果关系可能导致程序变得难以修改或者在将来的某天被引发bug。
第一个例子中 getCookie('UId')
这是一种技术细节 技术细节出现在条件语句里并不是稳定的,技术细节经常会随着产品或者技术方案迭代失效,改进方法有2种:
- 让程序依赖后台返回的 isLogin 字段,向后台查询登录态,比从 cookie 里查找某个字段是否存在可靠的多
- 即使不依赖后台,也需要将
getCookie('UId')
封装成isLogin
方法,让业务逻辑去依赖isLogin
这个更“抽象”的方法,而不是依赖getCookie('UId')
这些技术细节
1 | function isLogin(){ |
至少我们上百处需要判断登录态的业务逻辑变得稳定了:
1 | if (isLogin()){ |
当有天增加了企业微信登录的时候,我们只需要统一修改isLogin方法即可,不会影响到其他业务逻辑。
第二个例子中,if (isOwner || isManager )
是一种产品需求细节,它是有可能随着产品需求变化而不停变化的,我们需要将它改为稳定的逻辑:
1 | if (sheetStatus.canCopy() && sheetStatus.canEdit()){ |
然后:
1 | sheetStatus.canCopy = function(){ |
通过这种方式,我们新增了一种更稳定的“文档状态”,并且让业务逻辑去依赖这个稳定的文档状态,业务逻辑就会随之变得稳定。当增加高级管理员角色的时候,我们只需要去修改 canCopy 和 canEdit 的返回逻辑就可以。
第三个例子中,我们不应该让程序去依赖非常不稳定的UI模块,解决方式依然是需要在程序新增一种稳定状态,让业务逻辑去依赖这个稳定状态,而不是依赖UI模块。
终端无关性:
腾讯文档现在大概有10多个终端作为宿主,比如 pc-web、mobile-h5、小程序、mobile-app-webview、pc-app-webview等,以后还可能接入各种各样的终端,理论上这个数量是无限扩张的。
在我们代码中经常会出现一些判断终端的代码,在不同的终端需要对程序做出不同的处理。
比如:
1 | if (pc){ |
腾讯文档现在大概有10来个终端作为宿主,比如pc-web、mobile-h5、小程序、mobile-app-webview、pc-app-webview等,以后还可能接入各种各样的终端,理论上这个数量是无限扩张的。
一旦出现了这种平铺直叙来判断端的代码,那我门这些代码都是违反开放-封闭原则的,因为一旦有新的端出现或者消失,我门都被迫去业务逻辑深处搜索这些代码加以修改。
而且这些修改是霰弹式的,各个端在业务里有多少种差异性的行为,我们就得修改多少处代码。
至少在我门项目里,这些差异是成千上万的,每修改一次的成本都无法接受。
那么怎么去解决程序在不同终端下将有不同处理的问题呢?
大部分场景下,用多态都可以解决:
1 | interface ITips{ |
或者
1 | const tips = new MobileTips(); |
这种用多态的方式,适用于程序在不同端具有相似操作,但操作的具体内容细节不同的场景。
第二种场景,如果程序在不同端具有不同操作呢?比如:
1 | if (pc){ |
我门得换个角度来观察这段代码,当程序判断当前环境分别是pc还是mobile的之后究竟是要想干什么。
程序只想做这样一件事情,当处在pc或者mobile环境下时,需要显示登陆按钮,而当处在app或者小程序中时,需要隐藏登陆按钮。
其实我们只是想决定究竟是要显示还是要隐藏loginbtn。但因为平台的数量是有一直膨胀的,所以我们这些if、else里的逻辑,也是不符合开放-封闭原则的,一旦有新的平台出现,都要新增一个if、else。
我们尝试增加一个更稳定的新的字段:showBtn,不管有多少种平台,showBtn都是只有两种可能,要么为true,要么为false,所以showBtn相对是要稳定很多的。接下来只要让具体业务逻辑去依赖这个稳定的showBtn就可以了。
将代码改成:
1 | if (showBtn) { |
这段代码就变得很稳定了,无论以后增加什么多少端,都不用去改动这些埋藏在业务逻辑深处的代码,他们将一直很安全。
剩下的就是我门需要建立pc、mobile、app等端和showBtn的映射关系,用简单的配置文件就可以表达。
1 | const config = { |
在这个例子中,复杂度和分歧其实并没有被消灭,当增加或者减少一个终端时,我们还是得作出相应的处理。当这些分歧被我们从业务逻辑里转移到了配置文件中,业务逻辑变成了和终端无关。
当分歧发生时候,本来我们可能需要改动业务深处成千上万个地方,现在只需要改动一处配置就可以了。对核心业务代码来说,我门没有违反开放封闭原则,不用担心因为需求变迁给核心业务代码带来的风险。而仅仅修改配置文件或者依赖注入实例,相比而言修改业务深处上千个地方,是一件很轻松的事情。