重新分配变量就像改变过去一样。当你看到
let pizza = { fillings: ['salami', 'mozzarella'] };
你不能确定你的比萨饼里永远有萨拉米和马苏里拉奶酪,因为。
- 变量可以被重新分配一个新的值,甚至是另一种类型的值。
- 值,如果是一个数组或一个对象,可以被改变。
知道这两种情况都是可能的,就会使你每次在代码中看到pizza ,就会想到它现在是哪个值。这是一个巨大而不必要的认知负担,我们应该避免。
而大多数时候,你可以避免这两种情况。让我们从重新赋值开始,在下一章再来讨论突变。
不要重复使用变量
有时候,一个变量被重复使用来存储不同的值:
function getProductsOnSale(category) {
category = loadCategory(category);
category = category.filter(product => product.onSale);
return category;
}
在这里,category 变量被用来存储一个类别的ID,一个类别中的产品列表,以及一个过滤后的产品列表。这个函数并不是完全没有希望,因为它很短,但是想象一下,在重新赋值之间有更多的代码。
同时,一个新的值被重新分配给一个函数参数,这被称为函数参数阴影。我认为这和普通的重新赋值没有什么区别,所以我也会以同样的方式对待它。
这种情况是最容易解决的:我们需要为每个值使用单独的变量:
function getProductsOnSale(categoryId) {
const products = loadCategory(categoryId);
return products.filter(product => product.onSale);
}
通过这样做,我们使每个变量的寿命变短,并选择更清晰的名字,所以代码更容易理解,我们需要阅读更少的代码来找出每个变量的当前(现在是唯一)值。
增量计算
重配的最常见的使用情况可能是增量计算。考虑一下这个例子:
const validateVideo = (video) => {
let errors = '';
if (!validateHeightWidthConsistency(video.videoFiles)) {errors = errors + ERROR_MESSAGES.InconsistentWidthHeight;} // Must provide either both a height + width, or neither
if (!validateVideoFileAndUrl(video.videoFiles)) {errors = errors + ERROR_MESSAGES.InvalidVideoFiles;} // Must have ONE OF either a file or a URL
if (!validateVideoURL(video.videoFiles)) {errors = errors + ERROR_MESSAGES.InvalidVideoURL;} // Video URL must be a valid link
if (!video[INPUT_TYPES.Title]) {errors = errors + ERROR_MESSAGES.BlankTitle;} // Title cannot be blank
if (!video[INPUT_TYPES.Id].match(ID_PATTERN) !== false) {errors = errors + ERROR_MESSAGES.InvalidId;} // ID must be alphanumeric
return errors;
};
我把注释缩短了一些,原来的代码有超过200个字符的行。如果你有一个非常大的屏幕,它看起来就像一个漂亮的表格,否则就像一个不可读的乱码。任何自动格式化工具,比如Prettier,也会把它弄成不可读的一团糟,所以你不应该依赖手动代码格式化。它也真的很难维护:如果任何 "列 "在你修改后变得比所有现有的 "列 "长,你就必须为所有其他 "列 "调整空白。
总之,这段代码在每次验证失败时都会在errors 字符串变量中附加一条错误信息。但是现在很难看到,因为消息的格式化代码和验证代码混在一起了。这使得它难以阅读和修改。要添加另一个验证,你必须理解并复制格式化代码。或者要把错误打印成一个HTML列表,你必须改变这个函数的每一行。
让我们把验证和格式化分开:
const VIDEO_VALIDATIONS = [
{
// Must provide either both a height + width, or neither
isValid: video =>
validateHeightWidthConsistency(video.videoFiles),
message: ERROR_MESSAGES.InconsistentWidthHeight
},
{
// Must have ONE OF either a file or a URL
isValid: video => validateVideoFileAndUrl(video.videoFiles),
message: ERROR_MESSAGES.InvalidVideoFiles
},
{
// Video URL must be a valid link
isValid: video => validateVideoURL(video.videoFiles),
message: ERROR_MESSAGES.InvalidVideoURL
},
{
// Title cannot be blank
isValid: video => !!video[INPUT_TYPES.Title],
message: ERROR_MESSAGES.BlankTitle
},
{
// ID must be alphanumeric
isValid: video =>
video[INPUT_TYPES.Id].match(ID_PATTERN) !== null,
message: ERROR_MESSAGES.InvalidId
}
];
const validateVideo = video => {
return VIDEO_VALIDATIONS.map(({ isValid, message }) =>
isValid(video) ? undefined : message
).filter(Boolean);
};
const printVideoErrors = video => {
console.log(validateVideo(video).join('\n'));
};
我们已经把验证、验证逻辑和格式化分开。苍蝇分开,肉片分开,就像我们在俄罗斯说的那样。每一段代码都有一个单一的责任和一个单一的理由来改变。现在,验证被声明性地定义,并像表格一样被阅读,而不是与条件和字符串连接混合。我们也把消极的条件*(无效?* )改为积极的*(有效?)*所有这些都提高了代码的可读性和可维护性:更容易看到所有的验证和添加新的验证,因为你不需要知道运行验证或格式化的实现细节。
而且现在很清楚,原来的代码有一个错误:错误信息之间没有空格。
另外现在我们可以交换格式化功能,将错误呈现为一个HTML列表,例如:
function VideoUploader() {
const [video, setVideo] = React.useState();
const errors = validateVideo(video);
return (
<>
<FileUpload value={video} onChange={setVideo} />
{errors.length > 0 && (
<>
<Text variation="error">Nooooo, upload failed:</Text>
<ul>
{errors.map(error => (
<Text key={error} as="li" variation="error">
{error}
</Text>
))}
</ul>
</>
)}
</>
);
}
我们还可以单独测试每个验证。你有没有注意到,在最后一次验证中,我把false 改为null ?这是因为match() ,当没有匹配的时候会返回null ,而不是false 。原来的验证总是返回true 。
我甚至会内联ERROR_MESSAGES 常量,除非它们在其他地方被重复使用。它们并没有真正使代码更容易阅读,但它们使代码更难改变,因为你必须在两个地方进行修改:
const VIDEO_VALIDATIONS = [
{
// Must provide either both a height + width, or neither
isValid: video =>
validateHeightWidthConsistency(video.videoFiles),
message:
'You should provide either both a height and a width, or neither'
}
];
现在,所有你需要接触的添加、删除或改变验证的代码都包含在VIDEO_VALIDATIONS 数组中。把有可能在同一时间被改变的代码放在同一个地方。
构建复杂的对象
重新分配变量的另一个常见原因是建立一个复杂的对象:
let queryValues = {
sortBy: sortField,
orderDesc: sortDirection === SORT_DESCENDING,
words: query
};
if (dateRangeFrom && dateRangeTo) {
queryValues = {
...queryValues,
from: format(dateRangeFrom.setHours(0, 0, 0, 0), DATE_FORMAT),
to: format(dateRangeTo.setHours(23, 59, 59), DATE_FORMAT)
};
}
在这里,我们只在from 和to 属性不为空时才添加。
如果我们教我们的后端忽略空值并一次性构建整个对象,那么代码会更清晰:
const hasDateRange = dateRangeFrom && dateRangeTo;
const queryValues = {
sortBy: sortField,
orderDesc: sortDirection === SORT_DESCENDING,
words: query,
from:
hasDateRange &&
format(dateRangeFrom.setHours(0, 0, 0, 0), DATE_FORMAT),
to:
hasDateRange &&
format(dateRangeTo.setHours(23, 59, 59), DATE_FORMAT)
};
现在查询对象总是有相同的形状,但一些属性可以是undefined 。这段代码感觉更具有声明性,而且更容易理解它在做什么--构建一个对象,并看到这个对象的最终形状。
避免Pascal风格的变量
有些人喜欢在一个函数的开头定义所有的变量。我把这称为Pascal风格,因为在Pascal中,你必须在程序或函数的开头声明所有变量:
function max(num1, num2: integer): integer;
var
result: integer;
begin
if (num1 > num2) then
result := num1
else
result := num2;
max := result;
end;
有些人在不需要这样做的语言中也使用这种风格:
let isFreeDelivery;
// 50 lines of code
if (
[
DELIVERY_METHODS.PIGEON,
DELIVERY_METHODS.TRAIN_CONDUCTOR
].includes(deliveryMethod)
) {
isFreeDelivery = 1;
} else {
isFreeDelivery = 0;
}
// 30 lines of code
submitOrder({
products,
address,
firstName,
lastName,
deliveryMethod,
isFreeDelivery
});
较长的变量寿命使你要经常滚动来了解一个变量的当前值。可能的重新分配使情况更加糟糕。如果一个变量的声明和它的使用之间有50行,那么它可以在这50行中的任何一行被重新分配。
我们可以通过将变量声明尽可能地靠近其用途并避免重新赋值来使代码更加可读:
const isFreeDelivery = [
DELIVERY_METHODS.PIGEON,
DELIVERY_METHODS.TRAIN_CONDUCTOR
].includes(deliveryMethod);
submitOrder({
products,
address,
firstName,
lastName,
deliveryMethod,
isFreeDelivery: isFreeDelivery ? 1 : 0
});
我们已经将isFreeDelivery 变量的寿命从100行缩短到了10行。现在也很清楚,它的值是我们在第一行分配的。
不过不要把它和PascalCase 混在一起,这个命名规则仍然在使用。
避免为函数返回值设置临时变量
当变量被用来保存一个函数的结果时,往往你可以摆脱这个变量:
function areEventsValid(events) {
let isValid = true;
events.forEach(event => {
if (event.fromDate > event.toDate) {
isValid = false;
}
});
return isValid;
}
这里我们要检查每个事件是否有效,用.every() 数组方法会更清楚:
function areEventsValid(events) {
return events.every(event => event.fromDate <= event.toDate);
}
我们还去掉了一个临时变量,避免了重新赋值,并使一个条件成为正数*(是否有效?* ),而不是负数*(是否无效?)*正的条件通常更容易理解。
对于局部变量,你可以使用三元运算符:
const handleChangeEstimationHours = event => {
let estimationHours = event.target.value;
if (estimationHours === '' || estimationHours < 0) {
estimationHours = 0;
}
return { estimationHours };
};
像这样:
const handleChangeEstimationHours = ({ target: { value } }) => {
const estimationHours = value !== '' && value >= 0 ? value : 0;
return { estimationHours };
};
或者你可以提取代码到一个函数:
let rejectionReasons = getAllRejectionReasons();
if (isAdminUser) {
rejectionReasons = rejectionReasons.filter(
reason => reason.value !== REJECTION_REASONS.HAS_SWEAR_WORDS
);
}
像这样:
const getRejectionReasons = isAdminUser => {
const rejectionReasons = getAllRejectionReasons();
if (isAdminUser) {
return rejectionReason.filter(
reason => reason.value !== REJECTION_REASONS.HAS_SWEAR_WORDS
);
}
return rejectionReasons;
};
// --- 8< -- 8< ---
const rejectionReasons = getRejectionReasons(isAdminUser);
这一点不太重要。你可能会说,仅仅因为重新赋值就把代码移到一个新的函数中并不是一个好主意,你可能是对的,所以在这里请你自己做出判断。
不确定的循环
有时候,有一个重新赋值是很好的。不确定的循环,也就是那些我们事先不知道迭代次数的循环,是重新赋值的一个好例子。
考虑一下这个例子:
function getStartOfWeek(selectedDay) {
let startOfWeekDay = selectedDay;
while (startOfWeekDay.getDay() !== WEEK_DAY_MONDAY) {
startOfWeekDay = addDays(startOfWeekDay, -1);
}
return startOfWeekDay;
}
在这里,我们通过在while 循环中向后移动一天,并检查是否已经是星期一,从而找到当前星期的开始。
即使有可能在这里避免重新赋值,它也可能使代码的可读性降低。欢迎尝试并让我知道情况如何。
重配并不是纯粹的邪恶,消灭所有的重配并不会使你的代码变得更好。它们更像是一种标志:如果你看到了重赋,问问自己,如果没有它,重写代码是否会使它更易读。答案没有对错之分,但如果你确实使用了重赋,请把它隔离在一个小函数中,在那里,变量的当前值是什么是很清楚的。
用惯例帮助你的大脑
在上面的所有例子中,我在变量声明中用const 来代替let 。这立即告诉读者,该变量不会被重新赋值。而且你可以肯定,它不会:如果你尝试,编译器会对你大喊。每当你在代码中看到let ,你就知道这段代码可能更复杂,需要更多的脑力来理解。
另一个有用的惯例是为常量使用UPPER_CASE 名称。这告诉读者,这更像是一个配置值,而不是某个计算的结果。这种常量的寿命通常很大:通常是整个模块甚至整个代码库,所以当你阅读代码时,你通常看不到常量的定义,但你仍然可以确定该值从未改变。而在一个函数中使用这样的常量并不会使这个函数变得不纯粹。
在JavaScript中,用const 关键字定义的变量和真正的常量之间有一个重要的区别。前者只是告诉编译器和读者,这个变量不会被重新赋值。第二种是描述值的性质是全局的和静态的,在运行时不会改变。
这两种约定都能减少一点认知负担,使代码更容易理解。
不幸的是,JavaScript没有真正的常量,即使你用const 关键字定义了一个变量,变异仍然是可能的。我们将在下一章中讨论突变问题。
开始思考:
- 使用不同的变量和有意义的名字,而不是为不同的目的重复使用同一个变量。
- 将数据从算法中分离出来,使代码更具有可读性和可维护性。
- 在一个地方建立一个复杂对象的形状,而不是一块一块地建立它。
- 尽可能地在使用变量的地方声明变量,以减少变量的寿命,并使其更容易理解变量的值。
- 将一段代码提取到一个小函数中,避免使用临时变量,而使用函数的返回值。