3

作为一个前端,最近我也踏上了刷题的不归路。本来想着每天留一个小时来复习和反思自己每天刷的leetcode,但是由于leetcode的服务器实在是渣,国内访问出奇的慢,导致这个过程的体验极其恶心。

于是自己写了个leetcode的爬虫,把自己在leetcode上通过的代码爬到本地,核心是ES6的generatorco,工具在此:leetcode-spider。结合自己的使用体验,优化改造了几次,现在这个工具已经挺好用了,已经发布到NPM。

代码已经爬下来了,作为一个前端,肯定是想搞点事情的(误),肯定是想把他们呈现出来的,我自己有博客,那么难道我要把代码一行行复制到博客里面吗,这么多篇leetcode解题报告发到我的博客里面,那我的博客完全就被leetcode解题报告给淹没了。

所以我仔细思考了一下到底该怎么呈现呢,如果low一点,那就用node写个后台,把文件内容读出来Ajax返回给我前端,前端网页呈现出来就可以了,但是这些解题源码全都是静态文件呀,这个网页也不用动态逻辑,那我完全可以基于backend-free(无后台)架构来弄啊,如果你用hexo等静态博客,那你就明白我想做什么了,我先把leetcode解题代码写进json里,然后用Vue2.x做一个单页应用的网页,JS直接向静态服务器发Ajax请求,去请求这个json文件,并把里面的代码内容呈现出来,一个leetcode源码呈现网站就这么搞起来了,而且是纯静态的,发到github pages或者你自己的服务器上,就直接上线了!线上地址在此:leetcode


借助一个社会化评论插件,多说、畅言blabla,评论功能也有了,如果我想写我的解题过程、心得、思路、感慨怎么办?也可以搞啊,我在代码文件旁边写个markdown文件,然后网页也是向静态服务器发请求,去拿到这个文件,也就可以呈现在网页上。于是一个带搜索功能、带评论、带自己的解题心得、带源码、带leetcode题目的leetcode博客就这样搭建起来了。而且如果只是写解题心得,那么根本就不需要别的操作,实时写实时呈现,如果是爬取了新的解题源码,那也就执行一个命令,更新json(时长不超过一秒钟)。整个过程远比你搞个leetcode博客什么的轻松多了。

项目地址在此:leetcode-viwer

爬虫工具leetcode-spider和Vue单页应用leetcode-viewer的详细使用方法大家可以点开链接各自进去查看。都已发至github。

个人觉得这还算是一个挺好的工具的,可以跟千千万万刷题的同学们交流心得,而且更重要的是这完全可以作为一个人展示的平台,找工作的时候把链接放在简历里面作为一项个人算法能力的展示也还是挺好的(嘿嘿,作为一个即将找工作的同学,我就是这么想的)。

具体实现过程

先说说leetcode-spider

既然是要爬虫,那肯定是有大量的异步请求,对于这种高I/O密集的场景,Node.js天然适合啊,但是既然是大量的异步操作,而且是一环扣一环的异步爬虫(也就是说要根据上一步的结果来发起下一步的异步请求),那么肯定要注定写一大堆的回调了,即使完全基于Promise,也是仍然有大量的.then和.catch绑定回调,流程控制能力很差,大量的.then并不比回调地狱的花括号好看多少。

所以必然是要上[generatorco]或者[asyncawait],但是因为想着把这个工具开源和发布到NPM让更多的人使用,上了async的话就意味着需要使用者的Node 7.x版本以上了,场景还是太局限了,而且命令行模式开--harmony-async-await在node低版本上报错,所以决定用[generatorco],如果你用过koa,那么肯定会明白这两兄弟在异步场景的如丝般顺滑的体验。

用了co,那就要保证你yield出来的东西是promise或者是thunk函数,而且co最新版已经明确表示请不要再yield thunk了,co指不定哪天就不支持thunk了,所以就需要完全基于promise,那么问题来了,是不是意味着我需要大量的promise封装呢?当然是不需要的,借助于dead-horse大牛写的thenifythenify-all,可以将基于回调的function转换为返回promise的function。所以我一行promise封装的代码都没有写全交由thenify两兄弟完成。

