这是我参与8月更文挑战的第1天,活动详情查看:8月更文挑战
什么是插件系统?
介绍:
插件系统是核心模块与应用功能完全分离的系统,开发者可以通过插件扩展丰富项目功能。
核心:最小可运行的集合。 插件:模块相互独立,单一职责。
- 优势:核心内容与功能应用的完全解耦。
- 社区使用插件设计方案的开源库: videojs、webpack、babel,开发者可以通过所提供的API实现各类插件来不断丰富其生态。
如何实现插件系统?
在实现插件系统之前我们先看下,一个安全可靠、稳定的系统应该遵守哪一些要求。
插件设计SOLID原则:
- **单一职责原则(SRP):**确保功能职责单一,每个独立模块只负责单个状态或功能。
- **开放封闭原则(OCP) :**对扩展保持开放,对核心修改保持封闭, 应当尽量避免修改源码。
- **里氏替换原则(LSP):**提供的父类接口具有可替换性,可以被子类接口替换扩展。
- **接口隔离原则(ISP) :**确保客户端只需依赖对应的接口,而不依赖前置接口数据。与接口单一原则一定相似。
- **依赖倒置原则(DIP) :**父类不依赖子类,子类依赖父类,子类在父类基础上进行扩展。
简单的示例:播放器
src/index.js
- 系统的核心部分就是让视频能够播放,其他的附加功能就交由插件处理。
- 在核心代码中我们所需要做的事情:
- 实现插件注入的机制。
- 初始播放器中所需要的基本配置。
class Player {
// 插件列表
static plugins = {};
constructor(config) {
this.element = config.element;
this.config = config;
this.createDomToHTML();
this.pluginsCall();
this.initVideoPlayer();
}
// 在div中创建所需要的元素。
createDomToHTML() {
this.root = createDOM({
el: 'div',
cname: 'container',
})
this.video = createDOM({
el: 'video',
cname: 'video-player',
attrs: {
width: '100%',
height: '100%',
}
})
this.element.appendChild(this.root);
this.root.appendChild(this.video);
}
// 关键:绑定所有的插件。
pluginsCall() {
if (Player.plugins) {
Object.keys(Player.plugins).forEach((name) => {
let descriptor = Player.plugins[name]
descriptor.call(this, this); // 将插件中的this指向到Player
})
}
}
// 关键:挂载插件
static install(name, fn) {
if (!Player.plugins[name]) {
Player.plugins[name] = fn;
}
}
// 生命周期 - 在播放器内容完成前
initVideoPlayer() {
this.video.src = this.config.url;
}
play() {
this.video.play().then(() => {
console.log('video play');
});
}
pause() {
this.video.pause()
}
}
src/play.js
- 在每个独立的插件中,确保功能单一性。
- 在play插件中,只控制视频的播放器,以及暂停。
const pluginPlay = function () {
console.log('pluginPlay')
const containerEl = createDOM({
el: 'vp-button',
tpl: `
<button class="play">播放</button>
<button class="pause">暂停</button>
`
});
this.element.appendChild(containerEl);
const playEL = containerEl.querySelector('.play');
const pauseEL = containerEl.querySelector('.pause');
playEL.addEventListener('click', () => {
this.play();
})
pauseEL.addEventListener('click', () => {
this.pause();
})
}
Player.install('pluginPlay', pluginPlay);
const pluginPlay = function () {
const containerEl = createDOM({
el: 'div',
tpl: `
<button class="play">播放</button>
<button class="pause">暂停</button>
`
});
this.element.appendChild(containerEl); // 通过player注册
const playEL = containerEl.querySelector('.play');
const pauseEL = containerEl.querySelector('.pause');
playEL.addEventListener('click', () => {
this.video.play();
})
pauseEL.addEventListener('click', () => {
this.video.pause();
})
}
Player.install('pluginPlay', pluginPlay);
src/utils.js
- 工具函数,Dom的创建以及查找。
const createDOM = ({
el = 'div',
tpl = '',
attrs = {},
cname = ''
}) => {
const dom = document.createElement(el);
dom.className = cname
dom.innerHTML = tpl
Object.keys(attrs).forEach(item => {
const key = item
const value = attrs[item]
dom.setAttribute(key, value)
})
return dom;
}
const findDom = (el, sel) => {
let dom;
try {
dom = el.querySelector(sel);
} catch (e) {
if (sel.indexOf('#') === 0) {
dom = el.getElementById(sel.slice(1));
}
}
return dom;
}
src/index.html
- 在js的文件应用中,需要注意先后顺序,先确保核心内容被加载后在加载插件, 最后再进行构造实例。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root"></div>
<script src="./utils.js"></script>
<script src="./index.js"></script>
<script src="./play.js"></script>
<script type="text/javascript">
const root = document.querySelector('#root');
new Player({
element: root,
url: './xg360.mp4',
});
</script>
</body>
</html>
插件系统如何在实际场景中使用?
在实际的项目开发过程中,我们的程序不会像上面的示例这么简单,往往是多人协作一起开发。 同时面临了代码的管理、样式隔离、插件管理以及插件间的通讯等一系列的问题,当然这系列的问题社区中都有成熟的方案解决。
问题一:项目目录划分、插件库管理
Lerna
将大型代码仓库分割成多个独立版本化的 软件包(package)对于代码共享来说非常有用。但是,如果某些更改 跨越了多个代码仓库的话将变得很 麻烦 并且难以跟踪,并且, 跨越多个代码仓库的测试将迅速变得非常复杂。 为了解决这些(以及许多其它)问题,某些项目会将 代码仓库分割成多个软件包(package),并将每个软件包存放到独立的代码仓库中。但是,例如 Babel、 React、Angular、Ember、Meteor、Jest 等项目以及许多其他项目则是在 一个代码仓库中包含了多个软件包(package)并进行开发。
以上是lerna的官方介绍。
而我们的实际项目中,会将核心,插件抽离单独开发,但同时我们也需要一个方便开发调试的环境。
这里我们使用到了lerna的几个特性:
- package依赖安装管理
- 减少安装包的体积,在顶层安装生成node_modules,下面各个子项目的node_modules 会指向顶层的node_modules
- 开发环境中的各个插件库引用
- 当我们引用一个外部库的时候,基本都是通过 **import 'xxx' from 'xxx'**来直接引用,在我们在开发插件,还未正式发布的时候在常规情况下本地开发会使用到 npm link去关联两个项目,一旦本地开发的库过多,那么手动管理就会过于复杂。
- 多项目启动
先看下实际的项目录:
├── app.js //
├── examples // 示例文件
│ ├── index.html
│ └── index.js
├── lerna.json
├── package.json
├── packages // 子项目目录文件夹
│ ├── vpplayer // 核心
│ │ ├── README.md
│ │ ├── lib // 脚手架包
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── rollup.config.js
│ │ ├── src
│ │ ├── tsconfig.json
│ │ ├── webpack.config.js
│ │ └── yarn.lock
│ └── vpplayerhls // 插件一
│ ├── lib
│ ├── package.json
│ ├── rollup.config.js
│ ├── src
│ ├── tsconfig.json
│ └── webpack.config.js
└── yarn.lock
- 安装 lerna
- 执行 lerna init 初始项目内容,会得到以下目录:
├── lerna.json // lerna 配置生成目录
├── package.json
├── packages // 子项目目录文件夹
- 通过lerna bootstarp安装项目依赖:在当前 Lerna 仓库中执行引导流程(bootstrap)。安装所有 依赖项并链接任何交叉依赖。
此命令至关重要,因为它让你可以 在 require() 中直接通过软件包的名称进行加载,就好像此软件包已经存在于 你的 node_modules 目录下一样。
import Player from 'vpplayer';
- 通过lerna run 执行我们的启动命名。 3. 可以通过** lerna run dev --scope='vpplayer' , 这样就会执行子项目vpplayer** scripts中dev命令, 相当于在vpplayer目录下执行yarn run dev。 3. lerna run dev则是执行packages下所有的子项目的 dev 命令。
此时我们可以修改顶层的package.json文件中的scripts命令,然后在顶层直接执行yarn dev 即可启动我们所有的项目。 如果有还有其他的脚本需要同时启动,则可以使用concurrently 库。
"scripts": {
"dev": "lerna run dev",
"node-start": "node app.js",
"start": "concurrently \"yarn run dev\" \"yarn run node-start\""
},
这样我们就解决了文件的管理,当然lerna并不知道这一些功能,官方文档有更详细的说明。
问题二:打包编译
在这里我们选择rollup作为我们的打包工具。 因为我们打包后的文件是需要支持在多种环境下面使用,使用rollup配置相对简单。
最终我们生成的文件都会在各个子项目下面的lib中。 只需要在子项目中的package.json中main指定,默认指定我们所生成的umd模块的文件。 main": "lib/index.umd.js",
import typescript from 'rollup-plugin-typescript2';
import commonjs from 'rollup-plugin-commonjs';
import clear from 'rollup-plugin-clear';
import resolve from 'rollup-plugin-node-resolve';
import svg from 'rollup-plugin-svg'
import styles from "rollup-plugin-styles";
import alias from '@rollup/plugin-alias';
import path from 'path';
import tsconfig from './tsconfig.json';
const resolveFile = function (filePath) {
return path.join(__dirname, filePath)
}
const outputConfig = {
name: 'Player',
outputFile: './lib',
format: ['iife', 'cjs', 'umd', 'esm']
}
const output = outputConfig.format.map((item) => {
return {
name: outputConfig.name,
file: `${outputConfig.outputFile}/index.${item}.js`,
format: item,
sourcemap: true,
}
})
export default {
input: './src/index.ts',
output,
plugins: [
styles({
modules: {
mode: "local",
generateScopedName: "vp_[local]_[hash:4]",
},
}),
typescript({
exclude: 'node_modules/**',
tsconfigDefaults: tsconfig,
objectHashIgnoreUnknownHack: false,
}),
svg(),
resolve(),
commonjs(),
clear('./lib'),
alias({
entries: [
{ find: '@', replacement: './src' },
]
})
]
};
问题三:样式的隔离
在插件系统中,我们无法避免编写css来完善我们的样式,但是我们并不希望影响到插件使用者的样式,同时也不希望我们的样式被无意修改。 所以在这里我们采取了两种方式。
- 在html中使用自定义元素。
- 通过css module来隔离我们的样式。
语义化的实现: 实现一个工具函数,用户创建dom元素。
type createDom = {
el?: string,
tpl?: string,
attrs?: any,
cname?: string
}
export const createDOM = ({
el = 'div',
tpl = '',
attrs = {},
cname = ''
}: createDom) => {
const dom = document.createElement(el);
dom.className = cname
dom.innerHTML = tpl
Object.keys(attrs).forEach(item => {
const key = item
const value = attrs[item]
dom.setAttribute(key, value)
})
return dom;
}
使用:
- 创建元素、添加样式。生成元素下面的内容
const containerEl = createDOM({
el: 'vp-progress',
tpl: `
<vp-outer>
<vp-cache></vp-cache>
<vp-played></vp-played>
<vp-progress-btn></vp-progress-btn>
<vp-point>
<div></div>
</vp-point>
<vp-thumbnail></vp-thumbnail>
</vp-outer>
`
});
至此我们的组件可以更加的有语义化,同时也不再担心使用者不小心修改到我们插件的样式了。 从can i use上查询custom elements对ie的兼容性不够友好IE浏览器无法使用,但实际测试后发现windos 8.1+ IE11展示暂无问题。
Css module的实现 这里我们选择使用scss,同时我们需修改我们rollup的配置。
// rollup.config.js
import styles from "rollup-plugin-styles";
....
plugins: [
styles({
modules: {
mode: "local",
generateScopedName: "vp_[local]_[hash:4]", // 控制我们的css生成规则。
},
})
]
...
修改下我们创建Dom元素内容:
import style from './index.scss';
import cn from 'classname';
const containerEl = createDOM({
el: 'vp-progress',
cname: style.progress,
tpl: `
<vp-outer class="${style.progressOuter}">
<vp-cache class="${style.progressCache}"></vp-cache>
<vp-played class="${style.progressPlayed}"></vp-played>
<vp-progress-btn class="${style.progressBtn}"></vp-progress-btn>
<vp-point class="${cn(style.progressPoint)}">
<div class="${cn(style.progressPointContent, globalStyle.tips)}"></div>
</vp-point>
<vp-thumbnail class="${cn(style.progressThumbnail, style.tips)}"></vp-thumbnail>
</vp-outer>
`
});
最终在我们的页面中输出会得到这样的内容:
通过这两种方式基本上就解决了我们样式隔离的问题。
注:如果碰到使用者要修改我们插件的样式,在我们修改插件的样式调整后,会导致生成的hash值改变从而让使用者的样式修改失效。所以可以把rollup中css module 生成规则的hash的配置删除,增加其他的固定标识。
插件的通讯
在插件系统中,我们遵循职责单一的原则,单个独立插件只处理单个状态或者功能。但插件中如何感知当前系统中间的操作呢? 这里我们可以采取发布订阅模式进行解耦,将插件的内容与核心的内容进行拆分。
例:
- 在我们注册完播放器的可行核心内容后,发布准备完成的消息。
class Player {
public root // 根节点
public controls: any = createDOM({el:'xx'})
constructor(config?) {
super()
// ...
this.root.appendChild(this.controls)
this.pluginsCall()
this.emit('ready') //在我们准备将操作控件、以及插件处理好后,可以发布准备完成的消息。
this.start()
}
}
- 接收到完成的消息后,我们将插件html插入对应元素中。
const pluginPlay = function () {
console.log('pluginPlay')
const containerEl = createDOM({
el: 'vp-button',
tpl: `
<button class="play">播放</button>
<button class="pause">暂停</button>
`
});
this.element.appendChild(containerEl);
const playEL = containerEl.querySelector('.play');
const pauseEL = containerEl.querySelector('.pause');
this.once('ready', () => {
this.controls.appendChild(containerEl);
});
}
Player.install('pluginPlay', pluginPlay);
小结
整体感受:
- 不是所有系统适合使用插件系统,目前的这种方式开发插件系统 this 如同黑盒,在编辑器中无法了解都有哪些属性,这里对多人协作不是很友好。
- 插件模式适合边缘业务较多,核心功能相对单一的系统,简单的系统如何使用插件模式开发会让整个系统变得个更为复杂。
- 一个完整的插件系统,需要做好的API 文档,同时也需要系统生命周期,提供完整的钩子函数
- 生命周期参考这篇文章:juejin.cn/post/684490…
- 看了西瓜视频播放器的源码,整体设计值得学习。
Ref: