記umi@3的一次實戰經歷

背景

疫情期間遠程面試量增長,這其實爲 coding 考覈提供了比到面更好的基礎(候選人可使用本身的電腦,在本身熟悉的環境裏,下降因爲不熟練、緊張致使的失誤率)。javascript

咱們用過codeshareHackerRankLeetCode,甚至嘗試過用vscodeLive Share。在頻繁使用這些工具後突發奇想,既然是前端測驗,反正跑的都是 javascript,咱們能不能作一個簡易版的 web 考覈工具,候選人寫完題目直接本頁面跑測試用例檢查結果呢?前端

答案確定是『能』,願意動手就行。因而有了本文涉及的這個小工具。java

等不及想看實際效果的,來這裏react

需求

首先咱們須要的是明確需求,這點全部研發的朋友都很瞭解。下面就來先梳理一下要完成的目標:webpack

  • 做爲面試官,我須要題目可選,由於面試時間有限、候選人經歷不一樣,能夠指定不一樣的題目要求候選人做答
  • 做爲面試官,若是現有題目不合我意,我但願增長一個新題目的成本不要過高
  • 做爲面試官,我但願每一個題目能有一個基本的代碼結構,而且能夠限定候選人在其中做答。(由於以前有遇到幾位候選人,修改了題目,並振振有詞『你也沒說不能改題目啊』)
  • 做爲面試官,我但願每一個題目都能有測試用例,而且測試用例要對候選人可見,方便候選人理解題目
  • 做爲候選人,我但願測試用例能夠在線執行,而且顯示每一個用例的執行結果,方便我排查錯誤
  • 做爲候選人,我但願系統能幫我記錄每一個題目的做答,這樣就不會在題目切換後,以前的做答丟失

UI/交互 設計

從使用者視角出發,提供一個簡單清爽的交互界面,讓人一目瞭然,儘量下降理解成本,畢竟面試時間有限,例如 以前使用 HackerRank 或者 LeetCode 這類不只提供代碼在線編輯,同時也能夠在線檢測結果的工具,若是候選人以前沒接觸過,咱們就必須留有必定時間讓他熟悉,甚至提供一些引導,以免候選人由於緊張而操做不當,最終影響面試結果。git

因此小工具,力求功能簡單粗暴。大體風格以下:github

online-interview-tool-mockup.png

簡單的左|中|右佈局:web

  • 左側題目選擇
  • 中間代碼編輯區域
  • 右側測試用例執行模塊。(可隱藏)

程序功能點分析

圖省事就選擇了基於 react 的企業級應用開發框架 umi。熟悉 java 的朋友能夠把她簡單理解成 前端 react 領域的 srping-boot,她提供了開發一個應用須要的各類技術(諸如:路由管理、權限控制、狀態管理、調試、代碼拆分等 )的最佳實踐以及預配置,並以Convention over configuration做爲指導思想提供了一系列便利開發者的接口,以此幫助開發者簡化應用開發的成本。面試

廣告結束。。。正則表達式

接下來爲每一個需求整理下設計思路。

要完成 UI/交互 設計的樣式,其實不復雜,利用 ant-design/layout 就能夠輕鬆實現一個左右佈局。

做爲面試官,我須要若干題目可選,由於面試時間有限、候選人經歷不一樣,能夠指定不一樣的題目要求候選人做答

題目選擇設計爲路由驅動便可,即:/:examId ,點擊左側不一樣的題目,切換路由。頁面根據路由參數 examId 加載指定的題目到右側的編輯器,以及測試用例模塊初始化。

做爲面試官,若是現有題目不合我意,我但願增長一個新題目的成本不要過高

目前的設計是從工具自己的源碼着手,因此要求整個項目在新增題目的部分具備相對的靈活性和簡便性。我如今採用以目錄爲單位的題目儲備形式,以下:

