浅谈如何写出高X格的代码

2,789 阅读11分钟

字节跳动幸福里团队【校招/社招/实习】同步进行中,【前端/后端/客户端/测试/数仓/算法/产品/运营】均海量HC,等你一起来搞事情。

幸福里是字节跳动旗下集内容、社区、工具于一体的房产信息、服务、交易平台。产品基于个性化推荐引擎向用户推荐优质的房产内容和全面、真实的房源信息,致力于为用户提供全面、专业、可靠的购房决策支持。幸福里始于2018年8月,是国内发展最快的,集内容、社区、工具于一体的房产信息与服务平台,业务覆盖一二线共23城,现累积注册用户千万,目前进入高速增长期。


代码篇

当你想写if...else...的时候,或许更需要三目运算符
// if...else...
if(someCondition){
   return a;
}else{
   return b;
}

//三目运算符
return someCondition ? a : b;
当if不需要处理else的时候,或许更需要短路符
// 条件语句
if(conditionA){
   doSomething();
}

// 短路符
conditionA && doSomething();
当if的判断条件比较多时,或许更需要用Array.some | Array.every |Array.includes
if(a === 1 || a === 2 || a ===3 || a ===4){
   //....
}

//更容易读的写法
if([1,2,3,4].includes(a)){
  //....
}

//多条件语句
if(condition1 && condition2 && condition3){
   //...
}

//更易读的写法
const conditions = [condition1, condition2, condition3];
if(conditions.every(c=>c)){
   //...
}

// 多条件或
if(condition1 || condition2 || condition3){
   //...
}

// 更易读的写法
const conditions = [condition1, condition2, condition3];
if(conditions.some(c=>c)){
   //...
}
当你想写for循环时,或许更需要调用数组的原生方法,比如map,reduce等,关注你想要的数据结果,而不是for循环里面的处理过程
// for循环
for(let i = 0; i < data.length; i++){
   //......
}

// 如果目的是数组映射,即函数执行前后数组长度不变
const result = data.map(item=>{
    return newItem
})

// 如果目的是数据聚合,即函数执行后结果只有一个值,初值和结果类型保持统一即可(空数组,空对象,0等等都可以)
const result = data.reduce((prev, cur)=>{
    //....
    return newResult;
},{})
当你想写switch...case时,或许更需要字典模式
// switch...case
switch(a){
  case 1:
     b = 60;
     break;
  case 2:
     b = 70;
     break;
  case 3:
     b = 80;
  default:
     b = 90;
}

// 字典模式
const valueMap = {
   1:60,
   2:70,
   3:80
}
const result = valueMap[a] ?? 90;
当你想用lodash提供的_.isArray校验数组时,直接用Array.isArray就可以了
当你想手动处理日期时,或许更需要读Dayjs的文档
当你想把一段复杂逻辑分成多个同步步骤去执行时,或许更需要【数据+纯函数】的FP模式
// 多步骤函数
function doSomething(data){
   //......
   return step1(data);
}

function step1(data){
   //...
   return step2(data);
}

function step2(data){
    //...
    return step3(data)
}

function step3(data){
    //...
}

// 更容易阅读的写法
function doSomething(data){
   const data1 = step1(data);
   const data2 = step2(data1);
   const data3 = step3(data2);
}

// 更容易阅读的写法
function doSomething(data){
   [step3, step2, step1].reduceRight((prev,cur)=>cur(prev),data);
}

// 更容易阅读的写法(数据和逻辑分离)
const compose = (...args)=>data=>args.reduceRight((prev, cur)=>cur(prev),data);
const doSomething = compose([step3, step2, step1]);
当一段复杂逻辑中有多个异步函数时,或许更需要async/await或者串联promise
// 多步骤函数
function doSomething(data){
   step1(data).then(resp=>{
      //....resp判断逻辑
      step2(resp.data).then(resp=>{
          //...resp判断逻辑
          step3(resp.data).then(resp=>{
              //...resp判断逻辑
              //......
          });
      })
   })
}

// 更容易阅读的写法
async function doSomething(data){
   try{
      //...
      const resp = await step1(data);
      const resp1 = await step2(resp.data);
      const resp2 = await step3(resp1.data);
      return resp2.data;
   }catch(err=>{
      console.log(err);
   })
}

// 更容易阅读的写法
function doSomething(data){
   step1(data)
   .then(resp=>step2(resp.data))
   .then(resp=>step3(resp.data))
   .catch(err=>{
      console.log(err);
   });
}
当你想从对象中删除一个属性时,请使用解构赋值和扩展运算符
// 删除属性
function deleteA(obj){
  delete obj.A
  return obj;
}

// 使用解构赋值
const deleteA = ({A, ...rest} = {}) => rest;
当你想在发请求之前校验或是重组一下请求参数时,应该单独写一个函数

发送请求前需要将数据加工为接口要求的格式时,转换函数名后缀为atob

获取到后端响应数据后需要二次加工才能使用时,转换函数名后缀为btoa

// 数据需要二次加工
const createInfo = (data) =>{
   //....一大堆重新加工data的操作
   someService.create(newData);
}

// 单独写重组函数(重组函数通常也被称为transformer函数,一般都为纯函数)
import { someServiceCreate_atob, someServiceCreate_btoa } from 'transformer.ts'
const createInfo = async (data) =>{
   //....一大堆重新加工data的操作
   await response = someServiceCreate_btoa(someService.create(someServiceCreate_atob(data)));
}
当部分场景你不想写代码块,想一行搞定时,或许需要扩展运算符和逗号表达式
//常规写法
const result = data.reduce((prev, cur)=>{
    prev[cur.key] = cur.value;
    return prev;
},{});

//使用逗号表达式
const result = data.reduce((prev, {key, value})=>(prev[key] = value, prev),{});
当你想要引用外层作用域的变量时,或许更需要返回新的对象,这样可以避免污染源数据。
// 不推荐的写法
const out = {
  name: 'Tony Stark'
}
const handleChange = ()=>{
   out.age = 56;
}

// 推荐的写法
const handleChange = (info = {})=>{
  return {
     ...info,
     age:56
  }
}
const result = handleChange(out);

结构篇

总原则:UI层代码尽可能少,逻辑层代码尽可能只声明主要业务逻辑动作,其他细节代码均在外部定义。

MVC结构——UI和状态分离

无论是MVC还是MVVM,其实都是希望通过代码结构层面的约束来达到分层和解耦的目的,Vue是天然的“UI-样式-行为”分离的,写起来问题不大;React提倡用JSX编写一切,导致很多新人忽略了解耦的必要性,所有代码都直接{ }表达式一把梭,导致维护的时候本来正在看UI的问题,然后突然塞进来几十行业务逻辑,看业务逻辑时突然塞进来几十行数据结构重组的代码,这种代码写法的维护成本很高。

参考答案:

**代码分4层:UI层,状态层,服务层,辅助层,写代码时多想想自己写的代码属于哪个类型。

  • 在UI层(index.tsx)尽量编写声明式代码,声明UI依赖于store里的哪个状态属性,声明交互动作应该触发store里的哪个方法。
  • 状态层(store.tsx)定义页面需要记录的状态,定义交互动作应该触发的方法,编写方法体时调用服务层代码来发送请求,调用辅助层代码来加工数据或是校验数据。
  • 服务层(service.tsx)聚合业务模型方法,从后台取数。
  • 辅助层(helper.tsx/transformer.tsx等等)定义用于改变数据结构的纯函数及其他不属于前三类的代码。

生产和消费分离

如果现在要求从后台获取数据,然后经过一些加工后再渲染出来,很多新手会把代码写成下面的样子:

<template>
   <div v-for="item in items" v-if="item.show">
       <span>{{ item.value }}</span>
   </div>
</template>

<script>
  export default {
     name:'dealPage',
     data(){
        return {
           items:[]
        }
     },
     mounted:function(){
        this.getList();
     },
     methods:{
        getList:function(){
            fetch('/getlist').then(data=>{
               this.items = data.items;
            });
        }
     }
  }
</script>

