【转载】抛弃变量,编写更加可读的JavaScript代码

发布于 大漠

动机

理解一段代码最大的阻碍是大量的变量。而变量可以引入状态,这又将成倍的增加复杂性。每一个变量都使得理解代码更加困难。

一个布尔值可以有两个状态,两个布尔值有四个。如果你的方法里有十个布尔值的话,它们可以产生1024种不同的状态。这远远超出人们的理解范畴。正确划分代码的作用域以及利用常量取代变量可以大大增加代码的可读性。从长远来看,几乎在所有情况下这都是最重要的。

命令式编程

命令式编程在今天仍然是主流的编码方式。这是因为执行命令是计算机本来的工作方式。命令式代码非常适合计算机,但它不适合人类。

用命令式的方式编程通常也是很容易的。它只有极少数的限制,而且该架构也规定了你如何组织代码。这使得写代码比读代码更容易。但是考虑到整个生命周期的话,代码是这样一本书 --- 一次编写,多次阅读。出乎意料的是,代码的行数并没有多大意义。只要代码是可读的,容易修改的,即使它比较长也是可以的。

JavaScript本质上是命令式语言,它也有一个动态类型系统。在静态类型语言,类型系统提供了一些有关变量的线索。但是在使用JavaScript时,却没有这种安全性。人们一直在想方设法解决这个问题。首先产生了 JSDoc annotations,然后是 Flow 以及现在的 TypeScript。它们的目的是为开发人员提供其他语言所拥有的舒适性。静态检查可以发现一些容易出bug的代码的写法,在一定程度上也可以提供帮助。

不使用变量

函数式语言并没有变量的概念,只有一些值,实际上就是常量。所有这些值也是不可修改的。这听起来似乎有悖常理,但它允许结构复用,使得操作在拥有不变性这个优点的同时变得更为有效。

命令式语言的编程者们常见的反驳是,函数式代码执行效率不是很高,浪费了大量的计算机资源。如果可以在一个简单的for循环里修改集合的话,每次复制和重新创建整个结构都会增加运行的复杂性。

但在实践中,大多数时间你不会注意到其中的差别。如果你打开一个分析工具并且仔细观察,可能会看到某部分代码运行了3毫秒,而不是1毫秒,但是你不会注意到一个按钮在点击之后产生的延迟。在某些情况下,比如一些复杂的数学计算,会有很大的区别。但是,请记住,不可变结构可能更容易控制。例如,如果使用React框架,你就可以放心的忽略没有变化的子树,而且不会遇到那些讨厌的类似应该更新而不更新的错误。

专注与那些你认为慢的部分,但是不要过早的去优化。每一次优化都是有代价的,因为它本质上是从人到电脑的可读性的转变。如果你的项目已经很快的话,那么就继续保持代码的干净和可读性。

怎样实现

使用常量

首先,你要做的最重要的事情就是将声明的变量替换为常量。通过使用一款适当的静态检查工具(例如 ESLint,使用 no-const-assign 规则),你可以在编译的时候发现无效的变量修改,这使得bug更容易被发现。

让我们设想一下下面的情景。有一群企鹅,我们希望知道雄性企鹅的平均年龄。如果用命令式编程方式,可以写成这样:

function avgAgeOfMales(penguins) {
    var total = 0;
    var num = 0;
    for (penguin of penguins) {
        if (penguin.male) {
            total += penguin.age;
            num++;
        }
    }
    return total / num;
}

而使用函数式编程方式来解决这个问题,则是这样的:

function avgAgeOfMales(penguins) {
    const males = penguins.filter((penguin) => penguin.male);
    const sumAge = males.reduce((memo, penguin) => {
        return memo + penguin.age;
    }, 0)

    return sumAge / males.length;
}

除了比第一种方法更简短之外,第二种方法还有一个决定性优势,那就是除了return,删除任何一行代码,静态检查都会立即识别出来并产生警告让你知道。

同时也会产生一些其他的效果,就是代码更容易复制粘贴。如果复制丢了部分代码,检测器就会产生一个警告来提醒你。

我们也更容易发现哪些值是多余的不需要的。如果声明了一个常量,但却从来没有用到它,那么你可以安全删除它。可以使用ESLint的no-unused-vars 规则来检测。而在命令式代码中,大多数情况都很难检测到这些。让我们看一下下面的代码,我们读取了一个变量并对它进行赋值,但这个变量并未被真正调用:

var lastDigit = 0;
for (number of numbers) {
    lastDigit = (lastDigit + number) % 10;
}

在上面的代码中。lastDigit这个变量被读取赋值,所以静态检查认为这个变量不是无用的。但如果你之后并没有用过这个变量,你仍可以对它进行安全删除。

但是也有个缺点,就是我们在JavaScript中可能很难编写纯粹的函数式代码。这时候就会产生副作用,它们能轻易地毁掉函数式编程所带来的所有好处。通常,我会遵守下面的这些最佳实践:

  • 将产生副作用的代码写在更容易识别的地方。例如,在代码的底部。这将帮助读代码的人辨别出哪一部分可以进行安全重构,哪一部分是比较危险的。不要在闭包中修改任何代码,因为人们通常不会认为这里会产生副作用。
  • 只将副作用写在短小的函数中。如果函数比较长,将其中的计算部分提取成单独的函数,并进行调用。这会使得复杂的函数更加容易理解。