├── src
│   └── exams
│       ├── exam1
│       │   ├── index.ts
│       │   ├── question.txt
│       │   └── testcase.ts
│       ├── exam2
│       │   ├── index.ts
│       │   ├── question.txt
│       │   └── testcase.ts
│       ├── ...
  • index.ts 做爲每一個題目的入口文件,負責整個題目的結構組裝
  • question.txt 題目的code base
  • testcase.ts 測試用例

既然要封裝一個統一的數據結構在入口文件 (index.ts)裏,那麼就爲她設計一個知足咱們需求的數據結構,以下:

interface ESM<T> {
  default: T
}

export interface IExamRaw {
  // 路由參數
  id: string
  // 左側菜單文字
  title: string
  // 驗證題目合法性的正則(防候選人篡改題目)
  contentRegexp: RegExp
  // 考慮到題目可能會比較多,爲避免初始化加載的 js bundle 過大,
  // 因此題目內容和測試用例採用延遲加載
  getExamInitial: () => Promise<ESM<string>>
  getTestcases: () => Promise<ESM<string[]>>
}

以 題目1 爲例,咱們分別看下 question.txttestcase.tsindex.ts 該如何編寫:

question.txt

/**
 *  要求,嘗試完成以下功能:
 *
 *  isString('hello')              = true
 *  isString(123)                  = false
 *  isString(undefined)            = false
 *  isString(null)                 = false
 *  isString(new String('hello'))  = true
 *
 **/
function isString(value) {
  //在這裏實現
}
純文本,用來初始化 右側的代碼編輯器。候選人在選中題目後,就會在這個基礎上進行編碼。

testcase.ts

export default [
  `assert(isString('hello'), '原始string類型校驗失敗')`,
  `assert.equal(isString(12445), false, '原始數值類型校驗失敗')`,
  `assert.equal(isString(undefined), false, '未初始化變量校驗失敗')`,
  `assert.equal(isString(null), false, '空值校驗失敗')`,
  `assert(isString(new String('hello')), '字符串對象校驗失敗')`,
  `assert.equal(isString({ name: 'aaa' }), false, '字面量類型校驗失敗')`
]
默認導出一個字符串數組,每條記錄就是一個測試用例,會被用來顯示在右側的測試用例模塊中

index.ts

import { defineExamRaw } from '@/types'

export default defineExamRaw({
  id: 'exam1',
  title: '01. 判斷一個變量是否字符串',
  getExamInitial: () => import(/* webpackChunkName: "exam1" */ './question.txt'),
  getTestcases: () => import(/* webpackChunkName: "case1" */ './testcase'),
  contentRegexp: /function\s*isString\(value\)\s*{[\s\S]*}/
})
這裏延遲加載是利用了 webpack/dynamic-imports 功能,使用 ECMA/import語法 來完成的;正則表達式用來實時驗證候選人是否在指定區域編寫方案,若是修改了題目,則給予提示

做爲面試官,我但願每一個題目能有一個基本的代碼結構,而且能夠限定候選人在其中做答

請回顧上面提到的數據結構:

export interface IExamRaw {
  id: string
  title: string
  // 這個正則時關鍵,她就是咱們用來限制候選人答題的基礎
  // 當候選人編輯源碼時,用該正則進行校驗,若是不符合條件則認爲候選人
  // 修改了題目
  contentRegexp: RegExp
  getExamInitial: () => Promise<ESM<string>>
  getTestcases: () => Promise<ESM<string[]>>
}

做爲面試官,我但願每一個題目都能有測試用例,而且測試用例要對候選人可見,方便候選人理解題目

做爲候選人,我但願測試用例能夠在線執行,而且顯示每一個用例的執行結果,方便我排查錯誤

這個需求是本項目的核心問題點,即:咱們須要在瀏覽器裏製造一個『源碼容器』來加載 用戶編輯後的題目代碼文本,並經過預置的測試用例運行該代碼,並反饋結果。

