[译] ES6+ 中的 JavaScript 工厂函数(第八部分)

Smoke Art Cubes to Smoke — MattysFlicks — (CC BY 2.0)
Smoke Art Cubes to Smoke — MattysFlicks — (CC BY 2.0)

注意:这是“软件编写”系列文章的第八部分,该系列主要阐述如何在 JavaScript ES6+ 中从零开始学习函数式编程和组合化软件(compositional software)技术(译注:关于软件可组合性的概念,参见维基百科 Composability)。后续还有更多精彩内容,敬请期待!
< 上一篇 | << 第一篇 | 下一篇 >javascript

工厂函数是一个能返回对象的函数,它既不是类也不是构造函数。在 JavaScript 中,任何函数均可以返回一个对象,若是函数前面没有使用 new 关键字,却又返回一个对象,那这个函数就是一个工厂函数。前端

由于工厂函数提供了轻松生成对象实例的能力,且无需深刻学习类和 new 关键字的复杂性,因此工厂函数在 JavaScript 中一直很具吸引力。java

JavaScript 提供了很是方便的对象字面量语法,代码以下:react

const user = {
  userName: 'echo',
  avatar: 'echo.png'
};复制代码

就像 JSON 的语法(JSON 就是基于 JavaScript 的对象字面量语法),:(冒号)左边是属性名,右边是属性值。你可使用点运算符访问变量:android

console.log(user.userName); // "echo"复制代码

或者使用方括号及属性名访问变量:ios

const key = 'avatar';
console.log( user[key] ); // "echo.png"复制代码

若是在做用域内还有变量和你的属性名相同,那你能够直接在对象字面量中使用这个变量,这样就省去了冒号和属性值:git

const userName = 'echo';
const avatar = 'echo.png';
const user = {
  userName,
  avatar
};
console.log(user);
// { "avatar": "echo.png",   "userName": "echo" }复制代码

对象字面量支持简洁表示法。咱们能够添加一个 .setUserName() 的方法:es6

const userName = 'echo';
const avatar = 'echo.png';
const user = {
  userName,
  avatar,
  setUserName (userName) {
    this.userName = userName;
    return this;
  }
};
console.log(user.setUserName('Foo').userName); // "Foo"复制代码

在简洁表示法中,this 指向的是调用该方法的对象,要调用一个对象的方法,只须要简单地使用点运算符访问方法并使用圆括号调用便可,例如 game.play() 就是在 game 这一对象上调用 .play()。要使用点运算符调用方法,这个方法必须是对象属性。你也可使用函数原型方法 .call().apply().bind() 把一个方法应用于一个对象上。github

本例中,user.setUserName('Foo') 是在 user 对象上调用 .setUserName(),所以 this === user。在.setUserName() 方法中,咱们经过 this 这个引用修改了 .userName 的值,而后返回了相同的对象实例,以便于后续方法链式调用。typescript

字面量偏向单一对象,工厂方法适用众多对象

若是你须要建立多个对象,你应该考虑把对象字面量和工厂函数结合使用。

使用工厂函数,你能够根据须要建立任意数量的用户对象。假如你正在开发一个聊天应用,你会用一个用户对象表示当前用户,以及用不少个用户对象表示其余已登陆和在聊天的用户,以便显示他们的名字和头像等等。

让咱们把 user 对象转换为一个 createUser() 工厂方法:

const createUser = ({ userName, avatar }) => ({
  userName,
  avatar,
  setUserName (userName) {
    this.userName = userName;
    return this;
  }
});
console.log(createUser({ userName: 'echo', avatar: 'echo.png' }));
/*
{
  "avatar": "echo.png",
  "userName": "echo",
  "setUserName": [Function setUserName]
}
*/复制代码

返回对象

箭头函数(=>)具备隐式返回的特性:若是函数体由单个表达式组成,则能够省略 return 关键字。()=>'foo' 是一个没有参数的函数,并返回字符串 "foo"

返回对象字面量时要当心。当使用大括号时,JavaScript 默认你建立的是一个函数体,例如 { broken: true }。若是你须要返回一个明确的对象字面量,那你就须要经过使用圆括号将对象字面量包起来以消除歧义,以下所示:

const noop = () => { foo: 'bar' };
console.log(noop()); // undefined
const createFoo = () => ({ foo: 'bar' });
console.log(createFoo()); // { foo: "bar" }复制代码

在第一个例子中,foo: 被解释为一个标签,bar 被解释为一个没有被赋值或者返回的表达式,所以函数返回 undefined

createFoo() 例子中,圆括号强制着大括号,使其被解释为要求值的表达式,而不是一个函数体。

