作为一个程序员,每天上班最痛苦的事情不是写 shit,而是改自己或者别人写的 shit
为了世界和平,为了能让老板能够暴富,希望大家能够好好写代码,不要为难你的同事,前端人不坑前端人
前言
为什么会写出恶心的代码
人们总会给自己写的烂代码找理由:
- 网上就这么说(不理解、不在乎,反正看起来是可以的)
- 跑起来没问题(反正我也不知道有更好的方法)
- 这样写很方便(虽然我知道后续不方便改动,不过不重要)
- 项目工期紧迫(需求老是改,还想我咋滴)
你会发现一个很有意思的事情,就是追本溯源还是自身的问题,能力不达标、经验不够导致最开始写出的代码就就是一个妥协的产物,是对自己能力的妥协,出来的代码缺少健壮性和可维护性,无法应对需求的变化,工期越紧就越瞎写,越瞎写就越改不动,进入恶心循环。
其实问题无非分成三种:
- 能力问题,不知道怎样写(更好)
- 经验问题,不知道这样写会有什么后果
- 态度问题,反正能跑就行
我不相信人与人之间有所谓的智力差距,只要态度端正,前面两点我相信都不是问题。
下文用于抛砖引玉,要是您不愿意去思考,非要死揪着某个点,可能下面讲的东西不适合阁下~
同事期望看到的是什么
“牛逼”的代码往往有几点共通性:
- 筛选人才。高级的代码理解起来就像在做面试官出的题一样,如果你不明白 1+1 为什么要写成 (1+100)/2+0.5*2-1-97*0.5 ,说明你没有资格看我的项目!
- 客户第一。快速迭代,主要是能跑起来,当新需求出现时,则指导需求方放弃改动!
- 保持惊喜。出现 bug 仅仅只是一个美丽的巧合,能让同事们在加班的同时感受打地鼠的乐趣!
而正常的代码往往都是:
- 看得懂(别人能不能很容易理解你当时的思路)
- 改得动(别人是否可以很有把握地改你的代码)
对应的就是可读性和可维护性,这在后面会多次提到
多多换位思考下,如果是你要接手别人的代码时,期望看到的是什么样子的
作为你的同事,这是他对你的代码的主要期望,而后面讲那么多方式方法也是为了落实这两个目标
目录
这里选了几个具体开发中遇到的问题来讲一下:
- 优雅地操作数据
- 避免“副作用”的出现
- 写出清晰的代码
优雅地操作数据
反面例子
const list1 = [];
const list2 = [];
function handleList(list: Array<any>): Array<any> {}
function fnBad(type: string, origin: Array<any>) {
let temp = [];
const result = [];
if (type === "a") {
temp = handleList(list1);
temp.forEach((v) => {
origin.forEach((item) => {
if (v.code === item.code) {
result.push(v);
}
});
});
} else {
temp = handleList(list2);
temp.forEach((v) => {
origin.forEach((item) => {
if (v.code === item.code) {
result.push(v);
}
});
});
}
return result;
}
分析讲解
可以看到,上面的代码问题主要有两个:
- 非线性,逻辑是非线性导致阅读起来也是非线性的,在 75 到 93 行中不断回过头去操作
temp
和result
两个数组 - 差异不明确,可以看到代码中基本都是重复内容,而且乍一看是很难发现
if
和else
中的片段到底差异在哪
如果深究起来,还不止这些问题,包括声明无意义变量、api 使用不当等等,但是这些都不是主要问题,我们只从同事体验的角度去分析
船新版本
请自行去了解 js 的内置方法,如果连基础都没有,就只能继续和泥巴了
reduce
,汇总concat
,拼接filter
,筛选
function fnGood(type: string, origin: Array<any>) {
return (
handleList(
// 三目运算符明确指示出唯一的差异
type === "a" ? list1 : list2
)
// 汇总
.reduce(
(total, current) =>
// 拼接筛选后的项
total.concat(
// 筛选符合的项
origin.filter((item) => item.code === current.code)
),
[]
)
);
}
再配合 typescript 把类型都写明白了后,不管谁去调用还是修改你写的这个
fnGood
方法都会感到痛快其实实际场景中,这种封装的所谓“公共”方法常常只会被一个地方调用,而且使用场景高度特化,连另外声明一个方法都不一定需要~
课外思考
如何在不声明第三个变量的情况下,将两个变量的值进行交换
避免“副作用”的出现
这里的副作用指的是:当一个函数、类、组件、文件在被引用或者调用时,会伴随触发一些意外逻辑或者内部隐式依赖于外部
避免副作用函数
不写具有副作用的代码或者写了但明确表明副作用
封装方法时要明确本方法的用途:
- 用于转化数据,不修改原数据,返回一个新数据
- 用于修改数据,修改原数据
实际使用中应该尽量将公共方法封装成纯函数
下面展示一段有隐患的代码:
const myFn = (buttons) => {
buttons.map((button) => {
// 省略 n 行代码
if (button.code === -1) {
// 省略 n 行代码
button.code = 0;
}
// 省略 n 行代码
});
return buttons;
};
const newButtons = myFn(oldButtons);
console.log(newButtons);
它最大的问题是直接修改了传入的(引用类型)参数,并且还煞有其事的 return 一份数据出来!!给使用者造成了严重误导,谁用了这个方法后,原数据已经被悄悄修改
实际情况中,这种函数绝对不会就那么几行代码,而这致命的一个操作就隐藏其中
当别人接手这个项目的时候发现用了这个方法后数据会有问题,但是又查不到这行代码上,然后竟然在调用这个函数之前先深拷贝一遍数据,彻底把项目搞烂,啥叫卧龙凤雏。。。
下面提出三种优化思路,各有优缺点,请辩证看待,并因地制宜地使用
优化思路-明确副作用
最简单直接的方法就是,把返回值去掉,明确本函数的用于修改数据(有副作用的)的:
const myFn = (buttons) => {
buttons.map((button) => {
if (button.code === -1) {
button.code = 0;
}
});
};
myFn(oldButtons);
console.log(oldButtons);
但是前面也说了,还是建议尽量避免函数是有副作用的,而且其实这样还是不够明确
优化思路-复制数据
函数内不直接修改原数据,而是开辟一个新的存储空间,在函数内完成数据的复制:
const myFn = (buttons: Array<{ code: number; text: string }>) => {
return buttons.map((button) => {
return {
...button,
code: button.code === -1 ? 0 : button.code,
};
});
};
const newButtons = myFn(oldButtons);
console.log(newButtons);
具体函数内部如何处理数据,要看入参数据的结构
请明确你真的需要复制一份数据~
优化思路-面向对象
改为调用“对象”中的 set 方法来修改数据,而非直接使用赋值语句,凸显“操作数据”这一意图:
class Button {
public readonly code: number;
public readonly text: string;
public setCode(code) {
this.code = code;
}
}
const myFn = (buttons: Array<Button>) => {
buttons.map((button) => {
if (button.code === -1) {
button.setCode(0);
}
});
};
myFn(oldButtons);
console.log(oldButtons);
这个案例是为了给你提供一种思路,实际使用中一般不会这样写,因为太“隆重”了,而是更多会把涉及到 code 的操作(包括循环那部分代码)一律封装成 Button 类的一个方法
随意修改原型链
一个存放公共工具方法的文件中,有一段代码是直接往数组的原型上加了一个方法。
// utils.js
export const myFn1 = function () {};
Array.prototype.test = function () {};
export const myFn2 = function () {};
export const myFn3 = function () {};
这种代码就是想搞死你的同事,鬼知道你在这里写了个方法,这个 test 方法能不能成功使用取决于这个 utils.js 有没有曾经被引入过,这就给这个项目添加了一丝玄幻色彩了~
就写一个 function 然后 export 就好了呀,你在干什么。。。
这种方式仅允许在 ployfill 时使用
写出清晰的代码
- 搞清楚你自己想干嘛
- 让别人明白你想干嘛
逻辑断点
突出会导致逻辑提前退出的情况
让别人一看就知道“如果发生 XX 情况,就直接退出了,不用看后面了”
function bad(arr) {
let result = [];
if (arr) {
// 省略 n 行代码
return result;
}
return result;
}
function good(arr) {
if (!arr) {
return [];
}
let result = [];
// 省略 n 行代码
return result;
}
嵌套调用
纯函数加上完整的 ts 定义能够明确调用过程:
// 搅拌
function stir(meat: Array<any>): string {}
// 灌装
function filling(slurry: ReturnType<typeof stir>): Array<any> {}
// 打包
function packing(bottles: ReturnType<typeof filling>): Array<object> {}
packing(filling(stir(["猪肉", "猪肉", "猪肉", "羊肉"])));
你可以想象成一个流水线一样,每个模块只管输入和输出~
这里是还用了
ReturnType
和typeof
来定义后一个方法的输入为前一个方法的输出
逻辑线性
我们当然希望看到的代码是从上到下一行行看下来像流水账一样,逻辑的执行顺序是符合直觉的、不跳跃的
这里举一个比较常见的“弹窗输入用户名”例子,交互如下:
- 点击“开始”按钮
- 显示弹窗,弹窗内有表单组件
- 点击弹窗的“提交”按钮
- 得到弹窗内
令人讨厌的写法
把一个本来的串联的逻辑硬是分成
- start
- change
- submit
而且几个方法是没有体现串联关系的
class StupidApp extends React.Component {
state = {
visible: false,
username: "",
};
handleStart = () => {
this.setState({ visible: true });
};
handleChange = (e: any) => {
this.setState({
username: e.target.value,
});
};
handleSubmit = () => {
console.log(`获取到数据:${this.state.username}`);
this.setState({
visible: false,
});
};
render() {
const { visible, username } = this.state;
return (
<>
<button onClick={this.handleStart}>开始</button>
<div
style={{
visibility: visible ? "visible" : "hidden",
}}
>
<input value={username} onChange={this.handleChange} />
<button onClick={this.handleSubmit}>提交</button>
</div>
</>
);
}
}
便于正常人看的写法
把 start->change->submit 一个流程封装成一个黑盒
外部只管把它当成异步方法来调用即可
class NormalEditor extends React.Component {
state = {
visible: false,
};
private username: string = "";
private resolve?: (username: string) => void;
public show = () => {
return new Promise<string>((resolve) => {
this.resolve = resolve;
this.setState({
visible: true,
});
});
};
private handleSubmit = () => {
this.resolve!(this.username);
this.setState({
visible: false,
});
};
render() {
return (
<div
style={{
visibility: this.state.visible ? "visible" : "hidden",
}}
>
<input onChange={(e) => (this.username = e.target.value)} />
<button onClick={this.handleSubmit}>提交</button>
</div>
);
}
}
class NormalApp extends React.Component {
private refEditor = React.createRef<NormalEditor>();
handleStart = async () => {
/**
* 在哪里触发就在哪里返回,外部使用者只需要知道它是异步的,并且会返回数据即可
* 我不管里面发生了啥,反正我只知道 show 是一个异步方法,会返回一个 username 即可
**/
const username = await this.refEditor.current!.show();
console.log(`获取到数据:${username}`);
};
render() {
return (
<>
<button onClick={this.handleStart}>开始</button>
<NormalEditor ref={this.refEditor} />
</>
);
}
}
其实这里是用到了 react 的
class component
和ref
的知识,其实同样的场景还能用hook
,甚至封装成不定表单项的钩子(内部渲染哪些表单项由调用时传参决定)具体形式不定,这里主要是一个例子,希望大家能举一反三
其实即使不用 async/await 方式,用 callback 方式也要比原来写法强
精简属性
经常发现一个类中存在大量的属性(包括 Vue、React 组件),这些属性往往是不必要的
例子一
interface Item {
id: string;
age: number;
name: string;
}
// 写法一
class Stupid {
public list: Array<Item> = [];
public selectedItem?: Item;
public selectedId?: string;
public handleSelect(id: string) {
this.selectedId = id;
this.selectedItem = this.list.find((item) => item.id === id);
}
}
// 写法二
class Normal {
public list: Array<Item> = [];
public selectedId?: string;
get selectedItem() {
return this.list.find((item) => item.id === this.selectedId);
}
public handleSelect(id: string) {
this.selectedId = id;
}
}
selectedItem 是不需要的,他可以通过 selectedId 推导出来,没必要维护两个属性去代表同一个东西
例子二
class Task {
private temp: number;
private age: number;
private fnA(type: string) {
this.temp = type === "big" ? 100 : 1;
}
private fnB() {
this.temp = this.temp + 1;
}
private fnC() {
this.temp = this.temp + 2;
return this.temp;
}
public start() {
this.fnA();
this.fnB();
this.age = this.fnC();
console.log(this.age);
}
}
fnA、fnB、fnC 三个方法都是有副作用的,代码一多,根本没办法捋清楚
需要思考以下几个问题:
- fnA、fnB、fnC 中的内容是否跟 Task 是无法解耦的呢?
- 三个方法之间这样划分之后有什么意思?他们互相解耦了么?还是只是单纯地“劈”开代码而已
- 这个 temp 和 age 属性存在的意义是什么?只是中间数据么?是必要的么?为什么存在?
拒绝没有意义的封装
经常看到别人的项目里面动辄十几个文件,里面全写的是这些东西,到底有什么呢??
转发一下接口吗:
/**
* 获取审批流程列表
* @param {*} params
*/
export default async function request(params) {
return axios.post("/api/test/something", params);
}
煞有其事地封装成一个方法集吗:
const ERROR_MSG = "请求失败";
const prefix =
process.env.NODE_ENV === "production"
? "http://localhost:3001"
: "http://baidu.com";
export default {
add: async (params: IAddSelectorParams) => {
const res = await axios.post(`${prefix}/api/selectors`, params, {
headers: {
"content-type": "application/json",
},
});
if (!res || res.status !== 200) return message.error(ERROR_MSG);
return res.data;
},
update: function () {
// ....
},
delete: function () {
// ...
},
};
自己思考下面的问题吧~
- 效率
- 让第一个开发者开发地更加快速?方便?
- 复用
- 这些封装后的方法真的可以复用的可能吗?
- 实际情况下,这些封装后的方法真的会被复用吗?真的需要被复用吗?
- 维护
- 让后面的开发者修改起来更加快、更加有把握啦?
- 哪怕后端跟你说其中一个接口仅仅只是改了参数的名称,你敢直接改这个方法吗?
- 实际最常见的情况是什么?是后端会用新接口去替代旧接口,和给就接口新增传参。这样封装写有助于这种情况了?
- 美观度
- 这样写满足你对于优美代码的强迫症了么?
- 其他
- 这样写性能会更好了还是怎样?
- 关键封装的方法都是 anyscript,不明确参数和返回值类型,且不说接口不接口了,本身作为一个方法就不合格
即使有 n 个不同的后端服务,也只要封装 n 个不同的请求方法就行了啊(里面的 interceptor 不一样),干嘛要对每个接口的包一遍
再有即使有些后端能力不行,导致同一个服务有些个别的接口返回格式不一样,那你就应该让你的请求方法可以接收一些参数来特殊处理这些情况,这样可以突出“这个接口是跟别的不一样的,有问题的,但是后端没改的,前端先这样处理”,让你项目的继任者能 get 到你的处境
不要没有理由地去分块
很多人会遇到一个情况,就是“我代码太多太长了,几千行呢,我得把它分成好几部份”
/**
* 不分块
**/
function schedule1() {
brushing(); // 刷洗
breakfast(); // 早饭
commuting(); // 通勤
working(); // 敲代码
lunch(); // 午饭
sleeping(); // 睡觉
coffee(); // 下午茶
dinner(); // 晚饭
}
/**
* 分块
**/
function morning() {
brushing(); // 刷洗
breakfast(); // 早饭
commuting(); // 通勤
working(); // 敲代码
}
function noon() {
lunch(); // 午饭
sleeping(); // 睡觉
}
function afternoon() {
coffee(); // 下午茶
dinner(); // 晚饭
}
function schedule2() {
morning();
noon();
afternoon();
}
在上面的例子中,所谓的“分块”其实没有任何意义,因为本身刷牙、洗脸这些方法已经高度内聚,没必要再按时间在包一层,这同时多了一层,仅仅把代码“挪”了一个地方是不会让代码变得更加可读或者可维护的,你要考虑的是解耦和复用。
- 找目前和未来的异同点
- 确定逻辑的边界
- 分层次
- 尽量满足分形架构
但是如果不同的代码块之间相对独立甚至完全不存在依赖或顺序关系,彼此仅仅指通过一个“小窗口”进行“交流”的话,情况就好很多了。后续无论你对某一块进行修改甚至重建都不会影响其他代码块,只要保证“窗口”是一致的即可~
总结一下
上面举的例子和解决方案中的代码我认为是比较片面,没办法完整展示实际开发遇到的蛋疼场景,如果有疑问,也希望各位可以在评论区发一段有疑问的代码一起探讨下实际问题~