前端编程之道7-2:谈谈前端开发中的单一职责原则

728 阅读9分钟

前端编程之道系列,欢迎关注,github.com/501351981/H…

单一职责原则SRP

单一职责是著名软件开发大佬鲍勃大叔提出的SOLID原则中的第一个重要原则,它是一个听起来好像很简单明了,实际使用时却是十分不好掌握的一个原则,先看下Bob大叔对于单一职责的定义:

单一职责原则(Single Responsibility Principle,SRP)是面向对象设计中的一个原则,它指出一个类或模块应该只有一个引起它变化的原因,即一个类或模块应该只负责一项职责。

单一职责原则的核心思想是将一个类或模块的功能划分为独立的、高内聚的部分,每个部分只负责一个明确的职责,在我们前端领域可以将类或模块替换为组件、模块、函数等概念,也就是一个组件、模块或者函数应该只有一个明确的职责,在维护时只有一个引起它变化的原因。

我们通过两个实际的例子来看下什么是单一职责。

函数开发示例

假设我们现在要实现这样一个功能,获取页面上id为**的Dom元素,然后设置他的class类名,很自然的我们想到要实现一个名为setClass函数,仅仅实现这个需求非常简单,可能的实现如下:

<div id="test">测试内容</div>

<script>
    function setClass(id, className){
        let dom = document.getElementById(id)

        dom.classList.add(className)
    }

    setClass('test', 'red')
</script>

可以看到这个函数做了两件事,首先获取元素,然后修改元素的类名,现在看起来一切正常,并没有什么不妥。

接着需求发生了变化,不光要支持根据元素id修改类名,还要支持class选择器和元素选择器,这时必须要修改setClass函数的实现了(假装不知道document.querySelector方法)

function setClass(selector, className){
        let dom
        if(selector.startsWith('#')){
            //id选择器
            dom = document.getElementById(selector.substr(1));
        }else if(selector.startsWith('.')){
            //类选择器
            dom = document.getElementsByClassName(selector.substr(1));
        }else {
            //tag选择器
            dom = document.getElementsByTagName(selector)
        }


        dom.classList.add(className)
}
setClass('#test', 'red')

没过多久,需求又发生了变化,上面的实现每次只能添加一个类,我们想要一次添加多个类,传递的类名要支持数组和字符串两种格式,继续修改setClass方法。

function setClass(selector, className){
        let dom
        if(selector.startsWith('#')){
            //id选择器
            dom = document.getElementById(selector.substr(1));
        }else if(selector.startsWith('.')){
            //类选择器
            dom = document.getElementsByClassName(selector.substr(1));
        }else {
            //tag选择器
            dom = document.getElementsByTagName(selector)
        }

        //增加对className格式的判断
        if(Array.isArray(className)){
            className.forEach(name => dom.classList.add(name))
        }else{
            dom.classList.add(className)
        }

    }

    setClass('#test', ['red', 'blue'])

通过上面的示例我们可以看出,修改setClass函数有两个原因

  • 如何选择要修改的元素发生了变化
  • 如何修改元素的类名发生了变化

很明显这样设计不符合单一职责原则,但是不符合原则会怎样呢,目前来看仿佛一切正常,别急,那就继续变更需求。

现在有了新需求,想要通过id、类、元素选择器选中元素,然后删除元素的某个类名,也就是要增加一个removeClass方法,其中选中元素的逻辑和setClass是一样的。

function removeClass(selector, className){
        let dom
        if(selector.startsWith('#')){
            //id选择器
            dom = document.getElementById(selector.substr(1));
        }else if(selector.startsWith('.')){
            //类选择器
            dom = document.getElementsByClassName(selector.substr(1));
        }else {
            //tag选择器
            dom = document.getElementsByTagName(selector)
        }

        dom.classList.remove(className)
}

可以看到setClass和removeClass在选择元素方面存在重复,为后续的维护带来成本,之所以存在重复就是因为这两个方法职责并不单一,一个函数做了两件事,导致多个函数中的重复部分无法复用,原因找到了解决起来也就非常简单,我们只需封装三个职责单一的函数即可:

  • selectDom:只负责选择Dom元素
  • setClass:只为Dom元素添加类名
  • removeClass:只为Dom元素移除类名
    function selectDom(selector){
        let dom;
        if(selector.startsWith('#')){
            //id选择器
            dom = document.getElementById(selector.substr(1));
        }else if(selector.startsWith('.')){
            //类选择器
            dom = document.getElementsByClassName(selector.substr(1));
        }else {
            //tag选择器
            dom = document.getElementsByTagName(selector);
        }
        return dom;
    }
    
    function setClass(dom, className){
        if(Array.isArray(className)){
            className.forEach(name => dom.classList.add(name))
        }else{
            dom.classList.add(className)
        }

    }

    function removeClass(dom, className){
        dom.classList.remove(className)
    }

经过这个修改之后,如果后续想要增加新的元素选择方式,则只需修改selectDom方法即可,不再会影响setClass和removeClass方法。

组件开发示例

