Ts + React + Mobx 實現移動端瀏覽器控制檯

m_le發表於2019-02-16

自從使用 Typescript 寫 H5 小遊戲後,就對 Ts 產生了依賴(智慧提示以及友好的重構提示),但對於其 Type System 還需要更多的實踐。

最近開發 H5 小遊戲,在移動端除錯方面,為求方便沒有采用 inspect 的模式。用的是粗暴的 vConsole,用人家東西要學會感恩,所以決定去了解它的原理,最後用 Ts + React 碼一個移動端瀏覽器控制檯,算是 Ts + React 實戰

通過該教程可以學習:

  • Ts + React + Mobx 開發流程
  • 基本的 Type System
  • 一些 JavaScript 基礎概念
  • 瀏覽器控制檯相關知識

    • Console
    • NetWork、XHR
    • Storage
    • DevTool 核心渲染程式碼

專案原始碼 供上, 第一次用 Typescript + React 碼專案,記錄迭代的過程,有興趣入坑的可 star 一下 期待 CodeReview。

開始

本著快速開發的理念(本人要帶娃),於是基於 Create React App 腳手架搭建專案,UI 框架使用了同樣採用
Ts 編寫的 AntMobile。 開始專案講解前,顯然需要對這兩個有一定的瞭解 ( 建議可作為進一步學習 Ts + React 的參考 )

下面,先來看下預覽圖片

UI 很簡單,按功能劃分為

  • Log 、 System
  • Network
  • Elemnet
  • Storage

主要從以上這幾個功能模組展開

PS: 教程會略過一些,諸如如何支援 stylus ( 專案執行過 yarn run eject ),interface 要不要加 I,render 要不要 Public, 如何去除一些 Tslint 等。( 跟蹤檔案 git history 可略知一二 )PWA 等

基本程式碼風格

通篇會按這種風格 ( 並不是最佳實踐 ) 去編寫元件,( 比較少無狀態元件,也沒有高階元件的應用 )。

import React, { Component } from `react`;

interface Props {
  // props type here
}

interface State {
  // state type here
}

export default class ClassName extends Component<Props, State> {
  // state: State = {...}; 我更喜歡將 state 寫在這。

  constructor(props: Props) {
    super(props);
    this.state = {
      // some state
    };
  }

  // some methods...

  render() {
    // return
  }
}

Log

除錯控制檯最常用是 Log,與之不可分割的 API 就是 window.console 。常用的方法有[`log`, `info`, `warn`, `debug`, `error`]。UI 表現上可分為 Log,Warn,Error 三類。

如何自己實現一個控制檯 console 皮膚呢? 其實很簡單,只需要 “重寫” window.console 對應的這些方法,然後再呼叫系統自帶的 console 方法即可。這樣你就可以實現在原有方法基礎上附加一些你想要的操作。( 可惜這麼做會有一些副作用,後面會講到。 )

程式碼邏輯如下:

const methodList = [`log`, `info`, `warn`, `debug`, `error`];

methodList.map(method => {
  // 1. 儲存 window 自帶 console 方法。
  this.console[method] = window.console[method];
});

methodList.map(method => {
  window.console[method] = (...args: any[]) => {
    // 2. 做一些儲存資料及展示的操作。

    // 3. 呼叫原生 console 方法。
    this.console[method].apply(window.console, infos);
  };
});

由於專案我們用的是 React ,由於是資料驅動,所以只需要關心資料即可。

在 Log 中的資料,其實就是 console.log(引數) 中的引數,再將這些引數用 mobx 以陣列的形式統一管理後交由 List 元件渲染。

import { observable, action, computed } from `mobx`;

export interface LogType {
  logType: string;
  infos: any[]; // 來自 console 方法的引數。
}

export class LogStore {
  @observable logList: LogType[] = [];
  @observable logType: string = `All`;

  // some action...
}

export default new LogStore();

資料和列表展示都有了,那麼 如何用樹形結構展示基本資料型別與引用型別

基本型別 ( undefined,null,string,number,boolean,symbol )展示比較簡單,這邊講一下引用型別 ( Array,Object )的展示實現。對應專案中就是 logView 元件。

