前言:接下来会开启一个小程序最佳实践的知识小册,从小程序作为切入点,但绝不限于此。将涵盖几大篇章『命名篇』、『单测篇』、『代码风格篇』、『注释篇』、『安全生产篇』、『代码设计篇』、『性能篇』、『国际化篇』和『Why 小程序开发最佳实践』。每一篇都会包含正面和反面示例以及详细的解释。
文中示例都是在 CR 过程中发现的一些典型的问题。希望大家能通过该知识小册提前规避此类问题,让我们的 CR 关注点更多聚焦在业务逻辑而非此类琐碎或基本理念的缺失上而最终形成高质量的 CR。
代码设计篇 🧘♂️🧘♀️
Python 之禅
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than right now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
好的代码是易于删除的代码
好的代码意味着低耦合高内聚,好的代码是易于重构的代码。
克制的权限
能私有不公共(Private over Public),公共 API 意味着对稳定性和对代码质量更高的要求,需要足够健壮,要考虑所有场景,需要单测以及充分的覆盖率来保障;不利于重构,不能轻易被删除和修改(Code should be easy to delete),需要考虑兼容性,让开发者战战兢兢如履薄冰。
『能力越大责任越大』。如果你不想考虑更多的场景,仅满足当前需求请将其私有。
注意本条和可复用并不冲突,思考不充分的复用后果等同于过早优化(premature optimization),均为万恶之源。详见 Duplication is less costly than premature abstraction。
With great power comes great responsibility
Bad
/**
* 异常弹窗
*/
export const errorAlert = ({
//...
})
Good
经过全局搜索该函数并未被其他文件引用,删除 export 让其私有 👍
/**
* 异常弹窗
*/
const errorAlert = ({
//...
})
函数设计
函数可提高可读性和可复用性,符合 DRY 原则。好的函数都是有迹可循的,坏的函数各有各的坏。
参数对象分组
[建议]:三个以及三个以上参数需采用对象参数形式,其次让相似功能的参数分组可让函数逻辑更清晰。
Bad
str 是目标主体,是必选项,其他参数,是可选配置项,混为一体缺乏重点,逻辑混乱
/**
* 账号类脱敏方法
* @param startNum 头部多少位开始脱敏 默认0
* @param lastNum 尾部多少位不脱敏 默认0
*/
interface IObjParamInterface {
str: string;
startNum?: number;
lastNum?: number;
formatStr: string;
}
export function accountDesensitization(objParam: IObjParamInterface): string {
// ...
}
Good
主体和配置分开,关注点分离,逻辑更清晰
/**
* 账号类脱敏方法
* @param startNum 头部多少位开始脱敏 默认0
* @param lastNum 尾部多少位不脱敏 默认0
*/
export function mask(str = '', objParam: IMaskOptions = {}): string {
// ...
}
interface IMaskOptions {
startNum?: number;
lastNum?: number;
formatStr?: string;
}
无副作用
[建议] 有副作用的函数容易引发不可预期的问题导致运行时隐患,且无副作用的函数更易测试,其次建议将副作用隔离。
案例请参考:小程序最佳实践之『安全生产篇』 👷🏻♂️。
SRP 原则
[强制] 一个模块、一个类或一个方法仅做一件事,不多不少。
Bad
getGuideCount 不仅做了 get,而且顺带做了 set 和 update 的事情,违反 SRP 原则
/**
* 获取登录次数首次和第二次都需要知道
* 注意:有副作用,每次 getGuideCount 都会加一
*/
getGuideCount () {
my.getStorage({
key: STORAGE_GUIDE_COUNT,
success: res => {
// 首次 res.data 返回 null
if (res.data === null || res.data < 3) {
// 预期 res.data 必定是 null 或数字
const incrementByOne = (res.data === null ? 0 : res.data) + 1;
my.setStorage({
key: STORAGE_GUIDE_COUNT,
data: incrementByOne,
});
// 存入 state
this.commit('updateGuideCount', incrementByOne);
}
},
});
},
Good
拆分成两个方法,让 getGuideCount 精兵简政,职责更单一,消除其副作用,让 incrementGuideCount 去专职去做有副作用的更新操作(隔离副作用)。额外获得好处消除 res.data 和 null 的重复判断,👍
{
/**
* 获取登录次数首次和第二次都需要知道
* @private
*/
async getGuideCount(): Promise<number> {
const res = await getStorage(STORAGE_GUIDE_COUNT);
return res.data || 0;
},
/**
* 累加新人引导次数
* @private
*/
async incrementGuideCount(): Promise<void> {
const guidCount = await this.getGuideCount();
if (guidCount < 3) {
const incrementByOne = guidCount + 1;
my.setStorage({
key: STORAGE_GUIDE_COUNT,
data: incrementByOne,
});
this.commit('updateGuideCount', incrementByOne);
}
}
}
抽象成函数的时机
相近逻辑或复杂逻辑抽象成函数通常能显著提高可读性。
1. 复杂逻辑
Bad
第 4 行逻辑较复杂,一眼很难看出其用意。
if (len && len > startNum + lastNum) {
return (
str.substring(0, startNum) +
new Array(len - startNum - lastNum).fill(formatStr).join('') +
str.substring(len - lastNum, len)
);
}
Good
抽取成方法 repeat,通过函数名达到自描述作用 👍。
function repeat(repeater = '', count = 1): string {
return new Array(count).fill(repeater).join('');
}
if (len && len > startNum + lastNum) {
return (
str.substring(0, startNum) +
repeat(formatStr, len - startNum - lastNum) +
str.substring(len - lastNum, len)
);
}
2. 可读性
为何要强调封装函数,第一自我解释,函数名就是最好的文档描述;第二团队分工效率高,若调用者和开发函数内部逻辑的人是两位,逻辑变化了,调用者无需修改代码,当做黑盒使用即可。
Bad
不同逻辑块杂糅,没有重点。
if (
task &&
task.taskTitle &&
task.actionShowName &&
task.prizedType &&
Number(task.prizedAmount) &&
Number(waitToGetInterestAmount)
) {
// deal with the valid task
}
Good
定义 isValidTask 函数:
function isValidTask(task) {
return task &&
task.taskTitle &&
task.actionShowName &&
task.prizedType &&
Number(task.prizedAmount)
;
}
使用该函数,函数名自我解释且逻辑分组,代码更清晰 👍🏻。
if (isValidTask(task) && Number(waitToGetInterestAmount)) {
// deal with the valid task
}
尽快 Return
异常或特殊逻辑放到函数最上方,尽快退出函数,一方面可减少嵌套,另一方面能减轻代码阅读者的心智负担。
Bad
嵌套逻辑过多,进入 else 分支必须将之前的 if 条件时刻 bearing in mind 🕷😓。
function foo() {
if (result.data?.length > 0) {
// 过滤未认证的车辆
if (result.data.some(item => !item.certificated)) {
commit('setState', { isCertificated: false });
}else {
// 过滤出 “已经认证过的车辆”
const vehicleInfoList = (result.data).filter(item => item.certificated);
// 判断是否有未同步车辆
const showSyncTips = vehicleInfoList.filter(item => !item.isSyncedCertFolder).length > 0;
commit('setState', {
vehicleInfoList,
showSyncTips,
});
}
}
}
Good
尽早退出函数,减轻代码阅读者心智,而且也减少了嵌套,更赏心悦目。这样写的另一个好处假如 if 部分是新加入的逻辑, diff 噪音更少。
function foo() {
const { data = [] } = result;
const uncertifiedPlateNumberExisting = data.some(item => !item.certificated);
if (uncertifiedPlateNumberExisting) {
commit('setState', { uncertifiedPlateNumberExisting: true });
return;
}
// 过滤出 “已经认证过的车辆”
const vehicleInfoList = data.filter(item => item.certificated);
// 判断是否有未同步车辆
const showSyncTips = vehicleInfoList.filter(item => !item.isSyncedCertFolder).length > 0;
commit('setState', {
vehicleInfoList,
showSyncTips,
});
}
逆向逻辑不可取,徒增理解难度
人思考问题通常习惯正向逻辑,逆向逻辑通常会导致双重否定从而含义不明显。
let isError: boolean; // NOT: isNoError
let isFound: boolean; // NOT: isNotFound
Bad
<view a:if="{{ !visible }}"> B </view>
<view a:else> A </view>
Good
采用正向逻辑,当 visible 则显示 A,否则显示 B
<view a:if="{{ visible }}"> A </view>
<view a:else> B </view>
Bad
flag 对应的注释是 “占位符对应的值是否不存在”,这就是逆向逻辑。其次好的命名无需注释
function replaceAdditionInfo(url = '', params = {}) {
let flag = false; // 占位符对应的值是否不存在
const resultStr = url.replace(/#(\w+)#/g, function($, $1) {
let res = params[$1];
if (!res) flag = true;
return res || '';
});
return flag ? '' : resultStr;
}
Good
函数重命名『replacePlaceholder』,标志位采用正向逻辑,逻辑更清晰,理解成本瞬间降低 👍
function replacePlaceholder(url = '', params = {}) {
let placeholderExisting = true; // 采用正向逻辑
const resultStr = url.replace(/#(\w+)#/g, function($, $1) {
const value = params[$1];
if (!value) { placeholderExisting = false; }
return value || '';
});
return placeholderExisting ? resultStr : '';
}
Bad
若版本号小于 x,则不能使用 cdp,『return false 不能使用』带入的『逆向』导致代码不容易看懂。
const canIUseCdp = async () => {
const version = await getVersion();
if (compareVersion(version, USABLE_VERSION) === -1) {
return false;
} else {
return true;
}
};
Good
采用正向逻辑,若大于 x 则可以使用 cdp,清晰符合直觉 👍
const canIUseCdp = async () => {
const version = await getVersion();
return compareVersion(version, USABLE_VERSION) > 0;
};
一致性原则
同一个变量不同上下文中含义也必须保持一致,不至于引起逻辑混乱。
Bad
indexOfCurrentShowTab 在第二行的含义是服务端放置 taskId 的位置,即首位,应该是常量;在第九行是新补充的任务的所在下标,二者使用同一变量会导致逻辑混乱。
// 默认后端将把对应的 taskId 的数据放到数组的首位
let currentTaskIdIndex = 0;
// 如果未按约定把对应的 taskId 的数据放到首位视为异常
if (showTabs[currentTaskIdIndex]?.taskId !== currentTabTaskId) {
const anotherTabTaskId = tabs[activeTab === 0 ? 1 : 0]?.taskId;
// 此时尽量去补一个 tab, 但是要避过已有 tab, 避免出现两个相同 tab
currentTaskIdIndex = showTabs.findIndex(({ taskId = '' }) => taskId !== anotherTabTaskId);
// ...
}
Good
通过两个变量 currentTaskIdIndex 、 newTaskIndex 将职责分开,逻辑变得清晰 👍
// 默认后端将把对应的 taskId 的数据放到数组的首位
let currentTaskIdIndex = 0;
let newTaskIndex;
// 如果未按约定把对应的 taskId 的数据放到首位视为异常
if (showTabs[currentTaskIdIndex]?.taskId !== currentTabTaskId) {
const anotherTabTaskId = tabs[activeTab === 0 ? 1 : 0]?.taskId;
// 此时尽量去补一个 tab, 但是要避过已有 tab, 避免出现两个相同 tab
newTaskIndex = showTabs.findIndex(({ taskId = '' }) => taskId !== anotherTabTaskId);
// ...
}
消除魔法数字或字符串
本质上还是 DRY(Don't Repeat Yourself)和代码即文档、可扩展性的原则体现。
不要通过注释去消除,请通过常量、enum 等代码手段去消除。收益是代码即文档,以及后续若有修改可统一收口,无需搜索全部替换。
Bad
状态 Start 多次出现,能确保下一次自己或新人接手后不会写成小写的 start 或 START?若重构需改成更规范的 START,还需要全局搜索,确保无一遗漏,人工成本过高,导致不敢重构。
Store({
state: {
deviceStatus: 'Start',
pageStatus: 'Start',
},
actions: {
async openDeviceDoor({ commit }, payload) {
// ...
commit('changeDeviceStatus', 'Start');
commit('changePageStatus', 'Start');
// ...
},
async pollingDeviceStatus({ state, commit }, payload) {
const { prevStatus, deviceId } = payload;
// 不同过程轮询时间不一样长,丢垃圾应该等待更长的时间。
let times = 0;
switch (prevStatus) {
case 'Start':
times = 200;
break;
case 'Opened':
times = 200;
break;
case 'Delivered':
times = 200;
break;
default:
}
},
},
})
Good
使用枚举集中管理所有状态,既解决了手写或复制带来的拼写不确定性,加上注释让接手代码者更清晰状态的种类,改一处所有使用处无需改变,让重构来的更有信心,而且可隔离后端『脏』命名 👍
const DoorStatusEnum = {
START: 'Start',
OPENED: 'Opened',
DELIVERED: 'Delivered',
APPRAISED: 'Appraised',
CLASSIFIED: 'Classified',
FAILED: 'Failed',
// 隔离后端『脏』命名
SYSTEM_BUSY: 'systemBusy',
}
冗余的类型标注
[建议] 利用 TS 类型推导,消除冗余注释。
Bad
str 和 count 有默认值,一眼可看出其类型,无需增加类型
function repeat(str: string = '', count: number = 0) {
let result = '';
for (let index = 0; index < count; index += 1) {
result += str;
}
return result;
}
Good
function repeat(str = '', count = 0) {
let result = '';
for (let index = 0; index < count; index += 1) {
result += str;
}
return result;
}
缺少必要的类型
函数类型定义是契约,是函数实现的约束,完整的类型会促使开发者设计出更符合预期的函数。
Bad
返回值缺少类型标注
function repeat(str = '', count = 0) {
let result = '';
for (let index = 0; index < count; index += 1) {
result += str;
}
return result;
}
Good
function repeat(str = '', count = 0): string {
let result = '';
for (let index = 0; index < count; index += 1) {
result += str;
}
return result;
}