如何设计开发一个 Web 插件系统?

前端工程师 @ 公众号:ELab团队

本文原文链接为 juejin.cn/post/696504…, 作者为 ELab 团队成员,已授权使用 ELab 掘金账号发布。

借助插件,开发者可以很方便地解决自己的问题或者扩展特定场景功能。系统用户可以使用到更多功能特性。系统拥有者可以构建一个产品生态,并减少维护成本。显然这个一个三赢的方案。

插件可以说是一种优秀的设计模式。本文通过介绍插件的使用场景、如何设计插件系统、如何开发插件系统、如何保证插件系统的安全等四个方面来介绍插件,让你了解并掌握这个日益广泛使用的强大的思想模式。

真的需要插件吗?

让系统支持插件并且可以高枕无忧却绝非易事。为了支持插件,我们的系统必将会引入一些功能无关的代码和逻辑,增加了系统的复杂度。另外,插件的安全性才是最大的挑战,需要确保插件不会相互影响,确保插件不会影响到主系统。在 web 环境中,是可以很容易修改对象原型和操作 DOM 的。

所以,不要轻易引入插件系统,除非你能确保它是安全、可控的。

Atom 的没落有人归因其插件,插件拖慢了整个编辑器的性能,引入了安全隐患等。

是否支持要插件,这是一个需要权衡的过程,如果满足以下场景,可以优先考虑插件系统。带来的收益是远远大于我们投入的成本(主要成本是确保安全性)。

插件可以看作是一种思想,给现有的系统(组件)扩展新的功能,而不需要关心该功能的具体实现。

  1. 确保核心层的稳定:微内核架构(Microkernel Architecture),也称为插件化架构(Plug-in Architecture)。核心系统提供通用能力,由插件去实现业务功能。比如 Web PPT
  2. 开放能力,让使用者自己解决特定场景问题。比如:Webpack、Figma。
  3. 旨在搭建一个产品平台,让第三方开发者自己开发新的功能。
  4. 适应多变的业务场景。某个模块会随业务场景多变时,就可以将其抽象为插件。

设计原则(Design Principles)

一个优秀的插件系统首先是个优秀的软件系统。

最优解就是最简单解

一个优秀的软件系统,我觉得它一定是简单(清晰)的,不管是设计方案、实现思路,还是系统交互,都应该是简单清晰的。

Nicholas Zakas(JavaScript 高级程序设计作者) 在 On Designing Great Systems 一文中说到:

A good framework or a good architecture makes it hard to do the wrong thing. A lot of people will always go for the easiest way to get something done. So as a systems designer, your job is to make sure that the easiest thing to do is the right thing. And once you get that, the whole system gets more maintainable.

是的,最优解往往是最简单的,当你的设计或方案很复杂时,可能需要重新审视下,是否还有更优解。

系统设计者应该设计出可以让功能实现起来更容易、修改起来更简单、扩展起来更轻松的系统架构。

抽象变与不变

好书推荐:架构整洁之道 (豆瓣)

系统架构最核心的原则就是抽象隔离系统中变与不变的部分,对于不变的部分,我们需要保持它的稳定;对于变的部分,进行封装和隔离,让它易于扩展。而插件系统的初衷就是对改原则的践行,把容易变的部分以插件的形式提供,不变的部分作为系统的核心,插件的变化就不会影响到系统的核心层。

SOLID 设计原则

而到具体模块(组件、类、函数)设计实现,我们需要遵循 SOLID 设计原则。

  1. SRP:单一职责原则。每个软件模块都有且只有一个需要被改变的理由。
  2. OCP:开闭原则。对扩展是开放的,对修改是封闭的。允许新增代码来扩展,而不是修改原有的代码。这里具体设计往往需要结合 SRP 和 DIP 原则。
  3. LSP:里氏替换原则。多态思想,依赖于一种接口,则实现该接口的具体类之间就都具有了可替换性。
  4. ISP:接口隔离原则。只依赖需要的东西,不受其他实现改动的影响。比如 lodash 按需引入。
  5. DIP:依赖反转原则。面向接口编程,多使用稳定的抽象接口,少依赖多变的具体实现。

在设计开发一个系统时,可以分为三层:

高层:架构设计。

中层:设计原则、设计模式。

底层:代码整洁之道。

如何设计插件系统

目标:设计一个简单、灵活、安全的插件系统。

核心概念

