本文参加了由公众号@若川视野发起的每周源码共读活动,点击了解详情一起参与
这是源码共读的第1期 | vue-devtools 组件可以打开编辑器,点击了解本期详情一起参与
1 学习目标
如何安装 Chrome 插件并实现一个简单的插件
了解通过 vue-devtools 插件打开 vsCode 对应组件原理
2 Chrome 插件
Chrome插件是一个用Web技术开发、用来增强浏览器功能的软件,是一个由HTML、CSS、JS、图片等资源组成的一个.crx后缀的压缩包
2.1 安装 vue-devtools 插件
通常情况下,使用 Chrome 插件,需要在 Chrome 应用商店 下载安装,只是在应用商店下载需要翻墙,在这里推荐使用极简插件下载安装
在 Chrome 安装 Vue Devtools插件
安装完成后,打开控制台即可看到 vue-devTools 插件
2.2 如何实现 Chrome 插件
可能我们也见过像掘金打开新标签页的插件,那么如何实现一个简单的打开新标签页的 Chrome 插件。
实现一个简易的 Chrome 插件,打开新的标签页显示如下
新建一个 chrome_new_tab 空文件夹,目录内容如下(贴上主要文件代码,图片就不贴了)
在文件夹下新建一个 manifest.json 文件
{
// 插件名称
"name": "new search",
// 插件描述
"description": "new search",
// 插件版本
"version": "1.0",
// manifest版本 chrome18开始为2
"manifest_version": 2,
// 浏览器右上角图标设置
"browser_action": {
"default_icon": "newTab.png",
// 鼠标悬停在扩展程序时的标题,可选
"default_title": "这是一个示例Chrome插件",
},
// 权限申请
"permissions": [
// 标签
"tabs"
],
// 注入页面的JS\CSS
"content_scripts": [
{
// <all_urls> 表示匹配所有地址
"matches": [
"<all_urls>"
],
// Js 注入,按顺序执行
"js": [
"jquery.min.js",
"canvas.js",
"newTab.js"
],
// 代码注入时间
"run_at": "document_start"
}
],
// 覆盖浏览器默认页面
"chrome_url_overrides": {
"newtab": "newTab.html"
}
}
新建 newTab.html 文件
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>new search</title>
<link rel="icon" href="favicon.ico">
<script type="text/javascript" src="jquery.min.js"></script>
<script type="text/javascript" defer src="canvas.js"></script>
<script type="text/javascript" defer src="newTab.js"></script>
<style>
html,
body,
.warp {
height: 100%;
width: 100%;
position: relative;
margin: 0;
}
canvas {
position: absolute;
}
.search {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
height: 50px;
display: flex;
}
input {
width: 550px;
height: 40px;
outline: none;
border: 0;
font-size: 18px;
padding: 0 10px;
border-radius: 10px 0 0 10px;
}
button {
width: 100px;
height: 40px;
font-size: 14px;
color: white;
border: 2px solid rgb(97, 126, 243);
border-radius: 0 10px 10px 0;
background-color: rgb(78, 110, 242);
cursor: pointer;
}
</style>
</head>
<body>
<div class="warp">
<canvas id="myCanvas"></canvas>
<div class="search">
<input id="input" type="text" />
<button>百度一下</button>
</div>
</div>
</body>
</html>
新建 newTab.js 文件
var inputValue = document.getElementById('input')
var btn = document.querySelector('button')
if (btn) {
btn.addEventListener('click', function () {
location.href = 'http://www.baidu.com/s?wd=' + inputValue.value
})
}
新建 canvans.js 文件(网上摘录)
let canvas = document.getElementById('myCanvas')
if (canvas) {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
let width = window.innerWidth
let height = window.innerHeight
let ctx = canvas.getContext('2d')
ctx.fillRect(0, 0, width, height)
function random(min, max) {
let num = Math.floor(Math.random() * (max - min) + min)
if (num === 0) {
num = 1
}
return num
}
function randomColor() {
return `rgb(${random(0, 255)},${random(0, 255)},${random(0, 255)})`
}
function Ball(x, y, vx, vy, size, color, line) {
this.x = x
this.y = y
this.vx = vx
this.vy = vy
this.size = size
this.color = color
this.lineColor = line
}
Ball.prototype.draw = function () {
ctx.beginPath()
ctx.fillStyle = this.color
ctx.arc(this.x, this.y, this.size, 0, 2 * Math.PI)
ctx.fill()
}
Ball.prototype.update = function () {
if (this.x + this.size >= width || this.x - this.size <= 0) {
this.vx = -this.vx
}
if (this.y + this.size >= height || this.y - this.size <= 0) {
this.vy = -this.vy
}
this.x += this.vx
this.y += this.vy
}
let list = []
for (let i = 0; i <= 90; i++) {
let circle = new Ball(
random(0, width),
random(0, height),
random(-6, 6) * (1 / 3.0),
random(-6, 6) * (1 / 3.0),
3,
'rgb(255,255,255)',
`rgba(${random(0, 255)},${random(0, 255)},${random(0, 255)}`
)
list.push(circle)
}
function loopCircle () {
ctx.fillStyle = 'rgba(0,0,0,0.6)'
ctx.fillRect(0, 0, width, height)
for (let i = 0; i < list.length; i++) {
for (let j = 0; j < list.length; j++) {
let lx = list[j].x - list[i].x
let ly = list[j].y - list[i].y
let LL = Math.sqrt(Math.pow(lx, 2) + Math.pow(ly, 2))
if (LL <= 180) {
ctx.beginPath()
ctx.strokeStyle = `${list[i].lineColor},${(180 - LL) / 180})`
ctx.moveTo(list[i].x, list[i].y)
ctx.lineWidth = 1
ctx.lineTo(list[j].x, list[j].y)
ctx.stroke()
}
}
list[i].draw()
list[i].update()
}
requestAnimationFrame(loopCircle)
}
loopCircle()
}
完成以后,把 chrome_new_tab 文件夹添加到 Chrome 扩展程序中

