重构 JavaScript 视频商店

计算和格式化视频商店账单的简单示例打开了我的 重构书籍 在 1999 年。如果用现代 JavaScript 完成,你可以选择几个重构方向。我在这里探讨了四种:重构到顶层函数,重构到带有调度器的嵌套函数,使用类,以及使用中间数据结构进行转换。

2016 年 5 月 18 日



很多年前,当我写 重构书籍 时,我用一个(非常)简单的示例来打开这本书,这个示例重构了一些代码,这些代码计算了客户租借一些视频的账单(在那些日子里,我们必须去商店才能做到这一点)。我最近一直在思考这个重构示例,特别是如果用现代 JavaScript 编写它会是什么样子。

任何重构都是为了朝着特定方向改进代码,这个方向适合开发团队的编码风格。在书中,这个示例是用 Java 编写的,而 Java(尤其是当时)建议使用某种编码风格,即面向对象风格。然而,对于 JavaScript 来说,关于要采用哪种风格,有很多选择。虽然你可以使用类似 Java 的 OO 风格,特别是使用 ES6(Ecmascript 2015),但并非所有 JavaScript 专家都赞成这种风格,事实上,许多人认为使用类是一件坏事。

这个最初的视频商店代码

为了进一步探索,我需要介绍一些代码。在这种情况下,是我在世纪之交写下的原始示例的 JavaScript 版本。

  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      let movie = movies[r.movieID];
      let thisAmount = 0;
  
      // determine amount for each movie
      switch (movie.code) {
        case "regular":
          thisAmount = 2;
          if (r.days > 2) {
            thisAmount += (r.days - 2) * 1.5;
          }
          break;
        case "new":
          thisAmount = r.days * 3;
          break;
        case "childrens":
          thisAmount = 1.5;
          if (r.days > 3) {
            thisAmount += (r.days - 3) * 1.5;
          }
          break;
      }
  
      //add frequent renter points
      frequentRenterPoints++;
      // add bonus for a two day new release rental
      if(movie.code === "new" && r.days > 2) frequentRenterPoints++;
  
      //print figures for this rental
      result += `\t${movie.title}\t${thisAmount}\n` ;
      totalAmount += thisAmount;
    }
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;
  }

我在这里使用 ES6。代码操作两个数据结构,它们都是 json 记录的列表。客户记录如下所示

{
  "name": "martin",
  "rentals": [
    {"movieID": "F001", "days": 3},
    {"movieID": "F002", "days": 1},
  ]
}

电影结构如下所示

{
  "F001": {"title": "Ran",                     "code": "regular"},
  "F002": {"title": "Trois Couleurs: Bleu",     "code": "regular"},
  // etc
}

在最初的书中,电影只是作为 java 对象结构中的对象存在。对于这篇文章,我更喜欢将 json 结构作为参数传递进来。我将假设使用某种全局查找,例如 存储库,不适合此应用程序。

statement 方法打印出租赁声明的简单文本输出

Rental Record for martin
  Ran 3.5
  Trois Couleurs: Bleu 2
Amount owed is 5.5
You earned 2 frequent renter points

即使按照示例代码的标准,这个输出也很粗糙。我甚至不能费心体面地格式化数字吗?请记住,这本书是用 Java 1.1 编写的,在 String.format 被添加到语言中之前。这可能部分原谅了我的懒惰。

statement 函数是 长方法 气味的示例。仅仅它的尺寸就足以让我怀疑。但仅仅因为代码闻起来很糟糕,不足以成为重构它的理由。代码结构不良是一个问题,因为它很难理解。难以理解的代码很难修改,无论是添加新功能还是调试。因此,如果你不需要阅读和理解某些代码,那么它的结构不良不会伤害你,你可以放心地暂时不管它。因此,为了激发我们对这段代码片段的兴趣,我们需要一个改变它的理由。我的理由,就像我在书中使用的那样,是编写一个 HTML 版本的 statement 方法,它会打印出类似于以下内容的东西。

<h1>Rental Record for <em>martin</em></h1>
<table>
  <tr><td>Ran</td><td>3.5</td></tr>
  <tr><td>Trois Couleurs: Bleu</td><td>2</td></tr>
</table>
<p>Amount owed is <em>5.5</em></p>
<p>You earned <em>2</em> frequent renter points</p>

正如我之前提到的,在这篇文章中,我正在探索几种可以重构这段代码的方法,以使其更容易添加额外的输出渲染。所有这些方法都有相同的起点:将单个方法分解成一组函数,以捕获逻辑的不同部分。完成此分解后,我将探索四种不同的方法,这些方法可以用来安排这些函数以支持替代渲染。

分解成多个函数

每当我处理像这样的过长函数时,我的第一个想法是使用 提取方法 来寻找逻辑代码块并将它们变成自己的函数。[1] 我首先看到的一个代码块是 switch 语句。

  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      let movie = movies[r.movieID];
      let thisAmount = 0;
  
      // determine amount for each movie
      switch (movie.code) {
        case "regular":
          thisAmount = 2;
          if (r.days > 2) {
            thisAmount += (r.days - 2) * 1.5;
          }
          break;
        case "new":
          thisAmount = r.days * 3;
          break;
        case "childrens":
          thisAmount = 1.5;
          if (r.days > 3) {
            thisAmount += (r.days - 3) * 1.5;
          }
          break;
      }
  
      //add frequent renter points
      frequentRenterPoints++;
      // add bonus for a two day new release rental
      if(movie.code === "new" && r.days > 2) frequentRenterPoints++;
  
      //print figures for this rental
      result += `\t${movie.title}\t${thisAmount}\n` ;
      totalAmount += thisAmount;
    }
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;
  }

