疫情期间远程面试量增长,这其实为 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