解构

请特别注意函数声明:

const createUser = ({ userName, avatar }) => ({复制代码

这一行里,大括号 ({, }) 表示对象解构。这个函数有一个参数(即一个对象),可是从这个参数中,却解构出了两个形参,userNameavatar。这些形参能够做为函数体内的变量使用。解构还能够用于数组:

const swap = ([first, second]) => [second, first];
console.log( swap([1, 2]) ); // [2, 1]复制代码

你可使用扩展语法 (...varName) 获取数组(或参数列表)余下的值,而后将这些值回传成单个元素:

const rotate = ([first, ...rest]) => [...rest, first];
console.log( rotate([1, 2, 3]) ); // [2, 3, 1]复制代码

计算属性值

前面咱们使用方括号的方法动态访问对象的属性值:

const key = 'avatar';
console.log( user[key] ); // "echo.png"复制代码

咱们也能够计算属性值来赋值:

const arrToObj = ([key, value]) => ({ [key]: value });
console.log( arrToObj([ 'foo', 'bar' ]) ); // { "foo": "bar" }复制代码

本例中,arrToObj 接受一个包含键值对(又称元组)的数组,并将其转化成一个对象。由于咱们并不知道属性名,所以咱们须要计算属性名以便在对象上设置属性值。为了作到这一点,咱们使用了方括号表示法,来设置属性名,并将其放在对象字面量的上下文中来建立对象:

{ [key]: value }复制代码

在赋值完成后,咱们就能获得像下面这样的对象:

{ "foo": "bar" }复制代码

默认参数

JavaScript 函数支持默认参数值,给咱们带来如下优点:

  1. 用户能够经过适当的默认值省略参数。
  2. 函数自我描述性更高,由于默认值提供预期的输入例子。
  3. IDE 和静态分析工具能够利用默认值推断参数的类型。例如,一个默认值 1 表示参数能够接受的数据类型为 Number

使用默认参数,咱们能够为咱们的 createUser 工厂函数描述预期的接口,此外,若是用户没有提供信息,能够自动地补充某些细节:

const createUser = ({
  userName = 'Anonymous',
  avatar = 'anon.png'
} = {}) => ({
  userName,
  avatar
});
console.log(
  // { userName: "echo", avatar: 'anon.png' }
  createUser({ userName: 'echo' }),
  // { userName: "Anonymous", avatar: 'anon.png' }
  createUser()
);复制代码

函数签名的最后一部分可能看起来有点搞笑:

} = {}) => ({复制代码

在参数声明最后那部分的 = {} 表示:若是传进来的实参不符合要求,则将使用一个空对象做为默认参数。当你尝试从空对象解构赋值的时候,属性的默认值会被自动填充,由于这就是默认值所作的工做:用预先定义好的值替换 undefined

若是没有 = {} 这个默认值,且没有向 createUser() 传递有效的实参,则将会抛出错误,由于你不能从 undefined 中访问属性。

类型判断

在写这篇文章的时候,JavaScript 都尚未内置的类型注解,可是近几年涌现了一批格式化工具或者框架来填补这一空白,包括 JSDoc(因为出现了更好的选择其呈现出降低趋势)、Facebook 的 Flow、还有微软的 TypeScript。我我的使用 rtype,由于我以为它在函数式编程方面比 TypeScript 可读性更强。

直至写这篇文章,各类类型注解方案其实都不相上下。没有一个得到 JavaScript 规范的庇护,并且每一个方案都有它明显的不足。

类型推断是基于变量所在的上下文推断其类型的一个过程,在 JavaScript 中,这是对类型注解很是好的一个替代。

若是你在标准的 JavaScript 函数中提供足够的线索去推断,你就能得到类型注解的大部分好处,且不用担忧任何额外成本或风险。

即便你决定使用像 TypeScript 或 Flow 这样的工具,你也应该尽量利用类型推断的好处,并保存在类型推断抽风时的类型注解。例如,原生 JavaScript 是不支持定义共享接口的。但使用 TypeScript 或 rtype 均可以方便有效地定义接口。

Tern.js 是一个流行的 JavaScript 类型推断工具,它在不少代码编辑器或 IDE 上都有插件。

微软的 Visual Studio Code 不须要 Tern,由于它把 TypeScript 的类型推断功能附带到了 JavaScript 代码的编写中。

当你在 JavaScript 函数中指定默认参数值时,不少诸如 Tern.js、TypeScript 和 Flow 的类型推断工具就能够在 IDE 中给予提示以帮助开发者正确地使用 API。

没有默认值,各类 IDE(更多的时候,连咱们本身)都没有足够的信息来判断函数预期的参数类型。

没有默认值, `userName` 的类型未知。
没有默认值, `userName` 的类型未知。

有了默认值,IDE (更多的时候,咱们本身) 能够从代码中推断出类型。

有默认值,IDE 能够提示 `userName` 的类型应该是字符串。
有默认值,IDE 能够提示 `userName` 的类型应该是字符串。

将参数限制为固定类型(这会使通用函数和高阶函数更加受限)是不怎么合理的。但要说这种方法何时有意义的话,使用默认参数一般就是,即便你已经在使用 TypeScript 或 Flow 作类型推断。

Mixin 结构的工厂函数

工厂函数擅于利用一个优秀的 API 建立对象。一般来讲,它们能知足基本需求,但不久以后,你就会遇到这样的状况,总会把相似的功能构建到不一样类型的对象中,因此你须要把这些功能抽象为 mixin 函数,以便轻松重用。

mixin 的工厂函数就要大显身手了。咱们来构建一个 withConstructor 的 mixin 函数,把 .constructor 属性添加到全部的对象实例中。

with-constructor.js:

const withConstructor = constructor => o => {
  const proto = Object.assign({},
    Object.getPrototypeOf(o),
    { constructor }
  );
  return Object.assign(Object.create(proto), o);
};复制代码

如今你能够导入和使用其余 mixins:

import withConstructor from `./with-constructor'; const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x); // 或者 `import pipe from 'lodash/fp/flow';` // 设置一些 mixin 的功能 const withFlying = o => { let isFlying = false; return { ...o, fly () { isFlying = true; return this; }, land () { isFlying = false; return this; }, isFlying: () => isFlying } }; const withBattery = ({ capacity }) => o => { let percentCharged = 100; return { ...o, draw (percent) { const remaining = percentCharged - percent; percentCharged = remaining > 0 ? remaining : 0; return this; }, getCharge: () => percentCharged, get capacity () { return capacity } }; }; const createDrone = ({ capacity = '3000mAh' }) => pipe( withFlying, withBattery({ capacity }), withConstructor(createDrone) )({}); const myDrone = createDrone({ capacity: '5500mAh' }); console.log(` can fly: ${ myDrone.fly().isFlying() === true } can land: ${ myDrone.land().isFlying() === false } battery capacity: ${ myDrone.capacity } battery status: ${ myDrone.draw(50).getCharge() }% battery drained: ${ myDrone.draw(75).getCharge() }% `); console.log(` constructor linked: ${ myDrone.constructor === createDrone } `);复制代码

正如你所见,可重用的 withConstructor() mixin 与其余 mixin 一块儿被简单地放入 pipeline 中。withBattery() 能够被其余类型的对象使用,如机器人、电动滑板或便携式设备充电器等等。withFlying() 能够被用来模型飞行汽车、火箭或气球。

对象组合更多的是一种思惟方式,而不是写代码的某一特定技巧。你能够在不少地方用到它。功能组合只是从头开始构建你思惟方式的最简单方法,工厂函数就是将对象组合有关实现细节包装成一个友好 API 的简单方法。

结论

对于对象的建立和工厂函数,ES6 提供了一种方便的语法,大多数时候,这样就足够了,但由于这是 JavaScript,因此还有一种更方便并更像 Java 的语法:class 关键字。

在 JavaScript 中,类比工厂更冗长和受限,当进行代码重构时更容易出现问题,但也被像是 React 和 Angular 等主流前端框架所采纳使用,并且还有一些少见的用例,使得类更有存在乎义。

“有时,最优雅的实现仅仅是一个函数。不是方法,不是类,不是框架。仅仅只是一个函数。” ~ John Carmack

最后,你还要切记,不要把事情搞复杂,工厂函数不是必需的,对于某个问题,你的解决思路应当是:

纯函数 > 工厂函数 > 函数式 Mixin > 类

Next: Why Composition is Harder with Classes >

接下来

想更深刻学习关于 JavaScript 的对象组合?

跟着 Eric Elliott 学 Javacript,机不可失时再也不来!

Eric Elliott“编写 JavaScript 应用” (O’Reilly) 以及 “跟着 Eric Elliott 学 Javascript” 两书的做者。他为许多公司和组织做过贡献,例如 Adobe SystemsZumba FitnessThe Wall Street JournalESPNBBC 等 , 也是不少机构的顶级艺术家,包括但不限于 UsherFrank Ocean 以及 Metallica

大多数时间,他都在 San Francisco Bay Area,同这世上最美丽的女子在一块儿。

感谢 JS_Cheerleader.


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOSReact前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索