仅从功能上来看上面代码确实能实现,但这里就有几个潜在的问题:

  1. v-forv-if一起使用时有一定性能风险,具体的可以自行百度
  1. {{ item.value }}是消费数据的代码,而v-if="item.show"本质上是加工数据的代码,加工数据的代码理论上不应该出现在UI的代码中,我们更希望UI层作为数据消费者,拿到的数据是直接可用的。
  1. 扩展性不好,假设现在数据太多,需要前端分页或是实现虚拟列表,上述代码的模式就需要大改。

参考答案:

<template>
   <div v-for="item in displayItems">{{ item.value }}</div>
</template>

<script>
  export default {
     name:'dealPage',
     data(){
        return {
           items:[]
        }
     },
     computed:{
      displayItems:function(){
         return this.items.filter(item=>item.show);
      }
     },
     mounted:function(){
        this.getList();
     },
     methods:{
        getList:function(){
            fetch('/getlist').then(data=>{
               this.items = data.items;
            });
        }
     }
  }
</script>

数据加工的代码转移到computed属性后,扩展性会增加,后续无论逻辑多复杂,随便倒腾,只要保证把最终结果return出去就好,当然数据加工的代码如果太复杂的话还是放在单独的文件中更好,这样可以保持顶层的UI和业务逻辑代码只包含核心逻辑,更加清爽,维护性起来也更容易。React中也是类似的道理,尤其是在使用 { } 来编写表达式时,尽量不要混入数据加工的代码,而只用它来描述UI。 希望你养成“先生产后消费” 的意识,先加工数据,再绑定给UI;先定义公共组件和公共方法,再在自己的代码中调用,当然,新增的公共代码在群里通知到其他人也是非常必要的。

中间件模式

中间件模式非常实用,感兴趣的可以研究express,koa2或redux源码的中间件执行器部分。

比如业务逻辑中需要创建一条交易记录,但实际的产品需求可能非常繁琐,前端需要针对ABC这3个字段进行合法性校验,发送请求时要先调用DEF这三个接口进行前置校验,都通过后,才能调用create接口创建交易记录,新人的代码很可能会写成下面的样子:

create(formData){
   // 检查A参数
   if(CheckA(formData)){
      message.error(/*.....*/);
      return;
   }
   
   // 检查B参数
   if(checkB(formData)){
      message.error(/*.....*/);
      return;
   }
   
   // 检查C参数
   if(checkC(formData)){
      message.error(/*.....*/);
      return;
   }
   
   // 拼接参数
   const requestParams = {
     //......
   }
   
   // 服务端校验D
   DealService.D(requestParams).then(resp=>{
       if(resp.status){
          this.E(resp.data);
       }
   }).catch(err=>{
       message.error(/*....*/);
   });
},
// 服务端校验E
E(data){
   DealService.E(data).then(resp=>{
    if(resp.status){
          this.F(resp.data);
       }
   }).catch(err=>{
       message.error(/*....*/);
   });
}

// 服务端校验F
F(data){
   DealService.F(data).then(resp=>{
    if(resp.status){
          this.realCreate(resp.data);
       }
   }).catch(err=>{
       message.error(/*....*/);
   });
}

// 校验后创建交易记录
realCreate(){
   DealService.create(data).then(resp=>{
      if(resp.status){
         this.data = resp.data;
      }
   }).catch(err=>{
       message.error(/*....*/);
   });
}

代码本身确实是能跑起来的,但它的问题非常明显:

  1. 阅读体验不够好,create函数包含了太多的内容,假如调试过程中突然哪一步报错了,很难快速定位到具体的代码段,可能需要逐行console.log来查看到底哪里出了问题,效率会很低。
  1. 扩展性也不够好,假如后续要求增加校验项G,把后端校验项D改成H,开发者就需要大量修改原有代码。
  1. 代码分层不清晰,为了方便调用,DEF这三个前置校验方法也定义在store里,但实际上它们并不需要和UI层有交互,现在的写法会干扰对主逻辑的理解,读代码时连续跳3~4个异步方法后,你可能已经不记得为啥要调这个方法了,服务层代码的细节不应该在状态层代码中展开。

