前端插件系统设计

越加丰富的 JS 语法和丰饶的 npm 社区让我们能构建解决复杂问题的应用程序,随着 UI 自动化生成技术的进步,前端开发者面对的主要问题将不再是样式和动效的实现,而将是异构数据源融合、异步互联数据(Async Linked Data)的展示和交互方案设计。

我们考虑在前端输入结构复杂的数据,对数据推理并展示推理结果时,这样的系统很难做到大而全,每个用户都有自己的个性,他们会想看到应用有不同的主题、会想用自己喜欢的方式输入信息、会想要你的应用支持他偏门的用法。这些其实都不是问题,只要你提供了友好的插件(plugin or extension)系统,总会有能人志士做出贡献,写出牛逼的插件。

我们在设计前端应用时如果提早考虑到插件架构,能为我们的应用带来很大的社区活跃性。本文综述了目前比较流行的插件系统架构,供 Web 游戏开发者、信息管理系统开发者参考。

Semantic Media Wiki

用于构建 DBPedia 的维基百科,是使用一款叫 Media Wiki 的程序架设的。

Media Wiki 广泛用于各种维基站点,但它有一个缺点,就是没法细粒度地把百科页面语义化。维基百科每个页面就是一堆 HTML,当你有数百万个页面时,想找到某个实体就只能用信息检索(IR)的方法。当维基系统被 Semantic Media Wiki 插件接管,你就能通过搜索性别、年龄、职业来查找实体,就像使用一个数据库一样。可以说这个插件提升了用户(主要指维基维护者)生产、分享和使用结构化数据的能力。[2]

Semantic Media Wiki 作为 Media Wiki 的大型扩展插件集(expansion),在 Media Wiki 的框架上提供了很复杂的结构化数据、查询数据等功能,例如可以把维基内容变成图表、时间轴、地图等形式来展现。而它自身又可攻可受,自己是别人的插件,然后又提供了插件系统给别人。

例如 SMW 的插件 SemanticResultFormat 就可以丰富 query 结果的格式;SemanticForms 通过表单编辑(新的编辑方式)结合SMW进行操作,增强了「录入属性」这一部分,同时为 SMW 使用者提供了一个非常强大的函数 {{#arraymap}};Arrays 插件则为使用者提供了 {{#arraydefine}}{{#arrayprint}} 两个函数。所以说 Semantic Media Wiki 是一个凸和凹的结合体,「泰卦」是也。[4]

Load

我们一般会把第三方插件下载到文件系统上,放在名叫 /plugin 之类的文件夹里。

火狐狸浏览器的插件是 .xpi 文件,其实就是个 zip 包,下载到火狐存放插件的文件夹里之后,我们在内存里解压它,加载里面的脚本。一般来说这种直接把打好的包放进某个文件夹就能用的方式对使用者会比较友好,但系统设计会比较复杂。而且虽然简化掉了解压到文件系统的过程,但每次还是得在内存里解压读取内容。[3]

读取压缩包的好处是不用读取大量零碎文件。Atom 曾经为读取 node_modules 中数以万计的零碎小文件太慢而发愁,最后选择了读取 Electron 提供的 ASAR 归档格式,一次性读取海量小文件打包成的一个大文件。[2]

VSCode 的插件会依赖一些 npm 包,但当你点击 VSCode 某个插件的安装按钮时,VSCode 并不选择在此时去执行一次 npm i,而是劝插件作者在上传插件到市场之前提前 npm i 一下,然后把所有依赖打了包一起上传。

VSCode 的插件需要声明自己在什么样的情况下才被唤醒,例如当前打开的文件类型是 javascript 的时候,这样声明自己的插件就会被唤醒:

"activationEvents": [
    "onLanguage:javascript"
]

或者一个颜色选择器可能会这样声明:

"activationEvents": [
  "onCommand:extension.colorHelper.pick"
],
"commands": [
  {
    "command": "extension.colorHelper.pick",
    "title": "Pick Color"
  }
]

commands 里声明了一个 Action,把它绑定到 VSCode 按 command + shift + p 会出来的菜单里的一个按钮上,当这个按钮被按下,这个 Action 就会触发 onCommand:extension.colorHelper.pick 从而打开这个颜色选择插件。然后在插件里也能拿到这个 Action:message.command === 'pick',这样如果插件能被多种方式触发打开的话,在插件里也能做出判断,打开之后看到不同的功能。

上述的 activationEventscommands 都在 package.json 的 contributes 字段里[12],VSCode 在启动时读取这些字段,在菜单里加上一些按钮、监听一些 Action,做好启动这个插件的准备但并不真正启动它,也不加载它的资源。

一篇值得阅读的源码是 C9 的 Architect.js,它展示了一个简单的插件系统,功能有:

  1. 读取 config 从而了解到要从哪些路径加载插件
  2. 加载插件的 JS 并分析插件的 consumesprovides 字段,从而分析是否所有被依赖的插件都加载完了
  3. 在启动一个插件时先启动它依赖的插件,并把这些依赖项传到插件的 imports 里。

它有相当一部分的代码用在容错上。[7]

MediaWiki 的很多插件是在主程序启动时的钩子函数上加载,在启动时就运行加载太多东西会导致性能问题。此外 MediaWiki 在加入越来越多的钩子,以减缓主程序扩张的趋势。除了插件以外,MediaWiki 也可以通过 API 扩展功能,用浏览器上运行的 JS、桌面端的 Python 都可以自动化地进行人能用 MediaWiki 界面能进行的任何工作。

插件与内容的交互

对于前端应用来说,几乎整个应用都是可以受 JavaScript 控制的。Google Chrome 的插件可以在页面内通过 querySelector 拿到整个页面,做 CSS 替换(Wikiwand),可以做类似 service worker 的网络请求替换或转发(ProxySwitchOmiga),也可以替换掉一些 HTTP 标签来做 AD Block;插件里运行的 JS 还可以通过只提供给插件端 JS 的 chrome 对象,拿到收藏夹往里面加东西或者生成收藏夹树状图,也可以拿到这些按钮的控制权 ↓ 往上面加点击事件监听器。 browseraction

但这种直接执行插件提供的 JS 实在是太底层了,它可能只能看到页面上干巴巴的 HTMl,根本不知道应用层里,Redux-Saga 运行着数十个协程,Apollo-Client 缓存着七八个 query。如果插件里也维护着自己的状态,那么就会因为「single source of truth」问题导致潜在的竞态条件。良好的前端插件系统不但开放 HTML 等底层信息,也要有合适的模型把应用层暴露给插件端 JS,以便插件能修改 Virtual Dom。

Atom 是一个用起来比较卡的,完全插件化的好看的文本编辑器,它安装后提供的功能是以 77 个官方插件的形式存在的。比如其中的 autocomplete 官方插件,因为不够好用,后来被社区大受好评的 autocomplete-plus 插件替换掉了,这插件系统就非常民主。[1] 只有完善了插件系统,我们的产品才是民有、民治、民享的。

Atom 可以通过 .tree-view 等选择器来选中界面上的 UI 元素,用宽高阴影等特效来美化 Atom 原有的界面。[14],因为 Atom 不再使用 Shadow DOM,所以做到这点变得很容易。

VSCode 出于性能考虑限制了插件能访问的 UI 元素,还将插件分到宿主进程、语言处理分到语言服务器,它出色的性能在打开巨型 JSON 文件和使用 TypeScript 推断类型时显现无疑。此外,VSCode 的开发团队认为如果将 DOM 暴露给插件开发者的话,它们就会和 VSCode 的界面紧密耦合很容易因为 VSCode 的更新而挂掉,他们希望只暴露一些松耦合的东西给插件开发者。

例如资源管理器是可以被插件有限定制的,你可以往资源管理器上加上一个自己的 tab,点开这个 tab 后出来一个列表,在插件里可以返回一个继承自 vscode.TreeItem 的类的实例的列表,从而显示一些定制化的内容。但不管怎么定制,它终归是一个列表。[11]

用户友好的界面

大部分用户是懒于使用「高级功能」的,例如在使用笔记类应用时,只有少数精英用户愿意大量使用 RDF-style 语义标记,而大部分用户只是想用一个友好美观的表单来填写数据,填完了事。

如果让插件有提供个性化 UI 组件的功能,会使用高级功能的工程师用户就能封装出特定场景下使用的表单 UI,造福大量小白用户。[10]

Atom 逐步在暴露给插件开发者的 atom 对象上加入新的函数,来增强开发者对界面做出修改的能力。例如 atom.workspace.getActiveTextEditor().decorateMarker 函数就可以在某个文字块的前面或后面加上内容。 [15]

让插件们知道发生了什么

Wiki 系统有一个 what links here(链入页面),当你在一篇文章里添加了指向另一篇文章的链接,另一篇文章就会注意到这一点,并把你刚写的这篇文章加入它的链入页面。这就像你盯着一个壮汉看,他就感到后脑勺发痒,转过身对你说「你瞅啥」一样。

MediaWiki 有一个任务队列,类似于 Redux,当你添加了指向另一篇文章的链接,这会形成一个消息(类似于 Redux Action),某个监听这个消息的函数就更新你所指页面的「链入页面」。这种消息也用于:1. 在你添加新页面的时候自动更新目录页把你的页面加进去 2. 在你删除页面的时候把所有其他页面上指向已删页面的链接从蓝色变成红色。[8]

上述行为类似于 Redux-Saga 通过监听消息来启动异步操作,例如你通过点击删除按钮,发出了一个删除 Action,一个 Saga 便开始执行,它会调查一下有哪些页面引用了你刚删的页面,并对每个页面启动一个新的更新页面上链接颜色的 Action。当有大于 500 个 Action 被同时发出时,它们会被分成每 500 个一组。[9] 如果你用过 GraphQL,你可能会发现这很类似于 dataloader 这个 npm 包做的事。

VSCode 对于语言服务和 Debug 服务使用 stdin 和 stdout 来和真正处理内容的程序通信,通信内容是一个 JSON。这样语言服务就可以用非 JS 的语言来书写,但坏处是在浏览器中运行的程序没法像 electron 中的程序那样接触到 stdio,这或许可以通过把这类服务做成服务端插件,因而把 stdio 改成 HTTP 来解决,虽然可能会爆慢,不适合一些自动补全等场景。对于可能的延迟问题,VSCode 的做法是让计算可取消,类似 redux-saga 的 takeLatest 或 cancel 的效果。插件会在收到新数据的同时接受到一个 CancellationToken 参数,以便像 IntelliSense 或者 GitLens 之类的插件能得知用户还在继续输入,之前接收到的数据已经作废了还是重新算吧。

与物理世界接触

许多非盈利机构都有爆多文件,他们的志愿者的电脑桌面可能密密麻麻堆满了文件,各种电子表格记录着各种各样的信息,这还比较好办,毕竟开一个支持电脑内所以文件全文搜索的软件就能找到你想要的东西。但许多人事档案是纸质化的,记录志愿者兴趣、住址的单子会经过志愿者转录变成整理好的纸质卡片,就像旧时代的图书馆借书卡一样。

他们可能曾经想过用更牛逼一点的信息管理系统,但这些系统除了学习困难(要求你按照它的设计思路来录入数据)、录入费时以外,还会有很多数据无法纳入系统,或者干脆就没空录入。所以每次他们觉得现在的信息管理系统用得不爽,打算换一个更牛逼的系统,都会在迁移过程中丢掉一大堆数据。[4]

但如果你的插件系统开放了通向物理世界的接口,使用者就有可能通过光学扫描仪、声呐、引力波探测器等物理设备导入他们本无法纳入或懒得录入的数据。

一般来说,反向插件系统做这事比较简单:

假设我们已经实现了一个图像预览的主程序,这个主程序能够实现放大、缩小、旋转、拍照等功能,并且这个主程序已经被发布了。忽然有一天,客户提出这样一个要求,他们手上有一台中控设备,这台设备可以通过串口和电脑相连,恰好这台设备可以发送三个不同的命令(或者更多),然后他们希望可以通过设备上的按键命令控制那个图像预览程序的放大、缩小和旋转。我们可以分析一下,为了不让客户的特殊需求污染了原来程序的设计,只能设计成某种形式的插件系统,并且可以通过配置文件灵活的取消或者启用这个特殊的插件。如果设计成前向插件系统,那么主程序必须实现插件约定的某种接口,如果使用C/C++实现的话可以使用回调函数或者虚类。一方面,如果插件需要主程序提供的API过多,将会让接口约定变得很长;另一方面,如果有另外更多用途的插件需要主程序提供各自不同的API,这个时候主程序的设计将陷入由插件主导的尴尬境地。如何解决上述(或许还有没有讲到的)问题呢?反向插件系统就是一种很好的选择。首先,反向插件系统要求主程序在接口设计上处于主导地位,主程序公开了什么API,插件就只能使用什么API;其次,主程序可以通过归纳对需要公开的接口进行分类,避免上述问题导致的导出重复API的可能。 [6]

这种从外部录入数据的接口,没有实时要求的部分(扫描仪输入)可以使用 HTTP API,例如 REST、GraphQL 等,外部程序是独立程序,只是扫描完图像后调用 HTTP API 上传数据而已。

类似 GraphQL 这种带反射功能的协议也可用于构造前向插件系统,例如用户使用第三方网站上传扫描的图像,我们的应用的前向插件系统应该能让用户登陆这个第三方网站的账户,获取 credentials,并扫描 GraphQL API 的字段信息,由字段信息生成一个专门的用户界面让用户能把第三方网站视为一个插件。

使用 Electron 等基于 Chromium 的框架时,我们可以把 Chromium 的 USB 支持 API 转发给开发者,也就是我们使用封装这些 API 的 npm 包(前向插件),并让开发者使用我们暴露的 JavaScript API 作为后向插件。

更新时的安全问题

如果有一个大家都在用的「material-design」主题插件,突然有人在某个贴吧里发布了它的最新版本,声称新版本增加了不少膜蛤动效,并要求大家保护性反对。所以大家都不顾评论里的警告就去下载安装它,结果这个插件并不是来自之前的开发者,而是另一个恶意开发者,他把用户的文件全部发送到自己的服务器上了。

因此用于表明发行者身份是之前的开发者,以及文件内容不是被第三方修改后才发到贴吧上的 signature 就很重要了。 signing [7] Chrome 的插件把主体内容放在一个 zip 压缩包里,并附上开发者公钥、由私钥和压缩包内容生成的 signature,来保障使用者在贴吧上随便下个包也不会下到社会闲散人员恶意篡改的插件。

可交互对象

Rimworld 有一种在前端应用中一般以 SEO 为目的出现的插件类型:Definations,简称为 defs。Defs 是用 XML 写就的定义文件,描述了游戏过程中出现的植物、动物、建筑、武器还有音效、背景音乐等游戏组成部分。 比如下面这个 XML 描述了世界地图上的帐篷:

<?xml version="1.0" encoding="utf-8"?>
<!-- https://github.com/skyarkhangel/Hardcore-SK/blob/master/Mods/Camp/Defs/WorldObjectDefs/WorldObjects.xml -->
<Defs>
    <WorldObjectDef>
    <defName>CaravanCamp</defName>
    <label>caravan camp</label>
    <description>A camp.</description>
    <worldObjectClass>Nandonalt_SetUpCamp.CaravanCamp</worldObjectClass>
    <texture>SetupCamp/Flag</texture>
    <expandingIcon>true</expandingIcon>
    <expandingIconTexture>SetupCamp/Flag</expandingIconTexture>
    <expandingIconPriority>8</expandingIconPriority>
  </WorldObjectDef>
</Defs>

而在早些时候,我们为了 SEO 可能会在界面上加上 microdata,比如像这样描述一个有两种口味的冰淇淋:


<div itemscope>
 <p>Flavors in my favorite ice cream:</p>
 <ul>
  <li itemprop="flavor">Lemon sorbet</li>
  <li itemprop="flavor">Apricot sorbet</li>
 </ul>
</div>

这段数据有其相对应的 JSON-LD 和 RDFa。[17]

在 Rimworld 里游戏开发者提供的主要游戏内容是以一个 core mod 的形式存在的,他们声称虽然这是一个科幻游戏,但你可以用一个中世纪 mod 替换掉 core mod,再下载别的某个开发者提供的武器 mod,启动后游戏还是能正常运行,从中世纪 mod 里随机选取生物生成在地图上,并从第三方武器 mod 里随机取出武器定义,生成出武器分发给生成的敌人。[16] 在一个 mod 内,这些 xml 文件分布在数个文件夹内,分别是可交互物品、科研项目、多语言,此外还有一个文件夹用于存放 C# 编译出的 dll 文件,这些 dll 可以小范围地修改游戏机制。

C# and Defs

参考

Atom 背后的故事

如何理解semantic mediawiki插件的用途?

python 插件系统

帮助:Semantic Mediawiki

A Semantic Web for End Users

反向(或者后向)插件系统设计

Google Chrome Extensions: Identity, Signing and Auto Update

链入页面如何起作用

Wikipedia Job Queue

Schema or not schema

VSCode 插件:nodeDependencies

VSCode 插件概念:Contributes

C9的插件系统:Architect.js

Atom Theme System

Atom Block Decorations

Modding Rimworld

microdata

Demo 项目地址:itonnote