IIFEs

使用常量能帮助你正确的划分作用域。由于你不能随时修改值,你会发现自己越来越多的使用到 IIFEs (立即执行的函数表达式)。

数组

要想JavaScript代码更加容易理解,最重要的一步就是学习集合的各种函数式方法,包括filter, map, reduce, some, every等等。正确使用这些函数能使你的代码更简短并易于理解。

使用这些函数来优化编程产生的编程模式被称为 collection pipeline(集合管道)。它本质上是一系列对于集合的操作并返回结果。

例如,我们需要按照年龄将雄性企鹅的名字进行分组:

var grouped = {};
for (let penguin of penguins) {
    if (penguin.male) {
        let age = penguin.age;
        if (!grouped[age]) {
            grouped[age] = [];
        }
        grouped[age].push(penguin.name);
    }
}

与之相比,使用 collection pipeline 模式的话,代码如下:

const grouped = penguins
    .filter((penguin) => penguin.male)
    .reduce((memo, penguin) => {
        const current = memo[penguin.age] || [];
        const group = {
            [penguin.age]: [...current, penguin.name]
        };

        return Object.assign({}, memo, group);
    }, {});

Collection pipeline 可以正确划分每一步操作的作用域。你不必检查所有的代码来查看filter的过滤条件,或者每一个group是怎样形成的。每部分代码都写在很容易识别的位置。

当使用reduce操作数组并且要记录多个值时,可以使用展开运算符(Spread operator)。例如,要汇总所有汽车的购物清单,可以这样写:

const aggregated = cars.reduce((memo, car) => {
    const {wheels, transmissions} = memo;

    return {
        wheels: wheels + (car.wheels || 0),
        transmissions: transmissions + (car.transmission || 0)
    };
}, {wheels: 0, transmissions: 0});

由于数组是可变的,你需要保证不去修改它。赋值比较容易分辨,但是push/pop这两个你可能偶尔会用到的函数是可变的。针对push,可以使用展开运算符,比如 const b = […a, 3];。针对pop,暂时没有可替代的函数,但是很多的类库也提供了这个方法。

要保证数组是不可变的,可以使用 Object.freeze(),尽管它叫这个名字,它同样也可以冻结数组,阻止所有的修改。但是要记住,只有在严格模式下它才会抛出异常,其他情况下它会忽略修改。当然,很可能你已经使用严格模式了,如果没有,那建议你使用。

Objects

对象的不可变稍微有些困难,但也是可以实现的。首先要使用括号记法,这样你就可以创建具有可变key值的对象:

function objectWithKey(key) {
    return {[key]: "value"};
}

其次,对象的级联可以用Object.assign来实现。该方法创建了一个浅拷贝,但是没有关系,因为你只使用不可变的值。

const mergedObject = Object.assign({}, objectWithKey("a"), objectWithKey("b"))

下面是一个创建动态对象的例子,让我们来看一下创建汽车的购物清单时会有什么不同:

var shoppingList = {};

if (needsWheels()) {
    shoppingList.wheels = howManyWheels();
}
if (needsTransmission()) {
    shoppingList.transmission = 1;
}

函数式编程的方式如下:

const wheels = (() => {
    if (needsWheels()) {
        return {wheels: howManyWheels()};
    }
})();

const transmission = (() => {
    if (needsTransmission()) {
        return {transmission: 1};
    }
}();

const shoppingList = Object.assign({}, wheels, transmission);

虽然第一种方式比较简短,但是函数式更好的划分了作用域。通过这种方式你可以很清楚的知道wheels的计算是在哪部分实现的。但是使用命令式编程方式,你需要查看所有的代码来确定是否有其他的地方修改了指定的参数。对于小的代码库来说,差别几乎可以忽略,但是随着组件的增多,两种方式还是有很大区别的。

使用立即执行的函数表达式更多的像是对函数的重构,比如说,你可以写getNeededWheels()getNeededTransmissions() 两个函数,然后调用之前的函数;实现的效果是一样的,但是如果这些函数你只用了一次,那最好把相关的代码写在一起。

类库

我上面写的示例都是使用的ES6的函数,还有很多其他的库能提供类似或者更多的函数。你可以查阅 Underscore.js , lodash 以及 immutable-js

如果你使用合适的编译器,比如 Babel的话,那些展开运算符以及丰富的箭头函数同样可以兼容老版本的浏览器。

总结

有许多方式可以编写出易读的代码。我的代码风格受函数式语言影响很大,但是我发现这些规则是写干净代码的基石。遵守这些规则一开始可能不是很容易上手而且有点奇怪,但从长远来看,代码将变得更具有可读性。

本文转载自:众成翻译 译者:泡泡 链接:http://www.zcfy.cc/article/339 原文:https://advancedweb.hu/2016/05/17/more-readable-js-without-vars/Air Jordan 2017 Casual Shoes