参考答案:

  • 把校验函数的定义和校验函数的调用分开(校验函数的执行可以跟form的提交方法结合起来),这样后续方便
  • 把异步方法的定义和异步方法的调用分开
  • 把异步方法的编排和异步方法的调用分开(需要一个异步执行器,参考express或koa2中间件执行器的实现即可)
// validators.tsx中定义校验器
function checkA(data={}){
  //...校验A属性
  if(/*通过校验*/){
     return null;
  }
  return '这里是A的错误提示信息'
}

function checkB(data={}){
  //...校验B属性
  if(/*通过校验*/){
     return null;
  }
  return '这里是B的错误提示信息';
}

function checkC(data={}){
  //...校验C属性
  if(/*通过校验*/){
     return null;
  }
  return '这里是C的错误提示信息';
}

export const validators = [checkA,checkB,checkC];
// transformer.tsx中定义数据结构转换函数
export const D_atob = (data)=>{
   const requestParams = {
     //....
   }
   return requestParams;
}

export const E_atob = (data) => {
   const requestParams = {
     //....
   }
   return requestParams;
}
// store.tsx 引用其他模块的工具函数
import { validators } from './validators.tsx';
import { E_atob, D_atob } from './transformers.tsx';

//...
async create(formData){
   // 同步校验逻辑
   for(const validator of validators){
       const flag = validator(formData);
       if(flag){
          message.error(flag);
          return;
       }
   }
   
   // 服务端校验并提交
   try{
       const Dresponse = await DealService.D(D_atob(formData));
       const Eresponse = await DealService.E(E_atob(Dresponse));
       const Fresponse = await DealService.F(Eresponse);
       const result = await DealService.create(Fresponse);
   }catch(err=>{
      message.error(err?.message || JSON.stringify(err));
   });
}
//....

如果定义一个compose函数,接收一个方法数组,使得无论同步异步都可按顺序执行(同步函数出错或校验失败时可以抛出错误,由外层来捕获处理),那么服务方法的编排和执行也可以分开(函数式编程的管道函数风格一般为从右向左,因为调用时传参在最右边,视觉上更符合直觉),这样store层就只需要声明一个createDeal方法,具体的执行步骤和细节都可以在其他地方定义:

const queue = compose([DealService.create, 
                       DealService.F,
                       DealService.E,
                       E_atob,
                       DealService.D, 
                       D_atob]);
try{
   queue();
}catch(err=>{
  message.error(err?.message || JSON.stringify(err));
});

再进一步,既然compose函数都无视同步异步了,那自定义校验逻辑也直接编排在一起就可以了(编排的好处在于复杂逻辑更容易扩展和单测):

// 服务端校验并提交
const queue = compose([DealService.create, 
                       DealService.F,
                       DealService.E,
                       E_atob,
                       DealService.D, 
                       D_atob,
                       ...validators]);
try{
   queue();
}catch(err=>{
  message.error(err?.message || JSON.stringify(err));
});

发布订阅模式

先上代码:

function doSomething(type){
    switch (type){
        case 1:
        //.....
        break;
        case 2:
        //....
        break;
        default:
        //....
    }
}

当UI中存在比较复杂的交互逻辑时,doSomething函数的代码量通常会增长地很快且难以维护,每当有新的分支时,就需要修改doSomething,这样与“开放封闭原则”不符。

参考答案

/* 使用发布订阅模式来拆分代码 */

// 定义指定事件的回调
eventEmitter.on('show-modal1', function(data){
   //data即为trigger方法调用时传入的data
})

eventEmitter.on('show-modal2', function(data){
   //....
})

// 触发事件
function doSomething(type,data){
   eventEmitter.trigger(type,data);
}
//

使用发布订阅模式后,trigger方法负责生产和传入事件相关的数据,on方法负责描述如何使用这些数据,代码并不一定要揉在一起,只要eventEmitter使用的是同一个单例即可,每当有新增逻辑时,直接调用on方法进行回调注册即可,不需要再修改doSomething函数。