Skip to main content

装饰器模式(结构型)

装饰器模式 —— 对象装上它,就像开了挂

装饰器模式,又名装饰者模式。在不改变原对象的基础上,通过对其进行包装拓展,使原有对象可以满足用户的更复杂需求。

一、生活中的装饰器

以一个电子水墨屏手机壳为例:

这个手机壳的安装方式和普通手机壳一样,就是卡在手机背面。不同的是它卡上去后会变成一块水墨屏,这样一来手机就有了两个屏幕。平时办公或玩游戏时用正面的普通屏幕;阅读时怕伤眼睛,就可以翻过来用背面的水墨屏。

这个水墨屏手机壳安装后,不会对手机原有功能产生影响,仅仅是使手机具备了新的能力(多了块屏幕),因此它在此处就是一个标准的装饰器。

二、装饰器的应用场景

举个例子:

<!-- 实现点击打开按钮弹出「您还未登录哦」的弹框,点击关闭按钮关闭弹框 -->
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<title>按钮点击需求1.0</title>
</head>
<style>
#modal {
height: 200px;
width: 200px;
line-height: 200px;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
border: 1px solid black;
text-align: center;
}
</style>

<body>
<button id='open'>点击打开</button>
<button id='close'>关闭弹框</button>
</body>
<script>
// 弹框创建逻辑
const Modal = (function () {
let modal = null
return function () {
if (!modal) {
modal = document.createElement('div')
modal.innerHTML = '您还未登录哦~'
modal.id = 'modal'
modal.style.display = 'none'
document.body.appendChild(modal)
}
return modal
}
})()

// 点击打开按钮 展示模态框
document.getElementById('open').addEventListener('click', function () {
// 未点击则不创建 modal 实例,避免不必要的内存占用
const modal = new Modal()
modal.style.display = 'block'
})

// 点击关闭按钮 隐藏模态框
document.getElementById('close').addEventListener('click', function () {
const modal = document.getElementById('modal')
if (modal) {
modal.style.display = 'none'
}
})
</script>

</html>

效果:

按钮发布上线后,有一天产品经理说这个弹框提示不够明显,应该在弹框被关闭后把关闭按钮文案改为快去登录并置灰。

但除了点击打开,还有点我开始点击购买等各种按钮,一个一个修改不仅麻烦,而且直接修改了已有的函数体,违背了开放封闭原则;往一个函数体塞各种逻辑还违背了单一职责原则

于是便有了装饰器模式。

三、初识装饰器模式

为了不被已有的业务逻辑干扰,当务之急就是将旧逻辑与新逻辑分离,把旧逻辑抽离出来:

// 将展示 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()
}

然后把三个操作逐个添加到打开按钮的监听函数中:

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()
decorator.onClick()
})

这里把按钮实例传给了 Decorator,以便于后续 Decorator 可以对它进行逻辑的拓展。

在按钮新逻辑中,文本修改和按钮置灰这两个变化,被封装在了两个不同的方法中,并以组合的形式出现在了最终的目标方法 changeButtonStatus 里。这样做的目的是为了遵循 单一职责原则,将不同的职责分离,可以做到每个职责都能被灵活地复用;同时,不同职责之间无法相互干扰,不会出现因为修改了 A 逻辑而影响 B 逻辑的情况。

四、装饰器语法糖 @

1、基本用法

在 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 目前都不支持装饰器语法,需要安装 Babel 进行转码。

2、装饰器语法糖 @ 的原理

正如 Class 语法糖背后是 ES5 构造函数一样,装饰器语法糖 @ 的原理如下:

2-1、函数传参及调用

上面使用 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 方法总是要依附其实例存在的,修饰 onClick 其实是修饰它的实例。但装饰器函数执行时,Button 实例还不存在。为了确保实例生成后可以顺利调用被装饰好的方法,装饰器只能去修饰 Button 类的原型对象。

  • 装饰器函数调用的时机

    装饰器函数执行的时候,Button 实例还不存在。这是因为实例是在代码运行时动态生成的,而装饰器函数则是在编译阶段就执行了。所以说装饰器函数真正能触及到的,就只有类这个层面上的对象。

2-2、交还属性描述对象

在编写类装饰器时,一般获取一个 target 参数就足够了。但在编写方法装饰器时,往往需要至少三个参数:

function funcDecorator(target, name, descriptor) {
let originalMethod = descriptor.value
descriptor.value = function () {
console.log('我是 Func 的装饰器逻辑')
return originalMethod.apply(this, arguments)
}
return descriptor
}
  • target:被装饰的类本身;

  • name:修饰目标的属性名;

  • descriptor:属性描述对象(attributes object)。与 Object.defineProperty 方法的 descriptor 参数一样,是 JavaScript 提供的一个内部数据结构、一个对象,专门用来描述对象的属性。它由各种各样的属性描述符组成,这些描述符又分为数据描述符和存取描述符:

    • 数据描述符:包括 value(存放属性值,默认为默认为 undefined)、writable(表示属性值是否可改变,默认为 true)、enumerable(表示属性是否可枚举,默认为 true)、configurable(属性是否可配置,默认为 true
    • 存取描述符:包括 get(访问属性时调用的方法,默认为 undefined),set(设置属性时调用的方法,默认为 undefined

很明显,拿到了 descriptor,就相当于拿到了目标方法的控制权。通过修改 descriptor,就可以对目标方法的逻辑进行拓展了~

上面通过 descriptor 获取到了原函数的函数体(originalMethod),把原函数推迟到了新逻辑(console)的后面去执行。

六、装饰器模式生产实践

1、React 中的装饰器:HOC

高阶组件是个函数,该函数接受一个组件作为参数,并返回一个新的组件。

高阶组件是装饰器模式在 React 中的实践,同时也是 React 应用中非常重要的一部分。编写高阶组件可以充分复用现有的逻辑,提高编码效率和代码的健壮性。

下面编写一个高阶组件,把传入的组件丢进一个有红色边框的容器中,以拓展其样式:

import React, { Component } from 'react'

const BorderHoc = (WrappedComponent) =>
class extends Component {
render() {
return (
<div style={{ border: 'solid 1px red' }}>
<WrappedComponent />
</div>
)
}
}
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 多次,只需要 @ 一下装饰器即可。

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) {
return bindActionCreators(action, dispatch)
}

// 把 App 组件与 Redux 绑在一起
export default connect(mapStateToProps, mapDispatchToProps)(App)

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的业务逻辑
}
}

connect 装饰器从实现和调用方式上来看,也相当于一个高阶组件。