3 Vue Devtools 插件
3.1 使用插件
// 先创建一个 vue3 项目,选择 vue、vue-ts
npm create vite-template
// 创建完成后,安装运行
npm i && npm run dev
浏览器打开控制台,可以看到调试界面 Vue 栏,点击下图所示图标,即可在 vscode 中打开项目中的组件
需要注意的是,首次使用的话,点击图标没有反应,我们返回 vscode 可以看到有报错提示,提示 code 指令失败
原因是打开组件是需要安装 code 指令,通过 code 指令控制 vscode 打开对应组件,那么我们安装一下 code 指令,在 mac 中,通过快捷键,
command + shift + p 打开快捷栏,输入 code ,安装成功后,即可通过点击图标打开对应的组件
3.1 vue-devtools 源码分析
带着疑问出发,为什么点击图标可以跳转到 vscode 对应的组件? 我们先克隆 vue-devtools 源码,从源码中找答案
git clone https://github.com/vuejs/devtools/tree/add-remote-devtools
打开 vue-devtools 源码项目,茫茫代码中,怎么找到点击图标对应的功能在什么位置,这时候我们注意到鼠标放到图标是有 tooltip 提示语的,而且是有规律的 open * in editor ,那么我们可以在项目中匹配正则搜索,从而快速找到源码对应的位置
接着搜索
ComponentInspector.openInEditor.tooltip,这时候,我们找了 button 标签,并且是有点击事件 openFile(),"打开文件" 看起来是有这个味道了,那么我们接着查看点击事件
openFile 点击事件很简单,如果存在文件路径则调用了openInEditor函数
function openFile () {
if (!data.value) return
openInEditor(data.value.file)
}
接着在工具函数packages/shared-utils/src/util中,找到了函数openInEditor,代码比较简单,简单分析一下,SharedData.openInEditorHost 默认为 /,也就是发送了一个携带文件路径的请求 /__open-in-editor?file=xxx
export function openInEditor (file) {
// Console display
const fileName = file.replace(/\\/g, '\\\\')
const src = `fetch('${SharedData.openInEditorHost}__open-in-editor?file=${encodeURI(file)}').then(response => {
if (response.ok) {
console.log('File ${fileName} opened in editor')
} else {
const msg = 'Opening component ${fileName} failed'
const target = typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : {}
if (target.__VUE_DEVTOOLS_TOAST__) {
target.__VUE_DEVTOOLS_TOAST__(msg, 'error')
} else {
console.log('%c' + msg, 'color:red')
}
console.log('Check the setup of your project, see https://devtools.vuejs.org/guide/open-in-editor.html')
}
})`
if (isChrome) {
target.chrome.devtools.inspectedWindow.eval(src)
} else {
// eslint-disable-next-line no-eval
eval(src)
}
}
我们可以在控制台点击图标后,在 Network 看到浏览器发送此请求
也就是说,点击图标打开 vscode 对应的组件,vue-devtools 发送了一个
/__open-in-editor请求,那么可以猜测本地有监听/__open-in-editor的方法,在监听方法里面实现了打开组件
3.2 vite-template 源码分析
我们需要在工程中找到监听/__open-in-editor的位置
回到 vite-template 工程中,拓展一下知识,当我们执行 npm run dev,实质上是执行了 vite
但是在命令面板中输入 vite,提示 vite 命令是不存在的,那么为何在工程中 vite 可以执行?
其实是在安装依赖npm install,npm 通过读取 vite 中的 package.json 的 bin,把文件加入到 path 中,并在node_modules/.bin下创建好 vite 的软连接,这样就可以作为命令运行工程
如果是全局安装依赖,就会把 bin 文件添加到全局命令中
扯远了,回到正题,我们在依赖包中,根据 vite 可执行文件,找到监听
/__open-in-editor方法的 js 文件
3.2.1 launchEditorMiddleware 函数
在创建服务createServer函数中执行launchEditorMiddleware方法
async function createServer(inlineConfig = {}) {
// ...
const middlewares = connect();
// open in editor support
middlewares.use('/__open-in-editor', launchEditorMiddleware());
// ...
}
// 中间件方法
var launchEditorMiddleware = (specifiedEditor, srcRoot, onErrorCallback) => {
// 参数交换,目的是为了不限制参数的位置
// 根据参数的类型来判断,确定参数传给指定的虚参
if (typeof specifiedEditor === 'function') {
onErrorCallback = specifiedEditor;
specifiedEditor = undefined;
}
if (typeof srcRoot === 'function') {
onErrorCallback = srcRoot;
srcRoot = undefined;
}
// /Users/user/study/soundCode/vite-template
// 获取node执行的当前工作目录
srcRoot = srcRoot || process.cwd();
// 返回中间件函数
return function launchEditorMiddleware (req, res, next) {
// url.parse 第一个参数为 Url 参数,第二个参数默认为 false ,为true 的话 query 属性会生成一个对象
// req.url= /?file=/Users/user/study/soundCode/vite-template/src/components/HelloWorld.vue
// file = /Users/user/study/soundCode/vite-template/src/components/HelloWorld.vue
const { file } = url$2.parse(req.url, true).query || {};
if (!file) {
res.statusCode = 500;
res.end(`launch-editor-middleware: required query param "file" is missing.`);
} else {
// path.resolve() 生成绝对路径
// launch('/Users/huanghaojie/study/soundCode/vite-template/src/components/HelloWorld.vue', undefined, undefined)
launch(path$4.resolve(srcRoot, file), specifiedEditor, onErrorCallback);
res.end();
}
}
};
3.2.2 launchEditor 函数
可以看出,当调用函数的时候,进程执行childProces.spawn(code, ['-r', '-g', '/Users/user/study/soundCode/vite-template/src/components/HelloWorld.vue:13:20'], { stdio: 'inherit' }) 的方式来打开编辑器对应的组件,核心点是 vsCode 支持使用命令 code ./文件路径 打开文件夹
var launchEditor_1 = launchEditor;
const launch = launchEditor_1;
// 启动编辑器
function launchEditor (file, specifiedEditor, onErrorCallback) {
// 解析文件,返回文件文件路径、行、列
const parsed = parseFile(file);
// 文件路径
let { fileName } = parsed;
// 文件行、文件列
const { lineNumber, columnNumber } = parsed;
// 判断 fileName 路径是否存在
if (!fs$6.existsSync(fileName)) {
return
}
// specifiedEditor 值为 undefined,不执行
if (typeof specifiedEditor === 'function') {
onErrorCallback = specifiedEditor;
specifiedEditor = undefined;
}
// 错误信息提示函数
onErrorCallback = wrapErrorCallback(onErrorCallback);
// 获取当前编辑器环境函数 specifiedEditor 值为 undefined
// 返回['code'] editor = 'code'
const [editor, ...args] = guessEditor(specifiedEditor);
if (!editor) {
onErrorCallback(fileName, null);
return
}
if (
process.platform === 'linux' &&
fileName.startsWith('/mnt/') &&
/Microsoft/i.test(os$1.release())
) {
// Assume WSL / "Bash on Ubuntu on Windows" is being used, and
// that the file exists on the Windows file system.
// `os.release()` is "4.4.0-43-Microsoft" in the current release
// build of WSL, see: https://github.com/Microsoft/BashOnWindows/issues/423#issuecomment-221627364
// When a Windows editor is specified, interop functionality can
// handle the path translation, but only if a relative path is used.
fileName = path$5.relative('', fileName);
}
// 如果有行数
if (lineNumber) {
// 获取额外的参数
// ['-r','-g','/Users/user/soundCode/vite-template/src/components/HelloWorld.vue:13:20']
const extraArgs = getArgumentsForPosition(editor, fileName, lineNumber, columnNumber);
args.push.apply(args, extraArgs);
} else {
args.push(fileName);
}
// 存在进程 && Linus中特定的编辑器 则 kill 掉进程
if (_childProcess && isTerminalEditor(editor)) {
// There's an existing editor process already and it's attached
// to the terminal, so go kill it. Otherwise two separate editor
// instances attach to the stdin/stdout which gets confusing.
_childProcess.kill('SIGKILL');
}
// window 系统
if (process.platform === 'win32') {
// On Windows, launch the editor in a shell because spawn can only
// launch .exe files.
_childProcess = childProcess$1.spawn(
'cmd.exe',
['/C', editor].concat(args),
{ stdio: 'inherit' }
);
// 其他系统
} else {
console.log(editor, args, { stdio: 'inherit' })
// 执行 code 命令打开对应的组件
_childProcess = childProcess$1.spawn(editor, args, { stdio: 'inherit' });
}
// 进程结束,重置
_childProcess.on('exit', function (errorCode) {
_childProcess = null;
if (errorCode) {
onErrorCallback(fileName, '(code ' + errorCode + ')');
}
});
_childProcess.on('error', function (error) {
onErrorCallback(fileName, error.message);
});
}
3.2.3 parseFile 函数
parseFile方法,目的是为了解析出打开文件的行和列,我们可以把 file 文件后天剑行和列,将会发现打开组件的同事,鼠标光标也停留在了行和列对应的位置
const positionRE = /:(\d+)(:(\d+))?$/;
function parseFile (file) {
const fileName = file.replace(positionRE, '');
const match = file.match(positionRE);
const lineNumber = match && match[1];
const columnNumber = match && match[3];
return {
fileName,
lineNumber,
columnNumber
}
}
例如执行 launch('/Users/huanghaojie/study/soundCode/vite-template/src/components/HelloWorld.vue:13:20'),鼠标位置自动定位到对应的位置
3.2.4 wrapErrorCallback 函数
在未安装 code 指令时,vscode 的错误信息就是从此函数打印,可以在 3.1 使用插件中看到
function wrapErrorCallback (cb) {
return (fileName, errorMessage) => {
console.log();
console.log(
colors.red('Could not open ' + path$5.basename(fileName) + ' in the editor.')
);
if (errorMessage) {
if (errorMessage[errorMessage.length - 1] !== '.') {
errorMessage += '.';
}
console.log(
colors.red('The editor process exited with an error: ' + errorMessage)
);
}
console.log();
if (cb) cb(fileName, errorMessage);
}
}
3.2.5 guessEditor 函数
根据获取当前系统的进程,判断出具体的编译器所需的 value,例如 vscode 的命令为 code
var guess = function guessEditor (specifiedEditor) {
if (specifiedEditor) {
return shellQuote.parse(specifiedEditor)
}
// We can find out which editor is currently running by:
// `ps x` on macOS and Linux
// `Get-Process` on Windows
try {
// mac 系统
if (process.platform === 'darwin') {
// 获取当前系统所有程序的进程,ps 查看当前系统进程,x 获取所有程序
const output = childProcess$2.execSync('ps x').toString();
// 获取编辑器进程 key
const processNames = Object.keys(COMMON_EDITORS_OSX);
// 编辑器进程和系统进程匹配,匹配到编辑器环境所在的 value
// '/Applications/Visual Studio Code.app/Contents/MacOS/Electron': 'code'
for (let i = 0; i < processNames.length; i++) {
const processName = processNames[i];
if (output.indexOf(processName) !== -1) {
// 返回 code
return [COMMON_EDITORS_OSX[processName]]
}
}
// window 系统
} else if (process.platform === 'win32') {
const output = childProcess$2
.execSync('powershell -Command "Get-Process | Select-Object Path"', {
stdio: ['pipe', 'pipe', 'ignore']
})
.toString();
const runningProcesses = output.split('\r\n');
for (let i = 0; i < runningProcesses.length; i++) {
// `Get-Process` sometimes returns empty lines
if (!runningProcesses[i]) {
continue
}
const fullProcessPath = runningProcesses[i].trim();
const shortProcessName = path$7.basename(fullProcessPath);
if (COMMON_EDITORS_WIN.indexOf(shortProcessName) !== -1) {
return [fullProcessPath]
}
}
// linux 系统
} else if (process.platform === 'linux') {
// --no-heading No header line
// x List all processes owned by you
// -o comm Need only names column
const output = childProcess$2
.execSync('ps x --no-heading -o comm --sort=comm')
.toString();
const processNames = Object.keys(COMMON_EDITORS_LINUX);
for (let i = 0; i < processNames.length; i++) {
const processName = processNames[i];
if (output.indexOf(processName) !== -1) {
return [COMMON_EDITORS_LINUX[processName]]
}
}
}
} catch (error) {
// Ignore...
}
// Last resort, use old skool env vars
if (process.env.VISUAL) {
return [process.env.VISUAL]
} else if (process.env.EDITOR) {
return [process.env.EDITOR]
}
return [null]
};
const COMMON_EDITORS_OSX = osx;
const COMMON_EDITORS_LINUX = linux;
const COMMON_EDITORS_WIN = windows$1;
var osx = {
'/Applications/Atom.app/Contents/MacOS/Atom': 'atom',
'/Applications/Atom Beta.app/Contents/MacOS/Atom Beta':
'/Applications/Atom Beta.app/Contents/MacOS/Atom Beta',
'/Applications/Brackets.app/Contents/MacOS/Brackets': 'brackets',
'/Applications/Sublime Text.app/Contents/MacOS/Sublime Text':
'/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl',
'/Applications/Sublime Text.app/Contents/MacOS/sublime_text':
'/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl',
'/Applications/Sublime Text 2.app/Contents/MacOS/Sublime Text 2':
'/Applications/Sublime Text 2.app/Contents/SharedSupport/bin/subl',
'/Applications/Sublime Text Dev.app/Contents/MacOS/Sublime Text':
'/Applications/Sublime Text Dev.app/Contents/SharedSupport/bin/subl',
'/Applications/Visual Studio Code.app/Contents/MacOS/Electron': 'code',
'/Applications/Visual Studio Code - Insiders.app/Contents/MacOS/Electron':
'code-insiders',
'/Applications/AppCode.app/Contents/MacOS/appcode':
'/Applications/AppCode.app/Contents/MacOS/appcode',
'/Applications/CLion.app/Contents/MacOS/clion':
'/Applications/CLion.app/Contents/MacOS/clion',
'/Applications/IntelliJ IDEA.app/Contents/MacOS/idea':
'/Applications/IntelliJ IDEA.app/Contents/MacOS/idea',
'/Applications/PhpStorm.app/Contents/MacOS/phpstorm':
'/Applications/PhpStorm.app/Contents/MacOS/phpstorm',
'/Applications/PyCharm.app/Contents/MacOS/pycharm':
'/Applications/PyCharm.app/Contents/MacOS/pycharm',
'/Applications/PyCharm CE.app/Contents/MacOS/pycharm':
'/Applications/PyCharm CE.app/Contents/MacOS/pycharm',
'/Applications/RubyMine.app/Contents/MacOS/rubymine':
'/Applications/RubyMine.app/Contents/MacOS/rubymine',
'/Applications/WebStorm.app/Contents/MacOS/webstorm':
'/Applications/WebStorm.app/Contents/MacOS/webstorm'
};
var linux = {
atom: 'atom',
Brackets: 'brackets',
code: 'code',
emacs: 'emacs',
'idea.sh': 'idea',
'phpstorm.sh': 'phpstorm',
'pycharm.sh': 'pycharm',
'rubymine.sh': 'rubymine',
sublime_text: 'subl',
vim: 'vim',
'webstorm.sh': 'webstorm'
};
var windows$1 = [
'Brackets.exe',
'Code.exe',
'atom.exe',
'sublime_text.exe',
'notepad++.exe',
'clion.exe',
'clion64.exe',
'idea.exe',
'idea64.exe',
'phpstorm.exe',
'phpstorm64.exe',
'pycharm.exe',
'pycharm64.exe',
'rubymine.exe',
'rubymine64.exe',
'webstorm.exe',
'webstorm64.exe'
];
3.2.6 getArgumentsForPosition 函数
将行、列转为编辑器的参数,当前例子中,返回['-r', '-g', '/Users/user/soundCode/vite-template/src/components/HelloWorld.vue:13:20']
// normalize file/line numbers into command line args for specific editors
var getArgs = function getArgumentsForPosition (
editor,
fileName,
lineNumber,
columnNumber = 1
) {
const editorBasename = path$6.basename(editor).replace(/\.(exe|cmd|bat)$/i, '');
switch (editorBasename) {
case 'atom':
case 'Atom':
case 'Atom Beta':
case 'subl':
case 'sublime':
case 'sublime_text':
case 'wstorm':
case 'charm':
return [`${fileName}:${lineNumber}:${columnNumber}`]
case 'notepad++':
return ['-n' + lineNumber, fileName]
case 'vim':
case 'mvim':
return [`+call cursor(${lineNumber}, ${columnNumber})`, fileName]
case 'joe':
return ['+' + `${lineNumber}`, fileName]
case 'emacs':
case 'emacsclient':
return [`+${lineNumber}:${columnNumber}`, fileName]
case 'rmate':
case 'mate':
case 'mine':
return ['--line', lineNumber, fileName]
case 'code':
case 'code-insiders':
case 'Code':
return ['-r', '-g', `${fileName}:${lineNumber}:${columnNumber}`]
case 'appcode':
case 'clion':
case 'clion64':
case 'idea':
case 'idea64':
case 'phpstorm':
case 'phpstorm64':
case 'pycharm':
case 'pycharm64':
case 'rubymine':
case 'rubymine64':
case 'webstorm':
case 'webstorm64':
return ['--line', lineNumber, fileName]
}
// For all others, drop the lineNumber until we have
// a mapping above, since providing the lineNumber incorrectly
// can result in errors or confusing behavior.
return [fileName]
};
3.2.7 isTerminalEditor 函数
判断是否在 Linux 中特定的编辑器中执行
function isTerminalEditor (editor) {
switch (editor) {
case 'vim':
case 'emacs':
case 'nano':
return true
}
return false
}
4 总结
通过阅读 vue-devtools、vite 源码,捋顺了整个流程,带着疑问出发,感觉更有针对性,阅读源码的过程也更顺利
通过安装 Chrome 插件,延伸到实现一个简单的插件,虽然只是稍微触碰,但是也能一叶知秋
比较遗憾的是,vue-devtools 源码无法 run 起来,提示@devtools/xxx 不存在,在 add-remote-devtools 分支倒是可以 run 起来,但是代码比较旧了,缺失了点击图标打开组件能力,这也是本次学习的遗憾