美文网首页
44.分析一道「微信」面试题

44.分析一道「微信」面试题

作者: ikonan | 来源:发表于2021-07-22 16:44 被阅读0次

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

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

    本讲知识点如下:


    题意分析

    我们来先看看题目:

    实现一个 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 || '', 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 的实现、中间件的编写,其实都是这些内容运用体现。进阶即是如此,如果不掌握好这些「难啃」的知识,那么永远无法写出优秀的框架和解决方案。

    相关文章

      网友评论

          本文标题:44.分析一道「微信」面试题

          本文链接:https://www.haomeiwen.com/subject/irtymltx.html