vue-devtools自动打开文件原理解析

260 阅读7分钟

vue-devtool这个工具大家应该都不陌生, 是协助我们进行vue项目开发的, 此处主要介绍这个插件的自动开启文件的功能

大体介绍

点击右下角的图标,即可开启对应的文件, 这样很有利于我们迅速定位问题, 找到代码.本文主要针对这个功能, 从源码角度, 探索其原理.

image.png

安装

首先,我们需要安装vue-devtool插件 点开浏览器扩展程序菜单

将下载好的vue-devtool插件拖拽进来

页面调试

打开调试界面, 选中Vue, 点击查看页面的标识:

点击图标的时候, 会发送一条请求/__open-in-editor?file=path/to/file.vue, 然后我们本地起的node服务会监听该请求, 并作出相应的处理. 打开文件的功能也是因此而触发的.

如何搜索node_modules中的文件?

既然我们知道了, 当我们点击查看文件图标的时候, 是发送了一条请求/__open-in-editor, 那么我们是否可以在node_modules文件夹当中搜索这条请求呢? 这里以vscode为例, 我们都知道, 一般情况下, vscode是不会去搜索node_modules下的文件的,所以我们可以找到搜索下下方的这个齿轮, 从提示可以看出, 它是让exclude配置生效, 所以只要将其关闭即可

所谓的exclude配置, 其实也就是在首选项-> Features -> Search -> Exclude 中, 我们也可以删除掉**/node_modules, 当然,并不建议这么做,毕竟我们正常开发的时候并不需要去搜索node_modules下的内容!

这样, 我们就找到了相关的文件了,接下来, 我们来进行断点调试吧

serve.js

首先我们进入源码,找到serve.js文件, 省略无关的上下文,我们可以看到起作用的实际上是launchEditorMiddleware方法, 而launchEditorMiddleware其实就是launch-editor-middleware这个nodejs包

...省略
const launchEditorMiddleware = require('launch-editor-middleware')
...省略
// 193行
before (app, server) {
  // launch editor support.
  // this works with vue-devtools & @vue/cli-overlay
  app.use('/__open-in-editor', launchEditorMiddleware(() => console.log(
    `To specify an editor, specify the EDITOR env variable or ` +
    `add "editor" field to your Vue project config.\n`
  )))
  ....省略
}
...省略

launch-editor-middleware

打开源码,我们可以看到, 该node包本质上返回了一个函数, 我们很容易总结这部分代码主要做了几件事:

  1. 检查第1参数(specifiedEditor)第2个参数(srcRoot)是否为函数, 如果是函数, 那么就将其赋给第三个参数,统一做错误处理!这是一个非常常见的node错误处理方式
  2. 检查请求的文件是否存在, 如果不存在, 返回500错误, 请求就此终止! 如果存在, 则继续调用launch方法, 我们可以看到, launch又是来自launch-editor这个包, 所以我们继续顺藤摸瓜, 找到launch-editor的源码
const url = require('url')
const path = require('path')
const launch = require('launch-editor')

// 注意: module.exports的这个函数只在初始化的时候执行一次
module.exports = (specifiedEditor, srcRoot, onErrorCallback) => {

  /**
   * specifiedEditor就是
   * () => console.log(
      `To specify an editor, specify the EDITOR env variable or ` +
      `add "editor" field to your Vue project config.\n`
    ))
   */
  // 参数切换, 非常常见的操作方式
  if (typeof specifiedEditor === 'function') {
    onErrorCallback = specifiedEditor
    // 将第一个参数置为undefined
    specifiedEditor = undefined
  }
  if (typeof srcRoot === 'function') {
    onErrorCallback = srcRoot
    srcRoot = undefined
  }
  // /Users/wangy/IT/lcsy/part-1/open-in-editor/vue3-project
  // 执行命令的地址
  srcRoot = srcRoot || process.cwd()

  // app.use中的函数
  return function launchEditorMiddleware (req, res, next) {
    // 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()
    }
  }
}

launch-editor

在launch-editor/index.js下,我们找到了实际被导出的launchEditor方法, 而这个方法, 可以说就是实现自动打开文件的功能的重点了!

我们将将多余的非主线代码删除后可以清晰地看到其实主要就是两大步骤:

  1. 和之前一样,将函数类型的参数(这里指specifiedEditor), 赋值给最后一个参数, 并统一做错误回调, 注意,这个操作再次出现了!
  2. 获取ide的信息的guessEditor方法, 返回一个可以被该编辑器执行的, 打开制定文件的命令, 此处为code
  1. 找到可执行开启文件命令之后, 通过childProcess.spawn来执行, 并最终打开想要的文件!

补充概念:

child_process, 一个nodejs中非常重要的模块,它可以创建子进程,执行shell脚本