任何的设计都需要建立在具体的需求上面,没有一个方案是放之四海而皆准的。插件系统有多种模式和形式,以下的基本设计可以帮助你构想和设计特定场景的系统:

image.png

Hook

系统需要提供一些 Hook,以让插件决定在什么时候被调用执行。比如 webpack 插件就定义了很多 hook。

compiler.hooks.beforeCompile.tapAsync("MyPlugin", (params, callback) => {
  params["MyPlugin - data"] = "important stuff my plugin will use later";
  callback();
});
复制代码

Plugin Interface

面向接口编程。定义了插件需要实现的接口,要想开发一个插件,就必须实现系统定义的插件接口。这是插件必须要遵循的一个规范,基于这些规范,系统就可以对插件进行检验、注册挂载、注销等操作。

class HelloWorldPlugin {
  apply(compiler) {
    compiler.hooks.done.tap(
      "Hello World Plugin",
      (
        stats /* stats is passed as an argument when done hook is tapped.  */
      ) => {
        console.log("Hello World!");
      }
    );
  }
}

module.exports = HelloWorldPlugin;
复制代码

Plugin Loader

插件加载器,主要功能是注册插件。主要有两种模式:

插件驱动:插件知道如何访问程序并进行自注册。系统提供了一种方法,插件可通过该方法将自身注册到程序中,比如 Vue.use。

系统驱动:系统知道如何查找插件并加载找到的插件。比如加载 plugins 文件下的文件、从也定接口拉取插件列表等。比如 Webpack 是从配置文件中拉取插件并加载。

开发插件系统

上面我们了解了插件系统的核心概念和雏形设计,接下来我们来简单实现一个插件系统。给计算器扩展插件系统,支持开发者扩展功能。

第一步:实现个简单的计算器。

class Calculator {
  currentValue: number = 0;

  setValue(newValue: number): void {
    this.currentValue = newValue;
    console.log(this.currentValue);
  }

  plus(addend: number): void {
    this.setValue(this.currentValue + addend);
  }

  minus(subtrahend: number): void {
    this.setValue(this.currentValue - subtrahend);
  }
}

const calculator = new Calculator();
calculator.setValue(3); // => 3
calculator.plus(3); // => 6
calculator.minus(2); // => 4
复制代码

这是一个没有插件系统的 Web 应用,所有的操作(加法、减法)都需要在主系统中开发,这样的系统不易于维护和扩展。接下来,基于这个简单的计算器,我们改造一下,将其设计为插件系统。

第二步:让计算器支持插件。

type IExec = (currentValue: number, operand?: number) => number;

interface IPlugin {
  name: string;
  exec: IExec;
}

class Calculator {

  constructor(private currentValue: number = 0) {}

  private plugins: { [name: string]: IExec } = {};

  private setValue(newValue: number): void {
    this.currentValue = newValue;
    console.log(this.currentValue);
  }

  public register(plugin: IPlugin) {
    const { name, exec } = plugin;
    this.plugins[name] = exec;
  }

  public press(operation: string, operand?: number) {
    const exec = this.plugins[operation];
    if (typeof exec !== 'function') {
      throw Error(`${operation} operation no support yet!`);
    }
    this.setValue(exec(this.currentValue, operand));
  }

}

const calculator = new Calculator(3);
calculator.press('plus', 2); // Uncaught Error: plus operation no support yet!
复制代码

我们将所有的运算操作都独立成一个插件,计算器的核心功能只提供插件的注册和调用逻辑。接下来,我们为各个运算符开发对应的插件。

第三步:实现自定义插件

// plus plugin
const plus: IPlugin = {
  name: "plus",
  exec: (currentValue, addend) => {
    if (!addend) {
      throw Error("addend is required!");
    }
    return currentValue + addend;
  },
};

// minus plugin
const minus: IPlugin = {
  name: "minus",
  exec: (currentValue, subtrahend) => {
    if (!subtrahend) {
      throw Error("subtrahend is required!");
    }
    return currentValue - subtrahend;
  },
};

const calculator = new Calculator(3);
calculator.register(plus);
calculator.press("plus", 2); // => 5
复制代码

至此,我们完成了一个简单的插件系统。

我们将计算器抽象成了两部分,控制和行为。所有的行为,比如加法、平方等都是通过插件方式提供,确保了核心层的稳定以及便于扩展。(架构层的设计)

