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

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

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

本讲知识点如下:


题意分析

我们来先看看题目:

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342

推荐阅读更多精彩内容

  • 正值金九银十的招聘旺季,我把我珍藏整理多年的前端面试题分享给大家,分三部分。这是第二部分,js相关的很有用的基础知...
    cbw100阅读 840评论 0 19
  • (掌握)Http和Https的区别? http协议和https协议的区别:传输信息安全性不同、连接方式不同、端口不...
    Grit_1024阅读 3,234评论 0 2
  • 本人是去年 7-8月开始准备面试,过五关斩六将,最终在年末抱得网易归,深深感受到高级前端面试的套路。以下是自己整理...
    前端一菜鸟阅读 2,613评论 1 43
  • 2:Vue双向数据绑定的实现 vue.js 则是采用数据劫持结合发布者-订阅者模式的方式,通过Object.def...
    十八人言阅读 332评论 0 0
  • 表情是什么,我认为表情就是表现出来的情绪。表情可以传达很多信息。高兴了当然就笑了,难过就哭了。两者是相互影响密不可...
    Persistenc_6aea阅读 123,926评论 2 7