源码阅读-DoKit for Web

410

源码阅读-DoKit for Web

本文对应代码截止至2021.5.1

一些第一次接触的包

npm-run-all

一个npm的库,这个工具是为了解决官方的 npm run 命令无法同时运行多个脚本的问题

这个包提供三个命令,分别是 npm-run-all run-s run-p,其中后两个都是 npm-run-all 带参数的简写,分别对应串行和并行。

lerna

Lerna 是一个管理工具,用于管理包含多个软件包(package)的 JavaScript 项目。

github 仓库

入门

# 安装
npm install --global lerna
# 创建一个新的仓库代码
git init lerna-repo && cd lerna-repo
# 变为lerna仓库
lerna init

你的代码仓库目前应该是如下结构:

my-lerna-repo/
  package.json
  packages/
    package-1/
      package.json
    package-2/
      package.json

常用指令

The two primary commands in Lerna are lerna bootstrap and lerna publish.

bootstrap will link dependencies in the repo together. publish will help publish any updated packages.

rollup.js

Rollup 是一个 JavaScript 模块打包器,可以将小块代码编译成大块复杂的代码,例如 library 或应用程序。

待补充...

源码分析-组织结构

目录结构是一个标准的lerna项目的结构,如上文介绍

package.json

首先分析下根目录中的package.json

  "scripts": {
    "bootstrap": "run-s bootstrap:project bootstrap:package",
    "bootstrap:project": "npm install",
    "bootstrap:package": "lerna bootstrap",
    "build": "lerna run build",
    "dev": "lerna run dev --parallel",
    "dev:playground": "run-p serve:playground dev",
    "serve:playground": "node scripts/dev-playground.js",
    "clean": "run-s clean:lerna clean:lock",
    "clean:lerna": "lerna clean --yes",
    "clean:lock": "run-s clean:lock-package clean:lock-subpackage",
    "clean:lock-package": "rm -rf ./package-lock.json",
    "clean:lock-subpackage": "rm -rf ./packages/**/package-lock.json"
  },

运行的指令分别为npm run bootstrap,npm run buildnpm run dev:playground

前两者都是在安装依赖相关内容,主要运行的部分为

node scripts/dev-playground.js

scripts/dev-playground.js

可以看到入口在dev-playground.js文件中,其代码如下:

const http = require('http');
const serveHandler = require('serve-handler');
const open = require('open');

run();
function run(){
  const server = http.createServer((request, response) => {
    return serveHandler(request, response);
  })   
  server.listen(3000, () => {
    console.log('Dev Server Running at http://localhost:3000');
    open('http://localhost:3000/playground');
  })
}

这一部分是运行了一个server,并指向了./playground/index.html文件

/playground/index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Dokit For Web</title>
</head>

<body>
  <h1>Dokit For Web</h1>
  <h2>Playground</h2>
</body>
<script src="https://unpkg.com/vue@next"></script>
<script src="../packages/web/dist/dokit.js"></script>
</html>

这边是主要的网页入口了,其引入的内容都在web/dist/dokit.js

packages/web/

该目录下是一个利用rollup打包的模块,查看rollup.config.js可以看到,上文所引用的dokit.js便是打包的输出文件,输入文件为src/index.js

src/index.js

import {Dokit} from '@dokit/web-core'
import {Features} from './feature'
/*
* TODO 全局注册 Dokit
*/
window.Dokit = new Dokit({
  features: Features,
});

根据js内容,继续找到feature.js@dokit/web-core

src/feature.js

在这个文件中,可以看到,一共引入了4个插件

import Console from './plugins/console/index'
import AppInfo from './plugins/app-info/index'
import DemoPlugin from './plugins/demo-plugin/index'
import HelloWorld from './components/ToolHelloWorld'

然而网页中显示的有很多个插件,继续向下看

export const BasicFeatures = {
  title: '常用工具',
  list: [Console, AppInfo, DemoPlugin, {
    nameZh: 'H5任意门a ',
    name: 'h5-door',
    icon: 'https://pt-starimg.didistatic.com/static/starimg/img/FHqpI3InaS1618997548865.png',
    component: AppInfo
  }]
}

export const DokitFeatures = {
  title: '平台功能',
  list: [{
    nameZh: 'Mock数据',
    name: 'mock',
    icon: 'https://pt-starimg.didistatic.com/static/starimg/img/aDn77poRDB1618997545078.png',
    component: HelloWorld
  }]
}

export const UIFeatures = {
  title: '视觉功能',
  list: [{
    nameZh: '取色器',
    name: 'color-selector',
    icon: 'https://pt-starimg.didistatic.com/static/starimg/img/QYUvEE8FnN1618997536890.png',
    component: HelloWorld
  }, {
    nameZh: '对齐标尺',
    name: 'align-ruler',
    icon: 'https://pt-starimg.didistatic.com/static/starimg/img/a5UTjMn6lO1618997535798.png',
    component: HelloWorld
  }, {
    nameZh: 'UI结构',
    name: 'view-selector',
    icon: 'https://pt-starimg.didistatic.com/static/starimg/img/XNViIWzG7N1618997548483.png',
    component: HelloWorld
  }]
}
export const Features = [BasicFeatures, DokitFeatures, UIFeatures]

