Refactoring a JavaScript video store[译]

原文链接:Refactoring a JavaScript video store
转载请注明出处

这是一个我1999年发行的重构中使用的关于一个计算和输出视频商店账单的例子。如果要使用现代Javascript,我们可以从这四个方向考虑重构:Top-Level Function,在嵌套函数中使用dipatcher,使用class和使用中间数据的transformer。

很多年前,当我编写重构这本书时,我在书中使用了一个(非常)简单的例子用于展示重构代码,这个例子计算出客户租借视频的帐单(在那些日子里,我们不得不去商店做 )。 我最近考虑这个重构的例子,特别是使用现代JavaScript。

任何重构都是有偏向性的区改进代码,以一种适合开发团队的编码风格。 在书中的例子是Java,而Java(特别是)建议一种特定的编码风格,即面向对象。 但是,使用JavaScript,有更多的选择关于使用什么样的风格。虽然你可以使用类似Java的OO风格,特别是与ES6(Ecmascript 2015),但并不是所有的JavaScript专家喜欢这种风格,许多人确实认为使用类是一个坏事。

Video Store 原始代码

为了进一步探索,我需要介绍一些代码。先看看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},
  ]
}

movies结构看起来像这样:

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

在原书中,movies只是作为data存在于Java对象中。对于这篇文章,我更喜欢传递在json结构作为参数。我将假设使用某种全局查找,如Repository,不适合这个应用程序。

这个方法用于打印出租赁简单文本:

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都还没有添加。请原谅我的懒惰。

这个例子是一个长方法Bad Smell的例子,光它的大小都足以让我可疑,但只是因为代码Bad Smell是不能构成我来重构它理由。糟糕的代码有问题是因为它很难理解,难以理解的代码很难修改,无论是添加新功能还是调试。 因此,如果你不需要阅读和理解一些代码,那么其糟糕的结构不会对你有害,你可以高兴地搁置它一段时间。所以为了触发我们对这个代码片段的兴趣,我们需要一个理由来改变。 我在这本书中使用的原因是为了写一个HTML版本的语句方法,打印出这样的东西。

<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>

正如我前面提到的,在本文中,我将探讨一些重构这份代码的方法,使其更容易添加额外的输出样式。 所有这些我们都使用相同的步骤开始:将单个方法分解成居然不用逻辑的一组函数。 一旦我完成这个分解,我将探讨四种不同的方式来让这些Function支持不同的输入方式。

重构步骤

分解方法

分解方法

每当我使用这样一个过长的函数,我的第一个想法是查找逻辑块的代码,并使用提取方法将它们转换为外部函数。 这里我首先来处理switch语句。

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;
}

我的IDE(IntelliJ)重构功能,但它有时会出错,它的JavaScript重构能力不像它的Java重构一样坚实或成熟。 所以我这里将使用手动的方式,涉及查看候选人提取使用的数据。 有三个数据:

  • thisAmount是由提取的代码计算的值。 我可以在函数中初始化它,并在结束时返回它;
  • r是循环中的租金信息,我可以将其作为参数传入;
  • movie是出租的电影信息,这是早期创建的一个临时变量。 这样的临时变量通常在重构过程代码时会出现问题,所以我更喜欢首先使用Replace Temp with Query将它们转换为一个函数,我可以在任何需要它的时候调用这个方法。

一旦我完成了Replace Temp with Query,代码看起来像这样:

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;
  }
}

我现在把注意力转向计算租赁积分,我可以做类似的提取其代码:

...
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;
}
...
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++;
}
...

虽然我已经提取了函数,我不喜欢它修改父域作用域变量。这样的副作用使代码难以推理,所以我改变它,使其没有副作用。

...
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;
}
...
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;
  ...
}

我喜欢使用一种通用策略是避免可变变量。 这里有三个,一个是结果最后的字符串,另外两个计算在该字符串中使用的值。 第一个可以接受,但其他两个需要修改。第一步我需要拆分循环, 简化循环和不必要的静态变量。

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;

然后把循环分成三个部分:

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;

一些程序员担心这样的重构的性能影响,在这种情况下看看一个老的但相关的软件性能文章

这种分解允许我使用更多的方法提取计算逻辑:

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;
}

