vue-devtools 打开组件分析

1,141 阅读9分钟

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

这是源码共读的第1期,链接:vue-devtools 组件可以打开编辑器

一、学习目标

  • 了解vue-devtools插件是如何在浏览器中直接通过编辑器打开组件;
  • 加深对源码阅读的熟练度,及扩展对源码中优秀代码的认知;
  • 提升用vscode进行代码调试的能力;

二、说明

本文的项目仓库代码取至若川视野于github中的项目文件,读者可自行克隆:

git clone https://github.com/lxchuan12/open-in-editor.git

我们先演示一下所谓的“ vue-devtools插件直接打开组件 ”这个情景。

项目拉下来,安装完依赖后,可以运行项目,该过程描述省略。 我们打开控制台,切换到vue-devtools,点击红圈处,如下:

image.png 可以看到,vscode中对应Helloworld.vue组件被打开了,如下:

image.png

接下来我们就分析这个过程,vue-devtools在其中扮演了什么角色。

三、源码分析

1.断点调试

我们打开项目的package.json,点击调试,下拉框中选择serve,然后系统会默认打开终端执行npm run serve脚本命令启动调试项目。

image.png 现在,我们进入了调试模式(如果中间有让选择环境时,可选择node即可)

image.png

2.launchEditorMiddleware函数分析

全局搜索launch-editor-middleware,找到使用地方serve.js。

image.png

找到调用处,在这里注册了一个中间件,中间件是launchEditorMiddleware函数的返回值,该函数传递了一个回调函数为参数,这个回调函数其实就是后面的错误处理的回调,后面会讲到。

总之,这里是入口处,当监到地址路径为/__open-in-editor时,就会执行中间件函数。当然这个注册过程是项目初始化就执行的,等下重点分析这个实际中间件函数,即launchEditorMiddleware函数的返回值。

image.png

回想之前【说明】中打开组件的过程,打开network,点击按钮打开组件时,会执行http://localhost:8081/__open-in-editor?file=src/components/HelloWorld.vue请求,印证了中间件监听处理__open-in-editor

image.png

3.中间件函数

我们找到launchEditorMiddleware函数导出位置,可以看到就是module.export导出的函数,而下面红圈的返回值函数就是中间件函数。

中间件函数调用了launch函数,这个函数才是真正的关键,我们打个断点,然后执行前面【说明】中通过控制台点击跳转组件的操作,程序就会执行到断点处。

image.png 在分析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函数接收是三个参数,第一个参数为当前目标文件的绝对路径,第二个是编辑器,第三个错误处理函数。

我们跟随断点进入该函数内部。

image.png

我们对其中的代码逐条分析。

(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函数就是解析并提取文件路径、行号、列号的。

image.png

继续。

(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函数当前在我们这个例子中不会执行,因此我们文件地址没有携带行号和列号,但是我们还是简单看下代码。

可以看到其实是根据编辑器类别,去打开目标文件,添加指定行和列时所需的的终端命令参数,并拼接好返回。

image.png

(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插件的使用;
  • ......