【第1期】vue-devtools 快捷打开组件源码

1,750 阅读8分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情

摘要

本期源码阅读的主题是 vue-devtools 打开源码功能,这是一个非常实用的技巧,尤其是多人开发的项目,当对代码不熟悉的时候能够直接打开想要查看的组件源码,真的是非常方便,那么接下来就对相关源码进行解读。

下载

首先我们需要安装 vue-devtools,这里为不能用 chrome 直接安装插件的小伙伴提供一个非常好用的网站,可以在里面下载非常多的插件。如何安装 chrome 插件在这里就不赘述了,网上教程很多。

项目搭建

为了调试代码,我们需要搭建一个 vue 项目,这里为了保证和川哥的文章内容一致,直接使用了他搭建的项目

主要包的版本:

webpack: 4.46
vue-cli: 4.5.12
vue: 3.0.11

开始调试

项目 clone install 完毕之后,就可以开始调试源码。

  1. 打开 package .json
  2. 点击 script 上的调试
  3. 选择想要执行的脚本, 这里选择 serve 就好
1658997219320.png

构建完成之后点击项目地址,则会弹出一个专门用于调试的 chrome 窗口,这时候需要在这个新的浏览器界面里也安装一下 vue-devtools

1658997469831.png

F12 后选择 Vue 页签,选择想要进入的组件,在右上角侧可以看到一个跳转按钮,点击后即可在本地编辑器中打开组件代码

1658997744160.png

源码分析

这个功能相关的源码主要有三个文件,下面对其依次进行分析

\node_modules\launch-editor-middleware\index.js

launch-editor-middleware 的作用接受在 vue-devtools 点击页面打开时的网络请求

例如: //localhost:8080/__open-in-editor?file=src/components/HelloWorld.vue

如果携带了请求参数 file, 则执行关键的 lunch 函数

const url = require('url')
const path = require('path')
const launch = require('launch-editor')
​
module.exports = (specifiedEditor, srcRoot, onErrorCallback) => {
  if (typeof specifiedEditor === 'function') {
    onErrorCallback = specifiedEditor
    specifiedEditor = undefined
  }
​
  if (typeof srcRoot === 'function') {
    onErrorCallback = srcRoot
    srcRoot = undefined
  }
​
  srcRoot = srcRoot || process.cwd()
​
  return function launchEditorMiddleware (req, res, next) {
    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()
    }
  }
}

node_modules\launch-editor\index.js

在 launchEditor 函数里实现了如何在编辑器里打开组件文件。

首先封装了一个错误输出函数 wrapErrorCallback, 统一了报错输出格式。

关键代码是执行了 childProcess.spawn,这是 node 的内置方法,开启了一个子进程, 使用 cmd.exe 执行 C:\software\Microsoft VS Code\Code.exe 程序来打开 args 参数中的文件

const fs = require('fs')
const os = require('os')
const path = require('path')
const chalk = require('chalk')
const childProcess = require('child_process')
​
const guessEditor = require('./guess')
const getArgumentsForPosition = require('./get-args')
​
// 程序报错后的回调函数
function wrapErrorCallback (cb) {
  return (fileName, errorMessage) => {
    console.log()
    console.log(
      chalk.red('Could not open ' + path.basename(fileName) + ' in the editor.')
    )
    if (errorMessage) {
      if (errorMessage[errorMessage.length - 1] !== '.') {
        errorMessage += '.'
      }
      console.log(
        chalk.red('The editor process exited with an error: ' + errorMessage)
      )
    }
    console.log()
    if (cb) cb(fileName, errorMessage)
  }
}
...
​
let _childProcess = nullfunction launchEditor (file, specifiedEditor, onErrorCallback) {
  // 解析组件文件相关参数,获取文件名
  const parsed = parseFile(file)
  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(fileName, null)
    return
  }
...
​
  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)
  })
}
​
module.exports = launchEditor

node_modules\launch-editor\guess.js

这块代码的功能就是来猜测你目前使用的是什么编辑器

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 {
    if (process.platform === 'darwin') {
      const output = childProcess.execSync('ps x').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')
      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')
        .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]
}

上述代码还是比较简单的,容易理解

本人遇到的问题

我再调试的过程中遇到了两个问题,花时间研究了下,下面记录一下。

项目路径带空格时,会报错