...省略
const childProcess = require('child_process')
...省略
// 中间件执行的launch方法
function launchEditor (file, specifiedEditor, onErrorCallback) {
  // onErrorCallback 此时是() => console.log(xx)
  const parsed = parseFile(file)
  // fileName就是选中组件文件的绝对路径
  let { fileName } = parsed
  // 行列数
  const { lineNumber, columnNumber } = parsed

  // 判断文件是否存在
  if (!fs.existsSync(fileName)) {
    return
  }

  if (typeof specifiedEditor === 'function') {
    onErrorCallback = specifiedEditor
    specifiedEditor = undefined
  }

  // 将报错方法包裹一层, 包裹函数打印了很多错误信息
  onErrorCallback = wrapErrorCallback(onErrorCallback)
  // 猜测编辑器, 此处为重点!!!!!
  const [editor, ...args] = guessEditor(specifiedEditor)
  if (!editor) {
    // onErrorCallback第一个参数是文件的绝对路径, 第二个是
    onErrorCallback(fileName, null)
    return
  }

  if (
    process.platform === 'linux' &&
    fileName.startsWith('/mnt/') &&
    /Microsoft/i.test(os.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.relative('', fileName)
  }
  // 错误的行数?
  if (lineNumber) {
    const extraArgs = getArgumentsForPosition(editor, fileName, lineNumber, columnNumber)
    args.push.apply(args, extraArgs)
  } else {
    args.push(fileName)
  }

  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')
  }

  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' })
  }
  _childProcess.on('exit', function (errorCode) {
    _childProcess = null

    if (errorCode) {
      onErrorCallback(fileName, '(code ' + errorCode + ')')
    }
  })

  _childProcess.on('error', function (error) {
    onErrorCallback(fileName, error.message)
  })
}

整体流程看完了, 我们再来分解具体的步骤, 刚才不是说要获取本编辑器可以执行的命令吗? 那如何获取? 我们得要进入guessEditor一探究竟.

通过阅读源码, 我们也可以得知, 其实它无非就做了几件事:

  1. 通过process.platform, 判断出当前运行的操作系统(mac/windows/linux).
  2. 这对不同的操作系统, 再使用nodejs的child_process模块, 执行不同的shell命令, 来获取当前所有进程, 关于显示指令的shell脚本主要分为以下几种, (大家也可以动手执行下, 看下返回的值, 来帮助理解)
    1. mac os 下为 ps x
    2. windows 下为 powershell -Command "Get-Process | Select-Object Path"
    1. linux 下为 ps x --no-heading -o comm --sort=comm

这里是mac os 上执行ps x 的结果:

  1. 再通过得到的运行的进程 和 COMMON_EDITORS_OSX 对象做匹配, 找出对应的可用的命令, 返回出去

补充概念:

process.platform属性是流程模块的内置应用程序编程接口,用于获取操作系统平台信息。看官网的解释:

module.exports = 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.execSync('ps x').toString()
      // COMMON_EDITORS_OSX实际上是一个对象, 具体内容可以看下方
      const processNames = Object.keys(COMMON_EDITORS_OSX)
      for (let i = 0; i < processNames.length; i++) {
        const processName = processNames[i]
        // 在这里找出对应的命令, 本案例中的code
        if (output.indexOf(processName) !== -1) {
          return [COMMON_EDITORS_OSX[processName]]
        }
      }
      // windows
    } 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')
      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]
        }
      }
      // 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
        .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]
}

以上代码中, COMMON_EDITORS_OSX, 实际上来自于launch-editor/editor-info/osx.js文件, 打开该文件, 我们可以看到, 导出一个对象, 通过COMMON_EDITORS_OSX[processName], 找到对应的命令!

module.exports = {
  '/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 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'
}

遇到的问题

通过以上的介绍, 大体能够了解打开对应文件的原理了, 但是, 有时候说是这么说, 做, 又是另一回事了, 在实际操作中, 我遇到了几个问题

  1. 我明明是mac os 下的vscode ,但是 COMMON_EDITORS_OSX[processName] 无法匹配出我需要的code! 也就是说, 我的code命令的软连接不是 COMMON_EDITORS_OSX中的/Applications/Visual Studio Code.app/Contents/MacOS/Electron, 所以最后使用了vi命令, 将整个文件的内容打印在了终端上而已!

这时候需要设置软连接和环境变量:

// 设置软链接
cd /usr/local/bin/
sudo ln -s "/Applications/Visual Studio Code.app/Contents/MacOS/Electron" code
// 设置环境变量
sudo vi ~/.bash_profile // 需要权限
export PATH=$PATH:/Applications/Visual\ Studio\ Code.app/Contents/Resources/app/bin
  1. 好了, 配置完之后, 发现, 还是打不开.... 于是通过检查发现还要install code一波

就此, 可以愉快地调试了