前言
说实话,我几乎没用过devtools,日常debugger已经足够了。不过它的实现还是让我挺感兴趣的,但这是一个体量巨大的项目,一篇文章肯定说不清楚,因此本文的目的就只进行流程梳理,以了解其实现思路为主
初始化
使用npm run dev启用示例项目,这将被作为iframe加载到页面
当页面load后,将会进行控制面板的初始化
installHook
在全局安装一组通信接口,这将用于vue侧与devtools的通信工作
而vue包和devtools包作为两个独立体,又共用着同一个页面窗口,则最直接的通信方式应该就是window了
initDevTools
这会创建一个vue应用,也就是我们在chrome控制台看到的如下部分,在使用devtools调试应用时的操作逻辑也都在这里
既然这是一个vue应用,那vue相关的代码就可省略,关键是去梳理devtools侧的逻辑是如何被接入的
这里的关键点在inject和new Bridge
inject
使用JavaScript动态的创建script标签,用于加载backend的创建逻辑
new Bridge
定义:
前端:app-frontend,即由vuejs创建的控制面板中显示的内容
后端:app-backend-core
这是一个继承自events库的符合发布订阅者模式的自定义类,用于前后端通信,比如在后端初始化结束后会通知前端自动选中第一个App应用;又或者,当我们在前端切换不同的组件实例时,要通知后端做相应的数据采集等
创建backend
前一部分,我们梳理了初始化流程,它为我们后续的操作预置了一系列接口,这包括对backend的初始化
进入initBackend,代码如下
在具体分析之前,我们先将代码做下优化(在不影响理解执行逻辑的前提下)
首先,在数据采集阶段我们会提到,vue2和vue3发出的初始化请求不同:init和app:init,每一组都是由"已连接(如hook.Vue)"和"待连接(如hook.on)"两部分组成,因此可以强制视为已连接,它们分别对应的订阅消息如下
vue2
vue3
接着,我们打开_legacy_getAndRegisterApps函数,我们发现它核心还是调用的registerApp
最后,else分支下的connectBridge函数在初次建立connect时也会执行,则不做区分,每次都重新建立应该也问题不大,毕竟当前仍处于初始化阶段,并不必担心数据状态丢失的情况
即如是,则去除"多余"代码后长这样
createBackendContext
创建的上下文是唯一的,并不会因为多个根实例而多次创建
registerApp
这将进入应用注册流程
connect
进行bridge和hook的消息订阅
应用注册
经过初始化阶段,我们已经埋好了通信接口,也知晓了在vue源码中存在"消息发布"的代码
vue2中触发的是"init"事件
vue3触发的是"app:init"事件
在devtools中分别对应如下消息订阅
有了订阅,亦有了发布,就可以进行应用注册了,其入口在backend创建中已经被找到
getBackend
框红一的位置,会根据当前使用的vue版本安装对应的backend
这本质上是一组操作接口,用于收集组件状态
2和3版本都共同配置了frameworkVersion、features和setup选项
frameworkVersion
该属性代表与vue版本对应的backend版本,这在初始化过程中由vue中发起的init或app:init请求中的参数可以拿到
features
该属性是backend2特有的,值为['flush'],该标识会影响后续对组件状态的收集
setup
该属性用于注册hook回调,目前来看其是在做一些参数的修正工作
从代码组织上来看,它是发布订阅模式的实现
订阅消息:这些回调函数将被缓存起来备用
发布消息:在初始化面板过程中调用,以获取页面中组件树的相关信息
setupApp
该属性是只针对backend2版本的,包含了部分兼容性的代码,比如:对vue原型上的方法进行重写
再比如,通过mixins向vue组件中混入一些生命周期,其本质上也是在重写,它向vue的hooks中埋点了devtools的采集接口
createAppRecord
其实,此时才算真正意义上完成了所有的准备工作,所有的通信接口都被正确的安装,也找到了当前版本最契合的操作列表(backend),这其实可以被类比作vue的beforeCreate和created流程,而接下来的则是应用程序的beforeMout和mounted流程
获取根组件实例
这将触发对应backend中在setup中订阅的消息,其实获取的就是在vue中触发init传递的根instance
判断是否启用了devtools
从取值来源来看,即每个组件实例上的devtools配置
比如配置根实例对devtools不可见后,控制面板将会是"暂无数据"状态
获取name和id
name即在业务代码中配置的name属性对应的值,如果没有,则devtools会内置生产一个
id被当作组件的唯一标识并挂载到对应的实例对象上
获取组件对应的页面dom
即实例上的el属性
生成当前根组件的配置对象并保存到上下文中,以便在多根情况下快速区分或查找
onBeforeMount
devtools中是没有相关的消息订阅的,个人以为,此处可以视为一个与用户侧的通信接口,其相当于vue组件中的beforeMount钩子,标识整个应用程序的即将挂载
则在我们自己的项目中,可以去监听该事件,并通过payload上挂载的__VUE_DEVTOOLS_APP_RECORD__属性获取devtools的api从而达到获取或影响内部状态的效果
onMounted
接着会与控制面板进行消息互换
(这里的消息可以认为是在告知控制面板:我马上就要给你发消息了,你准备一下......。也就是说,此时控制面板会做一些预备工作,这并不是必须的,但是却能提高程序的执行效率,此思路很值得学习......)
最后等待应用程序“mounted”之后执行控制面板的渲染
渲染控制面板
菜单
和我们日常开发需求一样,菜单列表一般都是通过后台获取的
获取菜单列表数据
其列表数据是保存在上下文的appRecords上的
由于存在多根的情况,因此需要一种增量添加的机制,即在上文提到的注册当前app时发出的BridgeEvents.TO_FRONT_APP_ADD消息
它们之间通过postMessage和addEventListener完成消息收发
回填页面
那么只需要页面中对应的值是响应式的,就可以在收到app列表更新时自动渲染到页面了
设置默认选中
当应用初始化后,默认选中第一个菜单页,这只需要监听app列表并取第一个值即可
这里有一个初看可能会比较懵逼的地方(至少我是没用过这种写法),那就是router.push是没有传递对应的path或name参数的,一开始我以为是devtools在接入vue-router时做的重写,但是仔细看了相应的代码,并不是!
能这么用,是因为如果不传递url或path或name时,vue-router会默认使用当前路由,并根据路由信息去与params中的值做匹配以生成路由地址
组件列表
上一步,我们通过路由跳转,已经打开了对应的页面,这会触发对组件树列表的获取
这会再一次的调用selectApp函数
这实际上会做两件事
创建时间穿梭(其他文章单独分享)
拉取组件列表
除了启动TimeLine(时间穿梭)创建外,也会去执行component tree的获取
省略中间过程,这调用的即创建backend时注册的通信接口
其核心是从根实例开始递归获取子组件的$children属性,结果如下
最后,前端只需要对响应对象设置值即可
还有一点,由于hmr下,组件的创建或者更新是通过url单独拉取并替换的,因此这不会触发root的mounted,也就无法重新收集,因此需要在vue指定的增删改位置向devtools发送通知(在vue2中发起的"flush"通知)
在devtools侧,只需要将该消息再转发给控制面板即可做到组件列表的同步
组件状态
状态读取
没看源码之前,我觉得这个需求是很简单的,因为在获取tree list的时候已经在前端保持了一份列表缓存
所以,在选中对应的组件的时候,直接从实例上取我觉得是可行的
但是实际上,获取的组件列表是"残缺的",它只包含了渲染列表项所需的内容
因此在具体选中后,还需要单独去获取对应实例的相关状态信息,即上图中的loadComponent
相比实例上对应的信息要如何获取而言,我觉得更重要的是,为什么要这么设计?我不是库作者,只能想到如下两者:
减少内存占用,提升组件列表渲染效率
按需加载
状态更新
在获取状态的时候,是以对象形式返回的,则由于是引用类型,直接更新是可以触发控制面板的更新的
但这仅作用于.map中生成的对象的value属性,而对实例本身并不产生影响,因此无法达到同步更新页面的效果,且切换后再切回来,值还是上一次的
因此,需要从实例修改
即我们在组件中定义的data中的属性,这是一个被收集过的属性,并在set发生时向相关依赖广播更新,这一点,和在项目中使用this.xxx = new value的效果是一样的
最后,就只需要重新将更新后的组件状态向控制面板更新一份即可
映射关系
这体现在两处:选择页面中的dom时,能定位到控制面板对应的组件;选中组件能滚动到页面对应的dom
映射到dom
由于vue在patch过程中会生成el属性标识一个组件的根
则只要拿到这个根,就可以调用原生dom api做滚动
映射到控制面板
devtools和业务项目共用同一个window,因此可以通过绑定事件来检测用户行为
当用户点击选中后,查找对应的组件,由于可能存在多层嵌套关系,因此也需要找到所有的父组件以便在选中后默认打开
然后通知控制面板选中对应的组件,并重新拉取对应的状态信息即可
chrome
我们在本地开发环境通过webpack设置开发服务器并以转发的方式将示例项目作为iframe嵌入到devtools中,说到底这只是一个多页的vue应用,想要能被用户访问,还需要将其"部署"至chorme中才行
这也不难,只需要将devtools的入口代码按规定引入并调用即可
installHook
当前不是通过iframe进行的用户项目的加载,因此我们无法得知是否是vue项目且是否已完成加载,但hooks又是vue和devtools交互的桥梁,因此需要提前进行加载
initDevTools
我们知道,installHook会向window挂载__VUE_DEVTOOLS_GLOBAL_HOOK__属性,我们只需要对其轮询,等监听到值后去触发devtools的初始化逻辑即可,剩下的,就基本和之前的分析是一模一样的了
写在最后
至此,其实现流程就了解的差不多了,不过还有一些值得深入研究的细节点:
为什么vue3中不需要设置setupApp和features.flush属性
第三方库如何接入devtools(如vux和vue-router)
如何将其改造成chrome扩展程序
如何与编辑器建立连接(vscode)
4种通信方式的实现与总结
TimeLine如何实现
网友评论