如何理解软件开发灵活性和高定制性?
前两讲我们介绍了前端开发领域常见的开发模式和封装思想,这一讲,我们将该主题升华,聊一聊软件开发灵活性和高定制性这个话题。
业务需求是烦琐多变的,因此开发灵活性至关重要,这直接决定了开发效率,而与灵活性相伴相生的话题就是定制性。本讲主要从设计模式和函数式思想入手,从实际代码出发,来阐释灵活性和高定制性。
设计模式
设计模式——我认为这是一个“一言难尽”的概念。维基百科对设计模式的定义为:
在软件工程中,设计模式(Design Pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。这个术语是由埃里希·伽玛(Erich Gamma)等人在 1990 年代从建筑设计领域引入到计算机科学的。设计模式并不是直接用来完成代码的编写,而是描述在各种不同情况下,要怎么解决问题的一种方案。
设计模式一般认为有 23 种,这 23 种设计模式的本质是面向对象设计原则的实际运用,是对类的封装性、继承性和多态性,以及类的关联关系和组合关系的总结应用。
事实上,设计模式是一种经验总结,它就是一套“兵法”,最终是为了更好的代码重用性、可读性、可靠性、可维护性。我认为设计模式不能只停留在理论上,而是应该结合到实际代码当中。在平常开发中,“也许你不知道,但是已经在使用设计模式了”。
下面我们将从前端中最常见的两种设计模式展开讲解。
代理模式
代理模式大家应该都不陌生,ES.next 提供的 Proxy 特性让我们实现代理模式变得更加容易。关于 Proxy 特性的使用这些基础内容这里不过多赘述,我们直接来看一些代理模式的应用场景。
一个常见的代理模式应用场景是针对计算成本比较高的函数,我们可以通过对函数进行代理,来缓存函数对应参数的计算返回结果。在函数执行时,优先使用缓存值,否则返回执行计算值,如下代码:
const getCacheProxy = (fn, cache = new Map()) =>
// 代理函数 fn
new Proxy(fn, {
// 代理 fn 的调用方法
apply(target, context, args) {
// 将调用参数字符串化,方便作为存储 key
const argsString = args.join(' ')
// 判断是否存在缓存,如果存在直接返回缓存值
if (cache.has(argsString)) {
return cache.get(argsString)
}
// 执行 fn 方法,得到计算结果
const result = fn(...args)
// 存储相关计算结果
cache.set(argsString, result)
return result
}
})
利用上述实现思想,我们还可以很轻松地实现一个根据调用频率来进行截流的函数代理,如下代码实现:
const createThrottleProxy = (fn, timer) => {
// 计算时间差
let last = Date.now() - timer
// 代理函数 fn
return new Proxy(fn, {
// 代理函数调用
apply(target, context, args) {
// 计算距离上次调用的时间差,如果大于 rate 则直接调用
if (Date.now() - last >= rate) {
fn(args)
// 记录此次调用时间
last = Date.now()
}
}
})
}
我们再看一个 jQuery 中的一个例子,jQuery 中$.proxy()
方法接受一个已有的函数,并返回一个带有特定上下文的新函数。比如对于向一个特定对象的元素添加事件回调,如下代码:
$( "button" ).on( "click", function () {
setTimeout(function () {
$(this).addClass( "active" );
});
});
上述代码中的$(this)
因为是在setTimeout
中执行,不再是预期之中的“当前触发事件的元素”,我们可以存储 this 指向来完成:
$( "button" ).on( "click", function () {
var that = $(this)
setTimeout(function () {
that.addClass( "active" );
});
});
也可以使用 jQuey 中的代理方法。如下代码:
$( "button" ).on( "click", function () {
setTimeout($.proxy( unction () {
// 这里的 this 指向正确
$(this).addClass( "active" );
}, this), 500);
});
其实,jQuery 源码中$.proxy
的实现也并不困难:
proxy: function( fn, context ) {
// ...
// 模拟 bind 方法
var args = slice.call(arguments, 2),
proxy = function() {
return fn.apply( context, args.concat( slice.call( arguments ) ) );
};
// 这里的做法主要为了使得 proxy 全局唯一,以便后续删除
proxy.guid = fn.guid = fn.guid || proxy.guid || jQuery.guid++;
return proxy;
}
上述代码中我们模拟了bind
方法,以保证 this 上下文的准确。
事实上,代理模式在前端中的使用场景非常多。我们熟悉的 Vue 框架,为了完成对数据的拦截和代理,以便结合观察者模式,对数据变化进行响应,在最新版本中,也使用了 Proxy 特性,这些都是代理模式的典型应用。
装饰者模式
简单来说,装饰者模式就是在不改变原对象的基础上,对其对象进行包装和拓展,使原对象能够应对更加复杂的需求。这有点像高阶函数,因此在前端开发中很常见,如下面代码:
import React, { Component } from 'react'
import {connect} from 'react-redux'
class App extends Component {
render() {
//...
}
}
export default connect(mapStateToProps,actionCreators)(App);
react-redux 类库中的connect
方法,对相关 React 组件进行包装,以拓展新的 Props。另外,这种方法在 ant-design 中也有非常典型的应用,如下面代码:
class CustomizedForm extends React.Component {}
CustomizedForm = Form.create({})(CustomizedForm)
如上代码,我们将一个 React 组件进行“装饰”,使其获得了表单组件的一些特性。
事实上,我们将上述介绍的两种模式相结合,很容易衍生出 AOP 面向切面编程的概念。如下代码:
Function.prototype.before = function(fn) {
// 函数本身
const self = this
return function() {
// 执行 self 函数前,需要执行的函数 fn
fn.apply(new(self), arguments)
return self.apply(new(self), arguments)
}
}
Function.prototype.after = function(fn) {
const self = this
return function() {
// 先执行 self 函数
self.apply(new(self), arguments)
// 执行 self 函数后,需要执行的函数 fn
return fn.apply(new(self), arguments)
}
}
如上代码,我们对函数原型进行了扩展,在函数调用前后分别调用了相关切面方法。一个典型的场景就是对表单提交值进行验证。如下代码:
const validate = function(){
// 表单验证逻辑
}
const formSubmit = function() {
// 表单提交逻辑
ajax( 'http:// xxx.com/login', param )
}
submitBtn.onclick = function() {
formSubmit.before( validate )
}
至此,我们对前端中常见的两种设计模式进行了分析,实际上,在前端中还处处可见观察者模式等经典设计模式的应用,我们将在下一讲中,进行更多说明。
函数式思想应用
前面我们介绍了设计模式相关内容,事实上,设计模式和面向对象话题相伴相生,而面向对象和函数式思想“相互对立”,互为补充。函数式思想在前端领域同样应用颇多,这里我们简单对函数式思想的基础应用进行说明。
函数组合的简单应用
纯函数是指:
一个函数如果输入参数确定,输出结果是唯一确定的,那么它就是纯函数。
同时,需要强调的是纯函数不能修改外部变量,不能调用 Math.radom() 方法以及发送异步请求等,因为这些操作都不具有确定性,可能会产生副作用。
纯函数是函数式编程中最基本的概念。另一个基本概念是——高阶函数:
高阶函数体现了“函数是第一等公民”,它是指这样的一类函数:该函数接受一个函数作为参数,返回另外一个函数。
我们来看一个例子:filterLowerThan10
这个函数接受一个数组作为参数,它会挑选出数组中数值小于 10 的项目,所有符合条件的值都会构成新数组被返回:
const filterLowerThan10 = array => {
let result = []
for (let i = 0, length = array.length; i < length; i++) {
let currentValue = array[i]
if (currentValue < 10) result.push(currentValue)
}
return result
}
另外一个需求,挑选出数组中非数值项目,所有符合条件的值都会构成新数组被返回,如下filterNaN
函数:
const filterNaN = array => {
let result = []
for (let i = 0, length = array.length; i < length; i++) {
let currentValue = array[i]
if (isNaN(currentValue)) result.push(currentValue)
}
return result
}
上面两个函数都是比较典型的纯函数,不够优雅的一点是 filterLowerThan10 和 filterNaN都有遍历的逻辑,都存在了重复的 for 循环。它们本质上都是遍历一个列表,并用给定的条件过滤列表。那么我们能否用函数式的思想,将遍历和筛选解耦呢?
好在 JavaScript 对函数式较为友好,我们使用 Filter 函数来完成,并进行一定程度的改造,如下代码:
const lowerThan10 = value => value < 10
[12, 3, 4, 89].filter(lowerThan10)
继续延伸我们的场景,如果输入比较复杂,想先过滤出小于 10 的项目,需要先保证数组中每一项都是 Number 类型,那么可以使用下面的代码:
[12, 'sd', null, undefined, {}, 23, 45, 3, 6].filter(value=> !isNaN(value) && value !== null).filter(lowerThan10)
我们通过组合,实现了更多的场景。
curry 和 uncurry
继续思考上面的例子,filterLowerThan10 还是硬编码写死了 10 这个阈值,我们用 curry 化的思想将其改造,如下代码:
const filterLowerNumber = number => {
return array => {
let result = []
for (let i = 0, length = array.length; i < length; i++) {
let currentValue = array[i]
if (currentValue < number) result.push(currentValue)
}
return result
}
}
const filterLowerThan10 = filterLowerNumber(10)
上面代码中我们提到了 curry 化这个概念,简单说明:
curry 化,柯里化(currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。这个技术由克里斯托弗·斯特雷奇以逻辑学家哈斯凯尔·加里命名的。
curry 化的优势非常明显:
提高复用性
减少重复传递不必要的参数
动态根据上下文创建函数
其中动态根据上下文创建函数,也是一种惰性求值的体现。比如这段代码:
const addEvent = (function() {
if (window.addEventListener) {
return function (type, element, handler, capture) {
element.addEventListener(type, handler, capture)
}
}
else if (window.attachEvent){
return function (type, element, fn) {
element.attachEvent('on' + type, fn)
}
}
})()
这是一个典型兼容 IE9 浏览器事件 API 的例子,根据兼容性的嗅探,充分利用 curry 化思想,完成了需求。
那么我们如何编写一个通用化的 curry 函数呢?下面我给出一种方案:
const curry = (fn, length) => {
// 记录函数的行参个数
length = length || fn.length
return function (...args) {
// 当参数未满时,递归调用
if (args.length < length) {
return curry(fn.bind(this, ...args), length - args.length)
}
// 参数已满,执行 fn 函数
else {
return fn.call(this, ...args)
}
}
}
如果不想使用 bind,另一种常规思路是对每次调用时产生的参数进行存储:
const curry = fn =>
judge = (...arg1) =>
// 判断参数是否已满
arg1.length >= fn.length
? fn(...arg1) // 执行函数
: (...arg2) => judge(...arg1, ...arg2) // 将参数合并,继续递归调用
对应 curry 化,还有一种反 curry 化的概念:反 curry 化在于扩大函数的适用性,使本来作为特定对象所拥有的功能函数可以被任意对象使用。
有一个 UI 组件 Toast,如下代码简化为:
function Toast (options) {
this.message = ''
}
Toast.prototype = {
showMessage: function () {
console.log(this.message)
}
}
这样的代码,使得 Toast 实例均可使用 ShowMessage 方法,使用方式如下:
new Toast({message: 'show me'}).showMessage()
如果脱离组件场景,我们不想实现 Toast 实例,而使用Toast.prototype.showMessage
方法,预期通过反 curry 化实现,如下代码:
// 反 curry 化通用函数
// 核心实现思想是:先取出要执行 fn 方法的对象,标记为 obj1,同时从 arguments 中删除,在调用 fn 时,将 fn 执行上下文环境改为 obj1
const unCurry = fn => (...args) => fn.call(...args)
const obj = {
message: 'uncurry test'
}
const unCurryShowMessaage = unCurry(Toast.prototype.showMessage)
unCurryShowMessaage(obj)
以上是正常函数实现 uncurry 的实现。我们也可以将 uncurry 挂载在函数原型上,如下代码:
// 反 curry 化通用函数挂载在函数原型上
Function.prototype.unCurry = !Function.prototype.unCurry || function () {
const self = this
return function () {
return Function.prototype.call.apply(self, arguments)
}
}
当然,我们可以借助 bind 实现:
Function.prototype.unCurry = function() {
return this.call.bind(this)
}
我们通过下面这个例子来理解:
// 将 Array.prototype.push 反 curry 化,实现一个适用于对象的 push 方法
const push = Array.prototype.push.unCurry()
const test = { foo: 'lucas' }
push(test, 'messi', 'ronaldo', 'neymar')
console.log(test)
// {0: "messi", 1: "ronaldo", 2: "neymar", foo: "lucas", length: 3}
反 curry 化的核心思想就在于:利用第三方对象和上下文环境,“强行改命,为我所用”。
最后我们再看一个例子,我们将对象原型上的toString
方法“为我所用”,实现了一个更普遍适用的类型检测函数。如下代码:
// 利用反 curry 化,创建一个检测数据类型的函数 checkType
let checkType = uncurring(Object.prototype.toString)
checkType('lucas'); // [object String]
总结
这一讲我们从设计模式和函数式两大编程思想流派入手,分析了如何在编程中做到灵活性和高定制性,并通过大量的实例来强化思想,巩固认识。
本讲主要内容如下:
事实上,前端领域中的灵活性和高定制性编码方案和其他领域相关思想是完全一致的,设计模式和函数式具有“普世意义”,我们将会在下一讲中继续延伸这类话题。
这里我也给大家留一个思考题:你还用过哪些设计模式的使用场景呢?欢迎在留言区和我分享你的经历。下一讲,我们将深入对象和原型,理解 JavaScript 在这个方向上的能力。请注意,下一讲我们不再过多赘述基础,而是面向进阶,需要你具有一定的知识准备。我们下一讲再见。
# 精选评论
# *聪:
使用过发布订阅模式来实现React任意组件间的数据传递功能
# *聪:
// 反 curry 化通用函数挂载在函数原型上Function.prototype.unCurry = !Function.prototype.unCurry || function () { const self = this return function () { return Function.prototype.call.apply(self, arguments) }}老师这个!Function.prototype.unCurry前面不需要取反吧?
# 讲师回复:
对,这是个 typo
# **青:
请教下,最后一个例子,与使用
Object.prototype.toString.call()
有什么不同吗?反柯里化与Function.prototype.call的区别在什么地方呢?
# 讲师回复:
Function.prototype.call 就是函数调用,和反柯里化没关系,只是实现反柯里化的手段