分别创建了两个项目:

项目路径 C:\Users\zhenlv\Test 成功打开

项目路径 C:\Users\zhenlv\Test 1 报错如图

1658904486757.png 下面开启疯狂调试模式:

  1. 调试到 _childProcess 的 spawnargs 参数对比,也就是空格的不同

1658988743638.png

那么就应该是 childProcess.spawn 这个函数使用的问题了。而这个是 node 源码,目前的方式好像是调试不了,F11 单步调试进不去这个函数。

  1. 参考了node 代码调试 中的介绍, 借助 chrome 进行调试。我把关键代码拿出来,用 node --inspect-brk test.js 的方式来调试
// test.js
const childProcess = require("child_process");
_childProcess = childProcess.spawn(
  "cmd.exe",
  ["/C", "code", "C:\Users\zhenlv\Test 1\src\components\HelloWorld.vue"],
  { stdio: "inherit" }
);
  1. 打开 chrome://inspect/#devices 就可以看到想要调试的代码, 点击 inspect,就打开了 test.js 文件的源码
1659002734964.png

1658991608219.png

1659002811316.png
  1. 继续调试,发现最终执行的是 this._handle.spawn(options); 是 node 创建了个进程 Process 来执行命令。而 Process 不是 js 代码, 而是 C++ 的代码, 参见。调试到这里也没有找到问题出现的根本原因,不过可以肯定是更底层的原因导致的,如果还需要继续研究的话需要换种方式调试 node 源码,所以调试到这里就告一段落。
1659060458463.png 1659060521329.png 1659060639302.png

1659060783123.png

1659060756302.png

  1. 通过问题的检索和查阅, 发现这个 github 上的这个 issue —— child_process.spawn fails on Windows given a space in both the command and an argument #7367,根据大伙提供的方法我也进行了尝试,不过失败了,不知道是哪用错了还是咋滴。

    5.1 尝试使用 cross-spawn

    const spawn = require("cross-spawn");
    _childProcess = spawn(
      "cmd.exe",
      [
        "/C",
        "C:\software\Microsoft VS Code\Code.exe",
        "C:\Users\zhenlv\Test 1\src\components\HelloWorld.vue",
      ],
      { stdio: "inherit" }
    );
    

    5.2 添加 shell : true

    const childProcess = require("child_process");
    _childProcess = childProcess.spawn(
      "cmd.exe",
      [
        "/C",
        "C:\software\Microsoft VS Code\Code.exe",
        "C:\Users\zhenlv\Test 1\src\components\HelloWorld.vue",
      ],
      { stdio: "inherit", shell: true }
    );
    

    很奇怪的是,issue 里有人说是命令,如 C:\software\Microsoft VS Code\Code.exe 和参数,如C:\Users\zhenlv\Test 1\src\components\HelloWorld.vue,中都有空格会报错,而我尝试的结果是只要参数中有空格,就出问题,有些搞不懂了。

    所以又作出以下尝试,VsCode 的命令行指令是 code, 做出如下改变,修改执行的命令C:\software\Microsoft VS Code\Code.exe => code, 此时无论路径参数带不带空格都能正常打开文件。

    const childProcess = require("child_process");
    _childProcess = childProcess.spawn(
      "cmd.exe",
      ["/C", "code", "C:\Users\zhenlv\Test 1\src\components\HelloWorld.vue"],
      { stdio: "inherit" }
    );
    

    那么接下来就是第二个问题,如何将 specifiedEditor 指定为 code

指定 specifiedEditor 时遇到的问题

想要指定 specifiedEditor, 有两个方式,配置环境变量文件、修改 webpack 配置。下面分别讲一下本人在使用两种方式遇到的问题,每个人的电脑环境不同,可能遇到的问题也不同。

配置环境变量
  1. 首先设置 EDITOR

1659062188878.png

  1. 在 guess.js 的代码里使用了该变量
1659062328949.png
  1. 但是在本人的调试过程中发现,我的代码都会进入到如下逻辑,获取到的就是 C:\software\Microsoft VS Code\Code.exe , 而得不到想要的 code
1659062406311.png
  1. 个人粗浅的考虑,是不是可以把环境变量的判断的代码块提前,这样的话如果用户使用的时候指定了,就不用猜了。而且还可以避免路径名称中存在空格带来的问题。