封装组件是现在前端开发中最主要的工作,在Vue和React项目开发中,一切皆组件,大到一个页面,小到一个按钮,都可以看作是一个组件。

框架虽然鼓励用户进行组件化开发,然而却不能强制控制组件化的粒度,所以每个人写出来的页面差别甚大,特别是一些前端新手写出来的页面,动辄2000行以上,所有的UI、逻辑、样式杂糅在一起,让后面维护的人甚是头大,就算是修改一个小功能,都要去阅读大量的代码,带来极大的维护成本,究其原因,就是没有按照单一职责原则进行组件设计。

下面这样一个用户列表,如果交给你开发,你会如何划分组件?

07 用户列表.png

我们先来根据UI图梳理下需求:

  • 操作区:这里仅有1个添加用户按钮,点击之后一般会弹窗进行用户添加,或者跳转到添加用户界面
  • 搜索区:根据用户角色进行筛选,用户角色下拉选项数据通过请求角色列表获取
  • 表格展示区:
    • 姓名:姓名可以点击,点击之后抽屉展示用户详细信息
    • 角色:不同角色有不同的颜色标识
    • 手机:隐藏手机号中间4位
  • 表格操作区:
    • 删除:点击删除后,二次确认是否确定删除,确定后删除数据,刷新表格
    • 编辑:点击后弹窗进行用户信息修改

可以看到,这个用户列表是一个混合了多个功能的组件,至少包括了添加用户、搜索、用户信息展示(姓名展示、角色展示、手机号展示)、用户管理(删除、编辑)四个功能,如果所有功能杂糅到一个组件中,势必造成单个文件过大,难以复用的问题。

按照单一职责进行组件划分:

  • 操作按钮组件 UserOperate:
    • 组件职责:实现用户添加操作
    • 属性:无
    • 事件:对外暴露添加成功事件,主组件接收后刷新表格
  • 搜索组件 UserSearch
    • 组件职责:负责维护搜索表单
    • 属性:默认筛选项的值
    • 事件:对外暴露search事件,主组件接收后刷新表格
  • 表格展示组件,一般都有第三方UI组件,无需封装
  • 用户姓名 UserName
    • 组件职责:展示用户姓名
    • 属性:用户姓名、用户id
  • 用户角色 UserRole
    • 组件职责:根据不同角色类型展示不同icon和名称
    • 属性:用户角色
  • 用户手机 UserMobile
    • 组件职责:隐藏手机号中间4位
  • 用户管理 UserManager
    • 组件职责:用户的管理,这里将删除和编辑混合到一起,虽然可能违反单一职责原则,但是太细的话也没有必要,如果操作很多,每个很复杂,可以酌情拆分
    • 事件:对外暴露删除成功、编辑成功事件,主组件接收后刷新表格
  • 主组件 UserList
    • 组件职责:负责整合各个组件,根据搜索条件请求数据,赋值给用户表格

按照单一职责拆分后,主组件结构清晰,需要修改哪一块需求,可以迅速定位到相关子组件,不再像之前一样需要阅读大量代码才能定位到要修改的内容。

单一职责优缺点

通过上面两个示例,我们可以看到,符合单一职责的代码有以下几个优点:

  • 增强代码的可维护性:单一职责代码一般比较精简,减少了复杂性,可读性更强;每个模块职责清晰,更加容易去除或者更换某个职责的模块,而不用担心影响其他职责的模块
  • 提升代码的复用性:单一职责原则使得每个模块的功能更加独立和自治,可以更容易地被其他部分引用和复用。这样可以减少代码的重复,提高代码的可重用性,从而提高开发效率
  • 提高代码的可扩展性:当一个模块只负责一项职责时,新增功能或修改现有功能时只需要修改相关的模块,而不会影响到其他部分。这使得系统更加灵活和易于扩展,可以更容易地应对变化和需求的变更。
  • 提高代码的可测试性:单一职责原则使得每个模块的职责明确,因此可以更容易地编写针对每个职责的单元测试,而不必Mock大量的测试数据

虽然遵循单一职责会提升代码的可维护性、可复用性、可扩展性和可测试性,但是也存在以下几个问题,需要在编写代码时去平衡:

  • 模块的数量/层级增加:遵循单一职责原则必然会导致模块的数量或层级增加,这可能会增加代码的复杂性和维护成本,取决于模块的粒度划分是否合理。

  • 职责划分困难:有时候,将功能划分为独立的职责可能并不是一件容易的事情。在某些情况下,职责之间可能存在一些交叉和重叠,这可能导致职责的划分变得困难,比如上面的表格操作区域,是把删除按钮和编辑按钮封装到一起作为一个表格项管理组件,还是把他们拆开成两个组件,是比较难权衡的

  • 跨职责的协调和通信:由于将一个大功能划分成不同的小模块,就比如会引起小模块之间通信的问题,这可能会增加代码的复杂性。

所以说在软件开发领域,并不存在"银弹",不能没有原则,也不能完全套用原则,就像咱们古人推崇的中庸之道,干啥都别过分,写代码就是平衡的艺术。