代码优雅的秘诀

82 阅读31分钟

江流天地外,山色有无中

一束代码丽,千行不乱纵

极目楚天舒,心源渐无穷

一、序言

也许你也曾听过:

“好的代码就像一首诗,优雅而有内涵”

“所谓优雅,相对应的是坑。只有见过足够多的坑,才会形成自己的编码理念”

也许你也曾和我一样在职业成长之路上零零散散的学着各种各样的编码技巧,直到你看到了一次“优雅”的设计,好奇心驱使着你下定决心要去了解其背后的设计思想,原来“优雅”的经验也是可以借鉴的

二、可读性

命名规范:帖一篇两年前的文章:juejin.cn/post/708418…

2.1 规范上常见问题

以下这些案例都是从真实业务场景中挑选出来的,具备一定参考性

2.1.1 不使用魔法数字

魔法数字:在编程领域指的是莫名其妙出现的数字。数字的意义必须通过详细阅读才能推断出来

魔法数字是我们代码仓库中最常见的问题之一,如果相同魔法数字的含义在不同地方所使用,并且它们的出处却不能保持在相同的路径,这真的太令人懊恼了

function fun(){
  const outNodes = getOutgoers(node, nodeList, edges);
  if (outNodes.length >= 20) { // So why 20? Where else is “20” useful
    return {
      isAddDisable: true,
      maxSplit: true,
    };
  }
}

当你不使用魔法数字而是一个常量后,你可以清楚的知道这个常量的含义是什么,以及它在哪里有使用,以后如果需要修改这个数字时,就无需了解所有业务场景而逐一修改

import { SPLIT_MAX_COUNT } from './constants'

function fun(){
  const outNodes = getOutgoers(node, nodeList, edges);
  if (outNodes.length >= SPLIT_MAX_COUNT) { 
    return {
      isAddDisable: true,
      maxSplit: true,
    };
  }
}

每一则案例背后,都有一个真实发生的故事:

2.1.2 Bool 类型否定命名

否定式的变量名如 notFound、notDone 和 notSuccessful 等在“非” 运算中是很难读懂的 如: if not notFound 这类名字应该用 found,done,successful 等来代替,以方便“非”运算

// bad
const cannotDeletable = deleteNodes.some(
   nd => nd.data.extras?.deletable !== undefined && !nd.data.extras?.deletable);
 
if (!cannotDeletable) {
   // do something
}

// good 
const deletable = deleteNodes.every(
   nd => nd.data.extras?.deletable === undefined || nd.data.extras?.deletable);
 
if (deletable) {
   // do something
}

2.1.3 映射关系 Map 的命名不清晰

你可能经常会看到 xxxMap,例如 titileMap、userMap、regionMap 等等作为维护映射关系的对象,但光看上述命名你怎么知道 Map 前的 title 是 key 还是 value 吗?如果使用 To 或者 2 呢

const titleMap = {} // not perfect

const id2TitleMap = {} // good
const idToTitileMap = {} // good

2.2 编码的一些技巧

2.2.1 德摩根定律优化判断条件

19世纪英国数学家奥古斯塔斯·德摩根首先发现了在命题逻辑中存在着下面这些关系:

¬(𝑝∧𝑞)≡(¬𝑝)∨(¬𝑞)

¬(𝑝∨𝑞)≡(¬𝑝)∧(¬𝑞)

// 不使用德摩根定律
if(!((A && B) || C)) {
  // do something
} else {
  // do something
}

看完这段代码可能你的大脑是这样的:

如果使用德摩根定律将上述案例进行拆解优化:

!((A && B) || C) 
== !(A && B) && !C   // 德摩根律
== (!A || !B) && !C  // 德摩根律
== (!A && !C) || (!B && !C)  // 分配律

可以根据最外层判断是否存在取反逻辑来决定是否需要优化,因为取反这个逻辑需要将所有之前的推导内容反义过来,在大脑内相当于你往逻辑栈里面压入了一次取反栈,对于复杂场景语义会更加难懂

// 比如 type 可以有 a、b、c、d、e 五种类型, 你可以尝试阅读下两种代码带来的直观感受
if(!(type === 'a' || type === 'b'))

if(type !== 'a' && type !== 'b')

2.2.2 提前返回 Early Return

迷惑代码鉴赏群里的老梗了

对于代码层级越深的函数,其可读性越低,为了避免多层嵌套 if 的情况,我们可以使用 early return 这个技巧,一般开发到三层及其以上你就需要注意下是否需要提前返回了

// bad
function handleClick(event) {
  // Make sure clicked element has the .save-data class
  if (event.target.matches('.save-data')) {
    // Get the value of the [data-id] attribute
    let id = event.target.getAttribute('data-id'); // Make sure there's an ID
    if (id) {
      // Get the user token from localStorage
      let token = localStorage.getItem('token'); // Make sure there's a token
      if (token) {
        // Save the ID to localStorage
        localStorage.setItem(`${token}_${id}`, true);
      }
    }
  }
}
// good
function handleClick (event) {
  if (!event.target.matches('.save-data')) return;
  
  let id = event.target.getAttribute('data-id');
  if (!id) return;

  let token = localStorage.getItem('token');
  if (!token) return;

  localStorage.setItem(`${token}_${id}`, true);
}

