JavaScript 优化(三)精简 if

2,081 阅读9分钟
原文链接: jrsinclair.com

This is part three of a series of articles on reducing complexity in JavaScript code. In previous articles, we suggested that indentation is an indicator of complexity. It is not an accurate or comprehensive indicator, but it can be a helpful guide. We then looked at how we can replace loops, in particular, with higher level abstractions. In this post, we turn our attention to conditionals.

Unfortunately, we can’t get rid of conditionals completely. It would mean drastically re-architecting most code bases. (Though it is technically possible). But, we can change the way we write conditionals to make them less complex. We will look at two strategies for dealing with if-statements. After that, we will turn our attention to switch-statements.

Ifs without else—a quick fix

The first approach for refactoring conditionals is getting rid of else. We just write our code as if there was no else statement in JavaScript. This may seem like an odd thing to do. But most of the time, we simply don’t need else.

Imagine we’re developing a website for ‘scientificists’ researching the luminiferous aether. Each scientificist has a notification menu that we load via AJAX. We have some code for rendering the menu once the data is loaded:

function renderMenu(menuData) {
    let menuHTML = '';
    if ((menuData === null) || (!Array.isArray(menuData)) {
        menuHTML = '<div class="menu-error">Most profuse apologies. Our server seems to have failed in it’s duties</div>';
    } else if (menuData.length === 0) {
        menuHTML = '<div class="menu no-notifications">No new notifications</div>';
    } else {
        menuHTML = '<ul class="menu notifications">'
            + menuData.map((item) => `<li><a href="${item.link}">${item.content}</a></li>`).join('')
            + '</ul>';
    }
    return menuHTML;
}

This code works. But once we’ve determined that there are no notifications to render, what is the point of hanging around? Why not just return the menuHTML straight away? Let’s refactor and see what it looks like:

function renderMenu(menuData) {
    if ((menuData === null) || (!Array.isArray(menuData)) {
        return '<div class="menu-error">Most profuse apologies. Our server seems to have failed in it’s duties</div>';
    }
    if (menuData.length === 0) {
        return '<div class="menu-no-notifications">No new notifications</div>';
    }

    return '<ul class="menu-notifications">'
        + menuData.map((item) => `<li><a href="${item.link}">${item.content}</a></li>`).join('')
        + '</ul>';
}

So, we’ve changed the code such that if we hit an edge case, we just return something and get out of there. For the reader, if this edge case is all you’re concerned about, there’s no need to read any further. We know there cannot be any relevant code after the if-statement. No need to scan down and check, just in case.

The other benefit to this code is that the ‘main’ path (where we return a list) has dropped a level of indentation. This makes it easier to see that this is the expected ‘usual’ path through the code. The if-statements are for handling exceptions to the main path. This makes the intention of our code clearer.

This tactic of not using else is a subset of a broader strategy I call ‘Return early. Return often’. In general, I find it makes code clearer and can sometimes reduce computation. For example, in the previous article we looked at find():

function find(predicate, arr) {
    for (let item of arr) {
        if (predicate(item)) {
            return item;
        }
    }
}

In the find() function, we return out of our loop early, as soon as we find the item we’re looking for. This makes the code more efficient.

Return early. Return often.

Removing else is a good start, but still, leaves us with a lot of indentation. A slightly better tactic is to embrace ternary operators.

Don’t fear the ternary

Ternary operators have a bad reputation for making code less readable. And I will say up front that you should never nest ternaries if you can help it. Nesting ternaries does make code incredibly hard to read. [1] But, ternaries have a massive advantage over traditional if-statements. But to show why we have to dig a little deeper into what if-statements do. Let’s look at an example:

let foo;
if (bar === 'some value') {
    foo = baz;
}
else {
    foo = bar;
}

This is pretty straightforward. But what happens if we wrap the blocks in immediately-invoked-function-expressions (IIFEs)?

let foo;
if (bar === 'some value') (function() {
    foo = baz;
}())
else (function() {
        foo = qux;
}());

So far, we’ve changed nothing, both code samples do the same thing. But notice that neither IIFE returns anything. This means that it is impure. This is to be expected since we’re just replicating the original if-statement. But could we refactor these IIFEs to be pure functions? … Actually, no. We can’t. At least, not with one function per block. The reason we can’t is that the if-statement doesn’t return anything. There is a proposal to change this. But for now, we have to accept that unless we return early, if-statements are going to be locally impure. To do anything useful we either have to mutate a variable or cause a side effect inside one of those blocks. Unless we return early, that is.

But… what if we wrapped a function around the whole if-statement? Could we make the wrapper function pure? Let’s try. First, we wrap the whole if-statement in an IIFE:

let foo = null;
(function() {
    if (bar === 'some value') {
        foo = baz;
    }
    else {
        foo = qux;
    }
})();

Then we move things around so that we return values from our IIFE:

let foo = (function() {
    if (bar === 'some value') {
        return baz;
    }
    else {
        return qux;
    }
})();

This is an improvement because we’re no longer mutating any variables. Our IIFE knows nothing about foo. But it’s still accessing variables from outside its scope: bar, baz, and qux. Let’s deal with baz and qux first. We’ll make them parameters to our function (note the last line):

let foo = (function(returnForTrue, returnForFalse) {
    if (bar === 'some value') {
        return returnForTrue;
    }
    else {
        return returnForFalse;
    }
})(baz, qux);

Finally, we need to deal with bar. We could just pass it in as a variable too, but then we’d always be tied comparing it to ‘some value’. We could add a bit more flexibility if we make the whole condition a parameter:

    let foo = (function(returnForTrue, returnForFalse, condition) {
        if (condition) {
            return returnForTrue;
        }
        else {
            return returnForFalse;
        }
    })(baz, qux, (bar === 'some value'));

Now we can move our function out on its own (and get rid of else while we’re at it):

function conditional(returnForTrue, returnForFalse, condition) {
    if (condition) {
        return returnForTrue;
    }
    return returnForFalse;
}

let foo = conditional(baz, qux, (bar === 'some value'));

So… what have we done? We’ve created an abstraction for if-statements that set a value. If we wanted to, we could refactor (almost) all our if-statements in this way, so long as they are setting a value. As a result, instead of if-statements everywhere, we have pure function calls. We would remove a bunch of indentation and improve the code.

But… we don’t really need conditional(). We already have the ternary operator that does exactly the same thing:

    let foo = (bar === 'some value') ? baz : qux;

The ternary operator is terse, and built-in to the language. We don’t have to write or import a special function to get all the same advantages. The only real disadvantage is that you can’t really use curry() and compose() with ternaries.[2] So, give it a try. See if you can refactor your if-statements with ternaries. At the very least you will gain a new perspective on how to structure code.

Switching out switches

JavaScript has another conditional construct, as well as if-statements. The switch-statement is another control structure that introduces indentation, and with it, complexity. In a moment we will look at how to code without switch-statements. But first, I want to say a couple of nice things about them.

Switch-statements are the closest thing we get in JavaScript to pattern matching. [3] And pattern matching is a Good Thing. Pattern matching is what computer scientists recommend we use instead of if-statements. So, it is possible to use switch-statements well.

Switch-statements also allow you to define a single response to multiple cases. This is, again, something like pattern matching in other languages. In some circumstances, this can be very convenient. So again, switch-statements are not always bad.

With those caveats, though, in many circumstances, we should be refactoring switch statements. Let’s look at an example. Recall our luminiferous ether community example. Let’s imagine we have three different types of notification. A scientificist might receive a notification when:

  • Someone cites a paper they’ve written;

  • Someone starts ‘following’ their work; or

  • Someone mentions them in a post.

We have a different icon and text format that we’d like to display for each type of notification.

let notificationPtrn;
switch (notification.type) {
    case 'citation':
        notificationPtrn = 'You received a citation from {{actingUser}}.';
        break;
    case 'follow':
        notificationPtrn = '{{actingUser}} started following your work';
        break;
    case 'mention':
        notificationPtrn = '{{actingUser}} mentioned you in a post.';
        break;
    default:
        // Well, this should never happen
}

// Do something with notificationPtrn

One of the things that make switch-statements a bit nasty, is that it’s far too easy to forget a break. But if we turn this into a function, we can use our ‘return early, return often’ trick from before. This means we can get rid of the break statements:

    function getnotificationPtrn(n) {
        switch (n.type) {
            case 'citation':
                return 'You received a citation from {{actingUser}}.';
            case 'follow':
                return '{{actingUser}} started following your work';
            case 'mention':
                return '{{actingUser}} mentioned you in a post.';
            default:
                // Well, this should never happen
        }
    }

    let notificationPtrn = getNotificationPtrn(notification);

This is much better. We now have a pure function instead of mutating a variable. But, we could also get the same result using a plain ol’ JavaScript object (POJO):

function getNotificationPtrn(n) {
    const textOptions = {
        citation: 'You received a citation from {{actingUser}}.',
        follow:   '{{actingUser}} started following your work',
        mention:  '{{actingUser}} mentioned you in a post.',
    }
    return textOptions[n.type];
}

This produces the same result as the previous version of getnotificationPtrn(). It is more compact. But is it more simple?

What we have done is replace a control structure with a data. This is more significant than it sounds. Now, if we wanted to, we could make textOptions a parameter of getNotification(). For example:

const textOptions = {
    citation: 'You received a citation from {{actingUser}}.',
    follow:   '{{actingUser}} started following your work',
    mention:  '{{actingUser}} mentioned you in a post.',
}

function getNotificationPtrn(txtOptions, n) {
    return txtOptions[n.type];
}

const notificationPtrn = getNotificationPtrn(txtOptions, notification);

That might not seem terribly interesting at first. But consider that now, textOptions is a variable. And that variable doesn’t have to be hard-coded anymore. We could move it into a JSON configuration file, or fetch it from a server. We can now change textOptions if we want to. We can add extra options, or remove options. We could merge together options from different places. There is also much less indentation in this version…

But, you may have noticed that none of this code deals with the case where we have an unknown notification type. With the switch-statement we have the default option there. We could use it to throw an error if we encounter an unknown type. Or we could return a sensible message to the user. For example:

function getNotificationPtrn(n) {
    switch (n.type) {
        case 'citation':
            return 'You received a citation from {{actingUser}}.';
        case 'follow':
            return '{{actingUser}} started following your work';
        case 'mention':
            return '{{actingUser}} mentioned you in a post.';
        default:
            throw new Error('You’ve received some sort of notification we don’t know about.';
    }
}

We’re now handling the unknown notification case. But we’re back to using switch-statements again. Could we handle this in our POJO option somehow?

One option would be to use an if-statement:

function getNotificationPtrn(txtOptions, n) {
    if (typeof txtOptions[n.type] === 'undefined') {
        return 'You’ve received some sort of notification we don’t know about.';
    }
    return txtOptions[n.type];
}

But we’re trying to cut down on our if-statements. So that’s not ideal either. Instead, we’ll take advantage of JavaScript’s loose typing, combined with some boolean logic. JavaScript will only check the second part of an OR-expression (||), if the first part is falsy. The notification type will be undefined if not found in the object. And JavaScript will interpret undefined as falsy. So, we use the OR-expression like so:

function getNotificationPtrn(txtOptions, n) {
    return txtOptions[n.type]
        || 'You’ve received some sort of notification we don’t know about.';
}

And, we could make that default message a parameter too:

const dflt = 'You’ve received some sort of notification we don’t know about.';

function getNotificationPtrn(defaultTxt, txtOptions, n) {
    return txtOptions[n.type] || defaultTxt;
}

const notificationPtrn = getNotificationPtrn(defaultTxt, txtOptions, notification.type);

Now, is this approach any better than a switch-statement? The answer is, as usual, ‘it depends’. Some might argue that this version is difficult for beginner programmers to read. That is a valid concern. To understand what’s going on, you have to know about how JavaScript coerces values to booleans. But the question to ask is, “Is it difficult because it is complex, or because it is unfamiliar?” Is familiarity a good enough reason to accept more complex code?

But is this code less complex? Let’s look at that last function we created. What if we changed its name to something more general (and tweaked the last parameter)?

    function optionOrDefault(defaultOption, optionsObject, switchValue) {
        return optionsObject[switchValue] || defaultOption;
    }

We could then build our getNotificationPtrn function like so:

    const dflt = 'You’ve received some sort of notification we don’t know about.';

    const textOptions = {
        citation: 'You received a citation from {{actingUser}}.',
        follow:   '{{actingUser}} started following your work',
        mention:  '{{actingUser}} mentioned you in a post.',
    }

    function getNotificationPtrn(notification) {
        return optionOrDefault(dflt, textOptions, notification.type);
    }

What we have now is a very clear separation of concerns. The text options and default message are now pure data. They are no longer embedded in a control structure. We also have a handy function, optionOrDefault(), for building similar types of constructs. The data is cleanly separated from the task of choosing which option to display.

This pattern is handy when we’re dealing with returning static values. In my experience it can replace a switch-statement in around 60–70% of cases. [4] But what if we wanted to do something a little bit more interesting? Imagine, what would happen if our options object contained functions instead of strings? This article is already too long, so we won’t dive into the details here. But it well worth thinking about.

Now, as usual, be careful to use your brain. A function like optionOrDefault() can replace many switch-statements. But not all. There will be some circumstances where it makes more sense to use a switch-statement. And that’s OK.

Summary

Refactoring conditionals is a little bit more work than removing loops. This is partly because we use them in so many different ways. Loops, however, are mainly (but not always) used with arrays. But there are a few simple patterns we can apply that make conditionals less intertwined. They include: ‘return early’, ‘use ternaries’, and ‘replace switch-statements with objects.’ These are not silver bullets, but rather, handy weapons for fighting complexity.


  1. Joel Thom disagrees with me on this point. But, we are both arguing for ternaries over if-statements.  ↩

  2. Curry and compose are tools we use a lot in functional programming. If you’ve not come across them before, have a read of A Gentle Introduction to Functional JavaScript. In particular, Part 3 and Part 4.  ↩

  3. That is, switch-statements look a bit like pattern matching if you squint. But only if you’re using the ‘return early’ technique. If you’re not returning early, then switch-statements are always a mess.  ↩

  4. These figures are a complete guess. I have no data to back them up.  ↩


本文对你有帮助?欢迎扫码加入前端学习小组微信群: