分析一道“微信”面试题

前一段时间,一道疑似“微信”招聘的面试题出现,可能有不少读者已经了解过了。这道题乍一看挺难,但是细细分析却还算简单,我们甚至可以用多种手段解题,用不同思想来给出答案。

网上零零碎碎的有一些解答,但是缺乏全面梳理。我认为通过这道题,有必要将前端多重知识点“融会贯通”,在这里和大家分享。

本讲知识点如下:

enter image description here

题意分析

我们来先看看题目:

实现一个 LazyMan,按照以下方式调用时,得到相关输出:

LazyMan("Hank")
// Hi! This is Hank!

LazyMan("Hank").sleep(10).eat("dinner")
// Hi! This is Hank!
// 等待10 秒..
// Wake up after 10
// Eat dinner~

LazyMan("Hank").eat("dinner").eat("supper")
// Hi This is Hank!
// Eat dinner~
// Eat supper~

LazyMan("Hank").sleepFirst(5).eat("supper")
// 等待 5 秒
// Wake up after 5
// Hi This is Hank!
// Eat supper

当面试者拿到这道题目的时候,乍看题干可能会有点慌张。其实很多面试失败是“自己吓唬自己”,在平时放松状态下写代码,也许解题不在话下。

下面我们就从接到题目开始,剖析应该如何进行分析:

  • 可以把 LazyMan 理解为一个构造函数,在调用时输出参数内容

  • LazyMan 支持链式调用

  • 链式调用过程提供了以下几个方法:sleepFirst、eat、sleep

  • 其中 eat 方法输出参数相关内容:Eat + 参数

  • sleep 方法比较特殊,链式调用将暂停一定时间后继续执行,看到这里也许应该想到 setTimeout

  • sleepFirst 最为特殊,这个任务或者这个方法的 优先级最高 ;调用 sleepFirst 之后,链式调用将暂停一定时间后继续执行。请再次观察题干,尤其是最后一个 demo,sleepFirst 的输出优先级最高,调用后先等待 5 秒输出 Wake up after 5,再输出 Hi This is Hank!

我们应该如何解这个题目呢,从拿到需求开始进行分析:

  • 先从最简单的,我们可以封装一些基础方法,比如 log 输出、封装 setTimeout 等

  • 因为 LazyMan 要实现一系列调用, 且调用并不是顺序执行的,比如如果 sleepFirst 出现在调用链时,优先执行;同时任务并不是全部都同步执行的 ,因此 我们应该实现一个任务队列 ,这个队列将调度执行各个任务

  • 因此每次调用 LazyMan 或链式执行时,我们应该将相关调用方法加入到(push)任务队列中,储存起来,后续统一被调度

  • 在写入任务队列时,如果当前的方法为 sleepFirst,那么需要将该方法放到队列的最头处,这应该是一个 unshift 方法

这么一分析,这道题就“非常简单”了。

我们来试图解剖一下这道题目的考察点:

  • 面向对象思想与设计,包括类的使用等

  • 对象方法链式调用的理解和设计

  • 小部分设计模式的设计

  • 因为存在“重复逻辑”,考察代码的解耦和抽象能力

  • 逻辑的清晰程度以及其他编程思维

常规思路解答

基于以上思路,我们给出较为常规的答案,其中代码已经加上了必要的注释:

class LazyManGenerator {
  constructor(name) {
    this.taskArray = []

    // 初始化时任务
    const task = () => {
      console.log(`Hi! This is ${name}`)
      // 执行完初始化时任务后,继续执行下一个任务
      this.next()
    }

    // 将初始化任务放入任务队列中
    this.taskArray.push(task)

    setTimeout(() => {
      this.next()
    }, 0)
  }

  next() {
      // 取出下一个任务并执行
    const task = this.taskArray.shift()
    task && task()
  }

  sleep(time) {
    this.sleepTask(time, false)
    // return this 保持链式调用
    return this
  }

  sleepFirst(time) {
    this.sleepTask(time, true)
    return this
  }

  sleepTask(time, prior) {
    const task = () => {
      setTimeout(() => {
        console.log(`Wake up after ${time}`)
        this.next()
      }, time * 1000)
    }

    if (prior) {
      this.taskArray.unshift(task)
    } else {
      this.taskArray.push(task)
    }
  }

  eat(name) {
    const task = () => {
      console.log(`Eat ${name}`)
      this.next()
    }

    this.taskArray.push(task)
    return this
  }
}

function LazyMan(name) {
  return new LazyManGenerator(name)
}