2.2.3 参数较多时,使用对象进行包装

一般我们建议在三个参数及其以上时使用对象进行包装能很好的降低代码出错的概率,修改的成本

// Bad Case

// function define
const checkMediaMaterial = (mediaType = 'video', mediaData = {}, checkOptions = DEFAULT_CHECK_OPTIONS, callBack) => {
    // ... do sth
}

// function use
// ❌ 结果错误 —— 参数顺序错误 —— 需要关注不必要的参数顺序
checkMediaMaterial(mediaType, checkOptions, mediaData, callBack)
// 预期使用默认参数 需要对位填入 undefined —— 有点傻
checkMediaMaterial(undefined, mediaData, undefined, callBack)
// 对参数名不敏感,开发时可能对参数含义不清楚,维护时也会产生困难
checkMediaMaterial(apple, banana, orange, peach)

// Good Case

const checkMediaMaterial({ mediaType = 'video', mediaData = {}, checkOptions = DEFAULT_CHECK_OPTIONS, callBack }) => {
    // ... do sth
}

// function use
// 1. 参数具名:以参数名代替参数位置做参数的区分 解决了顺序上的依赖 和 参数理解的问题
checkMediaMaterial({ mediaType, mediaData,checkOptions, callBack })

// 2. 需要取默认值的参数可直接不写
checkMediaMaterial({ mediaType, mediaData, callBack })

三、可维护 & 鲁棒性

3.1 防御性编程

防御性编程(Defensive programming)是防御式设计的一种具体体现,它是为了保证,对程序的不可预见的使用,不会造成程序功能上的损坏。它可以被看作是为了减少或消除墨菲定律的有效方案。

// bad
function parseJSON(jsonString:string) {
    return JSON.parse(jsonString);
}

const badJson = "{ “name”: “John”, “age”: 30 }"; // 单双引号错误

// good
function parseJSON(jsonString:string) {
    try {
        return JSON.parse(jsonString);
    } catch (e) {
        console.log('There was an error parsing the JSON: ', e);
        return null;
    }
}

简单来说防御性编程指的是通过编写防御性代码来确保函数的正确执行,这种编码方式能有效提升函数的稳定性,但同时也会带来额外的开发时间,在一般开发场景下我们的确很少会需要使用到它,仅有在以下几种情况下推荐你使用它:

  • 代码一旦上线后,修改的成本太高,比如安卓开发
  • 函数非常通用,并不仅针对当前开发的使用场景,可能还需要对外暴露,例如 lodash 中的 get、set
  • Ts 类型没法兜底,如上述案例
  • 异步语句

3.2 抽象与耦合(abstraction and coupling)

所谓的抽象,就是将一系列事物共有的特质提取出来,舍去独有的部分的过程

DRY 原则: Don't Repeat Yourself “不要重复你自己”,将通用的东西抽象起来

3.2.1 对抽象的理解

1 + 1 = 2 这个数学表达式就是一个抽象 当我用实际的案例来举例时:一条狗 + 一条狗 = 两条狗,而在这个例子中,狗的抽象就是实体,两个实体相加之后的实体数量为二,我们将实体数量简化为数字的过程叫做抽象

3.2.2 抽象和编程的关系

设计一个软件的时候我们需要考虑平台的“实体”都有哪些,将通用的实体做“适当的”抽象能简化我们的开发和维护成本,对于一个简单且出现频率非常高的抽象实体,想必大家都知道要抽象,但什么时候抽象,抽哪些内容能降低代码的耦合度是有一些方法论的

3.2.3 抽象与耦合的关系

既然抽象这么好的话,为什么不能无脑抽象?

这里我以 Dan abramov (react 核心开发者)的演讲作为案例来解释下抽象带来的代价

  1. 当你开发一个新的功能你发现它和原有代码中有一处一摸一样的部分,你可以将其内容进行抽象,于是它变成了右边这样

这看起来很好因为你省去了复制粘贴的工作,并且未来这部分功能你可以在一个地方维护,防止代码的过度分散而带来新的维护成本

  1. 以 DRY 为最佳实践的你又遇到了开发了一个新的功能,你发现它其中的一个功能和原本抽象的模块非常相似,于是乎你将这些内容也适配进了抽象,可能是在原本的基础上添加一些 if else 让这个抽象可以兼容新的代码

  1. 又过了一整子,你发现某个功能出现了一个 bug,于是乎你决定在抽象的内容里面进行修复,比如判断如果为 b 类型我们就给它特殊处理下

  1. 但好巧不巧,你又发现另一个功能也出现了 bug,你在原本的抽象里继续添加了这种适配逻辑,但很快你就意识到了不能将这种兼容性代码存放在抽象内容里面,因为这不符合开闭原则,如果未来有更多的组件使用你这个抽象,抽象本身也会越来越大。于是你将兼容性的内容从抽象中剥离出来(最右图),这样看起来很通用了对吧

  1. 然后在这种基础上的代码经历了数次的迭代,多人的经手,很可能我们最终的代码调用链路就变成了这样,这看起来很搞笑不可思议,但确实是很多不合理抽象会遇到的问题,如果此时需要你来维护这种代码必然痛苦万分

