忙里偷闲逛了下,看到一个有意思的工具: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 startfunctionalIds.clear()captureIds.clear()if (false) {}const payload = Object(util["y" /* stringify */])({// 根据id获取到对应的instanceinspectedInstance: 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 iif ((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// behaviourinstance.__VUE_DEVTOOLS_UID__ = getUniqueId(instance)// Dedupeif (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 startif (process.env.NODE_ENV !== 'production') {start = window.performance.now()}// Instance ID map// + add 'parent' propertiesconst map = {}function walk (instance) {map[instance.id] = instanceif (instance.children) {instance.children.forEach(child => {child.parent = instancewalk(child)})}}payload.instances.forEach(walk)// Mutationsstate.instances = Object.freeze(payload.instances)state.inspectedInstance = Object.freeze(payload.inspectedInstance)state.inspectedInstanceId = state.inspectedInstance ? state.inspectedInstance.id : nullstate.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,inspectedInstancecomputed: mapState('components', ['instances','inspectedInstance','loading'])<component-inspectorv-if="defer(3)"slot="right":target="inspectedInstance":loading="loading"/>// component-inspector<av-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即为inspectedInstanceconst file = this.target.fileopenInEditor(file)}
openInEditor方法定义在@utils/util中,发送特殊请求,然后借鉴react-dev-utils的方法实现打开对应编辑器(具体实现可查看文章开头提到的链接)
export function openInEditor (file) {// Console displayconst 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-evaleval(src)}}
至此,大致流程就结束了。
