- 作者:老汪软件技巧
- 发表时间:2024-08-21 00:02
- 浏览量:
导读
命令模式(Command Pattern)是一种行为设计模式,旨在将请求或操作封装成一个对象,从而使你可以用不同的请求、队列、日志和支持可撤销的操作来参数化对象。JavaScript 作为一种灵活的编程语言,非常适合应用命令模式来设计可扩展、可维护的系统。本文将探讨命令模式的核心概念,并展示如何在 JavaScript 中实现这一模式。
核心概念
命令模式的主要思想是将方法调用、请求或操作封装到一个独立的命令对象中。这种封装允许系统在不明确调用方法的情况下对命令进行参数化,并提供以下优势:
解耦调用者与接收者:调用者无需知道如何处理请求,只需将命令发送给接收者即可。支持可撤销操作:命令对象可以存储状态信息,从而支持操作的撤销与重做。扩展性强:增加新命令只需添加新的命令类,符合开放封闭原则。命令模式的结构
命令模式通常由以下几个部分组成:
命令接口(Command Interface) :定义命令的执行方法,例如 execute()。具体命令(Concrete Command) :实现命令接口,执行具体的操作。具体命令通常会持有接收者对象,并在执行时调用接收者的方法。接收者(Receiver) :执行实际操作的类。调用者(Invoker) :负责调用命令对象的类,它只知道如何执行命令,而不关心命令的具体实现。客户端(Client) :创建具体命令对象并设置调用者。应用场景
命令模式适用于以下场景:
需要参数化对象以执行命令:例如,实现操作的延迟执行或在运行时决定执行的命令。支持可撤销操作:例如,编辑器中的撤销/重做功能。支持日志功能:例如,系统可以将命令的执行记录到日志中,以支持系统崩溃后的恢复。支持事务系统:例如,多个操作需要被当作一个操作来执行。命令模式的优缺点优点:缺点:在 JavaScript 中实现命令模式
下面通过一个简单的例子展示如何在 JavaScript 中实现命令模式。假设我们有一个文本编辑器,它可以执行各种操作(如输入文本、删除文本),并且这些操作可以被撤销和重做。
// 1. 命令接口
class Command {
execute() {}
undo() {}
}
// 2. 接收者
class TextEditor {
constructor() {
this.content = '';
}
write(text) {
this.content += text;
}
delete(n) {
this.content = this.content.slice(0, -n);
}
getContent() {
return this.content;
}
}
// 3. 具体命令
class WriteCommand extends Command {
constructor(editor, text) {
super();
this.editor = editor;
this.text = text;
}
execute() {
this.editor.write(this.text);
}
undo() {
this.editor.delete(this.text.length);
}
}
class DeleteCommand extends Command {
constructor(editor, n) {
super();
this.editor = editor;
this.n = n;
}
execute() {
this.deletedText = this.editor.getContent().slice(-this.n);
this.editor.delete(this.n);
}
undo() {
this.editor.write(this.deletedText);
}
}
// 4. 调用者
class CommandInvoker {
constructor() {
this.history = [];
}
executeCommand(command) {
command.execute();
this.history.push(command);
}
undo() {
const command = this.history.pop();
if (command) {
command.undo();
}
}
}
// 5. 客户端
const editor = new TextEditor();
const invoker = new CommandInvoker();
const writeCommand = new WriteCommand(editor, 'Hello, World!');
invoker.executeCommand(writeCommand);
console.log(editor.getContent()); // 输出: Hello, World!
invoker.undo();
console.log(editor.getContent()); // 输出:
const deleteCommand = new DeleteCommand(editor, 6);
invoker.executeCommand(deleteCommand);
console.log(editor.getContent()); // 输出: Hello,
invoker.undo();
console.log(editor.getContent()); // 输出: Hello, World!
代码解读
可以看到,接收者 TextEditor 是实际干活执行命令的。调用者则它只知道如何执行命令,而不关心命令的具体实现,以及谁执行命令。这就是核心概念中说的“解耦调用者与接收者”。
具体的命令实现命令接口,执行具体的操作。准确的说具体命令通常会持有接收者对象,它接受不同的参数,然后将参数传递给调用接收者的具体方法,并在执行时调用接收者的方法。具体的命令算是接受者的一个 API “包装器”。
客户端是实际的应用者,它既创建具体命令对象,又需要设置调用者。它应该算是“中间商”了。
命令模式的应用实践
又要拿我的 outline.js 项目举例了。来看看 outline.js 项目的 API 文档页面:
界面中的 Toolbar 工具栏对象,需要同时调用 Navigator、Reader、Drawer 和 Outline 对象的不同 API 接口。
我没有为每个按钮绑定具体的执行函数:
addListeners() {
const buttons = this.attr('buttons') || []
const $el = this.$el
if (!buttons || buttons.length < 1) {
return this
}
on($el, `.outline-toolbar__button`, 'click', this.onExecute, this, true)
this.$on('toolbar:update', this.onToolbarUpdate)
this.$on('toolbar:add:button', this.onAddButton)
this.$on('toolbar:remove:button', this.onRemoveButton)
this.$on('toolbar:toggle', this.toggle)
return this
}
onExecute() 方法执行的是注册的命令:
execute(name) {
if (this.isDisabled(name)) {
return this
}
this.commands.execute(name)
return this
}
onExecute(evt) {
const $button = evt.delegateTarget
let cmd = ''
if ($button) {
cmd = $button.getAttribute('data-cmd')
if (cmd) {
this.execute(cmd)
}
}
return this
}
在 outline.js 的项目中,我简单实现了 Commands 和 Command 两个命令模块:
Commands 模块
Commands 模块用来统一管理在 Toolbar 模块中注册的各种具体的命令。
import isFunction from './utils/types/isFunction'
class Commands {
constructor() {
this.commands = []
}
get(name) {
return this.commands.find((cmd) => cmd.name === name)
}
add(command) {
this.commands.push(command)
return this
}
del(name) {
const commands = this.commands
const command = commands.find((cmd) => cmd.name === name)
const index = command ? commands.indexOf(command) : -1
if (index > -1) {
commands.splice(index, 1)
}
return this
}
clear() {
this.commands = []
return this
}
execute(name) {
const command = this.commands.find((cmd) => cmd.name === name)
if (isFunction(command?.execute)) {
command.execute()
}
return this
}
}
export default Commands
Command 模块
Command 模块就是最基础的命令模块了,用来生成独立的命令。
import isFunction from './utils/types/isFunction'
class Command {
constructor(name, action) {
this.name = name
if (isFunction(action)) {
this.action = action
}
}
execute() {
this.action()
return this
}
}
export default Command
注意,我的这个 Command 的实现跟前文的有些不同,它并没有接受接收者对象,而只是接受了将接收者的功能封装过的综合行为功能函数 action。但是实际的作用与前文示例的一致,都是执行时命令时,会调用接受者的具体方法执行任务。
分析 outline.js 应用场景中的命令模式的结构
简单分析一下使用命令模式后,outline.js 项目中各个模块扮演的角色。Toolbar 现在的身份就是 调用者 它只知道如何执行命令,而不关心命令的具体实现,以及谁执行命令。
execute(name) {
if (this.isDisabled(name)) {
return this
}
this.commands.execute(name)
return this
}
Navigator、Reader、Drawer 和 Outline 等模块就是 接收者 了,它们负责执行实际的命令。来看看在 Outline 模块初始化 Toolbar 模块时创建的命令:
// 向下按钮
const DOWN = {
name: 'down',
icon: 'down',
size: 20,
action: {
context: this,
// toBottom() 就是具体的命令了
handler: this.toBottom
}
}
toBottom() 一次要执行多个操作,对应前文的命令模式应用场景:支持事务系统,多个操作需要被当作一个操作来执行。它会执行控制 Toolbar 模块的按钮向下按钮隐藏,Navigation 模块高亮显示当前的章节导航,并且会控制 Outline 模块的定位逻辑。
toBottom() {
const afterScroll = this.attr('afterScroll')
const $scrollElement = this.$scrollElement
const toolbar = this.toolbar
const navigator = this.navigator
const count = this.count()
const top = Math.floor(
$scrollElement.scrollHeight - $scrollElement.clientHeight
)
const afterDown = () => {
const $main = navigator.$main
toolbar.hide('down')
toolbar.show('up')
if (count > 0) {
navigator.highlight(count - 1)
scrollTo($main, $main.scrollHeight)
navigator.playing = false
}
if (isFunction(afterScroll)) {
afterScroll.call(toolbar, 'bottom')
}
}
if (count > 0) {
navigator.playing = true
}
this.scrollTo(top, afterDown)
return this
}
只是这里的 toBottom 命令不是严格的使用 Command 对象创建的实例对象,但这并不能改变它作为实质具体命令的本质。实际上在 Toolbar 模块中,还是会将 toBottom() 方法使用 Command 对象包装成具体命令的。
仔细分析我们可以发现 Toolbar 模块此时还是接收者。而 Outline 模块它也还有个身份,就是 客户端。它既创建具体命令(toBottom),又需要设置调用者(Toolbar)。因为所有的 Toolbar 执行具体命令都是由 Outline 模块创建,并且 Toolbar 模块也是它负责初始化的。
使用命令模式带来的好处
outline.js 的应用场景中没有撤销的操作,但 解耦调用者与接收者 和 扩展性强 的优势就体现的特别明显。
使用命令模式后,以后 outline.js 项目中再扩展任何新的模块,需要与 Toolbar 进行交互,只用借助命令模块生成新模块的命令(或者执行方法)传递给 Toolbar 模块就 Ok 了。
add(button) {
const $el = this.$el
const $fragment = document.createDocumentFragment()
const buttons = this.attr('buttons') || []
const { name, disabled, context } = button
const command = this._getCommand(button)
const _add = (button) => {
const $button = _createButton(button)
$fragment.appendChild($button)
buttons.push(button)
this.buttons.push({
$el: $button,
name,
disabled: disabled || false,
context: context || this,
command
})
if (command) {
this.commands.add(command)
}
}
if (isObject(button)) {
_add(button)
} else if (isArray(button)) {
button.forEach((item) => {
if (isObject(item)) {
_add(item)
}
})
}
$el.appendChild($fragment)
return this
}
如示例代码中显示的,outline.js 模块自带的添加 Toolbar 模块按钮的功能 add() 方法也只是创建新的具体命令就可以了。
_getCommand(button) {
const _self = this
const { action, name } = button
let command = null
let handler = null
let context
let listener
if (!action) {
return command
}
handler = action.handler
context = action.context || this
if (isFunction(handler)) {
listener = handler
} else if (isString(handler)) {
listener = function () {
_self.$emit(handler, name)
}
}
if (isFunction(listener)) {
command = new Command(name, listener.bind(context))
}
return command
}
仔细查看 outline.js 项目中的命令,你会发现,之前在 Outline 模块初始化 Toolbar 模块时候传递的(具体命令)方法,现在还是用 Command 模块包装成了标准的具体命令对象了。
使用命令模式带来的问题
显而易见的,我又多整出了 Commands 和 Command 两个新的模块,代码的复杂度是又增加了一些。没有办法,相比较引入带来的好处,这点问题不算什么。
并且 outline.js 这个项目整到现在这个规模,本身也不能算简单了。
总结
命令模式在 JavaScript 中为开发者提供了一种强大且灵活的设计方式,用于封装和执行操作。通过将请求封装为命令对象,系统可以实现解耦、可撤销操作以及更强的扩展性。无论是在复杂的应用程序中,还是在需要支持撤销功能的简单工具中,命令模式都能发挥重要作用。
掌握命令模式,能让你的 JavaScript 项目更加模块化和易于维护。