字节跳动幸福里团队【校招/社招/实习】同步进行中,【前端/后端/客户端/测试/数仓/算法/产品/运营】均海量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>
仅从功能上来看上面代码确实能实现,但这里就有几个潜在的问题:
v-for
和v-if
一起使用时有一定性能风险,具体的可以自行百度
{{ item.value }}
是消费数据的代码,而v-if="item.show"
本质上是加工数据的代码,加工数据的代码理论上不应该出现在UI的代码中,我们更希望UI层作为数据消费者,拿到的数据是直接可用的。
- 扩展性不好,假设现在数据太多,需要前端分页或是实现虚拟列表,上述代码的模式就需要大改。
参考答案:
<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(/*....*/);
});
}
代码本身确实是能跑起来的,但它的问题非常明显:
- 阅读体验不够好,create函数包含了太多的内容,假如调试过程中突然哪一步报错了,很难快速定位到具体的代码段,可能需要逐行console.log来查看到底哪里出了问题,效率会很低。
- 扩展性也不够好,假如后续要求增加校验项G,把后端校验项D改成H,开发者就需要大量修改原有代码。
- 代码分层不清晰,为了方便调用,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
函数。