作为collection pipelines的粉丝,我也将使用它们来代替循环:

function totalFrequentRenterPoints() {
  return customer.rentals
    .map((r) => frequentRenterPointsFor(r))
    .reduce((a, b) => a + b);
}
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);
  }

  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的输出功能。分解的函数都嵌套在整个语句函数中,这使得提取的函数能使用这个函数作用域里所有的东西,其中包括彼此调用(例如amountFor调用movieFor)和参数customermovie。 但我不能写一个简单的htmlStatement函数引用这些提取函数。 为了能够使相同的计算逻辑支持不同的输出,我需要做一些进一步的重构。 我有几个方法重构,取决于我喜欢把我的代码分解成什么样子。 接下来我将介绍每种方法,解释每个方法的工作原理,然后在我完成所有四个方法后比较它们。

使用参数来决定输出

使用参数来决定输出

一种途径是我可以指定输出格式作为方法的参数。我将通过这种添加参数的方式开始这个重构,提取现有的文本格式化代码,并更加参数dispatch到被提取的函数。

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的方法,并作为dispatch的一个分支语句:

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;
  }
  ...
}

我可以使用更加fancy的一个数据结构来处理dispatch:

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();
  ...
}

使用top-level方法

使用top-level方法

编写生成html top-level函数的问题在于计算函数被嵌套在text函数内部。 所以一个明智的做法是将它们移动到顶层的上下文中去。
为了做到这一点,我开始寻找让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;
  }
  ...
}

这时我可以进行一次编译和测试,这将告诉我如果上下文的变化是否造成任何问题。 一旦功能正常我就可以inline这些方法了。

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中有一个类似的修改

除了inline方法外,我还重命名了top-level函数以匹配旧名称,因此唯一的区别是现在多了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);
}
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的函数了:

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开始:

// 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;
}

到目前为止,我们只是使用类包装了一下数据。我会使用类似方法来包装rental:

// 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。 但是这个函数需要电影列表作为上下文,我们可以在创建rental对象时传入这个参数。

// 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;
}

一旦可以在rental内部拿到movies,我可以移动方法了:

// statement.es6
function movieFor(rental) {
  return rental.movie;
}

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

与我之前做的移动一样,关键的第一步是将其放在新的上下文中,使其适合该上下文,并修改原来的函数以调用它。 一旦它是工作的,那么就可以轻松的inline这些函数调用了。

// 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;
  }
}

我可以使用相同的步骤将另外两个计算逻辑移动到reatal class中去:

// 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)
      ;
  }

  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;
}

然后,我可以将两个计算总额的函数移动到Customer中去:

// 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);
}
get amount() {
  return this.rentals
    .reduce((total, r) => total + r.amount, 0);
}

随着计算逻辑被移入rental和customer对象,编写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 Class的版本

ES2015中的Class语法是有争议的,有些人认为它不是必需的。你可以采用完全相同的重构步骤系列来得到这样的结果:

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)
      ;
  }
  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;
  }
}

此方法使用Function As Object模式。 构造函数(createCustomer和createRental)返回一个包含函数引用的JavaScript对象,每个构造函数都包含一个保存对象数据的闭包。 因为返回的函数对象在同一个函数上下文中,他们可以访问这些数据。 我认为这是与使用类语法完全相同的模式,但实现方式不同。 我更喜欢使用显式语法,因为它更明确 - 因此使我的思维更清晰。

数据转换

数据转换

所有这些方法都通过在打印函数调用其他函数来计算他们需要的数据。 另一种方法是将此数据传递给数据结构本身中的打印函数。 在这种方法中,计算方法用于转换customer数据结构,使其具有打印功能所需的所有数据。
这是Kent Beck去年夏天对我讨论的未被编写到书中的一个Split Phase重构手法例子。 通过这种重构,我将计算分为两个阶段,使用中间数据结构进行通信。 我通过引入中间数据结构开始这个重构:

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;
  }
}

对于这种情况,我通过调用Object.assign给customer数据结构添加更多地数据,我也可以选择构建一个全新的数据结构,这取决于新数据结构与原始数据结构有多少异同。
然后,我对每个rental做同样的事情:

// 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;
  }
}