這裏請你們瘋狂思考幾分鐘,若是是你,這個部分你怎麼設計?

thinking.gif

可能有同窗想到了,利用 eval 或者 Function 均可以完成需求。

我這裏選用了 Function 構造器,下面介紹下如何使用。依舊以上面提到的 題目1 爲例。假設候選人已經在編輯器裏修改了代碼,以下:

/**
 *  要求,嘗試完成以下功能:
 *
 *  isString('hello')              = true
 *  isString(123)                  = false
 *  isString(undefined)            = false
 *  isString(null)                 = false
 *  isString(new String('hello'))  = true
 *
 **/
function isString(value) {
  return typeof value === 'string'
}

這只是一段『純文本』,在咱們項目的執行上下文裏,若是將她轉換爲 真正的函數呢?

其實代碼寫出來,就特別簡單了,以下:

// 從編輯器讀取到代碼文本
export function reflectFunctionFromText(code: string) {
  try {
    // 經過正則刪除其中的註釋部分(即:題目說明)
    const realCode = removeComments(code)
    // 直接構造一個新的 Function,並執行她,就拿到了 咱們指望的 isString函數
    return new Function(`return ${realCode}`)()
  } catch (e) {
    return () => {}
  }
}

其中,new Function(`return ${realCode}`) 的就是以下代碼的等式:

function anonymous() {
  return function isString(value) {
    return typeof value === 'string'
  }
}

因而,咱們經過 reflectFunctionFromText 方法,就獲得了候選人實現的 isString 了。

之因此用 Function 構造器而不用 evalMDN/Never user eval 已經介紹的很是清楚,這裏就再也不贅述了。

接下來就是測試用例執行的問題了,繼續以 題目1 爲例,一條測試用例其實就是一條 string,感受很符合 Function 構造器的口味:

export default [
  `assert(isString('hello'), '原始string類型校驗失敗')`,
  `assert.equal(isString(12445), false, '原始數值類型校驗失敗')`,
  `assert.equal(isString(undefined), false, '未初始化變量校驗失敗')`,
  `assert.equal(isString(null), false, '空值校驗失敗')`,
  `assert(isString(new String('hello')), '字符串對象校驗失敗')`,
  `assert.equal(isString({ name: 'aaa' }), false, '字面量類型校驗失敗')`
]

只要能解決這條 string 中須要的變量 assertisString 就大功告成了。

因而咱們翻翻 Function 的文檔,找到了這麼一段:

function_desc.png

也就是說,Function 構造器的最後一個參數就是生成函數的 『函數體』,而前面的若干參數,就是生成函數的 『參數』。那麼咱們能夠針對每條測試用例生成一個執行本條測試用例的函數,以下:

// 從候選人編寫的源碼中提取函數名,這裏是: isString
function reflectFunctionName(code: string) {
  return code.match(/function\s*([a-zA-Z_][a-zA-Z_0-1]*).*/)?.[1]
}

// 從候選人輸入中提取
const currentFuncName = reflectFunctionName(code)
const testcaseExecFunc = new Function('assert', currentFuncName, testcase)

這裏的 testcaseExecFunc 轉換成普通的聲明代碼,就是:

const testcaseExecFunc = function (assert, isString) {
  assert(isString('hello'), '原始string類型校驗失敗')
}

執行測試用例的話,只要傳入 assert 庫引用和 以前用 reflectFunctionFromText 獲得的候選人輸入函數就能夠了。以下:

try {
  testcaseExecFunc(assert, currentFunc)
  // 測試用例執行成功
} catch (e) {
  // 測試用例執行失敗
}

做爲候選人,我但願系統能幫我記錄每一個題目的做答,這樣就不會在題目切換後,以前的做答丟失

這個簡單吧,監聽用戶輸入,變動時把內容存入 sessionStorage 便可。

程序數據管理

