装饰器模式,又名装饰者模式。它的定义是“在不改变原对象的基础上,通过对其进行包装拓展,使原有对象可以满足用户的更复杂需求”。
当然,对于没接触过装饰器的同学来说,这段定义意义不大。我们先借助一个生活中的例子来理解装饰器:
去年有个手机壳在同事里非常流行,我也随大流买了一个,它长这样:
这个手机壳的安装方式和普通手机壳一样,就是卡在手机背面。不同的是它卡上去后会变成一块水墨屏,这样一来我们手机就有了两个屏幕。平时办公或者玩游戏的时候,用正面的普通屏幕;阅读的时候怕伤眼睛,就可以翻过来用背面的水墨屏了。
这个水墨屏手机壳安装后,不会对手机原有的功能产生任何影响,仅仅是使手机具备了一种新的能力(多了块屏幕),因此它在此处就是一个标准的装饰器。
装饰器的应用场景
按钮是我们平时写业务时常见的页面元素。假设我们的初始需求是:每个业务中的按钮在点击后都弹出「您还未登录哦」的弹框。
那我们可以很轻易地写出这个需求的代码:
按钮点击需求1.0
按钮发布上线后,过了几天太平日子。忽然有一天,产品经理找到你,说这个弹框提示还不够明显,我们应该在弹框被关闭后把按钮的文案改为“快去登录”,同时把按钮置灰。
听到这个消息,你立刻马不停蹄地翻出之前的代码,找到了按钮的 click 监听函数,手动往里面添加了文案修改&按钮置灰逻辑。但这还没完,因为你司的几乎每个业务里都用到了这类按钮:除了“点击打开”按钮,还有“点我开始”、“点击购买”按钮等各种五花八门的按钮,这意味着你不得不深入到每一个业务的深处去给不同的按钮添加这部分逻辑。
有的业务不在你这儿,但作为这个新功能迭代的 owner,你还需要把需求细节再通知到每一个相关同事(要么你就自己上,去改别人的代码,更恐怖),怎么想怎么麻烦。一个文案修改&按钮置灰尚且如此麻烦,更不要说我们日常开发中遇到的更复杂的需求变更了
不仅麻烦,直接去修改已有的函数体,这种做法违背了我们的“开放封闭原则”;往一个函数体里塞这么多逻辑,违背了我们的“单一职责原则”。所以说这个事儿,越想越不能这么干。
我想一定会有同学质疑说为啥不把按钮抽成公共组件 Button,这样只需要在 Button 组件里修改一次逻辑就可以了。这种想法非常好。但注意,我们楼上的例子没有写组件直接写了 Button 标签是为了简化示例。事实上真要写组件的话,不同业务里必定有针对业务定制的不同 Button 组件,比如 MoreButton 、BeginButton等等,也是五花八门的,所以说我们仍会遇到同样的困境。
讲真,我想任何人去做这个需求的时候,其实都压根不想去关心它现有的业务逻辑是啥样的——你说这按钮的旧逻辑是我自己写的还好,理解成本不高;万一碰上是个离职同事写的,那阅读难度谁能预料呢?我不想接锅,我只是想对它已有的功能做个拓展,只关心拓展出来的那部分新功能如何实现,对不对?
程序员说:“我不想努力了,我想开挂”,于是便有了装饰器模式。
为了不被已有的业务逻辑干扰,当务之急就是将旧逻辑与新逻辑分离,把旧逻辑抽出去:
// 将展示Modal的逻辑单独封装
function openModal() {
const modal = new Modal()
modal.style.display = 'block'
}
编写新逻辑:
// 按钮文案修改逻辑
function changeButtonText() {
const btn = document.getElementById('open')
btn.innerText = '快去登录'
}
// 按钮置灰逻辑
function disableButton() {
const btn = document.getElementById('open')
btn.setAttribute("disabled", true)
}
// 新版本功能逻辑整合
function changeButtonStatus() {
changeButtonText()
disableButton()
}
然后把三个操作逐个添加open按钮的监听函数里:
document.getElementById('open').addEventListener('click', function() {
openModal()
changeButtonStatus()
})
如此一来,我们就实现了“只添加,不修改”的装饰器模式,使用changeButtonStatus的逻辑装饰了旧的按钮点击逻辑。以上是ES5中的实现,ES6中,我们可以以一种更加面向对象化的方式去写:
// 定义打开按钮
class OpenButton {
// 点击后展示弹框(旧逻辑)
onClick() {
const modal = new Modal()
modal.style.display = 'block'
}
}
// 定义按钮对应的装饰器
class Decorator {
// 将按钮实例传入
constructor(open_button) {
this.open_button = open_button
}
onClick() {
this.open_button.onClick()
// “包装”了一层新逻辑
this.changeButtonStatus()
}
changeButtonStatus() {
this.changeButtonText()
this.disableButton()
}
disableButton() {
const btn = document.getElementById('open')
btn.setAttribute("disabled", true)
}
changeButtonText() {
const btn = document.getElementById('open')
btn.innerText = '快去登录'
}
}
const openButton = new OpenButton()
const decorator = new Decorator(openButton)
document.getElementById('open').addEventListener('click', function() {
// openButton.onClick()
// 此处可以分别尝试两个实例的onClick方法,验证装饰器是否生效
decorator.onClick()
})
大家这里需要特别关注一下 ES6 这个版本的实现,这里我们把按钮实例传给了 Decorator,以便于后续 Decorator 可以对它进行逻辑的拓展。在 ES7 中,Decorator 作为一种语法被直接支持了,它的书写会变得更加简单,但背后的原理其实与此大同小异。在下一节,我们将一起去探究一下 ES7 中 Decorator 背后的故事。
单一职责原则
大家可能刚刚没来得及注意,按钮新逻辑中,文本修改&按钮置灰这两个变化,被我封装在了两个不同的方法里,并以组合的形式出现在了最终的目标方法changeButtonStatus里。这样做的目的是为了强化大家脑中的“单一职责”意识。将不同的职责分离,可以做到每个职责都能被灵活地复用;同时,不同职责之间无法相互干扰,不会出现因为修改了 A 逻辑而影响了 B 逻辑的狗血剧情。
但是,设计原则并非是板上钉钉的教条。在此处,我们的代码总共只有两行、且比较简单,逻辑分离的诉求并不特别强,分开最好,不分影响也不大(此处我们选择了拆散两段逻辑,更多地是为了强化大家的意识)。在日常开发中,当遇到两段各司其职的代码逻辑时,我们首先要有“尝试拆分”的敏感,其次要有“该不该拆”的判断——当逻辑粒度过小时,盲目拆分会导致你的项目里存在过多的零碎的小方法,这反而不会使我们的代码变得更好。
在 ES7 中,我们可以像写 python 一样通过一个@语法糖轻松地给一个类装上装饰器:
// 装饰器函数,它的第一个参数是目标类
function classDecorator(target) {
target.hasDecorator = true
return target
}
// 将装饰器“安装”到Button类上
@classDecorator
class Button {
// Button类的相关逻辑
}
// 验证装饰器是否生效
console.log('Button 是否被装饰了:', Button.hasDecorator)
也可以用同样的语法糖去装饰类里面的方法:
// 具体的参数意义,在下个小节,这里大家先感知一下操作
function funcDecorator(target, name, descriptor) {
let originalMethod = descriptor.value
descriptor.value = function() {
console.log('我是Func的装饰器逻辑')
return originalMethod.apply(this, arguments)
}
return descriptor
}
class Button {
@funcDecorator
onClick() {
console.log('我是Func的原有逻辑')
}
}
// 验证装饰器是否生效
const button = new Button()
button.onClick()
注:以上代码直接放进浏览器/Node 中运行会报错,因为浏览器和 Node 目前都不支持装饰器语法,需要大家安装 Babel 进行转码:
安装 Babel 及装饰器相关的 Babel 插件
npm install babel-preset-env babel-plugin-transform-decorators-legacy --save-dev
注:在没有任何配置选项的情况下,babel-preset-env 与 babel-preset-latest(或者 babel-preset-es2015,babel-preset-es2016 和 babel-preset-es2017 一起)的行为完全相同。
编写配置文件.babelrc:
{
"presets": ["env"],
"plugins": ["transform-decorators-legacy"]
}
最后别忘了下载全局的 Babel 命令行工具用于转码:
npm install babel-cli -g
执行完这波操作,我们首先是对目标文件进行转码,比如说你的目标文件叫做 test.js,想要把它转码后的结果输出到 babel_test.js,就可以这么写:
babel test.js --out-file babel_test.js
运行babel_test.js
babel_test.js
就可以看到你的装饰器是否生效啦~
OK,知道了装饰器长啥样,我们一起看看装饰器的实现细节:
所谓语法糖,往往意味着“美好的表象”。正如 class 语法糖背后是大家早已十分熟悉的 ES5 构造函数一样,装饰器语法糖背后也是我们的老朋友,不信我们一起来看看@decorator都帮我们做了些什么:
Part1:函数传参&调用
上一节我们使用 ES6 实现装饰器模式时曾经将按钮实例传给了 Decorator,以便于后续 Decorator 可以对它进行逻辑的拓展。这也正是装饰器的最最基本操作——定义装饰器函数,将被装饰者“交给”装饰器。这也正是装饰器语法糖首先帮我们做掉的工作 —— 函数传参&调用。
类装饰器的参数
当我们给一个类添加装饰器时:
function classDecorator(target) {
target.hasDecorator = true
return target
}
// 将装饰器“安装”到Button类上
@classDecorator
class Button {
// Button类的相关逻辑
}
此处的 target 就是被装饰的类本身。
方法装饰器的参数
而当我们给一个方法添加装饰器时:
function funcDecorator(target, name, descriptor) {
let originalMethod = descriptor.value
descriptor.value = function() {
console.log('我是Func的装饰器逻辑')
return originalMethod.apply(this, arguments)
}
return descriptor
}
class Button {
@funcDecorator
onClick() {
console.log('我是Func的原有逻辑')
}
}
此处的 target 变成了Button.prototype,即类的原型对象。这是因为 onClick 方法总是要依附其实例存在的,修饰 onClik 其实是修饰它的实例。但我们的装饰器函数执行的时候,Button 实例还并不存在。为了确保实例生成后可以顺利调用被装饰好的方法,装饰器只能去修饰 Button 类的原型对象。
装饰器函数调用的时机
装饰器函数执行的时候,Button 实例还并不存在。这是因为实例是在我们的代码运行时动态生成的,而装饰器函数则是在编译阶段就执行了。所以说装饰器函数真正能触及到的,就只有类这个层面上的对象。
Part2:将“属性描述对象”交到你手里
在编写类装饰器时,我们一般获取一个target参数就足够了。但在编写方法装饰器时,我们往往需要至少三个参数:
function funcDecorator(target, name, descriptor) {
let originalMethod = descriptor.value
descriptor.value = function() {
console.log('我是Func的装饰器逻辑')
return originalMethod.apply(this, arguments)
}
return descriptor
}
第一个参数的意义,前文已经解释过。第二个参 数name,是我们修饰的目标属性属性名,也没啥好讲的。关键就在这个 descriptor 身上,它也是我们使用频率最高的一个参数,它的真面目就是“属性描述对象”(attributes object)。这个名字大家可能不熟悉,但Object.defineProperty
方法我想大家多少都用过,它的调用方式是这样的:
Object.defineProperty(obj, prop, descriptor)
此处的descriptor和装饰器函数里的 descriptor 是一个东西,它是 JavaScript 提供的一个内部数据结构、一个对象,专门用来描述对象的属性。它由各种各样的属性描述符组成,这些描述符又分为数据描述符和存取描述符:
get
方法(访问属性时调用的方法,默认为 undefined),set
(设置属性时调用的方法,默认为 undefined )很明显,拿到了 descriptor,就相当于拿到了目标方法的控制权。通过修改 descriptor,我们就可以对目标方法的逻辑进行拓展了~
在上文的示例中,我们通过 descriptor 获取到了原函数的函数体(originalMethod),把原函数推迟到了新逻辑(console)的后面去执行。这种做法和我们上一节在ES5中实现装饰器模式时做的事情一模一样,所以说装饰器就是这么回事儿,换汤不换药~
装饰器在前端世界的应用十分广泛,即便是在 ES7 未诞生的那些个蛮荒年代,也没能阻挡我们用装饰器开挂的热情。要说优秀的生产实践,可以说是两天两夜也说不完。但有一些实践,我相信大家可能都用过,或者说至少见过、听说过,只是当时并不清楚这个是装饰器模式。此处为了强化大家脑袋里已有的经验与设计模式知识之间的关联,更为了趁热打铁、将装饰器模式常见的用法给大家加固一下,我们一起来看几个不错的生产实践案例:
React中的装饰器:HOC
高阶组件就是一个函数,且该函数接受一个组件作为参数,并返回一个新的组件。
HOC (Higher Order Component) 即高阶组件。它是装饰器模式在 React 中的实践,同时也是 React 应用中非常重要的一部分。通过编写高阶组件,我们可以充分复用现有逻辑,提高编码效率和代码的健壮性。
我们现在编写一个高阶组件,它的作用是把传入的组件丢进一个有红色边框的容器里(拓展其样式)。
import React, { Component } from 'react'
const BorderHoc = WrappedComponent => class extends Component {
render() {
return
}
}
export default borderHoc
用它来装饰目标组件
import React, { Component } from 'react'
import BorderHoc from './BorderHoc'
// 用BorderHoc装饰目标组件
@BorderHoc
class TargetComponent extends React.Component {
render() {
// 目标组件具体的业务逻辑
}
}
// export出去的其实是一个被包裹后的组件
export default TargetComponent
可以看出,高阶组件从实现层面来看其实就是上文我们提到的类装饰器。在高阶组件的辅助下,我们不必因为一个小小的拓展而大费周折地编写新组件或者把一个新逻辑重写 N 多次,只需要轻轻 @ 一下装饰器即可。
#8.3.2 使用装饰器改写 Redux connect
Redux 是热门的状态管理工具。在 React 中,当我们想要引入 Redux 时,通常需要调用 connect 方法来把状态和组件绑在一起:
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import action from './action.js'
class App extends Component {
render() {
// App的业务逻辑
}
}
function mapStateToProps(state) {
// 假设App的状态对应状态树上的app节点
return state.app
}
function mapDispatchToProps(dispatch) {
// 这段看不懂也没关系,下面会有解释。重点理解connect的调用即可
return bindActionCreators(action, dispatch)
}
// 把App组件与Redux绑在一起
export default connect(mapStateToProps, mapDispatchToProps)(App)
这里给没用过 redux 的同学解释一下 connect 的两个入参:mapStateToProps
是一个函数,它可以建立组件和状态之间的映射关系;mapDispatchToProps
也是一个函数,它用于建立组件和store.dispatch
的关系,使组件具备通过 dispatch 来派发状态的能力。
总而言之,我们调用 connect 可以返回一个具有装饰作用的函数,这个函数可以接收一 个React 组件作为参数,使这个目标组件和 Redux 结合、具备 Redux 提供的数据和能力。既然有装饰作用,既然是能力的拓展,那么就一定能用装饰器来改写: 把 connect 抽出来:
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import action from './action.js'
function mapStateToProps(state) {
return state.app
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(action, dispatch)
}
// 将connect调用后的结果作为一个装饰器导出
export default connect(mapStateToProps, mapDispatchToProps)
在组件文件里引入connect:
import React, { Component } from 'react'
import connect from './connect.js'
@connect
export default class App extends Component {
render() {
// App的业务逻辑
}
}
这样一来,我们的代码结构是不是清晰了很多?可维护性、可读性都上升了一个level,令人赏心悦目~
Tips: 回忆一下上面一个小节的讲解,对号入座看一看,connect装饰器从实现和调用方式上来看,是不是同时也是一个高阶组件呢?
优质的源码阅读材料——core-decorators
前面都在教大家怎么写装饰器模式,这里来聊聊怎么用好装饰器模式。
装饰器模式的优势在于其极强的灵活性和可复用性——它本质上是一个函数,而且往往不依赖于任何逻辑而存在。这一点提醒了我们,当我们需要用到某个反复出现的拓展逻辑时,比起自己闷头搞,不如去看一看团队(社区)里有没有现成的实现,如果有,那么贯彻“拿来主义”,直接@就可以了。所以说装饰器模式是个好同志,它可以帮我们省掉大量复制粘贴的时间。
这里就要给大家推荐一个非常赞的装饰模式库 —— core-decorators。core-decorators 帮我们实现好了一些使用频率较高的装饰器,比如@readonly
(使目标属性只读)、@deprecate
(在控制台输出警告,提示用户某个指定的方法已被废除)等等等等。这里强烈建议大家把 core-decorators 作为自己的源码阅读材料,你能收获的或许比你想象中更多~
适配器模式通过把一个类的接口变换成客户端所期待的另一种接口,可以帮我们解决不兼容的问题。
前段时间用了很久的 iPhone 6s丢了,请假跑出去买了台 iPhone X。结果有天听歌的时候发现X的耳机孔竟然是方形的,长这样:
而重度 iPhone 6s 用户&耳机发烧友的耳机线,可能是如图一所示,没错,它们都是圆头耳机,意识到这一点的时候,我佛了。
此时我好像只能在重新买一批耳机(很有可能同款耳机并没有方头的款式)和重新买一台手机之间做选择了。好在我不是一个普通的倒霉蛋,我学过设计模式,设计模式告诉我这种实际接口与目标接口不匹配的尴尬可以用一个叫适配器的东西来化解。打开万能的淘宝一搜,还真有,如图二所示。
只要装上它,圆头耳机就可以完美适配方形插槽,最终效果如图三所示。
大家现在回顾楼上这波操作,这个耳机转换头做的事情,是不是就是我们开头说的把一个类
(iPhone 新机型)的接口
(方形)变换成客户端
(用户)所期待的另一种接口
(圆形)?
最终达到的效果,就是用户
(我)可以像使用 iPhone 6s 插口一样使用 iPhoneX 的插口,而不用感知两者间的差异。我们设计模式中的适配器,和楼上这个适配器做的事情可以说是一模一样,同样具有化腐朽为神奇的力量。
大家知道我们现在有一个非常好用异步方案叫fetch,它的写法比ajax优雅很多。因此在不考虑兼容性的情况下,我们更愿意使用fetch、而不是使用ajax来发起异步请求。李雷是拜fetch教的忠实信徒,为了能更好地使用fetch,他封装了一个基于fetch的http方法库:
export default class HttpUtils {
// get方法
static get(url) {
return new Promise((resolve, reject) => {
// 调用fetch
fetch(url)
.then(response => response.json())
.then(result => {
resolve(result)
})
.catch(error => {
reject(error)
})
})
}
// post方法,data以object形式传入
static post(url, data) {
return new Promise((resolve, reject) => {
// 调用fetch
fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
},
// 将object类型的数据格式化为合法的body参数
body: this.changeData(data)
})
.then(response => response.json())
.then(result => {
resolve(result)
})
.catch(error => {
reject(error)
})
})
}
// body请求体的格式化方法
static changeData(obj) {
var prop,
str = ''
var i = 0
for (prop in obj) {
if (!prop) {
return
}
if (i == 0) {
str += prop + '=' + obj[prop]
} else {
str += '&' + prop + '=' + obj[prop]
}
i++
}
return str
}
}
当我想使用 fetch 发起请求时,只需要这样轻松地调用,而不必再操心繁琐的数据配置和数据格式化:
// 定义目标url地址
const URL = "xxxxx"
// 定义post入参
const params = {
// ...
}
// 发起post请求
const postResponse = await HttpUtils.post(URL,params) || {}
// 发起get请求
const getResponse = await HttpUtils.get(URL)
真是个好用的方法库!老板看了李雷的 HttpUtils 库,喜上眉梢——原来老板也是个拜 fetch 教。老板说李雷,咱们公司以后要做潮流公司了,写代码不再考虑兼容性,我希望你能把公司所有的业务的网络请求都迁移到你这个 HttpUtils 上来,这样以后你只用维护这一个库了,也方便。李雷一听,悲从中来——他是该公司的第 99 代员工,对远古时期的业务一无所知。而该公司第1代员工封装的网络请求库,是基于 XMLHttpRequest 的,差不多长这样:
function Ajax(type, url, data, success, failed){
// 创建ajax对象
var xhr = null;
if(window.XMLHttpRequest){
xhr = new XMLHttpRequest();
} else {
xhr = new ActiveXObject('Microsoft.XMLHTTP')
}
// ...(此处省略一系列的业务逻辑细节)
var type = type.toUpperCase();
// 识别请求类型
if(type == 'GET'){
if(data){
xhr.open('GET', url + '?' + data, true); //如果有数据就拼接
}
// 发送get请求
xhr.send();
} else if(type == 'POST'){
xhr.open('POST', url, true);
// 如果需要像 html 表单那样 POST 数据,使用 setRequestHeader() 来添加 http 头。
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
// 发送post请求
xhr.send(data);
}
// 处理返回数据
xhr.onreadystatechange = function(){
if(xhr.readyState == 4){
if(xhr.status == 200){
success(xhr.responseText);
} else {
if(failed){
failed(xhr.status);
}
}
}
}
}
实现逻辑我们简单描述了一下,这个不是重点,重点是它是这样调用的:
// 发送get请求
Ajax('get', url地址, post入参, function(data){
// 成功的回调逻辑
}, function(error){
// 失败的回调逻辑
})
李雷佛了 —— 不仅接口名不同,入参方式也不一样,这手动改要改到何年何日呢?
还好李雷学过设计模式,他立刻联想到了专门为我们抹平差异的适配器模式。要把老代码迁移到新接口,不一定要挨个儿去修改每一次的接口调用——正如我们想用 iPhoneX + 旧耳机听歌,不必挨个儿去改造耳机一样,我们只需要在引入接口时进行一次适配,便可轻松地 cover 掉业务里可能会有的多次调用(具体的解析在注释里):
// Ajax适配器函数,入参与旧接口保持一致
async function AjaxAdapter(type, url, data, success, failed) {
const type = type.toUpperCase()
let result
try {
// 实际的请求全部由新接口发起
if(type === 'GET') {
result = await HttpUtils.get(url) || {}
} else if(type === 'POST') {
result = await HttpUtils.post(url, data) || {}
}
// 假设请求成功对应的状态码是1
result.statusCode === 1 && success ? success(result) : failed(result.statusCode)
} catch(error) {
// 捕捉网络错误
if(failed){
failed(error.statusCode);
}
}
}
// 用适配器适配旧的Ajax方法
async function Ajax(type, url, data, success, failed) {
await AjaxAdapter(type, url, data, success, failed)
}
如此一来,我们只需要编写一个适配器函数AjaxAdapter,并用适配器去承接旧接口的参数,就可以实现新旧接口的无缝衔接了~
数月之后,李雷的老板发现了网络请求神库axios,于是团队的方案又整个迁移到了axios——对于心中有适配器的李雷来说,这现在已经根本不是个事儿。不过本小节我们要聊的可不再是“如何使现有接口兼容axios”了(这招我们上个小节学过了)。此处引出axios,一是因为大家对它足够熟悉(不熟悉的同学,点这里可以快速熟悉一下~),二是因为axios本身就用到了我们的适配器模式,它的兼容方案值得我们学习和借鉴。
在使用axios时,作为用户我们只需要掌握以下面三个最常用的接口为代表的一套api:
// Make a request for a user with a given ID
axios.get('/user?ID=12345')
.then(function (response) {
// handle success
console.log(response);
})
.catch(function (error) {
// handle error
console.log(error);
})
.then(function () {
// always executed
})
axios.post('/user', {
firstName: 'Fred',
lastName: 'Flintstone'
})
.then(function (response) {
console.log(response);
})
.catch(function (error) {
console.log(error);
});
axios({
method: 'post',
url: '/user/12345',
data: {
firstName: 'Fred',
lastName: 'Flintstone'
}
})
便可轻松地发起各种姿势的网络请求,而不用去关心底层的实现细节。 除了简明优雅的api之外,axios 强大的地方还在于,它不仅仅是一个局限于浏览器端的库。在Node环境下,我们尝试调用上面的 api,会发现它照样好使 —— axios 完美地抹平了两种环境下api的调用差异,靠的正是对适配器模式的灵活运用。
在 axios 的核心逻辑中,我们可以注意到实际上派发请求的是 dispatchRequest 方法。该方法内部其实主要做了两件事:
调用适配器的逻辑如下:
// 若用户未手动配置适配器,则使用默认的适配器
var adapter = config.adapter || defaults.adapter;
// dispatchRequest方法的末尾调用的是适配器方法
return adapter(config).then(function onAdapterResolution(response) {
// 请求成功的回调
throwIfCancellationRequested(config);
// 转换响应体
response.data = transformData(
response.data,
response.headers,
config.transformResponse
);
return response;
}, function onAdapterRejection(reason) {
// 请求失败的回调
if (!isCancel(reason)) {
throwIfCancellationRequested(config);
// 转换响应体
if (reason && reason.response) {
reason.response.data = transformData(
reason.response.data,
reason.response.headers,
config.transformResponse
);
}
}
return Promise.reject(reason);
});
大家注意注释的第一行,“若用户未手动配置适配器,则使用默认的适配器”。手动配置适配器允许我们自定义处理请求,主要目的是为了使测试更轻松。
实际开发中,我们使用默认适配器的频率更高。默认适配器在axios/lib/default.js
里是通过getDefaultAdapter
方法来获取的:
function getDefaultAdapter() {
var adapter;
// 判断当前是否是node环境
if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
// 如果是node环境,调用node专属的http适配器
adapter = require('./adapters/http');
} else if (typeof XMLHttpRequest !== 'undefined') {
// 如果是浏览器环境,调用基于xhr的适配器
adapter = require('./adapters/xhr');
}
return adapter;
}
我们再来看看 Node 的 http 适配器和 xhr 适配器大概长啥样:
http 适配器:
module.exports = function httpAdapter(config) {
return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) {
// 具体逻辑
}
}
xhr 适配器:
module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
// 具体逻辑
}
}
具体逻辑啥样,咱们目前先不关心,有兴趣的同学,可以狠狠地点这里阅读源码。咱们现在就注意两个事儿:
Tips:要是仔细读了源码,会发现两个适配器中的 Promise 的内部结构也是如出一辙。
这么一来,通过 axios 发起跨平台的网络请求,不仅调用的接口名是同一个,连入参、出参的格式都只需要掌握同一套。这导致它的学习成本非常低,开发者看了文档就能上手;同时因为足够简单,在使用的过程中也不容易出错,带来了极佳的用户体验,axios 也因此越来越流行。
这正是一个好的适配器的自我修养——把变化留给自己,把统一留给用户。在此处,所有关于 http 模块、关于 xhr 的实现细节,全部被 Adapter 封装进了自己复杂的底层逻辑里,暴露给用户的都是十分简单的统一的东西——统一的接口,统一的入参,统一的出参,统一的规则。用起来就是一个字 —— 爽!
适配器模式的思想可以说是遍地开花,稍微多看几个库,你会发现不仅 axios 在用适配器,其它库也在用。如果哪怕只有一个同学因为今天读了这一节,对这个“看起来很厉害”的 axios 产生了好奇,或者说对读源码这件事情萌生了兴趣、进而刻意地去培养了自己的阅读习惯,那么你在繁忙的工作/学业中抽出的宝贵的用来阅读这一节内容的时间就没有白费,这本小册也算不负使命、远远大于它本身的价值了。
代理模式,式如其名——在某些情况下,出于种种考虑/限制,一个对象不能直接访问另一个对象,需要一个第三者(代理)牵线搭桥从而间接达到访问目的,这样的模式就是代理模式。
代理模式非常好理解,因为你可能天天都在用,只是没有刻意挖掘过它背后的玄机——比如大家耳熟能详的科学上网,就是代理模式的典型案例。
科学上网,就是咱们常说的 VPN(虚拟专用网络)。大家知道,正常情况下,我们尝试去访问 Google.com,Chrome会给你一个这样的提示: 这是为啥呢?这就要从网络请求的整个流程说起了。一般情况下,当我们访问一个 url 的时候,会发生下图的过程: 为了屏蔽某些网站,一股神秘的东方力量会作用于你的 DNS 解析过程,告诉它:“你不能解析出xxx.xxx.xxx.xxx(某个特殊ip)的地址”。而我们的 Google.com,不幸地出现在了这串被诅咒的 ip 地址里,于是你的 DNS 会告诉你:“对不起,我查不到”。
但有时候,一部分人为了搞学习,通过访问VPN,是可以间接访问到 Google.com 的。这背后,就是代理模式在给力。在使用VPN时,我们的访问过程是这样的:
没错,比起常规的访问过程,多出了一个第三方 —— 代理服务器。这个第三方的 ip 地址,不在被禁用的那批 ip 地址之列,我们可以顺利访问到这台服务器。而这台服务器的 DNS 解析过程,没有被施加咒语,所以它是可以顺利访问 Google.com 的。代理服务器在请求到 Google.com 后,将响应体转发给你,使你得以间接地访问到目标网址 —— 像这种第三方代替我们访问目标对象的模式,就是代理模式。
这样看来,开婚介所确实是个发家致富的好路子。既然暴富的机会就在眼前,那么事不宜迟,我们接下来就一起用 JavaScript 来实现一个小型婚介所。
前置知识: ES6中的Proxy
前置知识: ES6中的Proxy
const proxy = new Proxy(obj, handler)
第一个参数是我们的目标对象,也就是上文中的“未知妹子”。handler 也是一个对象,用来定义代理的行为,相当于上文中的“婚介所”。当我们通过 proxy 去访问目标对象的时候,handler会对我们的行为作一层拦截,我们的每次访问都需要经过 handler 这个第三方。
“婚介所”的实现
未知妹子的个人信息,刚问了下我们已经注册了 VIP 的同事哥,大致如下:
// 未知妹子
const girl = {
// 姓名
name: '小美',
// 自我介绍
aboutMe: '...'(大家自行脑补吧)
// 年龄
age: 24,
// 职业
career: 'teacher',
// 假头像
fakeAvatar: 'xxxx'(新垣结衣的图片地址)
// 真实头像
avatar: 'xxxx'(自己的照片地址),
// 手机号
phone: 123456,
}
婚介所收到了小美的信息,开始营业。大家想,这个姓名、自我介绍、假头像,这些信息大差不差,曝光一下没问题。但是人家妹子的年龄、职业、真实头像、手机号码,是不是属于非常私密的信息了?要想 get 这些信息,平台要考验一下你的诚意了 —— 首先,你是不是已经通过了实名审核?如果通过实名审核,那么你可以查看一些相对私密的信息(年龄、职业)。然后,你是不是 VIP ?只有 VIP 可以查看真实照片和联系方式。满足了这两个判定条件,你才可以顺利访问到别人的全部私人信息,不然,就劝退你提醒你去完成认证和VIP购买再来。
// 普通私密信息
const baseInfo = ['age', 'career']
// 最私密信息
const privateInfo = ['avatar', 'phone']
// 用户(同事A)对象实例
const user = {
// ...(一些必要的个人信息)
isValidated: true,
isVIP: false,
}
// 掘金婚介所登场了
const JuejinLovers = new Proxy(girl, {
get: function(girl, key) {
if(baseInfo.indexOf(key)!==-1 && !user.isValidated) {
alert('您还没有完成验证哦')
return
}
//...(此处省略其它有的没的各种校验逻辑)
// 此处我们认为只有验证过的用户才可以购买VIP
if(user.isValidated && privateInfo.indexOf(key) && !user.isVIP) {
alert('只有VIP才可以查看该信息哦')
return
}
}
})
以上主要是 getter 层面的拦截。假设我们还允许会员间互送礼物,每个会员可以告知婚介所自己愿意接受的礼物的价格下限,我们还可以作 setter 层面的拦截。:
// 规定礼物的数据结构由type和value组成
const present = {
type: '巧克力',
value: 60,
}
// 为用户增开presents字段存储礼物
const girl = {
// 姓名
name: '小美',
// 自我介绍
aboutMe: '...'(大家自行脑补吧)
// 年龄
age: 24,
// 职业
career: 'teacher',
// 假头像
fakeAvatar: 'xxxx'(新垣结衣的图片地址)
// 真实头像
avatar: 'xxxx'(自己的照片地址),
// 手机号
phone: 123456,
// 礼物数组
presents: [],
// 拒收50块以下的礼物
bottomValue: 50,
// 记录最近一次收到的礼物
lastPresent: present,
}
// 掘金婚介所推出了小礼物功能
const JuejinLovers = new Proxy(girl, {
get: function(girl, key) {
if(baseInfo.indexOf(key)!==-1 && !user.isValidated) {
alert('您还没有完成验证哦')
return
}
//...(此处省略其它有的没的各种校验逻辑)
// 此处我们认为只有验证过的用户才可以购买VIP
if(user.isValidated && privateInfo.indexOf(key) && !user.isVIP) {
alert('只有VIP才可以查看该信息哦')
return
}
}
set: function(girl, key, val) {
// 最近一次送来的礼物会尝试赋值给lastPresent字段
if(key === 'lastPresent') {
if(val.value < girl.bottomValue) {
alert('sorry,您的礼物被拒收了')
return
}
// 如果没有拒收,则赋值成功,同时并入presents数组
girl[lastPresent] = val
girl[presents] = [...presents, val]
}
}
})
不过如果认为代理模式的本领仅仅是开个婚介所这么简单,那就太小瞧它了。代理模式在前端领域一直是一种应用十分广泛的设计模式
本节我们选取业务开发中最常见的四种代理类型:事件代理、虚拟代理、缓存代理和保护代理来进行讲解。
在实际开发中,代理模式和我们下节要讲的“大 Boss ”观察者模式一样,可以玩出花来。但设计模式这玩意儿就是这样,变体再多、玩得再花,它的核心操作都是死的,套路也是死的——正是这种极强的规律性带来了极高的性价比。相信学完这节后,大家对这点会有更深的感触。
事件代理,可能是代理模式最常见的一种应用方式,也是一道实打实的高频面试题。它的场景是一个父元素下有多个子元素,像这样:
事件代理
我们现在的需求是,希望鼠标点击每个 a 标签,都可以弹出“我是xxx”这样的提示。比如点击第一个 a 标签,弹出“我是链接1号”这样的提示。这意味着我们至少要安装 6 个监听函数给 6 个不同的的元素(一般我们会用循环,代码如下所示),如果我们的 a 标签进一步增多,那么性能的开销会更大。
// 假如不用代理模式,我们将循环安装监听函数
const aNodes = document.getElementById('father').getElementsByTagName('a')
const aLength = aNodes.length
for(let i=0;i
考虑到事件本身具有“冒泡”的特性,当我们点击 a 元素时,点击事件会“冒泡”到父元素 div 上,从而被监听到。如此一来,点击事件的监听函数只需要在 div 元素上被绑定一次即可,而不需要在子元素上被绑定 N 次——这种做法就是事件代理,它可以很大程度上提高我们代码的性能。
事件代理的实现
用代理模式实现多个子元素的事件监听,代码会简单很多:
// 获取父元素
const father = document.getElementById('father')
// 给父元素安装一次监听函数
father.addEventListener('click', function(e) {
// 识别是否是目标子元素
if(e.target.tagName === 'A') {
// 以下是监听函数的函数体
e.preventDefault()
alert(`我是${e.target.innerText}`)
}
})
在这种做法下,我们的点击操作并不会直接触及目标子元素,而是由父元素对事件进行处理和分发、间接地将其作用于子元素,因此这种操作从模式上划分属于代理模式。
我们此处简单地给大家描述一下懒加载是个什么东西:它是针对图片加载时机的优化:在一些图片量比较大的网站,比如电商网站首页,或者团购网站、小游戏首页等。如果我们尝试在用户打开页面的时候,就把所有的图片资源加载完毕,那么很可能会造成白屏、卡顿等现象。
此时我们会采取“先占位、后加载”的方式来展示图片 —— 在元素露出之前,我们给它一个 div 作占位,当它滚动到可视区域内时,再即时地去加载真实的图片资源,这样做既减轻了性能压力、又保住了用户体验。
除了图片懒加载,还有一种操作叫图片预加载。预加载主要是为了避免网络不好、或者图片太大时,页面长时间给用户留白的尴尬。常见的操作是先让这个 img 标签展示一个占位图,然后创建一个 Image 实例,让这个 Image 实例的 src 指向真实的目标图片地址、观察该 Image 实例的加载情况 —— 当其对应的真实图片加载完毕后,即已经有了该图片的缓存内容,再将 DOM 上的 img 元素的 src 指向真实的目标图片地址。此时我们直接去取了目标图片的缓存,所以展示速度会非常快,从占位图到目标图片的时间差会非常小、小到用户注意不到,这样体验就会非常好了。
上面的思路,我们可以不假思索地实现如下
class PreLoadImage {
// 占位图的url地址
static LOADING_URL = 'xxxxxx'
constructor(imgNode) {
// 获取该实例对应的DOM节点
this.imgNode = imgNode
}
// 该方法用于设置真实的图片地址
setSrc(targetUrl) {
// img节点初始化时展示的是一个占位图
this.imgNode.src = PreLoadImage.LOADING_URL
// 创建一个帮我们加载图片的Image实例
const image = new Image()
// 监听目标图片加载的情况,完成时再将DOM上的img节点的src属性设置为目标图片的url
image.onload = () => {
this.imgNode.src = targetUrl
}
// 设置src属性,Image实例开始加载图片
image.src = srcUrl
}
}
这个 PreLoadImage
乍一看没问题,但其实违反了我们设计原则中的单一职责原则。PreLoadImage
不仅要负责图片的加载,还要负责 DOM 层面的操作(img 节点的初始化和后续的改变)。这样一来,就出现了两个可能导致这个类发生变化的原因。
好的做法是将两个逻辑分离,让 PreLoadImage 专心去做 DOM 层面的事情(真实 DOM 节点的获取、img 节点的链接设置),再找一个对象来专门来帮我们搞加载——这两个对象之间缺个媒婆,这媒婆非代理器不可:
class PreLoadImage {
constructor(imgNode) {
// 获取真实的DOM节点
this.imgNode = imgNode
}
// 操作img节点的src属性
setSrc(imgUrl) {
this.imgNode.src = imgUrl
}
}
class ProxyImage {
// 占位图的url地址
static LOADING_URL = 'xxxxxx'
constructor(targetImage) {
// 目标Image,即PreLoadImage实例
this.targetImage = targetImage
}
// 该方法主要操作虚拟Image,完成加载
setSrc(targetUrl) {
// 真实img节点初始化时展示的是一个占位图
this.targetImage.setSrc(ProxyImage.LOADING_URL)
// 创建一个帮我们加载图片的虚拟Image实例
const virtualImage = new Image()
// 监听目标图片加载的情况,完成时再将DOM上的真实img节点的src属性设置为目标图片的url
virtualImage.onload = () => {
this.targetImage.setSrc(targetUrl)
}
// 设置src属性,虚拟Image实例开始加载图片
virtualImage.src = targetUrl
}
}
ProxyImage
帮我们调度了预加载相关的工作,我们可以通过 ProxyImage
这个代理,实现对真实 img 节点的间接访问,并得到我们想要的效果。
在这个实例中,virtualImage
这个对象是一个“幕后英雄”,它始终存在于 JavaScript 世界中、代替真实 DOM 发起了图片加载请求、完成了图片加载工作,却从未在渲染层面抛头露面。因此这种模式被称为“虚拟代理”模式。
缓存代理比较好理解,它应用于一些计算量较大的场景里。在这种场景下,我们需要“用空间换时间”——当我们需要用到某个已经计算过的值的时候,不想再耗时进行二次计算,而是希望能从内存里去取出现成的计算结果。这种场景下,就需要一个代理来帮我们在进行计算的同时,进行计算结果的缓存了。
一个比较典型的例子,是对传入的参数进行求和:
// addAll方法会对你传入的所有参数做求和操作
const addAll = function() {
console.log('进行了一次新计算')
let result = 0
const len = arguments.length
for(let i = 0; i < len; i++) {
result += arguments[i]
}
return result
}
// 为求和方法创建代理
const proxyAddAll = (function(){
// 求和结果的缓存池
const resultCache = {}
return function() {
// 将入参转化为一个唯一的入参字符串
const args = Array.prototype.join.call(arguments, ',')
// 检查本次入参是否有对应的计算结果
if(args in resultCache) {
// 如果有,则返回缓存池里现成的结果
return resultCache[args]
}
return resultCache[args] = addAll(...arguments)
}
})()
我们把这个方法丢进控制台,尝试同一套入参两次,结果喜人: 我们发现 proxyAddAll 针对重复的入参只会计算一次,这将大大节省计算过程中的时间开销。现在我们有 6 个入参,可能还看不出来,当我们针对大量入参、做反复计算时,缓存代理的优势将得到更充分的凸显。
保护代理,其实在我们上个小节大家就见识过了。此处我们仅作提点,不作重复演示。
开婚介所的时候,为了保护用户的私人信息,我们会在同事哥访问小美的年龄的时候,去校验同事哥是否已经通过了我们的实名认证;为了确保婚介所的利益同事哥确实是一位有诚意的男士,当他想获取小美的联系方式时,我们会校验他是否具有VIP 资格。所谓“保护代理”,就是在访问层面做文章,在 getter 和 setter 函数里去进行校验和拦截,确保一部分变量是安全的。
值得一提的是,上节中我们提到的 Proxy,它本身就是为拦截而生的,所以我们目前实现保护代理时,考虑的首要方案就是 ES6 中的 Proxy。