什么是可维护性的代码
-
容易理解:无须求助原始开发者,任何人一看代码就知道它是干什么的,以及它是怎么实现的
-
符合常识:代码中的一切都显得顺理成章,无论操作多么复杂
-
容易适配:即使数据发生变化也不用完全重写
-
容易扩展:代码架构经过认真设计,支持未来扩展核心功能
-
容易调试:出问题时,代码可以给出明确的信息,通过它能直接定位问题
容易理解(高可读性)
命名清晰
javascript
// bad
function b(a) {
// 一些代码...
}
// good
function calculateGrade(studentsGrages) {
// 更清晰的代码...
}
上面的代码方法以b
命名,会让代码难以理解;而下面的方法以calculateGrade
命名,能清晰的看出函数的用途
命名规范
-
避免使用单个字符的命名
-
变量名应该是名词,例如
car
或person
-
函数名应该以动词开始,例如
getName()
。返回布尔值的函数通常以is
开头,比如isEnabled()
-
对变量和函数都使用符合逻辑的名称,不用担心长度
- 🌰 需要写这样一个函数,获取用户名称,函数名更好是
getUserName
而不是用更简洁的getName
- 🌰 需要写这样一个函数,获取用户名称,函数名更好是
-
名词尽量用描述性和直观的词汇,但不要过于冗长。
getName
一看就知道会返回名称 -
变量、函数和方法应该以小写字母开头,使用驼峰大小写(camelCase)形式,如
getName()
和isPerson
。类名应该首字母大写,如Person
、RequestFactory
。常量值应该全部大写并以下划线相接,比如REQUEST_TIMEOUT
-
对于事件处理函数,使用
handle
或on
前缀,如handleClick
或onSubmit
使用代码注释
-
函数和方法:函数或方法应写清楚使用的前提;如果看函数的方法名、参数不能准确的知道其用途,应写相应的注释。
/**
- 根据开始时间和结束时间字段构造筛选「进行中」、「未开始」、「未结束」、「已结束」的列表的 query
- @param status - 筛选的状态
- @param startTimeKey - 开始时间字段的 identity_key
- @param endTimeKey - 结束时间字段的 identity_key
- @returns */ export function getTimeRelatedFilterQuery( status: TimeRelatedFilterStatus, startTimeKey = 'start_time', endTimeKey = 'end_time', ): TimeRelatedFilterQuery { switch (status) { case 'processing': // 开始时间小于等于当前时间并且结束时间大于当前时间 return { [startTimeKey]: { rgt: formatDate(new Date()), }, [endTimeKey]: { lft: formatDate(new Date()), }, } case 'notStarted': // ... } }
getTimeRelatedFilterQuery
获取时间相关的 query,并不能准确表达函数的用途,因此需要写相应的注释表明其用途
-
大型代码块:多行代码但用于完成单一任务的,应该在前面给出解释,把要完成的任务写清楚
-
复杂的算法:使用了特别的方法解决问题
-
使用黑科技:解决浏览器兼容问题等
-
避免无用或冗余的注释
// 获取流程状态的文本 ❌ function getFlowJourneyStatusText(status: FlowAssignmentStatus) { switch (status) { case FlowAssignmentStatus.Stashed: return '编写中' // ... } }
getFlowJourneyStatusText
获取流程记录状态的文本,已经能够准确表达方法的含义,因为不需要再添加冗余的注释
其它编码规定
-
空行:
-
在代码块之间使用适当的空行,以提高可读性。
-
在函数声明和逻辑块之间添加空行,使代码更易于理解。
-
-
文件和目录结构:
-
保持清晰、有组织的文件和目录结构,使用语义化的文件和文件夹命名。
-
将相关的文件组织到适当的目录中。
-
-
CSS 规范:
-
遵循一致的 CSS 命名规范(如 BEM 或其他)。
-
使用前缀来区分样式规则,以避免冲突。
-
避免使用
!important
,除非必要。
-
容易扩展
模块化设计
代码应该被划分为独立的模块,每个模块负责一个明确的功能。这样,新功能的添加或现有功能的修改可以通过修改相应的模块而不影响其他部分。
-
组件化
-
ES 6 module
单一职责原则
每个模块、类或函数应该有一个单一的责任。这使得修改和扩展更加直观,不容易引入意外的变化。单一职责原则(SRP)的职责被定义为"引起变化的原因"。如果我们有两个动机去改写一个方法,那么这个方法就具有两个职责。
-
把一个功能拆分成较小的函数单元,以获取来源于多条流程的由我发起和需我办理的流程记录为例
export async function fetchFlowList( category: 'proposed' | 'processed', completed: number, pageInfo: PageInfo, ): Promise<PaginateData<FlowRecord[]>> { switch (category) { case 'proposed': { // ...
typescriptreturn combineFlowList(flowIds, (flowId: string) => { return fetchProposedFlowRecord(flowId, completed, pageInfo) }) } default: { // ... return combineFlowList(flowIds, (flowId: string) => { return fetchProposedFlowRecord(flowId, completed, pageInfo) }) }
} }
async function fetchProposedFlowRecord( flowId: string, completed: number, pageInfo: PageInfo, ): Promise<FlowRecord[]> { // ...
return { data, pageData, } }
async function fetchProcessedFlowRecord( flowId: string, completed: number, pageInfo: PageInfo, ): Promise<FlowRecord[]> { // ...
return { data, pageData, } }
async function combineFlowRecord( flowIds: string[], fetchFunc: (flowId: string) => Promise<PaginateData<FlowRecord[]>>, ): Promise<FlowRecord[]> { const res = await Promise.all(flowIds.map(item => fetchFunc(item))) const { data, pageData } = combineData(res)
return { data, pageData, } }
// 合并 data 和 pageData function combineData<T = unknown>(data: PaginateData[]): PaginateData { // ...
return { data, pageData, } }
在实现过程中,把一个功能拆分成fetchProposedFlowRecord
、fetchProcessedFlowRecord
、combineFlowRecord
等几个单一职责的函数,降低了编写每个函数的复杂度,有助于代码的复用,也有利于进行单元测试。当一个函数出现问题时,不需要关心其它函数,增加了代码的可维护性。
-
computed 中只写获取计算属性相关的代码
const fields = shallowRef<Field[]>([])
//bad const showFieldComponent = ref(false)
const pictureField = computed(() => { if (fields.value.length !== 0) { showFieldComponent.value = true
inireturn fields.value.find(field => field.identity_key === 'picture')
} })
// good const pictureField = computed(() => fields.value.find(field => field.identity_key === 'picture'), )
const showFieldfComponent = computed(() => fields.value.length !== 0)
开放/封闭原则
代码应该对扩展开放,对修改封闭。这意味着应该通过添加新代码来引入新功能,而不是直接修改现有代码。这可以通过使用抽象、接口和设计模式来实现。
javascript
// 原始功能
const getDrawCircleFunc = (radius) => () => console.log(`Drawing a circle with radius ${radius}`);
const getDrawRectangleFunc = (width, height) => () => console.log(`Drawing a rectangle with width ${width} and height ${height}`);
// 客户端代码
const drawShapes = (shapes) => shapes.forEach((drawFunction) => drawFunction())
const drawCircleFunc = getDrawCircleFunc(5)
const drawRectangleFunc = getDrawRectangleFunc(8, 6)
drawShapes([drawCircleFunc, drawRectangleFunc])
// 扩展功能
const getDrawTriangleFunc = (side1, side2, side3) => () => console.log(`Drawing a triangle with sides ${side1}, ${side2}, and ${side3}`)
// 客户端代码无需修改,仍然可以绘制新的图形类型
const drawTriangleFunc = getDrawTriangleFunc(3, 4, 5);
drawShapes([drawCircleFunc, drawRectangleFunc, drawTriangleFunc])
在这个例子中,每个图形类型的绘制都是返回一个没有参数的函数,当调用这个函数时,它会执行真正的绘制操作。drawShapes
函数接受一个图形绘制函数的数组,并通过 forEach
循环调用每个函数,绘制相应的图形。
这种设计遵循开放封闭原则,因为我们可以轻松地添加新的图形类型,只需定义一个新的绘制函数,而无需修改已有的代码。这种函数式风格使得代码更加模块化和容易扩展。
javascript
// 原始功能
const drawCircle = (radius) => console.log(`Drawing a circle with radius ${radius}`)
// 原始功能
const drawRectangle = (width, height) => console.log(`Drawing a rectangle with width ${width} and height ${height}`)
// 客户端代码
const drawShapes = shapes =>
shapes.forEach(shape => {
switch (shape) {
case 'circle':
drawCircle(5)
break
case 'rectangle':
drawRectangle(8, 6)
break
}
})
drawShapes(['circle', 'rectangle'])
// 扩展功能
const drawTriangle = console.log(`Drawing a triangle with sides ${side1}, ${side2}, and ${side3}`)
// 客户端代码需要修改代码来绘制新的图形,才可以绘制新的图形
const drawShapes = shapes =>
shapes.forEach(shape => {
switch (shape) {
case 'circle':
drawCircle(5)
break
case 'rectangle':
drawRectangle(8, 6)
break
case 'triangle':
drawTriangle(3, 4, 5)
break
}
})
drawShapes(['circle', 'rectangle', 'triangle'])
在这个例子中,每个图形类型的绘制都是一个方法。drawShapes
函数根据不同的图形调用不同的方法。
这种设计不遵循开放封闭原则,因为我们每次添加新的图形,都需要修改drawShapes
方法。
低耦合高内聚
模块之间应该尽量减少依赖关系,即低耦合。每个模块应该有一个清晰的职责,即高内聚。这样,当一个模块发生变化时,不会影响到过多的其他模块。
可配置性
将可变的部分设计为可配置的,以便在不同场景下进行定制。这可以通过配置文件、环境变量或参数化函数等方式实现。
良好的文档
提供清晰的文档,说明代码的结构、功能和用法。这有助于其他开发者理解如何使用和扩展代码。
容易调试
清晰的错误消息
在代码可能出现错误的地方,给出错误消息
typescript
// bad
function divide(a: number, b: number) {
return a / b;
}
// good
function divide(a: number, b: number) {
if (b === 0) {
throw new Error('除数不能为零');
}
return a / b;
}
在调用上面的方法时,若除数为零,会直接返回Infinity
;而调用下面的方法,会直接报告错误信息,方便调试。
合理的错误处理
使用 try-catch 块处理可能出现的异常,同时确保错误信息能够提供有用的信息。这样可以更容易定位并解决问题
javascript
// bad
function formatStr(str: string) {
const date = parseISO(str)
return formatDate(date, 'yyyy/MM/dd HH:mm')
}
// good
function formatStr(str: string) {
try {
const date = parseISO(str)
return formatDate(date, 'yyyy/MM/dd HH:mm')
} catch {
console.error(`非法的时间字符串${str}`)
return ''
}
}
在调用上面的方法时,如果 str 不是个标准的时间字符串,会让页面直接异常终止;而下面的方法处理了异常,能够在控制台上看到更清晰的错误信息,更方便调试,且页面不会异常终止。
编码惯例
尊重对象的所有权
-
不要给对象添加属性和方法
// bad window.func = () => { // ... }
假如开发者 A 在 window 上定义了一个方法,开发者 B 又定义了一个和开发者 A 重名的方法,A 开发者之前写的方法就会被覆盖掉。
-
不改变对象的值
// bad function generateOptions(options: Option: []) { options.forEach((option, index) => { if (index % 2) { option.show = true } })
return options }
// good function generateOptions(options: Option: []) { return options.map((option, index) => { return { ...option, show: index % 2, } }) }
上面的方法直接修改 options 对象,如果 options 对象也作为其它功能的数据来源,出问题时会导致不知道是那一部分出现的问题, bug 难以定位;而下面的方法返回一个新的数组,不直接修改对象的值,代码更好维护。
使用常量
-
重复出现的值:任何使用查过一次的值都应该提取到常量中,这样可以消除一个值改了而另一个值没有改造成的错误
-
资源的 URL:Web 应用程序中资源的地址经常会发生变化,可以把 URL 集中在一个地方管理
-
任何可能变化的值:任何时候,只要代码中使用字面值,就问问自己这个值将来是否可能会变。如果答案是"是",那么就应该把它提取到常量中
-
有特殊含义的数字(避免魔法数字和硬编码)
// bad function calculatePrice(quantity: number) { return quantity * 5.5; }
// good const PRICE_PER_UNIT = 5.5; function calculatePrice(quantity: number) { return quantity * PRICE_PER_UNIT; }
上面的代码直接在方法中写具体的数值,使代码不好理解;而下面的代码将数字作为常量PRICE_PER_UNIT
存起来,更好理解。