我们将系统中的变量设计为私有变量,避免被外部篡改,只提供唯一修改数据的方法 press 和插件注册方法 register。定义了插件接口,遵循 DIP 原则。对插件进行隔离,所有插件的执行代码都是一个纯函数,不仅便于测试,也减少了插件对系统的耦合和降低插件对系统的攻击风险。(模块层的设计)

然而,在 Web 中,以上方式对插件的隔离是不是就是安全的了?

如果有人在插件中加入以下代码:

console.log = function () {
  alert("你被攻击了");
};
复制代码

image.png

在 JS 中,可以直接访问到很多全局方法和对象,如果某个对象被篡改了,那么将会影响到所有地方。另外,CSS 样式也是全局的,插件之间,或者插件与系统之间,都可能存在样式冲突和污染问题。

而我们的目标是设计一个安全的插件系统,特别是当我们插件是向第三方开发者开发时,插件安全是一个必须要引起重视的问题。

插件安全性

目前比较流行的保证插件安全性的方案是采用 Sandboxing (沙箱) 方案。插件一般是第三方开发者的代码,如果能在一个与系统隔离的环境(作用域)中执行插件代码,这样插件就不会对系统产生副作用了。Node 中有 vm 模块,可以让代码在一个独立的环境中执行,但是浏览器不行,需要我们自己实现。

接下来,将会介绍下目前比较流行的几种沙箱方案,包括 JS 沙箱和 CSS 沙箱。

JS 沙箱

Iframe

一个天然自带的沙箱,同时也是隔离性最好的,iframe 应该沙箱方案的首选。比如 CodePen。

当我们想把 iframe 设置为沙箱时,最好设置 sandbox 属性。

image.png

该属性会对 iframe 内容做更多的限制:

更多信息请查看 iframe

  1. script 脚本不能执行。
  2. 不能发送 ajax 请求。
  3. 不能使用本地存储,即 localStorage, cookie 等。
  4. 不能创建新的弹窗和 window。
  5. 不能发送表单。
  6. 不能加载额外插件比如 flash 等。

如果 sandbox 设置为空字符值,则表示应用所有的限制(最严格)。可以通过给 sandbox 设置特定的值来解除特定限制。

<!-- 所有限制生效 --->
<iframe src="xxx" sandbox=""></iframe>
<!-- 允许执行脚本、允许提交表单、允许同域请求 --->
<iframe src="xxx" sandbox="allow-scripts,allow-forms,allow-same-origin"></iframe>
复制代码

然而,在实际应用过程中,iframe 也会遇到其他一些问题:

  1. postMessage 传递的消息只能是纯字符串,如果插件与系统交互数据比较大,数据的序列化将会耗费大量时间。
  2. 特定场景下,不好集合到系统中,因为插件只能运行在一个独立的 iframe 中。

运行在主线程

当插件代码在系统的主线程执行时,这是很危险的,主要是因为它可以任意访问和调用浏览器的全局 API。所以我们要隐藏掉全局变量。

我们可以将 windowdocument 对象设置为 null,不过由于 JS 原型链模式,消除所有的全局变量是很困难的。比如通过 ({}).constructor 就可以拿到 Object 对象,还可以修改所有对象的原型链方法和属性。

所以我们需要构建一个沙箱,在沙箱里访问不到全局变量(或者是只能访问经过我们处理的全局变量)。

独立的 JS 解释器