我们需要知道在抽象的同时我们得到了什么,失去了什么,每一种编写代码的风格和最佳实践一定有它适用的范围,而不是成为所有场景的最佳选择。抽象将会无法避免的带来代码耦合性的提升,当 a b 模块同时依赖了抽象功能 c 时,你在为 a 中的 c 功能打补丁时也需要考虑 b 的兼容性。这便是抽象与耦合,那既如此什么时候我们选择抽象会更好呢

抽象与耦合就像天平上的两杯水,你每增加一点抽象的时候就必然会带来一点耦合,耦合的代价在设计之初是无法感受到的,但在维护时它将成为你的开发的负担

3.2.4 【抽象方法论】三次原则

YAGNI 原则: You aren't gonna need it "你不会需要它",无需过度封装,优先让代码跑起来,不用把精力浪费在“抽象”上

YANGNI 和 DRY 两个原则在编程中无法做到同时兼容于是就出现了三次原则

三次原则:当一个重复的功能出现在三次以上的地方时候,就应该考虑将共有的部分封装为函数或者类,在需要用到的地方调用,不同的部分用传参数的方式传进去

  • 第一次用到某个功能时,写一个特定的解决方法
  • 第二次又用到的时候,拷贝上一次的代码
  • 第三次出现的时候,着手"抽象化",写出通用的解决方法

当然,面对一些能预知的通用功能,应该先封装好,在进行封装,后面在调用,比如类似排序函数之类的功能等

3.3 最常见的设计模式与案例

好的设计模式能在代码编写和维护中起到事半功倍的作用,为此挑选了几个我在项目中最常见的设计模式和案例

3.3.1 对设计模式的理解:

GoF 成员之一 John Vlissides 在他的另一本关于设计模式的著作《设计模式沉思录》中写过这样一段话:

"设想有一个电子爱好者,虽然他没有经过正规的培训,但是却日积月累地设计并制 造出许多有用的电子设备:业余无线电、盖革计数器、报警器等。有一天这个爱好者决 定重新回到学校去攻读电子学学位,来让自己的才能得到真实的认可。随着课程的展开, 这个爱好者突然发现课程内容都似曾相识。似曾相识的并不是术语或者表述的方式,而 是背后的概念。这个爱好者不断学到一些名称和原理,虽然这些名称和原理原来他不知道,但事实上他多年来一直都在使用。整个过程只不过是一个接一个的顿悟"

一个稍有经验的程序员也许在不知不觉中数次使用过这些设计模式,而它们就像是特定场景的一个最优解决办法

3.3.2 策略模式 strategy

以编写一个名为 calculateBonus 的函数来计算每个人的奖金数额。很显然, calculateBonus 函数要正确工作,就需要接收两个参数:员工的工资数额和他的绩效考核等级。

var calculateBonus = function (performanceLevel, salary) {
  if (performanceLevel === 'E') {
    return salary * 5;
  }else if (performanceLevel === 'M') {
    return salary * 3;
  }else if (performanceLevel === 'F') {
    return salary * 2;
  }
};
calculateBonus('E', 20000); // 输出:100000
calculateBonus( 'M', 6000 ); // 输出:18000

这样的好处是:

  • 代码调试简单

坏处是:

  • 新增绩效种类会修改 calculateBonus 函数,如果其中的 if 判断条件有上下顺序关系,你可能还需要关注其它代码,比如上述不仅需要绩效是正常的并且在职,但是新增的 E + 和 O 绩效可以不在职也可以拿到正常奖金
  • 如果除了奖金之外还有期权也是按照上述的计算逻辑,那这段代码只能复制粘贴,无法复用

如果使用策略模式来进行重构

var strategies = {
  E: function (salary) {
    return salary * 5;
  },
  M: function (salary) {
    return salary * 3;
  },
  F: function (salary) {
    return salary * 2;
  },
};

var calculateBonus = function (level, salary) {
  return strategies[level](salary);
};
console.log(calculateBonus('E', 20000)); // 输出:100000

来看看我们仓库里的策略模式:

代码实现的功能:校验节点正确性,不同节点的校验逻辑不一致,同时一个节点可以有多个校验逻辑

对于这种场景你会发现两个关键词:

  • 多个校验 多意味着如果直接写 if else 校验功能将会变的异常庞大,拆分为策略模式让单节点的校验只需关注自身的校验逻辑
  • 可重复, 比如不同类型的节点有为空校验失败的逻辑,那这段代码即可被重复使用多次

总结下:将多个原本可以通过 if else 编写的代码维护为一个一个的方法的设计模式叫做策略模式,我们在面对一段代码设计的时候可以从以下几个角度来判断是否使用策略模式:

  • If else 这种判断条件的数量,一般情况下,超过三个以上你就警惕这段代码了
  • 逻辑是否可复用,比如 if 条件 1 执行了 a b 逻辑, if 条件 2 执行了 b c 逻辑,b 逻辑就是一个可复用的逻辑,这种单逻辑是否可以拆分为策略

这种模式和直接对外暴露接口函数不同的是策略维护的地方不一样,一般我们写 React 组件的时候,比如设计一个 button 组件,内部可能会携带 icon,icon 的样式由外部提供,外部也同样可以通过自定义函数的方式来渲染这部分内容,这种不叫做策略模式,策略模式有很明显的“高内聚”,面对相同问题的处理逻辑都会在一个地方/文件来通一进行管理,辨别策略模式的方式就是是否有两个特征

  1. 函数调用处统一
  2. 函数编写处来自同一个文件/类,当然也不可排除有一些情况维护策略模式的后来者把这些新增的策略维护在其它文件夹
3.3.3 装饰器模式 decorator

装饰器模式又称作包装模式,理解装饰器模式之前,我先来介绍一个面向对象的案例:

  1. 假设现在你设计了一个 A 类机器人,A 类机器人具备说话的功能,于是你创建了如下的代码
class RabotA {
  private info
  constructor(productInfo) {
    this.info = productInfo
  }
  say() {
    console.log('say something')
  }
}

2. 某一天产品找到你,需要新增一个机器人跳舞的逻辑,于是乎你现在有两种方案

1.  实现一个 RabotA2 版本,继承 RabotA 类,实现跳舞功能
2.      class RabotA2 extends RabotA {
          constructor(...props) {
            super(...props)
          }
          dance() {
            console.log('dancing')
          }
        }
3.  使用装饰器模式对原本的 RabotA 进行升级
4.      class WithDanceRobot {
          robot
          constructor(robot) {
            this.robot = robot
          }
          dance() {
            console.log('dancing')
          }
        }

3. 然后你的产品发布一整子后,产品再次找到你,它说机器人还得会唱歌

1.  > 于是你在想现在是开发 robotA3 还是说实现一个 WithSingRobot 对 robot 升级呢 ?
2.    装饰器模式简单来说就是不影响被装饰者功能的前提下对其能力进行拓展,这和继承的思想非常相似,但本质上两者解决问题不完全一样,装饰器模式相对于继承更加灵活,你可以同时对一个功能进行多次拓展(类比一个人可以穿很多件衣服)不需要重新创建一个具备更强能力的对象。

看到这里你大概应该了解了:

继承实现能力增强:你需要重新从零创建新的对象,比如在上述案例中 productInfo 就需要重新赋值

装饰实现能力增强:传入需要增强的对象后,返回一个具备更强能力的对象。

装饰器模式在前端开发中的应用

装饰器时常被用于处理跨切面(cross-cutting)关注点,如日志记录,性能度量,数据绑定等,在 gulux、mobx 中你会经常看到它的身影,但目前装饰器在 JS 中仍处于第 3 阶段提案中,等待浏览器支持,TS 将其作为实验性功能,在 TS 5+ 版本中可以支持装饰器语法

这里提供一个使用 TS 装饰器语法来实现一个打印函数执行前和执行后时间戳的工具函数:

function measure(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
        console.log(`Start method ${propertyKey} at ${new Date()}`);
        const result = originalMethod.apply(this, args);
        console.log(`End method ${propertyKey} at ${new Date()}`);
        return result;
    }

    return descriptor;
}

class Example {
    @measure
    method() {
        // do some actions
    }
}

观察上述代码你会发现装饰器模式在这种情况下和我们的 AOP(面向切面编程)的思想非常相似,是的,在服务端语言中你会经常看到它们的身影,这种可以完全将业务逻辑和功能函数分离开来解耦的方式深受开发者的喜爱

AOP 的目的是将横切关注点(如日志记录、事务管理、权限控制、接口限流、接口幂等等)从核心业务逻辑中分离出来,通过动态代理、字节码操作等技术,实现代码的复用和解耦,提高代码的可维护性和可扩展性

来看看我们项目中的案例:

这是一个 gulux 中的装饰器案例,该装饰器的作用是:根据接口的上线单 id (ticketId)获得 业务 code (bussinessCode)再将业务 code 注入到接口参数之中去,当然如果你不了解 gulux 中的语法的话,你只需要关注高亮部分代码即可,大意就是想要告诉你这种设计模式在实际生产开发中也非常重要

export function ticketAop(config?: { transformIdField: string }): AspectType {
  const { transformIdField = 'ticket_id' } = config || {};
  return {
    before: async (joint: JoinPoint) => {
      const ticketService = useInject(TicketService);
      const ticketId = joint.args?.[0]?.[transformIdField];

      if (ticketId) {
        const { TicketMap } = await ticketService.mGet({ TicketIds: [ticketId] });
        _.set(joint.args[0], '_businessCode', TicketMap[ticketId].BusinessKey);
      }
    },
  };
}

