打造一款js测验小工具

# 背景

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

我用过codeshareHackerRankLeetCode,甚至尝试过用vscodeLive 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.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

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

也就是说,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. 再提供一个系统级的 hooksuseModel,她内部向 dispatcher 注册自己为观察者,开发者在组件中使用她来获取自己指定 model 里的内容。当 Executor 更新时,dispatcher 通知所有的观察者,于是 useModel 收到了通知,并且通过 setState 驱动自身更新,这样,作为使用者,我们的组件就收到了数据更新

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

# 展示结果

源码地址:js-interview-online

demo