【译】组合软件:8. ES6中的JavaScript工厂函数

原文地址:https://medium.com/javascript-scene/javascript-factory-functions-with-es6-4d224591a8b1

工厂函数是既不是类,也不是构造函数,但返回值是(可能是新的)对象的任何一个函数。在JavaScript中,任何函数都可以返回一个对象。当没有new关键字时,它就是一个工厂函数。

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

JavaScript提供了非常方便的对象字面量语法,代码如下:

1
2
3
4
const user = {
userName: 'echo',
avatar: 'echo.png'
};

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

1
console.log(user.userName); // "echo"

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

1
2
const key = 'avatar';
console.log( user[key] ); // "echo.png"

如果在作用域内还有变量和你的属性名相同,那你也可以直接使用这一变量创建对象字面量:

1
2
3
4
5
6
7
8
9
10
const userName = 'echo';
const avatar = 'echo.png';

const user = {
userName,
avatar
};

console.log(user);
// { "avatar": "echo.png", "userName": "echo" }

对象字面量支持简单函数语法。我们可以添加一个 .setUserName()的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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()把一个方法应用于任何对象。

本例中, user.setUserName('Foo')是在user对象上调用.setUserName(),因此this === user。在.setUserName()方法中,我们通过 this这个引用修改了.userName的值,然后返回了相同的对象示例,以便于后续方法链调用。

创建一个对象用字面量,创建多个对象用工厂

如果需要创建很多对象,就应该结合使用对象字面量和工厂。

使用工厂函数,我们就可以根据需要,创建任意数量的用户对象。假如正在做一个聊天应用,你会拥有一个表示当前用户的用户对象,以及当前登录和聊天的其他所有用户的许多用户对象,以便显示他们的名字和头像等等。

让我们把 user对象转换成一个createUser() 的工厂方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 }。如果你要隐式返回一个对象字面量,就需要消除歧义,把对象字面量使用括号包起来:

1
2
3
4
5
const noop = () => { foo: 'bar' };
console.log(noop()); // undefined

const createFoo = () => ({ foo: 'bar' });
console.log(createFoo()); // { foo: "bar" }

在第一个例子中, foo:被解释为一个标签,bar被解释成一个没有赋值或者返回值的表达式。因此这个函数输出是undefined

在第二个createFoo() 例中,圆括号把大括号强制解释成需要求值的表达式,而不是一个函数体。

解构

请特别注意函数声明:

1
const createUser = ({ userName, avatar }) => ({

这一行里,大括号{, }表示了对象解构。这个函数需要一个参数(一个对象),但是从这个单一参数对象中,解构了两个正式的参数,userNameavatar。这些参数可以作为函数范围内的变量使用。函数声明中还可以解构数组:

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

并且我们可以用rest和spread语法(... varName)从数组(或参数列表)中获取剩余值,然后将这些数组元素扩展回成单个元素:

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

计算属性值

前面我们使用方括号的方法动态访问元素属性取得属性值:

1
2
const key = 'avatar';
console.log( user[key] ); // "echo.png"

我们也可以计算得到属性名来赋值:

1
2
const arrToObj = ([key, value]) => ({ [key]: value });
console.log( arrToObj([ 'foo', 'bar' ]) ); // { "foo": "bar" }

本例中,arrToObj 以一个包含键值对的数组为输入,并将其转化成一个对象。因为我们并不知道属性名,因此我们需要计算属性名以便设置对象的属性值。为了做到这一点,我们使用了方括号表示法,来设置属性名,并将其放在对象字面量的上下文中来创建对象:

1
{ [key]: value }

在语句解释完成后,我们就能得到如下的最终对象:

1
{ "foo": "bar" }

默认参数

JavaScript 函数支持默认参数值,这给我们带来以下这些优势:

  1. 用户可以通过适当的默认值省略参数。

  2. 函数自身可读性更好,因为默认值提供了参数输入样例。

  3. IDE和默认分析工具可以使用默认值来推测可能的参数类型。比如,一个默认值是1的设定就暗含了函数可能会使用 Number 的参数类型。

使用默认参数,我们相当于给createUser工厂提供文档,并且如果没有提供用户信息,那么就自动的设为默认值 'Anonymous'

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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()
);

函数定义的最后一部分其实看起来有点搞笑:

1
} = {}) => ({

在参数部分结束之前的最后一个= {}用于表示如果没有传入这一参数,那么将使用一个空对象作为默认值传入函数体。当你尝试从空对象中解构对象时,将会自动使用属性的默认值。这其实就是默认值的意义所在,用预定义的值(即空对象)替换undefined

假如没有= {} 这个默认值, createUser()就会报错,因为你无法访问undefined的属性值。

类型推断

JavaScript 没有任何原生的类型注解,但是近几年涌现了一批格式化工具或者框架来填补这一空白,包括JSDoc(由于出现了更好的选择其呈现出下降趋势),Facebook’s Flow,还有Microsoft’s TypeScript。我个人使用rtype,因为我觉得它在函数式编程方面比TypeScript更易读。

在我写本文的时候,各种类型解决方案并没有一个明显的获胜者。没有一个获得JavaScript规范的庇护,而且每一个都有明显的短处。

类型推断是指基于使用变量的上下文推断类型的过程。 在JavaScript中,类型注解是非常好的选择。

如果在标准的JavaScript函数签名中提供了足够多信息线索,就能获得类型注解的大部分好处,而不用担心增加任何成本或风险。

即便决定使用像TypeScript或者Flow这样的工具,也应该尽可能多的带上类型定义,这样就可以减少某些情况下发生的强类型推断。例如,原生JavaScript是不支持定义共享接口的。但使用TypeScript或 rtype都可以方便有效的定义接口。

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

微软的Visual Studio Code不需要Tern,因为它已经把TypeScript的类型定义功能带到了普通的JavaScript代码中去。

当在JavaScript函数中指定默认参数值时,很多类型推断工具就已经可以在IDE中给予提示以帮助正确的使用API。

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


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


有了默认值,userName的类型是string。

通过默认值,IDE就可以显示userName预计的输入是一个字符串。

将参数限制为固定类型(这会使通用函数和高阶函数更加受限)并不总是合理的。但是在这一方法合理时,使用默认参数通常就是最佳方式,即便是你已经在使用TypeScript或Flow做类型推断。

Mixin结构的工厂函数

工厂函数善于使用好的API创建对象。通常来说,它们基本满足需要,但不久之后,你就会遇到这样的情况,总会把类似的功能构建到不同类型的对象中,所以你需要把这些功能抽象为mixin函数,以便轻松重用。

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

with-constructor.js:

1
2
3
4
5
6
7
8
9
const withConstructor = constructor => o => {
const proto = Object.assign({},
Object.getPrototypeOf(o),
{ constructor }
);

return Object.assign(Object.create(proto), o);
};

现在你就可以引入它并和其他的mixin一起使用了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import withConstructor from `./with-constructor';

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
// or `import pipe from 'lodash/fp/flow';

// Set up some functional mixins
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 -> 类

欢迎关注我的其它发布渠道