我的 IDE(IntelliJ)提供了自动执行此重构的功能,但它没有正确执行 - 它的 JavaScript 能力不如它的 Java 重构那么强大或成熟。因此,我以手动方式执行此操作,这涉及查看候选提取使用的的数据。那里有三个数据位

  • thisAmount 是被提取代码计算的值。我可以在函数内部初始化它,并在最后返回它
  • r 是循环中正在检查的租赁,我可以将其作为参数传递进来。
  • movie 是租赁的电影,它是在前面创建的临时变量。当重构过程式代码时,像这样的临时变量通常会妨碍,因此我更喜欢首先使用 用查询替换临时变量 将它们变成一个可以在任何提取代码中调用的函数。

完成 用查询替换临时变量 后,代码如下所示。

  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      let thisAmount = 0;
  
      // determine amount for each movie
      switch (movieFor(r).code) {
        case "regular":
          thisAmount = 2;
          if (r.days > 2) {
            thisAmount += (r.days - 2) * 1.5;
          }
          break;
        case "new":
          thisAmount = r.days * 3;
          break;
        case "childrens":
          thisAmount = 1.5;
          if (r.days > 3) {
            thisAmount += (r.days - 3) * 1.5;
          }
          break;
      }
  
      //add frequent renter points
      frequentRenterPoints++;
      // add bonus for a two day new release rental
      if(movieFor(r).code === "new" && r.days > 2) frequentRenterPoints++;
  
      //print figures for this rental
      result += `\t${movieFor(r).title}\t${thisAmount}\n` ;
      totalAmount += thisAmount;
    }
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;
  
    function movieFor(rental) {return movies[rental.movieID];}
  }

现在我提取 switch 语句。

  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
  
    for (let r of customer.rentals) {
      const thisAmount = amountFor(r);
  
      //add frequent renter points
      frequentRenterPoints++;
      // add bonus for a two day new release rental
      if(movieFor(r).code === "new" && r.days > 2) frequentRenterPoints++;
  
      //print figures for this rental
      result += `\t${movieFor(r).title}\t${thisAmount}\n` ;
      totalAmount += thisAmount;
    }
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;
  
    function movieFor(rental) {return movies[rental.movieID];}
  
    function amountFor(r) {
      let thisAmount = 0;
  
      // determine amount for each movie
      switch (movieFor(r).code) {
        case "regular":
          thisAmount = 2;
          if (r.days > 2) {
            thisAmount += (r.days - 2) * 1.5;
          }
          break;
        case "new":
          thisAmount = r.days * 3;
          break;
        case "childrens":
          thisAmount = 1.5;
          if (r.days > 3) {
            thisAmount += (r.days - 3) * 1.5;
          }
          break;
      }
      return thisAmount;
    }
  }

现在我将注意力转向计算常客积分。我可以对其代码进行类似的提取

function statement(customer, movies) {
  let totalAmount = 0;
  let frequentRenterPoints = 0;
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    const thisAmount = amountFor(r);
    frequentRenterPointsFor(r);

    //print figures for this rental
    result += `\t${movieFor(r).title}\t${thisAmount}\n` ;
    totalAmount += thisAmount;
  }
  // add footer lines
  result += `Amount owed is ${totalAmount}\n`;
  result += `You earned ${frequentRenterPoints} frequent renter points\n`;

  return result;

  function frequentRenterPointsFor(r) {
   //add frequent renter points
    frequentRenterPoints++;
    // add bonus for a two day new release rental
    if (movieFor(r).code === "new" && r.days > 2) frequentRenterPoints++;
  }

虽然我提取了函数,但我不喜欢它通过更新父作用域变量的方式工作。这种副作用会使代码难以推理,因此我修改它,使其在函数体中没有副作用。

function statement(customer, movies) {
  let totalAmount = 0;
  let frequentRenterPoints = 0;
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    const thisAmount = amountFor(r);
    frequentRenterPoints += frequentRenterPointsFor(r);

    //print figures for this rental
    result += `\t${movieFor(r).title}\t${thisAmount}\n` ;
    totalAmount += thisAmount;
  }
  // add footer lines
  result += `Amount owed is ${totalAmount}\n`;
  result += `You earned ${frequentRenterPoints} frequent renter points\n`;

  return result;

  function frequentRenterPointsFor(r) {
    let result = 1;
    if (movieFor(r).code === "new" && r.days > 2) result++;
    return result;
  }

我趁机清理了两个提取的函数,因为我理解了它们。

  function amountFor(rental) {
    let result = 0;
    switch (movieFor(rental).code) {
      case "regular":
        result = 2;
        if (rental.days > 2) {
          result += (rental.days - 2) * 1.5;
        }
        return result;
      case "new":
        result = rental.days * 3;
        return result;
      case "childrens":
        result = 1.5;
        if (rental.days > 3) {
          result += (rental.days - 3) * 1.5;
        }
        return result;
    }
    return result;
  }

  function frequentRenterPointsFor(rental) {
    return (movieFor(rental).code === "new" && rental.days > 2) ? 2 : 1;
   }

我还可以对这些函数做更多的事情,尤其是 amountFor,这正是我在书中所做的。但在这篇文章中,我不会进一步检查这些函数的函数体。

完成这些后,我回到函数的函数体。

  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      const thisAmount = amountFor(r);
      frequentRenterPoints += frequentRenterPointsFor(r);
  
      //print figures for this rental
      result += `\t${movieFor(r).title}\t${thisAmount}\n` ;
      totalAmount += thisAmount;
    }
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;

