忙里偷闲逛了下,看到一个有意思的工具:react-dev-inspector,点击页面元素在编辑器打开对应代码
详细介绍文档可见 我点了页面上的元素,VSCode 乖乖打开了对应的组件?原理揭秘,对于大型项目的开发维护,这样的调试神器还挺不错。
原理也很巧妙,主要有两个问题需要解决:
- 怎样知道点击的元素是哪个组件,对应的文件以及行列?
- 编辑器怎样打开对应的文件,并跳转到对应的行列?
第一个问题,工具的作者利用webpack loader,在webpack构建时,操作生成的AST,给每个元素添加特殊属性表示行列和文件路径,在点击元素时就可以知道对应的文件和行列信息。
第二个问题,获取到点击元素的信息后,怎样用编辑器打开相应的文件呢?这里作者的方法:借助 fetch 发送了一个请求到本机的服务端,利用服务端执行脚本命令如 code src/Inspector/index.ts
这样的命令来打开 VSCode。借助react-dev-utils实现,监听一个特殊的url fetch(‘/__open-stack-frame-in-editor?fileName=xxx’)。
翻了下文章的评论区,有同学提到vue-devtools本身就支持这个功能了,那必须去一探究竟啊。
安装完vue-devtools后,打开一个vue项目,在Components标签页,选中某个组件,右上角确实有个open in editor的图标,点击,可惜我本地没能打开编辑器和对应的文件,控制台有打印”File src/common/selectComponent.vue opened in editor”,这个按下不表,来看看vue里大概是怎么做的。
我们发现在vue源码的flushSchedulerQueue方法中有这么一段代码:
// devtool hook
/* istanbul ignore if */
if (devtools && config.devtools) {
devtools.emit('flush');
}
即在更新后,通知devtools执行flush
backend.js:
/**
* Called on every Vue.js batcher flush cycle.
* Capture current component tree structure and the state
* of the current inspected instance (if present) and
* send it to the devtools.
*/
function flush () {
let start
functionalIds.clear()
captureIds.clear()
if (false) {}
const payload = Object(util["y" /* stringify */])({
// 根据id获取到对应的instance
inspectedInstance: getInstanceDetails(currentInspectedId),
// 此方法会调capture,然后调用mark,设置src_instanceMap,key为instance的id
// rootInstances通过监听一些钩子函数得到
instances: findQualifiedChildrenFromList(rootInstances)
})
if (false) {}
src_bridge.send('flush', payload)
}
/**
* Get the detailed information of an inspected instance.
*
* @param {Number} id
*/
function getInstanceDetails (id) {
const instance = src_instanceMap.get(id)
if (!instance) {
const vnode = findInstanceOrVnode(id)
if (!vnode) return {}
const data = {
id,
name: Object(util["m" /* getComponentName */])(vnode.fnOptions),
file: vnode.fnOptions.__file || null,
state: processProps({ $options: vnode.fnOptions, ...(vnode.devtoolsMeta && vnode.devtoolsMeta.renderContext.props) }),
functional: true
}
return data
} else {
const data = {
id: id,
name: getInstanceName(instance),
state: getInstanceState(instance)
}
let i
if ((i = instance.$vnode) && (i = i.componentOptions) && (i = i.Ctor) && (i = i.options)) {
// openInEditor时打开的file路径
data.file = i.__file || null
}
return data
}
}
// src_instanceMap 在哪儿设置的呢?
/**
* Mark an instance as captured and store it in the instance map.
*
* @param {Vue} instance
*/
function mark (instance) {
if (!src_instanceMap.has(instance.__VUE_DEVTOOLS_UID__)) {
src_instanceMap.set(instance.__VUE_DEVTOOLS_UID__, instance)
instance.$on('hook:beforeDestroy', function () {
src_instanceMap.delete(instance.__VUE_DEVTOOLS_UID__)
})
}
}
/**
* Capture the meta information of an instance. (recursive)
*
* @param {Vue} instance
* @return {Object}
*/
function capture (instance, index, list) {
// 省略...
// instance._uid is not reliable in devtools as there
// may be 2 roots with same _uid which causes unexpected
// behaviour
instance.__VUE_DEVTOOLS_UID__ = getUniqueId(instance)
// Dedupe
if (captureIds.has(instance.__VUE_DEVTOOLS_UID__)) {
return
} else {
captureIds.set(instance.__VUE_DEVTOOLS_UID__, undefined)
}
mark(instance)
const ret = {
uid: instance._uid,
id: instance.__VUE_DEVTOOLS_UID__,
name,
renderKey: getRenderKey(instance.$vnode ? instance.$vnode['key'] : null),
inactive: !!instance._inactive,
isFragment: !!instance._isFragment,
children: instance.$children
.filter(child => !child._isBeingDestroyed)
.map(capture)
.filter(Boolean)
}
// 省略...
return ret
}
我们看到backend.js中的flush最后执行了src_bridge.send(‘flush’, payload),去github上看看app-fronted的代码。
src/index.js中
bridge.on('flush', payload => {
store.commit('components/FLUSH', parse(payload))
})
views/components/modules.js
const mutations = {
FLUSH (state, payload) {
let start
if (process.env.NODE_ENV !== 'production') {
start = window.performance.now()
}
// Instance ID map
// + add 'parent' properties
const map = {}
function walk (instance) {
map[instance.id] = instance
if (instance.children) {
instance.children.forEach(child => {
child.parent = instance
walk(child)
})
}
}
payload.instances.forEach(walk)
// Mutations
state.instances = Object.freeze(payload.instances)
state.inspectedInstance = Object.freeze(payload.inspectedInstance)
state.inspectedInstanceId = state.inspectedInstance ? state.inspectedInstance.id : null
state.instancesMap = Object.freeze(map)
if (process.env.NODE_ENV !== 'production') {
Vue.nextTick(() => {
console.log(`devtools render took ${window.performance.now() - start}ms.`)
if (inspectTime != null) {
console.log(`inspect component took ${window.performance.now() - inspectTime}ms.`)
inspectTime = null
}
})
}
state.loading = false
}
}
至此,instances,inspectedInstance 等均已存在store中。
看看模板中的点击open in editor做了什么操作:
// 从store中之前存的取instances,inspectedInstance
computed: mapState('components', [
'instances',
'inspectedInstance',
'loading'
])
<component-inspector
v-if="defer(3)"
slot="right"
:target="inspectedInstance"
:loading="loading"
/>
// component-inspector
<a
v-if="fileIsPath"
v-tooltip="$t('ComponentInspector.openInEditor.tooltip', { file: target.file })"
class="button"
@click="openInEditor"
>
<VueIcon icon="launch" />
<span>Open in editor</span>
</a>
openInEditor () {
// 此处的target即为inspectedInstance
const file = this.target.file
openInEditor(file)
}
openInEditor方法定义在@utils/util中,发送特殊请求,然后借鉴react-dev-utils的方法实现打开对应编辑器(具体实现可查看文章开头提到的链接)
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://github.com/vuejs/vue-devtools/blob/master/docs/open-in-editor.md')
}
})`
if (isChrome) {
chrome.devtools.inspectedWindow.eval(src)
} else {
// eslint-disable-next-line no-eval
eval(src)
}
}
至此,大致流程就结束了。