专题背景
近几年随着开源在国内的蓬勃发展,一些高校也开始探索让开源走进校园,让同学们在学生时期就感受到开源的魅力,这也是高校和国内的头部互联网企业共同尝试的全新教学模式。本专题会记录这段时间内学生们的学习成果。
更多专题背景参考:【DoKit&北大专题】缘起
系列文章
【DoKit&北大专题】-实现DoKit For Web请求捕获工具(一)产品调研
【DoKit&北大专题】-DoKit For 小程序源码分析
【DoKit&北大专题】-浅谈滴滴DoKit业务代码零侵入思想(小程序端)
【DoKit&北大专题】-滴滴DoKit For Web模块源码阅读
【DoKit&北大专题】-滴滴DoKit For Web模块源码阅读(二)
【DoKit&北大专题】-DoKit For iOS视觉工具模块源码阅读
【DoKit&北大专题】-DoKit For iOSUI组件Color Picker源代码阅读分析
【DoKit&北大专题】-DoKit For Web源码阅读报告
原文
目录
项目简介
DoraemonKit,简称DoKit,是一个功能平台,能够让每一个 App 快速接入一些常用的或者你没有实现的辅助开发工具、测试效率工具、视觉辅助工具。
其中DoKit For Web是一款面向前端生态的开发工具,它可以让开发人员在移动端Web开发的过程中,在移动端查看各种数据信息与网络请求,而不仅仅是通过PC上的开发工具来模拟各型号的手机。
目前已上线的功能有日志和应用信息,未来预计还会有接口抓取、视图元素查看、资源查看、应用存储、接口Mock、屏幕录制与回放、自动化测试等功能。
源代码阅读工具
将DoKit项目代码clone到本地,使用VSCode 1.52.1阅读源代码与测试。
VScode是一个运行于 Mac OS X、Windows和 Linux 之上的,针对于编写现代Web和云应用的跨平台源代码编辑器,具有对JavaScript,TypeScript和Node.js的内置支持,配合Vetur等插件可以方便地阅读并运行DoKit For Web的源代码。
项目结构
Dokit For Web的项目结构如图所示。
node_modules
目录下是项目用到的各种依赖包;
packages
是本项目的核心部分,由core
、utils
、web
三部分组成。core
是容器层,在这里定义容器和进行路由管理;utils
可用于封装一些应用接口;web
包含DoKit For Web中的各种功能插件。
playground
相当于项目的启动程序;
scripts
目录下是项目用到的脚本,目前仅包含dev-playground.js
,用于在项目运行时创建并连接http服务器;
package.json
和lerna.json
说明了项目信息、使用的脚本、各种依赖的版本;
README.md
简单介绍了项目功能,DEVELOPMENT.md
简单介绍了项目的安装与运行方法。
源代码分析
这部分我们对现有的两个插件的源代码进行分析。
AppInfo
AppInfo
中展示应用信息,包括页面信息和设备信息。
界面如图所示。
这个插件比较简单,文件目录结构如下:
app-info
├── index.js
├── ToolAppInfo.vue
除此之外还用到了../common/Card.vue
index.js
可以看到,本页面用到了来自ToolAppInfo.vue
的Appinfo
组件
import AppInfo from './ToolAppInfo.vue'
import {RouterPlugin} from '@dokit/web-core'
export default new RouterPlugin({
nameZh: '应用信息',
name: 'app-info',
icon: 'https://pt-starimg.didistatic.com/static/starimg/img/z1346TQD531618997547642.png',
component: AppInfo
})
ToolAppInfo.vue
本页由两个Card
组成,Card
里面有标题和用于展示信息的表格。
可以看到,这里展示的信息都是window
和document
中的属性值。
<template>
<div class="app-info-container">
<div class="info-wrapper">
<Card title="Page Info">
<table border="1">
<tr>
<td>UA</td>
<td>{{ua}}</td>
</tr>
<tr>
<td>URL</td>
<td>{{url}}</td>
</tr>
</table>
</Card>
</div>
<div class="info-wrapper">
<Card title="Device Info">
<table border="1">
<tr>
<td>设备缩放比</td>
<td>{{ratio}}</td>
</tr>
<tr>
<td>screen</td>
<td>{{screen.width}}X{{screen.height}}</td>
</tr>
<tr>
<td>viewport</td>
<td>{{viewport.width}}X{{viewport.height}}</td>
</tr>
</table>
</Card>
</div>
</div>
</template>
<script>
import Card from '../../common/Card'
export default {
components: {
Card
},
data() {
return {
ua: window.navigator.userAgent,
url: window.location.href,
ratio: window.devicePixelRatio,
screen: window.screen,
viewport: {
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight
}
}
},
}
</script>
Card.vue
Card
组件的结构也很简单,分为header
和body
两部分。
<template>
<div class="dokit-card">
<div class="dokit-card__header">{{title}}</div>
<div class="dokit-card__body">
<slot/>
</div>
</div>
</template>
<script>
export default {
props: {
title: String
}
}
</script>
Console
Console
插件相当于浏览器本身的控制台。
其界面如下图所示。可以看到,本页上方有5个tab,用于展示不同类型的控制台信息,分别为All
、Log
、Info
、Warn
、Error
,中间区域输出控制台信息,下方命令行用于输入相关的指令。
Console
的文件目录结构如下。
console
├── css
| ├── var.less
├── js
| ├── console.js
├── console-tap.vue
├── index.js
├── log-container.vue
├── log-detail.vue
├── log-item.vue
├── main.vue
├── op-command.vue
index.js
从Console
插件的入口index.js
中可以看出,其核心部分是main.vue
中的Console
模块,onLoad时调用了overrideConsole
函数,onUnload时调用了restoreConsole
函数,这两个函数都写在./js/console.js
中。
import Console from './main.vue'
import {overrideConsole,restoreConsole} from './js/console'
import {getGlobalData, RouterPlugin} from '@dokit/web-core'
export default new RouterPlugin({
name: 'console',
nameZh: '日志',
component: Console,
icon: 'https://pt-starimg.didistatic.com/static/starimg/img/PbNXVyzTbq1618997544543.png',
onLoad(){
console.log('Load')
overrideConsole(({name, type, value}) => {
let state = getGlobalData();
state.logList = state.logList || [];
state.logList.push({
type: type,
name: name,
value: value
});
});
},
onUnload(){
restoreConsole()
}
})
main.vue
从代码中可以看出,页面自上而下的组件布局顺序为console-tap
、log-container
、operation-command
,与我们在前面看见的插件界面相对应。
这里还涉及到了一些重要的变量和计算属性:
logTabs
指明了各个tab的名字和编号;curTab
是当前tab的编号,默认为0(即默认进入All
页面);
计算属性的结果会被缓存,除非依赖的响应式 property 变化才会重新计算。logList
从全局获取了总的日志列表,curLogList
则是当前tab需要显示的日志列表,会跟随curTab
变化,在curTab
发生改变时,从logList
中读取所请求的类型的日志。
需要注意的是,console
这个object包含的内容非常丰富,除了Log
、Info
、Warn
、Error
,我们还可以产生许多其他类型的日志,但本插件目前只能显示前述的4类日志以及它们的合集。
方法handleChangeTab
会在切换tab时改变curTab
的值,从而影响curLogList
的值,改变显示的日志内容。
<template>
<div class="console-container">
<console-tap :tabs="logTabs" @changeTap="handleChangeTab"></console-tap>
<div class="log-container">
<div class="info-container">
<log-container :logList="curLogList"></log-container>
</div>
<div class="operation-container">
<operation-command></operation-command>
</div>
</div>
</div>
</template>
<script>
import ConsoleTap from './console-tap';
import LogContainer from './log-container';
import OperationCommand from './op-command';
import {LogTabs, LogEnum} from './js/console'
export default {
components: {
ConsoleTap,
LogContainer,
OperationCommand
},
data() {
return {
logTabs: LogTabs,
curTab: LogEnum.ALL //0
}
},
computed:{ //计算属性的结果会被缓存,除非依赖的响应式 property 变化才会重新计算。
logList(){
return this.$store.state.logList || []
},
curLogList(){
if(this.curTab == LogEnum.ALL){
return this.logList
}
return this.logList.filter(log => {
return log.type == this.curTab
})
}
},
created () {},
methods: {
handleChangeTab(type){
this.curTab = type
}
}
}
</script>
接下来分析main.vue
中的三个组件。
console-tap.vue
该组件根据main.vue
传入的logTabs
生成5个tab,并标明当前所在的tab。
handleClickTab
也是一个涉及到tab切换的方法,但它的主要目的是标记当前tab,只能影响console-tap
组件内的内容。
<template>
<div class="tab-container">
<div class="tab-list">
<div
class="tab-item"
:class="curIndex === index? 'tab-active': 'tab-default'"
v-for="(item, index) in tabs"
:key="index"
@click="handleClickTab(item, index)"
>
<span class="tab-item-text">{{ item.name }}</span>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
tabs: {
type: Array
},
},
data() {
return {
curIndex: 0
};
},
methods: {
handleClickTab(item, index) {
let { type } = item;
this.curIndex = index;
this.$emit("changeTap", type);
},
},
};
</script>
log-container.vue
这是页面中间显示日志内容的组件,根据logList
中的内容生成多个log-item
。
<template>
<div class="log-container">
<log-item
v-for="(log, index) in logList"
:key="index"
:value="log.value"
:type="log.type"
></log-item>
</div>
</template>
<script>
import LogItem from './log-item'
export default {
components: {
LogItem
},
props: {
logList: {
type: Array,
default: [],
},
},
data() {
return {};
}
};
</script>
log-item.vue
每条日志的显示分为log-preview
和Detail
两个部分,默认显示log-preview
,通过点击该部分的内容调用toggleDetail
方法可以显示或收起Detail
。
为了便于展示日志中输出的数据类型,这里还从../../assets/util.js
中引入了getDataType
和getDataStructureStr
,包含的数据类型有Number
、String
、Boolean
、RegExp
、Symbol
、Function
、Null
、Undefined
、Array
、Object
。
log-preview
的内容根据计算属性logPreview
得到,在不同的情况下,会展示文本内容和(或)数据类型。
<template>
<div class="log-ltem">
<div class="log-preview" v-html="logPreview" @click="toggleDetail"></div>
<div v-if="showDetail && typeof value === 'object'">
<div class="list-item" v-for="(key, index) in value" :key="index">
<Detail :detailValue="key" :detailIndex="index"></Detail>
</div>
</div>
</div>
</template>
<script>
import { getDataType, getDataStructureStr } from '../../assets/util'
import Detail from './log-detail'
const DATATYPE_NOT_DISPLAY = ['Number', 'String', 'Boolean']
export default {
components: {
Detail
},
props: {
type: [Number],
value: [String, Number, Object]
},
data () {
return {
showDetail: false
}
},
computed: {
logPreview () {
let dataType = ''
let html = `<div>`
this.value.forEach(arg => {
dataType = getDataType(arg)
if (DATATYPE_NOT_DISPLAY.indexOf(dataType) === -1) {
html += `<span class="data-type">${dataType}</span>`
}
html += `<span class="data-structure">${getDataStructureStr(arg, true)}</span>`
});
html += `</div>`
return html
}
},
methods: {
toggleDetail () {
this.showDetail = !this.showDetail
}
}
}
</script>
log-detail.vue
在视图部分可以注意到,Detail
部分是可折叠、可嵌套的。
展开Detail
部分时,显示的内容由displayDetailValue
方法生成。
如果遇到仍可折叠的Object
或者Array
,则只先显示一层未完全展开的内容。Object
会显示为编号:Object
,Array
会显示为编号:Array(长度)
。
如果要显示的内容已经无法继续折叠,则直接显示具体内容。这个地方的设计是不完善的,根据detailValue
,这里只能显示String
、Number
、Object
三种类型的变量,如果遇到其他内容则会报错。这一问题将在实验部分展示。
<template>
<div class="detail-container" :class="[canFold ? 'can-unfold':'', unfold ? 'unfolded' : '']" >
<div @click="unfoldDetail" v-html="displayDetailValue"></div>
<template v-if="canFold">
<div v-show="unfold" v-for="(key, index) in detailValue" :key="index">
<Detail :detailValue="key" :detailIndex="index"></Detail>
</div>
</template>
</div>
</template>
<script>
import Detail from './log-detail'
import { getDataType } from '../../assets/util'
const TYPE_CAN_FOLD = ['Object', 'Array']
export default {
name: "Detail",
components: {
Detail
},
props: {
detailValue: [String, Number, Object],
detailIndex: [String, Number]
},
data () {
return {
unfold: false
}
},
computed: {
dataType () {
return getDataType(this.detailValue)
},
canFold () {
if (TYPE_CAN_FOLD.indexOf(this.dataType) > -1) {
return true
}
return false
},
displayDetailValue () {
let value = ''
if (this.canFold) {
if (this.dataType === 'Object') {
value = 'Object'
}
if (this.dataType === 'Array') {
value = `Array(${this.detailValue.length})`
}
} else {
value = `<span style="color:#1802C7;">${this.detailValue}</span>`
}
return `<span style="color:#7D208C;">${this.detailIndex}</span>: ${value}`
}
},
methods: {
unfoldDetail() {
this.unfold = !this.unfold
}
}
}
</script>
op-command.vue
该组件是日志页面下方的命令行。
点击Excute按钮时,方法excuteCommand
检测命令行是否为空,如果不为空,则调用来自js/console.js
中的excuteScript
方法,执行输入的指令,然后将返回的内容打印到控制台。这一效果与在PC端浏览器的控制台执行指令的效果是相同的。
<template>
<div class="operation">
<div class="input-wrapper">
<input class="input" placeholder="Command……" v-model="command" />
</div>
<div class="button-wrapper" @click="excuteCommand">
<span>Excute</span>
</div>
</div>
</template>
<script>
import {excuteScript} from './js/console'
export default {
data(){
return {
command: ""
}
},
methods: {
excuteCommand(){
if(!this.command){
return
}
let ret = excuteScript(this.command)
console.log(ret)
}
}
};
</script>
js/console.js
console.js
中定义了一系列重要的常量和方法。
LogMap
用于生成LogTabs
并在main.vue
中被引入,这也就是Console
插件里面的几个tab;LogNum
和ConsoleLogMap
是日志类型和其下标之间的映射关系;CONSOLE_METHODS
是插件中用到的不同的日志(方法)类型。
export const LogMap = {
0: 'All',
1: 'Log',
2: 'Info',
3: 'Warn',
4: 'Error'
}
export const LogEnum = {
ALL: 0,
LOG: 1,
INFO: 2,
WARN: 3,
ERROR: 4
}
export const ConsoleLogMap = {
'log': LogEnum.LOG,
'info': LogEnum.INFO,
'warn': LogEnum.WARN,
'error': LogEnum.ERROR
}
export const CONSOLE_METHODS = ["log", "info", 'warn', 'error']
export const LogTabs = Object.keys(LogMap).map(key => {
return {
type: parseInt(key),
name: LogMap[key]
}
})
excuteScript
方法的内容比较明确,即执行输入的指令。
export const excuteScript = function(command){
let ret
try{
ret = eval.call(window, `(${command})`)
}catch(e){
ret = eval.call(window, command)
}
return ret
}
上文提到,Console
插件onLoad时调用了overrideConsole
方法,onUnload时调用了restoreConsole
方法。
overrideConsole
首先将浏览器中原有的console
保存到origin
中,即拦截原本的console
的部分功能的执行,执行从index.js
中传入的callback
后再执行原函数。这里的callback
的功能是将相应的日志加入state.logList
。
拦截浏览器中原始的方法,获取需要的信息后再执行原始方法,这是DoKit For Web工具的重要思想之一。
我们可以看到这里调用的CONSOLE_METHODS
和ConsoleLogMap
两个上面定义的常量,它们都只包含log
、info
、warn
、error
,这说明,在overrideConsole
中,被拦截的原始的方法只有console.log
、console.info
、console.warn
、console.error
,会被加入logList
并显示在Console
插件中的也只有这四类日志。
restoreConsole
的功能则是还原浏览器的console
。
export const origConsole = {}
export const noop = () => {}
export const overrideConsole = function(callback) {
const winConsole = window.console
CONSOLE_METHODS.forEach((name) => {
let origin = (origConsole[name] = noop)
if (winConsole[name]) {
origin = origConsole[name] = winConsole[name].bind(winConsole)
}
winConsole[name] = (...args) => {
callback({
name: name,
type: ConsoleLogMap[name],
value: args
})
origin(...args)
}
})
}
export const restoreConsole = function(){
const winConsole = window.console
CONSOLE_METHODS.forEach((name) => {
winConsole[name] = origConsole[name]
})
}
实验
本文的实验部分将展示DoKit For Web的主要功能界面,并重点对Console模块进行测试和讨论。
将项目clone到本地,根据DEVELOPMENT.md
中的提示,依次在命令行中执行npm run bootstrap
(安装相关依赖),npm run build
和npm run dev:playground
(开启Dev服务),通过浏览器访问浏览器访问 http://localhost:3000/playground
,就打开了下图所示的界面。该界面的源码在playground/index.html
中。
点击圆形按钮(可拖动,初始位置在页面左上角),展开DoKit For Web工具栏。在这些工具中,目前只有日志、应用信息包含实际功能,其他都是demo页。
接下来我们直接进入日志插件,对它的主要功能进行实验。
通过前面的源代码分析,我们得知,在该插件中被拦截的原始的方法只有console.log
、console.info
、console.warn
、console.error
,也只有通过这4个方法产生的日志能被加入本工具显示的logList
。
在浏览器控制台中执行以下4个指令:
console.log('log')
console.info('info')
console.warn('warn')
console.error('error')
可以发现输出内容会分别显示在4个tab下(在Console
插件的命令行中执行指令的效果与之相似,但还会多输出一行excuteScript
的返回值undefined
,影响展示效果)。
验证log-item
的折叠与展开效果,执行
console.log('aaa',{name:'alice',id:1},[1,2,[3,4]])
三项分别为字符串、object、array类型。我们可以看见浏览器的console和DoKit的console显示效果的差别。浏览器的预览效果比DoKit稍好;浏览器把数据类型放在展开detail的最后,DoKit则放在预览/第一层detail中。Console
插件在显示效果方面仍有改进空间。
对浏览器的console
对象的其他方法进行测试。在下图中,我们还执行了2次
console.count('log')
并在浏览器的控制台中正常输出了log的计数结果。但由于DoKit没有拦截console.count
,这个部分并不会显示在左边。
另外,现在的日志工具也没有捕获全局异常。
console.log(qqqq)
即输出一个未定义的变量。
如果在浏览器控制台执行上述语句,左侧不会有任何输出。
这是因为在dev环境中,vue不会捕获error,而是throw;线上环境才会捕获error,然后console.error
打印出来。
在当前版本中,如果在Console
插件的命令行里执行上述语句,则会重复产生如图所示的vue warn,并陷入死循环。
另外,如果console.log
一个函数(或其他不在detailValue
列表里的类型的变量),在展开detail时也会因为类型检查出错而陷入死循环。
function a(b){a=b}
console.log(a)
作者信息
作者:MistyMist
来源:掘金