打造一款js测验小工具

# 背景

疫情期间远程面试量增加,这其实为 coding 考核提供了比到面更好的基础(候选人可以使用自己的电脑,在自己熟悉的环境里,降低由于不熟练、紧张导致的失误率)。

我用过codeshare、HackerRank、LeetCode,甚至尝试过用vscode的Live Share。在频繁使用这些工具后突发奇想,既然是前端测验,反正跑的都是 javascript,我们能不能做一个简易版的 web 考核工具,候选人写完题目直接本页面跑测试用例检查结果呢?

答案肯定是『能』,愿意动手就行。于是有了本文涉及的这个小工具。

等不及想看实际效果的,来这里

# 需求

首先我们需要的是明确需求,这点所有研发的朋友都很了解。下面就来先梳理一下要完成的目标:

  • 作为面试官,我需要题目可选,因为面试时间有限、候选人经历不同,可以指定不同的题目要求候选人作答
  • 作为面试官,如果现有题目不合我意,我希望增加一个新题目的成本不要太高
  • 作为面试官,我希望每个题目能有一个基本的代码结构,并且可以限定候选人在其中作答。(因为之前有遇到几位候选人,修改了题目,并振振有词『你也没说不能改题目啊』)
  • 作为面试官,我希望每个题目都能有测试用例,并且测试用例要对候选人可见,方便候选人理解题目
  • 作为候选人,我希望测试用例可以在线执行,并且显示每个用例的执行结果,方便我排查错误
  • 作为候选人,我希望系统能帮我记录每个题目的作答,这样就不会在题目切换后,之前的作答丢失

# UI/交互 设计

从使用者视角出发,提供一个简单清爽的交互界面,让人一目了然,尽可能降低理解成本,毕竟面试时间有限,例如 之前使用 HackerRank 或者 LeetCode 这类不仅提供代码在线编辑,同时也可以在线检测结果的工具,如果候选人之前没接触过,我们就必须留有一定时间让他熟悉,甚至提供一些引导,以避免候选人因为紧张而操作不当,最终影响面试结果。

所以小工具,力求功能简单粗暴。大致风格如下:

mockup

简单的左|中|右布局:

  • 左侧题目选择
  • 中间代码编辑区域
  • 右侧测试用例执行模块。(可隐藏)

# 程序功能点分析

图省事就选择了基于 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.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[]>>
}

# 作为面试官,我希望每个题目都能有测试用例,并且测试用例要对候选人可见,方便候选人理解题目

# 作为候选人,我希望测试用例可以在线执行,并且显示每个用例的执行结果,方便我排查错误

这个需求是本项目的核心问题点,即:我们需要在浏览器里制造一个『源码容器』来加载 用户编辑后的题目代码文本,并通过预置的测试用例运行该代码,并反馈结果。

这里请大家疯狂思考几分钟,如果是你,这个部分你怎么设计?

thinking

可能有同学想到了,利用 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

也就是说,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,下面我们来谈谈他的实现原理。工作原理示意图如下:

model

设计思路正是利用了 hooks 的特性,外加观察者模式的一个小巧思:

  1. 创建一个全局的 dispatcher 作为主题(subject),存储数据,注册观察者,通知观察者
  2. 在根组件下创建若干 Executor 组件,每个 Executor 都引用一个我们编写的 model(普普通通的自定义 hooks),这样,model 更新,就会触发 Executor 更新了
  3. Executor 更新时调用 dispatcher 通知所有的观察者最新的数据
  4. 再提供一个系统级的 hooks 叫 useModel,她内部向 dispatcher 注册自己为观察者,开发者在组件中使用她来获取自己指定 model 里的内容。当 Executor 更新时,dispatcher 通知所有的观察者,于是 useModel 收到了通知,并且通过 setState 驱动自身更新,这样,作为使用者,我们的组件就收到了数据更新

至此,我们想用 hooks 做状态管理的希望就实现了。

# 展示结果

源码地址:js-interview-online

demo