记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

相关文章
相关标签/搜索