【DoKit&北大专题】-DoKit For Web源码阅读报告

338 阅读10分钟

专题背景

近几年随着开源在国内的蓬勃发展,一些高校也开始探索让开源走进校园,让同学们在学生时期就感受到开源的魅力,这也是高校和国内的头部互联网企业共同尝试的全新教学模式。本专题会记录这段时间内学生们的学习成果。

更多专题背景参考:【DoKit&北大专题】缘起

系列文章

【DoKit&北大专题】缘起

【DoKit&北大专题】-读小程序源代码(一)

【DoKit&北大专题】-读小程序源代码(二)

【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的源代码。

WechatIMG104.png

项目结构

Dokit For Web的项目结构如图所示。

WechatIMG105.png

node_modules目录下是项目用到的各种依赖包;

packages是本项目的核心部分,由coreutilsweb三部分组成。core是容器层,在这里定义容器和进行路由管理;utils可用于封装一些应用接口;web包含DoKit For Web中的各种功能插件。

playground相当于项目的启动程序;

scripts目录下是项目用到的脚本,目前仅包含dev-playground.js,用于在项目运行时创建并连接http服务器;

package.jsonlerna.json说明了项目信息、使用的脚本、各种依赖的版本;

README.md简单介绍了项目功能,DEVELOPMENT.md简单介绍了项目的安装与运行方法。

源代码分析

这部分我们对现有的两个插件的源代码进行分析。

AppInfo

AppInfo中展示应用信息,包括页面信息和设备信息。

界面如图所示。

截屏2021-04-30 21.14.45.png

这个插件比较简单,文件目录结构如下:

app-info
├── index.js
├── ToolAppInfo.vue

除此之外还用到了../common/Card.vue

index.js

可以看到,本页面用到了来自ToolAppInfo.vueAppinfo组件

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里面有标题和用于展示信息的表格。

可以看到,这里展示的信息都是windowdocument中的属性值。

<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组件的结构也很简单,分为headerbody两部分。

<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,用于展示不同类型的控制台信息,分别为AllLogInfoWarnError,中间区域输出控制台信息,下方命令行用于输入相关的指令。

截屏2021-04-30 13.09.22.png

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-taplog-containeroperation-command,与我们在前面看见的插件界面相对应。

这里还涉及到了一些重要的变量和计算属性:

logTabs指明了各个tab的名字和编号;curTab是当前tab的编号,默认为0(即默认进入All页面);

计算属性的结果会被缓存,除非依赖的响应式 property 变化才会重新计算。logList从全局获取了总的日志列表,curLogList则是当前tab需要显示的日志列表,会跟随curTab变化,在curTab发生改变时,从logList中读取所请求的类型的日志。

需要注意的是,console这个object包含的内容非常丰富,除了LogInfoWarnError,我们还可以产生许多其他类型的日志,但本插件目前只能显示前述的4类日志以及它们的合集。

截屏2021-04-30 15.15.10.png

方法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-previewDetail两个部分,默认显示log-preview,通过点击该部分的内容调用toggleDetail方法可以显示或收起Detail

为了便于展示日志中输出的数据类型,这里还从../../assets/util.js中引入了getDataTypegetDataStructureStr,包含的数据类型有NumberStringBooleanRegExpSymbolFunctionNullUndefinedArrayObject

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会显示为编号:ObjectArray会显示为编号:Array(长度)

如果要显示的内容已经无法继续折叠,则直接显示具体内容。这个地方的设计是不完善的,根据detailValue,这里只能显示StringNumberObject三种类型的变量,如果遇到其他内容则会报错。这一问题将在实验部分展示。

<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;LogNumConsoleLogMap是日志类型和其下标之间的映射关系;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_METHODSConsoleLogMap两个上面定义的常量,它们都只包含loginfowarnerror,这说明,在overrideConsole中,被拦截的原始的方法只有console.logconsole.infoconsole.warnconsole.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 buildnpm run dev:playground(开启Dev服务),通过浏览器访问浏览器访问 http://localhost:3000/playground ,就打开了下图所示的界面。该界面的源码在playground/index.html中。

截屏2021-04-30 18.03.23.png

点击圆形按钮(可拖动,初始位置在页面左上角),展开DoKit For Web工具栏。在这些工具中,目前只有日志、应用信息包含实际功能,其他都是demo页。

截屏2021-04-30 13.06.55.png

接下来我们直接进入日志插件,对它的主要功能进行实验。

通过前面的源代码分析,我们得知,在该插件中被拦截的原始的方法只有console.logconsole.infoconsole.warnconsole.error,也只有通过这4个方法产生的日志能被加入本工具显示的logList

在浏览器控制台中执行以下4个指令:

console.log('log')
console.info('info')
console.warn('warn')
console.error('error')

可以发现输出内容会分别显示在4个tab下(在Console插件的命令行中执行指令的效果与之相似,但还会多输出一行excuteScript的返回值undefined,影响展示效果)。

截屏2021-04-30 19.02.10.png

验证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,这个部分并不会显示在左边。

截屏2021-04-30 18.59.40.png

另外,现在的日志工具也没有捕获全局异常。

console.log(qqqq)

即输出一个未定义的变量。

如果在浏览器控制台执行上述语句,左侧不会有任何输出。

WechatIMG230.png

这是因为在dev环境中,vue不会捕获error,而是throw;线上环境才会捕获error,然后console.error打印出来。

WechatIMG236.png

在当前版本中,如果在Console插件的命令行里执行上述语句,则会重复产生如图所示的vue warn,并陷入死循环。 WechatIMG233.png

另外,如果console.log一个函数(或其他不在detailValue列表里的类型的变量),在展开detail时也会因为类型检查出错而陷入死循环。

function a(b){a=b}
console.log(a)

截屏2021-04-30 20.35.03.png

作者信息

作者:MistyMist

原文链接:juejin.cn/post/695693…

来源:掘金