我喜欢使用的一种通用策略是摆脱可变变量。这里有三个,一个是收集最终字符串,另外两个计算在该字符串中使用的值。我对第一个没问题,但想消除另外两个。为了开始这样做,我需要拆分循环。首先,我简化循环并内联常量。

  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      frequentRenterPoints += frequentRenterPointsFor(r);
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n` ;
      totalAmount += amountFor(r);
    }
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;

然后我将循环拆分成三个部分。

  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      frequentRenterPoints += frequentRenterPointsFor(r);
    }
    for (let r of customer.rentals) {
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
    }
    for (let r of customer.rentals) {
      totalAmount += amountFor(r);
    }
  
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;

一些程序员担心像这样的重构的性能影响,在这种情况下,请查看一篇关于软件性能的 旧但相关的文章

这种拆分允许我随后为计算提取函数。

  function statement(customer, movies) {
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
    }
    result += `Amount owed is ${totalAmount()}\n`;
    result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
    return result;
  
    function totalAmount() {
      let result = 0;
      for (let r of customer.rentals) {
        result += amountFor(r);
      }
      return result;
    }
    function totalFrequentRenterPoints() {
      let result = 0;
      for (let r of customer.rentals) {
        result += frequentRenterPointsFor(r);
      }
      return result;
    }

作为 集合管道 的粉丝,我还会调整循环以使用它们。

  function totalFrequentRenterPoints() {
    return customer.rentals
      .map((r) => frequentRenterPointsFor(r))
      .reduce((a, b) => a + b, 0)
      ;
  }
  function totalAmount() {
    return customer.rentals
      .reduce((total, r) => total + amountFor(r), 0);
  }

我不确定我最喜欢哪种管道风格。

检查组合函数

所以现在让我们看看我们现在所处的位置。以下是所有代码。

function statement(customer, movies) {
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
  }
  result += `Amount owed is ${totalAmount()}\n`;
  result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
  return result;

  function totalFrequentRenterPoints() {
    return customer.rentals
      .map((r) => frequentRenterPointsFor(r))
      .reduce((a, b) => a + b, 0)
      ;
  }
  function totalAmount() {
    return customer.rentals
      .reduce((total, r) => total + amountFor(r), 0);
  }
  function movieFor(rental) {
    return movies[rental.movieID];
  }
  function amountFor(rental) {
    let result = 0;
    switch (movieFor(rental).code) {
      case "regular":
        result = 2;
        if (rental.days > 2) {
          result += (rental.days - 2) * 1.5;
        }
        return result;
      case "new":
        result = rental.days * 3;
        return result;
      case "childrens":
        result = 1.5;
        if (rental.days > 3) {
          result += (rental.days - 3) * 1.5;
        }
        return result;
    }
    return result;
  }
  function frequentRenterPointsFor(rental) {
    return (movieFor(rental).code === "new" && rental.days > 2) ? 2 : 1;
  }
}

我现在有一个很好地组合的函数。函数的核心代码是 7 行,并且都与格式化输出字符串有关。所有计算代码都移动到它自己的嵌套函数集中,每个函数都很小,并且命名清晰,以显示其目的。

但我还没有完全准备好编写 html 发射函数。分解的函数都嵌套在整个 statement 函数内部,这使得提取函数更容易,因为它们可以引用函数作用域内的名称,包括彼此(例如 amountFor 调用 movieFor)以及提供的参数 customermovie。但我不能编写一个简单的 htmlStatement 函数来引用这些函数。为了能够使用相同的计算来支持一些不同的输出,我需要做一些进一步的重构。现在我到达一个点,我有多种选择,关于要进行哪些重构,这取决于我如何喜欢对代码进行分解。我将在接下来介绍每种方法,解释每种方法的工作原理,然后在完成所有四种方法后进行比较。

使用参数来确定输出

我可以采取的一条路线是将输出格式指定为 statement 函数的参数。我将通过使用 添加参数 开始此重构,提取现有的文本格式化代码,并在开头编写一些代码,当参数指示时,将代码调度到提取的函数。

function statement(customer, movies, format = 'text') {
  switch (format) {
    case "text":
      return textStatement();
  }
  throw new Error(`unknown statement format ${format}`);

  function textStatement() {
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
    }
    result += `Amount owed is ${totalAmount()}\n`;
    result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
    return result;
  }

然后我可以编写 html 生成函数,并在调度器中添加一个子句。

  function statement(customer, movies, format = 'text') {
    switch (format) {
      case "text":
        return textStatement();
      case "html":
        return htmlStatement();
    }
    throw new Error(`unknown statement format ${format}`);
  
    function htmlStatement() {
      let result = `<h1>Rental Record for <em>${customer.name}</em></h1>\n`;
      result += "<table>\n";
      for (let r of customer.rentals) {
        result += `  <tr><td>${movieFor(r).title}</td><td>${amountFor(r)}</td></tr>\n`;
      }
      result += "</table>\n";
      result += `<p>Amount owed is <em>${totalAmount()}</em></p>\n`;
      result += `<p>You earned <em>${totalFrequentRenterPoints()}</em> frequent renter points</p>\n`;
      return result;
    }

我可能喜欢使用数据结构来实现调度器逻辑。

function statement(customer, movies, format = 'text') {
  const dispatchTable = {
    "text": textStatement,
    "html": htmlStatement
  };
  if (undefined === dispatchTable[format]) throw new Error(`unknown statement format ${format}`);
  return dispatchTable[format].call();

使用顶层函数

编写顶层 html statement 函数的问题是,计算函数嵌套在文本 statement 函数内部。因此,一个显而易见的方法是将它们移动到顶层上下文。

为此,我首先寻找不引用任何其他函数的函数,在这种情况下是 movieFor

每当我移动函数时,我都会先将函数复制到新上下文,将其适应该上下文,然后用对移动函数的调用替换原始函数体。

function topMovieFor(rental, movies) {
  return movies[rental.movieID];
}
function statement(customer, movies) {
  // [snip]

  function movieFor(rental) {
    return topMovieFor(rental, movies);
  }

  function frequentRenterPointsFor(rental) {
    return (movieFor(rental).code === "new" && rental.days > 2) ? 2 : 1;
  }

我可以编译和测试此时,这将告诉我上下文的变化是否引起了任何问题。完成此操作后,我可以内联转发函数。

function movieFor(rental, movies) {
  return movies[rental.movieID];
}
function statement(customer, movies) {
  // [snip]

  function frequentRenterPointsFor(rental) {
    return (movieFor(rental, movies).code === "new" && rental.days > 2) ? 2 : 1;
  }

amountFor 内部有一个类似的更改

除了内联之外,我还将顶层函数重命名为与旧名称匹配,因此唯一的区别现在是 movies 参数。

然后我用所有嵌套函数执行此操作

function statement(customer, movies) {
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    result += `\t${movieFor(r, movies).title}\t${amountFor(r, movies)}\n`;
  }
  result += `Amount owed is ${totalAmount(customer, movies)}\n`;
  result += `You earned ${totalFrequentRenterPoints(customer, movies)} frequent renter points\n`;
  return result;
}
function totalFrequentRenterPoints(customer, movies) {
  return customer.rentals
    .map((r) => frequentRenterPointsFor(r, movies))
    .reduce((a, b) => a + b, 0)
    ;
}
function totalAmount(customer, movies) {
  return customer.rentals
    .reduce((total, r) => total + amountFor(r, movies), 0);
}
function movieFor(rental, movies) {
  return movies[rental.movieID];
}
function amountFor(rental, movies) {
  let result = 0;
  switch (movieFor(rental, movies).code) {
    case "regular":
      result = 2;
      if (rental.days > 2) {
        result += (rental.days - 2) * 1.5;
      }
      return result;
    case "new":
      result = rental.days * 3;
      return result;
    case "childrens":
      result = 1.5;
      if (rental.days > 3) {
        result += (rental.days - 3) * 1.5;
      }
      return result;
  }
  return result;
}
function frequentRenterPointsFor(rental, movies) {
  return (movieFor(rental, movies).code === "new" && rental.days > 2) ? 2 : 1;
}

现在我可以轻松地编写 html statement 函数

function htmlStatement(customer, movies) {
  let result = `<h1>Rental Record for <em>${customer.name}</em></h1>\n`;
  result += "<table>\n";
  for (let r of customer.rentals) {
    result += `  <tr><td>${movieFor(r, movies).title}</td><td>${amountFor(r, movies)}</td></tr>\n`;
  }
  result += "</table>\n";
  result += `<p>Amount owed is <em>${totalAmount(customer, movies)}</em></p>\n`;
  result += `<p>You earned <em>${totalFrequentRenterPoints(customer, movies)}</em> frequent renter points</p>\n`;
  return result;
}

声明一些部分应用的局部函数

当使用像这样的全局函数时,参数列表可能会变得很长。因此,有时声明一个局部函数来调用带有部分或全部参数填充的全局函数可能很有用。这个局部函数,它是全局函数的部分应用,可以随后使用。在 JavaScript 中有各种方法可以做到这一点。一种方法是将局部函数分配给变量。

  function htmlStatement(customer, movies) {
    const amount = () => totalAmount(customer, movies);
    const frequentRenterPoints = () => totalFrequentRenterPoints(customer, movies);
    const movie = (aRental) => movieFor(aRental, movies);
    const rentalAmount = (aRental) =>  amountFor(aRental, movies);
    let result = `<h1>Rental Record for <em>${customer.name}</em></h1>\n`;
    result += "<table>\n";
    for (let r of customer.rentals) {
      result += `  <tr><td>${movie(r).title}</td><td>${rentalAmount(r)}</td></tr>\n`;
    }
    result += "</table>\n";
    result += `<p>Amount owed is <em>${amount()}</em></p>\n`;
    result += `<p>You earned <em>${frequentRenterPoints()}</em> frequent renter points</p>\n`;
    return result;
  }

另一种方法是将它们声明为嵌套函数。

  function htmlStatement(customer, movies) {
    let result = `<h1>Rental Record for <em>${customer.name}</em></h1>\n`;
    result += "<table>\n";
    for (let r of customer.rentals) {
      result += `  <tr><td>${movie(r).title}</td><td>${rentalAmount(r)}</td></tr>\n`;
    }
    result += "</table>\n";
    result += `<p>Amount owed is <em>${amount()}</em></p>\n`;
    result += `<p>You earned <em>${frequentRenterPoints()}</em> frequent renter points</p>\n`;
    return result;
  
    function amount() {return totalAmount(customer, movies);}
    function frequentRenterPoints() {return totalFrequentRenterPoints(customer, movies);}
    function rentalAmount(aRental) {return amountFor(aRental, movies);}
    function movie(aRental) {return movieFor(aRental, movies);}
  }

另一种方法是使用 bind. 我会让你自己去查找它 - 这不是我在这里会使用的东西,因为我发现这些形式更容易理解。

使用类

面向对象对我来说很熟悉,因此我考虑类和对象并不奇怪。ES6 引入了经典 OO 的良好语法。让我们看看如何将它应用于这个示例。

我的第一步是将数据包装在对象中,从客户开始。

customer.es6…

  export default class Customer {
    constructor(data) {
      this._data = data;
    }
  
    get name() {return this._data.name;}
    get rentals() { return this._data.rentals;}
  }

statement.es6…

  import Customer from './customer.es6';
  
  function statement(customerArg, movies) {
    const customer = new Customer(customerArg);
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
    }
    result += `Amount owed is ${totalAmount()}\n`;
    result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
    return result;

到目前为止,该类只是原始 JavaScript 对象的简单包装器。接下来,我将对租赁进行类似的包装。

rental.es6…

  export default class Rental {
    constructor(data) {
      this._data = data;
    }
    get days() {return this._data.days}
    get movieID() {return this._data.movieID}
  }

customer.es6…

  import Rental from './rental.es6'
  
  export default class Customer {
    constructor(data) {
      this._data = data;
    }
  
    get name() {return this._data.name;}
    get rentals() { return this._data.rentals.map(r => new Rental(r));}
  }

现在我已经在我的简单 json 对象周围包装了类,我有一个 移动方法 的目标。与将函数移动到顶层一样,第一个要处理的函数是不调用任何其他函数的函数 - movieFor。但此函数需要电影列表作为上下文,这将需要提供给新创建的租赁对象。

statement.es6…

  function statement(customerArg, movies) {
    const customer = new Customer(customerArg, movies);
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
    }
    result += `Amount owed is ${totalAmount()}\n`;
    result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
    return result;

class Customer…

  constructor(data, movies) {
    this._data = data;
    this._movies = movies
  }
  get rentals() { return this._data.rentals.map(r => new Rental(r, this._movies));}

class Rental…

  constructor(data, movies) {
    this._data = data;
    this._movies = movies;
  }

一旦我有了支持数据,我就可以移动函数。

statement.es6…

  function movieFor(rental) {
    return rental.movie;
  }

class Rental…

  get movie() {
    return this._movies[this.movieID];
  }

与我之前进行的移动一样,第一步是将核心行为放在新上下文中,将其适应该上下文,并调整原始函数以调用新函数。一旦这可以工作,内联原始函数调用就相对容易了。

statement.es6…

function statement(customerArg, movies) {
  const customer = new Customer(customerArg, movies);
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    result += `\t${r.movie.title}\t${amountFor(r)}\n`;
  }
  result += `Amount owed is ${totalAmount()}\n`;
  result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
  return result;

  function amountFor(rental) {
    let result = 0;
    switch (rental.movie.code) {
      case "regular":
        result = 2;
        if (rental.days > 2) {
          result += (rental.days - 2) * 1.5;
        }
        return result;
      case "new":
        result = rental.days * 3;
        return result;
      case "childrens":
        result = 1.5;
        if (rental.days > 3) {
          result += (rental.days - 3) * 1.5;
        }
        return result;
    }
    return result;
  }

  function frequentRenterPointsFor(rental) {
    return (rental.movie.code === "new" && rental.days > 2) ? 2 : 1;
  }

我可以使用相同的基本序列将两个计算移动到租赁中。

statement.es6…

function statement(customerArg, movies) {
  const customer = new Customer(customerArg, movies);
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    result += `\t${r.movie.title}\t${r.amount}\n`;
  }
  result += `Amount owed is ${totalAmount()}\n`;
  result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
  return result;

  function totalFrequentRenterPoints() {
    return customer.rentals
      .map((r) => r.frequentRenterPoints)
      .reduce((a, b) => a + b, 0)
      ;
  }
  function totalAmount() {
    return customer.rentals
      .reduce((total, r) => total + r.amount, 0);
  }

class Rental…

  get frequentRenterPoints() {
    return (this.movie.code === "new" && this.days > 2) ? 2 : 1;
  }
  get amount() {
    let result = 0;
    switch (this.movie.code) {
      case "regular":
        result = 2;
        if (this.days > 2) {
          result += (this.days - 2) * 1.5;
        }
        return result;
      case "new":
        result = this.days * 3;
        return result;
      case "childrens":
        result = 1.5;
        if (this.days > 3) {
          result += (this.days - 3) * 1.5;
        }
        return result;
    }
    return result;
  }

然后,我可以将两个总计函数移到客户那里。

statement.es6…

function statement(customerArg, movies) {
  const customer = new Customer(customerArg, movies);
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    result += `\t${r.movie.title}\t${r.amount}\n`;
  }
  result += `Amount owed is ${customer.amount}\n`;
  result += `You earned ${customer.frequentRenterPoints} frequent renter points\n`;
  return result;
}

class Customer…

  get frequentRenterPoints() {
    return this.rentals
      .map((r) => r.frequentRenterPoints)
      .reduce((a, b) => a + b, 0)
      ;
  }
  get amount() {
    return this.rentals
      .reduce((total, r) => total + r.amount, 0);
  }

将计算逻辑移到租赁和客户对象中后,编写该语句的 HTML 版本就变得很简单了。

statement.es6…

  function htmlStatement(customerArg, movies) {
    const customer = new Customer(customerArg, movies);
    let result = `<h1>Rental Record for <em>${customer.name}</em></h1>\n`;
    result += "<table>\n";
    for (let r of customer.rentals) {
      result += `  <tr><td>${r.movie.title}</td><td>${r.amount}</td></tr>\n`;
    }
    result += "</table>\n";
    result += `<p>Amount owed is <em>${customer.amount}</em></p>\n`;
    result += `<p>You earned <em>${customer.frequentRenterPoints}</em> frequent renter points</p>\n`;
    return result;
  }

没有语法类的

ES2015 中的类语法存在争议,有些人认为它没有必要(通常还伴随着对 Java 开发人员的讽刺)。你可以采取完全相同的重构步骤来得到这样的结果。

function statement(customerArg, movies) {
  const customer = createCustomer(customerArg, movies);
  let result = `Rental Record for ${customer.name()}\n`;
  for (let r of customer.rentals()) {
    result += `\t${r.movie().title}\t${r.amount()}\n`;
  }
  result += `Amount owed is ${customer.amount()}\n`;
  result += `You earned ${customer.frequentRenterPoints()} frequent renter points\n`;
  return result;

  
}
function createCustomer(data, movies) {
  return {
    name: () => data.name,
    rentals: rentals,
    amount: amount,
    frequentRenterPoints: frequentRenterPoints
  };

  function rentals() {
    return data.rentals.map(r => createRental(r, movies));
  }
  function frequentRenterPoints() {
    return rentals()
      .map((r) => r.frequentRenterPoints())
      .reduce((a, b) => a + b, 0)
      ;
  }
  function amount() {
    return rentals()
      .reduce((total, r) => total + r.amount(), 0);
  }

}

function createRental(data, movies) {
  return {
    days: () => data.days,
    movieID: () => data.movieID,
    movie: movie,
    amount: amount,
    frequentRenterPoints: frequentRenterPoints
  };

  function movie() {
    return movies[data.movieID];
  }

  function amount() {
    let result = 0;
    switch (movie().code) {
      case "regular":
        result = 2;
        if (data.days > 2) {
          result += (data.days - 2) * 1.5;
        }
        return result;
      case "new":
        result = data.days * 3;
        return result;
      case "childrens":
        result = 1.5;
        if (data.days > 3) {
          result += (data.days - 3) * 1.5;
        }
        return result;
    }
    return result;
  }

  function frequentRenterPoints() {
    return (movie().code === "new" && data.days > 2) ? 2 : 1;
  }


}

这种方法使用了 函数作为对象 模式。构造函数(createCustomercreateRental)返回一个包含函数引用的 JavaScript 对象(哈希表)。每个构造函数都包含一个闭包,用于保存对象的 data。由于返回的函数对象处于相同的函数上下文中,因此它们可以访问此 data。我认为这与使用类语法完全相同,只是实现方式不同。我更喜欢使用显式语法,因为它更明确,从而使我的思路更加清晰。

数据转换

所有这些方法都涉及语句打印函数调用其他函数来计算它们需要的 data。另一种方法是将此 data 传递到 data 结构本身的语句打印函数中。在这种方法中,计算函数用于转换客户 data 结构,使其包含打印函数所需的所有 data。

在重构方面,这是一个尚未编写的 Split Phase 重构的示例,Kent Beck 在去年夏天向我描述了它。通过这种重构,我将计算分为两个阶段,它们使用中间 data 结构进行通信。我通过引入中间 data 结构来开始这种重构。

  function statement(customer, movies) {
    const data = createStatementData(customer, movies);
    let result = `Rental Record for ${data.name}\n`;
    for (let r of data.rentals) {
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
    }
    result += `Amount owed is ${totalAmount()}\n`;
    result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
    return result;
  
    function createStatementData(customer, movies) {
      let result = Object.assign({}, customer);
      return result;
    }

对于这种情况,我正在使用添加的元素来丰富原始的客户 data 结构,因此从调用 Object.assign 开始。我也可以创建一个全新的 data 结构,选择实际上取决于转换后的 data 结构与原始 data 结构的差异程度。

然后,我对每行租赁执行相同的操作。

function statement…

  function createStatementData(customer, movies) {
    let result = Object.assign({}, customer);
    result.rentals = customer.rentals.map(r => createRentalData(r));
    return result;

    function createRentalData(rental) {
      let result = Object.assign({}, rental);
      return result;
    }
  }

请注意,我将 createRentalData 嵌套在 createStatementData 中,因为 createStatementData 的任何调用者都不需要知道内部是如何构建的。

然后,我可以开始填充转换后的 data,从租借电影的标题开始。

function statement(customer, movies) {
  const data = createStatementData(customer, movies);
  let result = `Rental Record for ${data.name}\n`;
  for (let r of data.rentals) {
    result += `\t${r.title}\t${amountFor(r)}\n`;
  }
  result += `Amount owed is ${totalAmount()}\n`;
  result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
  return result;
  //…

  function createStatementData(customer, movies) {
    // …

    function createRentalData(rental) {
      let result = Object.assign({}, rental);
      result.title = movieFor(rental).title;
      return result;
    }
  }

我接着计算金额,然后是总计。

function statement(customer, movies) {
  const data = createStatementData(customer, movies);
  let result = `Rental Record for ${data.name}\n`;
  for (let r of data.rentals) {
    result += `\t${r.title}\t${r.amount}\n`;
  }
  result += `Amount owed is ${data.totalAmount}\n`;
  result += `You earned ${data.totalFrequentRenterPoints} frequent renter points\n`;
  return result;

  function createStatementData(customer, movies) {
    let result = Object.assign({}, customer);
    result.rentals = customer.rentals.map(r => createRentalData(r));
    result.totalAmount = totalAmount();
    result.totalFrequentRenterPoints = totalFrequentRenterPoints();
    return result;

    function createRentalData(rental) {
      let result = Object.assign({}, rental);
      result.title = movieFor(rental).title;
      result.amount = amountFor(rental);
      return result;
    }
  }

现在,我已经让所有计算函数将计算结果作为 data 放置,我可以将这些函数移开,使它们与语句渲染函数分离。首先,我将所有计算函数移到 createStatementData 中。

function statement (customer, movies) {
  // body …
  function createStatementData (customer, movies) {
    // body …

    function createRentalData(rental) { … }
    function totalFrequentRenterPoints() { … }
    function totalAmount() { … }
    function movieFor(rental) { … }
    function amountFor(rental) { … }
    function frequentRenterPointsFor(rental) { … }
  }
}

然后,我将 createStatementData 移到 statement 的外部。

function statement (customer, movies) { … }

function createStatementData (customer, movies) {
  function createRentalData(rental) { … }
  function totalFrequentRenterPoints() { … }
  function totalAmount() { … }
  function movieFor(rental) { … }
  function amountFor(rental) { … }
  function frequentRenterPointsFor(rental) { … }
}

将函数分离后,我可以编写语句的 HTML 版本来使用相同的 data 结构。

function htmlStatement(customer, movies) {
  const data = createStatementData(customer, movies);
  let result = `<h1>Rental Record for <em>${data.name}</em></h1>\n`;
  result += "<table>\n";
  for (let r of data.rentals) {
    result += `  <tr><td>${r.title}</td><td>${r.amount}</td></tr>\n`;
  }
  result += "</table>\n";
  result += `<p>Amount owed is <em>${data.totalAmount}</em></p>\n`;
  result += `<p>You earned <em>${data.totalFrequentRenterPoints}</em> frequent renter points</p>\n`;
  return result;
}

我还可以将 createStatementData 移到一个单独的模块中,以进一步明确计算 data 和渲染语句之间的边界。

statement.es6

  import createStatementData from './createStatementData.es6';
  function htmlStatement(customer, movies) { … }
  function statement(customer, movies) { … }

createStatementData.es6

  export default function createStatementData (customer, movies) {
    function createRentalData(rental) { … }
    function totalFrequentRenterPoints() { … }
    function totalAmount() { … }
    function movieFor(rental) { … }
    function amountFor(rental) { … }
    function frequentRenterPointsFor(rental) { … }
  }

比较方法

所以现在是时候退一步看看我得到了什么。我有一个最初的代码体,它被写成一个单一的内联函数。我希望重构此代码以启用 HTML 渲染,而无需重复计算代码。我的第一步是将此代码分解成几个函数,这些函数存在于原始函数中。从那里,我探索了四条不同的路径。

顶级函数

将所有函数写成顶级函数

    function htmlStatement(customer, movies)

    function textStatement(customer, movies)

    function totalAmount(customer, movies)

    function totalFrequentRenterPoints(customer, movies)

    function amountFor(rental, movies)

    function frequentRenterPointsFor(rental, movies)

    function movieFor(rental, movies)

显示代码

参数分派

使用顶级函数的参数来指定要发出的输出格式

    function statement(customer, movies, format)

        function htmlStatement()

        function textStatement()

        function totalAmount()

        function totalFrequentRenterPoints()

        function amountFor(rental)

        function frequentRenterPointsFor(rental)

        function movieFor(rental)

显示代码

将计算逻辑移到渲染函数使用的类中

    function textStatement(customer, movies)

    function htmlStatement(customer, movies)

    class Customer

        get amount()

        get frequentRenterPoints()

        get rentals()

    class Rental

        get amount()

        get frequentRenterPoints()

        get movie()

显示代码

转换

将计算逻辑拆分为单独的嵌套函数,该函数为渲染函数生成一个中间 data 结构

    function statement(customer, movies)

    function htmlStatement(customer, movies)

    function createStatementData(customer, movies)

        function createRentalData()

        function totalAmount()

        function totalFrequentRenterPoints()

        function amountFor(rental)

        function frequentRenterPointsFor(rental)

        function movieFor(rental)

显示代码

我将从顶级函数示例开始,因为它是我用于比较的基线,因为它在概念上是最简单的替代方案。[2] 它很简单,因为它将工作划分为一组纯函数,所有这些函数都可以在我代码中的任何地方调用。这使用起来很简单,测试起来也很简单——我可以通过测试用例或 REPL 轻松地测试任何单个函数。

顶级函数的缺点是存在大量重复的参数传递。每个函数都需要提供 movies data 结构,而客户级函数还需要提供客户结构。我不担心这里的重复输入,但我担心重复的阅读。每次阅读参数时,我都要弄清楚它们是什么,并检查参数是否正在改变。对于所有这些函数,客户和 movies data 是共同的上下文——但对于顶级函数,这种共同上下文并不明确。我在阅读程序并在我脑海中构建其执行模型时推断出来,但我更喜欢尽可能明确地表达事物。

当上下文增长时,这个因素变得更加重要。我这里只有两个 data 项目,但找到更多 data 项目并不罕见。仅使用顶级函数,我最终会在每次调用中得到很大的参数列表,每个列表都会增加我阅读理解的负担。这会导致将所有这些参数捆绑到一个上下文参数中的陷阱,该参数包含许多函数的所有上下文,并最终掩盖了这些函数的作用。我可以通过定义局部部分应用函数来减少所有这些痛苦,但这会引入很多额外的函数声明——这些声明必须与每段客户端代码一起重复。

其他三种替代方案的优势在于,它们都将共同上下文明确化,将其捕获在程序的结构中。参数分派方法通过将上下文捕获在顶级参数列表中来实现这一点,然后该上下文可作为所有嵌套函数的共同上下文使用。这与原始代码特别有效,使从单个函数到嵌套函数的重构比缺乏嵌套函数的语言更简单。

但是,当我的上下文需要不同的整体行为(例如 HTML 格式的响应)时,参数分派方法就开始摇摆不定。我需要编写某种分派器来决定要调用哪个函数。向渲染器指定格式并不太糟糕,但这种分派逻辑是一种明显的异味。无论我如何编写它,它本质上仍然在复制语言调用命名函数的核心能力。我正在走一条可能很快就会导致以下荒谬情况的道路。

function executeFunction (name, args) {
  const dispatchTable = {
    //...

这种方法有一个上下文,即当输出格式的选择作为 data 传递给我的调用者时。在这种情况下,必须对该 data 项目有一个分派机制。但是,如果我的调用者像这样调用 statement 函数…

const someValue = statement(customer, movieList, 'text');

…那么我的代码中就不应该编写分派逻辑。

调用方法是这里的关键。使用文字值来指示函数选择是一种异味。与其使用此 API,不如让调用者在函数名称中说明他们想要什么,例如 textStatementhtmlStatement。然后,我可以使用语言的函数分派机制,避免自己拼凑其他东西。

所以,有了这两个备选方案,我身处何处?我想要一些用于某些逻辑的显式共同上下文,但需要使用该逻辑调用不同的操作。当我感觉到这种需求时,我立即想到使用面向对象——本质上是一组独立可调用的操作,它们作用于共同上下文。[3] 这就导致了示例的类版本,它允许我将客户和 movies 的共同上下文捕获在客户和租赁对象中。我在实例化对象时设置一次上下文,然后所有后续逻辑都可以使用该共同上下文。

对象方法类似于顶级情况下的部分应用局部函数,只是这里的共同上下文由构造函数提供。因此,我只编写局部函数,而不是顶级函数。调用者使用构造函数指示上下文,然后直接调用局部函数。我可以将局部方法视为对对象实例的共同上下文上的假想顶级函数的部分应用。

使用类引入了另一个概念——将渲染逻辑与计算逻辑分离。原始单个函数的缺点之一是它将两者混合在一起。拆分成函数在一定程度上将它们分离,但它们仍然存在于同一个概念空间中。这有点不公平,我可以将计算函数放在一个文件中,将渲染函数放在另一个文件中,通过适当的导入语句将它们链接起来。但我发现共同上下文为如何将逻辑分组到模块中提供了一个自然的提示。

我已经将对象描述为一组共同的部分应用,但还有另一种看待它们的方式。对象使用输入 data 结构进行实例化,但通过其计算函数公开的计算 data 来丰富此 data。我已经通过将这些函数设置为 getter 来加强这种思维方式,因此客户端将它们与原始 data 完全相同对待——应用 统一访问原则。我可以将此视为从构造函数参数到 getter 的虚拟 data 结构的转换。转换示例是相同的思想,但通过创建一个新的 data 结构来实现,该结构将初始 data 与所有计算 data 结合在一起。就像对象将计算逻辑封装在客户和租赁类中一样,转换方法将该逻辑封装在 createStatementDatacreateRentalData 中。这种转换基本 列表和哈希表 data 结构的方法是许多函数式思维的常见特征。它允许 create…Data 函数共享它们需要的上下文,并允许渲染逻辑以简单的方式使用多个输出。

将类视为转换与转换方法本身之间的一个细微区别是转换计算发生的时间。这里的转换方法一次性转换所有内容,而类在每次调用时进行单独的转换。我可以轻松地切换计算发生的时间以匹配另一个。在类的情况下,我可以通过在构造函数中执行所有计算来一次性执行所有计算。对于转换方法,我可以通过在中间 data 结构中返回函数来按需重新计算。几乎总是,这里的性能差异将微不足道,如果这些函数中的任何一个都很昂贵,我的最佳选择通常是使用方法/函数并在第一次调用后缓存结果。

所以有四种方法——我更喜欢哪一种?我不喜欢编写调度器逻辑,所以不会使用参数调度方法。顶级函数是我会考虑的,但随着共享上下文大小的增加,我对它们的兴趣会迅速下降。即使只有两个参数,我也倾向于选择其他方法。在类和转换方法之间进行选择比较困难,两者都提供了一种很好的方法来明确常见的上下文并很好地分离关注点。我不喜欢笼子里的战斗,所以也许我会让他们玩弹珠游戏,然后选出获胜者。

进一步重构

在本探索中,我探索了四种安排计算和渲染函数的方法。软件是一种非常灵活的媒介,可以对它进行更多变化,但这些是我认为最值得讨论的四种方法。

除了这些函数的排列之外,还有进一步的重构。在书中的例子中,我分解了amountfrequentRenterPoint计算以支持使用新的电影类型扩展模型。我会对渲染代码进行一些更改,例如提取标题、行和页脚的通用模式。但我认为这四种路径足以供本文思考。

我的结论,如果我有一个结论,那就是有不同的方法可以合理地安排观察上相同的计算。不同的语言鼓励某些风格——最初的书本重构是在 Java 中完成的,它极大地鼓励了类风格。JavaScript 方便地支持多种风格,这很好,因为它为程序员提供了选择,但也糟糕,因为它为程序员提供了选择。(在 JavaScript 中编程的困难之一是,关于什么是好的风格几乎没有共识。)了解这些不同的风格很有用,但更重要的是要意识到是什么将它们联系在一起。小型函数,只要它们命名得当,就可以组合和操作以同时满足各种需求,并在一段时间内满足各种需求。常见的上下文建议将逻辑分组在一起,而编程艺术的很大一部分是决定如何将关注点分离成一组清晰的上下文。


脚注

1: 重构目录是在面向对象词汇流行时编写的,所以我使用“方法”来指代函数/子例程/过程或类似的东西。在 JavaScript 中,使用“函数”更合理,但我使用的是书中重构的名称。

2: 参数调度是一个更好的第一个重构,因为它的结构更接近于最初的一组嵌套函数。但在比较备选方案时,顶级函数案例是更好的起点。

致谢

Vitor Gomes 提醒我 ES6 中的默认参数值。

Beth Andres-Beck、Bill Wake、Chaoyang Jia、Greg Doench、Henrique Souza、Jay Fields、Kevin Yeung、Marcos Brizeno、Pete Hodgson 和 Ryan Boucher 在邮件列表中讨论了这篇文章的草稿。

Ruben Bartelink 通知了一些需要修复的错别字。

Udo Borkowski 指出了示例中的一个错误。

重大修订

2016 年 5 月 18 日: 首次发布