logView 元件

從之前的預覽圖片可以大致看到整個資料展示結構,都是 key-value 的形式。

這裡跟 Pc 端瀏覽器控制檯不一樣的是,沒有展示 __proto__ 相關的東西。然後,function 只是以方法名加括號的形式展示,如 log()

接下來我們看下這個 UI 對應的 html 結構。

我們需要展示的就只是 key 和 value 以及父子縮排,典型的樹形結構,遞迴可以搞定。

對於 Object 直接就是 key-valueArray 其實也是索引和值的對應關係。

基本邏輯:

<li className="my-code-wrap">
  <div className="my-code-box">
    // 1. 判斷是否需要顯示展開圖示
    {opener}
    <div className="my-code-key">
      // 2. 顯示 key
      {name}
    </div>
    <div className="my-code-val">
      // 3. 根據值型別,選擇其展示方式
      {preview}
    </div>
  </div>
  // 4. 如果是 Object 或 Array,則重複 1.
  {children}
</li>

至此一個簡單的 log 展示邏輯就完成了。接下來說一下控制檯裡面的 JS 命令列執行。

  sendCMD() {
    return (cmd: string) => {
      let result = void 0;
      try {
        result = eval.call(window, `(` + cmd + `)`);
      } catch (e) {
        try {
          result = eval.call(window, cmd);
        } catch (e) {
          ;
        }
      }
      // mobx中的 action
      logStore.addLog({ logType: `log`, infos: [result] })
    }
  }

eval() 函式會將傳入的字串當做 JavaScript 程式碼進行執行。但他是一個危險的函式,他執行的程式碼擁有著執行者的權利。這裡直接讓使用者傳參,意味著使用者可以決定執行什麼樣的程式碼(包括惡意程式碼),所以這種瀏覽器控制檯是絕對不能出現在生產環境的

小結

log 的實現不難,就在原有 winodw.console 方法的基礎上,新增引數收集功能,並交由 mobx 管理。再將引數通過樹形結構的方式展示給使用者。但是,這種方式可能造成非常多不必要的渲染,每次呼叫 console 方法 ( 包括 error 和 warning),都會觸發相應的 render ,如果在 log 元件的 render 方法裡面呼叫 console 就會造成棧溢位 (相當於在 render 呼叫 setState),不過好在這只是用於開發中的除錯階段,另外,對於線上 bug 排查,我們可以用 charles 代理的方式注入程式碼而無需影響原有程式碼。即便如此,前端自己實現的瀏覽器控制檯還是無法跟原生控制檯媲美的 (最多用來看下有沒有報錯,又不想使用麻煩的 inspect 模式) ,比如追蹤呼叫棧,以及 script error。所以,為什麼要使用 Typescript,很重要的一點是儘可能地在開發階段規避一些 bug。但面對海量級使用者,手機千奇百怪,這時就只能通過前端異常監控,專業的有 fundebug 或者自己簡單處理一下。扯遠了,還是回到我們走馬觀花的下一部分 system 吧。

System

system 主要用於展示瀏覽器端不太容易檢視的資訊,比如當前瀏覽器的使用者代理(user agent)字串或者當前真實的 URL (由於某些原因,URL 可能被修改)。當然這些要展示的資訊跟業務以及需要除錯的內容關聯比較大,因此這個皮膚還是自定義比較。需要注意的是:通過檢測 userAgent 的值來判斷瀏覽器型別是不可靠的,也是不推薦的,因為使用者可以修改 userAgent 的值。( 好在我們只是用來除錯,面向的是開發者,而不是提供給其他白菜使用者使用 )

PS: 作為擴充套件,可以使用 特徵檢測 來檢測 web 特性的在手機瀏覽器上的 ( 包括某些客戶端的 webview ) 支援情況,從而在開發階段提早做一些降級處理!另外,如果需要的話,可以在 system 展示一些呼叫客戶端協議 (JSbridge) 相關的資訊。我們就此跳過吧,進入更為關心的下一部分 network

Network

接著來實現 network,開始前先來了解下 XMLHttpRequest