请注意,我在createStatementData中嵌套createRentalData,因为createStatementData的任何调用者都不需要知道内部是如何构建的。
现在我可以开始向新数据结构中添加数据了,从rental的movie title开始:

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;
    }
  }
}

接下来是计算amount,其次是total:

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;
    }
  }
}

现在,我已经使所有的结算结果添加到了新的数据上,我可以移动函数,将他们从打印函数中分离出来。 首先,我移动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) { … }
  }
}

然后将createStatementDatastatement中移出:

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打印版本了:

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移动到一个单独的模块,以进一步划清计算数据和显示逻辑界线:

// 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的输出,而不是简单的拷贝重复代码。我第一步将代码分离成几个小的函数,并让它们和原来的方法并存,然后我们使用了四种不同的方式去进行下一步重构:

top-level-functions
write all functions as top-level functions
    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)

Code

parameter-dispatch
use a parameter to the top-level function to state what format of output to emit
    function statement(customer, movies, format)
        function htmlStatement()
        function textStatement()
        function totalAmount()
        function totalFrequentRenterPoints()
        function amountFor(rental)
        function frequentRenterPointsFor(rental)
        function movieFor(rental)

Code

classes
move calculation logic to classes which are used by rendering functions
    function textStatement(customer, movies)
    function htmlStatement(customer, movies)
    class Customer
        get amount()
        get frequentRenterPoints()
        get rentals()
    class Rental
        get amount()
        get frequentRenterPoints()
        get movie()

Code

transform
split calculation logic into separate nested function that produces an intermediate data structure for the rendering functions
    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)

Code

我们首先是从top-level函数示例开始的,并使用它来作为对比基础,它从概念上来说是最简单,因为它将功能划分为一组纯函数,所有这些函数都可以从代码中的任何地方调用。 它们使用起来非常简单,测试也很简单 - 我可以通过测试用例或REPL来轻松测试任何单个函数。
top-level函数的缺点是有很多重复的参数传递。 每个方法都需要传入customer和movies,我不关心这里的重复打字,但我担心重复的阅读。 每次我看到参数,我必须弄清楚它们是什么,并检查参数是否改变。 对于每个函数,customer和moveis参数是它们共同需要的上下文 - 但对于所有top-level函数来说,这两个参数的上下文都是不明确的。 我在阅读代码时需要推断它们,并在我心中建立运行模型,所以我更喜欢的每件事都更加明确和清晰。
随着代码的增长,这一因素变得更加重要。这里只有两个数据项,但实际情况往往会是更多。每次使用top-leave函数传入大量参数,它们会增加我阅读理解的负载。这也可能导致我们掉入参数过多而导致我们搞不清函数作用的陷阱。我可以通过定义局部本地函数来减少这些的痛苦,但这会使每个调用这些函数的地方都重复这样额外的负担。
而其他三种方式的优点是它们使公共上下文变得显示,并在代码中去获取这些上下文。参数调度的方式就是这样通过获取这样的上下文,然后将他们传给所有嵌套函数。使得从单个函数到嵌套函数的重构比没有嵌套函数的语言更简单。
但是当我需要改变整个函数行为时,使用参数调度的方式就变得不稳定了,例如这里我们增加了新的html格式化输出,我需要编写一个新的调度器并决定调用哪个函数。虽然这种为输出函数指定格式的方法不算坏,但是这样的dispatch逻辑也算不上好,例如:

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

使用这种方式,选择什么样的输入格式是作为数据存放在调用者内部上下文中,然后我们可以使用调度机制来处理这些数据。但是如果函数调用者使用这样的参数:

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