这是 Figma 尝试过的一个方案,他们想自己写个 JS 解释器,不过成本太大,最终使用了 [Duktape](https://github.com/svaarala/duktape)(一种 C++ 编写的轻量级 JavaScript 解释器),然后将其编译为 WebAssembly。Duktape 不支持任何浏览器 API,在 WebAssembly 中运行,无法访问浏览器 API,这看起来是一个成功方案。

但是它还是有些问题,主要是 Duktape 解释器太落后了,不方便调试,还有执行脚本性能差等,是无法跟浏览器的 JS 引擎相比的。

最终,Fimga 没有采用该方案,他们采用了一种更好的方案。

Realms

这是一个 Stage 2 的新提案,Realms 提案提供了一种新的机制,可以在新的全局对象和一组 JavaScript 内置对象的上下文中执 JavaScript 代码。

const red = new Realm();
globalThis.someValue = 1;
red.evaluate("globalThis.someValue = 2"); // Affects only the Realm's global
console.assert(globalThis.someValue === 1);
复制代码

这个提案的最佳实践之一就是 Sandboxing,然而现在还是 stage 2 提案,无法在生产中使用。

不过,该思想是可以使用已有 JavaScript 功能来实现该技术的,主要思想是创建一个独立的代码执行环境上下文。核心实现如下:

function simplifiedEval(scopeProxy, userCode) {
  with (scopeProxy) {
    eval(userCode);
  }
}
复制代码

with 语句会扩展一个语句的作用域链,它会把给定表达式添加到执行语句的最近作用域链上。

with (Math) {
  const r = 2;
  a = PI * r * r;
  x = r * cos(PI);
  y = r * sin(PI);
  console.log(x, y);
}
复制代码

在执行语句内的变量,如果在当前块作用域内找不到时,则会沿着作用域链往上找,而 with 后的表达式就是最近的作用域。

所以,我们可以借助 with + proxy 来实现沙箱。比如上面的例子,eval(userCode) userCode 中的全局变量会先在 scopeProxy 上查找,我们只要对 scopeProxy 设置 get 和 set ,那么 userCode 内访问和修改全局变量都会被我们拦截。

使用 with + proxy + whitelist 实现一个简单的沙盒 eval。

const whitelist = {
  window: undefined,
  document: undefined,
};
const scopeProxy = new Proxy(whitelist, {
  get(target, prop) {
    if (prop in target) {
      return target[prop];
    }
    return undefined;
  },
  set(target, name, value) {
    if (Object.keys(whitelist).includes(name)) {
      whitelist[name] = value;
      return true;
    }
    return false;
  },
});

function sandBoxingEval(scopeProxy, userCode) {
  with (scopeProxy) {
    eval(userCode);
  }
}
const code = `
  console.log(window); // undefined
  window.aa = 123; // Cannot set property 'aa' of undefined
`;
sandBoxingEval(scopeProxy, code);
复制代码

以上我们通过白名单机制,隐藏了 window 和 document 等全局变量。然而,仍然可以通过 ({}).constructor 这样的表达式来访问某些全局变量。此外,沙箱确实需要访问某些全局变量,如 Object,它常出现在合法的 JavaScript 代码(如 Object.keys )中。

这时候,iframe 又发挥作用了,iframe 内有个 contentWindow,它拥有所有全局变量的副本,如 Object.prototype。在同源情况下,我们可以在主线程获取到 contentWindow 。

const iframe = document.createElement('iframe', { url:'about:blank' });

document.body.appendChild(iframe);

const sandboxGlobal = iframe.contentWindow;

console.log(sandboxGlobal); // Window {window: Window, self: Window, document: document, name: "", location: Location, …}

我们对上面的实现稍微修改下,把 contentWindow 当做 whitelist。
const iframe = document.createElement('iframe', { url:'about:blank' });
document.body.appendChild(iframe);
const sandboxGlobal = iframe.contentWindow;
const scopeProxy = new Proxy(sandboxGlobal, {
  get(target, prop) {
    if (prop in target) {
      return target[prop]
    }
    return undefined
  },
  set(target, name, value){
    if(Object.keys(target).includes(name)){
      target[name] = value;
      return true;
    }
    return false;
  },
})

function sandBoxingEval(scopeProxy, userCode) {
  with (scopeProxy) {
    eval(userCode)
  }
}
const code = `
  window.aa = 123;
  ({}).constructor.prototype.aa = 'aa';
`;
sandBoxingEval(scopeProxy, code);

// 主线程下
window.aa; // undefined
({}).aa; // undefined
复制代码

至此,我们实现了个隔离性很好的沙箱,但这并不是终点。

虽然插件代码可以独立在沙箱里执行,但是在实现场景中,我们是需要向插件提供能力的。我们可能会暴露一些工具方法给插件,然而这是极其危险,因为插件内部可以通过该方法,顺着原型链来到我们的主线程,从而可以访问并修改主线程的全局变量。

所以,为插件提供能力也是一件需要极其小心的事情,一不小心可能就前功尽弃。更多信息可以了解下 Figma 的实现思路:How to build a plugin system on the web and also sleep well at night

这里简单实现下,主要的思路就是:通过在沙箱内对传进来的 function 包一层,改变其原型链的引用。

const iframe = document.createElement("iframe", { url: "about:blank" });
document.body.appendChild(iframe);
const sandboxGlobal = iframe.contentWindow;
sandboxGlobal.log = (v) => v + v;
const scopeProxy = new Proxy(sandboxGlobal, {});

function sandBoxingEval(scopeProxy, userCode) {
  with (scopeProxy) {
    eval(`
      const safeFactory = fun => (...args) => fun(...args);
      log = safeFactory(log);
    `);
    eval(userCode);
  }
}
const code = `
  log.constructor.prototype.__proto__.aa = 'aa';
  console.log(log(1)) // 2
`;
sandBoxingEval(scopeProxy, code);
({}.aa); // undefined
复制代码

CSS 沙箱

命名空间

这需要大家遵守一定的命名规范,比如定义唯一的 class 前缀。比如 Antd Design 、Iview。这是最简单直接的处理方式,成本也是最低的,需要人为的约束,是很容易造成样式冲突和污染的。

这里我们借助工程化工具,或者自己写个转化器,对插件代码进行处理,都加上唯一的命名空间。

Scope CSS

在 Vue 中,我们可以使用 scope 来控制 css 的影响范围。Scope CSS 与 CSS Module 稍微不太一样,它使用属性选择器,来缩小 CSS 生效的范围。

<style scope lang="less"></style>
复制代码

这里提供个思路,我们可以借鉴这种思想,在处理插件代码时,开发个中间处理器,给插件 HTML 节点都加上唯一的属性,然后在 CSS 中全部替换成属性选择器(vue-loader 干的活)。借助 @vue/component-compiler-utilspostcss 完全可以自己实现。

刨根问底,揭开 Vue 中 Scope CSS 实现的幕后(原理)

.box[data-v-992092a6] {
  width: 200px;
  height: 200px;
  background: #aff;
}
复制代码
<div data-v-992092a6>scoped css</div>
复制代码

Shadow DOM

Shadow DOM 是最新推出的 Web Components 技术的重要组成部分。

Web Components 技术的核心就是封装,Shadow DOM 就允许我们创建一个独立的 Shadow 空间,里面的样式不会影响到外部,外部的样式也不会影响到其里面样式,它是真正的独立。

这个方案是未来的一个趋势,只是现在兼容性还不怎么好,如果只在 chrome 上使用,该方案是首推。

image.png

通过 Element.attachShadow() 创建一个 Shadow 空间,然后往里面添加独立的 DOM。这里需要注意,不是所以的标签都可以调用 attachShadow 生成空间的。

const div = document.createElement("div");
const shadowRoot = div.attachShadow({ mode: "closed" });
const pluginDom = getPluginDom();
shadowRoot.appendChild(pluginDom);
复制代码

image.png

上图来自:mp.weixin.qq.com/s/pIRFNpAo8…

总结

本文首先介绍插件的使用场景,我们需要权衡是否真的有必要支持插件。一个好的插件系统,前提是一个好的软件系统,所以在设计之前,我们需要了解基本的设计原则和规范。插件系统可以看作是设计原则结合实际场景的一种最佳实践,本文给出了基本的设计模型和核心概念,可以结合实际业务场景设计我们自己的插件系统。本文还带领大家简单实现了一个插件系统,由此我们看到了插件系统安全的重要性。如何确保插件的安全性,我们可以从 JS 沙箱和 CSS 沙箱两方面着手,简单介绍了几种主流方案,供大家参考。

希望你能从中受益。

参考资料

  1. How to build a plugin system on the web and also sleep well at night
  2. Designing a JavaScript Plugin System | CSS-Tricks
  3. How to Design Software — Plugin Systems
  4. tc39/proposal-realms
  5. 刨根问底,揭开 Vue 中 Scope CSS 实现的幕后(原理)

❤️ 谢谢支持

以上便是本次分享的全部内容,希望对你有所帮助^_^

喜欢的话别忘了 分享、点赞、收藏 三连哦~。

欢迎关注公众号 ELab团队 收货大厂一手好文章~

我们来自字节跳动,是旗下大力教育前端部门,负责字节跳动教育全线产品前端开发工作。

我们围绕产品品质提升、开发效率、创意与前沿技术等方向沉淀与传播专业知识及案例,为业界贡献经验价值。包括但不限于性能监控、组件库、多端技术、Serverless、可视化搭建、音视频、人工智能、产品设计与营销等内容。

欢迎感兴趣的同学在评论区拍砖 🤪

文章分类
前端
文章标签