疫情期間遠程面試量增長,這其實爲 coding 考覈提供了比到面更好的基礎(候選人可使用本身的電腦,在本身熟悉的環境裏,下降因爲不熟練、緊張致使的失誤率)。javascript
咱們用過codeshare、HackerRank、LeetCode,甚至嘗試過用vscode的Live Share。在頻繁使用這些工具後突發奇想,既然是前端測驗,反正跑的都是 javascript
,咱們能不能作一個簡易版的 web 考覈工具,候選人寫完題目直接本頁面跑測試用例檢查結果呢?前端
答案確定是『能』,願意動手就行。因而有了本文涉及的這個小工具。java
等不及想看實際效果的,來這裏react
首先咱們須要的是明確需求,這點全部研發的朋友都很瞭解。下面就來先梳理一下要完成的目標:webpack
從使用者視角出發,提供一個簡單清爽的交互界面,讓人一目瞭然,儘量下降理解成本,畢竟面試時間有限,例如 以前使用 HackerRank
或者 LeetCode
這類不只提供代碼在線編輯,同時也能夠在線檢測結果的工具,若是候選人以前沒接觸過,咱們就必須留有必定時間讓他熟悉,甚至提供一些引導,以免候選人由於緊張而操做不當,最終影響面試結果。git
因此小工具,力求功能簡單粗暴。大體風格以下:github
簡單的左|中|右佈局: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 basetestcase.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.txt
、testcase.ts
、index.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[]>> }
這個需求是本項目的核心問題點,即:咱們須要在瀏覽器裏製造一個『源碼容器』來加載 用戶編輯後的題目代碼文本,並經過預置的測試用例運行該代碼,並反饋結果。
這裏請你們瘋狂思考幾分鐘,若是是你,這個部分你怎麼設計?
可能有同窗想到了,利用 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
構造器而不用eval
, MDN/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
中須要的變量 assert
和 isString
就大功告成了。
因而咱們翻翻 Function 的文檔,找到了這麼一段:
也就是說,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,下面咱們來談談他的實現原理。工做原理示意圖以下:
設計思路正是利用了 hooks
的特性,外加觀察者模式的一個小巧思:
dispatcher
做爲主題(subject),存儲數據,註冊觀察者,通知觀察者Executor
組件,每一個 Executor
都引用一個咱們編寫的 model
(普普統統的自定義 hooks
),這樣,model
更新,就會觸發 Executor
更新了Executor
更新時調用 dispatcher
通知全部的觀察者最新的數據hooks
叫 useModel
,她內部向 dispatcher
註冊本身爲觀察者,開發者在組件中使用她來獲取本身指定 model
裏的內容。當 Executor
更新時,dispatcher
通知全部的觀察者,因而 useModel
收到了通知,而且經過 setState
驅動自身更新,這樣,做爲使用者,咱們的組件就收到了數據更新至此,咱們想用 hooks
作狀態管理的但願就實現了。
源碼地址:js-interview-online