背景
项目原本为 Playwright 实现的 RPA
,现考虑平台兼容性与用户学习成本,使用 Chrome Extension 作为 Playwright 代替品。
所以 Chrome Extension 需要包含录制操作与播放功能。
调研维度
- 实现的基本原理,如基于 DOM Event Record 与 Workflow/Block
- 活跃度,主要从 github star 数、代码更新频率等判断
- 功能,可实现实际业务需求且需要考虑其缺陷与隐患
- 生产环境可用性,市面上是否已经存在使用这个解决方案的案例
- 可维护性,从工作量、学习/维护成本、对于业务的侵入度、最佳实践等方面考量
Automa
Automa 是一个基于 Workflow 实现自动化操作的开源平台。
Github Repo Star 8k+ Issues 42 Website
技术栈
- JavaScript
- Vue 3 (UI 框架)
- Chrome Extension API (Chrome 插件 API)
- Webpack 5 (模块化打包工具)
- Postcss + TailwindCss (CSS 框架)
- Tiptap (富文本编辑器)
- Vue Flow (流程图框架)
- Code Mirror (代码编辑器)
- RxJS (响应式编程扩展库)
功能
-
基于
Chrome Extension Content Script
实现的录制功能,包含切换标签页、事件监听等功能 -
完善的触发器系统可以实现主动/自动执行 Workflow 功能
-
Workflow 视图编辑器
-
Workflow 支持分支条件、循环等流程控制能力、完善的纠错系统以及
Script
注入 Block流程控制
-
持久化存储与凭据系统
-
基于
chrome.alarms
的定时任务功能 -
日志系统
-
数据备份与恢复系统
部分代码解析
记录部分 Automa 的技术架构与代码解析,以便日后调整、优化和迭代。
录制
入口文件:
src/content/services/recordWorkflow/index.js
- 执行通过
chrome.scripting.executeScript
注入 Document 上下文:
// browser 约等于 chrome
await browser.scripting.executeScript({
target: {
tabId: tab.id,
allFrames: true,
},
files: ['recordWorkflow.bundle.js'],
});
- 创建录制控制台元素至的 document 中:
export default function () {
const rootElement = document.createElement('div');
// 使用 shadow DOM 隔离 APP 实例,避免各种污染
rootElement.attachShadow({ mode: 'open' });
// 全局唯一ID,保证
rootElement.setAttribute('id', 'automa-recording');
rootElement.classList.add('automa-element-selector');
document.body.appendChild(rootElement);
// 注入样式
return injectAppStyles(rootElement.shadowRoot, customCSS).then(() => {
const appRoot = document.createElement('div');
appRoot.setAttribute('id', 'app');
rootElement.shadowRoot.appendChild(appRoot);
// 创建Vue3实例
const app = createApp(App).use(vRemixicon, icons);
// 挂载至 shadow DOM
app.mount(appRoot);
return app;
});
}
- 注册全局的事件捕获监听器,在这些监听器中添加
Block
:
if (isMainFrame) {
window.addEventListener('message', onMessage);
document.addEventListener('scroll', onScroll, true);
}
// 事件捕获
document.addEventListener('click', onClick, true);
document.addEventListener('change', onChange, true);
document.addEventListener('keydown', onKeydown, true);
- 将录制的 Recording 存入
chrome.storage
中
Workflow Execute
无论是从 Popup 还是 Dashboard 都是通过 Event 来触发 Workflow Execute。 所以最终都会在 background service worker 中执行:
MessageListener 实现在 src/utils/message.js 中
const message = new MessageListener('background');
// ...
// Workflow Execute Listener
message.on('workflow:execute', async async (workflowData, sender) => {
// ...
});
// ...
// 注入 runtime.onMessage
browser.runtime.onMessage.addListener(message.listener());
在 Workflow Execute Listener
,Automa 会创建一些状态,然后调用 BackgroundWorkflowUtils.executeWorkflow
方法:
BackgroundWorkflowUtils.executeWorkflow(workflowData, workflowData?.options || {});
跳转到 BackgroundWorkflowUtils.executeWorkflow
中,也同样只是对 startWorkflowExec
的一层封装:
// BackgroundWorkflowUtils static function
async function executeWorkflow(workflowData, options) {
if (workflowData.isDisabled) return;
// 检查版本
const isMV2 = browser.runtime.getManifest().manifest_version === 2;
startWorkflowExec(workflowData, options, isMV2);
}
所以 Workflow Execute
的核心执行逻辑在 src/workflowEngine/index.js
的 stopWorkflowExec
方法中。
那么我们开始解析一下 stopWorkflowExec
方法。
stopWorkflowExec
跳过其中的 helper code,我们直接切入核心点,在第 58 行这个函数创建了一个 WorkflowEngine
实例:
const engine = new WorkflowEngine(convertedWorkflow, {
options,
isPopup,
states: workflowState,
logger: workflowLogger,
blocksHandler: blocksHandler(),
});
其中 options,isPopup,workflowState
都是当前 workflow 上下文的状态,
而 workflowLogger
是 Automa 的日志记录模块,它通过 Dexie
(一个 indexedDB
)库实现的日志持久化:
WorkflowLogger 的依赖代码 src/db/logs.js
import dbLogs, { defaultLogItem } from '@/db/logs';
/* eslint-disable class-methods-use-this */
class WorkflowLogger {
async add({ detail, history, ctxData, data }) {
const logDetail = { ...defaultLogItem, ...detail };
await Promise.all([
dbLogs.logsData.add(data),
dbLogs.ctxData.add(ctxData),
dbLogs.items.add(logDetail),
dbLogs.histories.add(history),
]);
}
}
blocksHandler
是由多个 Handle 组合的、对应每个 Block 也就是 Workflow 执行块(可以理解为工作流中的每个步骤)。
它通过 require.context
解析 src/workflowEngine/blocksHandler/*.js
获取:
blocksHandler 入口 src/workflowEngine/blocksHandler.js
// 获取handles
const blocksHandler = require.context('./blocksHandler', false, /\.js$/);
// format handles 集合
const handlers = blocksHandler.keys().reduce((acc, key) => {
// 移除 handle 与 .js
const name = key.replace(/^\.\/handler|\.js/g, '');
// 转换为小驼峰命名并alias至default
acc[toCamelCase(name)] = blocksHandler(key).default;
return acc;
}, {});
接下来我们继续解析,engine.init();
与 engine.on('destroyed')
涉及到 WorkflowEngine
的实现,
那么我们看看它的实现:
文件地址 src/workflowEngine/WorkflowEngine.js
跳过 WorkflowEngine.init
中的状态更新代码,在初始化状态下,最终会执行到 303 行的 addWorker
结束:
class WorkflowEngine {
async init() {
// ...
this.addWorker({ blockId: triggerBlock.id });
}
}
那这个 addWorker
方法即为是执行逻辑的开始,看看它的逻辑:
class WorkflowEngine {
addWorker(detail) {
this.workerId += 1;
const workerId = `worker-${this.workerId}`;
const worker = new WorkflowWorker(workerId, this, { blocksDetail: blocks });
worker.init(detail);
this.workers.set(worker.id, worker);
}
}
这里又构造了 WorkflowWorker
的实例并初始化了它,那我们继续跳转:
文件地址 src/workflowEngine/WorkflowWorker.js
class WorkflowWorker {
init({ blockId, execParam, state }) {
// ...
this.executeBlock(block, execParam);
}
}
继续转到 executeBlock 的实现,我们跳过上下文环境检查的代码,看看它实现执行的核心逻辑是什么?
- 获取当前
block
对应的handle
:
const blockHandler = this.engine.blocksHandler[toCamelCase(block.label)];
const handler =
!blockHandler && this.blocksDetail[block.label].category === 'interaction'
? this.engine.blocksHandler.interactionBlock
: blockHandler;
这里 this.engine
是 WorkflowEngineInstance
的引用,
那么访问其 blocksHandler
就是访问之前我们提到的 BlockHandle 集合。
为方便后续阅读体验,这里将 blocksHandler
简写为 bh。
如果不存在对应的 block handler
直接退出并停止 workflow 的执行:
if (!handler) {
console.error(`${block.label} doesn't have handler`);
this.engine.destroy('stopped');
return;
}
bh 在执行过程需要的数据从 templating
函数中获取,
主要逻辑是将 Block Object 数据中的 refKeys
转换至上下文中:
const refData = {
prevBlockData,
...this.engine.referenceData,
activeTabUrl: this.activeTab.url,
};
const replacedBlock = await templating({
// ...
});
src/workflowEngine/templating/index.js
export default async function ({ block, refKeys, data, isPopup }) {
const copyBlock = cloneDeep(block);
const addReplacedValue = value => {
if (!copyBlock.replacedValue) copyBlock.replacedValue = {};
copyBlock.replacedValue = { ...copyBlock.replacedValue, ...value };
};
// ...
for (const blockDataKey of refKeys) {
// ...
addReplacedValue(renderedValue.list);
// ...
}
}
这里有用到一个 package: object-path
,主要提供路径访问数据的功能。
访问的数据来自可以追溯到 this.engine.referenceData
,在 WorkflowEngine
初始化的时候创建,在运行时也会动态更新:
this.referenceData = {
variables,
table: [],
secrets: {},
loopData: {},
workflow: {},
googleSheets: {},
globalData: parseJSON(globalData, globalData),
};
回到 WorkflowWorker
,再对 bh 做了一些上下文绑定之后,WorkflowWorker
开始执行 bh:
const bindedHandler = handler.bind(this, replacedBlock, {
refData,
prevBlock,
...(execParam || {}),
});
result = await blockExecutionWrapper(bindedHandler, block.data);
如果发生异常,将会由外部的 cry/catch 捕获,并生成对应的错误日志。 如果我们需要获取更多的错误信息可以在这里调整:
try {
// ...
result = await blockExecutionWrapper(bindedHandler, block.data);
} catch (error) {
const errorLogData = {
message: error.message,
...(error.data || {}),
...(error.ctxData || {}),
};
// ...
const errorLogItem = errorLogData;
addBlockLog('error', errorLogItem);
}
重试逻辑:
if (blockOnError.retry && blockOnError.retryTimes) {
await sleep(blockOnError.retryInterval * 1000);
blockOnError.retryTimes -= 1;
await this.executeBlock(replacedBlock, execParam, true);
return;
}
如果 bh 执行成功,WorkflowWorker
会继续递归调用 executeBlocks
执行下一个 bh,直到运行结束或出错:
if (result.nextBlockId && !result.destroyWorker) {
if (blockDelay > 0) {
setTimeout(() => {
executeBlocks(result.nextBlockId, result.data);
}, blockDelay);
} else {
executeBlocks(result.nextBlockId, result.data);
}
} else {
this.engine.destroyWorker(this.id);
}
好,到这里就结束了 Workflow
的运行,总结如下:
- 通过
chrome.runtime.sendMessage
触发on('workflow:execute')
事件 - 创建
WorkflowEngine
实例,并调用init
进行初始化 WorkflowEngine.init
中获取所有BlockHandles
并注册至实例中WorkflowEngine.init
中创建一个WorkflowWorker
实例,并初始化WorkflowWorker
获取当前 Block 对应的BlockHandle
,并注入上下文与数据再调用它- 重复第 5 项直到运行结束或出错
生产环境可用性
Automa 项目已入驻 Chrome 插件市场: 地址
商店使用人数有 80,000+ 位用户,评分 92.81 (4.6 Star),是一个比较优秀的项目。
但 Automa 使用 AGPL License,具有一定法律风险或要求业务项目开源!
License 相关链接:AGPL - 华为云
可维护性
由于功能繁杂涉及技术栈广,学习成本高。 并因为使用的是 Javascript,项目可维护性较差(JS 没有类型系统), 所以修改原有功能点会比新增功能更加困难。
复杂功能点:
- 录制功能,涉及代码量较广
- Workflow 编辑器,技术栈复杂且功能庞大
Plasmo
Plasmo 框架是一款功能强大的浏览器扩展程序软件开发工具包(SDK)。使用 Plasmo 来构建浏览器扩展程序,不需要操心扩展的配置文件和构建时的一些奇怪特性。
它只是一个框架并未提供录制操作与播放功能,需要自行实现。
Github Repo Star 6.6k+ Issues 80
技术栈
- Typescript
- React
功能
- 一流的 React + Typescript 支持
- 声明式开发(自动生成 manifest.json)
- 将 UI 组件渲染到网页
- 扩展内置页面
- 扩展热重载 + React 模块热更新
- .env* 文件支持
- 扩展储存 API
- 扩展通信 API
- 远程代码打包 (例如 Google Analytics)
- 支持多个浏览器和 manifest 版本
- 通过 BPP 进行自动部署
- 可选 Svelte 或 Vue 进行开发
生产环境可用性
作为兴欣的 Chrome Extension 框架,目前使用的人比较少, See: Plasmo Examples
可维护性
项目使用 TypeScript 开发,可维护性较强, 且未来录制操作与播放功能将为自实现,有很强的代码控制权。
总结
Automa 快速开发的首选
如果不考虑 License
的风险,且录制功能与 Workflow 编辑器无需大改的情况下,Automa 应当是第一选择。
其除了使用 js
作为基础语言(js 可维护性差)外,基本无可挑剔!
Plasmo 自研项目的优势
如果考虑自研项目的控制权(code 实现),使用 Plasmo 作为底层框架也具有一定可行性, 当然这也需要我们付出一些时间成本用于开发基础功能(录制操作与播放功能)。