// use ticketAop
  @Before(ticketAop())
  @Around(checkAuth({ authPoints: ['read'], codeField: '_businessCode' }))
  @Get('/api/v2/ticket/execution_by_ticket')
  async getExecutionByTicket(@Query() query: GetExecutionByTicketDto) {
    try {
      const ticketId = Number(query.ticket_id);
      const rpcResp = await this.ticketService.getExecutionByTicket({ TicketId: ticketId });
      if (rpcResp?.Info?.CurrentTaskExtra) {
        // Parsing CurrentTaskExtra
        try {
          const result = JSON.parse(rpcResp?.Info?.CurrentTaskExtra);
          rpcResp.Info.CurrentTaskExtra = underLine2UpperCamel(result);
        } catch (err) {
          throw genStandardError(err?.message, StandardErrorCode.LOSParseJSONError);
        }
      }
      return convertKeysToUnderscore(rpcResp);
    } catch (error) {
      return serializeError(error, Modules.Los);
    }
  }

在 React 中 HOC 就是装饰器模式思想的一种体现,在处理一个类型复杂且我们需要添加的功能比较繁琐时,一般我们会将增强型的组件封装为一个 HOC,每一个 HOC 都在不影响原本节点功能的情况下为其注入额外能力,当然还有一些 HOC 是借鉴了代理模式的思想,下文中会提及

比如下面的这个截图,每一个 Wrapper 都为 Node 注入了一个单独的能力,让其具备了全局弹出层、跳转、添加节点、复制粘贴等等功能,这些额外的 Wrapper 都不会影响 Node 本身的能力,也让我们为不同类型节点添加不同集合时有了更多选择

总结下:装饰器模式在实现对一个已有功能的增强时会和继承的能力非常类似,但装饰器是一种可以在不改变对象自身的基础上对对象能力进行增强,是一种“即用即付”的功能。装饰器模式与 AOP 思想非常契合,一般我们可以用来处理日志、鉴权、参数转换等等

3.3.4 代理模式 proxy

代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问

你在什么情况下可能会需要用到它:如果当你不方便直接访问对象,或者对象不满足需求的时候,你需要一个替身来控制这个对象的访问时,你就会需要用到这种模式

我们来看看一个实际案例:

  1. 假设现在你仅使用 JS 来实现对 dom 元素的操作,你需要实现一个购物车案例,购物车中的每一项都可以点击删除,新增,添加/减少等等功能
<ul id="shop-cart">
    <li>Item 0</li>
    <li>Item 1</li>
    <li>Item 2</li>
    <li>Item 3</li>
    <li>Item 4</li>
</ul>

2. 如果直接为其每一个元素都添加点击事件,那这段代码很可能就如下

const items = document.querySelectorAll('#shop-cart li');

for (var i = 0; i < items.length; i++) {
    items[i].addEventListener('click', function (e) {
      // do someting
    });
}

这会有什么问题吗?很显然在这个案例中你需要为所有的购物车中的 item 创建事件监听,抛开代码写法上的冗余不说,在性能上也会有比较大的损耗

  1. 如果使用事件代理呢
document.querySelector('#shop-cart').addEventListener('click', function (e) {
    if (e.target.tagName.toLowerCase() === 'li') {
      // do someting
    }
  });

我们只需要为 ul 元素添加事件,利用 dom 事件冒泡的特性为所有的商品元素 li 添加事件,这样是不是清晰多了呢

我们再看一个 React 中的常见的案例:

  1. 现在你需要为一个系统中部分需要鉴权的页面加上一些鉴权验证,没有权限则展示下方的图案

  1. 如果直接硬编码大概率你会把需要展示的图像组件和鉴权逻辑分别抽象为 component 和 hook
const checkAuthentication = async()=> {
    // checkAuthentication 
}

const NoPemissionCom = () => {
    return <img src ='xxx'/>
}

3. 此时不使用代理模式,你需要在所有需要鉴权的页面里面分别使用这两个函数,但更好的方式是你可以封装一个代理 HOC

function withAuthorization(WrappedComponent) {
  return function AuthorizedComponent(props) {
    const isAuthenticated = checkAuthentication();

    if (!isAuthenticated) {
      return <NoPemissionCom/>;
    }

    return <WrappedComponent {...props} />;
  };
}

// use
const DemoPage = () => {
    return <div> demo </div>
}
export default withAuthorization(DemoPage);

在 React 实际开发中你会越到非常多类似的场景,比如封装通用的 loading 动画、鉴权、懒加载等等,这种想要给多个组件让其在某种情况下改变组件展示形态/行为等等的组件,第一时间联想到 HOC 即可

代理模式和装饰器模式的区别:

在我看来两者非常类似,甚至有部分思想是重合的,但关键在于代理模式有一种访问之前就需要做额外事情的感觉,比如我们上述封装的鉴权 HOC 组件,你想访问到实际的 DemoPage 必须经过这一层权限校验,事件代理就必须要经历事件冒泡,当然即使搞不清楚这两个设计模式的区别也不会影响你实际开发,你只需要将这些内容吸收消化转化为自己的开发经验,就像我们最开始提到的:设计模式它们就像是特定场景的一个最优解决办法