可以看到,DokitFeaturesUIFeatures中只是占了位置,实际对应的插件还是HelloWorlddemo,最后将这些全部export出去即可。

packages/core

在上面看到,声明了一个全局组件Dokit,这个来自于@dokit/web-core,继续寻找其定义位置。既然是web-core,那就在core/文件夹下寻找试试。查看package.json,该目录下文件的name就是@dokit/web-core,其main文件为dist/index.js。不难猜出,这个文件仍是rollup打包出来的文件,查看rollup.config.js,源文件为src/index.js。在这里,找到了Dokit的定义:

import {createApp} from 'vue'
import App from './components/app'
import Store from './store'
import {applyLifecyle, LifecycleHooks} from './common/js/lifecycle' 
import {getRouter} from './router'
export class Dokit{
  options = null
  constructor(options){
    this.options = options
    let app = createApp(App);
    let {features} = options; 
    app.use(getRouter(features));
    app.use(Store);
    Store.state.features = features;
    this.app = app;
    this.init();
    this.onLoad();
  }

  onLoad(){
    // Lifecycle Load
    applyLifecyle(this.options.features, LifecycleHooks.LOAD)
  }

  onUnload(){
    // Lifecycle UnLoad
    applyLifecyle(this.options.features, LifecycleHooks.UNLOAD)
  }

  init(){
    let dokitRoot = document.createElement('div')
    dokitRoot.id = "dokit-root"
    document.documentElement.appendChild(dokitRoot);
    // dokit 容器
    let el = document.createElement('div')
    el.id = "dokit-container"
    this.app.mount(el)
    dokitRoot.appendChild(el)
  }
}

export * from './store'
export * from './common/js/feature'

export default {
  Dokit
}

Dokit的初始化过程中现根据App创建一个实例,然后加载了两个模板getRouterStore,并把插件Features传给了Storeinit()函数中将dokit插件插入到了文档流中。

按顺序依次查看源码。

components/app.vue

<template>
  <div class="dokit-app">
    <div class="dokit-entry-btn" v-dragable @click="toggleContainer"></div>
    <div class="mask" v-show="showContainer" @click="toggleContainer"></div>
    <router-container v-show="showContainer"></router-container>
  </div>
</template>

<script>
import dragable from "../common/directives/dragable";
import RouterContainer from './container';

export default {
  components: {
    RouterContainer
  },
  directives: {
    dragable,
  },
  data() {
    return {
      showContainer: false,
    };
  },
  methods: {
    toggleContainer() {
      this.showContainer = !this.showContainer;
    },
  },
};
</script>

app.vue里声明了一个组件,其对应的应该是Dokit的按钮,通过showContainer来控制是否展示插件容器。这里面又使用了RouterContainer

components/container.vue

<template>  <div class="container">    <top-bar :title="title" :canBack="canBack"></top-bar>    <div class="router-container">      <router-view v-slot="{ Component }">        <keep-alive>          <component :is="Component" />        </keep-alive>      </router-view>    </div>  </div></template><script>import TopBar from "../common/components/top-bar";export default {  components: {   TopBar   },  data(){    return {}  },  computed:{    curRoute(){      return this.$router.currentRoute.value    },    title(){      return this.curRoute.meta.title || 'Dokit'    },    canBack(){      return this.curRoute.name !== 'index'    }  },  created(){  }}</script>

可以看到,这里便是Dokit的菜单栏组件

store/index.js

接下来继续看Store的源码

import { Store } from "../common/js/store";const store = new Store({  state: {    features: []  }})// 更新全局 Store 数据export function updateGlobalData(key, value){  store.state[key] = value}// 获取当前 Store 数据的状态export function getGlobalData(){  return store.state}export default store

看起来这个类使用来进行数据存储

继续看一下Store的定义

common/js/store.js

import {reactive} from 'vue'const storeKey = 'store'/** * 简易版 Store 实现 * 支持直接修改 Store 数据 */export class Store{  constructor(options){    let {state} = options    this.initData(state)  }  initData(data = {}){    this._state = reactive({      data: data    })  }  get state(){    return this._state.data  }  install(app){    app.provide(storeKey, this)    app.config.globalProperties.$store = this  }}

源码里展现出的也的确是存取数据的功能。

router/index.js

继续分析Dokit引入的组件,下一个是getRouter组件:

app.use(getRouter(features));

这里将features都传给了getRouter

import { createRouter, createMemoryHistory } from 'vue-router'import {routes, getRoutes} from './routes'export function getRouter(features){  return createRouter({    routes: [...routes, ...getRoutes(features)],    history: createMemoryHistory()  })}