多说一句,thenify是用于把一个函数promisify,那thenify-all呢。比如node自带的文件模块fs,fs.statefs.mkdir,fs.writeFile等等都是fs对象上的基于回调的方法,如果你用thenify的话需要把上述三个方法逐一promisify,并且用3个变量存起来,使用起来比较麻烦。那使用thenify-all的就只用一行代码:

let thenFs = thenifyAll(fs,{},['stat','mkdir','writeFile']);

现在thenFs这个新对象就是将fs的三个方法promisify后的对象,thenFs.stat,thenFs.mkdir,thenFs.writeFile都是能返回promise的方法了。

爬取过程

说完了promise封装,来说说具体的爬取过程,爬取的过程使用了request来发起请求,使用cheerio来对返回的HTML内容进行解析,解析了之后就可以用jquery的方法查找指定的DOM,这俩是Node爬虫的常用工具不再赘述。一开始先需要用户写好一个JSON文件,里面写明用户名、密码和用来解leetcode的语言。然后就用账户密码登录leetcode,做好cookie管理,然后请求这个接口api/problems/algorithms/就可以拿到这个账户AC了哪些题,接下来到这些题目的页面上爬下代码就可以了。直接一个接一个爬速率肯定太慢,而且用node的优势就是可以并行爬取,而我不用进行并行线程的操作管理。co对于并行发起请求有着非常友好的支持,你可以yield一个数组,并在数组里面存放你想并行发起的promise,或者用Promise.all对数组处理为一个Promise之后再yield,这些都是可行的处理方案。

现在的问题在于用户第一次爬取的时候,他可能AC了200道题,如果我不加控制的直接爬取两百道题,一方面带宽问题存在,另外一方面leetcode的服务器是真的真的很辣鸡,这个问题大家在国内使用leetcode的同学肯定深有感触,我经常都是同时打开好几道题,同时写好几道题,因为打开网页太卡,提交代码太卡,代码出结果太卡....所以直接发出大量请求的话,就导致经常丢包、response返回不完整、连接中断等等问题,所以决定改换策略,对并发数进行限制,当到达任务数上限时,有任务完成时,后续的任务才可以开始,这一块的实现是用了TJ的co-parallel。其实co团队开发了大量的co流程的附属控制工具,如并发请求的容错控制co-gather,只取并发请求中最快的co-any等等,可以在co团队的项目列表里面查看。

剩下的工作就是对爬取结果的保存了,这里没有什么特殊的地方,借助于node自带的模块fs就可以完成,同时,我也将爬取结果保存在了result.json文件里,这样下次再爬的时候通过对比result.json里的信息和从Leetcode网站抓取的信息,我就可以知道哪些代码之前已经爬取过了,不用再爬(毕竟leetcode真的很卡,能节约点时间就节约点时间)。

leetcode-viewer的搭建过程

leetcode-viewer是用Vue2.x搭建的单页应用。之前重度使用Vue1.x,并用Vue1.x自己搭了个博客,但是自从开始刷题以后,就没有跟上Vue的最新发展了,时间基本都投入在刷题和研究生毕设的开题上了,所以也想借这个机会学学2.0版本。

看了下文档,其实没有特别大的改动,主要是新功能的加入,api虽然变动了,但是主体思想还是没变,因此上手起来还是比较快。