四、React Solid

Solid 原则由著名的软件大师 Robert C.Martin 在 Design Principles and Design Patterns 中提出,市面上的文章大多数是以面向对象语言来介绍这部分内容,然而在 React,函数式编程思想在其大放异彩的 Web 框架中,Solid 思想也能辅助我们设计更可维护的代码,尤其在函数、组件设计之中颇受广大开发者的追捧,同时你也可以在 React 官方文档上找到它的思想身影。在介绍这部分原则的时候,同时我也会结合自己的经验来聊聊我在实际开发中的一些感悟

全文案例出自:github.com/ipenywis/re…

4.1 S - Single Responsibility 单一职责

单一职责思想,指的是在设计类/函数/组件之时,其职责也就是功能需要保持单一,具体落实此思想到实际开发之中就是不要做额外的事情,原子化高内聚的内容在代码复用,维护上的优势毋庸置疑。这里我用一个简单的案例来向你介绍下好的单一职责组件应该是什么样的

案例:编写一个用户列表组件

Bad: users 组件内部维护 user view 和 user data

继续往下看我会用真实案例来向你说明为什么这样不好

// Bad Case
const Users = () => {
 const [users, setUsers] = useState([]);
 const [isLoading,setIsLoading] = useState(false)

useEffect(() => {
   setIsLoading(true)
   axios.get('https://jsonplaceholder.typicode.com/users')
     .then(res => {
        setUsers(res.data)
        setIsLoading(false)
     })
     .catch(err => console.log(err));
 }, []);

return (
   <div>
     <h2>{isLoading ? 'loading...' : 'users'}</h2>
     {users.map(user => (
       <div key={user.id}>
         <p>{user.name}</p>
         <p>{user.email}</p>
       </div>
     ))}
   </div>
 );
};

Good: 将 User 组件单独抽离出来,Users 仅作为管理者来进行数据与组件的拼接

管理者:可以理解为多个组件之间的拼接组件,其一般负责数据与视图的传递

// Good Case
const User = ({ user }) => (
  <div key={user.id}>
    <p>{user.name}</p>
    <p>{user.email}</p>
  </div>
);

const Users = () => {
 const [users, setUsers] = useState([]);
 const [isLoading,setIsLoading] = useState(false)

 useEffect(() => {
   setIsLoading(true)
   axios.get('https://jsonplaceholder.typicode.com/users')
     .then(res => {
        setUsers(res.data)
        setIsLoading(false)
     })
     .catch(err => console.log(err));
 }, []);

  return (
    <div>
      <h2>{isLoading ? 'loading...' : 'users'}</h2>
      {users.map(user => (
        <User user={user} />
      ))}
    </div>
  );
};

Perfect:抽象到上述代码也许你能应对大多数较为简单的场景,但更多的情况是 Users 其中可能会维护更多的状态,例如部分用户加入权限能力仅在部分场景展示等等。此时如果作为 Manage 组件管理的状态非常多,虽然组件已经分好层了,但是维护状态也是不小的负担,更近一步你可以把不同类型的数据也进行抽象

// Perfect Case
const User = ({ user }) => (
  <div key={user.id}>
    <p>{user.name}</p>
    <p>{user.email}</p>
  </div>
);

const useUsers = () => {
  const [users, setUsers] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    setIsLoading(true);
    axios
      .get('https://jsonplaceholder.typicode.com/users')
      .then(res => {
        setUsers(res.data);
        setIsLoading(false);
      })
      .catch(err => console.log(err));
  }, []);

  return { users, isLoading };
};

const Users = () => {
  const { users, isLoading } = useUsers();

  return (
    <div>
      <h2>{isLoading ? 'loading...' : 'users'}</h2>
      {users.map(user => (
        <User user={user} />
      ))}
    </div>
  );
};

实际开发场景会比上述案例要更复杂,在管理组件开发中如果没有数据抽离分层的习惯,很可能你的代码最终将会变得非常难以维护,就如同左下图展示一般成为了巨石组件

这也是我过去经常犯的一个问题,导师也会经常指出我代码编写的问题,感恩~

在设计代码之时,保持功能绝对单一并不简单,过度遵守规范反而会带来额外的心智负担。实际开发时需要结合代码复杂度、功能耦合程度、来判断适不适合进行拆分,毕竟拆分最直观的代价就是会让代码的调用层级变的更深,找代码的过程就没有那么方便了。通常当我看到一个管理组件中处理了多种不同子组件所需状态之时,我就会有意识的将其进行拆分,让每个组件所需要的状态变得更加内聚,组件和组件之间的状态进行代码隔离

4.2 O - Open-closed 开闭原则

开闭原则:设计的实体需要对扩展开放,但对修改关闭。拓展指的是在原有实体的基础上新增功能,修改则是改变其实体的功能。

