本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
这是源码共读的第1期,链接:vue-devtools 组件可以打开编辑器。
一、学习目标
- 了解
vue-devtools插件是如何在浏览器中直接通过编辑器打开组件; - 加深对源码阅读的熟练度,及扩展对源码中优秀代码的认知;
- 提升用vscode进行代码调试的能力;
二、说明
本文的项目仓库代码取至若川视野于github中的项目文件,读者可自行克隆:
git clone https://github.com/lxchuan12/open-in-editor.git
我们先演示一下所谓的“ vue-devtools插件直接打开组件 ”这个情景。
项目拉下来,安装完依赖后,可以运行项目,该过程描述省略。
我们打开控制台,切换到vue-devtools,点击红圈处,如下:
可以看到,vscode中对应
Helloworld.vue组件被打开了,如下:
接下来我们就分析这个过程,vue-devtools在其中扮演了什么角色。
三、源码分析
1.断点调试
我们打开项目的package.json,点击调试,下拉框中选择serve,然后系统会默认打开终端执行npm run serve脚本命令启动调试项目。
现在,我们进入了调试模式(如果中间有让选择环境时,可选择node即可)
2.launchEditorMiddleware函数分析
全局搜索launch-editor-middleware,找到使用地方serve.js。
找到调用处,在这里注册了一个中间件,中间件是launchEditorMiddleware函数的返回值,该函数传递了一个回调函数为参数,这个回调函数其实就是后面的错误处理的回调,后面会讲到。
总之,这里是入口处,当监到地址路径为/__open-in-editor时,就会执行中间件函数。当然这个注册过程是项目初始化就执行的,等下重点分析这个实际中间件函数,即launchEditorMiddleware函数的返回值。
回想之前【说明】中打开组件的过程,打开network,点击按钮打开组件时,会执行http://localhost:8081/__open-in-editor?file=src/components/HelloWorld.vue请求,印证了中间件监听处理__open-in-editor。
3.中间件函数
我们找到launchEditorMiddleware函数导出位置,可以看到就是module.export导出的函数,而下面红圈的返回值函数就是中间件函数。
中间件函数调用了launch函数,这个函数才是真正的关键,我们打个断点,然后执行前面【说明】中通过控制台点击跳转组件的操作,程序就会执行到断点处。
在分析launch函数之前,我们先分析下其余代码。
// 参数交换
if (typeof specifiedEditor === 'function') {
onErrorCallback = specifiedEditor
specifiedEditor = undefined
}
// 参数交换
if (typeof srcRoot === 'function') {
onErrorCallback = srcRoot
srcRoot = undefined
}
// 取用户传的地址 或 当前node进程的地址
srcRoot = srcRoot || process.cwd()
// 中间件函数
return function launchEditorMiddleware (req, res, next) {
// 获取并使用node的内置插件url处理url参数,得到文件地址,该例子中file为src/components/HelloWorld.vue
const { file } = url.parse(req.url, true).query || {}
// 判断文件不存在则结束
if (!file) {
res.statusCode = 500
res.end(`launch-editor-middleware: required query param "file" is missing.`)
} else {
// 关键执行函数
launch(path.resolve(srcRoot, file), specifiedEditor, onErrorCallback)
res.end()
}
这里其实用了参数切换,我们可以看到launchEditorMiddleware函数接受了三个虚参,但实际用户调用可能就想传一个参数,而且是传给第二个虚参的,这个时候就可以根据第一个参数的参数类型做判断,来确认用户传的这个参数其实是给指定的虚参的。
比如这里如果第一个参数specifiedEditor如果是函数,则表示用户其实想赋值给onErrorCallback的,则切换参数。
4.launch函数
launch函数接收是三个参数,第一个参数为当前目标文件的绝对路径,第二个是编辑器,第三个错误处理函数。
我们跟随断点进入该函数内部。
我们对其中的代码逐条分析。
(1)parseFile函数
// 对file文件路径处理:
const parsed = parseFile(file)
let { fileName } = parsed
const { lineNumber, columnNumber } = parsed
parseFile方法其实是对传进来的文件的绝对路径进行处理的,该方法代码如下:
const positionRE = /:(\d+)(:(\d+))?$/
function parseFile (file) {
const fileName = file.replace(positionRE, '')
// 此处match匹配和正则的exce匹配差不多,拿到的数组元素是正则中分组的数据
const match = file.match(positionRE)
const lineNumber = match && match[1]
const columnNumber = match && match[3]
return {
fileName,
lineNumber,
columnNumber
}
}
虽然我们这里的测试例子中file为src/components/HelloWorld.vue,但是他其实是可以有行号列号的,附上尤大launch-editor插件的github上例子,可以看到,行号和列号是可选的。而这个parseFile函数就是解析并提取文件路径、行号、列号的。
继续。
(2)wrapErrorCallback函数
// 同步判断当前目标文件是否存在
if (!fs.existsSync(fileName)) {
return
}
// 依旧交换参数
if (typeof specifiedEditor === 'function') {
onErrorCallback = specifiedEditor
specifiedEditor = undefined
}
// 对onErrorCallback错误提示参数进行二次包装:
onErrorCallback = wrapErrorCallback(onErrorCallback)
......
wrapErrorCallback函数是对我们传递的错误处理函数进行二次封装,在该函数中加了一些额外的错误信息的日志输出,最后执行错误回调,如下:
// 包装onErrorCallback错误提示的函数:对错误提示加了更多的log日志输出
function wrapErrorCallback (cb) {
return (fileName, errorMessage) => {
console.log()
console.log(
colors.red('Could not open ' + path.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)guessEditor函数
// 获取编辑器
const [editor, ...args] = guessEditor(specifiedEditor)
if (!editor) {
onErrorCallback(fileName, null)
return
}
该方法主要是为了获取当前的编辑器。 方法代码如下,可以看到该方法代码很长,我们总结一下即可:
- 如果指定了编辑器,则直接返回该编辑器,否则继续;
- 根据不同的系统执行不同的终端命令获取当前运行的所有进程信息runningProcesses;
- 然后遍历每一个进程信息和设置的该系统的多个编辑器常量匹配,看是否属于其中一个(断点调试时鼠标悬浮可以看到数组信息,因为当前我是运行在vscode上,最后会匹配到Code.exe,但是发现该元素索引处于34多,不可能步进调试循环几十次,因此可以点击左侧调试菜单中的监视器,新增监视器i=33,再步进就会直接到33了)
- 获取到对应的编辑器后返回;
- 因此上面的editor就是编辑器;
function guessEditor (specifiedEditor) {
// 有指定编译器,则返回
if (specifiedEditor) {
return shellQuote.parse(specifiedEditor)
}
if (process.versions.webcontainer) {
return [process.env.EDITOR || 'code']
}
// We can find out which editor is currently running by:
// `ps x` on macOS and Linux
// `Get-Process` on Windows
try {
// 根据不同的系统来创建子进程执行对应的shell命令获取当前所有的进程信息
if (process.platform === 'darwin') {
const output = childProcess
.execSync('ps x', {
stdio: ['pipe', 'pipe', 'ignore']
})
.toString()
const processNames = Object.keys(COMMON_EDITORS_OSX)
for (let i = 0; i < processNames.length; i++) {
const processName = processNames[i]
if (output.indexOf(processName) !== -1) {
return [COMMON_EDITORS_OSX[processName]]
}
}
} else if (process.platform === 'win32') {
const output = childProcess
.execSync('powershell -Command "Get-Process | Select-Object Path"', {
stdio: ['pipe', 'pipe', 'ignore']
})
.toString()
// 获取到的是字符串形式,截取成数组
const runningProcesses = output.split('\r\n')
// 遍历进程信息的数组,断点调试时可以设置监视表达式指定执行到对应i的值处
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.basename(fullProcessPath)
//获取到的进程最后的文件名和该系统预写的编辑器匹配,匹配上就返回
if (COMMON_EDITORS_WIN.indexOf(shortProcessName) !== -1) {
return [fullProcessPath]
}
}
} 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
.execSync('ps x --no-heading -o comm --sort=comm', {
stdio: ['pipe', 'pipe', 'ignore']
})
.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]
}
(4)getArgumentsForPosition函数
if (lineNumber) {
// 处理带有行号和列号的文件路径
const extraArgs = getArgumentsForPosition(editor, fileName, lineNumber, columnNumber)
args.push.apply(args, extraArgs)
} else {
args.push(fileName)
}
getArgumentsForPosition函数当前在我们这个例子中不会执行,因此我们文件地址没有携带行号和列号,但是我们还是简单看下代码。
可以看到其实是根据编辑器类别,去打开目标文件,添加指定行和列时所需的的终端命令参数,并拼接好返回。
(5)关于子进程child_process
child_process是node中用于创建子进程的内置模块,因为我们要打开指定文件,就需要新增一个子进程去执行。
// 为了方便阅读,这里直接把isTerminalEditor函数内容插在前面
...
// 查看当前编辑器是否属于这些
function isTerminalEditor (editor) {
switch (editor) {
case 'vim':
case 'emacs':
case 'nano':
return true
}
return false
}
...
// 当已经用子进程去指定编辑器打开文件,如果打开文件的进程还没结束,又再次点击打开文件,则先销毁之前的进程
//言外之意就是你点击打开组件,但是组件还没打开完成,你再次点击打开,自然取消之前的啊,这不就是防抖吗???
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')
}
// 根据不同的系统使用不同方式通过子进程执行编辑器打开目标文件,使用stdio: 'inherit'让子进程输入输出按主进程标准,即输出信息会在主进程看到
if (process.platform === 'win32') {
// On Windows, launch the editor in a shell because spawn can only
// launch .exe files.
_childProcess = childProcess.spawn(
'cmd.exe',
['/C', editor].concat(args),
{ stdio: 'inherit' }
)
} else {
_childProcess = childProcess.spawn(editor, args, { stdio: 'inherit' })
}
// 监听子进程退出:子进程结束时置为null
_childProcess.on('exit', function (errorCode) {
_childProcess = null
if (errorCode) {
onErrorCallback(fileName, '(code ' + errorCode + ')')
}
})
_childProcess.on('error', function (error) {
onErrorCallback(fileName, error.message)
})
以上就是关于vue-devtools插件直接打开组件的实现分析,由于后面是采用贴代码逐步分析各个函数的,建议读者阅读时自己本地先打开对应代码页面,这样阅读会直观点。
四、总结和心得
1.心得
其实这一期的阅读于我而言还是很有难度的,毕竟作为一个源码阅读不多的人来说,还是有挑战性的。其次,因为这个实现过程涉及到了很多的知识点,像js高级语法操作、nodejs、以及一些操作系统的内容,尽管花了几天的空闲时间,把整个过程啃了一遍,有些知识盲区还是难以克服,只能说混个眼熟,待有一天水平上来了,再回顾下,大概就能理得清楚了。
2.收获
- 提高了对源码阅读的熟练程度;
- 加强了对调试的使用程度,毕竟今天会监视按条件断点了;
- 重新查阅了一些nodejs的知识点,如path几个方法、child_process子进程等,也算了加深了印象;
- 理解了参数交换的场景;
- 掌握了vscode中BookMarks插件的使用;
- ......