许多程序员尝试编写干净,智能的代码。 但是,有时候,痴迷于智能可能会使代码库更难以理解,并且可能会花费大量时间来阅读和维护它。
如今,在团队合作中,人们逐渐意识到编写人工代码的意义,这意味着您在编写代码时应该尊重他人,而不是炫耀自己的智慧。 人们正在尝试不要使用"干净"一词,因为这意味着即使您不是故意的,代码也很脏。 丹尼尔·欧文(Daniel Irvine)在他的文章"干净代码,肮脏代码,人类代码"中谈到了这一点。
我并不是说干净是一件坏事。 在处理个人项目时,我会尝试以一种聪明的方式使代码库变得干净。 但更重要的是,我使代码库更具可读性和可理解性。
正如鲍伯叔叔在他的《清洁守则》中所说:
"总的来说,程序员是非常聪明的人。 聪明的人有时喜欢通过展示他们的心理杂耍能力来炫耀自己的聪明人。 毕竟,如果您可以可靠地记住r是URL的小写版本,并且除去了主机和Schema,那么您显然必须非常聪明。 聪明的程序员和专业的程序员之间的区别是,专业人士理解清晰为王。 专业人士会尽力而为,并编写他人可以理解的代码。"-罗伯特·C·马丁
最重要的是编写清晰易懂的代码。 其他人不仅是其他人,而且还是您,他们将在几个月内重写代码。
在本文中,我并不是在谈论人的代码方面。 相反,我将通过一些示例来重点介绍如何将该原理应用于您的代码,并最大程度地减少花一些时间来理解它的时间。
注意:为了解释一些技巧,我将使用JavaScript或TypeScript。
涵盖的示例:
· 命名
· 注释
· 条件
· 循环
· 职能
· 测试
命名
在软件开发中,许多程序员在命名事物时遇到麻烦。 但是我个人认为,关键是避免歧义并使用特定的词语。
例如:
const fetch = async () => {
return await axios.get('/users')
}
const users = await fetch()
...
在此代码中,您可以预期将从服务器获取什么内容。 但是,如果导出了导出功能并在其他文件中使用该怎么办?
export const fetch = async () => {
return await axios.get('/users')
}
在其他文件中:
import { fetch } from './utils'fetch()
// fetch ... what?
相反,您可以更具体地命名:
export const fetchUsers = async () => {
return await axios.get('/users')
}
我说过你应该避免歧义。 请注意以下通用动词:
· set
· get
· group
· begin
· validate
· send
另一个例子:
const xxx = validateForm()
在这段代码中,您可以理解validateForm是正在验证表单,但是您期望返回什么?
但是假设您这样写:
const xxx = isFormValid()
然后非常清楚,该方法将返回true或false。
而且,如果您这样编写代码,则可以假定该方法将返回一个数组或形式错误的映射:
const xxx = getFormErrors()
另一个例子:
const token = getToken()
如您所见,getToken可能会获得一个令牌。 但是从什么呢? 如果它使用异步功能从服务器获取令牌怎么办?
const token = getToken()
// use token for somethingdoSomething(token)
这可能会在doSomething函数中导致未定义的错误,因为在这种情况下,您需要等待getToken完成。
const token = await getToken()
// use token for somethingdoSomething(token)
它工作正常。 但是getToken在这种情况下不合适,因此您可以将其重命名:
const token = await fetchToken()
// use token for somethingdoSomething(token)
这样一来,更清楚的是该方法将从某些服务器或异步设备中获取令牌。
为了解决这些问题,许多聪明的人提出了一个很好的例子,但是重要的是让人们以简单的方式知道它的用途。
注释
通常,注释的目的是帮助人们尽可能地了解代码,并且注释可以使人们更快地理解代码。 但是您不必总是对代码发表注释。 您需要知道毫无价值的注释和良好的注释之间的界限。
什么没什么好评论
如果人们可以轻松理解代码的功能,则无需对代码进行注释。
例如:
// Find student from lists, with the given id
const student = students.find(s => s.id === id)
另一个例子:
// Calculate tax based on the income and wealth value and ....
const income = document.getElementById('income').value;
const wealth = document.getElementById('wealth').value;
tax.value = (0.15 * income) + (0.25 * wealth);
// ...
这似乎是解释它是什么的很好的评论,但可以进行改进。
function calculateTax(income, wealth) {
return (0.15 * income) + (0.25 * wealth);
}
应将代码块移至函数中,并输入一个名称来解释其功能。 简洁明了的功能名称和自记录功能要好于注释。
注释什么
我们介绍了您不应该注释的代码类型。 接下来,我们将看到应该注释的内容。
您需要在以下代码处添加注释:
· 有缺陷,例如性能问题
· 可能会导致人们意想不到的行为
· 需要进行总结,以便人们可以轻松掌握细节
· 需要解释为什么有更好的方法时必须以这种方式编写
这些是您在编写代码时想出的宝贵见解。 如果没有这些注释,人们可能会认为存在错误,或者应该对代码进行测试或修复,这可能会浪费时间。 为避免这种情况,您应该解释为什么以某种方式编写代码。
重要的是要让自己穿上别人的鞋子。 提前考虑并预测人们可能会陷入的陷阱。
首先处理正面,而不是负面
哪个更适合您阅读?
if (!debug) {
// do something
} else {
debugSomething()
}
要么
if (debug) {
debugSomething()
} else {
// do something
}
在大多数情况下,优先使用正面案例。 但是,如果否定情况是更简单,更谨慎的情况,则可以这样编写:
if (!user)
throw new Error('Please sign in first')
// do a lot of things here
// ...
早点返回
例如:
export const formatDate = (date) => {
let result
if (date) {
const dateObj = new Date(date)
if (isToday(dateObj)) {
result = 'Today'
} else if (isYesterday(dateObj)) {
result = 'Yesterday'
} else if (!isThisYear(dateObj)) {
result = format(dateObj, 'MMMM d, yyyy')
} else {
result = format(dateObj, 'MMMM d')
}
} else {
throw new Error('No date')
}
return result
}
它工作正常,但是代码有点长且嵌套。 而且,如果添加了if / else语句,将更难弄清楚右括号在哪里,并且更难调试代码。
为了使它看起来更整洁,我们需要做的是:
· 如果没有日期,则抛出错误
· 如果日期是今天,则返回"今天"
· 如果日期是昨天,则返回"昨天"
· 如果日期不在今年,则返回日期和年份
· 如果不符合上述条件,则返回日期和月份和日期
export const formatDate = (date) => {
if (!date) throw new Error('No date') // If no date, throw an error
const dateObj = new Date(date)
if (isToday(dateObj)) return 'Today' // If the date is today, return 'Today'
if (isYesterday(dateObj)) return 'Yesterday' // If the date is yesterday, return 'Yesterday'
if (!isThisYear(dateObj)) return format(dateObj, 'MMMM d, yyyy') // If the date is not in this year, return date with year
return format(dateObj, 'MMMM d') // If no matching the above, return date with month and date
}
看起来更好。 从函数中多次返回非常适合使代码可读。
使用Array.includes处理多个案例
如果您有多个条件,则可以使用Array.includes以避免扩展语句。
例如:
if (kind === 'Persian' || kind === 'Maine' || kind === 'British Shorthair') {
// do something ...
}
考虑到以后可以将其他条件添加到语句中,我们想要重构代码,如下所示:
const CATS_TYPE = ['Persian', 'Maine', 'British Shorthair']
if (CATS_TYPE.includes(kind)) {
// do something ...
}
具有类型数组,您可以从代码中单独提取条件。
使用可选链接处理未定义的检查
可选的链接允许您深入访问嵌套对象,而无需在临时变量中重复分配结果。 通过使用此选项,可以减少条件检查中的多次检查。
注意:如果要在JavaScript中使用可选的链接运算符,则需要安装Babel插件。 在3.7以上的Typescript中,无需任何配置即可使用它。
例如:
if (user && user.addressInfo) {
let zipcode
if (user.addressInfo.zipcode) {
zipcode = user.addressInfo.zipcode
} else {
zipcode = ''
}
// do something
}
如果要检查用户是否存在并避免发生未定义的错误,则需要编写类似于上面示例的条件。
但是通过使用可选的链接运算符,代码将是:
const zipcode = user?.addressInfo?.zipcode || ''
这样看起来更好并且更易于维护。 可以访问内部的嵌套对象并避免发生未定义的错误。
您可以在TypeScript游乐场中使用此炫酷功能
循环
简化循环使您的代码更容易理解。
在实际情况下,您可能会在对象中遇到复杂的嵌套循环。 如果您有嵌套对象并且必须在todo3中获得列表名称,该怎么办:
const todos = [
{
code: 'code',
name: 'name',
list: [
{
name: 'todo name',
},
{
name: 'todo name',
},
],
todo2: [
{
code2: 'code2',
name2: 'name2',
list: [
{
name: 'todo name2',
description: '',
},
{
name: 'todo name2',
description: '',
}
],
todo3: [
{
code3: 'code3',
name3: 'name3',
list: [
{
name: 'todo name3',
description: '',
},
{
name: 'todo name3',
description: '',
}
]
}
]
}
]
},
]
例如,您可以这样编写:
const list = [];
todos.forEach(t => {
t.todo2.forEach(t2 => {
t2.todo3.forEach(t3 => {
t3.list.forEach(l => {
list.push({
todo3Name: l.name
})
})
})
})
})
但这可以使用reduce函数来改进:
const list = todos
.reduce((acc, t) => [...acc, ...t.todo2], [])
.reduce((acc, t2) => [...acc, ...t2.todo3], [])
.reduce((acc, t3) => [...acc, ...t3.list], [])
.map(l => ({ todo3Name: l.name }))
职能
在编写函数时,请牢记以下提示:
· 使用摘要名称来说明其操作。
· 为一个目的创建一个功能。
· 较小的函数更易读。
使用摘要名称来说明其操作
乍看之下的代码如下,您可能会停止阅读并试图弄清楚它在做什么:
const tmp = new Set();
const filtered = lists.filter(a => !tmp.has(a.code) && tmp.add(a.code))
那呢?
const filtered = uniqueByCode(lists)
您可能会期望有一个函数,通过查看对象来删除具有重复代码的对象。
两者都能很好地工作,并获得相同的结果。
但是第二个更具可读性,可以帮助解释该功能的作用。
另一个例子:
const person = { score: 25 };
let newScore = person.score
newScore = newScore + newScore
newScore += 7
newScore = Math.max(0, Math.min(100, newScore));
console.log(newScore) // 57
如果我们为其编写函数,则代码将如下所示:
let newScore = person.score
newScore = double(newScore)
newScore = add(newScore, 7)
newScore = boundScore(0, 100, newScore)
console.log(newScore) // 57
好多了 但个人而言,我喜欢管道运算符的想法,该运算符与将多个函数链接在一起以提高函数编程的可读性一起使用。
如果我们在JavaScript中使用管道,则代码将如下所示:
const person = { score: 25 };
const newScore = person.score
|> double
|> add(7, ?)
|> boundScore(0, 100, ?);
newScore //=> 57
为一个目的创建功能
现在我们了解了摘要名称的重要性。 但是,如果您不能为您的功能起一个好名字怎么办?
例如:
const updateUser = async (user) => {
try {
await axios.post('/users', user)
await axios.post('/user/profile', user.profile)
const email = new Email()
await email.send(user.email, 'User has been updated successfully')
const logger = new Logger()
logger.notify()
} catch (e) {
console.log(e)
throw new Error(e)
}
}
您可能想知道它应该是updateUserAndProfile,updateUserAndProfileAndNotify还是其他名称。 当您陷入困境时,就该将代码分成较小的部分了,因为人们很难同时理解多条代码。
当您编写用于更新用户的函数时,代码应如下所示:
const updateUser = async (user) => {
try {
await axios.post('/users', user)
} catch (e) {
// handling error
}
}
const handleUpdate = async (user, onUpdated) => {
try {
await updateUser(user)
await updateProfile(user.profile)
await onUpdated(user) // email or notify something
} catch (e) {
// handling error
}
}
这是一个非常简单的示例,但是实际开发中有很多情况。 要牢记的关键思想是退后一步,考虑功能应该做什么,并考虑所有问题,以便一次只执行一项任务。
较小的函数更易读
当您出于某个目的编写较小的函数时,代码将更具可读性和可理解性。
例如:
const generateQuery = (params) => {
const query = {}
try {
if (params.email) {
const isValid = isValidEmail(params.email)
if (isValid) {
query.email = params.email
}
}
const defaultMaxAgeLimit = JSON.parse(localStorage.getItem('defaultMaxAgeLimit') || '')
if (params.maxAge) {
if (params.maxAge < 25) {
query.maxAge = params.maxAge
}
} else {
query.maxAge = defaultMaxAgeLimit
}
if (params.limit) {
query.limit = params.limit
}
// do a lot of things here
// ...
} catch(err) {
// error handing
}
return query
}
假设您在一个函数中有大量代码,这些函数创建查询来搜索某些数据。 如果电子邮件查询出了点问题,则您必须浏览内部的函数,找到电子邮件的实现并进行修复。 之后,您将必须检查更改是否会影响该函数中的其他代码。
通常,人们一次只能考虑两件事。 代码表达越大,理解和维护就越困难。
因此,使代码更小:
const email = (query, params) => {
if (!params.email) return query
const isValid = isValidEmail(params.email)
if (!isValid) throw new Error('Invalid email')
return { ...query, ...{ email: params.email } }
}
const maxAge = (query, params) => {
const obj = { maxAge: '' }
if (!params.maxAge) obj.maxAge = JSON.parse(localStorage.getItem('defaultMaxAgeLimit') || '')
if (params.maxAge && params.maxAge < 25) obj.maxAge = params.maxAge
return { ...query, ...obj }
}
const limit = (query, params) => {
if (!params.limit) return query
return { ...query, ...{ limit: params.limit } }
}
const generateQuery = (params) => {
let query = {}
try {
query = email(query, params)
query = maxAge(query, params)
query = limit(query, params)
} catch(err) {
// error handing
}
return query
}
将巨型代码分解为小段可以使代码更清晰,更易读。 更重要的是,每个问题都与其余代码分开,因此您可以轻松地调试和测试它。
如果您还想使其更具通用性和声明性,则可以这样编写:
const generateQuery = (params, callbacks) => {
let query = {}
try {
callbacks.forEach(c => {
query = c(query, params)
})
} catch(err) {
// error handing
}
return query
}
const result = generateQuery({ email: 'xxx@gmail.com', maxAge: 20 }, [email, maxAge, limit])
尽管我建议我们希望使代码更小,更通用,但在重构之前,您将不得不首先考虑为什么以这种方式编写此代码。 也许您的同事出于某种原因写了它。 即使有很多改进,您也想与编写它的人交谈,并讨论它是否好。
这全都与人工密码有关。 您可以在"再见,干净代码"一文中更详细地了解它。
测试中
我不是在谈论TDD,而是在团队开发中可读性对测试的重要性。
编写测试非常重要,因为:
· 在没有文档的情况下,您的队友可以通过阅读测试说明轻松了解详细信息。
· 您的队友可以理解真正的代码应该如何工作以及为什么。
· 您的队友可以轻松添加新功能,而不必担心会破坏代码。
· 鼓励您的队友添加测试。 (如果测试代码太大且令人生畏,则可能会破窗!)
这些是我个人在团队发展中的经验教训。 优秀的程序员始终会编写具有良好可维护性的测试。
这是编写测试时的一些技巧:
· 用简单的英语描述测试的目的(最好使用您的母语)。
· 遵循AAA(安排,执行,声明)模式。
· 使添加测试用例(表驱动的测试模式)变得容易。
用简单的英语描述测试要做什么
就像我在上面说的,如果测试是描述性的,则人们可以轻松地了解它在做什么。
假设我们有一个类似util的函数:
export const getAnimal = (code) => {
if (code === 1) return 'CATS'
if (code === 2) return 'DOGS'
if (code === 3) return 'RABBITS'
return null
}
并编写一个测试:
import { getAnimal } from './util';
describe('getAnimal', () => {
it('passes', () => {
expect(getAnimal(1)).toEqual('CATS')
})
})
这是一个非常简单的示例,因此您可能可以理解它正在尝试执行的操作。 但是,如果它变大并弄乱了,您将很难理解它。
没关系,因为您已经编写了此功能,并且知道其功能。 但是测试不仅适合您,还适合您的队友。
让我们更具描述性:
describe('getAnimal', () => {
it('should get CATS when passing code 1', () => {
expect(getAnimal(1)).toEqual('CATS')
})
})
这看起来有点多余。 但这不是重点。 通过描述它,您可以使人们知道正确的行为是在代码为1时获取CATS。
为了使其在每个上下文中都更清楚,可以使用上下文块,如下所示:
describe('getAnimal', () => {
context('when passing code 1', () => {
it('should get CATS', () => {
expect(getAnimal(1)).toEqual('CATS')
})
})
})
注意:如果使用Jest,则可以安装jest-plugin-context。
通过这样编写,您可以在每个块中分隔特定的上下文。
遵循AAA模式
AAA模式允许您将测试分为三个部分:安排,操作和声明。
在安排部分,您可以在其中设置数据或模拟要在测试中使用的功能。
act部分是调用测试方法并在需要时捕获输出值的地方。
assert部分是您对输出进行声明的地方。
如果将其应用于上面的示例,代码将如下所示:
describe('getAnimal', () => {
context('when passing code 1', () => {
beforeEach(() => {
// arrange
// prepare data here
})
it('should get CATS', () => {
// act
const result = getAnimal(1)
// assert
expect(result).toEqual('CATS')
})
})
})
这是使用酶进行反应测试的另一个示例:
describe('Component', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
// arrange
// mock useEffect function
jest
.spyOn(React, 'useEffect')
.mockImplementation(f => f());
});
it('should render successfully', () => {
// act
wrapper = mount(<Component {...props()} />);
// assert
expect(wrapper).toMatchSnapshot();
});
it('should update the text after clicking', () => {
// act
wrapper = mount(<Component {...props()} />);
wrapper.find('button').simulate('click');
// assert
expect(wrapper.text().includes("text updated!"));
});
});
一旦习惯了这种模式,就可以更轻松地阅读和理解测试。
在GitHub上,javascript-testing-best-practices是解释JavaScript测试的好指南。
轻松添加测试用例(表驱动测试模式)
在Go测试中,经常使用表驱动测试模式。 它的优点是能够通过定义每个表条目中的输入和预期结果来涵盖许多测试用例。
这是Go中fmt包的示例:
var flagtests = []struct {
in string
out string
}{
{"%a", "[%a]"},
{"%-a", "[%-a]"},
{"%+a", "[%+a]"},
{"%#a", "[%#a]"},
{"% a", "[% a]"},
{"%0a", "[%0a]"},
{"%1.2a", "[%1.2a]"},
{"%-1.2a", "[%-1.2a]"},
{"%+1.2a", "[%+1.2a]"},
{"%-+1.2a", "[%+-1.2a]"},
{"%-+1.2abc", "[%+-1.2a]bc"},
{"%-1.2abc", "[%-1.2a]bc"},
}
func TestFlagParser(t *testing.T) {
var flagprinter flagPrinter
for _, tt := range flagtests {
t.Run(tt.in, func(t *testing.T) {
s := Sprintf(tt.in, &flagprinter)
if s != tt.out {
t.Errorf("got %q, want %q", s, tt.out)
}
})
}
}
输入和预期输出在flagtests变量中定义。 您要做的就是循环浏览,运行测试并检查结果。
您可以将其应用于JavaScript测试。
例如:
import { getAnimal } from './util';
describe('getAnimal', () => {
context('when passing code 1', () => {
it('should get CATS', () => {
const result = getAnimal(1)
expect(result).toEqual('CATS')
})
})
context('when passing code 2', () => {
it('should get DOGS', () => {
const result = getAnimal(2)
expect(result).toEqual('DOGS')
})
})
context('when passing code 3', () => {
it('should get RABBITS', () => {
const result = getAnimal(3)
expect(result).toEqual('RABBITS')
})
})
context('when no match', () => {
it('should get null', () => {
const result = getAnimal(100)
expect(result).toEqual(null)
})
})
})
并使其适用:
const cases = [
{
code: 1,
expected: 'CATS',
},
{
code: 2,
expected: 'DOGS',
},
{
code: 3,
expected: 'RABBITS',
},
{
code: 100,
expected: null,
},
]
describe('getAnimal', () => {
cases.forEach(c => {
context(`when passing code ${c.code}`, () => {
it(`should get ${c.expected}`, () => {
const result = getAnimal(c.code)
expect(result).toEqual(c.expected)
})
})
})
}
如果您有很多测试用例,则此技术将很有用。
结论
我通过一些示例介绍了如何使代码易于理解。 记住,干净,智能的代码并不总是更好。 重要的是退后一步并问自己:"这比较干净,但是可以理解和阅读吗?" 或"其他队友是否可以维护?" 如果是这样,你可以去做。
希望本文对您有所帮助。
如果您有任何建议,意见和想法,请告诉我。
(本文翻译自Manato Kuroda的文章《Clarity Is King When Writing Code》,参考:https://medium.com/better-programming/clarity-is-king-when-writing-code-752b85101484)