數據狀態管理是本項目最有趣的嘗試,用久了 react-redux,面對大量的 boilerplate、繁瑣的結構,IDE沒法提供有效幫助(自動補全、跳轉。。。)。不論如何,鑑於如今社區中利用自定義 hooks 管理數據的思路呼聲很高,因此我想試試。

因而設計了一個這樣的自定義 hooks,給她命名爲 useInterviewModel,她應該具有以下功能:

// 以前定義的題目數據結構
import rawExams from '../exams'

export interface IExam {
  id: string
  title: string
  code: string
  contentRegexp: RegExp
  testcases: ITestcase[]
}

export default function useInterviewModel() {
  // 一個當前操做的 題目 狀態,左側菜單切換時,該狀態變動
  const [workingExam, setWorkingExam] = useState<IExam>()

  const matchExam = useCallback((pathname: string): boolean => {
    // pathname 是當前路由,根據以前的路由約定,應該就是 /:examId
    // 判斷當前路由裏的 examId 是否存在於 rawExams。
    // 用做 用戶輸入不存在題目路徑時,重定向
  }, [])

  const setupExam = useCallback(
    (examId: string) => {
      
      // 根據當前訪問的 examId,找到對應的 examRaw,
      // 並加載其中的 getExamInitial 和 getTestcases
      // 加載完畢後設置爲 workingExam

      return () => {
        // 切換路由時,重置數據
        setWorkingExam(undefined)
        setExecutorVisible(false)
      }
    },
    [setWorkingExam]
  )

  const modifyCode = useCallback(
    (code: string) => {
      // 候選人每次修改代碼時,經過這裏設置到 workingExam / sessionStorage 中
      // 並利用 workingExam 裏的正則檢查輸入是否合法,不合法給予提示
    },
    [setWorkingExam]
  )

  const execTestcases = useCallback(() => {
    // 點擊 運行測試用例按鈕,依次執行每一個測試用例,並修改 
    // workingExam 中 testcases 的狀態
  }, [setWorkingExam])

  return {
    matchExam,
    setupExam,
    workingExam,
    modifyCode,
    execTestcases
  }
}

那麼問題來了,咱們都知道 hooks 在多個組件中引用時複用的不是內部的狀態,而是邏輯。官網介紹在此:

Do two components using the same Hook share state? No. Custom Hooks are a mechanism to reuse stateful logic (such as setting up a subscription and remembering the current value), but every time you use a custom Hook, all state and effects inside of it are fully isolated.

那麼,個人 workingExam 可怎麼辦?我就是但願能在多個組件中使用 useInterviewModel 時,workingExam 是共享的。否則 useInterviewModel 的設計就無心義了。因而搞一個能夠把狀態也共享的輪子,把 自定義 hooks 處理一下,而且能保證引用關係和類型被準確導出就顯得頗有必要了。

萬幸,這個東西在 umi 裏已經有了,叫 model,下面咱們來談談他的實現原理。工做原理示意圖以下:

plugin-model.png

設計思路正是利用了 hooks 的特性,外加觀察者模式的一個小巧思:

  1. 建立一個全局的 dispatcher 做爲主題(subject),存儲數據,註冊觀察者,通知觀察者
  2. 在根組件下建立若干 Executor 組件,每一個 Executor 都引用一個咱們編寫的 model(普普統統的自定義 hooks),這樣,model 更新,就會觸發 Executor 更新了
  3. Executor 更新時調用 dispatcher 通知全部的觀察者最新的數據
  4. 再提供一個系統級的 hooksuseModel,她內部向 dispatcher 註冊本身爲觀察者,開發者在組件中使用她來獲取本身指定 model 裏的內容。當 Executor 更新時,dispatcher 通知全部的觀察者,因而 useModel 收到了通知,而且經過 setState 驅動自身更新,這樣,做爲使用者,咱們的組件就收到了數據更新

至此,咱們想用 hooks 作狀態管理的但願就實現了。

源碼

源碼地址:js-interview-online

相關文章
相關標籤/搜索