修改 webpack 配置

根据官网说明,修改 vue 项目的 webpack 配置就能指定编辑器。因此创建如下 vue.config.js 文件。

const openInEditor = require("launch-editor-middleware");
module.exports = {
  devServer: {
    before(app) {
      app.use("/__open-in-editor", openInEditor("code"));
    },
  },
};

但是在 vue.config.js 修改了 webpack 的 devServer 配置,但是不生效, 所以又得开启调试大法

  1. 首先找到默认的中间件配置代码。全局搜索 launch-editor-middleware,在 node_modules 里找到对应文件

    node_modules@vue\cli-service\lib\commands\serve.js

1659063514953.png

  1. 调试并阅读源码,从171 行开始是对 server 对象的创建。其中 projectDevServerOptions 是来自项目中对 devServer 的配置,但是从代码中我们可以发现,projectDevServerOptions 的里 before 属性被后面那个对象的 before 属性覆盖了,并且在后面对象的 before 里,最后调用了 projectDevServerOptions 里定义的 before 函数,猜测目的是想用 projectDevServerOptions 里的 before 定义的 app.use("/__open-in-editor", openInEditor("code")) 覆盖掉默认的。但是经过测试发现,这里并没有覆盖掉。

    // create server
    const server = new WebpackDevServer(compiler, Object.assign({
          logLevel: 'silent',
          clientLogLevel: 'silent',
          historyApiFallback: {
            disableDotRule: true,
            rewrites: genHistoryApiFallbackRewrites(options.publicPath, options.pages)
          },
          contentBase: api.resolve('public'),
          watchContentBase: !isProduction,
          hot: !isProduction,
          injectClient: false,
          compress: isProduction,
          publicPath: options.publicPath,
          overlay: isProduction // TODO disable this
            ? false
            : { warnings: false, errors: true }
        //!! 下面这行,projectDevServerOptions 见名知意,是在项目里配置的 devServer 相关信息,上面配置的 before 就在这里,Object.assign 合并后被后面对象中的 before 属性覆盖掉了
        }, projectDevServerOptions, { 
          https: useHttps,
          proxy: proxySettings,
          // eslint-disable-next-line no-shadow
          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`
            )))
            // allow other plugins to register middlewares, e.g. PWA
            api.service.devServerConfigFns.forEach(fn => fn(app, server))
            // apply in project middlewares
            projectDevServerOptions.before && projectDevServerOptions.before(app, server)
          },
          // avoid opening browser
          open: false
        }))
    
  2. 调整了如下两行代码的顺序,那么 specifiedEditor 就能得到指定的值 code

    projectDevServerOptions.before && projectDevServerOptions.before(app, server)
    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`
    )))
    

    然后又做了另外一个测试,将自定义的 before 里的 url 地址 改为/__open-in-editor2,在调试的时候分别访问__open-in-editor?file=src/components/HelloWorld.vue__open-in-editor?file=src/components/HelloWorld.vue ,则都能打开文件

  3. 综上,这里推断可能是因为在 before 里,对一个 url 的监听只能生效一个,并且是声明在前面的生效。等在学习学习 webpack 的知识后在回头研究下

总结收获

本次文章的产生是因为突然发现了 vue-devtools 这个功能,然后觉得很好用很新奇,正好川哥在源码阅读中也有相应的解读,就花了些时间好好研究下。源码其实并不是很难,但是在调试过程中遇到了不少的问题,正是这些问题给源码阅读带来了不少的乐趣。源码其实并不可怕,慢慢来,多看多练也就能掌握。调试遇到问题不要怕,通过查阅、检索、提出猜想、进行测试,才能发现问题产生原因并寻找解决办法,同时也能发现自己知识体系的缺漏。所以能够读到这篇文章的小伙伴都来参加源码阅读吧!

本次阅读有以下几点收获:

  1. 掌握了 vue 项目找源码的便捷方式
  2. 了解了 webpack 中间件
  3. 调试 node 源码的方式
  4. 阅读源码的能力
  5. 发现问题并调研的能力
  6. 项目路径不要带空格!

接下来再尝试构建基于 wepback 5 和 vite 的项目,看看这个打开源码的功能有么有什么问题。

参考文章

zhuanlan.zhihu.com/p/378790297

zhuanlan.zhihu.com/p/156771848