声明:实际开发中的代码可能会更加复杂,后续的示例将尽可能简单。
编写简明的 JavaScript 函数需要注重的有以下几点
其中,函数名即变量名,因此我可以参考上一篇关于变量的命名建议来命名。其次,函数参数不宜超过 2 个,单一的函数参数易于测试。
超过两个函数参数就可以使用对象进行传参,并且使用ES6
的解构语法进行解构。使用对象传参和对象解构能提高可读性,阅读者可以通过函数签名轻松了解函数参数。而在函数内,开发者也可以灵活地使选用传入的参数来处理自己的逻辑。
在函数解构的时候将会从传入的对象克隆其原始数据类型的属性,而对象和数组不会进行克隆,在使用的时候需要明确这一点,以免修改了外部的属性,从而引发难以察觉的Bug
。
通过解构还有一个好处,那就是诸如ESLint
此类工具可以检测开发者解构了未使用的变量,这对于优化代码可读性和整洁性很有帮助。
最后,推荐大家保持良好的代码注释。我们可以使用JSDoc
来简单描述函数参数类型和函数功能,以便于后续阅读调用的函数时能提供良好的签名提示。
使用 VSCode 可以安装 JSDoc 的插件,便于编写 Doc
下面是一个例子:
// 不好的例子❌
function createMenu(title, body, buttonText, cancelable) {
// ...
};
// 参数众多,在调用的时候就难以直白地理解其参数的含义
createMenu("Foo", "Bar", "Baz", true);
// 推荐的写法 ✅
/**
* 创建菜单
*
* @param {Object} options - 选项对象
* @param {string} options.title - 菜单标题
* @param {string} options.body - 菜单内容
* @param {string} options.buttonText - 按钮文本
* @param {boolean} options.cancellable - 是否可取消
* @returns {void}
*/
function createMenu({ title, body, buttonText, cancellable }) {
// ...
}
// 即使不看函数签名,光从调用的时候传参的属性名我们就能了解到传如的参数的大致含义
createMenu({
title: "Foo",
body: "Bar",
buttonText: "Baz",
cancellable: true
});
软件工程中最重要的准则之一就是“函数应该只做一件事”。
功能越多的函数,其复杂度就越高,并且难以编写、测试和理解。当我们将函数单独隔离开来,让一个函数仅处理一件事,那么这个函数将会非常易于重构。如果你需要修改某个复杂流程中的某个函数的单一功能,那么修改功能单一的函数就能让复杂度降低到最小。
简单地说,单一功能的函数专注于解决一个具体的问题,并且具有清晰的输入和输出。
举个例子:
// 不推荐的写法 ❌
function emailClients(clients) {
clients.forEach(client => {
const clientRecord = database.lookup(client);
if (clientRecord.isActive()) {
email(client);
}
});
}
// 推荐的写法 ✅
function emailActiveClients(clients) {
clients
.filter(isActiveClient)
.forEach(email);
}
function isActiveClient(client) {
const clientRecord = database.lookup(client);
return clientRecord.isActive();
}
这或许并不是一个很好的例子,但确实能够体现函数仅做一件事的理念。isActiveClient
和email
函数分别实现自己单一的功能:
isActiveClient
:判断客户端是否可用email
:发送邮件而emailClients
函数将所有逻辑全部封装在一起,当我们需要修改逻辑的时候,就需要理解更多的上下文。
来看这个例子,我们需要一个parseBetterJSAlternative()
函数将代码进行解析和构建语法树进行更多的抽象处理:
// 不推荐 ❌
unction parseBetterJSAlternative(code) {
const REGEXES = [
// 若干正则表达式...
];
// 语句拆分
const statements = code.split(" ");
const tokens = [];
REGEXES.forEach(REGEX => {
statements.forEach(statement => {
// 遍历处理语句...
});
});
const ast = [];
tokens.forEach(token => {
// 遍历 token,构建抽象语法树...
});
ast.forEach(node => {
// 解析语法树...
});
}
在上述函数中,函数体内需要处理多层抽象逻辑,接下来我们试试把多层抽象独立出去:
// 推荐 ✅
// 构建 tokens
function tokenize(code) {
const REGEXES = [
// ...
];
const statements = code.split(" ");
const tokens = [];
REGEXES.forEach(REGEX => {
statements.forEach(statement => {
tokens.push(/* ... */);
});
});
return tokens;
}
// 解析 tokens
function parse(tokens) {
const syntaxTree = [];
tokens.forEach(token => {
syntaxTree.push(/* ... */);
});
return syntaxTree;
}
// 单层抽象函数实现
function parseBetterJSAlternative(code) {
const tokens = tokenize(code);
const syntaxTree = parse(tokens);
syntaxTree.forEach(node => {
// parse...
});
}
相较而言,后者对多层抽象进行拆分,可以提高代码的可读性、降低代码的复杂性。
我们可以尽最大努力避免重复代码,即使我们现在很忙。
重复代码是不好的,因为这意味着如果你需要更改某些逻辑,那么就会有多个地方需要更改,这是一件容易遗漏的事情。
重复代码的来源通常是我们需要处理若干件不同的事情,但这些需求里存在通用的逻辑,并且其存在某些特殊的差别让你“不得不”创建多段重复代码来实现这些需求。
为了去除重复代码,我们需要创建一个函数、模块或类来抽象其中的逻辑以应对不同需求的差异。
尽管糟糕的抽象比重复的代码更加恶心,但还是推荐大家去尝试创建良好的抽象逻辑,不要重复编写代码,否则你或其他人可能会在需要修改的时候花费很大的力气。
让我们来看看一个重复的代码示例:
// 不推荐 ❌
function showDeveloperList(developers) {
developers.forEach(developer => {
const expectedSalary = developer.calculateExpectedSalary();
const experience = developer.getExperience();
const githubLink = developer.getGithubLink();
const data = {
expectedSalary,
experience,
githubLink
};
render(data);
});
}
function showManagerList(managers) {
managers.forEach(manager => {
const expectedSalary = manager.calculateExpectedSalary();
const experience = manager.getExperience();
const portfolio = manager.getMBAProjects();
const data = {
expectedSalary,
experience,
portfolio
};
render(data);
});
}
接下来,我们将重复的逻辑抽离出来:
// 推荐 ✅
function showEmployeeList(employees) {
employees.forEach(employee => {
// 一致的逻辑
const expectedSalary = employee.calculateExpectedSalary();
const experience = employee.getExperience();
// 通用的数据
const data = {
expectedSalary,
experience
};
// 单独的属性
switch (employee.type) {
case "manager":
data.portfolio = employee.getMBAProjects();
break;
case "developer":
data.githubLink = employee.getGithubLink();
break;
}
render(data);
});
}
之前提及多个参数的函数建议优化成单一参数并且使用对象传参,这里我们继续来看一个参数是对象的函数例子:
// 不推荐 ❌
const menuConfig = {
title: null,
body: "Bar",
buttonText: null,
cancellable: true
};
function createMenu(config) {
config.title = config.title || "Foo";
config.body = config.body || "Bar";
config.buttonText = config.buttonText || "Baz";
config.cancellable =
config.cancellable !== undefined ? config.cancellable : true;
}
createMenu(menuConfig);
上述代码使用||
符号来设置初始值,可读性不佳。我们可以使用Object.assign
函数来构建一个具有初始化值的新对象:
// 推荐 ✅
const menuConfig = {
title: "Order",
// 参数不包含 body 属性
buttonText: "Send",
cancellable: true
};
function createMenu(config) {
// 克隆的同时,如果不传入某属性,则使用默认值
const finalConfig = Object.assign(
{
title: "Foo",
body: "Bar",
buttonText: "Baz",
cancellable: true
},
config
);
// finalConfig: {title: "Order", body: "Bar", buttonText: "Send", cancellable: true}
// ...
}
createMenu(menuConfig);
接下来看一个使用Flag(标志)
作为参数的函数例子:
// 不推荐 ❌
function createFile(name, temp) {
if (temp) {
fs.create(`./temp/${name}`);
} else {
fs.create(name);
}
}
使用Flag
参数则通常表示函数内部需要处理不同的参数值,这就意味着函数需要处理更多的逻辑问题,相较而言更推荐功能单一的函数:
// 简明且单一的函数是更推荐的写法 ✅
function createFile(name) {
fs.create(name);
}
function createTempFile(name) {
createFile(`./temp/${name}`);
}
在工作中我们需要处理的函数逻辑会更加复杂,与此同时功能单一的函数的好处就更加明显。
单一功能函数在执行过程中如果对外部的环境产生了可观察的影响,那么我们就可以称之为产生了副作用
。
比如:
DOM
在软件开发中函数存在副作用是必不可少的,毕竟我们需要执行一些副作用的逻辑来实现需求。因此,我们可以尽可能地使用单个函数去处理单个的副作用。
接下来来看代码例子:
// 不推荐 ❌
function addToCart(item) {
let cart = localStorage.getItem('cart'); // 从本地存储中获取购物车数据
if (cart) {
cart = JSON.parse(cart);
cart.push(item); // 修改购物车数据
} else {
cart = [item];
}
localStorage.setItem('cart', JSON.stringify(cart)); // 将购物车数据写回本地存储
console.log('Item added to cart');
}
addToCart({ id: 1, name: 'Product A', price: 10 }); // 调用函数
在上述示例中,addToCart
函数用于将商品添加到购物车。函数首先从本地存储中获取购物车数据,然后将新商品添加到购物车数据中,最后将更新后的购物车数据写回本地存储。函数还在控制台输出添加商品到购物车的信息。
该函数存在多个副作用。首先,它修改了本地存储的购物车数据,即全局状态。其次,它依赖于本地存储中的数据进行操作。此外,函数还使用 console.log
方法在控制台输出信息。
为了减少副作用并提高函数的可测试性和可维护性,可以将本地存储的读写操作封装成独立的函数,并将其作为依赖传递给 addToCart
函数:
// 推荐 ✅
function getCartFromLocalStorage() {
const cart = localStorage.getItem('cart');
return cart ? JSON.parse(cart) : [];
}
function saveCartToLocalStorage(cart) {
localStorage.setItem('cart', JSON.stringify(cart));
}
function addToCart(item, getCart, saveCart) {
let cart = getCart();
cart.push(item);
saveCart(cart);
console.log('Item added to cart');
}
addToCart(
{ id: 1, name: 'Product A', price: 10 },
getCartFromLocalStorage,
saveCartToLocalStorage
);
在这个修改后的示例中,我们将本地存储的读写操作封装成了独立的函数 getCartFromLocalStorage
和 saveCartToLocalStorage
。然后,我们修改了 addToCart
函数的签名,使其接受这两个函数作为参数。
在上述代码示例中,我们还可以继续抽象出通用的
localStorage
读取和存储的函数,后续可以更好地复用。
这样,函数内部不再直接操作全局状态,而是通过传递的函数来获取和保存购物车数据。这样做可以减少副作用,使函数更加可控、可测试和可维护。
在JavaScript
中,我们可以给内置对象的原型赋值以增强其灵活性,举个例子:
// 不推荐 ❌
Array.prototype.diff = function diff(comparisonArray) {
const hash = new Set(comparisonArray);
return this.filter(elem => !hash.has(elem));
};
上述代码为数组原型添加了一个diff
函数,如此一来开发者便可以在不同的地方直接调用这个函数。这是一个非常灵活的机制,但或许过于灵活了。
这样的代码通常在某些新特性的polyfill
代码中出现,并不推荐我们在日常的开发中添加这样的函数,同样的运行环境中可能存在不同的人开发的代码功能,编写这样的全局函数可能会带来难以预测的问题。
看示例:
// 不推荐 ❌
// 如果请求正在发送中,并且当前列表为空
if(request.isLoading === true && list.length === 0) {
// ...
}
如上所示是非常常见的逻辑条件判断的代码,更推荐的做法是可以将这类条件封装成起来使用:
// 推荐 ✅
const shouldShowSpinner = (request, list) => {
return request.isLoading && isEmpty(list)
}
if(shouldShowSpinner(request, list)) {
//...
}
不好的例子❌:
function isClientNotPrepare(client) {
// ...
}
if(!isClientNotPrepare(client)) {
// ...
}
推荐的做法✅:
function isClientPrepare(client) {
// ...
}
if(isClientPrepare(client)) {
// ...
}
不再使用的代码跟重复的代码一样糟糕,尽管删除这些遗留无用的代码吧,如果你确实需要这些代码,那么你还是可以从代码版本管理工具中找到它们。
不推荐的代码 ❌
function oldRequestModule(url) {
// ...
}
function newRequestModule(url) {
// ...
}
const req = newRequestModule;
inventoryTracker("apples", req, "www.inventory-awesome.io");
推荐将oldRequestModule
函数清理掉。
JavaScript 函数应该避免回调地狱,这是因为回调地狱会导致代码难以理解、调试和维护。
假设我们有一个需求:从服务器获取用户信息,然后根据用户信息获取其订单列表,最后获取每个订单的详细信息。如果使用回调函数来实现,可能会出现回调地狱的情况。
以下是代码示例:
getUserInfo(function(userInfo) {
getOrderList(userInfo.userId, function(orderList) {
orderList.forEach(function(order) {
getOrderDetails(order.orderId, function(orderDetails) {
// 处理订单详细信息
console.log(orderDetails);
});
});
});
});
在上面的代码中,每个异步操作都有一个回调函数,而且这些回调函数嵌套在一起,形成了回调地狱。这使得代码难以阅读和理解,而且在处理错误和异常时也变得困难。
为了避免回调地狱,可以使用一些现代的 JavaScript 技术,例如 Promise、async/await 或使用异步库(如 async.js 或 Bluebird)来处理异步操作。
下面是使用 Promise 和 async/await 改写上述代码的示例:
getUserInfo()
.then(function(userInfo) {
return getOrderList(userInfo.userId);
})
.then(function(orderList) {
return Promise.all(orderList.map(function(order) {
return getOrderDetails(order.orderId);
}));
})
.then(function(orderDetailsList) {
orderDetailsList.forEach(function(orderDetails) {
// 处理订单详细信息
console.log(orderDetails);
});
})
.catch(function(error) {
// 处理错误
console.error(error);
});
使用 async/await:
async function processOrders() {
try {
const userInfo = await getUserInfo();
const orderList = await getOrderList(userInfo.userId);
const orderDetailsList = await Promise.all(orderList.map(async function(order) {
return await getOrderDetails(order.orderId);
}));
orderDetailsList.forEach(function(orderDetails) {
// 处理订单详细信息
console.log(orderDetails);
});
} catch (error) {
// 处理错误
console.error(error);
}
}
processOrders();
相较之下,使用async/await
语法糖是最简洁的,笔者也比较推荐大家保持async/await
语法糖的代码风格。
好了,关于函数的分享就到此为止了,后续笔者会继续分享class
的建议。