那么我没有办法在我的代码中修改调度逻辑了。
所有这里的关键是,我们的方法是怎么被调用的。使用字符串值在决定怎么调用方法在这里是一种不好的实践。我们更应该把想要的东西作为方法名的一部分,例如textStatementhtmlStatement。然后我们就可以使用调度机制,来避免把逻辑夹杂在一起。
我们再来看看剩下的两种方式。我想为某些逻辑提供一些明确的通用上下文,但需要使用该逻辑调用不同的操作。当我感到这种需要时,我立即想到使用面向对象 - 这本质上是一个在一个共同的上下文中独立调用的操作。这促使了我的class版本的示例,让我能够在Customer和Rental对象中获取到customer和movies这样的上下文。当我实例化对象时通过传入构造参数来设置一次共同的上下文,然后就可以在类方法中去使用这些数据了。
对象方法类似于top-level中所使用到的局部函数,除了这里常见的上下文由构造函数提供。因此,我只写本地功能,而不是顶级的功能。调用者使用构造函数指示上下文,然后直接调用本地函数。
使用类引入了另一个概念 - 将渲染逻辑与计算逻辑分离。原来单一函数的一个缺点是它将两者混合在一起。 通过拆分函数在一定程度上将它们分开,但它们仍然存在于相同的概念空间中。这样还是不够的,我可以进一步将计算函数放入一个文件中,并将渲染函数放入另一个文件中,并通过适当的导入语句进行链接。但是我发现使用共同的上下文提供了如何将逻辑分组为模块的更自然的方式。
通常我们将对象描述为程序的一组功能集合,但还有另一种方法看待它们。这些对象通过传入构造参数进行实例化,并通过一些计算逻辑,让数据更加丰富。我们可以根据这一点更进一步,让客户端用完全一样的方式去处理它们 - 应用统一访问原则。我可以将它看作是从构造函数参数到虚拟数据结构的转换。Transform示例就是这样做的,将初始数据转换为所需计算数据组合的新数据结构。正如类对象将计算逻辑封装在Customer和Rental类中一样,transform方法将该逻辑封装在createStatementDatacreateRentalData内。转换基本的List和Hash数据结构的这种方法是函数式编程中的一个常见特征。它允许以简单的方式来让不同的数据处理函数使用通过的上下文,并使用不用的渲染逻辑生成输出。

把类作为数据转换的想法和使用Transfomer在转换计算时有小小的区别,Transformer方式是一次转换所有,而类在每个调用中进行单独的转换。对于它们我都可以轻易改变计算逻辑来适应新的变化,比如在类中我可以在构造函数中将所有数据进行转换。对于Transformer来所,我可以根据我的需要改变函数返回的中间数据结构。 大部分情况下这里的性能差异是微不足道的,如果这些方法是比较耗时的,更好的方式是在第一次计算后将数据缓存起来。

所以对于这四种方法我推荐哪种呢? 我不喜欢写dispatch逻辑,所以我不会使用参数调度方式。Top-Level function是我会考虑的,但是随着函数上下文的大小增加,我对他们的喜好会迅速下降。 即使只有两个论点,我也倾向于找到其他选择。 在Class和Transformer之间进行选择比较困难,它们在显示提供上下文和职责分离都做的不错,所有也许我们需要在我们的项目实践中去决定我们选用哪一种方式了。

进一步重构

在这次探讨中,我探讨了四种分离计算和渲染逻辑的方式。 软件是一中富可塑性的东西,有很多方式可以做到这一点,但这些是我认为最有趣的四个讨论。
还有进一步的重构,而不仅仅是拆分和组织这些函数。在重构书的示例中,我分离了amountfrequentRenterPoint的计算,以支持使用新的movie类型来扩展模型。 我会对渲染代码进行更改,例如提取标题,行和页脚。 但是我认为对于这篇文章来说这四种方式的探讨已经足够了。
我的结论是,我们其实是有办法让同样的一种计算逻辑,通过不同的方式将其分离并组织,以达到更清晰,更易于扩展的目的。不同的语言鼓励某些风格 - 原书重构是在Java中完成的,它鼓励使用Class风格。 JavaScript支持不同的范式,这是很好的,因为它为程序员提供了更多的选择,也正因为这点,有时会变得更糟糕(JavaScript编程中的一个难点在于对什么是好的风格几乎没有共识。)了解这些不同的风格是很有用的,但是更重要的是要将它们联系在一起。 为小函数提供有意义的命名,对他们进行不同的组合以支持不同的需求。将具有共同上下文的逻辑放在一起,而大多数编程艺术正在决定如何将问题分解为一系列清晰的上下文。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,907评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,546评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,705评论 0 238
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,624评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,940评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,371评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,672评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,396评论 0 195
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,069评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,350评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,876评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,243评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,847评论 3 231
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,004评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,755评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,378评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,266评论 2 259

推荐阅读更多精彩内容