TS代码整洁之道——"净"
maxueming|2022-10
干”净“的代码,有利于项目代码维护、升级和迭代。最近读了Robert C. Martin 的 《Clean Code》一书,感触颇多,结合TS,聊聊TS的代码整洁之道——“净”
可能因为我们一直关注需求和模型,代码往往被忽略。虽然现在有很多低代码平台可以批量生产代码,但是代码层面的抽象和其呈现的细节是无法被忽视的。
我们可能都碰到过类的情况:项目初期迅速迭代,随着项目日渐复杂,维护迭代成本逐渐变大,对A处代码的修改都会影响C和D处代码,甚至你不知道会不会影响其他地方代码。随着混乱增加,团队的生产力下降,开发效率降低,开发成本回增高。
为了解决以上因“糟糕”代码带来的各种问题,在读完《Clean Code》后总结一下代码整洁之道,核心围绕“净”。
1.命名之道——"意净"
计算机科学只存在两个难题:缓存失效和命名。——Phil KarIton在代码中,好的命名和艺术一般美妙,在大型项目中,由于项目参与人数多,迭代时间长,好的命名规则可以防止项目命名系统混乱,提高开发效率。
变量名应该具备一下几个特性:
意义性:可以根据变量的本身作用和含义给变量取一个有明确具体含义的变量名称。
可读性:根据变量的意义和作用给变量起一个可以读的名称,方便沟通、理解、维护。
搜索性:在大量的代码中,变量的可搜索性非常重要,将一些代码抽象并命名为变量,有利于他人开发,提升开发效率。
自解释:取的变量名,可以自己解释自己本身的含义,让开发者一目了然,容易理解,避免使用无意义的名称。
合并性:功能重复的情况下,要精简成一个,其余都是冗余代码。
明确性:避免思维映射,不要让别人猜测或者想象你的变量含义。
无用上下文:如果类型名已经表达了明确的信息,那么,内部变量名中不要再重复表达。
默认参数:使用默认参数,而非短路或条件判断。
//1.意义性const yyyymmdd = moment().format("YYYY/MM/DD");// badconst currentDate = moment().format("YYYY/MM/DD");//good//2.可读性class Usr123 { //bad private asdf = '123'; // ... }class User { //good private id = '123'; // ...}// 3.搜索性setTimeout(restart, 86400000); // bad 86400000 代表什么?它是魔数!const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000;//good 声明为常量,要大写且有明确含义。setTimeout(restart, MILLISECONDS_IN_A_DAY);//4.自解释declare const users: Map<string, User>;for (const kv of users) { // bad kv是啥??? // ...}declare const users: Map<string, User>;// 变量名解析自身的含义for (const [id, user] of users) {//good id 和user都有意义 // ...}//5.合并性//badfunction getUserInfo(): User;function getUserDetails(): User;function getUserData(): User;function getUser(): User;//good//6.明确性//badconst u = getUser();const s = getSubscription();const t = charge(u, s);//goodconst user = getUser();const subscription = getSubscription();const transaction = charge(user, subscription);//7.无用上下文//badtype User = { userId: string; userName: string; userAge: number;}//goodtype User = { id: string; name: string; age: string;}// 8.默认参数//badfunction loadPages(count: number) { const loadCount = count !== undefined ? count : 10; // ...}//goodfunction loadPages(count: number = 10) { // ...}
推荐命名神器:
对于我们国内程序员来说,取个有意义的变量名也着实考验英语基本功。可以尝试使用 CODELF 变量命名神器,在 VSCode、Sublime Text 都有插件。另外,对现有代码的命名进行重构,最好使用 IDE 自带的重构功能,避免出错。
当然以上的变量命名规则是最基本的,在大型项目中,还需要我们根据项目本身的特点,设计具体的命名规范,来约束开发者,增强代码可维护性。
2.函数之道——“干净”
一个函数函数应该只干一件事,这就是函数体本身要干净,不能干其他琐碎的事,不利于开发者阅读与理解代码。
少参数:一个函数,参数最多不超过3个,超过3个参数让函数功能变得复杂,应为函数的组合便多了。
一般最多两个参数,如果超过两个参数,你可以考虑他否否和一件事原则吗?不符合,可以考虑继续拆分函数。
但是在某些特殊情况下,确实需要多个参数,但是可以考虑使用对象,为了让函数定义更清晰,可以使用结构。结构传参有以下好处:1.便于查看函数签名;2.结构深拷贝,无副作用。3.TS校验未使用的属性。
//badfunction reqeust(method: string, url: string, body: string, headers: string) { // ...}reqeust('POST', '/api/service/v1/users', body, headers);//goodfunction reqeust(method: string, url: string, body: string, headers: string) { // ...}reqeust( { method: 'POST', url: '/api/service/v1/users', body: body, headers: headers });//也可以使用 TypeScript 的类型别名,进一步提高可读性。type RequestOptions = {method: string, url: string, body: string, headers: string};function request(options: RequestOptions) { // ...}reqeust( { method: 'POST', url: '/api/service/v1/users', body: body, headers: headers });
一件事:单一功能的函数,让代码更清晰,更容易理解。比如勿用Flag,Flag 参数则说明这个函数做了不止一件事。如果函数使用布尔值实现不同的代码逻辑路径,则考虑将其拆分。
//badfunction sendEmail(users: User) { users.forEach((user) => { const userRecord = database.lookup(user); if (userRecord.isActive()) { email(user); } });}//goodfunction sendEmail(users: User) { users.filter(isActive).forEach(email);}function isActive(user: User) { return database.lookup(user).isActive();}
名副其实:见其名,懂其能。
//bad// 从函数名很难看清楚对日期做什么操作?加一天、一月?addToDate(date, 1);//goodaddMonthToDate(date, 1);
单层级抽象:当有多个抽象级别时,函数应该是做太多事了
//badfunction parseCode(code: string) { const REGEXES = [ /* ... */ ]; const statements = code.split(' '); const tokens = []; REGEXES.forEach((regex) => { statements.forEach((statement) => { // ... }); }); const ast = []; tokens.forEach((token) => { // 词法分析... }); ast.forEach((node) => { // 解析 ... });}//goodconst REGEXES = [ /* ... */ ];function parseCode(code: string) { const tokens = tokenize(code); const syntaxTree = parse(tokens); syntaxTree.forEach((node) => { // 解析... });}function tokenize(code: string):Token[] { const statements = code.split(' '); const tokens:Token[] = []; REGEXES.forEach((regex) => { statements.forEach((statement) => { tokens.push( /* ... */ ); }); }); return tokens;}function parse(tokens: Token[]): SyntaxTree { const syntaxTree:SyntaxTree[] = []; tokens.forEach((token) => { syntaxTree.push( /* ... */ ); }); return syntaxTree;}
避免重复:重复万恶之源
如果需要某一处重复的代码逻辑,你需要修改多处,很容易遗漏。一般是存在多个相似功能的模块,但又不同,造成的。
使用抽象来解决,抽象成函数、模块或者类来处理这些不同的地方。注意遵循 SOLID 原则, 因为糟糕的抽象可能还不如重复代码。
当然也要权引入不必要的抽象造成增加复杂性,去要开发者去权衡。当来自不同领域的两个不同模块,它们的实现看起来相似,复制也是可以接受的,并且比抽取公共代码要好一点。因为抽取公共代码会导致两个模块产生间接的依赖关系。
//badprivate showDeveloperList(developers: Developer[]) { developers.forEach((developer) => { const expectedSalary = developer.calculateExpectedSalary(); const experience = developer.getExperience(); const githubLink = developer.getGithubLink(); // 此处存在不同的地方,其余逻辑一样 const data = { expectedSalary, experience, githubLink }; render(data); });}private showManagerList(managers: Manager[]) { managers.forEach((manager) => { const expectedSalary = manager.calculateExpectedSalary(); const experience = manager.getExperience(); const portfolio = manager.getMBAProjects(); // 此处存在不同的地方,其余逻辑一样 const data = { expectedSalary, experience, portfolio }; render(data); });}//goodclass Developer { // ... getExtraDetails() { return { githubLink: this.githubLink, } }}class Manager { // ... getExtraDetails() { return { portfolio: this.portfolio, } }}private showEmployeeList(employee: Developer | Manager) { employee.forEach((employee) => { const expectedSalary = developer.calculateExpectedSalary(); const experience = developer.getExperience(); const extra = employee.getExtraDetails(); // 把不同的地方抽象出来,交给不同的实现处理 const data = { expectedSalary, experience, extra, }; render(data); });}
避免副作用:
函数完成本省功能外,还修还或者访问外部数据而造成一定程度改变系统环境。
在 TypeScript 中,原类型是值传递,对象、数组是引用传递。如果给函数传入的是对象,那么这个对象可能在其他地方被修改,从而产生副作用。
//bad/ 全局变量 namelet name = 'Robert C. Martin';function toBase64() { name = btoa(name);}toBase64(); // 产生了副作用console.log(name); // 实际上要打印的值已经不是 'Robert C. Martin'//goodconst name = 'Robert C. Martin';function toBase64(text:string):string { return btoa(text);}const encodedName = toBase64(name);console.log(name);
用 Object.assign 或解构来设置默认
Object.assign 方法用于将源对象的所有可枚举属性,复制到目标对象。可用此方法设置默认值,需要注意:
Object.assign 方法浅拷贝,意味着属性值是对象,那么目标对象拷贝的是这个对象的引用。
同名属性替换,而非添加。
数组当对象处理。
//badtype MenuConfig = {title?: string, body?: string, buttonText?: string, cancellable?: boolean};function createMenu(config: MenuConfig) { config.title = config.title || 'Foo'; config.body = config.body || 'Bar'; config.buttonText = config.buttonText || 'Baz'; config.cancellable = config.cancellable !== undefined ? config.cancellable : true;}const menuConfig = { title: null, body: 'Bar', buttonText: null, cancellable: true};createMenu(menuConfig)//goodtype MenuConfig = {title?: string, body?: string, buttonText?: string, cancellable?: boolean};function createMenu(config: MenuConfig) { const menuConfig = Object.assign({ title: 'Foo', body: 'Bar', buttonText: 'Baz', cancellable: true }, config);}createMenu({ body: 'Bar' });//或者,也可以使用默认值的解构:type MenuConfig = {title?: string, body?: string, buttonText?: string, cancellable?: boolean};function createMenu({title = 'Foo', body = 'Bar', buttonText = 'Baz', cancellable = true}: MenuConfig) { // ...}createMenu({ body: 'Bar' });
为了避免副作用,不允许显式传递 undefined 或 null 值。参见 TypeScript 编译器的 --strictnullcheck 选项。
避免全局函数
在 JavaScript 中污染全局非常不可取,一旦和其他库冲突,使用者在运行产生异常之前对此一无所知。
例如:想要扩展 JavaScript 的 Array,增加一个 diff 方法,用于显示两个数组之间差异。通常会将 diff 写入 Array.prototype。如果另一个库也增加了 diff 方法,却用于查找数组的第一个元素和最后一个元素之间的区别?
此时就发生冲突了,更好的办法是继承 Array,在子类中实现diff。
//baddeclare global { interface Array<T> { diff(other: T[]): Array<T>; }}if (!Array.prototype.diff){ Array.prototype.diff = function <T>(other: T[]): T[] { const hash = new Set(other); return this.filter(elem => !hash.has(elem)); };}//goodclass MyArray<T> extends Array<T> { diff(other: T[]): T[] { const hash = new Set(other); return this.filter(elem => !hash.has(elem)); };}
函数式编程
尽量使用函数式编程,让数据转换过程管道化。
//badconst contributions = [ { name: 'Uncle Bobby', linesOfCode: 500 }, { name: 'Suzie Q', linesOfCode: 1500 }, { name: 'Jimmy Gosling', linesOfCode: 150 }, { name: 'Gracie Hopper', linesOfCode: 1000 }];let totalOutput = 0;for (let i = 0; i < contributions.length; i++) { totalOutput += contributions[i].linesOfCode;}//good// 使用函数式编程,如下:const totalOutput = contributions.reduce((totalLines, output) => totalLines + output.linesOfCode, 0)
封装判断条件
对于复杂点的判断条件,封装成函数,提升可读性
//badif (subscription.isTrial || account.balance > 0) { // ...}//goodfunction canActivateService(subscription: Subscription, account: Account) { return subscription.isTrial || account.balance > 0}if (canActivateService(subscription, account)) { // ...}
避免否定判断
//badfunction isNotStudent(user: User) { // ...}if (isNotStudent(user)) { // ...}//goodfunction isStudent(user: User) { // ...}if (!isStudent(user)) { // ...}
避免类型检查
TypeScript 是 JavaScript 的一个严格的语法超集,具有静态类型检查的特性。所以一定要指定变量、参数和返回值的类型,以充分利用此特性,能让重构、理解代码更容易。
//badfunction travelToTexas(vehicle: Bicycle | Car) { if (vehicle instanceof Bicycle) { vehicle.pedal(this.currentLocation, new Location('texas')); } else if (vehicle instanceof Car) { vehicle.drive(this.currentLocation, new Location('texas')); }}//goodtype Vehicle = Bicycle | Car;function travelToTexas(vehicle: Vehicle) { vehicle.move(this.currentLocation, new Location('texas'));}
使用迭代器与生成器
像使用流一样处理数据集合时,请使用生成器和迭代器。理由如下:
将调用者与生成器实现解耦,在某种意义上说,由调用者决定要访问多少。
延迟执行,按需使用。
内置支持使用 for-of 语法进行迭代。
//badfunction fibonacci(n: number): number[] { if (n === 1) return [0]; if (n === 2) return [0, 1]; const items: number[] = [0, 1]; while (items.length < n) { items.push(items[items.length - 2] + items[items.length - 1]); } return items;}function print(n: number) { fibonacci(n).forEach(fib => console.log(fib));}print(10);//good// 使用 Generator,yield 关键字用来暂停和继续执行一个生成器函数。function* fibonacci(): IterableIterator<number> { let [a, b] = [0, 1]; while (true) { yield a; [a, b] = [b, a + b]; }}function print(n: number) { let i = 0; for (const fib in fibonacci()) { if (i++ === n) break; console.log(fib); } }print(10);
有些库通过链接 map、slice、forEach 等方法,达到与原生数组类似的方式处理迭代。参见 itiriri 里面有一些使用迭代器的高级操作示例(或异步迭代的操作 itiriri-async)。
import itiriri from 'itiriri';function* fibonacci(): IterableIterator<number> { let [a, b] = [0, 1]; while (true) { yield a; [a, b] = [b, a + b]; }}itiriri(fibonacci()) .take(10) .forEach(fib => console.log(fib));