getRouter接收到features参数之后,调用getRoutes函数并进一步创建路由

router/routers.js

import Index from '../components/index'export const routes = [{  path: '/',  name: 'index',  component: Index}]export function getRoutes(features){  let routes = []  features.forEach(feature => {    let {list, title:featureTitle} = feature    list.forEach(item => {      // TODO 暂时只支持路由方式的插件      let {name, title, component} = item      routes.push({        path: `/${name}`,        name: name,        component: component.component || component,        meta: {          title: title,          feature: featureTitle        }      })    })  })  return routes}

这部分的内容也很容易理解,即对features里的内容依次生成路由,从而完成不同插件页面的跳转。

可以看到,在头部还引入了一个根目录路由Index,继续深入分析

components/index.vue

<template>  <div class="index-container">    <card      v-for="(item, index) in features"      :key="index"      :title="item.title"      :list="item.list"    ></card>    <version-card :version="version"></version-card>  </div></template><script>import TopBar from "../common/components/top-bar";import Card from "../common/components/card";import VersionCard from "../common/components/version";export default {  components: {    TopBar,    Card,    VersionCard  },  data(){    return {      version: '1.3.0'    }  },  mounted(){  },  computed: {    features(){      return this.$store.state.features    }  }};</script>

该组件便是Dokit的菜单页面了,将features的内容分别生成一个<card>。在web/src/features.js中可以看到,features中还对不同插件进行了功能分类,每种功能下有若干个插件,那么可以猜想<card>中会再次遍历list,然后生成不同插件的按钮。

找到card.vue:

comon/components/card.vue

<template>  <div class="card">    <div class="card-title">      <span class="card-title-text"> {{title}} </span>    </div>    <div class="item-list">      <div class="item" v-for="(item,index) in list" :key="index" @click="handleClickItem(item)">        <div class="item-icon">          <img            class="item-icon-image"            :src="item.icon || defaultIcon"          />        </div>        <div class="item-title">{{item.nameZh || '默认功能'}}</div>      </div>    </div>  </div></template><script>import {DefaultItemIcon} from '../js/icon'export default {  props: {    title: {      default: '专区'    },    list: {      default: []    }  },  data(){    return {      defaultIcon: DefaultItemIcon    }  },  methods: {    handleClickItem(item){      this.$router.push({        name: item.name      })    }  }};</script>

的确,代码中对list进行遍历,然后对每个插件生成一个按钮,与猜想一样。

源码分析-功能实现

目前web端仅实现了日志应用信息两个功能,其他功能只有demo模板。

app-info

ToolAppInfo.vue

<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      }    }  },}

这一部分内容比较简单,都是调用了jswindowdocument中的内置函数来获取设备信息,通过vue的数据绑定显示出来

console

该插件功能实现略微复杂,从目录结构就可以看出,分了很多个小模块

console/- css/- js/--- console.js- console-tap.vue- index.js- log-container.vue- log-detail,vue- log-item.vue- main.vue- op-command.vue

index.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

main.vue

<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    }  },  computed:{    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>

在这里一下次又引入了四个组件,接下来继续依次进行分析

console-tab.vue

<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>

这一部分为console上方的tab栏,根据传进来的tabs生成所有的tab

log-container.vue

<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>

该组件为cosolelog部分,根据logList把所有的log显示出来。显示的方式为log-item,引用于log-item.vue

log-item.vue

<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>

每个log-item都按照Detail的方式显示了出来,

log-detail.vue

<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部分根据不同的内容还会改变样式,这里不具体分析了。

op-command.vue

<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>

这里是console中输入/执行指令的部分,每一个指令都会传给excuteScript并执行。

以上便是控制consoleUI部分的代码,剩下的部分均来自于js/console,为具体的逻辑实现部分

js/console.js

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]  }})

该文件上来先定义了几个常量,LogMapconsole中展示出来的tabLogNumConsoleLogMap为类型和对应下标之间的映射,CONSOLE_METHODSconsole中不同的方法类型。LogTabs则是生成一个tab列表,生成console-tab时使用。

export const excuteScript = function(command){  let ret   try{    ret = eval.call(window, `(${command})`)  }catch(e){    ret = eval.call(window, command)  }  return ret}

这个函数很容易理解,就是将输入的指令执行。

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]  })}

最后一部分则是将浏览器中的console进行重载,在console执行的过程中会先调用传入的callback,然后才执行原函数。原函数保存在了origConsole中,在restoreConsole中便是将原始的console还原的过程。

index.js中有一段:

onLoad(){    console.log('Load')    overrideConsole(({name, type, value}) => {      let state = getGlobalData();      state.logList = state.logList || [];      state.logList.push({        type: type,        name: name,        value: value      });    });  },

可以看到,传给overrideCOnsolecallback便是将每个指令都存到state中,从而能够将其在log-container中展示出来。