案例:你拥有一个基础 Button 组件,然后你需要在原有基础上添加一个向前/向后的按钮 icon

此时你可以根据【前/后】这两个界定词来限定属性 role,从而展示不同的 icon 以满足需求

const OCP = () => {
  return (
    <div className="flex space-x-10">
      <Button text="Go Home" role="forward" />
      <Button text="Go Back" role="back" />
    </div>
  );
};

// Button.tsx
interface IButtonProps {
  role?: "back" | "forward";
  ...
}

export const Button = (props: IButtonProps) => {
  const { text, role } = props;

  return (
    <button
      {...props}
    >
      {text}
      <div className="m-2">
        {role === "forward" && <HiOutlineArrowNarrowRight />}
        {role === "back" && <HiOutlineArrowNarrowLeft />}
      </div>
    </button>
  );
};

此时在上述组件的基础上再新增更多类型,如果继续沿用上述代码,光靠枚举 role 来维护所有的 Icon 就显得有一点捉襟见肘了,如果你了解开闭原则此时你就可以将这部分内容作为开放属性的一部分,仅需限制其属性类型即可

const OCP = () => {
  return (
    <div className="flex space-x-10">
      <Button text="Go Home" icon={<HiOutlineArrowNarrowRight />} />
      <Button text="Go Back" icon={<HiOutlineArrowNarrowLeft />} />
    </div>
  );
};

interface IButtonProps{
  icon?: React.ReactNode;
  ... 
}

export const Button = (props: IButtonProps) => {
  const { text, icon } = props;

  return (
    <button
      className="flex items-center font-bold outline-none pt-4 pb-4 pl-8 pr-8 rounded-xl bg-gray-200 text-black"
      {...props}
    >
      {text}
      <div className="m-2">{icon}</div>
    </button>
  );
};

实际开发时,如果一个属性类型能非常明确其类型可枚举,且数量不多时完全可以按照第一种做法,符合“对修改关闭”,反之则将其作为拓展功能暴露组件消费者,符合“对拓展开放”。两者各有优劣,如果在明确属性枚举数量不多的情况下将其暴露给组件消费者也会带来额外的使用负担,需结合实际情况来判断

4.3 L - Liskov Substitution 里氏替换

里氏替换:在面向对象语言中,派生类(子类)对象可以在程序中代替其基类(超类)对象。简单理解就是所有父类出现的地方,都可以修改为子类。在 React 中,这种思想体现在当开发者想对一个组件功能进行增强,却又不想破坏其组件的原始功能之时

案例:当我们相对原始 Input 组件进行功能增强,让其具备放大字体的效果

interface ISearchInputProps
  extends React.InputHTMLAttributes<HTMLInputElement> {
  isLarge?: boolean;
}

export function SearchInput(props: ISearchInputProps) {
  const { value, onChange, isLarge, ...restProps } = props;

  return (
    <div className="flex w-10/12">
      <div className="relative w-full">
        <div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
          <svg
            aria-hidden="true"
            className="w-5 h-5 text-gray-500 dark:text-gray-400"
            fill="none"
            stroke="currentColor"
            viewBox="0 0 24 24"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth="2"
              d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
            ></path>
          </svg>
        </div>
        <input
          type="search"
          id="default-search"
          placeholder="Search for the right one..."
          className={isLarge ? "text-3xl" : ""}
          required
          value={value}
          onChange={onChange}
          {...restProps}
        />
      </div>
    </div>
  );
}

例如在 Antd 团队的 ProComponent 就在代码里大量的使用量里氏替换原则,在保持 Antd 基础组件库功能不变的前提下,提供更高程度的抽象,更上层的设计规范,他们都会有如上非常明显的特征,分别为:

  1. 继承 TS 类型
  2. 拓展基础功能
  3. 保持原有属性传递

关于里氏替换的使用没有特别需要注意的地方,这是一个非常容易遵守的原则,如果你细心一点你会发现这种思想和装饰器模式有非常类似的特点,都是在不破坏基础组件功能的前提下为其增强能力

4.4 I - Interface Segregation 接口隔离

接口隔离:在设计接口时不应该提供其不需要的方法或功能。因为接口越大,其适配性就越小,可替换的成本也越大

比如下面这个案例,开发一个购物车中的列表产品,组件内需要展示产品的信息以及 Thumbnail 缩略图,此时虽然 Thumbnail 只应用到了产品的图片属性,但提供了一个更大范围的接口类型

export interface IProduct {
  id: string;
  title: string;
  price: number;
  rating: { rate: number };
  image: string;
}

interface IProductProps {
  product: IProduct;
}

interface IThumbnailProps {
  product: IProduct;
  // imageUrl: string;
}

export function Thumbnail(props: IThumbnailProps) {
  const { product } = props;
  // const { imageUrl } = props;

  return <img className="p-8 rounded-t-lg h-48" src={product.image} alt="product image" />;
}