使用 XMLHttpRequest (XHR)物件可以與伺服器互動。您可以從 URL 獲取資料,而無需讓整個的頁面重新整理。這使得 Web 頁面可以只更新頁面的區域性,而不影響使用者的操作。XMLHttpRequest 在 Ajax 程式設計中被大量使用。

比較重要的方法 opensendgetAllResponseHeaders,還有一些需要了解的屬性 onreadystatechangereadyStatestatusresponse 等,不瞭解的讀者自行補習下。

我們如果要捕獲使用者傳送請求並用於前端展示,需要用到 open 和 send 方法,監聽變換需要用到 onreadystatechange

另外,XMLHttpRequest.readyState 屬性返回的是一個 XMLHttpRequest 代理當前所處的狀態。一個 XHR 代理總是處於下列狀態中的一個:

狀態 描述
0 UNSENT 代理被建立,但尚未呼叫 open() 方法。
1 OPENED open() 方法已經被呼叫。
2 HEADERS_RECEIVED send() 方法已經被呼叫,並且頭部和狀態已經可獲得。
3 LOADING 下載中; responseText 屬性已經包含部分資料。
4 DONE 下載操作已完成。

瞭解這些基礎知識後,來看下程式碼實現邏輯:

  mockAjax() {
    // 這裡的 (window as any).XMLHttpRequest 我用的很虛。太粗暴了
    const XMLHttpRequest = (window as any).XMLHttpRequest;
    if (!XMLHttpRequest) {
      return;
    }
    const that = this;
    // 1、備份原生 XMLHttpRequest 的 open 和 send 方法
    const XHRnativeOpen = XMLHttpRequest.prototype.open;
    const XHRnativeSend = XMLHttpRequest.prototype.send;

    // 2、重寫 open 方法
    XMLHttpRequest.prototype.open = function (...args: any) {
      // 3、獲取 open 方法傳入的引數
      const [method, url] = args;

      // 4、儲存原有  onreadystatechange
      const userOnreadystatechange = this.onreadystatechange;

      this.onreadystatechange = function (...stateArgs: any) {
        // do something

        // 5、根據 readyState 做相應處理,主要是儲存需要展示的資料,比如 response 和 header

        // 6、呼叫原有 onreadystatechange
        return (
          userOnreadystatechange &&
          userOnreadystatechange.apply(this, stateArgs)
        );
      };

      // 7、呼叫原生 XMLHttpRequest.open 方法
      return XHRnativeOpen.apply(this, args);
    };
    XMLHttpRequest.prototype.send = function (...args: any) {
      // 8、重寫 XMLHttpRequest.send 方法並儲存資料
      return XHRnativeSend.apply(this, args);
    };
  }

這樣基本上就完成了 network 資料的收集,接下來就是表格展示的事了。但,擼完還是覺得過於粗暴,我碼專案以來還是第一次修改 prototype,而且是 XMLHttpRequest 的,生怕對基礎掌握的不夠引發了更多的 bug。於是準備去看下 axios 的原始碼,看人家是怎麼玩弄 XMLHttpRequest ,後看能不能優化一下。(後話了…) 這邊需要說的是,如果使用 fetch 傳送請求,就 GG 了。給了自己迭代足夠的理由,( 當然前提是否有必要,萬一我又去做 PC端了呢 !)

Element

在用 vconsole 的時候,我就特別關心 element 皮膚究竟是怎麼實現的。下面就讓我們來撩一下:

回顧下 UI 介面

如果資料來源是 document.documentElement,那不就是下圖麼!

有必要的話,先熟悉下 HTML5 標籤,和 DOM Node

這邊我們只需要關心,三個型別的節點:元素, 文字 和 註釋 ( 瞭解 nodeType)。

對於元素 (標籤) 我們只需要知道兩種不同的展示方式,自閉合標籤以及非自閉合 (對於UI來說,僅僅是縮排的區別),以及它們都是由標籤名和屬性組成,如:<body style="background:#000"></body><img src="...">。下面看下要實現這樣一個 elemnt 的 html 結構是怎麼樣的:

對應實現就是專案裡的 htmlView 元件,主要的程式碼邏輯如下:


import { parseDOM } from `htmlparser2`;

// 1. 將 HTML 文字,解析為 JSON 格式
const tree = parseDOM(document.documentElement.outerHTML);


// 2. 轉換為易於展示的 JSON 格式,並轉換為 Immutable 資料

  getRoot() {
    const { tree, defaultExpandedTags } = this.props;

    transformNodes(tree, [], true);
    return Immutable.fromJS(tree[0]);

    function transformNodes(trees: any[], keyPath: any, initial?: boolean) {
      trees.forEach((node: any, i: number) => {
        // 3. 資料轉換邏輯
      });
    }
  }

// 3. 根據 type 來區分渲染 UI

if (type === `text` || type === `comment`) {

}

對於 htmlparser2 的轉換規則可以看這個 demohtmlparser2得到的資料可能並不適用於渲染,經過處理後最終用於渲染資料的結構如下:

依然是資料驅動的思路,剩下的就只是渲染的邏輯處理。

Storage

Storage 實現也比較簡單。前端比較關心的一般是 localstoragecookies。它們都有自己的獲取,修改,和清除方法。我們只需要拿到資料給表格渲染即可。

關於 Typescript

到目前為止,講得更多的是控制檯的實現思路。有點對不起標題黨 Ts + React + Mobx,說實話,碼玩這個專案發現並沒有太多的技巧。在這聊一下我用 Typescript 的感受。正如文章一開是說的,最大的感受就是開發體驗的改善。另外就是:

元件 props 和 state 的定義

// Ts 讓程式碼更加易於閱讀,只需要看元件這部分程式碼即可知道,
// 元件接受哪些屬性以及其內部狀態,並且可以知道他們都接受什麼樣的型別。

interface Props {
  togglePane: () => void;
  logList: LogType[]
}

interface State {
  searchVal: string
}

// 元件泛型
export default class ClassName extends PureComponent<Props, State> {
  // ...
}

其他常用 type,如果想了解 React 相關的 type 可以看這裡
高質量的 Type definitions

  "devDependencies": {
    "@types/jest": "^23.3.9",
    "@types/node": "^10.12.5",
    "@types/react": "^16.7.2",
    "@types/react-dom": "^16.0.9",
    "typescript": "^3.1.6"
  }
// 獲取 ref 上有所不同
export default class Log extends Component<Props, State> {
  private searchBarRef = createRef<SearchBar>()
  sendCMD = ()=> {
      this.searchBarRef.current!.focus()
  }
  render() {
    return (
      <Flex>
        <SearchBar
          ref={this.searchBarRef}
          onclic={this.sendCMD}
        />
      </Flex>
    );
  }
}

能總結的確實很少,對 Ts 中 type system 的感受就是少用 any。大概瞭解下常用的 React 和 window 的 type 即可。(在vscode 編輯器下。直接F12跳轉到 window 或 React 定義處就可以看到所有的型別宣告)

另外在不知道型別的時候,可以利用型別推斷來獲取型別。

我也是剛開始用 Typescript ,說多錯多!不誤人子弟了,就總結到這吧。

yarn run eject

使用 Create React App 腳手架建立完專案後,在 package.json 裡面提供了這樣一個命令

{
  "scripts": {
    "eject": "react-scripts eject"
  }
}

執行完這個命令後,會將封裝的配置全部反編譯到當前專案,這樣使用者就可以完全取得webpack檔案的控制權。出於學習目的,還是放出來比較好!

Create React App 水好深,適合單獨拎出來研究!

總結

不得不承認,這是一個練手的專案。可能都完全不適合用 Ts + React 來做,只是希望自己跨出這一步,擁抱 Ts。教程通篇圍繞 前端如何實現瀏覽器控制檯 展開,比較少介紹 TS + React 技巧方面。可以說是一種比較保守的實現方式 ( 因為不確定是不是最佳實踐 ),
希望拋磚引玉,有人可以 codeReview 下,不勝感激!另外,希望這篇教程有給大家帶來一些知識擴充套件的作用。

參考

相關文章