原文地址:JavaScript Factory Functions with ES6+
工厂函数是一种不使用类或者构造函数来返回一个对象的函数。在JavaScript中,任何一个函数都可以返回一个对象。当该函数没有使用关键字new的时候,那么它就是一个工厂函数。
因为工厂函数能够轻松地产生对象而不入深入class和new关键字的复杂性,所以工厂函数在JavaScript中一直很受欢迎。
JavaScript 提供了一种十分友好的对象文法。就像下面示例中:
const user = {
name:'test',
age:18
}
就像JSON(JavaScript对象表示法)一样,在 : 左边是对象名,右边是对象属性。你可以通过 . 的方式来获取对象属性:
console.log(user.name); // test
同样,你也可以使用 [ ] 的方式来获取:
const keyValue= 'age',
console.log(user[keyValue]]); //18
如果域内已经申明的变量与你想要的属性名称相同,你可以省略冒号和创建对象的值:
const name = 'test';
const age = '18';
const user = {
name,
age
};
console.log(user); // {name:'test', age:18}
对象文法支持简明的方法。我们可以直接添加一个 .setUserName() 方法:
const name = 'test';
const age = '18';
const user = {
name,
age,
setUserName(name){
this.name = name;
return this;
}
};
console.log(user.setUserName(name).name); // 'test'
这种方法通过 this 关键字指向调用该方法的对象。调用对象当中的一个方法,可以简单通过 . 文法并加上圆括号来调用。为了能够使用 . 文法,这一方法必须是函数的一个属性。另外,你也可以使用函数原型的方式来添加新方法, .call(), .apply() 和 .bind()。
在上面的例子当中,user.setUserName('test')将 .setUserName() 绑定到对象user上了,因此这里 this == user. 在 .setUserName() 方法中,我们改变了user 对象的name属性并且也为方法提供相同的实例。
对象文法对应一个,工厂方法对应多个
如果你想要创建很多个新对象,你会想要把对象和工厂的能力组合起来使用。
通过一个工厂函数方法,你想要多少个对象就可以创建多少个。举个例子,如果你正在开发一个聊天应用,你可以用一个用户对象来代表当前的一个用户。同时,也会存在很多个其他用户对象来代表当前登录并且正在使用的用户,并且你也可以显示他们的用户名和头像。
现在,让我们把 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 关键字。
()=>'str'; // 这个表达式没有任何参数并且返回字符串'str'
在使用返回对象文法时需要小心。当你使用花括号 {} 时,JavaScript 会默认你想要创建一个函数主体,如 { broken: true } 。在使用隐式返回对象时,你想要避免使用花括号返回对象。
const noop = () => {foo:'bar'};
console.log(noop()); // undefined
const createFoo = () => ({ foo:'bar' });
console.log(createFoo())); // {foo:'bar'}
在第一个例子当中,foo 被推断是一个label 标签,而 bar 则被认为是一个没有被申明或者返回表达式。所以整个函数表达式返回 undefined。
在第二个例子当中,圆括号强制把花括号解释为一个计算表达式而不是一个函数主体。
解构赋值
对于这个函数功能需要小心:
const createUser = ({ userName, avatar }) => ({
在这一行表达式当中,花括号当中代表了对象解构赋值。这个函数有一个入参(一个对象),但是从这个单一对象中解构出了2个形参,userName 和 avatar。这两个参数可以直接当成变量在函数主体中使用。同样,我们也可以对数组这样使用:
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 组成的数组 并把它转换成一个对象。因为我们不知道具体关键字的名字,所以我们需要计算属性名来在对象中设置 key/value 。为了达到这个目的,我们借鉴了方括号访问对象属性的方法来在对象上下文中构建对象:
{ [key]: value }
在完成了这一操作后,我们得到了最后的对象:
{ "foo": "bar" }
默认参数
JavaScript 中的函数支持设置默认参数,它带来了以下几个好处:
- 在满足的情况下,用户可以省略参数。
- 这个函数可读性更好,它本身就提供了预期入参的例子。
- 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 和Microsoft Typescript。目前作者在撰写文档时使用的是rtype——在函数式编程中比Typescript具有更高的可读性。
目前,对于类型申明还没有一个明确的胜者。JavaScript 规范中并没有推荐任何一种,因为它们或多或少都存在一些毛病。
类型推断是基于使用的上下文判断变量类型的一个过程。在JavaScript中,使用类型声明是一种很好的方式。
如果你在JavaScript函数声明中提供足够多的推断线索,你将会从类型推断中得到很多好处并且不会引人额外的开销和风险。
当你决定使用Typescript或者Flow的时候,你应该尽量多地利用类型推断,并且在类型推断不足的时候保存类型声明。举个例子,在JavaScript当中没有提供原生方法来申明公共接口。但是在Typescript或者rtype中是很容易且很有效的。
Tern.js 是JavaScript中一种很流行的类型推断工具并且在很多开发工具中都有插件。
Microsoft VSCode不需要Tern.js,它将Typescript的类型推断能力也带入到了常规的JavaScript当中。
当你在JavaScript当中定义函数的默认参数时,类型推断工具(Tern.js, Typescript和Flow)都能够提供IDE hints 来帮助你正确使用API。
没有默认参数的话,IDEs没有足够的提示来指出参数的具体类型(.ts文件中会默认指定any类型)。
带有默认参数的例子则可以推断出参数类型
将一个参数类型固定成一个具体类型并不总是有意义的(它会让泛型函数和高阶函数变得很困难)。当你需要的时候,默认参数会是一个最佳的完成方式,即使你使用的是Typescript和Flow。
工厂函数的Mixin组合
工厂函数非常擅长使用不错的API来处理对象。通常你会发现,这些对象都是你需要的,但是你会发现自己正在将类型的功能放进不同类型的对象中。这个时候,你会想要尝试抽象出这些功能放进一个功能性的Mixin当中,以便以后可以简单的重用。
这就是功能性Mixin 的好处所在。让我们来创建一个 withConstructor Mixin并向所有对象加入.constructor属性。
with-contructor.js
const withConstructor = (constructor) => (o) => ({
// create the delegate [[Prototype]]
__proto__: {
// add the constructor prop to the new [[Prototype]]
constructor,
},
// mix all o's props into the new object
...o,
});
现在你可以在其他Mixin当中调用它了。
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,
getCapacity: () => 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.getCapacity() }
battery status: ${ myDrone.draw(50).getCharge() }%
battery drained: ${ myDrone.draw(75).getCharge() }% remaining
`);
console.log(`
constructor linked: ${ myDrone.constructor === createDrone }
`);
上面的例子中,你可以看到withConstructor只是简单和其他Mixin一块放入管道中。withBattery()也可以使用其他类型的对象,withFlying()也是如此。
在代码当中,组合更多的是一种思考方式而不是一种具体的实现技术。你可以通过很多方式去实现它。函数组合只是一种最简单的构建方式,而工厂函数则是一种封装API实现细节的简单方式。
总结
ES6提供了一种方便的语法来创建对象和工厂函数。对你来说,大多数时候这就够用了。但是因为在JavaScript中,有另一种类似于Java实现的方式:使用class关键字。
在JavaScript当中,class要比工厂方式来得更加繁琐和严格,并且在重构方面也存在一些问题。但是,主流的前端框架,如React和Angular,都包含了这种方式。对于解决一些少见的例子时,这种方式也是值得的。
“Sometimes, the elegant implementation is just a function. Not a method. Not a class. Not a framework. Just a function.” ~ John Carmack
有的时候,优雅的实现也仅是一个函数罢了。
从最简单的实现开始,仅仅在需要的时候考虑更加复杂的实现方式。