export function Bad(props: IProductProps) {
  const { product } = props;
  const { id, title, price, rating, image } = product;

  return (
    <div>
      <a href="#">
        <Thumbnail product={product} />
      </a>
      <div className="flex flex-col px-5 pb-5">{/* other props render */}</div>
    </div>
  );
}

这样做的后果就是 Thumbnail 和 IProduct 进行了强绑定,组件不可复用至其它地方,也对组件的消费者也进行了限制。我们更新下 Thumbnail 的接口只其只提供所需属性,即可快速解决上述问题

interface IThumbnailProps {
 //  product: IProduct;
  imageUrl: string;
}

export function Thumbnail(props: IThumbnailProps) {
  const { imageUrl } = props;
  // const { imageUrl } = props;

  return <img className="p-8 rounded-t-lg h-48" src={imageUrl} alt="product image" />;
}

接口隔离原则这个编码技巧出现的时机非常早,在组件设计之前一般我们就会先设计好其接口类型,减少不必要的 Props 不仅能让你的组件适配性更强,同时也能减少因为多余 Props 变更所带来的性能负担

4.5 D - Dependency Inversion 依赖倒置

依赖导置:又叫依赖反转原则,指的是一种解耦的方式,该原则主张高层次模块不应依赖于低层次模块的具体实现,而应依赖于其抽象接口。这样,低层次模块的具体实现可以独立变化,而不会影响高层次模块。在 React 中,高层次指的是组件的消费方,也可以理解为外层组件,低层次则是组件提供者,为内层被包裹的组件

简而言之就是在通用组件设计之时不应该在其内部捆绑其特定方法/函数的实现方式,而是将这种具体实现暴露给组件消费者,这样的目的也是为了的适配性变得更好

比如当我们设计一个登录的表单时,可能会写出如下代码,此时我们将提交表单的函数维护在表单组件内部。其登录url 以及 fetch 都已经强耦合于组件内部,只能适用单一场景

export function Bad() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    const request = new Request('https://localhost:3000/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });
    await fetch(request);
  };

  return (
    <section>
      <form onSubmit={handleSubmit}>
        <div>
          <h1>Sign in to your account</h1>
          <label htmlFor="email">Your email</label>
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input
            type="password"
            name="password"
            value={password}
            onChange={e => setPassword(e.target.value)}
            id="password"
            placeholder="••••••••"
          />
        </div>
      </form>
    </section>
  );
}

当你想把这部分提交表单的内容控制权给外部组件时,你只需要对外提供 props 方法即可

interface IFormProps {
  onSubmit: (email: string, password: string) => Promise<any>;
}
export function Good(props: IFormProps) {
  const { onSubmit } = props;
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    onSubmit(email, password);
  };

  return (
    <section>
      <form onSubmit={handleSubmit}>
        <div>
          <h1>Sign in to your account</h1>
          <label htmlFor="email">Your email</label>
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input
            type="password"
            name="password"
            value={password}
            onChange={e => setPassword(e.target.value)}
            id="password"
            placeholder="••••••••"
          />
        </div>
      </form>
    </section>
  );
}

当然如果你的登录表单在大多数场景下就是只需要进行强绑定,那你就可以用到我们上述提到的里氏替换原则,来增强其功能。就此你实现了和第一个版本一样好用的组件,但你的登录表单组件的适配性变得更强了

type IConnectedFormProps = Omit<IFormProps, 'onSubmit'>;

export function ConnectedForm(props: IConnectedFormProps) {
  const { onSubmit, ...restProps } = props;

  const handleSubmit = async (email: string, password: string) => {
    const request = new Request('https://localhost:3000/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });
    await fetch(request);
  };
  
  return <Form onSubmit={handleSubmit} {...restProps} />;
}

对于一个组件其内部调用的方法是否使用里氏替换原则,取决于开发者在设计之时认为这个方法未来是否有可能会存在变更。并不能完全将其奉为执行准则将所有方法都暴露对外,这样很可能你的代码量反而会徒增不少

参考

最后打一个小广告,诚挚邀请各位加入

我们的团队:我们是负责TT机器审核业务的管理端技术团队,团队包括前后端研发,所负责业务和团队均处于快速成长期。

我们的业务: 机器审核当前依托统一审核平台项目,建设复杂的特征、策略及策略图管理能力,用户场景复杂、底层运行域状态多样,对技术架构、分层设计、交互标准的持续优化、工具打磨有强诉求。

我们能提供的机会

  • 于资深的前端或后端的你: 有机会负责包括前后端技术的业务方向;同时有机会负责整体前端/后端的技术架构及演进方向。
  • 于初阶或中阶的你: 因业务场景复杂,前后端均有复杂业务场景的架构设计、技术演进和持续优化的需要;部分可以开始或深入全栈研发。
  • 其他的机会: 部分跨地域、跨语言合作机会,提供良好的英语学习锻炼机会;有去海外工区现场沟通的机会,体验不同地域的工作文化。

如果你希望做前端技术架构工作,我们有依托内容安全业务的WebArch方向前端工程师机会~ 欢迎有冲劲有意愿的你加入~