1. 背景
为了方便 Cyra 用户查看/管理项目中的视图及视图间的跳转和数据传递,决定开发一个 Chrome extension(扩展)来方便展示。开发中,最关键的问题在于如何解决各模块之间的通信。最终的实现效果如下图:
2. 开发
2.1 模块划分
这部分网上已经有很多文章来介绍,在此不再赘述。但网上涉及到devtools
类型的插件比较少,所以简单介绍下。首先,在 devtools 中创建的 panel 本质是一个 HTML 页面。代码主要分为以下4部分:
- background.js:Chrome 为扩展提供的一个独立的脚本运行环境,在本例中,主要作为 content_script.js 和 devtools.js 之间通信的桥梁,因为 Chrome 并没有提供后面二者直接通信的服务。
- content_script.js:用来向打开的页面注入脚本。
- devtools.js:用来在 devtools 中创建一个 panel,并实现该 panel 和 background.js 之间的通信。
- draw.js:devtools.js 中创建 panel 所引用的页面脚本,本例中用来绘制状态。
下文为方便描述,不再添加 .js 后缀,直接使用模块名指代该模块。
2.2 事件注册(content_script)
为了动态显示项目中的所有视图信息和视图间的跳转(包括强数据传递),需要 Cyra 框架和扩展之间能够进行通信。所以考虑采用 pub-sub 模式,打开一个页面时,脚本动态向 window 对象上挂在一个简单的事件处理器,并且监听指定事件;当 Cyra 项目启动时,自动触发绘制视图的事件,从而执行事件处理函数向 devtools(以 background 为桥梁)发送信息,并绘制视图信息。
而 2.1 中已经说明,注入代码的工作应该由 content_script 来执行,所以它的工作主要就是实现一个简单的事件管理器,监听事件,并将代码注入到页面中。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
function installHook (window) { var CYRA_HOOK_KEY = '__CYRA_DEVTOOLS_GLOBAL_HOOK__'; var _hook = { eventList: [], on: function (key, cb) { if (!this.eventList[key]) { this.eventList[key] = []; } this.eventList[key].push(cb); }, off: function (key, cb) { var cbs = this.eventList[key]; if (!cbs) return false; if (!cb) { cbs && (cbs.length = 0); } else { for (var l = cbs.length - 1; l >= 0; l--) { var _cb = cbs[l]; if (cb === _cb) { cbs.splice(l, 1); } } } }, emit: function () { var key = Array.prototype.shift.apply(arguments), cbs = this.eventList[key]; if (!cbs || cbs.length === 0) return false; for (var i = 0, cb; cb = cbs[i++]; ) { cb.apply(this, arguments); } } }; Object.defineProperty(window, CYRA_HOOK_KEY, { value: _hook }); window[CYRA_HOOK_KEY].on('init_routes', function (routes) { if (!routes || routes.length === 0) { // 初始化失败 } else { var _routes = []; for (var i = 0; i < routes.length; i++) { _routes.push(routes[i].path); } window.postMessage({ source: 'FROM_PAGE', type: 'init_routes', routes: _routes, }, '*'); } }); window[CYRA_HOOK_KEY].on('switch_route', function (pathObj) { if (!pathObj) { // 跳转失败 } else { window.postMessage({ source: 'FROM_PAGE', type: 'switch_route', pathObj: pathObj, }, '*'); } }); } window.addEventListener("message", function(event) { if (event.source !== window) return; if (event.data.source && (event.data.source == "FROM_PAGE")) { chrome.runtime.sendMessage(event.data); } }, false); var script = document.createElement('script'); script.appendChild(document.createTextNode('(' + installHook.toString() + ')(window)')); (document.body || document.head || document.documentElement).appendChild(script); |
如此,当进入一个页面时,扩展会自动向该页面注入上面的脚本,然后在 Cyra 框架中初始化路由,以及执行页面跳转时,触发window.__CYRA_DEVTOOLS_GLOBAL_HOOK__
对象上注册的相应事件,即可发送信息给扩展,剩下的由下一步处理。这里有以下两点需要注意:
-
处于安全考虑,Chrome 规定 content_script 只能访问页面的 DOM,而不能访问它的 JavaScript 运行环境。所以只能动态添加 script 标签的形式向页面中插入一段脚本字符串。
-
插入的脚本和 content_script 之间本质和上一点中提及的一样,也是两个执行环境,所以需要用
window.postMessage
来进行通信。
2.3 传递(缓存)事件
由于 content_script 和 devtools 之间无法直接通信,所以以 background 为中介。content_script 和 background 之间的通信只需要使用chrome.runtime.sendMessage
即可,如上述代码中那样。但 background 和 devtools 之间也没有直接通信的 API 可供调用,官方给出的办法是在二者之间创建一个长连接保持通信,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
// background.js var connections = {}; var cache = {}; chrome.runtime.onConnect.addListener(function (port) { var extensionListener = function (message, sender, sendResponse) { if (message.name === "init") { var tabId = message.tabId; connections[tabId] = port; if (cache[tabId] && cache[tabId].length) { for (var i = 0; i < cache[tabId].length; i++) { var req = cache[tabId][i]; port.postMessage(req); } } return; } } port.onMessage.addListener(extensionListener); port.onDisconnect.addListener(function(port) { port.onMessage.removeListener(extensionListener); var tabs = Object.keys(connections); for (var i = 0, len = tabs.length; i < len; i++) { if (connections[tabs[i]] == port) { delete connections[tabs[i]] break; } } }); }); chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { if (sender.tab) { var tabId = sender.tab.id; if (tabId in connections) { connections[tabId].postMessage(request); } else { cache[tabId] = cache[tabId] || []; cache[tabId].push(request); } } else { console.log("sender.tab not defined."); } return true; }); // devtools.js chrome.devtools.panels.create( 'Cyra', '', 'panel.html', function (panel) { panel.onShown.addListener(function (window) { var port = chrome.runtime.connect({ name: 'panel' }); port.postMessage({ name: 'init', tabId: chrome.devtools.inspectedWindow.tabId }); port.onMessage.addListener(function(request) { if (request.type === 'init_routes') { var actions = request.actions || []; window.cyraOps.initRoute(request.routes, actions); } else if (request.type === 'switch_route') { window.cyraOps.switchRoute(request.pathObj); } }); }); } ); |
代码对应的 API 在官方文档可以直接查到用法,background 的主要工作除了保持与另两个模块的通信,还负责缓存事件。当进入一个页面,但还没有打开开发者工具时,先将事件缓存。待开发者工具打开后,devtools 会主动向 background 发起长连接创建请求,然后 background 会自动将缓存过的事件依次模拟触发一遍,使得 devtools 知悉哪些事件被触发。
2.4 绘制视图和跳转(draw)
最后一步,devtools 得到 content_script 经 background 传递来的信息后,会在 devtools 创建的 panel.html 中绘制相应的 SVG 图片。这里没有采用 canvas 主要是因为 SVG 也有文档模型,可以在上面绑定事件。具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 |
window.cyraOps = (function () { // CONSTANT var NSSVG = 'http://www.w3.org/2000/svg', VIEW_WIDTH = 120, VIEW_HEIGHT = 180, CURRENT_VIEW_COLOR = '#CAE5F9', PREVIOUS_VIEW_COLOR = '#ffecd1', VIEW_BORDER_COLOR = '#6f9dbf', TEXT_COLOR = '#f79705', VIEW_INTERVAL = 30, STARTX = 5, STARTY = 5, TEXT_OFFSET_X = 25, TEXT_OFFSET_Y = 30 BETHEL_OFFSET = 50, DOM_PREFIX_VIEW = 'CYRA_VIEW_', DOM_PREFIX_JOINT = 'CYRA_JOINT_', DOM_PREFIX_LINE = 'CYRA_LINE_', LINE_OFFSET_HEIGHT = 12; var _routes = {}, _currRoute = '', _previousRoute = ''; var initRoute = function (routes, actions) { if (!routes || !routes.length || !actions) { return false; } _clearContext(); var svg = document.getElementById('svg'); svg.style.border = 'none'; svg.innerHTML = ''; var groupNode, startX = STARTX, startY = STARTY; // 渲染 View 框图 for (var i = 0; i < routes.length; i++) { if (_routes[routes[i]]) continue; groupNode = _createRouteView(routes[i], startX, startY); _routes[routes[i]] = { startX: startX, startY: startY, index: i, path: routes[i], }; startX += VIEW_WIDTH + VIEW_INTERVAL; svg.appendChild(groupNode); } // 渲染 Action 连线 for (var i = 0; i < actions.length; i++) { _drawLine(_routes[actions[i].from], _routes[actions[i].to]); } svg.setAttribute('width', startX); }; var _clearContext = function () { _routes = {}, _currRoute = '', _previousRoute = '', LINE_OFFSET_HEIGHT = 12; }; // 绘制 Action 连线 var _drawLine = function (from, to) { var pathNode = document.createElementNS(NSSVG, 'path'), strPath, circleNode = document.createElementNS(NSSVG, 'circle'), cx, cy, svg = document.getElementById('svg'); var startX = from.startX, startY = from.startY, endX = to.startX, endY = to.startY; if (from.index === to.index - 1) { strPath = 'M' + (startX + VIEW_WIDTH) + ' ' + (startY + VIEW_HEIGHT / 2) + ' H ' + endX; cx = endX; cy = startY + VIEW_HEIGHT / 2; lineLength = VIEW_INTERVAL; } else { var offsetWidth = Math.abs((to.index - from.index) * 10); // strPath = 'M' + (startX + VIEW_WIDTH / 2) + ' ' + (startY + VIEW_HEIGHT) + ' Q ' + ((startX + endX) / 2) + ' ' + (STARTY + VIEW_HEIGHT + BETHEL_OFFSET) + ' ' + (endX + VIEW_WIDTH / 2) + ' ' + (endY + VIEW_HEIGHT); strPath = 'M' + (startX + VIEW_WIDTH / 2 + offsetWidth) + ' ' + (startY + VIEW_HEIGHT) + ' V ' + (STARTY + VIEW_HEIGHT + LINE_OFFSET_HEIGHT) + ' H ' + (endX + VIEW_WIDTH / 2 - offsetWidth) + ' V ' + (endY + VIEW_HEIGHT); cx = endX + VIEW_WIDTH / 2 - offsetWidth; cy = endY + VIEW_HEIGHT; lineLength = LINE_OFFSET_HEIGHT * 2 + Math.abs(endX - startX - 2 * offsetWidth); LINE_OFFSET_HEIGHT += 10; } var _id = from.path + '_' + to.path; // 绘制连接点 _setAttribute(circleNode, { cx: cx, cy: cy, r: 4, fill: '#fff', stroke: TEXT_COLOR, 'stroke-width': 2, id: (DOM_PREFIX_JOINT + _id), }); _setStyle(circleNode, { opacity: 0, transition: 'opacity 800ms', }); // 绘制连线 _setAttribute(pathNode, { fill: 'transparent', d: strPath, stroke: TEXT_COLOR, 'stroke-width': 2, id: (DOM_PREFIX_LINE + _id), }); // 设置连线动画 _setStyle(pathNode, { transition: 'stroke-dashoffset 300ms ease-in-out', 'strokeDasharray': [lineLength, lineLength].join(' '), 'strokeDashoffset': lineLength, }); var g = document.createElementNS(NSSVG, 'g'); g.appendChild(pathNode); g.appendChild(circleNode); svg.setAttribute('height', STARTY + VIEW_HEIGHT + LINE_OFFSET_HEIGHT); svg.appendChild(g); setTimeout(function () { pathNode.style.strokeDashoffset = 0; circleNode.style.opacity = 1; }, 10); }; // 绘制 View 框图 var _createRouteView = function (text, startX, startY) { var g = document.createElementNS(NSSVG, 'g'); var rectNode = _drawRect(text, startX, startY); var textNode = _drawText(text, startX + TEXT_OFFSET_X, startY + TEXT_OFFSET_Y); g.appendChild(rectNode); g.appendChild(textNode); return g; }; var _drawRect = function (text, startX, startY) { var rectNode = document.createElementNS(NSSVG, 'rect'); _setAttribute(rectNode, { x: startX, y: startY, stroke: VIEW_BORDER_COLOR, fill: 'none', 'stroke-width': 2, id: (DOM_PREFIX_VIEW + text), }); _setStyle(rectNode, { width: 0, height: 0, transition: 'width 1s, height 1s', }); setTimeout(function () { _setStyle(rectNode, { width: VIEW_WIDTH, height: VIEW_HEIGHT, }); }, 100); return rectNode; }; var _drawText = function (text, startX, startY) { var textNode = document.createElementNS(NSSVG, 'text'); _setAttribute(textNode, { x: startX, y: startY, 'stroke-width': 1, stroke: TEXT_COLOR, }); _setStyle(textNode, { opacity: 0, transition: 'opacity 1s', }); textNode.textContent = 'View : ' + text; setTimeout(function () { textNode.style.opacity = 1; }, 100); return textNode; }; var switchRoute = function (pathObj) { // 判断是否是 Action 并相应绘制连线 if (_currRoute) { if (_previousRoute) { var previous = document.getElementById(DOM_PREFIX_VIEW + _previousRoute); previous.setAttribute('fill', 'transparent'); } var current = document.getElementById(DOM_PREFIX_VIEW + _currRoute); current.setAttribute('fill', PREVIOUS_VIEW_COLOR); if (pathObj.useSwitch) { var _id = _currRoute + '_' + pathObj.path; var arrowJoint = document.getElementById(DOM_PREFIX_JOINT + _id), arrowLine = document.getElementById(DOM_PREFIX_LINE + _id); if (!arrowJoint) { _drawLine(_routes[_currRoute], _routes[pathObj.path]); } } } var currEl = document.getElementById(DOM_PREFIX_VIEW + pathObj.path); currEl.setAttribute('fill', CURRENT_VIEW_COLOR); _previousRoute = _currRoute; _currRoute = pathObj.path; var currActionText = [_previousRoute, _currRoute].join(' ---> '); currActionText += ' ( ' + (pathObj.useSwitch ? 'switchRoute' : 'popstate') + ' )'; document.getElementById('data-current-action').innerHTML = currActionText; var data = JSON.stringify((pathObj.data && Object.keys(pathObj.data).length) ? pathObj.data : 'NO DATA'); document.getElementById('data-transfered-data').innerHTML = data.slice(1, data.length-1); }; // 设置 SVG 元素属性 var _setAttribute = function (el, attrs) { for (var key in attrs) { el.setAttribute(key, attrs[key]); } }; var _setStyle = function (el, styles) { for (var key in styles) { el.style[key] = styles[key]; } } return { initRoute: initRoute, switchRoute: switchRoute, }; })(); |
当然,最后还添加了一点动画效果。扩展已经发布到了 Chrome App Store:Cyra devtools
3. 总结
这次开发主要了解了下 Chrome 扩展各模块的作用和他们之间的通信方式,并且在最后学习了下 SVG 的一些绘制方法,挺有意思。当然,扩展还有很多地方没有做到,后续会持续迭代。
注:转载注明出处并联系作者,本文链接:https://nodefe.com/chrome-extension-cyra-devtools/