简单分析一下:

  • LazyMan 方法返回一个 LazyManGenerator 构造函数的实例

  • 在 LazyManGenerator constructor 当中,我们维护了 taskArray 用来存储任务,同时将初始化任务放到 taskArray 当中

  • 还是在 LazyManGenerator constructor 中,将任务的逐个执行即 next 调用放在 setTimeout 中,这样就能够保证在开始执行任务时,taskArray 数组已经填满了任务

  • 我们来看看 next 方法,取出 taskArray 数组中的首项,进行执行

  • eat 方法将 eat task 放到 taskArray 数组中,注意 eat task 方法需要调用 this.next() 显式调用“下一个任务”;同时返回 this,完成链式调用

  • sleep 和 sleepFirst 都调用了 sleepTask,不同在于第二个参数:sleepTask 第二个参数表示是否优先执行,如果 prior 为 true,则使用 unshift 将任务插到 taskArray 开头

这个解法最容易想到,也相对来说容易,主要是面向过程。关键点在于对于 setTimeout 任务队列的准确理解以及 return this 实现链式调用的方式。

事实上,sleepTask 应该作为 LazyManGenerator 类的私有属性出现,因为 ES class 暂时 private 属性没有被广泛实现,这里不再追求实现。

设计模式解答

关于这道题目的解答,网上最流行的是一种发布订阅模式的方案。相关代码出处:lazyMan

但是其实仔细看其实现,也是上一环节中常规解法的变种。虽然说是发布订阅模式,但是其实仍然是 next 思想执行下一个任务的思路,该实现 publish 和 subscribe 方法分别是完成执行任务和注册任务逻辑。我认为这样的代码实现有一点“过度设计”之嫌,更像是往发布订阅模式上去靠,整体流程不够自然。

当然读者仍可参考,并有自己的思考,这里我不再更多分析。

再谈流程控制和队列、中间件启发

这道题目我们给出解法并不算完,更重要也更有价值的是思考、延伸。微信题目较好地考察了候选者的流程控制能力,而流程控制在前端开发者面前也非常重要。

我们看上述代码中的 next 函数,它负责找出 stack 中的下一个函数并执行:

next() {
    // 取出下一个任务并执行
    const task = this.taskArray.shift()
    task && task()
}

NodeJS 中 connect 类库,以及其他框架的中间件设计也都离不开类似思想的 next。比如生成器自动执行函数 co、redux、koa 也通过不同的实现,可以让 next 在多个函数之间执行完后面的函数再折回来执行 next,较为巧妙。我们具体来看一下。

senchalabs connect 和 express

具体场景:在 Node 环境中,有 parseBody、checkIdInDatabase 等相关中间件,他们组成了 middlewares 数组:

const middlewares = [
  function middleware1(req, res, next) {
    parseBody(req, function(err, body) {
      if (err) return next(err);
      req.body = body;
      next();
    });
  },
  function middleware2(req, res, next) {
    checkIdInDatabase(req.body.id, function(err, rows) {
      if (err) return next(err);
      res.dbResult = rows;
      next();
    });
  },
  function middleware3(req, res, next) {
    if (res.dbResult && res.dbResult.length > 0) {
      res.end('true');
    }
    else {
      res.end('false');
    }
    next();
  }
]

当一个请求打开时,我们需要链式调用各个中间件:

const requestHandler = (req, res) => {
  let i = 0

  function next(err) {
    if (err) {
      return res.end('error:', err.toString())
    }

    if (i < middlewares.length) {
      middlewares[i++](req, res, next)
    } else {
      return
    }
  }

  // 初始执行第一个中间件
  next()
}

基本思路和面试题解法一致:

  • 将所有中间件(任务处理函数)储存在一个 list 中

  • 循环依次调用中间件(任务处理函数)

senchalabs/connect 这个库做了很好的封装,是 express 等框架设计实现的原始模型。这里我们简单分析一下 senchalabs/connect 这个库的实现。

用法:

首先使用 createServer 方法创建 app 实例,

const app = createServer()

对应源码:

function createServer() {
  function app(req, res, next){ app.handle(req, res, next); }
  merge(app, proto);
  merge(app, EventEmitter.prototype);
  app.route = '/';
  app.stack = [];
  return app;
}

我们看 app 实例“继承”了 EventEmitter 类,实现事件发布订阅,同时 stack 数组来维护各个中间件任务。

接着使用 app.use 来添加中间件:

app.use('/api', function(req, res, next) {//...})

源码实现:

proto.use = function use(route, fn) {
  var handle = fn;
  var path = route;

  // default route to '/'
  if (typeof route !== 'string') {
    handle = route;
    path = '/';
  }

  // wrap sub-apps
  if (typeof handle.handle === 'function') {
    var server = handle;
    server.route = path;
    handle = function (req, res, next) {
      server.handle(req, res, next);
    };
  }

  // wrap vanilla http.Servers
  if (handle instanceof http.Server) {
    handle = handle.listeners('request')[0];
  }

  // strip trailing slash
  if (path[path.length - 1] === '/') {
    path = path.slice(0, -1);
  }

  // add the middleware
  debug('use %s %s', path || '/', handle.name || 'anonymous');
  this.stack.push({ route: path, handle: handle });

  return this;
};

通过 if...else 逻辑区分出三种不同的 fn 类型:

  • fn 是一个普通的 function(req,res[,next]){} 函数

  • fn 是一个普通的 httpServer

  • fn 是一个普通的是另一个 connect 的 app 对象(sub app 特性)

对于这三种类型,分别转换为 function(req, res, next) {} 的形式,具体我们不再分析。最重要的执行过程是:

this.stack.push({ route: path, handle: handle })

以及返回:

return this

以上就完成了中间件即任务的注册,我们有:

app.stack = [function1, function2, function3, ...];

接下来看看任务的调度和执行。使用方法:

app.handle(req, res, out)

handle 源码实现:

proto.handle = function handle(req, res, out) {
  var index = 0;
  var protohost = getProtohost(req.url) || '';
  var removed = '';
  var slashAdded = false;
  var stack = this.stack;

  // final function handler
  var done = out || finalhandler(req, res, {
    env: env,
    onerror: logerror
  });

  // store the original URL
  req.originalUrl = req.originalUrl || req.url;

  function next(err) {
    // ...
  }

  next();
};

源码导读:out 参数是关于 sub app 的特性,这个特性可以暂时忽略,我们暂时不关心。handle 实现我们并不陌生,它构建 next 函数,并触发第一个 next 执行。

next 实现:

function next(err) {
    if (slashAdded) {
      req.url = req.url.substr(1);
      slashAdded = false;
    }

    if (removed.length !== 0) {
      req.url = protohost + removed + req.url.substr(protohost.length);
      removed = '';
    }

    // next callback
    var layer = stack[index++];

    // all done
    if (!layer) {
      defer(done, err);
      return;
    }

    // route data
    var path = parseUrl(req).pathname || '/';
    var route = layer.route;

    // skip this layer if the route doesn't match
    if (path.toLowerCase().substr(0, route.length) !== route.toLowerCase()) {
      return next(err);
    }

    // skip if route match does not border "/", ".", or end
    var c = path.length > route.length && path[route.length];
    if (c && c !== '/' && c !== '.') {
      return next(err);
    }

    // trim off the part of the url that matches the route
    if (route.length !== 0 && route !== '/') {
      removed = route;
      req.url = protohost + req.url.substr(protohost.length + removed.length);

      // ensure leading slash
      if (!protohost && req.url[0] !== '/') {
        req.url = '/' + req.url;
        slashAdded = true;
      }
    }

    // call the layer handle
    call(layer.handle, route, err, req, res, next);
}

源码导读:

  • 取出下一个中间件

var layer = stack[index++]
  • 如果当前请求路由和 handler 不匹配,则跳过:

if (path.toLowerCase().substr(0, route.length) !== route.toLowerCase()) {
  return next(err);
}
  • 若匹配,则执行 call 函数,call 函数实现:

function call(handle, route, err, req, res, next) {
  var arity = handle.length;
  var error = err;
  var hasError = Boolean(err);

  debug('%s %s : %s', handle.name || '<anonymous>', route, req.originalUrl);

  try {
    if (hasError && arity === 4) {
      // error-handling middleware
      handle(err, req, res, next);
      return;
    } else if (!hasError && arity < 4) {
      // request-handling middleware
      handle(req, res, next);
      return;
    }
  } catch (e) {
    // replace the error
    error = e;
  }

  // continue
  next(error);
}

注意:我们使用了 try...catch 包裹逻辑,这是很必要的容错思维,这样第三方中间件的执行如果出错,不至于打挂我们的应用。

较为巧妙的一点是:function(err, req, res, next){} 形式为错误处理函数,function(req, res, next){} 为正常的业务逻辑处理函数。因此通过 Function.length 来判断当前 handler 是否为容错函数,来做到参数的传入。

call 函数是 next 函数的核心,它是一个执行者,并在最后的逻辑中继续执行 next 函数,完成中间件的顺序调用。

NodeJS 的框架 express,实际就是 senchalabs connect 的升级版,通过对 connect 源码的学习,我们应该更加清楚流程的调度和控制,再去看 express 就轻而易举了。

Senchalabs connect 用流程控制库的回调函数及中间件的思想来解耦回调逻辑;Koa 则是用generator 方法解决回调问题(最新版使用 async/await)。事实上,也可以用事件、Promise 的方式实现,下一环节,我们就分析 Koa 的洋葱模型。

Koa 的洋葱模型

对 Koa 中间的洋葱模型的分析文章上不少,著名的洋葱圈图示我也不在自己画了,具体使用不再介绍,不了解的读者请先自行学习。

我想先谈一下面向切面编程(AOP),在 JavaScript 语言为例,一个简单的示例:

Function.prorotype.before = function (fn) {
  const self = this
  return function (...args) {
    console.log('')
    let res = fn.call(this)
    if (res) {
      self.apply(this, args)
    }
  }
}

Function.prototype.after = function (fn) {
  const self = this
  return function (...args) {
    let res = self.apply(this, args)
    if (res) {
      fn.call(this)
    }
  }
}

这样的代码实现,是我们能够在执行某个函数 fn 之前,先执行某段逻辑;在某个函数 fn 之后,再去执行另一段逻辑。其实是一种简单中间件流程控制的体现。不过这样的 AOP 有一个问题:无法实现异步模式。

那么如何实现 Koa 的异步中间件模式呢?即某个中间件执行到一半,交出执行权,之后再回来继续执行。我们直接看源码分析,这段源码实现了 Koa 洋葱模型中间件:

function compose(middleware) {
  return function *(next) {(
    if (!next) next = noop();

    var i = middleware.length;

    while (i--) {
      next = middleware[i].call(this, next);
      console.log('isGenerator:', (typeof next.next === 'function' && typeof next.throw === 'function')); // true
    }

    return yield *next;
  }
}

function *noop(){}

其中,一个中间件的写法类似:

app.use(function *(next){
  var start = new Date;
  yield next;
  var ms = new Date - start;
  this.set('X-Response-Time', ms + 'ms');
});

这是一个很简单的记录 response time 的中间件,中间件跳转的信号是 yield next。

较新版本的 Koa 已经改用 async/await 实现,思路也是完全一样的,当然看上去更加优雅:

function compose (middleware) {
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  return function (context, next) {
    let index = -1
    return dispatch(0)

    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) {
        fn = next
      }
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

我们来重点解读一下这个版本的实现:

  • compose 传入的 middleware 参数必须是数组,否则抛出错误

  • middleware 数组的每一个元素必须是函数,否则抛出错误

  • compose 返回一个函数,保存对 middleware 的引用

  • compose 返回函数的第一个参数是 context,所有中间件的第一个参数就是传入的 context

  • compose 返回函数的第二个参数是 next 函数,next 是实现洋葱模型的关键

  • index 记录当前运行到第几个中间件

  • 执行第一个中间件函数:return dispatch(0)

  • dispatch 函数中,参数 i 如果小于等于 index,说明一个中间件中执行了多次 next,我们进行报错,由此可见一个中间件函数内部不允许多次调用 next 函数

  • 取出中间件函数 fn = middleware[i]

  • 如果 i === middleware.length,说明执行到了圆心,将 next 赋值给 fn

  • 因为 async 需要后面是 Promise,我们包一层 Promise

  • next 函数是固定的,它可以执行下一个中间件函数

function next () {
  return dispatch(i + 1)
}

如果读者不好理解,可以参考应用示例:

async function middleware1(ctx, next) {
  console.log('1')
  await next()
  console.log('2')
};

async function middleware2(ctx, next) {
  console.log('3')
  await next()
  console.log('4')
};

如果读者还是难以理解,我给出一个简版逻辑:

function compose (middleware) {
  return dispatch(0) 
  function dispatch(i) {
    fn = middleware[i]
    if(!fn) return
    return fn(() => dispatch(i + 1))
  }
}

co 库不再神秘

说到流程控制,也少不了大名鼎鼎的 co 库。co 函数库是 TJ 大神基于 ES6 generator 的异步解决方案,因此这里需要读者熟练掌握 ES6 generator。目前虽然 co 库可能不再“流行”,但是了解其实现,模拟类似场景也是非常有必要的。

我们这里不解读其源码,而是实现一个类似的自动执行 generator 的方案:

const runGenerator = generatorFunc => {
  const it = generatorFunc()
  iterate(it)

  function iterate (it) {
    step()

    function step(arg, isError) {
      const {value, done} = isError ? it.throw(arg) : it.next(arg)

      let response

      if (!done) {
        if (typeof value === 'function') {
          response = value() 
        } else {
          response = value  
        }

        Promise.resolve(response).then(step, err => step(err, true))
      }
    }
  }
}

代码解读:

  • runGenerator 函数接受一个生成器函数 generatorFunc

  • 运行 generatorFunc 得到结果,并通过 iterate 函数,迭代该生成器结果

  • iterate 函数中执行 step 函数,step 函数的第一个参数 arg 是上一个 yield 右表达式的“求出的值”,即下面对应的 response

  • 这里需要考虑 response 的求值过程,它通过 value 计算得来,value 是 yield 右侧的值,它有这么几种情况:

  • yield new Promise(),value 是一个 promise 实例,那么 response 就是该 Promise 实例 resolve 后的值

  • yield () => {return value},value 是一个函数,那么 response 就是执行该函数后的返回值

  • yield value,value 是一个普通值,那么 response 就是该值

  • 我们最终统一利用 Promise.resolve 的特性,对 response 进行处理,并递归(迭代)调用 step

  • 同时利用 step 函数 arg 参数,赋值给上一个 yield 的左表达式值,并返回下一个 yield 右表达式的值

执行代码:

function* gen1() {
  yield console.log(1)
  yield console.log(2)
  yield console.log(3)
}

runGenerator(gen1)

或者:

function* gen2() {
  var value1 = yield Promise.resolve('promise')
  console.log(value1)

  var value2 = yield () => Promise.resolve('thunk')      
  console.log(value2)

  var value3 = yield 2
  console.log(value3)
}

 runGenerator(gen2);

最后还是附上 co 的实现:

function co(gen) { // co 接受一个 generator 函数
    var ctx = this
    var args = slice.call(arguments, 1)

    return new Promise(function(resolve, reject) { // co 返回一个 Promise 对象
        if(typeof gen === 'function') gen = gen.apply(ctx, args) // gen 为 generator 函数,执行该函数
        if(!gen || typeof gen.next !== 'function') return resolve(gen) // 不是则返回并更新 Promise状态为 resolve

        onFulfilled() // 将generator 函数的 next 方法包装成 onFulfilled,主要是为了能够捕获抛出的异常

        /**
          * @param {Mixed} res
          * @return {Promise}
          * @api private
         */
        function onFulfilled(res) {
            var ret;
            try {
                ret = gen.next(res)
            } catch (err) {
                return reject(err)
            }
            next(ret)
        }

        /**
          * @param {Error} err
          * @return {Promise}
          * @api private
         */
        function onRejected(err) {
            var ret
            try {
                ret = gen.throw(err)
            } catch (err) {
                return reject(err)
            }
            next(ret)
        }

        /**
          * Get the next value in the generator,
         * return a promise.
         *
         * @param {Object} ret
         * @return {Promise}
         * @api private
         */
        function next(ret) {
            if(ret.done) return resolve(ret.value)
            var value = toPromise.call(ctx, ret.value) // if (isGeneratorFunction(obj) || isGenerator(obj)) return co.call(this, obj);
            if(value && isPromise(value)) return value.then(onFulfilled, onRejected)
            return onRejected(new TypeError('You may only yield a function, promise, generator, but the following object was passed: ' + String(ret.value) + '"'))
        }
    })
}

如果读者对于以上内容理解有困难,那么我建议还是从 generator 等最基本的概念切入,不必心急,慢慢反复体会。

总结

这道“著名”的“微信”面试题,绝不只是网上分析的几行代码答案那么简单,本讲我们从这道题目出发,分析了几种解决方案。更重要的是,在解决方案的基础上,我们重点剖析了 JavaScript 处理任务流程、控制触发逻辑的方方面面。也许在小型传统页面应用中,这样“相对复杂”的处理场景并不多见,但是在大型项目、富交互项目、后端 NodeJS 中非常重要,尤其是中间件思想、洋葱模型是非常典型的编程思路,希望读者能认真体会。

最后我们分析了 generator 以及 Koa 中间件实现原理,也许读者在平时基础业务开发中接触不到这些知识,但是请想一想 redux-saga 的实现、中间件的编写,其实都是这些内容运用体现。进阶即是如此,如果不掌握好这些“难啃”的知识,那么永远无法写出优秀的框架和解决方案。

Last updated