【手撸系列 】 WEB 插件系统

359 阅读9分钟

这是我参与8月更文挑战的第1天,活动详情查看:8月更文挑战

什么是插件系统?

介绍:

插件系统是核心模块与应用功能完全分离的系统,开发者可以通过插件扩展丰富项目功能。

核心:最小可运行的集合。 插件:模块相互独立,单一职责。

  1. 优势:核心内容与功能应用的完全解耦。
  2. 社区使用插件设计方案的开源库: videojs、webpack、babel,开发者可以通过所提供的API实现各类插件来不断丰富其生态。

如何实现插件系统?

在实现插件系统之前我们先看下,一个安全可靠、稳定的系统应该遵守哪一些要求。

插件设计SOLID原则:

  1. **单一职责原则(SRP):**确保功能职责单一,每个独立模块只负责单个状态或功能。
  2. **开放封闭原则(OCP) :**对扩展保持开放,对核心修改保持封闭, 应当尽量避免修改源码。
  3. **里氏替换原则(LSP):**提供的父类接口具有可替换性,可以被子类接口替换扩展。
  4. **接口隔离原则(ISP) :**确保客户端只需依赖对应的接口,而不依赖前置接口数据。与接口单一原则一定相似。
  5. **依赖倒置原则(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

  1. 在每个独立的插件中,确保功能单一性。
  2. 在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的几个特性:

  1. package依赖安装管理
    1. 减少安装包的体积,在顶层安装生成node_modules,下面各个子项目的node_modules 会指向顶层的node_modules
  2. 开发环境中的各个插件库引用
    1. 当我们引用一个外部库的时候,基本都是通过 **import 'xxx' from 'xxx'**来直接引用,在我们在开发插件,还未正式发布的时候在常规情况下本地开发会使用到 npm link去关联两个项目,一旦本地开发的库过多,那么手动管理就会过于复杂。
  3. 多项目启动

先看下实际的项目录:

├── 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
  1. 安装 lerna
  2. 执行 lerna init 初始项目内容,会得到以下目录:

├── lerna.json   // lerna 配置生成目录
├── package.json 
├── packages	   // 子项目目录文件夹
  1. 通过lerna bootstarp安装项目依赖:在当前 Lerna 仓库中执行引导流程(bootstrap)。安装所有 依赖项并链接任何交叉依赖。

此命令至关重要,因为它让你可以 在 require() 中直接通过软件包的名称进行加载,就好像此软件包已经存在于 你的 node_modules 目录下一样。

import Player from 'vpplayer';
  1. 通过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来完善我们的样式,但是我们并不希望影响到插件使用者的样式,同时也不希望我们的样式被无意修改。 所以在这里我们采取了两种方式。

  1. 在html中使用自定义元素。
  2. 通过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;
}

使用:

  1. 创建元素、添加样式。生成元素下面的内容
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);

小结

整体感受:

  1. 不是所有系统适合使用插件系统,目前的这种方式开发插件系统 this 如同黑盒,在编辑器中无法了解都有哪些属性,这里对多人协作不是很友好。
  2. 插件模式适合边缘业务较多,核心功能相对单一的系统,简单的系统如何使用插件模式开发会让整个系统变得个更为复杂。
  3. 一个完整的插件系统,需要做好的API 文档,同时也需要系统生命周期,提供完整的钩子函数
    1. 生命周期参考这篇文章:juejin.cn/post/684490…
  4. 看了西瓜视频播放器的源码,整体设计值得学习。

Ref:

  1. github.com/bytedance/x…
  2. www.lernajs.cn/
  3. css-tricks.com/designing-a…