首先想了想用户应该怎么样去使用这个网页去构建起他自己的leetcode博客,一方面是觉得刷leetcode的同学非常的多,但是前端的同学不多,不是前端的同学的话怎么用得舒服,所以想到了完全不用后台,后台写逻辑很麻烦,而且别的同学要搭的话肯定得看懂代码自己改,所以决定把要呈现的信息写入到json里面的形式来作为数据的来源,因为任何一个静态资源服务器比如Nginx、Apache或者github pages,国内的git cafe等等,当你把静态文件如json、txt等等放在上面的时候,你直接去请求他们(比如在浏览器里面输入地址),服务器都是会把他们返回给你的。所以就让用户先爬好代码,代码爬下来后执行一行npm run generate(耗时不会超过1秒)把爬下来的代码写进json里,然后单页应用跑起来的时候去请求json就可以了。这样,一个以往应用于hexo等静态博客的backend-free架构的网页就搞起来了。这个网页扔到随便一个静态服务器上就可以上线了。

Vue2.0的踩坑经历

生命周期钩子

Vue 2.0感觉变化最大的就是生命周期的一堆钩子函数变了,不过原先1.0的钩子函数其实并不好用,主要是1.0时期,开启keep-alive和结合vue-router之后,就比较复杂了,当一个组件被切换掉时,他的destroy钩子不会执行了,因为组件没有destroy掉,留在内存里,要用vue-router的deactivate钩子,切换回来的时候组件的ready钩子也没用,要改成用vue-router的canreuse钩子,但是!坑爹的地方来了,有的时候canreuse钩不中,deactivate也钩不中...这就蛋疼了把,所以keep-alive本来是一个挺好的功能的,但是遇到坑的时候真心很麻烦,debug的过程非常痛苦,。

现在生命周期的钩子变了之后,vue-router的组件期间的钩子没了,data,deactivatedeactivatecanActivate等等统统给干掉了,只留下了导航期间的钩子,这让以往一直是在data钩子里写数据获取逻辑的我一开始有点晕,但是写了几行代码后来反而觉得异常清晰了,其实1.0版本的vue-router的许多钩子替代了vue的钩子,导致vue本身的钩子有点形同虚设的感觉了,但是vue-router的钩子又不如vue的钩子好用。现在一刀切了之后思路就简洁了,扰乱也减少了。

但是vue-router的data钩子被取代了,那么按照官方文档,数据获取的逻辑就放在了watch函数里,通过watch $route对象的变动来进行数据获取,但是$在你切换到其他页面之前这个watch方法也在起着作用,所以比如你在watch里写了当前页面的数据获取逻辑,那么当你去到其他页面时,这个数据获取逻辑也依然会执行,这个小问题就不太对了,所以我把我的数据获取逻辑写在了一个if语句里,if先对this.$route.path进行正则校验,校验path是否是当前页面的路由,如果你是切换到其他页面去的,那我就什么都不做。

但是这种方法就使得我在代码里耦合了路由的设置,如果你改路由配置,不仅要改Vue-router的配置,还要记得来改此处的正则表达式,这其实是可能出问题的。如果大家有好的方法,欢迎提出来。

如何确保dom已经在document里了

因为我想为这个leetcode源码的呈现网页引入评论的功能,这样源码的作者就能和读者沟通解题方法和代码。而第三方评论插件并不少,这个功能自然实现起来也没有问题。而畅言需要备案,所以我还是回到了多说

但是多说是一个多年前写的插件了,时不时出bug什么的就不说了,似乎官方也已经不维护了(前段时间微信无法登录多说的问题1个多月才修复),最关键的是他还是以前的那种基于dom操作的插件,你需要全局写好一个对象叫做duoshuoQuery,里面写入参数,然后加载一个外部的多说的JS,JS加载好之后你要自己创建一个div,在div的属性上也写入一些参数,然后对这个div执行一个DUOSHUO.EmbedThread方法,执行好了之后把这个div append到一个已经存在于document里的元素中。

期间踩的一些坑就不说了,就说最后这个append到一个已经存在于dom里的元素中,之前在vue1.0时代就在vue上用过多说,所以如何在vue组件里保证这个组件里的一个元素已经在dom里这其实是一个已经踩过坑。同学别急着告诉我用$nextTick,我在vue1.0时也是第一时间想到$nextTick,可是发现不行,$nextTick执行的时候并没有保证组件的元素已经加载到dom里了。后来仔细查询了一番之后,看到了尤大大在vue-router的一个issue下面回复:

nextTick is intended to be used right after you modified some reactive data.

nextTick是计划在当你更改了某些响应式的数据时使用的。

也就是说,nextTick应该被用在某些计算属性或者watch再或者某个按钮click事件绑定的methods当中。用来保证这些响应式的数据的变化已经反映到dom里,但不是用来保证组件加载过程中dom已经真正加载到document里了。而尤大在那个issue里提到的attached钩子我试了,也并没有保证元素已经加载进dom里。后来采用比较hack的方法,就是不断setTimeout检测元素是否在dom里来实现了功能。

上面这段vue1.0时代的坑,现在到了vue2.0肯定要想着找一个方法解决,首选,如我所料,$nextTick在这个场景下依然不行(其实很好理解,$nextTick是基于MutationObserver,这个API大家可以使用一下,是一个dom在它变化时会触发事件告诉你他发生了变动,而现在我们的使用场景下这个dom都还不存在,$nextTick当然不起作用)。不过,喜讯是mounted钩子起作用了,但是问题又来了,mounted钩子在开启keep-alive之后只在元素第一次加载进文档的时候执行一次,如果你切换到其他页面再切换回来,这个时候因为组件其实mounted过了,是不会执行的,所以你回来以后发现评论框加载不出来了,而给keep-alive组件使用的两个钩子activateddeactivated只能告诉你组件切换回来和切换出去了,并不能保证dom在文档里了。哎 心累啊...

后来于是放弃使用vue的方案来解决,以前在看jquery的$(document).ready()的源码时,了解过,当时因为由于低版本的IE浏览器里,onreadystatechange事件不可靠,所以jquery为了知道dom到底是否进入异步事件状态了,采用如下的代码来实现ready():

try {
    top.doScroll("left");
} catch(e) {
    return setTimeout( doScrollCheck, 50 ); 
}

就是不断的在网页上触发滚动事件,如果不能滚动,那说明还处在加载阶段,就绑定50毫秒以后执行滚动事件,直到网页可以滚动了,就说明网页加载好了,进入异步事件监听状态了。

所以我的实现方式是在组件的滚动事件上绑定了多说的启动逻辑,这样leetcode源码和文章出来后,用户滚动时才进行多说组件的加载工作,同时也可以起到一个懒加载的效果。依然很hack,但是性能上比之前vue1.0时代的setTimeout好了点了。

值得总结的地方

其一就是错误处理,以前用koa的时候大多的是yield 单个异步任务,而现在我在爬取出了你AC出了哪些题之后,就要并行发起很多个异步请求了,也就是yield 一个数组,数组里面存放了很多的Promise,那我是在每个Promise后面写好catch,还是对这个数组执行Promise.all之后再在返回的promise上写catch,还是直接用try catch把整个yield包起来呢,这里就需要对co的整个处理流程烂熟于胸,同时,还需要考虑你的容错策略,而且并行发起请求了之后你是没法把嫁出去的女儿收回来的,如果一个出错了你改怎么办,你知道已经错了一个了在不能收回发出去的请求的情况下该怎么办,错了很多你又该怎么办?所以结合了自己的一些思考和研究。目前已经开了一篇文章讲述co/koa中的错误处理,正在填坑,写好了就发出来。

其二就是并发控制,前面说过用了co-parallel,其实他的代码量很短,最近也会仔细分析一下实现的方法。

todo list

  • 网页的响应式改造

    • 已完成

  • 使用Vuex进行改造

    • 当时想着这个应用的状态不多,状态的兄弟节点传递也挺少,就没上Vuex,现在来看代码在状态管理这块还是太hack


以上大体就是我在写这两个小工具时的思考和过程了。

原发表于我的博客:欢迎围观


ma63d
722 声望105 粉丝