diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 000000000..e69de29bb diff --git a/2012/not-feeling-good-recently/index.html b/2012/not-feeling-good-recently/index.html new file mode 100644 index 000000000..f3fb49a5f --- /dev/null +++ b/2012/not-feeling-good-recently/index.html @@ -0,0 +1,445 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +最近状态不好 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 最近状态不好 +

+ + +
+ + + + +

开学两周以来身体真的不太好,感觉又和高三后期差不多,不过好在时间没有那么紧,每天可以抽一点两点时间出来锻炼,到现在也感觉有了那么一些好转。霍香正气丸吃了没有用,以后再也不买了。不敢去看医生,实在看不起,重点是看了也白看。

+

本来平常的话也没什么,坏在上学期挂了两科,明天就要补考去,可惜真的没有精力很认真的看书做题所以它要重修就重修吧,大四上多几节课我也不是很在乎。不过从今往后大概真的别挂科了。毕竟没有班长大人的魄力,我还是图样啊。现在饭堂卖的东西也比以前干净很多了,也有面条和粥,而且还很便宜,所以应该不会像以前那样困难,虽然饭吃了可能还是会有点问题。这两周以来嘴巴都感觉特别特别苦,会想喝汽水,会想吃雪糕,可是肚子又胀胀的,不太敢动那些。忌生冷烟酒辛辣油腻,知道的,奶不能喝,青菜少吃,什么什么的,都还记得,所以妈你不用担心我。我有分寸。你要多注意你自己。每次你跟我说晚上痛得睡不着都让我非常揪心。

+

晚上经常会小发烧,可能是炎症吧之前真没考虑到。今天好些,没有。所以最近都穿长袖示街。偶尔还是会感到冷和孤独,不过没有高中那么强烈。今天吃了一天宿舍菜,中间还被小吓一跳,那个新买的电磁炉水都没煲开就怒放两炮,然后随着一丝烧焦的味道就哑火了,还好后来又神奇复活以不至于没东西吃。吃完以后十分想念婆婆和大姨,我想吃炸茄子和葱条和腌菜艾米果…

+

希望可以好起来。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2013/2013-annual-summary/index.html b/2013/2013-annual-summary/index.html new file mode 100644 index 000000000..82a7a9188 --- /dev/null +++ b/2013/2013-annual-summary/index.html @@ -0,0 +1,507 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2013 年终总结 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 2013 年终总结 +

+ + +
+ + + + +

首先感谢郭凯瑞同学百忙中抽出时间接受访问,那么事不宜迟,就开始吧。

+

Q: 今年最开心的事?

+

A: 没挂科,有奖励学分。

+

Q: 这么自信能过马哲?

+

A: 那是明年的事好吧。

+

Q: 还有什么值得开心的事吗?

+

A: 身体健康。 家人朋友都健康。

+

Q: 那么最伤心的事?

+

A: 婆婆去世了。

+

Q: 有多伤心?

+

A: 很伤心。

+

Q: 还有其它不开心的事吗?

+

A: 妈妈变老了,身体毛病多,不过今年做手术治好了肠胃。

+

Q: 现在在做什么呢?

+

A: 回答问题。

+

Q: 好吧,在这之前呢?

+

A: 准备音乐鉴赏考试。

+

Q: 会难吗?

+

A: 小菜一碟,相比马哲。

+

Q: 今年的大学生涯,过得如何?

+

A: 无惊无险。

+

Q: 修了些什么课程?

+

A: 多数是专业基础课,少量专业选修和公选课。

+

Q: 感觉掌握了相应知识吗?

+

A: 我只能说没挂科。

+

Q: 有认识新的朋友吗?

+

A: 没有。

+

Q: 为什么?

+

A: 太宅了,没办法。

+

Q: 不改变一下吗?

+

A: 付诸行动总是很难的,明年再来问这个问题吧。

+

Q: 有没有坚持运动?

+

A: 坚持了一段时间,主要是慢跑。说到运动,我想起一件事。

+

Q: 什么事?

+

A: 那个体能测试,引体向上我一个也做不了,然后我让计分的同学给我记了八个。

+

Q: 真的一个也做不了吗?

+

A: 真的一个也做不了。

+

Q: 这真是羞耻啊,这么差劲,怎么找女朋友。

+

A: 我也是这么觉得的。

+

Q: 你还说谎了。

+

A: 要是早知道重在参与,这就不会发生。

+

Q: 回到朋友这个话题上吧,你希望认识多一些朋友吗?

+

A: 自然希望。

+

Q: 希望认识什么样的朋友呢?

+

A: 异性朋友。。。

+

Q: 你没有异性朋友吗?

+

A: 有,太少了。

+

Q: 多少?

+

A: 我拒绝回答这个问题。

+

Q: 好吧,那么,再回到学业上,觉得自己的专业知识水平达到了什么地步呢?

+

A: 中等略偏上吧。

+

Q: 很快就大四了,能找到工作吗?

+

A: 现在肯定是不够的,明年要加把劲,继续努力。

+

Q: 找不到工作怎么办?

+

A: 找原因吧,肯定是自己还不够好。

+

Q: 会有紧张感吗?

+

A: 有一些。

+

Q: 对今年总结一下?

+

A: 肯定是悲伤的一年。

+

Q: 对明年有什么期待?

+

A: 身体健康,六级能过,不要挂科,学习多一些专业知识,认识多一些朋友。

+

Q: 好的,时间差不多了,这次的总结就到这里吧。

+

A: 谢谢。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2013/review-of-an-unexpected-journey/index.html b/2013/review-of-an-unexpected-journey/index.html new file mode 100644 index 000000000..b30f52495 --- /dev/null +++ b/2013/review-of-an-unexpected-journey/index.html @@ -0,0 +1,446 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +《意外之旅》 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 《意外之旅》 +

+ + +
+ + + + +

本来也没有抱很大希望,所以算是乐在其中。最让我开心的是看到了熟悉的人和事物,戒指坠地的声音依旧震慑人心。甘道夫虽然说年轻了六十岁但是看起来更老了,另外就是大招的冷却时间明显缩短了。然后剩下的内容,基本可以用“吃饭睡觉打兽人”概括, 而且可以看出六十年前的兽人智商还不太发达。有点像成龙大哥的风格,相比艰辛,更多的还是幽默。

+

这一次只能说中规中矩,如果想要惊世骇俗吃老本肯定是不行的了,我设想的话,既然都不搞原著了,那么第三部不如来个惊天大逆转,矮人勇者斗巨龙团灭,甘道夫和比尔博灰头土脸踏上归乡之路,这叫道高一尺,魔高一丈!

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2013/review-of-cp5/index.html b/2013/review-of-cp5/index.html new file mode 100644 index 000000000..92dcd10f3 --- /dev/null +++ b/2013/review-of-cp5/index.html @@ -0,0 +1,458 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +仙五感想 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 仙五感想 +

+ + +
+ + + + +

前两天觉得实在无聊,花了50人民币买了个仙5KEY,并且挂一晚上同时下载了5和5前,本来打算都玩玩看的,现在有谁想要玩的吗我给你KEY,不过只有一个哦,而且是5不是最新的5前。不准备购买5前KEY了。

+

老实说我也还没有耍通关,大概是走了一半多一点的剧情,不过确实没什么耍下去的愿望了,于是就广泛观察了一下广大玩家的意见,基本还是毁誉参半。支持者的观点也基本还是那几句,你不懂仙剑,你不懂感动,你不懂传承。反对的人,提出意见的人和建议改革的人一般都会被喷的很惨,尤其是那些用国外优秀游戏作品和仙剑做比较的,反观这个人群却能够更加理性的和广大支持者辩论。当然从四代开始正版的销量也成为了广大玩家所认可的仙剑成功的标志之一,在我个人看来这是一个很可怕的现象,如果仙剑系列的开发者也认为这是他们作品的一个成功标志的话那么这个游戏就彻底完蛋了。中国人开始买正版仙剑和上海软星的解散有直接关联,仔细想想是能够知道为什么的,这是一种民族情怀,不是因为它成功,而是希望它成功,希望终有一天我们的游戏文化也可以走出国门,而不是占山为王固步自封一万年。仙剑是国产游戏业的第一品牌,谁都希望它能够越做越优秀,这点还是统一的。

+

那么我也来说一说对5代的感想。

+

首先仍然是很传统的人设和故事,大大咧咧的男主角,误打误撞认识了一位知书达理的女主角,然后还有一个英俊帅气的男二号,和另外一位蛮横霸道的女主角,一共是四个人。然后就是混杂着各种纠缠不清的关系的剧情发展,到最后男一号自然是打败了为害人间的大魔头,但是却牺牲了其中一名美丽可爱的女主角,于是又引发了各种凄美的爱情故事。恩,至于5代后面的剧情我就是看攻略得来的了,暂时还没有亲身体会。这个主角阵容几乎从它祖宗开始就是这个模样,俊男美女闯六界,所以也没什么好奇怪的,这个剧情嘛也就这个样子,广大人民群众喜闻乐见。然后就是回合制战斗模式加强加强再加强版,仙剑虽然每一代都是回合制,但是又每一代都有新花样,这个新花样也会成为正式发布前的宣传重点之一。至于这一代的亮点,第一,我个人认为是李逍遥的回归,毕竟这一代是姚仙的孩子,给足了逍遥哥戏份,对于一代迷的我来说,很满意,第二,就是它的剧情配音,感情丰富,声调饱满,非常幽默,十分满意,逗笑了我很多次。5前对于角色数量方面似乎有很大创新,可能是基情与百合的发展使得游戏也不得不跟上时代的步伐。

+

然而这次我想说的重点不在这里。

+

一代的画面是仙剑系列永远的痛,于是后代仙剑人从来没有放弃过对画面的追求,从一代的数格子,到二代的线条2D,到三代的方块3D,再到4代5代的真3D。5代的画面在我看来已经非常成功了,各种光影,渲染,迷雾,反射,应有尽有,色彩鲜艳,场景宏大,角色的模型也是很有进步,丝毫没有愧对玩家的期待。问题就出在这里,在这如诗如画的梦幻般的游戏过程里,我完全感觉不到游戏制造者的诚意。

+

就提几点吧。都是些细节。

+

第一,仙剑奇侠传系列的主角们,从1995年至今,嘴巴从来就没有动过,但是他们却会说话。难道这也是特色传统之一吗?不要跟我说以前还没有这样的技术,李逍遥在1995年不用动嘴巴,到了2013年仍然是不用动嘴巴,这说明他天生就不用动嘴巴。腹语术。

+

第二,太空步无处不在,真的又好气又好笑,尤其是当角色上下楼梯的时候,已经不能用不自然来形容了,简直是灵异事件。当然,毫不客气的说,这也是传统之一。

+

第三,这点很重要,游戏角色永远只有屈指可数的动作,然而制作人又想要用这些动作来表达复杂多变的游戏情节,于是后面是怎样的一组情形就不必多言了。这个情况是从仙剑系列踏入3D,也就是第三代开始的,当时由于技术限制,我并没有太大的关注这个问题。可是到了今天游戏制作人仍然没有一丝一毫想要改进的意思,一方面想要让角色尽量生动,一方面又偷工减料不制作实时动作,让我感到非常可笑。一个人进门怎么表现呢?凭空消失呗。一个人给另外一个人一件事物怎么表现呢?手突然平举呗,事物还是腾空的呗。像这样的画面堂而皇之的出现在近距刻画中,在今天我觉得难以接受。

+

第四,历代都在期盼的角色实时换装系统千呼万唤不出来,再飘逸的服饰装备设计也失去了意义。不要说换了武器能体现,如果连这个都不能体现,我早喊QNMLGB了。

+

第五,角色进入居民屋可以翻箱倒柜,顺手牵羊,这个是真正的传统,我不知道姚仙在今天对于这个设计是怎么样的一个看法。

+

还有很多,不列举了,关于这些问题,只希望仙剑开发者有朝一日能发现并解决之,这将是对所有仙剑爱好者极大的鼓舞。这些就是细节,细节就是诚意。

+

至于我为什么半途就失去了将仙5通关的愿望,并不是因为游戏性,仙剑系列每一代的游戏性都半斤八两,只不过由于画面不断提高,所以才显得它的游戏性愈加飘渺,想要体验游戏性的话膝盖中箭才是最佳选择。我只是对这一代的主角全无好感而已。当然,对四代的主角也全无好感。这两代的人设简直就是同一个妈生的。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2013/review-of-gravity/index.html b/2013/review-of-gravity/index.html new file mode 100644 index 000000000..86d336f22 --- /dev/null +++ b/2013/review-of-gravity/index.html @@ -0,0 +1,452 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +《地心引力》 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 《地心引力》 +

+ + +
+ + + + +
    +
  • 很独特的设定。地球很美丽,宇宙很黑暗。
  • +
  • 镜头经常走得很慢,处于宇宙中的各种物体也看似很慢,其实不然,经常在对比建立起来一瞬间就能感觉到可怕的速度。这样的镜头处理也让我觉得很独特。 而且对主角有很多又长又慢的镜头特写,然而影片的节奏并不慢。
  • +
  • 片头直入主题,片尾紧凑收官,90分钟全无拖沓,难能可贵。
  • +
  • 非常酷炫的3D效果,感觉又是一次突破,太空碎片往荧幕外飞的时候老夫的面部神经抽搐了很多下,从未有过的体验。感觉imax的地心引力会非常精彩!
  • +
  • 女主遇到的连续挫折感觉已经超出了人类所能接受的极限,即使在最后一刻,依然存在挑战。
  • +
  • 太空垃圾真的不会形成一个地球专属的小行星带吗?
  • +
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2013/some-memory/index.html b/2013/some-memory/index.html new file mode 100644 index 000000000..60fc29578 --- /dev/null +++ b/2013/some-memory/index.html @@ -0,0 +1,448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +留念 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 留念 +

+ + +
+ + + + +

玩了那么多年,经历了大大小小的版本更替,如今已脱胎换骨,与当年小小的一张地图相比已经面目全非,不得不说这也是一种软件的生命周期,或者开发模式。陪伴了老夫孤独寂寞的高中时光,消磨了大量宝贵的大学时间,亦由此对它产生了深厚的感情。如今大家各奔东西,在一起的时间越来越少,于是dota也渐渐变得没什么意思,偶尔上线也只是习惯所趋。

+

我还记得我玩的第一个英雄是胖子,那么第一把自然是坑队友了,然后下一把玩了个传说哥,大家都懂的。不过好在老夫war3功底雄厚,渐渐也有了起色,开始没打算继续下去的,是因为身边的朋友在老夫带领下居然也开始喜欢dota,于是大感欣慰,遂征战至今,主要也是因为当年3C实在前路渺茫。朋友不多不少,刚好足够开一间黑店,可惜连跪几乎已是命中注定的剧本,于是我又要批评一下达Q了,你TM能不能不要裸秘法。徐尘是老夫最喜欢的选手,低调不失华丽,实力与智商兼顾,还会拍马屁。至于贝伦同学尽心尽力辅助了这么多年,只能说辛苦了。椰子同学自从退伍归来后实力大减,简直成为团队毒瘤。。好吧开玩笑的。

+

从最开始的QQ平台开始,也不知道到底耍了多少把了,印象深刻的也没有多少,只能说记性不好。无数个白昼与通宵,就在这上面一点一点的消逝去了,这里却留下不少回忆,比如小林被他不知道什么亲属拽回家去的那晚,以为是个抢劫的,老夫差点就拍案而起。还有一次和徐尘通宵,第二天一早老夫回学校睡觉,下午睡醒吃饭回到网吧看到他居然还坐在那里继续操作,那个哭笑不得。记得那时候的水饺,炒饭,泡面,汽水,和各式各样的FirstBlood。

+

如今大势已去,dota虽然还在发展,却已不适合你我,只能当做茶余饭后之娱乐了。不过也好,人总要成长。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2013/untitled/index.html b/2013/untitled/index.html new file mode 100644 index 000000000..379302627 --- /dev/null +++ b/2013/untitled/index.html @@ -0,0 +1,446 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +无题 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 无题 +

+ + +
+ + + + +

时间过得真快,转眼就大学三年级了,两年前作为新生的各种场景依然历历在目,像是昨天一样,当年的小软工如今已几乎是大师兄,不得不时时拷问自己两年来到底学到了什么,学到了多少,有什么资格。去年还没有什么感觉,如今比较强烈了。而且也开始想两年后我会在哪里。实在是前路茫茫啊。是工作呢,还是要去读研比较好呢。我个人还是倾向继续读书。唉,不知不觉就大龄青年了。真是岁月催。

+

大学读下来,从前很多选择也慢慢觉得如果再理性一点的话,或许会有变化。不过人还是脚踏实地的好,往事已去不再追。今年婆婆去世了,即使到现在还是很难接受的事实,不过我也知道时间带走一切,不知哪刻自己也将被带走,能做的只有珍惜。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2014/2014-01-29/index.html b/2014/2014-01-29/index.html new file mode 100644 index 000000000..46b8edee6 --- /dev/null +++ b/2014/2014-01-29/index.html @@ -0,0 +1,448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2014-01-29 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 2014-01-29 +

+ + +
+ + + + +

很快就过年了。

+

回了一次赣州,小姑的生意越做越大了,店面越来越多,小时候第一次去广州好像还是在一个住房里做事,应该十多年了吧,跟着老妈参加了他们的年会,很多比我小的男孩都参加工作了,家里的两个弟弟也都在工作,唯独我还在读书,有种说不出的感觉。郭慧姐应该快生小孩了吧,罗云哥嫂子的小孩也快了,不知道会叫什么名字呢。奶奶身体好像还不错,挺好的。

+

在大姨家住了几天,大姨做的菜还是很好吃,比婆婆做的都要好吃,但是有些东西还是婆婆做的更熟悉。谢金宏读五年级了,看起来还是小时候那个样子,不过这次没有看到他哭,大概也算长大了一点。后来感冒了,很不舒服。去水东走了一次,姑奶家养了很多狗,有小狗也有老狗,婆婆家的房子还有人住,不过是租给的别人,记忆已模糊得不可辨识。

+

前天回家,昨天宅掉,今天出去走了一圈,没什么变化。最近发现自己变得犹豫了很多,不喜欢这样,一直觉得自己做起事来都是干净利落的,但这次不知道为什么。真的很抱歉,让你看到一个这么寡断的我。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2014/2014-11-11/index.html b/2014/2014-11-11/index.html new file mode 100644 index 000000000..cf5eb9d12 --- /dev/null +++ b/2014/2014-11-11/index.html @@ -0,0 +1,451 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2014-11-11 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 2014-11-11 +

+ + +
+ + + + +

之前写过一篇很长的关于异形的东西,不知道为什么发不出来。可能有关键字被和谐了,但我又找不到,后来草稿不知道为什么也没有了,明明保存了的。没有了我也不想再重新写一遍了。 说一下最近看过的一些有点意思的电影。

+

先说印象比较深的,Atonement(中文名《赎罪》),其实这电影发展过程一般吧,主要的亮点是在结局,当然我也不能说太多,否则剧透了就很没意思了。当时我是很惊讶的,一部电影成功真的很靠原著小说以及剧本。这是一个悲剧,西方,二战时期,题材为爱情。另外一个亮点在于打字机的使用,作为配音是很独特的存在,当时也刚好ios有一个打字机应用(好像是靠汤姆汉克斯宣传的),所以印象比较深刻。

+

然后是李安的《色戒》,这部电影出了那么久一直没有看, 可能主要是被大多数人对它的关注点误导了,觉得看这种电影还不如去看爱情动作片。后来发现其实色戒是一部非常优秀的电影吧,只能说非常优秀。也是悲剧,中国,抗战时期,题材为爱情,谍战。其实我推荐家里人看这部电影的,比如爸妈,因为好像大家都比较喜欢看谍战剧,然而关于谍战的电影很少,也少有优秀的作品。色戒的背景内容庞大,但整部电影的视角一直限制得很小,剧情紧凑,毫不拖沓,主题突出,以小窥大,得益于李安深厚的功力。

+

后来有一段时间比较无聊,看完了《异形》系列。由于刚才说的原因,我都没兴趣多写了。反正我感触还是很深的,但电影其实拍的一般般,可能是年代的原因现在看来没什么触动。它的题材很好,期待普罗米修斯的续集。

+

说到科幻片,最近也看过一些科幻片,主要是因为之前看了阿汤哥的《明日边缘》,对科幻片的热情又高涨了。明日边缘真的是一部非常,非常,非常优秀的科幻片,我甚至觉得是我看过的最优秀的科幻片,可能有些夸张了,但就像当年看完盗梦空间以后难以抑制自己激动的心情,一定要给这部电影打满分的感觉,明日边缘也是这样的一部电影。相比于其它的外星人入侵地球的电影,明日边缘对于外星人的设想非常有意思,以至于电影也陷入了一种很特别的节奏。如果仔细想想的话,它的看似狗血的结局也是非常值得推敲的。这部电影的题材是科幻,外星人,四维空间,拯救地球。美国,未来。

+

然后还看了邓肯琼斯导演的《月球》,这部电影相比明日边缘就比较文艺了,但同样有比较特别的剧情,看似科幻片,我觉得其实是一个关于人类道德的电影。另外比较特别的一点是,这是一部独角戏(参考《我是传奇》),一般来说这种电影逼格都比较高,这部电影也是如此啦。然后它除了剧情设定以外并没有其它的比较大的亮点,所以总体感觉一般吧。美国,未来,题材为科幻,克隆。

+

另外看了《机器纪元》 以后,觉得最近的科幻片都比较探讨人性啊,都不是在往科幻片应有的路子在走。这部片子说的东西还是挺有意思的,不过剧本感觉缺少冲击力,就是没什么能让人感到眼前一亮的东西,比较平淡的电影。之前上人工智能课的时候听周密说,“人类需要完成的最后一个发明就是智能机器人”,这部电影说的就是这个啦!为了让智能机器人不成为人类的最后一个发明,人类为其设定了两个原则,一是机器人不能伤害任何生物,二是机器人不能维修自己。但是智能机器人明明比人类要智能得多,又有什么技术制定的原则能限制得了智能机器人呢。美国,未来,题材为科幻,机器人。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2014/nearing-the-duan-wu/index.html b/2014/nearing-the-duan-wu/index.html new file mode 100644 index 000000000..145a16eaa --- /dev/null +++ b/2014/nearing-the-duan-wu/index.html @@ -0,0 +1,448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +临近端午 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 临近端午 +

+ + +
+ + + + +

很久没有回过家,也没有关心过家里的情况了。今天和妈妈说了几句话,得知最近有发烧生病,虽然说已经好了,但还是觉得不知道怎么的。五十多岁了,一个人在广州上班,没亲没戚的,生了病也没哪个照顾,也不跟我说。婆婆去世以后我就应该要照顾我妈了,去年在赣州大姨也跟我说过,可惜还没有毕业,分身乏术。唉,真是忧伤。真希望你退休了吧,别干了。

+

天气变得很热很闷,情绪也变得特别容易坏,很可能因为一些琐碎事情发脾气,像今天早上,本来不应该发生这样的事的,我应该更关心你一些,而不是独自生闷气。现在想来确实后悔,不过微信是真的没有必要了。

+

这学期过的很快,感觉是大学这么久以来最快的了吧,已经快十四周了,又要结束了。过得快的原因大概有几方面,一直很忙,基本没停过,到现在也是很多事情在做,都接近尾声但又没有结束,所以有时候会觉得很多事情要做但又不知道要做什么。遗憾的是还没有找到实习,暑假仍没有着落。这学期和web有关的东西做的比较多,不过我不太希望这是最终的方向。

+

找了一个女朋友,很喜欢,希望可以有多远走多远。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2015/2048-game-base-on-jquery/index.html b/2015/2048-game-base-on-jquery/index.html new file mode 100644 index 000000000..8fbf73b93 --- /dev/null +++ b/2015/2048-game-base-on-jquery/index.html @@ -0,0 +1,452 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +jQuery 写的 2048 小游戏 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ jQuery 写的 2048 小游戏 +

+ + +
+ + + + +

2048

+

Game: http://wxsms.github.io/jquery-2048/

+

Code:https://github.com/wxsms/jquery-2048

+

几年前还在学校的时候刚学 JS/jQuery,为了找点事情练练手寻思着做点什么,当时又特别沉迷于一个叫 2048 的小游戏,于是就有了这么个东西。刚做出来的时候开心了好一阵子,现在回头看代码觉得简直惨不忍睹,根本不像是一个学过算法的人写出来的,字里行间充斥的都是简单与暴力。那时候主要是为了学一门新语言就没有在意这些东西。以后有时间再来优化一下。 在开始的时候是有记分,重启,排行榜一票功能的,现在为了纯粹一点就把垃圾都去掉了。代码过于恶臭就不说了。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2015/blog-migration/index.html b/2015/blog-migration/index.html new file mode 100644 index 000000000..78d8ad33e --- /dev/null +++ b/2015/blog-migration/index.html @@ -0,0 +1,446 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +博客迁移 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 博客迁移 +

+ + +
+ + + + +

原来的博客太简陋了(虽然现在依然很简陋),一直想改都没有时间,最近在公司培训了三个月的Java也发现自己差不多忘了怎么写C#代码,曾经觉得很顺手的IDE用起来也不习惯了,反正就是改不下去了。鉴于工作以后空闲时间变得捉襟见肘,最终还是放弃了自己动手的想法,直接用了模板博客。虽然没有了一切如己所愿的快感,但毕竟是开源软件,想怎么玩都可以,感觉还是不错的选择。

+

7/25/2019 注:当时是迁移到了 WordPress

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2015/ext-usage-summary/index.html b/2015/ext-usage-summary/index.html new file mode 100644 index 000000000..b13aa6226 --- /dev/null +++ b/2015/ext-usage-summary/index.html @@ -0,0 +1,476 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Ext 使用总结 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Ext 使用总结 +

+ + +
+ + + + +

公司要求会用Ext Js,没办法必须学,下面总结了一些学习与使用过程中的经验。

+

从一开始就使用英文文档

这点是我认为最重要的,有人也许会觉得很奇怪,不是有国人翻译的中文文档吗?而且很全,为什么要强行用原版呢?

+

两种文档我都参考与使用过,最终还是选择了直接使用原版文档。最重要的原因是Ext本身自定义的名字与行为很多,不去看原版的话很难将两个或多个多个地方出现的同一个词语联系起来,另外这些词语也与代码有最直接的联系,看翻译版的话也许很容易就会出现被翻译误导的情况,翻译能正确地指出一个词的意思,但不能建立像原版那样名词之间的联系。

+

另一个原因是,英文文档中自带了很多基本与扩展使用的例子,我们在完成一些日常功能的时候都可以去参考文档内的例子而不用自己去根据API文档苦思冥想。

+

需要大量使用Ext文档的时候我们一般可以把它下载回来到本机搭建一个本地文档,这样就可以解决国内访问国外网站延迟较高的问题。需要注意的是文档需要部署在Http服务器(如Tomcat,Apache等)上方可正常访问。

+

下载文档的链接可以在http://docs.sencha.com/extjs/6.0/找到,如图所示:

+

+

关于是否使用Sencha Architect

Sencha Architect的所有功能都可以无限期试用,基本上就是一款免费软件,它可以帮助Ext Js开发者使用可视化界面快速开发复杂结构与逻辑的页面,以及添加自定义插件,缺点就是做出来的页面在build之前体积非常大(一个页面大概100至200 MB),build以后也经常会出现问题。用不用它是一个很纠结的问题。

+

公司开发部门的老师们看起来都不太喜欢这款工具,主要原因大概是使用工具会妨碍自己对代码的理解,担心在脱离工具以后不能很好地掌控代码的行为(准确地说应该是不如从一开始手打代码掌控得好)。但我觉得这对于一个有一定经验的Ext开发者应该完全不成问题,不知道为什么老师们不喜欢这款工具。手撸Ext界面是一件再痛苦不过的事,一个复杂的Ext页面动辄成千上万个括号,根本不是普通代码的形式,可阅读性与可修改性都非常差。而且Ext号称完全兼容IE,但这是基于我们的代码不出错的情况,IE浏览器相比于其它浏览器最常见的问题是Js数组最后面的元素多了一个逗号后导致报错,很不幸Ext简直就是这种情况的代言人,更致命的是一个逗号就能够导致整个页面崩溃而且IE调试工具根本找不到这个错误所在,于是才出现了神奇的“二分调试法”。

+

我个人的建议是,Ext Js的初学者与有经验的开发者都可以尝试使用Sencha Architect去搭建View页面,但最好仅限于View页面,搭建完最初始的结构后就可以将代码Copy到自己的项目中去,然后转为自己编码控制。毕竟Ext的工具性这么强,也不在乎再强一点,Ext的Layout学到飞起来又怎样,对其它形式的Web开发没有任何帮助,然并卵。

+

Config, Property, Method与Event

Ext的所有类一开始对于这几个概念的理解比较模糊,尤其是Config与Property之间的关系,后来用多了才慢慢开始感觉到其中的区别。

+

Config是在创建一个Ext类的时候给予的参数,相当于Java中Constructor的参数,有些时候某些config是必要的,有时候又有默认值,每个类所需要以及能配置的config都不太一样,所以在我习惯在使用之前查看一遍文档,顺便吐槽一下,个人觉得在Ext的世界里只有文档和源码是可靠的,经验往往不可靠。Config在配置以后不能通过直接的方式修改,修改必须重新配置该类,使用 Ext.apply 或者 Ext.applyIf,最后再说一遍,不要直接去修改config中的变量,这样往往会导致预想之外的结果。

+

Property很容易就能联想到成员变量,这里的值在类创建后就会存在,可以被修改(与config的区别),不过在Method中一般能找到与其对应的Getter和Setter方法,所以一般情况下也不会直接去修改Property的值。

+

Method自然而然就是一个类中的成员方法了,一般是在controller里面使用。Ext的文档中会列举所有该类本身以及继承的方法,在使用任何方法之前都强烈建议先查看文档,可以找到方法接受的参数,行为以及返回类型,在Ext的世界里面游戏规则最重要,一切按部就班才不会出问题。

+

Event是该类可以触发的事件,并不是所有类都能触发所有类型的事件,比如panel并不能触发Ext的Click事件,所以,在绑定事件之前也有必要查看文档,文档会介绍事件的触发条件以及返回参数,十分有用,千万不要自己去想像。当然这里可以有些许的变通,我们知道Js本身可以对任何DOM节点绑定任何事件,所以当我们需要做一些Ext不打算帮我们做好的事情的时候也并不是没有办法。

+

HTML Element, Element与Component

HTML Element

HTML Element就是Ext对HTML世界中最普通的DOM节点的称呼,后两位是Ext自己创造的东西,也是其精华所在(精华与糟粕并存)。不过,就算Ext没有了Element与Component,它依然是一个出色的MVC框架。

+

Element

+

Encapsulates a DOM element, adding simple DOM manipulation facilities, normalizing for browser differences.

+
+

正如其文档所言,Element是Ext对DOM节点进行了一次封装以后产生的对象。该对象的主要目的是为其添加一些简单的DOM操作,以及标准化浏览器之间的差异。构造这个对象的目的与jQuery对DOM封装的目的非常相似,不过对Ext来说,它主要的目的应该还是为更上层的建筑服务。

+

Component

Ext的精华与糟粕所在地,吐槽重灾区。

+

Ext的Component基本上涵盖了Web开发平常会用到的所有控件与功能,极大地简化了Web开发的流程,我们只需要在用的时候创建一个相应的component,Ext就会为我们完成所有事情,其中Grid(表格)控件更是登峰造极,让人不得不服,基本上一个人一般情况下能想到的所有功能都能被一一满足。同时,Ext所创建的十数种Layout类型更是为这些Component提供了强大的定位支持,再也不用担心做不出像样的页面。同时,Ext考虑到开发者可能不一定能在任何情况下满足,也提供了插件与自定义控件的开发方式,至此,component已经超神了。

+

但是这些强大且丰富的component也带来了或多或少的问题。

+

表面上看起来强大的东西,它背后一定更强大,所以,Ext真的非常强大,2 MB走起的Js库文件,远远超出了前端所能容忍的极限。搭一个本机环境跑一个debug库,页面刷新一次居然要超过两秒,复杂的页面更是四五秒,可见一斑。所以这东西注定只适合在内网用。

+

Ext 4.0开始,构建一个component所用到的HTML元素变得非常臃肿,一个普通的panel创建出来少说要七八层结构,这些都是为了高度可定制性做出的妥协,而且我们为了一些布局的需要常常会大量使用容器类 component,其后果就是浏览器不堪重负,渲染效率大打折扣。结合上一点来看更是惨不忍睹。

+

Ext的component虽说可以自定义,但是实际上一些复杂度高的需求自定义起来会比较困难。首先它必须继承某一个component,因为我们生产中不可能从头开始写,所以需要熟悉原来的component有哪些东西。然后我们在自定义的过程中也不能脱离Ext世界的游戏规则,否则这个新的东西很可能就会与其他已有的component脱节。一种常见的情况就是在操作component的一个element或者HTML element的时候一定要使用Ext提供的方法,否则就会产生预料之外的结果。

+

版本更替与版权问题

Ext不是免费工具,商业使用需要购买,即使是个人使用也只能将项目开源才能享有免费版的Ext Js,虽然Js代码都是想用就能用,但是作为公司的话还是要考虑一下这个问题。这个原因也导致了Ext的网络资源非常缺乏,系统的教程非常少,基本上遇到了问题都只能在文档里面打滚。

+

Ext大版本之间基本不兼容,改动代价也非常高,所以我目前见过的都是选择了一个版本就只能一个版本用到死的情况,另外再补一刀,就算购买了低版本的使用权,再想购买升级版依然需要付费。

+

总结

个人不太喜欢使用Ext作为前端框架,虽然是作为非常适合搭建内网系统的技术存在,抛开千遍一律的UI不谈,用过Ext的人应该都有一种被包养的感觉。Ext帮我们做好了所有事情,久而久之我们就会忘记事情原本应该是怎样的,从而走上一条不归路。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2015/first-mid-autumn-after-graduation/index.html b/2015/first-mid-autumn-after-graduation/index.html new file mode 100644 index 000000000..cbd15f9a3 --- /dev/null +++ b/2015/first-mid-autumn-after-graduation/index.html @@ -0,0 +1,447 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +毕业后的第一个中秋 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 毕业后的第一个中秋 +

+ + +
+ + + + +

学生渐渐开学,才意识到毕业以来已经过了一个暑假的时间。公司为期三个月的培训终于快结束了,我也终于有空回家休息一段时间。培训结束后,感觉自己的变化除了学到的知识以外,就是多了一些自信,对很多东西的理解不再是处于未知或一知半解的状态。学习使人进步。

+

挺久没有回过家,上一次应该是在五一的时候,所以比较想念家人。不知道家里现在是怎样的了,应该没有什么变化。前段时间出租屋的椅子坏了,往后靠就会摔倒,想买一把才发现椅子挺贵的,房东不给换,郁闷的时候想到在家里从来没有操心过类似的问题,只要跟爸爸或者妈妈说一句就会有替代品,虽然可能不合己意但却不需要付出任何代价。这些事情可能只有在独立生活后才能发现,饭要自己做,碗要自己洗,衣要自己晾,门要自己锁,下班回来累了一躺就是到半夜,醒来发现灯还亮着,门禁卡还戴着,一看手机早上四点多,这时候就能体会到一些孤独。体会到在家是多么的幸福。感谢爸妈给我的回忆里充满的都是快乐。

+

马上过完今年的生日,我也要24岁了,人生走到了一个过渡期,从学生到打工者,从学校到到职场,时间过得这么快,觉得有一些不适。生活还没有转变过来,以后的路还那么长,对未知的未来充满了恐惧。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2015/git-ssh-key-gen-and-gitextension-configuration/index.html b/2015/git-ssh-key-gen-and-gitextension-configuration/index.html new file mode 100644 index 000000000..6ee36eb93 --- /dev/null +++ b/2015/git-ssh-key-gen-and-gitextension-configuration/index.html @@ -0,0 +1,459 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Git SSH key 生成与 GitExtension 配置 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Git SSH key 生成与 GitExtension 配置 +

+ + +
+ + + + +

使用ssh key配置git可以省去每次操作时输入ID/Password的麻烦,操作一旦频繁起来还是很有必要的。实际操作需要添加一些环境变量,或者到git/bin目录下执行。

+

设置Git的默认username和email

这一步没有验证过是否可以省略。

+
$ git config --global user.name "xxx"
$ git config --global user.email "xxx@xxx.xxx"
+ +

本地生成SSH Key

查看是否已有密钥

有的教程说通过 $ cd ~/.ssh 查看目录是否存在,不过我的机器上测试无论有没有这一步的结果都是不存在。所以我的方法是到c:/users/username/下查看是否存在.ssh文件夹,存在则将里面的内容删除。

+

生成密钥

执行 $ ssh-keygen,连续回车确认,到最后 ssh key 就会在 .ssh 文件夹下生成,带 .pub 后缀的为公钥。遇到找不到路径的情况则需要手动指定 .ssh 文件夹的正确位置,我尝试把它放在 D 盘结果 server 不认,还是要指定 c:/users/username/.ssh 这个目录去生成,密钥名字为 id_rsa

+

上传到server

生成结束后需要将公钥上传到相应 server,以 Github 为例:

+

+

将公钥文件中的所有内容copy到key输入框中,添加保存即可。

+

配置Git Extension(windows)

以上步骤执行完后可以使用命令行执行推拉等操作,但是在Git Extension就死活不行,后来发现这个工具安装的时候默认使用了putty作为ssh代理,需要手动换成git自带的ssh工具,如图所示:

+

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2015/graduation/index.html b/2015/graduation/index.html new file mode 100644 index 000000000..092943bf5 --- /dev/null +++ b/2015/graduation/index.html @@ -0,0 +1,452 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +毕业 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 毕业 +

+ + +
+ + + + +

在家上中学的时候并不知道每天可以有家可归是一种怎样的幸福,直到后来再也没有这样的机会。如今人生已走过三分之一,想到以后都不会有机会在自己熟悉的地方长住,心里很不是滋味。有时候会想,如果目前还是自己一个人在生活的话,我就可以回到家这边来找一份不痛不痒的工作,先做个一两年。然而,看到以往的玩伴也渐渐走上正轨,供车供房,同学纷纷开始工作,读研留学,自己也会有些迷惘。尤其羡慕留学的朋友,从此以往告别这片神奇的土地。后悔大学没有好好学习,不然可以争取一些保研的机会,就不用这么早和自己的学生生涯说再(可以再打两年dota)。

+

三分之一已经过去了,学生时代的一些人和一些事也都应该告一段落了,该奋斗的奋斗,该拼爹的拼爹,都要上路了,也没什么闲暇来和老同学扯淡。一些当年觉得很好的朋友,如今看来也不过如此,以后估计也难再有交集。虽然不知道自己以后还能走多远,但是想到人生过了这么长,没有留下多少美好的记忆,也没有交到很多很好的朋友,满房间的物件,却并没有承载很多过去,觉得自己虚度了很多光阴,却也无法弥补,就总是会觉得很伤感。小学时候的课本笔记等早已不知所踪,只留下一两张泛黄的照片,初中的记忆本该满满却没有珍惜,高中不谈,大学更差,就是dota的一千个日与夜。我以后再也不想通宵玩游戏了,每次看到天亮都十分不安。如果我以后有了小孩,一定会帮她(他)把成长过程中的物件都收拾整理好,待到长大,将会是珍贵的回忆。

+

说到小孩,如今父母也会开始谈及小孩了,真是措手不及,我不还是个孩子吗,怎么就说到我的孩子了。你们一定是在逗我。

+

感谢毕业照那天来看我的同学朋友们,当日一别,更不知道何时再见。

+

大四最后一个学期都是在外居住,并不知道学校里的冷暖。周末回校,也只是洗洗衣服,打打游戏,完全感受不到自己还是一个在校学生。有一次在工作日请假回了学校,中午过了饭点仍自信下楼,才猛然想起原来大家还是要上课的,这会刚下课呢。这几年来我也算是有惊无险的体验了,挂了不少课,还好重修能过,马哲顺利,不然极有可能自信心受到打击从而陷入无尽轮回(需要感谢一下窦庆萍老师)。在知道自己大四上没有挂科,毋须延迟毕业后,心里面真的是很轻松,那么我也算是走过来了。

+

大学的同学里,我并没有与很多人熟识,也没有交到许多朋友,有几位可能甚至四年来都没有说过一句话,不过也不能完全怪我,我们的专业选得好,完全不用与人交流。谢师宴过后,很多人我仍然是只知道名字,其它一无所知,可能再也不会有机会相见了,然而我并不在乎,因为本来就跟不认识一样。对事不对人,自己的大学生活失败,与同学无关。

+

工作的地方在唐家湾的软件园,近期也可能会一直住在这一片,在珠海的同学朋友没事可以来找我玩,有活动也可以带上我,有麻烦如果能帮上忙也请找我。从学校到工作地点的一路上都是海岸,大概有二十多公里,每次经过都觉得很舒服,然而不知道台风会不会封路。

+

有一个正经的女朋友会给自己带来压力与动力,同时也会非常大程度地限制自己的自由,不能想去哪里就去哪里,不能想做什么就做什么,这点让我非常不自在。也许现在到了一个我需要照顾别人的时候,但是我还没有完全准备好。并没有想到大学最后一年能找到女朋友,我也没有准备好。要出外工作,住两三年出租屋,同样没有准备好。 我本来只是一个呆在宿舍每天打dota的大学生,现在生活需要做出这么大的转变,有点不知所措。独生儿习惯了被照顾,现在要开始学会自己打理一切,总有些转不过来。说到底,我就是想呆在家里睡觉。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2015/mean-js-note/index.html b/2015/mean-js-note/index.html new file mode 100644 index 000000000..8835a29a4 --- /dev/null +++ b/2015/mean-js-note/index.html @@ -0,0 +1,529 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +MEAN.js 学习笔记 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ MEAN.js 学习笔记 +

+ + +
+ + + + +

+

之前一直以为 MEAN 只是一个概念上的东西,表示以 Mongodb Express AngularJs NodeJs 为基础的全栈应用开发模式。这几天在公司接手相应项目的时候发现已经有人做出来并且维护着一些这样的 App 结构体,用过以后觉得还不错。MEANJS 是一个开源的 JavaScript 全栈应用解决方案,主要用到的技术自然就是以上提到的那些。使用成熟的解决方案可以使自己的项目更加易于开发以及维护,等等好处就不再赘述。

+ + +

关于MEAN

本文主要关注 MEANJS本 身,对于MEAN之中的种种技术就不再多做介绍。下面贴MEANJS给出的一些链接。

+ +

安装依赖

作为一个集大成者,MEANJS 需要的运行环境还是挺多的,但是相对 Java 项目来说简直不值一提。

+
    +
  • NodeJs - 没有 Node 谈何 MEAN,注意的是目前的 MEANJS 版本(0.3.x)还不支持最新的 5.x NodeJs,我就中了这招
  • +
  • MongoDB - 和 NodeJs 的集成比较好,下载安装包一路 next 即可
  • +
  • Ruby/Python/.Net 2+ - 一些 Node 的模块需要用到这些东西,毕竟 Node 只是一个 runtime,在服务器端一些稍微底层的操作还是要用到其他东西(12-14-2015更新:Python必须是 2.x 版本)
  • +
  • Bower - 前端包管理器,和 npm 组成一前一后的完整管理体系,相当于 Java 的 Maven
  • +
  • Grunt/Grunt CLI - JavaScript 世界的自动化工具,重复工作全靠它。CLI 是 Grunt 的命令行工具。
  • +
  • Sass/Less - MEANJS 用到了 Sass 去编译 CSS,所以也要添加它的支持。其实我个人感觉这个有点多余了。
  • +
+

启动 MEANJS APP

安装依赖模块

完成以上安装以及相应环境变量的配置以后,就可以准备启动 MEANJS 服务器了。首先需要在文件夹根目录运行 $ npm install 指令,根据 Readme 中的说法,这条指令做了以下的事情:

+
    +
  1. 安装运行所需的 Node 模块
  2. +
  3. 如果是测试环境则安装开发测试所需的 Node 模块
  4. +
  5. 最后执行 bower 安装前端模块
  6. +
+

不过我在最后一步有时候会遇到问题,需要手动再进行一次 $ bower install,另外,npm 的官方源在大陆访问并不稳定,可以使用 淘宝镜像 替代,Ruby 也是同理:Ruby镜像

+

12-14-2015 更新:这一步容易出现问题,一般仔细看 Log 都能找到问题所在,无非是哪个依赖没有配置环境变量/版本不对等,重新配置好以后删除 Node_modules 文件夹再重新运行命令。

+

启动 Mongodb

因为 MEANJS 默认为我们做了一个简单的用户注册登录模块,里面有一些数据库的增删查改,所以在启动服务器之前需要先启动数据库。随便找一个地方打开控制台输入 $ mongod --dbpath ***\*** 处填写一个路径,mongod 就能够在指定位置创建一个文件型数据库并连接之,如果该位置已存在数据库文件则会直接打开连接。

+

启动服务器

在以上都准备完成以后,我们就可以在项目根目录通过一条简单的指令 $ grunt 来启动服务器了,启动成功后可以在 http://localhost:3000 看到项目主页。

+

关于Grunt

$ grunt 这条指令会读取项目目录下的 gruntfile.js** **文件,并执行文件中定义的 task。MEANJS 的文档中并没有对其功能进行说明,以下是我的解读:

+

插件配置

以下都是 grunt task 中用到的插件的相关配置,具体插件以及相关文档都可以在 Grunt插件页面 找到。

+
env: {
test: {
NODE_ENV: 'test'
},
dev: {
NODE_ENV: 'development'
},
prod: {
NODE_ENV: 'production'
}
}
+ +

env 定义了三个服务器的运行环境:测试,开发,以及产品,在文件的最后会用到。

+
//......
defaultAssets = require('./config/assets/default'),
testAssets = require('./config/assets/test'),
//......
watch: {
serverViews: {
files: defaultAssets.server.views,
options: {
livereload: true
}
},
//......
}
+ +

watch 指定了动态监听的目录/文件,可以看到在每一个 View/Js/Css 监听列表中都加入了 livereload 选项,这个选项的作用是当被监听的文件发生变化时,浏览器会自动刷新。不过 watch 会再创建一个监听端口(默认为 35729),打开 http://localhost:35729/ 可以发现。被加载的首先是配置文件 ./config/assets/default.js 与相应的 test.js 等,然后再配置文件内可以找到文件列表,其中已经包括已经用到的以及将来会加入的文件(通过通配符实现),只要我们在开发时把文件放在相应结构位置上,grunt 就会自动监听。

+
nodemon: {
dev: {
script: 'server.js',
options: {
nodeArgs: ['--debug'],
ext: 'js,html',
watch: _.union(defaultAssets.server.gruntConfig, defaultAssets.server.views, defaultAssets.server.allJS, defaultAssets.server.config)
}
}
}
+ +

nodemon 配置了服务器自动重启。当 server 端的 config/views/js 文件发生变化时,server.js 脚本就会自动执行。由于只是服务器的重启而不是重新执行 grunt,所以几乎是秒速。以前用过一些类似的 node module 叫 supervisor 和 forever,不过这个集成到了 grunt task 中。写过 JavaEE project 的人再用这个才能体会到时间的宝贵。

+
concurrent: {
default: ['nodemon', 'watch'],
debug: ['nodemon', 'watch', 'node-inspector'],
options: {
logConcurrentOutput: true
}
}
+ +

concurrent 插件可以使任务并发执行,让前端与服务器端监听同时在一个终端窗口中执行/ Log

+

cshint/csslint 这两个插件主要是为了在 build 的时候顺便检查一下 js/css 文件中有没有常见的 warning / error,存在 error 时会停止 build task 并给出提示,不过控制台输出用户体验不是很好,开发过程中作用不大,我们都有 IDE,需要作为产品上线时跑一遍可能会更有参考价值。

+

后面的 ngAnnotae 插件可以在build的过程中对 angular js 的 annotation 进行简化以减少代码量,提高效率,属于锦上添花型。uglify/cssmin 则相应地执行 js/css 代码压缩任务。至于 sass/less 很明显就是 css 编译器了。再之后的多是 debug / test 插件。

+

注册任务

grunt.registerTask('taskName', ['***', '***']);
+ +

类似像这样的代码就是向grunt注册一个任务,第二个数组参数则是注册任务的内容,里面可以填另一个任务的名字或者是插件的名字,或者直接填写 function 取代该数组。通过在控制台输入 $ grunt taskName 执行任务,而不输入 taskName 的话则是执行 default 任务,当前 gruntfile.js 中的 default task 如下:

+
// Run the project in development mode
grunt.registerTask('default', ['env:dev', 'lint', 'mkdir:upload', 'copy:localConfig', 'concurrent:default']);
+ +

这个任务里面包含了一些子任务,就不一一说明了,有兴趣的可以自行查看,到这里终于可以说说 $ grunt 指令到底做了什么:

+
    +
  1. 设置运行环境为 dev,即开发
  2. +
  3. 执行 js/css 等文件的语法检查
  4. +
  5. 确保上传路径存在(MEANJS 默认带了一个用户上传头像的功能)
  6. +
  7. 加载一个自定义配置文件(里面可以填写 db 以及一些 api key 等信息)
  8. +
  9. default 模式启动 concurrent 前后端热部署
  10. +
+

可以看到这里面并没有启动服务器的指令,其实在nodemon中已经配置了服务器入口即 server.js。于是在所有准备工作完成后,开发环境的服务器就启动起来了。

+

当然 gruntfile 中也包含了 dev 以及 tes t环境的 task,需要切换运行环境的时候只需要在 grunt 命令中加入相应参数即可,还是比较方便的。

+

项目结构

根目录结构

├── bower.json
├── config
├── gruntfile.js
├── modules
├── package.json
└── server.js
+ +

以上是精简过后的根目录组成,不包括node_modules和public文件夹,以及一些optional和test相关的文件。

+
    +
  • bower.json/package.json - 前端/后端依赖说明文件,需要添加依赖时在文件里指定 ID /版本,再运行 $ bower install 或者 $ npm install 就会将指定包下载到 node_modules/public 文件夹中
  • +
  • gruntfile.js - grunt 任务配置文件
  • +
  • server.js - 服务器启动文件
  • +
  • config - 配置文件
  • +
  • modules - App 模块,也就是需要我们大量写代码的地方了,可以看到 MEANJS 项目已经包含了若干模块,我们可以在这基础之上添加自己的业务逻辑,或者推到重来
  • +
+

由于 MEANJS 的目录原则是模块优先,所以前后端的 MVC 会在相应模块目录内得到体现,这点与使用 express js 创建的目录结构有所区别。不过之前公司一位 STE share ExtJs 的时候提到其实都是大同小异,反正到最后目录结构都会变得臃肿。

+

模块结构

modules
│ └── moduleName
│ ├── client
│ │ ├── config
│ │ ├── controllers
│ │ ├── css
│ │ ├── img
│ │ ├── services
│ │ └── views
│ └── server
│ ├── config
│ ├── controllers
│ ├── models
│ ├── policies
│ ├── routes
│ └── templates
+ +

一个模块一般包含以上目录,首先从前端/后端分开,然后是各自的配置/ MVC,非常科学。值得一提的是每个模块各自用到的独立 css / image 等资源也是分开存放的,grunt 会在 build 的时候把它们全部读取并且载入,如果是 production 环境更会将同类压缩到一个文件中去,所以我们并不需要写很多的 include 之流。

+

总结

相对于手动使用 MEAN 各项技术结合写程序来说,使用 MEANJS 解决方案可以让我们更方便且快速地搭建项目,并且使我们不用太过于关注业务逻辑以外的问题,开发效率在全栈统一的保证下又提高了不少,不得不说确实是值得中小型项目去研究并且尝试使用一下。至于企业级大型项目,不知道有没有研究或者什么公司尝试过,不太清楚是否适合。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/2015-annual-summary/index.html b/2016/2015-annual-summary/index.html new file mode 100644 index 000000000..614135501 --- /dev/null +++ b/2016/2015-annual-summary/index.html @@ -0,0 +1,467 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2015年度总结 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 2015年度总结 +

+ + +
+ + + + +

2015对我来说是有喜有悲的一年。我还清楚地记得十一个月前刚过完年的时候,在上班的第一天迟到了,然后收到同事哥哥姐姐们的一桌子红包的情景,然而不知不觉就已经过去这么久了。最近生活和工作上都遇到了一点瓶颈,中午无聊的时候翻看了一下这一年下来的邮件,于是就想写点东西。

+ + +

实习

2014年11月的时候我找到了大学以来的第一份正经工作(实习),岗位是前端开发,一做就是近九个月。这九个月中我从一个什么都不懂的应届生成长为了一个对社会生活有初步了解的普通人。至今当时的Leader还在拿我的语录打趣,她问我说觉得公司怎么样,答曰:“我觉得挺正规的”。可见我是有多不会说话。虽然现在也没有变得很能说,但是在大家的帮助和教导下(感激不尽),至少是有一些进步了。

+

实习阶段工作内容非常简单,只需要根据哥哥姐姐们提出的需求去完成一系列相应的任务即可。通常都是一些编写或者维护Web页面以及通用控件的任务。作为一个实习生,很多情况下我都不用对所做的东西负很多的责任,质量有大师傅们把关着呢,就算一时间做得不好也不会被责怪,师傅们会尽心尽力地进行指导。所以现在回想起来,这真是一段无忧无虑的时光。工作上没有太多的压力,不用接触业务,根据详细的需求去实现不会太难的东西,同时也有着大量学习与实践的时间。

+
+

Hi Kairui, Jessie,

+

Welcome to join UCD as intern.

+
+

这是我在公司收到的第一封邮件,来自当时的Leader,这其中还有一个故事。一开始我并没有给公司投递简历,而是女朋友投了,并且收到了面试通知,于是我就抱着看看的心态,跟着一起去了。后来觉得“这间公司好像还挺正规”,于是也就提出了希望试一试的请求。好在HR比较宽容,给了我一次机会,也让我做了笔试与面试。几天后就有了上面的结果,我们都被录取了。但是女朋友她同时也面试了另一间公司的实习岗,也通过了,她权衡利弊以后觉得那个地方要更适合自己,所以最后我们只好各自入职。为这件事我郁闷了好一段时间。

+

毕设

因为全职实习的缘故,大学最后的半年时间我基本上都没有在学校度过,很久很久都没有听到过上课的钟声,也懒得跟任课老师请假。一开始还能够从学校宿舍早出晚归,后来直接就住在公司旁边了,每周回那么一两次学校。因此,我只想选一个最简单的,最不动脑的毕设,能过就行,实在是没有很多时间能投入进去。学校的毕设选题网站同样也是一个毕设,没有做完善的安全防卫,因此为了达到以上目的我还不择手段地写了一段浏览器脚本来抢题,大概就是每隔多少毫秒就给服务器发送“我要选这道题”的消息。后来比较后悔的事情是我没有及时地把这个主意分享给室友(因为是在开放选题前一两个小时的时间里想到的,而且我也不确定是不是真的能用),不然我们就都可以选到自己喜欢的题目了,而不是周末回去后发现大家都怨声载道的。

+

我的毕设内容是做一个供师生发布和选择实验课题的网站平台,实在是太简单了,导师说这种题目做出花来也就是七八十分。因此为了增加点技术含量,我使用了当时流行度还不是特别高的技术来制作它。整个过程当然是真金白银,这两个月期间我还直接或者间接地帮助了一些认识或者不认识的同学完成了他们的毕设,说请我吃饭的人最后都不知道哪里去了。后来论文得了八十六分,差点就能评优。

+

做完毕设以后,大学本科生涯就算结束了。曾经的舍友同学各奔东西。但是我觉得还不够过瘾,其实我还想读研。然而我没有得到保研的机会。也许工作一两年以后,我会有兴趣再去考一次研。

+

培训

大概在实习的第六个月的时候,我终于通过了公司的转正考核并且拿到OFFER,这段时间非常开心,一块大石终于落下,不至于在毕业以后回家打游戏。等待我的是毕业后长达三个月的入职培训。

+

培训的主要内容是Java以及Java Web,也是公司一贯以来的技术主流,然而并不是我非常熟悉的东西。除了在大二还是大三的时候选过一门不知所谓的Java课程,以及可以用Java来秒杀一些ACM中简单的大数题目以外,我对它几乎是没有任何印象。公司的培训有它的淘汰机制,所以我也怕被淘汰。所幸后来顺利地过去了。

+

培训进行的节奏非常快,基本是每周都要写一个对我们来说较大的Project,不睡觉也得写完。某天老师还说了一句听起来很污的话,“周六你们一起睡嘛”。然而我觉得现在好像也只有在这么紧张的节奏中才能专注于一件事情了。公司给我们提供午餐,目的就是让我们能够从早到晚呆在十七楼顶,就只专注一件事情。这段时间好像没有谁敢请假,好像一请假就要脱不知道多少节的样子。

+

在这段时间里面我认识了很多新同事,印象最深的一位同学是来自政法大学的研究生,专业是刑法。一份计算机的题目我做六十分,他能做一百分。他对知识的热情,真的让我有些无地自容。当然也还有其它非常优秀的同学,和这样的同学在一起参加培训,除了真的能学到东西以外,也能够鞭策自己。当然也还有厉害的老师,来自中科院的老师们,感觉在他们在Java世界的造诣已经成仙了。

+

培训结束后不久,我经历了人生的第一次手术,同时我觉得也会是整个人生历程中较为痛苦的一次。具体过程就不提了,前前后后已经经历了近四个月,至今仍未痊愈。这段时间也是一整年中情绪最为低落的时间,我有点不知道自己在做什么以及该做什么。因为在病急时选择了一间非医保定点医院,医保不给报销,到现在不仅我自己没有赚到钱不说,还花掉了妈妈的大把积蓄。妈妈为了照顾我把工作也辞掉了,她自己心力交瘁看起来也老了不少。我是真的觉得很惭愧啊,可是又不知道自己能做些什么。做一份工作赚着微薄的工资,还得了这么个折磨人的病,总是不痊愈也不知道什么时候是个头,妈妈为了我再苦再累也不会说,每当想起都觉得好忧伤。

+

工作

因为疾病的关系,我在培训结束正式入职后不久就请了一个多月的病假。这对我来说又是一个打击。本来应该是迅速熟悉业务进入工作状态的时间,我却不得不缺席。回来以后发现当时的同事们都已经轻车熟路,而自己依然是一问三不知。后来我被调离了原项目组,进入到了一个新的环境,重新开始一个新的项目。好在新项目使用的技术相对来说是我比较熟悉的,而且没有涉及到大量的业务逻辑,情况才慢慢好起来。

+

一开始做起来觉得没什么问题,然而功能越加越多,慢慢就觉得有些力不从心,需要重构。重构的前提是要对相关技术有相当的熟悉程度与足够的项目结构经验,然而我发现自己对稍复杂的项目就已经没有了头绪。现在看来除了写代码以外,我要学的东西真的还有很多很多。很多事情我虽然知道怎样做是不好或者不够好的,但是我却不知道应该如何把它们变得更好。

+

因为工作的缘故,能够回家的时间越来越少了。从前还有寒暑假,现在也没有了。本来我是很喜欢冬天的,但是这个冬天过得一点都不安分。时不时就成了南风天,又下雨又潮湿,气温还异常高,以至于在珠海一月份穿短袖也不是一件过分的事。

+

以上总结。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/2016-08-25/index.html b/2016/2016-08-25/index.html new file mode 100644 index 000000000..944a6cade --- /dev/null +++ b/2016/2016-08-25/index.html @@ -0,0 +1,446 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2016-08-25 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 2016-08-25 +

+ + +
+ + + + +

今天下班路上看到一对情侣,一路欢声笑语,走在没有信号灯的斑马线上,也要往马路上面站,车流离他们不到十米吧,女生不时地尝试走向对面又马上退回,脸上带着无忧无虑笑容,男友也丝毫没有阻止的意思。

+

我在后面看着,有种奇怪的感觉。觉得,这世界上也许有些人就是注定要死得早些吧。不过,又有点希望自己也能做一个这样的人。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/angular-router-note/index.html b/2016/angular-router-note/index.html new file mode 100644 index 000000000..4d02c5a28 --- /dev/null +++ b/2016/angular-router-note/index.html @@ -0,0 +1,552 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Angular Router 学习笔记 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Angular Router 学习笔记 +

+ + +
+ + + + +

使用Angular Router可以很方便地构建SPA应用,同时它支持深度链接,支持各种浏览器操作(前进、后退、收藏等),非常有趣。使用过类似模块就会觉得它要比传统的路由方式,比如服务端的Forward,Redirect以及一般的JavaScript Redirect等,好用得多。特别是用户体验这一块,上升了很大的档次。

+

就在不久前我还开发了一个使用iframe与jQuery的SPA项目,当时由于是老板提供的所有前端页面所以也没多想。现在学过了Angular Router真是有些不堪回首的感觉。

+ + +

传统路由方式

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Server Forward + + Server Redirect + + Client Redirect +
+ Request(s) + + 1 + + 2 + + 1 +
+ Browser URL Change + + NO + + YES + + YES +
+ Page Refresh + + YES + + YES + + YES +
+ Maintainable + + YES + + YES + + NO +
+ Browser Actions + + NO + + YES + + YES +
+
+ +

以上是一个简单的对比,从请求次数、显示URL是否变化、页面是否刷新、是否可维护、是否支持浏览器动作这5个方面进行,可以看到彼此都有一些遗憾。由于Server Forward以及Client Redirect的限制实在太大,很多情况下我们用到的都是Server Redirect,但是两次请求是硬伤。并且以上所有方式都需要强制刷新页面。Wordpress博客使用的就都是Redirect方式。

+

前端路由

Angular Router支持两种使用方式:

+
    +
  • #锚点
  • +
  • HTML5 API
  • +
+

其实在我看来很多情况下第一种较为朴素的方式已经足够用了。以下是一个简单的Demo:

+

独立页面链接:http://wxsm.space/others/examples/angular-router/

+

点击Edit/Delete/Add/Show几个链接,可以发现下面的内容发生了变化。同时地址栏的URL也相应地变了。也可以尝试前进、后退、收藏等操作(在独立页面进行)。 代码如下:

+
<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8"/>
<title>$routeProvide example</title>
</head>

<body>
<div ng-app="pathApp">

Choose your option:
<br/>
<br/>
<a href="#/Book/Edit">Edit</a> |
<a href="#/Book/Delete">Delete</a> |
<a href="#/Book/Add">Add</a> |
<a href="#/Book/Show">Show</a>

<div ng-view></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.7/angular.min.js"></script>
<script>
angular.module('pathApp', [], function ($routeProvider, $locationProvider) {
$routeProvider
.when('/Book/Edit', {
template: '<div>Edit</div>',
})
.when('/Book/Delete', {
template: '<div>Delete</div>',
})
.when('/Book/Show', {
template: '<div>Show</div>',
})
.when('/Book/Add', {
template: '<div>Add</div>',
})
.otherwise({
redirectTo: '/Book'
});
});
</script>
</body>

</html>
+ +

这个Demo为了简便使用了直接的HTML字符串来作为一个页面的内容,我们在实际使用的时候可以把它换成一个实际页面的地址,这样在点击一个链接的时候Angular Router就会异步地加载相应的页面并且填充到相应的ng-view节点中去。整个过程无需全局刷新。由此带来的好处是非常多的。我们不需要重新加载和渲染一些固定的板块(比如通常情况下一个网站的Header和Footer部分都是不会发生变化的,当然如果需要变化Angular Router也能做到),同时也可以真正地给页面切换添加一些酷炫的动画(CSS或者JS)。并且由于加载一个新页面只需要从服务器读取它的HTML内容而不需要如JS/CSS等静态文件(这些都将会在页面第一次打开的时候加载完毕),因此速度将会非常快。

+

需要注意的是,这里使用的是比较旧的AngularJS版本。在新版本中,Angular Router从AngularJS的核心代码内分离了出来,成为了一个叫做ngRoute的独立模块。我们需要同时引入AngularJS和ngRoute模块来启用Angular Router路由器。

+

关于$routeProvider与$route

这个路由功能是由Angular的一个服务提供者(service provider)实现的,它的名字就叫做$routeProvider 。Angular服务是由服务工厂创建出来的一系列单例对象,而工厂则是由服务提供者来创建。服务提供者必须实现一个$get方法,它就是该服务的工厂方法了。 当我们使用AngularJS的依赖注入给控制器注入一个服务对象的时候,Angular会使用$injector来查找相应的注入器。一旦找到了,它就会调用相应$get方法来或取服务对象的实例。有时候服务提供者在实例化服务对象之前需要其调用者提供一些参数。

+

Angular路由功能是由$routeProvider声明的,同时它也是$route服务的提供者。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/baidu-submit-for-wordpress-update-0-1-0/index.html b/2016/baidu-submit-for-wordpress-update-0-1-0/index.html new file mode 100644 index 000000000..589b2d83f --- /dev/null +++ b/2016/baidu-submit-for-wordpress-update-0-1-0/index.html @@ -0,0 +1,452 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Baidu Submit for WordPress update 0.1.0 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Baidu Submit for WordPress update 0.1.0 +

+ + +
+ + + + +

这次更新主要是把工具做成了 WordPress 插件的形式,安装和使用起来都更符合 WordPress 的风格了,也不用再通过改代码去更改配置参数。之所以一次性把版本号提到了 0.1.0,是因为我觉得它虽然功能还不是非常完善,但是已经达到了“至少能用”的程度。

+

工具的主要功能目前为止并没有什么变化,至于这个过程中获得的少许 WordPress 插件开发经验下次再总结,好在没走多少弯路。

+

使用方式:Clone https://github.com/wxsms/baidu-submit-for-wordpress 仓库并上传至主机的

+
/wp-content/plugins
+ +

目录,在 WordPress 插件控制面板中设置启用即可。准入密钥以及域名的配置页面可以在“设置”中找到,其中也包含了手动推送的页面。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/baidu-submit-for-wordpress/index.html b/2016/baidu-submit-for-wordpress/index.html new file mode 100644 index 000000000..420cd8f51 --- /dev/null +++ b/2016/baidu-submit-for-wordpress/index.html @@ -0,0 +1,592 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +WordPress 百度主动提交工具 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ WordPress 百度主动提交工具 +

+ + +
+ + + + +

作为目前的国内搜索主流,百度的收录规则与国外搜索引擎如谷歌、必应等不太一样,虽然它也有提供普通的Sitemap模式,但是据它自己所言通过这种方式收录效率是最低的。另外还有一种是自动推送,即在网站所有页面都加入一个JS脚本,有人访问时就会自动向百度推送该链接,但实测经常会被浏览器的AD Block插件阻拦。因此还剩下效率最高的一种方式:主动推送。我试过了一些现成的插件,好像都不太好用。因为是一个简单的功能,所以就自己写了一个小工具来实现。

+ + +

主动推送规则

通过调用百度的一个接口,并给它传送要提交的链接,即完成了主动推送的过程,根据接口返回的信息可以判断提交的结果如何(成功/部分成功/失败)。

+

核心代码如下(由百度提供):

+
$urls = array(
'http://www.example.com/1.html',
'http://www.example.com/2.html',
);
$api = 'http://data.zz.baidu.com/urls?site=xxx&token=xxx';
$ch = curl_init();
$options = array(
CURLOPT_URL => $api,
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => implode("\n", $urls),
CURLOPT_HTTPHEADER => array('Content-Type: text/plain'),
);
curl_setopt_array($ch, $options);
$result = curl_exec($ch);
echo $result;
+ +

$urls 就是我们需要推送的 url 数组了,除此之外还有两个需要修改的地方,都在第5行。一是自己站点的域名,二是准入密钥。密钥会由百度站长工具提供。域名则有一点需要注意,必须填写在百度站长平台注册的域名,比如注册的时候是带有 www 的,则这里也必须带 www,否则会返回域名不一致的错误。

+

API返回的 $result 是一个 JSON 对象,若推送成功可能包含以下字段:

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段是否必选参数类型说明
successint成功推送的url条数
remainint当天剩余的可推送url条数
not_same_sitearray由于不是本站url而未处理的url列表
+ not_valid + + 否 + + array + + 不合法的url列表 +
+
+ +

示例:

+
{
"remain":4999998,
"success":2,
"not_same_site":[],
"not_valid":[]
}
+ +

推送失败可能返回的字段:

+
+ + + + + + + + + + + + + + + + + + + +
+ 字段 + + 是否必选 + + 参数类型 + + 说明 +
+ error + + 是 + + int + + 错误码,与状态码相同 +
+ message + + 是 + + string + + 错误描述 +
+
+ +

示例:

+
{
"error":401,
"message":"token is not valid"
}
+ +

实测小站点一天只能推500条链接,超过了就会报错。不过目前来说是绝对够用了。

+

实现逻辑

关于这个工具,我能想到的比较合理的使用逻辑是这样的:

+
    +
  • 建站已有一段时间,但是从来没用过百度主动推送,需要能够选择以往的链接并推送之
  • +
  • 旧的链接都已推送过,需要在有新页面发布时自动将其推送
  • +
+

需要推送的页面包括但不限于:

+
    +
  • 首页
  • +
  • 文章(Post)
  • +
  • 页面(Page)
  • +
  • 目录
  • +
  • 标签
  • +
+

由于是第一版,目前这个工具的逻辑就是这样的:

+
    +
  1. 首先获取到Wordpress站点下所有的正常页面(已发布,无密码)
  2. +
  3. 让用户选择哪些页面需要被推送
  4. +
  5. 用户点击按钮,请求经由AJAX发回后台
  6. +
  7. 后台调用百度接口,实行推送
  8. +
  9. 返回并显示结果
  10. +
+

实际的效果就像这样(点此参观 2016-03-10更新:由于已更换为插件模式,原页面失效):

+

+

这个工具目前还很简陋。当然,如果你没有登录或者已登陆但没有管理员权限的话,点击Submit是会被拒绝的。

+

未来的目标

虽然是一个简单的东西,但我觉得它可以变得更好:

+
    +
  1. 用户应该可以填写自定义的链接
  2. +
  3. 它应该记住哪些链接已经被提交过了,这个状态应该显示在页面上,并且不再自动勾选
  4. +
  5. 自动触发推送的功能尚未实现,这个也是很重要的
  6. +
  7. 表格可以以一种更好的形式展现
  8. +
  9. Log可以写得更友好一些
  10. +
  11. 做成插件
  12. +
  13. ……
  14. +
+

如无意外,这些都将在之后的版本更新。

+

GitHub: https://github.com/wxsms/baidu-submit-for-wordpress

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/bfc-theory-and-applications/index.html b/2016/bfc-theory-and-applications/index.html new file mode 100644 index 000000000..162a5dc47 --- /dev/null +++ b/2016/bfc-theory-and-applications/index.html @@ -0,0 +1,498 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +BFC 原理及应用 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ BFC 原理及应用 +

+ + +
+ + + + +

什么是 BFC

BFC(Block formatting context)是 CSS 中的一个概念,先来看一下定义 (By MDN):

+
+

A block formatting context is a part of a visual CSS rendering of a Web page. It is the region in which the layout of block boxes occurs and in which floats interact with each other.

+
+

大意就是,BFC 是 Web 页面通过 CSS 渲染的一个块级(Block-level)区域,具有独立性。

+

BFC 对浮动元素的定位与清除都很重要:

+
    +
  • 浮动元素的定位与清除规则只适用于同一 BFC 中的元素
  • +
  • 不同 BFC 中的浮动元素不会相互影响
  • +
  • 浮动元素的清除只适用于同一 BFC 中的元素
  • +
+ + +

如何生成 BFC

一个元素要成为 BFC,必须具备以下特征之一:

+
    +
  • 根元素,或者包含根元素的元素
  • +
  • 浮动元素(float 属性不为none
  • +
  • 绝对定位元素(position 属性为 absolutefixed
  • +
  • 行内块级元素(display 属性为 inline-block
  • +
  • 表格单元格或者标题(display 属性为 table-celltable-caption
  • +
  • 元素的 overflow 属性不为 visible
  • +
  • Flex 元素(display 属性为 flexinline-flex
  • +
+

BFC 的应用

自适应双栏布局

之前一直困扰我的一个问题是,如何使用 CSS 实现一个双栏布局,其中一栏宽度固定,另一栏则自动根据父节点剩余宽度填满容器呢?

+

因为 CSS 2.x 是不支持计算的,所以不使用 calc 的话,还真的好像没什么办法的样子。

+

然而,通过使用 BFC 却可以很容易地达到效果。

+

先来看一个没有 BFC 的例子:

+
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>BFC Example</title>
<style>
.aside {
width: 100px;
height: 150px;
float: left;
background: #f66;
}

.main {
height: 200px;
background: #fcc;
}
</style>
</head>
<body>
<div class="aside"></div>
<div class="main"></div>
</body>
</html>
+ +

效果:

+

+

在这种情况下,二者共享了同一个 BFC,即 body 根元素,因此,右边元素的定位受到了浮动的影响。

+

我们给 .main 添加一个属性,让它成为独立的 BFC:

+
.main {
overflow: hidden;
}
+ +

效果:

+

+

这就是一个自适应两栏布局了。主栏的宽度是随父节点而自动变化的。

+

清除内部浮动

写 CSS 代码的时候经常会遇到一个问题:如果一个元素内部全部是由浮动元素组成的话,那么它经常会没有高度,即“不能被撑开”。

+

我们可以通过在所有浮动元素的最后清除浮动来解决问题,但通过 BFC 的方式其实更简单。

+

只需要通过任意方式将浮动元素的容器转换为 BFC(比如添加 overflow: hidden 属性),即使不清除浮动,其依然能被正常“撑开”。

+

就像这样:

+
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>BFC Example</title>
<style>
body {
width: 660px;
}

.parent {
background: #fcc;
overflow: hidden;
}

.child {
border: 5px solid #f66;
width: 200px;
height: 200px;
float: left;
}
</style>
</head>
<body>
<div class="parent">
<div class="child"></div>
<div class="child"></div>
</div>
</body>
</html>
+ +

效果:

+

+

清除 margin 重叠

场景:我们连续定义两个 div,并且都给予 margin: 100px 属性,实际上它们之间的距离也将是 100px 而非 200px,因为 margin 重叠了。

+

如果不想让 margin 出现这种重叠的情况,依然可以使用 BFC:给二者都各自套上一个 BFC 容器(或者其中之一),因为 BFC 的独立性,内部布局不会产生对外影响,外部也不会产生对内影响,所以二者的 margin 属性都能生效,最终就能得到 200px 的间距。

+

这个就不举实际例子了。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/bootstrap-file-input-in-firefox/index.html b/2016/bootstrap-file-input-in-firefox/index.html new file mode 100644 index 000000000..b984d2115 --- /dev/null +++ b/2016/bootstrap-file-input-in-firefox/index.html @@ -0,0 +1,470 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Bootstrap file input in Firefox | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Bootstrap file input in Firefox +

+ + +
+ + + + +

默认的Bootstrap文件上传框在Chrome/Firefox/IE上的表现都不一样,如下所示。 代码:

+
<input class="form-control" type="file">
+ + + +

Chrome:

+

+

Firefox:

+

+

IE:

+

+

先忽略掉文字表述的差异(由浏览器所使用语言引起),可以看到File input在Chrome和FF下的表现比较相似,IE则差距略大。但是至少Chrome和IE是可以正常显示其样式的,FF则出现了奇怪的样式问题,好像因为按钮太大而超出了输入框。 解决方法也很简单,最快捷的:

+
.form-control {
height: auto;
}
+ +

但是这个方法可能会影响到其它输入框组,可以稍作修改:

+
.form-control[type=file] {
height: auto;
}
+ +

这样CSS就会自动选择类型为file的输入框并且添加以上样式。虽然IE家族对CSS属性选择器的支持有限制(7/8)或者完全不支持(6),但是实际上并不影响。因为Bootstrap最低也只能支持到IE 9或IE 8(添加额外库),所以这个方法已经足够了。 修改后的Firefox(Chrome/IE无变化):

+

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/css-triangle/index.html b/2016/css-triangle/index.html new file mode 100644 index 000000000..2e9eb9811 --- /dev/null +++ b/2016/css-triangle/index.html @@ -0,0 +1,464 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +CSS 绘制三角形 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ CSS 绘制三角形 +

+ + +
+ + + + +

关于如何使用CSS中的border属性绘制各式各样的三角形。下面有一个国外友人制作的动画,对其原理进行了直观的阐释,我简单地做了点翻译。

+ + + + +

 

+

要点:

+
    +
  • 元素不能有宽高(当然也可以稍作变化来绘制梯形)
  • +
  • 只有一边border显示颜色,其宽度即为三角形的高
  • +
  • 与其相邻的border设置为透明色,它们将决定三角形的形状
  • +
+

更多的例子:

+
<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8"/>
<title>CSS Triangle</title>
<style>
.arrow-up {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid black;
}

.arrow-down {
width: 0;
height: 0;
border-left: 20px solid transparent;
border-right: 20px solid transparent;
border-top: 20px solid #f00;
}

.arrow-right {
width: 0;
height: 0;
border-top: 60px solid transparent;
border-bottom: 60px solid transparent;
border-left: 60px solid green;
}

.arrow-left {
width: 0;
height: 0;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-right: 10px solid blue;
}
</style>
</head>
<body>
<div class="arrow-up"></div>
<div class="arrow-down"></div>
<div class="arrow-left"></div>
<div class="arrow-right"></div>
</body>
</html>
+ +

效果:

+

以上的例子都是使用实体元素来绘制三角形,其实实际情况下使用伪元素的(before,after)会更多一些。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/d3-note-basis/index.html b/2016/d3-note-basis/index.html new file mode 100644 index 000000000..27e84ec74 --- /dev/null +++ b/2016/d3-note-basis/index.html @@ -0,0 +1,534 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +D3 Note - Basis | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ D3 Note - Basis +

+ + +
+ + + + +

D3 (Data-Driven Documents) 是一个 JavaScript Library,用来做 Web 端的数据可视化实现以及各种绘图。

+
+

D3.js is a JavaScript library for manipulating documents based on data. D3 helps you bring data to life using HTML, SVG, and CSS.

+
+

学习 D3 需要很多预备知识:

+
    +
  1. HTML / DOM
  2. +
  3. CSS
  4. +
  5. JavaScript (better with jQuery)
  6. +
  7. SVG
  8. +
+

HTML / CSS 不必多说,因为 D3 含有大量链式操作函数以及选择器等,因此如果有 jQuery 基础将轻松很多。此外,由于一般采用 SVG 方式进行绘图,所以 SVG 基础知识也需要掌握。

+

虽然必须的预备知识如此之多,但 D3 的定位其实是 Web 前端绘图的底层工具,所谓底层,即是操作复杂而功能强大者。

+ + +

关于 SVG

SVG (Scalable Vector Graphics) 是一种绘图标准,已经被绝大多数的现代浏览器所支持。SVG 采用 XML 语法定义图像,可直接嵌入 HTML 中使用。

+

SVG 的特点是矢量绘图(与 Canvas 不同),除了预设样式以外同时也支持 CSS 样式。

+

比如,画一个园圈,坐标为 (100, 50),半径为 40px,拥有 2px 的黑色 border,以及红色填充:

+
<svg>
<circle cx="100" cy="50" r="40" stroke="black" stroke-width="2" fill="red"/>
</svg>
+ +

SVG 有一些预定义的形状元素,可被开发者使用和操作:

+
    +
  • 矩形 <rect>
  • +
  • 圆形 <circle>
  • +
  • 椭圆 <ellipse>
  • +
  • 线 <line>
  • +
  • 折线 <polyline>
  • +
  • 多边形 <polygon>
  • +
  • 路径 <path>
  • +
+

其中,path 是功能最强大者,使用 path 可以构成所有图形。

+

选择器

选择元素

D3 使用与 jQuery 类似的选择器来获取 HTML 元素。常用的方法有:

+
    +
  • d3.select(selector)
  • +
  • d3.selectAll(selector)
  • +
+

(参数既可以传 selector 也可以直接传 HTML Element )

+

顾名思义,selectAll 就是选择所有符合条件的元素了,那么 select 选择的是符合条件的第一个元素。如:

+
d3.select('body') //选择 body 元素

d3.selectAll('p') //选择所有 p 元素

d3.selectAll('.className') //选择所有 class 包含 className 的元素
+ +

更多就不说了。

+

操作选择

选择器返回的是一组选择(selection),这组选择可以进行一些操作,如:

+
    +
  • 在此选择的基础上继续选择;
  • +
  • 改变属性;
  • +
  • 改变样式;
  • +
  • 绑定事件;
  • +
  • 插入、删除;
  • +
  • 绑定数据。
  • +
+

大多数操作都与 jQuery 十分类似,同时也支持链式操作,不再赘述。只是这个“绑定数据”操作稍有特别。

+

数据绑定

通过 D3 可以把数据“绑定”到 HTML 元素上,绑定的目的主要是为了方便一些需要相应数据才能进行的元素操作(如:更改元素大小、位置等)。

+

绑定数据有两个方法:

+
    +
  • datum: 将一个数据绑定到选择上;
  • +
  • data: 将一个数组绑定到选择上,数组的各项分别与选择的各项一一对应。
  • +
+

下面引用一个例子来说明这二者的不同。假设有如下三个节点:

+
<p>Apple</p>
<p>Pear</p>
<p>Banana</p>
+ +

datum

执行以下代码:

+
let str = 'datum';
let p = d3.selectAll('p');

p.datum(str);
p.text((d, i) => `Element ${i} bind with ${d}`);
+ +

将得到:

+
Element 0 bind with datum
Element 1 bind with datum
Element 2 bind with datum
+ +

在对选择进行操作时,传入的可以是,也可以是函数。当传入函数时,D3 会向函数注入两个参数,分别是 d (data) 与 i (index),代表当前元素绑定的数据与其索引。

+

data

执行以下代码:

+
let strArr = ['data0', 'data1', 'data2'];
let p = d3.selectAll('p');

p.data(strArr);
p.text((d, i) => `Element ${i} bind with ${d}`);
+ +

将得到:

+
Element 0 bind with data0
Element 1 bind with data1
Element 2 bind with data2
+ +

可以看到,数组中的 3 个项分别与 3 个 p 元素绑定到了一起。因此,可以将 datum 看作是 data 函数的一个特例,实际开发中使用更多的是 data 函数。

+

实践:简单柱状图

先定义一个 SVG 画布,并将它插入到 HTML 的 body 中去:

+
let width = 300,
height = 300

let svg = d3.select('body')
.append('svg')
.attr('width', width)
.attr('height', height);
+ +

在这里,画布的宽高都为 300 像素。

+

然后,定义一组数据:

+
let data = [250, 210, 170, 100, 190];
+ +

最后使用以上数据画出柱状图,柱子使用 SVG 预定义的 rect 元素:

+
let rectWidth = 25;

svg.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('y', (d, i) => height - d)
.attr('x', (d, i) => i * rectWidth)
.attr('height', d => d)
.attr('width', rectWidth - 2)
.attr('fill', 'steelblue');
+ +

rectWidth 表示柱子的宽度,至于坐标、宽高则分别通过 x / y 以及 height / width 属性来控制,效果如下:

+

+

可以发现,这里并没有指定需要插入的 rect 个数,但 D3 却根据数据量自动地把图画出来了,这个工作是通过 enter 语句完成的。关于其工作原理,下回分解。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/d3-note-enter-update-and-exit/index.html b/2016/d3-note-enter-update-and-exit/index.html new file mode 100644 index 000000000..32a5195dd --- /dev/null +++ b/2016/d3-note-enter-update-and-exit/index.html @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +D3 Note - Enter, Update and Exit | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ D3 Note - Enter, Update and Exit +

+ + +
+ + + + +

在 D3 的使用过程中,我们见得最多的应当是类似如下的代码:

+
let div = d3.select('body')
.selectAll('p')
.data([3, 6, 9, 12, 15])
.enter()
.append('p')
.text(d => d);
+ +

将得到:

+
<body>
<p>3</p>
<p>6</p>
<p>9</p>
<p>12</p>
<p>15</p>
</body>
+ +

光看代码完全不能理解 D3 到底做了些什么,其实这里关键是 enter 的使用。

+ + +

Overall

enter 其实是一个选择集(selection),与其对应的还有 updateexit,选择集中的元素由原始选择集与绑定的数据决定。

+

+

selection.enter

+

Returns the enter selection: placeholder nodes for each datum that had no corresponding DOM element in the selection. The enter selection is determined by selection.data, and is empty on a selection that is not joined to data.

+

The enter selection is typically used to create “missing” elements corresponding to new data.

+
+

简述就是,enter 会根据现有 selection 与绑定的数据量,自动“补齐”所缺失的元素。

+

比如,例子中,如果 .selectAll('p') 返回的 selection 中包含 3 个元素,那么因为 data 的长度为 5,enter 就会补齐缺失的 2 个元素,并返回包含这三个补齐元素的 selection,接下来的操作,就是针对这个 selection 进行的。

+

因此,在进行 enter 操作时,一般会事先把相关现有元素尽数清除,以免出现漏操作的情况。

+

至于再对 enter 选择集进行 append 操作时为什么会追加到 body 节点上去,这里就涉及到另一个概念:selection.update

+

selection.update

理解了 enterupdate 就很简单了,顾名思义,所指就是已有的,能够与绑定 data 一一对应上的元素的选择集。

+

因此,实际上并没有 selection.update 这个方法,因为没有必要,当前选到的就是 update 集了。

+

至于为什么例子中的 enter 集能够追加到 body 中去,根据 D3 文档:

+
+

If the specified type is a string, appends a new element of this type (tag name) as the last child of each selected element, or the next following sibling in the update selection if this is an enter selection.

+
+

当进行 selection.append 操作时,如果 selection 是一个 enter 集,那么 append 就会向相应 update 集的末尾追加。那么,自然,如果 update 集为空,就会往父元素内追加。

+

selection.exit

+

Returns the exit selection: existing DOM elements in the selection for which no new datum was found.

+
+

对于已有 DOM 元素但没有 data 与之绑定的集合,使用 selection.exit 来获取。

+

如果集合没有绑定 data,则返回空集合。如果多次调用 exit,之后的 exit 会返回空集合。

+

通常,对于 exit 集的操作,都是 remove

+
selection.exit().remove()
+ +

 

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/d3-note-interpolate/index.html b/2016/d3-note-interpolate/index.html new file mode 100644 index 000000000..94515fd64 --- /dev/null +++ b/2016/d3-note-interpolate/index.html @@ -0,0 +1,477 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +D3 Note - Interpolate | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ D3 Note - Interpolate +

+ + +
+ + + + +

d3-interpolate 是 D3 的核心模块之一,与比例尺有些类似,interpolate (插值)所做的也是一些数值映射的工作。区别是,interpolate 的定义域始终是 0 ~ 1,并且始终为线性的。所以,更多时候它用来与 D3 的一些其他模组集成使用(如 transition, scale 等)。

+ + +

举个例子:

+
let i = d3.interpolateNumber(10, 20); // 10 as a, and 20 as b
i(0.0); // 10
i(0.2); // 12
i(0.5); // 15
i(1.0); // 20
+ +

返回的函数 i 称作 interpolator (插值器)。给定值域 ab,并且传入 [0, 1] 这个闭区间内的任意值,插值器将返回对应的结果。通常情况下,a 对应参数 0,b 对应参数 1

+

跟比例尺一样,插值器也可以接受其他类型的参数,如:

+
d3.interpolateLab("steelblue", "brown")(0.5); // "rgb(142, 92, 109)"
+ +

甚至对象、数组:

+
let i = d3.interpolate({colors: ["red", "blue"]}, {colors: ["white", "black"]});
i(0.0); // {colors: ["rgb(255, 0, 0)", "rgb(0, 0, 255)"]}
i(0.5); // {colors: ["rgb(255, 128, 128)", "rgb(0, 0, 128)"]}
i(1.0); // {colors: ["rgb(255, 255, 255)", "rgb(0, 0, 0)"]}
+ +

d3.interpolate

interpolate 模块提供了很多子方法,然而,大多数情况下,直接调用这个就足够了。因为 D3 会根据传入的数据类型自动匹配子方法(注意:是基于参数 b 的数据类型)。

+

决定算法:

+
    +
  1. 如果 b 是 null, undefinedboolean,则函数返回的是常量 b
  2. +
  3. 如果 b 是数字,则使用 interpolateNumber 方法
  4. +
  5. 如果 b 是颜色或者可以转换为颜色的字符串,则使用 interpolateRgb 方法
  6. +
  7. 如果 b 是时间,则使用 interpolateDate 方法
  8. +
  9. 如果 b 是字符串,则使用 interpolateString 方法
  10. +
  11. 如果 b 是数组,则使用 interpolateArray 方法
  12. +
  13. 如果 b 可以强转为数字,则使用 interpolateNumber 方法
  14. +
  15. 使用 interpolateObject 方法
  16. +
  17. 基于 b 的类型,将 a 强转为相同类型
  18. +
+

各个方法可以直接查看文档获取用法,大同小异。比较有趣的是 interpolateString,它可以检测字符串中的数字,并且做类似这样的事情:

+
+

For example, if a is “300 12px sans-serif”, and b is “500 36px Comic-Sans”, two embedded numbers are found. The remaining static parts of the string are a space between the two numbers (“ “), and the suffix (“px Comic-Sans”). The result of the interpolator at t = 0.5 is “400 24px Comic-Sans”.

+
+

至于插值函数的用处,比较多,举一个例子:d3-transition 有一些平滑动画的实现函数需要用到插值,比如说地球的动画滚动效果:

+
d3.transition()
.duration(1000)
.tween('rotate', () => {
let r = d3.interpolate(projection.rotate(), [-geo[0], -geo[1]])
return (t) => {
rotateGlobeByAngle(r(t))
}
})
.on('end', () => {
// do something...
})
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/d3-note-scale/index.html b/2016/d3-note-scale/index.html new file mode 100644 index 000000000..5883a6148 --- /dev/null +++ b/2016/d3-note-scale/index.html @@ -0,0 +1,534 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +D3 Note - Scale | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ D3 Note - Scale +

+ + +
+ + + + +

之前做的柱状图例子:

+
let data = [250, 210, 170, 100, 190]

let rectWidth = 25

svg.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('y', (d, i) => height - d)
.attr('x', (d, i) => i * rectWidth)
.attr('height', d => d)
.attr('width', rectWidth - 2)
.attr('fill', 'steelblue')
+ +

有一个严重的问题,就是没有比例尺的概念,柱状图的高度完全由数据转换成像素值来模拟。这明显是不科学的:如果数据的值过小或过大,作出来的图就会很奇怪,同时也无法做到非线性的映射。

+

就跟地图需要比例尺一样,绝大多数的数据图表也需要比例尺。

+
+

Scales are a convenient abstraction for a fundamental task in visualization: mapping a dimension of abstract data to a visual representation.

+
+

比例尺 - Scale - “将某个维度的抽象数据做可视化映射”

+

至于可视化映射的具体实现,d3-scale 模块提供了许多方案,大致可以分为两类:

+
    +
  • Continuous Scales(连续映射)
  • +
  • Ordinal Scales(散点映射)
  • +
+ + +

Continuous Scales

Continuous Scales(连续映射)将连续的、定量的 Input Domain(定义域)映射为一个连续的 Output Range(值域)。如果 Range 也是一个数值范围,那么映射操作可以被反转(即从值域到定义域)。

+

连续值映射是一个抽象的概念,不能直接构造。因此,d3-scale 提供了一些具体实现,如线性、次方、对数等。

+

一个简单的例子:

+
let x = d3.scaleLinear()
.domain([10, 130])
.range([0, 960]);

x(20); // 80
x(50); // 320
+ +

这里构造了一个 scaleLinear (线性比例尺),并设置了输入及输出范围。构造器将会返回一个函数,这个函数接受输入值,并且返回对应的输出值。

+

如果输入值超出了预定义的范围,那么自然而然地,函数返回的输出值也会超出范围。但是,D3 提供了一个选项 clamp, 可以将输出范围保持在定义值内:

+
x.clamp(true);
x(-10); // 0, clamped to range
+ +

Output Domain 除了可以为数字,也可以是其它东西。比如颜色:

+
let color = d3.scaleLinear()
.domain([10, 100])
.range(["brown", "steelblue"]);

color(20); // "#9a3439"
color(50); // "#7b5167"
+ +

同时,Continuous Scales 也支持插值(interpolate)操作,这是 D3 的另一个模块。

+

Linear Scales

线性比例尺,顾名思义,输出值对于输入值而言是线性变化的。

+
+

y = ax + b

+
+

Power Scales

次方比例尺,与 Linear Scales 类似,但是需要多加一个参数:exponent (次方)

+
+

y = mx^k + b

+
+
pow.exponent([exponent]) // default 1
+ +

在需要做次方根的时候,使 exponent = 0.x 就可以了,对于 0.5 这个特值,D3 还提供了快捷方式:d3.scaleSqrt(),这将直接构造 exponent = 0.5 的 Power Scale

+

Log Scales

对数比例尺,与 Power Scales 类似,参数变为 base (底数)

+
+

y = m log(x) + b

+
+

因为 log(0) = -∞,Log Scales 的 Input Domain 不能够跨越 0,即要么全为正,要么全为负

+

Identity Scales

全等比例尺,特殊的线性比例尺。定义域与值域完全相等。因此,它的 invert 方法也就是它本身。

+
+

y = x

+
+

Time Scales

时间比例尺,线性比例尺的变体。例子:

+
let x = d3.scaleTime()
.domain([new Date(2000, 0, 1), new Date(2000, 0, 2)])
.range([0, 960]);

x(new Date(2000, 0, 1, 5)); // 200
x(new Date(2000, 0, 1, 16)); // 640
x.invert(200); // Sat Jan 01 2000 05:00:00 GMT-0800 (PST)
x.invert(640); // Sat Jan 01 2000 16:00:00 GMT-0800 (PST)
+ +

Sequential Scales

Sequential ScalesContinuous Scales 类似,区别是,这个比例尺的值域是由 interpolator 决定的,不可控制。同时,invert, range, rangeRound 以及 interpolate 都不可用。

+

D3 提供了一系列的颜色插值器,因此其应用场景多与连续的颜色值域有关。

+

Quantize Scales

Quantize ScalesLinear Scales 类似,区别是,其值域是离散的。定义域将基于值域元素的个数被切割为相等的线段,输出值为线段到值域的一对一映射

+
+

y = m round(x) + b

+
+

例子:

+
let color = d3.scaleQuantize()
.domain([0, 1])
.range(["brown", "steelblue"]);

color(0.49); // "brown"
color(0.51); // "steelblue"
+ +

Quantile Scales

Quantile scalesQuantize Scales 类似,区别是,其值域是“离散连续的”,即“离散的连续片段”。

+

首先,构造器会对定义域进行排序操作,然后根据值域元素的个数切分为相等的片段。如果无法等分,多余的元素将被加入到最后一组。

+

如:

+
let quantile = d3.scaleQuantile()
.domain([1, 1, 2, 3, 2, 3, 16])
.range(['blue', 'white', 'red']);

quantile(3) // will output "red"
quantile(16) // will output "red"
+ +

解析:

+

其定义域将先被排序,而后被切分为 3 个片段:[1, 1], [2, 2], [3, 3, 16]

+

此时,如果执行 quantile.quantiles(),将得到一个数组 [2, 3],长度为值域长度减一。假设将其赋值为 quantiles,其含义为:

+
    +
  • 定义域中小于 quantiles[0] 的值的,将被划分到第一个片段
  • +
  • 大于等于数组元素 quantiles[0] 的值但是小于数组元素 quantiles[1] 的值的,将被划分到第二个片段
  • +
  • 以此类推
  • +
+

划分线段后,定义域就与值域成为一一对应的关系了。因此就有了以上结果。

+

Threshold Scales

Threshold scalesQuantile Scales 类似,区别是,我们将往 domain 中 直接传入与前者类似的 quantiles,也就是说,真正的定义域不做限制,限制的是它划分片段的方式

+

例子:

+
let color = d3.scaleThreshold()
.domain([0, 1])
.range(["red", "white", "green"]);

color(-1); // "red"
color(0); // "white"
color(0.5); // "white"
color(1); // "green"
color(1000); // "green"
+ +

Ordinal Scales

Ordinal Scales(散点映射)

+

与连续映射不同,散点映射接受离散的定义域与值域。比如在一个博客中把不同的标签映射到一组颜色上去等。如果值域的元素量比定义域少,那么值域会“重复使用”。如:

+
let ordinal = d3.scaleOrdinal()
.domain(["apple", "orange", "banana", "grapefruit"])
.range([0, 100]);

ordinal("apple"); // 0
ordinal("orange"); // 100
ordinal("banana"); // 0
ordinal("grapefruit"); // 100
+ +

Band Scales

Band ScalesOrdinal Scales 类似,区别是,其值域是连续的数值。例子:

+
let band = d3.scaleBand()
.domain(["apple", "orange", "banana", "grapefruit"])
.range([0, 100]);

band("apple"); // 0
band("orange"); // 25
band("banana"); // 50
band("grapefruit"); // 75
+ +

Band Scales 提供了一些实用方法,用于控制映射的结果。比如获取 Band Width,强制转换整数,添加 Padding 等。

+

Point Scales

Point ScalesBand Scales 的特例,它的 Band Width 始终为 0

+
let point = d3.scalePoint()
.domain(["apple", "orange", "banana", "grapefruit"])
.range([0, 100]);

point("apple"); // 0
point("orange"); // 33.333333333333336
point("banana"); // 66.66666666666667
point("grapefruit"); // 100
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/deleted-a-game/index.html b/2016/deleted-a-game/index.html new file mode 100644 index 000000000..dde5e4755 --- /dev/null +++ b/2016/deleted-a-game/index.html @@ -0,0 +1,478 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +删除了一个游戏 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 删除了一个游戏 +

+ + +
+ + + + +

今天,在N连跪呕心沥血终于推倒对面一座塔赢了以后,我把皇室战争这个游戏删了。

+

通常来说,删除一个游戏的原因无非几种:没兴趣,不好玩,玩腻了,等等。但是,皇室战争这个游戏,却比较特别。

+

要说它不好玩呢,其实挺有意思的,也挺符合现代游戏的节奏,不需要长时间在线,有空抽几分钟玩一局即可。

+

要说玩腻了呢,其实也没有,虽说已经玩了好几个月,但是很多酷炫的卡我依然还没有开到。

+

然后,问题在哪里呢?

+ + +

我在这个游戏里面完全找不到快乐。

+

很奇葩啊。一个在其它方面如此优秀的游戏,却完全忽视了一个游戏应该拥有的最基本的要素:它至少要能够让人感到快乐,哪怕是历尽八十一难以后的快乐。

+

然而,这个游戏,除了给予痛苦,愤怒,以及绝望以外,完全没有其它产出。

+

痛苦

+

一个人在这个游戏里能够获得的成就,由什么来决定呢?很不幸,我给出的答案是,5% 的技术,35% 的运气,以及 60% 的氪金。简单来说,这是一个氪金决定一切的游戏,因此,人民币战士就不讨论了,重点说一下像我这样的零氪玩家。

+

零氪玩家群体,在游戏中就是一个平等的群体了吗?很不幸,依然不是。在这种前提下,运气就是决定成就的最终因素。因为,皇室战争是一款卡牌对战游戏,卡牌在前,对战在后,你连卡都没有,拿什么对战呢?手上一堆屌丝卡牌,想凭借过人的技术和风骚的走位屹立在零氪之巅?我只能说,这个梦做得可以。

+

于此同时,更加残酷的现实是,游戏并没有零氪专区,就好像现实世界也没有设置单身狗专区一样。我除了要与运气比我好得多的零氪玩家斗智斗勇以外,时不时还要充当一下游戏本身为人民币玩家提供的服务。

+

愤怒

+

如果这个游戏仅仅是由以上内容,那它并不会使我感到愤怒,因为至少我还可以安静地玩我自己的游戏。

+

然而,很遗憾,游戏有一项特色机制,我觉得,应该称之为嘲讽机制。

+

在对战过程中,对手可以不断地发表情对我进行调戏,其实认真地说,都是一些很普通的表情,但是在某些场景下,就一定会让人觉得是一种嘲讽。

+

就很像 DOTA 中的“技不如人,甘拜下风”。

+

也许你会说,这不是很正常吗,任何对抗类游戏都会存在嘲讽这样的东西吧。

+

但是,皇室战争中的嘲讽,比较蛋疼。面对对手的嘲讽,97% 的情况都是无能为力的。为什么是 97% 呢?因为刚才说了,选手技术对游戏胜负只有 5% 的决定作用,而我对自己比较自信,给自己多加 1% 不过分吧。

+

也就是说,开局第一波交锋以后发现怼不过对面并且受到了嘲讽,97% 的情况下我都做不了任何改变。只能选择默默地被继续嘲讽,或者直接退出游戏。

+

这种情况,一开始遇到也许会觉得有趣,但是认真玩下去以后就会使人感到愤怒。

+

绝望

+

我痛苦着,我愤怒着,我卧薪尝胆总可以吧,前期痛苦越大,后面不就会获得更强烈的成就感吗?这样一来,它也可以稳稳地留住我这样的玩家。

+

然而并不会。

+

这也就是我说“绝望”的原因。

+

前期运气差,通过长时间的收集,总能够获得一些稀有卡片的。但是,有意义吗?卡片变好了就会进入更高阶的竞技场,结果是继续被更高阶的玩家吊打。就算我冲到了零氪玩家之巅,又如何?依然要被人民币战士吊打。

+

我要氪金呢?很遗憾,这个游戏,小氪完全不能给玩家带来任何改变,因为除了氪金,还有 35% 的运气成分在啊!而且,就算我疯狂氪金,在吊打一波渣渣以后,我也还是得继续被比我更有钱的人吊打。这个游戏带给我的东西不会有任何变化。

+

至于嘲讽,自然而然,我也可以嘲讽别人。但是问题在于,唯一能让我感到有趣的嘲讽是在首先受到了对手的嘲讽并成功翻盘的案例。这种情况,聊胜于无。

+

那么,我可以故意降杯去欺负新人吗?

+

啊,终于,发现了一条可行的路子。但是,我依然要保持一定的负场率,不然还是会在吊打菜狗的过程中不知不觉爬回到原本的竞技场,继续被吊打。

+

其实有很多人也确实是这么做了。我经常玩着玩着看到一些人掏出几个 9 级甚至 10 级的卡片,就会想,你这么厉害,还在这里干什么啊。然后就只能等死。这真是一个忧伤的故事。

+

其实这游戏有一个缺陷:它只有一种游戏模式。虽然它对游戏性把握得比较好,不容易感到疲倦,但是,这也就导致了我完全无法回避上面所提出的种种问题。最近新出的一个锦标赛模式就不谈了,我连加入都从来没有成功过,谈何体验。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/efficient-css-and-reflow-repaint/index.html b/2016/efficient-css-and-reflow-repaint/index.html new file mode 100644 index 000000000..2b9177a48 --- /dev/null +++ b/2016/efficient-css-and-reflow-repaint/index.html @@ -0,0 +1,486 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +高效 CSS 与 Reflow & Repaint | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 高效 CSS 与 Reflow & Repaint +

+ + +
+ + + + +

高效 CSS

如何编写高效 CSS 其实是一个过时的话题。

+

这方面曾经存在许多真知灼见,比如说 CSS 选择器的解析方向是从子到父,比如说 ID 选择器是最快的,不应该给 Class 选择器加上 Tag 限制,尽量避免使用后代选择器等。但是,随着浏览器解析引擎的发展,这些都已经变得不再那么重要了。MDN 上阐述高效 CSS 的文章也已经被标记为过时。

+

Antti Koivisto 是 Webkit 核心的贡献者之一,他曾说:

+
+

My view is that authors should not need to worry about optimizing selectors (and from what I see, they generally don’t), that should be the job of the engine.

+
+

因此,如果把“高效 CSS”的含义限制为“高效 CSS 选择器”的话,那么实际上现在它已经不是开发者需要关心的问题了。我们需要做的事情变得更“政治正确”:保证功能与结构的良好可维护性即可。

+

那么 CSS 的性能还能通过什么方式提升呢?这就是下面的内容。

+ + +

Reflow & Repaint

概览

Reflow (回流)和 Repaint(重绘)是浏览器的两种动作。

+
    +
  • Repaint 会在某个元素的外观发生变化,但没有影响布局时触发。比如说 visibility / outline / background-color 等 CSS 属性的变化将会触发 Repaint
  • +
  • Reflow 在元素变化影响到布局时触发
  • +
+

显然,Reflow 的代价要比 Repaint 高昂得多,它影响到了页面部分(或者所有)的布局。一个 元素的 Reflow 动作同时也会触发它的所有后代 / 祖先 / 跟随它的 DOM 节点产生 Reflow

+

比如说:

+
<body>
<div>
<h4>Hello World</h4>
<p><strong>Welcome: </strong>......</p>
<h5>......</h5>
<ol>
<li>......</li>
<li>......</li>
</ol>
</div>
</body>
+ +

对这一小段 HTML 来说,如果 <p> 元素上产生了 Reflow,那么 <strong> 将会受到影响(因为它属于前者的后代元素),当然也跑不了 <div><body> (祖先元素),<h5><ol> 则躺枪:没事跟在别人后面干啥呢。

+

因此,大多数的 Reflow,其实都导致了整个页面重新渲染。这对于计算能力稍低的设备(如手机)来说是非常困难的。我经常发现桌面计算机上运行良好的动画效果到了手机上就看起来很痛苦。

+

Reflow 的触发点

    +
  • Window resizing
  • +
  • 改变字体
  • +
  • 增删样式表
  • +
  • 内容改变,比如用户在输入框中输入
  • +
  • 触发 CSS 伪类,比如 :hover
  • +
  • 更改 class 属性
  • +
  • 脚本操作 DOM
  • +
  • 计算 offsetWidthoffsetHeight
  • +
  • 更改 style 属性
  • +
  • ……
  • +
+

如何优化

恩。。。看了这么多发现,要完全避免 Reflow 还是比较困难的。那么我们至少可以有一些办法去减少它们的影响吧。

+

以下的方法都是收集于一些国外作者的博客。

+

尽量选择 DOM 树底层的元素去修改 Class

比如说,不要选择类似 Wrapper 这样的元素去修改 Class,而尽量找更加底层的元素。因为 Reflow 会影响所有后代祖先以及后邻,这么干可以尽量地减少 Reflow 的影响,从而提高 CSS 渲染性能。

+

避免设置多个内联样式

这里的意思其实是说不要使用 JS 来给元素按部就班地设置样式 —— 因为每一次样式变化都会引起一次 Reflow,最好把样式整合为一个 Class 然后一次性加到元素上面去。

+

还有另外一种解决办法是在设置样式前先将其脱离正常文档流,比如 display 属性设为 none,然后所有设置都完成后再变回来。这样也是可以接受的。

+

如果要使用动画尽量选择 Position 为 Fixed 或 Absolute 的元素

动画的每一帧都会引起 Repaint 或者 Reflow,最好是可以让它脱离正常文档流,这样就绝对不会引起大规模持续的 Reflow

+

不要选用 Table 布局

虽然我们已经有很多理由不去使用 table 布局了,但这又是另外一个 —— 任意一个单元格的小改动都很有可能触发整个布局所有节点的变化,带来巨大的性能开销。

+

 

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/github-pages-and-ssl/index.html b/2016/github-pages-and-ssl/index.html new file mode 100644 index 000000000..b5caddbde --- /dev/null +++ b/2016/github-pages-and-ssl/index.html @@ -0,0 +1,485 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Github Pages and SSL | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Github Pages and SSL +

+ + +
+ + + + +

经过一些努力,把博客迁移到了 Github Pages,将域名改成了自定义,并且成功启用了 SSL,以下是步骤(就不截图了)。

+ + +

部署代码

Github Pages 支持两种级别的部署:

+
    +
  1. user / organization 方式
      +
    • repo 名字必须为 <user-name or org-name>.github.io
    • +
    • pages build branch 固定为 master
    • +
    • 部署后的发布域名即为 repo 名
    • +
    +
  2. +
  3. project 方式
      +
    • repo 名字没有限制
    • +
    • pages build branch 可以任意指定
    • +
    • 发布域名为 <user-name or org-name>.github.io/<project-name>
    • +
    +
  4. +
+

因此,如果要做个人页面则必然选择第一种方式。

+

因为 build branch 限制为 master,因此我一开始选择了重建 repo,实际上没有必要,可以直接 rename 旧的 repo

+

rename 后,到 repo settings -> options -> Github Pages,即可发现自动部署已经开始了。即刻访问 <user-name or org-name>.github.io 可以看到部署结果。

+

自定义域名

    +
  1. 在 repo settings -> options -> Github Pages -> Custom Domain 中,填入自己的域名,如 wxsm.space,保存
  2. +
  3. 在自己的域名供应商处修改域名解析,添加两条 A 记录(此处可以参考最新文档):
      +
    • 192.30.252.153
    • +
    • 192.30.252.154
    • +
    +
  4. +
  5. 等待记录生效
  6. +
+

过一会就可以发现,使用自定义域名可以访问网站了,并且原 <user-name or org-name>.github.io 会重定向到自定义域名。

+

启用 SSL

这里是最麻烦的。虽然 Github Pages 原生支持 SSL,但是只针对 *.github.io 域名,对于自定义域名,无法直接启用。因此需要找一个支持 SSL 的 CDN 供应商。考虑到免费这个关键因素,选择了 CloudFlare(以下简称 CF)

+
    +
  1. 前往 CF 官网,注册账号,填入自己的域名,点几个 Continue
  2. +
  3. 到注册的最后一步时,需要将域名的 DNS 服务器切换为 CF 服务器(CF 会提供两个,两个都要填上),到原域名供应商处修改域名 DNS 服务器即可,24 小时内生效
  4. +
  5. 生效后可以打开网站查看是否正常。控制台页面上方有一个 Crypto Tab,点开,SSL 选择 Flexible 或者 Full,同样需要等待一段时间生效
  6. +
  7. 生效后即可以通过 https 访问网站了,如果需要强制 SSL,可以到 Page Rules Tab,添加一些记录,为某些域名段设置强制 SSL Aways use https
  8. +
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/graceful-degradation-versus-progressive-enhancement/index.html b/2016/graceful-degradation-versus-progressive-enhancement/index.html new file mode 100644 index 000000000..0f67e75f0 --- /dev/null +++ b/2016/graceful-degradation-versus-progressive-enhancement/index.html @@ -0,0 +1,523 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +“渐进增强”与“优雅降级” | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ “渐进增强”与“优雅降级” +

+ + +
+ + + + +

“渐进增强”与“优雅降级”是 Web 页面两种不同的开发理念,为了简单起见,先给出定义(By W3C):

+
+

Graceful degradation Providing an alternative version of your functionality or making the user aware of shortcomings of a product as a safety measure to ensure that the product is usable. Progressive enhancement Starting with a baseline of usable functionality, then increasing the richness of the user experience step by step by testing for support for enhancements before applying them.

+
+

翻译:“优雅降级”的目的是为你的功能模块提供一种替代方案,或者让用户意识到某种产品(浏览器)的缺陷来保证你的产品的可用性。“渐进增强”是在一个最基本的可用功能之上,通过在拓展功能前检测(浏览器的)支持性逐步地提升用户体验。

+

这两种方案看起来好像没有什么太大区别,并且最终的结果貌似也是一样的。但是看完后面更多的解释和示例,就会更明白一些,其实这里面是真的有区别的。

+

一些博文将其简单地归结为如下内容:

+
.transition {   /*渐进增强写法*/
-webkit-transition: all .5s;
-moz-transition: all .5s;
-o-transition: all .5s;
transition: all .5s;
}
.transition { /*优雅降级写法*/
transition: all .5s;
-o-transition: all .5s;
-moz-transition: all .5s;
-webkit-transition: all .5s;
}
+ +

这个解释是完全错误的。实际上任何情况下我们都应该使用前者的 CSS 写法。

+ + +

在不断变化的环境中开发

稍微接触过 Web 开发的同学都能发现,我们目前所面对的最大难题不是如何实现强大的功能,而是如何保证即使是不那么强大的功能也能够被所有(退一步说,大多数)用户正常地使用。

+
+

The web was invented and defined to be used with any display device, in any language, anywhere you want. The only thing expected of end users is that they are using a browsing device that can reach out to the web and understand the protocols used to transmit information — http, https, ftp and so on.

+
+

正是因为可以访问 Web 的设备、语言、环境等因素太多太复杂,在极大促进 Web 发展的同时,也导致了以上困境。我们开发者无法对用户所使用的设备有任何期待。因此,我们无法保证 Web 应用上的所有功能都能够正确运行。

+

我们需要在未知中寻找出路,这就是“渐进增强”与“优雅降级”出现的原因。

+

二者概览

上面有对二者有一个简单的定义,这里再作一些扩展。

+

优雅降级:

+

首先确保使用现代浏览器的用户能够获得最好的用户体验,然后为使用老旧浏览器的用户优雅降级。降级的结果可能会导致功能不再那么美好,但是,只要能够为用户保留住访问网站的最后一点价值就算是达到了目的。让他们至少能看到正常的页面。

+

渐进增强:

+

相似,但又不一样。首先为所有浏览器都提供一个能够正常渲染并工作的版本,然后为更高级的浏览器构建高级功能。

+

换句话说,“优雅降级”是从复杂的现状开始,尝试去修复一些问题;而“渐进增强”则从最基础入手,为更好的环境提供扩展。“渐进增强”可以让你的基础更加牢固,并且始终保持向前看的姿态。

+

一个例子

光说真的很难理解,我们来看个例子。(By W3C)

+

“打印此页”链接

+

有时候我们会想让用户可以点击一个链接或按钮以打印整个页面,于是拍脑袋就有了如下代码:

+
<p id="printthis">
<a href="javascript:window.print()">Print this page</a>
</p>
+ +

这段语句在启用 JavaScript 并且支持 print 方法的浏览器中非常完美。然而,悲催的是,万一浏览器禁用了 JavaScript,或者根本就不支持,那么点击它就完全没有任何反应了。这就造成了一个问题,作为站点开发者,你写出这个链接就相当于向用户保证了这项功能,然而并没有,用户会感到困惑、被欺骗,并且责怪你提供了如此差的用户体验。

+

为了减轻问题的严重程度,开发者通常会使用优雅降级的策略:

+

告诉用户这个链接可能会不起作用,或者提供替代方案。一般来说我们会使用 noscript 元素来达到目的,就像这样:

+
<p id="printthis">
<a href="javascript:window.print()">Print this page</a>
</p>
<noscript>
<p class="scriptwarning">
Printing the page requires JavaScript to be enabled.
Please turn it on in your browser.
</p>
</noscript>
+ +

这就是优雅降级的一种体现 —— 我们告诉用户发生了错误并且如何去修复。但是,这有一个前提,用户必须是:

+
    +
  • 知道什么是 JavaScript
  • +
  • 知道怎么启用它
  • +
  • 有权限去启用它
  • +
  • 愿意去启用它
  • +
+

下面这种方式可能会更好些:

+
<p id="printthis">
<a href="javascript:window.print()">Print this page</a>
</p>
<noscript>
<p class="scriptwarning">
Print a copy of your confirmation.
Select the "Print" icon in your browser,
or select "Print" from the "File" menu.
</p>
</noscript>
+ +

这样上面所说的问题就都解决了。然而它的前提是所有浏览器都提供了“打印”功能。并且,事实依然没有任何改变:我们提供了一些可能完全没用的功能,并且需要做出解释。实际上这个“打印此页”链接完全就是没有必要存在的。

+

如果我们换一种方式,使用“渐进增强”法,则步骤如下。

+

首先我们考虑是否有一种方式可以不用写脚本实现打印功能,事实上并没有,因此我们从一开始就不应该选择“链接”这种 HTML 元素来使用。如果一项功能依赖 JavaScript 来实现,那就应该用 button

+

第二步,告诉用户去打印这个页面,就这么简单:

+
<p id="printthis">Thank you for your order. Please print this page for your records.</p>
+ +

注意,这无论在什么情况下都一定是适用的。接下来,我们使用“循序渐进”的 JavaScript 来给支持此功能的浏览器添加一个打印按钮:

+
<p id="printthis">Thank you for your order. Please print this page for your records.</p>
<script type="text/javascript">
(function(){
if(document.getElementById){
var pt = document.getElementById('printthis');
if(pt && typeof window.print === 'function'){
var but = document.createElement('input');
but.setAttribute('type','button');
but.setAttribute('value','Print this now');
but.onclick = function(){
window.print();
};
pt.appendChild(but);
}
}
})();
</script>
+ +

注意到何为“循序渐进”了吗:

+
    +
  • 使用自执行匿名函数包装法,不留下任何影响
  • +
  • 测试 DOM 方法的支持性,并且尝试获取节点
  • +
  • 测试节点是否存在,window 对象以及 print 方法是否存在
  • +
  • 如果全都没问题,我们就创建这个功能按钮
  • +
  • 把按钮添加到需要的位置上去
  • +
+

我们永远不给用户提供不能工作的 UI —— 只在它真正能工作时才显示出来。

+

适用场景

优雅降级适用场景:

+
    +
  • 当你在改进一个旧项目但时间有限的时候
  • +
  • 当你没有足够的时间使用渐进增强法去完成项目的时候
  • +
  • 当你要做的产品比较特别,比如说对性能要求特别高的时候
  • +
  • 当你的项目必须要有一些基础功能的时候(地图,邮箱等)
  • +
+

其余的所有情况下,渐进增强都能让用户与你都更开心:

+
    +
  • 不管环境如何,你的产品都能工作
  • +
  • 当新技术或浏览器发布的时候,你只需在原有基础上扩展功能,而无需修改最基础的解决方案
  • +
  • 技术可以更“用得其所”,而不仅仅是为了实现功能
  • +
  • 维护方便
  • +
+

总结

“渐进增强”与“优雅降级”最终都是为了实现一个目标:让所有用户都能够使用我们的产品。“渐进增强”方案看起来更优雅,但它需要更多的时间与资源去达成。“优雅降级”可以看成是现有产品的补丁:易于开发但难以维护。

+

注:本文大部分内容来自 W3C

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/how-to-build-a-wordpress-blog/index.html b/2016/how-to-build-a-wordpress-blog/index.html new file mode 100644 index 000000000..2a1427c2b --- /dev/null +++ b/2016/how-to-build-a-wordpress-blog/index.html @@ -0,0 +1,473 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +WordPress 博客搭建 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ WordPress 博客搭建 +

+ + +
+ + + + +

本文是本站的建站历程记录。每个人都可以使用极少的代价(甚至免费)拥有一个域名独立且完全自主的个人网站或博客,在于怎么选择而已。此类网站的搭建很多情况下并不要求其操作者是一个程序狗,所以个人感觉可玩性还是挺强的。整个过程一共需要准备三种事物:域名托管程序(特殊情况,如果选择国内主机则需要准备第四种,即备案)。

+ + +

域名

搭建一个网站首先必不可少的就是自己的域名,比如本站的域名是wxsm.space,域名可以在任意服务商处购买,不需要与托管的服务商一致。根据域名后缀的不同,价格在几十元至百元每年之间不等,大多数服务商都会提供首年优惠,少部分首年的价格甚至可以达到个位数(比如万网的 .top 域名购买首年只需4软妹币)。

+

域名的选择没什么技术含量,主要就是自己喜欢。本站的域名是在万网购买。域名购买以后可以解析到托管的IP地址。

+

需要注意的地方:

+
    +
  1. 如果打算购买国内(特指大陆)主机,就一定要确定自己购买的域名后缀是可以备案的类型。具体可以在工信部网站(公共查询 ⇒ 域名类型)查询,能查到的则是可备案后缀。切记切记。
  2. +
  3. 为自己的安全着想,最好不要使用 .cn 类型的域名。
  4. +
+

托管

托管有很多种选择,首先从大的方向说,尽量选择靠谱的供应商。淘宝小商家之类的虽然便宜,但是很多时候我们需要的更多是稳定,毕竟是把代码和数据都放在别人家,还是很要命的一件事。当然如果免费的话又另当别论。至于配置就纯看个人需求了。如果是玩玩个人小网站,一般选最低的那些都没问题。

+

国内/香港/国外

国内主机理论上来说从国内访问速度最快,其最大的特点是需要备案,其二是会被GFW限制,主机上的程序将会无法访问到Google等公司提供的服务。

+

香港主机速度可媲美国内主机,是主攻国内但是又不想备案的不二之选。

+

国外主机在国内的访问速度可能会非常之慢,尤其是欧美地区,稍快一些的可以选择新加坡。其特点是很多都相对国内以及香港主机较为便宜,以及有部分免费(比如AppHarbor,免费空间,免费SQL Server,无限流量,同步Github仓库,自动部署,简直不要太良心,可惜只能跑.Net)。

+

本站目前使用的是万网提供的国内主机。

+

虚机/VPS/其它

虚机即虚拟主机。该类型主机一般都是共享系统资源,如CPU,内存,带宽,IP等。虚机好像只有ASP.Net和PHP两种类型(反正我是没见过其它的),并且所有功能都是由外部配置好的,用户只能使用服务商所提供的功能,超出范围则无能为力,因此它的限制会比较大。操作方式就是通过FTP访问其储存空间,然后将写好的程序上传,马上就能在浏览器看到结果,不需要考虑部署、环境等。如果使用CMS(如Wordpress)的话,这种主机已经足够用了。

+

VPS相当于一台属于自己的计算机,用户可以通过各种方式登录并且对它操作,安装环境,部署网站等。Java,Node.js等类型的程序好像只能跑在VPS上。因为没有用过所以不太了解。其价格要普遍比虚机贵一些。

+

还有一些是类似Github Pages的静态服务器,它们只能够作为静态网页的托管。这些可能会很便宜,但是需要一定的技巧才能玩出花样来。

+

本站目前使用的是万网提供的虚机,免费版。每个人都可以申请,使用期限为两年。运行Wordpress完全没有问题。传送门:http://wanwang.aliyun.com/hosting/free/

+

程序

有了域名和托管,我们需要的最后一件事物就是程序。程序才是真正运行着的东西。

+

程序可以自己写,也可以用开源软件。自己写的好处就是完全控制,以及比较有意思,但如果更多的是想要做内容的话还是用开源软件比较好,这样就可以更好地关注于网站的内容本身而不是实现。

+

本站使用的是开源Wordpress CMS,我们要做的事情很简单,在中文官网https://cn.wordpress.org/首页把Wordpress程序下载回来,解压,然后将文件夹上传到服务器根目录,通过访问其任意页面来配置站点的基本信息,如名字,描述,数据库连接等,就可以非常方便地搭建好整个网站。数据库表等其它事物都会由程序自动生成。关于这个软件的安装和使用方法网络上有非常多的详细教程,遇到问题多用搜索就好啦。

+

WordPress的主题和插件简直数不过来,我觉得满足98%个人网站用户的需求完全是没问题的。加之它有强大的缓存插件,可以自动将所有的动态页面都缓存成HTML然后301之,所以访问性能也不在话下。当然如果想要实现某些特别的自定义功能的话,还是要懂一点点编程技巧才行。

+

备案

如果使用的是国内的托管,则需要进行最麻烦的一步:备案。

+

备案一般是由代理商完成,不需要我们自己直接与工信部沟通。

+

整个流程有两个地方稍微麻烦,一是初审填表,需要下载、打印、填表、扫描、上传这么多的步骤。其次是“当面核验”,其实就是拍个照上传,但是需要它专用的背景幕布,可以到指定的拍照地点免费拍摄,或者代理商以免费或者到付的形式快递幕布给申请人,然后自行拍照上传。

+

管局审核需要的时间从从两个小时到三十天不等,主要看运气。反正我是这个时间段内的都遇到过(广东)。

+

备案通过以后,需要把备案号以链接的形式加到网站的底部,然后就可以该干嘛干嘛了。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/idea-scrolling-issue/index.html b/2016/idea-scrolling-issue/index.html new file mode 100644 index 000000000..c4cceeda6 --- /dev/null +++ b/2016/idea-scrolling-issue/index.html @@ -0,0 +1,445 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +IDEA 滚动条问题 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ IDEA 滚动条问题 +

+ + +
+ + + + +

用 IDEA 撸代码的时候有一个非常恶心的问题,它的滚动条经常会无缘无故地跳动,最常见的就是拖动滚动条之后它会马上跳回到原本的位置,纵向和横向都有此问题,因此基本上每次都至少要拖两次滚动条才能成功,烦不胜烦。升级版本等等都没有用。今天终于找到了真正的解决方法,就是关闭屏幕取词软件或禁用软件的取词功能(比如有道)。完全、彻底地解决此问题。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/ie-cache-issue/index.html b/2016/ie-cache-issue/index.html new file mode 100644 index 000000000..14aa54196 --- /dev/null +++ b/2016/ie-cache-issue/index.html @@ -0,0 +1,486 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +IE Cache Issue | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ IE Cache Issue +

+ + +
+ + + + +

昨天发现了一个奇怪的问题,一个Web Application Update Entity的功能,在Chrome/Firefox上测试都正常运行,到了IE 11上就不行了,主要表现就是Update成功以后再次读取记录会读取出Update之前的值。功能逻辑就是一些简单的通过RESTful API来执行CRUD操作的Ajax调用。在IE上用控制台仔细调试一番后,发现在打开控制台的时候居然能表现正常,而关掉以后就立刻不行,这明显就是IE爸爸不走寻常路,把API也Cache下来了。于是就有了以下的解决方案。

+ + +

前端解决方案

既然是因为Cache产生的问题,那么就很容易解决,在API调用(主要是GET)中都添加一个随机数或者时间戳就行了,强制浏览器刷新。比如,原本请求的应该是这样的地址:

+
var url = '/api/metadata/entity/list?type=car&name=qq'
+ +

可以通过添加一个时间戳修改成这样:

+
var url = '/api/metadata/entity/list?type=car&name=qq&_t=' + new Date().getTime()
+ +

其中添加的 _t 参数如果服务端没有定义的话就会自然而然地被扔掉(如果是有意义的参数就换个key,或者不写key也行),浏览器缓存也会因为每次请求的URL实际上都不一样而失效,这样问题就解决了。但是,对于一个大型项目来说,如果每个URL都要怎么来一遍,那么用软件工程界的专业术语来说,叫做不好维护。很有可能什么时候漏掉了一个URL没有加时间戳,就埋下了一个BUG的种子。

+

服务端解决方案

此处以使用ExpressJS搭建的NodeJS服务器为例,其它代码也可以使用类似的方法达到同样的效果。

+

以下是一本万利的解决思路:

+
// No cache for RESTful APIs
app.use('/api/*', function (req, res, next) {
res.header("Cache-Control", "no-cache, no-store, must-revalidate");
res.header("Pragma", "no-cache");
res.header("Expires", 0);
next();
});
+ +

这段代码所做的事情是,对于所有进来的以 /api 开头为路由的请求,都执行以下操作:

+
    +
  • 给响应头添加 "Cache-Control": "no-cache, no-store, must-revalidate" 键值对
  • +
  • 给响应头添加 "Pragma": "no-cache" 键值对
  • +
  • 给响应头添加 "Expires": 0 键值对
  • +
  • 将请求交给下游中间件,继续处理,该干嘛干嘛
  • +
+

Cache-Control :

+
    +
  • no-cache:指示请求或响应消息不能缓存
  • +
  • no-store:用于防止重要的信息被无意的发布。在请求消息中发送将使得请求和响应消息都不使用缓存。
  • +
  • must-revalidate:字面理解,必须重新验证
  • +
+

Pragma :

+
    +
  • no-cache:在HTTP/1.1协议中,它的含义和Cache- Control:no-cache相同
  • +
+

Expires:

+
    +
  • 自然就是缓存的过期时间了
  • +
+

那么通过以上方法,只要浏览器是支持基本HTTP协议的,它就应该能够做出相应的操作,从而不对API进行缓存。很显然这段代码应该在所有API的具体方法执行之前被执行,对于Express来说我们只需要把它放在其他路由代码之前就可以了。

+

总结

经过验证,两种方法都可以达到预期的效果。至于实际使用哪一种,可能还要视具体需求而定。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/javascript-event-delegation/index.html b/2016/javascript-event-delegation/index.html new file mode 100644 index 000000000..8068cb234 --- /dev/null +++ b/2016/javascript-event-delegation/index.html @@ -0,0 +1,474 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +JavaScript 事件代理 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ JavaScript 事件代理 +

+ + +
+ + + + +

jQuery 曾经存在 3 种绑定事件的方法:bind / live / delegate,后来 live 被砍掉了,只留下 bind 与 delegate,它们之间的区别是,通过 bind 方法绑定的事件,只对当前存在的元素生效,而通过 delegate 则可以绑定“现在”以及“将来”的所有元素。

+

为“将来”元素绑定事件的适用场景还是挺多的。比如一个列表,或者一个表格,它可能会动态地被插入或者移除一些子元素,然后每个元素都需要有一个点击事件,这样的话我们就需要保证“现在”已存在的元素以及“将来”可能被添加进去的元素都能够正常工作。怎么办呢,我们总不能每插入一个元素就给它绑一次事件吧(事实上我以前没少干这事),因此 jQuery 就为我们提供了后者的方法。

+

一开始我觉得很奇怪,像 delegate 这样的方法是怎么实现的呢?通过监听 DOM 树变化吗?性能开销会不会特别大?后来知道了 JavaScript 有一种机制叫事件代理(event delegation),也就是本文要说的东西,才明白,原来一切都很简单。

+ + +

事件代理及其工作原理

何为代理呢,大概就是,你把你要做的事情告诉我,我帮你做,我就是你的代理。

+

那么事件代理,顾名思义,在一个元素上触发的事件,由另一个元素去处理,后者就是前者的事件代理。

+

大概就是这么回事。那么,如何实现呢?

+

这里就涉及两个关于 JavaScript 事件的知识:事件冒泡(event bubbling)以及目标元素(target element):

+
    +
  • 当一个元素上触发事件的时候,比如说鼠标点击了一个按钮,同样的事件将会在它的所有祖先元素上触发。这就是所谓的事件冒泡。
  • +
  • 所有事件的目标元素都将是最原始触发它的那个特定元素,就比如说那个按钮,其引用将被储存在事件对象内。
  • +
+

因此,我们可以做到:

+
    +
  • 给一个节点绑定事件
  • +
  • 等待其子孙节点的冒泡事件
  • +
  • 判断事件实际来源
  • +
  • 做出相应处理
  • +
+

这就是事件代理的工作原理。

+

有什么用

一个典型的场景是,如果一个表格有 100 行 100 列,你需要给每一个单元格都添加点击事件,怎么办?

+

当然可以说一次性把它们全选出来,绑定事件不就完了。但是,内存 BOOM,浏览器 BOOM

+

用事件代理就简单多了,给 table 绑一次事件,然后等它们冒泡上来就行了。

+

还有就是动态添加的元素。比如某一时刻 table 被添加了一行,那么新的一行其事件同样能冒泡并且被 table 上的事件处理器接收到。

+

代码

Talk is cheap, show me the code.

+
//Some browser diff issue handler
function getEventTarget(e) {
e = e || window.event;
return e.target || e.srcElement;
}

//Easy event handler on 'table' element
function editCell(e) {
var target = getEventTarget(e);
if(target.tagName.toLowerCase() === 'td') {
// DO SOMETHING WITH THE CELL
}
}
+ +

 

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/javascript-promise/index.html b/2016/javascript-promise/index.html new file mode 100644 index 000000000..9eab4886d --- /dev/null +++ b/2016/javascript-promise/index.html @@ -0,0 +1,524 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +JavaScript Promise | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ JavaScript Promise +

+ + +
+ + + + +

知乎上有一个黑 JavaScript 的段子,大概是说:

+
+

N 年后,外星人截获了 NASA 发射的飞行器并破解其源代码,翻到最后发现好几页的 }}}}}}……

+
+

这是因为 NASA 近年发射过使用 JavaScript 编程的飞行器,而 Node.js 环境下的 JavaScript 有个臭名昭著的特色:Callback hell(回调地狱的意思)

+

JavaScript Promise 是一种用来取代超长回调嵌套编程风格(特指 Node.js)的解决方案。

+

比如:

+
getAsync("/api/something", (error, result) => {
if(error){
//error
}
//success
});
+ +

将可以写作:

+
let promise = getAsyncPromise("/api/something"); 
promise.then((result) => {
//success
}).catch((error) => {
//error
});
+ +

乍一看好像并没有什么区别,依然是回调。但最近在做的一个东西让我明白,Promise 的目的不是为了干掉回调函数,而是为了干掉嵌套回调函数。

+ + +

定义

MDN 定义:

+
+

The Promise object is used for asynchronous computations. A Promise represents a value which may be available now, or in the future, or never.

+
+

意思大概就是,Promise 是专门用于异步处理的对象。一个 Promise 代表着一个值,这个值可能已经获得了,又可能在将来的某个时刻会获得,又或者永远都无法获得。

+

简单地说,Promise 对象就是值的代理。经纪人。

+

简单用法

创建一个 Promise:

+
let promise = new Promise((resolve, reject) => {
//success -> resolve(data)
//error -> reject(data)
});
+ +

使用 new Promise 来创建 Promise 对象,构造器中传入一个函数,同时对该函数传入 resolvereject 参数,分别代表异步处理成功与失败时将要调用的方法。

+

处理 Promise 结果:

+
promise.then(onFulfilled, onRejected)
+ +

使用 then 方法来注册结果函数,共可以注册两个函数,其中 onFulfilled 代表成功,后者代表失败。两个参数都是可选参数。

+

不过,对于失败处理,更加推荐的方式是使用 catch 方法:

+
promise.catch(onRejected)
+ +

这两个方法可以进行链式操作。组合示例:

+
function asyncFunction() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Async Hello world');
}, 1000);
});
}

asyncFunction()
.then((value) => {
console.log(value); //Async Hello world
})
.catch((error) => {
console.log(error);
});
+ +

这里使用了定时器来模拟异步过程,实际上其它异步过程(如 XHR)也大概都是这么个写法。

+

状态

Promise 对象共有三种状态:

+
    +
  • Fulfilled (成功)
  • +
  • Rejected (失败)
  • +
  • Pending (处理中)
  • +
+

有两条转换路径:

+
    +
  • Pending -> Fulfilled -> then call
  • +
  • Pending -> Rejected -> catch call
  • +
+

Promise 对象的状态,从 Pending 转换为 Fulfilled 或 Rejected 之后, then 方法或者 catch 方法就会被立即调用,并且这个 promise 对象的状态不会再发生任何变化。也就是说,调用且只调用一次。

+

链式操作

链式操作是 Promise 对象的一大亮点。

+

本节引用一些 Promise Book 的内容。

+

例如:

+
function taskA() {
console.log("Task A");
}
function taskB() {
console.log("Task B");
}
function onRejected(error) {
console.log("Catch Error: A or B", error);
}
function finalTask() {
console.log("Final Task");
}

var promise = Promise.resolve();
promise
.then(taskA)
.then(taskB)
.catch(onRejected)
.then(finalTask);

//Task A
//Task B
//Final Task
+ +

该代码块实际流程如图所示:

+

+

 

+

可以看到,这个 onRejected 并不仅仅是 TaskB 的失败处理函数,同时它也是 TaskA 的失败处理函数。而且当 TaskA 失败(reject 被调用或者抛出异常)时,TaskB 将不会被调用,直接进入失败处理。熟悉 express 的玩家应该能看出来了,这简直就和中间件一模一样嘛。

+

比如说,TaskA 出现异常:

+
function taskA() {
console.log("Task A");
throw new Error("throw Error @ Task A")
}
function taskB() {
console.log("Task B");// 不会被调用
}
function onRejected(error) {
console.log(error);// => "throw Error @ Task A"
}
function finalTask() {
console.log("Final Task");
}

var promise = Promise.resolve();
promise
.then(taskA)
.then(taskB)
.catch(onRejected)
.then(finalTask);
+ +

这里的输出应该就是:

+
//Task A
//Error: throw Error @ Task A
//Final Task
+ +

需要注意的是,如果在 onRejectedfinalTask 中出现异常,那么这个异常将不会再被捕捉到。因为并没有再继续注册 catch 函数。

+

借助 Promise 链式操作的特点,复杂的 JavaScript 回调简化将不再是梦。

+

递归

Promise 可以实现递归调用,在用来一次性抓取所有分页内容的时候有用。例:

+
function get(url, p) {
return $.get(url + "?page=" + p)
.then(function(data) {
if(!data.list.length) {
return [];
}

return get(url, p+1)
.then(function(nextList) {
return [].concat(data.list, nextList);
});
});
}

get("urlurl", 1).then(function(list) {
console.log(list);//your full list is here
});
+ +

实用方法

Promise.all

Promise.all 接受一个 promise 对象的数组作为参数,当这个数组里的所有promise对象全部变为 resolve 或 reject 状态的时候,它才会去调用 then 方法。

+

例:

+
function taskA() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('TaskA resolved!');
resolve();
}, 1000);
});
}

function taskB() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('TaskB resolved!');
resolve();
}, 2000);
});
}

function main() {
return Promise.all([taskA(), taskB()]);
}

main()
.then((value) => {
console.log('All resolved!');
})
.catch((error) => {
console.log(error);
});

//TaskA resolved!
//TaskB resolved!
//All resolved!
+ +

Promise.race

Promise.all 类似,略有区别,从名字就能看出来,只要有一个 Task 执行完毕,整个 Promise 就会返回。但是需要注意的是,返回以后并不会取消其它未完成的 Promise 的执行。

+
function taskA() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('TaskA resolved!');
resolve();
}, 1000);
});
}

function taskB() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('TaskB resolved!');
resolve();
}, 2000);
});
}

function main() {
return Promise.race([taskA(), taskB()]);
}

main()
.then((value) => {
console.log('All resolved!');
})
.catch((error) => {
console.log(error);
});

//TaskA resolved!
//All resolved!
//TaskB resolved!
+ +

支持性

由于是 ES6 语法,目前在浏览器端支持不是特别好,很多移动端浏览器以及 IE 家族均不支持(具体可查看 MDN)。如果要在浏览器端使用需要借助 Babel 编译器。

+

至于 Node.js 环境则毫无问题。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/mean-js-menu-service-extension/index.html b/2016/mean-js-menu-service-extension/index.html new file mode 100644 index 000000000..85098501d --- /dev/null +++ b/2016/mean-js-menu-service-extension/index.html @@ -0,0 +1,471 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +MEAN.js Menu Service Extension | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ MEAN.js Menu Service Extension +

+ + +
+ + + + +

MEAN.js解决方案只提供了1级/2级菜单栏的service支持,最近项目中需要用到第3级菜单,所以需要进行一个小的功能扩展。一开始我以为可以很容易地做到无限级,真正做起来以后发现并没有那么简单,所以目前通过这个办法只能达到第3级。

+ + +

修改Menu服务

初始的Menu Service中为使用者写了两个添加菜单项的方法:

+
// Add menu item object
this.addMenuItem = function (menuId, options)
+ +

以及

+
// Add submenu item object
this.addSubMenuItem = function (menuId, parentItemState, options)
+ +

第一个方法很显然就是用来添加顶级菜单了,第二个在没有看代码以前我曾经天真地以为它可以无限嵌套,然而并没有,它做的事情仅限于添加第2级菜单。所以现在我需要自己写第三个方法来完成添加第三级菜单。考虑到三级循环的效率问题,虽然一般来说菜单项不会有太多,但看起来就是非常不爽,所以我给每个Menu项都添加了一个哈希表来储存其下面所有菜单项的引用,这样多花费一点点内存就可以不用写循环嵌套了。由于使用了哈希表,对原2级菜单做了一些修改:

+
// Add submenu item object
this.addSubMenuItem = function (menuId, parentItemState, options) {
options = options || {};

// Validate that the menu exists
this.validateMenuExistance(menuId);

// Search for menu item
for (var itemIndex in this.menus[menuId].items) {
if (this.menus[menuId].items[itemIndex].state === parentItemState) {
// Push new submenu item
var newSubmenuItem = {
title: options.title || '',
state: options.state || '',
disabled: options.disabled || false,
roles: ((options.roles === null || typeof options.roles === 'undefined') ? this.menus[menuId].items[itemIndex].roles : options.roles),
position: options.position || 0,
shouldRender: shouldRender,
items: []
};
this.menus[menuId].items[itemIndex].items.push(newSubmenuItem);
this.menus[menuId].menuHash[newSubmenuItem.state] = newSubmenuItem;

if (options.items) {
for (var i in options.items) {
this.addSubMenuItemToSubMenu(menuId, options.state, options.items[i]);
}
}
}
}

// Return the menu object
return this.menus[menuId];
};
+ +

然后是新的添加3级菜单的方法:

+
//For level 3 menu items
this.addSubMenuItemToSubMenu = function (menuId, parentItemState, options) {
options = options || {};
this.validateMenuExistance(menuId);
for (var itemIndex in this.menus[menuId].menuHash) {
if (this.menus[menuId].menuHash[itemIndex].state === parentItemState) {
// Push new submenu item
var newSubMenuItem = {
title: options.title || '',
state: options.state || '',
disabled: options.disabled || false,
roles: ((options.roles === null || typeof options.roles === 'undefined') ? this.menus[menuId].menuHash[itemIndex].roles : options.roles),
position: options.position || 0,
shouldRender: shouldRender,
items: []
};
this.menus[menuId].menuHash[itemIndex].items.push(newSubMenuItem);
this.menus[menuId].menuHash[newSubMenuItem.state] = newSubMenuItem;
}
}
return this.menus[menuId];
};
+ +

修改Header模板

原Header模板中嵌套了两层Angular循环来遍历菜单项,我们给它加一层就好了,改完以后就像这样:

+
<nav class="collapse navbar-collapse" uib-collapse="!isCollapsed" role="navigation">
<ul class="nav navbar-nav" ng-if="menu.shouldRender(authentication.user);">
<li ng-repeat="item in menu.items | orderBy: 'position'" ng-if="item.shouldRender(authentication.user);"
ng-switch="item.type"
ng-class="{ active: $state.includes(item.state), dropdown: item.type === 'dropdown',disabled:item.disabled }"
class="{{item.class}}" uib-dropdown="item.type === 'dropdown'">
<a ng-switch-when="dropdown" class="dropdown-toggle" uib-dropdown-toggle role="button">{{::item.title}}&nbsp;<span
class="caret"></span></a>
<ul ng-switch-when="dropdown" class="dropdown-menu">
<li ng-repeat="subitem in item.items | orderBy: 'position'" ng-if="subitem.shouldRender(authentication.user);"
ui-sref-active="active" ng-class="{'dropdown-submenu':subitem.items.length>0,disabled:subitem.disabled}">
<a ui-sref="{{subitem.state}}" ng-bind="subitem.title" ng-if="subitem.items.length===0"></a>
<a href="javascript:;" ng-bind="subitem.title" ng-if="subitem.items.length>0"></a>
<ul class="dropdown-menu" ng-if="subitem.items.length>0">
<li ng-repeat="i in subitem.items | orderBy: 'position'" ng-if="i.shouldRender(authentication.user);"
ui-sref-active="active" ng-class="{disabled:i.disabled}">
<a ui-sref="{{i.state}}" ng-bind="i.title"></a>
</li>
</ul>
</li>
</ul>
<a ng-switch-default ui-sref="{{item.state}}" ng-bind="item.title"></a>
</li>
</ul>
<ul class="nav navbar-nav navbar-right" ng-hide="authentication.user">
<li ui-sref-active="active">
<a ui-sref="authentication.signup">Sign Up</a>
</li>
<li class="divider-vertical"></li>
<li ui-sref-active="active">
<a ui-sref="authentication.signin">Sign In</a>
</li>
</ul>
<ul class="nav navbar-nav navbar-right" ng-show="authentication.user">
<li class="dropdown" uib-dropdown>
<a class="dropdown-toggle user-header-dropdown-toggle" uib-dropdown-toggle role="button">
<img ng-src="{{authentication.user.profileImageURL}}" alt="{{authentication.user.displayName}}"
class="header-profile-image"/>
<span ng-bind="authentication.user.displayName"></span> <b class="caret"></b>
</a>
<ul class="dropdown-menu" role="menu">
<li ui-sref-active="active">
<a ui-sref="settings.profile">Edit Profile</a>
</li>
<li ui-sref-active="active">
<a ui-sref="settings.picture">Change Profile Picture</a>
</li>
<li ui-sref-active="active" ng-show="authentication.user.provider === 'local'">
<a ui-sref="settings.password">Change Password</a>
</li>
<!--li ui-sref-active="active">
<a ui-sref="settings.accounts">Manage Social Accounts</a>
</li-->
<li class="divider"></li>
<li>
<a href="/api/auth/signout" target="_self">Signout</a>
</li>
</ul>
</li>
</ul>
</nav>
+ +

修改CSS

为了让菜单看起来更自然些,这里修改的是 core.css,添加以下内容:

+
.dropdown-submenu {
position: relative;
}

.dropdown-menu {
-webkit-border-radius: 0;
-moz-border-radius: 0;
border-radius: 0;
}

.dropdown-submenu > .dropdown-menu {
top: 0;
left: 100%;
margin-top: -6px;
margin-left: -1px;
}

.dropdown-submenu:hover > .dropdown-menu {
display: block;
}

.dropdown-submenu > a:after {
display: block;
content: " ";
float: right;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
border-width: 5px 0 5px 5px;
border-left-color: #333;
margin: 5px -10px 0;
}

.dropdown-submenu:hover > a:after {
border-left-color: #333;
}

.dropdown-submenu.pull-left {
float: none;
}

.dropdown-submenu.pull-left > .dropdown-menu {
left: -100%;
margin-left: 10px;
-webkit-border-radius: 6px 0 6px 6px;
-moz-border-radius: 6px 0 6px 6px;
border-radius: 6px 0 6px 6px;
}
+ +

 

+

使用方法

3级菜单的定义方法与2级菜单一模一样,除了直接调用 addSubMenuItemToSubMenu 以外,还可以通过在2级菜单内定义 items 来实现添加子菜单,示例如下,高亮部分则为3级菜单:

+
Menus.addMenuItem('topbar', {
title: '...',
state: '...',
type: 'dropdown',
position: 0,
roles: ['*'],
items: [{
title: '...',
state: '...',
roles: ['*']
}, {
title: '...',
state: '...',
roles: ['*'],
items: [{
title: '...',
state: '...',
roles: ['*']
}, {
title: '...',
state: '...',
roles: ['*']
}]
}]
});
+ +

实现无限级

目前看来菜单层级的限制不是在于Service代码,而在于模板。如何在模板中让Angular做一个DFS搜索才是重点。Angular貌似没有提供类似的API,要做的话比较好的办法应该是自己写一个指令。以后有时间再来实现。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/mean-js-use-forever-to-prevent-app-crash/index.html b/2016/mean-js-use-forever-to-prevent-app-crash/index.html new file mode 100644 index 000000000..f16dbf6f7 --- /dev/null +++ b/2016/mean-js-use-forever-to-prevent-app-crash/index.html @@ -0,0 +1,472 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +MEAN.JS 搭配 forever 使用以防止 app crash | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ MEAN.JS 搭配 forever 使用以防止 app crash +

+ + +
+ + + + +

MEANJS 预设的 Grunt task 中没有提供类似出错自动重启的任务,因此当实际使用它搭建了一个 app 部署到服务器上后发现经常有一些奇怪的问题导致其崩溃挂掉。然而根据 log 来看问题应该不是由于项目代码导致的,可能是 MEANJS 本身的问题,也可能是某些 Lib 的问题。这种情况下,我能想到的暂时性解决方案就是使用 forever 了。

+

个人觉得 MEANJS 在 production mode 中也使用 nodemon 来跑 watch 任务有些鸡肋,因为我们并不需要在产品服务器上频繁地更改代码。因此,我直接把它替换掉了。

+ + +

这里需要注意的是,我们不能直接用 forever 去跑 server.js 脚本,因为这样的话下层代码拿不到 env settings,就会把启动模式设置为默认的开发模式。

+

因为 MEANJS 中已经自带了 forever 模块,所以就不用装它本身了,但是要安装 forever 的 grunt 插件:grunt-forever

+
npm install grunt-forever -save
+ +

在 tasks(initConfig) 中加多一项:

+
forever: {
server: {
options: {
index: 'server.js',
logFile: 'log.log',
outFile: 'out.log',
errFile: 'err.log'
}
}
}
+ +

这里指定了 forever 执行的对象,以及 log 文件名,路径可以不指定,默认为项目根目录下的 forever 文件夹。因为这个插件生成的是守护进程,所以 log 只能输出到文件啦。

+

最后更改一下 prod task:

+
// Run the project in production mode
grunt.registerTask('prod', ['build', 'env:prod', 'mkdir:upload', 'copy:localConfig', 'forever:server:start']);
+ +

OK,大功告成。

+

启动 production 服务器方式:

+
grunt prod
+ +

重启方式:

+
grunt forever:server:restart
+ +

停止服务器:

+
grunt forever:server:stop
+ +

 

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/node-js-web-spider-note-1/index.html b/2016/node-js-web-spider-note-1/index.html new file mode 100644 index 000000000..f5549bca1 --- /dev/null +++ b/2016/node-js-web-spider-note-1/index.html @@ -0,0 +1,510 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Node.js Web Spider Note - 1 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Node.js Web Spider Note - 1 +

+ + +
+ + + + +

项目地址:https://github.com/wxsms/zhihu-spider

+

简介:使用 Node.js 实现的一个简单的知乎爬虫,可以以一个用户为入口,爬取其账号下的一些基本信息,关注者,关注话题等。再通过关注者的 ID 继续爬取其他用户,以此循环。

+

实现功能:登录知乎(因为调用一些知乎 API 需要保存 session),解析页面,访问 AJAX API,保存到数据库。

+ + +

执行流程

+

蓝色部分的任一流程出现失败或错误,程序都会直接返回到“从种子队列取出一个用户ID”这一步。因为作为一个完整的知乎用户来说,它应该是包含了个人信息,关注以及话题的,缺失一项会导致其失去很大部分的意义。

+

技术栈

本程序以 Node.js 为实现核心(本机版本 v6.5.0),用到的依赖很少,如下:

+
"dependencies": {
"asyncawait": "^1.0.6", //模拟ES7 async/await语法
"jquery": "^3.1.0", //用来解析HTML
"jsdom": "^3.1.2", //为HTML和jQuery模拟浏览器环境
"lodash": "^4.15.0",
"log4js": "^0.6.38",
"mocha": "^3.0.2", //用于单元测试
"mongoose": "^4.6.0",
"superagent": "^2.2.0", //请求发送
"superagent-promise": "^1.1.0" //请求发送的Promise封装
}
+ +

程序解析

程序源代码在代码仓库的 src 目录下,目录结构:

+
├─config
├─constants
├─http
├─model
├─parsers
│ ├─follow
│ ├─topic
│ └─user
├─services
├─spiders
└─utils
+ +

配置相关以及通用模块

config 目录主要放置一些程序的配置文件,比如用来登录知乎的用户名和密码,想要抓取的用户种子,数据库连接地址,以及 log 的配置。

+

constants 目录下是一些对知乎的特定配置,如 url 地址、规则,以及知乎 API 的一些信息,如表单、请求头格式等。

+

util 目录下目前是放置了一些数据库以及 log4js 的初始化方法,如自动扫描 models 加载以及创建 log 目录等,以便于在程序入口处调用。

+

models 则是 mongoose 的各种 schema了,用于持久化。

+

登录模块

登录虽然不是爬虫的重点,但却是必不可少的前提。因为对于知乎网站来说,不登录的话只能看到单个用户的个人页面,想要再前往关注者页面就不可能了。这就造成一个问题:爬虫无法持续工作。

+

因此,此模块的主要职责是,在爬虫运行的过程当中保证已登录状态。

+

此模块放置在 http/session 中。

+

爬虫模块

程序核心之一,同时也是最容易出现问题的地方(尤其是启用多线程以后),负责发送 Http 请求并接受响应。

+

除了简单的单次请求以外,因为一些特定的原因,里面还涉及到了递归请求。

+

转换模块

程序核心之一,负责将爬取回来的 HTML 文本、API 返回体等转换成 model 对象,没什么技术含量,体力活。

+

Service

其实就是将单次爬取的整个流程定义封装好,供主程序调用。

+

同时也负责爬取结果的储存,以及用户种子队列的管理。

+

重点难点

模拟登录

这次经历让我意识到什么都靠 Google 也有不行的时候,因为爬虫这东西,虽然肯定也有别人做过,但是基本上都过时了,人家网站早更新了,真刀真枪还是得靠自己。

+

整个过程虽然简单,但是由于经验匮乏,还是走了不少弯路。最终总结出来的必须步骤如下:

+

+

代码如下:

+
function login(_user) {
user = _user;
return new Promise((resolve, reject) => {
getCaptcha()
.then(resolveCaptcha)
.then(getLoginCookie)
.then(_getXsrfToken)
.then(() => {
logger.info('Login success!');
resolve();
})
.catch((err) => {
reject('Login failed: ' + err);
})
})
}
+ +

(目前并没有在所有地方都用上 async await,之后改过来)

+

获取验证码很简单,但是要注意的是把 response 里面的 set-cookie 信息保存起来添加到一会要登录的请求上去,因为不这么做的话,知乎服务器不认为登录那一次请求跟这个验证码有什么关系。

+

“解析验证码”这一步目前是这个程序最难看(难看,不是难)的地方,因为用的是土法炼钢:人眼解析。我尝试过用一些通用的验证码识别库去做自动化,但是正确率太低,而且知乎它的验证码有随机两套字体,反正好像训练起来也麻烦,所以就没继续研究下去了,毕竟不是爬虫主体,而且只需要在程序启动的时候输入一次即可。

+

登录请求的模拟,可以在知乎网站上使用浏览器的开发控制台启用“任意 XHR 断点”来截获网站发送的真实登录请求以及服务器返回来进行伪造。实际上就是填一个表单 POST 出去,然后返回的时候把 response 里面的 set-cookie 信息保存起来添加到以后要使用的所有请求的 header 上去就行了,因为服务器它就是靠这一堆 cookie 值来判断客户端所处的会话。

+

最后就是那个所谓的“秘钥”,网站上的名字叫 xsrf ,知乎在这里做了一些手脚。它在 set-cookie 中并没有提供这一串秘钥,但是如果我要请求它的 API,那么 cookie 里面就必须有这个键值对,明显网站是通过 JS 动态加进去的。然而我没有必要这么做,只需要在一开始的时候就把它拿到,然后以后每次请求都带上它就可以了。至于怎么拿也很简单,知乎每个页面都有的隐藏的输入框,里面的值就是。

+
xsrfToken = $('input[name=_xsrf]').val();
+ +

这四步做完以后,程序就成功登录了。

+

获取关注

去到知乎的关注者、已关注页面可以发现,内容是随着页面滚动逐步加载的,因此这里存在一个 API 可以使用,无需爬页面。使用浏览器开发者控制台,我们可以截取到 API 的详细信息,以下是我总结的一个:

+
userFollowers: {
url: () => 'https://www.zhihu.com/node/ProfileFollowersListV2',
pageSize: () => 20,
form: (hashId, offset) => {
offset = typeof offset === 'undefined' ? 0 : offset;
return {
method: 'next',
params: `{"offset":${offset},"order_by":"created","hash_id":"${hashId}"}`
}
},
header: (userName, token) => {
return {
'X-Xsrftoken': token,
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
Referer: zhihu.url.userFollowers(userName)
}
}
}
+ +

form 是 API 需要发送的表单,而 header 则是这次请求额外需要的 header(指包括登录获取的 header)。

+

由于它这个是按 page 来的,每次请求最多只会返回 20 条记录,到底了就会返回空数组,因此我做了一个 Promise 递归来实现这个功能。另外,由于有的用户关注加起来上万条,全部遍历完实在是太慢(不知道会不会一去不回),所以我把抓取的总数限制在了 100 条。至于总数的这个数量,在其它地方可以获取到,不需要通过这里获取,所以无所谓。

+
function resolveByPage(user, offset, apiObj) {
let header = Object.assign(session.getHttpHeader(), apiObj.header(user.id, session.getXsrfToken()));
let form = apiObj.form(user.hashId, offset);
//No more than 100 (too slow)
if (offset + apiObj.pageSize() > 100) {
return Promise.resolve([]);
}
return superagent
.post(apiObj.url())
.set(header)
.send(form)
.end()
.then((res) => {
let data = parser.fromJson(res.text);
if (!data.list.length) {
return [];
}
return resolveByPage(user, offset + apiObj.pageSize(), apiObj)
.then((nextList) => {
return [].concat(data.list, nextList);
})
.catch((e) => {
return [];
});
})
}
+ +

持续工作

爬虫如何持续工作这个问题一开始我是挺头疼的,就是说当一次任务结束以后,要如何自动开始下一次任务。由于全是异步操作,直接 while 1 肯定要炸。

+

后来看到 async await 语法,终于写出了一个可工作的版本。(Node.js v6.5.0 还没有原生支持 async await,所以用到了一个库)

+
let next = async(function (threadId) {
try {
let userId = await(userQueueService.shift());
logger.info(`Thread ${threadId} working on user ${userId}`);
let user = await(userService.resolveAndSave(userId));
await(userQueueService.unshiftAll([].concat(user.followers_sample, user.followees_sample)));
} catch (err) {
if (err.name === 'MongoError') {
err = err.message;
}
logger.error(err);
}
});

let thread = async(function (id) {
while (1) {
await(next(id));
}
});
+ +

程序执行到 await 关键字的地方就会阻塞,直到语句返回再继续。

+

至于多线程,直接简单暴力:

+
let main = async(function () {
await(userService.login());
for (let i = 0; i < 5; i++) {
thread(i)
}
});
+ +

这样,执行 main 方法,程序就有 5 个线程同时工作了。

+

目前的问题

效率

5 条线程还是太慢,一小时大约能抓 1000 个用户的样子,但是我又不能再多开,再开线程请求数就爆了,各种报错、失败,得不偿失。

+

我在想是不是能再开几个 Node.js 进程同时跑这个程序。质量不行数量来补。

+

09/12/2016 更新:这个办法不行。请求数限制是在服务器端做出的。貌似无解。

+

稳定性

这个是目前很头疼的问题。我发现程序在跑一个小时或者两个小时以后,5 条线程就只剩下一条或者两条还在工作,其它的都失踪了,或者干脆全都死在那里了。其实我知道它们没死,只不过不知道为什么卡住了。

+

第一次发现的时候觉得有点逗,感觉就像自己生了五个孩子后来死剩两个一样。

+

09/12/2016 更新:目前发现了一个原因,即有些知乎用户的个人主页被屏蔽了,导致解析失败,但是又没有 catch 导致线程无限挂住。解决这个以后问题依然存在,高度怀疑是因为 session 过期。

+

09/14/2016 更新:果然是 session 过期的原因。在登录的表单中加入一个字段 remember_me: true 以后,线程死掉的问题就解决了!Excited!

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/node-js-web-spider-note-2/index.html b/2016/node-js-web-spider-note-2/index.html new file mode 100644 index 000000000..f41e18a24 --- /dev/null +++ b/2016/node-js-web-spider-note-2/index.html @@ -0,0 +1,499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Node.js Web Spider Note - 2 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Node.js Web Spider Note - 2 +

+ + +
+ + + + +

HTTP 是一种无状态协议,服务器与客户端之间储存状态信息主要靠 Session,但是,Session 在浏览器关闭后就会失效,再次开启先前所储存的状态都会丢失,因此还需要借助 Cookie

+

一般来说,网络爬虫不是浏览器,因此,只能靠手动记住 Cookie 来与服务器“保持联系”。

+

Cookie 是 HTTP 协议的一部分,处理流程为:

+
    +
  • 服务器向客户端发送 cookie
      +
    • 通常使用 HTTP 协议规定的 set-cookie 头操作
    • +
    • 规范规定 cookie 的格式为 name = value 格式,且必须包含这部分
    • +
    +
  • +
  • 浏览器将 cookie 保存
  • +
  • 每次请求浏览器都会将 cookie 发向服务器
  • +
+

因此,爬虫要做的工作就是模拟浏览器,识别服务端发来的 Cookie 并保存,之后每次请求都带上 Cookie 头。

+

在 Node.js 中有很多与 Cookie 处理相关的 package,就不再赘述。

+

Session

Cookie 虽然方便,但是由于保存在客户端,可保存的长度有限,且可以被伪造。因此,为了解决这些问题,就有了 Session

+

区别:

+
    +
  • Cookie 保存在客户端
  • +
  • Session 保存在服务端
  • +
+

Cookie 与 Session 储存的都是客户端与服务器之间的会话状态信息,它们之间主要靠一个秘钥来进行匹配,称之为 SESSION_ID ,如 express 中默认为 connect.sid 字段。只要浏览器发出的 SESSION_ID 与服务器储存的字段匹配上,那么服务器就将其认作为一个 Session,只要 SESSION_ID 的长度足够大,几乎是不可能被伪造的。因此,敏感信息储存在 Session 中要比 Cookie 安全得多。

+

常见的 Session 存放媒介有:

+
    +
  • RAM
  • +
  • Database
  • +
  • Cache (e.g. Redis)
  • +
+

Session 不是爬虫可以接触到的东西。

+

AJAX 页面

对于静态页面(服务端渲染),使用爬虫不需要考虑太多,把页面抓取下来解析即可。但对于客户端渲染,尤其是前后端完全分离的网站,一般不能直接获取页面(甚至没有必要获取页面),而是转而分析其实际请求内容。

+

请求分析

通过一些请求拦截分析工具(如 Chrome 开发者工具)可以截获网站向服务器发送的所有请求以及相应的回复。

+

包括(不限于)以下信息:

+
    +
  • 请求地址
  • +
  • 请求方法(GET / POST 等)
  • +
  • 所带参数
  • +
  • 请求头
  • +
+

只要把信息尽数伪造,那么爬虫发出的请求照样可以从服务器取得正确的结果。

+

秘钥处理

一些请求中会带有秘钥(token / sid / secret),可能随除了请求方法外的任一个位置发出,也可能都带有秘钥。更可能不止一个秘钥。

+

理论上来说,正常客户端取得秘钥有两种方式:

+
    +
  • 服务端提供
  • +
  • 客户端自行计算,由服务端校对
  • +
+

对于服务端提供给客户端的秘钥,只要仔细分析 HTML 或服务端返回的 Cookie Header 就一定能发现。

+

而对于客户端自行计算的秘钥则比较麻烦了,尤其是在 JS 代码加密、混淆的情况下。这种时候,只能自己去用开发者工具调试原始站点代码,找出加密代码段,并在爬虫中实现。这里面有许多技巧,如各种断点、单步调试等。

+

表单处理

表单实际上也是 HTTP 请求,使用 GET / POST 等方法即可模拟表单提交。然而这不是重点。重点是表单常常伴随着验证码而存在。

+

验证码的识别暂未涉及。

+

浏览器模拟

爬虫的下下策才是使用浏览器完全模拟用户操作。实在是属于无奈之举。Nodejs 可以驱动 Chrome 与 Firefox 浏览器,存在相应的 Package,但是,更方便的是使用各种 E2E Testing 工具。

+

比如 Night Watch JS:

+
module.exports = {
'Demo test Google' : function (client) {
client
.url('http://www.google.com')
.waitForElementVisible('body', 1000)
.assert.title('Google')
.assert.visible('input[type=text]')
.setValue('input[type=text]', 'rembrandt van rijn')
.waitForElementVisible('button[name=btnG]', 1000)
.click('button[name=btnG]')
.pause(1000)
.assert.containsText('ol#rso li:first-child',
'Rembrandt - Wikipedia')
.end();
}
};
+ +

在这种模式下,Cookie / Session / 请求等各种细节都不用关心了。只需要按部就班地执行操作即可。模拟浏览器的代价是效率太低,内存开销大,但在某些特定需求情况下,却比一般爬虫要简单得多。

+

 

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/review-of-the-monkey-king-2/index.html b/2016/review-of-the-monkey-king-2/index.html new file mode 100644 index 000000000..ec342fb78 --- /dev/null +++ b/2016/review-of-the-monkey-king-2/index.html @@ -0,0 +1,453 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +《西游记之孙悟空三打白骨精》 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 《西游记之孙悟空三打白骨精》 +

+ + +
+ + + + +

有剧透。

+

2016年看的第一场电影,昨天,三打白骨精。有点晚了。春节档唯一感兴趣的就是它。

+

美人鱼看了预告片和简介,结合各种评分短评影评,我觉得它远远没有达到期望。个人认为评分7分上下,对于其它电影或许是“值得一看”,对于周星驰的电影来说,只能相当于“不是垃圾”。这是在砸招牌。更不用说很多人的高分只是给的这块招牌。

+

说回到三打白骨精(以下简称三打)。虽然它评分不高,虽然我看过的国产电影不多,虽然它题材滥上加滥,但我还是要说,这是我看过的最好的最值的最不坑的国产爆米花电影,比去年的口碑高峰寻龙诀还要好上不少。国产电影能有这么大的进步,作为一个普通电影爱好者我是觉得很高兴。

+

为什么说三打要比寻龙诀好呢。其实它的特效没有比寻龙诀高,尤其是3D这一方面,但是,三打把电影的使命捡了回来,就是讲故事。寻龙诀根本就没有在讲故事,看的过程中就感觉各种特效乱飞,火花四溅,然后就结束了。然而三打不一样,它做到了特效为故事服务。虽然一路走过来依然很酷炫,但是作为观众我能感受到重要的角色都有它背后的故事,以及正在发生的故事。能感受到角色的立体度。实实在在的角色,而不是只活在大银幕这个平面之上。

+

三打对原故事进行了不少的改编,以往的国产电影很多改编都是坑爹,但是我认为这些改编却偏偏很多都是恰到好处的。为什么呢。因为改编后的电影可以让观众更加关注于主要的故事其本身,另外节省说故事的时间。就比如说,我们都知道师傅是如何收的二师兄以及沙师弟,但是电影就将其极简化了,他俩简单粗暴地一起搭上了大师兄的顺风车。这么做虽然当时看的时候觉得有点怪,但是事后想想是非常妙的。观众不需要导演去告诉他们师傅在白骨精之前是怎么走过来的,90%的观众都知道这背后到底是怎么回事,观众看的电影叫三打白骨精,直入主题。这样的改编在电影中还有不少,我认为都是为了简化故事结构突出主线而生。

+

但是,有几处改编,却又是在“三打白骨精”这个原著故事上做出了扩展。这也是很有意思的一点。电影把无关紧要的剧情都尽量简要交代,然后竭尽所能地拓展主线。原著故事没有吃人血的国君,没有白骨精的前世今生,也没有佛祖亲自收它,白骨精之于大师兄更是蝼蚁之于巨象。但是,电影偏偏在这么一个简单的故事上脱离了纯爆米花的低级趣味:要探讨人性,要探讨佛性,要挖掘黑暗面。其实我觉得如果要更有意思一点的话,其它可以有,白骨精还是不要那么强的好,就保持原著的水平,千年修行,最后被大师兄一棍子打死,然后师父再舍生取义,再打死师傅,更探讨,更黑暗。不过这么搞特效就没法做了。

+

此外,看了那么多的西游电影电视剧,貌似也只有三打在真正地学习老版西游记的精华。不是说它的“二师兄,师傅被妖怪抓走了”之类的吐槽以及片尾曲,而是说只有这只猴子以及老版西游记的猴子是在演猴子。看得出郭天王努力地在向六小龄童大师学习,各种动作都是以猴为基准,而不是人,虽然水平是差了一个筋斗云,但是最起码有认真地去学。要是不说他是郭富城我估计真没多少人能猜得出来,说得夸张些,他的影子里只有猴。如果说老版西游记的猴子是精华,那么师傅就是糟粕。三打不但吸取了精华,还扔掉了糟粕。这里面的师傅,虽然在大圣和妖怪面前看起来依然是手无缚鸡之力,但是,重要的一点,这是一个有主见,有信仰,有觉悟的师傅,是不辱其名的圣僧(吐槽一下电影的圣僧之翻译:Holy monk,上帝的和尚)。多说无益,看过便知。

+

要说缺陷的话,自然还是不少,不然不会只有5+的评分。二师兄和沙师弟是打了整场的酱油,就俩高级步兵,除了会吐槽以外屁用没有。认真想想的话其实有他俩没他俩剧情根本一模一样,即使最后大师兄回家了也不是二师兄给讨回来的。电影的审美过于西化了,比如小白龙的形象,比如白骨精的形象。但是,瑕不掩瑜,还是要说,这是我看过的最好的最值的最不坑的国产爆米花电影。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/solution-to-windows-cant-remove-node-modules-folder/index.html b/2016/solution-to-windows-cant-remove-node-modules-folder/index.html new file mode 100644 index 000000000..ee3e864a8 --- /dev/null +++ b/2016/solution-to-windows-cant-remove-node-modules-folder/index.html @@ -0,0 +1,459 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Windows 无法删除 Node_modules 文件夹的解决方案 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Windows 无法删除 Node_modules 文件夹的解决方案 +

+ + +
+ + + + +

在 Windows 操作系统下开发 NodeJS 项目的时候经常会遇到无法删除 Node_modules 文件夹的尴尬(因为依赖过多,文件路径长度爆炸),解决办法如下。

+ + +

全局安装 rimraf 模块到系统下:

+
npm install -g rimraf
+ +

CD 到相应文件夹,执行如下指令:

+
rimraf node_modules
+ +

等待其完成即可。

+

其实这个模块也可以用来删除其它无法正常删除的东西,挺好用的。Node 用习惯了以后可以为系统提供许多便利,比如说现在我都不怎么使用系统自带的计算器了,直接 WIN + R + NODE 就可以得到一个 Node 环境下的计算器,非常快捷。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/some-oddities-about-javascript/index.html b/2016/some-oddities-about-javascript/index.html new file mode 100644 index 000000000..2a61cdc28 --- /dev/null +++ b/2016/some-oddities-about-javascript/index.html @@ -0,0 +1,535 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +JavaScript 的一些古怪之处 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ JavaScript 的一些古怪之处 +

+ + +
+ + + + +

大概一年前在看一本介绍JavaScript与jQuery的书籍之时看到了这么一个有趣的章节,当时印象挺深刻的。现在突然回想起来了这回事,于是就重新翻出来做了个笔记。作者将这些材料归结为两类:神奇的知识点以及WTF。这里去除了与浏览器有关的部分,因为那些和JavaScript本身并没有关联。

+ + +

数据类型与定义

NULL是一个对象

不同于C或者Java之类的语言,JavaScript的 null 值是一个对象。也许你会说“null 应该定义为一个完全没有意义的值”,也许你是对的,然并卵,事实是:

+
alert(typeof null); //object
+ +

尽管如此,null 并不是任何对象的一个实例(补充:JavaScript中的所有“值”都是基本对象的实例,比如说数字是 Number 对象的实例,字符串是 String 对象的实例,所有对象都是 Object 对象的实例,等等)。于是我们可以理智地认为:如果 null 代表的是没有值,那么它就不能是任何对象的实例。因此下面的表达式应该返回 false

+
alert(null instanceof Object); //evaluates false
+ +

NAN是一个数字

你以为 null 是一个对象已经够离谱了吗,too young too simple!NaN→ Not a Number → 它是一个数字。还有更过分的呢,它甚至不等于它自身。我受到了伤害。

+
alert(typeof NaN); //alerts 'Number'
alert(NaN === NaN); //evaluates false
+ +

事实上,NaN 不与任何值相等。如果想要判断一个值是不是 NaN,唯一的办法是通过调用 isNaN() 函数。

+

空数组==FALSE

这个特性其实很受欢迎的呢:

+
alert(new Array() == false); //evaluates true
+ +

要弄明白这里面到底发生了什么事,首先要知道在JavaScript世界中真假相的概念。它在逻辑上有一些简化。

+

作者认为最简单的理解方式是:在JavaScript的世界中,所有非布尔类型的值,它们都存在有一个内置的布尔类型标志位,当该非布尔值在要求做出布尔类型的比较时,实际上调用的是它的标志位。

+

(我觉得理解为JavaScript有内置的比较逻辑表也是可以的吧)

+

因为苹果没办法和梨比较,猫不能和狗比较,因此当JavaScript需要比较两种不同类型的数值时,它要做的第一件事必然是将其强转为通用的可比较的类型。FalsenullundefinedNaN,空字符串以及零到最后全都会变成 false。不过这当然不是永久的,这种转换只在特定的表达式(布尔表达式)中生效。

+
var someVar = 0;
alert(someVar == false); //evaluates true
+ +

以上就是一个强转的例子。

+

至此还没有开始讨论数组的行为呢。空数组是一件非常奇特的事物,它们实际上是表示真,但如果你拿它来做布尔运算,它又是假的。我总觉得这里面隐藏着什么不可告人的秘密 (¬_¬)

+
var someVar = []; //empty array
alert(someVar == false); //evaluates true
if (someVar) alert('hello'); //alert runs, so someVar evaluates to true
+ +

为了避免类似的困扰,我们可以使用全等操作符(三个等号,同时比较类型与值):

+
var someVar = 0;
alert(someVar == false); //evaluates true – zero is a falsy
alert(someVar === false); //evaluates false – zero is a number, not a boolean
+ +

这个问题十分广泛,这里也就不过多介绍了。如果想要深入了解其内部原理,可以阅读ECMA-262标准之11.9.3章节文档。

+

正则表达式

REPLACE()可以接受回调函数

这绝对是JavaScript最为隐秘的特性之一,从1.3版本之后加入。绝大多数人都是这么用它的:

+
alert('10 13 21 48 52'.replace(/\d+/g, '*')); //replace all numbers with *
+ +

(原文中有一些疏忽,比如使用了 d+ 而非 \d+,这里均做出了修正)

+

简单的替换,字符串,星号。但如果我们想要更进一步的控制呢?比如我们只想替换30以下的数字?这个逻辑通过正则来实现会较为困难,毕竟它不是数学运算,我们可以这样:

+
alert('10 13 21 48 52'.replace(/\d+/g, function(match) {
return parseInt(match) < 30 ? '*' : match;
}));
+ +

这段代码的意思是,如果匹配到的字符串转换为整型数值后小于30,则替换为星号,否则原样返回。

+

不仅仅是比较和替换

通常情况下我们都只用到了正则表达式的比较和替换功能,但其实JavaScript提供的方法远远不止两个。

+

比如说 test() 函数,它和比较十分类似,但它不反回比较值,只确认字符串是否匹配。这样代码可以更轻一些。

+
alert(/\w{3,}/.test('Hello')); //alerts 'true'
+ +

以上表达式判断了字符串是否有3个或以上的字符。

+

还有就是 RegExp 对象,通过它我们可以构建动态的正则表达式。一般情况下正则表达式都是通过短格式声明的(封闭在斜杠中,就像上面所用到的)。这么做的话,我们不能在其中插入变量。当然,我们还有 RegExp

+
function findWord(word, string) {
var instancesOfWord = string.match(new RegExp('\\b'+word+'\\b', 'ig'));
alert(instancesOfWord);
}
findWord('car', 'Carl went to buy a car but had forgotten his credit card.');
+ +

这里我们基于 word 参数构建了一个动态的正则表达式。这个函数会返回car作为独立单词在字符串中出现的次数。本例只有一次。

+

由于 RegExp 使用字符串来表示正则表达式,而非斜杠,因此我们可以在里面插入变量。但是,与此同时,需要注意的是,表达式中特殊符号前的反斜杠我们也要写两次(转义处理)。

+

函数与作用域

你可以伪造作用域

作用域决定了变量可以在哪些地方被访问。独立(即不在函数内部)的JavaScript可以在全局作用域(对浏览器来说是 window 对象)下访问,函数内部定义的变量则只能在内部访问,其对外部不可见。

+
var animal = 'dog';
function getAnimal(adjective) { alert(adjective+' '+this.animal); }
getAnimal('lovely'); //alerts 'lovely dog';
+ +

这里,我们的变量和函数都是在全局作用域下定义的(比如 window)。因为 this 总是指向当前作用域,因此在本例中它指向了 window.animal,于是就找到了。一切看起来都没问题。但是,我们可以骗过函数本身,让它认为自己执行在另一个作用域下,并无视其原本的作用域。我们通过调用内置的 call() 函数来达到目的:

+
var animal = 'dog';
function getAnimal(adjective) { alert(adjective+' '+this.animal); };
var myObj = {animal: 'camel'};
getAnimal.call(myObj, 'lovely'); //alerts 'lovely camel'
+ +

在这里,函数不在 window 而在 myObj 中运行 — 作 为 call 方法的第一个参 数。本质上说 call 方法将函数 getAnimal 看成 myObj 的一个方法(如果没看懂这是什么意思, 你可能需要去看一下 JavaScrip t的原型继承系统相关内容)。注意,我们传递给 call 的第一个参数后面的参数都会被传递给我们的函数 — 因此我们将 lovely 作为相关参数传递进来。尽管好的代码设计不需要采用这种伪造手段,这依然是非常有趣的知识。apply 函数与 call 函数作用相似,它的参数应该被指定为数组。所以,上面的例子如果用 apply 函数的话如下:

+
getAnimal.apply(myObj, ['lovely']); //func args sent as array
+ +

函数可以自执行

显然:

+
(function() { alert('hello'); })(); //alerts 'hello'
+ +

这个语法非常简单:我们定义了一个函数,然后立刻就调用了它,就像调用其它函数一样。也许你会觉得这有些奇怪,函数包含的代码一般都是在之后执行的,比如我们想在某个时刻调用它,既然它需要立即执行,那为什么要把代码放在函数体内呢?

+

自执行函数的一大用处就是将变量的当前值绑定到将来要被执行的函数中去。就比如说回调,延迟或者持续运行:

+
var someVar = 'hello';
setTimeout(function() { alert(someVar); }, 1000);
var someVar = 'goodbye';
+ +

这段代码有一个问题,它的输出永远都是goodbye而不是hello,这是因为timeout中的函数在真正执行之前永远不会去关心里面的变量发生了什么变化,到那时候,someVar 早就被goodbye覆盖了。

+

(JavaScript新手经常会犯的一个错误就是在循环中定义事件,并且将index作为参数传入,到最后发现真正绑上了事件的只有最后的那个元素,这也是同理)

+

解决办法如下:

+
var someVar = 'hello';
setTimeout((function(someVar) {
return function() { alert(someVar); }
})(someVar), 1000);
var someVar = 'goodbye';
+ +

在这里,被传入函数中的相当于是一个快照,而不是真正的变量本身。

+

其它

0.1 + 0.2 !== 0.3

其实这是计算机科学中的一个普遍问题,我已经在很多编程语言中都发现了它的影子,它是由浮点数不能做到完全精确导致的。实际的计算结果是0.30000000000000004

+

如何解决,归根到底取决于计算需求:

+
    +
  • 转换成整型计算,而后再转回浮点
  • +
  • 允许某个范围内的误差
  • +
+

因此,与其:

+
var num1 = 0.1, num2 = 0.2, shouldEqual = 0.3;
alert(num1 + num2 == shouldEqual); //false
+ +

不如:

+
alert(num1 + num2 > shouldEqual - 0.001 && num1 + num2 < shouldEqual + 0.001); //true
+ +

这就是一个简单的允许误差的办法。

+

UNDEFINED可以被DEFINED

这个看起来有点蠢了。undefined在JavaScript中其实不是一个关键字,尽管它一般是用来表示一个变量是否未被定义。就像这样:

+
var someVar;
alert(someVar == undefined); //evaluates true
+ +

然而也可以这样:

+
undefined = "I'm not undefined!";
var someVar;
alert(someVar == undefined); //evaluates false!
+ +

看起来很有趣的样子……

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/some-project-memo/index.html b/2016/some-project-memo/index.html new file mode 100644 index 000000000..98f0a0e40 --- /dev/null +++ b/2016/some-project-memo/index.html @@ -0,0 +1,460 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +记一次项目经历 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 记一次项目经历 +

+ + +
+ + + + +

前几天收到一个项目请求,其实是某人希望做个简单的毕设代码实现。因为去年毕业季的时候帮同学的一些朋友做过毕设项目,因此找到了我,希望继续帮忙。因为这种东西一般都比较简单,所以我也没想很多就答应了。

+ + +

这位同学通过 QQ 联系上了我。她的具体需求是一个基于 C# 的 ASP.NET 网站,就几个页面,非常简单,但是相比去年做的那几个稍复杂些,因此我提高了要价,开价 500,为了让对方确信我没有在坐地起价,我还把她师姐们的需求文档都发了过去,让她自己对比。

+

然而,对方依然觉得太贵了,说想要“友情价”。我觉得挺搞笑的,脸皮很厚嘛。不过我也不想扯皮,就当你是个穷困潦倒的学生吧,大家都经历过,我也当做好事了,于是就降到了 400,说实话这个价格我是真不想做。虽然只需要一天,但是没意思,就跟上班一天一样,而且还是加班,还没有双倍工资。

+

其实除了这些,更让我觉得难过的是,对方是“几乎什么都不懂”,因此我后期可能还有非常多的工作需要做。在对方论文完成以前,我可能会成为免费技术顾问,而且她会觉得这是我理所当然应该做的。

+

约定的交货期限大概是十天的样子。于是就开始了。

+

大概在第五天的时候,对方想找我要数据库的截图,我说干嘛呢,她说贴论文里,要交初稿了。我去,合着我还帮你写论文呢。然而这时间我还没开始动手,我也没有义务提前交货,于是说想要就给加班费。对方就放弃了。

+

后来几天没有联系过。到了约定日期我把项目通过 QQ 发送给了对方,第二天早上就发现自己给删了好友。

+

其实故事到这里本该结束了。我没花多少时间,也不在乎这点钱。爱给不给吧。

+

然而,好在我有一个责任感强烈的“经纪人”。

+

就是那位给我介绍这个项目的同学,我简单地说明了一下情况,她深感自己把我坑了,于是千辛万苦帮我追数。其实我也挺过意不去的,这说到底是我的疏忽,现在反而要麻烦别人。我一直强调没事算了吧,然而“经纪人”始终不肯善罢甘休。

+

在这期间,有一些不知道是与项目主人何种关系的人来联系我,希望通过支付部分款项以息事宁人等。然而这些人的交流方式让我略感奇怪,三句不离同情,说得我跟个要饭的一样,因此没有同意。

+

最终,在“将作弊行为告知导师”的压力下,项目主人现身道歉,并且支付了全款。

+

后来,我反思了一下。我是以在校学生的思维方式来对待这件事情的,其实最近我也越发觉得这种思维方式让自己在社会中非常吃亏。现在来说,至少我也应该尊重自己的劳动力吧。同时,自己的错误也给别人带来了不必要的麻烦。

+

至于项目主人那边的几位,我只能说“人各有志”。收到钱的第二分钟,她们就全在我的黑名单里面了。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/static-blog-built-with-vue/index.html b/2016/static-blog-built-with-vue/index.html new file mode 100644 index 000000000..9c5e8cd37 --- /dev/null +++ b/2016/static-blog-built-with-vue/index.html @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Static Blog Built with Vue | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Static Blog Built with Vue +

+ + +
+ + + + +

博客再次迁移,这次是从 Wordpress 转向静态博客(自建)。

+

技术栈:

+
    +
  • 前端:vue + vue-router + vuex + bootstrap + webpack
  • +
  • 服务端:没有
  • +
  • 数据库:没有
  • +
+

整站打包后,一次加载所有资源(HTML + CSS + JS + DATA)300K 不到(gzip 后 80K+),秒速渲染,与先前真的是天差地别。

+

图片资源从本地服务器搬迁到免费云。 写作使用 Markdown,从此 IDE 写博客不是梦。

+

代码地址:https://github.com/wxsms/wxsms.github.io/tree/src

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/tasteless-chicken-soup/index.html b/2016/tasteless-chicken-soup/index.html new file mode 100644 index 000000000..443b6b1cb --- /dev/null +++ b/2016/tasteless-chicken-soup/index.html @@ -0,0 +1,456 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +过期鸡汤 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 过期鸡汤 +

+ + +
+ + + + +

花了两个晚上读了最近挺火的一本书,名曰《解忧杂货店》,同时也是我看过的第一本日本小说。看完以后只有一个感觉:这大概是过期的鸡汤吧。一点味道都没有。与此同时,总觉得有些什么地方不对。现在认真想了想,果然是奇葩。由于不清楚日本文化,也不知道该说是日本人奇葩,还是说仅仅是故事或者作者奇葩。

+ + +

这本书大概讲述了这么一件事:

+

三名无业青年爆了一个老板娘的格,跑路途中车抛锚了无奈躲进一个荒废的屋子里,后发现有信投入,内容是吐槽烦恼。回信后立马又收到了回信,终此往复。由于咨询者不知手机为何物,因此闹洞大开认为这屋子大概是个时空机器BlaBlaBla,迷途的少年感觉找到了人生的价值,摇身一变成为烦恼终结者。接下来就是各种各样奇葩的往事,各种咨询,然后通过一个孤儿院把大家都联系在了一起,最后回到现实少年发现刚爆的可怜老板娘就是最后一个与自己通信的人。少年们随即决定重新做人,义无反顾地开始捡肥皂生涯。

+

故事的核心是“一间能够连接时空的杂货店”,因此它本身是一个和时空有关的故事。这种故事太多太多了,现在读起来已经不是特别有趣,同时也非常容易出BUG,然而我想吐槽的东西不在这里。简单地说,故事里的主角们,基本都是重度自私+自恋狂。当然这里要除去杂货店主那爷俩,他俩没有任何特别之处,普通人。以下的吐槽也不是针对他俩。

+

什么意思呢,大概就是脑子里想的永远都只有自己,无论别人发生什么事,只要一切按照自己的意愿来就行。什么爱人,什么父母,都不在考虑范围内。有些故事的角色看起来是在非常无私地处处替别人考虑,比如运动员的男友,比如富二代的老爸,然而这却是更可怕的自私。他们都有如下特点:完全不考虑别人的感受,完全不给人选择的余地,然后依然是一切按照自己的意愿来就行。

+

看这本书的时候就觉得,那些人做那么多事情,完全都不需要理由的啊,或者自己觉得这么做有理就一条路走到黑做下去了,根本就不管对身边的人有什么影响,反正老子喜欢就要干。此外,也看不出角色们有什么心理活动,感觉大家都只有一根筋。

+

难道这就是日本人的特征吗?

+

除此以外,基本上所有故事都是主流鸡汤文,以挫折、烦恼和梦想为主题。最后大家通过自己的努力(辅以杂货店的建议),都达到了自己所有或者部分的目标,或者干脆啥都没做成,但最后还是觉得自己得到了升华,甚至干脆整个人升华。整本书没有任何起伏跌宕,所有故事就那么以第三人称视觉丝毫不带感情地展开了。所以说它无味。

+

作者说,希望读者在合卷的时候能够喃喃自语曰“从来没有读过这样的小说”。我想说的倒是,从来没有读过这么无聊的鸡汤。赵国的鸡汤起码能给人打鸡血,你国连鸡血都打不了。

+

 

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/token-apps-usage-experiences/index.html b/2016/token-apps-usage-experiences/index.html new file mode 100644 index 000000000..07e7788b9 --- /dev/null +++ b/2016/token-apps-usage-experiences/index.html @@ -0,0 +1,460 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +令牌软件使用体验 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 令牌软件使用体验 +

+ + +
+ + + + +

所谓令牌,就是说,一个账号在登录的时候,除了要提供常规密码外,还要提供一组动态密码。而动态密码的来源,可以是实体设备,也可以是软件。

+

这里就说两个手机 APP:Steam 令牌与网易将军令。

+

APP 的功能很简单:在用户需要登录的时候提供动态密码。

+
    +
  • Steam 令牌会在用户需要的时候主动推送动态密码到通知;
  • +
  • 而网易将军令需要用户手动打开软件查看动态密码。
  • +
+

哪一种设计更好呢?

+ + +

我在用的时候就觉得,为什么将军令这么笨,不懂得直接把密码推送给我呢?每次要自己去打开烦不烦。

+

网易招回去的研究生、博士们,真的就想不到这一点吗?

+

后来仔细想了想,这里面大概还是有原因的!

+

除了是否要主动打开 APP 以外,手机软件还有一个隐藏区别:用户是否已解锁。显然,使用推送方式谁都能看到敏感的动态密码,而打开 APP 则必须要有用户手机已解锁的前提。

+

这样一来,万一用户的账号在使用令牌的情况下被盗,责任划分可就不一样了。Steam 不好说,可能要扯皮,反正网易将军令肯定是 100% 免责:用户不设手机密码,手机密码被盗或破解,越狱,ROOT 等情况或行为,均与网易无关。

+

(不过这些可能在服务条款里声明也没什么事,毕竟银行提供的还是实体设备,其安全与否就只能看用户是否持有设备。难道网易是真的没考虑过主动推送?)

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/untitled-2/index.html b/2016/untitled-2/index.html new file mode 100644 index 000000000..a1ee1c348 --- /dev/null +++ b/2016/untitled-2/index.html @@ -0,0 +1,449 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +无题 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 无题 +

+ + +
+ + + + +

年廿七回家,到今天是第七天。这么快就已经过去了整整一周,马上又要回珠海上班了。

+

回家这么多天来,今天是第一次在家吃晚饭。一直都在亲戚朋友家过节,自己家冷冷清清的时间比较多。因为自己家里没有老人,只有我和爸妈一家三口,所以大概只能往外跑吧。我们很少在广东过年,只是今年可能是因为我的身体还不太好,妈妈也比较累,所以就不想回江西了。其实过节在哪里都无所谓啦,大家在一起开心就好。倒是不能去看看年事已高的奶奶觉得很忧伤。妈妈看起来又老了一些,是照顾我的那段时间太劳累了。

+

今年印象比较深的是,大家都喜欢在茶余饭后玩红包了。尤其是除夕晚上的时候,开着电视,但其实没多少时间去看,大家都忙着摇摇摇咻咻咻,完事以后继续关注下一轮的时刻,至于春晚什么的,谁管呢。当然老人还是在看。腾讯老大给的一块几毛就图个乐(一块几毛是说微信,至于QQ真是太小气了),但这里要吐槽一下支付宝,我一直以为它要么会大量放出稀有卡,要么会给集齐四张卡的同学一些安慰奖,结果也是呵呵,于是我毫不犹豫地就把除了家人以外的加起来的好友都删了。这游戏在春晚打了那么硬的广告,结果让全国99%的玩家都吃了个闭门羹,这么有种也是没谁了。老人一直在问为什么会有奇怪的声音,他们在年夜饭的过程中反而不太受到关注。

+

老人们有时候会问什么时候结婚的事,我都是回答说还早。两个人在一起的压力有时候真要比一个人要大得多,毕竟一个人生活不用考虑什么时候能买房,反正都是自己住。珠海的房价一天比一天高,然而刚工作半年的我也只能看着它高。

+

和小伙伴们谈起工作的时候,发现自己果然是最闲的。突然感觉没有赚加班费的机会也是一件挺忧伤的事。手术的伤依然是还没有好,总是觉得有这个问题在生活中处处都受到了限制。过两天又要回到那个以断网为常态并且每晚跳三四次闸的地方去住,再次回家又不知道是什么时候。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/untitled-3/index.html b/2016/untitled-3/index.html new file mode 100644 index 000000000..5e63d3786 --- /dev/null +++ b/2016/untitled-3/index.html @@ -0,0 +1,473 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +无题 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 无题 +

+ + +
+ + + + +

最近,工作地所在的园区推出了一款 App,宣传的主要功能是获取园区动态以及扫码付款,感觉这样吃饭可以方便一些,因此就下载了。

+

应用的名字叫“园圈”,在 App Store 上搜索出来,底下是一个没有见过的开发商。我觉得还算正常吧,一般这种小范围应用,不都是外包的吗。只是,这使我对于这个应用的使用埋下了一丝戒心。(后来我搜索了一下这家公司,网站充斥着强烈的国企风)

+

进入应用,首先要我注册,这很简单,手机号码验证码啪啪啪就输完了。然后,它要求我输入一个密码。我毫不犹豫地就输入了常用密码,在即将要点下一步的时候,却犹豫了一下。

+ + +

我在想的是:

+
    +
  1. 它是一个不知名的小公司
  2. +
  3. 它已经知道了我的手机号码
  4. +
  5. 它即将要知道我的常用密码
  6. +
+

虽然我已经在不知道多少地方用过这个密码,但是这一次我就是不想在这用了。其它很多手机应用,提供了手机号码就会让用户直接登录,然而这个应用却强制要我再填一个密码。这对于它来说太简单了,获得一个用户的手机号以及常用密码,它可以用来做任何事情。并且,更可怕的是,没有人会关注它。

+

于是我清除输入并换了一组密码。

+

接下来,它要求我输入一个手势密码。

+

我从来没有用过手势密码,但它没有提供跳过选项,因此就画了个圈以示敬意。

+

但是,我想,如果是在其它地方用过手势密码的用户,这里应该是毫不犹豫的吧。虽然得到这个貌似用处并没有像手机号加密码那么明显,但是,不论怎么说,这家公司又获得了一项用户信息。

+

并且,这个以园区动态发布以及小额支付为主要(或者说唯二)功能的应用,有什么必要同时使用密码与手势密码呢?不得而知。

+

历尽千辛,终于来到了主界面,界面其实就是支付宝和咸鱼的结合体,没什么特别的。动态都是一些领导视察之类的文章,于是我就点开了“付款”功能。

+

首先,我要同意一个用户协议。

+

然后,输入六位数的支付密码。

+

至此,这是它要求我输入的第三个密码。并且是较为敏感的六位数密码。

+

这样就很不好了。

+

然而,不知道为什么,我当时还是如实填写了。

+

一切都填好以后,我终于可以仔细查看一下它的付款功能。很简单,支付宝或者微信充值,然后二维码扫码支付,却缺少了重要的一项:余额转出。

+

这真 TM 是一个忧伤的故事,说到底还是跟充饭卡没什么区别,只要你用不完,那么充进去的钱就算是捐了。

+

唯一的区别是,我向这家公司无偿提供了我的手机号码,以及密码,手势密码以及支付密码。

+

我突然觉得,这是一波实实在在的送温暖行动。

+

这让我想起几个月前在一家米粉店付款的时候,我选择使用支付宝,从店家的微信公众号直接跳转到了一个要求输入账号密码的,UI 跟支付宝一模一样的页面。几乎是本能反应的我点了“使用浏览器打开”,果然这不是一个 alipay 域名下的网站。

+

然而,如果用户不是一个程序员,或者不熟悉互联网的点点滴滴,他有多大几率能注意到并且发现诸如此类的事情呢?

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/using-excerpt-in-wp-2016-theme/index.html b/2016/using-excerpt-in-wp-2016-theme/index.html new file mode 100644 index 000000000..bf3a1c9f6 --- /dev/null +++ b/2016/using-excerpt-in-wp-2016-theme/index.html @@ -0,0 +1,446 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +WP 2016 主题使用摘要 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ WP 2016 主题使用摘要 +

+ + +
+ + + + +

2016 主题设置里没有提供是否使用摘要的选项,因此如果文章不做任何操作,首页以及归档页都会显示全文,导致页面非常地长。但是,一番机缘巧合,我发现只要在文章里面插入了 more 标签,主题就会自动检测到并且切换到摘要模式。

+

妄我在 Google 上苦苦探索,搜集到一堆垃圾代码,然而并没有什么用。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/using-idea-to-config-ftp-auto-deployment/index.html b/2016/using-idea-to-config-ftp-auto-deployment/index.html new file mode 100644 index 000000000..d3ad59368 --- /dev/null +++ b/2016/using-idea-to-config-ftp-auto-deployment/index.html @@ -0,0 +1,473 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +使用 IDEA 配置自动同步到FTP服务器 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 使用 IDEA 配置自动同步到FTP服务器 +

+ + +
+ + + + +

使用虚拟主机的时候经常会想到一个问题,就是改了代码以后还要手动上传到服务器上,非常麻烦,且不利于保持本地开发代码与服务器上运行代码之间的同步,容易出错。今天突然想着能不能用IDE来完成类似自动同步的事情,如果可以的话开发效率自然是大幅度提高。拜强大到没朋友的IDEA所赐,结果非常可观。

+

首先确保安装好IDEA,测试用IDEA版本为15.0.1,然后我们从FTP服务器上copy一份代码到本地,并创建好存放目录。此时代码应该是完全同步的。以上为准备工作。

+ + +

然后我们打开IDEA,选择File -> Open,打开代码根目录。

+

打开Tools -> Deployment -> Configuration

+

+

在弹出的界面中点击 + 按钮,添加一个服务器。

+

+

如下图所示,填写主机地址,端口(如果不一样),用户名与密码以后,就可以点 Test FTP connection 按钮进行连接测试,如果连接成功,IDEA会有相应的提示。以下的步骤需要以此为前提。

+

点击 Autodetect 按钮后,选择服务器的根目录,一般选择最顶端的文件夹就OK了。即使代码并不是在根目录,我们也还有后面的配置来选择代码所处的实际目录。

+

最下面的 Web server root URL 字段可以填写网站的实际访问地址,这样在使用IDEA的实时预览功能时,浏览器就会以该Domain为基准进行路由。

+

+

切换到 Mappings 标签,我们需要填写的字段也如下图。

+

Local path 即本地代码根目录,IDEA已经自动设置好了。

+

Deployment path则是FTP服务器上实际同步的位置,在此选择代码所处的文件夹即可。以上都填好后点击 OK 按钮。

+

+

现在就大功告成了。我们可以选中一些文件或者文件夹,右键,然后就可以看到 Deployment 菜单,其子菜单有 Upload,Download,Compare,Sync四个。其中 Sync 就是我们所期望的功能,IDEA 会帮我们完成文件比较,与 VCS 的文件比较系统非常相似,确认无误后点击绿色的向右箭头按钮,代码就同步到服务器上去了。如下所示。

+

+

当然每次都要右键然后找到 Sync 选项可能会有点太麻烦。我们可以把这个功能放到主工具栏上去,以后每次点它就行了。

+

+

接下来就可以享受愉快的开发体验了。唯一需要注意的是在网络不是非常理想的情况下,Sync 的时候不要选择项目根目录,选择真正有改变的文件或者文件夹即可,因为它毕竟不是 VCS,所有文件一个个比对的话实在是太慢。当然我们也可以配合 VCS 使用,效果更佳,这里就不再赘述。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/webpack-hmr-not-work-in-idea/index.html b/2016/webpack-hmr-not-work-in-idea/index.html new file mode 100644 index 000000000..4260089e6 --- /dev/null +++ b/2016/webpack-hmr-not-work-in-idea/index.html @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Webpack HMR Not Work in IDEA | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Webpack HMR Not Work in IDEA +

+ + +
+ + + + +
    +
  1. goto ‘File | Settings | Appearance & Behavior | System Settings’;
  2. +
  3. uncheck ‘Use save write’ option
  4. +
+

+

Problem solved.

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/wordpress-archives-page-implementation/index.html b/2016/wordpress-archives-page-implementation/index.html new file mode 100644 index 000000000..a537f106c --- /dev/null +++ b/2016/wordpress-archives-page-implementation/index.html @@ -0,0 +1,494 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +WordPress 文章归档页面实现 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ WordPress 文章归档页面实现 +

+ + +
+ + + + +

归档页就是一个包含站点所有已发布文章的列表页面,通常默认会根据发布时间来进行排序,然后可能会有一些分页排序页内搜索等功能。实现这个功能可以用Wordpress插件,当然也可以自己写代码,我一开始就是用了一款插件,觉得实现了功能还不错就没管它。后来想要做一些自定义的修改,比如插件是按月份分组然而我想改成年份,就稍微看了看它的代码。一看不得了,莫名地有一种总算见识到了什么叫又烂又臭的代码的感觉涌上心头,做了这么多年伸手党总算是被恶心到了,简直不能忍,于是琢磨着自己写一个简单的模板页,不用它了。

+ + +

吐槽区

首先来说说为什么这个插件的代码又烂又臭,在后面我再对它进行针对性的改进。哦对了它的名字叫Clean Archives Reloaded,作者叫Viper007Bond,来自美国俄勒冈州,没错就是点名批评,看来鬼佬的编码水平也不是普遍的高啊,这坨屎简直是开源界的耻辱。去到各搜索引擎搜索“Wordpress归档”关键字还有很多文章推荐使用该插件,看来大家都不太关心代码质量,只要能用就行。 该插件的主要设计思路如下:

+
    +
  1. 从WP数据库中抓取文章
  2. +
  3. 根据用户配置分组并排序
  4. +
  5. 组织并输出HTML到页面相关位置
  6. +
+

OK,就这么三步,实际上我们也只需要这么点东西。暂且不讨论步骤是否可以简化,我们先来看看它有着怎样的内心世界。

+
// A direct query is used instead of get_posts() for memory reasons
$rawposts = $wpdb->get_results( "SELECT ID, post_date, post_date_gmt, comment_status, comment_count FROM $wpdb->posts WHERE post_status = 'publish' AND post_type = 'post' AND post_password = ''" );
+ +

这个是它的唯一一条SQL语句,可以看到作者为了给我们节省内存真是殚精竭力,本着够用就行的精神,放弃使用Wordpress自带的API,直接使用查询语句从数据库中查询出来了非常有限的一些字段。值得称赞。 按照插件的思路,紧接着就是分组啦:

+
// Loop through each post and sort it into a structured array
foreach( $rawposts as $post ) {
$posts[ mysql2date( 'Y.m', $post->post_date ) ][] = $post;
}
$rawposts = null; // More memory cleanup
+ +

排序啦:

+
( 'new' == $atts['monthorder'] ) ? krsort( $posts ) : ksort( $posts );

// Sort the posts within each month based on $atts
foreach( $posts as $key => $month ) {
$sorter = array();
foreach ( $month as $post )
$sorter[] = $post->post_date_gmt;

$sortorder = ( 'new' == $atts['postorder'] ) ? SORT_DESC : SORT_ASC;

array_multisort( $sorter, $sortorder, $month );

$posts[$key] = $month;
unset($month);
}
+ +

分组的思路就是根据一篇文章的年以及月来将原本的一维数组重新组织到一个新的二维数组中去,以方便后面的循环。排序有点复杂,首先大局上它是能够根据配置按月份从新到旧或者反方向的排序,然后在每个月份里面也能够根据配置从新到旧或者反方向的排序,这个设定简直蛋疼,谁这么无聊正着排一遍在里面反着又排一遍,即折磨自己又折磨读者,不过存在即合理,这里也不说它。**我想吐槽的是,既然你都把SQL写出来了,你也知道至少要排一次序了,又何必费尽周章在查出来以后排呢,我们直接在SQL里面排不比这一大串代码优雅吗?不快速吗?不节省内存吗?此外,这个分组也是萌萌哒,我们就不能在SQL里面先把组给分好吗,非要写个循环来调用 **mysql2date,这样真的好吗?当然如果作者没有学过 ORDER BY,也不知道SQL都有各自的内置日期函数,这些也就算了。我们接着往下看。 接下来的步骤是组织HTML:

+
// Generate the HTML
$html = '<div class="car-container';
if ( 1 == $atts['usejs'] ) $html .= ' car-collapse';
$html .= '">'. "\n";

// 此处省略n行

$html .= "</ul>\n</div>\n";
return $html;
+ +

看到这里我已经瞎了。。。尤其是高亮的那一行。。。省略的N行中充斥着的都是如此的代码。它还不止有 . "\n" 之流,在省略的内容中甚至连HTML的编码器缩进作者都保留得很好很好。WTF??这TM都是些什么鬼??作者的这些杠N和缩进是写给鬼看的吗???字符串拼凑各种内容这种事我自己不懂事的时候也干过不少也就不说了,但这作者这一种原汁原味的拼法真是我有屎以来见过的最特立独行的行为艺术。

+

让我们接着来看生成HTML之中的一部分核心代码。显然其中会有一些循环用来生成列表,并且在每个内层循环之前应该输出一个标题之类的东西用来指示以下的内容属于哪一年哪一个月。代码如下:

+
$firstmonth = TRUE;
foreach( $posts as $yearmonth => $posts ) {
list( $year, $month ) = explode( '.', $yearmonth );

$firstpost = TRUE;
foreach( $posts as $post ) {
if ( TRUE == $firstpost ) {
$html .= ' <li><span class="car-yearmonth">' . sprintf( __('%1$s %2$d'), $wp_locale->get_month($month), $year );
if ( '0' != $atts['postcount'] ) $html .= ' <span title="' . __('Post Count', 'clean-archives-reloaded') . '">(' . count($posts) . ')</span>';
$html .= "</span>\n <ul class='car-monthlisting'>\n";
$firstpost = FALSE;
}

$html .= ' <li>' . mysql2date( 'd', $post->post_date ) . ': <a href="' . get_permalink( $post->ID ) . '">' . get_the_title( $post->ID ) . '</a>';

// Unless comments are closed and there are no comments, show the comment count
if ( '0' != $atts['commentcount'] && ( 0 != $post->comment_count || 'closed' != $post->comment_status ) )
$html .= ' <span title="' . __('Comment Count', 'clean-archives-reloaded') . '">(' . $post->comment_count . ')</span>';

$html .= "</li>\n";
}

$html .= " </ul>\n </li>\n";
}
+ +

第5-12行代码,第一眼看到的时候马上就能闻到一股弱者的气息。作者想要在循环开始之前先输出一个列表标题,所以想到了一个使用标志位的办法,但是我们明明可以直接在循环前面做这件事的,根本不需要这个萌萌哒标志位。

+

还有第14行。作者明明一直在标榜自己是如何节省时间节省内存的,结果在这里却使用了内置函数 get_the_title 以及 get_permalink,后者很正常,因为 wordpress 的文章链接是可以改变的,不能直接写死,必须查,那前者这个函数是做什么的呢?很明显,根据一篇文章的 ID 来获取它的标题。要如何根据 ID 来获取标题呢,我们能用算法算出来吗?显然不能,这里面显然需要一次数据库查询,至少也是一次缓存查询,而且它这个函数写在循环里面,我的天,这里面是多少条 SQL,你直接在一开始把 Title 也给查出来不就万事大吉了吗。。。

+

插件的核心功能大概就到此为止,为了实现让用户可以点击收起与展开每个内层列表的功能,作者还添加了一些 JavaScript 代码,就不吐槽了吧,我已经好累了。

+

改进

赶紧把这插件删了,删个干净,然后我们来改代码。因为我并不需要配置什么什么的,也不需要什么JS,怎么个分组怎么个排序的需求很明确,所以直接 HARD CODE。原插件还有一个缓存查询出来的数据的功能,由于我已经用了更强大的缓存,直接将动态页面缓存成纯 HTML,所以也不需要。以上内容通通砍掉,核心代码就很简单了。 首先是 SQL 查询:

+
global $wpdb;
$rawposts = $wpdb->get_results("SELECT ID, year(post_date) as post_year, post_date, post_date_gmt, post_title FROM $wpdb->posts WHERE post_status = 'publish' AND post_type = 'post' AND post_password = '' order by post_date_gmt desc");
+ +

这里按照发布时间降序排序,为什么要用GMT时间而不直接用本地时间呢,我猜可能是为了防止我在这边发了一篇文章然后马上飞到美国又发一篇,可能会乱套吧,反正这么写更严谨,虽然不太可能发生。然后除了多选择一个post_title字段以外,还使用MySQL的一个内置函数选择了这篇文章发布时的年度,这样就不用在分组的时候使用N多遍 mysql2date 函数了。节省了大量步骤。 然后是分组:

+
foreach ($rawposts as $post) {
$posts[$post->post_year][] = $post;
}
$rawposts = null;
+ +

然后是HTML部分:

+
$html = '<div class="archives-container"><ul class="archives-list">';
foreach ($posts as $year => $posts_yearly) {
$html .= '<li><div class="archives-year">' . $year . '年</div><ul class="archives-sublist">';
foreach ($posts_yearly as $post) {
$html .= '<li>';
$html .= '<time datetime="' . $post->post_date . '">' . mysql2date('m月d日 D', $post->post_date, true) . '</time>';
$html .= '<a href="' . get_permalink($post->ID) . '">' . $post->post_title . '</a>';
$html .= "</li>";
}
$html .= "</ul></li>";
}
$html .= "</ul></div>";
return $html;
+ +

两个字:简洁。

+

使用方法

我们复制一份主题目录下的 page.php 文件,然后重命名为 template-archives.php,主要是给它加上以上的代码并且调用之。 对于我正在使用的主题来说,文件内容如下:

+
<?php
/*
Template Name: archives
*/


function _PostList($atts = array())
{
global $wpdb;
$rawposts = $wpdb->get_results("SELECT ID, year(post_date) as post_year, post_date, post_title FROM $wpdb->posts WHERE post_status = 'publish' AND post_type = 'post' AND post_password = '' order by post_date desc");
foreach ($rawposts as $post) {
$posts[$post->post_year][] = $post;
}
$rawposts = null;
$html = '<div class="archives-container"><ul class="archives-list">';
foreach ($posts as $year => $posts_yearly) {
$html .= '<li><div class="archives-year">' . $year . '年</div><ul class="archives-sublist">';
foreach ($posts_yearly as $post) {
$html .= '<li>';
$html .= '<time datetime="' . $post->post_date . '">' . mysql2date('m月d日 D', $post->post_date, true) . '</time>';
$html .= '<a href="' . get_permalink($post->ID) . '">' . $post->post_title . '</a>';
$html .= "</li>";
}
$html .= "</ul></li>";
}
$html .= "</ul></div>";
return $html;
}

function _PostCount()
{
$num_posts = wp_count_posts('post');
return number_format_i18n($num_posts->publish);
}

get_header(); ?>

<div id="primary" class="content-area">
<main id="main" class="site-main" role="main">

<article <?php post_class(); ?>>
<header class="entry-header">
<h1 class="entry-title"><?php the_title(); ?></h1>
</header>
<!-- .entry-header -->

<div class="entry-content">
<?php
echo _PostList();
?>
</div>
<!-- .entry-content -->
</article>
<!-- #post-## -->


</main>
<!-- #main -->
</div>
<!-- #primary -->

<?php get_sidebar(); ?>
<?php get_footer(); ?>
+ +

然后我们把它上传到主机的主题目录下,来到wordpress管理控制台新建一个page,模板选择 archives,什么也不用输入(可以加个标题),保存,就可以看到效果了。当然这里没有涉及到CSS样式,可以在主题的 style.css 中自定义,也可以直接写在 template-archives.php 内,爱写哪写哪。本站使用的CSS如下所示:

+
.archives-year {
color: #777;
border-bottom: 1px solid #e8e8e8;
margin: 40px 0 10px 0;
padding-bottom: 7px;
}

.archives-list {
list-style: none;
margin: 20px 0!important;
}

.archives-sublist {
list-style: none;
font-size: 90%;
margin-left: 0 !important;
}

.archives-sublist li time {
color: #777;
width: 140px;
min-width: 140px;
max-width: 140px;
display: table-cell;
vertical-align: top;
}

.archives-sublist li a {
display: table-cell;
vertical-align: top;
}
+ +

点此查看实际效果 (可能因为网站更新而不符合)

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/wordpress-change-admin-panel-font/index.html b/2016/wordpress-change-admin-panel-font/index.html new file mode 100644 index 000000000..7f7f078c5 --- /dev/null +++ b/2016/wordpress-change-admin-panel-font/index.html @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +WordPress 更改后台字体为雅黑 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ WordPress 更改后台字体为雅黑 +

+ + +
+ + + + +

这个问题其实困扰了我很久。默认的后台字体实在是惨不忍睹。今天终于发现了一个很好的方案,完美解决。

+

在当前主题的 functions.php 中,加上如下代码:

+
/**
* 更改后台字体为雅黑
*/
function change_admin_font(){
echo '<style type="text/css">.wp-admin{font-family: \'Helvetica Neue\', Helvetica, \'Microsoft Yahei\', \'Hiragino Sans GB\', \'WenQuanYi Micro Hei\', sans-serif;}</style>';
}
add_action('admin_head', 'change_admin_font');
+ +

顺便提供一下更改 Twenty Sixteen 主题字体的代码吧,要改的地方挺多的。

+ + +
/* reset font */
body,
button,
input,
select,
textarea,
button,
button[disabled]:hover,
button[disabled]:focus,
input[type="button"],
input[type="button"][disabled]:hover,
input[type="button"][disabled]:focus,
input[type="reset"],
input[type="reset"][disabled]:hover,
input[type="reset"][disabled]:focus,
input[type="submit"],
input[type="submit"][disabled]:hover,
input[type="submit"][disabled]:focus,
.post-password-form label,
.main-navigation,
.post-navigation,
.post-navigation .post-title,
.pagination,
.image-navigation,
.comment-navigation,
.site .skip-link,
.logged-in .site .skip-link,
.widget .widget-title,
.widget_recent_entries .post-date,
.widget_rss .rss-date,
.widget_rss cite,
.tagcloud a,
.site-title,
.entry-title,
.entry-footer,
.sticky-post,
.page-title,
.page-links,
.comments-title,
.comment-reply-title,
.comment-metadata,
.pingback .edit-link,
.comment-reply-link,
.comment-form label,
.no-comments,
.required,
.site-footer .site-title:after,
.widecolumn label,
.widecolumn .mu_register label {
font-family: 'Helvetica Neue', Helvetica, 'Microsoft Yahei', 'Hiragino Sans GB', 'WenQuanYi Micro Hei', sans-serif;
}

::-webkit-input-placeholder {
font-family: 'Helvetica Neue', Helvetica, 'Microsoft Yahei', 'Hiragino Sans GB', 'WenQuanYi Micro Hei', sans-serif;
}

:-moz-placeholder {
font-family: 'Helvetica Neue', Helvetica, 'Microsoft Yahei', 'Hiragino Sans GB', 'WenQuanYi Micro Hei', sans-serif;
}

::-moz-placeholder {
font-family: 'Helvetica Neue', Helvetica, 'Microsoft Yahei', 'Hiragino Sans GB', 'WenQuanYi Micro Hei', sans-serif;
}

:-ms-input-placeholder {
font-family: 'Helvetica Neue', Helvetica, 'Microsoft Yahei', 'Hiragino Sans GB', 'WenQuanYi Micro Hei', sans-serif;
}
+ +

 

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/wordpress-hole-record/index.html b/2016/wordpress-hole-record/index.html new file mode 100644 index 000000000..c0dcea81a --- /dev/null +++ b/2016/wordpress-hole-record/index.html @@ -0,0 +1,460 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +WordPress 掉坑记录 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ WordPress 掉坑记录 +

+ + +
+ + + + +

忍无可忍,长期更新。

+

(其实我很想自己重新做一个 blog,但是太麻烦,也没什么实践价值了,无非 CRUD,而且维护起来很容易忽略 blog 本身的目的所在)

+ + +

关于代码高亮

本站目前(截至 09/20/2016)使用的是 Crayon 插件,这个插件配合 TinyMCE Advanced 简直神了,用户的数据对它们来说都不是什么东西,反正就随着各自的意愿来搞。其实这样还好,关键是,他俩意愿不一致。这 TM 就很尴尬了。以至于我很多文章,编辑再保存以后,格式出现各式各样的问题。

+

最终解决方案:

+
    +
  1. 禁用 TinyMCE Advanced 的 keep p & br 功能;
  2. +
  3. 禁用 Crayon 的所有其它扫描功能,只保留 pre 扫描,即只保留块级代码高亮,同时禁用移除 code 标签的相关功能;
  4. +
  5. 关于行内代码的解决继续看下面。
  6. +
+

那么行内代码怎么办呢。这个 Crayon 太奇葩,如果用它自带的工具插入行内标签(原始是 span),会被它自己扫描出来认为是过时标签,然后强行转为 pre,关键是这一转它自己认得倒还好,然而 TinyMCE 不认为它仍然是行内元素,强行给它换行,套 p 元素。

+

然后文章的格式就完了,而且是全完。

+

所以,解决办法是,不要使用 Crayon 的行内模式,也不要让它扫描行内代码,直接使用 code 标签,然后去改 style,改得跟块级代码差不多就行了。

+

注:写完这些我就把 Crayon 这插件给删了。一个乱搞用户数据库,而且不用标准标签的东西,不要也罢。就直接用 codepre,还方便以后向其它平台转移。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2016/wordpress-unable-to-send-email-under-aliyun-virtual-host/index.html b/2016/wordpress-unable-to-send-email-under-aliyun-virtual-host/index.html new file mode 100644 index 000000000..31b3cda3d --- /dev/null +++ b/2016/wordpress-unable-to-send-email-under-aliyun-virtual-host/index.html @@ -0,0 +1,470 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +WordPress 在阿里云虚拟主机下无法发送邮件 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ WordPress 在阿里云虚拟主机下无法发送邮件 +

+ + +
+ + + + +

安装在阿里云虚拟主机环境下的Wordpress死活都发不出邮件,用户注册的邮件发不出,评论总结也发不出,等等等等,尝试了各种方法都以失败告终。今天用更改代码+SMTP插件终于试成功了,以下是解决方案。

+ + +

更改主机设置

首先阿里云虚拟主机发邮件相关的函数只开放了一个,即 fsockopen,默认情况下还是禁用的,所以我们要去控制台打开它(主机管理 ⇒ 站点信息 ⇒ 高级环境设置 ⇒ PHP.ini设置)。如图所示。

+

+

更改Wordpress代码

找到代码安装路径下的 wp-includes/class-smtp.php 文件,搜索以下代码段:

+
$this->smtp_conn = @stream_socket_client(
$host . ":" . $port,
$errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
$socket_context
);
+ +

将其替换成:

+
$this->smtp_conn = fsockopen($host, $port, $errno, $errstr);
+ +

注意:升级Wordpress可能会导致这段修改过的代码丢失,因此可能每次Wordpress主程序升级后都要再次修改此段代码!

+

安装SMTP插件

改完代码以后,到Wordpress控制台搜索插件Easy WP SMTP(其它类似插件应该也行,这里以它为例),安装并启用。如图所示配置好。

+

+

配置好后点击Save,保存成功后下方会有一个测试发送的表单。可以用它来测试SMTP是否已经可以正确工作。

+

如果测试邮件已经可以正常发送接收,则说明Wordpress的其它邮件也都可以正常收发了。

+

另外,本人测试过使用QQ和126的SMPT服务器,均以失败告终,原因未知。

+

问题待解决

现在Wordpress主程序是可以正常发邮件了,但是BackWPup插件的邮件依然是完全发不出去,使用它提供的所有方式都不行,使用同样的SMTP配置也是不行,它也没有提供什么有用的错误信息,完全摸不着头脑。

+

为什么需要这个插件发邮件呢。因为它是网站的备份主力,但是因为邮件发不出去的关系,不能通过邮件发送备份,只能备份到主机的文件夹下。这样就很没安全感了,要完蛋都是一锅端的感觉,有无备份没什么区别。它提供的其它方案也好像都被墙了,比如Dropbox什么的。

+

这个问题待解决。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/2016/index.html b/2017/2016/index.html new file mode 100644 index 000000000..a915d95d3 --- /dev/null +++ b/2017/2016/index.html @@ -0,0 +1,470 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2016 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 2016 +

+ + +
+ + + + +

刚刚过完了一个非常无聊的元旦。虽然像是很快过了一年的样子,但是又感觉过了很久。因为我已经想不起来年初我在做些什么了。

+

这次主要说些职业生涯和工作上的东西。其它的,想到再说。

+ + +

工作

真的要翻看以前写的每周 report 才能想起来自己上半年做了些什么,不过也都是些没多大意思的项目。比较可喜的是,目前我已经从对内支持工具开发转为了对外产品开发。也算是公司对我的一种肯定吧。

+

公司下半年好像一直在寻找传统商业模式下的新方向,也尝试了一些新的项目,我基本上都有参与。开发任务基本都可以按时保质完成,然而,目前来说,有些事情于我而言还是比较困难的。比如说思考行业未来,探索可能性等等。说到底,我对公司的业务方向(航运)还是不太感兴趣(或者说完全不感兴趣),再加上本身就不够了解,因此这种事情都是一头雾水。

+

我们干的是互联网的活,实际却跟这互联网世界相差甚远。这也是我不太满意的地方。在互联网公司工作,做的事情往往比较贴近生活,自己也能有些想法,然而,在这样一家业务深度极大的公司则不然。有种感觉就是,做什么都完全看 PM 怎么说,做完了也不清楚它的价值在哪里,工作就变成了纯粹的开发开发开发。一个项目开发完了紧接着就是下一个项目。

+

这样子,对公司来说也许足够了,但对个人来说感觉不是一种好的发展模式。不过,最大的问题还是在于兴趣。对业务不感兴趣。

+

毕业以来,加上实习期,在公司呆了也两年多了,感觉自己在渐渐成长。但,长久呆在一个地方,思维必会僵化。有时候也会想,是不是该换个环境,去看看更大的世界。

+

学习

因为工作关系,主攻 Web 开发,因此,2016 年,我认为自己最大的进步是在 ES6 的学习以及组件化开发思想的学习上。

+

上半年我还是在用传统的 NG 1.x 做前端开发的,Node 也是写 ES5,那时候我还觉得,Angular 是世界上最好的前端框架,ES5 已经足够优秀,完全没有必要再去学新的东西了,一套技术可以用到死。然而拜公司强制所赐,我在一次新项目里面不得不使用 Vue 这个更轻量的东西。

+

说起来,这事也得感谢公司,人还是太容易满足。就像我现在也开始觉得,Vue 是世界上最优雅的东西,没有必要再用其它的一样。

+

不过话说回来,我这么喜欢 Vue,以至于远胜谷歌大爹的 Angular,不是空穴来风,最主要的原因是:Vue-loader 实在是非常优秀,将组件化思想以及 ES6 的使用均发挥到了目前看来较高的程度。组件化的开发体验是非常优秀的,这点真的是亲身用过才会有所感悟。

+

此外,我个人觉得,有所加分的是,Vue 是一个单人项目:只有一个作者,维护者也是作者本人。我认为,一个能力超强的人,远胜五个能力优秀的人。这也是 Vue 能够保持优雅的原因之一。

+

而 ES6,作为下一代的 JavaScript,虽然看起来大多是语法糖,但是不得不说用还是挺好用。代码量缩减了不少,莫名其妙的问题也少了许多。我个人还是比较希望 Async / Await 早日进入标准,早日实现,这样 JS 也能真正变成优雅且好用的编程语言。如此一来,JavaScript 的天下,必将更大了。

+

生活

今年就年底一件大事:买房。把自己的积蓄花光了,父母的积蓄也花了不少,如今过上了吃土的日子。从前都是别人向我借钱,今天也到我跟别人借钱度日的时候。

+

不过,压力其实不大,就是这俩月会较为难过,能不能活下去就看公司按不按时发工资。

+

毕业工作一年,我好像也变成了一个无趣的人。曾经日夜鏖战天梯,如今也是要等死了才能醒悟自己身上还有一枚芒果。不过不得不承认的是,很多事情我确实没有天赋,再怎么做,也不过是熟练,熟悉。

+

前两天元旦假期的时候,过于无聊,把几件有些价值的装备卖了,到 steam 去买了几款好评游戏。100M 的网速下载是快,但我进入游戏后放弃得更快。提不起劲。

+

有时候也会觉得好笑,我从小就饱受垃圾电脑和网络所带来的痛苦,简直是苦不堪言。可为什么我到今天,进入游戏第一件事依然是把所有配置调到最低呢?

+

展望

新的一年,首先希望自己可以继续学习,更甚者,换个紧张些的工作环境去学习。我对目前自己的评价是:基础还行,但知识面不够广。像 React / RN 等东西,我将标注为上半年重点学习对象。虽然 JSX 脱离了历史的进程,我不喜欢,但用的人越来越多,说明它自有好处,因此有必要深入了解一番。

+

学到的东西多了起来,我也会想自己做点什么。目前来说,Vue 的组件库太少,且质量也非常一般,是阻碍其被广泛使用的一大因素,尤其是 Vue2,因此,我有较大的冲动去开发一个基于 Boostrap 风格或 Material 风格的 Vue2 组件库。来弥补自己对开源社区贡献为 0 的尴尬。

+

CSS3 某些前沿属性是我目前的基础短板,Codepen 上面很多酷炫的作品如此亮瞎,希望可以找个时间好好学习一番。

+

希望可以多些涉及移动端开发。不要再在 Web 领域固步自封(当然,也许是通过 RN 或者其它基于 JS 的工具)。

+

希望新的一年可以赚到更多的钱,还起房贷来没那么大压力,欠父母的钱早日还清。如果还有余钱,考虑买个代步工具。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/2017/index.html b/2017/2017/index.html new file mode 100644 index 000000000..9a5d8360f --- /dev/null +++ b/2017/2017/index.html @@ -0,0 +1,504 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2017 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 2017 +

+ + +
+ + + + +

今天是 2017 年的最后一个(法定)工作日。做个简单的总结。

+

先对比一下去年的自己与目标:

+
    +
  • 关于工作,年初就换了。现在到了一个游戏公司(西山居)上班。对于去年吐槽最多的「业务」问题来说,如今算是彻底解决了。
  • +
  • 关于学习,感觉自己从某些方面来说,是有一点进步的。
  • +
  • 关于生活,今年入了两台主要设备,一台 RMBP,以及一台游戏主机。感觉都很值。
  • +
+

大概就这些。

+ + +

职业

现在回头想想,自己通过公交车上下班已经快一年了,每天两个小时在路上,时间真的过得很快(好在公司马上要搬)。今年全勤,加上加班时间,略有困倦。

+

今年:

+
    +
  • Vue 基本上已经轻车熟路;
  • +
  • 接触了 Electron 开发框架,用它负责并完成了公司的一个 H5 + Canvas 视频工具客户端项目的开发;
  • +
  • 接触了 React 全家桶以及 ReactNative,目前开发时间也有一月余了,略知一二,找个时间写个系统性的学习与使用总结(算是个人技术栈上的一个突破);
  • +
  • 前端工具链有了更加深入的理解;
  • +
  • 等等……
  • +
+

此外,工作之余,还拥有了两个(真正意义上的)开源项目:

+ +

第一个,组件库,是我一直想做的,今年终于算是略有小成了。从一开始非常简陋的东西,变成了现在这副模样。在用户数量逐渐增加的同时,也得到了越来越多的反馈与支持。同时,在设计与改善的过程中,我也从其它的开源项目中得到了很多启发(如 Element / iView / Bootstrap-Vue 等)。

+

第二个,将 Markdown 转换为 Vue 组件的 Webpack loader,是为了解决组件库项目的一个问题(文档撰写)而产生的。这个功能很普通,但它能帮我(或者跟我有类似需求的人)解决一个突出的问题。详情请看:Better Documents

+

开源项目的乐趣在于,开发者能够实实在在地从社区获得一些「认同感」。也就是,有人真的在使用我的项目。他们会给我提改进意见,给我贡献代码,还会对我说「谢谢」。虽然我做的事情根本微不足道,但每天看到项目的星星在一个个地变多,还是让我非常开心的事情。

+

生活

今年去了两次女朋友的家(五一、十一),见了家长。第二次还是跟自己这边的亲戚们一起去的,很多人。现在双方应该说是「认识了」。

+

如今很多亲戚经常会问我的事情是:结婚了没,什么时候结婚,怎么还不结婚。也许这就是顺理成章的事情,很正常。但我并没有觉得目前有这个必要(静纯应该也是这么认为的吧),现在这样不就挺好的吗。房子买了(虽然还未落定),工作正常,生活富足,过得开心,可以说现在我真的是无欲无求。所以我根本没想过「要结婚」这件事情。

+

有一点遗憾的是,公司原定的「塞班岛」旅行,后来取消了。本来我跟静纯纠结了好久才决定要去,很多东西都在准备了,结果因为工作原因不得不取消。后来补数的旅行线路,我觉得都没有这个好了,所以就干脆就没有再参与。

+

公司即将搬迁到唐家,这样一来我的生活圈子就又回到唐家了,而且还变得比一年前还更小了。不知道到时候我还能不能有买车的动力。我觉得车还是挺有必要的,没有它,不要说一线城市,在珠海这个二线城市都有点力不从心,去哪里都要担心回家的事情。而且,回家也不方便,东西带不了多,不能说走就走。

+

今年又有一些朋友从别的城市来看我了,我希望等到明年工作闲下来的时候,我也能去看看朋友。

+

老妈今年跟亲友到处游玩,我也觉得挺好的,有机会的时候就是要去玩。可惜的是我没有时间参与。前段时间比较冷,不想呆在房子里被冻僵,就买了个取暖器,后来想到家里没有,就给老妈也买了个。其实我为家里做过的事情真的很少,从现在起我要养成这样的习惯。

+

对了,如果年底发的奖金足够多,那么我今年就能还清借父母的首付了!

+

爱好

今年买的最值的一样东西就是 MacBook Pro (Retina, 13-inch, Early 2015),基本替代了我办公室的台式电脑。原因有几:

+
    +
  • 对于非 MS 系开发者来说,比 Windows 简单、易用、好用;
  • +
  • ReactNative 开发时可以进行 Emulator 调试;
  • +
  • 软件齐全,且非常「干净」;
  • +
  • 速度飞快;
  • +
  • 屏幕好看。
  • +
+

13 寸也是我非常喜欢的尺寸,极度轻便,搭配 Retina 屏幕,不要太完美。

+

此外,自己动手组装了一台游戏主机,花了大概 6k,自从那时开始,打开游戏第一件事,分辨率 MAX,特效 MAX,毫无压力(当然是对我的垃圾 1080P 来说)。再此外,买了一个 XBOX ONE 手柄。

+

有了设备,自然就有软件。在有限的游玩时间内,今年我最喜欢的两个游戏:

+
    +
  • DARK SOULS™ III「黑暗之魂 3」
  • +
  • NieR:Automata™「尼尔:机械纪元」
  • +
+

之后我会单独写两篇文来记录感想。

+

下半年开始就没怎么玩 DOTA 了,一是工作繁忙,二是状态有点迷,跟不上节奏。

+

接下来

希望可以:

+
    +
  • 大家都身体健康
  • +
  • 在现有基础上继续钻研 React / ReactNative,至少达到「熟练」的程度
  • +
  • ReactNative 做多了以后,少不了要接触原生开发,希望至少可以「入门」
  • +
  • 学习 GO 技术栈,希望至少可以「入门」
  • +
  • 玩(买)更多的游戏!
  • +
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/better-documents/index.html b/2017/better-documents/index.html new file mode 100644 index 000000000..e5fd3101e --- /dev/null +++ b/2017/better-documents/index.html @@ -0,0 +1,569 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Better Documents | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Better Documents +

+ + +
+ + + + +

这篇文章记录了我是如何一步步地把 https://github.com/wxsms/uiv 这个项目的用户文档变得更优雅的。实际上,如何以一种高效又优雅的方式编写实例文档一直是我的一个疑惑,比如主要的问题体现在:

+
    +
  • 如何使文档更易读?
  • +
  • 如何使文档更易于维护?
  • +
  • 如何减少编写文档的工作量?
  • +
  • 实例代码无可避免地需要手工维护吗?
  • +
+

最后一点是让我最头疼的地方。举个例子,我想要给用户展示一个组件的使用方式,以下代码可以在页面上创建一个 Alert:

+
<alert type="success"><b>Well done!</b> You successfully read this important alert message.</alert>
+ +

那么,我总要给用户一个相对应的实例吧。我要在我的文档上面就创建一个这样的 Alert,同时告诉用户说你可以这么用。这是一个很普遍的展示方式,那么问题就在这里了,我是否要将同样的代码写两次呢?

+

一开始我确实就是这么做的,虽然我知道这不科学,不高效,更不优雅。但我实在是想不到更好的办法了。

+

但是,现在,我已经(几乎)把以上的问题都解决了。

+ + +

Stage-1

写文档这件事,实际上跟写文章差不多,写作体验很重要。

+

在最开始的时候,项目文档是直接用 Vue 文件编写的,没有经过任何处理,没有经验的我甚至还作死地加入了 i18n,可以说是非常有趣了。以至于到最近,在没有发生这次重构之前,我根本不想动它们。

+

可以想象,我给关键字句加个粗要手写 <b>...</b>,标记一点代码要用 <code>...</code>,每写一段话都要注意标签标签标签,文档里充斥这些东西,烦不胜烦。

+

这阶段的文档,存在的问题主要有:

+
    +
  • 难以编写
  • +
  • 无法在网站以外的地方阅读(因为是 Vue 源码)
  • +
  • 给项目增加了许多额外代码
  • +
  • 手工维护的实例代码
  • +
+

Stage-2

以上提到的写作体验令人作呕,经过了漫长的时间后,在这一阶段得到了解决。某次机缘巧合,我发现了这样一个工具,它可以通过 webpack 将 Markdown 格式的文本直接转换成为 Vue 组件:vue-markdown-loader

+

比如:

+
module.exports = {
module: {
rules: [
{
test: /\.md$/,
loader: 'vue-markdown-loader'
}
]
}
};
+ +

这样一来,就可以通过 import [*].md 的方式,得到一个内含 Markdown 内容(已转 HTML)的 Vue 组件。可以直接在页面上用了!

+

如果不考虑实例部分的话,这就已经完美了。准确地说,如果一开始就不需要实例这种东西,那么我肯定会直接用 Gitbook 了。也不需要这个 markdown to vue 来做什么。

+
+

经过了长时间的折磨的我身心疲惫,最终还是决定尝试一下。

+

然而,就在这个尝试的过程中惊喜地发现:它居然还可以执行 Markdown 中的 Code block 中的代码!

+

这是什么鬼。一开始发现这个的时候我还是很惊讶的。仿佛打开了新世界的大门。

+

在后来的不断尝试 - 失败 - 尝试的过程中,我发现了它更多的 Feature:

+
    +
  • 可以执行 Code blocks 中的代码(<script>
  • +
  • 可以执行 Code blocks 中的样式(<style>
  • +
  • 可以通过插件给文档 header 加锚点
  • +
+

但是,也发现了以下问题:

+
    +
  • 多个 Code blocks 中的 <style> 可以合并,但 <script> 不行,它始终只会执行所找到的第一段 <script>
  • +
+

通过查阅 vue-loader 的文档发现,这是 .vue 文件本身的限制:支持多个 <template>,多个 <style>一个 <script>

+

也就是说,如果页面上有多个实例需要展示的话,给给。

+

如果这个问题能够解决的话,再结合我本身的需求,以下内容也需要实现:

+
    +
  • 将实例代码中的 <template> 模板插入到其代码块之前,让其成为 Markdown 文件的一部分,然后 Vue 就会自动将它们统统实例化
  • +
+
+

其实到了这里,也就是这两个问题需要解决了。

+

首先是模板插入的问题。这个其实不难,在 Markdown 完成渲染前,通过一些手段找到这些需要渲染的模板,然后手动插入。幸而 loader 提供了 preprocess 钩子,让我能直接完成这件事情。

+

然后,关于 <script> 这块,我尝试了好久好久,实在是没办法。但是又真的舍不得因为这仅仅一个问题丢弃以上的那么多的好处。于是就想到了一个折中的办法:禁用 loader 的自动执行代码功能,并手动组装代码块。然而一个悲催的问题又出现了:禁用自动代码执行后,<style> 也无法自动执行了。

+

解决方案:我需要在 preprocess 中将 Code blocks 里面的 <style> 块全部切出来,贴到 code blocks 的外面(比如文件结尾处)去。一开始我还尝试了将它们的内容合并成为一个 <style>,后来发现其实不需要,因为 vue-loader 本身就支持一个文件多个 <style> 节点。

+

最后的最后,轮到了 <script> 的组装。我尝试了很久的自动合并,比如将它们的 export 内容转为 object 再 merge 啦,function 转为 object 再 merge 啦,toString 再 merge 啦,等等等等,然而各种方式都以失败告终。结论是:我无法将数个字符串代码块直接合并,也无法转为 object 再合并再转回字符串。实在的实在是没办法了,hard code 吧。

+
+

至此,一个新的解决方案就出现了。简单来说,编写一篇文档,我需要做以下的事情:

+
    +
  • 用 Markdown 写文档以及实例代码
  • +
  • 实例代码块中加入约定的标志
  • +
  • 注意同一个 Markdown 中的实例代码块的 <script> 不能相互冲突
  • +
  • 做完所有事情以后,用我自己的智商和爱将所有的实例代码合并成一份
  • +
+

大功告成。

+

虽然依然有些麻烦,但相比与 Stage-1,我至少解决了以下的大事:

+
    +
  • 文档编写体验大幅度提升!
  • +
  • 文档可以在网站以外的地方被阅读(如 Github)
  • +
  • 实例的 <template><style> 代码无需再有特殊照顾
  • +
  • 维护工作量大大减少
  • +
+

依然存在的问题是:

+
    +
  • 实例的 <script> 代码需要维护两份,而且不能彼此冲突
  • +
+

Stage-3

虽然解决了 80% 的问题,但 Stage-2 依然不完美。我始终想要解决最后一个问题:无需特殊照顾的实例 <script>

+

想要达到这个目标,有一个完美的办法就是:将实例也作为子组件来插入到 Markdown 父组件中去。这样一来,同一页面的实例代码无法冲突的问题也就一并解决了。

+

显然,通过目前的 loader 无法达到我想要的效果,它只能够简单地将代码插入 Markdown,并不能构建子组件。因此,要解决这个问题,我需要自己造轮子

+

……

+
+

于是就有了:

+

https://github.com/wxsms/vue-md-loader

+

关于这个轮子,它是原有 markdown-loader 的一个替代品,并且能够解决以上提出的所有问题

+

除了完善的原有 Markdown 转换功能以外,它还可以将 Markdown 中的实例代码,比如:

+
<template>
<div class="cls">{{msg}}</div>
</template>
<script>
export default {
data () {
return {
msg: 'Hello world!'
}
}
}
</script>
<style>
.cls {
color: red;
background: green;
}
</style>
<!-- some-live-demo.vue -->
+ +

变成类似这样的结构:

+
<some-live-demo/>
<pre><code>...</code></pre>
+ +
+

A Vue component with all it’s <template>, <script> and <style> settled will be inserted before it’s source code block.

+
+

毫无疑问,它支持同一文件中的多个代码块

+

关于这个插件,其实就是一个典型的、简单的 webpack loader,将一个 markdown 文件转换成了可以被 vue-loader 识别并加载的 vue 文件。

+

它的实现思路主要有:

+
    +
  • 将实例代码块中的 <style> 直接截取,并放到 Markdown 组件下
  • +
  • 将实例代码块中的 <script>export default 的内容截取,并作为各自的 Component options
  • +
  • 加上相应代码块中的 <template> 中的内容,稍微组装一下,它就成为了一个 Vue component
  • +
  • 在 Markdown 组件中局部注册该 component,并将它插入到代码块的前面去
  • +
  • 对于 export default 外部的内容,把它们抽取出来,集中放到 Markdown 组件下
  • +
+

以上这些操作,全部通过字符串与正则操作就足以完成了。

+

然而可以发现,这里面仍有一些有待解决的问题:

+
    +
  • <style> 有可能冲突
  • +
  • export default 之外的内容有可能冲突
  • +
+

这两个问题目前也还没有想到有效的解决办法。但是,就目前来说,满足我的需求已经完全足够了。遗留问题通过后续的开发来逐步解决吧。

+
+

至此,优雅地编写项目文档的全部要素就齐备了:

+
    +
  • 纯文档编写体验(Markdown)
  • +
  • 文档可以在网站以外的地方被阅读(如 Github)
  • +
  • 实例代码均无需特殊照顾,所有过程自动完成
  • +
  • 没有维护压力
  • +
+

Enjoy!

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/change-socks-proxy-to-http/index.html b/2017/change-socks-proxy-to-http/index.html new file mode 100644 index 000000000..6305ce10c --- /dev/null +++ b/2017/change-socks-proxy-to-http/index.html @@ -0,0 +1,465 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Change SOCKS Proxy to HTTP | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Change SOCKS Proxy to HTTP +

+ + +
+ + + + +

OSX

Use brew to install polipo via socks proxy:

+
$ ALL_PROXY=socks5://127.0.0.1:9500 brew install polipo
+ +

Create polipo.config file under Document:

+
socksParentProxy = "127.0.0.1:9500"
socksProxyType = socks5
proxyAddress = "::0"
proxyPort = 8123
+ +

Start polipo server:

+
$ polipo -c ~/Documents/polipo.config
Established listening socket on port 8123.
+ +

Verify it at http://localhost:8123.

+

Windows

Use privoxy tool. Download: http://www.privoxy.org/sf-download-mirror/Win32/

+

Install it, find the config file at \Privoxy\config.txt, append following to the bottom of it:

+
forward-socks5 / 127.0.0.1:9500 .
+ +

(Mind the dot at the end)

+

The default port is 8118, search from the config file to replace it.

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/common-used-commands/index.html b/2017/common-used-commands/index.html new file mode 100644 index 000000000..4cd88706e --- /dev/null +++ b/2017/common-used-commands/index.html @@ -0,0 +1,587 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Common-used Commands | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Common-used Commands +

+ + +
+ + + + +

Personal common-used commands list, including windows, osx, git, etc.

+ + +

Git

Clone

Full clone

+
$ git clone [url]
+ +

Fast clone

+
$ git clone --depth=1 [url]
$ git fetch --unshallow
+ +

Fetch

$ git fetch [origin] [branch]
+ +

Pull

$ git pull [origin] [orinin-branch]:[local-branch]
+ +

Push

$ git push [origin] [orinin-branch]:[local-branch]
+ +

Force push

+
$ git push --force origin 
+ +

Tags push

+
$ git push --tags origin 
+ +

Config

Show

+
$ git config user.name
wxsm

$ git config --list
user.name=wxsm
user.email=wxsms@foxmail.com
+ +

Set

+

Repo level:

+
$ git config user.name [name]
$ git config user.email [email]
$ git config http.proxy [proxy]
$ git config https.proxy [proxy]
+ +

Supports socks & http proxy.

+

Global level:

+
$ git config --global user.name [name]
$ git config --global user.email [email]
+ +

Unset

+
$ git config --unset user.email
$ git config --global --unset user.email
+ +

Remote

$ git remote -v
origin https://github.com/wxsms/uiv.git (fetch)
origin https://github.com/wxsms/uiv.git (push)

$ git remote set-url origin git@github.com:wxsms/uiv.git

$ git remote -v
origin git@github.com:wxsms/uiv.git (fetch)
origin git@github.com:wxsms/uiv.git (push)
+ +

NVM

nvm ls

nvm install [version]
NVM_NODEJS_ORG_MIRROR=https://npm.taobao.org/mirrors/node/ nvm install [version]

nvm use [version]
nvm alias default [version]
+ +

OSX

Keys

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameSymbol
command
option
shift
caps lock
control
return
enter
+

Shortcuts

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameSymbol
search⌘ + space
switch input⌃ + space
delete⌘ + delete
Lock screen⌘ + ⌃ + Q
Screen shot (full)⌘ + ⇧ + 3
Screen shot (custom)⌘ + ⇧ + 4
Screen shot (window)⌘ + ⇧ + 4 + space
Screen shot & copy (full)⌘ + ⇧ + ⌃ + 3
Screen shot & copy (custom)⌘ + ⇧ + ⌃ + 4
Screen shot & copy (window)⌘ + ⇧ + ⌃ + 4 + space
Hide window⌘ + H
Minimize window⌘ + M
Quit⌘ + Q
+

Proxy command

$ ALL_PROXY=socks5://127.0.0.1:9500 brew update
+ +

Toggle hidden files

$ defaults write com.apple.finder AppleShowAllFiles YES
$ defaults write com.apple.finder AppleShowAllFiles NO
+ +

Open files

$ open nginx.conf
$ open -a TextEdit nginx.conf
+ + + +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/cors-headers-note/index.html b/2017/cors-headers-note/index.html new file mode 100644 index 000000000..65c4a00ca --- /dev/null +++ b/2017/cors-headers-note/index.html @@ -0,0 +1,475 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +CORS Headers Note | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ CORS Headers Note +

+ + +
+ + + + +

CORS HTTP Header 是解决 Ajax 跨域问题的方案之一。详情查看:MDN

+

这篇文章主要是记录使用过程中遇到的问题以及解决方案。

+ + +

客户端

客户端正常情况无需特殊配置。但有一些需要注意的地方。

+

请求预检

CORS 请求与非跨域请求不一样的是,它会将请求分成两种类型:Simple Request(简单请求)Preflighted Request(预检请求)

+

Simple Request

满足所有条件的请求为简单请求。

+

看了文档以后发现跟普通请求别无二致。

+

Preflighted Request

满足任一条件的请求为预检请求。

+

与简单请求不同,预检请求要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求,以避免跨域请求对服务器的用户数据产生未预期的影响。

+

预检请求示意图

+

所以,实际上这种跨域请求会产生两次 HTTP Request:一个预检请求,以及预检成功后的真正的请求。由于预检请求使用 OPTIONS 方法而不是常见的 POST 等,因此服务器必须为跨域 API 提供能够正确返回的相应方法。

+

身份验证

如果需要进行 Cookie / Session / HTTP Authentication 等操作,则必须在进行 Ajax 请求时带上一个 withCredentials 参数。至于如何带这个参数,每个 Lib 应该都有自己的配置方式,下面是两个例子。

+

Raw Ajax Example:

+
var invocation = new XMLHttpRequest();
var url = 'http://bar.other/resources/credentialed-content/';

function callOtherDomain(){
if(invocation) {
invocation.open('GET', url, true);
invocation.withCredentials = true;
invocation.onreadystatechange = handler;
invocation.send();
}
}
+ +

Using Axios Example:

+
let corsAgent = axios.create({
withCredentials: true
})
+ +

服务端

服务端的配置并不是只需要给请求响应加个 Access-Control-Allow-Origin Header 这么简单,还有其它需要处理的地方。因此自己做远不如直接使用相关 Lib 来得方便。比如:

+ +

withCredentials

当启用 withCredentials 参数后,Access-Control-Allow-Origin 将不能设置为 * (允许所有域名),必须指定为唯一的域名,否则预期的效果将无法达到。由于这个规则不会产生 Warning 或 Error,出了问题不了解情况的话还是比较难发现的。

+

可以预见(事实)的是,当 Access-Control-Allow-Origin 指定了唯一域名后,使用其它域名访问该 API 也会出现无效的问题。不过相应地也有一个取巧的办法,就是将它设置为 Request 的 Origin Header,这样一来问题就解决了。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/egret-note/index.html b/2017/egret-note/index.html new file mode 100644 index 000000000..039d0c379 --- /dev/null +++ b/2017/egret-note/index.html @@ -0,0 +1,615 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Egret Note | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Egret Note +

+ + +
+ + + + +

Egret Engine 的学习笔记。

+

Egret Engine 是一款基于 JavaScript 的游戏制作引擎,支持 2D 与 3D 模式,支持 Canvas 与 WebGL 渲染,目前使用 TypeScript 编写。

+ + +

显示对象

“显示对象”,准确的含义是可以在舞台上显示的对象。可以显示的对象,既包括可以直接看见的图形、文字、视频、图片等,也包括不能看见但真实存在的显示对象容器。

+

在Egret中,视觉图形都是由显示对象和显示对象容器组成的。

+

对象树

    +
  • 根:舞台 DisplayObjectContainer:Stage
  • +
  • 茎:主容器(文档类) DisplayObjectContainer
  • +
  • 树枝:容器 DisplayObjectContainer
  • +
  • 树叶:显示对象 DisplayObject
  • +
+

对象类型

    +
  • DisplayObject 显示对象基类,所有显示对象均继承自此类
  • +
  • Bitmap 位图,用来显示图片
  • +
  • Shape 用来显示矢量图,可以使用其中的方法绘制矢量图形
  • +
  • TextField 文本类
  • +
  • BitmapText 位图文本类
  • +
  • DisplayObjectContainer 显示对象容器接口,所有显示对象容器均实现此接口
  • +
  • Sprite 带有矢量绘制功能的显示容器
  • +
  • Stage 舞台类
  • +
+

基本概念

二维坐标系。原点位于左上角

+
var shape:egret.Shape = new egret.Shape();
shape.x = 100;
shape.y = 20;
+ +

支持的操作:

+
    +
  • alpha:透明度
  • +
  • width:宽度
  • +
  • height:高度
  • +
  • rotation:旋转角度
  • +
  • scaleX:横向缩放
  • +
  • scaleY:纵向缩放
  • +
  • skewX:横向斜切
  • +
  • skewY:纵向斜切
  • +
  • visible:是否可见
  • +
  • x:X 轴坐标值
  • +
  • y:Y 轴坐标值
  • +
  • anchorOffsetX:对象绝对锚点 X
  • +
  • anchorOffsetY:对象绝对锚点 Y
  • +
+

锚点

Display Object 显示在舞台上的的位置需要通过 Anchor 来计算(初始值位于 Display Object 的左上角),可以通过 anchorOffsetXanchorOffsetY 方法来改变对象的锚点(比如移至中点)。

+

定位

Display Object 的初始坐标为 (0, 0),即位于容器的左上角(而非舞台)。

+
    +
  • 相对于容器的位置可以类比作 position: relative
  • +
  • 相对于舞台的位置可以类比作 position: absolute
  • +
+

如果要获取绝对位置,需要调用 container.globalToLocal(x, y) 方法,参数代表舞台坐标,返回值为容器坐标。

+

至于 z-index 则跟 svg 的处理类似。

+

尺寸

两种方法更改尺寸:

+
    +
  • height / width
  • +
  • scaleX / scaleY
  • +
+

斜切

斜切可以造成类似矩形变形为平行四边形的效果。

+
    +
  • skewX:横向斜切
  • +
  • skewY:纵向斜切
  • +
+

对象容器

DisplayObjectContainerDisplayObject 的子类。

+

向 Container 中添加 DisplayObject:

+
container.addChild(displayObject);
+ +
+

同一个显示对象无论被代码加入显示列表多少次,在屏幕上只绘制一次。如果一个显示对象 A 被添加到了 B 这个容器中,然后 A 又被添加到了 C 容器中。那么在第二次执行 C.addChild(A) 的时候,A 自动的从 B 容器中被删除,然后添加到 C 容器中。

+
+

移除:

+
container.removeChild(displayObject);
+ +

深度管理

DisplayObject 的 z-index 由其插入到容器中的顺序决定。后插入的显示在上层。

+

插入到指定位置使用 container.addChildAt(object, index) 方法。

+

同时也有 container.removeChileAt(index) 方法。

+

删除全部对象使用 container.removeChildren() 方法。

+

交换 DisplayObject 的位置有两个方法:

+
    +
  • container.swapChildren(object, object)
  • +
  • container.swapChildrenAt(index, index)
  • +
+

手动设置 z-index 使用 container.setChildIndex( object, index ) 方法。

+

子对象选择

通过 z-index 获取:container.getChildAt(index)

+

通过 name 获取(需要预先给 DisplayObject 设置 name 属性):container.getChildByName(name)

+
+

通过 z-index 获取子对象性能更佳。

+
+

矢量绘图

+

Egret中可以直接使用程序来绘制一些简单的图形,这些图形在运行时都会进行实时绘图。要进行绘图操作,我们需要使用 Graphics 这个类。但并非直接使用。 一些显示对象中已经包含了绘图方法,我们可以直接调用这些方法来进行绘图。 Graphics 中提供多种绘图方法。

+
+

已有的绘图方法包括:矩形、圆形、直线、曲线、圆弧。

+

以下的 shp 代表 shape,即一个 Shape 对象的实例。

+

shp.graphics.clear() 是通用的清楚绘图方法。

+

基本图形

矩形

var shp:egret.Shape = new egret.Shape();
shp.graphics.beginFill( 0xff0000, 1); //color and alpha
shp.graphics.drawRect( 0, 0, 100, 200 ); // x y width height
shp.graphics.lineStyle( 10, 0x00ff00 ); // border-width and border-color
shp.graphics.endFill();
this.addChild( shp );
+ +

圆形

shp.graphics.lineStyle( 10, 0x00ff00 );
shp.graphics.beginFill( 0xff0000, 1);
shp.graphics.drawCircle( 0, 0, 50 ); // x y r
+ +
+

此处需要注意的是,圆形的X轴和Y轴位置是相对于Shape对象的锚点计算的。

+
+

直线

shp.graphics.lineStyle( 2, 0x00ff00 );
shp.graphics.moveTo( 10,10 ); // 起点
shp.graphics.lineTo( 100, 20 ); // 终点(可以多次执行 lineTo)
+ +

曲线

shp.graphics.lineStyle( 2, 0x00ff00 );
shp.graphics.moveTo( 50, 50);
shp.graphics.curveTo( 100,100, 200,50); // 控制点 x y ,终点 x y
+ +

圆弧

drawArc( x:number, y:number, radius:number, startAngle:number, endAngle:number, anticlockwise:boolean ):void
+ +

前面的参数跟前面绘制圆形的一样,圆弧路径的圆心在 (x, y) 位置,半径为 radius 。后面的参数表示根据 anticlockwise : 如果为 true,逆时针绘制圆弧,反之,顺时针绘制。

+
+

需要注意是传入的 startAngle 和 endAngle 均为弧度而不是角度。

+
+

遮罩

DisplayObject 有一个 mask 属性,简单来说,就是类似蒙版上面的一个洞。但这个 mask 是洞而不是蒙版。如果添加了 mask 属性,则 Object 只能显示这个“洞中”的内容。

+

用作遮罩的显示对象可设置动画、动态调整大小。遮罩显示对象不一定需要添加到显示列表中。但是,如果希望在缩放舞台时也缩放遮罩对象,或者如果希望支持用户与遮罩对象的交互(如调整大小),则必须将遮罩对象添加到显示列表中

+
+

不能使用一个遮罩对象来遮罩另一个遮罩对象。

+
+

通过将 mask 属性设置为 null 可以删除遮罩。

+
mySprite.mask = null;
+ +

碰撞检测

    +
  • 非精确:var isHit:boolean = shp.hitTestPoint( 10, 10 );
  • +
  • 精确:shp.hitTestPoint( 10, 10,ture);
  • +
+

非精确大概可以看做面积相交,精确则是边缘相交。

+
+

大量使用精确碰撞检测,会消耗更多的性能。

+
+

文本

Egret 提供三种不同的文本类型,不同类型具有以下特点:

+
    +
  • 普通文本:用于显示标准文本内容的文本类型
  • +
  • 输入文本:允许用户输入的文本类型
  • +
  • 位图文本:借助位图字体渲染的文本类型
  • +
+

样式

var label:egret.TextField = new egret.TextField(); 
label.text = "这是一个文本";
label.size = 20; // 全局默认值 egret.TextField.default_size,下同
label.width = 70;
label.height = 70;
label.textAlign = egret.HorizontalAlign.RIGHT; // CENTER LEFT
label.verticalAlign = egret.VerticalAlign.BOTTOM; // MIDDLE TOP
label.fontFamily = "KaiTi"; // default_fontFamily
label.textColor = 0xff0000; // default_textColor

//设置粗体与斜体
label.bold = true;
label.italic = true;

//设置描边属性
label.strokeColor = 0x0000ff;
label.stroke = 2;
this.addChild( label );
+ +

支持格式混排:

+
// JSON 模式
label.textFlow = <Array<egret.ITextElement>>[
{text: "妈妈再也不用担心我在", style: {"size": 12}}
, {text: "Egret", style: {"textColor": 0x336699, "size": 60, "strokeColor": 0x6699cc, "stroke": 2}}
, {text: "里说一句话不能包含各种", style: {"fontFamily": "楷体"}}
, {text: "五", style: {"textColor": 0xff0000}}
, {text: "彩", style: {"textColor": 0x00ff00}}
, {text: "缤", style: {"textColor": 0xf000f0}}
, {text: "纷", style: {"textColor": 0x00ffff}}
, {text: "、\n"}
, {text: "大", style: {"size": 36}}
, {text: "小", style: {"size": 6}}
, {text: "不", style: {"size": 16}}
, {text: "一", style: {"size": 24}}
, {text: "、"}
, {text: "格", style: {"italic": true, "textColor": 0x00ff00}}
, {text: "式", style: {"size": 16, "textColor": 0xf000f0}}
, {text: "各", style: {"italic": true, "textColor": 0xf06f00}}
, {text: "样", style: {"fontFamily": "楷体"}}
, {text: ""}
, {text: "的文字了!"}
];

// HTML 模式 (标签与属性部分支持)
label.textFlow = (new egret.HtmlTextParser).parser(
'没有任何格式初始文本,' +
'<font color="#0000ff" size="30" fontFamily="Verdana">Verdana blue large</font>' +
'<font color="#ff7f50" size="10">珊瑚色<b>局部加粗</b>小字体</font>' +
'<i>斜体</i>'
);
+ +

事件与链接

tx.textFlow = new Array<egret.ITextElement>(
{ text:"这段文字有链接", style: { "href" : "event:text event triggered" } }
,{ text:"\n这段文字没链接", style: {} }
);
tx.touchEnabled = true;
tx.addEventListener( egret.TextEvent.LINK, function( evt:egret.TextEvent ){
console.log( evt.text );
}, this );
+ +

也可以直接将 href 设置为 url,这样不需要事件监听,将直接打开链接。但只适用 Web 端

+

文本输入

关键代码是设置其类型为 INPUT。

+
var txInput:egret.TextField = new egret.TextField;
txInput.type = egret.TextFieldType.INPUT;
+ +

绘制输入背景可以用其它 DisplayObject,目前没有内置实现。

+

获取焦点使用 textIput.setFocus(); 方法。

+

除此以外,还有 inputType 属性表示输入内容的区别,这个主要用于移动端弹出相应的键盘。

+

事件处理

事件类:egret.Event

+

执行流程

+

事件机制包含4个步骤:注册侦听器,发送事件,侦听事件,移除侦听器。这四个步骤是按照顺序来执行的。

+
+

事件类

其构建器可以传 3 个参数:事件类型、是否冒泡、是否可取消(什么是取消?)。

+
class DateEvent extends egret.Event
{
public static DATE:string = "约会";
public _year:number = 0;
public _month:number = 0;
public _date:number = 0;
public _where:string = "";
public _todo:string = "";
public constructor(type:string, bubbles:boolean=false, cancelable:boolean=false)
{
super(type,bubbles,cancelable);
}
}
+ +

监听器

跟常见的情况不太一样,Egret 的事件绑定在发送者上(而不是接收者)。

+

监听器函数

+

一个侦听器必须是函数,它可以是一个独立函数,也可以是一个实例的方法。侦听器必须有一个参数,并且这个参数必须是 Event 类实例或其子类的实例, 同时,侦听器的返回值必须为空(void)。

+
+

注册与移除事件监听

注册侦听器

+
eventDispatcher.addEventListener(eventType, listenerFunction, this);
+ +

移除侦听器

+
eventDispatcher.removeEventListener(eventType, listenerFunction, this);
+ +

检测侦听器

+
eventDispatcher.hasEventListener(eventType);
+ +

优先级

public addEventListener(type:string, listener:Function, thisObject:any, useCapture:boolean = false, priority:number = 0)
+ +
+

该属性为一个number类型,当数字越大,则优先级越大。在触发事件的时候优先级越高。

+
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/holiday-soon-finally/index.html b/2017/holiday-soon-finally/index.html new file mode 100644 index 000000000..da3defbda --- /dev/null +++ b/2017/holiday-soon-finally/index.html @@ -0,0 +1,447 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +终于要放假了 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 终于要放假了 +

+ + +
+ + + + +

最近事情有点多,导致好久没有更新过博客。过完后天终于要到国庆假期了,希望可以多点时间在家休息(睡觉)。经常加班到 10 点,周末也时常单休,连续下来还是挺累人的。

+

公司的饭菜开始吃腻了,每天都能找到不想吃的菜(或者找不到想吃的菜)。

+

假期一定要抽空把这几个月学到的东西总结一下。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/jsx-in-vuejs/index.html b/2017/jsx-in-vuejs/index.html new file mode 100644 index 000000000..79bc92987 --- /dev/null +++ b/2017/jsx-in-vuejs/index.html @@ -0,0 +1,470 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +JSX in Vue.js | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ JSX in Vue.js +

+ + +
+ + + + +

在基于 Webpack 的 Vue 项目中添加 JSX 支持:

+
$ yarn add babel-plugin-syntax-jsx babel-plugin-transform-vue-jsx babel-helper-vue-jsx-merge-props --dev
+ +

各依赖的作用:

+
    +
  • babel-plugin-syntax-jsx 提供基础的 JSX 语法转换
  • +
  • babel-plugin-transform-vue-jsx 提供基于 Vue 的 JSX 特殊语法
  • +
  • babel-helper-vue-jsx-merge-props 是可选的,提供对类似 <comp {...props}/> 写法的支持
  • +
+

然后在 .babelrc 中,增加:

+
{
...
"plugins": [
"transform-vue-jsx",
...
]
...
}
+ +

注意如果有其它 env 也要如此加上 transform-vue-jsx 插件。

+ + +

Difference from React JSX

render (h) {
return (
<div
id="foo"
domPropsInnerHTML="bar"
onClick={this.clickHandler}
nativeOnClick={this.nativeClickHandler}
class={{ foo: true, bar: false }}
style={{ color: 'red', fontSize: '14px' }}
key="key"
ref="ref"
refInFor
slot="slot">
</div>
)
}
+ +

需要注意的是,事件绑定中,还有另外一个跟 react 不一样的地方:onMouseEnter 是不起作用的,只能写 onMouseenter 或者 on-mouseenter,以此类推。

+

Vue directives

除了 v-show 以外,所有的内置指令都不能在 JSX 中工作。

+

自定义指令可以使用 v-name={value} 的写法,但是这样会缺少修饰符以及参数。如果需要完整的指令功能,可以这么做:

+
const directives = [
{ name: 'my-dir', value: 123, modifiers: { abc: true } }
]

return <div {...{ directives }}/>
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/koa-note/index.html b/2017/koa-note/index.html new file mode 100644 index 000000000..9b5db066e --- /dev/null +++ b/2017/koa-note/index.html @@ -0,0 +1,495 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Koa Note | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Koa Note +

+ + +
+ + + + +
+

Koa是一个类似于 Express 的 Web 开发框架,创始人也是同一个人。它的主要特点是,使用了 ES6 的 Generator 函数,进行了架构的重新设计。也就是说,Koa的原理和内部结构很像 Express,但是语法和内部结构进行了升级。

+

—— 阮一峰博客

+
+

想要达到使用 Koa2 的完整体验,需要将 Node 版本升级到 v7.6+ 以支持 async 语法。

+

为什么是 Koa 而不是 Express 4.0?

+

因为 Generator 带来的改动太大了,相当于推倒重来。

+

以下内容基于 Koa2

+ + +

应用

一个 Koa Application(以下简称 app)由一系列 generator 中间件组成。按照编码顺序在栈内依次执行,从这个角度来看,Koa app 和其他中间件系统(比如 Ruby Rack 或者 Connect / Express )没有什么太大差别。

+

简单的 Hello World 应用程序:

+
const Koa = require('koa');
const app = new Koa();

// response
app.use(ctx => {
ctx.body = 'Hello Koa';
});

app.listen(3000);
+ +

级联代码

Koa 中间件以一种非常传统的方式级联起来。

+

在以往的 Node 开发中,频繁使用回调不太便于展示复杂的代码逻辑,在 Koa 中,我们可以写出真正具有表现力的中间件。与 Connect 实现中间件的方法相对比,Koa 的做法不是简单的将控制权依次移交给一个又一个的中间件直到程序结束,而有点像“穿越一只洋葱”。

+

图示 Koa 中间件级联

+

下边这个例子展现了使用这一特殊方法书写的 Hello World 范例。

+
const Koa = require('koa');
const app = new Koa();

// x-response-time
app.use(async function (ctx, next) {
// (1) 进入路由
const start = new Date();
await next();
// (5) 再次进入 x-response-time 中间件,记录2次通过此中间件「穿越」的时间
const ms = new Date() - start;
// (6) 返回 this.body
ctx.set('X-Response-Time', `${ms}ms`);
});

// logger
app.use(async function (ctx, next) {
// (2) 进入 logger 中间件
const start = new Date();
await next();
// (4) 再次进入 logger 中间件,记录2次通过此中间件「穿越」的时间
const ms = new Date() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}`);
});

// response
app.use(ctx => {
// (3) 进入 response 中间件
ctx.body = 'Hello World';
});

app.listen(3000);
+ +

也许刚从 Express 过来的同学会一脸懵逼,实际上我们可以把它想象成这样的一个流程(类似 LESS 代码):

+
.x-response-time {
// (1) do some stuff
.logger {
// (2) do some other stuff
.response {
// (3) NO next yield !
// this.body = 'hello world'
}
// (4) do some other stuff later
}
// (5) do some stuff lastest and return
}
+ +

这便是 Koa 中间件的一大特色了。另一点也能在例子中找到:即 Koa 支持 async 以及 await 语法,可以在中间件中进行任意方式的使用(比如 await mongoose 操作),这样对比起来 Express 其优点就十分明显了。

+

常用的中间件

+

(等等)

+

错误处理

除非 app.silent 被设置为 true,否则所有 error 都会被输出到 stderr,并且默认的 error handler 不会输出 err.status === 404 || err.expose === true 的错误。可以自定义「错误事件」来监听 Koa app 中发生的错误,比如一个简单的例子:记录错误日志

+
app.on('error', err =>
log.error('server error', err)
);
+ +

应用上下文

Koa 的上下文(Context)将 request 与 response 对象封装至一个对象中,并提供了一些帮助开发者编写业务逻辑的方法。

+

每个 request 会创建一个 Context,并且向中间件中传引用值。

+
app.use(async (ctx, next) => {
ctx; // is the Context
ctx.request; // is a koa Request
ctx.response; // is a koa Response
});
+ +

需要注意的是,挂载在 Context 对象上的并不是 Node.js 原生的 Response 和 Request 对象,而是经过 Koa 封装过的。Koa 提供另外的方法来访问原生对象,但是并不建议这么做!

+

为了使用方便,许多上下文属性和方法都被委托代理到他们的 ctx.requestctx.response,比如访问 ctx.typectx.length 将被代理到 response 对象,ctx.pathctx.method 将被代理到 request 对象。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/limit-prerender-plugin-workers-by-webpack/index.html b/2017/limit-prerender-plugin-workers-by-webpack/index.html new file mode 100644 index 000000000..f085b9ff9 --- /dev/null +++ b/2017/limit-prerender-plugin-workers-by-webpack/index.html @@ -0,0 +1,466 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Limit Prerender Plugin Workers By Webpack | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Limit Prerender Plugin Workers By Webpack +

+ + +
+ + + + +

Prerender SPA Plugin 是一个可以将 Vue 页面预渲染为静态 HTML 的 webpack 插件,对静态小站(比如博客)来说很棒棒。但是最近用的时候总发现一个问题:它的 build 失败率越来越高,尤其是在 CI 上。后来在其 repo 的一个 issue 中发现了问题所在,就是它没有限制 PhantomJS workers 的数量,导致页面一多就直接全部卡死不动,然后超时。

+
+

(Workers) Default is as many workers as routes.

+
+

虽然有人已经发了 PR 来修复这个问题,然而好几个月过去了也没有 merge,不知道是什么情况。于是我在自己的尝试中找到了一种可以接受的解决方案:虽然我不能限制你插件 workers 的数量,但是可以限制每个插件渲染的 route 数量呀。

+ + +

具体思路就是:

+
    +
  1. 将所有的 route chunk 成小组,比如 10 个一组
  2. +
  3. 针对每一个 chunk 创建一个 prerender 插件
  4. +
  5. 将所有插件都加入到 webpack plugin 中去
  6. +
+

这样一来,就可以保证每个 plugin 最多同时创建 10 个 worker,全部渲染完成后再由下一个 plugin 接着工作。

+

简单的代码示例:

+
// Generate url list for pre-render
exports.generateRenderPlugins = () => {
let paths = [] // the routes
let chunks = _.chunk(paths, 10) // using lodash.chunk
let plugins = []
let distPath = path.join(__dirname, '../dist')
let progress = 0
chunks.forEach(chunk => {
plugins.push(new PrerenderSpaPlugin(distPath, chunk, {
postProcessHtml (context) {
// need to log something after each route finish
// or CI will fail if no log for 10 mins
console.log(`[PRE-RENDER] (${++progress} / ${paths.length}) ${context.route}`)
return context.html
}
}
))
})
return plugins
}
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/meanjs-5-x-ng-repeat-flashing/index.html b/2017/meanjs-5-x-ng-repeat-flashing/index.html new file mode 100644 index 000000000..60f728572 --- /dev/null +++ b/2017/meanjs-5-x-ng-repeat-flashing/index.html @@ -0,0 +1,460 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +MEAN.JS 在 0.5 版本下发现的 NG-REPEAT 闪动问题 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ MEAN.JS 在 0.5 版本下发现的 NG-REPEAT 闪动问题 +

+ + +
+ + + + +

如题,经过长期痛苦的观察以及 debug 过程,以下原因被一一排除:

+
    +
  • 浏览器差异问题
  • +
  • 数据更新问题
  • +
  • ng-repeat 没有添加 track by key 导致的性能问题
  • +
  • Angular 版本问题
  • +
  • MEAN.js 架构问题
  • +
+

实际原因却是因为 MEAN.js 在全局引入了 ngAnimate 依赖。(也算是一个架构问题?)

+

因此解决办法:

+
    +
  • 要么将全局依赖去掉,改为各自添加依赖
  • +
  • 要么使用 transition: none !important
  • +
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/react-note-basic/index.html b/2017/react-note-basic/index.html new file mode 100644 index 000000000..1e88a4fe8 --- /dev/null +++ b/2017/react-note-basic/index.html @@ -0,0 +1,564 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +React Note - Basic | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ React Note - Basic +

+ + +
+ + + + +

React 学习笔记(基础篇)。

+ + +

安装

npm install -g create-react-app
create-react-app hello-world
cd hello-world
npm start
+ +

实践:create 这一步会同时执行 npm install 因此有失败的可能,多尝试几次就成功了。

+

这个程序跟 vue-loader 很像,会造出一个简单的手脚架,包含了 Babel 编译器以及打包工具等等。但是细看它的 package.json 文件并没有包含上述内容:

+
"devDependencies": {
"react-scripts": "0.8.5"
},
"dependencies": {
"react": "^15.4.2",
"react-dom": "^15.4.2"
}
+ +

因此,跟 vue-loader 不一样的是,react 这个手脚架把无关内容都封装了。这么做我觉得有利有弊:它让人用起来更方便,然而不可能达到直接使用原组件的自由度了。相比之下,这里我更喜欢 vue-loader 的处理方式。

+

Hello World

最简示例:

+
ReactDOM.render(
<h1>Hello, world!</h1>,
document.getElementById('root')
);
+ +

JSX 语法

JSX 是 JavaScript 的一种语法扩展,实际上可以看做是语法糖。通过编译器,以下语法:

+
const element = (
<h1 className="greeting">
Hello, world!
</h1>
);
+ +

相当于:

+
const element = {
type: 'h1',
props: {
className: 'greeting',
children: 'Hello, world'
}
};
+ +

后者就是编译后的结果,JSX 语法块变成了一个对象(称之为 React element)。

+

(JB 家的 IDE 已经对 JSX 语法提供了默认支持,不然这篇笔记就到此为止了)

+

JSX 支持一些稍微高级的用法,如:

+
function formatName(user) {
return user.firstName + ' ' + user.lastName;
}

const user = {
firstName: 'Harper',
lastName: 'Perez'
};

const element = (
<h1>
Hello, {formatName(user)}!
</h1>
);

ReactDOM.render(
element,
document.getElementById('root')
);
+ +

在任何地方使用 JSX:

+
function getGreeting(user) {
if (user) {
return <h1>Hello, {formatName(user)}!</h1>;
}
return <h1>Hello, Stranger.</h1>;
}
+ +

元素

上面有说到 React element(元素),元素的概念与组件不同:元素是组件的组成部分。

+

元素渲染

const element = <h1>Hello, world</h1>;
ReactDOM.render(
element,
document.getElementById('root')
);
+ +

显然,它掌控了 DOM 中一个 ID 为 root 的节点,并往里面插入了元素。

+

元素更新

已创建的元素是无法更新属性的。因此,如果要改变它,只能够重新创建并渲染一次。

+

然而,托虚拟 DOM 的福,重新渲染并不代表重新渲染整个 DOM,React 会查找并只更新有改变的节点。

+

但是一般不回这么做。因为有一点很重要:在设计一个元素的时候就要考虑到它在所有状态下的表现。这个其实在其它框架下也是一样的。

+

组件

React 是组件化框架,因此组件是组成一个应用的基础。组件的特点:独立、可重用。

+

组件有两种定义方法:

+
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}
+ +

或者:

+
class Welcome extends React.Component {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
+ +

组件渲染

一个简单的例子:

+
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}

const element = <Welcome name="Sara" />;

ReactDOM.render(
element,
document.getElementById('root')
);
+ +

可以看到元素组成了组件,组件又组成了元素,最后渲染在 DOM 上的是元素。

+

这个跟 Vue 很像了,区别是 Vue 没有区分所谓的“元素”跟“组件”,通通都是组件。

+

需要注意的是,在 React 世界中有个约定:自定义控件以大写字母打头。这是为了跟 HTML 元素有所区分。

+

组件使用与拆解

一个简单的例子:

+
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}

function App() {
return (
<div>
<Welcome name="Sara" />
<Welcome name="Cahal" />
<Welcome name="Edite" />
</div>
);
}

ReactDOM.render(
<App />,
document.getElementById('root')
);
+ +

需要注意的是,组件只能有一个根节点。(如例子中的 3 个 Welcome 必须包裹在 div 中)

+

参数只读

简单地说,React 不允许在控件内修改参数(包括值的修改以及对象修改)。允许修改的称之为“状态”(约等于 Vue 中的 component data)

+

状态管理与生命周期

添加状态管理

组件的更新依赖于状态,因此需要实时更新的组件应在其内部建立状态管理机制(低耦合高内聚)。

+

需要状态管理机的组件,必须使用 ES6 方式声明,如:

+
class Clock extends React.Component {
constructor(props) {
super(props);
this.state = {date: new Date()};
}

render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}

ReactDOM.render(
<Clock />,
document.getElementById('root')
);
+ +

但是,此时,组件是无法更新的:因为状态在创建时就已经被决定了。

+

添加生命周期

代码有注释:

+
class Clock extends React.Component {
constructor(props) {
super(props);
this.state = {date: new Date()};
}

// 组件渲染到 DOM 后调用
componentDidMount() {
this.timerID = setInterval(
() => this.tick(),
1000
);
}

// 组件将销毁后调用
componentWillUnmount() {
clearInterval(this.timerID);
}

tick() {
this.setState({
date: new Date()
});
}

render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}

ReactDOM.render(
<Clock />,
document.getElementById('root')
);
+ +

整个流程很简单清晰了:

+
    +
  1. ReactDOM 渲染 Clock,并对 state 做第一次初始化
  2. +
  3. render 方法被调用,插入 DOM
  4. +
  5. componentDidMount 方法被调用,计时器启动,tick 每秒钟执行一次
  6. +
  7. 每次 tick 执行都调用 setState 方法去更新状态,这样 React 就知道需要更新 DOM 了
  8. +
  9. 当组件被从 DOM 移除后,componentWillUnmount 执行
  10. +
+

正确使用状态

直接更改 state 属性是不会触发 UI 更新的。因此,有一些规则需要遵守。

+

不直接修改状态

在组件内进行修改状态操作,使用 setState 方法:

+
// Wrong
this.state.comment = 'Hello';

// Correct
this.setState({comment: 'Hello'});
+ +

关于异步更新

// Wrong
this.setState({
counter: this.state.counter + this.props.increment,
});

// Correct
this.setState((prevState, props) => ({
counter: prevState.counter + props.increment
}));
+ +

状态合并

在进行 setState 的时候,只关心需要更改的属性即可,没有传入的属性会被保留。就好像新的状态被“合并”进入旧状态一样。

+

数据流

在 React 世界,组件与组件之间的状态传递是单向的,传值的方式就是将 state 当做 prop 传给子组件。

+

事件处理

跟 DOM 操作很像,区别:

+
    +
  1. 事件命名使用驼峰式
  2. +
  3. 直接向 JSX 中传入方法
  4. +
  5. 不支持 return false 操作
  6. +
+

例:

+
// DOM
<button onclick="activateLasers()">
Activate Lasers
</button>

// React
<button onClick={activateLasers}>
Activate Lasers
</button>

// A prevent default sample
function ActionLink() {
function handleClick(e) {
e.preventDefault();
console.log('The link was clicked.');
}

return (
<a href="#" onClick={handleClick}>
Click me
</a>
);
}

// A class sample
class Toggle extends React.Component {
constructor(props) {
super(props);
this.state = {isToggleOn: true};

// This binding is necessary to make `this` work in the callback
this.handleClick = this.handleClick.bind(this);
}

handleClick() {
this.setState(prevState => ({
isToggleOn: !prevState.isToggleOn
}));
}

render() {
return (
<button onClick={this.handleClick}>
{this.state.isToggleOn ? 'ON' : 'OFF'}
</button>
);
}
}
+ +

注意:这个 e 是 React 封装过的,但遵循 W3C 标准,因此无需做浏览器差异化处理。

+

另外,this.handleClick.bind 方法是为了保证在 onClick 中调用了正确的 this,但使用箭头函数可以避免这个累赘的方法:

+
class LoggingButton extends React.Component {
// This syntax ensures `this` is bound within handleClick.
// Warning: this is *experimental* syntax.
handleClick = () => {
console.log('this is:', this);
}

render() {
return (
<button onClick={this.handleClick}>
Click me
</button>
);
}
}
+ +

条件渲染

例子:

+
render() {
const isLoggedIn = this.state.isLoggedIn;

let button = null;
if (isLoggedIn) {
button = <LogoutButton onClick={this.handleLogoutClick} />;
} else {
button = <LoginButton onClick={this.handleLoginClick} />;
}

return (
<div>
<Greeting isLoggedIn={isLoggedIn} />
{button}
</div>
);
}
+ +

行内判断

例子:

+
function Mailbox(props) {
const unreadMessages = props.unreadMessages;
return (
<div>
<h1>Hello!</h1>
{unreadMessages.length > 0 &&
<h2>
You have {unreadMessages.length} unread messages.
</h2>
}
</div>
);
}

const messages = ['React', 'Re: React', 'Re:Re: React'];
ReactDOM.render(
<Mailbox unreadMessages={messages} />,
document.getElementById('root')
);
+ +

这段代码的工作方式跟 JavaScript 一致:

+
    +
  • true && expression -> expression
  • +
  • false && expression -> false
  • +
+

因此,当 unreadMessages.length > 0 为真时,后面的 JSX 会被渲染,反则不会。

+

除此以外还有三元表达式:

+
render() {
const isLoggedIn = this.state.isLoggedIn;
return (
<div>
The user is <b>{isLoggedIn ? 'currently' : 'not'}</b> logged in.
</div>
);
}
+ +

阻止渲染

在组件的 render 方法内 return null 会阻止组件的渲染,但是其生命周期不受影响。

+

循环

一个简单的例子:

+
const numbers = [1, 2, 3, 4, 5];
const listItems = numbers.map((number) =>
<li>{number}</li>
);

ReactDOM.render(
<ul>{listItems}</ul>,
document.getElementById('root')
);
+ +

循环组件

一个列表组件示例:

+
function NumberList(props) {
const numbers = props.numbers;
const listItems = numbers.map((number) =>
<li key={number.toString()}>
{number}
</li>
);
return (
<ul>{listItems}</ul>
);
}

const numbers = [1, 2, 3, 4, 5];
ReactDOM.render(
<NumberList numbers={numbers} />,
document.getElementById('root')
);
+ +

注意,这里对列表项添加了一个 key 属性。

+

Key

Key 是 React 用来追踪列表项的一个属性。跟 angular 以及 vue 中 track-by 的概念一样。

+

如果列表项没有唯一标识,也可以用索引作为 key (不推荐):

+
const todoItems = todos.map((todo, index) =>
// Only do this if items have no stable IDs
<li key={index}>
{todo.text}
</li>
);
+ +

注意:Key 只能直接在数组循环体内定义。如:

+
function ListItem(props) {
const value = props.value;
return (
// Wrong! There is no need to specify the key here:
<li key={value.toString()}>
{value}
</li>
);
}

function NumberList(props) {
const numbers = props.numbers;
const listItems = numbers.map((number) =>
// Wrong! The key should have been specified here:
<ListItem value={number} />
);
return (
<ul>
{listItems}
</ul>
);
}
+ + +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/serve-static-with-pm2/index.html b/2017/serve-static-with-pm2/index.html new file mode 100644 index 000000000..70f4c54be --- /dev/null +++ b/2017/serve-static-with-pm2/index.html @@ -0,0 +1,453 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +用 PM2 代理静态文件 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 用 PM2 代理静态文件 +

+ + +
+ + + + +

命令 (2.4.0+):

+
$ pm2 serve <path> <port>
+ +

举例:

+
$ pm2 serve /dist 80
+ +

默认情况下,如果页面未找到,它将显示 404.html 目录中的文件 (无法配置)。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/travis-ci-in-github/index.html b/2017/travis-ci-in-github/index.html new file mode 100644 index 000000000..1c22ad7c6 --- /dev/null +++ b/2017/travis-ci-in-github/index.html @@ -0,0 +1,547 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Travis CI in GitHub | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Travis CI in GitHub +

+ + +
+ + + + +

Travis CI 是一款免费的持续集成工具,可以与 Github 无缝集成。能够自动完成项目代码的日常测试、编译、部署等工作。现在,我把它应用到了我的两个项目中。

+

首先,要在这个平台上做持续集成的前提是到它上面 https://travis-ci.org/ 去注册个账号。实际上直接用 Github 账号进行 OAuth 登录就行了。登录以后可以在首页找到自己的所有仓库,在需要进行持续集成的项目前面的开关打开即可。开启后,Travis CI 会监听项目的代码推送与 PR,当发生改变时会立刻进行相应操作。

+

至于具体操作内容,由项目根目录的 .travis.yml 文件决定。这个文件的简单用法由下面两个具体例子来说明。

+ + +

wxsms.github.io

该项目就是这个博客了。因为它是静态博客,所以代码上线前都要进行一次打包过程,在之前这个工作是手动完成的,主要的流程如下:

+
    +
  1. src 分支上进行代码编辑,
  2. +
  3. src 分支上 push
  4. +
  5. src 分支上运行 npm run postnpm run build 分别生成文章与博客代码
  6. +
  7. 切换到 master 分支,将上一步打包编译出来的东西覆盖到相应目录下
  8. +
  9. master 分支上 push
  10. +
  11. 切换回 src 分支
  12. +
+

这些步骤看似简单却又容易出错,每次想要刷博客都必须做这么多事情,烦不胜烦。而且,Github 仓库会因为充斥了无意义的 master 历史记录而变得臃肿与难看。

+

现在有了 Travis CI,一切都将变得简单。

+

.travis.yml 文件内容:

+
language: node_js
cache:
directories:
- node_modules
node_js:
- "node"
script:
- npm run post
- npm run build
- npm run dist-config
deploy:
- provider: pages
skip_cleanup: true
github_token: $GITHUB_TOKEN
local_dir: dist
target_branch: master
on:
branch: src
+ +

说明:

+
    +
  • language 指项目代码的语言,这里使用 node_js
  • +
  • cache 是 Travis CI 会缓存的内容,比如一些依赖文件无需每次都完全安装。这里缓存了 npm_modules 这个目录
  • +
  • node_js 这里指定 node 的版本,node 的意思是使用最新版
  • +
  • script 则是 Travis CI 具体会去完成的工作,是有顺序关系的,如果没有指定,则默认是 npm run test,这里依次执行了 3 个脚本:
      +
    • npm run post 打包文章
    • +
    • npm run build 打包代码
    • +
    • npm run dist-config 生成配置文件以及 Readme 等。前两步显而易见,至于第三步,因为 Travis 部署会是一个 force push 的过程,会删除原有分支上的所有内容,因此需要手动生成 Github 的 README.md 文件以及 Github Page 的 CNAME 文件。
    • +
    +
  • +
  • deploy 则是项目在所有脚本执行完成后会进行的部署操作,部署只会在脚本全部执行成功(返回 0)后进行
      +
    • 这里使用 page 即 Github Page 方式部署。
    • +
    • skip_cleanup 这个参数用来防止 Travis 删除脚本生成的文件(删掉了就没意义了)
    • +
    • github_token 是我们 Github 账号的 Access Token,因为私密原因不能写在代码文件里,因此可以在此写一个变量 $GITHUB_TOKEN,然后在 Travis 相应的仓库设置中添加 GITHUB_TOKEN 环境变量,Travis 会在运行时自动替换
    • +
    • local_dir 是指需要部署的打包出来的目录,设置为 dist 目录
    • +
    • target_branch 即目标分支,Travis 会将为 dist 目录整个部署到 master 分支上去
    • +
    • on 则是附加条件。这里的含义应该是只监听 src 分支上的更改
    • +
    +
  • +
+

因此,Travis 可以帮我完成以下工作

+
    +
  • 监听 src 分支上的改动
  • +
  • 出现改动时,自动执行所有 build 步骤
  • +
  • 如果 build 成功则将相应文件部署到 master 分支上去
  • +
+

如此一来,我自己需要做的事情就只剩下简单的两步了:

+
    +
  1. src 分支上进行代码编辑
  2. +
  3. src 分支上 push
  4. +
+

在我无需关注发布过程的同时,Travis 还能帮我保持整个代码仓库的整洁(master 分支始终进行的都是 force push,不存在无用的历史记录),简直完美!

+

uiv

这个项目其实也差不多,有些许变化:

+
    +
  • 脚本变为:
      +
    • npm run test 执行测试
    • +
    • npm run build 打包代码
    • +
    • npm run build-docs 打包文档
    • +
    +
  • +
  • 需要将代码部署到 npm,而文档部署到 Github Page
  • +
  • 代码与文档都只在版本发布时(Tagged)才进行部署
  • +
+

.travis.yml 文件内容:

+
language: node_js
cache:
directories:
- node_modules
node_js:
- "node"
script:
- npm run test
- npm run build
- npm run build-docs
after_success: 'npm run coveralls'

deploy:
- provider: npm
skip_cleanup: true
email: "address@email.com"
api_key: $NPM_TOKEN
on:
tags: true
branch: master
- provider: pages
skip_cleanup: true
github_token: $GITHUB_TOKEN
local_dir: docs
on:
tags: true
branch: master
+ +

这个配置文件多了一些内容:

+
    +
  • after_success: 'npm run coveralls' 这个是在所有脚本成功以后执行的,目的是与 Coveralls 集成来在项目仓库上添加测试覆盖率的集成,这个在后面说
  • +
  • deploy 中增加了 npm 一项,配置内容跟 pages 基本一致,其中不同的:
      +
    • email 是用来发布的 npm 账户邮箱名
    • +
    • api_key 是用来发布的 npm 账户 token,可以在本地 ~/.npmrc 文件中找到(前提是本地电脑的 npm 已登录)
    • +
    • on -> tags: true 这个标志是说只在带有标签的 Commit 推送时才进行 deploy
    • +
    +
  • +
  • Github Page 的部署配置中也加入了 on -> tags: true,起的是一样的作用。这里的 Github Page 是从 master 分支的 docs 文件夹 deploy 到 gh-pages 分支(gh-pages 是 Github Page 的默认分支,所以不用配置 target_branch 项)
  • +
+

这样一来,Travis 就可以:

+
    +
  • 在日常 push 的时候执行 test and build 脚本,但不发布
  • +
  • 在版本 push 的时候执行 test and build 脚本,全部成功则将内容分别发布到 NPM 与 Github Pages
  • +
+

完美!

+

关于 Coveralls

Coveralls https://coveralls.io/ 是一个将代码测试覆盖率集成到 Github 的工具,在 Travis 的加持下,算是锦上添花的一项。同样,到相应网站注册账号是第一步。

+

由于 vue-cli 生成的项目默认已经附带了代码测试覆盖率的检测,我要做的只是把这个结果上传而已。

+

步骤:

+
    +
  1. npm install coveralls --save-dev
  2. +
  3. "coveralls": "cat test/unit/coverage/lcov.info | ./node_modules/.bin/coveralls" 添加到 npm scripts 中。注意:cat 的路径是随项目不同而改变的
  4. +
  5. .travis.yml 中添加 after_success: 'npm run coveralls' 配置项
  6. +
+

它可以:

+
    +
  1. 在测试完成后生成覆盖率文件(这一步 vue-cli 已经做了)
  2. +
  3. 将文件内容传给 coveralls,这个模块可以将结果从 Travis 上传到 Coveralls 平台
  4. +
  5. Github 上会 by commit 地显示测试率是增加还是降低了
  6. +
+

总结

持续集成的好处无需多言,反正 Travis 就是一个免费的、能与 Github 集成的持续集成工具(实际上其它开源平台也可以,以及可以付费为私有项目提供服务)。简单、易用。

+

这些配置看似简单,却花费了我大量时间去摸索。由于只能通过不断推送 commit 的方式来触发 build 并验证配置的正确性,其过程异常繁琐,但是现在看来是十分值得的!

+

BTW:测试用的 commit 事后可以用本地 reset 与 force push 干掉。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/uiv-release/index.html b/2017/uiv-release/index.html new file mode 100644 index 000000000..ad6f48016 --- /dev/null +++ b/2017/uiv-release/index.html @@ -0,0 +1,501 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +基于 Vue 2 与 Bootstrap 3 的组件库 uiv 发布啦 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 基于 Vue 2 与 Bootstrap 3 的组件库 uiv 发布啦 +

+ + +
+ + + + +

一点微小的工作。

+

Demo: https://uiv.wxsm.space

+

Github: https://github.com/wxsms/uiv

+

NPM: https://www.npmjs.com/package/uiv

+

项目使用 MIT 许可,随便用。

+ + +

简单介绍

做这个东西的初衷是,想要一些简单的、基础的、常用的基于 Vue 2 与 Bootstrap 3 的可重用组件。因为我还有一个目标:一个灵活健壮的、类似 MEAN.js 这样的 Vue + Node.js + MongoDB 的 Seed 项目。没有一个简单的组件库,项目无法进行。

+

其实现在社区有很多开源作品了,但是简单来说,就是觉得不是很满意,怎么说呢:

+
    +
  • VueStrap 这个作品虽然出现的比较早也比较全,然而貌似止步在 Vue 1 了,更新比较慢,不客气地说,里面很多组件其实是不好用的。只要稍稍对比下 Angular UI Bootstrap 就能发现差距,有些东西从设计上就有问题。
  • +
  • Bootstrap-Vue 这个作品是基于 Bootstrap 4 的,不知道为什么,就是不太喜欢。
  • +
  • Material Design 的作品有两三个,但实际使用上,感觉还是 Bootstrap 的应用场景更多,也更轻量。
  • +
  • 至于 ElementUI,做得非常好非常全,然而是自立门户做的,跟 Bootstrap 与 Material 都没有关联。
  • +
+

我想要的是:

+
    +
  • 能够完全使用到 Bootstrap CSS
  • +
  • 很多方面只要像 Angular UI Bootstrap 靠齐就行,毕竟经过了 Angular 1 时代的考验,事实证明它是最好用的
  • +
  • 最小的体积
  • +
  • 纯净的依赖,没有除了 Vue 与 Bootstrap CSS 以外的东西
  • +
  • 主流浏览器支持
  • +
+

好吧,说白了就是想自己做。跟前辈们做的东西好与不好无关。反正开源作品,人畜无害。

+

做着做着,于是就有了这个东西。感谢静纯的参与,帮我完成了一部分工作。

+

项目现状

目前已完成的组件有:

+
    +
  • Alert (警告)
  • +
  • Carousel (轮播)
  • +
  • Collapse (收缩与展开)
  • +
  • Date Picker (日期选择)
  • +
  • Dropdown (下拉)
  • +
  • Modal (模态框)
  • +
  • Pagination (分页)
  • +
  • Popover (弹出框)
  • +
  • Tabs (标签页)
  • +
  • Time Picker (时间选择)
  • +
  • Tooltip (提示)
  • +
  • Typeahead (自动补全)
  • +
+

共 12 个。

+

依赖只有 Vue 2 与 Bootstrap 3,最终打包压缩 + Gzip 后体积约 9 KB,应该算是比较轻比较小的啦。

+

所有组件在主流浏览器(Chrome / Firefox / Safari)与 IE 9 / 10 / 11 下都经过了测试,暂时没有发现问题。当然,由于 IE 9 不支持 Transition 属性,因此是没有动画效果的,不过功能正常,不影响使用流程。

+

当然,除了以上的浏览器环境测试以外,还进行了完善的单元测试,组件代码测试覆盖率达到 99%(Github 与项目主页上的测试率标签显示为 97%,因为其中包括了文档源码,与实际组件无关)。可以保证在大多数情况下正常工作。

+

Road Map

接下来要做的事:

+
    +
  • 把自动化的 E2E 测试搞起来,目前项目使用的自动测试只有单元测试,无法自动测试不同的浏览器,这个很重要,保证项目在跨浏览器上的质量
  • +
  • 收集一些意见与反馈,完善一下现有的东西
  • +
  • 将 Date Picker 与 Time Picker 组合
  • +
  • Multi Select (多选组件)
  • +
  • 等等等等……
  • +
+

有问题请提 issue,一定尽快解决。同时也欢迎 PR

+

最后,欢迎使用。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/upgrade-projects-scaffolded-by-vue-cli/index.html b/2017/upgrade-projects-scaffolded-by-vue-cli/index.html new file mode 100644 index 000000000..c8fcd585b --- /dev/null +++ b/2017/upgrade-projects-scaffolded-by-vue-cli/index.html @@ -0,0 +1,484 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Upgrade Projects Built by vue-cli | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Upgrade Projects Built by vue-cli +

+ + +
+ + + + +

使用 vue-cli 创建的脚手架项目,目前最大的问题是创建后无法自动地进行升级。虽然 3.0 版本已经计划将其作为头等大事来进行改善 (#589),但是现行的版本依然要面对它。以下基于 webpack template 来进行升级时的一些要点解析。

+ + +

依赖

项目整体升级的一个重要目的体现在依赖的升级,如 webpack 从老版本 2 升级到 3,以及 babel / eslint 等各种配套工具的升级(至于 Vue 反倒不是什么大问题)。

+

在对依赖进行升级的时候主要有两个参考:

+ +

outed version 如果是 MINOR / PATCH 更新,直接 upgrade 即可。如果是 MAJOR 更新则需要到相应项目主页上确认一下 breaking changes 是否对自己有影响。

+

以下列举一些主要的依赖。

+

Webpack

Webpack 2 -> 3 其实是无痛升级的。也就是说基本不用更改什么配置。

+

ESLint

ESlint 及其相关库的升级也没什么需要特别注意的地方,因为它并不参与最终构建。只不过升级以后可能会有 lint failed cases (因为新版本一般会添加新的 rules),注意修复即可。

+

Babel

Babel 相关的升级是最麻烦(也是最头疼)的一部分。其主要问题体现在:

+
    +
  • 其直接参与代码构建,影响巨大,需要特别谨慎
  • +
  • Babel 作为一个重要工具有一定的学习成本
  • +
  • Babel 相关库变更较为频繁,典型的如 babel-preset-latest 库废弃并被 babel-preset-env 替代,而后者在最新的版本中又变成了 @babel/preset-env,甚至 babel-core 也废弃了,变成了 @babel/core
  • +
+

在经过了几次的迁移尝试后,建议目前的方案是:

+
    +
  • 进行 MINOR 升级,如果还在使用 babel-preset-latest 可以将其替换为 babel-preset-env(注意两者的配置大致一样,但略有不同,需要仔细比对)
  • +
  • 暂时不要将 babel 升级至 7.x-beta
  • +
  • 暂时也不要使用 @babel 类型的依赖(实测中出现奇怪的报错,难以追踪、搜索)
  • +
  • 等待 Vue.js 社区给出解决方案
  • +
+

AutoPrefixer

autoprefixer 从 6.x 升级到 7.x 时,注意将 package.json 中的 browserlist 改成 browserslist (一个 s 的区别)

+

配置文件

这里说的配置文件主要有两方面:Babel 以及 Webpack

+

Babel

最简单的操作是,直接到 vuejs-templates/webpack 找到最新的 babel 文件,复制更新的内容下来即可。当然要注意自己已经更改过的内容不要被覆盖。

+

Webpack

Webpack 配置稍微麻烦一些,主要体现在 webpack.base.conf.js 以及 webpack.prod.conf.js,个人总结的升级步骤:

+
    +
  1. 先升级 Webpack 相关工具到最新版本
  2. +
  3. 打开官方项目,对文件进行比对并更新相应内容(一般 webpack.prod.conf.js 会有较多内容更新,而且主要是 plugins 配置项)
  4. +
  5. 如果遇到目前没有安装的依赖则安装之
  6. +
+

当然这只适用于 Webpack 2 -> 3 的升级,至于 1 -> 2 或者 1-> 3 没试过,不好说。

+

做完以上操作,跑过所有 npm scripts 一切正常的话,项目脚手架升级就基本完成了。这个过程说难不难,但是如果对 Webpack / Babel 不熟悉的话还是挺痛苦的,期待 vue-cli 3.0 可以带来更优秀的脚手架解决方案,达到类似 Nuxt.js 的效果,彻底解决升级烦恼。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/vue-components-i18n/index.html b/2017/vue-components-i18n/index.html new file mode 100644 index 000000000..21bafd335 --- /dev/null +++ b/2017/vue-components-i18n/index.html @@ -0,0 +1,481 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +为 Vue 组件库实现国际化支持 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 为 Vue 组件库实现国际化支持 +

+ + +
+ + + + +

其实这部分代码主要是参考着 element ui 和 iview 做的(iview 又是抄的 element),对关键代码进行了一些简化。主要需要实现的需求有:

+
    +
  1. 用户可以更改、切换组件库使用的语言(应用级别)
  2. +
  3. 用户可以自定义组件使用的措辞
  4. +
  5. 兼容 vue-i18n 这个库
  6. +
+ + +

关键代码

src/locale/lang/en-US.js

首先是 Locale 文件,把措辞映射到一个 key 上面去,比如说英文:

+
export default {
uiv: {
datePicker: {
clear: 'Clear',
today: 'Today',
month: 'Month',
month1: 'January',
month2: 'February',
// ...
}
}
}
+ +

对应的中文文件只需要把相应的 Value 翻译成中文即可。这里有一个最基本的设想就是,如果需要增加一种语言,应该是只需要增加一个这样的文件即可

+

src/locale/index.js

import defaultLang from './lang/en-US'
let lang = defaultLang

let i18nHandler = function () {
const vuei18n = Object.getPrototypeOf(this).$t
if (typeof vuei18n === 'function') {
return vuei18n.apply(this, arguments)
}
}

export const t = function (path, options) {
let value = i18nHandler.apply(this, arguments)
if (value !== null && typeof value !== 'undefined') {
return value
}
const array = path.split('.')
let current = lang

for (let i = 0, j = array.length; i < j; i++) {
const property = array[i]
value = current[property]
if (i === j - 1) return value
if (!value) return ''
current = value
}
return ''
}

export const use = function (l) {
lang = l || lang
}

export const i18n = function (fn) {
i18nHandler = fn || i18nHandler
}

export default {use, t, i18n}
+ +

这段代码乍一看挺复杂,其实弄明白后就很简单:

+
    +
  1. i18nHandler 是用来检测并套用 vue-i18n 的,如果用户安装了这个插件,则会使用绑定在 Vue 实例上的 $t 方法进行取值
  2. +
  3. t 方法是用来取值的。首先看能否用 i18nHandler 取到,如果能取到则直接用,取不到就要自行解决了。最后返回取到(或者取不到,则为空)的值。
  4. +
  5. usei18n 这两个方法是在整个组件库作为插件被 Vue 安装的时候调用的,主要用来让用户自定义语言等等。
  6. +
+

原版的 t 方法有一个与之配合的模板字符串替换的方法(比如说处理 My name is ${0} 这种 Value),这里简洁起见把它删掉了,实际上也暂时用不到。

+

src/mixins/locale.js

一个 mixin,很简单:

+
import { t } from '../locale'

export default {
methods: {
t (...args) {
return t.apply(this, args)
}
}
}
+ +

就是给组件加上一个 t 方法。那么现在组件在需要根据语言切换的地方,只要加入这个 mixin 并在输出的地方使用 t(key) 即可,比如 t('uiv.datePicker.month1') 在默认的配置下会使用 January,而如果用户配置了中文则会使用 一月

+

src/components/index.js

最后一步:将上述的两个方法 usei18n 写入到组件库入口的 install 方法中去。

+
const install = (Vue, options = {}) => {
locale.use(options.locale)
locale.i18n(options.i18n)
// ...
}
+ +

如何使用

简单用法

切换中文:

+
import Vue from 'vue'
import uiv from 'uiv'
import locale from 'uiv/src/locale/lang/zh-CN'

Vue.use(uiv, { locale })
+ +

显然, 如果对预设的措辞不满意,我们还可以自定义, 只需要创造一个 locale 对象并替换之即可。

+

配合 Vue I18n 使用

只要跟着 vue-i18n 的文档把自己的 App 配好就行,不用管组件库,会自动适配。但有一点要注意:需要先将 组件库的语言包合并到 App 语言包中去。比如:

+
import uivLocale from 'uiv/src/locale/lang/zh-CN'

let appLocale = Object.assign({}, uivLocale, {
// ...
})

// 接下来该干嘛干嘛
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/vue-router-note/index.html b/2017/vue-router-note/index.html new file mode 100644 index 000000000..934230010 --- /dev/null +++ b/2017/vue-router-note/index.html @@ -0,0 +1,489 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Vue-Router Note | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Vue-Router Note +

+ + +
+ + + + +

Vue Router (https://github.com/vuejs/vue-router) 使用笔记。虽然官方文档比较详尽,但实际用起来依然有些地方需要特别注意的(其实主要是我的个人需求)。

+ + +

Scroll Behaviours

文档上有 scroll behaviours 的示例,但实际上用起来不太完美,还需要自己改造一下。需要注意的是 scrollBehavior 必须搭配 history 模式,否则代码无效且无任何错误信息。

+

上面说到不完美的地方主要是在模拟 ‘scroll to anchor’ 这一行为时,文档的代码是不够好的:

+
scrollBehavior (to, from, savedPosition) {
if (to.hash) {
return {
selector: to.hash
}
}
}
+ +

这里实际上会调用 querySelector(to.hash) 来实现滚动,但是用起来会发现有些时候这段会报错,因为类似 #1-anything 这样的数字(或者其他非字母字符)打头的 hash 作为 selector 是 Invalid 的。但是要修复只需要稍微改动一下就好了:

+
if (to.hash) {
return {
selector: `[id='${to.hash.slice(1)}']`
}
}
+ +

所以一段完善的 scroll behaviour 代码应该是:

+
scrollBehavior (to, from, savedPosition) {
if (to.hash) {
return {
selector: `[id='${to.hash.slice(1)}']`
}
} else if (savedPosition) {
return savedPosition
} else {
return {x: 0, y: 0}
}
}
+ +

它可以做到在路由变化时:

+
    +
  1. 有锚点时滚动到锚点
  2. +
  3. 有历史位置时滚动到历史位置
  4. +
  5. 都没有时滚动到页头
  6. +
+

Lazy Loading

官方的 Lazy load 示例代码换了很多茬,比如之前有类似 require('...', resolve) 的,还有用 System.import 的,但是它们并不能向后兼容,所以如果用的是新版本的话,并不能够直接 copy 旧项目的方式。目前感觉会稳定下来的方式是:

+
const Foo = () => import('./Foo.vue')
+ +

但是这里又有一个注意点,以上语法必须引入一个 babel 插件 syntax-dynamic-import 才行:

+
npm install --save-dev babel-plugin-syntax-dynamic-import
+ +

.babelrc

+
{
"plugins": ["syntax-dynamic-import"]
}
+ +

以上就可以在 webpack + babel 的环境下实现代码分块了。

+

Progress

经常会有这样的需求(尤其是使用 lazy load 时):路由跳转时提供一个进度条(像 Github 头部那种),然而 Vue Router 没有提供这方面的示例。经过实际使用发现,并不需要刻意使用 Vue 封装的进度条,比如说轻量级的 nprogress 也可以很好地搭配使用。

+

但是需要注意的是,Vue Router 会将 hash 跳转也视为一次 route 跳转,因此如果在全局钩子中注册 progress 方法的话,那么它也会在 hash 跳转中出现,实际上应该是不需要的。所以需要一点点判断:

+
import NProgress from 'nprogress'

// ...

router.beforeEach((to, from, next) => {
// not start progressbar on same path && not the same hash
// which means hash jumping inside a route
if (!(from.path === to.path && from.hash !== to.hash)) {
NProgress.start()
}
next()
})

router.afterEach((to, from) => {
// Finish progress
NProgress.done()
})
+ +

以上就是一个简单的页面跳转进度条示例,它会在除了同页 hash 跳转以外的所有页面跳转行为发生时,在页头显示一个简单的进度条。

+

Active Style

当使用 <router-link> 的时候,Vue Router 会自动给当前路由的 link 加一个 active class,用来做 nav menu 时非常方便。但是有一点需要注意的是,它默认并不是一个精确匹配的模式,而是一个 matchStart,比如说 <router-link to="/a"> 会被一个 /a/b 的路由激活,更甚者,<router-link to="/"> 会被所有路由激活(真的)。然而这一般来说都不会是想要的结果。

+

在老旧版本(0.x)的 Vue-Router 中这个问题是无解的,现在则可以使用 <router-link exact> 来将它转换为精确匹配

+

Route Reuse

当使用 <router-view> 时,默认会启用组件复用,也就是说在可能的情况下,作为路由页面的组件不会被销毁重建,而是直接复用。

+

就好像一个博客的文章页面,一般来说会是给出这样的路由配置:/post/:id,那么在从 /post/1 跳转到 /post/2 的时候,实际上路由组件是不会重建的。

+

有时候我们会想要避免这样的事情发生,因为一个路由可能在创建的时候有比较多的逻辑(如数据动态获取、判断等),如果它在路由变化的时候直接复用的话,那么 mount 方法将不再被调用,我们还要为 update 再写一套类似的逻辑。更过分的是,其所用到的所有子组件也不再会执行 mount 方法,那么我们要为所有子组件编写 update 方法。非常麻烦。

+

不知道为什么,老版本的文档是有为这种情况提供解决方案的,但是在现在的文档里面找不到了。实际上很简单:

+
<router-view :key="$route.path"></router-view>
+ +

就这样就可以了。如此一来,只要在 $route.path 变化的时候,路由组件就会被销毁重建。用一点点的性能损耗,节省大量冗余代码。

+

当然这里也可以使用定制化逻辑来控制,比如使用 computed value 来实现更复杂的复用逻辑。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/vue-transition-height/index.html b/2017/vue-transition-height/index.html new file mode 100644 index 000000000..8845ad4d0 --- /dev/null +++ b/2017/vue-transition-height/index.html @@ -0,0 +1,505 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +使用 Vue Transition 实现高度渐变动画 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 使用 Vue Transition 实现高度渐变动画 +

+ + +
+ + + + +

CSS Transition 中的高度从 0 到 auto 以及从 auto 到 0 是个艰难的任务(相比于其它属性的 transition 而言),原因也很简单:就是浏览器不支持此类 CSS 动画,无论在何种情况下,它都不会成功。

+

但是高度渐变是个很常用的动画效果,如果绕过纯 CSS height 属性,有如下方式来实现:

+
    +
  • 使用 max-height 属性,为元素设置一个不可能达到的最大高度,然后将 transition 转换为 max-height 从 0 到某个固定的值;
  • +
  • 使用 transform: scaleY 实现;
  • +
  • 使用 JavaScript 动画。
  • +
+

上面的解决方案都从某种程度上解决了问题,但是,各有各的限制于缺点:

+
    +
  • 使用 max-height 会造成动画效果与预期有些许出入(加速与延迟),实际体验是,它与实际 height 区别越大,这种感觉就会越明显,原因也很容易想到,因为 transition 的起点与终点均不在实际的起点与终点上;
  • +
  • 使用 scaleY 有两个问题:一是动效与高度渐变不一样,元素的内容看上去是被压缩了(而不是被收起或展开),这个倒可以忍耐。可恶的是第二点,它虽然看起来是渐变了,然而高度却并没有被渐变!意思是,在它下面的元素会在动画结束后”跳”到另一个位置而不是平滑地渐变到这个位置;
  • +
  • 使用 JavaScript 动画其实已经可以完美地实现高度渐变了,然而,问题是我们需要引入额外的 lib 来做成这件事,我可没心情纯 js 手写动画。
  • +
+

所以,我的目标是:

+
    +
  1. 使用 css transition 完成动画;
  2. +
  3. 动画效果必须完美;
  4. +
  5. 与 vue transition 组件集成。
  6. +
+

这实际上是一个很艰难的任务。经过了大量的失败尝试,最终还是 google 救了我。。下面先直接上解决方案。

+ + +

css 部分非常简单,因为它不可以完成从 0 到 auto 的渐变,却可以完成从 0 到固定值的渐变,因此,思路是渐变仍由 css 完成,但会通过钩子给元素做些魔法操作:

+
.collapse {
transition: height .3s ease-in-out;
overflow: hidden;
}
+ +

下面是重点 vue transition 部分:

+
on: {
enter (el) {
el.style.height = 'auto'
// noinspection JSSuspiciousNameCombination
let endWidth = window.getComputedStyle(el).height
el.style.height = '0px'
el.offsetHeight // force repaint
// noinspection JSSuspiciousNameCombination
el.style.height = endWidth
},
afterEnter (el) {
el.style.height = null
},
leave (el) {
el.style.height = window.getComputedStyle(el).height
el.offsetHeight // force repaint
el.style.height = '0px'
},
afterLeave (el) {
el.style.height = null
}
}
+ +

这些钩子大概是这样的:

+
    +
  • enter 会在元素从无到有的时候触发,即我们期望的高度从 0 到 auto 的时候;
  • +
  • afterEnter 会在 enter 结束后触发;
  • +
  • leave 会在元素从有到无,即高度从 auto 到 0 的时候触发;
  • +
  • afterLeave 同理
  • +
+

这些钩子内的代码真的很魔性,大概是这样的:

+

enter

+

这个方法被调用的时候,元素实际上已经被插入到 dom 中( v-if )或者 display 属性不为 none 了( v-show ),因此,是可以获取到它的实际高度的。

+
    +
  1. 先将其高度设置为 auto,然后通过 getComputedStyle 方法来获取其实际高度;
  2. +
  3. 将其高度设置为 0
  4. +
  5. 将其高度设置为第一步取得的实际高度。
  6. +
+

但是!这么做有个致命问题,我是在同一个方法内同步完成这些步骤的,因此,第二步和第三步执行的结果看起来就像跳过了第二步而只执行了第三步一样,这样就没有高度从 0 到某个值的过程,自然也就没有渐变动画了。

+

重点!

+

这里说的魔法,实际上就是那一句看似啥都没做的 el.offsetHeight,它使浏览器强制进入了一个 repaint 流程。至于它为什么能实现这个功能,真的不太清楚,google 一波也只是知其然不知其所以然,我们甚至不用给它赋值,只要引用一次就行了。可以看做一个非常神奇的技巧。实测在 IE 10 以上 / Chrome / Firefox / Safari 上均能工作。

+

因此,enter 的流程变为:

+
    +
  1. 先将其高度设置为 auto,然后通过 getComputedStyle 方法来获取其实际高度;
  2. +
  3. 将其高度设置为 0
  4. +
  5. 强制浏览器重绘;
  6. +
  7. 将其高度设置为第一步取得的实际高度。
  8. +
+

这样动画就成功执行了!

+

理解了 enter 过程,剩下的 afterEnter / leave / afterLeave 钩子,里面的内容就很容易理解了。

+

效果演示:https://uiv.wxsm.space/collapse

+

回过头来看一下实现原理其实很简单粗暴,因此,除了在 vue 上面可以这么玩,其实其他支持 css transition 的框架肯定也是可以的(如 angular 中有 ngAnimate 可以实现),最终达到的动画效果十分完美,并且没有借助主框架以外的任何额外 js 库。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2017/youth/index.html b/2017/youth/index.html new file mode 100644 index 000000000..ca0fbf075 --- /dev/null +++ b/2017/youth/index.html @@ -0,0 +1,452 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +《芳华》 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 《芳华》 +

+ + +
+ + + + +

我个人非常喜欢冯导的这部电影。

+

我的理解,这部电影的内容、主题,就跟它的名字一样,芳华。虽然我不是生活在那个年代的人,但是我也许可以理解那些都是什么。电影把一代人最美的形象,最好的年华,最真的梦想,展示给了我们看。相信这一点没有争议,不用过多解释。

+

至于其它的,我觉得都不重要。

+

有些人在这个故事里看到的更多是人的「恶」。如林丁丁,如红二代,如政委。认为所谓的「战友情」不过是镜花水月。但是,生活不就是这样的吗?

+

在电影里面,最终没有任何事情被追究,就连「迫害」了刘峰的林丁丁,最后也能被拿来给受害人打趣,然而我并没有觉得有任何反感之处。

+

人不就是这样的吗?当你对形势做出了错误的判断,就理应承担造成的后果。认真就输了,可谓一语成谶。既然是自己酿成的错,有什么好追究的呢?

+

百年以后,没有人会记得这些人当年的那些点点滴滴的琐事,善也好,恶也罢,大概都已经如萧穗子散落的情书一般,仿佛从来就没有存在过。即使是残酷至极的战争,也终究会被人遗忘。

+

也许能留下来的,也不过存在于现实与记忆中的,一代又一代人的最美的芳华吧。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/auto-height-webview-of-react-native/index.html b/2018/auto-height-webview-of-react-native/index.html new file mode 100644 index 000000000..c8e897ee3 --- /dev/null +++ b/2018/auto-height-webview-of-react-native/index.html @@ -0,0 +1,458 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Auto-height Webview of ReactNative | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Auto-height Webview of ReactNative +

+ + +
+ + + + +

自动高的 Webview 实现方式其实跟 iframe 无二,无非是计算其内容高度后再赋值给容器样式。但是普通的办法实际上用起来差强人意,其问题主要体现在页面加载过慢,需要整个页面(包括图片)加载完成后才能计算出高度。而实际想要的效果往往是跟普通“网页”的表现一致,即:先加载文字,图片等内容异步加载、显示。在尝试了多款开源解决方案后,问题均没有得到解决,因此有了自己动手的想法。

+

不过本方案目前也只适用于自己拼接的 HTML,不适用于直接打开链接的 Webview,应用场景主要是在 ReactNative 应用内打开由 CMS 编辑的类新闻页面。

+ + +

主要思路为:通过 Webview 提供的 postMessage 交互方式,不断地从 HTML 页面把自己计算好的高度抛送给 APP 端。但是这里其实有个问题,ReactNative Webview 的 postMessage 必须在页面加载完成以后才会注入,因此可以先加载一个空白页,待 postMessage 注入完成以后,再将实际文章内容插入到 body 中。

+

但是这么做有一个问题就是,页面将无法知道真正的内容“是否已加载完”,因为 window.onload 事件在加载开始之前就已经结束了。因此它只能不停地抛送高度信息,直到页面被销毁。

+

核心代码(HTML):

+
<html>
<head>
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon">
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
<script>
var inserted = false;
var interval = setInterval(function () {
var body = document.body, html = document.documentElement;
var height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight);
if (window.postMessage) {
if (!inserted) {
document.body.innerHTML = '${valueParsed}';
inserted = true;
}
window.postMessage(height + '');
}
if (document.readyState === 'complete') {
//clearInterval(interval)
}
}, 200);
</script>
</head>
<body></body>
</html>
+ +

核心代码(App):

+
export default class AutoHeightWebview extends PureComponent {
constructor (props) {
super(props);
this.state = {
webviewHeight: 0
};
}

assembleHTML = (value) => {
// 组装HTML,略
};

onMessage = (event) => {
const webviewHeight = parseFloat(event.nativeEvent.data);
if (!isNaN(webviewHeight) && this.state.webviewHeight !== webviewHeight) {
this.setState({webviewHeight});
}
};

render () {
const HTML = this.assembleHTML(this.props.html);
const onLoadEnd = this.props.onLoadEnd || function () {};
// 防止 postMessage 与页面原有方法冲突
const patchPostMessageFunction = function () {
var originalPostMessage = window.postMessage;
var patchedPostMessage = function (message, targetOrigin, transfer) {
originalPostMessage(message, targetOrigin, transfer);
};
patchedPostMessage.toString = function () {
return String(Object.hasOwnProperty).replace('hasOwnProperty', 'postMessage');
};
window.postMessage = patchedPostMessage;
};

const patchPostMessageJsCode = '(' + String(patchPostMessageFunction) + ')();';

return (
<WebView
injectedJavaScript={patchPostMessageJsCode}
source={{html: HTML, baseUrl: 'http:'}}
scalesPageToFit={Platform.OS !== 'ios'}
bounces={false}
scrollEnabled={false}
startInLoadingState={false}
automaticallyAdjustContentInsets={true}
onMessage={this.onMessage}
onLoadEnd={onLoadEnd}
/>
);
}
}
+ + +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/case-insensitive-auto-complete-in-oxs-terminal/index.html b/2018/case-insensitive-auto-complete-in-oxs-terminal/index.html new file mode 100644 index 000000000..17200f72b --- /dev/null +++ b/2018/case-insensitive-auto-complete-in-oxs-terminal/index.html @@ -0,0 +1,450 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Case insensitive auto-complete in OSX Terminal | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Case insensitive auto-complete in OSX Terminal +

+ + +
+ + + + +

在 Mac OSX 终端里面由于默认 Home 下面的文件夹都是大写开头,如 Downloads / Desktop 等,cd 的时候比较烦。解决方法:

+
$ echo "set completion-ignore-case On" >> ~/.inputrc
+ +

然后重启终端即可。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/disappointed/index.html b/2018/disappointed/index.html new file mode 100644 index 000000000..2f54542c1 --- /dev/null +++ b/2018/disappointed/index.html @@ -0,0 +1,455 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +失望 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 失望 +

+ + +
+ + + + +

今年的 TI 本子到目前为止已经充了 ¥850 左右,770 级。

+
    +
  • 不朽 1 没有开到极其珍稀(PA),其它齐全
  • +
  • 不朽 2 齐全,一件极其珍稀(黑鸟)
  • +
  • 不朽 3 没有开到极其珍稀(巫医),其它齐全
  • +
  • 宝瓶 1 一轮,一件稀有额外(术士)
  • +
  • 宝瓶 2 一轮,一件稀有额外(大屁股)
  • +
+

战绩可以说非常不尽人意。虽然中途 V 社承认自己失误(被迫?)发了一次补偿,但依然没我。

+

现在每周就肝肝幽穴风云,肝肝代币,箱子开了马上又是一次轮回,感觉除了中看不中用的等级以外什么都没留下。想要的东西永远开不到,除了失望以外说不出别的感受来。

+

今天中午又开了一个箱子,依然是熟悉的啥都没有,突然就觉得好累,有点不想肝了。人生啊。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/dying-to-survive/index.html b/2018/dying-to-survive/index.html new file mode 100644 index 000000000..56916b7d0 --- /dev/null +++ b/2018/dying-to-survive/index.html @@ -0,0 +1,450 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +《中国药神》 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 《中国药神》 +

+ + +
+ + + + +

《我不是药神》是一部好电影。

+

影片最打动我的一段,是小吕请勇哥去他家吃饭的那几分钟。这些小人物倾家荡产,拼了命地活着,到底只不过就是为了一些「小事」而已。不然何苦呢?得了绝症的小吕幸福吗?从某个角度看,他非常幸福:有一个至死都不离不弃的爱人,还有一个至少到现在为止都健健康康的孩子。但生活就是这样残酷。

+

吃不起特效药的人,去抗议药厂卖天价药,对于不幸的患者来说,我命都快没了,管你是对是错呢?影片故意刻画了一个近乎反派立场的药厂,是不得已而为之,但我们要记住:真正对人类社会的发展做出贡献的是药厂。它卖天价药,卖任何价格,都没有问题,你永远不知道药厂为了第一片药付出了多少。至于吃不起,那是你的问题。就像影片说的一样:穷病,没法治。

+

影片从「病」这个角度,揭露出了绝大部分人生活在这个社会上的一些无奈。除非你有钱到刘强东这种程度,否则这个世界上总有你付不起的代价,这一刻是公平的。

+

这部电影好就好在,它选取了一个能够引发共鸣,但又值得深思的角度,同时把故事给讲好了。其实真的不难,真心希望它能够赚一笔大的,让大家以后都有样学样,多拍点有营养的东西。

+

PS. 毕导可以出来点评一下了,我猜这绝对又是境外势力的阴谋?

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/fill-jd-slider-captcha-by-puppeteer/index.html b/2018/fill-jd-slider-captcha-by-puppeteer/index.html new file mode 100644 index 000000000..404065323 --- /dev/null +++ b/2018/fill-jd-slider-captcha-by-puppeteer/index.html @@ -0,0 +1,476 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +使用 Puppeteer 自动输入京东滑动验证码 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 使用 Puppeteer 自动输入京东滑动验证码 +

+ + +
+ + + + +

京东网页端登录有时候需要输入滑动验证码,就像这样:

+

jd-verify

+

在做自动签到脚本的时候遇到这个很不舒服,如果不处理的话就只能每次弹出浏览器手动登录,因此稍微研究了下。下面是一个非常简单,但成功率很高(达到80%)的自动识别并输入方案,使用 puppeteer 实现。

+

总体思路:通过图像特征识别出滑块缺口的位置,然后通过模拟用户点击将滑块拖动到该处。

+ + +

首先,我们能够看到这个滑块缺口是一个黑色半透明的遮罩区域,通过代码分析并不能得到它的具体色值。因此只能自行比对尝试。通过肉眼测试与对比,可以得到这个遮罩的色值大约为 rgba(0,0,0,0.65)

+

img

+

得到这个色值的目的是:通过判断相邻像素点 a, b 的色值之差,来决定像素点 b 的色值是否是像素点 a 的色值加上遮罩之后的结果,以此来推断遮罩所在的位置。

+

下面介绍两个函数:

+
    +
  • combineRgba rgba 色值相加,返回相加结果
  • +
  • tolerance rgba 色值比对,通过传入一个「容忍值」,返回颜色是否相似
  • +
+
/**
* combine rgba colors [r, g, b, a]
* @param rgba1 底色
* @param rgba2 遮罩色
* @returns {number[]}
*/
export function combineRgba (rgba1: number[], rgba2: number[]): number[] {
const [r1, g1, b1, a1] = rgba1
const [r2, g2, b2, a2] = rgba2
const a = a1 + a2 - a1 * a2
const r = (r1 * a1 + r2 * a2 - r1 * a1 * a2) / a
const g = (g1 * a1 + g2 * a2 - g1 * a1 * a2) / a
const b = (b1 * a1 + b2 * a2 - b1 * a1 * a2) / a
return [r, g, b, a]
}

/**
* 判断两个颜色是否相似
* @param rgba1
* @param rgba2
* @param t
* @returns {boolean}
*/
export function tolerance (rgba1: number[], rgba2: number[], t: number): boolean {
const [r1, g1, b1] = rgba1
const [r2, g2, b2] = rgba2
return (
r1 > r2 - t && r1 < r2 + t
&& g1 > g2 - t && g1 < g2 + t
&& b1 > b2 - t && b1 < b2 + t
)
}
+ +

接下来就可以写出距离算法了,通过传入包含缺口的验证码图片的 base64 编码,以及图片的实际宽度,返回缺口位置 x 值。具体思路是:通过自左而右,自上而下的逐列像素分析,找出第一个跟上个像素的色值与遮罩色值相加后的结果相似的像素点,就认为是遮罩的 x 位置。

+

img

+
function getVerifyPosition (base64: string, actualWidth: number): Promise<number> {
return new Promise((resolve, reject) => {
const canvas = createCanvas(1000, 1000)
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
const width: number = img.naturalWidth
const height: number = img.naturalHeight
ctx.drawImage(img, 0, 0)
const maskRgba: number[] = [0, 0, 0, 0.65]
const t: number = 10 // 色差容忍值
let prevPixelRgba = null
for (let x = 0; x < width; x++) {
// 重新开始一列,清除上个像素的色值
prevPixelRgba = null
for (let y = 0; y < height; y++) {
const rgba = ctx.getImageData(x, y, 1, 1).data
if (prevPixelRgba) {
// 所有原图中的 alpha 通道值都是1
prevPixelRgba[3] = 1
const maskedPrevPixel = combineRgba(prevPixelRgba, maskRgba)
// 只要找到了一个色值匹配的像素点则直接返回,因为是自上而下,自左往右的查找,第一个像素点已经满足"最近"的条件
if (tolerance(maskedPrevPixel, rgba, t)) {
resolve(x * actualWidth / width)
return
}
} else {
prevPixelRgba = rgba
}
}
}
// 没有找到任何符合条件的像素点
resolve(0)
}
img.onerror = reject
img.src = base64
})
}
+ +

得到 x 位置后,就可以使用 puppeteer 操纵滑块来实现验证了:

+
// 验证码图片(带缺口)
const img = await page.$('.JDJRV-bigimg > img')
// 获取缺口左x坐标
const distance: number = await getVerifyPosition(
await page.evaluate(element => element.getAttribute('src'), img),
await page.evaluate(element => parseInt(window.getComputedStyle(element).width), img)
)
// 滑块
const dragBtn = await page.$('.JDJRV-slide-btn')
const dragBtnPosition = await page.evaluate(element => {
// 此处有 bug,无法直接返回 getBoundingClientRect()
const {x, y, width, height} = element.getBoundingClientRect()
return {x, y, width, height}
}, dragBtn)
// 按下位置设置在滑块中心
const x: number = dragBtnPosition.x + dragBtnPosition.width / 2
const y: number = dragBtnPosition.y + dragBtnPosition.height / 2

if (distance > 10) {
// 如果距离够长,则将距离设置为二段(模拟人工操作)
const distance1: number = distance - 10
const distance2: number = 10
await page.mouse.move(x, y)
await page.mouse.down()
// 第一次滑动
await page.mouse.move(x + distance1, y, {steps: 30})
await page.waitFor(500)
// 第二次滑动
await page.mouse.move(x + distance1 + distance2, y, {steps: 20})
await page.waitFor(500)
await page.mouse.up()
} else {
// 否则直接滑到相应位置
await page.mouse.move(x, y)
await page.mouse.down()
await page.mouse.move(x + distance, y, {steps: 30})
await page.mouse.up()
}
// 等待验证结果
await page.waitFor(3000)
+ +

img

+

大概就这样。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/gitlab-ci-setup/index.html b/2018/gitlab-ci-setup/index.html new file mode 100644 index 000000000..60bda431a --- /dev/null +++ b/2018/gitlab-ci-setup/index.html @@ -0,0 +1,485 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Gitlab CI Setup | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Gitlab CI Setup +

+ + +
+ + + + +

Gitlab 有一套内置的 CI 系统,相比集成 Jenkins 来说更加方便一些,用法也稍为简单。以下是搭建过程。

+

前置准备:须要准备一台用来跑 CI 任务的机器(可以是 Mac / Linux / Windows 之一)。

+ + +

创建 .gitlab-ci.yml 文件

和 Github CI 一样,Gitlab CI 也使用 YAML 文件来定义项目的整个构建任务。只要在需要集成 CI 的项目根目录下添加这份文件并写入内容,默认情况下 Gitlab 就会为此项目启用构建。

+

配置文档:https://docs.gitlab.com/ee/ci/yaml/README.html

+

一份较为完整的配置文件样例:

+
#指定 docker 镜像
image: node:9.3.0

#为 docker 镜像安装 ssh-agent 以执行部署任务
before_script:
- 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
- mkdir -p ~/.ssh
- echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config
- chmod 700 ~/.ssh

#定义构建的三个阶段
stages:
- build
- test
- deploy

#定义可缓存的文件夹
cache:
paths:
- node_modules/

#构建任务
build-job:
stage: build
script:
- "npm install"
- "npm run build"
tags:
- node

#测试任务
test-job:
stage: test
script:
- "npm install"
- "npm run lint"
- "npm test"
tags:
- node

#部署任务
deploy-job:
stage: deploy
only:
- release
script:
- "npm install"
- "npm run build"
- ssh user@host "[any shell commands]"
tags:
- node
+ +

整个构建过程基本上一目了然,比 Jenkins 简便很多。Gitlab CI 会按顺序执行 build / test / deploy 三个 stage 的任务,遇到出错即中止,并不再往下执行。同个 stage 中的多个任务会并发执行。需要注意的是,各个 stage 的工作空间是独立的。

+

其中 $SSH_PRIVATE_KEY 是在相应 Gitlab 项目中配置的一个 Secret Value,是构建机的 ssh 私钥。后面再谈。

+

.gitlab-ci.yml 文件推送到服务器后,打开项目主页,点击 Commit 记录,会发现构建任务启动并处于 pending 状态:

+

img

+

点击构建图标,则可以进入到 CI 详情页面:

+

img

+

点击具体任务查看 log 则提示项目没有配置相应的 runner 来执行构建任务。也就是下一步要做的事情。

+

搭建 Gitlab runner

Gitlab runner 是用来执行 CI 任务的客户端,它可以在一台机器上搭建,并且同时为多个项目服务。安装教程

+

安装好 runner 后,还要为机器安装 Docker,用来作为具体构建的容器。

+

以上均安装完成后,就可以开始配置 runner 了。配置过程中需要用到的一些信息可以在下图位置找到(项目主页 -> Settings -> CI / CD -> Runners settings)。

+

img

+
$ sudo gitlab-runner register

Please enter the gitlab-ci coordinator URL (e.g. https://gitlab.com )
(填写上图位置的地址)

Please enter the gitlab-ci token for this runner
(填写上图位置的token)

Please enter the gitlab-ci description for this runner
[hostame] my-runner

Please enter the gitlab-ci tags for this runner (comma separated):
node

Please enter the executor: ssh, docker+machine, docker-ssh+machine, kubernetes, docker, parallels, virtualbox, docker-ssh, shell:
docker

Please enter the Docker image (eg. ruby:2.1):
node:latest
+ +

其中 description 与 tags 将来都可以在 Gitlab UI 中更改。注意 tag 必须与 .gitlab-ci.yml 中各个 job 指定的 tag 一致,这个 job 才会分配到这个 runner 上去。

+

如此一来则大功告成,回到 Gitlab UI,在 Runner settings 内可以看到配置好的 runner,并且可以执行任务了。

+

img

+

遇到的问题

其实本地构建基本上都没什么问题,遇到的坑基本集中在 deploy 阶段,即远程到服务器上去发布的这一步。按照 Gitlab 提供的文档,走完了所有的步骤后,构建机总是无法使用 private key 直接登录,而是必须输入密码登录。尝试了查看 ssh 日志,重启服务器 sshd 服务,修改文件夹权限等等,debug 了半天还是没有解决该问题。后来偶然发现在部署服务器上使用 sshd 开启一个新的服务,用新的端口即可顺利登录,也不知道是为什么。

+

更新:另外一个方法,可以使用 sshpass 命令来进行登录。用法:

+
    +
  1. 在 docker 镜像中安装 sshpass
    $ which sshpass || ( apt-get update -y && apt-get install sshpass -y )
    +其中 -y 是为了防止安装过程中出现需要选择的项目,一律选 YES
  2. +
  3. 在项目 CI 变量中设置 ssh 密码
  4. +
  5. 使用 sshpass 复制文件,或登录远程服务器
    # scp
    $ SSHPASS=$YOUR_PASSWORD_VAR sshpass -e scp -r local_folder user@host:remote_folder"
    # ssh
    $ SSHPASS=$YOUR_PASSWORD_VAR sshpass -e ssh user@host
  6. +
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/linux-setup-for-work/index.html b/2018/linux-setup-for-work/index.html new file mode 100644 index 000000000..e5e48a0b6 --- /dev/null +++ b/2018/linux-setup-for-work/index.html @@ -0,0 +1,546 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Linux Setup for Work | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Linux Setup for Work +

+ + +
+ + + + +

因为各种烦人的原因,公司搬家后到新办公室第一件事先把老电脑格了。犹豫了一下,最终还是放弃了重装 Windows,支持我做出选择的原因有几:

+
    +
  • 不需要进行(纯)MS 系开发
  • +
  • 没有必须使用的 Windows 软件
  • +
  • Windows 上跑 Android emulator 卡得头疼
  • +
  • NVIDIA 已有支持 Linux 的官方显卡驱动
  • +
  • Linux 开发效率更高
  • +
  • Linux 学习价值更高
  • +
+

本文是办公室适用(对我来说)的安装记录。

+ + +

System installation

这次选择的发行版是 Ubuntu 16.04 LTS,从 https://www.ubuntu.com/download/desktop 下载镜像安装包,复制到 u 盘后启动。

+

这里有一点问题是,我这台机器必须选择 UEFI 安装,Ubuntu 才能正常安装与启动。如果选择了 Legacy 安装,Ubuntu 可以正常安装,但启动后会一直停留在黑屏光标闪烁的状态,原因未知。

+

NVIDIA driver setup

系统默认安装了一个第三方的显卡驱动,基本上没什么可用性,在桌面上都有点卡。因此官方驱动是必须的。但如果安装不正确,会导致系统重启后无限卡在登录界面。如果不幸已经发生了这种情况,可以按 Ctrl + Alt + F1 进入纯命令行操作界面进行修复。

+

(以下步骤应该在纯命令行界面下执行)

+

首先禁用开源驱动:

+
$ sudo vim /etc/modprobe.d/blacklist.conf
+ +

添加以下内容:

+
blacklist amd76x_edac
blacklist vga16fb
blacklist nouveau
blacklist nvidiafb
blacklist rivatv
+ +

然后,依次执行(注意先到 NVIDIA 官网查询适用自己显卡的版本号,比如我的辣鸡 GTX650 是适用 384):

+
$ sudo apt-get remove  --purge nvidia-*
$ sudo add-apt-repository ppa:graphics-drivers/ppa
$ sudo apt-get update
$ sudo service lightdm stop
$ sudo apt-get install nvidia-384 nvidia-settings nvidia-prime
$ sudo nvidia-xconfig
$ sudo update-initramfs -u
+ +

最后重启系统:

+
$ sudo reboot
+ +

如此,显卡驱动就装好了。

+

Chrome setup

虽然 Ubuntu App Store 有提供开源版本的 Chromium,但是经过实测它在有些情况下并不能完全替代 Chrome(比如有些工具会调用 google-chrome 来打开一个浏览器页,如果安装的是 Chromium 就会失败)。因此,还是建议到 Google Chrome Downloads 下载适用于 Linux 平台的 Chrome 完全体。

+

Secondary drive mount

两块硬盘已经不是什么新鲜事了。痛苦的是系统盘以外的另一块硬盘需要手动挂载。

+

首先,使用 sudo fdisk -l 命令来显示目前可用的所有硬盘。假设 /dev/sdb 是未分区并且想要挂载的一块硬盘:

+

执行 sudo fdisk /dev/sdb

+
    +
  1. Press O and press Enter (creates a new table)
  2. +
  3. Press N and press Enter (creates a new partition)
  4. +
  5. Press P and press Enter (makes a primary partition)
  6. +
  7. Then press 1 and press Enter (creates it as the 1st partition)
  8. +
  9. Finally, press W (this will write any changes to disk)
  10. +
+

然后,执行 sudo mkfs.ext4 /dev/sdb1

+

现在新硬盘就已经被分区并格式化了。接下来让系统在启动的时候自动挂载它,执行 sudo gnome-disks 打开一个 GUI 界面。

+

img

+

选择刚才添加的那块硬盘,点击配置按钮,选择目标挂载点,并点击 OK 即可。

+

img

+

需要注意的是,目前硬盘是只有读权限的,使用以下命令来给用户赋予读写权限:

+
$ cd /mount/point
$ sudo chmod -R -v 777 *
$ sudo chown -R -v username:username *
+ +

Input method setup

https://pinyin.sogou.com/linux/ 下载合适的输入法包,并安装之。然后从 Settings -> Language Support 中将 Keyboard input method system 从 iBus 切换为 fcitx(有可能会遇到语言包安装不完全的情况,输入 sudo apt-get install -f 可以修复),然后重启。

+

重启后,右键桌面右上角的 fcitx 图标,选择 ConfigureFcitx,点击 + 号添加输入法,去掉 Only show current language 的勾,然后输入 sogou 搜索即可看到安装好的搜狗输入法。添加即可。

+

Email setup

因为我的公司邮箱是用的 Ms Exchange,所以设置步骤很简单:

+
    +
  1. Ubuntu 自带 Mozilla Thunderbird 邮件客户端,直接用这个就行了。
  2. +
  3. 它本身是不支持 Exchange 配置的,需要添加一个插件 ExQuilla for Microsoft Exchange 以支持。
  4. +
  5. 安装好插件后,从菜单栏的 Tools -> ExQuilla for Microsoft Exchange -> Add Microsoft Exchange Account 进入配置入口,然后就是正常的邮件配置了。
  6. +
+

Screenshot

以下安装截图工具 Shutter,并设置快捷键:

+
$ sudo add-apt-repository ppa:shutter/ppa
$ sudo apt-get update
$ sudo apt-get install shutter
+ +

打开 Settings -> Keyboard -> Shortcuts -> Custom Shortcuts,点击 + 添加,输入 Name (Shutter Select) Command (shutter -s),保存。然后点击刚才添加的项目,在快捷键那里按下 Ctrl + Alt + A 即可。

+

shutter-1

+

shutter-2

+

JDK setup

JDK 可以到 Oracle 网站下载,也可以通过 apt-get 安装 openjdk,以下是安装 openjdk 的过程:

+
$ sudo apt-get install openjdk-8-jdk
$ apt-cache search jdk
$ export JAVA_HOME=/usr/lib/jvm/java-8-openjdk
$ export PATH=$PATH:$JAVA_HOME/bin
+ +

注意 JAVA_HOME 的 folder 可能有所变化,注意使用实际目录。

+

Node.js setup

Node.js 不直接安装,而是选择使用 nvm 进行管理。

+
$ wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.33.8/install.sh | bash
$ export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" # This loads nvm
$ command -v nvm
+ +

使用方法:https://github.com/creationix/nvm#usage

+

MongoDB setup

这里其实参照官方文档就行了。

+
$ sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 2930ADAE8CAF5059EE73BB4B58712A2291FA4AD5
$ echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu xenial/mongodb-org/3.6 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-3.6.list
$ sudo apt-get update
$ sudo apt-get install -y mongodb-org
$ sudo service mongod start
+ +

Change launcher position

Ubuntu 默认的 Launcher 设置在了屏幕的左边,但是如果有三屏的话,那用起来其实并不方便。可以通过一个简单的命令将其下置:

+
$ gsettings set com.canonical.Unity.Launcher launcher-position Bottom
+ +

这样 Launcher 就到了屏幕下方了,就像 Windows 默认的任务栏一样。Ubuntu 会记住这个设定,所以下次登录时也无需重新输入。

+

Enable workspace

Ubuntu 16.04 默认关闭了 Workspace (即类似 OSX 的全屏切换功能),其实挺好用的。可以手动开启:

+
Settings -> Appearance -> Behavior -> Enable workspaces
+ +

enable-workspace

+

如果有配置双屏的话,一般会想固定副屏的内容,只需在副屏标题栏右键,选择 Always on Visible Workspace 即可。

+

always-on-visible-workspace

+

默认的切换屏幕快捷键是 Ctrl + Alt + Arrow,跟 Intellij 的快捷键冲突了,并且与 OSX 上的不一致。可以手动修复:

+
Settings -> Keyboard -> Shortcuts -> Navigation
+ +

找到 Switch workspace to left / right / up / down, 各自改成相应的 Ctrl + Arrow 即可。

+

Other apps

+

Linux 下可玩的 Steam 游戏还是挺多的。玩 DOTA2 感觉跟 Windows 也没什么差别。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/react-native-text-inline-image/index.html b/2018/react-native-text-inline-image/index.html new file mode 100644 index 000000000..623dedfab --- /dev/null +++ b/2018/react-native-text-inline-image/index.html @@ -0,0 +1,470 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +React Native Text Inline Image | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ React Native Text Inline Image +

+ + +
+ + + + +

原文地址(需科学上网):React Native Text Inline Image

+

RN 版本:0.49

+

图文混排(在文字中插入图片,并保持正确换行)是客户端普遍的需求,但在 RN 中它有一点问题,具体表现在 Android 平台下图片显得异常的小,并且相同系统不同设备之间的表现也不尽一样,而 ios 则表现正常。

+ + +

就像这样:

+
import React, { Component } from 'react';
import {
AppRegistry,
StyleSheet,
Text,
View,
Image,
} from 'react-native';

const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 50,
backgroundColor: '#f6f7f8',
},
image: {
width: 80,
height: 80,
},
text: {
backgroundColor: '#dcdcde',
},
});

class App extends Component {

render() {
return (
<View style={styles.container}>
<Text style={styles.text}>
Hello people!
<Image
style={styles.image}
source={{uri: 'http://s3.hilariousgifs.com/displeased-cat.jpg'}}
/>
</Text>
</View>
);
}
}

AppRegistry.registerComponent('App', () => App);
+ +

它在 ios 下看起来是这样的:

+

ios-before

+

而在 Android 下看起来是这样的:

+

android-before

+

可以看到,在 Android 下面这张图异常地小!

+

实际上这与设备的像素比(pixel ratio)有关,是现版本 React Native 在渲染文字内联图片时的一个 Bug,为了解决这个问题,我们可以给图片设定一个基于设备像素比的宽高。

+

就像这样:

+
import React, { Component } from 'react';
import {
AppRegistry,
StyleSheet,
Text,
View,
Image,
Platform,
PixelRatio,
} from 'react-native';

const width = 80 * (Platform.OS === 'ios' ? 1 : PixelRatio.get());
const height = 80 * (Platform.OS === 'ios' ? 1 : PixelRatio.get());

const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 50,
backgroundColor: '#f6f7f8',
},
image: {
width: width,
height: height,
},
text: {
backgroundColor: '#dcdcde',
},
});

class App extends Component {

render() {
return (
<View style={styles.container}>
<Text style={styles.text}>
Hello people!
<Image
style={styles.image}
source={{uri: 'http://s3.hilariousgifs.com/displeased-cat.jpg'}}
/>
</Text>
</View>
);
}
}

AppRegistry.registerComponent('App', () => App);
+ +

结果:

+

android-after

+

如此一来,内联图片在 Android 下就能以正常缩放比显示了。

+

方便起见,可以将这段逻辑封装到组件中去。

+
import React from 'react';
import {
StyleSheet,
Image,
Platform,
PixelRatio,
} from 'react-native';

// This component fixes a bug in React Native with <Image> component inside of
// <Text> components.
const InlineImage = (props) => {
let style = props.style;
if (style && Platform.OS !== 'ios') {
// Multiply width and height by pixel ratio to fix React Native bug
style = Object.assign({}, StyleSheet.flatten(props.style));
['width', 'height'].forEach((propName) => {
if (style[propName]) {
style[propName] *= PixelRatio.get();
}
});
}

return (
<Image
{...props}
style={style}
/>
);
};

// "Inherit" prop types from Image
InlineImage.propTypes = Image.propTypes;

export default InlineImage;
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/react-node-starter/index.html b/2018/react-node-starter/index.html new file mode 100644 index 000000000..c3894bb8d --- /dev/null +++ b/2018/react-node-starter/index.html @@ -0,0 +1,516 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +React node starter | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ React node starter +

+ + +
+ + + + +

出于某种需求搭建了一个非常简单的、基于 React / Node / Express / MongoDB 的 starter 工程:wxsms/react-node-starter,旨在简化小型或中小型项目开发流程,关注实际业务开发。

+

目前所实现的内容有:

+
    +
  • 前后端完全分离
  • +
  • 热重载
  • +
  • 用户注册、登录
  • +
+

+

麻雀虽小,五脏俱全。下面记录搭建过程。

+ + +

React

整个项目实际上是一个使用 facebook/create-react-app 创建出来的架构。

+
$ npm install create-react-app -g
$ create-react-app react-node-starter
+ +

如此就完事了。创建出来的项目会包含 React 以及 React Scripts,Webpack 等配置都已经包含在了 React Scripts 中。执行 npm start 会打开 http://localhost:3000,但是有一个遗憾之处是,这里提供的热重载不是 HMR,而是整个页面级别的重新加载。

+

Node & Express

要在前端项目的基础上加入 Node 服务端,由于项目的极简性质,需要考虑一个问题是:如何在不跨域、不加入额外反代的情况下完成这个任务。有幸的是 create-react-app 贴心地加入了 Proxying API Requests in Development 功能,只需要给 package.json 加入一对键值,就可以达成目的:

+
"proxy": "http://localhost:3001"
+ +

这样一来,在开发环境下,前端会自动将 Accept Header 不包含 text/html 的请求(即 Ajax 请求)转发到 3001 端口,那么我们只需要将服务端部署到 3001 端口就好了。

+

至于生产环境则无此烦恼,只需要将 npm run build 打包出来的文件当做静态资源,服务器依旧照常启动即可。

+

在项目根目录下新建 server 文件夹,用来存放服务端代码。

+

server/server.js:

+
#!/usr/bin/env node

const app = require('./app');
const http = require('http');

const port = 3001;
app.set('port', port);

const server = http.createServer(app);

server.listen(port);
+ +

server/app.js:

+
const express = require('express');
const session = require('express-session');
const path = require('path');
const logger = require('morgan');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');

const router = require('./router');

const app = express();

app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: false}));
app.use(cookieParser());
app.use(session({
secret: 'test',
resave: false,
saveUninitialized: true
}));
app.use(express.static(path.join(__dirname, '../build')));
app.use('/public', express.static(path.join(__dirname, '../public')));
app.use('/api', router);
app.get('*', (req, res) => {
res.sendFile('build/index.html', {root: path.join(__dirname, '../')});
});

module.exports = app;
+ +

就是一个典型的 Express HTTP 服务器。当处于开发环境时,build 目录只存在于内存中。执行生产构建脚本后,会打包至硬盘,因此上面的代码可以同时覆盖到开发与生产环境,无需再做额外配置。

+

准备完成后,将 start 脚本更新为:

+
"start": "concurrently \"react-scripts start\" \"nodemon server/server.js\""
+ +

即可。其中:

+
    +
  • concurrently 是为了在一个终端窗口中同时执行前端与服务端命令
  • +
  • nodemon 是为了实现服务端热重载
  • +
+

熟悉 Node.js 的应该对这两个工具都不陌生。

+

这里有一个对原项目作出改变的地方是,出于尽可能简化的目的,将 registerServiceWorker.js 文件及其引用移除了,同时使用 Express 来对 public 文件夹做静态资源路由。

+

如此一来,重新执行 npm start 会发现 Express 服务器能够按照预期运行了。

+

MongoDB

建好 Express 整体框架后,加入 MongoDB 的相关支持就非常简单了。安装 mongoose,然后在 server 目录下新建一个 models 文件夹用来存放 Model,然后新建一个 db 初始化文件:

+

server/mongodb.js

+
const mongoose = require('mongoose');
const path = require('path');
const fs = require('fs');

mongoose.connect('mongodb://localhost:27017');

fs.readdirSync(path.join(__dirname, '/models')).forEach(file => {
require('./models/' + file);
});
+ +

最后将此文件在 app.js 中引用即可:

+
require('./mongodb');
+ +

Session Auth

本项目采用 Session 鉴权,那么在前后端分离的项目中,无法通过服务端模板来同步赋值,因此有一个问题就是如何让前端项目获取到当前登录的角色。出于尽可能简单的目的,最终做法是在页面入口初始化时向服务端发起请求获取当前登录角色,获取过程中显示 Loading 界面。用户信息获取成功后才开始真正的路由渲染,如果具体页面鉴权失败则重定向回登录页面。

+

AntD

前端选用 Ant Design 作为 UI 框架,为了更方便地使用它,参考其文档教程,这里做一点小小的配置,首先安装 react-app-rewiredbabel-plugin-import

+
$ yarn add react-app-rewired babel-plugin-import
+ +

修改 package.json 中的脚本,将 react-scripts 全都替换为 react-app-rewired

+
{
"scripts": {
"start": "concurrently \"react-app-rewired start\" \"nodemon server/server.js\"",
"build": "react-app-rewired build",
"test": "react-app-rewired test --env=jsdom",
"eject": "react-app-rewired eject"
}
}
+ +

然后在项目根目录中创建 config-overrides.js 文件:

+
const {injectBabelPlugin} = require('react-app-rewired');

module.exports = function override (config, env) {
config = injectBabelPlugin(
['import', {libraryName: 'antd', libraryDirectory: 'es', style: 'css'}],
config,
);
return config;
};
+ +

这样做的好处是,CSS 可以按需加载,并且引用 AntD 组件更方便了,如:

+
import {Button} from 'antd';
+ +

Redux

安装 Redux 全家桶:

+
$ yarn add redux redux-thunk react-redux immutable
+ +

然后按照 示例项目 插入到项目中去即可。区别是为了在 action 中执行异步操作加入了一个中间件 redux-thunk,以及原示例没有使用 Immutable.js,也在本项目中加入了。

+

src/redux/store.js:

+
import {createStore, applyMiddleware} from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/index';

export default createStore(
rootReducer,
applyMiddleware(thunk)
);
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/simplest-wechat-client-on-linux/index.html b/2018/simplest-wechat-client-on-linux/index.html new file mode 100644 index 000000000..6de2d320a --- /dev/null +++ b/2018/simplest-wechat-client-on-linux/index.html @@ -0,0 +1,462 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Simplest Wechat Client on Linux | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Simplest Wechat Client on Linux +

+ + +
+ + + + +

微信没有为 Linux 提供桌面客户端,可用的替代方式有:

+
    +
  1. 使用网页版微信
  2. +
  3. 使用第三方客户端,如 electronic-wechat
  4. +
  5. 自己动手,将网页版微信封装为桌面应用程序
  6. +
+

但是每种方式都有不尽人意的地方。网页版总是嵌入在浏览器中,用起来不太方便;第三方客户端安全性无法保证;自己做一个客户端又太麻烦。

+

然而,实际上还有一种更简单的方式:通过 Chrome 将网页直接转化为桌面应用。

+ + +

步骤:

+
    +
  1. 使用 Chrome 打开网页版微信
  2. +
  3. 右上角设置,More tools -> Create shortcut...
  4. +
  5. 然后就可以在 Chrome Apps 中找到微信了
  6. +
+

通过此方式创建的 Apps 同时拥有桌面应用的表现以及网页版的功能,并且可以将它固定到 Dock 栏,以及独立于浏览器运行,只能用「完美」两个字形容。

+

除微信外,其它缺少 Linux 客户端但有网页客户端的应用亦可如法炮制,如有道云笔记等。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/thoughts-of-react-native/index.html b/2018/thoughts-of-react-native/index.html new file mode 100644 index 000000000..0c150668e --- /dev/null +++ b/2018/thoughts-of-react-native/index.html @@ -0,0 +1,513 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +对 ReactNative 的一些想法 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 对 ReactNative 的一些想法 +

+ + +
+ + + + +

使用 ReactNative 开发半年有余,本文是作为一些简单的感想。

+

官网简介:

+
+

Build native mobile apps using JavaScript and React.

+
+

简约,不简单。看着很牛逼,但实际用起来总是差了点意思。

+

总而言之:帮你节省时间的同时,隐藏着无处不在的坑。

+ + +

关于框架本身

一个东西要辩证地看:ReactNative 的伟大之处在于它的定位,再一次验证了一句古老的预言:一切能用 JavaScript 实现的东西,终将被 JavaScript 实现。然而就目前的状态来看,还有许多问题。

+

使用 ReactNative 的目的是:让不会写原生 App 的人,通过 JavaScript 以及 React 也能编写出原生 App,并且跨 ios / Android 平台,乍一看相当美好。然而实际用过以后会发现,这其实是一个悖论,因为:

+

如果开发者真的完全对原生开发一窍不通,那么他根本不应该使用 ReactNative,因为他一旦遇到问题将完全没有任何解决能力,除了 Google -> Try -> Fail -> Google 直到成功(大部分时候也许是“看起来成功”)以外毫无办法。

+

使用一项技术的前提是,至少对其有所了解。而普通 JavaScript 用户使用 ReactNative,简直就像是在对着一个黑盒子编程,没有任何可靠性可言。这也是我在开发初期的真实情况:出 bug 了,不知道为什么,解决了,也不知道为什么。处于一种非常恐慌的状态。

+

也许你会说,Electron 不也是这种模式吗?那我为什么没有吐槽它呢?是的,他们俩“看起来”是一样的,但是实际上又完全不一样:

+
    +
  • Electron App 实际上是一个 Hybrid App,开发者写的 HTML 代码不需要经过任何处理,直接使用浏览器内核解析、显示,整个过程是透明的、可控的
  • +
  • ReactNative App 是一个真正的 Native App,开发者写的任何组件都会先被转化为原生组件,然后才显示给用户,而这个转化过程是一个黑盒子,是不可控的
  • +
+

因此,理想与现实总是存在差距。ReactNative 开发者不能闭门造车,一定要不断地深入底层,才能真正明白自己“在干嘛”以及“该怎么干”。这也正是悖论所在:既然如此,我为什么不从一开始就使用原生方式编写 App 呢?当然,使用 ReactNative 还有另一个重要原因,即提供跨平台开发的可能性。但要知道,它在节省大量时间的同时,也给项目组带来了大量的限制和坑。

+

关于这个项目

ReactNative 毫无疑问是一个相当庞大的项目。

+

目前 ReactNative 还没有发布 1.0 版本,也就是说项目依旧在发展期。目前来说,我觉得最大的一个问题是项目升级问题。项目保持快速发展当然很棒,但是如何能够让现有的版本升级到最新版本呢?这对于实力不强的开发者来说几乎是不可能事件。

+

主要原因:

+
    +
  • MINOR version 会包含大量 breaking changes,无痛升级不存在的
  • +
  • 也许要同时升级 React 版本
  • +
  • 第三方库不一定兼容,尤其是涉及底层的
  • +
+

另一方面,向 ReactNative 提 PR 可要比向其它 JavaScript 项目提 PR 门槛要高得多:JavaScript / ios / Android 你至少得会其中两个才行。

+

我说这个不是为了别的。我在 issues 下面最常看到的一句话就是:

+
+

Hi there! This issue is being closed because it has been inactive for a while. Maybe the issue has been fixed in a recent release, or perhaps it is not affecting a lot of people. Either way, we’re automatically closing issues after a period of inactivity. Please do not take it personally!

+
+

可以说相当无情了。关闭一个 issue 的原因,可以是 maybe,可以是 perhaps,极少有 resolved,这就是现状。

+

我理解它是一个开源项目,开发者的时间有限,更没有义务。但这可以为使用者提供一些参考。ReactNative 存在超大量诸如此类的 issue,没有被 fix,更没有 fix 计划,有很大一部分其实是非常基础的诉求,比如图文混排,这在 Android 平台下已然是不可能事件。

+

因此,综上来说,如果你对 ios/ Android 并不精通,那你一旦遇到棘手的问题,只能祈祷:

+
    +
  • 还有更多、非常多的人遇到了跟你同样的问题
  • +
  • 并且引发了激烈的讨论
  • +
  • 并且成功地被开发者修复了
  • +
  • 并且没有跨很多版本
  • +
+

否则还是歇着吧。

+

掉坑总结

正如上文所言,ReactNative App 最主要的功能之一是 Layout 绘制,而它自带的黑盒子属性,也正是最大的坑之所在

+

简单来说,你写了一个控件,如果不经过测试的话:

+
    +
  1. 你不知道它是否能在 ios 下正常表现、工作
  2. +
  3. 你也不知道它是否能在 Android 下正常表现、工作
  4. +
  5. 你更不知道它是否能在两个平台之间保持一致
  6. +
+

总而言之,如果你不真的去试试,那你什么都不知道。也许它在 ios 下完全正常,在 Android 就直接崩溃了。

+

ReactNative 提供了许多基础的跨平台组件,但是他们基本上都各有各的坑,更有组合坑。比如:

+
    +
  • <Text> 中不能有 <View> (Android 崩溃)
  • +
  • <Text> 中不能有 <Image> (Android 显示异常)
  • +
  • <Image> 不能同时使用 borderRadiusbackgroundColor 样式 (Android 显示异常)
  • +
  • overflow 样式在 Android 下无效,始终表现为 hidden
  • +
  • 等等…
  • +
+

(冰山一角)

+

以上所说的“异常”,是无法通过适配得到解决的异常,也就是说你一定不能这么用。这些有的在文档里会标为“已知问题”,有的则没有,如果你是一个新手,那么处处都存在着惊喜等待你去发掘。

+

除此以外,还有一个显著问题就是,在 ReactNative 的世界中,Debug 是不完全可靠的。因为它在 Debug 时用的是开发电脑上的 chrome 附带的 JavaScript 引擎,而在真正运行时则使用手机内置浏览器的 JavaScript 引擎。虽然大部分时候你感觉不到差异,但是一旦出现了差异则往往是致命的。

+

更新

2020/10/05

+

MINOR version 会包含大量 breaking changes,无痛升级不存在的

+
+

关于这一点,目前我理解了:因为 React Native 至今还是 0.x 版本,没有发布正式版。也就是说,不保证 minor 版本号能够兼容。

+

时隔两年,再回来看当年写的这篇文章,感觉写得还是挺对的。当然上面提到的一小部分问题,在今天的版本已经被修复了。不过总体的问题依然存在,在享受双端开发的快感同时,就必须要接受它带来的诸多问题和限制。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/thougths-about-momentjs/index.html b/2018/thougths-about-momentjs/index.html new file mode 100644 index 000000000..2194cbfa0 --- /dev/null +++ b/2018/thougths-about-momentjs/index.html @@ -0,0 +1,487 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +对 Moment.js 的一些想法 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 对 Moment.js 的一些想法 +

+ + +
+ + + + +

Moment.js 是一个流行的基于 JavaScript 的时间处理工具库。应该是一个从 2011 年开始启动的项目,至今它的 Github repo 也有了 3w+ 的星星,可以说在前端界人尽皆知了。反正我自从用了它基本上就没再接触过其它的相关库。

+

但最近我却对它的看法却产生了些许改变。原因是,它的 API 设计给使用者埋下了巨大无比的坑,简单来说:“名不副实”。

+ + +

具体看图吧:

+

strange-moment-js

+

很明显,调用 Moment.js 的 API 产生了预期之外的副作用。函数在带有返回值的同时却又对原始值进行了修改,违反了基本的 OO 设计原则。

+
+

Command–query separation (CQS) is a principle of imperative computer programming. It was devised by Bertrand Meyer as part of his pioneering work on the Eiffel programming language. It states that every method should either be a command that performs an action, or a query that returns data to the caller, but not both. In other words, Asking a question should not change the answer. More formally, methods should return a value only if they are referentially transparent and hence possess no side effects.

+
+

也就是说,设计一个函数,它应该:

+
    +
  • 要么进行操作(Mutable);
  • +
  • 要么进行返回(Immutable);
  • +
  • 但,以上两点不能同时进行。
  • +
+

这里的“返回”,我的理解不是所有类型的返回,而是特指与原始值相对应的返回。

+

比如说,在 JavaScript 世界中 array.slice 是一个 Immutable 类型的函数,它不会对输入值进行改变,而是返回一份 copy:

+
+

The slice() method returns a shallow copy of a portion of an array into a new array object selected from begin to end (end not included). The original array will not be modified.

+
+

array.splice 则不同(它虽然也有返回值,但跟输入值并不是对应的关系了):

+
+

The splice() method changes the contents of an array by removing existing elements and/or adding new elements. Return value: An array containing the deleted elements.

+
+

同理还有 array.push / array.pop 等。

+

而 Moment.js 是如何设计的呢?

+

这里有一个 issue,通过它,基本可以看出来 Moment 有哪些 API 是有问题的:make moment mostly immutable #1754

+

比如一个简单的 add 方法,对日期进行“加”操作(比如日期加一天)。那么它应该是这样的:

+
    +
  • 要么直接对输入进行“加”操作;
  • +
  • 要么产生一份复制值,对复制进行“加”操作并返回。
  • +
+

但是,Moment 真正的做法是,直接对输入进行“加”操作,并且返回。这样就很让人头疼了。

+

更过分的就是上图的例子,名如 startOf / endOf 这样的方法,看起来像是 Immutable 操作,实际上却还是 Mutable 的。所以说,如果用户使用了 Moment,那么所有的原始输入值基本上都是无法得到任何保证。你根本不知道输入值在什么时候就被修改了。

+

值得欣慰的是,在 Moment 发展了三年以后的 2014 年,终于有人提出了上述问题,并且被维护者认可并加入版本计划中了。但是,三年之后又三年,如今已经到了 2018,问题依旧没有得到解决。在 ES 发展如此迅速的时代,一个基本上处于垄断地位的流行库,以及一个三年都没能解决的问题,不知道是否还有救?

+

不过也许它已经完成曲线救国了(推倒重来总是比较简单):https://github.com/moment/luxon

+
+

Features: Immutable, chainable, unambiguous API.

+
+

不可否认 Moment.js 确实帮助开发者解决了很多问题,节省了大量时间。但是有一个问题:一个质量如此的库,是如何做到流行,如何拿到 3w 个 stars 的呢?是不是包括我在内的这些开发者,从根本上就存在软件开发基础知识的不足呢。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2018/unicode-substring/index.html b/2018/unicode-substring/index.html new file mode 100644 index 000000000..fba54b032 --- /dev/null +++ b/2018/unicode-substring/index.html @@ -0,0 +1,461 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Unicode Substring | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Unicode Substring +

+ + +
+ + + + +

最近遇到一个问题:在做字符串截取操作时,如果字符串中包含了 emoji 字符(一个表情占多个 unicode 字符),而碰巧又把它截断了,程序会出错。在 ReactNative App 下的具体表现就是崩溃。由于以前做的是网页比较多,基本没有输入表情字符的案例,而在手机上就不一样了,因此这个问题还是第一次发现。

+

比如说:

+
'😋Emoji😋'.substring(0, 2) // 😋
+ +

因此,如果对这个字符串做 substring(0, 1) 操作,就会截取到一个未知字符。

+ + +

中间的探索过程就不谈了,Google 了一下解决方案,以及咨询同事们以后,发现最简单的办法是通过 lodash 自带的 toArray 方法,先将它转为数组,然后将整个逻辑改为数据的截取操作,最后再转回字符串。

+
export function safeSubStr (str, start, end) {
const charArr = _.toArray(str);
return _.slice(charArr, start, end).join('');
}
+ +

实际上解决问题的是 _.toArray,它帮我们把表情字符正确地截了出来:

+
_.toArray('😋Emoji😋') // ["😋", "E", "m", "o", "j", "i", "😋"]
+ +

其实我也比较好奇它是怎么做的,通过观察源码,发现了真正的解决方案:

+
// lodash/_unicodeToArray.js
/** Used to compose unicode character classes. */
var rsAstralRange = '\\ud800-\\udfff',
rsComboMarksRange = '\\u0300-\\u036f',
reComboHalfMarksRange = '\\ufe20-\\ufe2f',
rsComboSymbolsRange = '\\u20d0-\\u20ff',
rsComboRange = rsComboMarksRange + reComboHalfMarksRange + rsComboSymbolsRange,
rsVarRange = '\\ufe0e\\ufe0f';

/** Used to compose unicode capture groups. */
var rsAstral = '[' + rsAstralRange + ']',
rsCombo = '[' + rsComboRange + ']',
rsFitz = '\\ud83c[\\udffb-\\udfff]',
rsModifier = '(?:' + rsCombo + '|' + rsFitz + ')',
rsNonAstral = '[^' + rsAstralRange + ']',
rsRegional = '(?:\\ud83c[\\udde6-\\uddff]){2}',
rsSurrPair = '[\\ud800-\\udbff][\\udc00-\\udfff]',
rsZWJ = '\\u200d';

/** Used to compose unicode regexes. */
var reOptMod = rsModifier + '?',
rsOptVar = '[' + rsVarRange + ']?',
rsOptJoin = '(?:' + rsZWJ + '(?:' + [rsNonAstral, rsRegional, rsSurrPair].join('|') + ')' + rsOptVar + reOptMod + ')*',
rsSeq = rsOptVar + reOptMod + rsOptJoin,
rsSymbol = '(?:' + [rsNonAstral + rsCombo + '?', rsCombo, rsRegional, rsSurrPair, rsAstral].join('|') + ')';

/** Used to match [string symbols](https://mathiasbynens.be/notes/javascript-unicode). */
var reUnicode = RegExp(rsFitz + '(?=' + rsFitz + ')|' + rsSymbol + rsSeq, 'g');

/**
* Converts a Unicode `string` to an array.
*
* @private
* @param {string} string The string to convert.
* @returns {Array} Returns the converted array.
*/
function unicodeToArray(string) {
return string.match(reUnicode) || [];
}

module.exports = unicodeToArray;
+ +

一大堆正则就不谈了,也不知道它是从哪里找来的这些值,最后组装了一个 reUnicode 正则来实现 unicode 转数组。话又说回来,这么做会不会有性能问题呢?我表示比较担忧。好在项目里面需要这么做的场景不多,字符串也不长,可以如此暴力解决。如果换个场景,还真不好说。也许又需要一种更高效的解决方案了。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2019/benz-and-996/index.html b/2019/benz-and-996/index.html new file mode 100644 index 000000000..82870e7c0 --- /dev/null +++ b/2019/benz-and-996/index.html @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +奔驰事件与 996 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 奔驰事件与 996 +

+ + +
+ + + + +

4S 店之所以敢贪得无厌、明目张胆,我想很大一部分原因是来自于普通人想要维权实在是太过困难了。想要维权,那就相当于:

+
    +
  1. 你得放弃很大一部分的工作时间(甚至丢掉工作)
  2. +
  3. 你得付出前期的诉讼成本
  4. +
  5. 你得面对来自各方的压力(家庭与社会)
  6. +
  7. 你得面对可能最终维权失败的结果
  8. +
+

再结合最近热议的 996,再来看这件事,对于普通人来说实在是太难了。生活与工作本身已经如此不易,要是再来这么一出,谁顶得住啊。也难怪绝大多数人在受到欺负之后最终只能无奈选择忍气吞声。毕竟大多数国人都是很「精明」的,就算维权成功,带来的收益也可能远不如其负面影响,那么为什么要维权呢?

+

996 其实也是同样的道理。公司敢于非法压榨员工,员工却无可奈何,只能通过在 Github 发声聊以自慰。近期互联网大佬频频发声,大谈创业艰难史,可是始终是避重就轻,你想奋斗没有人拦着你,但逼别人奋斗是怎么回事呢?问题的关键是「强制」而不是「996」,没有一人提及。最可笑的是马云的「你要来谈法律,那法律有规定这么齐全的设备吗?有规定这么好的食堂吗?」,可以看出这些站在企业顶端的人都是些什么嘴脸。求求你把这些都撤了,给我发合法的加班费好吗?当然这是不可能的,大佬们会跟你谈梦想,谈兄弟,这些都不成,那您请滚吧。

+

可是有多少人经受得住这种后果呢?一旦维权,即使成功,你也可能被列入行业黑名单,就算他们无法可依。这种事情不是没有先例,我印象中见过好多了。说了一堆废话,最终问题的根源到底在哪里,相信大家都懂的。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2019/blog-migrated-to-vuepress/index.html b/2019/blog-migrated-to-vuepress/index.html new file mode 100644 index 000000000..c7390f9e6 --- /dev/null +++ b/2019/blog-migrated-to-vuepress/index.html @@ -0,0 +1,450 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Blog Migrated to VuePress | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Blog Migrated to VuePress +

+ + +
+ + + + +

博客正式迁移到了 VuePress,有以下两点原因:

+
    +
  1. 想做一个极简化改版,但懒得折腾了
  2. +
  3. 希望以后重心放在写文章,而不是维护博客上
  4. +
+

共勉。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2019/centos7-firewall-commands/index.html b/2019/centos7-firewall-commands/index.html new file mode 100644 index 000000000..daa42fe05 --- /dev/null +++ b/2019/centos7-firewall-commands/index.html @@ -0,0 +1,511 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +CentOS7 Firewalld | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ CentOS7 Firewalld +

+ + +
+ + + + +

FirewallD (firewall daemon) 作为 iptables 服务的替代品,已经默认被安装到了 CentOS7 上面。

+ + +

管理

服务启动/停止

启动服务并设置自启动:

+
sudo systemctl start firewalld
sudo systemctl enable firewalld
+ +

停止服务并禁用自启动:

+
sudo systemctl stop firewalld
sudo systemctl disable firewalld
+ +

检查运行状态

sudo firewall-cmd --state
sudo systemctl status firewalld
+ +

服务重启

有两种办法可以重启 FirewallD:

+
    +
  1. 重启 FirewallD 服务
  2. +
+
sudo systemctl restart firewalld
+ +
    +
  1. 重载配置文件(不断开现有会话与连接)
  2. +
+
sudo firewall-cmd --reload
+ +

建议使用第二种方法。

+

配置

FirewallD 使用两个配置集:「运行时配置集」以及「持久配置集」。

+
    +
  1. 在 FirewallD 运行时:
      +
    1. 对运行时配置的更改即时生效
    2. +
    3. 对持久配置集的更改不会被应用到本次运行中
    4. +
    +
  2. +
  3. 在 FirewallD 重启(如系统重启或服务重启)或重载配置时:
      +
    1. 运行时配置集的更改不会被保留
    2. +
    3. 持久配置集的更改作为新的运行时配置而应用
    4. +
    +
  4. +
+

默认情况下,使用 firewall-cmd 命令对防火墙做出的更改都将作用于运行时配置集,但如果添加了 permanent 参数则可以将改动持久化。如果要将规则同时添加到两个配置集中,有两种方法:

+
    +
  1. 将规则同时添加到两个配置集中
  2. +
+
sudo firewall-cmd --zone=public --add-service=http --permanent
sudo firewall-cmd --zone=public --add-service=http
+ +
    +
  1. 将规则添加到持久配置集中,并重载
  2. +
+
sudo firewall-cmd --zone=public --add-service=http --permanent
sudo firewall-cmd --reload
+ +

区域

区域(Zone)是 FirewallD 的核心特性,其它所有特性都与 Zone 相关,Zone 可以理解为场景、位置等,我们可以给不同的 Zone 定义不同的规则集。

+

FirewallD 的默认配置中预定义了几个 Zone,按照可信度作升序排序依次为:drop -> block -> public -> external -> dmz -> work -> home -> internal -> trusted,其中 public 是默认值。

+

相关指令:

+
# list all zones
sudo firewall-cmd --get-zones

# get & set default zone
sudo firewall-cmd --get-default-zone
sudo firewall-cmd --set-default-zone=external

# interfaces
sudo firewall-cmd --zone=public --add-interface=wlp1s0
sudo firewall-cmd --zone=public --change-interface=wlp1s0

# get a list of all active zones
sudo firewall-cmd --get-active-zones

# print information about a zone
sudo firewall-cmd --info-zone public
+ +

端口

使用 --add-port 参数来打开一个端口以及指定它的协议,zone 如果不指定的话则为当前的默认值。例如,通过以下命令来允许 HTTP 以及 HTTPS 协议的网络流量进入:

+
sudo firewall-cmd --zone=public --permanent --add-port=80/tcp --add-port=443
sudo firewall-cmd --reload
+ +

通过 info 指令可以查看刚才添加的端口:

+
sudo firewall-cmd --info-zone public
+ +

使用 --remove-port 参数来阻止或关闭一个端口:

+
sudo firewall-cmd --zone=public --permanent --remove-port=80/tcp --remove-port=443/tcp
+ +

服务

使用 --add-service 以及 --remove-service 来启用、禁用服务。

+
# enable
sudo firewall-cmd --zone=public --permanent --add-service=http
sudo firewall-cmd --reload

# disable
sudo firewall-cmd --zone=public --permanent --remove-service=http
sudo firewall-cmd --reload
+ +

端口转发

# 启用 ip masquerade
sudo firewall-cmd --zone=public --add-masquerade

# 在同一台服务器上将 80 端口的流量转发到 8080 端口
sudo firewall-cmd --zone="public" --add-forward-port=port=80:proto=tcp:toport=8080

# 将本地的 80 端口的流量转发到 IP 地址为 :1.2.3.4 的远程服务器上的 8080 端口
sudo firewall-cmd --zone="public" --add-forward-port=port=80:proto=tcp:toport=8080:toaddr=1.2.3.4

# 删除规则
sudo firewall-cmd --zone=public --remove-masquerade
+ + +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2019/conditional-rendering-in-react/index.html b/2019/conditional-rendering-in-react/index.html new file mode 100644 index 000000000..e9a55bc89 --- /dev/null +++ b/2019/conditional-rendering-in-react/index.html @@ -0,0 +1,504 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Conditional Rendering in React | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Conditional Rendering in React +

+ + +
+ + + + +

如何进行条件渲染是一个 MVx 框架最基础的问题之一,但是它在 React 中总是会给人提出各种各样的问题。要么「不够优雅」,要么「不够可靠」,要么「不够好用」,现有的各种各样的方法之中,总是逃不过这三种问题的其中之一。至于 React-Native,虽然它与 React 「原则上一致」,但它存在的问题实际上就是要比 React 更多一些。

+ + +

if 语句与三元表达式

在 JSX 世界中,用 if 语句以及三元表达式去完成条件渲染是最直观的方式。

+
function Greeting(props) {
const isLoggedIn = props.isLoggedIn;
// the 'if' way
if (isLoggedIn) {
return <UserGreeting />;
}
return <GuestGreeting />;
// or the 'conditional operator' way
// return isLoggedIn ? <UserGreeting /> : <GuestGreeting />;
}
+ +

这种方式确实足够优雅,也足够可靠:毕竟它完美地沿用了语言本身的逻辑和语法,没有创造其它累赘的东西。但是它真的好用吗?我相信许多重度 React 使用者都会对此表示无奈:现实工程中诸如此类需要做条件渲染的地方多如牛毛,如果每一处我们都得给它写 if-else(三元表达式虽然相对来说更好用一些,但是它的应用场景毕竟更有限)并且将条件渲染体抽离主体作为子组件来做,那我真的好绝望,这感觉就好像是在现代社会躬行刀耕火种一样。况且不是所有的项目都需要「完美地优雅」,更多时候我们这种开发者只想尽快把工作完成,仅此而已。

+

实际上这种方案已经足以应付 100% 场景的需求了,并且你可能已经意识到,本质上来说这就是唯一的方案。但其存在的问题实在过于让人沮丧,因此才有了下面的一些「拓展」方案。

+

使用变量

React 官方文档提出的第二种方式是使用变量,通过将元素暂存在变量中,可以让开发者控制组件中的一部分而不影响其它内容。

+
class LoginControl extends React.Component {
//...
render() {
const isLoggedIn = this.state.isLoggedIn;
let button;

if (isLoggedIn) {
button = <LogoutButton onClick={this.handleLogoutClick} />;
} else {
button = <LoginButton onClick={this.handleLoginClick} />;
}

return (
<div>
<Greeting isLoggedIn={isLoggedIn} />
{button}
</div>
);
}
}
+ +

这样做的好处是能够更灵活地控制组件内部的渲染,而不必去创建更多的子组件。但是其问题非常明显:创建了一个「多余」的变量,非常地不「优雅」。所谓「如无必要,勿增实体」,放在代码世界同样适用。它所做的事情也仅仅是将原本在 JSX 内部的条件判断挪到了外面,仅此而已。

+

想象一下这种场景:一个导航栏组件,其中的每个菜单、每个按钮都要根据某种条件去决定是否渲染,一个多余的变量就会变成几十个,最终导致代码中充斥着这样重复的、没有实际意义的垃圾,这是一个有追求的码农绝对无法忍受的。

+

这种方式有一个变体,就是通过创建一个类的 getter 来代替创建一个变量:

+
class LoginControl extends React.Component {
//...
get _button () {
const isLoggedIn = this.state.isLoggedIn;
if (isLoggedIn) {
return <LogoutButton onClick={this.handleLogoutClick} />;
} else {
return <LoginButton onClick={this.handleLoginClick} />;
}
}

render() {
return (
<div>
<Greeting isLoggedIn={isLoggedIn} />
{this._button}
</div>
);
}
}
+ +

这是一个小变通,好处是可以让 render 函数在条件渲染的数量上来以后不那么臃肿,易于维护。它与使用变量的方式并没有本质区别,同样是创建了一个无必要的 getter,但是确实是看起来更「优雅」了一些。但它只能在类组件中使用,无法在函数式组件中使用。

+

行内表达式

行内三元表达式可以用来解决一些小的 case,但由于其本身存在着巨大的限制,不可能被广泛使用。

+
render() {
const isLoggedIn = this.state.isLoggedIn;
return (
<div>
The user is <b>{isLoggedIn ? 'currently' : 'not'}</b> logged in.
</div>
);
}
+ +

它的自身限制,一是只能做是非判断,如果要在是非的结果中继续判断就要再套一层三元表达式,那将会相当臃肿;二是它本身的语法就只适用于「小」的东西,像这种案例:

+
render() {
const isLoggedIn = this.state.isLoggedIn;
return (
<div>
{isLoggedIn ? (
<LogoutButton onClick={this.handleLogoutClick} />
) : (
<LoginButton onClick={this.handleLoginClick} />
)}
</div>
);
}
+ +

我是绝对写不出来的。实在是太不优雅、太难以阅读了。想象一下充斥着 && / || / ? / : 的 JSX 代码。:smile:

+

至于行内的 if-else,虽然不能直接写 if-else,但有一个利用了语言本身特性的方案,即利用逻辑操作符:

+
function Mailbox(props) {
const unreadMessages = props.unreadMessages;
return (
<div>
<h1>Hello!</h1>
{unreadMessages.length > 0 &&
<h2>
You have {unreadMessages.length} unread messages.
</h2>
}
</div>
);
}
+ +

这种方式适合用来做单方面的条件渲染,即条件成立则渲染,否则不渲染,或者反之。使用场景依然有限,同样可读性较差,只适合用来渲染较小的代码块。

+

而且,很多人也许不知道,这种写法在 React-Native 中是一个陷阱。由于 JavaScript 本身的特性允许(或者说鼓励)truthyfalsy 形式的条件判断,很多情况下我们不会去刻意将它显式转换为一个 Boolean 值。当然在大多数情况下这都没有问题,除非它是一个空字符串:

+
render() {
const isLoggedIn = this.state.isLoggedIn;
// when it is ''
return (
<View>
{isLoggedIn && <LogoutButton/>}
</View>
);
}
+ +

这种代码在运行的时候会抛出一个 Error,导致应用崩溃:

+
Error: Text strings must be rendered within a <Text> component.
+ +

比如说,当你用某个 API 返回的数据中的某个值去进行条件渲染的时候,正常来说没有问题,但某一天服务突然出错了,这个字段返回了一个空字符串,那么应用就会突然面临大规模的崩溃。数据来源不可靠且没有进行显式 Boolean 转换的条件判断就像一个地雷,随时随地都可能会爆炸。

+

封装的方法

以上的几种形式其实都与刀耕火种无异,因此我们还有更高级的方案,比如封装一个方法:

+
function renderIf (flag) {
return function _renderIf (componentA, componentB = null) {
return flag ? componentA : componentB;
};
}
+ +

这样一来代码就可以简洁多了:

+
class LoginControl extends React.Component {
//...
render() {
const isLoggedIn = this.state.isLoggedIn;
return (
<div>
<Greeting isLoggedIn={isLoggedIn} />
{renderIf(isLoggedIn)(<LogoutButton/>, <LoginButton/>)}
</div>
);
}
}
+ +

类似的方案还有封装组件:

+
function RenderIf ({flag, children}}) {
return flag ? children : null;
}

class LoginControl extends React.Component {
//...
render() {
const isLoggedIn = this.state.isLoggedIn;
return (
<div>
<Greeting isLoggedIn={isLoggedIn} />
<RenderIf flag={isLoggedIn}>
<LogoutButton/>
</RenderIf>
<RenderIf flag={!isLoggedIn}>
<LoginButton/>
</RenderIf>
</div>
);
}
}
+ +

这种方案避免了在渲染函数体内出现大量的逻辑控制语句,取而代之的是可读性更强,也更易于使用的方法或组件,可以认为是在 React 世界中比较好的条件渲染解决方案了,「优雅」与「好用」都得到了较好的兼顾。但是,问题就出在,它不够「可靠」。

+

比如说以下的代码:

+
class NavBar extends React.Component {
//...
render() {
const user = this.state.user;
return (
<div>
{renderIf(user)(<Welcome name={user.name}/>)}
</div>
);
}
}
+ +

跟以上提及过的所有方案都不一样的是,当 usernullundefined,或者其它任何在执行 user.name 会报错的 value 时,这一段代码就会报错。如果是 React-Native 应用,很不幸它就崩溃了。

+
TypeError: Cannot read property 'name' of null
+ +

导致这个区别的原因是,JavaScript 函数在执行之前会先对它的参数进行解析,而非等到真正运行时才解析。所以当 user 为以上 value 之一的时候,虽然按照函数的流程来说应该会对第一个参数直接忽略,但是实际上它还是被解析了。而 if-else 等内置条件判断语句则不同,假值条件之后的代码块会被完全忽略。

+

说到这里我已经开始怀念 ng-ifv-if 了:我只是一个普普通通的开发者,为什么我需要在如此基础的事情上考虑这么多?大家都是前端 MVx 框架,为什么 Vue.js 和 Angular.js 从来没有提出过这种问题?

+

大概这就是 React,这就是 Facebook 吧!

+

其实在这个方案出现问题之后,我已经找不出别的更好的方案了,除非某一天 EcmaScript 有了新的提案,新的语法,否则都将无解。因为无论怎么做,最终都无法绕过传参必然会被事先解释这一障碍。也就是说,在目前的大环境下,我无法得到一个在各种场景下都同时兼具「优雅」、「可靠」、「好用」的条件渲染解决方案,只能通过以上各种方案在不同场景下的混用来达到一个(有追求的码农的内心的)平衡。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2019/koa-js-art-of-code/index.html b/2019/koa-js-art-of-code/index.html new file mode 100644 index 000000000..adf48c9a7 --- /dev/null +++ b/2019/koa-js-art-of-code/index.html @@ -0,0 +1,540 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +代码的艺术:koa 源码精读 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 代码的艺术:koa 源码精读 +

+ + +
+ + + + +

Node.js 界大名鼎鼎的 koa,不需要多废话了,用了无数次,今天来拜读一下它的源码。

+

Koa 并不是 Node.js 的拓展,它只是在 Node.js 的基础上实现了以下内容:

+
    +
  • 中间件式的 HTTP 服务框架 (与 Express 一致)
  • +
  • 洋葱模型 (与 Express 不同)
  • +
+

一统天下级别的框架,只包含了约 500 行源代码。极致强大,极致简单。大概这就是码农与码神的区别,真正的代码的艺术吧。

+ + +

源码结构如下:

+
lib
├── application.js
├── context.js
├── request.js
└── response.js
+ +

一共就这四个文件(当然,还包含了发布在 npm 上面的其它 package,后面会说到),一目了然。

+
    +
  1. application.js 是应用的入口,也就是 require('koa') 所得到的东西。它是一个继承自 events 的 Class
  2. +
  3. context.js 就是对应每一个 req / res 的 ctx
  4. +
  5. request.js / response.js 就不用说了
  6. +
+

下面从最基础的看起。

+

request.js

request.js 大概的样子如下:

+
// request.js
module.exports = {
get header() {
return this.req.headers;
},
set header(val) {
this.req.headers = val;
},
get headers() {
return this.req.headers;
},
set headers(val) {
this.req.headers = val;
},
// 其它 getter & setter......
}
+ +

这里的 this.req 实际上是 http.IncomingMessage,创建的时候传入的,后面会提到。

+

这个文件绝大多数是 helper 方法,把本来已经存在在 http.IncomingMessage 中的属性通过更方便的方式(getter / setter)来存取,以达到通过同一个属性来读写的目的。比如想要获取一个 request 的 header 时,通过 ctx.request.heder,而想写入 header 时,可以通过 ctx.request.heder = xxx 来实现。这也是 koa 的友好特性之一。

+

其中有一个特殊的是 ip

+
// request.js
const IP = Symbol('context#ip');

module.exports = {
// ...
get ip() {
if (!this[IP]) {
this[IP] = this.ips[0] || this.socket.remoteAddress || '';
}
return this[IP];
},
set ip(_ip) {
this[IP] = _ip;
},
// ...
}
+ +

Symbol('context#ip')request 对象唯一一个来自自身的 key,我猜测它的目的是:

+
    +
  1. 允许开发者对真实请求 ip 进行改写
  2. +
  3. 同时利用 Symbol 不等于任何值的特性,使它成为私有属性,对外不可见,只可通过 getter 获取
  4. +
+

response.js

response.jsrequest.js 类似,不同之处在于,response.js 重点更多在 setter 上面,很好理解,因为 response 的重点是一个服务器向用户返回内容的过程。

+

koa 的一大特性是在于,只需要向 ctx.response.body 赋值就能完成一次请求响应。代码:

+
module.exports = {
// ...
get body() {
return this._body;
},
set body(val) {
const original = this._body;
this._body = val;

// no content
if (null == val) {
if (!statuses.empty[this.status]) this.status = 204;
this.remove('Content-Type');
this.remove('Content-Length');
this.remove('Transfer-Encoding');
return;
}

// set the status
if (!this._explicitStatus) this.status = 200;

// set the content-type only if not yet set
const setType = !this.header['content-type'];

// string
if ('string' == typeof val) {
if (setType) this.type = /^\s*</.test(val) ? 'html' : 'text';
this.length = Buffer.byteLength(val);
return;
}

// buffer
if (Buffer.isBuffer(val)) {
if (setType) this.type = 'bin';
this.length = val.length;
return;
}

// stream
if ('function' == typeof val.pipe) {
onFinish(this.res, destroy.bind(null, val));
ensureErrorHandler(val, err => this.ctx.onerror(err));

// overwriting
if (null != original && original != val) this.remove('Content-Length');

if (setType) this.type = 'bin';
return;
}

// json
this.remove('Content-Length');
this.type = 'json';
},
// ...
}
+ +

可以看到,在 body 的 setter 里面,分别对传入的值为 null / string / buffer / stream / json 的情况进行了处理,并完成了向客户端返回的其它逻辑(设置各种响应头以及状态码),以达到上述目的。

+

为了达到「至简」目的,koa 对外暴露的 API 基本都是通过 getter / setter 的方式实现的,值得借鉴。

+

context.js

Context 「上下文」(通常简写为 ctx)是 koa 的核心之一,它代表了一次用户请求,每个请求都对应着一个独立的 context,实际上它就是 requestresponse 的结合体,通过「委托模式」实现。它的作用是,开发者对于每一个请求,只需要拿到它的 ctx,就能获取到所有请求的相关信息,亦能做出任何形式的响应。

+

它的核心代码如下:

+
'use strict';
const delegate = require('delegates');
const Cookies = require('cookies');

const COOKIES = Symbol('context#cookies');

const proto = module.exports = {
// ...
get cookies() {
if (!this[COOKIES]) {
this[COOKIES] = new Cookies(this.req, this.res, {
keys: this.app.keys,
secure: this.request.secure
});
}
return this[COOKIES];
},
set cookies(_cookies) {
this[COOKIES] = _cookies;
}
};

delegate(proto, 'response')
.method('attachment')
.method('redirect')
.method('remove')
.method('vary')
.method('set')
.method('append')
.method('flushHeaders')
.access('status')
.access('message')
.access('body')
.access('length')
.access('type')
.access('lastModified')
.access('etag')
.getter('headerSent')
.getter('writable');

delegate(proto, 'request')
.method('acceptsLanguages')
.method('acceptsEncodings')
.method('acceptsCharsets')
.method('accepts')
.method('get')
.method('is')
.access('querystring')
.access('idempotent')
.access('socket')
.access('search')
.access('method')
.access('query')
.access('path')
.access('url')
.access('accept')
.getter('origin')
.getter('href')
.getter('subdomains')
.getter('protocol')
.getter('host')
.getter('hostname')
.getter('URL')
.getter('header')
.getter('headers')
.getter('secure')
.getter('stale')
.getter('fresh')
.getter('ips')
.getter('ip');
+ +

可以看到里面的主要内容有:

+
    +
  1. 实现 Cookies 的 getter / setter(因为 koa 把 req 和 res 的 cookies 结合在一起了,所以它须要在 ctx 内实现)
  2. +
  3. 将 request / response 的逻辑代理到 ctx 上面
  4. +
+

关于这个「委托模式」的具体实现,TJ 把它放到了一个独立的 NPM Package delegates 中。它的功能是:将一个类的子类中的方法与属性,暴露到父类中去,而暴露在父类上的方法可以看做真实方法的「代理」。koa 使用了其中的三种模式,分别是:

+
    +
  1. method 代理方法
  2. +
  3. access 代理 getter 与 setter
  4. +
  5. getter 仅代理 getter
  6. +
+

其主要源码:

+
module.exports = Delegator;

function Delegator(proto, target) {
if (!(this instanceof Delegator)) return new Delegator(proto, target);
this.proto = proto;
this.target = target;
this.methods = [];
this.getters = [];
this.setters = [];
this.fluents = [];
}

Delegator.prototype.method = function(name){
var proto = this.proto;
var target = this.target;
this.methods.push(name);

proto[name] = function(){
return this[target][name].apply(this[target], arguments);
};

return this;
};

Delegator.prototype.access = function(name){
return this.getter(name).setter(name);
};

Delegator.prototype.getter = function(name){
var proto = this.proto;
var target = this.target;
this.getters.push(name);

proto.__defineGetter__(name, function(){
return this[target][name];
});

return this;
};

Delegator.prototype.setter = function(name){
var proto = this.proto;
var target = this.target;
this.setters.push(name);

proto.__defineSetter__(name, function(val){
return this[target][name] = val;
});

return this;
};
+ +

依然非常简洁。method 代理使用 Function.apply 实现,getter / setter 代理使用 object.__defineGetter__object.__defineSetter__ 实现。

+

application.js

去除兼容、校验、实用方法等逻辑,精简过后,该文件的主要内容如下:

+
'use strict';
const onFinished = require('on-finished');
const response = require('./response');
const compose = require('koa-compose');
const context = require('./context');
const request = require('./request');
const Emitter = require('events');
const util = require('util');

/**
* Expose `Application` class.
* Inherits from `Emitter.prototype`.
*/

module.exports = class Application extends Emitter {

constructor() {
super();

this.middleware = [];
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}

listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}

use(fn) {
this.middleware.push(fn);
return this;
}

callback() {
const fn = compose(this.middleware);

const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};

return handleRequest;
}

handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

createContext(req, res) {
const context = Object.create(this.context);
const request = context.request = Object.create(this.request);
const response = context.response = Object.create(this.response);
context.app = request.app = response.app = this;
context.req = request.req = response.req = req;
context.res = request.res = response.res = res;
request.ctx = response.ctx = context;
request.response = response;
response.request = request;
context.originalUrl = request.originalUrl = req.url;
context.state = {};
return context;
}

onerror(err) {
if (!(err instanceof Error)) throw new TypeError(util.format('non-error thrown: %j', err));

if (404 == err.status || err.expose) return;
if (this.silent) return;

const msg = err.stack || err.toString();
console.error();
console.error(msg.replace(/^/gm, ' '));
console.error();
}
};
+ +

不到一百行,Koa 主要功能已经全在里面了。

+

现在可以梳理一下当我们创建一个 koa 服务器的时候,实际上都干了些什么吧:

+
    +
  1. 调用 constructor,初始化 ctx / req / res,以及最重要的 middleware 数组(不过不理解的是,为什么命名没有加 s 呢?)
  2. +
  3. 对于各种业务场景,调用 app.use,这一步只是一个简单的向 middleware 数组 push 的过程
  4. +
  5. 调用 app.listen,启动 HTTP 服务器
  6. +
  7. 对于每一个进来的请求,调用 callback 方法,这个方法做了三件事:
      +
    1. 通过 koa-compose 将中间件数组组合为一个「洋葱」模型
    2. +
    3. 调用 createContext 方法,为请求创建 ctx 上下文,同时挂载 req / res
    4. +
    5. 调用 handleRequest 方法,按洋葱模型的顺序执行中间件,并最终返回或报错
    6. +
    +
  8. +
+

这里面最重要的一步就是「洋葱」模型的构建。实际上这个过程也非常简单,以下是 koa-compose 的源码(为了精简,已去除校验等逻辑):

+
function compose (middleware) {
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
+ +

它是一个递归:

+
    +
  1. 首先约定,每一个 middleware 都是一个 async 函数(即 Promise),接受两个参数 ctxnext
  2. +
  3. 当 middleware 内部调用 next 函数时,实际上是递归调用了 dispatch.bind(null, i + 1) 函数,也就是,将 index + 1 的中间件取出来并执行了。因为中间件都是 Promise,所以能够被 await
  4. +
  5. 递归执行步骤 2,直到调用到最后一个 middleware 时,最后被调用的 middleware 会最先结束,然后到上一个,再到上上一个,如此往复就形成了「洋葱」模型
  6. +
  7. 最终所有 middleware 都执行完毕,compose 函数返回 Promise.resolve(),即退出递归
  8. +
+

「洋葱」模型构建完毕后,compose 函数返回一个 Promise,所有 middleware 都已经被有序串联,只需要直接执行该 promise 实例即可。

+

让人不禁感叹:大道至简

+

end

至此,koa 的最主要的功能实现都已过了一遍了。

+

总结一下它做了的事情:

+
    +
  1. 通过 getter / setter 方法简化 Node.js HTTP 的使用方式
  2. +
  3. 通过 ctx 简化开发者访问 req / res 的方式
  4. +
  5. 通过「洋葱」模型简化 HTTP 请求的处理流程
  6. +
+

大概就这样。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2019/my-crohns-disease-and-treatment-records/index.html b/2019/my-crohns-disease-and-treatment-records/index.html new file mode 100644 index 000000000..6eec697ad --- /dev/null +++ b/2019/my-crohns-disease-and-treatment-records/index.html @@ -0,0 +1,529 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +克罗恩病患病与治疗记录 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 克罗恩病患病与治疗记录 +

+ + +
+ + + + +
+

克罗恩病是一种原因不明的肠道炎症性疾病,在胃肠道的任何部位均可发生,但好发于末端回肠和右半结肠。本病和慢性非特异性溃疡性结肠炎两者统称为炎症性肠病(IBD)。本病临床表现为腹痛、腹泻、肠梗阻,伴有发热、营养障碍等肠外表现。病程多迁延,反复发作,不易根治。本病又称局限性肠炎、局限性回肠炎、节段性肠炎和肉芽肿性肠炎。目前尚无根治的方法,许多病人出现并发症,需手术治疗,而术后复发率很高。本病的复发率与病变范围、病症侵袭的强弱、病程的延长、年龄的增长等因素有关,死亡率也随之增高。

+
+

现在是 2019 年 10 月,大约是我患克罗恩病(CD)的第 10 个年头。我写这篇记录的目的是记录自己的治疗过程,同时也为他人提供参考。

+ + +

患病经历

大约在 10 年前,我高二在读,那时候第一次出现以肠胃为主的身体不适,主要表现为发热、腹泻、腹胀、腹痛,但是由于缺乏经验与警觉,以为是普通的肠胃炎,并没有引起重视。秉着能拖就拖的精神,可能持续了一到两个月,拖到实在是受不了了,就回家进行了第一次就医、住院。诊治医院为广东省四会市万隆医院(二甲),做了胃镜、肠镜,诊断为胃溃疡+肠溃疡(年代久远,记不太清了,大概是这样)。静脉注射了一种我记得叫雷尼替丁的药物,辅以口服抗生素,经过约 1~2 周的治疗,病情好转,大便成形,即转出院。

+

之后经过了较长一段时间的缓解期,期间几乎与常人无异,直到高三末期,炎症复发。

+

那段时间的印象比较深刻,也是因为症状达到了一种相当严重的地步,午后(大概六点过后)高热不退,频繁腹泻(几乎每节课下课后我都必须在第一时间上厕所),腹痛等等。但由于临近高考,一直没有请假治疗。直到高考前一个月左右,我实在是无以为继了,于是请假,继续来到上述医院住院诊治。这次医生排除了肿瘤、结核等疾病,最后诊断为慢性溃疡性结肠炎(UC),治疗方式大致同上,同样在经过约 1~2 周的治疗以后,病情好转,大便成形,即转出院。出院后我依然在遵医嘱口服大量抗生素,书桌里几乎塞满了药物。持续到高考结束。

+

但是这一次治疗以后状态的持续明显没有第一次那么久,症状很快又回来了。

+

大学四年时期是我病症拖延最严重的时期,午后发热、频繁腹泻腹痛,期间也经历了一次住院,但由于个人原因产生了一些消极情绪,不配合检查,诊断也没有进展,依旧按照之前的方法在治疗。然而这一次治疗效果持续时间则更短,让我感到极其负面,也逐渐开始拒绝就医,觉得医生也没办法治好我。开始自作主张服用先前医生开过的抗生素,自然是毫无疗效,让我越发沮丧。四年期间病情大约一半时间处于缓解期,另一半则处于活动期,断断续续,时好时坏。苦不堪言。即使在这种时候,我依然觉得我得的只是普通的慢性肠炎,丝毫没有意识到即使是溃疡性结肠炎(UC)其实也是一种严重的终生不愈的疾病,况且我还不是 UC。

+

可以预见,这种盲目乐观所带来的惨痛代价即将到来。

+

第一次手术

2015 年 9 月某日,我突然觉得屁股开始疼痛,这股疼痛极速发展,很快便到了让人坐立不安的程度。期间我慢慢地感受到肛周某处有一肿块,开始是坐着有点疼,后来是坐着疼走路也疼,最后发展到即使躺着也疼。期间某一天忍无可忍去中大五院看了急诊,急诊医生诊断为肛周脓肿,建议手术。但由于我感到害怕,要求先进行保守治疗试试,于是就打了几天的静脉消炎滴注,然而并没有什么卵用,脓肿依然极速发展,但仍然没有引起我的重视,我总抱着一种它会自愈的天真想法,国庆期间甚至还带病回了趟家。直到假期结束,脓肿发展到让我即使躺着不动也大汗淋漓的时候,我才意识到问题的严重性:手术治疗已经迫在眉睫。由于三甲医院需要等床位,我一刻都等不了了,便找了一家莆田医院。当天的情况时至今日我依然记忆犹新:公交下车走到医院几百米的距离,几乎是要了我的命。

+

外科医生为我做了根治术,即脓肿切除+挂线,一共挂了三根橡皮筋,愈合过程大概持续了两个月,期间苦不堪言。

+

由于愈合时间持续过长,让我感到情绪低落,大概在两个月后我因此又住进了中大五院肛肠外科,也是在这个时期内第一次有医生提到了,我可能是患了克罗恩病(CD)。

+

此时,距我症状初现的时候已经过去了大约六年。

+

我和家属迅速地查阅了 CD 的相关资料,不料其症状竟如此亲切。住院期间在医生建议下进行了 CT 检查,随即确诊为 CD。外科医生开立口服美沙拉秦治疗方案,剂量高达 12 片一天 (6.0g/d)。

+

这个剂量的美沙拉秦我总共服用了大概有两年多,中间有一段时间因为自我感觉良好以及美沙拉秦费用高昂,擅自决定停药长达约一年,后来在 18 年的时候症状复发,在医生建议下做了一次肠镜,发现肠道已呈鹅卵石状,并有狭窄,只能进镜 25cm,便又开始服用。期间一直是找的胃肠外科医生诊治。状况时好时坏,偶尔会出现一些体外症状,如发热,虹膜炎,关节疼痛,牙齿松动,肛周疼痛等,但会自愈,所以也没有太注意。

+

第二次手术

18 年底开始的一年多时间内我大部分时间都处于无症状期,让我再一次误以为我的疾病已经被控制住了。这次我没有停药,坚持服用美沙拉秦,但开始考虑减少剂量。

+

直到 2019 年 8 月,我因持续午后发热与肛周脓肿复发不愈,再次住院手术,此后我才对外科医生的诊治产生了怀疑,开始更深入地学习克罗恩病,发现此病应该看消化内科,外科只能作为内科用药控制失败的辅助治疗。说来也可笑,后来内科医生得知我是克罗恩病时当即就提出了一个疑问,说我为什么一直在外科看病。我也是觉得奇怪,因为我不知道啊,外科医生也没有建议我转诊到内科啊?倒是第二次肛周脓肿手术住院时期,有一个从外地来进修的医生详细询问我的病症后提示我不应该看外科,应该去看免疫科。

+

说来也是奇怪,在手术前一天我还是持续午后发热状态,手术后第一天开始直到一个月后我痊愈上班,期间都再没有发热过。而上班第一天我又开始了午后发热,这立即让我产生警觉。我开始尝试排除一些因素,如中午休息、饮用水、三餐等。后来发现,只要我不喝公司的饮用水,我就不会发热(公司的饮用水是直饮水,可能存在某些不得而知的问题)。后来再经过观察,公司的水如果烧开再引用,也没有问题。

+

第二次手术后我开始变得警觉起来,会观察各种食物对我产生的影响,避免会导致问题的食物。在这期间我开始觉得饮食确实非常重要,可以对疾病控制起到至关重要的作用。

+

在消化内科看完后,发现内科医生与外科医生有许多说法不一致的地方,用药也是天差地别,更是提出我一直在坚持服用 5-ASA 类药物(美沙拉秦,柳氮氨磺吡啶)对此病几乎毫无作用,这才知道之前我到底走了多少弯路,不由感叹。内科医生提示我病情已较严重,建议使用生物制剂治疗(类克),费用高昂但疗效立竿见影,并当即要求住院检查诊治。也就是在这个时间点,我写下了这篇记录。

+

以后的治疗进展,我会持续更新。

+

2019/10/21

在中大五院住院 8 天。

+

住院期间做了肠镜、胃镜、CTE(小肠造影)、MRI(盆腔),肠镜病理没有取到诊断依据,由于 CTE 显示小肠(除回肠末端外)无异常,胃镜大部分正常,让医生的诊断陷入了难处:无法确诊是 UC 还是 CD,经过 MDT 会诊后,主任医生建议:全肠内营养,复查胸部 CT,排除结核后开始用激素治疗。由于我目前处于缓解期,暂时不想接受进一步检查和治疗,医生也表示理解、同意,所以就出院了。

+

从检查结果上看,目前存在的问题是:

+
    +
  1. 部分结肠存在结节样隆起(鹅卵石),导致部分肠腔狭窄,并且已纤维化
  2. +
  3. 肛瘘(1 个内口,1 个外口)
  4. +
+

可能导致的严重结果:

+
    +
  1. 肠梗阻
  2. +
  3. 肠穿孔
  4. +
+

除此以外倒还好。住院期间医生也建议类克,不过我觉得还是等活动期再打吧,毕竟现在打也不好评估疗效。至于肠内营养,每个医生的建议都有区别,有建议全肠内营养的,有建议肠内营养为主的,有建议日常饮食为主的,我目前开始吃安素作为辅助营养,希望能有帮助。

+

2019/12/25

在中大六院住院 7 天。

+

做了肠镜、胃镜、MRE,以及一堆抽血项目,检查结果和上次差不多。由于存在狭窄不能用类克,另外由于营养不良用激素效果预计也不好,所以医生建议禁食+鼻饲三个月后再复查,说是鼻饲的疗效与激素相当,有可能可以让炎症愈合以及让炎性狭窄缓解,于是就插管了。每天 4 瓶百普力 + 12 勺安素,三个月。虽然难受但是也没办法。

+

六院有许多同病相怜的病友,我也从中获得了一些帮助与鼓励。

+

2020/04/07

肠内营养两周后复查了血常规,结果显示 CRP 12.08mg/L,但是血小板达到了 500 多,ESR 依然高位 69 mm/h,血红蛋白有所好转,结果有所好转,但不是很理想。

+

四周后复查了肠道彩超,报告总结:

+
+

6组小肠、回肠末段、升结肠、横结肠、降结肠多发肠壁增厚,血供不丰富,考虑炎症不活动,请结合临床。

+
+

这个报告看起来还不错。

+

三个月后住院大复查,CRP 30+,ESR 40+,血小板接近正常值,血红蛋白等回到正常值。

+

肠镜结果并不理想。因为结肠有多处狭窄,乙状结肠的稍有好转,肠镜能通过了,但降结肠的狭窄肠镜依然无法通过。但医生说炎症有所好转,直观的表现就是以往肠镜报告见得最多的字眼是「充血水肿」,这次没有了,改为「粘膜粗糙」。我猜可能是愈合的表现?

+

CTE 结果同样不理想。提示病变较前区别不大。

+

虽然结果在我看来不太好,但医生说从炎症角度来看好转还是明显的。根据检查结果,医生经过慎重考虑,并且与胡品津教授商定后,建议我做外科手术,把结肠整个切除,造口一段时间,然后把小肠与直肠接上。医生的考虑是我的小肠是好的,所以希望通过完全切除病变部位来达到一个较好的短期效果,同时避免因为病变肠道可能带来的风险:比如说由于狭窄而导致的梗阻,以及狭窄前端肠壁变薄导致的扩张穿孔,以及直肠病变控制不好导致不得不切除直肠,需要终生造口等。

+

但说实话这个结果对我打击很大,思考了很久,也与家人商量了,我们还是不愿意接受手术,希望先维持保守治疗,不想这么快放弃病变肠道。我完全相信医生给的方案是综合各种因素权衡利弊以后给出的最佳方案,医生的目的就是让病人尽快恢复正常生活,可以正常饮食。但是我作为病人我有自己的考虑,我想要的是长期利益。只要还没有到最后关头,我不愿意就这么放弃了。

+

更何况这个病不是切一次就能痊愈的。我不是没见过手术做了十几次肠子已经切无可切的病友。

+

好在医生最后也同意我保守治疗,但是代价就是需要继续严格鼻饲三个月。因此我在定方案以后更换了一条鼻饲管,并在当天(03/18)接受了第一次类克治疗(300mg),第二天就出院了。

+

在注射类克以后的第二天起,我的身体体征就有大幅改善,在注射类克之前平均每天约 4-5 次水便,便量较多。注射以后平均每天 1-2 次稀便,同时大便总量有大幅减少,大概只有以前的 1/4,或者更少。同时肛瘘部位也安静了,不肿不痛不痒不流脓。

+

两周以后(04/01),我在中大五院接受了第二次类克治疗。这一次抽血复查结果有大幅改善。CRP 与 ESR 都已接近正常值,其它血液指标也较好,可以说是我四五年来做过的最好的一次血常规。

+

各种指征是好了很多,但是我现在最担心的就是第四次类克前的复查,万一结果还是不好,那我还是避免不了要考虑手术的事。

+

2020/05/21

在 4 月 29 日接受了第三次类克治疗,各项指标基本正常。

+

5 月份开始没有继续用百普力,换用了较为便宜的瑞素。没想到瑞素可能更适合我,自从用瑞素开始大便就变成了两天一次,而且能成型。经过 20 多天以后,现在大便稳定一天一次。

+

现在就等第四次类克复查了。

+

2020/06/22

6 月 15 (周一)号到中六住院,做了检查(结肠镜、CTE),结果意外地好。于是 18 号(周四)就拔了鼻饲管,打了第四次类克,出院了。

+

这一次的肠镜结果:

+
+

插镜情况: 进镜约65cm顺利达回肠末段。
回肠末段:所见黏膜未见异常。
回 盲 瓣: 所见黏膜未见异常,回盲瓣呈唇状。
阑尾内口: 阑尾口呈弧形。
盲 肠:所见黏膜未见异常。
升 结 肠:所见黏膜未见异常。
结肠肝曲:所见黏膜未见异常。
横 结 肠-乙状结肠:距肛缘约27-55cm见散在直径约0.3-0.6cm结节样增生及疤痕改变,距肛缘约37cm及30cm分别感肠腔狭窄稍固定,直径约10.5mm内镜可勉强通过。
直 肠: 所见黏膜未见异常。
肛  门: 未见异常。

+
+

CTE 结果:

+
+

克罗恩病复查:回肠末段、盲肠、升结肠、横结肠、降结肠多节段病变伴结肠部分狭窄,病变范围较前缩小,炎症程度较前好转,提示缓解期。

+
+

虽然狭窄依然存在,但是黏膜完全愈合,是这么多年来最正常的一次了。因此医生没有建议立刻进一步的治疗,而是说过一段时间再看看需不需要做内镜扩张。另外,主任认为类克对我效果好,建议加依木兰加强疗效。但是这一次出院暂时还不用。说下次门诊再去评估。

+

医生说我可以半肠内营养(也就是可以吃点东西)了。不过我现在并不想立即就恢复饮食。先吃一段时间安素看看吧。毕竟这 6 个月得来的成果,可以说来之不易。

+

2020/10/26

如今我已经打完了 6 次类克,将在 11 月底进行第七次注射,一个疗程也快走完了。

+

6 月份出院以后大约吃了一个月左右的全肠内(安素),然后逐渐转为半肠内 + SCD 食物,到今天已经基本就是 SCD 饮食了。期间没有健康状况没有出过大问题,基本保持在 6 月份的水准:

+
    +
  1. 血液指标基本能够维持在正常范围内;
  2. +
  3. 炎症指标(CRP、血沉)无异常;
  4. +
  5. 体重有所降低,目前大约在 55~56 公斤左右。
  6. +
+

虽然医生一直在建议我加药(硫唑嘌呤),但是我和我的家人都不是很想加。主要原因是它的副作用太大了,尤其是与类克并用的时候,说是毒药也不为过。我们有自信通过食疗来控制病情,因此暂时不想借助这个药物。

+

另外,值得一提的一点是,我以前基本都是腹泻,拉得太多得时候会觉得难受,现在经过了两次便秘以后,我觉得便秘才是真的让人难受。

+

2021/01/22

01/16 打完第 8 次类克。目前仍在 SCD 中,维持良好状态,血检无异常。第 9 次类克需要做大检查,肠镜+影像学。目前虽然体感良好,但仍然感到害怕。

+

2021/09/29

08/22 打了第 12 次类克。目前仍在 SCD 中。

+

状态有起伏,偶尔会出现不舒服(眼睛炎症、鼻炎等),CRP 和血沉偶尔会有小幅度的升高,但总的来说肠胃大部分时间依然维持无症状,偶尔有不舒服也很快可以恢复。猜测是一是增加的食物多了,偶尔会有质量不好的批次。二是从小就有空调过敏症,眼睛和鼻子应该和空调也有关。

+

2022/05/20

很长一段时间没有更新了,前几天刚打完第 17 次类克,目前仍在 SCD 中(第四阶段)。

+

没有更新的话,基本就代表这段时间非常稳定,没什么变化。没有出现过长时间的明显不适。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2019/parcel-note/index.html b/2019/parcel-note/index.html new file mode 100644 index 000000000..ba01d25fb --- /dev/null +++ b/2019/parcel-note/index.html @@ -0,0 +1,468 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Parcel Note | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Parcel Note +

+ + +
+ + + + +

Parcel Bundler 发布了这么久,终于有机会体验了一次。在一个新的基于 jQuery 的小项目中尝试了这个打包器。结合它的宣传点,整体来说最大的感受是:

+
    +
  1. 确实比 Webpack 快很多
  2. +
  3. 确实「基本上」不需要配置
  4. +
+

虽然没有太多其它的亮点,但这不妨碍它用起来就是比 Webpack 「爽」。

+ + +

关于「快」

就我的感受而言,Parcel 的快大多是要基于它的文件系统缓存的,这也是 Webpack 没有的东西(也许将来会有也说不定)。从体验上来说,应用启动以后「热重载」的速度基本上差不多,就算有差距也可以忽略不计,因为基本两者都是秒速。但是「重启」就不一样了。得益于它的文件系统缓存,Parcel 要比 Webpack 快两到三倍,甚至五倍十倍,我觉得一点都不夸张。这在日常开发中体现的优势还是相当明显的。

+

关于「零配置」

说零配置是有些夸张了,应该说 90% 的场景都不需要写配置。比如说 Webpack 必写的 babel-loader / css-loader 等东西,它都已经给内置了。开发者需要做的东西仅仅是把它安装下来而已。比如说:

+
    +
  1. 我需要使用 Babel,则安装 @babel/core 就好了
  2. +
  3. 我需要使用 Less 预编译 CSS,则安装 less 就好了
  4. +
  5. 我需要使用 Pug 预编译 HTML,则安装 pug 就好了
  6. +
+

事实上,你可能甚至不用做「安装」这一步。当它检测到你输入了某个类型的文件而需要安装某种依赖才能进行时,它会自动安装。

+

这种做法给人的感觉是,它并不是真正的零配置,而是所有的配置其实都已经写好了,内置了,然后它会检测你输入的文件类型,去匹配现有的规则,该干嘛干嘛。所以,这并不是什么黑科技,只是「约定大于配置」的一种体现。

+

吐槽点

在日常开发中,确实 90% 的场景下都不需要写配置,那么另外的那 10% 呢?

+

真正用下来会发现,Parcel 在带来方便的同时,也会带来一些问题:任何事物都是有两面性的。某些在 Webpack 下很稀松平常的任务,比如 js 代码混淆,加多一个 loader,配多一个规则就能解决的事情,在 Parcel 的世界里,对不起,做不到。你得自己想办法。

+

当然这也许是我对 Parcel 的了解还不够深入,不知道如何定制。但 Parcel 的文档里面确实没有提及任何相关的可定制化的东西。所以,真的要说到「可靠性」,「安全感」的话,可能我还是往 Webpack 这边站。但是,毋庸置疑的是 Parcel 确实为小项目提供了一个非常棒的选择。

+

另外再吐槽一点,Parcel 的文档强制给我跳转中文版,然而中文版文档更新滞后,缺斤少两,我选了英文以后,下次再打开还是中文,这一点太不友好了。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2019/plug-alipay-and-wxpay-with-react-native-webview/index.html b/2019/plug-alipay-and-wxpay-with-react-native-webview/index.html new file mode 100644 index 000000000..25e8a2e64 --- /dev/null +++ b/2019/plug-alipay-and-wxpay-with-react-native-webview/index.html @@ -0,0 +1,455 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +ReactNative WebView 接入支付宝与微信支付 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ ReactNative WebView 接入支付宝与微信支付 +

+ + +
+ + + + +

在 ReactNative App 的 WebView 中接入支付宝与微信支付其实很简单。首先前提是:使用 H5 网页提前做好了支付相关的动作,ReactNative 方面只负责展示 H5 页面,以及调起相应的 App 来完成支付,不需要接入底层相关的 SDK 或其它代码。

+ + +

对于 Android 平台来说,经过一番研究,发现 ReactNative 方不需要添加任何额外代码即可达成目的,通过 WebView 拉起支付 App 完成相应支付功能,并在支付成功后返回原 App,体验完美。但是有一点要注意的是,不要随意修改 WebView 的 UserAgent,如果需要修改的话最好使用追加的方式,因为支付宝的支付页面如果检测不到 Android 相关的 UserAgent 则不会拉起 App,只能在网页上支付。

+

iOS 使用以下代码来达到拉起 App 的目的。但有一个问题是,从 App 完成支付动作后,系统会打开浏览器来显示支付结果,而不是回到原 App,这个缺陷应该是使用 WebView 方式无法避免的。

+
onShouldStartLoadWithRequest = ({url}) => {
// 实际上应该不需要判断, 因为 onShouldStartLoadWithRequest 只支持 iOS,但是保险起见
if (Platform.OS !== 'ios') {
return;
}
const isAlipay = url && url.startsWith('alipay'); // 支付宝支付链接为 alipay:// 或 alipays:// 开头
const isWxPay = url && url.startsWith('weixin'); // 微信支付链接为 weixin:// 开头
const isPay = isAlipay || isWxPay;
if (isPay) {
// 检测客户端是否有安装支付宝或微信 App
Linking.canOpenURL(url)
.then(supported => {
if (supported) {
Linking.openURL(url); // 使用此方式即可拉起相应的支付 App
} else {
console.log(`请先安装${isAlipay ? '支付宝' : '微信'}客户端`);
}
});
return false; // 这一步很重要
} else {
return true;
}
};
+ +

补充一点:IOS 9.0 以上需要在 info.plist 中添加白名单,否则 canOpenURL 会始终返回 false,不管用户安装与否:

+
<key>LSApplicationQueriesSchemes</key>
<array>
<!-- 微信 URL Scheme 白名单-->
<string>wechat</string>
<string>weixin</string>

<!-- 支付宝 URL Scheme 白名单-->
<string>alipay</string>
</array>
+ +

参考:developer.apple.com

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2019/react-hooks/index.html b/2019/react-hooks/index.html new file mode 100644 index 000000000..ae2edecbc --- /dev/null +++ b/2019/react-hooks/index.html @@ -0,0 +1,500 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +React Hooks | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ React Hooks +

+ + +
+ + + + +

Hooks 是 React 在 v16.8.0 版本所支持的一个新特性,允许开发者在 Functional Component 中实现「状态」以及「生命周期」等原本只能在 Class Component 中实现的特性。

+

Vue Function-based API 是将来会出现在 Vue.js 3.0 大版本中的一个 API 变革的整体预览,二者(至少)在形式上保持了高度统一,而 yyx 也在文章中直言是受到了 React Hooks 的启发,二者分别解决了自身框架的一些痛点,并允许开发写编者更加「纯粹」的函数式组件。也许可以认为是未来前端框架发展的一个大方向?

+ + +

以下代码例子大部分来自于官方文档

+

简介

React Hooks 提供了两个基本 Hooks: useStateuseEffect,其中:

+
    +
  • useState hook 赋予了函数式组件保存以及更新「状态」的能力
  • +
  • useEffect hook 赋予了函数式组件在「生命周期」之中执行函数的能力
  • +
+

官网上的一个简单例子:

+
import React, { useState, useEffect } from 'react';

function Example() {
const [count, setCount] = useState(0);

// Similar to componentDidMount and componentDidUpdate:
useEffect(() => {
// Update the document title using the browser API
document.title = `You clicked ${count} times`;
});

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
+ +

以上组件定义了一个函数式组件,并在组件内注册了一个 state count,实现了每当点击按钮的时候,count 会自增 1,视图相应更新,并且页面标题会随着 count 更新而更新的功能。

+

useState

useState 的作用很明显,也很简单:它接受一个参数作为 state 的初始值,返回一个数组,数组第一位是 state 的值,第二位是改变该 state 的方法。以上例子使用了 ES6 的数组解构特性来简化了代码,同时这也是推荐的写法。

+

如果一个组件需要保有多个状态,那么有两种实现方式:

+
    +
  1. 分别定义
    const [age, setAge] = useState(42);
    const [fruit, setFruit] = useState('banana');
    const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);
  2. +
  3. 合并定义
    const [state, setState] = useState({
    age: 42,
    fruit: 'banana',
    todos: [{ text: 'Learn Hooks' }]
    });
  4. +
+

React 并没有明确推荐哪一种形式,但是有一点需要注意的是,如果采用第二种形式,与传统的 Class Component 有所区别的是,setState 不会默认为 state 进行 merge 操作,而是 replace,也就是说如果要达到预期的效果应该这么写:

+
const [state, setState] = useState({
age: 42,
fruit: 'banana',
todos: [{ text: 'Learn Hooks' }]
});

// 将 age 变更为 50 而不影响其它 state
setState(state => ({
...state,
age: 50
}))
+ +

useEffect

useEffect 可以看作是传统生命周期函数 componentDidMount / componentDidUpdate / componentWillUnmount 的结合,不过有一点区别是 useEffect 是异步执行的,不会阻塞渲染。它的用法要比 useState 稍微复杂些。

+

最简单的例子就跟上面的一样:

+
useEffect(() => {
// Update the document title using the browser API
document.title = `You clicked ${count} times`;
});
+ +

接受一个函数作为参数,每当视图重新渲染完成后,函数将会被执行。也就是说,它可以看做是一个 componentDidMountcomponentDidUpdate 的综合。

+

有时候我们需要在 componentDidMount 的时候为组件注册一些事件,然后在 componentWillUnmount 时销毁它,那么这时候可以在函数结束时返回另一个函数,返回的函数就将会作为「清理」函数。

+
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
+ +

不过很明显我们还需要做一件事情:并不是所有 componentDidUpdate 都需要进行注册、销毁这一系列操作,只有在当某个监听的 value 真正发生了变化的时候才需要。因此 useEffect hook 提供了第二个参数。参数为一个数组,数组中传入需要监听的变量。只有当数组中任一参数的值(或引用)发生了改变时,effect 函数才会被执行。

+
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
}, [props.friend.id]); // Only re-subscribe if props.friend.id changes
+ +

(文档中有提到在之后的版本中这个参数可能会在构建阶段自动加入,这样相当于 React 在某种程度上也向 Vue.js 靠近了一点点,或者说相互借鉴)

+

如果想要定义一个只在 componentDidMount 时执行一次的 effect,那么第二个参数可以传一个空数组,它就再也不会在 componentDidUpdate 时被执行。

+

规则

React 为 Hooks 制定了两条规则:

+
    +
  1. 只在顶层调用 Hooks,避免在循环体、条件判断或者嵌套函数中调用。因为 React 对 Hooks 的解释依赖于它们定义的顺序,开发者必须保证每次 Render 的过程中 Hooks 执行的顺序都是一致的,这样 Hooks 才能正确工作。
  2. +
  3. 只在 React Function 中调用 Hooks。
  4. +
+

此外,React 还提供了一个 Eslint 插件 eslint-plugin-react-hooks 来确保各位遵守规则。

+

自定义 Hooks

自定义 Hooks 实际上跟 React Hooks 的初衷有一定关系:为了解决某些与状态绑定的逻辑很难在跨组件中复用的问题。由于 React 并不提倡 Class Component 使用继承的方式来复用高阶逻辑(实际上是因为 React 并没有像 Vue 一样对生命周期函数等做类似 Mixin 的工作,因此会导致一些 Bug),所以这个问题在传统写法中几乎无解。而 Hooks 则是为了解决这个问题而来的。

+

Vue Function-based API 这篇文章中也提到了 Mixin 虽然为 Vue 带来了一些方便,但是同时也存在许多问题,3.x 版本中 Vue 也将使用类似的方式来使逻辑复用更清晰,算是殊途同归)

+

官网上举了一个例子:有多个组件需要根据「用户是否在线」这个标志来显示不一样的东西,而获取这个标志的逻辑是固定的,因此可以写成一个自定义 Hook:

+
import React, { useState, useEffect } from 'react';

function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);

useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
};
});

return isOnline;
}
+ +

而使用它的方式则非常简单:

+
function FriendStatus(props) {
const isOnline = useFriendStatus(props.friend.id);

if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}
+ +

至此可以发现,所谓的自定义 Hooks,其实只是把能复用的逻辑「抽离」了出来当做一个函数用以在各处执行,并没有什么特别之处。React 建议自定义 Hooks 使用 ‘use’ 作为方法名的前缀,这样可以让代码可读性显得更高,同时也可以让 lint 工具自动识别并检测该函数是否符合既定规则。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2019/use-lodash-in-wechat-mini-programs/index.html b/2019/use-lodash-in-wechat-mini-programs/index.html new file mode 100644 index 000000000..3be776b7c --- /dev/null +++ b/2019/use-lodash-in-wechat-mini-programs/index.html @@ -0,0 +1,469 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +在微信小程序中使用 lodash | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 在微信小程序中使用 lodash +

+ + +
+ + + + +

由于微信小程序中的 JavaScript 运行环境与浏览器有些许区别,因此在引用某些 npm lib 时会发生问题。这时候需要对源码做出一些改动。

+
+

小程序环境比较特殊,一些全局变量(如 window 对象)和构造器(如 Function 构造器)是无法使用的。

+
+

在小程序中直接 import lodash 会导致以下错误:

+
Uncaught TypeError: Cannot read property 'prototype' of undefined
+ + + +

解决方案:

+
    +
  1. 安装独立的 lodash method package,如 lodash.get
  2. +
+
yarn add lodash.get
import get from 'lodash.get'
+ +
    +
  1. 修改 lodash 源码
  2. +
+

找到:

+
var root = freeGlobal || freeSelf || Function('return this')();
+ +

替换为:

+
var root = {
Array: Array,
Date: Date,
Error: Error,
Function: Function,
Math: Math,
Object: Object,
RegExp: RegExp,
String: String,
TypeError: TypeError,
setTimeout: setTimeout,
clearTimeout: clearTimeout,
setInterval: setInterval,
clearInterval: clearInterval
};
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2019/webkit-overflow-scrolling/index.html b/2019/webkit-overflow-scrolling/index.html new file mode 100644 index 000000000..7dc92d2cf --- /dev/null +++ b/2019/webkit-overflow-scrolling/index.html @@ -0,0 +1,461 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +-webkit-overflow-scrolling | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ -webkit-overflow-scrolling +

+ + +
+ + + + +

-webkit-overflow-scrolling CSS 属性可以让滚动元素在 ios 设备上获得接近原生的平滑滚动以及滚动回弹效果。

+

支持的值:

+
    +
  • auto 普通滚动行为,当手指离开屏幕时,滚动会立即停止(默认)
  • +
  • touch 基于动量的滚动行为,当手指离开屏幕时,滚动会根据手势强度以相应的速度持续一段时间,同时会赋予滚动回弹的效果
  • +
+ + +

一个例子:

+
<template>
<section>
<div class="scroll-touch">
<p>
This paragraph has momentum scrolling
</p>
</div>
<div class="scroll-auto">
<p>
This paragraph does not.
</p>
</div>
</section>
</template>

<style scoped>
div {
width: 100%;
overflow: auto;
}

p {
width: 200%;
background: #f5f9fa;
border: 2px solid #eaf2f4;
padding: 10px;
}

.scroll-touch {
-webkit-overflow-scrolling: touch; /* Lets it scroll lazy */
}

.scroll-auto {
-webkit-overflow-scrolling: auto; /* Stops scrolling immediately */
}
</style>
+ +

但是,这个属性在当容器内有 position: fixed 元素时会产生冲突,fixed 元素会在平滑滚动结束时才回到正确的位置,解决方案通常是重新整理组件树,使 fixed 元素不出现在滚动容器之内即可。

+

ref:

+
    +
  1. https://stackoverflow.com/questions/29695082/mobile-web-webkit-overflow-scrolling-touch-conflicts-with-positionfixed
  2. +
  3. https://stackoverflow.com/questions/25963491/position-fixed-and-webkit-overflow-touch-issue-ios-7
  4. +
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2020/06-23-2020/index.html b/2020/06-23-2020/index.html new file mode 100644 index 000000000..a1976935a --- /dev/null +++ b/2020/06-23-2020/index.html @@ -0,0 +1,448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +06/23/2020 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 06/23/2020 +

+ + +
+ + + + +

这次出院本来应该是很开心的,一切都如(甚至超出了)我所愿,但是不知道怎么的,就是觉得很平淡。什么都不想做,朋友圈都不想发,只想安安静静地躺在家里或者工作一段时间。

+

15 号住院之前,我一直在担心,这次检查到底会怎么样,会不会被要求手术,会不会还是全结肠切除+造口的结局,会不会……

+

因为我真的是被打击到了,近一年来一直在承受打击。害怕了,就像是一直在被突破底线,刚刚才鼓起勇气接受这个它,突然又来说,这样不行,还得再往下一点。如此往复了好几次好几次,以致我实在是没有信心了。

+

所以,觉得这次的住院经历太突然了,太不常规了。有点没缓过来的感觉。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2020/a-difficult-debug-note/97097835-c465e780-16b0-11eb-8d93-ac2cee64895b.png b/2020/a-difficult-debug-note/97097835-c465e780-16b0-11eb-8d93-ac2cee64895b.png new file mode 100644 index 000000000..1778790bd Binary files /dev/null and b/2020/a-difficult-debug-note/97097835-c465e780-16b0-11eb-8d93-ac2cee64895b.png differ diff --git a/2020/a-difficult-debug-note/index.html b/2020/a-difficult-debug-note/index.html new file mode 100644 index 000000000..64021a661 --- /dev/null +++ b/2020/a-difficult-debug-note/index.html @@ -0,0 +1,561 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +记一次艰难的 Debug | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 记一次艰难的 Debug +

+ + +
+ + + + +

这是一次关于本博客的 Debug 经历,过程非常曲折。关键词:Vue / SSR / 错配

+

不知道从哪篇博文开始,博客在直接从内页打开时,或者在内页刷新浏览器时,会报以下错误:

+
app.73b8bd4d.js:8
DOMException: Failed to execute 'appendChild' on 'Node':
This node type does not support this method.
+ +

该错误:

+
    +
  1. 只会在 build 模式出现;
  2. +
  3. 只会在发布上 GitHub Pages 后出现;
  4. +
  5. 只会在某些博文中出现;
  6. +
  7. 只会在直接从链接进入该博文,或者在该博文页面刷新时出现。
  8. +
+

该错误带来的影响,会导致页面上的所有 JavaScript 功能全部失效,具体来说是与 Vue.js 相关的功能。如:导航链接(因为使用了 Vue-Router),评论框,一些依赖于 Vue.js 的 VuePress 插件,等等。

+

screenshot

+ + +

主题?

初次看到这个报错时,我的第一想法是:是不是我不经意间在哪里调用了 appendChild 这个方法导致的?因为我的博客使用了我自己发布的主题,有可能是我哪个地方写得不好导致了这个问题。

+

但是,再三检查主题代码 vuepress-theme-mini 后,我并没有发现任何可疑之处。

+

实际上我也不太相信这个错误是由我的主题导致的,因为查看错误抛出处的源码时发现这些代码都不像是我写的。

+

插件?

另一个可疑之处,则是插件。具体来说,是以下 3 个插件:

+
    +
  1. valine 用于展示评论
  2. +
  3. vuepress-plugin-flowchart 用于绘制流程图
  4. +
  5. vuepress-plugin-right-anchor 用于显示浮动的目录
  6. +
+

我 Google 了很多次,最后在 vuepress/issues/1692 里,有一句话引起了我的注意:

+
+

Just a hint: Another common mistake is to use dynamic vue components that should render client side but forget to ignore them in static builds… 99% of those issues in our projects were missing <ClientOnly>. So try:

+
<ClientOnly>
<NonSSRFriendlyComponent/>
</ClientOnly>
+
+

这个 99% 的表述让我不得不引起重视,审视上述插件的代码以后,发现其确实没有加入 <ClientOnly>,难道这就是问题所在?

+

然而经过实践,并不行。再三确认所有 fork 版本里面已经正确使用了 <ClientOnly>,错误依然存在。

+

该 issue 里面提到的另一点:

+
+

I had the same problem, and then I found out it had to do with document

+

enhanceApp.js

+
if (typeof window !== 'undefined') { // add this line
window.document.xxx
}
+
+

我同样再三确认已经修正,依旧不能解决错误。

+

YAML?

以上两处都无法找到问题,我有点迷惘了。因此开始漫无目的地寻找出错页面的共同点,以试图定位问题。最后,我发现:出现错误的页面貌似大多数都有 yaml 高亮的代码块。当然只是大多数,依然存在其它个例。

+

我尝试将 yaml 格式去除,即将:

+
```yaml
# ...
```
+ +

写为:

+
```
# ...
```
+ +

然后部署上线,错误神奇地消失了!

+

但是,我还没有开心过一分钟,立即发现:错误是消失了,但其带来的副作用依然存在:Vue.js 依然处于崩溃状态,任何功能都无法使用

+

这让我感到很沮丧:这种 SPA 带来的体验还不如最原始的 <a> 标签。

+

因此,我尝试对主题做出了一些小改动,将导航栏的跳转链接全部换成了普通的 <a> 标签。这件事情如果做到这一步,在某种层面上来说也算是解决了吧。除了以下一些问题持续地让我感到难受:

+
    +
  1. 不能使用 yaml 高亮;
  2. +
  3. 不能使用 Vue-Router
  4. +
  5. 无法追根朔源的痛苦。
  6. +
+

路径?

虽然从使用性的层面上来说问题算是解决了,但是我还是很在意以上几点。因此仍在持续地探究问题根源。

+

后来,我在 netlify 上看到了这样一个帖子:VuePress deployment on Netlify succeeds, but experience errors when reloading specific pages

+

作者所提到的问题基本跟我一模一样:

+
+

Hi, I have a VuePress generated static website deployed on Netlify, I am currently running into errors like:

+
Failed to execute 'appendChild' on 'Node': This node type does not support this method. only when reloading inner pages (i.e, not homepage.).
+ +

I have searched for similar issues on GitHub and it seems that it is related to Vue’s failing hydration as described here: nuxt.js/issues/1552, and here: vuepress/issues/1692.

+

However, I didn’t come across these issues when I’m in my local environment (both in dev mode and in production mode), I only run into these issues when I deploy my site to Netlify.

+

Confusing…

+
+

这简直有一种抓住了救命稻草的感觉!激动地往下翻,作者还说他找到了问题所在:

+
+

New update! So I converted all of my file names and directory names to lowercase and it actually solved the problem!

+
+

我立刻开始检查出问题的页面是否存在类似问题。然而,很遗憾,我的页面所有 url 都是小写的,不存在任何大写字符。

+

我又想,是不是有任何加载的资源里面出现了大写字符,导致了加载失败,因而产生错误?

+

结果再次让人感到遗憾:从我的域名中加载的所有资源,均没有出现大写字符的情况,更没有任何一个资源加载失败。

+

调查再次陷入僵局。

+

SSR?

Google 之余,偶然看到了这样一个 issue: vue/issues/6874,作者提到,当 SSR 发生「错配」时,Vue.js 应用会出现类似「宕机」的表现。他希望可以通过参数控制这个行为,即当 SSR 出现「错配」时,允许用户选择自己想要的行为。如忽略 SSR 的结果并以客户端渲染结果覆盖,或仅提出 warning 而不是整个挂掉。

+

而 yyx 则认为,只要不是白屏 (white screen),则都能接受。

+

此时我不想深入探讨这个行为。我只想知道,我的问题到底是不是跟这个有关?

+

VuePress 有一个插件 vuepress-plugin-dehydrate,可以实现禁用所有 JavaScript,将页面作为纯静态 HTML 使用。在测试中我发现,禁用「客户端接管」以后页面确实没有问题了。但我觉得这不是废话吗?这个实验完全没有任何意义啊。

+

后面,我想到一个办法,即通过创建一个全新的仓库,不加任何主题与插件,看看 yaml 高亮是否会出现问题。

+

复现

我在 Github 新建了一个仓库,用最少的配置搭建出来了一个 VuePress 程序。尝试:

+
    +
  1. 添加一个包含 yaml 高亮的页面,没有出现问题
  2. +
  3. 将出现问题的博文整篇添加进去,没有出现问题
  4. +
  5. 将博客的所有主题、插件、博文均导入到新仓库中,依然没有出现问题
  6. +
+

走到这一步我头都大了,好像只剩下最后一个区别了,即自定义域名。要是再不能复现,干脆我把博客迁移过来算了。

+

然而,最出乎意料的是,在添加自定义域名,并通过域名访问后,问题复现了!后来,我逐步将所有东西复原到步骤 1 的状态,即最简 VuePress + 一个 yaml 高亮的页面,问题持续复现。

+

到这里,问题就很明朗了:

+

这就是 Cloudflare 的锅!(我的域名托管在 Cf)

+

解决

最终定位到问题以后,解决似乎变得顺利成章了起来。我将 Google 关键词换成了 Cloudflare + VuePress,没有发现有价值的信息。再换成 Cloudflare + Vue + SSR,找到了这篇博文:Cloudflare and Vue SSR; Client Hydration Failures,里面详细地描述了作者遇到的问题(基本跟我一致)以及解决思路。

+

按照他的说法,他的应用之所以出现这个问题,是因为 Cloudflare 启用了一种叫 AutoMin 的优化,会自动对静态资源 (JavaScript / CSS / HTML) 再做进一步的压缩。然而 HTML 中被去掉了的 <!-- --> 注释则是问题的关键所在:这是 v-if 节点用来成功挂载的重要组成部分。

+

至于如何发现,由于错误提示基本没有调试价值,尝试了各种办法后,最终通过将本地编译的静态 HTML 与服务器上面的 HTML 进行逐行比对,最终发现区别。

+

知道了这点我立马就打开 Cloudflare 控制台试图关闭该配置,但经过一番寻找后发现,该配置从来就没有打开过!

+

不过没关系,既然如此,那我也来对比一下。最终得到的有意义的区别,我本地编译的版本是:

+
ssh user@host
+ +

Cloudflare 返回的版本是:

+
ssh <a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="b6c3c5d3c4f6ded9c5c2">[email&#160;protected]</a>
+ +

西巴。原来它还有这个功能。淦!

+

这个功能在 CloudFlare 上叫 Email Address Obfuscation,会自动保护出现在 HTML 上的邮箱地址,相当于打码处理。但是,正是因为它,导致了 Vue.js 出现 SSR 错配,进而导致网站崩溃。

+

我把该功能关掉后,问题就消失了。

+

总结

追根朔源,问题的根本与我先前的猜想大相庭径:与主题、插件、yaml 等均无关,而是因为 HTML 中出现了类似邮件地址的文本,被 Cloudflare 转换了。

+

当然,我承认这是因为我在 SSR 方面的经验不足,才走了这么多弯路。以后再发现这种问题,我一定第一步就做比对。

+

不过,在这个过程中,我也确实感受到了 Vue.js 在 SSR 方面的一些不足(我认为的):

+
    +
  1. 错配即崩溃,Vue.js 直接放弃接管;
  2. +
  3. 错误提示过于晦涩;
  4. +
  5. 用户对于该行为没有选择权。
  6. +
+

从另一层面来说,能够最终找到问题根源并通过最简单的方式将其解决,这种感受很爽。在这个过程中我有很多理由去放弃,但我没有。对于这点我感到很开心。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2020/auto-changelog-with-gitlab/index.html b/2020/auto-changelog-with-gitlab/index.html new file mode 100644 index 000000000..622a1b8c2 --- /dev/null +++ b/2020/auto-changelog-with-gitlab/index.html @@ -0,0 +1,495 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Auto Changelog with GitLab | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Auto Changelog with GitLab +

+ + +
+ + + + +

上一篇博文 Integrate Renovate with GitLab 中介绍了为私有代码仓库与私有源提供依赖自动检测更新并发起 Merge Request 的方式。Renovate 可以自动通过 Release Notes 获取到版本之间的更新日志,并在 MR 中展示,这为执行合并的评审人提供了极大的便利。

+

接下来需要解决另一个问题:如何为分散在各处的私有依赖自动生成更新日志?

+ + +

工具

首先需要说明,自动生成 Changelog 的前提条件是使用 约定式提交 ,这样各类程序才能从 git 仓库的提交记录中提取出有价值的信息并加以整理归类。

+

可供选择的程序有很多,可以按需选择。这里选用的是 lob/generate-changelog

+

时机

一个合适的生成 Changelog 的时机是创建新 Tag 的时候。如果是一个 npm package,那么执行 npm version xxx 命令的时候就会自动得到一个 Tag,将其推送到远端即可。

+

也可以使用预定义的脚本:

+
"release:major": "npm version major && git push origin && git push origin --tags",
"release:minor": "npm version minor && git push origin && git push origin --tags",
"release:patch": "npm version patch && git push origin && git push origin --tags",
+ +

CI

如何驱使 GitLab 来完成 Release Note 的创建,有很多方式。

+

1. 使用 .gitlab-ci.yml

从 GitLab 13.2 开始,runner 可以使用以下镜像直接操作 Release:

+
release_job:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
rules:
# 只有当 Tag 被创建的时候才执行该任务
- if: $CI_COMMIT_TAG
script:
- echo 'running release_job'
# 使用命令行生成 Changelog
# 该命令行可以根据需求自定义
- export EXTRA_DESCRIPTION=$(changelog)
release:
name: 'Release $CI_COMMIT_TAG'
# 将得到的 Changelog 填入 description 字段
description: '$EXTRA_DESCRIPTION'
tag_name: '$CI_COMMIT_TAG'
ref: '$CI_COMMIT_TAG'
+ +

这是最简单的方式。但是由于我司的 GitLab 版本过低,不支持此操作。因此需要另外想办法。

+

2. bash script

GitLab CI 可以执行一个 bash script,因此可以利用 GitLab 提供的 API,结合一个 Access Token 向 GitLab 发起请求,最终得到 Changelog。

+

这种方式应该是大多数老版本 GitLab 所使用的。但是它存在一些我认为无法接受的问题:

+
    +
  1. 每个项目都需要有此脚本(不过这一点实际上可以通过 npx 绕过);
  2. +
  3. 每个项目的 .gitlab-ci.yml 都需要修改,这点是无法避免的(实际上方式 1 也存在此问题);
  4. +
  5. 每个项目都需要配置 Secret Token( Access Token 不可能直接暴露在代码中)。
  6. +
+

因此,我觉得这个办法不够优雅。

+

3. webhook

为了解决以上问题,我决定继续改造之前的博文 Gitlab CE Code-Review Bot 中介绍的评审机器人,让它可以

+
    +
  1. 识别 Tag 事件;
  2. +
  3. 自动拉取仓库代码;
  4. +
  5. 自动生成 Changelog;
  6. +
  7. 调用 GitLab API 完成 Release Note 的创建。
  8. +
+

首先在入口处加多一个事件监听:

+
module.exports = async (ctx) => {
try {
const { object_kind, object_attributes } = ctx.request.body

// ...
} else if (object_kind === 'tag_push') {
// tag 事件
await tag(ctx)
}
// ...
} catch (e) {
console.error(e)
}
}
+ +

GitLab 并没有区分 Tag 创建与删除的事件,因此需要通过代码判断:

+
const { after, ref, project_id, project: { git_http_url } } = ctx.request.body
if (after === '0000000000000000000000000000000000000000') {
// 该事件是 tag 删除事件,不作处理
return
}
+ +

使用 simple-git 来拉取 Git 仓库,注意这里需要使用 oauth2:Access Token 来完成授权:

+
const simpleGit = require('simple-git')
const git = simpleGit()

await git.clone(git_http_url.replace('https://', `https://oauth2:${process.env.GITLAB_BOT_ACCESS_TOKEN}@`), projectPath)
+ +

生成 Changelog:

+
const Changelog = require('generate-changelog')
const simpleGit = require('simple-git')

/**
* 为 projectPath 的 tag 生成 Changelog
* @param projectPath
* @param tag
* @returns {Promise<String|null>}
*/
async function generateChangelog (projectPath, tag) {
// 旧的当前路径
const oldPath = process.cwd()
try {
// 生成之前先要切换路径
process.chdir(projectPath)
const git = simpleGit()

// 获取 Git 仓库下所有的 Tags
const tagsString = await git.raw(['for-each-ref', '--sort=-creatordate', '--format', '%(refname)', 'refs/tags'])
const tags = tagsString.trim().split(/\s/)

for (let i = 0; i < tags.length - 1; ++i) {
if (tags[i] !== tag) {
// 循环找到目标 Tag
continue
}
if (!tags[i] || !tags[i + 1]) {
// 第一个 Tag(往往)不需要 Changelog
break
}
// 找到 Tag 的哈希值
const hash0 = (await git.raw(['show-ref', '-s', tags[i]])).trim()
const hash1 = (await git.raw(['show-ref', '-s', tags[i + 1]])).trim()
// 使用哈希值范围来生成 Changelog
// 为什么不直接使用 Tag:
// 因为 Tag 中如果包含了某些特殊字符串,会造成无法识别问题
return await Changelog.generate({ tag: `${hash0}...${hash1}` })
}
} catch (e) {
console.error(e)
return null
} finally {
// 任务结束后将当前路径切换回原来的
process.chdir(oldPath)
}
}
+ +

最后,使用 GitLab API 将得到的 Changelog 更新上去即可:

+
/**
* 为 Tag 增加 Release note
* @param projectId
* @param tagName
* @param body
* @returns {IDBRequest<IDBValidKey> | Promise<void>}
*/
function addReleaseNote (projectId, tagName, body) {
return agent.post(`${BASE}/${projectId}/repository/tags/${tagName}/release`, {
tag_name: tagName,
description: body
})
}
+ +

最后的最后,删除之前拉取下来的仓库,这个任务就算完成了。

+

这么做最大的好处是:仓库启用与否,只需要在 Webhook 处多勾选一个 Tag push Event 即可,无需任何其他操作。

+

但是它也有一个不好的地方:如果原仓库特别大的话,拉取可能会非常耗时。不过考虑到 GitLab 和 Bot 一般都会处在同一个内网环境下,这点基本可以忽略。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2020/automatic-cd-from-shell-scripts-to-k8s/index.html b/2020/automatic-cd-from-shell-scripts-to-k8s/index.html new file mode 100644 index 000000000..daf2dbfb6 --- /dev/null +++ b/2020/automatic-cd-from-shell-scripts-to-k8s/index.html @@ -0,0 +1,774 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +自动化部署: 从脚本到 K8s | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 自动化部署: 从脚本到 K8s +

+ + +
+ + + + +

如果公司有专业运维,项目的部署上线过程一般来说开发者都不会接触到。但是很不幸,我所在的团队没有独立的运维团队,所以一切都得靠自己(与同事)。

+

以下都只是工作中逐步优化得到的经验总结,并且只以 Node.js 程序部署为例。

+ + +

部署上线的原始版本

流程图

举例:服务器使用 PM2 管理部署。纯手工操作:

+
+ +

总结

整个过程耗时 10~30 分钟不等。

+

优点:

+
    +
  1. 不依赖任何工具/系统
  2. +
  3. 适用于任何分支
  4. +
+

缺点:

+
    +
  1. 麻烦、耗时
  2. +
  3. 易出错
  4. +
  5. 无法持续部署
  6. +
  7. 多节点怎么办?
  8. +
+

基于 CI 系统的全自动版本

一般来说企业都会有一套 CI 系统,可能是传统的 Jenkins,也可能是 Gitlab CI / Github Actions / Travis CI / Circle CI 等等。它们之间大同小异,都是通过某种方式写好若干份配置文件,当某些操作(如 git push)触发时以及满足某种条件(如当前分支为发布分支,或提交了 tag 等)执行某些任务。

+

这里使用 Gitlab CI 举例,CI/CD 通过 Gitlab Runner 完成,服务器使用 PM2 管理部署。

+

流程图

+ +

技术细节

配置文件 .gitlab-ci.yml:

+
image: node

before_script:
- yarn --frozen-lockfile

stages:
- test
- build
- deploy

# 代码测试
test:
stage: test
script:
- npm run lint
- npm run test
tags:
- node

# 前端代码构建测试
build-frontend:
stage: build
script:
- npm run build
tags:
- node

# 发布 dev 环境,其他环境略
deploy-dev:
stage: deploy
# 仅在 release-dev 分支上执行改任务
only:
- release-dev
script:
- npm run build:dev
- scp -r . user@host:~/path/to/deploy
- ssh user@@host "
pm2 delete -s frontend || true &&
pm2 delete -s server || true &&
pm2 serve /path/to/deploy/frontend/ 8080 --name frontend &&
yarn --cwd /path/to/deploy/server/ &&
pm2 start /path/to/deploy/server/server.js --name server"
tags:
- node

# ...
+ +

总结

优点:

+
    +
  1. 全过程仅依赖 Gitlab 与 Gitlab Runner (基于或不基于容器)
  2. +
  3. 全自动测试,提交到发布分支则全自动部署,测试失败的代码不会被部署
  4. +
+

缺点:

+
    +
  1. 需要在 Gitlab 上配置远程机器的登录凭证(账号/密码),或在 Runner 机器上配置 ssh key
  2. +
  3. Runner 会拥有部署机器的访问权限
  4. +
  5. 多节点?运维?
  6. +
+

Gitlab 与 Agent 平台结合的半自动版本

为了解决上述 Runner 机器权限过高的问题,这个版本引入了 Agent 平台的概念。每个企业使用的平台可能有所区别,有可能是自研的(如我司),也有可能是外部提供的的(如「宝塔」)。但大体功能基本一致。

+

该版本中:

+
    +
  1. CI 通过 Gitlab Runner 完成。任务完成后会将代码打包,并放置于服务器上的某个位置,该位置通过 Nginx 暴露(仅对内)
  2. +
  3. CD 通过 Agent 平台完成。Agent 从上一步暴露的地址中下载代码,解压缩并放置到指定位置,重启 PM2 服务
  4. +
+

流程图

+ +

总结

这一个版本中,CI 系统的配置简化了,去除部署部分的任务即可。至于 Agent 平台的配置方式,可能是一个完整的 bash 脚本,也可能是其它配置,就不在此展开了。

+

优点:

+
    +
  1. CI 过程全自动
  2. +
  3. CI/CD 权限解耦
  4. +
  5. 适用于各种分支
  6. +
+

缺点:

+
    +
  1. 非线上发布过程也需要手动完成,麻烦
  2. +
  3. 严重依赖 Agent 平台
  4. +
  5. 运维?
  6. +
+

Gitlab 与 k8s 结合的全自动版本 v1

k8s (kubernetes) 是一个容器集群部署管理系统。

+

容器基础知识:

+
    +
  1. 镜像 Image
  2. +
  3. 容器 Container
  4. +
+

k8s 基础知识:

+
    +
  1. 工作单元 pod
  2. +
  3. 服务 service
  4. +
  5. 节点 node
  6. +
  7. Kustomize
  8. +
+

流程图

+ +

技术细节

Dockerfile:

+
FROM alpine

RUN apk add --no-cache --update nodejs nodejs-npm yarn
RUN adduser -u 1000 -D app -h /data

USER app

COPY --chown=app start.sh /data/start.sh

WORKDIR /data

EXPOSE 8000
ENTRYPOINT [ "sh", "/data/start.sh" ]
+ +

start.sh:

+
#!/usr/bin/env sh

# 从文件服务器获取该版本包
VERSION_DEPLOY_HTTPCODE=`curl -s "https://xxx/${VERSION}" -o pkg.tgz -w "%{http_code}"`
if [ "$VERSION_DEPLOY_HTTPCODE" == "200" ]; then
echo "using version: ${VERSION}"
tar zxf pkg.tgz
else
echo "version package not exist: ${VERSION}"
exit 1
fi

sh deploy.sh
+ + +

kill 实现:

+
// koa
router.all('/api/kill', async (ctx, next) => {
if (!IS_PROD) {
ctx.body = 'ok'
process.exit(0)
} else {
next()
}
})
+ +

总结

优点:

+
    +
  1. CI 过程全自动
  2. +
  3. 非线上环境 CD 全自动,线上环境 CD 手动指定版本,兼顾方便与安全
  4. +
  5. 无需配置远程机器权限
  6. +
+

缺点:

+
    +
  1. k8s 使用原始镜像启动 pod,拉取代码与安装依赖的过程非常耗时(每次启动都是全新镜像,无缓存)。
  2. +
  3. CD 过程不确定性较多,存在代码文件服务器故障、依赖安装故障等风险。
  4. +
  5. kill 指令发出后服务会暂时不可用。
  6. +
  7. 多节点?
  8. +
+

Gitlab 与 k8s 结合的全自动版本 v2

为了解决上面的问题 3/4,引入 Consul 对 CI/CD 过程做出了改进。

+

Consul 是为基础设施提供服务发现和服务配置的工具,包含多种功能,这次用到了其中两个功能:

+
    +
  1. 服务发现
  2. +
  3. 健康检查
  4. +
+

流程图

+ +

技术细节

这个流程里面涉及到几个问题:

+

关于“逐个发送 kill 指令”

虽然通过 Consul 可以获取到所有运行中 pod 的 ip 及端口,但是如果集中发送 kill 命令仍然会造成服务不可用。目前我司服务端的 CI 就有这个问题,他们虽然每个 kill 会有一段固定时间的 sleep 间隔,但无法保证下一个 kill 发出时上个服务时候已重启完毕。

+

为了解决这个问题,我写了一个脚本。

+

流程:

+
+ +

代码:

+
#! /usr/bin/env node

/**
* 此脚本在gitlab-runner中作为CI的最后一步执行
* 在非线上环境中可以对容器进行逐个重启,尽量减少downtime
*/

// 确保线上环境不执行此脚本
if (process.env.NODE_ENV === 'production') {
return
}

const http = require('http')

function request (host, port, path) {
return new Promise(((resolve, reject) => {
const req = http.request({
hostname: host,
port: port,
path: path,
method: 'GET',
timeout: 1,
headers: {
'Content-Type': 'application/json'
}
}, function (res) {
res.setEncoding('utf8')
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => {
resolve({
status: res.statusCode,
data
})
})
})
req.on('error', e => {
console.log(e.message)
resolve()
})
req.end()
}))
}

async function sleep (time) {
return new Promise((resolve) => {
setTimeout(resolve, time)
})
}

(async () => {
// 各环境的consul请求地址
// 具体使用时需要填入指定 host 与 port
const apiMap = {
development: ['host', 'port'],
qa: ['host', 'port'],
pp: ['host', 'port']
}
// 从consul获取已注册pod列表
const api = apiMap[process.env.NODE_ENV]
const res = await request(api[0], api[1], api[2])
if (!res || !res.data) {
console.log('consul find service failed, exiting...')
return
}
console.log('consul raw:', JSON.stringify(res.data))
const pods = JSON.parse(res.data).map(v => [v.Service.Address, v.Service.Port])
console.log('consul services:', JSON.stringify(pods))
// 循环杀进程
for (let i = 0; i < pods.length; i++) {
const pod = pods[i]
console.log('-------------')
console.log(pod[0], pod[1])
// 重启一个pod
const killRes = await request(pod[0], pod[1], `/api/kill`)
if (killRes && killRes.status === 200) {
// kill返回成功,这个节点原本是活着的才继续监测它的状态
console.log(pod[0], 'killed.')
if (i === pods.length - 1) {
// 已经杀完了最后一个pod,无需继续等待重启,直接退出
process.exit(0)
}
let isServerUp = false
// let isFrontendUp = false
// 每个pod最多检测12次(2分钟),超过时间则放弃,直接重启下一个pod
let tryTimes = 12
// 循环检测是否重启成功
do {
console.log(pod[0], 'waiting for pod up...', tryTimes)
// 每10秒检测一次
await sleep(10 * 1000)
console.log(pod[0], 'check if pod up...')
// 如果返回200表示已服务已重新启动
const serverRes = await request(pod[0], pod[1], `/api/health-check`)
isServerUp = !!(serverRes && serverRes.status === 200)
console.log(pod[0], 'pod server up:', isServerUp)
// 等待 k8s readinessProbe 开始,节点被认为已存活,则可以对外访问
if (isServerUp) {
const waitSecondsStr = process.env.WAIT_FOR_PROBE_SECONDS || '10'
const waitSeconds = parseInt(waitSecondsStr)
if (isNaN(waitSeconds) || waitSeconds >= 120 || waitSeconds < 0) {
// 最大等待120秒,超过视为参数错误
console.log(pod[0], `WAIT_FOR_PROBE_SECONDS is invalid, skip waiting for prob.`)
} else {
console.log(pod[0], `pod is up internally, but need to wait for live probe (${waitSeconds}s)...`)
await sleep(waitSeconds * 1000)
}
}
} while (!isServerUp && --tryTimes > 0)
} else {
console.log(pod[0], 'kill failed, skip.')
}
}
})()

+ +

在 gitlab-ci 的最后一步执行此脚本:

+

.gitlab-ci.yml:

+
# ...
deploy-dev:
stage: deploy
only:
- release-dev
script:
- ./build.sh
- NODE_ENV=development SERVICE_NAME=some-name npm_config_registry=http://private.registry.com npx restart-project-via-consul-script@latest
tags:
- node
# ...
+ +

关于“解注册所有同名服务”与“注册自己”

项目内部使用 https://www.npmjs.com/package/consul 来与 Consul 通信。

+

需要“解注册所有同名服务”的原因:

+

Consul 在注册服务时并没有类似“主键”的概念,一个 Consul 有多个 Agent,也就是说相同 ip、相同 id 的服务可能会在不同 Agent 上被注册多次,并且由于 pod 的 ip 是短暂的,每次重启 pod 获得的 ip 可能会有差异,因此如果不进行解注册就会导致从 Consul 上获得的服务与现实正在运行的不一致。

+

流程:

+
+ +

总结

优点:

+
    +
  1. CI 过程全自动
  2. +
  3. 非线上环境 CD 全自动,线上环境CD手动指定版本,兼顾方便与安全
  4. +
  5. 无需配置远程机器权限
  6. +
  7. 支持多节点
  8. +
  9. 高可用性的重启
  10. +
+

缺点:

+
    +
  1. k8s 使用原始镜像启动 pod,拉取代码与安装依赖的过程非常耗时(每次启动都是全新镜像,无缓存)。
  2. +
  3. CD 过程不确定性较多,存在代码文件服务器故障、依赖安装故障等风险。
  4. +
+

为什么会做成这个样子呢,因为我司的服务端使用 golang 是走的这一套流程,但是使用 golang 打包编译出的是一个二进制文件,镜像直接拉取就可以启动,不需要依赖安装等步骤。因此他们使用这种方式的缺点并不明显。但是对于 Nodejs 程序来说,这依然是一个较大缺陷。

+

Gitlab 与 k8s 结合的全自动版本 v3

为了解决上面的问题 1/2,这个版本更改了代码部署到 k8s 的方式:使用完整的预构建镜像,而不是空白镜像。

+

流程图

+ +

技术细节

build.sh:

+
echo "Process: 构建 Docker 镜像..."
docker build -t wohx-${PROJECT_NAME}:${COMMIT_SHA} .
docker tag wohx-${PROJECT_NAME}:${COMMIT_SHA} private.registry.com/${PROJECT_NAME}:${COMMIT_SHA}
docker tag wohx-${PROJECT_NAME}:${COMMIT_SHA} private.registry.com/${PROJECT_NAME}:${BRANCH}-latest

echo "Process: 上传 Docker 镜像..."
docker push private.registry.com/${PROJECT_NAME}:${COMMIT_SHA}
docker push private.registry.com/${PROJECT_NAME}:${BRANCH}-latest
+ +

Dockerfile:

+
FROM alpine
RUN apk add --no-cache --update nodejs nodejs-npm yarn
RUN adduser -u 1000 -D app -h /data
USER app
WORKDIR /data
EXPOSE 8000
# server 确保在 yarn.lock 与 package.json 没有改变的情况下,此层能被缓存
# frontend 的 node_modules 已在 .dockerignore 中忽略,无需关注
RUN mkdir ./server && mkdir -p ./frontend/dist
COPY --chown=app server/yarn.lock server/package.json ./server/
RUN yarn --ignore-engines --cwd /data/server/
# 启动脚本
COPY --chown=app ./start.sh ./
# server 源代码层
COPY --chown=app ./server ./server/
# frontend dist 层
COPY --chown=app ./frontend/dist ./frontend/dist
# entry
# CMD [ "node", "./server/server.js" ]
# start.sh 允许 k8s 自定义启动逻辑
ENTRYPOINT [ "sh", "/data/start.sh" ]
+ +

总结

优点:

+
    +
  1. CI过程全自动
  2. +
  3. 非线上环境CD全自动,线上环境CD手动指定版本,兼顾方便与安全
  4. +
  5. 无需配置远程机器权限
  6. +
  7. 支持多节点
  8. +
  9. 高可用性的重启
  10. +
  11. 预构建的镜像,CD 阶段开箱即用,无任何依赖
  12. +
+

缺点:

+
    +
  1. 为了使每个 pod 能够获得一个稳定的名称(用于在 Consul 中注册、解注册),部署类型使用了 Statefulset,因此带来了一些本来不需要的特性。
  2. +
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2020/cache-node-modules-in-github-actions/index.html b/2020/cache-node-modules-in-github-actions/index.html new file mode 100644 index 000000000..5f63423a7 --- /dev/null +++ b/2020/cache-node-modules-in-github-actions/index.html @@ -0,0 +1,466 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Cache Yarn in Github Actions | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Cache Yarn in Github Actions +

+ + +
+ + + + +

在 CI 中缓存安装下来的依赖项是提速的关键,Github Actions 官方文档 提供了如下方案 (NPM):

+ + +
jobs:
build:
# ...
- name: Cache node modules
uses: actions/cache@v2
env:
cache-name: cache-node-modules
with:
# npm cache files are stored in `~/.npm` on Linux/macOS
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Install Dependencies
run: npm install
# ...
+ +

Yarn 则复杂,多了一步操作(文档):

+
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v1
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
+ +

这些方案可以说是又臭又长,我只想简单做个 cache,何必让我关心那么多东西?项目多的话,简直疯了。看看人家 Gitlab 的方案:

+
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
+ +

简单、明确。

+

因此,我找到了这个 action c-hive/gha-yarn-cache 作为替代,现在代码可以简化为:

+
jobs:
build:
# ...
- uses: c-hive/gha-yarn-cache@v1
- run: yarn --frozen-lockfile
# ...
+ +

一行解决。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2020/enable-soft-wrap-for-markdown-files-in-idea-by-default/98069913-fc341280-1e9a-11eb-82d4-2dbaa96672bd.png b/2020/enable-soft-wrap-for-markdown-files-in-idea-by-default/98069913-fc341280-1e9a-11eb-82d4-2dbaa96672bd.png new file mode 100644 index 000000000..e8ea13b85 Binary files /dev/null and b/2020/enable-soft-wrap-for-markdown-files-in-idea-by-default/98069913-fc341280-1e9a-11eb-82d4-2dbaa96672bd.png differ diff --git a/2020/enable-soft-wrap-for-markdown-files-in-idea-by-default/index.html b/2020/enable-soft-wrap-for-markdown-files-in-idea-by-default/index.html new file mode 100644 index 000000000..04423a2cd --- /dev/null +++ b/2020/enable-soft-wrap-for-markdown-files-in-idea-by-default/index.html @@ -0,0 +1,452 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +IDEA 为 Markdown 文件默认启用 SoftWrap | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ IDEA 为 Markdown 文件默认启用 SoftWrap +

+ + +
+ + + + +

应该 JetBrains 家的所有 IDE 都有这个配置。习惯了用 Markdown 写博客的人每次都要手动点一下 SoftWrap 挺烦的。后来发现了一个配置可以帮我省去这一步:

+

打开设置,找到:Editor > General > Soft Wraps,将 Soft-wrap files 选项勾上即可。IDE 默认已经填上了 *.md; *.txt; *.rst; *.adoc,因此不需要再做别的事情。

+

image

+

这样一来,每次只要打开以上格式的文件,编辑器就会自动开启 SoftWrap,一劳永逸。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2020/gitlab-ce-code-review-bot/93973898-8e7ace00-fda7-11ea-9735-8ee3de0e663d.png b/2020/gitlab-ce-code-review-bot/93973898-8e7ace00-fda7-11ea-9735-8ee3de0e663d.png new file mode 100644 index 000000000..1d01eadae Binary files /dev/null and b/2020/gitlab-ce-code-review-bot/93973898-8e7ace00-fda7-11ea-9735-8ee3de0e663d.png differ diff --git a/2020/gitlab-ce-code-review-bot/index.html b/2020/gitlab-ce-code-review-bot/index.html new file mode 100644 index 000000000..b8768008f --- /dev/null +++ b/2020/gitlab-ce-code-review-bot/index.html @@ -0,0 +1,565 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Gitlab CE Code-Review Bot | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Gitlab CE Code-Review Bot +

+ + +
+ + + + +

由于 Gitlab CE 做代码评审时缺少了关键的评审员功能(详情参考此 issue),因此在使用 CE 的同时又想要做代码评审的话,就必须要自己想办法了。

+

网上能找到的最多的解决方案就是在 Gitlab 前面再部署一套 Gerrit,通过拦截推送的代码以及同步两个库来实现。但是这种方案有诸多弊端。比如:

+
    +
  1. 割裂的用户体验。原本习惯了使用 Gitlab 系统的人,要开始学习晦涩难懂的 Gerrit;
  2. +
  3. 代码同步的不稳定性和不确定性。系统每增加一层逻辑,可靠性就降低一些;
  4. +
  5. 复杂的使用方式:代码必须要从 Gerrit clone,同时 push 时分支名必须加上 refs 前缀,否则无法进入评审
  6. +
  7. +
+

总体来说,以上的种种原因让我觉得 Gerrit 并不是最好的解决方案。对于凡事追求完美的处女座的我来说,我想要的东西大概应该具备以下几点:

+
    +
  1. 最好是能直接在 Gitlab 上面进行评审。因为 CE 可以说是万万事俱备,只差流程;
  2. +
  3. 最好是对原 Git 和 Gitlab 使用流程、习惯没有任何更改和侵入,仅增加评审流程;
  4. +
  5. 最好是可以可以自动化整个流程(评审人自动分配、评审完自动合并,等等)。
  6. +
+

好在,Gitlab 有一套完备的 Web hook 以及 API 系统,可以支撑起我的想法。

+ + +

实现原理

首先,所有评审流程要基于一些分支使用原则:

+
    +
  1. 分支分为主干分支与特性分支,另外还有一些额外的分支(如发布分支);
  2. +
  3. 除业务分支外,其它分支均为保护分支,不允许直接推送,只能通过 Merge Request 添加代码;
  4. +
  5. 特性分支可以任意使用、推送
  6. +
+

因此,代码评审的环节就设计在 Merge Request (以下简称 MR)中,这是一个合理的时机。

+

整个评审流程如下所示:

+
+ +

可以看到,除了最后的「合并」操作外,评审系统只是作为一个「旁观者」的角色,帮助我们完成了整个评审流程,并没有任何侵入性的操作。

+

实现细节

评审系统的实现,我选择的是一个用 Node.js 和 Koa2 搭建的普通 web 服务器。它会做两件事情:

+
    +
  1. 监听从 web hook 进入到系统的请求,分析请求参数,实现具体逻辑;
  2. +
  3. 调用 Gitlab API,完成诸如评论、合并等操作。
  4. +
+

因为 Gitlab web hook 访问时仅存在参数区别,因此服务器入口只需要一个路径监听就够了:

+
// gitlab.route.js
const gitlab = require('./gitlab.controller')

module.exports = (router) => {
// 所有请求都进入到 gitlab controller
router.all('(.*)', gitlab)
}
+ +
// gitlab.controller.js
const mr = require('./merge-request.handler')
const mrc = require('./merge-request-comment.handler')

module.exports = async ctx => {
try {
const { object_kind, object_attributes } = ctx.request.body
if (object_kind === 'merge_request' && object_attributes.action === 'open') {
// 新的 merge request
await mr(ctx)
} else if (object_kind === 'note' && object_attributes.noteable_type === 'MergeRequest') {
// merge request 收到评论
await mrc(ctx)
}
} catch (e) {
console.error(e)
}
// 这里的返回并不重要
ctx.body = 'gitlab-bot'
}
+ +

MR 创建时通知到评审系统

在上面的 mr(ctx) 中,可以实现新 MR 创建时的逻辑:

+
    +
  1. 从预先配置好的小组名单(可以是写死在代码中的,也可以是储存在 db 中的)中,随机抽取 N 位成员(假设为 B/C);
  2. +
  3. 通过 Gitlab API 向 MR 添加评论,说明意图,并且 @B @C。
  4. +
+

至于如何向 API 发出请求,开源世界有许多现成的解决方案,也可以直接参考 API 文档,这里不再赘述。

+
// pid 为 projectId,mid 为 mergeRequestId,webhook 调用内均会携带。下同
await service.addMergeRequestComment(pid, mid, `请 [@${ra}] 与 [@${rb}] 评审`)
+ +

这里面有几个问题:

+
    +
  1. 如何防止小组成员略过评审系统,主动合并?
  2. +
  3. 不是所有分支合并都需要评审(如主干分支到发布分支),如何避免?
  4. +
+

如何防止手动合并

Gitlab 提供了一种方式:WIP (work in progress),只要标记了 WIP 的 MR 就无法直接点击合并。使用方式也很简单,只需要在原 MR 的标题前面加上 WIP: 字符串即可:

+
await service.updateMergeRequest(pid, mid, {
title: `WIP:${object_attributes.title}`
})
+ +

效果如下图所示:

+

wip

+

可以看到,WIP 并不是一个强制状态。在 Web UI 上点击 Resolve WIP status 或手动去除标题中的 WIP: 都可以解除 WIP 状态,从而允许手动合并。也就是说,这是一个「防君子不防小人」的状态。如果是在一个团队内的成员中使用,我觉得这样已经足够了。

+

如何兼容不需要评审的场景

这里其实可以利用保护分支的规则,作出一个共识:凡是已合并到保护分支上的代码,都是已经过评审的「安全」代码,无需再次评审。

+

因此可以得出结论:只有从非保护分支(特性分支)往保护分支合并的场景需要评审,其它场景均无需评审。

+
const targetBranch = await service.getBranchInfo(pid, object_attributes.target_branch)
const sourceBranch = await service.getBranchInfo(pid, object_attributes.source_branch)
if (sourceBranch.protected || !targetBranch.protected) {
// do something
return
}
+ +

通过评论实现评审流程

mrc(ctx) 中,可以实现 MR 收到新评论时的逻辑,如下图所示:

+
+ +

部分关键代码:

+
// 获取 mr 下的所有评论
const notes = await service.listCommentsOfMergeRequest(pid, mid)
// 找出邀请评论
const inviteNote = _.find(notes, v => v.author.username === 'bot' && /请.+?@.+?评审/.test(v.body))
// 找出邀请了的人
const inviters = inviteNote.body.match(/\[@.+?]/g).map(v => v.replace('[@', '').replace(']', ''))
// 找出没有 lgtm 的人
const notReviewPeople = []
inviters.forEach((uid, index) => {
const regex = new RegExp('lgtm')
if (!_.find(notes, v => v.author.username === uid && regex.test(v.body))) {
notReviewPeople.push(uid)
}
})
+ +

后记

以上评审流程,基本就是来自现在 Github 各大仓库流行的 bot 系统。可以发现,这套系统对比 Gerrit 等实现方案,除了对现有 Gitlab 用户十分友好之外,其最大的好处之一,就是控制权完全在自己手里。除了以上说到的逻辑以外,还可以自己实现任意想要的东西。如:

+
    +
  1. 为 MR 打标签。如评审人标签、评审状态标签、MR Change Size 标签,等等;
  2. +
  3. 检查 MR 内的 Commit Msg 是否合法;
  4. +
  5. 检查 MR 的 CI 是否通过;
  6. +
  7. 实现管理员用户,拥有更高的权限,通过特殊评论可以略过其它评审员直接合并,或完成其他功能
  8. +
  9. +
+

限制你的只有想象力。

+

在实现了以上的一些逻辑后,目前我司评审系统的代码量加起来也没有超过 300 行。可以说相比于购买 Gitlab EE 来说,性价比还是相当高的。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2020/integrate-renovate-with-gitlab/20210217165423.png b/2020/integrate-renovate-with-gitlab/20210217165423.png new file mode 100644 index 000000000..0a72e4588 Binary files /dev/null and b/2020/integrate-renovate-with-gitlab/20210217165423.png differ diff --git a/2020/integrate-renovate-with-gitlab/98614561-a5fc1f00-2333-11eb-8c9e-3d33107cd7ec.png b/2020/integrate-renovate-with-gitlab/98614561-a5fc1f00-2333-11eb-8c9e-3d33107cd7ec.png new file mode 100644 index 000000000..f7451c935 Binary files /dev/null and b/2020/integrate-renovate-with-gitlab/98614561-a5fc1f00-2333-11eb-8c9e-3d33107cd7ec.png differ diff --git a/2020/integrate-renovate-with-gitlab/index.html b/2020/integrate-renovate-with-gitlab/index.html new file mode 100644 index 000000000..cd940acce --- /dev/null +++ b/2020/integrate-renovate-with-gitlab/index.html @@ -0,0 +1,554 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Integrate Renovate with GitLab | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Integrate Renovate with GitLab +

+ + +
+ + + + +

企业项目群中往往会有部分代码逻辑需要公用,将其抽离作为公共包发布到私有源的做法是比较优雅的解决方式。但是这么做的话后期需要面临一个问题:当一个公共依赖包的使用者数量逐渐庞大的时候,如何保证当此包发布新版本时,所有使用者都能尽可能快地得到更新?

+

传统的解决方案:

+
    +
  1. 手工对所有项目逐个升级。这种办法相当繁琐,且容易产生遗漏,当项目数量足够庞大的时候,发布一次将会是相当痛苦的体验;
  2. +
  3. 在依赖安装时指定版本为 latest。这种办法虽然能保证每次安装时都能得到最新版本,但是却有诸多弊端,如:
      +
    1. 无法保证依赖的安全性,有可能一次更新不慎造成大面积的瘫痪;
    2. +
    3. 对「依赖锁」不友好,如 yarn.lock 等。
    4. +
    +
  4. +
+

因此,如何使这个过程变得优雅,是一个亟待解决的问题。

+ + +

关于 Renovate

Renovate 是一个专注于解决依赖问题的库,使用 Node.js 编写,因此它也许会更适合于使用 NPM 或 Yarn 作为依赖管理的项目。我最早从 zexo.dev/使用 renovate 监控第三方依赖更新 这篇博文中得知了这个工具,在 GitHub 上托管的个人项目上尝试了一段时间后发现它非常好用。

+

如何工作?

复杂的流程就不讲了。总的来说,它会对启用了它的项目做以下几件事情:

+
    +
  1. 发起一个 Onboard PR(示例),将它的配置文件以 PR 的形式合并到项目中。在这个 PR 被合并前,不会有任何后续操作。
  2. +
  3. 在 Onboard 被合并后,发起一个 Pin PR(示例),将项目中用到的依赖的版本锁定,对于 package.json 来说,即去除任何模糊的通配符,如 ^ / ~ 等,改用精确的版本号。在这个 PR 被合并前,不会有任何后续操作。
  4. +
  5. Pin PR 被合并后,开始周期性地检索依赖。当发现有更新时,为每个依赖(或依赖群)更新发起一个 PR(示例),内容包含依赖定义文件(如 package.json) 与依赖锁文件(如 yarn.lock)。
  6. +
  7. 如果用户想要做本次升级,将其合并即可。将来如果该依赖再次有更新可用,会再次生成新的 PR;
  8. +
  9. 如果用户不想做本次升级,不理会或将其关闭即可:
      +
    1. 若不理会,在将来该依赖再次升级时,Renovate 会更新该 PR 至新版本;
    2. +
    3. 若关闭,Renovate 将忽略该版本,不再发起 PR。
    4. +
    +
  10. +
+

以上只是大致流程,实际上 Renovate 还有非常多的配置项可以发掘,可以提供高度定制化的使用体验。

+

如何使用?

如果是在 GitHub 上使用,只需到应用市场安装 Renovate 并为它提供想要开启服务的项目的访问权限即可,过几分钟就能在项目内收到 Onboard PR。但这部分不是本文的重点。

+

本文重点是如何在私有环境中使用它,即 Self-hosted 环节,与我司的自建 GitLab 进行集成。

+

根据 官方文档,自建 Renovate 服务有以下几种方式:

+

方式 1:npm install -g renovate

该方式最简便,只需要安装了 Node.js 环境以后,通过以上 cli 工具即可实现所有功能。但是官方文档对他的描述十分简略,几乎没有,勉强通过 --help 才试出了用法:

+
GITHUB_COM_TOKEN=your-github-token renovate \
--platform=gitlab \
--endpoint=https://gitlab.cpmpany.com/api/v4/ \
--token=your-gitlab-token \
--onboarding=true \
--onboarding-config="{\"extends\": [\"config:base\"]}" \
--log-level=debug \
--yarnrc="registry \"http://npm-registry.cpmpany.com\"" \
--npmrc="registry=\"http://npm-registry.cpmpany.com\"" \
path/to/project
+ +

解释:

+
    +
  1. GITHUB_COM_TOKEN 是用来从 GitHub 上获取 Changelog 时要用到的。如果没有提供这个 token,则 Renovate 不会尝试去获取 Changelog;
  2. +
  3. platform / endpoint / token 分别对应目标平台的参数;
  4. +
  5. onboarding 表示项目必须先接受 Onboard PR 才会执行后续操作;
  6. +
  7. onboarding-config 为 Onboard PR 所提供的默认配置文件;
  8. +
  9. log-leveldebug 时才能得到详细的日志,方便调试;
  10. +
  11. 项目内一般自带了 yarnrc 与 npmrc,如果项目内自带的已经覆盖了私有源则无需配置,否则需要配置。需要注意的是,如果使用 npm 则只需要提供 npmrc,但如果使用 yarn 则需要同时提供 yarnrcnpmrc,缺一不可。
  12. +
+

命令行可以作为本地调试工具,最终部署的话还是直接使用打包好的镜像更好用一些。

+

方式2:使用 Docker 镜像

Renovate 提供了构建好的 renovate/renovate 镜像,可以直接使用。

+
docker run --rm -v "/path/to/your/config.js:/usr/src/app/config.js" renovate/renovate
+ +

这个镜像有多个版本,其中大体区分为 slim 版与完整版。它们之间的区别是:

+
    +
  1. 完整版包含了所有可能要用到的构件工具,如 Python 等,约 1.3GB;
  2. +
  3. slim 版仅包含 Renovate 自身,约 130MB。
  4. +
+

可以使用 GitLab CI 与该镜像直接集成,image 指定 renovate/renovate 即可。但是,最终我选择了使用 k8s 集成。

+

方式3:使用 Kubernetes

官方文档 贴心地提供了 k8s 的配置样例,基本上复制粘贴就能完成配置了:

+
apiVersion: v1
kind: Secret
metadata:
name: renovate-env
type: Opaque
stringData:
GITHUB_COM_TOKEN: 'any-personal-user-token-for-github-com-for-fetching-changelogs'
# set to true to run on all repos you have push access to
RENOVATE_AUTODISCOVER: 'false'
RENOVATE_ENDPOINT: 'https://github.company.com/api/v3'
RENOVATE_GIT_AUTHOR: 'Renovate Bot <bot@renovateapp.com>'
RENOVATE_PLATFORM: 'github'
RENOVATE_TOKEN: 'your-github-enterprise-renovate-user-token'
---
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: renovate
spec:
schedule: '@hourly'
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
containers:
- name: renovate
# Update this to the latest available and then enable Renovate on
# the manifest
image: renovate/renovate:23.19.2
args:
- user/repo
# Environment Variables
envFrom:
- secretRef:
name: renovate-env
restartPolicy: Never
+ +

但是,这里有一个地方比较坑的是,像 RENOVATE_AUTODISCOVER 这种环境变量的命名,官方并没有提供一个文档明确说明到底是以何种规则得到的(或者是我没有找到)。不过经过一番搜索,我找到了它的具体实现:

+

lib/config/env.ts:

+
export function getEnvName(option: Partial<RenovateOptions>): string {
if (option.env === false) {
return '';
}
if (option.env) {
return option.env;
}
const nameWithUnderscores = option.name.replace(/([A-Z])/g, '_$1');
return `RENOVATE_${nameWithUnderscores.toUpperCase()}`;
}
+ +

也就是,将普通配置名(驼峰命名):

+
    +
  1. 全部转为大写字母;
  2. +
  3. 单词首字母前加上 _
  4. +
  5. 前面加上 RENOVATE_
  6. +
+

就得到了环境变量的命名。

+

但是,经过实践发现这里有一个特例:logLevel 这个配置并不是转换为 RENOVATE_LOG_LEVEL,而仅仅是 LOG_LEVEL 而已。

+

另外,官方提供的样例对于所有 secrets 都使用了 stringData 来储存,不提倡这种做法。我建议将 token 类密钥信息做 base64 编码储存在 data 中:

+
apiVersion: v1
kind: Secret
metadata:
name: renovate-secret
type: Opaque
data:
GITHUB_COM_TOKEN: ...
RENOVATE_TOKEN: ...
stringData:
RENOVATE_AUTODISCOVER: 'false'
RENOVATE_ENDPOINT: 'https://gitlab.company.com/api/v4/'
RENOVATE_PLATFORM: 'gitlab'
RENOVATE_ONBOARDING: 'true'
RENOVATE_ONBOARDING_CONFIG: '{...}'
RENOVATE_SEMANTIC_COMMITS: 'enabled'
RENOVATE_YARNRC: 'registry "http://npm-registry.company.com"'
RENOVATE_NPMRC: 'registry="http://npm-registry.company.com"'
LOG_LEVEL: 'debug'
+ +

集成 GitLab

在之前的博文 Gitlab CE Code-Review Bot 中,我介绍了 GitLab CE 评审机器人的实现。由于 Renovate 也是基于 Merge Request 实现的,因此它们能够很好地相处:

+
    +
  1. Renovate 发起 MR
  2. +
  3. 评审机器人随机分派评审人
  4. +
  5. 评审通过,合并
  6. +
+

但是有几点需要注意:

+
    +
  1. 由于评审机器人使用了 WIP 来阻止 MR 被手动合并,因此 Renovate 的配置中也需要将 MR 设置为 draft 状态,这样才能维持 MR 的 WIP 标记。否则,Renovate 会在发起 MR 后的第二次扫描中尝试去除 MR 的 WIP 标记;
  2. +
  3. 最好给 Renovate 开设一个独立的账号。如果与其他用户或程序共用账号,Renovate 可能会在 force-push 的过程中使某些由其它用户做出的改动丢失;
  4. +
  5. 因为 Renovate 的设计中存在一些高危操作(分支删除,强制推送等),因此最好只赋予 Developer 权限。实际上如果不启用自动合并,它也只需要 Developer 权限。
  6. +
+

符合我需求的最终配置 renovate.json

+
{
"extends": [
"config:base",
// 除了 peerDependencies 以外所有依赖都 pin,
// 注意仅适用于业务项目,在 library 中不要这样做
":pinAllExceptPeerDependencies"
],
// 仅启用 npm 依赖管理,项目里有其它依赖项不想被 Renovate 管理的,
// 如:Docker / Gradle / Cocoa Pod 等
"enabledManagers": [
"npm"
],
// 仅对 @company/ 开头的私有包启用依赖管理,其它外部依赖一律禁用
"packageRules": [
{
"packagePatterns": [
"*"
],
"excludePackagePatterns": [
"^@company/"
],
"enabled": false
}
],
// 将 MR 标记为 draft,即 WIP
"draftPR": true
}
+ +

遇到的问题

    +
  1. 将 Renovate 部署上 Kubernetes 的时候,要注意能够分配的节点是否都有私有源的访问权限。如果 CronJob 被分配到了无权访问的节点会导致私有包 Lookup Failed,从而更新失败。如果只有部分节点拥有访问权限,可以用 nodeSelectornodeName 指定节点;
  2. +
  3. Changelog 在 GitLab (10.3.2) 上面会丢失,并且格式错乱,如图所示:screenshot
    这个问题猜测是由于我司的 GitLab 版本过低导致的。因为 gitlab.com (13.x) 上不存在这个问题。但是因为 GitLab 不在我的管辖范围内,因此目前没有找到很好的解决方案,后续如果解决了会更新。
  4. +
+

解决 Changelog 问题

我在 GitHub 上提了一个 issue,但是作者表示这是老版本 GitLab 出现的问题,建议升级 GitLab,不会为其做出改动及修复。不过他建议可以修改源码内的某些文件并自己构建一个 Docker 镜像来达到目的:

+
+

You could perhaps try building your own image with a modified version of that file, or even just sed replace parts of it at runtime. You can find it at dist/workers/pr/changelog/hbs-template.js in the built/distributed version.

+
+

但是我不是很喜欢这种做法,这样的话会更新镜像会比较麻烦。不过如他所说,也可以选择在运行时进行替换。由于之前开发过一款 评审机器人,机器人的执行逻辑刚好适合用来做这一块的热修复。只需要在 MR 创建逻辑内加多一个判断,如果是来自 renovate 的 MR 则执行修复操作;

+
    +
  1. 解决格式错乱问题:读取 MR 的 description 字段,并将 <details> 节点去除;
  2. +
  3. 解决 changelog 丢失问题:调用 GitLab API 获取 Changelog,并粘贴到 description 中;
  4. +
  5. Renovate 更新 MR 时会丢失 description 中的更改,为了保险起见,再将 Changelog 作为输出到评论中去。
  6. +
+

大致代码:

+
if (isRenovateMR && enableRenovateFix) {
try {
// renovate 在旧版 gitlab 上有问题,此处为修复逻辑
const { description } = object_attributes
// 根据 mr 内容获取项目名与 tag 名
// getStringBetween 函数:截取头尾字符串中间的内容
const projectName = getStringBetween(description, '<summary>', '</summary>')
const tagName = getStringBetween(description, '[Compare Source]', ')').split('...')[1]
// 获取 release note
const tag = await service.getTagInfo(projectName, tagName)
const releaseNote = _.get(tag, 'release.description', '')
// 移除 details 标签,并添加 release note
const _desc = description.replace('<details>', '').replace('</details>', releaseNote)
// 更新 mr 内容
await service.updateMergeRequest(pid, mid, {
description: _desc
})
// 添加评论
if (!!releaseNote) {
await service.addMergeRequestComment(pid, mid, `
更新日志:

${releaseNote}
`)
}
} catch (e) {
console.error(e)
}
}
+ +

效果如图所示:

+

screenshot

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2020/integrate-renovate-with-gitlab/revonate.png b/2020/integrate-renovate-with-gitlab/revonate.png new file mode 100644 index 000000000..4419cf5e0 Binary files /dev/null and b/2020/integrate-renovate-with-gitlab/revonate.png differ diff --git a/2020/publish-using-github-action/index.html b/2020/publish-using-github-action/index.html new file mode 100644 index 000000000..5a172f133 --- /dev/null +++ b/2020/publish-using-github-action/index.html @@ -0,0 +1,477 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Publish using GitHub Action | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Publish using GitHub Action +

+ + +
+ + + + +

本文是一些 GitHub Actions 常用发布动作的总结。

+

强烈建议将所有 Publish actions 分开执行,不要集中到一个 Workflow 内。原因是如果其中一个动作因为某些原因失败了,GitHub 目前只能重启整个 Workflow,而如果 Workflow 内某个 Job 已经成功了,那么该 Job 下一次执行必然是失败(因为此类任务一般不能对同一个版本号执行两次,发布成功一次以后第二次尝试将会被拒绝发布),因此这一个提交的 Workflow 将永远不可能成功。

+

需要注意的是,以下所提到的 secrets.GITHUB_TOKEN 均是 GitHub Action 内置的 Access Token,无需自行创建。而其它 secrets 则需要在 项目主页 -> Settings -> Secrets 处创建。

+ + +

GitHub Pages

发布 GitHub Pages 使用的是 crazy-max/ghaction-github-pages 这个 action:

+
# publish_pages.yaml
name: CD

on:
push:
tags:
- 'v*'

jobs:
deploy_gh_pages:
runs-on: ubuntu-latest
steps:
# checkout & yarn
- uses: actions/checkout@v2
- uses: c-hive/gha-yarn-cache@v1
- run: yarn --frozen-lockfile
# build
- run: npm run build
- name: GitHub Pages
uses: crazy-max/ghaction-github-pages@v2.1.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
target_branch: gh-pages
build_dir: dist
jekyll: false # 禁用 GitHub 默认开启的 jekyll 构建
fqdn: some.domain.com # 自定义域名,需要时填写
+ +

如果想要在 push 到分支时就直接 deploy,可以使用:

+
on:
push:
branches:
- master
+ +

GitHub Release

发布 Release 包含几个动作:

+
    +
  1. 根据提交记录生成 Changelog,使用 ScottBrenner/generate-changelog-action
  2. +
  3. 创建一个 Release,正文填写上一步得到的 Changelog,使用 actions/create-release
  4. +
  5. 为 Release 附加需要的 assets,使用 actions/upload-release-asset
  6. +
+
# publish_release.yaml
name: CD

on:
push:
tags:
- 'v*'

jobs:
deploy_release:
runs-on: ubuntu-latest
steps:
# checkout,由于 changelog 需要读取所有历史记录
# 因此这里 `fetch-depth` 需要填 0,代表所有
- uses: actions/checkout@v2
with:
fetch-depth: 0
ref: dev
# yarn & build
- uses: c-hive/gha-yarn-cache@v1
- run: yarn --frozen-lockfile
- name: Build
run: npm run build
# 生成 changelog
- name: Changelog
uses: scottbrenner/generate-changelog-action@master
id: Changelog
env:
REPO: ${{ github.repository }}
# 创建 release
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
body: |
${{ steps.Changelog.outputs.changelog }}
draft: false
prerelease: false
# 添加 assets
- name: Upload Release Asset
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./dist/some-js.min.js
asset_name: some-js.min.js
asset_content_type: text/javascript
+ +

NPM

发布 npm 有一个预定义的 action: JS-DevTools/npm-publish,但是用过以后我觉得实际上没有自己敲命令行好用,因为它会做一些额外的不必要的动作,可能会导致发布出错。如:第一次发布时,它默认会检查历史包而报找不到 package 的错误,虽然它文档提示可以通过参数关闭该功能,但实测下来并不行。

+

注:下面的 NPM_TOKEN 是需要自行配置的。

+
# publish_npm.yaml
name: CD

on:
push:
tags:
- 'v*'

jobs:
deploy_npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: c-hive/gha-yarn-cache@v1
- uses: actions/setup-node@v1
with:
node-version: 12.x
registry-url: https://registry.npmjs.org
- run: yarn --frozen-lockfile
- name: Build
run: npm run build
# 发布
- name: Publish NPM
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ +

NPM GitHub Registry

GitHub Registry 与 NPM 不一样的是,它要求发布的包必须是以当前仓库所属的 username 为 scope 的。因此如果要同时发布 NPM 和 GitHub Registry,在执行此步骤时 package.json 需要做一点小更改:将包名改为 scoped 的。

+

这里为求简便,使用了 deef0000dragon1/json-edit-action 来执行替换。实际上熟悉 shell 命令的话一行代码也可以完成。

+
# publish_github.yaml
name: CD

on:
push:
tags:
- 'v*'

jobs:
publish_github:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v1
with:
node-version: 12
- uses: actions/checkout@v2
# 将 package.json 中的 name 字段替换
- name: change package name
uses: deef0000dragon1/json-edit-action@v1
env:
KEY: name
VALUE: "@username/some-package"
FILE: package.json
- uses: c-hive/gha-yarn-cache@v1
- run: yarn --frozen-lockfile
# 配置 npmrc
- run: echo //npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }} >> .npmrc
# 发布
- run: npm publish --registry=https://npm.pkg.github.com
+ +

关于 npmrc 这一步,使用 NODE_AUTH_TOKEN 环境变量应该也可以达到相同目的。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2020/upgrade-webpack-of-vue-cli-projects-from-3-to-4/index.html b/2020/upgrade-webpack-of-vue-cli-projects-from-3-to-4/index.html new file mode 100644 index 000000000..adf8a87ff --- /dev/null +++ b/2020/upgrade-webpack-of-vue-cli-projects-from-3-to-4/index.html @@ -0,0 +1,468 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Upgrade Webpack of Vue-Cli Projects from 3 to 4 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Upgrade Webpack of Vue-Cli Projects from 3 to 4 +

+ + +
+ + + + +

package.json

Change webpack related devDependencies versions:

+
    +
  1. webpack to ^4
  2. +
  3. webpack-dev-server to ^3
  4. +
  5. Add webpack-cli
  6. +
  7. Replace extract-text-webpack-plugin with mini-css-extract-plugin
  8. +
  9. Replace uglifyjs-webpack-plugin with terser-webpack-plugin
  10. +
+
{
"devDependencies": {
"mini-css-extract-plugin": "^1",
"terser-webpack-plugin": "^4",
"webpack": "^4",
"webpack-cli": "^3",
"webpack-dev-server": "^3"
}
}
+ +

webpack.base.conf.js

Add mode option.

+
// ...

module.exports = {
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
context: path.resolve(__dirname, '../'),
// ...
}
+ +

webpack.prod.conf.js

    +
  1. Add performance and optimization option
  2. +
  3. Replace ExtractTextPlugin with MiniCssExtractPlugin
  4. +
  5. Remove UglifyJsPlugin and all webpack.optimize.CommonsChunkPlugin
  6. +
+
// ...
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const TerserPlugin = require('terser-webpack-plugin');
// ...
const webpackConfig = merge(baseWebpackConfig, {
// ...
performance: {
hints: false
},
optimization: {
runtimeChunk: {
name: 'manifest'
},
minimizer: [
new TerserPlugin(),
new OptimizeCSSPlugin({
cssProcessorOptions: config.build.productionSourceMap
? { safe: true, map: { inline: false } }
: { safe: true }
}),
],
splitChunks: {
chunks: 'async',
minSize: 30000,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
name: false,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'initial',
priority: -10
}
}
}
},
// ...
plugins: [
// new UglifyJsPlugin({
// uglifyOptions: {
// compress: {
// warnings: false
// }
// },
// sourceMap: config.build.productionSourceMap,
// parallel: true
// }),
// new ExtractTextPlugin({
// filename: utils.assetsPath('css/[name].[contenthash].css'),
// // Setting the following option to `false` will not extract CSS from codesplit chunks.
// // Their CSS will instead be inserted dynamically with style-loader when the codesplit chunk has been loaded by webpack.
// // It's currently set to `true` because we are seeing that sourcemaps are included in the codesplit bundle as well when it's `false`,
// // increasing file size: https://github.com/vuejs-templates/webpack/issues/1110
// allChunks: true,
// }),
new MiniCssExtractPlugin({
filename: utils.assetsPath('css/[name].css'),
chunkFilename: utils.assetsPath('css/[name].[contenthash].css')
}),
// split vendor js into its own file
// new webpack.optimize.CommonsChunkPlugin({
// name: 'vendor',
// minChunks (module) {
// // any required modules inside node_modules are extracted to vendor
// return (
// module.resource &&
// /\.js$/.test(module.resource) &&
// module.resource.indexOf(
// path.join(__dirname, '../node_modules')
// ) === 0
// )
// }
// }),
// extract webpack runtime and module manifest to its own file in order to
// prevent vendor hash from being updated whenever app bundle is updated
// new webpack.optimize.CommonsChunkPlugin({
// name: 'manifest',
// minChunks: Infinity
// }),
// This instance extracts shared chunks from code splitted chunks and bundles them
// in a separate chunk, similar to the vendor chunk
// see: https://webpack.js.org/plugins/commons-chunk-plugin/#extra-async-commons-chunk
// new webpack.optimize.CommonsChunkPlugin({
// name: 'app',
// async: 'vendor-async',
// children: true,
// minChunks: 3
// }),
],
// ...
})
+ +

That’s it, enjoy. 🎉

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2020/use-eslint-to-forbid-entire-import-of-lodash/index.html b/2020/use-eslint-to-forbid-entire-import-of-lodash/index.html new file mode 100644 index 000000000..3e4f10fa1 --- /dev/null +++ b/2020/use-eslint-to-forbid-entire-import-of-lodash/index.html @@ -0,0 +1,462 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +使用 Eslint 来禁止 Lodash 的整体引入 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 使用 Eslint 来禁止 Lodash 的整体引入 +

+ + +
+ + + + +

前端项目使用 lodash 时需要注意,一不小心就会把整个库引入进来,大大增加最终打包体积。

+ + +

两种真正可以实现按 method 引入的方式,一是:

+
import get from 'lodash/get'
+ +

二是:

+
// yarn add lodash.get
import get from 'lodash.get'
+ +

除此以外,其它所有方式都会导致整体引入。如:

+
import { get } from 'lodash'
import _ from 'lodash'
import * as _ from 'lodash'
+ +

虽然我知道这件事,但有时候我的队友不知道,辛辛苦苦改了半天的成果可以被别人一行代码就摧毁。因此我决定找一个方法来永久杜绝这件事的发生:

+
// http://eslint.org/docs/user-guide/configuring
module.exports = {
'rules': {
// ...
'no-restricted-imports': [2, {
'paths': [
{
'name': 'lodash',
'message': '仅允许类似 import get from \'lodash/get\' 的引入方式'
}
]
}]
}
}
+ +

这样的话,只要代码里面一出现 import ... from 'lodash',eslint 就会报错,提示他去改代码。就算提交了,CI 也通过不了。岂不美哉。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2020/wsl-on-windows-10-and-node-js/index.html b/2020/wsl-on-windows-10-and-node-js/index.html new file mode 100644 index 000000000..58e490769 --- /dev/null +++ b/2020/wsl-on-windows-10-and-node-js/index.html @@ -0,0 +1,532 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +WSL on Windows 10 and Node.js | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ WSL on Windows 10 and Node.js +

+ + +
+ + + + +

Linux 的命令行与构建工具一般来说要比 Windows 好用,但 Windows 的用户界面毫无疑问要比 Linux 好用。以往在 Windows 10 上安装 Linux,要么是使用虚拟机,要么是使用双系统,总是无法做到两头兼顾。现在 Windows 10 有了 WSL 技术,使得「二者合一」成为了可能。

+ + +

WSL

安装

关于如何安装 WSL,可以参考 适用于 Linux 的 Windows 子系统安装指南 (Windows 10),总的来说:

+
    +
  1. 将 Windows 10 系统版本升到最高,如果需要安装 WSL 2 则目前来说需要比高更高(体验版);
  2. +
  3. 在「启用或关闭 Windows 功能」中,开启「适用于 Linux 的 Windows 子系统」,如果要安装 WSL 2 还需要开启「Hyper-V」;
  4. +
  5. 在 Windows 10 应用商店中搜索关键字「Linux」,并选择自己喜欢的发行版下载,比如我选择了「Ubuntu」;
  6. +
  7. 下载完成后,在开始菜单中找到它,并点击,会继续安装,过程大概需要几分钟;
  8. +
  9. 安装完成后,会提示输入 username 与 password,此即为 Linux 的用户凭据,至此 WSL 已安装完毕。
  10. +
+

权限

为新增加的用户赋予 root 权限:

+
$ sudo vim /etc/sudoers
+ +

在:

+
# User privilege specification
root ALL=(ALL:ALL) ALL
+ +

下面增加一行:

+
username	ALL=(ALL:ALL) ALL
+ +

这里的 username 即是刚才创建的用户名,:wq! 退出即可。

+

测试

安装完毕后,可以通过在终端输入 wsl 来进入已安装的 Linux 子系统。Linux 与 Windows 共享文件系统,Windows 的文件可以在 /mnt 下找到:

+
$ ls /mnt/
c d e f
+ +

这里的 c d e f 就分别代表 C/D/E/F 盘。

+

查看发行版本:

+
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 20.04 LTS
Release: 20.04
Codename: focal
+ +

Terminal

Windows 10 自带的 CommandLine 和 PowerShell 都不好用,而且丑。可以下载 Windows 新推出的 Windows Terminal,直接在 Windows 10 应用商店就能找到。

+

Github: Microsoft/Terminal

+

同样,打开 Windows Terminal 后可以输入 wsl 来进入 Linux 子系统。

+

Git

安装

有了 WSL 后,开发相关工具环境都不需要在 Windows 下安装了。可以直接使用 Linux 内的程序。以 Git 为例:

+
    +
  1. 打开 C:\Users\[username]\AppData\Roaming\
  2. +
  3. 在这里新建一个 bin 文件夹;
  4. +
  5. 在文件夹内新建一个 git.cmd 文件,输入内容:
    @echo off
    %WINDIR%\System32\bash.exe -c "git %*"
  6. +
  7. 在 Path 内加入刚刚设置的文件:C:\Users\[username]\AppData\Roaming\bin\git.cmd
  8. +
+

这样一来,就可以直接在 Windows 内访问到安装在 WSL 内的 git 了:

+
$ git --version
git version 2.25.1
+ +

除了 git 以外,其它程序也都可以如法炮制。

+

SSH Key

$ git config --global user.name "username"
$ git config --global user.email "email@example.com"
$ ssh-keygen -trsa -C "email@example.com"
$ cat ~/.ssh/id_rsa.pub
+ +

如此可以得到公钥。

+

Node.js

使用 apt get 之前,先替换一下镜像源:

+
$ sudo vim /etc/apt/sources.list

# 将文件内容替换为以下:
# 默认注释了源码镜像以提高 apt update 速度,如有需要可自行取消注释
deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal main restricted universe multiverse
# deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal main restricted universe multiverse
deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-updates main restricted universe multiverse
# deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-updates main restricted universe multiverse
deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-backports main restricted universe multiverse
# deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-backports main restricted universe multiverse
deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-security main restricted universe multiverse
# deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-security main restricted universe multiverse

# 预发布软件源,不建议启用
# deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-proposed main restricted universe multiverse
# deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-proposed main restricted universe multiverse
+ +

这里使用的是 清华大学镜像源

+

Node.js & NPM

替换完以后,安装 Node.js 与 NPM:

+
$ sudo apt-get update
$ sudo apt-get upgrade
$ sudo apt-get install nodejs
$ sudo apt-get install npm
+ +

n

安装 Node.js 版本管理工具 n:

+
$ sudo npm install -g n
# 查看所有已安装版本
$ n ls
# 安装最新的 LTS 版本并切换
$ n lts
+ +

nrm

安装 NPM 源管理工具 nrm:

+
$ sudo npm install -g nrm
# 查看所有源
$ nrm ls
# 切换至 taobao 镜像源
$ nrm use taobao
+ +

Yarn

安装 Yarn:

+
$ sudo npm install -g yarn
+ +

WebStorm

WebStorm 可以直接与 WSL 完美集成。

+
    +
  1. Terminal: File | Settings | Tools | Terminal,将 Shell path 设置为 "cmd.exe" /k "wsl.exe",这样 Terminal 打开就直接进入了 WSL
  2. +
  3. Git: File | Settings | Version Control | Git,将 Path to Git excutable 设置为 C:\Users\[username]\AppData\Roaming\bin\git.cmd
  4. +
  5. Node.js: File | Settings | Languages & Frameworks | Node.js and NPM,Node interpreter 这里选择 Add 可以直接添加 WSL 内的 Node.js,NPM 在 \\wsl$\Ubuntu\usr\local\lib\node_modules\npm,Yarn 在 \\wsl$\Ubuntu\usr\local\lib\node_modules\yarn
  6. +
+

这样一来,就可以实现 「Windows 的开发界面,Linux 的开发工具」了。

+
+

update:

+

目前发现 Git 的 Commit 功能会报错:

+
Commit failed with error
0 file committed, 2 files failed to commit: update theme
could not read log file 'C:UsersedisoAppDataLocalTempgit-commit-msg-.txt': No such file or directory
+ +

Push 正常。

+

解决办法:

+
    +
  1. 直接在 Terminal 内使用 git commit
  2. +
  3. 升级到 2020.2 版本(目前是 EAP),但是经测试该版本要求 WSL2 才能正常工作,也就是 Windows 也要升级到 EAP 才行。不推荐。
  4. +
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/2021-spring-festival/index.html b/2021/2021-spring-festival/index.html new file mode 100644 index 000000000..3dc3e5d21 --- /dev/null +++ b/2021/2021-spring-festival/index.html @@ -0,0 +1,464 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2021 春节 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 2021 春节 +

+ + +
+ + + + +

今年疫情原因,本来不是很想回家过年的,想着工作累了,在珠海(中山)做几天废人也不错。但是现在回想起来,虽然家里比较小也比较无聊,逢年过节还是应该回家看看。

+ + +

说是回家过年,其实除了:

+
    +
  1. 除夕夜聚餐一次
  2. +
  3. 初一早茶聚餐一次
  4. +
  5. 初二集体出游一次
  6. +
+

以外,并没有其它特别的活动。基本就是宅在家里吃饭、睡觉、看电视。我一直觉得在广东过年比较冷清,今年好像又更冷清了一点。

+

妈妈老了,马上要 60 岁了。有两个瞬间让我特别有感触:

+
    +
  1. 回家的时候,看到妈妈在洗手间挂了一个四连排的洗漱架,上面还特意写上了名字;
  2. +
  3. 临走时跟家人在一桌吃饭,我爸给我拿了一双金属筷子,妈妈默默地给换成了木头筷子。我就说,我用勺子就行了,不需要筷子,她又笑了一下放了回去。
  4. +
+

第一件事,家虽然又小又老旧,但是家就是家。妈妈始终希望我过年能够回家,并且也做了很多准备。如果因为私欲就不想回家,未免太伤人了些。

+

第二件事,是因为我曾经说过我不喜欢金属筷子,它如果碰到我补过牙的地方会产生电流的感觉。但是她可能又忘了我现在不需要筷子。

+

我总感觉老了的人能记住特别多的细节,我喜欢什么,不喜欢什么,都记得特别清楚。而且会变得特别小心,我曾经说过不喜欢的东西,或者为此发过火的东西,就感觉以后再也没有碰到了。就好像以前妈妈很喜欢敲我的房间门叫我吃饭,我就有一次特别生气,说就让我好好睡一下吧,之类的。从那以后,只要我关着房门,妈妈就再也没有叫过我。

+

以前小时候孩子是弱势群体,大人总是表现得很强势。现在大人老了,也即将变为弱势群体,轮到孩子来照顾大人了。

+

另外,这次回家还得知了一些弟妹的近况,具体就不说了,但是,有一种我这一代已经成为了被拍在岸上的人的感觉,已经跟不上时代的潮流了。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/4-examples-of-mongodb-aggregate/index.html b/2021/4-examples-of-mongodb-aggregate/index.html new file mode 100644 index 000000000..f2808efb3 --- /dev/null +++ b/2021/4-examples-of-mongodb-aggregate/index.html @@ -0,0 +1,467 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +MongoDB Aggregate 4 例 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ MongoDB Aggregate 4 例 +

+ + +
+ + + + +

有数据格式如下:

+
{
"id": "745",
"knownName": {
"en": "A. Michael Spence",
"se": "A. Michael Spence"
},
"familyName": {
// 结构同上,下同
// ..
},
"orgName": {
// orgName 当获奖者为组织时出现
// ..
},
"gender": "male",
"nobelPrizes": [
{
"awardYear": "2001",
// ...
"affiliations": [
{
"name": {
"en": "Stanford University",
// ...
},
"city": {
// ...
},
"country": {
// ...
},
// ...
}
]
}
]
}
+ +

想要实现:

+
    +
  1. 查找名为 CERNaffiliation 的所在国家
  2. +
  3. 查找获奖次数大于等于 5 次的 familyName
  4. +
  5. 查找 University of California 的不同所在位置总数
  6. +
  7. 查找至少一个诺贝尔奖授予组织而非个人的年份总数
  8. +
+ + +

查找名为 CERN 的 affiliation 的所在国家

需要注意的是 affiliationsnobelPrizes 下的数组(嵌套数组结构),因此需要分两次展开:

+
db.laureates.aggregate(
[
// 展开 nobelPrizes
{ $unwind: '$nobelPrizes' },
// 展开 nobelPrizes 下面的 affiliations
{ $unwind: '$nobelPrizes.affiliations' },
// 找到名为 CERN 的记录
{ $match: { 'nobelPrizes.affiliations.name.en': 'CERN' } },
// 将结果限制为 1 条
{ $limit: 1 },
// 映射输出
{ $project: { '_id': 0, 'country': '$nobelPrizes.affiliations.country.en' } }
]
);

// output:
// { "country" : "Switzerland" }
+ +

查找获奖次数大于等于 5 次的 familyName

这里需要用到 $group 操作,根据 familyName 来进行分组,并且需要提前计算好每条记录所获奖的数量:

+
db.laureates.aggregate(
[
// 映射每条记录的 nobelPrizes 长度为 nobelPrizesLength,familyName.en 为 familyName
{ $project: { nobelPrizesLength: { $size: "$nobelPrizes" }, familyName: "$familyName.en" } },
// 找到 familyName 存在的记录(非组织获奖)
{ $match: { familyName: { $exists: !0, $ne: null } } },
// 以 familyName 为依据进行分组,并累加 nobelPrizesLength 作为 count
{ $group: { _id: "$familyName", count: { $sum: "$nobelPrizesLength" }, familyName: { $first: "$familyName" } } },
// 找到 count 大于等于 5 的记录
{ $match: { count: { $gte: 5 } } },
// 映射输出
{ $project: { familyName: "$familyName", _id: 0 } }
]
);

// output:
// { "familyName" : "Smith" }
// { "familyName" : "Wilson" }
+ +

查找 University of California 的不同所在位置总数

一个相比上个查询更简单的 group 查询:

+
db.laureates.aggregate(
[
// 展开 nobelPrizes
{ $unwind: "$nobelPrizes" },
// 展开 nobelPrizes 下面的 affiliations
{ $unwind: "$nobelPrizes.affiliations" },
// 找到名为 University of California 的记录
{ $match: { "nobelPrizes.affiliations.name.en": "University of California" } },
// 以 city 名作为依据分组
{ $group: { _id: "$nobelPrizes.affiliations.city.en" } },
// 输出分组后的总记录数
{ $count: "locations" }
]
);

// output:
// { "locations" : 6 }
+ +

查找至少一个诺贝尔奖授予组织而非个人的年份总数

这里注意 group 之前先把授予个人的记录筛除掉:

+
db.laureates.aggregate(
[
// 找到 orgName 存在的记录(组织获奖)
{ $match: { orgName: { $exists: !0, $ne: null } } },
// 展开 nobelPrizes
{ $unwind: "$nobelPrizes" },
// 以获奖年份分组
{ $group: { _id: "$nobelPrizes.awardYear" } },
// 输出分组后的总记录数
{ $count: "years" }
]
);

// output:
// { "years" : 26 }
+
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/a-memorable-moment/13d7282a4d2649e1b8768d3ceffd71c2.png b/2021/a-memorable-moment/13d7282a4d2649e1b8768d3ceffd71c2.png new file mode 100644 index 000000000..b4a476bdc Binary files /dev/null and b/2021/a-memorable-moment/13d7282a4d2649e1b8768d3ceffd71c2.png differ diff --git a/2021/a-memorable-moment/index.html b/2021/a-memorable-moment/index.html new file mode 100644 index 000000000..1721a23a4 --- /dev/null +++ b/2021/a-memorable-moment/index.html @@ -0,0 +1,448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +值得纪念的时刻 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 值得纪念的时刻 +

+ + +
+ + + + +

昨天正式受邀(实际上是我申请的)进入了 vuejs 组织。虽然目前只是 doc team,但是我相信以后可以做更多的事情。

+

638f7ff6d334b2d7616039a3787efe6.png

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/a-simple-way-to-speed-up-github-connection/index.html b/2021/a-simple-way-to-speed-up-github-connection/index.html new file mode 100644 index 000000000..e8d67d48f --- /dev/null +++ b/2021/a-simple-way-to-speed-up-github-connection/index.html @@ -0,0 +1,462 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +比较简单的 GitHub 加速方式 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 比较简单的 GitHub 加速方式 +

+ + +
+ + + + +

在不想全局 vpn 的情况下,可以用 host 加速。

+

该方法主要利用 github.com/ineo6/hosts 的 hosts 文件,国内镜像 gitee.com/ineo6/hosts

+

方法一:手动

手动复制 hosts 的内容,并粘贴至对应操作系统的 hosts 文件内。

+

方法二:自动

    +
  1. 下载开源的 host 切换软件 SwitchHosts
  2. +
  3. 新建一条规则:
      +
    1. 方案名:随便
    2. +
    3. 类型:远程
    4. +
    5. URL 地址:https://gitee.com/ineo6/hosts/raw/master/hosts
    6. +
    7. 自动更新:随便,或 1 小时
    8. +
    +
  4. +
  5. 保存,保存后可以先手动刷新一次
  6. +
  7. 启用即可
  8. +
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/blog-migrate-to-hexo/index.html b/2021/blog-migrate-to-hexo/index.html new file mode 100644 index 000000000..46a26a09f --- /dev/null +++ b/2021/blog-migrate-to-hexo/index.html @@ -0,0 +1,452 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +博客迁移至 Hexo | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 博客迁移至 Hexo +

+ + +
+ + + + +

博客迁移至 Hexo。主要原因是:

+
    +
  1. Vuepress 有部分 bug 难以忍受,而且 v1 仓库已经停止维护了;
  2. +
  3. Vuepress 的功能对于 blog 来说还是有些弱;
  4. +
  5. Vuepress v1 存在文章数量增加,首屏加载大小不断变多的问题;
  6. +
  7. Vuepress 没有 blog 主题,而我自己写的主题是基于 v1 的,且无法升上 v2 (因为:为了解决问题 3,v2 中 $posts 变量被移除了,而该主题的首页依赖这个变量做渲染);
  8. +
  9. …… (其它难以忍受的问题)
  10. +
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/learn-golang/0f272d0e38754f6d8d75e654464b45ef.png b/2021/learn-golang/0f272d0e38754f6d8d75e654464b45ef.png new file mode 100644 index 000000000..7d61dac1d Binary files /dev/null and b/2021/learn-golang/0f272d0e38754f6d8d75e654464b45ef.png differ diff --git a/2021/learn-golang/17e50758d4e84a1683bde714e4ca8e64.png b/2021/learn-golang/17e50758d4e84a1683bde714e4ca8e64.png new file mode 100644 index 000000000..d403c7c24 Binary files /dev/null and b/2021/learn-golang/17e50758d4e84a1683bde714e4ca8e64.png differ diff --git a/2021/learn-golang/1ce7baff04564b6787035adb5e9d3469.png b/2021/learn-golang/1ce7baff04564b6787035adb5e9d3469.png new file mode 100644 index 000000000..9525487de Binary files /dev/null and b/2021/learn-golang/1ce7baff04564b6787035adb5e9d3469.png differ diff --git a/2021/learn-golang/455e27fce6654ef98338197e7891c2ca.png b/2021/learn-golang/455e27fce6654ef98338197e7891c2ca.png new file mode 100644 index 000000000..e4e915c6d Binary files /dev/null and b/2021/learn-golang/455e27fce6654ef98338197e7891c2ca.png differ diff --git a/2021/learn-golang/4ded1987bf6847b08a0ab90c8f44a4af.png b/2021/learn-golang/4ded1987bf6847b08a0ab90c8f44a4af.png new file mode 100644 index 000000000..10b8bfed9 Binary files /dev/null and b/2021/learn-golang/4ded1987bf6847b08a0ab90c8f44a4af.png differ diff --git a/2021/learn-golang/638ef979ec104bec813670c252048d28.png b/2021/learn-golang/638ef979ec104bec813670c252048d28.png new file mode 100644 index 000000000..b30d1939f Binary files /dev/null and b/2021/learn-golang/638ef979ec104bec813670c252048d28.png differ diff --git a/2021/learn-golang/7a4e9184666b4e8c88f8268dd9b1f9e5.png b/2021/learn-golang/7a4e9184666b4e8c88f8268dd9b1f9e5.png new file mode 100644 index 000000000..cb3e542fe Binary files /dev/null and b/2021/learn-golang/7a4e9184666b4e8c88f8268dd9b1f9e5.png differ diff --git a/2021/learn-golang/853c6909c14145e5be8208d08b8624cb.png b/2021/learn-golang/853c6909c14145e5be8208d08b8624cb.png new file mode 100644 index 000000000..020fdf920 Binary files /dev/null and b/2021/learn-golang/853c6909c14145e5be8208d08b8624cb.png differ diff --git a/2021/learn-golang/8e295bcb68f842d9888683b2ab517fd2.png b/2021/learn-golang/8e295bcb68f842d9888683b2ab517fd2.png new file mode 100644 index 000000000..fa354b7a5 Binary files /dev/null and b/2021/learn-golang/8e295bcb68f842d9888683b2ab517fd2.png differ diff --git a/2021/learn-golang/9bebdf3479a146bcb2d405e9fbf2d1f4.png b/2021/learn-golang/9bebdf3479a146bcb2d405e9fbf2d1f4.png new file mode 100644 index 000000000..900a185ef Binary files /dev/null and b/2021/learn-golang/9bebdf3479a146bcb2d405e9fbf2d1f4.png differ diff --git a/2021/learn-golang/9db7a35f8c634ba9a4bbee5e3fc72810.png b/2021/learn-golang/9db7a35f8c634ba9a4bbee5e3fc72810.png new file mode 100644 index 000000000..c9c527c8f Binary files /dev/null and b/2021/learn-golang/9db7a35f8c634ba9a4bbee5e3fc72810.png differ diff --git a/2021/learn-golang/c3ab258e951d4ee2aa5a06790c70419b.png b/2021/learn-golang/c3ab258e951d4ee2aa5a06790c70419b.png new file mode 100644 index 000000000..2b2510f52 Binary files /dev/null and b/2021/learn-golang/c3ab258e951d4ee2aa5a06790c70419b.png differ diff --git a/2021/learn-golang/c87f0addb2a64ecda21d603fbd737a74.png b/2021/learn-golang/c87f0addb2a64ecda21d603fbd737a74.png new file mode 100644 index 000000000..f3fe971ee Binary files /dev/null and b/2021/learn-golang/c87f0addb2a64ecda21d603fbd737a74.png differ diff --git a/2021/learn-golang/d3cbfbf4906e4e32afdcc105e2ff827b.png b/2021/learn-golang/d3cbfbf4906e4e32afdcc105e2ff827b.png new file mode 100644 index 000000000..9ac511904 Binary files /dev/null and b/2021/learn-golang/d3cbfbf4906e4e32afdcc105e2ff827b.png differ diff --git a/2021/learn-golang/ddbc35f312ef43a1867d4c7c9e65ad86.png b/2021/learn-golang/ddbc35f312ef43a1867d4c7c9e65ad86.png new file mode 100644 index 000000000..e7a59c7bf Binary files /dev/null and b/2021/learn-golang/ddbc35f312ef43a1867d4c7c9e65ad86.png differ diff --git a/2021/learn-golang/f5c87e2b40da4e1d836c95e81bfa4854.png b/2021/learn-golang/f5c87e2b40da4e1d836c95e81bfa4854.png new file mode 100644 index 000000000..b32bb3bf8 Binary files /dev/null and b/2021/learn-golang/f5c87e2b40da4e1d836c95e81bfa4854.png differ diff --git a/2021/learn-golang/image2.png b/2021/learn-golang/image2.png new file mode 100644 index 000000000..38ba72727 Binary files /dev/null and b/2021/learn-golang/image2.png differ diff --git a/2021/learn-golang/image3.png b/2021/learn-golang/image3.png new file mode 100644 index 000000000..1217c594f Binary files /dev/null and b/2021/learn-golang/image3.png differ diff --git a/2021/learn-golang/index.html b/2021/learn-golang/index.html new file mode 100644 index 000000000..83999b1e6 --- /dev/null +++ b/2021/learn-golang/index.html @@ -0,0 +1,917 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +golang 学习笔记 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ golang 学习笔记 +

+ + +
+ + + + +

我的 golang 学习笔记。好几年前就说要学了,现在终于兑现。

+ + + +

开发环境

安装

https://studygolang.com/dl

+
go version
go env
+ +

国内镜像

https://goproxy.cn/

+
go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.cn,direct
+ +

goimports

go get -v golang.org/x/tools/cmd/goimports
+ +

IDE

    +
  • IDEA 安装 go 和 file watcher 插件
  • +
  • 新建项目使用 goimports 模板
  • +
  • filewather 增加 goimports,配置默认
  • +
  • 快速生成变量快捷键:ctrl+alt+v
  • +
+

基础语法

变量定义

    +
  • 变量类型写在后面,名字写在前面,形似 typescript
  • +
  • 类型可以推断
  • +
  • 没有 char,只有 rune
  • +
  • 原生支持复数类型
  • +
+

var

var a ,b, c bool
var s1, s2 string = "hello", "world"
+ +
    +
  • 可放在包内或函数内
  • +
  • 可以用 var() 集中定义变量
  • +
  • 类型可以自动推断
  • +
+

:=

只能在函数内使用

+

内建变量类型

    +
  • bool, string
  • +
  • (u)int, (u)int8, … (u)int64, uintptr (指针)
  • +
  • byte, rune (char)
  • +
  • float32, float64, complex64, complex128 (复数)
  • +
+

类型转换是强制的,没有隐式转换。

+
var c int = int(math.Sqrt(float64(a*a + b*b)))
+ +

常量

const

const filename = "abc.txt"
+ +

常量数值可以作为各种类型使用:

+
const (
a, b = 3, 4
)
var c int = int(math.Sqrt(a*a + b*b))
+ +

枚举

    +
  • 用 const 定义枚举。
  • +
  • 可以是固定值,也可以用 iota 自增。iota 可以参与运算。
  • +
+
const (
b = 1 << (10 * iota)
kb
mb
gb
tb
pb
)
+ +

条件

if

    +
  • if 不需要括号
  • +
  • 条件内可以定义变量,变量的作用域局限于 if
  • +
+
const filename = "abc.txt"
if contents, err := ioutil.ReadFile(filename); err != nil {
fmt.Println(err)
} else {
fmt.Printf("%s\n", contents)
}
+ +

switch

    +
  • switch **默认 break**,除非加 fallthrough
  • +
  • switch 可以没有表达式,条件写在 case
  • +
+
func grade(score int) string {
g := ""
switch {
case score < 0 || score > 100:
panic(fmt.Sprintf("Wrong score: %d", score))
case score < 60:
g = "F"
case score < 80:
g = "C"
case score < 90:
g = "B"
case score <= 100:
g = "A"
}
return g
}
+ +

循环

for

    +
  • for 不需要括号
  • +
  • for 可以省略初始条件(相当于 while)、结束条件、递增表达式
  • +
+
for n := 100 ; n > 0; n /= 2 {
// todo
}
+ +
for scanner.Scan() {
fmt.Println(scanner.Text())
}
+ +

函数

    +
  • 返回值类型写在最后面(类似 typescript)
  • +
  • 函数可以返回多个值(一般用法为第二个参数返回 error)
  • +
  • 函数返回多个值时可以起名
  • +
  • 函数可以作为参数
  • +
  • 有可变参数列表
  • +
  • 有匿名函数
  • +
  • 没有默认参数、可选参数、函数重载等
  • +
+
func eval(a, b int, op string) (int, error) {
switch op {
case "+":
return a + b, nil
case "-":
return a - b, nil
case "*":
return a * b, nil
case "/":
q, _ := div(a, b)
return q, nil
default:
return 0, fmt.Errorf("unsupported operation: %s", op)
}
}

func div(a, b int) (q, r int) {
return a / b, a % b
}

func sum(numbers ...int) int {
s := 0
for i := range numbers {
s += numbers[i]
}
return s
}

func apply(op func(int, int) int, a, b int) int {
p := reflect.ValueOf(op).Pointer()
opName := runtime.FuncForPC(p).Name()
fmt.Printf("Calling func %s with args (%d, %d)\n", opName, a, b)
return op(a, b)
}

fmt.Println(apply(func(a int, b int) int {
return int(math.Pow(float64(a), float64(b)))
}, 3, 4))
+ +

指针

    +
  • go 指针不能运算
  • +
  • 相比于其它语言的基础类型值传递、复杂类型引用传递,go 语言只能进行值传递,引用传递要显式声明
  • +
+
func swap(a, b *int) {
*b, *a = *a, *b
}

swap(&a, &b)
+ +

+

+

内建容器

Array 数组

    +
  • [10]int[20]int不同的类型
  • +
  • 数组传入函数中的是,不是引用,值会进行拷贝
  • +
  • go 语言中一般不直接使用数组,而是使用 slice 切片
  • +
+

定义

数量写在类型前

+
var arr1 [5]int
arr2 := [3]int{1, 2, 3}
arr3 := [...]int{2, 4, 6, 8, 10}

var grid [4][5]int
+ +

遍历

for i := 0; i < len(arr3); i++ {
fmt.Println(arr3[i])
}

for i, v := range arr3 {
fmt.Println(i, v)
}
+ +

Slice 切片

    +
  • slice 不是值类型,它是 array 的一个视图 (view),对 slice 的改动会反映到 array
  • +
  • slice 可以向后扩展,但不能向前扩展
  • +
  • s[i] 不可以超越 len(s),向后拓展可以超越 len(s) 但不能超越 cap(s)
  • +
+
arr := [...]int{0, 1, 2, 3, 4, 5, 6, 7}

fmt.Println("arr[2:6] =", arr[2:6])
fmt.Println("arr[:6] =", arr[:6])
fmt.Println("arr[2:] =", arr[2:])
fmt.Println("arr[:] =", arr[:])

func updateSlide(s []int) {
s[0] = 100
}
updateSlide(s1)
+ +
fmt.Println("Extending slide")
arr[0], arr[2] = 0, 2
fmt.Println("arr =", arr)
s1 = arr[2:6]
s2 = s1[3:5]
//fmt.Println(s1[4])
fmt.Printf("s1=%v, len(s1)=%d, cap(s1)=%d\n", s1, len(s1), cap(s1))
fmt.Printf("s2=%v, len(s2)=%d, cap(s2)=%d\n", s2, len(s2), cap(s2))
+ +

+

+

create

var s []int // nil
+ +

这种方式创建的 slice,初始值等于 nil

+

往里面添加元素时,lencap 是动态的。

+

+

另一种方法:

+
// 指定初始 len
s2 := make([]int, 16)
// 指定初始 len cap
s3 := make([]int, 10, 32)
+ +

append

有内建函数:

+
s = append(s, val)
+ +

添加元素时如果超越了 cap,系统会重新分配更大的底层数组

+

由于值传递的关系,必须接收 append 的返回值

+

copy

copy(s2, s1)
+ +

delete

删除下标为 3 的元素:

+
s2 = append(s2[:3], s2[4:]...)
+ +

shift/pop

// shift
front := s2[0]
s2 = s2[1:]
// pop
tail := s2[len(s2)-1]
s2 = s2[:len(s2)-1]
+ +

Map

操作

    +
  • 创建:make(map[string]int)
  • +
  • 获取:m[key],字符不存在返回 zero value
  • +
  • 判断 key 是否存在:value, ok := m[key]
  • +
  • 删除:delete(m, key)
  • +
  • 遍历:for k, v := range m,无序的
  • +
  • 获取长度:len(m)
  • +
  • map 使用哈希表,key 必须可以比较相等,除了 slice map function 以外的内建类型都可以作为 key,不包含上述字段的 struct 也可以
  • +
+

例:寻找最长的不含有重复字符的子串

func longest(s string) int {
lastOccurred := make(map[byte]int)
start := 0
maxLength := 0
for i, c := range []byte(s) {
if last, ok := lastOccurred[c]; ok && last >= start {
start = last + 1
}
if (i - start + 1) > maxLength {
maxLength = i - start + 1
}
lastOccurred[c] = i
}
return maxLength
}
+ +

String

    +
  • for i, b := range []byte(s) 得到的是 8 位 byte
  • +
  • for i, b := range []rune(s) 得到的是 utf8 解码后的字符
  • +
  • 获取 utf8 字符串长度:utf8.RuneCountInString(s)
  • +
  • 字符串操作库:strings.ToUpper / strings.xxx
  • +
+

面向对象

    +
  • 仅支持封装,不支持继承和多态
  • +
  • 没有 class,只有 struct
  • +
+

struct

    +
  • 无需关注结构体是储存在栈还是堆上
  • +
  • 知识点:nil 指针也调用方法
  • +
+

定义

    +
  • 值定义与成员方法的定义方式与传统方式有区别
  • +
  • 成员方法定义只有使用指针接收者(引用传递)才能改变结构的内容
  • +
  • 结构过大要考虑使用指针接收者(拷贝成本)
  • +
  • 注意方法的一致性:最好要么都是指针接收者,要么都是值接收者
  • +
+
type TreeNode struct {
value int
left, right *TreeNode
}

// 这里是值传递
func (node TreeNode) print() {
fmt.Println(node.value)
}

// 这里是引用传递
func (node *TreeNode) setValue(value int) {
// 不是 node->value
node.value = value
}

root.print()
root.setValue(1)
+ +

创建

var root TreeNode
root.left = &TreeNode{}
// 指针也可以直接“点”
root.left.right = &TreeNode{4, nil, nil}
root.right = &TreeNode{value: value}
+ +
func createNode(value int) *TreeNode {
return &TreeNode{value: value}
}

root.right = createNode(3)
+ +

例子:遍历树

func (node *TreeNode) travel() {
if node == nil {
return
}
// 即使 node.left 是 nil,它也能调用方法!
node.left.travel()
node.print()
node.right.travel()
}
+ +

包与封装

+

    +
  • 每个目录是一个包
  • +
  • “main 包”包含可执行入口
  • +
  • 为结构定义的方法必须放在同一个包内,可以是不同的文件
  • +
+

封装

    +
  • 名字 CamelCase
  • +
  • 首字母大写代表 public
  • +
  • 首字母小写代表 private
  • +
+
// node.go
package tree

import "fmt"

type Node struct {
Value int
Left, Right *Node
}

func CreateNode(value int) *Node {
return &Node{Value: value}
}

// ...
+ +
// travalsal.go
package tree

func (node *Node) Travel() {
// ...
}
+ +
// main.go
package main

import (
"fmt"
"learngo/tree"
)

func main() {
var root tree.Node
// ...
}
+ +

扩展

方法一:别名(简单)
package queue

type Queue []int

func (q *Queue) Push(value int) {
*q = append(*q, value)
}

func (q *Queue) Pop() int {
pop := (*q)[0]
*q = (*q)[1:]
return pop
}

func (q *Queue) IsEmpty() bool {
return len(*q) == 0
}
+ +
方法二:组合(常用)

与 js 的 {node: ...node} 类似,没有其它处理:

+
type myTreeNode struct {
node *tree.Node
}

func (node *myTreeNode) postOrder() {
if node == nil || node.node == nil {
return
}

left := myTreeNode{node.node.Left}
left.postOrder()

right := myTreeNode{node.node.Right}
right.postOrder()

node.node.Print()
}

myNode := myTreeNode{&root}
myNode.postOrder()
+ +
方法三:内嵌(少写代码)
    +
  • 其实是一个语法糖,编译器自动将字段以 Node 命名了。并且:Node 的属性和方法会自动提升到顶层。
  • +
  • 与继承类似,可以看作继承行为的模拟,但有本质区别。
  • +
  • 可以重写方法,重写的方法称作 shallowed method,而非 override,调用原 struct 方法使用 root.Node.xxx,相当于 super
  • +
+
type myTreeNodeEmbedded struct {
*tree.Node
}

func (node *myTreeNodeEmbedded) postOrder() {
// 必须!
if node == nil || node.Node == nil {
return
}

left := myTreeNodeEmbedded{node.Left}
left.postOrder()

right := myTreeNodeEmbedded{node.Right}
right.postOrder()

node.Print()
}
+ +

依赖管理

三个阶段 GOPATH/GOVENDOR/go mod

+

GOPATH

+
    +
  • 默认在 ~/go (linux, unix) %USERPROFILE%\go (windows)
  • +
  • 目录下面必须有 src 文件夹,作为所有依赖与项目的根目录(Google 将 20 亿行代码,9 百万个文件放在了一个 repo 里)
  • +
  • GOPATH 可以更改
  • +
  • GOPATH 内的两个项目无法依赖同一个库的不同版本
  • +
+
export GOPATH=/path/to/go
export GO111MODULE=off
// in src/proj folder
go get -u go.uber.org/zap
+ +

GOVENDER

+

GOPATH 内的两个项目无法依赖同一个库的不同版本

+
+
    +
  • 为了解决这个问题诞生了 GOVENDER,只需要在 project 里面新建 vender 文件夹,依赖就会从首先 vender 文件夹内查找
  • +
  • 有许多配套的依赖管理工具
  • +
+

+

GO MOD

go 命令统一管理,不必关心目录结构

+

初始化

相当于 npm init

+
go mod init modname
+ +

安装/升级依赖

与 GOPATH 一样:

+
go get -u go.uber.org/zap@1.12.0
+ +

依赖管理 go.mod

相当于 package.json

+
module learngo

go 1.16

require (
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.7.0 // indirect
go.uber.org/zap v1.19.0 // indirect
)
+ +

依赖锁 go.sum

相当于 package-json.lock

+
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
// ...
+ +

依赖锁瘦身

(也许相当于 npm uninstall

+

命令:

+
go mod tidy
+ +

构建

注意后面是 3 个点

+
go build ./...
+ +

旧项目迁移到 gomod

go mod init
go build ./...
+ +

接口

type retriever interface {
Get(string) string
}
+ +

duck typing

    +
  • “长得像鸭子,那么就是鸭子”
  • +
  • 描述事物的外部行为,而非内部结构
  • +
  • go 属于结构化类型系统,类似 duck typing(但本质上不是)
  • +
  • 同时具有 python c++ 的 duck typing 灵活性
  • +
  • 又具有 java 的类型检查
  • +
+

接口的定义

    +
  • 接口由使用者定义
  • +
  • 接口的实现是隐式的,只要实现了里面的内容即可
  • +
+

接口变量

    +
  • 接口变量自带指针
  • +
  • 接口变量同样采用值传递
  • +
  • 指针接收者实现只能以指针方式使用,值接收者都可
  • +
  • 接口变量里面有实现者的类型和值。
  • +
+
fmt.Printf("%T %v\n", r, r)
// test.Retriever {EMT}
+ +

接口的真实类型可以通过 switch 获取:

+
switch v := r.(type) {
case *infra.Retriever:
fmt.Println(v.TimeOut)
case test.Retriever:
fmt.Println(v.Content)
}
+ +

也可以通过 type assertion 获取:

+
// type assertion
if realRetriever, ok := r.(*infra.Retriever); ok {
fmt.Println(realRetriever.UserAgent)
} else {
fmt.Println("type incorrect")
}
+ +

实现者的值也可以换成实现者的指针:

+
type Retriever struct {
UserAgent string
TimeOut time.Duration
}

func (r *Retriever) Get(url string) string {
// ...
}

type retriever interface {
Get(string) string
}

var r retriever = &infra.Retriever{
TimeOut: time.Minute,
UserAgent: "Mozilla",
}

fmt.Printf("%T %v\n", r, r)
// *infra.Retriever &{Mozilla 1m0s}
+ +

表示任意类型的接口

类似 any

+
interface{}

// for example
type Queue []interface{}
+ +

接口的强制类型转换

type Queue []interface{}

func (q *Queue) Push(value interface{}) {
*q = append(*q, value.(int))
}
+ +

接口的组合

type retriever interface {
Get(string) string
}

type poster interface {
Post(string, map[string]string) string
}

type retrieverPoster interface {
retriever
poster
}
+ +

常用内置接口

stringer

相当于 toString

+
type Retriever struct {
Content string
}

func (r *Retriever) String() string {
return fmt.Sprintf("Test Retriever: {Content=%s}", r.Content)
}

fmt.Printf("%T %v\n", r, r)
// *test.Retriever Test Retriever: {Content=EMT}
+ +

reader/writer

func printFile(filename string) {
file, err := os.Open(filename)
if err != nil {
panic(err)
}
printFileContent(file)
}

func printFileContent(reader io.Reader) {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
fmt.Println(scanner.Text())
}
}

printFile("./basic/abc.txt")

printFileContent(strings.NewReader(`EMT
YES!
`))
+ +

函数式编程

+

例子:累加器

func adder() func(v int) int {
sum := 0
return func(v int) int {
sum += v
return sum
}
}

func main() {
add := adder()
for i := 0; i < 10; i++ {
fmt.Println(add(i))
}
}
+ +

例子:斐波那契数列

func fib() func() int {
before, after := 0, 1
return func() int {
fmt.Printf("before=%d, after=%d\n", before, after)
before, after = after, after+before
return after
}
}

func main() {
f := fib()
for i := 0; i < 10; i++ {
f()
}
}
+ +

例子:二叉树遍历

func (node *Node) TravelFunc(f func(*Node)) {
if node == nil {
return
}

node.Left.TravelFunc(f)
f(node)
node.Right.TravelFunc(f)
}

func (node *Node) Count() int {
count := 0
node.TravelFunc(func(node *Node) {
count++
})
return count
}
+ +

错误处理和资源管理

defer 调用

    +
  • defer 调用确保在函数结束时发生
  • +
  • defer 先进后出(栈)
  • +
  • defer 参数在语句时计算(非结算时)
  • +
+

何时使用 defer:

+
    +
  • open/close
  • +
  • lock/unlock
  • +
  • print header/footer
  • +
+

错误处理

file, err := os.OpenFile(filename, os.O_EXCL|os.O_CREATE, 0666)
if err != nil {
if e, ok := err.(*os.PathError); !ok {
fmt.Println(err)
} else {
fmt.Printf("Op: %s, Path: %s, Err: %s\n", e.Op, e.Path, e.Err)
}
}
+ +

统一的错误处理

以 http server 为例,思路是让 controller 可以直接返回 error,而 error 在外层的包裹函数内统一处理:

+
type handler func(w http.ResponseWriter, r *http.Request) error

func errWrapper(h handler) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
err := h(w, r)
if err != nil {
log.Printf("%s\n", err.Error())
code := http.StatusInternalServerError
switch {
case os.IsNotExist(err):
code = http.StatusNotFound
}
http.Error(w, http.StatusText(code), code)
}
}
}

func main() {
http.HandleFunc("/list/", errWrapper(controller.ListFile))

err := http.ListenAndServe(":8888", nil)
if err != nil {
panic(err)
}
}
+ +

panic

    +
  • 停止当前函数执行
  • +
  • 一直向上返回,执行每一层的 defer
  • +
  • 如果没有遇见 recover,程序退出
  • +
+

recover

    +
  • 仅在 defer 中使用
  • +
  • 获取 panic 的值
  • +
  • 如果无法处理,可以重新 panic
  • +
+
defer func() {
e := recover()
if err, ok := e.(error); ok {
fmt.Println("catch error: ", err)
} else {
panic(errors.New("don't know what to do: " + fmt.Sprintf("%v", e)))
}
}()

//panic(errors.New("..."))
//panic(123)
b := 0
a := 5 / b
fmt.Println(a)
//catch error: runtime error: integer divide by zero
+ +

error vs. panic

    +
  • 尽量使用 error
  • +
  • 意料之中的:error。如:文件打不开
  • +
  • 意料之外的:panic。如:数组越界
  • +
+

自定义错误

type UserError string

func (u UserError) Error() string {
return u.Message()
}

func (u UserError) Message() string {
return string(u)
}

func ListFile(writer http.ResponseWriter, request *http.Request) error {
if strings.Index(request.URL.Path, prefix) < 0 {
// 外部可以使用 type assertion 判断,并输出自定义 message
return UserError("path muse starts with " + prefix)
}

// ...
}
+ +

统一的错误处理(进阶)

func errWrapper(h handler) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
if err, ok := r.(error); ok {
log.Printf("%s\n", err.Error())
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} else {
log.Printf("%v\n", r)
}
}
}()

err := h(w, r)
if err != nil {
log.Printf("%s\n", err.Error())
if userError, ok := err.(controller.UserError); ok {
http.Error(w, userError.Message(), http.StatusBadRequest)
return
}

code := http.StatusInternalServerError
switch {
case os.IsNotExist(err):
code = http.StatusNotFound
}
http.Error(w, http.StatusText(code), code)
}
}
}
+ +

测试

单元测试

+
$ go test .
ok learngo/basic/basic 0.087s
+ +

单元测试文件以 _test.go 结尾,如 some_function_test.go,ide 可自动识别单元测试文件

+

如果要引用私有方法,需要跟方法在同一个 package

+

命令行执行:go test .

+
package main

import (
"testing"
)

func TestTriangle(t *testing.T) {
tests := []struct{ a, b, c int }{
{3, 4, 5},
{5, 12, 13},
{8, 15, 17},
{12, 35, 37},
{30000, 40000, 50000},
}
for _, tt := range tests {
if c := calcTriangle(tt.a, tt.b); c != tt.c {
t.Errorf("calcTriangle(%d,%d), expected %d, got %d", tt.a, tt.b, tt.c, c)
}
}
}
+ +

覆盖率

+

go 自带覆盖率工具

+
# 生成报告
go test . -coverprofile=c.out
# 查看报告
go tool cover -html=c.out
+ +

性能测试

性能测试方法需要以 Benchmark 开头:

+
func BenchmarkNonRepeating(b *testing.B) {
// 运行一秒钟,具体次数由 go 决定
for i := 0; i < b.N; i++ {
assert.Equal(b, 8, longest("黑化肥挥发发灰会花飞灰化肥挥发发黑会飞花"))
}
}
+ +
go test -bench .
+ +

pprof

+

web 报告需要安装 https://www.graphviz.org/download/

+
go test -bench . -cpuprofile cpu.out
go tool pprof cpu.out
# 打开基于网页的性能报告
(pprof) web
+ +

+

http 单元测试

func errorPanic(w http.ResponseWriter, r *http.Request) error {
panic(123)
}

func errorNotExist(w http.ResponseWriter, r *http.Request) error {
return os.ErrNotExist
}

// ...

var tests = []struct {
h handler
code int
message string
}{
{errorNotExist, http.StatusNotFound, http.StatusText(http.StatusNotFound)},
// ...
}
+ +

方式一:测代码逻辑

使用 fake req\res mock 测试:

+
func TestErrorWrapper(t *testing.T) {
for _, test := range tests {
h := errWrapper(test.h)
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "https://google.com", nil)
h(res, req)
all, _ := ioutil.ReadAll(res.Body)
assert.Equal(t, test.message, strings.TrimSpace(string(all)))
assert.Equal(t, test.code, res.Code)
}
}
+ +

方式二:真实服务器测试

func TestServer(t *testing.T) {
for _, test := range tests {
h := errWrapper(test.h)
s := httptest.NewServer(http.HandlerFunc(h))
res, _ := http.Get(s.URL)
all, _ := ioutil.ReadAll(res.Body)
assert.Equal(t, test.message, strings.TrimSpace(string(all)))
assert.Equal(t, test.code, res.StatusCode)
}
}
+ +

生成文档

go get golang.org/x/tools/cmd/godoc
// 启动文档服务器
godoc -http :6060
+ +

+

编写注释

// Queue is a FIFO queue
// q := Queue{1,2,3}
type Queue []interface{}

// Push add an item into queue
func (q *Queue) Push(value interface{}) {
*q = append(*q, value)
}

// Pop remove an item from queue
func (q *Queue) Pop() interface{} {
pop := (*q)[0]
*q = (*q)[1:]
return pop
}

// IsEmpty check if queue is empty
func (q *Queue) IsEmpty() bool {
return len(*q) == 0
}
+ +

编写样例

样例代码也是一种单元测试,以 Example 开头,并且以 Output 表示输出。输出不正确时 test 会不通过。

+

(我在想,go 的约定大于配置是不是做得有点太激进了?)

+
func ExampleQueue_Push() {
s := Queue{1, 2}

s.Push(3)
fmt.Println(s.Pop())
fmt.Println(s.Pop())
fmt.Println(s.IsEmpty())
fmt.Println(s.Pop())
fmt.Println(s.IsEmpty())

// Output:
// 1
// 2
// false
// 3
// true
}
+ +

goroutine

    +
  • go 语言使用 goroutine 来实现并发编程。
  • +
  • 任何函数加入 go 关键字就能送给调度器运行
  • +
  • 不需要在定义时区分是否是异步函数
  • +
  • 调度器在合适的时机自动进行切换
  • +
  • 开多少个线程、goroutine 分布在哪个线程上是由调度器自动决定的
  • +
+

+

协程 Coroutine

    +
  • 轻量级“线程”
  • +
  • 非抢占式多任务处理,由协程主动交出控制权
  • +
  • 编译器、解释器、虚拟机层面的多任务
  • +
  • 多个协程可以在一个或多个线程上运行
  • +
+

+

race condition

检查数据读写冲突:

+
go run -race gorouting.go
+ +

goroutine 可能的切换点

只是参考,不能保证切换,不能保证在其他地方不切换

+
    +
  • io, select
  • +
  • channel
  • +
  • waiting for lock
  • +
  • function call
  • +
  • runtime.Gosched()
  • +
+

channel

+

不要通过共享内存来通信——通过通信来共享内存。

+
+
    +
  • channel 是 goroutine 之间通信的桥梁。
  • +
  • channel 可以定义可收发,也可以定义仅收、仅发
  • +
  • channel 收值可以用死循环,也可以用 range,也可以用条件。但是用死循环的话要注意:channel 关闭后依然会不断发送消息
  • +
  • channel 可以关闭 close(c)
  • +
  • channel 可以定义缓冲区,make(chan int, 3)
  • +
  • 注意:channel 收发是同步的,也就是说:
  • +
  • 当发消息的时候,发送方要等待消息被接受才会继续执行
  • +
  • 当收消息的时候,接收方要等待消息被发送才会继续执行
  • +
  • 如果在 goroutine 之间只发不收或只收不发,会出现死锁
  • +
+

+
func worker(id int, c chan int) {
// 当 channel 收到值,且未关闭时
for n := range c {
fmt.Printf("worker %d receive %c\n", id, n)
}
}

// 返回值为只收 channel
func createWorker(id int) chan<- int {
c := make(chan int)
go worker(id, c)
return c
}

func channelDemo() {
var cs = make([]chan<- int, 10)
for i := 0; i < 10; i++ {
cs[i] = createWorker(i)
}
for i := 0; i < 10; i++ {
// 向 channel 发送数据
cs[i] <- 'a' + i
}
for i := 0; i < 10; i++ {
cs[i] <- 'A' + i
}
time.Sleep(time.Millisecond)
}
+ +

等待任务结束

方式一:使用 channel

type worker struct {
id int
in chan int
done chan bool
}

func doWork(w worker) {
for n := range w.in {
fmt.Printf("worker %d receive %c\n", w.id, n)
w.done <- true
}
}

func createWorker(id int) worker {
w := worker{
in: make(chan int),
done: make(chan bool),
id: id,
}
go doWork(w)
return w
}

func channelDemo() {
var w = make([]worker, 10)
for i := 0; i < 10; i++ {
w[i] = createWorker(i)
}
for i, worker := range w {
worker.in <- 'a' + i
}
for _, worker := range w {
<-worker.done
}
for i, worker := range w {
worker.in <- 'A' + i
}
for _, worker := range w {
<-worker.done
}
}

func main() {
channelDemo()
}
+ +

方式二:使用 WaitGroup

type worker struct {
id int
in chan int
done func()
}

func doWork(w worker) {
for n := range w.in {
fmt.Printf("worker %d receive %c\n", w.id, n)
w.done()
}
}

func createWorker(id int, wg *sync.WaitGroup) worker {
w := worker{
in: make(chan int),
done: wg.Done,
id: id,
}
go doWork(w)
return w
}

func channelDemo() {
var w = make([]worker, 10)
wg := sync.WaitGroup{}

for i := 0; i < 10; i++ {
w[i] = createWorker(i, &wg)
}
//wg.Add(20)
for i, worker := range w {
wg.Add(1)
worker.in <- 'a' + i
//wg.Add(1) 错误!应该先 Add 再发数据
}
//wg.Wait()
for i, worker := range w {
wg.Add(1)
worker.in <- 'A' + i
}
wg.Wait()
}

func main() {
channelDemo()
}
+ +

使用 channel 进行树遍历

func (node *Node) TravelFunc(f func(*Node)) {
// 普通的回调式遍历...
}

func (node *Node) TravelWithChannel() chan *Node {
c := make(chan *Node)
go func() {
defer close(c)
node.TravelFunc(func(node *Node) {
c <- node
})
}()
return c
}

func main() {
var root tree.Node
// ...
max := 0
node := root.TravelWithChannel()
for n := range node {
if n.Value > max {
max = n.Value
}
}
fmt.Println("Max: ", max)
}
+ +

使用 select 进行调度

    +
  • select 的使用,可以不加锁控制任务的执行
  • +
  • 定时器的使用:定时器返回的也是 channel
  • +
  • 在 select 中使用 nil channel:nil channel 永远不会被 select
  • +
+
tm := time.After(time.Second * 10)
tick := time.Tick(time.Second)
for {
var activeChannel chan<- int
var activeValue int
if len(values) > 0 {
activeChannel = w.in
activeValue = values[0]
}

select {
case <-tm:
fmt.Println("program exit")
return
case n := <-c1:
values = append(values, n)
//fallthrough
case n := <-c2:
values = append(values, n)
case activeChannel <- activeValue:
values = values[1:]
case <-tick:
fmt.Println("len(values)=", len(values))
case <-time.After(time.Millisecond * 800):
fmt.Println("timeout")
}

}
+ +

传统的同步机制

    +
  • WaitGroup
  • +
  • Mutex
  • +
  • Cond
  • +
+

如果不加锁,使用 -race 执行会发生 data race:

+
type atomicInt struct {
Value int
Lock sync.Mutex
}

func (i *atomicInt) add(v int) {
i.Lock.Lock()
defer i.Lock.Unlock()
i.Value += v
}

func (i *atomicInt) getValue() int {
i.Lock.Lock()
defer i.Lock.Unlock()
return i.Value
}

func main() {
i := atomicInt{}
i.add(1)
go func() {
i.add(1)
}()
time.Sleep(time.Millisecond)
fmt.Println(i.getValue())
}
+ +

并发编程模式

生成器

func msgGen(id string) <-chan string {
c := make(chan string)
i := 0
go func() {
for {
time.Sleep(time.Duration(rand.Intn(2000)) * time.Millisecond)
c <- fmt.Sprintf("generator %s sended %d", id, i)
i++
}
}()
return c
}
+ +

fanIn

将多个 channel 的消息合并为一个输出以避免阻塞:

+
func fanIn(channels ...<-chan string) <-chan string {
c := make(chan string)
for _, ch := range channels {
go func(ch <-chan string) {
for {
c <- <-ch
}
}(ch)
}
return c
}

func main() {
m := fanIn(
msgGen("svc1"),
msgGen("svc2"),
msgGen("svc3"),
msgGen("svc4"),
msgGen("svc5"),
)
for {
fmt.Println(<-m)
}
}
+ +

fanIn by select

func fanInBySelect(c1 <-chan string, c2 <-chan string) <-chan string {
c := make(chan string)
go func() {
for {
select {
case n := <-c1:
c <- n
case n := <-c2:
c <- n
}
}
}()
return c
}
+ +

任务的控制

非阻塞等待

func noneBlockingWait(c <-chan string) (string, bool) {
select {
case n := <-c:
return n, true
default:
return "", false
}
}
+ +

超时机制

func timeoutWait(c <-chan string, timeout time.Duration) (string, bool) {
select {
case n := <-c:
return n, true
case <-time.After(timeout):
return "", false
}
}
+ +

优雅退出

当消息的内容无所谓时,channel 可以用空的 struct,体积比 boolean 更小。

+
func msgGen(id string, done chan struct{}) <-chan string {
c := make(chan string)
i := 0
go func() {
for {
select {
case <-done:
fmt.Println("cleaning...")
time.Sleep(time.Second * 2)
fmt.Println("clean done.")
done <- struct{}{}
return
case <-time.After(time.Duration(rand.Intn(2000)) * time.Millisecond):
c <- fmt.Sprintf("generator %s sended %d", id, i)
i++
}
}
}()
return c
}

func main() {
done := make(chan struct{})
m1 := msgGen("svc1", done)
for i := 0; i < 5; i++ {
if msg, ok := timeoutWait(m1, 1*time.Second); ok {
fmt.Println("msg from svc1: ", msg)
} else {
fmt.Println("timeout")
}
}
done <- struct{}{}
<-done
}
//msg from svc1: generator svc1 sended 0
//timeout
//msg from svc1: generator svc1 sended 1
//timeout
//msg from svc1: generator svc1 sended 2
//cleaning...
//clean done.
+ +

http

标准库

简单访问

resp, err := http.Get("https://www.imooc.com")
response, err := httputil.DumpResponse(resp, true)
+ +

自定义 Header

request, err := http.NewRequest(http.MethodGet, "http://www.imooc.com", nil)
request.Header.Add("User-Agent", "xxx")
client := http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
fmt.Println("Redirect:", req)
return nil
}}
resp, err2 := client.Do(request)
response, err3 := httputil.DumpResponse(resp, true)
+ +

性能监测

导入 pprof 后,可以在 web 端查看调试界面(端口为 web 服务端口):

+

http://localhost:8888/debug/pprof/

+
import (
// ...
_ "net/http/pprof"
)
+ +

+

也可以在控制台查看 cpu 与 内存信息:

+
$ go tool pprof http://localhost:6060/debug/pprof/heap
$ go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/npm-history/032ed620f28b4399ac6ac0c126b0c90c.png b/2021/npm-history/032ed620f28b4399ac6ac0c126b0c90c.png new file mode 100644 index 000000000..b2e9d880c Binary files /dev/null and b/2021/npm-history/032ed620f28b4399ac6ac0c126b0c90c.png differ diff --git a/2021/npm-history/17d47edd486b4a67ad6462bacd1a4c94.png b/2021/npm-history/17d47edd486b4a67ad6462bacd1a4c94.png new file mode 100644 index 000000000..6727aefbc Binary files /dev/null and b/2021/npm-history/17d47edd486b4a67ad6462bacd1a4c94.png differ diff --git a/2021/npm-history/1d2e4d8068d34d24aadb23a5b27e07d3.png b/2021/npm-history/1d2e4d8068d34d24aadb23a5b27e07d3.png new file mode 100644 index 000000000..6f8d2a4da Binary files /dev/null and b/2021/npm-history/1d2e4d8068d34d24aadb23a5b27e07d3.png differ diff --git a/2021/npm-history/27ade74c8a634c0890e78052594d3321.png b/2021/npm-history/27ade74c8a634c0890e78052594d3321.png new file mode 100644 index 000000000..569be6b5d Binary files /dev/null and b/2021/npm-history/27ade74c8a634c0890e78052594d3321.png differ diff --git a/2021/npm-history/281c08a568254878bd9c2c04e12a199c.png b/2021/npm-history/281c08a568254878bd9c2c04e12a199c.png new file mode 100644 index 000000000..11b88b160 Binary files /dev/null and b/2021/npm-history/281c08a568254878bd9c2c04e12a199c.png differ diff --git a/2021/npm-history/2d30a4d64bd54a1db200eb317798da85.png b/2021/npm-history/2d30a4d64bd54a1db200eb317798da85.png new file mode 100644 index 000000000..2cd449256 Binary files /dev/null and b/2021/npm-history/2d30a4d64bd54a1db200eb317798da85.png differ diff --git a/2021/npm-history/4c43709b3dbd431a95fc6cfb42ca828c.png b/2021/npm-history/4c43709b3dbd431a95fc6cfb42ca828c.png new file mode 100644 index 000000000..dbb5b4872 Binary files /dev/null and b/2021/npm-history/4c43709b3dbd431a95fc6cfb42ca828c.png differ diff --git a/2021/npm-history/53b38bd1375e42f5b66151d782598f7b.png b/2021/npm-history/53b38bd1375e42f5b66151d782598f7b.png new file mode 100644 index 000000000..9eceadb91 Binary files /dev/null and b/2021/npm-history/53b38bd1375e42f5b66151d782598f7b.png differ diff --git a/2021/npm-history/5d35909dba0c443cbc67ece57750fb92.png b/2021/npm-history/5d35909dba0c443cbc67ece57750fb92.png new file mode 100644 index 000000000..360ca69e1 Binary files /dev/null and b/2021/npm-history/5d35909dba0c443cbc67ece57750fb92.png differ diff --git a/2021/npm-history/672ca3805fb342a988899ee8fc0bcc31.png b/2021/npm-history/672ca3805fb342a988899ee8fc0bcc31.png new file mode 100644 index 000000000..faba4cd08 Binary files /dev/null and b/2021/npm-history/672ca3805fb342a988899ee8fc0bcc31.png differ diff --git a/2021/npm-history/6cb8e2d058f24013bb676be47b50d8a0.png b/2021/npm-history/6cb8e2d058f24013bb676be47b50d8a0.png new file mode 100644 index 000000000..74d199fa1 Binary files /dev/null and b/2021/npm-history/6cb8e2d058f24013bb676be47b50d8a0.png differ diff --git a/2021/npm-history/74e9d763d51b4d02a39c8b8628be2f35.png b/2021/npm-history/74e9d763d51b4d02a39c8b8628be2f35.png new file mode 100644 index 000000000..7d85ec09f Binary files /dev/null and b/2021/npm-history/74e9d763d51b4d02a39c8b8628be2f35.png differ diff --git a/2021/npm-history/7601019bdc044ca786896c2d54de955a.png b/2021/npm-history/7601019bdc044ca786896c2d54de955a.png new file mode 100644 index 000000000..155bbf9a3 Binary files /dev/null and b/2021/npm-history/7601019bdc044ca786896c2d54de955a.png differ diff --git a/2021/npm-history/77ed4d7193bb4cf8b1125915e1b532ad.png b/2021/npm-history/77ed4d7193bb4cf8b1125915e1b532ad.png new file mode 100644 index 000000000..da56fda70 Binary files /dev/null and b/2021/npm-history/77ed4d7193bb4cf8b1125915e1b532ad.png differ diff --git a/2021/npm-history/789e830b6b4247a9807fa4639815b53c.png b/2021/npm-history/789e830b6b4247a9807fa4639815b53c.png new file mode 100644 index 000000000..53aae5de2 Binary files /dev/null and b/2021/npm-history/789e830b6b4247a9807fa4639815b53c.png differ diff --git a/2021/npm-history/79bed46a30504448bb38f03bf47084fa.png b/2021/npm-history/79bed46a30504448bb38f03bf47084fa.png new file mode 100644 index 000000000..c3b936650 Binary files /dev/null and b/2021/npm-history/79bed46a30504448bb38f03bf47084fa.png differ diff --git a/2021/npm-history/88b24d6586164844971a21531cb4fe40.png b/2021/npm-history/88b24d6586164844971a21531cb4fe40.png new file mode 100644 index 000000000..3c33d8aca Binary files /dev/null and b/2021/npm-history/88b24d6586164844971a21531cb4fe40.png differ diff --git a/2021/npm-history/8909e5d0c4b747f787b3787314f7e8d8.png b/2021/npm-history/8909e5d0c4b747f787b3787314f7e8d8.png new file mode 100644 index 000000000..98cb1ebd5 Binary files /dev/null and b/2021/npm-history/8909e5d0c4b747f787b3787314f7e8d8.png differ diff --git a/2021/npm-history/8f1ef8b7ea174596bdc8b6ebf0601dc0.png b/2021/npm-history/8f1ef8b7ea174596bdc8b6ebf0601dc0.png new file mode 100644 index 000000000..106958668 Binary files /dev/null and b/2021/npm-history/8f1ef8b7ea174596bdc8b6ebf0601dc0.png differ diff --git a/2021/npm-history/91ba2aeacbb640fe931c2b3f9e762b2b.png b/2021/npm-history/91ba2aeacbb640fe931c2b3f9e762b2b.png new file mode 100644 index 000000000..d30f68345 Binary files /dev/null and b/2021/npm-history/91ba2aeacbb640fe931c2b3f9e762b2b.png differ diff --git a/2021/npm-history/92c7961c84ae4c64a15588cfad9b4294.png b/2021/npm-history/92c7961c84ae4c64a15588cfad9b4294.png new file mode 100644 index 000000000..fc07dc173 Binary files /dev/null and b/2021/npm-history/92c7961c84ae4c64a15588cfad9b4294.png differ diff --git a/2021/npm-history/9eb92971cb044cb3a05c62a43dd23142.png b/2021/npm-history/9eb92971cb044cb3a05c62a43dd23142.png new file mode 100644 index 000000000..9a99d317f Binary files /dev/null and b/2021/npm-history/9eb92971cb044cb3a05c62a43dd23142.png differ diff --git a/2021/npm-history/a167fbb7eb1a4e068bdfd9b2aa3c1f4e.png b/2021/npm-history/a167fbb7eb1a4e068bdfd9b2aa3c1f4e.png new file mode 100644 index 000000000..0765a27c1 Binary files /dev/null and b/2021/npm-history/a167fbb7eb1a4e068bdfd9b2aa3c1f4e.png differ diff --git a/2021/npm-history/bd96f102244c4a498c544bf759266e81.png b/2021/npm-history/bd96f102244c4a498c544bf759266e81.png new file mode 100644 index 000000000..104244bd9 Binary files /dev/null and b/2021/npm-history/bd96f102244c4a498c544bf759266e81.png differ diff --git a/2021/npm-history/bdb7f88e64b8463387c78f563e709ce4.png b/2021/npm-history/bdb7f88e64b8463387c78f563e709ce4.png new file mode 100644 index 000000000..a65a3eba0 Binary files /dev/null and b/2021/npm-history/bdb7f88e64b8463387c78f563e709ce4.png differ diff --git a/2021/npm-history/c28854e4264a401e8a1e33d53e3d8397.png b/2021/npm-history/c28854e4264a401e8a1e33d53e3d8397.png new file mode 100644 index 000000000..7f5dba20a Binary files /dev/null and b/2021/npm-history/c28854e4264a401e8a1e33d53e3d8397.png differ diff --git a/2021/npm-history/d51b29d640de4f6fa97858e68db9144a.png b/2021/npm-history/d51b29d640de4f6fa97858e68db9144a.png new file mode 100644 index 000000000..c9940c9e4 Binary files /dev/null and b/2021/npm-history/d51b29d640de4f6fa97858e68db9144a.png differ diff --git a/2021/npm-history/index.html b/2021/npm-history/index.html new file mode 100644 index 000000000..8577138cd --- /dev/null +++ b/2021/npm-history/index.html @@ -0,0 +1,642 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Node.js 包管理器发展史 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Node.js 包管理器发展史 +

+ + +
+ + + + +

在没有包管理器之前

正确来说 Node.js 是不存在没有包管理器的时期的。从 A brief history of Node.js 里面可以看到,当 2009 年 Node.js 问世的时候 NPM 的雏形也发布了。当然因为 Node.js 跟前端绑得很死,这里主要谈一谈前端在没有包管理器的时期是怎样的。

+

那时候做得最多的事情就是:

+
    +
  1. 网上寻找各软件的官网,比如 jQuery;
  2. +
  3. 找到下载地址,下载 zip 包;
  4. +
  5. 解压,放到项目中一个叫 libs 的目录中;
  6. +
  7. 想更方便的话,直接将 CDN 链接粘贴到 HTML 中。
  8. +
+

四个字总结:刀耕火种。 模块化管理?版本号管理?依赖升级?不存在的。当然,那时候前端也没有那么复杂,这种模式勉强来说也不是不能用。

+ + + +

npm v1-v2

Npm-logo.svg_.png

+

+

2009 年,Node.js 诞生,npm(Node.js Package Manager)的雏形也正在酝酿。

+

2011 年,npm 发布了 1.0 版本。

+

初版 npm 带来的文件结构,是嵌套结构:

+

npm-history-0.png

+

一切都很美好,除了…

+

+

node_modules 堪比黑洞,图来自 https://github.com/tj/node-prune

+

node_modules 体积过大

显而易见的问题,如果一个库,比如 lodash,被不同的包依赖了,那么它就会被安装两次。这种形式的结构很快就能把磁盘占满。rm -rf node_modules 成为了前端程序员最常用的命令之一。

+

node_modules 嵌套层级过深

只有当找到一片不依赖任何第三方包的叶子时,这棵树才能走到尽头。因此 node_modules 的嵌套深度十分可怕。

+

具体到实际的问题,相信早期 npm 的 windows 用户都见过这个弹窗:

+

+

(node_modules 文件夹无法删除,因为超过了 windows 能处理的最大路径长度)

+

详情见 这个 issue

+

Yarn & npm v3

yarn-logo-F5E7A65FA2-seeklogo.com.png

+

+

2016 年,yarn 诞生了。yarn 解决了 npm 几个最为迫在眉睫的问题:

+
    +
  1. 安装太慢(加缓存、多线程)
  2. +
  3. 嵌套结构(扁平化)
  4. +
  5. 无依赖锁(yarn.lock)
  6. +
+

yarn 带来对的扁平化结构:

+

npm-history-yarn.png

+

扁平化后,实际需要安装的包数量大大减少,再加上 Yarn 首发的缓存机制,因此依赖的安装速度也得到了史诗级提升。

+

依赖锁

相比于扁平化结构,可以说 yarn 更大的贡献是发明了 yarn.lock。而 npm 在一年后的 v5 才跟上了脚步,发布了 package-lock.json。

+

在没有依赖锁的年代,即使没有改动任何一行代码,一次 npm install 带来的实际代码量变更很可能是非常巨大的。 因为 npm 采用 语义化版本 约定,简单来说,a.b.c 代表着:

+
    +
  1. a 主版本号:当你做了不兼容的 API 修改
  2. +
  3. b 次版本号:当你做了向下兼容的功能性新增
  4. +
  5. c 修订号:当你做了向下兼容的问题修正
  6. +
+

问题在于,这只是一个理想化的“约定”,具体到每个包有没有遵守,遵守得好不好,不是为我们所控的。 而默认情况下安装依赖时,得到的版本号是类似 ^1.0.0 这样的。这个语法代表着将安装主版本号为 1 的最新版本。

+

虽然可以通过去掉一级依赖的 ^ 指定精确版本,但是无法指定二级、三级依赖的精确版本号,因此安装依然存在非常大的不确定性。

+

npm-history-semver.png

+

因此,为了解决这个问题,Yarn 提出了“锁”的解决方案:精确地将版本号锁定在一个值,并且在安装时通过计算哈希值校验文件一致性,从而保证每次构建使用的依赖都是完全一致的。

+

一个 yarn.lock 文件示例片段:

+
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


"@babel/code-frame@7.12.11":
version "7.12.11"
resolved "https://registry.npmmirror.com/@babel/code-frame/download/@babel/code-frame-7.12.11.tgz?cache=0&sync_timestamp=1633553739126&other_urls=https%3A%2F%2Fregistry.npmmirror.com%2F%40babel%2Fcode-frame%2Fdownload%2F%40babel%2Fcode-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f"
integrity sha1-9K1DWqJj25NbjxDyxVLSP7cWpj8=
dependencies:
"@babel/highlight" "^7.10.4"

"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.14.5", "@babel/code-frame@^7.15.8":
version "7.15.8"
resolved "https://registry.npmmirror.com/@babel/code-frame/download/@babel/code-frame-7.15.8.tgz?cache=0&sync_timestamp=1633553739126&other_urls=https%3A%2F%2Fregistry.npmmirror.com%2F%40babel%2Fcode-frame%2Fdownload%2F%40babel%2Fcode-frame-7.15.8.tgz#45990c47adadb00c03677baa89221f7cc23d2503"
integrity sha1-RZkMR62tsAwDZ3uqiSIffMI9JQM=
dependencies:
"@babel/highlight" "^7.14.5"

// more...
+ +

“双胞胎陌生人”问题

这个词在英文中是 doppelgangers,意思是它们长得很像,但是除此以外又完全没有其它的关联。

+

+

想象一下有一个 library-a,它同时依赖了 library-b、c、d、e:

+

npm-history (1).png

+

而 b 和 c 依赖了 f@1.0.0,d 和 e 依赖了 f@2.0.0

+

npm-history.png

+

这时候,node_modules 树需要做出选择了,到底是将 f@1.0.0 还是 f@2.0.0 扁平化,然后将另一个放到嵌套的 node_modules 中?

+

答案是:具体做那种选择将是不确定的,取决于哪一个 f 出现得更靠前,靠前的那个将被扁平化。

+

举例,将 f@1.0.0 扁平化的结果:

+

npm-history-3.png

+

f@2.0.0 扁平化的结果:

+

npm-history-4.png

+

无论如何,这个选择必须做,我们必然会在 node_modules 中拥有多份的 library-f,窘境将是无法避免的。因此它们也就成为了“双胞胎陌生人”。

+

其它编程语言没有这种问题,这是 Node.js & npm 独有的。 这种问题会造成:

+
    +
  1. 安装更慢
  2. +
  3. 耗费的磁盘空间更大
  4. +
  5. 某些只能存在单例的库(比如 React 或 Vue)如果被同时安装了两份则会出现问题
  6. +
  7. 当使用依赖 f 使用了 TypeScript 时会造成 .d.ts 文件混乱,导致编译器报错
  8. +
  9. 假设 f 有一个依赖 g,项目里也存在 g 的“双胞胎陌生人”,那么根据 Node.js 的依赖查找原则(从当前目录逐级向上查找),两个 f 有可能会检索到不同版本的 g,这可能导致高度混乱的编译器错误。
  10. +
+

“幽灵依赖”问题

+

假设我们有以下依赖:

+
{
"name": "my-library",
"version": "1.0.0",
"main": "lib/index.js",
"dependencies": {
"minimatch": "^3.0.4"
},
"devDependencies": {
"rimraf": "^2.6.2"
}
}
+ +

理论上来说,我们项目的代码中可以使用的依赖只有 minimatch。但是实际上,以下代码也能运行:

+
var minimatch = require("minimatch")
var expand = require("brace-expansion"); // ???
var glob = require("glob") // ???

// ???
+ +

这是因为扁平化结构将一些没有直接依赖的包也提升到了 node_modules 的一级目录,但是 Node.js 并没有对其校验。所以引用它们也不会报错。

+

npm-history-ghost-deps.png

+

这种情况带来的问题:

+
    +
  1. 在没有显式指定“间接依赖”的版本号的时候,如果它被依赖到它的包做了大版本升级,存在不兼容的 API 变更,那么应用代码很可能就会跑不起来
  2. +
  3. 没有显式指定依赖带来的额外管理成本
  4. +
+

Workspace

Yarn 1.0 带来的另一个特性是 workspace,也是 monorepo 能够发展起来的一个重要原因。

+

假设我们有一个 workspace-a,它依赖了 cross-env:

+
{
"name": "package-a",
"version": "1.0.0",

"dependencies": {
"cross-env": "5.0.5"
}
}
+ +

还有一个 package-b,它依赖了 cross-env 和 package-a:

+
{
"name": "package-b",
"version": "1.0.0",

"dependencies": {
"cross-env": "5.0.5",
"workspace-a": "1.0.0"
}
}
+ +

那么这时候在使用 workspace 模式安装的话,将得到以下结构:

+

npm-history-workspace.png

+

其中,node_modules 中的 package-a 只是实际文件的链接。也就是说,Yarn workspace 模式可以将项目底下的子项目的依赖提升到根目录来进行扁平化安装,这样可以节省更多的磁盘空间,带来更快的安装效率,也可以使得项目管理更方便。

+

但是,结合上面所提到的两个问题,workspace 带来的问题只会更多,不会更少。这里就不详细展开了,应用级 Monorepo 优化方案 这篇文章总结得很好。

+

Lerna

lerna.png

+

由于 Workspace 的特性实在是太过好用,monorepo(multi-package repositories, multi-project repositories)开始迅速发展。许多知名的开源库开始转向 monorepo,还有更激进者将 monorepo 使用在业务项目中。Lerna 顺势而生。

+

但是,Lerna 并不是 Node.js 包管理器的一部分,也没有解决任何已存在的包管理器问题。它所做的只是将 monorepo 的使用体验变得更舒服了,比如:

+
    +
  1. 可以更方便地创建 monorepo
  2. +
  3. 可以更方便地管理 packages 中的依赖项
  4. +
  5. 可以一键发布 packages、自动根据 git commit log 更新每个 package 的 changelog
  6. +
  7. 等等
  8. +
+

仅此而已。按照官网的说法,Lerna 所做的事情是“优化了这个流程(optimizes the workflow)”。

+

pnpm

+

+

P for Performance —— 性能更强的 npm。

+

pnpm 复刻了 npm 的所有命令,同时在安装目录结构上做了大幅改进。

+

善用链接

这里通过一个例子来看 pnpm 的安装结构特点。

+

安装依赖

假设我们要安装一个 foo 包,它依赖了 bar。首先,pnpm 会先将所有直接和间接依赖安装进来,并“摊平”(注意,这里没有扁平化算法,是字面意义上的摊平):

+

npm-history-pnpm1.png

+

你可能注意到,在 xxx@1.0.0 的目录下面,首先是一个 node_modules 目录,然后才是 xxx,这么做的目的是:

+
    +
  1. 允许包引用自己
  2. +
  3. 将包自身和其依赖打平,避免循环结构。在 Node.js 中,这么做其实跟原本的样子并没有太大区别。
  4. +
+

处理间接依赖

然后,在 foo 的平级创建一个 bar 文件夹,链接至 bar@1.0.0 下面的 bar

+

npm-history-pnpm2.png

+

处理直接依赖

在顶层 node_modules 创建一个 foo 硬链接,连接至 foo@1.0.0 中的 foo,以供应用访问:

+

npm-history-pnpm3.png

+

处理更深层次的间接依赖

假设 foobar 都依赖了 qar@2.0.0

+

npm-history-pnpm4.png

+

可以看到,虽然依赖层级变深了,但是文件树并没有变深。这就是 pnpm 的特色结构:通过硬链接创造的依赖“树”。

+

性能对比

由于硬链接的巨大优势加成,在绝大多数情况下,pnpm 的安装速度都要比 yarn 和 npm 更快:

+

+

自动解决锁冲突

pnpm 能够自动解决锁文件的冲突。当冲突发生时,只需要运行一次 pnpm install,冲突就能自动由 pnpm 解决。很人性化。不过,据说 Yarn 从 1.0 版本开始也提供了类似的功能。

+

存在的问题

    +
  1. 并不是所有项目都能“无痛”迁移至 pnpm。由于历史原因(扁平化),我们的应用或者应用的某些依赖并没有很好地遵循“使用到的包必须在 package.json 中声明”这一原则,或者把它当作一项 feature 享受其中。这样的话迁移至 pnpm 会导致原本会被提升到顶层的扁平化依赖重新回到正确的位置,从而无法被找到。如果问题出在应用上,那么只需要将依赖写入 package.json 即可。但是如果出在依赖就比较棘手了。不过官方也提供了解决方案
  2. +
  3. 由于特殊的安装结构,以往一个很有用的打补丁工具 patch-package 用起来就不是那么顺手了。
  4. +
+

Rush

rush.png

+

Rush 是微软出品的一款 monorepo 管理工具。与 Lerna 不同的是:Rush 不仅做了许多“优化流程”的工作,还提供了一套与 pnpm 十分类似的硬链接目录结构方案来解决超大型项目中的依赖管理问题。

+

虽然它声称支持全部的三种包管理工具,但是:

+
    +
  1. 配合高版本 npm 使用时有 bug,只能使用 4.x 版本
  2. +
  3. 配合 Yarn 使用时无法启用 workspace,因为这会跟硬链接方案冲突
  4. +
  5. 只有在配合 pnpm 使用时才能解决“双胞胎陌生人”问题
  6. +
+

很显然,如果想要正常使用,pnpm 几乎是唯一选择。

+

这里简单列出一些 Rush 提供的特色功能:

+
    +
  1. 顺序构建:自动检测包的依赖关系,按照从下至上有序构建
  2. +
  3. 多进程构建:对于可以同时构建的包,开启多个 Node.js 进程同时构建
  4. +
  5. 增量构建:只对发生了变化的包,以及所有受影响的上游或下游包启动构建,支持缓存构建产物
  6. +
  7. 增量发布:自动检测需要发布的包并执行发布,甚至可以将发布任务设置为定时执行
  8. +
  9. 等等……
  10. +
+

+

总的来说,微软的一套理论是:企业的项目(不管是业务还是基础)都应该尽可能地放在一个超大型仓库中来管理。并且微软声称自己确实是这么做的(见 Rush: Why one big repo⁈ )。Rush 的目的也是为了解决这套方法论的后顾之忧,比如:

+
    +
  1. npm 扁平化结构的各种问题
  2. +
  3. 项目逐渐庞大以后的构建速度问题
  4. +
  5. 项目如何发布的问题
  6. +
+

参考链接

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/paste-image-into-markdown-in-jetbrains-ide/0f014f66da4940ed84c124e4febd0010.png b/2021/paste-image-into-markdown-in-jetbrains-ide/0f014f66da4940ed84c124e4febd0010.png new file mode 100644 index 000000000..2bf99a514 Binary files /dev/null and b/2021/paste-image-into-markdown-in-jetbrains-ide/0f014f66da4940ed84c124e4febd0010.png differ diff --git a/2021/paste-image-into-markdown-in-jetbrains-ide/d39ec6e11506404ea595afff42a20b43.png b/2021/paste-image-into-markdown-in-jetbrains-ide/d39ec6e11506404ea595afff42a20b43.png new file mode 100644 index 000000000..3ddc320a0 Binary files /dev/null and b/2021/paste-image-into-markdown-in-jetbrains-ide/d39ec6e11506404ea595afff42a20b43.png differ diff --git a/2021/paste-image-into-markdown-in-jetbrains-ide/index.html b/2021/paste-image-into-markdown-in-jetbrains-ide/index.html new file mode 100644 index 000000000..d99ff2e4e --- /dev/null +++ b/2021/paste-image-into-markdown-in-jetbrains-ide/index.html @@ -0,0 +1,463 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +在 JetBrains IDE 中向 Markdown 粘贴图片 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 在 JetBrains IDE 中向 Markdown 粘贴图片 +

+ + +
+ + + + +

其实不需要装任何插件,IDE 自带的 Markdown 插件即可支持该操作:

+
    +
  1. 使用任意截图软件截图到剪贴板;
  2. +
  3. Ctrl + V 复制到编辑器中;
  4. +
  5. IDE 会自动生成图片文件 img.png(如果已存在,则会加自增后缀),以及相应的 Markdown 标签 ![img.png](img.png)
  6. +
+

但是,默认的插件不能配置保存路径(只能是 markdown 文件所在的路径),也不能配置命名规则,因此找了一个插件来增强这个功能。

+ + +

插件名:Markdown Image Support。

+

+

配置界面如下:

+

+

不过,这个插件也有个 bug:当取消粘贴时,会回退到 ide 自身的操作,也就是创建 img.png

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/php-note/index.html b/2021/php-note/index.html new file mode 100644 index 000000000..91ce417e4 --- /dev/null +++ b/2021/php-note/index.html @@ -0,0 +1,525 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Php Note | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Php Note +

+ + +
+ + + + +

Php 个人速查笔记。

+ + +

基础

字符串长度

strlen($string)
+ +

数组长度

count($arr)
+ +

日期

获取当前时间

$d1 = new DateTime();
+ +

获取指定时间

$d2 = new DateTime('2021-01-01');
+ +

正则匹配

preg_match("/^[A-Za-z]+$/", $Lastname)
// boolean
+ +

获取时间差

$diff = $d2->diff($d1);
// 年份差
echo $diff->y;
+ +

循环

foreach ($posts as $key=>$value) {
// todo
}
+ +

EOT

<?php foreach ($csv as $i => $value) {
$dateToDisplay = date('F d, Y', $value[0]);
echo <<<EOT
<div class="post-preview">
<a href="post.php?author=$value[2]&date=$value[0]&image=$value[1]&content=$value[3]&comment=$value[4]">
<h2 class="post-title">
<img class="Post1" src="./files/$value[1]" alt="farm" height="380px" width="720px">
</h2>
<h3 class="post-subtitle">$value[5]</h3>
</a>
<p class="post-meta">Posted by
<a href="about.php">$value[2]</a>
on $dateToDisplay
</p>
</div>
<hr>
EOT;
} ?>
+ + +

获取请求方法

$request = $_SERVER['REQUEST_METHOD'];
// POST or GET or anything else
+ +

输入过滤

单条

$id  = filter_input(INPUT_POST, 'id', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
+ +

一次性

$_POST = filter_input_array(INPUT_POST, FILTER_SANITIZE_STRING);
+ +

发请求

$json_url = 'https://data.winnipeg.ca/resource/tx3d-pfxq.json';
$json = file_get_contents($json_url);
$list = json_decode($json, true);
+ +

JSON

解码

$json = json_decode(file_get_contents("./member.json"), true);
$points = $json['points']
+ +

编码

$json = json_encode($array);
echo $json;
+ +

MySQLi

连接

session_start();

$host = 'localhost';
$user = 'root';
$password = '';
$db = 'database';

// connect to mysql database
$conn = new mysqli($host, $user, $password, $db);
if ($conn->connect_error) {
// connection error
die($conn->connect_error);
}
+ +

建表

$sql = "CREATE TABLE IF NOT EXISTS tablename (
ID INT AUTO_INCREMENT PRIMARY KEY,
Name varchar(100) NOT NULL,
RefID int,
FOREIGN KEY (RefID) REFERENCES Ref (ID)
)";

if ($conn->query($sql) !== TRUE) {
die("Error creating table: " . $conn->error);
}
+ +

插入

$stmt = $conn->prepare("insert into table (email, date) VALUE (?,?)");
$stmt->bind_param("ss", $_SESSION['user'], $_POST['date']);
if (!$stmt->execute()) {
die($conn->error);
} else {
echo "inserted, id is " . $stmt->insert_id;
}
+ +

更新

$query = $conn->prepare("update User set profile = ?, photo = ? where id = ?");
$query->bind_param('ssi', $_POST['profile'], $photo, $_SESSION['user'][0]);
$query->execute();
+ +

查询 (单条)

$query = $conn->prepare("SELECT * FROM user where email=? and password=?");
$query->bind_param('ss', $email, $password);
$query->execute();
$result = $query->get_result();
$user = $result->fetch_array(MYSQLI_NUM);
// user 是数组,
// 字段从 0 开始排列,没有 named key
+ +

查询 (多条)

$query = $conn->prepare("SELECT * from meal where email=?");
$query->bind_param('s', $_SESSION["email"]);
$query->execute();
$result = $query->get_result()->fetch_all();

// result 是数组,每个元素也是数组。
// 字段从 0 开始排列,没有 named key
+ +

删除

$query = $conn->prepare("delete from Likes where photoId = ? and userId = ?");
$query->bind_param('ii', $_GET['id'], $_SESSION['user'][0]);
$query->execute();
+ +

PDO

连接

define('DB_DSN','mysql:host=localhost;dbname=blog');
define('DB_USER','root');
define('DB_PASS','');
$db = null;
try {
$db = new PDO(DB_DSN, DB_USER, DB_PASS);
} catch (PDOException $e) {
print "Error: " . $e->getMessage();
die();
}
+ +

插入

$query = "INSERT INTO post (title, content) values (:title, :content)";
$statement = $db->prepare($query);
$statement->bindValue(':title', $title);
$statement->bindValue(':content', $content);
$statement->execute();
$insert_id = $db->lastInsertId();
+ +

更新

$query = "UPDATE post SET title = :title, content = :content WHERE id = :id";
$statement = $db->prepare($query);
$statement->bindValue(':title', $title);
$statement->bindValue(':content', $content);
$statement->bindValue(':id', $id);
$statement->execute();
$insert_id = $db->lastInsertId();
+ +

查询

$query = "SELECT * FROM post ORDER BY creation_time DESC LIMIT 5";
$statement = $db->prepare($query);
$statement->execute();
$posts= $statement->fetchAll();
+ +

删除

$query = "DELETE FROM post WHERE id = :id";
$statement = $db->prepare($query);
$statement->bindValue(':id', $id, PDO::PARAM_STR);
$statement->execute();
+ +

授权

登录

// select user from db first
session_start();
$_SESSION['user'] = $user;
header("Location: index.php");
die();
+ +

注销

unset($_SESSION['user']);
session_destroy();
header('Location: login.php');
die();
+ +

检查授权

if (!isset($_SESSION['user'])) {
header("Location: login.php");
die();
}
+ +

密码加密

$hashed_password = hash('ripemd128', $psw);
+ +

Basic Auth

define('ADMIN_LOGIN','wally');
define('ADMIN_PASSWORD','mypass');
if (!isset($_SERVER['PHP_AUTH_USER']) ||
!isset($_SERVER['PHP_AUTH_PW']) ||
($_SERVER['PHP_AUTH_USER'] != ADMIN_LOGIN) ||
($_SERVER['PHP_AUTH_PW'] != ADMIN_PASSWORD)) {
header('HTTP/1.1 401 Unauthorized');
header('WWW-Authenticate: Basic realm="Our Blog"');
exit("Access Denied: Username and password required.");
}
+ +

Memcached

$memcached = new Memcached();
$memcached->addServer('localhost', 11211);
$memcached->set('test', 'testcache');
var_dump($memcached->get('test'));
$memcached->set('test2', '123');
var_dump($memcached->get('test2'));
var_dump($memcached->get('test3'));
+ +

业务场景

为导航设置激活状态

在 page include header 之前:

+
$page = 'home';
+ +

在 header:

+
<li><a class="<?= ($page == 'home') ? "current" : ""; ?>" href="index.php">Home</a></li>
+ +

文件上传

保存至文件系统

// upload photo to images/photos
$photo = '';
$photoExt = pathinfo($_FILES['photo']['name'], PATHINFO_EXTENSION);
$photo = time() . "." . $photoExt;
move_uploaded_file($_FILES['photo']['tmp_name'], "images/photos/" . $photo);

// insert photo to database
$query = $conn->prepare("insert into Photo (photo, description, type, userId) value (?,?,?,?)");
$query->bind_param('sssi', $photo, $_POST['description'], $_POST['type'], $_SESSION['user'][0]);
$query->execute();
$id = $query->insert_id;

// go homepage
header('Location: index.php');
die();
+ +

保存至数据库

$fileContent = file_get_contents($_FILES['fileContent']['tmp_name']);
$contentName = mysql_fix_string($conn, $_POST['contentName']);
$query = $conn->prepare("INSERT INTO files (contentName, fileContent, userId) values (?,?,?)");
$query->bind_param('ssi', $contentName, $fileContent, $user[0]);
$query->execute();
$query->close();
+ +

MySQLi 初始化数据库

$conn = new mysqli($host, $user, $password);
if ($conn->connect_error) {
die($conn->connect_error);
}

// create database
$sql = "CREATE DATABASE if not exists $db";
if ($conn->query($sql) === TRUE) {
echo "Database $db created.";
} else {
echo "Error creating database: " . $conn->error;
}


// connect to database
$conn = new mysqli($host, $user, $password, $db);
if ($conn->connect_error) {
// connection error
die($conn->connect_error);
}

$sql = "
create table if not exists faculty
(
id int not null auto_increment primary key,
name text not null
);
";

if ($conn->query($sql) === TRUE) {
echo "<br/> faculty table created successfully";
} else {
echo "<br/> faculty table create error:" . $conn->error;
}
+
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/react-hooks-vs-vca/2ab2c69e-41bf-458d-8d69-074ee188044e.gif b/2021/react-hooks-vs-vca/2ab2c69e-41bf-458d-8d69-074ee188044e.gif new file mode 100644 index 000000000..7690a4ec8 Binary files /dev/null and b/2021/react-hooks-vs-vca/2ab2c69e-41bf-458d-8d69-074ee188044e.gif differ diff --git a/2021/react-hooks-vs-vca/35dff667-33ef-4403-86d4-ded321ea8bea.gif b/2021/react-hooks-vs-vca/35dff667-33ef-4403-86d4-ded321ea8bea.gif new file mode 100644 index 000000000..b04e4b2d6 Binary files /dev/null and b/2021/react-hooks-vs-vca/35dff667-33ef-4403-86d4-ded321ea8bea.gif differ diff --git a/2021/react-hooks-vs-vca/b39cea16-d17f-4472-8fa4-671cd4a459c4.gif b/2021/react-hooks-vs-vca/b39cea16-d17f-4472-8fa4-671cd4a459c4.gif new file mode 100644 index 000000000..fa8cbcbc9 Binary files /dev/null and b/2021/react-hooks-vs-vca/b39cea16-d17f-4472-8fa4-671cd4a459c4.gif differ diff --git a/2021/react-hooks-vs-vca/bench.png b/2021/react-hooks-vs-vca/bench.png new file mode 100644 index 000000000..f3d5615b4 Binary files /dev/null and b/2021/react-hooks-vs-vca/bench.png differ diff --git a/2021/react-hooks-vs-vca/cf7fa8cb-e185-4e49-b81a-297d402633cb.gif b/2021/react-hooks-vs-vca/cf7fa8cb-e185-4e49-b81a-297d402633cb.gif new file mode 100644 index 000000000..c46f73cf0 Binary files /dev/null and b/2021/react-hooks-vs-vca/cf7fa8cb-e185-4e49-b81a-297d402633cb.gif differ diff --git a/2021/react-hooks-vs-vca/f773cbbb-79bd-4920-8164-cdd998748c02.gif b/2021/react-hooks-vs-vca/f773cbbb-79bd-4920-8164-cdd998748c02.gif new file mode 100644 index 000000000..f7c1cf3a0 Binary files /dev/null and b/2021/react-hooks-vs-vca/f773cbbb-79bd-4920-8164-cdd998748c02.gif differ diff --git a/2021/react-hooks-vs-vca/f88339f0-a7f2-401b-a5b3-03de5cf75e3c.gif b/2021/react-hooks-vs-vca/f88339f0-a7f2-401b-a5b3-03de5cf75e3c.gif new file mode 100644 index 000000000..9bb85d12b Binary files /dev/null and b/2021/react-hooks-vs-vca/f88339f0-a7f2-401b-a5b3-03de5cf75e3c.gif differ diff --git a/2021/react-hooks-vs-vca/f9bafbe2-24f7-46bf-89a4-fee6aae5c33c.gif b/2021/react-hooks-vs-vca/f9bafbe2-24f7-46bf-89a4-fee6aae5c33c.gif new file mode 100644 index 000000000..7ac4cd415 Binary files /dev/null and b/2021/react-hooks-vs-vca/f9bafbe2-24f7-46bf-89a4-fee6aae5c33c.gif differ diff --git a/2021/react-hooks-vs-vca/index.html b/2021/react-hooks-vs-vca/index.html new file mode 100644 index 000000000..ec75aa269 --- /dev/null +++ b/2021/react-hooks-vs-vca/index.html @@ -0,0 +1,686 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +前端 MVC 的未来:浅谈 Hooks 与 VCA 在设计思路上的异同 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 前端 MVC 的未来:浅谈 Hooks 与 VCA 在设计思路上的异同 +

+ + +
+ + + + +

关于 React Hooks 与 Vue Composite API:

+ +

二者为了共同的目的,在接近的时间点,以非常相似但是又带有本质区别的方式,推出了各自对于未来前端代码结构发展的新思路。本文在对二者做一些简单介绍的同时,也会重点关注二者之间的统一与区别。

+ + +

先说结论

共同目的

1. 优化代码复用

以下所说的代码复用,不包含组件复用等内容。两大框架老的复用方式存在的共同问题:

+
    +
  • 变量、参数来源不明确(混乱);
  • +
  • 无命名空间,变量之间可能冲突、覆盖(不可靠);
  • +
+

React

+
    +
  • mixin
  • +
+

因为 mixin 的缺点根本多到数不清,React mixin 是一种已经基本上被废弃了的写法。它在 class 组件中已经不可用了。

+
const TickTock = React.createClass({
mixins: [SetIntervalMixin], // Use the mixin
// ...
});
+ +
    +
  • HOC
  • +
+

HOC 是 Higher-Order Components 的简称。HOC 是通过语言自身的特性实现的,跟 React 本身没有关系。

+
class AdvancedComp extends React.Component { 
render() {
return <BaseComp {...props} text={'someText'} />;
}
}
+ +

HOC 是在 React Hooks 出现之前被广泛使用的代码复用方式,但是它存在自己的问题和局限性:

+
    +
  1. 不能在 render 函数内定义 HOC(会导致组件丢失状态,以及消耗性能)
  2. +
  3. 高阶组件会丢失原组件的静态与实例方法,需要手动复制
  4. +
  5. ref 将无法得到原始组件的引用,必须用 React.forwardRef 处理
  6. +
  7. 复杂的高阶组件跟 mixin 一样,存在参数来源以及去向混乱的问题
  8. +
+
    +
  • 继承(不支持生命周期钩子)
  • +
+

继承这种方式,看起来很符合语言特性,但是 React 对它的支持是不完备的,甚至没有出现在官方推荐的方式里面。最主要的问题是,高阶组件没有办法复用基类的生命周期以及 render 函数,也不能通过形如 super.componentDidMount() 的形式来绕过这个问题。

+
class AdvancedComp extends BaseComp {
// ...
}
+ +

Vue

+
    +
  • Mixin
  • +
+

Vue 的 mixin 跟 React 非常类似,在提供便利的同时,同样带来了多到数不清的问题。摘录一下来自 Vue 官方文档的吐槽:

+
+

在 Vue 2 中,mixin 是将部分组件逻辑抽象成可重用块的主要工具。但是,他们有几个问题:

+
    +
  1. Mixin 很容易发生冲突:因为每个 mixin 的 property 都被合并到同一个组件中,所以为了避免 property 名冲突,你仍然需要了解其他每个特性。

    +
  2. +
  3. 可重用性是有限的:我们不能向 mixin 传递任何参数来改变它的逻辑,这降低了它们在抽象逻辑方面的灵活性。

    +
  4. +
+
+
    +
  • Directive
  • +
+

Directive(指令)是一种特殊的代码复用,它的目的非常局限:操作 DOM 节点。也就是说,它的复用范围仅限于跟 DOM 操作相关的内容。

+

2. 减轻心智负担

在以职能来组织代码的时候,当我们的组件开始变得更大时,逻辑关注点的列表也会增长,举例(一张来自 Vue 文档的图片):

+

+

相信对于这类文件写过 Vue 的同学都深有体会。当我们需要查找跟某项功能相关的代码的时候,需要在文件中不停地搜索、上下跳动。非常难受。

+

3. 干掉 this

this 在 JavaScript 这个大环境下始终存在指向不明确的问题,无论是对初学者还是资深前端工程师来说也始终是一个需要特别注意的地方,同时也不利于静态分析和强类型检查。

+

本质区别

二者的区别,说来非常简单,但是又非常巨大:

+
    +
  1. React Hooks 是 effect (副作用),在组件每次渲染的时候都会执行
  2. +
  3. VCA 是 setup (安装/配置),仅在组件初始化的时候执行一次
  4. +
+

这些区别是由框架本身的特性决定的,而它们具体代表了什么,需要在下文继续阐释。

+

常用场景差异

生命周期

Hooks 移除了生命周期的概念,取而代之的是 effect ,VCA 则近乎完整地保留了生命周期概念与函数。

+

1. Mount / Unmount

hooks

+

React Hooks 摒弃了 mount / unmount / update 等生命周期概念,转而引入了一个新的 useEffect 函数,简而言之:

+
    +
  1. useEffect 接收两个参数,第一个是回调函数 callback,第二个是数组 deps
  2. +
  3. callback 可以没有返回值,也可以返回一个函数,如果返回了函数,那将会是这个 effect 的「清除」函数
  4. +
  5. 当组件初次挂载,或者每当 deps 里面的任意一个元素发生变化的时候(这个时机由 React 判断),回调函数将会被执行一次
  6. +
  7. 特殊情况:
      +
    1. deps 未传:callback 在每次渲染的时候都会执行一次
    2. +
    3. deps 为空数组:callback 当且仅当组件第一次挂载的时候执行一次
    4. +
    +
  8. +
+

因此,可以使用 useEffect 同时模拟 mount 与 unmount 事件:

+
// componentDidMount (mounted)
useEffect(() => {
console.log('[componentDidMount]')

const clicked = (e: MouseEvent) => {
setXy([e.clientX, e.clientY])
}

document.addEventListener('click', clicked)

// componentWillUnmount (beforeUnmount)
return () => {
console.log('[componentWillUnmount]')
document.removeEventListener('click', clicked)
}
}, [])
+ +

这样做有几个好处:

+
    +
  1. 对于通常需要成对出现的,类似注册、解注册的逻辑来说,这么做可以使得逻辑更加内聚
  2. +
  3. 挂载和卸载函数可以读取到同一个作用域下的变量和方法,就比如以上例子中的 clicked 事件
  4. +
+

但是,由此也带来了一个显而易见的问题:callback 没法使用 async 函数了。因为 async 函数必定会返回一个 Promise 实例,而这明显与设计相悖。想要在 useEffect 内部使用 async 函数的话,做法会有点绕:

+
useEffect(() => {
(async () => {
// do something...
})()
})
+ +

vca

+

与 Hooks 大相庭径:

+
    +
  1. VCA 保留了传统的 mounted 与 unmount 事件,只不过换了个形式
  2. +
  3. VCA 不需要写 deps
  4. +
+
export default defineComponent({
setup () {
const xy = reactive({
x: 0,
y: 0
})

const clicked = (e: MouseEvent) => {
xy.x = e.clientX
xy.y = e.clientY
}

onMounted(() => {
document.addEventListener('click', clicked)
})

onBeforeUnmount(() => {
document.removeEventListener('click', clicked)
})

return {xy}
},
})
+ +

2. Update / Watch

hooks

+

useEffectdeps 不为空时,回调函数在组件第一次挂载时,以及后续每次 deps 的其中之一变化时都会执行。

+

这里有一点需要注意的是:**除 useRef 以及 useState 的 setter 以外,其它所有回调函数中用到的变量,都需要写进 deps**,包括 state / memo / callback 等,否则(因为闭包的存在)函数调用时永远会拿到旧的值。

+
// componentDidUpdate (watch / updated)
useEffect(() => {
console.log('[componentDidUpdate]', xy)
}, [xy])

// 错误!fetchData 也需要写进 deps
useEffect(() => {
// useRef
console.log(countRef.current)
// useCallback useState
fetchData(page)
}, [page])
+ +

vca

+

VCA 的 updated 与 watch 与原 API 也基本类似,但是有几个需要注意的点:

+
    +
  1. 增加了一个新的概念 watchEffect,与 useEffect 十分类似,但是不需要写 deps
  2. +
  3. watchwatchEffect不能直接监听 reactive 本身——因为只有 reactive 下面的属性才是真正意义上的 reactive
  4. +
+
onUpdated(() => {
console.log('updated', xy)
})

// 正确
watch(() => xy.y, (y, oy) => {
console.log('watch',y, oy)
})

// 错误,两个参数都将是更新后的值
watch(xy, (y, oy) => {
console.log('watch',y, oy)
})

// 正确
watchEffect(() => {
console.log('watchEffect', xy.y)
})

// 错误,无法触发
watchEffect(() => {
console.log('watchEffect', xy)
})
+ +

变量定义

总体区别:

+
    +
  • 从利于维护的角度出发,hooks 内原则上不允许直接定义任何变量,包括常量、方法等,因为组件每次渲染时都会重新初始化。因此从某种意义上来说,直接定义的变量也是一种响应式变量(当能够正确赋予初始值的时候)。
  • +
  • VCA 无此限制。并且直接定义的变量为常量。
  • +
+

注:关于第一点,社区一直存在争议。争议的关键点在于每次渲染都重新初始化变量到底会不会对性能造成压力。官方文档 的说法是不会,但是从目前的 benchmark 结果来看,React Hooks 确实已经处于下风了(当然这里面也会有其它方面的影响因素):

+

+

1. 变量

hooks

+

hooks 大致提供了以下几种定义变量的方法:

+
    +
  • useState: 响应式变量,不需要 deps
  • +
  • useMemo: 常量(不可变)或计算值,需要写 deps
  • +
  • useRef: 变量(可改变,但不影响渲染),不需要 deps
  • +
  • 直接定义: 通常来说是错误的写法
  • +
+
// state, 影响渲染
const [count, setCount] = useState(0)

// 计算属性,count 发生变化时会改变,影响渲染
const doubleCount = useMemo(() => count * 2, [count])

// 变量,但不影响渲染
const doubleCountRef = useRef(count * 2)

// 直接定义,每次渲染时都会重新计算值,因此也能影响渲染
const renderEveryTime = count * 2
+ +

+

vca

+

VCA 提供了以下几种定义变量的方法:

+
    +
  • ref: 包裹(深度)响应式对象。之所以存在,是因为基础类型目前来说无法做到响应式,所以必须通过一个对象来包裹,通过 xxx.value 访问基础类型才能获得响应式。同时允许 object/array 的重新赋值。
  • +
  • reactive: 与 ref 实现的效果一样,区别是不需要通过 .value 即可访问,同时也不能被重新赋值。
  • +
  • computed: 计算值
  • +
  • 直接定义: 常量
  • +
+

无论哪种方式,VCA 都不需要写 deps

+
// state, 影响渲染
const count = ref(0)

// 计算属性,count 发生变化时会改变,影响渲染
const double = computed(() => count.value * 2)

// 常量,一次性的值,不影响渲染
const doubleCountRef = count.value * 2;
+ +

+

2. 方法

hooks

+
    +
  • 从利于维护的角度出发,方法定义需要使用 useCallback 包裹,某则每次渲染都会被重新创建,并且当方法作为 PureComponent 子组件的参数使用时会触发子组件的重新渲染。
  • +
  • 方法必须正确定义 deps,否则内部取值将得不到变化后的值
  • +
  • 方法的 deps 一旦改变,方法将会被重新创建,闭包也会得到更新
  • +
+
const [count, setCount] = useState(0)

// 正确
const addCount = useCallback(() => {
setCount(v => ++v)
}, [])

// 正确
const addCount = useCallback(() => {
setCount(count + 1)
}, [count])

// 错误, 永远相当于 setCount(0 + 1)
const addCount = useCallback(() => {
setCount(count + 1)
}, [])
+ +

vca

+

没什么限制,可以随心所欲。

+
const addCount = function () {
++count.value
}
+ +

代码复用

二者在代码复用这一块的理念十分类似,最终体现在代码上就像是两兄弟。

+

hooks

+
import React, { useEffect, useState, memo } from 'react'

function useMousePosition () {
const [xy, setXy] = useState([0, 0])
useEffect(() => {
const moved = (e: MouseEvent) => {
setXy([e.clientX, e.clientY])
}

document.addEventListener('mousemove', moved)

return () => {
document.removeEventListener('mousemove', moved)
}
}, [])

return {
x: xy[0],
y: xy[1]
}
}

export default memo(
function () {
const { x, y } = useMousePosition()

return (
<>
<h2>CustomHooks</h2>
<div>Mouse Position: {x},{y}</div>
</>
)
}
)
+ +

+

vca

+
function useMousePosition () {
const xy = reactive({ x: 0, y: 0 })
const moved = (e: MouseEvent) => {
xy.x = e.clientX
xy.y = e.clientY
}

onMounted(() => {
document.addEventListener('mousemove', moved)
})

onBeforeUnmount(() => {
document.removeEventListener('mousemove', moved)
})

return xy
}

export default defineComponent({
setup () {
const xy = useMousePosition()

return { xy }
},
})
+ +

+

存在的问题

简单来说,由于两个框架各自的特性,问题也通常来自于:

+
    +
  1. deps (hook)
  2. +
  3. Proxy (VCA)
  4. +
+

不过有一个好消息是,React 提供了一个插件 eslint-plugin-react-hooks 可以帮忙检测 deps 的缺失,并且后续有计划通过代码静态分析去除掉这个烦人的依赖项。

+

Hook: 忘记写 deps 导致变量不更新

// state
const [count, setCount] = useState(0)

// method
const addCount = useCallback(() => {
setCount(count + 1)
}, [])
+ +

+

Hook: Deps 写不好导致死循环

案例一:在 setState 的同时又依赖了 state

常见于列表加载:

+
    +
  1. 首页数据可以直接 setState
  2. +
  3. 后续分页的数据要在现有基础上追加
  4. +
+
const [count, setCount] = useState(0)

const addCount = useCallback(() => {
setCount(count + 1)
}, [count])

useEffect(() => {
addCount()
}, [addCount])
+ +

+

案例二: useEffect 忘记写 deps

const addCount = useCallback(() => {
setCount(v => v + 1)
}, [])

useEffect(() => {
addCount()
})
+ +

案例三:deps 里面填入了直接定义的引用类型变量

let someValue = []
const [count, setCount] = useState(0)

// 由于 someValue 每次渲染时都会重新初始化,
// 而引用类型重新初始化后其地址是不等的,
// 因此会触发死循环
useEffect(() => {
setCount(v => ++v)
}, [someValue])
+ +

Hooks: 定义位置的限制

因为 Hooks 的实现原理是链表,必须保证每次组件渲染得到的 hooks 及其顺序都是一致的,因此使用 Hook 需要遵循两条额外的规则:

+
    +
  • 只能在 React 函数中调用 Hook,不能在普通的 JavaScript 函数中调用;
  • +
  • 不能在循环,条件或嵌套函数中调用 Hook
  • +
+
// 错误的写法,会直接报错
const [count, setCount] = useState(0)

if (count === 1) {
const [double, doubleCount] = useState(count)
}
+ +

VCA: 解构丢失响应性

包括 props / reactive 在内的所有 Proxy 类型变量都不能解构,否则会丢失响应性。解构必须使用 toRefs 方法

+
export default defineComponent({
setup () {
const count = reactive({ value: 0 })

const addCount = function () {
++count.value
}
const { value } = count

return { count, value, addCount }
},
})
+ +

+

总结

React Hooks

优点:

+
    +
  • 目前为止最好的代码复用方式(之一)
  • +
  • 优秀且精炼的设计理念
  • +
+

缺点:

+
    +
  • 需要写 deps
  • +
  • 由于其每次渲染都执行 (effect) 的特点,目前被业界公认为心智负担极重
  • +
+

Vue Composite API

优点:

+
    +
  • 目前为止最好的代码复用方式(之一)
  • +
  • 作为 setup,上手难度相较 hooks 可谓是极低,心智负担极低
  • +
+

缺点:

+
    +
  • 与 Hooks 相比,API 设计(也许)不够精炼,受制于历史包袱
  • +
  • Proxy 虽然带来了便利,但是也带来了麻烦,经常需要考虑:
      +
    • 一个对象能否解构?
    • +
    • 一个属性到底应该用 ref 还是 reactive
    • +
    • 取值的时候要不要加 .value(常常被忘记)?
    • +
    +
  • +
+

参考资料

    +
  1. React Hooks 官方文档
  2. +
  3. Vue Composite API 官方文档
  4. +
  5. 知乎:Vue3 究竟好在哪里?(和 React Hook 的详细对比)
  6. +
  7. 知乎:新版react中,useCallback 和 useMemo 是不是值得大量使用?
  8. +
  9. Vue 3.2 Released!
  10. +
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/react-hooks-vs-vca/vue-sfc.png b/2021/react-hooks-vs-vca/vue-sfc.png new file mode 100644 index 000000000..74bcfefea Binary files /dev/null and b/2021/react-hooks-vs-vca/vue-sfc.png differ diff --git a/2021/regex-assertions/index.html b/2021/regex-assertions/index.html new file mode 100644 index 000000000..c46b9ef7c --- /dev/null +++ b/2021/regex-assertions/index.html @@ -0,0 +1,491 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +正则断言 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 正则断言 +

+ + +
+ + + + +
+

Assertions include boundaries, which indicate the beginnings and endings of lines and words, and other patterns indicating in some way that a match is possible (including look-ahead, look-behind, and conditional expressions).

+
+

断言是正则表达式组成的一部分,包含两种断言。本文记录了一些常用断言。

+ + +

边界类断言

^

匹配输入的开头。在多行模式匹配中,^ 在换行符后也能匹配。

+
/^A/.test('Apple');
// true

/^B/.test('Apple\nBanana');
// false

/^B/m.test('Apple\nBanana');
// true
+ +

$

匹配输入的结尾。在多行模式匹配中,$ 在换行符前也能立即匹配。

+
/e$/.test('Apple');
// true

/e$/.test('Apple\nBanana');
// false

/e$/m.test('Apple\nBanana');
// true
+ +

其它断言

x(?=y)

向前断言。x 被 y 跟随时匹配 x,匹配结果不包括 y。

+

举例:

+
/Jack(?=Sprat)/.exec('JackSprat');
// ["Jack", index: 0, input: "JackSprat", groups: undefined]

/Jack(?=Sprat)/.exec('Jack Sprat');
// null
// 因为多了一个空格,无法匹配

/Jack(?=\s?Sprat)/.exec('Jack Sprat');
// ["Jack", index: 0, input: "Jack Sprat", groups: undefined]
// 加上空格后匹配成功

/Jack(?=Sprat|Frost)/.exec('JackFrost');
// ["Jack", index: 0, input: "JackFrost", groups: undefined]
+ +

x(?!y)

向前否定断言。x 没有被 y 紧随时匹配 x,匹配结果不包括 y。

+

举例,匹配小数点后的数字:

+
/\d+(?!\.)/.exec('3.1415926');
// ["1415926", index: 2, input: "3.1415926", groups: undefined]
+ +

(?<=y)x

向后断言。x 跟随 y 的情况下匹配 x,匹配结果不包括 y。

+

举例:

+
/(?<=Jack)Sprat/.exec('JackSprat');
// ["Sprat", index: 4, input: "JackSprat", groups: undefined]

/(?<=Jack\s?)Sprat/.exec('Jack Sprat');
// ["Sprat", index: 5, input: "Jack Sprat", groups: undefined]
+ +

(?<!y)x

向后否定断言。x 不跟随 y 时匹配 x,匹配结果不包括 y。

+

举例,匹配小数点前的数字:

+

综合举例

匹配二级域名

匹配某个完整域名中的二级域名:

+
/\w+(?=\.daily\.xoyo)/.exec('https://tg.daily.xoyo.com/');
// ["tg", index: 8, input: "https://tg.daily.xoyo.com/", groups: undefined]

/\w+(?=\.daily\.xoyo)/.exec('https://tg.service.daily.xoyo.com/');
// ["service", index: 11, input: "https://tg.service.daily.xoyo.com/", groups: undefined]
+ +

社交场景

比如,某条 ugc 内容包含以下规则:

+
    +
  1. @某人 表示 @
  2. +
  3. #某话题 表示话题
  4. +
  5. [某表情] 表示表情
  6. +
+

某个字符串如下:

+
const str = '@大吧主 @小吧主 你们好,什么时候能把我的号解封[微笑][微笑] #狗管理 #玩不了了';
+ +

获取所有 @ 人

str.match(/(?<=@).+?(?=\s|$)/g)
// ["大吧主", "小吧主"]
+ +

获取所有话题

str.match(/(?<=#).+?(?=\s|$)/g);
// ["狗管理", "玩不了了"]
+ +

获取所有表情

str.match(/(?<=\[).+?(?=\]|$)/g);
// ["微笑", "微笑"]
+ +

一次性获取所有特殊内容

str.match(/(?<=@).+?(?=\s|$)|(?<=#).+?(?=\s|$)|(?<=\[).+?(?=\]|$)/g);
// ["大吧主", "小吧主", "微笑", "微笑", "狗管理", "玩不了了"]
+
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/simple-css-dark-mode/index.html b/2021/simple-css-dark-mode/index.html new file mode 100644 index 000000000..9f602e1ae --- /dev/null +++ b/2021/simple-css-dark-mode/index.html @@ -0,0 +1,456 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +简单 CSS 实现暗黑模式 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 简单 CSS 实现暗黑模式 +

+ + +
+ + + + +
+ +
@media (prefers-color-scheme: dark) {
html {
filter: invert(90%) hue-rotate(180deg);
}

img, video, svg, div[class*="language-"] {
filter: invert(110%) hue-rotate(180deg);
opacity: .8;
}
}
+ +

具体效果参考本站(打开系统级别的暗黑模式)。 解释:

+
    +
  1. invert 将所有色值反转,hue-rotate 将黑白以外的其它主色调再反转回来(防止页面主题色出现大的变化);
  2. +
  3. 网上的 invert 通常取值为 100%,但是这样反转得到的黑色往往太过黑,眼睛看起来有点累,因此我觉得 90% 是一个更合理的值;
  4. +
  5. 将图片、视频等其它不需要被反转的元素再反转回来,并加一个透明度,让其不那么刺眼;
  6. +
  7. 如果 html 反转 90%,则图片等元素需要反转 110%
  8. +
  9. div[class*="language-"] 对应的是本站 (VuePress) 上的代码块。
  10. +
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/unit-test-best-practice-of-mini-program/index.html b/2021/unit-test-best-practice-of-mini-program/index.html new file mode 100644 index 000000000..aea938ff5 --- /dev/null +++ b/2021/unit-test-best-practice-of-mini-program/index.html @@ -0,0 +1,552 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +小程序单元测试最佳实践 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 小程序单元测试最佳实践 +

+ + +
+ + + + +

微信小程序单元测试的可查资料少得可怜,由于微信官方开发的自动化测试驱动器 miniprogram-automator 不开源,唯一靠谱的地方只有这 一份简单的文档。然而实际使用下来发现文档介绍的方式有不少问题。

+ + +

关于单元测试如何启动的问题

官方推荐的方式

官方推荐通过 Jest 来组织单元测试,这点我是认可的。文档上面标注的步骤是:

+
    +
  1. 启动并连接工具
  2. +
  3. 重新启动小程序到首页
  4. +
  5. 断开连接并关闭工具
  6. +
+

代码:

+
const automator = require('miniprogram-automator')

describe('index', () => {
let miniProgram
let page

beforeAll(async () => {
miniProgram = await automator.launch({
projectPath: 'path/to/miniprogram-demo'
})
page = await miniProgram.reLaunch('/page/component/index')
await page.waitFor(500)
}, 30000)

afterAll(async () => {
await miniProgram.close()
})
})
+ +

乍一看没什么特别的问题,然而实际上跑了几次以后发现,它存在一个巨大的缺陷:就是 automator.launch 这一操作相当耗时,启动一次至少要 30 秒

+

举例:

+
    +
  1. 项目定义了 10 套单元测试,每套测试都得重新走 launchclose 流程;
  2. +
  3. 使用 watch 方式启动 Jest,每次触发执行都要重新走 launchclose 流程;
  4. +
  5. 等等……
  6. +
+

以上场景都将带来巨大的时间损耗,完全无法容忍。

+

因此,如何缩短这里的耗时将是重中之重。

+

Jest 全局共享连接实例?

既然每个单元测试都要用到连接实例 miniProgram,那么大家共享同一个实例自然是我能想到的第一个办法。

+

globalSetup / globalTeardown

第一个办法是通过 globalSetupglobalTeardown 参数,为单元测试提供一个全局 setup 函数,并且它支持 async,看起来非常完美。

+

但是,在尝试过后发现并不起作用,在 setup 过程中挂载到 global 的属性无法从单元测试中读取,后来查阅文档才发现这个 setup 函数有一个致命的缺陷:

+
+

Note: Any global variables that are defined through globalSetup can only be read in globalTeardown. You cannot retrieve globals defined here in your test suites.

+
+

原来,单元测试是运行在「沙盒」环境下的,彼此隔离,因此它们无法读取到来自外部的 global 变量。

+

facebook/jest/issues/7184 中,有人提到可以在 setup 函数中用 process.env.FOO = 'bar' 这种方式来达成目的。经测试确实可以,但是问题在于:

+
    +
  1. process 下面只能挂载基础类型,不能挂载对象实例;
  2. +
  3. 实际上「它能工作」本身是一个 Bug,在 issue 内有人提到,它可能会在任意时间被修复。
  4. +
+

因此,这个方案不可行。

+

setupFiles

第二个办法是使用 setupFiles 参数。

+

该方式支持 global 挂载,但是很遗憾,我不用尝试也知道,setupFiles 目前仅支持同步执行,无法满足需求。

+

见:facebook/jest/issues/11038

+

testEnvironment

找到的第三个办法是使用 testEnvironment 参数。

+

该方式支持:

+
    +
  1. global 挂载
  2. +
  3. 异步执行
  4. +
+

但是,依旧很遗憾,经过查阅文档发现,testEnvironment 并不是作用全局的,也就是说,它在每个单元测试执行时都会走一遍创建、销毁流程,跟最初的方式并没有本质区别。

+

总结

由于 Jest 本身的设计问题,全局共享连接实例这个方案基本(至少目前)不可行。

+

将单元测试挪到一个文件下?

既然跨单元测试的变量共享不可行,那么第二个方向就是:将所有单元测试集合起来,共享一套环境。这样一来,大家自然就可以共享一个连接了。

+
// package.json
{
"scripts": {
// 注:指定了只执行 ./tests 下的测试
"test": "node node_modules/jest-cli/bin/jest.js ./tests --runInBand --verbose",
"test:watch": "npm run test -- --watchAll=true"
},
// ...
}

+ +
// index.spec.js
const automator = require('miniprogram-automator')
const path = require('path')

let mp

const launchOptions = {
// ...
}

beforeAll(async () => {
mp = await automator.launch({ ...launchOptions })
global.mp = mp
}, 60000)

afterAll(async () => {
await mp.disconnect()
})

require('path/to/test1')
require('path/to/test2')
// more...
+ +

这样一来,各单元测试可以通过 global.mp 得到连接实例。实测也确实可行,仅需要启动一次就可以跑完所有单元测试。

+

但是,这种实现方式存在一些问题:

+
    +
  1. 所有单元测试被归总到了一个 test suit 内,测试结果的打印慢了许多,需要等到所有测试跑完才能看到结果;
  2. +
  3. 同理,无法利用好 Jest 的 watch 功能,无法做到开发时仅执行某个 test suit
  4. +
  5. 添加或删除了测试,需要手动更改 require 列表,相当麻烦
  6. +
  7. 该方式并未解决 watch 触发需要重启连接实例的问题,依旧相当耗时
  8. +
+

Launch or Connect?

由于官方文档给出的例子是使用 Launch 实现的,所以自然而然会从这方面入手寻找解决方案,但是走了这么多弯路以后还是不行,我开始考虑:是否可以绕过 Launch?

+

查看 文档 以后发现,除了 launch 以外,automator 还提供了一个 connect 方法。

+
+

automator.connect

+

连接开发者工具。

+

automator.connect(options: Object): Promise<MiniProgram>

+
+

因此,我想到了一个办法:如果不通过 launch,直接 connect 至现有窗口,应该会快很多吧。

+

但是尝试后发现,即使在开发者工具中打开了服务端口,connect 也无法连接上,始终报错「端口未打开」。 后来通过搜索才发现,此「端口」非彼「端口」,如果要用过 websocket 连接,开发者工具就必须以 cli 方式加 --auto 参数启动才行。

+

因此,我也想到了最终解决方案:

+
    +
  1. 先尝试 connect,如果成功则进入测试
  2. +
  3. 如果失败,则执行 launch(该方式启动默认开启自动化)
  4. +
  5. 测试结束时,不调用 close,而是调用 disconnect
  6. +
+

这样一来,第一次单元测试启动时会启动开发者工具,测试完成以后,连接会断开,但是开发者工具不会关闭。等到第二次启动时,automator 就能直接连上,无需再次启动。

+

编写 setup 文件(该文件不以 spec 结尾,只作为 mixin 使用):

+
// setup.js
const automator = require('miniprogram-automator')
const path = require('path')

let mp

const launchOptions = {
// ...
}

beforeAll(async () => {
try {
mp = await automator.connect({
wsEndpoint: 'ws://localhost:9420',
})
} catch (err) {
console.error(err)
try {
mp = await automator.launch({ ...launchOptions })
} catch (err) {
console.error(err)
}
}

global.mp = mp
}, 60000)

afterAll(async () => {
await mp.disconnect()
})
+ +

在每个单元测试中引用 setup:

+
// some-test.spec.js
describe('some-test', () => {
require('path/to/setup')

let page = null

// ...
})
+ +

如此一来,以上发现的所有问题都能很好地解决:

+
    +
  1. 单元测试极大地提速
  2. +
  3. test suit 按照正常方式组织,无需额外操作
  4. +
  5. watch 模式也能正常使用,速度极快
  6. +
+

但是,该方式同样带来了一个问题:即 test suit 不再拥有独立运行环境,每个 suit 要注意清理自己带来的影响。

+

不过,权衡利弊来说,肯定是好处远远大于坏处。

+

关于如何进行页面导航的问题

通过实例方法导航

miniProgram 实例提供了一系列的导航方法,如 navigateTonavigateBack 等,经实践,能够正常使用。但是,它们有一个通病:耗时明显(又来了)。

+

经测试,在导航开始前记录时间,await 至导航结束,打印时间差,每次导航耗时大概在 3000 毫秒以上。具体表现为,页面虽然已跳转到位,但方法就是没有返回。由于驱动框架不开源,也并不知道在这段时间内它究竟做了什么。

+
// 耗时在 3000ms 以上
page = await global.mp.navigateTo('/pages/index/index')
+ +

单元测试少的话可以容忍,但是一旦多起来了,也是非常浪费生命的。

+

通过页面元素导航

通过模拟页面内的导航元素点击来达到效果,这种方式耗时极短,500 毫秒内即可完成。虽然相比实例方法来说较为繁琐,但胜在量大的时候节省时间效果非常明显。

+
// 耗时在 500ms 左右
const btn = await page.$('#some-nav-btn')
await btn.tap()
// 实际上所有耗时几乎都发生在这里,等待导航动画结束
await page.waitFor(500)
page = await global.mp.currentPage()
+ +

关于如何与原生元素交互问题

由于驱动不支持选择原生元素,也不支持对其进行交互,因此唯一的办法是通过 mock 修改其定义。

+

举例,要模拟 wx.showModal确定 点击:

+
await global.mp.mockWxMethod('showModal', {
confirm: true,
cancel: false
})
+ +

如此一来,当 showModal 被调用时,会直接进入 confirm 流程。

+

当然,测试结束后要记得 restore:

+
await global.mp.restoreWxMethod('showModal')
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2021/windows-idea-cygwin/7455121287654941ac9f9a935d0ccf07.png b/2021/windows-idea-cygwin/7455121287654941ac9f9a935d0ccf07.png new file mode 100644 index 000000000..dbbb94483 Binary files /dev/null and b/2021/windows-idea-cygwin/7455121287654941ac9f9a935d0ccf07.png differ diff --git a/2021/windows-idea-cygwin/96560471a12f469db0212b2f065de6c9.png b/2021/windows-idea-cygwin/96560471a12f469db0212b2f065de6c9.png new file mode 100644 index 000000000..af73c4162 Binary files /dev/null and b/2021/windows-idea-cygwin/96560471a12f469db0212b2f065de6c9.png differ diff --git a/2021/windows-idea-cygwin/b04eda3d7db64fdaaccb489f41c392a2.png b/2021/windows-idea-cygwin/b04eda3d7db64fdaaccb489f41c392a2.png new file mode 100644 index 000000000..8df4dab0e Binary files /dev/null and b/2021/windows-idea-cygwin/b04eda3d7db64fdaaccb489f41c392a2.png differ diff --git a/2021/windows-idea-cygwin/fc7090eae0d94fd881cf53c5b6c8b1ce.png b/2021/windows-idea-cygwin/fc7090eae0d94fd881cf53c5b6c8b1ce.png new file mode 100644 index 000000000..e5c2abd29 Binary files /dev/null and b/2021/windows-idea-cygwin/fc7090eae0d94fd881cf53c5b6c8b1ce.png differ diff --git a/2021/windows-idea-cygwin/index.html b/2021/windows-idea-cygwin/index.html new file mode 100644 index 000000000..edc6e0fb2 --- /dev/null +++ b/2021/windows-idea-cygwin/index.html @@ -0,0 +1,464 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +在 Windows 中使用 Cygwin | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 在 Windows 中使用 Cygwin +

+ + +
+ + + + +

之前在 WSL on Windows 10 中尝试了 WSL,但是几经周折最后发现问题比较多,用得有点难受。最后还是换回了 windows。

+ + +

下载

https://www.cygwin.com/

+

设置 Windows Terminal

注意,后面的 C:\cygwin64 换成实际安装路径。

+

增加 cygwin 配置

+

效果

+

设置 IDEA

修改 Shell path

注意里面填的是 C:\cygwin64\bin\env.exe CHERE_INVOKING=1 /bin/bash -l,这样才能在项目目录打开终端:

+

+

效果

+

设置 ssh keys

可以建一对新的 key pair,也可以直接使用 windows 下面建好的。

+

如果要使用 windows 的,只需将 .ssh 文件夹下面的内容复制到 C:\cygwin64\home\user\.ssh 即可。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2022/2022-05-20/index.html b/2022/2022-05-20/index.html new file mode 100644 index 000000000..602ce0dfa --- /dev/null +++ b/2022/2022-05-20/index.html @@ -0,0 +1,448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2022/05/20 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 2022/05/20 +

+ + +
+ + + + +

很久没更新了,最近有点懒。也没什么想写的。

+

在新公司(金山办公)上班一年了,工作量并不大,但是干得感觉比之前更累了。主要可能有两个原因:一是之前的负责人在我入职不久后就走了,结果我又变成了负责人(离开西山居的原因之一就是不想做不责人)。二是,做的项目比较偏探索向,不是常规的业务项目,整天要思考这个那个,很累。有时候(经常)也会想放弃。不过看在去年刚来半年就给我 3.75 的份上,还是再干一段时间吧。

+

最近理财跌得不要不要的,3 个月已经把之前 3 年的收益都跌完了。好在我买的不是很多。现在也不怎么看了。

+

可能是因为理财亏得太多了,我开始到各种平台薅羊毛,然后又开始把梦幻西游捡起来玩了。家产全部变卖以后转到了朋友所在的区,每天就当作一个打发时间的消遣,分散一下亏钱的注意力。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2023/1-month-of-backend-dev/index.html b/2023/1-month-of-backend-dev/index.html new file mode 100644 index 000000000..c93e529bb --- /dev/null +++ b/2023/1-month-of-backend-dev/index.html @@ -0,0 +1,477 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +服务端开发一月记 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 服务端开发一月记 +

+ + +
+ + + + +

5 月份休完陪产假,再回到公司,发现原本的小组已经整体重组,只有我一人还在工位上了。后来跟 TL 聊了一下,最后反正就是几个选项,要么跟着原来的同事一起去新的部门继续写前端,要么就做点别的事情。

+

当时我还是挺头疼的,主要是那会事情太多了,一是小满还在月子里,二是新房子还在装修,三是那段时间身体状态有一点波动(其实主要可能还是因为这个)。继续搞前端肯定是最稳的,但是我自己其实已经有点厌倦了,属于是看到前端代码就已经有点不耐烦的程度。但是对前端以外的东西又还是很感兴趣,偶尔自己写点非前端的玩意都会感到很愉快。犹豫再三,也是跟朋友家人都聊了一下,最后选择了转成服务端开发。(其实当时还有一个可能是 C++ 客户端的方向,但是因为太过陌生,加上前面说的那些现实情况,实在是有点绷不住)

+

其实我在刚毕业那会做过一段时间(半年左右?)的 Java 开发,但是那段时间基本上来说还是处于比较懵的状态,也没学到什么东西,加上后来很快就转型(基本上)全职前端,Java 服务端就荒废了。现在也算是一个从头来过。

+

到现在 7 月份,大概算下来时间过去一个多月了,也简单说下转型后的感想。

+ + +

技术栈

小组主要使用的语言大概是 Go 和 Python。Web 服务来说是 Go 居多,但是由于是在一个跟 AI 业务相关的小组,所以 Python 服务也不少。这一个多月下来 Go 的工作量大概占八成,python 二成的样子。

+

至于别的目前暂时就还没有接触太多,也只是粗略了解了一下业务项目的整个开发发布流程以及链条上的各种工具,还没有来得及深入学习。

+

学习方式

我大概在两年前的时候找同事借过一门视频课,学过一次 go 语言(算是入门)。但是到了要用的时候,年久失修,感觉大部分都已经还给老师了。周围同事朋友问了一圈,有个同事推荐了一个网站的课程,看了下各种知识的覆盖面还算可以,也没想太多,就花了四位数充了个年会(这大概是我毕业后对自己最大的一笔投资)。

+

充值后我就马不停蹄地开始学 Tony Bai 老师的《Go 语言第一课》,说实话这门课确实写得很好,绝对的物超所值。学习的过程中新的需求也很快就安排到了我这里来,我基本是在边学边做的情况下学完了这门课。Go 语言这门课一个星期差不多就肝完了。学完了 Go 语言后,我就打算根据工作需要,按部就班地把所有基本知识都过一遍,包括数据库、消息队列、缓存,等等。由于工作并不空闲,下班后也经常要带娃或者游戏肝日常,每天如果能学个一两章、两三章,我觉得也是不错的。

+

当然我毕竟因为花了四位数的钱,我必须对得起这笔开销。于是在学完这门课程后,就用刚学的 Go 语言写了一个命令行工具,它可以帮我把一门课程完整地下载到本地(包括文字里的图片!)并保存为 Markdown 格式,这样我就可以不用受年费会员的时间限制,提前下载好所有感兴趣的课程,无限期地学习了。项目地址在这里(当然,这不是一个盗版工具,要使用它的前提是使用者有年会帐号)。

+

开发体验

以下内容为练习时间一个半月的菜鸟程序员的一家之言。先说结论:Go 语言的开发体验明显优于 JavaScript,但服务端开发的心智模型远比前端复杂,并不是一句 CRUD 可以概括的。

+

为什么说体验明显优于 JavaScript 呢,首先毫无疑问 JS 是一门历史包袱很重的语言,即使发展到今天已经做了很多优化,但依然属于是带着脚镣跳舞,没有办法随心所欲。但 Go 不一样,它比较新。另外 Go 语言的哲学是“简单”,这种哲学内涵也体现在它语言设计的方方面面。举几个例子:

+
    +
  1. 首先 Go 是强类型,你可能会说 TypeScript 也是强类型,但 TS 的类型体操太复杂了,Go 语言的强类型跟它相比属于是非常简单的那种,首先这一点就能够给开发体验带来质的飞跃
  2. +
  3. 包含了很多 JavaScript 的优点,比如闭包、第一公民函数、值类型&引用类型
  4. +
  5. 也包含了一些 TypeScript 的优点,比如类型自动推断,类型后置
  6. +
  7. 文件夹即包,同包内不需要写 import,跨包不需要写 export,大写属性即默认 export
  8. +
  9. 没有 class,没有继承,只有 ducktype
  10. +
  11. ……
  12. +
+

至于为什么说心智模型复杂呢,主要还是在多线程处理这件事情上。

+

前端这个领域(包括 Node.js 等一系列 JS runtime),再复杂的模块也是单个线程,代码都是线性执行的,不存在资源抢占。浏览器就不用说了,即使是 Node.js 中也没有“锁”的概念(主要是指线程锁)。Node.js 最多就是开个 Cluster 模式,多启动几个线程占满 CPU,但是每个线程之间都是独立的,完全没有交集。所以写 JS 代码的时候完全不用考虑资源抢占,一路莽到底。

+

(不过话说回来我也是现在才意识到 Node.js 到底有多牛:这家伙居然能用一条线程实现那么高的吞吐量!)

+

但是 Go 不一样:包括 Go 在内的众多服务端技术,它们有多线程(在 Go 中则是线程更轻量级的“协程 goroutine”)。任何一个变量,哪怕是一个 int,一个 bool,只要涉及到多线程读写,就会有问题。要么传统方案“加锁”,要么使用 Go 独有的 channel 方式。总之就是绕不开这个话题。

+

另外说到 channel,相比“锁”,这确实是一种很有意思的设计,也给 Go 语言增加了一些趣味。将本来复杂且易错的“锁”替换成了另一种更为直观的心智模型。这也算是一种强大的体验优化。

+

至于 Python 的体验,感觉跟写 Node.js 大差不差,以及大家永远都在吐槽的性能问题,这个就没什么好说的。

+

我的工作

由于我所在的小组不是重业务组,属于是 AI 研究团队,工作更多是各类配合 AI 同学的基础设施建设和维护,当然也会有一部分 AI 相关的业务。所以到现在为止我甚至还没写过正经的 CRUD。

+

目前接手过的工作,比如说从零开始开发某个微服务项目(Go 或 Python)并部署上线,配置网关和 K8S,开发一些实用脚本,给某个 Web 服务添加一些功能,维护某个内部使用的数据工具,以及开发它的 Go 语言版 sdk等。比较杂。有时候新的工作来了,当我没有理解它要怎么做的时候也会比较焦虑(毕竟转行)。但是在这些过程中,我还是比较快乐的,包括编码的过程都很快乐。我有很强的动力去重构我写的或者前人写的代码,可能会在不断的删代码写代码、删代码写代码循环中度过好几天,并在这过程中从各个方面反复地体验一门新语言的设计艺术,试图寻找一件事情最最优雅的解决方案。这跟两个月前写前端的时候完全不一样。感觉我又回到了大三大四那段学习前端技术的时间的充满热情、废寝忘食的状态。

+

总的来说,我还是喜欢编码这件事情的。在一个方向上呆久了可能会有点腻,但对于编码本身的兴趣目前来说依然没有任何变化。很高兴我有勇气做出了这个转行的决定,希望两年后可以成长为一个不那么菜的后端工程师,顺便多赚点钱。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2023/a-static-file-docker-image-issue/index.html b/2023/a-static-file-docker-image-issue/index.html new file mode 100644 index 000000000..b36c331a2 --- /dev/null +++ b/2023/a-static-file-docker-image-issue/index.html @@ -0,0 +1,451 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +静态文件 Docker 镜像问题一则 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 静态文件 Docker 镜像问题一则 +

+ + +
+ + + + +

今天想要打包一个 Docker 镜像,里面只包含一些静态的前端文件。为了使体积足够小,想到的方案是把命令全部集中在一个 RUN 上,类似这样:

+
FROM node

WORKDIR /usr/src/app
COPY . .

RUN yarn --frozen-lockfile --check-files --ignore-engines && \
yarn build && \
rm -rf node_modules
+ +

但是打包出来的镜像,死活都是 2.2G,node 镜像自身 900MB,静态文件总共才 10MB+,run container 进去查看 node_modules 也确实删掉了,百思不得其解。一度以为是 Docker 出了 bug,遂升级 Docker,但仍不能解决。

+

折腾了一下午后,尝试去掉 rm -rf node_modules,观察到打出来的镜像 2.8G,突然觉得是不是还有什么东西没删干净,然后很快就想到了 yarn 的缓存。添加 yarn cache clean 后,打出来的镜像来到 910MB。世界终于清净了。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2023/bv2mp3/1d085d07b0214fd6ad2c0ef871af2ae8.png b/2023/bv2mp3/1d085d07b0214fd6ad2c0ef871af2ae8.png new file mode 100644 index 000000000..84a4f300d Binary files /dev/null and b/2023/bv2mp3/1d085d07b0214fd6ad2c0ef871af2ae8.png differ diff --git a/2023/bv2mp3/2ed49043d3f046f28740ad1e415b810f.png b/2023/bv2mp3/2ed49043d3f046f28740ad1e415b810f.png new file mode 100644 index 000000000..37b84908d Binary files /dev/null and b/2023/bv2mp3/2ed49043d3f046f28740ad1e415b810f.png differ diff --git a/2023/bv2mp3/4e382b83db7a4dd085e2d2087a774cec.jpg b/2023/bv2mp3/4e382b83db7a4dd085e2d2087a774cec.jpg new file mode 100644 index 000000000..9faf5d69a Binary files /dev/null and b/2023/bv2mp3/4e382b83db7a4dd085e2d2087a774cec.jpg differ diff --git a/2023/bv2mp3/84d3afb467a54e999e35e9acc4fa2206.png b/2023/bv2mp3/84d3afb467a54e999e35e9acc4fa2206.png new file mode 100644 index 000000000..410c6ca29 Binary files /dev/null and b/2023/bv2mp3/84d3afb467a54e999e35e9acc4fa2206.png differ diff --git a/2023/bv2mp3/87679900c2fe4fce89f7d5bc113a0381.png b/2023/bv2mp3/87679900c2fe4fce89f7d5bc113a0381.png new file mode 100644 index 000000000..ced663442 Binary files /dev/null and b/2023/bv2mp3/87679900c2fe4fce89f7d5bc113a0381.png differ diff --git a/2023/bv2mp3/bd22e7fc1754481198b53856c0d119f8.png b/2023/bv2mp3/bd22e7fc1754481198b53856c0d119f8.png new file mode 100644 index 000000000..58aa3662e Binary files /dev/null and b/2023/bv2mp3/bd22e7fc1754481198b53856c0d119f8.png differ diff --git a/2023/bv2mp3/bv2mp3.png b/2023/bv2mp3/bv2mp3.png new file mode 100644 index 000000000..bdca788b7 Binary files /dev/null and b/2023/bv2mp3/bv2mp3.png differ diff --git a/2023/bv2mp3/d5ac4ae89b5c4827be79b324647af60c.jpg b/2023/bv2mp3/d5ac4ae89b5c4827be79b324647af60c.jpg new file mode 100644 index 000000000..0294af7ef Binary files /dev/null and b/2023/bv2mp3/d5ac4ae89b5c4827be79b324647af60c.jpg differ diff --git a/2023/bv2mp3/index.html b/2023/bv2mp3/index.html new file mode 100644 index 000000000..7c3f9bedc --- /dev/null +++ b/2023/bv2mp3/index.html @@ -0,0 +1,584 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +做了一个 b 站视频下载与 mp3 转换工具 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 做了一个 b 站视频下载与 mp3 转换工具 +

+ + +
+ + + + +

b 站上的歌姬,很多歌只发布在 b 站。比如说直播时唱的歌,或者一些发布到正经音乐平台上会有版权问题的歌。然而,对于爱听歌的人来说,b 站的听歌体验实在是太差了,这里就不展开细说。

+

我习惯用网易云听歌。网易云虽然版权方面很惨,但有它一个很好用的功能:云盘。每个用户有 60G 的云盘容量,基本用不完,不管是什么歌,有没有版权,只要上传上去了就能随时随地听。因此,我的目标是,希望可以有一个自动化的工具,帮我把 b 站上的歌以 mp3 的格式下载下来,让我可以上传到云盘,这样我就可以用网易云听歌了。

+

综上所述,我就做了这么一个小工具:bv2mp3 ,这是一个开源工具,完整的代码可以在代码仓库中找到。下面,我主要讲一下这个工具的实现思路以及优化过程。

+

126f0ee04db59831d6a9820ac89c471.jpg

+ + +

在这之前

起初,我还是不愿意自己造轮子的,毕竟我觉得这应该是个非常 common 的需求,应该会有不少东西可以拿来直接用。

+

最开始的时候,我尝试使用了网络上的一些随处可见的在线服务,搜索“b 站视频下载”可以找到很多相关的在线网站。此类服务通常来说是能用的,但是当需要下载的量一旦大起来以后,它就显得非常麻烦了。比如说它需要看广告,或者下载的质量参差不齐,并且需要下载后手动再转一次MP3,非常麻烦。

+

在受够了此类工具以后,我开始转向一些付费工具。我找到了一款比较强大的“哔哩哔哩助手”浏览器插件。只要付费,它就能提供将视频直接下载为 mp3 的功能。并且可以直接在 b 站页面上操作,相对来讲比较好用。但是用了几个月下来以后,仍然觉得还不够好:

+
    +
  1. 首先,当然是因为它要付费;
  2. +
  3. 其次,我要下载的一般都是整个播放列表,而这款插件只能一次下载一个分集,这意味着我要将分集一个一个地打开,再一个一个地打开助手菜单,找到并点击MP3下载按钮;
  4. +
  5. 有时候,我甚至需要一次性下载多个播放列表,这种体验的痛苦就成倍增加了。
  6. +
+

总的来说,它虽然能用,但是用户体验依旧很原始。

+

我想要的是:可以一次性下载整个列表里面的所有视频,甚至一次性下载多个列表,然后将他们批量转为 mp3 的工具。

+

找了一圈 GitHub,虽然有类似的工具,但都不能完美契合我的需求。因此我决定:这次还是自己来吧!

+

程序主框架

我希望我做的工具可以尽可能地简单(无论是从使用还是开发层面):

+
    +
  1. 它是高度定制化的,可以只为我服务(当然如果可以帮助到其它人就更好了);
  2. +
  3. 它不需要界面,因为写界面是很麻烦的事情,只需要一个命令行就好了;
  4. +
  5. 它可以一键帮我完成上述所有事情:
      +
    1. 下载一个或多个列表里面的所有视频
    2. +
    3. 将视频转为mp3
    4. +
    5. 拥有批量化自动命名、自动失败重试等其它基本功能
    6. +
    7. 上传到网易云盘这一步,由于没有找到网易云的可用接口,因此这一步仍需手动
    8. +
    +
  6. +
+

从“尽可能地简单”为出发点,总体技术栈自然是选择我最熟悉的 Node.js,并且是 v16+,以此直接开启 type=module,舍弃 cjs 的裹脚布。

+

然后,首选 tj 的 commander.js 来实现命令行程序。

+

那么,问题来了,将一个 b 站视频下载下来并且转为mp3,要分几步?

+

爬取网页

https://www.bilibili.com/video/BV1wV411t7XQ 为例。这个列表里面共有 336 首歌,我需要把它们全部下载下来。

+

点击列表中的视频,仔细观察可以发现,它们的 url 是有类似的模式的,比如:

+ +

可以发现它们前面的格式都是一样的,区别只在后面的 ?p=x

+

那么我需要做的事就是:

+
    +
  1. 程序接收一个链接
  2. +
  3. 找到链接里面共有多少集
  4. +
  5. 然后分别组装每一集的 url 并下载
  6. +
+

从国际惯例来讲,为了实现步骤2,我需要去爬取这个网页,解析里面的 HTML,找到跟集数有关的节点。但是 b 站是个特例,它有更方便的办法。它的 HTML 网页上挂载了一个 __INITIAL_STATE__ 对象,下面就有准确的信息。

+
+ +

因此,这一步变得非常简单,只需要解析这个对象即可。

+
export async function getDataByUrl(url) {
const { data } = await agent.get(url);
// console.log(data)
const initialStateStr = data.match(/__INITIAL_STATE__=(.*?);/)[1];
return JSON.parse(initialStateStr);
}

const data = await getDataByUrl(`https://www.bilibili.com/video/BV1wV411t7XQ`);
console.log(data.videoData.pages);
+ +

这样一来就拿到了这个列表的分集信息,接下来要解决下载问题。

+

下载视频文件

之前的网页上没有找到视频下载地址的信息,因此这部分需要单独的接口。通过在 GitHub 上寻找类似项目得到了一个可用方案:

+
const params = `cid=${cid}&module=movie&player=1&quality=112&ts=1`;
const params =`appkey=iVGUTjsxvpLeuDCf&cid=${cid}&otype=json&qn=112&quality=112&type=`;
const sign = crypto.createHash("md5").update(params + "[apikey]").digest("hex");
const playUrl = `https://interface.bilibili.com/v2/playurl?${params}&sign=${sign}`;
+ +

其中这个 cid 在之前的 pages 变量中是存在的,然后需要将 [apikey] 替换为实际的 apikey(大家都把它放在仓库中了,我也不例外,取之 GitHub 用之 GitHub,反正不是我泄密的就不算泄密),并且用 md5 算法做签名。至于其它变量,不太明白它们的实际意义。

+

这个工具主攻 mp3 转换,因此也不需要关心视频质量、水印等问题,突出一个能用就行。

+

调用接口,可以得到一个 flv 的下载链接:

+

+

然后就是简单粗暴的下载环节:

+
agent({
url,
method: 'GET',
responseType: 'stream',
headers: {
// 表示从第 0 个字节开始下载,直到最后一个字节,即完整的文件
Range: `bytes=${0}-`,
'User-Agent': 'PostmanRuntime/7.28.4',
// 实际调用时发现该接口还需要加上 referer 才能正常调用
Referer: 'https://www.bilibili.com/',
},
})
.then(({ data, headers }) => {
const writeStream = fs.createWriteStream(filename);
const total = parseInt(headers['content-length'], 10);
// 下载到的数据写入文件流
data.pipe(writeStream);
data.on('data', (chunk) => {
// todo 下载进度
});
data.on('end', () => {
// todo 下载结束
});
data.on('error', (err) => {
// todo 下载出错
});
})
.catch((err) => {
// todo 接口请求出错
});
+ +

转换mp3

在最初的版本,我能直接想到的工具就是 ffmpeg ,代码里面简单粗暴地直接调用 ffmpeg:

+
import { exec } from 'child_process';

export async function flv2mp3 (filename) {
return new Promise((resolve, reject) => {
const mp3 = filename.replace('.flv', '.mp3');
exec(`ffmpeg -i ${filename} -q:a 0 ${mp3}`, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
+ +

这么做,能用。但是用起来不太舒心。因为我毕竟要先下载一个 C 语言版本的 ffmpeg,并且把它设置到系统 Path 中,这样程序才能正常调用到它。这个对我来说是个一次性的工作,倒也没啥,但是对于想要使用这个工具的其它用户来说就很可能劝退了。但是不管怎么说,主流程到现在已经结束了。我已经完成了下载整个视频列表并且转换成 mp3 的程序,它能正常工作了。

+

但是,它现在还非常粗糙,需要在细节上做进一步的打磨。

+

细节优化

并行下载

我最早想到的并行方案,是用 lodash.chunk 将视频分割成一个个的小块(如:10个视频一块),处理完一个块再处理下一个块。

+
const pageChunks = chunk(pages, 10);
for (const c of pageChunks) {
await Promise.all(c.map(download2mp3));
}
+ +

这种方法好处是确实能实现并行下载,并且实现起来非常简单。至于它存在的问题,后面还会提到。

+

展示进度条

进度条对于一个下载工具来说至关重要,尤其是并行下载多个任务的时候。好在我不用自己实现,即使是命令行界面也有优秀的现成工具:progress 以及 multi-progress

+

而我只需要对它做一点简单的封装即可:

+
import Progress from 'multi-progress';

const multi = new Progress(process.stderr);

export function createProgressBar(index, title, total) {
// index 代表本次下载的序号
// [:bar]是进度条本体
// percent 进度百分比
// eta 预估剩余时间
// status 下载或转换进度
// title 下载文件的标题
return multi.newBar(`${index} [:bar] :percent :etas :status ${title}`, {
complete: '=',
incomplete: ' ',
width: 30,
total: total,
// renderThrottle: 1000,
});
}
+ +
agent({
url,
method: 'GET',
responseType: 'stream',
headers: {
Range: `bytes=${0}-`,
'User-Agent': 'PostmanRuntime/7.28.4',
Referer: 'https://www.bilibili.com/',
},
})
.then(({ data, headers }) => {
const writeStream = fs.createWriteStream(filename);
const total = parseInt(headers['content-length'], 10);
// 创建一个进度条,给它总字节长度
const bar = createProgressBar(index, title, total);
data.pipe(writeStream);
data.on('data', (chunk) => {
// 下载进度,本次传输的 chunk 字节长度,进度条将自动计算百分比
bar.tick(chunk.length, { status: 'downloading' });
});
data.on('end', () => {
writeStream.close();
// 将 bar resolve 出去,后面还要用到
resolve(bar);
});
data.on('error', (err) => {
// 出错了,进度条到底吧那就
bar.tick(total);
// 显示下载出错
bar.tick({ status: 'error' });
writeStream.close();
reject(err);
});
})
.catch((err) => {
reject(err);
});
+ +

失败重试

b 站的视频下载地址有一定的失败概率,因此做了一个简单粗暴的失败重试逻辑:

+
export async function download2mp3({ url, index }) {
let b;
try {
// 下载文件
const { filename, bar } = await download(url, offsetIndex);
b = bar;
// 开始转换了,设置一下进度条状态
bar.tick({ status: 'converting' });
// 转换 mp3
await flv2mp3(filename);
// 转换结束了,可以删掉下载的视频文件
await fs.promises.unlink(filename);
// 设置进度条状态为 done
bar.tick({ status: 'done' });
} catch (err) {
// 失败了
b?.tick({ status: 'error' });
// 等待 2 秒后,重新开始下载+转换
await sleep(2000);
await download2mp3({ url, index });
}
}
+ +

这个方法永远不会抛错,只要出错了就一直重试下去。对于我这种脚本程序来说,非常好用。

+

自定义命名

我希望下载下来的文件可以按照我想要的规则去命名,这样不管是从哪里下载的文件,最后都不会显得杂乱无章。

+

这个功能的实现部分参考了“哔哩哔哩助手”这个浏览器插件的做法,使用了 pattern 命名法:

+
export function getName(index, title, author, date) {
const argv = program.opts();
return (
argv.naming
.replace('INDEX', index)
.replace('TITLE', title)
.replace('AUTHOR', author)
.replace('DATE', date)
);
}
+ +

这个 naming 参数的默认值是 TITLE-AUTHOR-DATE,也就是 视频标题-视频作者-视频上传日期。它会将这个命名模式套用到具体的文件上。这个东西的用法是很灵活的。比如说,我希望我下载的文件要有序号,另外视频的上传者并不是演唱者,我希望显示演唱者。那么我常用的命名模式是 INDEX-TITLE-yousa-DATE。注意这里第三个坑位变成了 yousa,它并不在支持的 pattern 中,代表的含义就是它将固定为 yousa,不会再被替换了。

+

ffmpeg 优化

做开源软件的好处,除了得到用户的肯定外,我永远可以从别人那里学到新的东西,比如:

+

+

原来 ffmpeg 已经有了 wasm 版本:ffmpeg.wasm 。将 ffmpeg 替换为 ffmpeg.wasm 后,使用时使就不再需要预先安装 C 语言版本的 ffmpeg,也无需设置 path,用户体验可以得到大幅度的提升。

+

修改后的转换代码(大概长这样):

+
import { fetchFile, createFFmpeg } from '@ffmpeg/ffmpeg';
import * as fs from 'fs';
import { resolve } from 'path';

export async function flv2mp3 (filename) {
const after = filename.replace('.flv', '.mp3');
const ffmpeg = createFFmpeg({ log: false });
await ffmpeg.load();
ffmpeg.FS('writeFile', 'before.flv', await fetchFile(resolve(process.cwd(), filename)));
// ffmpeg -y -i ${filename} -q:a 0 ${mp3}
await ffmpeg.run('-y', '-i', 'before.flv', '-q:a', '0', 'after.mp3');
await fs.promises.writeFile(resolve(process.cwd(), after), ffmpeg.FS('readFile', 'after.mp3'));
}
+ +

看起来很美好。但实际运行起来后,发现一个令人哭笑不得的问题:

+
Rejection (Error): ffmpeg.wasm can only run one command at a time
+ +

它一次居然只能跑一个命令!也就是说,它跟我的多线程下载并不能很好地兼容。当多个文件同时下载完成后,如果即刻开始多个转换,程序就报错了。因此我需要做一个流程控制:当转换正在进行的时候,其它下载完的视频文件需要先进入排队状态:mp3 转换的过程得一个一个来。

+

根据上述思想修改一下 flv2mp3,用一个全局变量来控制同一时间只有一个任务能进来转换:

+
// 将 ffmpeg 实例提到外面来,全局共用
let ffmpeg = createFFmpeg({ log: false });
ffmpeg.load();
let isRunning = false;

export async function flv2mp3(filename, bar) {
while (!ffmpeg.isLoaded() || isRunning) {
// 有其它任务正在进行中,那么就排队等待吧...
bar.tick({ status: 'queueing' });
await sleep(1000);
}
bar.tick({ status: 'converting' });
isRunning = true;
// 这里是具体的转换任务...
isRunning = false;
}
+ +

虽然缺点有点夸张,但思考再三,我觉得跟 C 语言 ffmpeg 的使用体验比起来,这种方式还是更优一点。

+

并行优化

前面有提到我一开始设计的并行方案:

+
+

用 lodash.chunk 将视频分割成一个个的小块(如:10个视频一块),处理完一个块再处理下一个块

+
+

这种办法好处是实现简单,但缺点也很明显:b 站似乎对每个下载地址做了限速,因此有时候一个块里面一两个文件特别大,其它文件都下载完了,它还在慢悠悠的下载,让人感到浪费生命。实际上,它可以立即开始余下的其它任务的,只要保证正在运行的总的任务数量不超过设定的数值即可。

+

也许你会问:既然这么麻烦,为什么不全部同时开始下载呢?这个方式实际上我也试过,但是一旦同时下载的任务太多了(比如上面的一个链接,有 300 多个视频,更何况我们还能支持一次输入多个链接),下载的出错率会陡增。这样反而会导致效率急剧下降。因此是不可取的。

+

为了解决这个问题,我将并行控制的代码又优化了一下,摒弃了 chunk 的做法:

+
// 最大的进程数,默认为 10
let maxThreads = argv.threads;
// 当前进行中的进程数
let currentThreads = 0;
// 已完成的任务数
let finished = 0;

for (const page of pages) {
while (currentThreads === maxThreads) {
// 运行中的线程数量已达最大值,先排会队吧
await sleep(100);
}
// 开始新的线程
currentThreads += 1;
// 注意这里不用 await 了
download2mp3(page).finally(() => {
// 一个线程结束了
currentThreads -= 1;
finished += 1;
if (finished === pages.length) {
// 所有的任务都已完成,可以退出进程了
process.exit(0);
}
});
}
+ +

这样一来,任务并行的效率得到了极大的提升:同时进行中的任务数量会始终保持在最接近允许的最大数量的水平。充分利用上电脑的带宽。

+

ffmpeg.wasm 优化

上面我们解决了 C 语言 ffmpeg 带来的不适,但是同时引入了新的问题:同时只能运行一个转换任务,其它的下载完的视频要排队。这个问题在并发问题得到优化后被极大地放大了:经常是所有文件都已下载完,却仍有一大批文件在等待转换。这让人感到非常痛苦:我写的程序不应该这么蠢的。

+

因此,我又开始考虑这个问题的解决方案了:既然一个 ffmpeg.wasm 进程只能跑同时跑一个任务,那我为每个下载任务都单独开一个进程行不行?

+

Node.js 提供了一个 child_process.exec 函数,可以用来运行一个命令行任务。在最初的版本中,调用 C 语言的 ffmpeg 的任务也是通过这个完成的。现在,我能否利用它来调用 ffmpeg.wasm 呢?

+

先将 flv2mp3 的函数,抽离为一个独立的文件,在这个文件中直接运行转换:

+
(async () => {
// 要转换的文件名
let filename = process.argv[2];
let ffmpeg = createFFmpeg({ log: false });
await ffmpeg.load();
try {
// 转换...
process.exit(0);
} catch (err) {
// 出错了
process.exit(1);
}
})();
+ +

然后改造原来的 flv2mp3 文件:

+
export function flv2mp3(filename) {
return new Promise((resolve, reject) => {
exec(
`node ${join(__dirname, '_flv2mp3.js')} "${filename}"`,
{ cwd: process.cwd() },
(error) => {
if (error) {
reject(error);
} else {
resolve();
}
}
);
});
}
+ +

改造完后实测,这确实将该问题解决了。现在多个文件同时下载完后,也可以同时开始转换了!虽然同时开启多个转换进程后 CPU 的利用率会飙涨、风扇狂飙,但相比节约的时间来说,这都不是问题。

+

写在最后

这个工具现在看起来很简单,并没有什么过人之处。但是我在实现它的时候还是花了非常多的时间去调试。比如说,在加上了进度条后,整个 stdout 都会被进度条占据,console API 难以再打印出错误信息。因此我只能给它设计了一套基于文件的日志系统用来 debug。又比如说,项目早期在下载文件的时候,程序经常莫名奇妙地、没有任何征兆地就退出了,我需要非常细心地去寻找每一个可能抛错的点,尽可能优雅地捕获所有错误。

+

至今为止我已经用它下载了一千多首 yousa 的歌。这种自己开发工具来解决自己的问题,并且一步步地将它变得完美以及节约生命的愉快感真的是非常棒的。

+
126f0ee04db59831d6a9820ac89c471.jpg
46ff0c8d1022eaf087e0e42b7cd0319.jpg
+ +

最后更开心的当然是,我做的工具同时也能给其它素不相识的人带来愉悦:

+

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2023/go-pprof-note/011d3b578be449a796e550d80e9e364f.png b/2023/go-pprof-note/011d3b578be449a796e550d80e9e364f.png new file mode 100644 index 000000000..8d6d0c5f3 Binary files /dev/null and b/2023/go-pprof-note/011d3b578be449a796e550d80e9e364f.png differ diff --git a/2023/go-pprof-note/149dcc3708494697bdcd2f4822adb34d.png b/2023/go-pprof-note/149dcc3708494697bdcd2f4822adb34d.png new file mode 100644 index 000000000..065124e36 Binary files /dev/null and b/2023/go-pprof-note/149dcc3708494697bdcd2f4822adb34d.png differ diff --git a/2023/go-pprof-note/ab39c95412bb488f8878c13a72a2ae84.png b/2023/go-pprof-note/ab39c95412bb488f8878c13a72a2ae84.png new file mode 100644 index 000000000..da2eea0f8 Binary files /dev/null and b/2023/go-pprof-note/ab39c95412bb488f8878c13a72a2ae84.png differ diff --git a/2023/go-pprof-note/b3732fa081ba444c815af0f4d7992ce8.png b/2023/go-pprof-note/b3732fa081ba444c815af0f4d7992ce8.png new file mode 100644 index 000000000..a012d96ba Binary files /dev/null and b/2023/go-pprof-note/b3732fa081ba444c815af0f4d7992ce8.png differ diff --git a/2023/go-pprof-note/b428af1b09094765a67f58bfeb42e24d.png b/2023/go-pprof-note/b428af1b09094765a67f58bfeb42e24d.png new file mode 100644 index 000000000..867b2c8fb Binary files /dev/null and b/2023/go-pprof-note/b428af1b09094765a67f58bfeb42e24d.png differ diff --git a/2023/go-pprof-note/ed645cd64588431b8e6c11e29f7c6e46.png b/2023/go-pprof-note/ed645cd64588431b8e6c11e29f7c6e46.png new file mode 100644 index 000000000..a1e917779 Binary files /dev/null and b/2023/go-pprof-note/ed645cd64588431b8e6c11e29f7c6e46.png differ diff --git a/2023/go-pprof-note/f03c5ccb2076423c9ae6ba75aa6f4747.png b/2023/go-pprof-note/f03c5ccb2076423c9ae6ba75aa6f4747.png new file mode 100644 index 000000000..ea6d9a703 Binary files /dev/null and b/2023/go-pprof-note/f03c5ccb2076423c9ae6ba75aa6f4747.png differ diff --git a/2023/go-pprof-note/index.html b/2023/go-pprof-note/index.html new file mode 100644 index 000000000..e423e9bed --- /dev/null +++ b/2023/go-pprof-note/index.html @@ -0,0 +1,595 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Go 语言性能调试与分析工具:pprof 用法简介 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ Go 语言性能调试与分析工具:pprof 用法简介 +

+ + +
+ + + + +

pprof 是 Google 开发的一款用于数据分析和可视化的工具。

+

最近我编写的 go 程序遇到了一次线上 OOM,于是趁机学习了一下 Go 程序的性能问题排查相关知识。其基本路线是:先通过内置的 net/http/pprof 模块生成采集数据,然后在使用 pprof 命令行读取并分析。Go 语言目前已经内置了该工具。

+

本文不会介绍 pprof 的太多细节,只关注主要流程(主要的是太细的我现在也不会)。

+ + +

数据采集

由于 Go 语言内置了对 pprof 的支持,因此无需额外安装其它依赖,只需要在程序入口处引入相关包,并且启动一个服务即可:

+
package main

import (
"net/http"
_ "net/http/pprof"
// ...
)

func main() {
go func() {
// pprof 服务器,将暴露在 6060 端口
if err := http.ListenAndServe(":6060", nil); err != nil {
panic(err)
}
}()

// ...
}
+ +

需要注意的是 pprof 服务需要使用独立的协程运行,否则会阻塞代码运行。添加这段代码后,程序除了运行原本的逻辑外,还将额外监听一个端口(此处为 6060),在本地运行程序,打开 http://localhost:6060/debug/pprof/,将看到如下界面:

+

+

数据采集服务即启动成功。

+

我刚开始看到这段代码的时候有点好奇:为什么它的 handler 传了 nil,但是却能够启动一个 debug 服务呢?

+

后来仔细看了一下源码后就发现,handler 如果传 nil 的话,即默认为 DefaultServeMux

+
// net/http/server.go
type Server struct {
// ...
Handler Handler // handler to invoke, http.DefaultServeMux if nil
// ...
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
// ...
}

// HandleFunc registers the handler function for the given pattern
// in the DefaultServeMux.
// The documentation for ServeMux explains how patterns are matched.
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, handler)
}
+ +

net/http/pprof/pprof.go 在 init 函数中,向 DefaultServeMux 注册了几个路径:

+
// net/http/pprof/pprof.go

func init() {
http.HandleFunc("/debug/pprof/", Index)
http.HandleFunc("/debug/pprof/cmdline", Cmdline)
http.HandleFunc("/debug/pprof/profile", Profile)
http.HandleFunc("/debug/pprof/symbol", Symbol)
http.HandleFunc("/debug/pprof/trace", Trace)
}
+ +

因此,如果启动一个 handler 为 nil 的 http 服务,即默认会添加上 pprof 的一系列路由。

+
+ + +

这个 web 界面上面有一些链接,每个链接代表一个监控项目,页面下方也对它们做了一些解释。直接点进去的话,会展示一些数据,但是目前这些数据比较原始,可读性不佳。但好消息是,后面可以通过 pprof 命令行对它们进行进一步的分析。

+

指标解释

除了 cmdlinetrace 以外,上面的每一个链接都代表一种指标。常用指标如下:

+
    +
  • profile:CPU 占用率
  • +
  • heap:当前时刻的内存使用情况
  • +
  • allocs:所有时刻的内存使用情况,包括正在使用的及已经回收的
  • +
  • goroutine:目前的 goroutine 数量及运行情况
  • +
  • mutex:锁争用情况
  • +
  • block:协程阻塞情况
  • +
+

pprof 命令行工具

程序运行一段时间后,我们就可以通过 pprof 命令行来进行数据分析了。打开一个终端环境,输入 go tool pprof http://localhost:6060/debug/pprof/allocs 并按下回车,就能看到如下界面:

+

+

此时即连接成功。接下来在终端中操作,将展示命令行启动时刻的监控数据。输入 help 可以展示所有的命令,输入 help [command] 可以展示具体命令的帮助界面。

+

现在回到上面那个链接 http://localhost:6060/debug/pprof/allocs,观察一下它的构成:

+
    +
  1. 首先,是一个常规的 host+port 组合,由于我们在本地启动服务且指定监听 6060 端口,因此这里填写 localhost:6060。但是它同样也支持远程连接。即对部署在服务器上的程序进行分析。只要遵循同样的格式,以及保证路径可访问即可;
  2. +
  3. 然后跟随的是一个 /debug/pprof/ 路径,此为固定值;
  4. +
  5. 最后是一个 allocs,这个代表某一种监控指标。回到刚刚的那个 web 界面,除了 cmdline 以外,每一个链接都代表一种指标,可以在此处直接填入,即可更换分析目标。
  6. +
+

命令行实际上读取的是一个由 pprof web 服务提供的 .pb.gz 文件,它是一个通过 gzip 压缩的 protocol buffer 数据。其源码在 runtime/pprof 包中。

+
type profileBuilder struct {
// ...
zw *gzip.Writer
pb protobuf
// ...
}

func newProfileBuilder(w io.Writer) *profileBuilder {
zw, _ := gzip.NewWriterLevel(w, gzip.BestSpeed)
b := &profileBuilder{
// ...
}
b.readMapping()
return b
}
+ +

如果要退出 pprof,可以输入 exit 并回车。

+

下面介绍几个常用命令。

+

top:列出数据

要列出当前资源的占用情况,可以在 pprof 中使用 top 命令:

+

+

默认会按照资源占用率从高到低,显示 10 条数据。

+

它上面的每项指标(flat/flat%/sum/cum/cum%)大致理解就是数值越大则资源占用情况越严重。结果默认按照 flat 排序。其指标含义的详细解释可以参考 pprof 文档

+
+

flat: the value of the location itself.
cum: the value of the location plus all its descendants.

+
+

cum 是 cumulative(累积) 的缩写。

+
+ +

我的理解是:

+
    +
  • flat:函数内所有直接语句的时间或内存消耗;
  • +
  • cum:函数内所有直接语句,以及其调用的子函数的时间或内存消耗;
  • +
  • sum:没有在文档中找到对应解释,但是通过观察可以发现,它是 flat% 的累加值。
  • +
+

通过一个例子来解释:

+
func foo(){
a() // step1,假设消耗 1s
b() // step2,假设消耗 2s
time.Sleep(3 * time.Second) // step3,消耗 3s
c() // step4,假设消耗 4s
}
+ +

这个函数总共将花费 1 + 2 + 3 + 4 = 10 秒,其中:

+
    +
  • flat 等于 3,因为该函数的直接操作只有 step3
  • +
  • cum 包含所有直接语句以及子函数的消耗,即 step1 + step2 + step3 + step4
  • +
  • step 4 的 sum% 为 step1、step2、step3、step4 的 flat% 总和
  • +
+
+ +

list:显示详情

当发现某个函数资源占用情况可疑时,可以通过 list 函数名 定位到具体的代码位置。举例:

+

+

该案例显示,在第 666 行处,dw 占用了 8.81MB 的内存,667 行占用 5.95MB 内存,该函数合计占用 14.76 MB。

+

此处也可以对应到上面提及的 flat/cum 含义:666 行计入 flat,666 + 667 行计入 cum。

+
+ +

web:可视化分析

在使用 web 命令之前需要做一个准备工作:安装 Graphviz 工具,并将它添加到系统 path 中。

+

直接在命令行输入 web,pprof 将打开一个浏览器,并展示一个可视化的分析界面:

+

+

在 web 界面上将显示函数的完整调用链路,界面可以通过鼠标拖拽、缩放。其图形详细的解释(来自 pprof 文档):

+
    +
  • 节点颜色与 cum 值有关:
      +
    • 正值大的为红色
    • +
    • 负值大的为绿色(负值通常在 profile 对比时出现)
    • +
    • 值接近 0 的为灰色
    • +
    +
  • +
  • 节点的字号与 flat 的绝对值有关:值越大则字号越大
  • +
  • 边的粗细与该路径下的资源使用有关:资源使用越多则线条越粗
  • +
  • 边的颜色与节点颜色类似
  • +
  • 边的形状:
      +
    • 虚线:两个节点之间的部分节点被移除了(间接调用)
    • +
    • 实现:两个节点之间存在直接调用关系
    • +
    +
  • +
+

粗略地来说,每个节点的方块越大、线条越粗、颜色越红,则代表资源占用情况(相对来说)越严重,需要重点关注。

+
+ +

对应上图的例子来说:

+
    +
  • (*Rand).Read 的 flat 值较小(字号较小、灰色)
  • +
  • (*compressor).deflate 的 flat 值与 cum 值均较大(字号较大、红色)
  • +
  • (*Writer).Flush 的 flat 值较小(字号较小),但 cum 值较大(红色)
  • +
  • (*Writer).Write(*compressor).write 之前的线条是较粗、红色的虚线,因此它们之间的某些节点被移除了,且使用的资源较多
  • +
  • (*Rand).Readread 之前的线条是较细、灰色的虚线,因此它们之间的某些节点被移除了,且使用的资源较少
  • +
  • read(*rngSource).Int63 之前的线条是较细、灰色的实线,因此它们之间存在直接调用关系,且使用的资源较少
  • +
+

sample_index:切换采样值

某些监测类型会拥有多种采样值,可以通过 help sample_index 查看当前可用的采样值:

+
(pprof) help sample_index
Sample value to report (0-based index or name)
Profiles contain multiple values per sample.
Use sample_index=i to select the ith value (starting at 0).
Or use sample_index=name, with name in [alloc_objects alloc_space inuse_objects inuse_space].
+ +

通过 sample_index=i 可以切换采样方式。切换后再次使用 top 命令,展示的结果将会有些区别。

+

pprof 的 Web 界面

可以通过 go tool pprof -http=:8888 http://localhost:6060/debug/pprof/allocs 命令直接打开一个 web 界面,这个 web 界面将拥有与命令行类似的功能,并且可以显示火焰图。同样,这个命令需要先安装 Graphviz 工具。

+

+
    +
  • View 菜单展示的几项功能:
      +
    • Top 与命令行的 top 类似
    • +
    • Graph 与命令行的 web 类似
    • +
    • Peek/Source 与 list 命令类似:在 Top 选中一行,或者 Graph 选中一个节点后,切换到 Peek 或 Source 界面,将展示该行/节点的代码详情
    • +
    • Frame Graph 为火焰图
    • +
    • Disassemble:查看汇编代码
    • +
    +
  • +
  • Sample 菜单与命令行的 sample_index 类似
  • +
+

火焰图

此处以 Frame Graph (new) 举例。

+

+

解释:

+
    +
  • 节点的颜色是由它的包名决定的,相同包名的节点将拥有相同的颜色
  • +
  • 节点的字号可能会有区别,但是与上面的图形不同的是,此处的字号仅为适应其节点大小,并无其它含义
  • +
  • 上方的节点为调用者,下方的节点为被调用者
  • +
  • 节点的宽度表示资源使用情况,越宽则资源使用越多
      +
    • 其总宽度代表 cum
    • +
    • 去除其子节点后,剩余的宽度代表 flat
    • +
    +
  • +
  • 如果上下节点之间没有边框,则表示这两个函数被“内联”了。关于内联的具体含义,可以参考 Go: Inlining Strategy & Limitation
  • +
+

一个函数可能会被多个不同的函数调用,因此 pprof 对传统的火焰图进行了改良:点击任意函数将显示所有最终导向该函数的调用栈,而非仅当前点击节点的调用栈。

+
+
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2023/vue3-reactive/index.html b/2023/vue3-reactive/index.html new file mode 100644 index 000000000..512201068 --- /dev/null +++ b/2023/vue3-reactive/index.html @@ -0,0 +1,697 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +从零开始实现 Vue3 响应式 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 从零开始实现 Vue3 响应式 +

+ + +
+ + + + +

Vue3 与 Vue2 的最大不同点之一是响应式的实现方式。众所周知,Vue2 使用的是 Object.defineProperty,为每个对象设置 getter 与 setter,从而达到监听数据变化的目的。然而这种方式存在诸多限制,如对数组的支持不完善,无法监听到对象上的新增属性等。因此 Vue3 通过 Proxy API 对响应式系统进行了重写,并将这部分代码封装在了 @vue/reactivity 包中。

+

本文将参照 Vue3 的设计,从零开始实现一套响应式系统。注意本文引用的代码与实际的 Vue3 实现方式有所出入,Vue3 需要更多地考虑高效与兼容各种边界情况,但此处以易懂为主。 文中提到的大部分代码可以在 https://github.com/wxsms/learning-vue 找到。

+ + +

什么是响应式

Evan 经常举的一个例子是电子表格(如:Excel)。当我们需要对某一列或行求和,将计算结果设置在某个单元格中,并且在该列(行)的数据发生变化时,求和单元格的数据实现实时更新。这就是响应式。

+

以代码来表达的话:

+
let col = [1, 2, 3, 4, 5]
let s = sum(col)
+ +

我们就可以的得到一个求和值 s

+

不同的是,以上代码是命令式的。也就是说,当 col 发生变化时,s 的值并不会随之改变。我们需要再次调用 s = sum(col) 才能得到新的值。

+

响应式就是要解决这个问题:当 col 发生变化时,我们可以自动地得到基于变化后的 col 计算而来的 s

+

我们的目标:

+
graph LR
+
+A[依赖变更] -->|自动触发| B[响应函数]
+B -->|自动监听| A
+ +

依赖与依赖监听

当要实现一个响应式系统的时候,我们实际需要的是什么?

+

答案是依赖依赖的监听

+

用上面的例子来说,col 是依赖,s=sum(col) 是监听依赖做出的反应。当依赖发生变化时,反应可以自动执行,这件事情就完成了。

+

依赖

那么我们先来实现依赖。 一个依赖:

+
    +
  1. 代表了某个对象下面的某个值;
  2. +
  3. 当值发生变化时,需要触发跟它有关的作用(effect)。
  4. +
+

以下是实现代码:

+
// Dep -> Dependency 依赖
// 比如上面提到的 `col` 是一个 dep,
// `c = a.b + 1` 中,a.b 是一个 dep。
export class Dep {
constructor () {
// 储存与这个依赖有关的“作用”
// 这里使用 Set,可以利用其天然的去重属性,
// 因为作用无需重复添加
this._effects = new Set();
}

// 取消作用对此依赖的追踪
untrack (e) {
this._effects.delete(e);
}

// 作用开始追踪此依赖
track (e) {
this._effects.add(e);
}

// 触发追踪了此依赖的所有作用
trigger () {
for (let e of this._effects) {
e.run();
}
}
}
+ +

除此以外,我们还需要一个变量,用来存储所有的依赖:

+
// target (object) -> key (string) -> dep
export const depsMap = new Map();
+ +

depsMap 是一个嵌套的 Map:

+
    +
  1. 它的 key 是一个 Object,如 c = a.b + 1 中,key 是 a 这个对象;
  2. +
  3. 它的 value 又是一个 Map:
      +
    1. 它的 key 是一个键名,如 c = a.b + 1 中,key 是 b
    2. +
    3. 它的 value 是一个 Dep 实例。
    4. +
    +
  4. +
+
mindmap
+depsMap
+    A[Key: Object]
+    B[Value: Map]
+      C[Key: string]
+      D[Value: dep]
+ +

在实际的 Vue3 代码中,这里的第一层使用的是 WeakMap 而非 Map。原因是 WeakMap 对 key 是弱引用,当 key 在代码中的其它地方已经不存在应用时,它 (key) 以及对应的 value 都会被 GC。而如果使用 Map 的话,保有的是强引用,就会导致内存泄漏。

+

依赖监听

一个依赖监听模块大致需要以下内容:

+
import { Dep, depsMap } from './dep';

/**
* 当前正在运行的 effect
*/
let currentEffect;

export class ReactiveEffect {
// todo
}

/**
* 创建一个作用函数,并将自动追踪函数内的依赖
* @param fn 接收的函数
*/
export function effect (fn) {
// todo
}

/**
* 当前正在运行的作用追踪一个依赖
* @param target 目标对象
* @param prop 目标对象的属性名
*/
export function track (target, prop) {
// todo
}

/**
* 触发一个依赖下的所有作用
* @param target 目标对象
* @param prop 目标对象的属性名
*/
export function trigger (target, prop) {
// todo
}
+ +

trigger

触发作用的代码非常简单,只需直接拿到对应的 dep,并调用它的 trigger 函数:

+
export function trigger (target, prop) {
depsMap.get(target)?.get(prop)?.trigger();
}
+ +

track

trigger 相反:trigger 是将 dep 取出来并触发里面的 effects,而 track 是将 effect 保存到 dep 中去。

+

需要注意的是,因为 depsMap 一开始是空的,所以取 dep 会包含一个初始化的过程:

+
function getDep (target, prop) {
// 从 depsMap 中找到本 target 的 Map
let deps = depsMap.get(target);
if (!deps) {
// 没找到,需要初始化
deps = new Map();
depsMap.set(target, deps);
}
// 从第二级的 Map 中找到本 prop 的 dep
let dep = deps.get(prop);
if (!dep) {
// 没找到,需要初始化
dep = new Dep();
deps.set(prop, dep);
}
return dep;
}
+ +

下面是 track 函数的具体实现:

+
export function track (target, prop) {
if (!currentEffect) {
// 当前没有正在运行中的作用,无需追踪,可直接退出
return;
}
let dep = getDep(target, prop);
// 追踪正在运行中的作用
dep.track(currentEffect);
}
+ +

effect

effect 作为一个工厂函数,只需完成 ReactiveEffect 实例的创建并立即运行:

+
export function effect (fn) {
let e = new ReactiveEffect(fn);
// 直接运行
e.run();
return e;
}
+ +

ReactiveEffect

最后来实现 ReactiveEffect 这个类。从上面的其它函数可以看出,这个类需要以下功能:

+
    +
  1. 接收一个 fn 函数;
  2. +
  3. 包含一个 run 成员方法,可以运行一次该作用;
  4. +
+

下面我们来分别实现它们。

+

1. 构造器

+

简单赋值即可:

+
constructor (fn) {
this.fn = fn;
}
+ +

2. run

+

run 函数的关键在于 currentEffect 的赋值:我们在这里默认在 fn 函数运行的过程中,会发起对相应依赖的 track(),而 track 函数中会使用到 currentEffect。这也是为什么它需要作为一个全局变量单独抽离出来,成为 track 与 effect 之间的纽带:

+
run () {
// 赋值 currentEffect
currentEffect = this;
// 运行用户传入的函数
this.fn();
// 取消赋值
currentEffect = null;
};
+ +

仔细看的话会发现,这里每一次调用 run 都会给 currentEffect 赋值,可以理解为发起了依赖收集的流程。换而言之,每次执行这个作用都会收集依赖。为什么要这么做?举个例子:

+
effect(() => {
if (a.b && a.b.c) {
d = a.b.c;
// ...
}
})
+ +

如果依赖收集只执行一次,并且第一次执行的时候 a.bfalsely 的,那么第一次执行就只收集到了 a.b 这个依赖,而 a.b.c 没有收集到。那么后续当只有 a.b.c 发生变化时,d 将不会被重新赋值,这是不符合预期的。因此,目前来说依赖收集需要在每次作用函数运行时都进行。

+

小结

目前为止,我们定义了两个类以及一些工具函数:

+
    +
  1. Dep 表示一个“依赖”,它内部含有一个 effects 集合,用来触发与它有关的作用;
  2. +
  3. ReactiveEffect 表示一个“作用”;
  4. +
  5. tracktrigger 函数,分别用来追踪依赖与触发作用。
  6. +
+

它们可以实现如下效果:

+
let obj = { a: 1, b: { c: 2 } };
let fn = jest.fn(() => {
// 用户的函数中发起了依赖追踪,
// 后续该行为将被自动化
track(obj, 'a');
track(obj.b, 'c');
});
effect(fn);
// fn 总共被调用了 1 次
expect(fn).toHaveBeenCalledTimes(1);

obj.a = 2;
// 依赖发生了变化,后续该行为将被自动化
trigger(obj, 'a');
// fn 总共被调用了 2 次
expect(fn).toHaveBeenCalledTimes(2);

obj.b.c = 3;
// 依赖发生了变化,后续该行为将被自动化
trigger(obj.b, 'c');
// fn 总共被调用了 3 次
expect(fn).toHaveBeenCalledTimes(3);
+ +

看起来好像是那么回事了,但还有点抽象,距离我们的最终目标还有一定距离。

+

响应式变量

现在我们有了依赖与依赖追踪,是时候来实现第二个关键组件 reactive 了。 它将帮我们完成“在作用函数内部自动调用 track()”以及“依赖变化时自动调用 trigger()”的工作。

+

众所周知,Vue3 使用了 Proxy 来实现响应式:

+
import { track, trigger } from './effect.js';

export function reactive (obj) {
return new Proxy(obj, {
get (target, p) {
// todo
},
set (target, p, value) {
// todo
}
});
}
+ +

我们需要做的两件事:

+
    +
  1. 实现 getter:当 get 触发时,追踪依赖
  2. +
  3. 实现 setter:当 set 触发时,触发作用
  4. +
+
graph LR
+
+A[reactive] -->|发生读取| B[track]
+A[reactive] -->|发生变更| C[trigger]
+B --> D[ReactiveEffect]
+C --> D[ReactiveEffect]
+ +

get

get 的第一版实现:

+
get (target, p) {
// 追踪依赖!
track(...arguments);
// 获取值并返回
let value = Reflect.get(...arguments);
return value;
}
+ +

Reflect 通常是与 Proxy 成对出现的 API,这里的 Reflect.get(...arguments) 约等于 target[p]

+

但是,这么做有个问题!因为 Proxy 代理的是浅层属性,举个例子,当我取 a.b.c 时,实际上分了两步:

+
    +
  1. 先取 a.b,这里 a 是 reactive 对象,能够触发 getter,没问题;
  2. +
  3. 再取 b.c,注意这里如果不做任何操作的话,b 将是一个普通对象,也就是说取值到这里响应性就丢失了。
  4. +
+

为了解决这个问题,我们需要做一点小小的改造:

+
get (target, p, receiver) {
// 追踪依赖!
track(...arguments);
// 获取值
let value = Reflect.get(...arguments);
if (value !== null && typeof value === 'object') {
// 如果 value 是一个对象,需要递归调用 reactive 将它再次包裹
return reactive(value);
}
return value;
}
+ +

set

实现 setter 需要注意的点是:

+
    +
  1. 触发作用要在设置新值后进行;
  2. +
  3. 需要判断新旧值是否相等以避免死循环。
  4. +
+
set (target, p, value, receiver) {
// 先取值
let oldValue = Reflect.get(...arguments);
if (oldValue === value) {
// 如果新旧值相等,无需触发作用
return value;
}
// 设置新的值,约等于 `target[p] = value`
let newValue = Reflect.set(...arguments);
// 触发作用!这里是在设置新值后才进行的。
trigger(...arguments);
// 注意这里返回的是 Reflect.set 的返回值
return newValue;
}
+ +

大功告成!

+

小结

我们现在可以:

+
    +
  1. 定义响应式变量;
  2. +
  3. 定义作用函数;
  4. +
  5. 响应式变量发生变化时,函数将自动执行。
  6. +
+
let a = reactive({ value: 1 });
let b;

effect(() => {
b = a.value * 2;
});
expect(b).toEqual(2);

a.value = 100;
// b 自动更新了!
expect(b).toEqual(200);

a.value = 300;
// b 自动更新了!
expect(b).toEqual(600);
+ +

实际上当进行到这里的时候,响应式的两大基石就已经完成了。因此下面其它的 API 实现我决定都通过 reactiveeffect 来实现。当然实际上 Vue3 考虑的更多,做的也会更复杂一些,但是原理是类似的。

+

其它响应式 API

mindmap
+   Reactive & Effect
+      A)ref(
+      A)computed(
+      A)watch(
+      A)watchEffect(
+      A)...(
+ +

ref

上面的 reactive API 可以对对象和数组这样的复杂类型完成监听,但对于字符串、数组或布尔值这样的基本类型,它是无能为力的。因为 Proxy 不能监听这种基本类型。因此,我们需要对它进行一层包裹:先将它包裹到一个对象中,然后通过 a.value 来访问实际的值(这实际上是 Vue3 目前仍在致力于解决的问题之一)。

+

下面,我们将以惊人的效率实现 ref

+
export function ref (value) {
return reactive({ value: value });
}
+ +

这种方式非常简单直接,并且能够完美地运行:

+
let a = ref(1);
let b;

effect(() => {
b = a.value * 2;
});
expect(b).toEqual(2);

a.value = 100;
expect(b).toEqual(200);
+ +

当然,实际上 Vue3 不是这么干的:它实现了一个 RefImpl 类,并且与 reactive 类似地,通过 getter 与 setter 完成对 value 的追踪。

+

computed

计算属性 (computed) 是经典的 Vue.js API,它能够接受一个 getter 函数,并且返回一个实时更新的值。

+

仅 getter

我们先来实现一个最常见的版本:

+
export function computed (getter) {
// 计算属性返回的是一个 ref
let result = ref(null);
// 调用 getter 函数,更新 ref 的值
effect(() => {
result.value = getter();
});
return result;
}
+ +

这是一个只包含 getter 函数的计算属性,它可以这么用:

+
let a = ref(1);
let b = computed(() => a.value + 1);
expect(b.value).toEqual(2);

a.value = 100;
expect(b.value).toEqual(101);

a.value = 300;
expect(b.value).toEqual(301);
+ +

getter & setter

复杂的计算属性可以同时拥有 getter 和 setter:

+
let a = ref(1);
let b = computed({ get: () => a.value + 1, set: (val) => a.value = val - 1});
+ +

为了优雅起见,我们先对 computed 内部的函数做一下封装,首先是 getterEffect,它与上面的实现一样,接受一个 ref 与一个 effect 函数:

+
function getterEff (computedRef, eff) {
effect(() => {
computedRef.value = eff();
});
}
+ +

然后是 setterEffect:

+
function setterEff (computedRef, eff) {
effect(() => {
eff(computedRef.value);
});
}
+ +

与 getterEffect 不同的是,setter 是将 ref 值作为参数传入到 effect 函数内,而 getterEffect 是将 effect 函数的返回赋值给 ref。

+

最后,我们就可以得到完整的 computed 函数了:

+
export function computed (eff) {
let result = ref(null);
if (typeof eff === 'function') {
// 这是一个简单的 getter 函数
getterEff(result, eff);
} else {
// 同时传入了 getter 和 setter
getterEff(result, eff.get);
setterEff(result, eff.set);
}
return result;
}
+ +

watch

除了经典的 watch API 以外,Vue3 还带来了一个新的 watchEffect API。与 watch 不同的是,它可以:

+
+

立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。

+
+

也就是说,watchEffect 无需指定它监听的值,可以完成自动的追踪,且会立即执行。

+

然而在实现这两个 API 之前,需要先对 ReactiveEffect 做一点小小的扩展改造。

+

effect 改造 - 允许停止运行

Vue3 的 watch/watchEffect API 会返回一个 stop 函数,该函数可以将侦听器停止,停止后即不再自动触发。而我们目前的 ReactiveEffect 尚不支持停止。因此我们需要给它加一个 stop 函数。

+

想要停止一个 effect,我们需要做的事情就是把它从所有的 dep 中移除,这样一来 effect 就不能被 dep 触发了。比如:

+
for (let deps of depsMap) {
for (let dep of deps) {
dep.untrack(thisEffect);
}
}
+ +

但是,这样做有几个问题:

+
    +
  1. 太过暴力,性能堪忧;
  2. +
  3. 由于 Vue3 实际上在第一层使用了 WeakMap,而 WeakMap 是不支持遍历的。
  4. +
+

因此,我们需要对 ReactiveEffect 做一些改造,将这些相关的 dep 存下来。

+
export class ReactiveEffect {
constructor (fn) {
this.fn = fn;
// 新增一个 deps Set,表示所有与本 effect 相关的依赖
this.deps = new Set();
// 新增一个 active 属性,表示是否已停止
this.active = true;
}

run () {
// effect 已经停止了,无需再收集依赖。
// 但既然 run 被调用了,还是运行并返回一下吧!
if (!this.active) {
return this.fn();
}
// 与之前一样
// ...
};

/**
* 新增的 stop 函数,用来停止 effect
*/
stop () {
// 将 active 设置为 false,表示已停止
this.active = false;
// 将本 effect 实例从所有 deps 中移除
for (let dep of this.deps) {
dep.untrack(this);
}
// 清空 deps
this.deps.clear();
}
}
+ +

同时,我们需要在追踪依赖时,将依赖添加到 effect 的 deps 中(双向追踪):

+
export function track (target, prop) {
if (!currentEffect) {
return;
}
let dep = getDep(target, prop);
dep.track(currentEffect);
// 新增!将 dep 也添加到 currentEffect 的 deps 中
currentEffect.deps.add(dep);
}
+ +

watchEffect

加入 stop 函数后,watchEffect 实现如下:

+
export const watchEffect = (cb) => {
let e = effect(cb);
// 注意这里要 bind(e),否则 this 指针会错乱
return e.stop.bind(e);
};
+ +

非常地“水到渠成”!

+
let a = ref(1);
let b = ref(0);
let stop = watchEffect(() => {
b.value = a.value * 2;
});
expect(b.value).toEqual(2);

a.value = 100;
expect(b.value).toEqual(200);

// 调用停止函数,后续 a.value 再变化时,函数将不再执行
stop();
+ +

effect 改造 - 加入 scheduler

与 watchEffect 不同的是,watch 有更多特性:

+
    +
  1. watch 方法接收两个参数:source 和 callback,分别代表监听的对象和 effect 函数;
  2. +
  3. effect 函数接收两个参数,value 和 oldValue,分别代表新的值和变化后的值;
  4. +
  5. 初次定义时,effect 函数不会运行;
  6. +
  7. 只有 source 的改变才能触发 effect。
  8. +
+

为了实现第 3&4 点,我们需要给 ReactiveEffect 加入一个 scheduler 的概念:它将决定 run 函数何时执行。

+

首先我们需要修改一下 Dep 类:

+
export class Dep {
// 同上...

trigger () {
for (let e of this._effects) {
if (e.scheduler) {
// 如果存在 scheduler,则执行 scheduler
e.scheduler();
} else {
// 否则直接 run
e.run();
}
}
}
}
+ +

然后修改 effect:

+
export class ReactiveEffect {
constructor (fn, scheduler) {
// 同上...
// 但添加一个 scheduler 选项
this.scheduler = scheduler;
}

// 同上...
}

// 添加了一个 scheduler 参数
export function effect (fn, scheduler) {
let e = new ReactiveEffect(fn, scheduler);
e.run();
return e;
}
+ +

OK,完成了。实际上只是添加了一个可以自由更改 run 执行时机的选项。但 scheduler 非常强大,Vue 的另一个核心功能 nextTick 也是基于它实现的,此处先不展开。

+

watch

watch 的函数重载非常多,为了简单起见,我们只实现其中一种形式:

+
    +
  1. getter:函数,返回监听的值;
  2. +
  3. cb:回调函数
  4. +
+
export function watch (getter, cb) {
let oldValue;

let job = () => {
// 获取新值
let newVal = e.run();
// 调用回调函数
cb(newVal, oldValue);
// 设置“新的旧值”
oldValue = newVal;
};
// 定义 effect
// 注意 effect 的本体是 getter,
// 也就是说只有 getter 可以触发依赖收集
// 而 job 将作为 scheduler 传入
let e = effect(getter, job);
// 首次运行,完成第一个旧值的获取
oldValue = e.run();
// 与 watchEffect 一样返回 stop 函数
return e.stop.bind(e);
}
+ +

至此,watch 函数也实现完了。

+
let a = reactive({ value: 1 });
let fn = jest.fn();
let stop = watch(() => a.value, fn);
// 没有初次调用
expect(fn).not.toBeCalled();

a.value = 2;
// 值改变后自动调用
expect(fn).toBeCalledWith(2, 1);

stop();

a.value = 3;
// 停止后,不再调用
expect(fn).toHaveBeenCalledTimes(1);
+ +

小结

在本节中,我们使用现成的 effectreactive API 实现了 refcomputed,并且通过对 effect 扩展的两个功能(stop、scheduler)分别实现了 watchEffectwatch

+

至此,Vue3 响应式的核心功能已全部实现完!

+

响应式 UI

现在既然已经实现了响应式,那么我们回到最初的问题:

+
let col = [1, 2, 3, 4, 5]
let s = sum(col)
+ +

我们如何将这段代码变成响应式的,或者说,是否可以更进一步,直接将它变成响应式的 UI?

+
graph LR
+
+A[数据变更] -->|自动触发| B[界面渲染]
+B -->|自动监听| A
+ +

那么我们直接来定义一个(似曾相识的)组件:

+
const App = {
// 一个最原始的 render 函数,接收 ctx 参数
// 返回一个 HTML 节点
render (ctx) {
let div = document.createElement('div');
// 使用 reduce 获得累加的和
div.textContent = ctx.col.reduce((a, b) => a + b, 0);
return div;
},
// 模仿组合式 API 的 setup 函数...
setup () {
const col = reactive([1, 2, 3, 4, 5])
return { col }
}
}
+ +

然后,我们编写一个(似曾相识的) createApp 函数:

+
function createApp (Component) {
return {
// 挂载函数
mount (root) {
// 一点兼容代码,获取挂载的根节点
let rootNode = typeof root === 'string' ? document.querySelector(root) : root;
// 调用 setup 获取 context
let context = Component.setup();
// 每当 context 发生变化时,effect 都将自动执行
effect(() => {
rootNode.innerHTML = '';
let node = Component.render(context);
rootNode.append(node);
});
}
};
}
+ +

最后,我们将组件挂载到 #app 上:

+
createApp(App).mount('#app')
+ +

(当然我们还需要一个 HTML 文件):

+
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app"></div>
<script src="index.js" type="module"></script>
</body>
</html>
+ +

完成了!虽然还非常简陋,但我们已经用与 Vue 类似的方式实现了一个响应式的前端页面:当 col 更新时,页面上将显示新的求和值。

+

再次强调,本文使用的实现思路与 Vue 大致相同,但简化了许多。对此感兴趣的同学,欢迎阅读 vuejs/core 源码。

+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/2023/xiao-man/0.jpg b/2023/xiao-man/0.jpg new file mode 100644 index 000000000..e77b32e70 Binary files /dev/null and b/2023/xiao-man/0.jpg differ diff --git a/2023/xiao-man/1.jpg b/2023/xiao-man/1.jpg new file mode 100644 index 000000000..ff673c47b Binary files /dev/null and b/2023/xiao-man/1.jpg differ diff --git a/2023/xiao-man/2.jpg b/2023/xiao-man/2.jpg new file mode 100644 index 000000000..7566ebaa4 Binary files /dev/null and b/2023/xiao-man/2.jpg differ diff --git a/2023/xiao-man/3.jpg b/2023/xiao-man/3.jpg new file mode 100644 index 000000000..68cb00d59 Binary files /dev/null and b/2023/xiao-man/3.jpg differ diff --git a/2023/xiao-man/4.jpg b/2023/xiao-man/4.jpg new file mode 100644 index 000000000..7acab4792 Binary files /dev/null and b/2023/xiao-man/4.jpg differ diff --git a/2023/xiao-man/index.html b/2023/xiao-man/index.html new file mode 100644 index 000000000..1b92c7756 --- /dev/null +++ b/2023/xiao-man/index.html @@ -0,0 +1,455 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +小满 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + +
+

+ 小满 +

+ + +
+ + + + +

我和静纯的孩子在 2023 年 5 月 22 日出生,当天并不是小满,而是小满的次日。然而犹豫再三,最后我们还是给孩子取名为“小满”。

+

“小满”的含义,在于小满,而非大满,满而未盈。我们本打算如果孩子能在小满当日出生就叫他“小满”,却偏偏差了一日。但是转念想想,这一点点偏差,不是刚好对应上了“小满”的内在涵义吗?再者,虽然小满不是在当天出生的,但是妈妈却是在小满那天进的产房,生产过程除了手术室,我基本是全程陪着妈妈,这多少也能代表我们的一点回忆。

+

另外,除了这个结果以外,生孩子的过程也出现了偏差。但好在最后的结果是好的。孩子目前为止很健康,妈妈也恢复得很好,这样就足够了。这就是我这个小家庭的“小满”。

+ + +

最后,放几张孩子的照片吧!

+
+ +
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CNAME b/CNAME new file mode 100644 index 000000000..af23239ab --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +wxsm.space \ No newline at end of file diff --git a/about/index.html b/about/index.html new file mode 100644 index 000000000..b9ea05d2b --- /dev/null +++ b/about/index.html @@ -0,0 +1,417 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +关于 | wxsm's pace + + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+ +

关于 +

+ + + +
+ + + +
+

九零后码农一枚。在珠海上学,在珠海打工。

+

JetBrains 系列产品忠实用户。

+

克罗恩病患者,详见这篇文章

+

这个网站

这个网站主要用作我的个人文章记录,比如一些技术文章、生活随想或日常琐事。

+

写文章/博客的习惯已经很多年了,但是小时候写的文章,现在感觉很多都已经不再适合拿出来看,因此这里保留的大概是从上大学以后写的文章。

+

网站程序开源:https://github.com/wxsms/blog,托管在 GitHub Pages。CDN 使用的是 CloudFlare 的免费版本。

+

我的一些开源项目

按开发时间倒序排列。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项目名GitHub说明
geekbang-downloaderwxsms/geekbang-downloader 一款用 Go 语言编写的极客时间图文课程下载器,支持将课程作为 markdown 下载到本地(包括图片)。
vite-plugin-libcsswxsms/vite-plugin-libcss 一个 Vite 插件,可以在 lib 模式下将 css 插入到 js 文件中去。
bv2mp3wxsms/bilibili-video2mp3 一款基于 Node.js 的 bilibili 视频下载器,并且可以利用 ffmpeg 将下载的视频自动转为 mp3。详见这篇博文
IBD 日记wxsms/food-diary 一个微信小程序,可以帮助 IBD 病人记录每日饮食、状况与用药记录。最多的时候一天大概有五六十个独立用户。遗憾的是微信在几年后取消了免费云计算套餐,因此它就下架了。
vuepress-theme-miniwxsms/vuepress-theme-mini 为 VuePress 开发的一个非常轻量级的博客主题,它继承自默认主题,添加了一个首页和一个文章归档页,以及集成了一套评论系统。
vue-md-loaderwxsms/vue-md-loader 一个 webpack loader,可以将 Markdown 以及其内部的代码块转换为 Vue 组件。开发背景见这篇博文
uivuiv-lib/uiv 一个 Vue 2 实现的 Bootstrap 3 组件库,当时的情况详见这篇博文。同时它也是我第一个花了好几年时间认真在做的开源项目。
daily-signerwxsms/daily-signer 一个用 Node.js 编写的日常签到助手,可以完成某东、V2EX 的一些签到任务。甚至能自动破解某东的滑动输入验证码。
zhihu-spiderwxsms/zhihu-spider 一个用 Node.js 编写的知乎爬虫。
珠海公交巴士实时地图wxsms/zh-bus-realtime 可以同时监视任意条线路,并且能够在瓦片地图上显示它们的实时位置。可以记住我的常用线路,需要时完成一键查询。
jquery-2048wxsms/jquery-2048 2048 小游戏 by jQuery。
+ +
+ + + +
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2012/09/index.html b/archives/2012/09/index.html new file mode 100644 index 000000000..16e75ccb0 --- /dev/null +++ b/archives/2012/09/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2012 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2012/index.html b/archives/2012/index.html new file mode 100644 index 000000000..63c76b16e --- /dev/null +++ b/archives/2012/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2012 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2013/02/index.html b/archives/2013/02/index.html new file mode 100644 index 000000000..a9b1b829c --- /dev/null +++ b/archives/2013/02/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2013 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2013/09/index.html b/archives/2013/09/index.html new file mode 100644 index 000000000..a7946d35b --- /dev/null +++ b/archives/2013/09/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2013 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2013/10/index.html b/archives/2013/10/index.html new file mode 100644 index 000000000..0faa83aab --- /dev/null +++ b/archives/2013/10/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2013 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2013/11/index.html b/archives/2013/11/index.html new file mode 100644 index 000000000..5444dd669 --- /dev/null +++ b/archives/2013/11/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2013 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2013/12/index.html b/archives/2013/12/index.html new file mode 100644 index 000000000..bd21688db --- /dev/null +++ b/archives/2013/12/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2013 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2013/index.html b/archives/2013/index.html new file mode 100644 index 000000000..7d5d61772 --- /dev/null +++ b/archives/2013/index.html @@ -0,0 +1,431 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2013 +
+ + + + + + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2014/01/index.html b/archives/2014/01/index.html new file mode 100644 index 000000000..fe40df2d5 --- /dev/null +++ b/archives/2014/01/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2014 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2014/05/index.html b/archives/2014/05/index.html new file mode 100644 index 000000000..0906af7ae --- /dev/null +++ b/archives/2014/05/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2014 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2014/11/index.html b/archives/2014/11/index.html new file mode 100644 index 000000000..1af9d4bf9 --- /dev/null +++ b/archives/2014/11/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2014 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2014/index.html b/archives/2014/index.html new file mode 100644 index 000000000..9fd508c60 --- /dev/null +++ b/archives/2014/index.html @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2014 +
+ + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2015/06/index.html b/archives/2015/06/index.html new file mode 100644 index 000000000..266a83286 --- /dev/null +++ b/archives/2015/06/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2015 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2015/09/index.html b/archives/2015/09/index.html new file mode 100644 index 000000000..5cac81ad3 --- /dev/null +++ b/archives/2015/09/index.html @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2015 +
+ + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2015/12/index.html b/archives/2015/12/index.html new file mode 100644 index 000000000..77d71a2d5 --- /dev/null +++ b/archives/2015/12/index.html @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2015 +
+ + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2015/index.html b/archives/2015/index.html new file mode 100644 index 000000000..c868edbf2 --- /dev/null +++ b/archives/2015/index.html @@ -0,0 +1,451 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2015 +
+ + + + + + + + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/01/index.html b/archives/2016/01/index.html new file mode 100644 index 000000000..b12d2ce5e --- /dev/null +++ b/archives/2016/01/index.html @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/02/index.html b/archives/2016/02/index.html new file mode 100644 index 000000000..f5ca32e09 --- /dev/null +++ b/archives/2016/02/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/02/page/2/index.html b/archives/2016/02/page/2/index.html new file mode 100644 index 000000000..74ba2ff26 --- /dev/null +++ b/archives/2016/02/page/2/index.html @@ -0,0 +1,334 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/03/index.html b/archives/2016/03/index.html new file mode 100644 index 000000000..d325cd7f7 --- /dev/null +++ b/archives/2016/03/index.html @@ -0,0 +1,411 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/04/index.html b/archives/2016/04/index.html new file mode 100644 index 000000000..56319d513 --- /dev/null +++ b/archives/2016/04/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/05/index.html b/archives/2016/05/index.html new file mode 100644 index 000000000..55423b671 --- /dev/null +++ b/archives/2016/05/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/07/index.html b/archives/2016/07/index.html new file mode 100644 index 000000000..0a26dc002 --- /dev/null +++ b/archives/2016/07/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/08/index.html b/archives/2016/08/index.html new file mode 100644 index 000000000..b2e41f09e --- /dev/null +++ b/archives/2016/08/index.html @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/09/index.html b/archives/2016/09/index.html new file mode 100644 index 000000000..61c4ca8ae --- /dev/null +++ b/archives/2016/09/index.html @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/10/index.html b/archives/2016/10/index.html new file mode 100644 index 000000000..03a3740e4 --- /dev/null +++ b/archives/2016/10/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/11/index.html b/archives/2016/11/index.html new file mode 100644 index 000000000..1ca2c5c21 --- /dev/null +++ b/archives/2016/11/index.html @@ -0,0 +1,411 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/12/index.html b/archives/2016/12/index.html new file mode 100644 index 000000000..a3dcb4c16 --- /dev/null +++ b/archives/2016/12/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/index.html b/archives/2016/index.html new file mode 100644 index 000000000..be2cb6958 --- /dev/null +++ b/archives/2016/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/page/2/index.html b/archives/2016/page/2/index.html new file mode 100644 index 000000000..89c888a44 --- /dev/null +++ b/archives/2016/page/2/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/page/3/index.html b/archives/2016/page/3/index.html new file mode 100644 index 000000000..488c3fec7 --- /dev/null +++ b/archives/2016/page/3/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/page/4/index.html b/archives/2016/page/4/index.html new file mode 100644 index 000000000..19ec2c686 --- /dev/null +++ b/archives/2016/page/4/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2016/page/5/index.html b/archives/2016/page/5/index.html new file mode 100644 index 000000000..3c211c0b0 --- /dev/null +++ b/archives/2016/page/5/index.html @@ -0,0 +1,334 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/01/index.html b/archives/2017/01/index.html new file mode 100644 index 000000000..7b10e713a --- /dev/null +++ b/archives/2017/01/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/02/index.html b/archives/2017/02/index.html new file mode 100644 index 000000000..08c64613d --- /dev/null +++ b/archives/2017/02/index.html @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/03/index.html b/archives/2017/03/index.html new file mode 100644 index 000000000..14fe29c90 --- /dev/null +++ b/archives/2017/03/index.html @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/04/index.html b/archives/2017/04/index.html new file mode 100644 index 000000000..ee8060810 --- /dev/null +++ b/archives/2017/04/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/06/index.html b/archives/2017/06/index.html new file mode 100644 index 000000000..39825991f --- /dev/null +++ b/archives/2017/06/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/07/index.html b/archives/2017/07/index.html new file mode 100644 index 000000000..28f2f5817 --- /dev/null +++ b/archives/2017/07/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/09/index.html b/archives/2017/09/index.html new file mode 100644 index 000000000..f24645718 --- /dev/null +++ b/archives/2017/09/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/10/index.html b/archives/2017/10/index.html new file mode 100644 index 000000000..6d90681b6 --- /dev/null +++ b/archives/2017/10/index.html @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/11/index.html b/archives/2017/11/index.html new file mode 100644 index 000000000..d2b42a3bd --- /dev/null +++ b/archives/2017/11/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/12/index.html b/archives/2017/12/index.html new file mode 100644 index 000000000..4140e77a0 --- /dev/null +++ b/archives/2017/12/index.html @@ -0,0 +1,411 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/index.html b/archives/2017/index.html new file mode 100644 index 000000000..dcafcaae9 --- /dev/null +++ b/archives/2017/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/page/2/index.html b/archives/2017/page/2/index.html new file mode 100644 index 000000000..d757b00a4 --- /dev/null +++ b/archives/2017/page/2/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2017/page/3/index.html b/archives/2017/page/3/index.html new file mode 100644 index 000000000..1fa1f9cbd --- /dev/null +++ b/archives/2017/page/3/index.html @@ -0,0 +1,334 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2018/01/index.html b/archives/2018/01/index.html new file mode 100644 index 000000000..81c861295 --- /dev/null +++ b/archives/2018/01/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2018 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2018/04/index.html b/archives/2018/04/index.html new file mode 100644 index 000000000..9d541df84 --- /dev/null +++ b/archives/2018/04/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2018 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2018/05/index.html b/archives/2018/05/index.html new file mode 100644 index 000000000..70aea246a --- /dev/null +++ b/archives/2018/05/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2018 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2018/06/index.html b/archives/2018/06/index.html new file mode 100644 index 000000000..d9deaea2a --- /dev/null +++ b/archives/2018/06/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2018 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2018/07/index.html b/archives/2018/07/index.html new file mode 100644 index 000000000..1d053c45b --- /dev/null +++ b/archives/2018/07/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2018 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2018/08/index.html b/archives/2018/08/index.html new file mode 100644 index 000000000..1d0b5b799 --- /dev/null +++ b/archives/2018/08/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2018 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2018/09/index.html b/archives/2018/09/index.html new file mode 100644 index 000000000..44b148c11 --- /dev/null +++ b/archives/2018/09/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2018 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2018/10/index.html b/archives/2018/10/index.html new file mode 100644 index 000000000..d20e3736b --- /dev/null +++ b/archives/2018/10/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2018 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2018/11/index.html b/archives/2018/11/index.html new file mode 100644 index 000000000..b1aed6d13 --- /dev/null +++ b/archives/2018/11/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2018 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2018/index.html b/archives/2018/index.html new file mode 100644 index 000000000..13bb11ed2 --- /dev/null +++ b/archives/2018/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2018 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2018/page/2/index.html b/archives/2018/page/2/index.html new file mode 100644 index 000000000..ca0b0bcf5 --- /dev/null +++ b/archives/2018/page/2/index.html @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2018 +
+ + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2019/03/index.html b/archives/2019/03/index.html new file mode 100644 index 000000000..eb5d86eaf --- /dev/null +++ b/archives/2019/03/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2019 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2019/04/index.html b/archives/2019/04/index.html new file mode 100644 index 000000000..424d83f64 --- /dev/null +++ b/archives/2019/04/index.html @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2019 +
+ + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2019/07/index.html b/archives/2019/07/index.html new file mode 100644 index 000000000..3cbb65533 --- /dev/null +++ b/archives/2019/07/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2019 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2019/09/index.html b/archives/2019/09/index.html new file mode 100644 index 000000000..a6b0230ae --- /dev/null +++ b/archives/2019/09/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2019 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2019/10/index.html b/archives/2019/10/index.html new file mode 100644 index 000000000..becc05f3f --- /dev/null +++ b/archives/2019/10/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2019 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2019/11/index.html b/archives/2019/11/index.html new file mode 100644 index 000000000..2df077aff --- /dev/null +++ b/archives/2019/11/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2019 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2019/12/index.html b/archives/2019/12/index.html new file mode 100644 index 000000000..b7a0ca49a --- /dev/null +++ b/archives/2019/12/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2019 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2019/index.html b/archives/2019/index.html new file mode 100644 index 000000000..c607d9a01 --- /dev/null +++ b/archives/2019/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2019 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2019/page/2/index.html b/archives/2019/page/2/index.html new file mode 100644 index 000000000..a765dc0b0 --- /dev/null +++ b/archives/2019/page/2/index.html @@ -0,0 +1,334 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2019 +
+ + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2020/06/index.html b/archives/2020/06/index.html new file mode 100644 index 000000000..b3622d022 --- /dev/null +++ b/archives/2020/06/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2020 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2020/07/index.html b/archives/2020/07/index.html new file mode 100644 index 000000000..0bea96b12 --- /dev/null +++ b/archives/2020/07/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2020 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2020/09/index.html b/archives/2020/09/index.html new file mode 100644 index 000000000..58de764c5 --- /dev/null +++ b/archives/2020/09/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2020 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2020/10/index.html b/archives/2020/10/index.html new file mode 100644 index 000000000..486ddd461 --- /dev/null +++ b/archives/2020/10/index.html @@ -0,0 +1,411 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2020 +
+ + + + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2020/11/index.html b/archives/2020/11/index.html new file mode 100644 index 000000000..81e26e056 --- /dev/null +++ b/archives/2020/11/index.html @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2020 +
+ + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2020/index.html b/archives/2020/index.html new file mode 100644 index 000000000..39b4549fd --- /dev/null +++ b/archives/2020/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2020 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2020/page/2/index.html b/archives/2020/page/2/index.html new file mode 100644 index 000000000..d9cac6096 --- /dev/null +++ b/archives/2020/page/2/index.html @@ -0,0 +1,354 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2020 +
+ + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2021/02/index.html b/archives/2021/02/index.html new file mode 100644 index 000000000..9cd04c8c1 --- /dev/null +++ b/archives/2021/02/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2021 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2021/03/index.html b/archives/2021/03/index.html new file mode 100644 index 000000000..61753facf --- /dev/null +++ b/archives/2021/03/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2021 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2021/04/index.html b/archives/2021/04/index.html new file mode 100644 index 000000000..723d1d5a5 --- /dev/null +++ b/archives/2021/04/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2021 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2021/07/index.html b/archives/2021/07/index.html new file mode 100644 index 000000000..40a4ff5f4 --- /dev/null +++ b/archives/2021/07/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2021 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2021/09/index.html b/archives/2021/09/index.html new file mode 100644 index 000000000..384cde837 --- /dev/null +++ b/archives/2021/09/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2021 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2021/10/index.html b/archives/2021/10/index.html new file mode 100644 index 000000000..e539c1bdb --- /dev/null +++ b/archives/2021/10/index.html @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2021 +
+ + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2021/11/index.html b/archives/2021/11/index.html new file mode 100644 index 000000000..944473a9d --- /dev/null +++ b/archives/2021/11/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2021 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2021/index.html b/archives/2021/index.html new file mode 100644 index 000000000..dc9a58cbf --- /dev/null +++ b/archives/2021/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2021 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2021/page/2/index.html b/archives/2021/page/2/index.html new file mode 100644 index 000000000..fc16e9951 --- /dev/null +++ b/archives/2021/page/2/index.html @@ -0,0 +1,394 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2021 +
+ + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2022/05/index.html b/archives/2022/05/index.html new file mode 100644 index 000000000..b58fd7dda --- /dev/null +++ b/archives/2022/05/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2022 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2022/index.html b/archives/2022/index.html new file mode 100644 index 000000000..b78c5fab7 --- /dev/null +++ b/archives/2022/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2022 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2023/03/index.html b/archives/2023/03/index.html new file mode 100644 index 000000000..fef92979a --- /dev/null +++ b/archives/2023/03/index.html @@ -0,0 +1,351 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2023 +
+ + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2023/04/index.html b/archives/2023/04/index.html new file mode 100644 index 000000000..dea8b3e00 --- /dev/null +++ b/archives/2023/04/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2023 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2023/06/index.html b/archives/2023/06/index.html new file mode 100644 index 000000000..c3468281d --- /dev/null +++ b/archives/2023/06/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2023 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2023/07/index.html b/archives/2023/07/index.html new file mode 100644 index 000000000..3242f054b --- /dev/null +++ b/archives/2023/07/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2023 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2023/09/index.html b/archives/2023/09/index.html new file mode 100644 index 000000000..bc6d35e5a --- /dev/null +++ b/archives/2023/09/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2023 +
+ + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/2023/index.html b/archives/2023/index.html new file mode 100644 index 000000000..e038e106d --- /dev/null +++ b/archives/2023/index.html @@ -0,0 +1,431 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2023 +
+ + + + + + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/index.html b/archives/index.html new file mode 100644 index 000000000..deed6b52a --- /dev/null +++ b/archives/index.html @@ -0,0 +1,520 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2023 +
+ + + + + + + + + + + + +
+ 2022 +
+ + +
+ 2021 +
+ + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/10/index.html b/archives/page/10/index.html new file mode 100644 index 000000000..6f88cc498 --- /dev/null +++ b/archives/page/10/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/11/index.html b/archives/page/11/index.html new file mode 100644 index 000000000..c160d460e --- /dev/null +++ b/archives/page/11/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/12/index.html b/archives/page/12/index.html new file mode 100644 index 000000000..f7de2144f --- /dev/null +++ b/archives/page/12/index.html @@ -0,0 +1,517 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + + + + + + + + + +
+ 2015 +
+ + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/13/index.html b/archives/page/13/index.html new file mode 100644 index 000000000..7bec5fc38 --- /dev/null +++ b/archives/page/13/index.html @@ -0,0 +1,520 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2015 +
+ + + + + + + + + + + + +
+ 2014 +
+ + + + + + +
+ 2013 +
+ + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/14/index.html b/archives/page/14/index.html new file mode 100644 index 000000000..1ce77aab3 --- /dev/null +++ b/archives/page/14/index.html @@ -0,0 +1,437 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2013 +
+ + + + + + + + + + +
+ 2012 +
+ + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/2/index.html b/archives/page/2/index.html new file mode 100644 index 000000000..8ec7182b5 --- /dev/null +++ b/archives/page/2/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2021 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/3/index.html b/archives/page/3/index.html new file mode 100644 index 000000000..00c2bc086 --- /dev/null +++ b/archives/page/3/index.html @@ -0,0 +1,517 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2021 +
+ + +
+ 2020 +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/4/index.html b/archives/page/4/index.html new file mode 100644 index 000000000..c455b8a0a --- /dev/null +++ b/archives/page/4/index.html @@ -0,0 +1,517 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2020 +
+ + + + + + +
+ 2019 +
+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/5/index.html b/archives/page/5/index.html new file mode 100644 index 000000000..893ff2780 --- /dev/null +++ b/archives/page/5/index.html @@ -0,0 +1,517 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2019 +
+ + + + + + + + +
+ 2018 +
+ + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/6/index.html b/archives/page/6/index.html new file mode 100644 index 000000000..5afcc3fbc --- /dev/null +++ b/archives/page/6/index.html @@ -0,0 +1,517 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2018 +
+ + + + + + + + + + + + + + +
+ 2017 +
+ + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/7/index.html b/archives/page/7/index.html new file mode 100644 index 000000000..e0607bba1 --- /dev/null +++ b/archives/page/7/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/8/index.html b/archives/page/8/index.html new file mode 100644 index 000000000..8ba62e1e5 --- /dev/null +++ b/archives/page/8/index.html @@ -0,0 +1,517 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2017 +
+ + + + + + + + + + + + + + + + +
+ 2016 +
+ + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/archives/page/9/index.html b/archives/page/9/index.html new file mode 100644 index 000000000..c8fd617b6 --- /dev/null +++ b/archives/page/9/index.html @@ -0,0 +1,514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +归档 | wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
+ + 非常好! 目前共计 136 篇日志。 继续努力。 +
+ + +
+ 2016 +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/atom.xml b/atom.xml new file mode 100644 index 000000000..f37427742 --- /dev/null +++ b/atom.xml @@ -0,0 +1,468 @@ + + + wxsm's pace + https://wxsm.space/icon.png + + + + + 2023-12-03T15:39:47.890Z + https://wxsm.space/ + + + wxsm + + + + Hexo + + + Go 语言性能调试与分析工具:pprof 用法简介 + + https://wxsm.space/2023/go-pprof-note/ + 2023-09-11T08:01:04.000Z + 2023-12-03T15:39:47.890Z + + + <p><a href="https://github.com/google/pprof">pprof</a> 是 Google 开发的一款用于数据分析和可视化的工具。</p> +<p>最近我编写的 go 程序遇到了一次线上 OOM,于是趁机学习了一下 Go 程序的性能问题排查相关知识。其基本路线是:先通过内置的 <code>net/http/pprof</code> 模块生成采集数据,然后在使用 pprof 命令行读取并分析。Go 语言目前已经内置了该工具。</p> +<p>本文不会介绍 pprof 的太多细节,只关注主要流程(主要的是太细的我现在也不会)。</p> + + + + + + + + + + + + 服务端开发一月记 + + https://wxsm.space/2023/1-month-of-backend-dev/ + 2023-07-21T08:23:19.000Z + 2023-12-03T15:39:47.882Z + + + <p>5 月份休完陪产假,再回到公司,发现原本的小组已经整体重组,只有我一人还在工位上了。后来跟 TL 聊了一下,最后反正就是几个选项,要么跟着原来的同事一起去新的部门继续写前端,要么就做点别的事情。</p> +<p>当时我还是挺头疼的,主要是那会事情太多了,一是小满还在月子里,二是新房子还在装修,三是那段时间身体状态有一点波动(其实主要可能还是因为这个)。继续搞前端肯定是最稳的,但是我自己其实已经有点厌倦了,属于是看到前端代码就已经有点不耐烦的程度。但是对前端以外的东西又还是很感兴趣,偶尔自己写点非前端的玩意都会感到很愉快。犹豫再三,也是跟朋友家人都聊了一下,最后选择了转成服务端开发。(其实当时还有一个可能是 C++ 客户端的方向,但是因为太过陌生,加上前面说的那些现实情况,实在是有点绷不住)</p> +<p>其实我在刚毕业那会做过一段时间(半年左右?)的 Java 开发,但是那段时间基本上来说还是处于比较懵的状态,也没学到什么东西,加上后来很快就转型(基本上)全职前端,Java 服务端就荒废了。现在也算是一个从头来过。</p> +<p>到现在 7 月份,大概算下来时间过去一个多月了,也简单说下转型后的感想。</p> + + + + + + + + + + + + 小满 + + https://wxsm.space/2023/xiao-man/ + 2023-06-08T07:04:47.000Z + 2023-12-03T15:39:47.922Z + + + <p>我和静纯的孩子在 2023 年 5 月 22 日出生,当天并不是小满,而是小满的次日。然而犹豫再三,最后我们还是给孩子取名为“小满”。</p> +<p>“小满”的含义,在于小满,而非大满,满而未盈。我们本打算如果孩子能在小满当日出生就叫他“小满”,却偏偏差了一日。但是转念想想,这一点点偏差,不是刚好对应上了“小满”的内在涵义吗?再者,虽然小满不是在当天出生的,但是妈妈却是在小满那天进的产房,生产过程除了手术室,我基本是全程陪着妈妈,这多少也能代表我们的一点回忆。</p> +<p>另外,除了这个结果以外,生孩子的过程也出现了偏差。但好在最后的结果是好的。孩子目前为止很健康,妈妈也恢复得很好,这样就足够了。这就是我这个小家庭的“小满”。</p> + + + + + + + + + + 做了一个 b 站视频下载与 mp3 转换工具 + + https://wxsm.space/2023/bv2mp3/ + 2023-04-18T07:40:58.000Z + 2023-12-03T15:39:47.882Z + + + <p>b 站上的歌姬,很多歌只发布在 b 站。比如说直播时唱的歌,或者一些发布到正经音乐平台上会有版权问题的歌。然而,对于爱听歌的人来说,b 站的听歌体验实在是太差了,这里就不展开细说。</p> +<p>我习惯用网易云听歌。网易云虽然版权方面很惨,但有它一个很好用的功能:云盘。每个用户有 60G 的云盘容量,基本用不完,不管是什么歌,有没有版权,只要上传上去了就能随时随地听。因此,我的目标是,希望可以有一个自动化的工具,帮我把 b 站上的歌以 mp3 的格式下载下来,让我可以上传到云盘,这样我就可以用网易云听歌了。</p> +<p>综上所述,我就做了这么一个小工具:<a href="https://github.com/wxsms/bilibili-video2mp3">bv2mp3</a> ,这是一个开源工具,完整的代码可以在代码仓库中找到。下面,我主要讲一下这个工具的实现思路以及优化过程。</p> +<p><img src="/2023/bv2mp3/bv2mp3.png" alt="126f0ee04db59831d6a9820ac89c471.jpg"></p> + + + + + + + + + + + + 静态文件 Docker 镜像问题一则 + + https://wxsm.space/2023/a-static-file-docker-image-issue/ + 2023-03-23T09:41:39.000Z + 2023-12-03T15:39:47.882Z + + + + + + + <p>今天想要打包一个 Docker 镜像,里面只包含一些静态的前端文件。为了使体积足够小,想到的方案是把命令全部集中在一个 RUN 上,类似这样:</p> +<figure class="highlight dockerfile"><table><tr><td + + + + + + + + + + + + + + 从零开始实现 Vue3 响应式 + + https://wxsm.space/2023/vue3-reactive/ + 2023-03-17T08:09:48.000Z + 2023-12-03T15:39:47.918Z + + + <p>Vue3 与 Vue2 的最大不同点之一是响应式的实现方式。众所周知,Vue2 使用的是 <code>Object.defineProperty</code>,为每个对象设置 getter 与 setter,从而达到监听数据变化的目的。然而这种方式存在诸多限制,如对数组的支持不完善,无法监听到对象上的新增属性等。因此 Vue3 通过 Proxy API 对响应式系统进行了重写,并将这部分代码封装在了 <code>@vue/reactivity</code> 包中。</p> +<p>本文将参照 Vue3 的设计,从零开始实现一套响应式系统。注意本文引用的代码与实际的 Vue3 实现方式有所出入,Vue3 需要更多地考虑高效与兼容各种边界情况,但此处以易懂为主。 文中提到的大部分代码可以在 <a href="https://github.com/wxsms/learning-vue">https://github.com/wxsms/learning-vue</a> 找到。</p> + + + + + + + + + + + + 2022/05/20 + + https://wxsm.space/2022/2022-05-20/ + 2022-05-20T03:08:57.000Z + 2023-12-03T15:39:47.882Z + + + + + + + <p>很久没更新了,最近有点懒。也没什么想写的。</p> +<p>在新公司(金山办公)上班一年了,工作量并不大,但是干得感觉比之前更累了。主要可能有两个原因:一是之前的负责人在我入职不久后就走了,结果我又变成了负责人(离开西山居的原因之一就是不想做不责人)。二是,做的项目比较偏探索 + + + + + + + + + + + + 值得纪念的时刻 + + https://wxsm.space/2021/a-memorable-moment/ + 2021-11-12T03:26:58.000Z + 2023-12-03T15:39:47.882Z + + + + + + + <p>昨天正式受邀(实际上是我申请的)进入了 <a href="https://github.com/vuejs">vuejs</a> 组织。虽然目前只是 doc team,但是我相信以后可以做更多的事情。</p> +<p><img + + + + + + + + + + + + Node.js 包管理器发展史 + + https://wxsm.space/2021/npm-history/ + 2021-11-08T04:56:13.000Z + 2023-12-03T15:39:47.906Z + + + <h2 id="在没有包管理器之前"><a href="#在没有包管理器之前" class="headerlink" title="在没有包管理器之前"></a>在没有包管理器之前</h2><p>正确来说 Node.js 是不存在没有包管理器的时期的。从 <a href="https://nodejs.dev/learn/a-brief-history-of-nodejs">A brief history of Node.js</a> 里面可以看到,当 2009 年 Node.js 问世的时候 NPM 的雏形也发布了。当然因为 Node.js 跟前端绑得很死,这里主要谈一谈前端在没有包管理器的时期是怎样的。</p> +<p>那时候做得最多的事情就是:</p> +<ol> +<li>网上寻找各软件的官网,比如 jQuery;</li> +<li>找到下载地址,下载 zip 包;</li> +<li>解压,放到项目中一个叫 libs 的目录中;</li> +<li>想更方便的话,直接将 CDN 链接粘贴到 HTML 中。</li> +</ol> +<p>四个字总结:刀耕火种。 模块化管理?版本号管理?依赖升级?不存在的。当然,那时候前端也没有那么复杂,这种模式勉强来说也不是不能用。</p> + + + + + + + + + + + + 在 Windows 中使用 Cygwin + + https://wxsm.space/2021/windows-idea-cygwin/ + 2021-10-25T02:23:24.000Z + 2023-12-03T15:39:47.918Z + + + <p>之前在 <a href="/2020/wsl-on-windows-10-and-node-js/">WSL on Windows 10</a> 中尝试了 WSL,但是几经周折最后发现问题比较多,用得有点难受。最后还是换回了 windows。</p> + + + + + + + + + + + + Php Note + + https://wxsm.space/2021/php-note/ + 2021-10-13T14:05:10.000Z + 2023-12-03T15:39:47.910Z + + + <p>Php 个人速查笔记。</p> + + + + + + + + + + + + 博客迁移至 Hexo + + https://wxsm.space/2021/blog-migrate-to-hexo/ + 2021-10-13T09:23:16.000Z + 2023-12-03T15:39:47.882Z + + + + + + + <p>博客迁移至 Hexo。主要原因是:</p> +<ol> +<li>Vuepress 有部分 bug 难以忍受,而且 v1 仓库已经停止维护了;</li> +<li>Vuepress 的功能对于 blog 来说还是有些弱;</li> +<li>Vuepress v1 + + + + + + + + + + + + 简单 CSS 实现暗黑模式 + + https://wxsm.space/2021/simple-css-dark-mode/ + 2021-10-07T19:38:08.745Z + 2023-12-03T15:39:47.914Z + + + + + + + <!-- 「」 --> + +<figure class="highlight css"><table><tr><td class="code"><pre><span class="line"><span class="keyword">@media</span> (<span + + + + + + + + + + + + golang 学习笔记 + + https://wxsm.space/2021/learn-golang/ + 2021-09-30T01:49:29.406Z + 2023-12-03T15:39:47.898Z + + + <p>我的 golang 学习笔记。好几年前就说要学了,现在终于兑现。</p> + + + + + + + + + + 在 JetBrains IDE 中向 Markdown 粘贴图片 + + https://wxsm.space/2021/paste-image-into-markdown-in-jetbrains-ide/ + 2021-09-30T01:14:26.910Z + 2023-12-03T15:39:47.910Z + + + <p>其实不需要装任何插件,IDE 自带的 Markdown 插件即可支持该操作:</p> +<ol> +<li>使用任意截图软件截图到剪贴板;</li> +<li>Ctrl + V 复制到编辑器中;</li> +<li>IDE 会自动生成图片文件 <code>img.png</code>(如果已存在,则会加自增后缀),以及相应的 Markdown 标签 <code>![img.png](img.png)</code>。</li> +</ol> +<p>但是,默认的插件不能配置保存路径(只能是 markdown 文件所在的路径),也不能配置命名规则,因此找了一个插件来增强这个功能。</p> + + + + + + + + + + + + 前端 MVC 的未来:浅谈 Hooks 与 VCA 在设计思路上的异同 + + https://wxsm.space/2021/react-hooks-vs-vca/ + 2021-07-28T01:18:04.925Z + 2023-12-03T15:39:47.910Z + + + <p>关于 React Hooks 与 Vue Composite API:</p> +<ul> +<li>React 16.8 新增了 <a href="https://reactjs.org/docs/hooks-intro.html">Hooks API</a> (简称 hooks)</li> +<li>Vue 3.0 新增了 <a href="https://v3.cn.vuejs.org/guide/composition-api-introduction.html">Composite API</a> (简称 VCA)</li> +</ul> +<p>二者为了共同的目的,在接近的时间点,以非常相似但是又带有本质区别的方式,推出了各自对于未来前端代码结构发展的新思路。本文在对二者做一些简单介绍的同时,也会重点关注二者之间的统一与区别。</p> + + + + + + + + + + + + + + 比较简单的 GitHub 加速方式 + + https://wxsm.space/2021/a-simple-way-to-speed-up-github-connection/ + 2021-07-25T19:39:37.923Z + 2023-12-03T15:39:47.882Z + + + + + + + <p>在不想全局 vpn 的情况下,可以用 host 加速。</p> +<p>该方法主要利用 <a href="https://github.com/ineo6/hosts">github.com&#x2F;ineo6&#x2F;hosts</a> 的 hosts 文件,国内镜像 + + + + + + + + + + + + + + 正则断言 + + https://wxsm.space/2021/regex-assertions/ + 2021-04-03T03:59:18.278Z + 2023-12-03T15:39:47.914Z + + + <blockquote> +<p>Assertions include boundaries, which indicate the beginnings and endings of lines and words, and other patterns indicating in some way that a match is possible (including look-ahead, look-behind, and conditional expressions).</p> +</blockquote> +<p>断言是正则表达式组成的一部分,包含两种断言。本文记录了一些常用断言。</p> + + + + + + + + + + + + 小程序单元测试最佳实践 + + https://wxsm.space/2021/unit-test-best-practice-of-mini-program/ + 2021-03-14T20:26:49.652Z + 2023-12-03T15:39:47.918Z + + + <p>微信小程序单元测试的可查资料少得可怜,由于微信官方开发的自动化测试驱动器 <a href="https://www.npmjs.com/package/miniprogram-automator">miniprogram-automator</a> 不开源,唯一靠谱的地方只有这 <a href="https://developers.weixin.qq.com/miniprogram/dev/devtools/auto/">一份简单的文档</a>。然而实际使用下来发现文档介绍的方式有不少问题。</p> + + + + + + + + + + + + + + 2021 春节 + + https://wxsm.space/2021/2021-spring-festival/ + 2021-02-17T21:01:23.779Z + 2023-12-03T15:39:47.882Z + + + <p>今年疫情原因,本来不是很想回家过年的,想着工作累了,在珠海(中山)做几天废人也不错。但是现在回想起来,虽然家里比较小也比较无聊,逢年过节还是应该回家看看。</p> + + + + + + + + + diff --git a/css/main.css b/css/main.css new file mode 100644 index 000000000..10fb9aba3 --- /dev/null +++ b/css/main.css @@ -0,0 +1,2942 @@ +:root { + --body-bg-color: #fff; + --content-bg-color: #fff; + --card-bg-color: #f5f5f5; + --text-color: #555; + --blockquote-color: #666; + --link-color: #555; + --link-hover-color: #222; + --brand-color: #fff; + --brand-hover-color: #fff; + --table-row-odd-bg-color: #f9f9f9; + --table-row-hover-bg-color: #f5f5f5; + --menu-item-bg-color: #f5f5f5; + --theme-color: #222; + --btn-default-bg: #222; + --btn-default-color: #fff; + --btn-default-border-color: #222; + --btn-default-hover-bg: #fff; + --btn-default-hover-color: #222; + --btn-default-hover-border-color: #222; + --highlight-background: #f0f0f0; + --highlight-foreground: #444; + --highlight-gutter-background: #dedede; + --highlight-gutter-foreground: #555; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root { + --body-bg-color: #282828; + --content-bg-color: #333; + --card-bg-color: #555; + --text-color: #ccc; + --blockquote-color: #bbb; + --link-color: #ccc; + --link-hover-color: #eee; + --brand-color: #ddd; + --brand-hover-color: #ddd; + --table-row-odd-bg-color: #282828; + --table-row-hover-bg-color: #363636; + --menu-item-bg-color: #555; + --theme-color: #222; + --btn-default-bg: #222; + --btn-default-color: #ccc; + --btn-default-border-color: #555; + --btn-default-hover-bg: #666; + --btn-default-hover-color: #ccc; + --btn-default-hover-border-color: #666; + --highlight-background: #1d1f21; + --highlight-foreground: #c5c8c6; + --highlight-gutter-background: #2d2f31; + --highlight-gutter-foreground: #b4b7b5; + color-scheme: dark; + } + img { + opacity: 0.75; + } + img:hover { + opacity: 0.9; + } + iframe { + color-scheme: light; + } +} +html { + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} +body { + margin: 0; +} +main { + display: block; +} +h1 { + font-size: 2em; + margin: 0.67em 0; +} +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} +a { + background: transparent; +} +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} +b, +strong { + font-weight: bolder; +} +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} +small { + font-size: 80%; +} +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} +sub { + bottom: -0.25em; +} +sup { + top: -0.5em; +} +img { + border-style: none; +} +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} +button, +input { +/* 1 */ + overflow: visible; +} +button, +select { +/* 1 */ + text-transform: none; +} +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; +} +button::-moz-focus-inner, +[type='button']::-moz-focus-inner, +[type='reset']::-moz-focus-inner, +[type='submit']::-moz-focus-inner { + border-style: none; + padding: 0; +} +button:-moz-focusring, +[type='button']:-moz-focusring, +[type='reset']:-moz-focusring, +[type='submit']:-moz-focusring { + outline: 1px dotted ButtonText; +} +fieldset { + padding: 0.35em 0.75em 0.625em; +} +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} +progress { + vertical-align: baseline; +} +textarea { + overflow: auto; +} +[type='checkbox'], +[type='radio'] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} +[type='number']::-webkit-inner-spin-button, +[type='number']::-webkit-outer-spin-button { + height: auto; +} +[type='search'] { + outline-offset: -2px; /* 2 */ + -webkit-appearance: textfield; /* 1 */ +} +[type='search']::-webkit-search-decoration { + -webkit-appearance: none; +} +::-webkit-file-upload-button { + font: inherit; /* 2 */ + -webkit-appearance: button; /* 1 */ +} +details { + display: block; +} +summary { + display: list-item; +} +template { + display: none; +} +[hidden] { + display: none; +} +::selection { + background: #262a30; + color: #eee; +} +html, +body { + height: 100%; +} +body { + background: var(--body-bg-color); + box-sizing: border-box; + color: var(--text-color); + font-family: Georgia, "Nimbus Roman No9 L", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", "Source Han Serif CN", STSong, "AR PL New Sung", "AR PL SungtiL GB", NSimSun, SimSun, "TW\-Sung", "WenQuanYi Bitmap Song", "AR PL UMing CN", "AR PL UMing HK", "AR PL UMing TW", "AR PL UMing TW MBE", PMingLiU, MingLiU, serif, 'PingFang SC', 'Microsoft YaHei', sans-serif; + font-size: 1em; + line-height: 2; + min-height: 100%; + position: relative; + transition: padding 0.2s ease-in-out; +} +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: Georgia, "Nimbus Roman No9 L", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", "Source Han Serif CN", STSong, "AR PL New Sung", "AR PL SungtiL GB", NSimSun, SimSun, "TW\-Sung", "WenQuanYi Bitmap Song", "AR PL UMing CN", "AR PL UMing HK", "AR PL UMing TW", "AR PL UMing TW MBE", PMingLiU, MingLiU, serif, 'PingFang SC', 'Microsoft YaHei', sans-serif; + font-weight: bold; + line-height: 1.5; + margin: 30px 0 15px; +} +h1 { + font-size: 1.5em; +} +h2 { + font-size: 1.375em; +} +h3 { + font-size: 1.25em; +} +h4 { + font-size: 1.125em; +} +h5 { + font-size: 1em; +} +h6 { + font-size: 0.875em; +} +p { + margin: 0 0 20px; +} +a { + border-bottom: 1px solid #999; + color: var(--link-color); + cursor: pointer; + outline: 0; + text-decoration: none; + overflow-wrap: break-word; +} +a:hover { + border-bottom-color: var(--link-hover-color); + color: var(--link-hover-color); +} +iframe, +img, +video, +embed { + display: block; + margin-left: auto; + margin-right: auto; + max-width: 100%; +} +hr { + background-image: repeating-linear-gradient(-45deg, #ddd, #ddd 4px, transparent 4px, transparent 8px); + border: 0; + height: 3px; + margin: 40px 0; +} +blockquote { + border-left: 4px solid #ddd; + color: var(--blockquote-color); + margin: 0; + padding: 0 15px; +} +blockquote cite::before { + content: '-'; + padding: 0 5px; +} +dt { + font-weight: bold; +} +dd { + margin: 0; + padding: 0; +} +.table-container { + overflow: auto; +} +table { + border-collapse: collapse; + border-spacing: 0; + font-size: 0.875em; + margin: 0 0 20px; + width: 100%; +} +tbody tr:nth-of-type(odd) { + background: var(--table-row-odd-bg-color); +} +tbody tr:hover { + background: var(--table-row-hover-bg-color); +} +caption, +th, +td { + padding: 8px; +} +th, +td { + border: 1px solid #ddd; + border-bottom: 3px solid #ddd; +} +th { + font-weight: 700; + padding-bottom: 10px; +} +td { + border-bottom-width: 1px; +} +.btn { + background: var(--btn-default-bg); + border: 2px solid var(--btn-default-border-color); + border-radius: 0; + color: var(--btn-default-color); + display: inline-block; + font-size: 0.875em; + line-height: 2; + padding: 0 20px; + transition: background-color 0.2s ease-in-out; +} +.btn:hover { + background: var(--btn-default-hover-bg); + border-color: var(--btn-default-hover-border-color); + color: var(--btn-default-hover-color); +} +.btn + .btn { + margin: 0 0 8px 8px; +} +.btn .fa-fw { + text-align: left; + width: 1.285714285714286em; +} +.toggle { + line-height: 0; +} +.toggle .toggle-line { + background: #fff; + display: block; + height: 2px; + left: 0; + position: relative; + top: 0; + transition: all 0.4s; + width: 100%; +} +.toggle .toggle-line:first-child { + margin-top: 1px; +} +.toggle .toggle-line:not(:first-child) { + margin-top: 4px; +} +.toggle.toggle-arrow :first-child { + top: 2px; + transform: rotate(-45deg); + width: 50%; +} +.toggle.toggle-arrow :last-child { + top: -2px; + transform: rotate(45deg); + width: 50%; +} +.toggle.toggle-close :nth-child(2) { + opacity: 0; +} +.toggle.toggle-close :first-child { + top: 6px; + transform: rotate(-45deg); +} +.toggle.toggle-close :last-child { + top: -6px; + transform: rotate(45deg); +} +/* + +Original highlight.js style (c) Ivan Sagalaev + +*/ + +.hljs { + display: block; + overflow-x: auto; + padding: 0.5em; + background: #F0F0F0; +} + + +/* Base color: saturation 0; */ + +.hljs, +.hljs-subst { + color: #444; +} + +.hljs-comment { + color: #888888; +} + +.hljs-keyword, +.hljs-attribute, +.hljs-selector-tag, +.hljs-meta-keyword, +.hljs-doctag, +.hljs-name { + font-weight: bold; +} + + +/* User color: hue: 0 */ + +.hljs-type, +.hljs-string, +.hljs-number, +.hljs-selector-id, +.hljs-selector-class, +.hljs-quote, +.hljs-template-tag, +.hljs-deletion { + color: #880000; +} + +.hljs-title, +.hljs-section { + color: #880000; + font-weight: bold; +} + +.hljs-regexp, +.hljs-symbol, +.hljs-variable, +.hljs-template-variable, +.hljs-link, +.hljs-selector-attr, +.hljs-selector-pseudo { + color: #BC6060; +} + + +/* Language color: hue: 90; */ + +.hljs-literal { + color: #78A960; +} + +.hljs-built_in, +.hljs-bullet, +.hljs-code, +.hljs-addition { + color: #397300; +} + + +/* Meta color: hue: 200 */ + +.hljs-meta { + color: #1f7199; +} + +.hljs-meta-string { + color: #4d99bf; +} + + +/* Misc effects */ + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} + +@media (prefers-color-scheme: dark) { +/* Tomorrow Night Theme */ +/* http://jmblog.github.com/color-themes-for-google-code-highlightjs */ +/* Original theme - https://github.com/chriskempson/tomorrow-theme */ +/* http://jmblog.github.com/color-themes-for-google-code-highlightjs */ + +/* Tomorrow Comment */ +.hljs-comment, +.hljs-quote { + color: #969896; +} + +/* Tomorrow Red */ +.hljs-variable, +.hljs-template-variable, +.hljs-tag, +.hljs-name, +.hljs-selector-id, +.hljs-selector-class, +.hljs-regexp, +.hljs-deletion { + color: #cc6666; +} + +/* Tomorrow Orange */ +.hljs-number, +.hljs-built_in, +.hljs-builtin-name, +.hljs-literal, +.hljs-type, +.hljs-params, +.hljs-meta, +.hljs-link { + color: #de935f; +} + +/* Tomorrow Yellow */ +.hljs-attribute { + color: #f0c674; +} + +/* Tomorrow Green */ +.hljs-string, +.hljs-symbol, +.hljs-bullet, +.hljs-addition { + color: #b5bd68; +} + +/* Tomorrow Blue */ +.hljs-title, +.hljs-section { + color: #81a2be; +} + +/* Tomorrow Purple */ +.hljs-keyword, +.hljs-selector-tag { + color: #b294bb; +} + +.hljs { + display: block; + overflow-x: auto; + background: #1d1f21; + color: #c5c8c6; + padding: 0.5em; +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} + +} +.highlight:hover .copy-btn, +.code-container:hover .copy-btn { + opacity: 1; +} +.code-container { + position: relative; +} +.copy-btn { + color: #333; + cursor: pointer; + line-height: 1.6; + opacity: 0; + padding: 2px 6px; + position: absolute; + transition: opacity 0.2s ease-in-out; + color: var(--highlight-foreground); + font-size: 14px; + right: 0; + top: 2px; +} +figure.highlight { + border-radius: 5px; + box-shadow: 0 10px 30px 0 rgba(0,0,0,0.4); + padding-top: 30px; +} +figure.highlight .table-container { + border-radius: 0 0 5px 5px; +} +figure.highlight::before { + background: #fc625d; + box-shadow: 20px 0 #fdbc40, 40px 0 #35cd4b; + left: 12px; + margin-top: -20px; + position: absolute; + border-radius: 50%; + content: ' '; + height: 12px; + width: 12px; +} +code, +kbd, +figure.highlight, +pre { + background: var(--highlight-background); + color: var(--highlight-foreground); +} +figure.highlight, +pre { + line-height: 1.6; + margin: 0 auto 20px; +} +figure.highlight figcaption, +pre .caption, +pre figcaption { + background: var(--highlight-gutter-background); + color: var(--highlight-foreground); + display: flow-root; + font-size: 0.875em; + line-height: 1.2; + padding: 0.5em; +} +figure.highlight figcaption a, +pre .caption a, +pre figcaption a { + color: var(--highlight-foreground); + float: right; +} +figure.highlight figcaption a:hover, +pre .caption a:hover, +pre figcaption a:hover { + border-bottom-color: var(--highlight-foreground); +} +pre, +code { + font-family: consolas, Menlo, monospace, 'PingFang SC', 'Microsoft YaHei'; +} +code { + border-radius: 3px; + font-size: 0.875em; + padding: 2px 4px; + overflow-wrap: break-word; +} +kbd { + border: 2px solid #ccc; + border-radius: 0.2em; + box-shadow: 0.1em 0.1em 0.2em rgba(0,0,0,0.1); + font-family: inherit; + padding: 0.1em 0.3em; + white-space: nowrap; +} +figure.highlight { + overflow: auto; + position: relative; +} +figure.highlight pre { + border: 0; + margin: 0; + padding: 10px 0; +} +figure.highlight table { + border: 0; + margin: 0; + width: auto; +} +figure.highlight td { + border: 0; + padding: 0; +} +figure.highlight .gutter { + -moz-user-select: none; + -ms-user-select: none; + -webkit-user-select: none; + user-select: none; +} +figure.highlight .gutter pre { + background: var(--highlight-gutter-background); + color: var(--highlight-gutter-foreground); + padding-left: 10px; + padding-right: 10px; + text-align: right; +} +figure.highlight .code pre { + padding-left: 10px; + width: 100%; +} +figure.highlight .marked { + background: rgba(0,0,0,0.3); +} +pre .caption, +pre figcaption { + margin-bottom: 10px; +} +.gist table { + width: auto; +} +.gist table td { + border: 0; +} +pre { + overflow: auto; + padding: 10px; +} +pre code { + background: none; + padding: 0; + text-shadow: none; +} +.blockquote-center { + border-left: 0; + margin: 40px 0; + padding: 0; + position: relative; + text-align: center; +} +.blockquote-center::before, +.blockquote-center::after { + left: 0; + line-height: 1; + opacity: 0.6; + position: absolute; + width: 100%; +} +.blockquote-center::before { + border-top: 1px solid #ccc; + text-align: left; + top: -20px; + content: '\f10d'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; +} +.blockquote-center::after { + border-bottom: 1px solid #ccc; + bottom: -20px; + text-align: right; + content: '\f10e'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; +} +.blockquote-center p, +.blockquote-center div { + text-align: center; +} +.group-picture { + margin-bottom: 20px; +} +.group-picture .group-picture-row { + display: flex; + gap: 3px; + margin-bottom: 3px; +} +.group-picture .group-picture-column { + flex: 1; +} +.group-picture .group-picture-column img { + height: 100%; + margin: 0; + object-fit: cover; + width: 100%; +} +.post-body .label { + color: #555; + padding: 0 2px; +} +.post-body .label.default { + background: #f0f0f0; +} +.post-body .label.primary { + background: #efe6f7; +} +.post-body .label.info { + background: #e5f2f8; +} +.post-body .label.success { + background: #e7f4e9; +} +.post-body .label.warning { + background: #fcf6e1; +} +.post-body .label.danger { + background: #fae8eb; +} +.post-body .link-grid { + display: grid; + grid-gap: 1.5rem; + gap: 1.5rem; + grid-template-columns: 1fr 1fr; + margin-bottom: 20px; + padding: 1rem; +} +@media (max-width: 767px) { + .post-body .link-grid { + grid-template-columns: 1fr; + } +} +.post-body .link-grid .link-grid-container { + border: solid #ddd; + box-shadow: 1rem 1rem 0.5rem rgba(0,0,0,0.5); + min-height: 5rem; + min-width: 0; + padding: 0.5rem; + position: relative; + transition: background 0.3s; +} +.post-body .link-grid .link-grid-container:hover { + animation: next-shake 0.5s; + background: var(--card-bg-color); +} +.post-body .link-grid .link-grid-container:active { + box-shadow: 0.5rem 0.5rem 0.25rem rgba(0,0,0,0.5); + transform: translate(0.2rem, 0.2rem); +} +.post-body .link-grid .link-grid-container .link-grid-image { + border: 1px solid #ddd; + border-radius: 50%; + box-sizing: border-box; + height: 5rem; + padding: 3px; + position: absolute; + width: 5rem; +} +.post-body .link-grid .link-grid-container p { + margin: 0 1rem 0 6rem; +} +.post-body .link-grid .link-grid-container p:first-of-type { + font-size: 1.2em; +} +.post-body .link-grid .link-grid-container p:last-of-type { + font-size: 0.8em; + line-height: 1.3rem; + opacity: 0.7; +} +.post-body .link-grid .link-grid-container a { + border: 0; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +@keyframes next-shake { + 0% { + transform: translate(1pt, 1pt) rotate(0deg); + } + 10% { + transform: translate(-1pt, -2pt) rotate(-1deg); + } + 20% { + transform: translate(-3pt, 0pt) rotate(1deg); + } + 30% { + transform: translate(3pt, 2pt) rotate(0deg); + } + 40% { + transform: translate(1pt, -1pt) rotate(1deg); + } + 50% { + transform: translate(-1pt, 2pt) rotate(-1deg); + } + 60% { + transform: translate(-3pt, 1pt) rotate(0deg); + } + 70% { + transform: translate(3pt, 1pt) rotate(-1deg); + } + 80% { + transform: translate(-1pt, -1pt) rotate(1deg); + } + 90% { + transform: translate(1pt, 2pt) rotate(0deg); + } + 100% { + transform: translate(1pt, -2pt) rotate(-1deg); + } +} +.mermaid { + margin-bottom: 20px; + text-align: center; +} +.post-body .note { + border-radius: 3px; + margin-bottom: 20px; + padding: 1em; + position: relative; + background: #f9f9f9; + border: initial; + border-left: 3px solid #eee; +} +.post-body .note summary { + cursor: pointer; + outline: 0; +} +.post-body .note summary p { + display: inline; +} +.post-body .note h2, +.post-body .note h3, +.post-body .note h4, +.post-body .note h5, +.post-body .note h6 { + border-bottom: initial; + margin: 0; + padding-top: 0; +} +.post-body .note :first-child { + margin-top: 0; +} +.post-body .note :last-child { + margin-bottom: 0; +} +.post-body .note:not(.no-icon) { + padding-left: 2.5em; +} +.post-body .note:not(.no-icon)::before { + font-size: 1.5em; + left: 0.3em; + position: absolute; + top: calc(50% - 1em); +} +.post-body .note.default { + background: #f7f7f7; + border-left-color: #777; +} +@media (prefers-color-scheme: dark) { + .post-body .note.default { + background: #3c3c3c; + } +} +.post-body .note.default h2, +.post-body .note.default h3, +.post-body .note.default h4, +.post-body .note.default h5, +.post-body .note.default h6 { + color: #777; +} +.post-body .note.default:not(.no-icon)::before { + content: '\f0a9'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; + color: #777; +} +.post-body .note.primary { + background: #f5f0fa; + border-left-color: #6f42c1; +} +@media (prefers-color-scheme: dark) { + .post-body .note.primary { + background: #3c3b3c; + } +} +.post-body .note.primary h2, +.post-body .note.primary h3, +.post-body .note.primary h4, +.post-body .note.primary h5, +.post-body .note.primary h6 { + color: #6f42c1; +} +.post-body .note.primary:not(.no-icon)::before { + content: '\f055'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; + color: #6f42c1; +} +.post-body .note.info { + background: #eef7fa; + border-left-color: #428bca; +} +@media (prefers-color-scheme: dark) { + .post-body .note.info { + background: #3b3c3c; + } +} +.post-body .note.info h2, +.post-body .note.info h3, +.post-body .note.info h4, +.post-body .note.info h5, +.post-body .note.info h6 { + color: #428bca; +} +.post-body .note.info:not(.no-icon)::before { + content: '\f05a'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; + color: #428bca; +} +.post-body .note.success { + background: #eff8f0; + border-left-color: #5cb85c; +} +@media (prefers-color-scheme: dark) { + .post-body .note.success { + background: #3b3c3b; + } +} +.post-body .note.success h2, +.post-body .note.success h3, +.post-body .note.success h4, +.post-body .note.success h5, +.post-body .note.success h6 { + color: #5cb85c; +} +.post-body .note.success:not(.no-icon)::before { + content: '\f058'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; + color: #5cb85c; +} +.post-body .note.warning { + background: #fdf8ea; + border-left-color: #f0ad4e; +} +@media (prefers-color-scheme: dark) { + .post-body .note.warning { + background: #3d3c3b; + } +} +.post-body .note.warning h2, +.post-body .note.warning h3, +.post-body .note.warning h4, +.post-body .note.warning h5, +.post-body .note.warning h6 { + color: #f0ad4e; +} +.post-body .note.warning:not(.no-icon)::before { + content: '\f06a'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; + color: #f0ad4e; +} +.post-body .note.danger { + background: #fcf1f2; + border-left-color: #d9534f; +} +@media (prefers-color-scheme: dark) { + .post-body .note.danger { + background: #3d3c3c; + } +} +.post-body .note.danger h2, +.post-body .note.danger h3, +.post-body .note.danger h4, +.post-body .note.danger h5, +.post-body .note.danger h6 { + color: #d9534f; +} +.post-body .note.danger:not(.no-icon)::before { + content: '\f056'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; + color: #d9534f; +} +.post-body .tabs { + margin-bottom: 20px; +} +.post-body .tabs, +.tabs-comment { + padding-top: 10px; +} +.post-body .tabs ul.nav-tabs, +.tabs-comment ul.nav-tabs { + background: var(--body-bg-color); + display: flex; + display: flex; + flex-wrap: wrap; + justify-content: center; + margin: 0; + padding: 0; + position: -webkit-sticky; + position: sticky; + top: 0; + z-index: 5; +} +@media (max-width: 413px) { + .post-body .tabs ul.nav-tabs, + .tabs-comment ul.nav-tabs { + display: block; + margin-bottom: 5px; + } +} +.post-body .tabs ul.nav-tabs li.tab, +.tabs-comment ul.nav-tabs li.tab { + border-bottom: 1px solid #ddd; + border-left: 1px solid transparent; + border-right: 1px solid transparent; + border-radius: 0 0 0 0; + border-top: 3px solid transparent; + flex-grow: 1; + list-style-type: none; +} +@media (max-width: 413px) { + .post-body .tabs ul.nav-tabs li.tab, + .tabs-comment ul.nav-tabs li.tab { + border-bottom: 1px solid transparent; + border-left: 3px solid transparent; + border-right: 1px solid transparent; + border-top: 1px solid transparent; + } +} +@media (max-width: 413px) { + .post-body .tabs ul.nav-tabs li.tab, + .tabs-comment ul.nav-tabs li.tab { + border-radius: 0; + } +} +.post-body .tabs ul.nav-tabs li.tab a, +.tabs-comment ul.nav-tabs li.tab a { + border-bottom: initial; + display: block; + line-height: 1.8; + padding: 0.25em 0.75em; + text-align: center; + transition: all 0.2s ease-out; +} +.post-body .tabs ul.nav-tabs li.tab a i[class^='fa'], +.tabs-comment ul.nav-tabs li.tab a i[class^='fa'] { + width: 1.285714285714286em; +} +.post-body .tabs ul.nav-tabs li.tab.active, +.tabs-comment ul.nav-tabs li.tab.active { + border-bottom-color: transparent; + border-left-color: #ddd; + border-right-color: #ddd; + border-top-color: #fc6423; +} +@media (max-width: 413px) { + .post-body .tabs ul.nav-tabs li.tab.active, + .tabs-comment ul.nav-tabs li.tab.active { + border-bottom-color: #ddd; + border-left-color: #fc6423; + border-right-color: #ddd; + border-top-color: #ddd; + } +} +.post-body .tabs ul.nav-tabs li.tab.active a, +.tabs-comment ul.nav-tabs li.tab.active a { + cursor: default; +} +.post-body .tabs .tab-content, +.tabs-comment .tab-content { + border: 1px solid #ddd; + border-radius: 0 0 0 0; + border-top-color: transparent; +} +@media (max-width: 413px) { + .post-body .tabs .tab-content, + .tabs-comment .tab-content { + border-radius: 0; + border-top-color: #ddd; + } +} +.post-body .tabs .tab-content .tab-pane, +.tabs-comment .tab-content .tab-pane { + padding: 20px 20px 0; +} +.post-body .tabs .tab-content .tab-pane:not(.active), +.tabs-comment .tab-content .tab-pane:not(.active) { + display: none; +} +.pagination .prev, +.pagination .next, +.pagination .page-number, +.pagination .space { + display: inline-block; + margin: -1px 10px 0; + padding: 0 10px; +} +@media (max-width: 767px) { + .pagination .prev, + .pagination .next, + .pagination .page-number, + .pagination .space { + margin: 0 5px; + } +} +.pagination .page-number.current { + background: #ccc; + border-color: #ccc; + color: var(--content-bg-color); +} +.pagination { + border-top: 1px solid #eee; + margin: 120px 0 0; + text-align: center; +} +.pagination .prev, +.pagination .next, +.pagination .page-number { + border-bottom: 0; + border-top: 1px solid #eee; + transition: border-color 0.2s ease-in-out; +} +.pagination .prev:hover, +.pagination .next:hover, +.pagination .page-number:hover { + border-top-color: var(--link-hover-color); +} +@media (max-width: 767px) { + .pagination { + border-top: 0; + } + .pagination .prev, + .pagination .next, + .pagination .page-number { + border-bottom: 1px solid #eee; + border-top: 0; + } + .pagination .prev:hover, + .pagination .next:hover, + .pagination .page-number:hover { + border-bottom-color: var(--link-hover-color); + } +} +.pagination .space { + margin: 0; + padding: 0; +} +.comments { + margin-top: 60px; + overflow: hidden; +} +.comment-button-group { + display: flex; + display: flex; + flex-wrap: wrap; + justify-content: center; + justify-content: center; + margin: 1em 0; +} +.comment-button-group .comment-button { + margin: 0.1em 0.2em; +} +.comment-button-group .comment-button.active { + background: var(--btn-default-hover-bg); + border-color: var(--btn-default-hover-border-color); + color: var(--btn-default-hover-color); +} +.comment-position { + display: none; +} +.comment-position.active { + display: block; +} +.tabs-comment { + margin-top: 4em; + padding-top: 0; +} +.tabs-comment .comments { + margin-top: 0; + padding-top: 0; +} +.headband { + background: var(--theme-color); + height: 3px; +} +@media (max-width: 991px) { + .headband { + display: none; + } +} +.site-brand-container { + display: flex; + flex-shrink: 0; + padding: 0 10px; +} +.use-motion .column, +.use-motion .site-brand-container .toggle { + opacity: 0; +} +.site-meta { + flex-grow: 1; + text-align: center; +} +@media (max-width: 767px) { + .site-meta { + text-align: center; + } +} +.custom-logo-image { + margin-top: 20px; +} +@media (max-width: 991px) { + .custom-logo-image { + display: none; + } +} +.brand { + border-bottom: 0; + color: var(--brand-color); + display: inline-block; + padding: 0 40px; +} +.brand:hover { + color: var(--brand-hover-color); +} +.site-title { + font-family: Georgia, "Nimbus Roman No9 L", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", "Source Han Serif CN", STSong, "AR PL New Sung", "AR PL SungtiL GB", NSimSun, SimSun, "TW\-Sung", "WenQuanYi Bitmap Song", "AR PL UMing CN", "AR PL UMing HK", "AR PL UMing TW", "AR PL UMing TW MBE", PMingLiU, MingLiU, serif, 'PingFang SC', 'Microsoft YaHei', sans-serif; + font-size: 1.375em; + font-weight: normal; + line-height: 1.5; + margin: 0; +} +.site-subtitle { + color: #999; + font-size: 0.8125em; + margin: 10px 0; +} +.use-motion .site-title, +.use-motion .site-subtitle, +.use-motion .custom-logo-image { + opacity: 0; + position: relative; + top: -10px; +} +.site-nav-toggle, +.site-nav-right { + display: none; +} +@media (max-width: 767px) { + .site-nav-toggle, + .site-nav-right { + display: flex; + flex-direction: column; + justify-content: center; + } +} +.site-nav-toggle .toggle, +.site-nav-right .toggle { + color: var(--text-color); + padding: 10px; + width: 22px; +} +.site-nav-toggle .toggle .toggle-line, +.site-nav-right .toggle .toggle-line { + background: var(--text-color); + border-radius: 1px; +} +@media (max-width: 767px) { + .site-nav { + --scroll-height: 0; + height: 0; + overflow: hidden; + transition: 0.2s ease-in-out; + transition-property: height, visibility; + visibility: hidden; + } + body:not(.site-nav-on) .site-nav .animated { + animation: none; + } + body.site-nav-on .site-nav { + height: var(--scroll-height); + visibility: unset; + } +} +.menu { + margin: 0; + padding: 1em 0; + text-align: center; +} +.menu-item { + display: inline-block; + list-style: none; + margin: 0 10px; +} +@media (max-width: 767px) { + .menu-item { + display: block; + margin-top: 10px; + } + .menu-item.menu-item-search { + display: none; + } +} +.menu-item a { + border-bottom: 0; + display: block; + font-size: 0.8125em; + transition: border-color 0.2s ease-in-out; +} +.menu-item a:hover, +.menu-item a.menu-item-active { + background: var(--menu-item-bg-color); +} +.menu-item i[class^='fa'] { + margin-right: 8px; +} +.menu-item .badge { + display: inline-block; + font-weight: bold; + line-height: 1; + margin-left: 0.35em; + margin-top: 0.35em; + text-align: center; + white-space: nowrap; +} +@media (max-width: 767px) { + .menu-item .badge { + float: right; + margin-left: 0; + } +} +.use-motion .menu-item { + visibility: hidden; +} +.sidebar-inner { + color: #999; + padding: 18px 10px; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; +} +.cc-license .cc-opacity { + border-bottom: 0; + opacity: 0.7; +} +.cc-license .cc-opacity:hover { + opacity: 0.9; +} +.cc-license img { + display: inline-block; +} +.site-author-image { + border: 2px solid #333; + max-width: 96px; + padding: 2px; + border-radius: 50%; +} +.site-author-name { + color: #f5f5f5; + font-weight: normal; + margin: 5px 0 0; +} +.site-description { + color: #999; + font-size: 1em; + margin-top: 5px; +} +.links-of-author a { + font-size: 0.8125em; +} +.sidebar .sidebar-button:not(:first-child) { + margin-top: 15px; +} +.sidebar .sidebar-button button { + background: transparent; + color: #fc6423; + cursor: pointer; + line-height: 2; + padding: 0 15px; + border: 1px solid #fc6423; + border-radius: 4px; +} +.sidebar .sidebar-button button:hover { + background: #fc6423; + color: #fff; +} +.sidebar .sidebar-button button i[class^='fa'] { + margin-right: 5px; +} +.links-of-blogroll { + font-size: 0.8125em; +} +.links-of-blogroll-title { + font-size: 0.875em; + font-weight: 600; +} +.links-of-blogroll-list { + list-style: none; + margin: 0; + padding: 0; +} +.sidebar-nav { + font-size: 0.875em; + height: 0; + margin: 0; + overflow: hidden; + padding-left: 0; + pointer-events: none; + transition: 0.2s ease-in-out; + transition-property: height, visibility; + visibility: hidden; +} +.sidebar-nav-active .sidebar-nav { + height: calc(2em + 1px); + pointer-events: unset; + visibility: unset; +} +.sidebar-nav li { + border-bottom: 1px solid transparent; + color: #666; + cursor: pointer; + display: inline-block; + transition: 0.2s ease-in-out; + transition-property: border-bottom-color, color; +} +.sidebar-nav li.sidebar-nav-overview { + margin-left: 10px; +} +.sidebar-nav li:hover { + color: #f5f5f5; +} +.sidebar-toc-active .sidebar-nav-toc, +.sidebar-overview-active .sidebar-nav-overview { + border-bottom-color: #87daff; + color: #87daff; + transition-delay: 0.2s; +} +.sidebar-toc-active .sidebar-nav-toc:hover, +.sidebar-overview-active .sidebar-nav-overview:hover { + color: #87daff; +} +.sidebar-panel-container { + align-items: start; + display: grid; + flex: 1; + overflow-x: hidden; + overflow-y: auto; + padding-top: 0; + transition: padding-top 0.2s ease-in-out; +} +.sidebar-nav-active .sidebar-panel-container { + padding-top: 20px; +} +.sidebar-panel { + animation: deactivate-sidebar-panel 0.2s ease-in-out; + grid-area: 1/1; + height: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; + transform: translateY(0); + transition: 0.2s ease-in-out; + transition-delay: 0s; + transition-property: opacity, transform, visibility; + visibility: hidden; +} +.sidebar-nav-active .sidebar-panel, +.sidebar-overview-active .sidebar-panel.post-toc-wrap { + transform: translateY(-20px); +} +.sidebar-overview-active:not(.sidebar-nav-active) .sidebar-panel.post-toc-wrap { + transition-delay: 0s, 0.2s, 0s; +} +.sidebar-overview-active .sidebar-panel.site-overview-wrap, +.sidebar-toc-active .sidebar-panel.post-toc-wrap { + animation-name: activate-sidebar-panel; + height: auto; + opacity: 1; + pointer-events: unset; + transform: translateY(0); + transition-delay: 0.2s, 0.2s, 0s; + visibility: unset; +} +.sidebar-panel.site-overview-wrap { + display: flex; + flex-direction: column; + justify-content: center; + gap: 10px; + justify-content: flex-start; +} +@keyframes deactivate-sidebar-panel { + from { + height: var(--inactive-panel-height, 0); + } + to { + height: var(--active-panel-height, 0); + } +} +@keyframes activate-sidebar-panel { + from { + height: var(--inactive-panel-height, auto); + } + to { + height: var(--active-panel-height, auto); + } +} +.sidebar-toggle { + bottom: 61px; + height: 16px; + padding: 5px; + width: 16px; + background: #222; + cursor: pointer; + opacity: 0.8; + position: fixed; + z-index: 30; + right: 30px; +} +@media (max-width: 991px) { + .sidebar-toggle { + right: 20px; + } +} +.sidebar-toggle:hover { + opacity: 1; +} +@media (max-width: 991px) { + .sidebar-toggle { + opacity: 1; + } +} +.sidebar-toggle:hover .toggle-line { + background: #87daff; +} +@media (any-hover: hover) { + body:not(.sidebar-active) .sidebar-toggle:hover :first-child { + top: 2px; + transform: rotate(-45deg); + width: 50%; + } + body:not(.sidebar-active) .sidebar-toggle:hover :last-child { + top: -2px; + transform: rotate(45deg); + width: 50%; + } +} +.sidebar-active .sidebar-toggle :nth-child(2) { + opacity: 0; +} +.sidebar-active .sidebar-toggle :first-child { + top: 6px; + transform: rotate(-45deg); +} +.sidebar-active .sidebar-toggle :last-child { + top: -6px; + transform: rotate(45deg); +} +.post-toc { + font-size: 0.875em; +} +.post-toc ol { + list-style: none; + margin: 0; + padding: 0 2px 0 10px; + text-align: left; +} +.post-toc ol > :last-child { + margin-bottom: 5px; +} +.post-toc ol > ol { + padding-left: 0; +} +.post-toc ol a { + transition: all 0.2s ease-in-out; +} +.post-toc .nav-item { + line-height: 1.8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.post-toc .nav .active > a { + border-bottom-color: #87daff; + color: #87daff; +} +.post-toc .nav .active-current > a { + color: #87daff; +} +.post-toc .nav .active-current > a:hover { + color: #87daff; +} +.site-state { + display: flex; + flex-wrap: wrap; + justify-content: center; + line-height: 1.4; +} +.site-state-item { + padding: 0 15px; +} +.site-state-item a { + border-bottom: 0; + display: block; +} +.site-state-item-count { + display: block; + font-size: 1.25em; + font-weight: 600; +} +.site-state-item-name { + color: inherit; + font-size: 0.875em; +} +.footer { + color: #999; + font-size: 0.875em; + padding: 20px 0; + transition: 0.2s ease-in-out; + transition-property: left, right; +} +.footer.footer-fixed { + bottom: 0; + left: 0; + position: absolute; + right: 0; +} +.footer-inner { + box-sizing: border-box; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + margin: 0 auto; + width: 700px; +} +@media (max-width: 767px) { + .footer-inner { + width: auto; + } +} +@media (min-width: 1200px) { + .footer-inner { + width: 800px; + } +} +@media (min-width: 1600px) { + .footer-inner { + width: 900px; + } +} +.use-motion .footer { + opacity: 0; +} +.languages { + display: inline-block; + font-size: 1.125em; + position: relative; +} +.languages .lang-select-label span { + margin: 0 0.5em; +} +.languages .lang-select { + height: 100%; + left: 0; + opacity: 0; + position: absolute; + top: 0; + width: 100%; +} +.with-love { + color: #999; + display: inline-block; + margin: 0 5px; +} +@keyframes icon-animate { + 0%, 100% { + transform: scale(1); + } + 10%, 30% { + transform: scale(0.9); + } + 20%, 40%, 60%, 80% { + transform: scale(1.1); + } + 50%, 70% { + transform: scale(1.1); + } +} +.back-to-top { + font-size: 12px; + align-items: center; + bottom: -100px; + color: #fff; + display: flex; + height: 26px; + transition: bottom 0.2s ease-in-out; + background: #222; + cursor: pointer; + opacity: 0.8; + position: fixed; + z-index: 30; + right: 30px; +} +.back-to-top span { + margin-right: 8px; + display: none; +} +.back-to-top .fa { + text-align: center; + width: 26px; +} +@media (max-width: 991px) { + .back-to-top { + right: 20px; + } +} +.back-to-top:hover { + opacity: 1; +} +@media (max-width: 991px) { + .back-to-top { + opacity: 1; + } +} +.back-to-top:hover { + color: #87daff; +} +.back-to-top.back-to-top-on { + bottom: 30px; +} +.rtl.post-body p, +.rtl.post-body a, +.rtl.post-body h1, +.rtl.post-body h2, +.rtl.post-body h3, +.rtl.post-body h4, +.rtl.post-body h5, +.rtl.post-body h6, +.rtl.post-body li, +.rtl.post-body ul, +.rtl.post-body ol { + direction: rtl; + font-family: UKIJ Ekran; +} +.rtl.post-title { + font-family: UKIJ Ekran; +} +.post-button { + margin-top: 40px; + text-align: center; +} +.use-motion .post-block, +.use-motion .pagination, +.use-motion .comments { + visibility: hidden; +} +.use-motion .post-header { + visibility: hidden; +} +.use-motion .post-body { + visibility: hidden; +} +.use-motion .collection-header { + visibility: hidden; +} +.posts-collapse .post-content { + margin-bottom: 35px; + margin-left: 35px; + position: relative; +} +@media (max-width: 767px) { + .posts-collapse .post-content { + margin-left: 0; + margin-right: 0; + } +} +.posts-collapse .post-content .collection-title { + font-size: 1.125em; + position: relative; +} +.posts-collapse .post-content .collection-title::before { + background: #999; + border: 1px solid #fff; + margin-left: -6px; + margin-top: -4px; + position: absolute; + top: 50%; + border-radius: 50%; + content: ' '; + height: 10px; + width: 10px; +} +.posts-collapse .post-content .collection-year { + font-size: 1.5em; + font-weight: bold; + margin: 60px 0; + position: relative; +} +.posts-collapse .post-content .collection-year::before { + background: #bbb; + margin-left: -4px; + margin-top: -4px; + position: absolute; + top: 50%; + border-radius: 50%; + content: ' '; + height: 8px; + width: 8px; +} +.posts-collapse .post-content .collection-header { + display: block; + margin-left: 20px; +} +.posts-collapse .post-content .collection-header small { + color: #bbb; + margin-left: 5px; +} +.posts-collapse .post-content .post-header { + border-bottom: 1px dashed #ccc; + margin: 30px 2px 0; + padding-left: 15px; + position: relative; + transition: border 0.2s ease-in-out; +} +.posts-collapse .post-content .post-header::before { + background: #bbb; + border: 1px solid #fff; + left: -6px; + position: absolute; + top: 0.75em; + transition: background 0.2s ease-in-out; + border-radius: 50%; + content: ' '; + height: 6px; + width: 6px; +} +.posts-collapse .post-content .post-header:hover { + border-bottom-color: #666; +} +.posts-collapse .post-content .post-header:hover::before { + background: #222; +} +.posts-collapse .post-content .post-meta-container { + display: inline; + font-size: 0.75em; + margin-right: 10px; +} +.posts-collapse .post-content .post-title { + display: inline; +} +.posts-collapse .post-content .post-title a { + border-bottom: 0; + color: var(--link-color); +} +.posts-collapse .post-content::before { + background: #f5f5f5; + content: ' '; + height: 100%; + margin-left: -2px; + position: absolute; + top: 1.25em; + width: 4px; +} +.post-body { + font-family: Georgia, "Nimbus Roman No9 L", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", "Source Han Serif CN", STSong, "AR PL New Sung", "AR PL SungtiL GB", NSimSun, SimSun, "TW\-Sung", "WenQuanYi Bitmap Song", "AR PL UMing CN", "AR PL UMing HK", "AR PL UMing TW", "AR PL UMing TW MBE", PMingLiU, MingLiU, serif, 'PingFang SC', 'Microsoft YaHei', sans-serif; + overflow-wrap: break-word; +} +@media (min-width: 1200px) { + .post-body { + font-size: 1.125em; + } +} +@media (min-width: 992px) { + .post-body { + text-align: justify; + } +} +@media (max-width: 991px) { + .post-body { + text-align: justify; + } +} +.post-body h1 .header-anchor, +.post-body h2 .header-anchor, +.post-body h3 .header-anchor, +.post-body h4 .header-anchor, +.post-body h5 .header-anchor, +.post-body h6 .header-anchor, +.post-body h1 .headerlink, +.post-body h2 .headerlink, +.post-body h3 .headerlink, +.post-body h4 .headerlink, +.post-body h5 .headerlink, +.post-body h6 .headerlink { + border-bottom-style: none; + color: inherit; + float: right; + font-size: 0.875em; + margin-left: 10px; + opacity: 0; +} +.post-body h1 .header-anchor::before, +.post-body h2 .header-anchor::before, +.post-body h3 .header-anchor::before, +.post-body h4 .header-anchor::before, +.post-body h5 .header-anchor::before, +.post-body h6 .header-anchor::before, +.post-body h1 .headerlink::before, +.post-body h2 .headerlink::before, +.post-body h3 .headerlink::before, +.post-body h4 .headerlink::before, +.post-body h5 .headerlink::before, +.post-body h6 .headerlink::before { + content: '\f0c1'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; +} +.post-body h1:hover .header-anchor, +.post-body h2:hover .header-anchor, +.post-body h3:hover .header-anchor, +.post-body h4:hover .header-anchor, +.post-body h5:hover .header-anchor, +.post-body h6:hover .header-anchor, +.post-body h1:hover .headerlink, +.post-body h2:hover .headerlink, +.post-body h3:hover .headerlink, +.post-body h4:hover .headerlink, +.post-body h5:hover .headerlink, +.post-body h6:hover .headerlink { + opacity: 0.5; +} +.post-body h1:hover .header-anchor:hover, +.post-body h2:hover .header-anchor:hover, +.post-body h3:hover .header-anchor:hover, +.post-body h4:hover .header-anchor:hover, +.post-body h5:hover .header-anchor:hover, +.post-body h6:hover .header-anchor:hover, +.post-body h1:hover .headerlink:hover, +.post-body h2:hover .headerlink:hover, +.post-body h3:hover .headerlink:hover, +.post-body h4:hover .headerlink:hover, +.post-body h5:hover .headerlink:hover, +.post-body h6:hover .headerlink:hover { + opacity: 1; +} +.post-body .exturl .fa { + font-size: 0.875em; + margin-left: 4px; +} +.post-body img + figcaption, +.post-body .fancybox + figcaption { + color: #999; + font-size: 0.875em; + font-weight: bold; + line-height: 1; + margin: -15px auto 15px; + text-align: center; +} +.post-body iframe, +.post-body img, +.post-body video, +.post-body embed { + margin-bottom: 20px; +} +.post-body .video-container { + height: 0; + margin-bottom: 20px; + overflow: hidden; + padding-top: 75%; + position: relative; + width: 100%; +} +.post-body .video-container iframe, +.post-body .video-container object, +.post-body .video-container embed { + height: 100%; + left: 0; + margin: 0; + position: absolute; + top: 0; + width: 100%; +} +.post-gallery { + display: flex; + min-height: 200px; +} +.post-gallery .post-gallery-image { + flex: 1; +} +.post-gallery .post-gallery-image:not(:first-child) { + clip-path: polygon(40px 0, 100% 0, 100% 100%, 0 100%); + margin-left: -20px; +} +.post-gallery .post-gallery-image:not(:last-child) { + margin-right: -20px; +} +.post-gallery .post-gallery-image img { + height: 100%; + object-fit: cover; + opacity: 1; + width: 100%; +} +.posts-expand .post-gallery { + margin-bottom: 60px; +} +.posts-collapse .post-gallery { + margin: 15px 0; +} +.posts-expand .post-header { + font-size: 1.125em; + margin-bottom: 60px; + text-align: center; +} +.posts-expand .post-title { + font-size: 1.5em; + font-weight: normal; + margin: initial; + overflow-wrap: break-word; +} +.posts-expand .post-title-link { + border-bottom: 0; + color: var(--link-color); + display: inline-block; + position: relative; +} +.posts-expand .post-title-link::before { + background: var(--link-color); + bottom: 0; + content: ''; + height: 2px; + left: 0; + position: absolute; + transform: scaleX(0); + transition: transform 0.2s ease-in-out; + width: 100%; +} +.posts-expand .post-title-link:hover::before { + transform: scaleX(1); +} +.posts-expand .post-title-link .fa { + font-size: 0.875em; + margin-left: 5px; +} +.post-sticky-flag { + display: inline-block; + margin-right: 8px; + transform: rotate(30deg); +} +.posts-expand .post-meta-container { + color: #999; + font-family: Georgia, "Nimbus Roman No9 L", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", "Source Han Serif CN", STSong, "AR PL New Sung", "AR PL SungtiL GB", NSimSun, SimSun, "TW\-Sung", "WenQuanYi Bitmap Song", "AR PL UMing CN", "AR PL UMing HK", "AR PL UMing TW", "AR PL UMing TW MBE", PMingLiU, MingLiU, serif, 'PingFang SC', 'Microsoft YaHei', sans-serif; + font-size: 0.75em; + margin-top: 3px; +} +.posts-expand .post-meta-container .post-description { + font-size: 0.875em; + margin-top: 2px; +} +.posts-expand .post-meta-container time { + border-bottom: 1px dashed #999; +} +.post-meta { + display: flex; + flex-wrap: wrap; + justify-content: center; +} +:not(.post-meta-break) + .post-meta-item::before { + content: '|'; + margin: 0 0.5em; +} +.post-meta-item-icon { + margin-right: 3px; +} +.post-meta-item-text { + display: none; +} +@media (max-width: 991px) { + .post-meta-item-text { + display: none; + } +} +.post-meta-break { + flex-basis: 100%; + height: 0; +} +.post-nav { + border-top: 1px solid #eee; + display: flex; + gap: 30px; + justify-content: space-between; + margin-top: 1em; + padding: 10px 5px 0; +} +.post-nav-item { + flex: 1; +} +.post-nav-item a { + border-bottom: 0; + display: block; + font-size: 0.875em; + line-height: 1.6; +} +.post-nav-item a:active { + top: 2px; +} +.post-nav-item .fa { + font-size: 0.75em; +} +.post-nav-item:first-child .fa { + margin-right: 5px; +} +.post-nav-item:last-child { + text-align: right; +} +.post-nav-item:last-child .fa { + margin-left: 5px; +} +.post-footer { + display: flex; + flex-direction: column; + justify-content: center; +} +.post-eof { + background: #ccc; + height: 1px; + margin: 80px auto 60px; + width: 8%; +} +.post-block:last-of-type .post-eof { + display: none; +} +.post-copyright ul { + list-style: none; + overflow: hidden; + padding: 0.5em 1em; + position: relative; + background: var(--card-bg-color); + border-left: 3px solid #ff2a2a; + margin: 1em 0 0; +} +.post-copyright ul::after { + content: '\f25e'; + font-family: 'Font Awesome 6 Brands'; + font-size: 200px; + opacity: 0.1; + position: absolute; + right: -50px; + top: -150px; +} +.post-tags { + margin-top: 40px; + text-align: center; +} +.post-tags a { + display: inline-block; + font-size: 0.8125em; +} +.post-tags a:not(:last-child) { + margin-right: 10px; +} +.social-like { + border-top: 1px solid #eee; + font-size: 0.875em; + margin-top: 1em; + padding-top: 1em; + display: flex; + flex-wrap: wrap; + justify-content: center; +} +.social-like a { + border-bottom: none; +} +.reward-container { + margin: 1em 0 0; + padding: 1em 0; + text-align: center; +} +.reward-container button { + background: transparent; + color: #87daff; + cursor: pointer; + line-height: 2; + padding: 0 15px; + border: 2px solid #87daff; + border-radius: 2px; + outline: 0; + transition: all 0.2s ease-in-out; + vertical-align: text-top; +} +.reward-container button:hover { + background: #87daff; + color: #fff; +} +.post-reward { + display: none; + padding-top: 20px; +} +.post-reward.active { + display: block; +} +.post-reward div { + display: inline-block; +} +.post-reward div span { + display: block; +} +.post-reward img { + display: inline-block; + margin: 0.8em 2em 0; + max-width: 100%; + width: 180px; +} +@keyframes next-roll { + from { + transform: rotateZ(30deg); + } + to { + transform: rotateZ(-30deg); + } +} +.followme { + color: #bbb; + padding: 1em 1.5em; + text-align: center; + background: var(--card-bg-color); + border-left: 3px solid #ff2a2a; + margin: 1em 0 0; +} +.followme .social-list { + display: flex; + flex-wrap: wrap; + justify-content: center; +} +.followme .social-list .social-item { + margin: 0.5em 2em; + position: relative; +} +@media (max-width: 991px) { + .followme .social-list .social-item { + margin: 0.5em 0.75em; + } +} +.followme .social-list .social-link { + border: 0; + display: block; +} +.followme .social-list .social-link .icon { + font-size: 1.75em; +} +.followme .social-list .social-link .label { + display: block; + font-size: 14px; +} +.followme .social-list .social-link:hover + .social-item-img { + display: block; +} +.followme .social-list span.social-link { + color: var(--link-color); +} +.followme .social-list span.social-link:hover { + color: var(--link-hover-color); +} +.followme .social-list .social-item-img { + display: none; + left: 50%; + max-width: 180px; + position: absolute; + transform: translate(-50%, 20px); +} +.category-all-page .category-all-title { + text-align: center; +} +.category-all-page .category-all { + margin-top: 20px; +} +.category-all-page .category-list { + list-style: none; + margin: 0; + padding: 0; +} +.category-all-page .category-list-item { + margin: 5px 10px; +} +.category-all-page .category-list-count { + color: #bbb; +} +.category-all-page .category-list-count::before { + content: ' ('; +} +.category-all-page .category-list-count::after { + content: ') '; +} +.category-all-page .category-list-child { + padding-left: 10px; +} +.event-list hr { + background: #222; + margin: 20px 0 45px; +} +.event-list hr::after { + background: #222; + color: #fff; + content: 'NOW'; + display: inline-block; + font-weight: bold; + padding: 0 5px; +} +.event-list .event { + --event-background: #222; + --event-foreground: #bbb; + --event-title: #fff; + background: var(--event-background); + padding: 15px; +} +.event-list .event .event-summary { + border-bottom: 0; + color: var(--event-title); + margin: 0; + padding: 0 0 0 35px; + position: relative; +} +.event-list .event .event-summary::before { + animation: dot-flash 1s alternate infinite ease-in-out; + background: var(--event-title); + left: 0; + margin-top: -6px; + position: absolute; + top: 50%; + border-radius: 50%; + content: ' '; + height: 12px; + width: 12px; +} +.event-list .event:nth-of-type(odd) .event-summary::before { + animation-delay: 0.5s; +} +.event-list .event:not(:last-child) { + margin-bottom: 20px; +} +.event-list .event .event-relative-time { + color: var(--event-foreground); + display: inline-block; + font-size: 12px; + font-weight: normal; + padding-left: 12px; +} +.event-list .event .event-details { + color: var(--event-foreground); + display: block; + line-height: 18px; + padding: 6px 0 6px 35px; +} +.event-list .event .event-details::before { + color: var(--event-foreground); + display: inline-block; + margin-right: 9px; + width: 14px; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; +} +.event-list .event .event-details.event-location::before { + content: '\f041'; +} +.event-list .event .event-details.event-duration::before { + content: '\f017'; +} +.event-list .event .event-details.event-description::before { + content: '\f024'; +} +.event-list .event-past { + --event-background: #f5f5f5; + --event-foreground: #999; + --event-title: #222; +} +@keyframes dot-flash { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.8); + } +} +ul.breadcrumb { + font-size: 0.75em; + list-style: none; + margin: 1em 0; + padding: 0 2em; + text-align: center; +} +ul.breadcrumb li { + display: inline; +} +ul.breadcrumb li:not(:first-child)::before { + content: '/\00a0'; + font-weight: normal; + padding: 0.5em; +} +ul.breadcrumb li:last-child { + font-weight: bold; +} +.tag-cloud { + text-align: center; +} +.tag-cloud a { + display: inline-block; + margin: 10px; +} +.tag-cloud-0 { + border-bottom-color: #aaa; + color: #aaa; +} +.tag-cloud-1 { + border-bottom-color: #9a9a9a; + color: #9a9a9a; +} +.tag-cloud-2 { + border-bottom-color: #8b8b8b; + color: #8b8b8b; +} +.tag-cloud-3 { + border-bottom-color: #7c7c7c; + color: #7c7c7c; +} +.tag-cloud-4 { + border-bottom-color: #6c6c6c; + color: #6c6c6c; +} +.tag-cloud-5 { + border-bottom-color: #5d5d5d; + color: #5d5d5d; +} +.tag-cloud-6 { + border-bottom-color: #4e4e4e; + color: #4e4e4e; +} +.tag-cloud-7 { + border-bottom-color: #3e3e3e; + color: #3e3e3e; +} +.tag-cloud-8 { + border-bottom-color: #2f2f2f; + color: #2f2f2f; +} +.tag-cloud-9 { + border-bottom-color: #202020; + color: #202020; +} +.tag-cloud-10 { + border-bottom-color: #111; + color: #111; +} +@media (prefers-color-scheme: dark) { + .tag-cloud-0 { + border-bottom-color: #555; + color: #555; + } + .tag-cloud-1 { + border-bottom-color: #646464; + color: #646464; + } + .tag-cloud-2 { + border-bottom-color: #737373; + color: #737373; + } + .tag-cloud-3 { + border-bottom-color: #828282; + color: #828282; + } + .tag-cloud-4 { + border-bottom-color: #929292; + color: #929292; + } + .tag-cloud-5 { + border-bottom-color: #a1a1a1; + color: #a1a1a1; + } + .tag-cloud-6 { + border-bottom-color: #b0b0b0; + color: #b0b0b0; + } + .tag-cloud-7 { + border-bottom-color: #c0c0c0; + color: #c0c0c0; + } + .tag-cloud-8 { + border-bottom-color: #cfcfcf; + color: #cfcfcf; + } + .tag-cloud-9 { + border-bottom-color: #dedede; + color: #dedede; + } + .tag-cloud-10 { + border-bottom-color: #eee; + color: #eee; + } +} +.search-active { + overflow: hidden; +} +.search-pop-overlay { + background: rgba(0,0,0,0); + display: flex; + height: 100%; + left: 0; + position: fixed; + top: 0; + transition: visibility 0.4s, background 0.4s; + visibility: hidden; + width: 100%; + z-index: 40; +} +.search-active .search-pop-overlay { + background: rgba(0,0,0,0.3); + visibility: visible; +} +.search-popup { + background: var(--card-bg-color); + border-radius: 5px; + height: 80%; + margin: auto; + transform: scale(0); + transition: transform 0.4s; + width: 700px; +} +.search-active .search-popup { + transform: scale(1); +} +@media (max-width: 767px) { + .search-popup { + border-radius: 0; + height: 100%; + width: 100%; + } +} +.search-popup .search-icon, +.search-popup .popup-btn-close { + color: #999; + font-size: 18px; + padding: 0 10px; +} +.search-popup .popup-btn-close { + cursor: pointer; +} +.search-popup .popup-btn-close:hover .fa { + color: #222; +} +.search-popup .search-header { + background: #eee; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + display: flex; + padding: 5px; +} +@media (prefers-color-scheme: dark) { + .search-popup .search-header { + background: #666; + } +} +.search-popup input.search-input { + background: transparent; + border: 0; + outline: 0; + width: 100%; +} +.search-popup input.search-input::-webkit-search-cancel-button { + display: none; +} +.search-popup .search-result-container { + height: calc(100% - 55px); + overflow: auto; + padding: 5px 25px; +} +.search-popup .search-result-container hr { + margin: 5px 0 10px; +} +.search-popup .search-result-container hr:first-child { + display: none; +} +.search-popup .search-result-list { + margin: 0 5px; + padding: 0; +} +.search-popup a.search-result-title { + font-weight: bold; +} +.search-popup p.search-result { + border-bottom: 1px dashed #ccc; + padding: 5px 0; +} +.search-popup .search-input-container { + flex-grow: 1; + padding: 2px; +} +.search-popup .no-result { + display: flex; +} +.search-popup .search-result-list { + width: 100%; +} +.search-popup .search-result-icon { + color: #ccc; + margin: auto; +} +mark.search-keyword { + background: transparent; + border-bottom: 1px dashed #ff2a2a; + color: #ff2a2a; + font-weight: bold; +} +.use-motion .animated { + animation-fill-mode: none; + visibility: inherit; +} +.use-motion .sidebar .animated { + animation-fill-mode: both; +} +header.header { + margin: 0 auto; + width: 700px; +} +@media (max-width: 767px) { + header.header { + width: auto; + } +} +@media (min-width: 1200px) { + header.header { + width: 800px; + } +} +@media (min-width: 1600px) { + header.header { + width: 900px; + } +} +.main-inner { + margin: 0 auto; + width: 700px; + padding-bottom: 60px; +} +@media (max-width: 767px) { + .main-inner { + width: auto; + } +} +@media (min-width: 1200px) { + .main-inner { + width: 800px; + } +} +@media (min-width: 1600px) { + .main-inner { + width: 900px; + } +} +@media (max-width: 767px) { + .main-inner { + padding-left: 20px; + padding-right: 20px; + } +} +.post-block:first-of-type { + padding-top: 70px; +} +@media (max-width: 767px) { + .post-block:first-of-type { + padding-top: 35px; + } +} +.custom-logo-image { + background: #fff; + margin: 0 auto 10px; + max-width: 150px; + padding: 5px; +} +.brand { + background: var(--btn-default-bg); +} +header.header { + padding-top: 100px; +} +@media (max-width: 767px) { + header.header { + padding-top: 50px; + } +} +@media (max-width: 767px) { + .site-nav { + padding-top: 30px; + } +} +@media (max-width: 767px) { + .main-menu { + border-bottom: 1px solid #ddd; + border-top: 1px solid #ddd; + } +} +@media (max-width: 767px) { + .menu { + text-align: left; + } +} +@media (max-width: 767px) { + .menu .menu-item { + margin: 0 10px; + } +} +.menu .menu-item a { + border-bottom: 1px solid transparent; +} +@media (max-width: 767px) { + .menu .menu-item a { + padding: 5px 10px; + } +} +.menu .menu-item a:hover, +.menu .menu-item a.menu-item-active { + background: transparent; + border-bottom: 1px solid var(--link-hover-color); +} +@media (max-width: 767px) { + .menu .menu-item a:hover, + .menu .menu-item a.menu-item-active { + border-bottom: 1px dotted #ddd; + } +} +@media (min-width: 768px) { + .menu .menu-item i[class^='fa'] { + display: block; + line-height: 2; + margin-right: 0; + width: 100%; + } +} +.menu .menu-item .badge { + background: #eee; + color: #555; + padding: 1px 4px; +} +.sub-menu { + margin: 10px 0; +} +.sub-menu .menu-item { + display: inline-block; +} +@media (min-width: 1200px) { + .sidebar-active { + padding-right: 320px; + } + .sidebar-active .footer-fixed { + right: 320px; + } +} +.sidebar { + right: -320px; +} +.sidebar-active .sidebar { + right: 0; +} +.sidebar { + background: #222; + bottom: 0; + box-shadow: inset 0 2px 6px #000; + max-height: 100vh; + overflow-y: auto; + position: fixed; + top: 0; + transition: all 0.2s ease-out; + width: 320px; + z-index: 20; +} +.sidebar a { + border-bottom-color: #555; + color: #999; +} +.sidebar a:hover { + border-bottom-color: #eee; + color: #eee; +} +.links-of-author:not(:first-child) { + margin-top: 15px; +} +.links-of-author a { + border-bottom-color: #555; + display: inline-block; + margin-bottom: 10px; + margin-right: 10px; + vertical-align: middle; + transition: all 0.2s ease-in-out; +} +.links-of-author a::before { + background: #abe355; + display: inline-block; + margin-right: 3px; + transform: translateY(-2px); + border-radius: 50%; + content: ' '; + height: 4px; + width: 4px; +} +.links-of-blogroll-item { + padding: 2px 10px; +} +.links-of-blogroll-item a { + box-sizing: border-box; + display: inline-block; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.popular-posts .popular-posts-item .popular-posts-link:hover { + background: none; +} +.sidebar-dimmer { + background: #000; + height: 100%; + left: 0; + opacity: 0; + position: fixed; + top: 0; + transition: visibility 0.4s, opacity 0.4s; + visibility: hidden; + width: 100%; + z-index: 10; +} +.sidebar-active .sidebar-dimmer { + opacity: 0.7; + visibility: visible; +} +@media (min-width: 1200px) { + .sidebar-dimmer { + display: none; + } +} +.wxsm-github-badges { + text-align: center; +} +.wxsm-github-badges a { + border-bottom: none; +} +.wxsm-github-badges a img { + display: inline-block !important; +} +.post-body .note:not(.no-icon)::before { + top: 10px !important; +} +.post-body table img { + margin: 5px; +} diff --git a/css/noscript.css b/css/noscript.css new file mode 100644 index 000000000..6418c57d4 --- /dev/null +++ b/css/noscript.css @@ -0,0 +1,48 @@ +body { + margin-top: 2rem; +} +.use-motion .menu-item, +.use-motion .sidebar, +.use-motion .sidebar-inner, +.use-motion .post-block, +.use-motion .pagination, +.use-motion .comments, +.use-motion .post-header, +.use-motion .post-body, +.use-motion .collection-header { + visibility: visible; +} +.use-motion .column, +.use-motion .site-brand-container .toggle, +.use-motion .footer { + opacity: initial; +} +.use-motion .site-title, +.use-motion .site-subtitle, +.use-motion .custom-logo-image { + opacity: initial; + top: initial; +} +.use-motion .logo-line { + transform: scaleX(1); +} +.search-pop-overlay, +.sidebar-nav { + display: none; +} +.sidebar-panel { + display: block; +} +.noscript-warning { + background-color: #f55; + color: #fff; + font-family: sans-serif; + font-size: 1rem; + font-weight: bold; + left: 0; + position: fixed; + text-align: center; + top: 0; + width: 100%; + z-index: 50; +} diff --git a/images/alipay.png b/images/alipay.png new file mode 100644 index 000000000..4a9a56667 Binary files /dev/null and b/images/alipay.png differ diff --git a/images/apple-touch-icon-next.png b/images/apple-touch-icon-next.png new file mode 100644 index 000000000..86a0d1d33 Binary files /dev/null and b/images/apple-touch-icon-next.png differ diff --git a/images/avatar.gif b/images/avatar.gif new file mode 100644 index 000000000..3b5d744b1 Binary files /dev/null and b/images/avatar.gif differ diff --git a/images/avatar.jpg b/images/avatar.jpg new file mode 100644 index 000000000..4f5122f38 Binary files /dev/null and b/images/avatar.jpg differ diff --git a/images/emt.gif b/images/emt.gif new file mode 100644 index 000000000..ba834ea60 Binary files /dev/null and b/images/emt.gif differ diff --git a/images/favicon-16x16-next.png b/images/favicon-16x16-next.png new file mode 100644 index 000000000..de8c5d3a5 Binary files /dev/null and b/images/favicon-16x16-next.png differ diff --git a/images/favicon-32x32-next.png b/images/favicon-32x32-next.png new file mode 100644 index 000000000..e02f5f4d5 Binary files /dev/null and b/images/favicon-32x32-next.png differ diff --git a/images/logo-algolia-nebula-blue-full.svg b/images/logo-algolia-nebula-blue-full.svg new file mode 100644 index 000000000..886c422ec --- /dev/null +++ b/images/logo-algolia-nebula-blue-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/logo.svg b/images/logo.svg new file mode 100644 index 000000000..992c1a581 --- /dev/null +++ b/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/projects/2048.png b/images/projects/2048.png new file mode 100644 index 000000000..aa2215050 Binary files /dev/null and b/images/projects/2048.png differ diff --git a/images/projects/bv2mp3.png b/images/projects/bv2mp3.png new file mode 100644 index 000000000..bdca788b7 Binary files /dev/null and b/images/projects/bv2mp3.png differ diff --git a/images/projects/uiv.png b/images/projects/uiv.png new file mode 100644 index 000000000..d01d40e7e Binary files /dev/null and b/images/projects/uiv.png differ diff --git a/images/projects/vue-md-loader.png b/images/projects/vue-md-loader.png new file mode 100644 index 000000000..d45da77f9 Binary files /dev/null and b/images/projects/vue-md-loader.png differ diff --git a/images/projects/vuepress-theme-mini.png b/images/projects/vuepress-theme-mini.png new file mode 100644 index 000000000..5e80bedbd Binary files /dev/null and b/images/projects/vuepress-theme-mini.png differ diff --git a/images/projects/zhbus.png b/images/projects/zhbus.png new file mode 100644 index 000000000..5b7c67044 Binary files /dev/null and b/images/projects/zhbus.png differ diff --git a/images/wechatpay.png b/images/wechatpay.png new file mode 100644 index 000000000..939e47533 Binary files /dev/null and b/images/wechatpay.png differ diff --git a/index.html b/index.html new file mode 100644 index 000000000..2e74f42ca --- /dev/null +++ b/index.html @@ -0,0 +1,1198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

pprof 是 Google 开发的一款用于数据分析和可视化的工具。

+

最近我编写的 go 程序遇到了一次线上 OOM,于是趁机学习了一下 Go 程序的性能问题排查相关知识。其基本路线是:先通过内置的 net/http/pprof 模块生成采集数据,然后在使用 pprof 命令行读取并分析。Go 语言目前已经内置了该工具。

+

本文不会介绍 pprof 的太多细节,只关注主要流程(主要的是太细的我现在也不会)。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

5 月份休完陪产假,再回到公司,发现原本的小组已经整体重组,只有我一人还在工位上了。后来跟 TL 聊了一下,最后反正就是几个选项,要么跟着原来的同事一起去新的部门继续写前端,要么就做点别的事情。

+

当时我还是挺头疼的,主要是那会事情太多了,一是小满还在月子里,二是新房子还在装修,三是那段时间身体状态有一点波动(其实主要可能还是因为这个)。继续搞前端肯定是最稳的,但是我自己其实已经有点厌倦了,属于是看到前端代码就已经有点不耐烦的程度。但是对前端以外的东西又还是很感兴趣,偶尔自己写点非前端的玩意都会感到很愉快。犹豫再三,也是跟朋友家人都聊了一下,最后选择了转成服务端开发。(其实当时还有一个可能是 C++ 客户端的方向,但是因为太过陌生,加上前面说的那些现实情况,实在是有点绷不住)

+

其实我在刚毕业那会做过一段时间(半年左右?)的 Java 开发,但是那段时间基本上来说还是处于比较懵的状态,也没学到什么东西,加上后来很快就转型(基本上)全职前端,Java 服务端就荒废了。现在也算是一个从头来过。

+

到现在 7 月份,大概算下来时间过去一个多月了,也简单说下转型后的感想。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

我和静纯的孩子在 2023 年 5 月 22 日出生,当天并不是小满,而是小满的次日。然而犹豫再三,最后我们还是给孩子取名为“小满”。

+

“小满”的含义,在于小满,而非大满,满而未盈。我们本打算如果孩子能在小满当日出生就叫他“小满”,却偏偏差了一日。但是转念想想,这一点点偏差,不是刚好对应上了“小满”的内在涵义吗?再者,虽然小满不是在当天出生的,但是妈妈却是在小满那天进的产房,生产过程除了手术室,我基本是全程陪着妈妈,这多少也能代表我们的一点回忆。

+

另外,除了这个结果以外,生孩子的过程也出现了偏差。但好在最后的结果是好的。孩子目前为止很健康,妈妈也恢复得很好,这样就足够了。这就是我这个小家庭的“小满”。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

b 站上的歌姬,很多歌只发布在 b 站。比如说直播时唱的歌,或者一些发布到正经音乐平台上会有版权问题的歌。然而,对于爱听歌的人来说,b 站的听歌体验实在是太差了,这里就不展开细说。

+

我习惯用网易云听歌。网易云虽然版权方面很惨,但有它一个很好用的功能:云盘。每个用户有 60G 的云盘容量,基本用不完,不管是什么歌,有没有版权,只要上传上去了就能随时随地听。因此,我的目标是,希望可以有一个自动化的工具,帮我把 b 站上的歌以 mp3 的格式下载下来,让我可以上传到云盘,这样我就可以用网易云听歌了。

+

综上所述,我就做了这么一个小工具:bv2mp3 ,这是一个开源工具,完整的代码可以在代码仓库中找到。下面,我主要讲一下这个工具的实现思路以及优化过程。

+

126f0ee04db59831d6a9820ac89c471.jpg

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

今天想要打包一个 Docker 镜像,里面只包含一些静态的前端文件。为了使体积足够小,想到的方案是把命令全部集中在一个 RUN 上,类似这样:

+
FROM node

WORKDIR /usr/src/app
COPY . .

RUN yarn --frozen-lockfile --check-files --ignore-engines && \
yarn build && \
rm -rf node_modules
+ +

但是打包出来的镜像,死活都是 2.2G,node 镜像自身 900MB,静态文件总共才 10MB+,run container 进去查看 node_modules 也确实删掉了,百思不得其解。一度以为是 Docker 出了 bug,遂升级 Docker,但仍不能解决。

+

折腾了一下午后,尝试去掉 rm -rf node_modules,观察到打出来的镜像 2.8G,突然觉得是不是还有什么东西没删干净,然后很快就想到了 yarn 的缓存。添加 yarn cache clean 后,打出来的镜像来到 910MB。世界终于清净了。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

Vue3 与 Vue2 的最大不同点之一是响应式的实现方式。众所周知,Vue2 使用的是 Object.defineProperty,为每个对象设置 getter 与 setter,从而达到监听数据变化的目的。然而这种方式存在诸多限制,如对数组的支持不完善,无法监听到对象上的新增属性等。因此 Vue3 通过 Proxy API 对响应式系统进行了重写,并将这部分代码封装在了 @vue/reactivity 包中。

+

本文将参照 Vue3 的设计,从零开始实现一套响应式系统。注意本文引用的代码与实际的 Vue3 实现方式有所出入,Vue3 需要更多地考虑高效与兼容各种边界情况,但此处以易懂为主。 文中提到的大部分代码可以在 https://github.com/wxsms/learning-vue 找到。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

很久没更新了,最近有点懒。也没什么想写的。

+

在新公司(金山办公)上班一年了,工作量并不大,但是干得感觉比之前更累了。主要可能有两个原因:一是之前的负责人在我入职不久后就走了,结果我又变成了负责人(离开西山居的原因之一就是不想做不责人)。二是,做的项目比较偏探索向,不是常规的业务项目,整天要思考这个那个,很累。有时候(经常)也会想放弃。不过看在去年刚来半年就给我 3.75 的份上,还是再干一段时间吧。

+

最近理财跌得不要不要的,3 个月已经把之前 3 年的收益都跌完了。好在我买的不是很多。现在也不怎么看了。

+

可能是因为理财亏得太多了,我开始到各种平台薅羊毛,然后又开始把梦幻西游捡起来玩了。家产全部变卖以后转到了朋友所在的区,每天就当作一个打发时间的消遣,分散一下亏钱的注意力。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

昨天正式受邀(实际上是我申请的)进入了 vuejs 组织。虽然目前只是 doc team,但是我相信以后可以做更多的事情。

+

638f7ff6d334b2d7616039a3787efe6.png

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

在没有包管理器之前

正确来说 Node.js 是不存在没有包管理器的时期的。从 A brief history of Node.js 里面可以看到,当 2009 年 Node.js 问世的时候 NPM 的雏形也发布了。当然因为 Node.js 跟前端绑得很死,这里主要谈一谈前端在没有包管理器的时期是怎样的。

+

那时候做得最多的事情就是:

+
    +
  1. 网上寻找各软件的官网,比如 jQuery;
  2. +
  3. 找到下载地址,下载 zip 包;
  4. +
  5. 解压,放到项目中一个叫 libs 的目录中;
  6. +
  7. 想更方便的话,直接将 CDN 链接粘贴到 HTML 中。
  8. +
+

四个字总结:刀耕火种。 模块化管理?版本号管理?依赖升级?不存在的。当然,那时候前端也没有那么复杂,这种模式勉强来说也不是不能用。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

之前在 WSL on Windows 10 中尝试了 WSL,但是几经周折最后发现问题比较多,用得有点难受。最后还是换回了 windows。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/bookmark.js b/js/bookmark.js new file mode 100644 index 000000000..8e3ae6adf --- /dev/null +++ b/js/bookmark.js @@ -0,0 +1,56 @@ +/* global CONFIG */ + +document.addEventListener('DOMContentLoaded', () => { + 'use strict'; + + const doSaveScroll = () => { + localStorage.setItem('bookmark' + location.pathname, window.scrollY); + }; + + const scrollToMark = () => { + let top = localStorage.getItem('bookmark' + location.pathname); + top = parseInt(top, 10); + // If the page opens with a specific hash, just jump out + if (!isNaN(top) && location.hash === '') { + // Auto scroll to the position + window.anime({ + targets : document.scrollingElement, + duration : 200, + easing : 'linear', + scrollTop: top + }); + } + }; + // Register everything + const init = function(trigger) { + // Create a link element + const link = document.querySelector('.book-mark-link'); + // Scroll event + window.addEventListener('scroll', () => link.classList.toggle('book-mark-link-fixed', window.scrollY === 0), { passive: true }); + // Register beforeunload event when the trigger is auto + if (trigger === 'auto') { + // Register beforeunload event + window.addEventListener('beforeunload', doSaveScroll); + document.addEventListener('pjax:send', doSaveScroll); + } + // Save the position by clicking the icon + link.addEventListener('click', () => { + doSaveScroll(); + window.anime({ + targets : link, + duration: 200, + easing : 'linear', + top : -30, + complete: () => { + setTimeout(() => { + link.style.top = ''; + }, 400); + } + }); + }); + scrollToMark(); + document.addEventListener('pjax:success', scrollToMark); + }; + + init(CONFIG.bookmark.save); +}); diff --git a/js/comments-buttons.js b/js/comments-buttons.js new file mode 100644 index 000000000..505c21b7d --- /dev/null +++ b/js/comments-buttons.js @@ -0,0 +1,25 @@ +/* global CONFIG */ + +(function() { + const commentButton = document.querySelectorAll('.comment-button'); + commentButton.forEach(element => { + const commentClass = element.classList[2]; + element.addEventListener('click', () => { + commentButton.forEach(active => active.classList.toggle('active', active === element)); + document.querySelectorAll('.comment-position').forEach(active => active.classList.toggle('active', active.classList.contains(commentClass))); + if (CONFIG.comments.storage) { + localStorage.setItem('comments_active', commentClass); + } + }); + }); + let { activeClass } = CONFIG.comments; + if (CONFIG.comments.storage) { + activeClass = localStorage.getItem('comments_active') || activeClass; + } + if (activeClass) { + const activeButton = document.querySelector(`.comment-button.${activeClass}`); + if (activeButton) { + activeButton.click(); + } + } +})(); diff --git a/js/comments.js b/js/comments.js new file mode 100644 index 000000000..4045e8c06 --- /dev/null +++ b/js/comments.js @@ -0,0 +1,21 @@ +/* global CONFIG */ + +window.addEventListener('tabs:register', () => { + let { activeClass } = CONFIG.comments; + if (CONFIG.comments.storage) { + activeClass = localStorage.getItem('comments_active') || activeClass; + } + if (activeClass) { + const activeTab = document.querySelector(`a[href="#comment-${activeClass}"]`); + if (activeTab) { + activeTab.click(); + } + } +}); +if (CONFIG.comments.storage) { + window.addEventListener('tabs:click', event => { + if (!event.target.matches('.tabs-comment .tab-content .tab-pane')) return; + const commentClass = event.target.classList[1]; + localStorage.setItem('comments_active', commentClass); + }); +} diff --git a/js/config.js b/js/config.js new file mode 100644 index 000000000..caa0075b1 --- /dev/null +++ b/js/config.js @@ -0,0 +1,66 @@ +if (!window.NexT) window.NexT = {}; + +(function() { + const className = 'next-config'; + + const staticConfig = {}; + let variableConfig = {}; + + const parse = text => JSON.parse(text || '{}'); + + const update = name => { + const targetEle = document.querySelector(`.${className}[data-name="${name}"]`); + if (!targetEle) return; + const parsedConfig = parse(targetEle.text); + if (name === 'main') { + Object.assign(staticConfig, parsedConfig); + } else { + variableConfig[name] = parsedConfig; + } + }; + + update('main'); + + window.CONFIG = new Proxy({}, { + get(overrideConfig, name) { + let existing; + if (name in staticConfig) { + existing = staticConfig[name]; + } else { + if (!(name in variableConfig)) update(name); + existing = variableConfig[name]; + } + + // For unset override and mixable existing + if (!(name in overrideConfig) && typeof existing === 'object') { + // Get ready to mix. + overrideConfig[name] = {}; + } + + if (name in overrideConfig) { + const override = overrideConfig[name]; + + // When mixable + if (typeof override === 'object' && typeof existing === 'object') { + // Mix, proxy changes to the override. + return new Proxy({ ...existing, ...override }, { + set(target, prop, value) { + target[prop] = value; + override[prop] = value; + return true; + } + }); + } + + return override; + } + + // Only when not mixable and override hasn't been set. + return existing; + } + }); + + document.addEventListener('pjax:success', () => { + variableConfig = {}; + }); +})(); diff --git a/js/motion.js b/js/motion.js new file mode 100644 index 000000000..aad22db13 --- /dev/null +++ b/js/motion.js @@ -0,0 +1,140 @@ +/* global NexT, CONFIG */ + +NexT.motion = {}; + +NexT.motion.integrator = { + queue: [], + init : function() { + this.queue = []; + return this; + }, + add: function(fn) { + const sequence = fn(); + if (CONFIG.motion.async) this.queue.push(sequence); + else this.queue = this.queue.concat(sequence); + return this; + }, + bootstrap: function() { + if (!CONFIG.motion.async) this.queue = [this.queue]; + this.queue.forEach(sequence => { + const timeline = window.anime.timeline({ + duration: 200, + easing : 'linear' + }); + sequence.forEach(item => { + if (item.deltaT) timeline.add(item, item.deltaT); + else timeline.add(item); + }); + }); + } +}; + +NexT.motion.middleWares = { + header: function() { + const sequence = []; + + function getMistLineSettings(targets) { + sequence.push({ + targets, + scaleX : [0, 1], + duration: 500, + deltaT : '-=200' + }); + } + + function pushToSequence(targets, sequenceQueue = false) { + sequence.push({ + targets, + opacity: 1, + top : 0, + deltaT : sequenceQueue ? '-=200' : '-=0' + }); + } + + pushToSequence('.column'); + CONFIG.scheme === 'Mist' && getMistLineSettings('.logo-line'); + CONFIG.scheme === 'Muse' && pushToSequence('.custom-logo-image'); + pushToSequence('.site-title'); + pushToSequence('.site-brand-container .toggle', true); + pushToSequence('.site-subtitle'); + (CONFIG.scheme === 'Pisces' || CONFIG.scheme === 'Gemini') && pushToSequence('.custom-logo-image'); + + const menuItemTransition = CONFIG.motion.transition.menu_item; + if (menuItemTransition) { + document.querySelectorAll('.menu-item').forEach(targets => { + sequence.push({ + targets, + complete: () => targets.classList.add('animated', menuItemTransition), + deltaT : '-=200' + }); + }); + } + + return sequence; + }, + + subMenu: function() { + const subMenuItem = document.querySelectorAll('.sub-menu .menu-item'); + if (subMenuItem.length > 0) { + subMenuItem.forEach(element => { + element.classList.add('animated'); + }); + } + return []; + }, + + postList: function() { + const sequence = []; + const { post_block, post_header, post_body, coll_header } = CONFIG.motion.transition; + + function animate(animation, elements) { + if (!animation) return; + elements.forEach(targets => { + sequence.push({ + targets, + complete: () => targets.classList.add('animated', animation), + deltaT : '-=100' + }); + }); + } + + document.querySelectorAll('.post-block').forEach(targets => { + sequence.push({ + targets, + complete: () => targets.classList.add('animated', post_block), + deltaT : '-=100' + }); + animate(coll_header, targets.querySelectorAll('.collection-header')); + animate(post_header, targets.querySelectorAll('.post-header')); + animate(post_body, targets.querySelectorAll('.post-body')); + }); + + animate(post_block, document.querySelectorAll('.pagination, .comments')); + + return sequence; + }, + + sidebar: function() { + const sequence = []; + const sidebar = document.querySelectorAll('.sidebar-inner'); + const sidebarTransition = CONFIG.motion.transition.sidebar; + // Only for Pisces | Gemini. + if (sidebarTransition && (CONFIG.scheme === 'Pisces' || CONFIG.scheme === 'Gemini')) { + sidebar.forEach(targets => { + sequence.push({ + targets, + complete: () => targets.classList.add('animated', sidebarTransition), + deltaT : '-=100' + }); + }); + } + return sequence; + }, + + footer: function() { + return [{ + targets: document.querySelector('.footer'), + opacity: 1 + }]; + } +}; diff --git a/js/next-boot.js b/js/next-boot.js new file mode 100644 index 000000000..fceb80bb2 --- /dev/null +++ b/js/next-boot.js @@ -0,0 +1,79 @@ +/* global NexT, CONFIG */ + +NexT.boot = {}; + +NexT.boot.registerEvents = function() { + + NexT.utils.registerScrollPercent(); + NexT.utils.registerCanIUseTag(); + + // Mobile top menu bar. + document.querySelector('.site-nav-toggle .toggle').addEventListener('click', event => { + event.currentTarget.classList.toggle('toggle-close'); + const siteNav = document.querySelector('.site-nav'); + if (!siteNav) return; + siteNav.style.setProperty('--scroll-height', siteNav.scrollHeight + 'px'); + document.body.classList.toggle('site-nav-on'); + }); + + document.querySelectorAll('.sidebar-nav li').forEach((element, index) => { + element.addEventListener('click', () => { + NexT.utils.activateSidebarPanel(index); + }); + }); + + window.addEventListener('hashchange', () => { + const tHash = location.hash; + if (tHash !== '' && !tHash.match(/%\S{2}/)) { + const target = document.querySelector(`.tabs ul.nav-tabs li a[href="${tHash}"]`); + target && target.click(); + } + }); + + window.addEventListener('tabs:click', e => { + NexT.utils.registerCodeblock(e.target); + }); +}; + +NexT.boot.refresh = function() { + + /** + * Register JS handlers by condition option. + * Need to add config option in Front-End at 'scripts/helpers/next-config.js' file. + */ + CONFIG.prism && window.Prism.highlightAll(); + CONFIG.mediumzoom && window.mediumZoom('.post-body :not(a) > img, .post-body > img', { + background: 'var(--content-bg-color)' + }); + CONFIG.lazyload && window.lozad('.post-body img').observe(); + CONFIG.pangu && window.pangu.spacingPage(); + + CONFIG.exturl && NexT.utils.registerExtURL(); + NexT.utils.wrapTableWithBox(); + NexT.utils.registerCodeblock(); + NexT.utils.registerTabsTag(); + NexT.utils.registerActiveMenuItem(); + NexT.utils.registerLangSelect(); + NexT.utils.registerSidebarTOC(); + NexT.utils.registerPostReward(); + NexT.utils.registerVideoIframe(); +}; + +NexT.boot.motion = function() { + // Define Motion Sequence & Bootstrap Motion. + if (CONFIG.motion.enable) { + NexT.motion.integrator + .add(NexT.motion.middleWares.header) + .add(NexT.motion.middleWares.postList) + .add(NexT.motion.middleWares.sidebar) + .add(NexT.motion.middleWares.footer) + .bootstrap(); + } + NexT.utils.updateSidebarPosition(); +}; + +document.addEventListener('DOMContentLoaded', () => { + NexT.boot.registerEvents(); + NexT.boot.refresh(); + NexT.boot.motion(); +}); diff --git a/js/pjax.js b/js/pjax.js new file mode 100644 index 000000000..f81a6a0b1 --- /dev/null +++ b/js/pjax.js @@ -0,0 +1,50 @@ +/* global NexT, CONFIG, Pjax */ + +const pjax = new Pjax({ + selectors: [ + 'head title', + 'script[type="application/json"]', + // Precede .main-inner to prevent placeholder TOC changes asap + '.post-toc-wrap', + '.main-inner', + '.languages', + '.pjax' + ], + switches: { + '.post-toc-wrap': function(oldWrap, newWrap) { + if (newWrap.querySelector('.post-toc')) { + Pjax.switches.outerHTML.call(this, oldWrap, newWrap); + } else { + const curTOC = oldWrap.querySelector('.post-toc'); + if (curTOC) { + curTOC.classList.add('placeholder-toc'); + } + this.onSwitch(); + } + } + }, + analytics: false, + cacheBust: false, + scrollTo : !CONFIG.bookmark.enable +}); + +document.addEventListener('pjax:success', () => { + pjax.executeScripts(document.querySelectorAll('script[data-pjax]')); + NexT.boot.refresh(); + // Define Motion Sequence & Bootstrap Motion. + if (CONFIG.motion.enable) { + NexT.motion.integrator + .init() + .add(NexT.motion.middleWares.subMenu) + .add(NexT.motion.middleWares.postList) + // Add sidebar-post-related transition. + .add(NexT.motion.middleWares.sidebar) + .bootstrap(); + } + if (CONFIG.sidebar.display !== 'remove') { + const hasTOC = document.querySelector('.post-toc:not(.placeholder-toc)'); + document.querySelector('.sidebar-inner').classList.toggle('sidebar-nav-active', hasTOC); + NexT.utils.activateSidebarPanel(hasTOC ? 0 : 1); + NexT.utils.updateSidebarPosition(); + } +}); diff --git a/js/schedule.js b/js/schedule.js new file mode 100644 index 000000000..8f0c26cc4 --- /dev/null +++ b/js/schedule.js @@ -0,0 +1,138 @@ +/* global CONFIG */ + +// https://developers.google.com/calendar/api/v3/reference/events/list +(function() { + // Initialization + const calendar = { + orderBy : 'startTime', + showLocation: false, + offsetMax : 72, + offsetMin : 4, + showDeleted : false, + singleEvents: true, + maxResults : 250 + }; + + // Read config form theme config file + Object.assign(calendar, CONFIG.calendar); + + const now = new Date(); + const timeMax = new Date(); + const timeMin = new Date(); + + timeMax.setHours(now.getHours() + calendar.offsetMax); + timeMin.setHours(now.getHours() - calendar.offsetMin); + + // Build URL + const params = { + key : calendar.api_key, + orderBy : calendar.orderBy, + timeMax : timeMax.toISOString(), + timeMin : timeMin.toISOString(), + showDeleted : calendar.showDeleted, + singleEvents: calendar.singleEvents, + maxResults : calendar.maxResults + }; + + const request_url = new URL(`https://www.googleapis.com/calendar/v3/calendars/${calendar.calendar_id}/events`); + Object.entries(params).forEach(param => request_url.searchParams.append(...param)); + + function getRelativeTime(current, previous) { + const msPerMinute = 60 * 1000; + const msPerHour = msPerMinute * 60; + const msPerDay = msPerHour * 24; + const msPerMonth = msPerDay * 30; + const msPerYear = msPerDay * 365; + + let elapsed = current - previous; + const tense = elapsed > 0 ? ' ago' : ' later'; + + elapsed = Math.abs(elapsed); + + if (elapsed < msPerHour) { + return Math.round(elapsed / msPerMinute) + ' minutes' + tense; + } else if (elapsed < msPerDay) { + return Math.round(elapsed / msPerHour) + ' hours' + tense; + } else if (elapsed < msPerMonth) { + return 'about ' + Math.round(elapsed / msPerDay) + ' days' + tense; + } else if (elapsed < msPerYear) { + return 'about ' + Math.round(elapsed / msPerMonth) + ' months' + tense; + } + + return 'about ' + Math.round(elapsed / msPerYear) + ' years' + tense; + } + + function buildEventDOM(tense, event, start, end) { + const durationFormat = { + weekday: 'short', + hour : '2-digit', + minute : '2-digit' + }; + const relativeTime = tense === 'now' ? 'NOW' : getRelativeTime(now, start); + const duration = start.toLocaleTimeString([], durationFormat) + ' - ' + end.toLocaleTimeString([], durationFormat); + + let location = ''; + if (calendar.showLocation && event.location) { + location = `${event.location}`; + } + let description = ''; + if (event.description) { + description = `${event.description}`; + } + + const eventContent = `
+

+ ${event.summary} + ${relativeTime} +

+ ${location} + ${duration} + ${description} +
`; + return eventContent; + } + + function fetchData() { + const eventList = document.querySelector('.event-list'); + if (!eventList) return; + + fetch(request_url.href).then(response => { + return response.json(); + }).then(data => { + if (data.items.length === 0) { + eventList.innerHTML = '
'; + return; + } + // Clean the event list + eventList.innerHTML = ''; + let prevEnd = 0; // used to decide where to insert an
+ const utc = new Date().getTimezoneOffset() * 60000; + + data.items.forEach(event => { + // Parse data + const start = new Date(event.start.dateTime || (new Date(event.start.date).getTime() + utc)); + const end = new Date(event.end.dateTime || (new Date(event.end.date).getTime() + utc)); + + let tense = 'now'; + if (end < now) { + tense = 'past'; + } else if (start > now) { + tense = 'future'; + } + + if (tense === 'future' && prevEnd < now) { + eventList.insertAdjacentHTML('beforeend', '
'); + } + + eventList.insertAdjacentHTML('beforeend', buildEventDOM(tense, event, start, end)); + prevEnd = end; + }); + }); + } + + fetchData(); + const fetchDataTimer = setInterval(fetchData, 60000); + document.addEventListener('pjax:send', () => { + clearInterval(fetchDataTimer); + }); +})(); diff --git a/js/schemes/muse.js b/js/schemes/muse.js new file mode 100644 index 000000000..ba60b5156 --- /dev/null +++ b/js/schemes/muse.js @@ -0,0 +1,60 @@ +/* global CONFIG */ + +document.addEventListener('DOMContentLoaded', () => { + + const isRight = CONFIG.sidebar.position === 'right'; + + const sidebarToggleMotion = { + mouse: {}, + init : function() { + window.addEventListener('mousedown', this.mousedownHandler.bind(this)); + window.addEventListener('mouseup', this.mouseupHandler.bind(this)); + document.querySelector('.sidebar-dimmer').addEventListener('click', this.clickHandler.bind(this)); + document.querySelector('.sidebar-toggle').addEventListener('click', this.clickHandler.bind(this)); + window.addEventListener('sidebar:show', this.showSidebar); + window.addEventListener('sidebar:hide', this.hideSidebar); + }, + mousedownHandler: function(event) { + this.mouse.X = event.pageX; + this.mouse.Y = event.pageY; + }, + mouseupHandler: function(event) { + const deltaX = event.pageX - this.mouse.X; + const deltaY = event.pageY - this.mouse.Y; + const clickingBlankPart = Math.hypot(deltaX, deltaY) < 20 && event.target.matches('.main'); + // Fancybox has z-index property, but medium-zoom does not, so the sidebar will overlay the zoomed image. + if (clickingBlankPart || event.target.matches('img.medium-zoom-image')) { + this.hideSidebar(); + } + }, + clickHandler: function() { + document.body.classList.contains('sidebar-active') ? this.hideSidebar() : this.showSidebar(); + }, + showSidebar: function() { + document.body.classList.add('sidebar-active'); + const animateAction = isRight ? 'fadeInRight' : 'fadeInLeft'; + document.querySelectorAll('.sidebar .animated').forEach((element, index) => { + element.style.animationDelay = (100 * index) + 'ms'; + element.classList.remove(animateAction); + setTimeout(() => { + // Trigger a DOM reflow + element.classList.add(animateAction); + }); + }); + }, + hideSidebar: function() { + document.body.classList.remove('sidebar-active'); + } + }; + if (CONFIG.sidebar.display !== 'remove') sidebarToggleMotion.init(); + + function updateFooterPosition() { + const footer = document.querySelector('.footer'); + const containerHeight = document.querySelector('.main').offsetHeight + footer.offsetHeight; + footer.classList.toggle('footer-fixed', containerHeight <= window.innerHeight); + } + + updateFooterPosition(); + window.addEventListener('resize', updateFooterPosition); + window.addEventListener('scroll', updateFooterPosition, { passive: true }); +}); diff --git a/js/third-party/addtoany.js b/js/third-party/addtoany.js new file mode 100644 index 000000000..f9009f87b --- /dev/null +++ b/js/third-party/addtoany.js @@ -0,0 +1,8 @@ +/* global NexT */ + +document.addEventListener('page:loaded', () => { + NexT.utils.getScript('https://static.addtoany.com/menu/page.js', { condition: window.a2a }) + .then(() => { + window.a2a.init(); + }); +}); diff --git a/js/third-party/analytics/baidu-analytics.js b/js/third-party/analytics/baidu-analytics.js new file mode 100644 index 000000000..c10e7d014 --- /dev/null +++ b/js/third-party/analytics/baidu-analytics.js @@ -0,0 +1,7 @@ +/* global _hmt */ + +if (!window._hmt) window._hmt = []; + +document.addEventListener('pjax:success', () => { + _hmt.push(['_trackPageview', location.pathname]); +}); diff --git a/js/third-party/analytics/google-analytics.js b/js/third-party/analytics/google-analytics.js new file mode 100644 index 000000000..2cd128f76 --- /dev/null +++ b/js/third-party/analytics/google-analytics.js @@ -0,0 +1,35 @@ +/* global CONFIG, dataLayer, gtag */ + +if (!CONFIG.google_analytics.only_pageview) { + if (CONFIG.hostname === location.hostname) { + window.dataLayer = window.dataLayer || []; + window.gtag = function() { + dataLayer.push(arguments); + }; + gtag('js', new Date()); + gtag('config', CONFIG.google_analytics.tracking_id); + + document.addEventListener('pjax:success', () => { + gtag('event', 'page_view', { + page_location: location.href, + page_path : location.pathname, + page_title : document.title + }); + }); + } +} else { + const sendPageView = () => { + if (CONFIG.hostname !== location.hostname) return; + const uid = localStorage.getItem('uid') || (Math.random() + '.' + Math.random()); + localStorage.setItem('uid', uid); + navigator.sendBeacon('https://www.google-analytics.com/collect', new URLSearchParams({ + v : 1, + tid: CONFIG.google_analytics.tracking_id, + cid: uid, + t : 'pageview', + dp : encodeURIComponent(location.pathname) + })); + }; + document.addEventListener('pjax:complete', sendPageView); + sendPageView(); +} diff --git a/js/third-party/analytics/growingio.js b/js/third-party/analytics/growingio.js new file mode 100644 index 000000000..0460833b2 --- /dev/null +++ b/js/third-party/analytics/growingio.js @@ -0,0 +1,10 @@ +/* global CONFIG, gio */ + +if (!window.gio) { + window.gio = function() { + (window.gio.q = window.gio.q || []).push(arguments); + }; +} + +gio('init', `${CONFIG.growingio_analytics}`, {}); +gio('send'); diff --git a/js/third-party/analytics/matomo.js b/js/third-party/analytics/matomo.js new file mode 100644 index 000000000..290a3e091 --- /dev/null +++ b/js/third-party/analytics/matomo.js @@ -0,0 +1,19 @@ +/* global CONFIG */ + +if (CONFIG.matomo.enable) { + window._paq = window._paq || []; + const _paq = window._paq; + + /* tracker methods like "setCustomDimension" should be called before "trackPageView" */ + _paq.push(['trackPageView']); + _paq.push(['enableLinkTracking']); + const u = CONFIG.matomo.server_url; + _paq.push(['setTrackerUrl', u + 'matomo.php']); + _paq.push(['setSiteId', CONFIG.matomo.site_id]); + const d = document; + const g = d.createElement('script'); + const s = d.getElementsByTagName('script')[0]; + g.async = true; + g.src = u + 'matomo.js'; + s.parentNode.insertBefore(g, s); +} diff --git a/js/third-party/chat/chatra.js b/js/third-party/chat/chatra.js new file mode 100644 index 000000000..e495b8e18 --- /dev/null +++ b/js/third-party/chat/chatra.js @@ -0,0 +1,19 @@ +/* global CONFIG, Chatra */ + +(function() { + if (CONFIG.chatra.embed) { + window.ChatraSetup = { + mode : 'frame', + injectTo: CONFIG.chatra.embed + }; + } + + window.ChatraID = CONFIG.chatra.id; + + const chatButton = document.querySelector('.sidebar-button button'); + if (chatButton) { + chatButton.addEventListener('click', () => { + Chatra('openChat', true); + }); + } +})(); diff --git a/js/third-party/chat/tidio.js b/js/third-party/chat/tidio.js new file mode 100644 index 000000000..bffb918e0 --- /dev/null +++ b/js/third-party/chat/tidio.js @@ -0,0 +1,10 @@ +/* global tidioChatApi */ + +(function() { + const chatButton = document.querySelector('.sidebar-button button'); + if (chatButton) { + chatButton.addEventListener('click', () => { + tidioChatApi.open(); + }); + } +})(); diff --git a/js/third-party/comments/changyan.js b/js/third-party/comments/changyan.js new file mode 100644 index 000000000..18a1be4f8 --- /dev/null +++ b/js/third-party/comments/changyan.js @@ -0,0 +1,39 @@ +/* global NexT, CONFIG */ + +document.addEventListener('page:loaded', () => { + const { appid, appkey } = CONFIG.changyan; + const mainJs = 'https://cy-cdn.kuaizhan.com/upload/changyan.js'; + const countJs = `https://cy-cdn.kuaizhan.com/upload/plugins/plugins.list.count.js?clientId=${appid}`; + + // Get the number of comments + setTimeout(() => { + return NexT.utils.getScript(countJs, { + attributes: { + async: true, + id : 'cy_cmt_num' + } + }); + }, 0); + + // When scroll to comment section + if (CONFIG.page.comments && !CONFIG.page.isHome) { + NexT.utils.loadComments('#SOHUCS') + .then(() => { + return NexT.utils.getScript(mainJs, { + attributes: { + async: true + } + }); + }) + .then(() => { + window.changyan.api.config({ + appid, + conf: appkey + }); + }) + .catch(error => { + // eslint-disable-next-line no-console + console.error('Failed to load Changyan', error); + }); + } +}); diff --git a/js/third-party/comments/disqus.js b/js/third-party/comments/disqus.js new file mode 100644 index 000000000..4d1ca9e73 --- /dev/null +++ b/js/third-party/comments/disqus.js @@ -0,0 +1,41 @@ +/* global NexT, CONFIG, DISQUS */ + +document.addEventListener('page:loaded', () => { + + if (CONFIG.disqus.count) { + if (window.DISQUSWIDGETS) { + window.DISQUSWIDGETS.getCount({ reset: true }); + } else { + // Defer loading until the whole page loading is completed + NexT.utils.getScript(`https://${CONFIG.disqus.shortname}.disqus.com/count.js`, { + attributes: { id: 'dsq-count-scr', defer: true } + }); + } + } + + if (CONFIG.page.comments) { + // `disqus_config` should be a global variable + // See https://help.disqus.com/en/articles/1717084-javascript-configuration-variables + window.disqus_config = function() { + this.page.url = CONFIG.page.permalink; + this.page.identifier = CONFIG.page.path; + this.page.title = CONFIG.page.title; + if (CONFIG.disqus.i18n.disqus !== 'disqus') { + this.language = CONFIG.disqus.i18n.disqus; + } + }; + NexT.utils.loadComments('#disqus_thread').then(() => { + if (window.DISQUS) { + DISQUS.reset({ + reload: true, + config: window.disqus_config + }); + } else { + NexT.utils.getScript(`https://${CONFIG.disqus.shortname}.disqus.com/embed.js`, { + attributes: { dataset: { timestamp: '' + +new Date() } } + }); + } + }); + } + +}); diff --git a/js/third-party/comments/disqusjs.js b/js/third-party/comments/disqusjs.js new file mode 100644 index 000000000..d8401eee7 --- /dev/null +++ b/js/third-party/comments/disqusjs.js @@ -0,0 +1,23 @@ +/* global NexT, CONFIG, DisqusJS */ + +document.addEventListener('page:loaded', () => { + if (!CONFIG.page.comments) return; + + NexT.utils.loadComments('#disqus_thread') + .then(() => NexT.utils.getScript(CONFIG.disqusjs.js, { condition: window.DisqusJS })) + .then(() => { + window.dsqjs = new DisqusJS({ + api : CONFIG.disqusjs.api || 'https://disqus.com/api/', + apikey : CONFIG.disqusjs.apikey, + shortname : CONFIG.disqusjs.shortname, + url : CONFIG.page.permalink, + identifier: CONFIG.page.path, + title : CONFIG.page.title + }); + window.dsqjs.render(document.querySelector('.disqusjs-container')); + }); +}); + +document.addEventListener('pjax:send', () => { + if (window.dsqjs) window.dsqjs.destroy(); +}); diff --git a/js/third-party/comments/gitalk.js b/js/third-party/comments/gitalk.js new file mode 100644 index 000000000..08d07f4cf --- /dev/null +++ b/js/third-party/comments/gitalk.js @@ -0,0 +1,24 @@ +/* global NexT, CONFIG, Gitalk */ + +document.addEventListener('page:loaded', () => { + if (!CONFIG.page.comments) return; + + NexT.utils.loadComments('.gitalk-container') + .then(() => NexT.utils.getScript(CONFIG.gitalk.js, { + condition: window.Gitalk + })) + .then(() => { + const gitalk = new Gitalk({ + clientID : CONFIG.gitalk.client_id, + clientSecret : CONFIG.gitalk.client_secret, + repo : CONFIG.gitalk.repo, + owner : CONFIG.gitalk.github_id, + admin : [CONFIG.gitalk.admin_user], + id : CONFIG.gitalk.path_md5, + proxy : CONFIG.gitalk.proxy, + language : CONFIG.gitalk.language || window.navigator.language, + distractionFreeMode: CONFIG.gitalk.distraction_free_mode + }); + gitalk.render(document.querySelector('.gitalk-container')); + }); +}); diff --git a/js/third-party/comments/isso.js b/js/third-party/comments/isso.js new file mode 100644 index 000000000..2c7060133 --- /dev/null +++ b/js/third-party/comments/isso.js @@ -0,0 +1,15 @@ +/* global NexT, CONFIG */ + +document.addEventListener('page:loaded', () => { + if (!CONFIG.page.comments) return; + + NexT.utils.loadComments('#isso-thread') + .then(() => NexT.utils.getScript(`${CONFIG.isso}js/embed.min.js`, { + attributes: { + dataset: { + isso: `${CONFIG.isso}` + } + }, + parentNode: document.querySelector('#isso-thread') + })); +}); diff --git a/js/third-party/comments/livere.js b/js/third-party/comments/livere.js new file mode 100644 index 000000000..c4bcd2e12 --- /dev/null +++ b/js/third-party/comments/livere.js @@ -0,0 +1,19 @@ +/* global NexT, CONFIG, LivereTower */ + +document.addEventListener('page:loaded', () => { + if (!CONFIG.page.comments) return; + + NexT.utils.loadComments('#lv-container').then(() => { + window.livereOptions = { + refer: CONFIG.page.path.replace(/index\.html$/, '') + }; + + if (typeof LivereTower === 'function') return; + + NexT.utils.getScript('https://cdn-city.livere.com/js/embed.dist.js', { + attributes: { + async: true + } + }); + }); +}); diff --git a/js/third-party/comments/utterances.js b/js/third-party/comments/utterances.js new file mode 100644 index 000000000..332ee0577 --- /dev/null +++ b/js/third-party/comments/utterances.js @@ -0,0 +1,17 @@ +/* global NexT, CONFIG */ + +document.addEventListener('page:loaded', () => { + if (!CONFIG.page.comments) return; + + NexT.utils.loadComments('.utterances-container') + .then(() => NexT.utils.getScript('https://utteranc.es/client.js', { + attributes: { + async : true, + crossOrigin : 'anonymous', + 'repo' : CONFIG.utterances.repo, + 'issue-term': CONFIG.utterances.issue_term, + 'theme' : CONFIG.utterances.theme + }, + parentNode: document.querySelector('.utterances-container') + })); +}); diff --git a/js/third-party/fancybox.js b/js/third-party/fancybox.js new file mode 100644 index 000000000..178db4b1e --- /dev/null +++ b/js/third-party/fancybox.js @@ -0,0 +1,35 @@ +/* global Fancybox */ + +document.addEventListener('page:loaded', () => { + + /** + * Wrap images with fancybox. + */ + document.querySelectorAll('.post-body :not(a) > img, .post-body > img').forEach(image => { + const imageLink = image.dataset.src || image.src; + const imageWrapLink = document.createElement('a'); + imageWrapLink.classList.add('fancybox'); + imageWrapLink.href = imageLink; + imageWrapLink.setAttribute('itemscope', ''); + imageWrapLink.setAttribute('itemtype', 'http://schema.org/ImageObject'); + imageWrapLink.setAttribute('itemprop', 'url'); + + let dataFancybox = 'default'; + if (image.closest('.post-gallery') !== null) { + dataFancybox = 'gallery'; + } else if (image.closest('.group-picture') !== null) { + dataFancybox = 'group'; + } + imageWrapLink.dataset.fancybox = dataFancybox; + + const imageTitle = image.title || image.alt; + if (imageTitle) { + imageWrapLink.title = imageTitle; + // Make sure img captions will show correctly in fancybox + imageWrapLink.dataset.caption = imageTitle; + } + image.wrap(imageWrapLink); + }); + + Fancybox.bind('[data-fancybox]'); +}); diff --git a/js/third-party/math/katex.js b/js/third-party/math/katex.js new file mode 100644 index 000000000..ad745b18f --- /dev/null +++ b/js/third-party/math/katex.js @@ -0,0 +1,7 @@ +/* global NexT, CONFIG */ + +document.addEventListener('page:loaded', () => { + if (!CONFIG.enableMath) return; + + NexT.utils.getScript(CONFIG.katex.copy_tex_js).catch(() => {}); +}); diff --git a/js/third-party/math/mathjax.js b/js/third-party/math/mathjax.js new file mode 100644 index 000000000..fe4d4488a --- /dev/null +++ b/js/third-party/math/mathjax.js @@ -0,0 +1,36 @@ +/* global NexT, CONFIG, MathJax */ + +document.addEventListener('page:loaded', () => { + if (!CONFIG.enableMath) return; + + if (typeof MathJax === 'undefined') { + window.MathJax = { + tex: { + inlineMath: { '[+]': [['$', '$']] }, + tags : CONFIG.mathjax.tags + }, + options: { + renderActions: { + insertedScript: [200, () => { + document.querySelectorAll('mjx-container').forEach(node => { + const target = node.parentNode; + if (target.nodeName.toLowerCase() === 'li') { + target.parentNode.classList.add('has-jax'); + } + }); + }, '', false] + } + } + }; + NexT.utils.getScript(CONFIG.mathjax.js, { + attributes: { + defer: true + } + }); + } else { + MathJax.startup.document.state(0); + MathJax.typesetClear(); + MathJax.texReset(); + MathJax.typesetPromise(); + } +}); diff --git a/js/third-party/pace.js b/js/third-party/pace.js new file mode 100644 index 000000000..c22d59f01 --- /dev/null +++ b/js/third-party/pace.js @@ -0,0 +1,7 @@ +/* global Pace */ + +Pace.options.restartOnPushState = false; + +document.addEventListener('pjax:send', () => { + Pace.restart(); +}); diff --git a/js/third-party/quicklink.js b/js/third-party/quicklink.js new file mode 100644 index 000000000..2543ad1ef --- /dev/null +++ b/js/third-party/quicklink.js @@ -0,0 +1,37 @@ +/* global CONFIG, quicklink */ + +(function() { + if (typeof CONFIG.quicklink.ignores === 'string') { + const ignoresStr = `[${CONFIG.quicklink.ignores}]`; + CONFIG.quicklink.ignores = JSON.parse(ignoresStr); + } + + let resetFn = null; + + const onRefresh = () => { + if (resetFn) resetFn(); + if (!CONFIG.quicklink.enable) return; + + let ignoresArr = CONFIG.quicklink.ignores || []; + if (!Array.isArray(ignoresArr)) { + ignoresArr = [ignoresArr]; + } + + resetFn = quicklink.listen({ + timeout : CONFIG.quicklink.timeout, + priority: CONFIG.quicklink.priority, + ignores : [ + uri => uri.includes('#'), + uri => uri === CONFIG.quicklink.url, + ...ignoresArr + ] + }); + }; + + if (CONFIG.quicklink.delay) { + window.addEventListener('load', onRefresh); + document.addEventListener('pjax:success', onRefresh); + } else { + document.addEventListener('page:loaded', onRefresh); + } +})(); diff --git a/js/third-party/search/algolia-search.js b/js/third-party/search/algolia-search.js new file mode 100644 index 000000000..12a554c87 --- /dev/null +++ b/js/third-party/search/algolia-search.js @@ -0,0 +1,130 @@ +/* global instantsearch, algoliasearch, CONFIG, pjax */ + +document.addEventListener('DOMContentLoaded', () => { + const { indexName, appID, apiKey, hits } = CONFIG.algolia; + + const search = instantsearch({ + indexName, + searchClient : algoliasearch(appID, apiKey), + searchFunction: helper => { + if (document.querySelector('.search-input').value) { + helper.search(); + } + } + }); + + if (typeof pjax === 'object') { + search.on('render', () => { + pjax.refresh(document.querySelector('.algolia-hits')); + }); + } + + // Registering Widgets + search.addWidgets([ + instantsearch.widgets.configure({ + hitsPerPage: hits.per_page || 10 + }), + + instantsearch.widgets.searchBox({ + container : '.search-input-container', + placeholder : CONFIG.i18n.placeholder, + // Hide default icons of algolia search + showReset : false, + showSubmit : false, + showLoadingIndicator: false, + cssClasses : { + input: 'search-input' + } + }), + + instantsearch.widgets.stats({ + container: '.algolia-stats', + templates: { + text: data => { + const stats = CONFIG.i18n.hits_time + .replace('${hits}', data.nbHits) + .replace('${time}', data.processingTimeMS); + return `${stats} + Algolia`; + } + }, + cssClasses: { + text: 'search-stats' + } + }), + + instantsearch.widgets.hits({ + container : '.algolia-hits', + escapeHTML: false, + templates : { + item: data => { + const { title, excerpt, excerptStrip, contentStripTruncate } = data._highlightResult; + let result = `${title.value}`; + const content = excerpt || excerptStrip || contentStripTruncate; + if (content && content.value) { + const div = document.createElement('div'); + div.innerHTML = content.value; + result += `

${div.textContent.substring(0, 100)}...

`; + } + return result; + }, + empty: data => { + return `
+ ${CONFIG.i18n.empty.replace('${query}', data.query)} +
`; + } + }, + cssClasses: { + list: 'search-result-list' + } + }), + + instantsearch.widgets.pagination({ + container: '.algolia-pagination', + scrollTo : false, + showFirst: false, + showLast : false, + templates: { + first : '', + last : '', + previous: '', + next : '' + }, + cssClasses: { + list : ['pagination', 'algolia-pagination'], + item : 'pagination-item', + link : 'page-number', + selectedItem: 'current', + disabledItem: 'disabled-item' + } + }) + ]); + + search.start(); + + // Handle and trigger popup window + document.querySelectorAll('.popup-trigger').forEach(element => { + element.addEventListener('click', () => { + document.body.classList.add('search-active'); + setTimeout(() => document.querySelector('.search-input').focus(), 500); + }); + }); + + // Monitor main search box + const onPopupClose = () => { + document.body.classList.remove('search-active'); + }; + + document.querySelector('.search-pop-overlay').addEventListener('click', event => { + if (event.target === document.querySelector('.search-pop-overlay')) { + onPopupClose(); + } + }); + document.querySelector('.popup-btn-close').addEventListener('click', onPopupClose); + document.addEventListener('pjax:success', onPopupClose); + window.addEventListener('keyup', event => { + if (event.key === 'Escape') { + onPopupClose(); + } + }); +}); diff --git a/js/third-party/search/local-search.js b/js/third-party/search/local-search.js new file mode 100644 index 000000000..92a264dc9 --- /dev/null +++ b/js/third-party/search/local-search.js @@ -0,0 +1,99 @@ +/* global CONFIG, pjax, LocalSearch */ + +document.addEventListener('DOMContentLoaded', () => { + if (!CONFIG.path) { + // Search DB path + console.warn('`hexo-generator-searchdb` plugin is not installed!'); + return; + } + const localSearch = new LocalSearch({ + path : CONFIG.path, + top_n_per_article: CONFIG.localsearch.top_n_per_article, + unescape : CONFIG.localsearch.unescape + }); + + const input = document.querySelector('.search-input'); + + const inputEventFunction = () => { + if (!localSearch.isfetched) return; + const searchText = input.value.trim().toLowerCase(); + const keywords = searchText.split(/[-\s]+/); + const container = document.querySelector('.search-result-container'); + let resultItems = []; + if (searchText.length > 0) { + // Perform local searching + resultItems = localSearch.getResultItems(keywords); + } + if (keywords.length === 1 && keywords[0] === '') { + container.classList.add('no-result'); + container.innerHTML = '
'; + } else if (resultItems.length === 0) { + container.classList.add('no-result'); + container.innerHTML = '
'; + } else { + resultItems.sort((left, right) => { + if (left.includedCount !== right.includedCount) { + return right.includedCount - left.includedCount; + } else if (left.hitCount !== right.hitCount) { + return right.hitCount - left.hitCount; + } + return right.id - left.id; + }); + const stats = CONFIG.i18n.hits.replace('${hits}', resultItems.length); + + container.classList.remove('no-result'); + container.innerHTML = `
${stats}
+
+ `; + if (typeof pjax === 'object') pjax.refresh(container); + } + }; + + localSearch.highlightSearchWords(document.querySelector('.post-body')); + if (CONFIG.localsearch.preload) { + localSearch.fetchData(); + } + + if (CONFIG.localsearch.trigger === 'auto') { + input.addEventListener('input', inputEventFunction); + } else { + document.querySelector('.search-icon').addEventListener('click', inputEventFunction); + input.addEventListener('keypress', event => { + if (event.key === 'Enter') { + inputEventFunction(); + } + }); + } + window.addEventListener('search:loaded', inputEventFunction); + + // Handle and trigger popup window + document.querySelectorAll('.popup-trigger').forEach(element => { + element.addEventListener('click', () => { + document.body.classList.add('search-active'); + // Wait for search-popup animation to complete + setTimeout(() => input.focus(), 500); + if (!localSearch.isfetched) localSearch.fetchData(); + }); + }); + + // Monitor main search box + const onPopupClose = () => { + document.body.classList.remove('search-active'); + }; + + document.querySelector('.search-pop-overlay').addEventListener('click', event => { + if (event.target === document.querySelector('.search-pop-overlay')) { + onPopupClose(); + } + }); + document.querySelector('.popup-btn-close').addEventListener('click', onPopupClose); + document.addEventListener('pjax:success', () => { + localSearch.highlightSearchWords(document.querySelector('.post-body')); + onPopupClose(); + }); + window.addEventListener('keyup', event => { + if (event.key === 'Escape') { + onPopupClose(); + } + }); +}); diff --git a/js/third-party/statistics/firestore.js b/js/third-party/statistics/firestore.js new file mode 100644 index 000000000..3ea7ba67a --- /dev/null +++ b/js/third-party/statistics/firestore.js @@ -0,0 +1,60 @@ +/* global CONFIG, firebase */ + +firebase.initializeApp({ + apiKey : CONFIG.firestore.apiKey, + projectId: CONFIG.firestore.projectId +}); + +(function() { + const getCount = (doc, increaseCount) => { + // IncreaseCount will be false when not in article page + return doc.get().then(d => { + // Has no data, initialize count + let count = d.exists ? d.data().count : 0; + // If first view this article + if (increaseCount) { + // Increase count + count++; + doc.set({ + count + }); + } + return count; + }); + }; + + const db = firebase.firestore(); + const articles = db.collection(CONFIG.firestore.collection); + + document.addEventListener('page:loaded', () => { + + if (CONFIG.page.isPost) { + // Fix issue #118 + // https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent + const title = document.querySelector('.post-title').textContent.trim(); + const doc = articles.doc(title); + let increaseCount = CONFIG.hostname === location.hostname; + if (localStorage.getItem(title)) { + increaseCount = false; + } else { + // Mark as visited + localStorage.setItem(title, true); + } + getCount(doc, increaseCount).then(count => { + document.querySelector('.firestore-visitors-count').innerText = count; + }); + } else if (CONFIG.page.isHome) { + const promises = [...document.querySelectorAll('.post-title')].map(element => { + const title = element.textContent.trim(); + const doc = articles.doc(title); + return getCount(doc); + }); + Promise.all(promises).then(counts => { + const metas = document.querySelectorAll('.firestore-visitors-count'); + counts.forEach((val, idx) => { + metas[idx].innerText = val; + }); + }); + } + }); +})(); diff --git a/js/third-party/statistics/lean-analytics.js b/js/third-party/statistics/lean-analytics.js new file mode 100644 index 000000000..8397112b1 --- /dev/null +++ b/js/third-party/statistics/lean-analytics.js @@ -0,0 +1,107 @@ +/* global CONFIG */ +/* eslint-disable no-console */ + +(function() { + const leancloudSelector = url => { + url = encodeURI(url); + return document.getElementById(url).querySelector('.leancloud-visitors-count'); + }; + + const addCount = Counter => { + const visitors = document.querySelector('.leancloud_visitors'); + const url = decodeURI(visitors.id); + const title = visitors.dataset.flagTitle; + + Counter('get', `/classes/Counter?where=${encodeURIComponent(JSON.stringify({ url }))}`) + .then(response => response.json()) + .then(({ results }) => { + if (results.length > 0) { + const counter = results[0]; + leancloudSelector(url).innerText = counter.time + 1; + Counter('put', '/classes/Counter/' + counter.objectId, { + time: { + '__op' : 'Increment', + 'amount': 1 + } + }) + .catch(error => { + console.error('Failed to save visitor count', error); + }); + } else if (CONFIG.leancloud_visitors.security) { + leancloudSelector(url).innerText = 'Counter not initialized! More info at console err msg.'; + console.error('ATTENTION! LeanCloud counter has security bug, see how to solve it here: https://github.com/theme-next/hexo-leancloud-counter-security. \n However, you can still use LeanCloud without security, by setting `security` option to `false`.'); + } else { + Counter('post', '/classes/Counter', { title, url, time: 1 }) + .then(response => response.json()) + .then(() => { + leancloudSelector(url).innerText = 1; + }) + .catch(error => { + console.error('Failed to create', error); + }); + } + }) + .catch(error => { + console.error('LeanCloud Counter Error', error); + }); + }; + + const showTime = Counter => { + const visitors = document.querySelectorAll('.leancloud_visitors'); + const entries = [...visitors].map(element => { + return decodeURI(element.id); + }); + + Counter('get', `/classes/Counter?where=${encodeURIComponent(JSON.stringify({ url: { '$in': entries } }))}`) + .then(response => response.json()) + .then(({ results }) => { + for (const url of entries) { + const target = results.find(item => item.url === url); + leancloudSelector(url).innerText = target ? target.time : 0; + } + }) + .catch(error => { + console.error('LeanCloud Counter Error', error); + }); + }; + + const { app_id, app_key, server_url } = CONFIG.leancloud_visitors; + const fetchData = api_server => { + const Counter = (method, url, data) => { + return fetch(`${api_server}/1.1${url}`, { + method, + headers: { + 'X-LC-Id' : app_id, + 'X-LC-Key' : app_key, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + }; + if (CONFIG.page.isPost) { + if (CONFIG.hostname !== location.hostname) return; + addCount(Counter); + } else if (document.querySelectorAll('.post-title-link').length >= 1) { + showTime(Counter); + } + }; + + let api_server; + if (server_url) { + api_server = server_url; + } else if (app_id.slice(-9) === '-MdYXbMMI') { + api_server = `https://${app_id.slice(0, 8).toLowerCase()}.api.lncldglobal.com`; + } + + document.addEventListener('page:loaded', () => { + if (api_server) { + fetchData(api_server); + } else { + fetch(`https://app-router.leancloud.cn/2/route?appId=${app_id}`) + .then(response => response.json()) + .then(({ api_server }) => { + fetchData(`https://${api_server}`); + }); + } + }); +})(); diff --git a/js/third-party/tags/mermaid.js b/js/third-party/tags/mermaid.js new file mode 100644 index 000000000..54f62885e --- /dev/null +++ b/js/third-party/tags/mermaid.js @@ -0,0 +1,32 @@ +/* global NexT, CONFIG, mermaid */ + +document.addEventListener('page:loaded', () => { + const mermaidElements = document.querySelectorAll('.mermaid'); + if (mermaidElements.length) { + NexT.utils.getScript(CONFIG.mermaid.js, { + condition: window.mermaid + }).then(() => { + mermaidElements.forEach(element => { + const newElement = document.createElement('div'); + newElement.innerHTML = element.innerHTML; + newElement.className = element.className; + const parent = element.parentNode; + // Fix issue #347 + // Support mermaid inside backtick code block + if (parent.matches('pre')) { + parent.parentNode.replaceChild(newElement, parent); + } else { + parent.replaceChild(newElement, element); + } + }); + mermaid.initialize({ + theme : CONFIG.darkmode && window.matchMedia('(prefers-color-scheme: dark)').matches ? CONFIG.mermaid.theme.dark : CONFIG.mermaid.theme.light, + logLevel : 4, + flowchart: { curve: 'linear' }, + gantt : { axisFormat: '%m/%d/%Y' }, + sequence : { actorMargin: 50 } + }); + mermaid.run(); + }); + } +}); diff --git a/js/third-party/tags/pdf.js b/js/third-party/tags/pdf.js new file mode 100644 index 000000000..7e828911f --- /dev/null +++ b/js/third-party/tags/pdf.js @@ -0,0 +1,23 @@ +/* global NexT, CONFIG, PDFObject */ + +document.addEventListener('page:loaded', () => { + if (document.querySelectorAll('.pdf-container').length) { + NexT.utils.getScript(CONFIG.pdf.object_url, { + condition: window.PDFObject + }).then(() => { + document.querySelectorAll('.pdf-container').forEach(element => { + PDFObject.embed(element.dataset.target, element, { + pdfOpenParams: { + navpanes : 0, + toolbar : 0, + statusbar: 0, + pagemode : 'thumbs', + view : 'FitH' + }, + PDFJS_URL: CONFIG.pdf.url, + height : element.dataset.height + }); + }); + }); + } +}); diff --git a/js/third-party/tags/wavedrom.js b/js/third-party/tags/wavedrom.js new file mode 100644 index 000000000..ddd9a1d97 --- /dev/null +++ b/js/third-party/tags/wavedrom.js @@ -0,0 +1,13 @@ +/* global NexT, CONFIG, WaveDrom */ + +document.addEventListener('page:loaded', () => { + NexT.utils.getScript(CONFIG.wavedrom.js, { + condition: window.WaveDrom + }).then(() => { + NexT.utils.getScript(CONFIG.wavedrom_skin.js, { + condition: window.WaveSkin + }).then(() => { + WaveDrom.ProcessAll(); + }); + }); +}); diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 000000000..abc50ff78 --- /dev/null +++ b/js/utils.js @@ -0,0 +1,486 @@ +/* global NexT, CONFIG */ + +HTMLElement.prototype.wrap = function(wrapper) { + this.parentNode.insertBefore(wrapper, this); + this.parentNode.removeChild(this); + wrapper.appendChild(this); +}; + +(function() { + const onPageLoaded = () => document.dispatchEvent( + new Event('page:loaded', { + bubbles: true + }) + ); + + if (document.readyState === 'loading') { + document.addEventListener('readystatechange', onPageLoaded, { once: true }); + } else { + onPageLoaded(); + } + document.addEventListener('pjax:success', onPageLoaded); +})(); + +NexT.utils = { + + registerExtURL: function() { + document.querySelectorAll('span.exturl').forEach(element => { + const link = document.createElement('a'); + // https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings + link.href = decodeURIComponent(atob(element.dataset.url).split('').map(c => { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join('')); + link.rel = 'noopener external nofollow noreferrer'; + link.target = '_blank'; + link.className = element.className; + link.title = element.title; + link.innerHTML = element.innerHTML; + element.parentNode.replaceChild(link, element); + }); + }, + + registerCodeblock: function(element) { + const inited = !!element; + let figure = (inited ? element : document).querySelectorAll('figure.highlight'); + let isHljsWithWrap = true; + if (figure.length === 0) { + figure = document.querySelectorAll('pre:not(.mermaid)'); + isHljsWithWrap = false; + } + figure.forEach(element => { + if (!inited) { + let span = element.querySelectorAll('.code .line span'); + if (span.length === 0) { + // Hljs without line_number and wrap + span = element.querySelectorAll('code.highlight span'); + } + span.forEach(s => { + s.classList.forEach(name => { + s.classList.replace(name, `hljs-${name}`); + }); + }); + } + const height = parseInt(window.getComputedStyle(element).height.replace('px', ''), 10); + const needFold = CONFIG.fold.enable && (height > CONFIG.fold.height); + if (!needFold && !CONFIG.copycode.enable) return; + let target; + if (isHljsWithWrap && CONFIG.copycode.style === 'mac') { + target = element; + } else { + let box = element.querySelector('.code-container'); + if (!box) { + // https://github.com/next-theme/hexo-theme-next/issues/98 + // https://github.com/next-theme/hexo-theme-next/pull/508 + const container = element.querySelector('.table-container') || element; + box = document.createElement('div'); + box.className = 'code-container'; + container.wrap(box); + } + target = box; + } + if (needFold && !target.classList.contains('unfold')) { + target.classList.add('highlight-fold'); + target.insertAdjacentHTML('beforeend', '
'); + target.querySelector('.expand-btn').addEventListener('click', () => { + target.classList.remove('highlight-fold'); + target.classList.add('unfold'); + }); + } + if (inited || !CONFIG.copycode.enable) return; + // One-click copy code support. + target.insertAdjacentHTML('beforeend', '
'); + const button = target.querySelector('.copy-btn'); + button.addEventListener('click', () => { + const lines = element.querySelector('.code') || element.querySelector('code'); + const code = lines.innerText; + if (navigator.clipboard) { + // https://caniuse.com/mdn-api_clipboard_writetext + navigator.clipboard.writeText(code).then(() => { + button.querySelector('i').className = 'fa fa-check-circle fa-fw'; + }, () => { + button.querySelector('i').className = 'fa fa-times-circle fa-fw'; + }); + } else { + const ta = document.createElement('textarea'); + ta.style.top = window.scrollY + 'px'; // Prevent page scrolling + ta.style.position = 'absolute'; + ta.style.opacity = '0'; + ta.readOnly = true; + ta.value = code; + document.body.append(ta); + ta.select(); + ta.setSelectionRange(0, code.length); + ta.readOnly = false; + const result = document.execCommand('copy'); + button.querySelector('i').className = result ? 'fa fa-check-circle fa-fw' : 'fa fa-times-circle fa-fw'; + ta.blur(); // For iOS + button.blur(); + document.body.removeChild(ta); + } + }); + element.addEventListener('mouseleave', () => { + setTimeout(() => { + button.querySelector('i').className = 'fa fa-copy fa-fw'; + }, 300); + }); + }); + }, + + wrapTableWithBox: function() { + document.querySelectorAll('table').forEach(element => { + const box = document.createElement('div'); + box.className = 'table-container'; + element.wrap(box); + }); + }, + + registerVideoIframe: function() { + document.querySelectorAll('iframe').forEach(element => { + const supported = [ + 'www.youtube.com', + 'player.vimeo.com', + 'player.youku.com', + 'player.bilibili.com', + 'www.tudou.com' + ].some(host => element.src.includes(host)); + if (supported && !element.parentNode.matches('.video-container')) { + const box = document.createElement('div'); + box.className = 'video-container'; + element.wrap(box); + const width = Number(element.width); + const height = Number(element.height); + if (width && height) { + box.style.paddingTop = (height / width * 100) + '%'; + } + } + }); + }, + + updateActiveNav: function() { + if (!Array.isArray(NexT.utils.sections)) return; + let index = NexT.utils.sections.findIndex(element => { + return element && element.getBoundingClientRect().top > 10; + }); + if (index === -1) { + index = NexT.utils.sections.length - 1; + } else if (index > 0) { + index--; + } + this.activateNavByIndex(index); + }, + + registerScrollPercent: function() { + const backToTop = document.querySelector('.back-to-top'); + const readingProgressBar = document.querySelector('.reading-progress-bar'); + // For init back to top in sidebar if page was scrolled after page refresh. + window.addEventListener('scroll', () => { + if (backToTop || readingProgressBar) { + const contentHeight = document.body.scrollHeight - window.innerHeight; + const scrollPercent = contentHeight > 0 ? Math.min(100 * window.scrollY / contentHeight, 100) : 0; + if (backToTop) { + backToTop.classList.toggle('back-to-top-on', Math.round(scrollPercent) >= 5); + backToTop.querySelector('span').innerText = Math.round(scrollPercent) + '%'; + } + if (readingProgressBar) { + readingProgressBar.style.setProperty('--progress', scrollPercent.toFixed(2) + '%'); + } + } + this.updateActiveNav(); + }, { passive: true }); + + backToTop && backToTop.addEventListener('click', () => { + window.anime({ + targets : document.scrollingElement, + duration : 500, + easing : 'linear', + scrollTop: 0 + }); + }); + }, + + /** + * Tabs tag listener (without twitter bootstrap). + */ + registerTabsTag: function() { + // Binding `nav-tabs` & `tab-content` by real time permalink changing. + document.querySelectorAll('.tabs ul.nav-tabs .tab').forEach(element => { + element.addEventListener('click', event => { + event.preventDefault(); + // Prevent selected tab to select again. + if (element.classList.contains('active')) return; + const nav = element.parentNode; + // Get the height of `tab-pane` which is activated before, and set it as the height of `tab-content` with extra margin / paddings. + const tabContent = nav.nextElementSibling; + tabContent.style.overflow = 'hidden'; + tabContent.style.transition = 'height 1s'; + // Comment system selection tab does not contain .active class. + const activeTab = tabContent.querySelector('.active') || tabContent.firstElementChild; + // Hight might be `auto`. + const prevHeight = parseInt(window.getComputedStyle(activeTab).height.replace('px', ''), 10) || 0; + const paddingTop = parseInt(window.getComputedStyle(activeTab).paddingTop.replace('px', ''), 10); + const marginBottom = parseInt(window.getComputedStyle(activeTab.firstElementChild).marginBottom.replace('px', ''), 10); + tabContent.style.height = prevHeight + paddingTop + marginBottom + 'px'; + // Add & Remove active class on `nav-tabs` & `tab-content`. + [...nav.children].forEach(target => { + target.classList.toggle('active', target === element); + }); + // https://stackoverflow.com/questions/20306204/using-queryselector-with-ids-that-are-numbers + const tActive = document.getElementById(element.querySelector('a').getAttribute('href').replace('#', '')); + [...tActive.parentNode.children].forEach(target => { + target.classList.toggle('active', target === tActive); + }); + // Trigger event + tActive.dispatchEvent(new Event('tabs:click', { + bubbles: true + })); + // Get the height of `tab-pane` which is activated now. + const hasScrollBar = document.body.scrollHeight > (window.innerHeight || document.documentElement.clientHeight); + const currHeight = parseInt(window.getComputedStyle(tabContent.querySelector('.active')).height.replace('px', ''), 10); + // Reset the height of `tab-content` and see the animation. + tabContent.style.height = currHeight + paddingTop + marginBottom + 'px'; + // Change the height of `tab-content` may cause scrollbar show / disappear, which may result in the change of the `tab-pane`'s height + setTimeout(() => { + if ((document.body.scrollHeight > (window.innerHeight || document.documentElement.clientHeight)) !== hasScrollBar) { + tabContent.style.transition = 'height 0.3s linear'; + // After the animation, we need reset the height of `tab-content` again. + const currHeightAfterScrollBarChange = parseInt(window.getComputedStyle(tabContent.querySelector('.active')).height.replace('px', ''), 10); + tabContent.style.height = currHeightAfterScrollBarChange + paddingTop + marginBottom + 'px'; + } + // Remove all the inline styles, and let the height be adaptive again. + setTimeout(() => { + tabContent.style.transition = ''; + tabContent.style.height = ''; + }, 250); + }, 1000); + if (!CONFIG.stickytabs) return; + const offset = nav.parentNode.getBoundingClientRect().top + window.scrollY + 10; + window.anime({ + targets : document.scrollingElement, + duration : 500, + easing : 'linear', + scrollTop: offset + }); + }); + }); + + window.dispatchEvent(new Event('tabs:register')); + }, + + registerCanIUseTag: function() { + // Get responsive height passed from iframe. + window.addEventListener('message', ({ data }) => { + if (typeof data === 'string' && data.includes('ciu_embed')) { + const featureID = data.split(':')[1]; + const height = data.split(':')[2]; + document.querySelector(`iframe[data-feature=${featureID}]`).style.height = parseInt(height, 10) + 5 + 'px'; + } + }, false); + }, + + registerActiveMenuItem: function() { + document.querySelectorAll('.menu-item a[href]').forEach(target => { + const isSamePath = target.pathname === location.pathname || target.pathname === location.pathname.replace('index.html', ''); + const isSubPath = !CONFIG.root.startsWith(target.pathname) && location.pathname.startsWith(target.pathname); + target.classList.toggle('menu-item-active', target.hostname === location.hostname && (isSamePath || isSubPath)); + }); + }, + + registerLangSelect: function() { + const selects = document.querySelectorAll('.lang-select'); + selects.forEach(sel => { + sel.value = CONFIG.page.lang; + sel.addEventListener('change', () => { + const target = sel.options[sel.selectedIndex]; + document.querySelectorAll('.lang-select-label span').forEach(span => { + span.innerText = target.text; + }); + // Disable Pjax to force refresh translation of menu item + window.location.href = target.dataset.href; + }); + }); + }, + + registerSidebarTOC: function() { + this.sections = [...document.querySelectorAll('.post-toc:not(.placeholder-toc) li a.nav-link')].map(element => { + const target = document.getElementById(decodeURI(element.getAttribute('href')).replace('#', '')); + // TOC item animation navigate. + element.addEventListener('click', event => { + event.preventDefault(); + const offset = target.getBoundingClientRect().top + window.scrollY; + window.anime({ + targets : document.scrollingElement, + duration : 500, + easing : 'linear', + scrollTop: offset, + complete : () => { + history.pushState(null, document.title, element.href); + } + }); + }); + return target; + }); + this.updateActiveNav(); + }, + + registerPostReward: function() { + const button = document.querySelector('.reward-container button'); + if (!button) return; + button.addEventListener('click', () => { + document.querySelector('.post-reward').classList.toggle('active'); + }); + }, + + activateNavByIndex: function(index) { + const nav = document.querySelector('.post-toc:not(.placeholder-toc) .nav'); + if (!nav) return; + + const navItemList = nav.querySelectorAll('.nav-item'); + const target = navItemList[index]; + if (!target || target.classList.contains('active-current')) return; + + const singleHeight = navItemList[navItemList.length - 1].offsetHeight; + + nav.querySelectorAll('.active').forEach(navItem => { + navItem.classList.remove('active', 'active-current'); + }); + target.classList.add('active', 'active-current'); + + let activateEle = target.querySelector('.nav-child') || target.parentElement; + let navChildHeight = 0; + + while (nav.contains(activateEle)) { + if (activateEle.classList.contains('nav-item')) { + activateEle.classList.add('active'); + } else { // .nav-child or .nav + // scrollHeight isn't reliable for transitioning child items. + // The last nav-item in a list has a margin-bottom of 5px. + navChildHeight += (singleHeight * activateEle.childElementCount) + 5; + activateEle.style.setProperty('--height', `${navChildHeight}px`); + } + activateEle = activateEle.parentElement; + } + + // Scrolling to center active TOC element if TOC content is taller then viewport. + const tocElement = document.querySelector(CONFIG.scheme === 'Pisces' || CONFIG.scheme === 'Gemini' ? '.sidebar-panel-container' : '.sidebar'); + if (!document.querySelector('.sidebar-toc-active')) return; + window.anime({ + targets : tocElement, + duration : 200, + easing : 'linear', + scrollTop: tocElement.scrollTop - (tocElement.offsetHeight / 2) + target.getBoundingClientRect().top - tocElement.getBoundingClientRect().top + }); + }, + + updateSidebarPosition: function() { + if (window.innerWidth < 1200 || CONFIG.scheme === 'Pisces' || CONFIG.scheme === 'Gemini') return; + // Expand sidebar on post detail page by default, when post has a toc. + const hasTOC = document.querySelector('.post-toc:not(.placeholder-toc)'); + let display = CONFIG.page.sidebar; + if (typeof display !== 'boolean') { + // There's no definition sidebar in the page front-matter. + display = CONFIG.sidebar.display === 'always' || (CONFIG.sidebar.display === 'post' && hasTOC); + } + if (display) { + window.dispatchEvent(new Event('sidebar:show')); + } + }, + + activateSidebarPanel: function(index) { + const sidebar = document.querySelector('.sidebar-inner'); + const activeClassNames = ['sidebar-toc-active', 'sidebar-overview-active']; + if (sidebar.classList.contains(activeClassNames[index])) return; + + const panelContainer = sidebar.querySelector('.sidebar-panel-container'); + const tocPanel = panelContainer.firstElementChild; + const overviewPanel = panelContainer.lastElementChild; + + let postTOCHeight = tocPanel.scrollHeight; + // For TOC activation, try to use the animated TOC height + if (index === 0) { + const nav = tocPanel.querySelector('.nav'); + if (nav) { + postTOCHeight = parseInt(nav.style.getPropertyValue('--height'), 10); + } + } + const panelHeights = [ + postTOCHeight, + overviewPanel.scrollHeight + ]; + panelContainer.style.setProperty('--inactive-panel-height', `${panelHeights[1 - index]}px`); + panelContainer.style.setProperty('--active-panel-height', `${panelHeights[index]}px`); + + sidebar.classList.replace(activeClassNames[1 - index], activeClassNames[index]); + }, + + getScript: function(src, options = {}, legacyCondition) { + if (typeof options === 'function') { + return this.getScript(src, { + condition: legacyCondition + }).then(options); + } + const { + condition = false, + attributes: { + id = '', + async = false, + defer = false, + crossOrigin = '', + dataset = {}, + ...otherAttributes + } = {}, + parentNode = null + } = options; + return new Promise((resolve, reject) => { + if (condition) { + resolve(); + } else { + const script = document.createElement('script'); + + if (id) script.id = id; + if (crossOrigin) script.crossOrigin = crossOrigin; + script.async = async; + script.defer = defer; + Object.assign(script.dataset, dataset); + Object.entries(otherAttributes).forEach(([name, value]) => { + script.setAttribute(name, String(value)); + }); + + script.onload = resolve; + script.onerror = reject; + + if (typeof src === 'object') { + const { url, integrity } = src; + script.src = url; + if (integrity) { + script.integrity = integrity; + script.crossOrigin = 'anonymous'; + } + } else { + script.src = src; + } + (parentNode || document.head).appendChild(script); + } + }); + }, + + loadComments: function(selector, legacyCallback) { + if (legacyCallback) { + return this.loadComments(selector).then(legacyCallback); + } + return new Promise(resolve => { + const element = document.querySelector(selector); + if (!CONFIG.comments.lazyload || !element) { + resolve(); + return; + } + const intersectionObserver = new IntersectionObserver((entries, observer) => { + const entry = entries[0]; + if (!entry.isIntersecting) return; + + resolve(); + observer.disconnect(); + }); + intersectionObserver.observe(element); + }); + } +}; diff --git a/page/10/index.html b/page/10/index.html new file mode 100644 index 000000000..371ba1d82 --- /dev/null +++ b/page/10/index.html @@ -0,0 +1,1213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

所谓令牌,就是说,一个账号在登录的时候,除了要提供常规密码外,还要提供一组动态密码。而动态密码的来源,可以是实体设备,也可以是软件。

+

这里就说两个手机 APP:Steam 令牌与网易将军令。

+

APP 的功能很简单:在用户需要登录的时候提供动态密码。

+
    +
  • Steam 令牌会在用户需要的时候主动推送动态密码到通知;
  • +
  • 而网易将军令需要用户手动打开软件查看动态密码。
  • +
+

哪一种设计更好呢?

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

今天下班路上看到一对情侣,一路欢声笑语,走在没有信号灯的斑马线上,也要往马路上面站,车流离他们不到十米吧,女生不时地尝试走向对面又马上退回,脸上带着无忧无虑笑容,男友也丝毫没有阻止的意思。

+

我在后面看着,有种奇怪的感觉。觉得,这世界上也许有些人就是注定要死得早些吧。不过,又有点希望自己也能做一个这样的人。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

最近,工作地所在的园区推出了一款 App,宣传的主要功能是获取园区动态以及扫码付款,感觉这样吃饭可以方便一些,因此就下载了。

+

应用的名字叫“园圈”,在 App Store 上搜索出来,底下是一个没有见过的开发商。我觉得还算正常吧,一般这种小范围应用,不都是外包的吗。只是,这使我对于这个应用的使用埋下了一丝戒心。(后来我搜索了一下这家公司,网站充斥着强烈的国企风)

+

进入应用,首先要我注册,这很简单,手机号码验证码啪啪啪就输完了。然后,它要求我输入一个密码。我毫不犹豫地就输入了常用密码,在即将要点下一步的时候,却犹豫了一下。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

在 Windows 操作系统下开发 NodeJS 项目的时候经常会遇到无法删除 Node_modules 文件夹的尴尬(因为依赖过多,文件路径长度爆炸),解决办法如下。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

今天,在N连跪呕心沥血终于推倒对面一座塔赢了以后,我把皇室战争这个游戏删了。

+

通常来说,删除一个游戏的原因无非几种:没兴趣,不好玩,玩腻了,等等。但是,皇室战争这个游戏,却比较特别。

+

要说它不好玩呢,其实挺有意思的,也挺符合现代游戏的节奏,不需要长时间在线,有空抽几分钟玩一局即可。

+

要说玩腻了呢,其实也没有,虽说已经玩了好几个月,但是很多酷炫的卡我依然还没有开到。

+

然后,问题在哪里呢?

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

这个问题其实困扰了我很久。默认的后台字体实在是惨不忍睹。今天终于发现了一个很好的方案,完美解决。

+

在当前主题的 functions.php 中,加上如下代码:

+
/**
* 更改后台字体为雅黑
*/
function change_admin_font(){
echo '<style type="text/css">.wp-admin{font-family: \'Helvetica Neue\', Helvetica, \'Microsoft Yahei\', \'Hiragino Sans GB\', \'WenQuanYi Micro Hei\', sans-serif;}</style>';
}
add_action('admin_head', 'change_admin_font');
+ +

顺便提供一下更改 Twenty Sixteen 主题字体的代码吧,要改的地方挺多的。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

MEANJS 预设的 Grunt task 中没有提供类似出错自动重启的任务,因此当实际使用它搭建了一个 app 部署到服务器上后发现经常有一些奇怪的问题导致其崩溃挂掉。然而根据 log 来看问题应该不是由于项目代码导致的,可能是 MEANJS 本身的问题,也可能是某些 Lib 的问题。这种情况下,我能想到的暂时性解决方案就是使用 forever 了。

+

个人觉得 MEANJS 在 production mode 中也使用 nodemon 来跑 watch 任务有些鸡肋,因为我们并不需要在产品服务器上频繁地更改代码。因此,我直接把它替换掉了。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

前几天收到一个项目请求,其实是某人希望做个简单的毕设代码实现。因为去年毕业季的时候帮同学的一些朋友做过毕设项目,因此找到了我,希望继续帮忙。因为这种东西一般都比较简单,所以我也没想很多就答应了。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

jQuery 曾经存在 3 种绑定事件的方法:bind / live / delegate,后来 live 被砍掉了,只留下 bind 与 delegate,它们之间的区别是,通过 bind 方法绑定的事件,只对当前存在的元素生效,而通过 delegate 则可以绑定“现在”以及“将来”的所有元素。

+

为“将来”元素绑定事件的适用场景还是挺多的。比如一个列表,或者一个表格,它可能会动态地被插入或者移除一些子元素,然后每个元素都需要有一个点击事件,这样的话我们就需要保证“现在”已存在的元素以及“将来”可能被添加进去的元素都能够正常工作。怎么办呢,我们总不能每插入一个元素就给它绑一次事件吧(事实上我以前没少干这事),因此 jQuery 就为我们提供了后者的方法。

+

一开始我觉得很奇怪,像 delegate 这样的方法是怎么实现的呢?通过监听 DOM 树变化吗?性能开销会不会特别大?后来知道了 JavaScript 有一种机制叫事件代理(event delegation),也就是本文要说的东西,才明白,原来一切都很简单。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

高效 CSS

如何编写高效 CSS 其实是一个过时的话题。

+

这方面曾经存在许多真知灼见,比如说 CSS 选择器的解析方向是从子到父,比如说 ID 选择器是最快的,不应该给 Class 选择器加上 Tag 限制,尽量避免使用后代选择器等。但是,随着浏览器解析引擎的发展,这些都已经变得不再那么重要了。MDN 上阐述高效 CSS 的文章也已经被标记为过时。

+

Antti Koivisto 是 Webkit 核心的贡献者之一,他曾说:

+
+

My view is that authors should not need to worry about optimizing selectors (and from what I see, they generally don’t), that should be the job of the engine.

+
+

因此,如果把“高效 CSS”的含义限制为“高效 CSS 选择器”的话,那么实际上现在它已经不是开发者需要关心的问题了。我们需要做的事情变得更“政治正确”:保证功能与结构的良好可维护性即可。

+

那么 CSS 的性能还能通过什么方式提升呢?这就是下面的内容。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/page/11/index.html b/page/11/index.html new file mode 100644 index 000000000..ee867021f --- /dev/null +++ b/page/11/index.html @@ -0,0 +1,1203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

什么是 BFC

BFC(Block formatting context)是 CSS 中的一个概念,先来看一下定义 (By MDN):

+
+

A block formatting context is a part of a visual CSS rendering of a Web page. It is the region in which the layout of block boxes occurs and in which floats interact with each other.

+
+

大意就是,BFC 是 Web 页面通过 CSS 渲染的一个块级(Block-level)区域,具有独立性。

+

BFC 对浮动元素的定位与清除都很重要:

+
    +
  • 浮动元素的定位与清除规则只适用于同一 BFC 中的元素
  • +
  • 不同 BFC 中的浮动元素不会相互影响
  • +
  • 浮动元素的清除只适用于同一 BFC 中的元素
  • +
+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

“渐进增强”与“优雅降级”是 Web 页面两种不同的开发理念,为了简单起见,先给出定义(By W3C):

+
+

Graceful degradation Providing an alternative version of your functionality or making the user aware of shortcomings of a product as a safety measure to ensure that the product is usable. Progressive enhancement Starting with a baseline of usable functionality, then increasing the richness of the user experience step by step by testing for support for enhancements before applying them.

+
+

翻译:“优雅降级”的目的是为你的功能模块提供一种替代方案,或者让用户意识到某种产品(浏览器)的缺陷来保证你的产品的可用性。“渐进增强”是在一个最基本的可用功能之上,通过在拓展功能前检测(浏览器的)支持性逐步地提升用户体验。

+

这两种方案看起来好像没有什么太大区别,并且最终的结果貌似也是一样的。但是看完后面更多的解释和示例,就会更明白一些,其实这里面是真的有区别的。

+

一些博文将其简单地归结为如下内容:

+
.transition {   /*渐进增强写法*/
-webkit-transition: all .5s;
-moz-transition: all .5s;
-o-transition: all .5s;
transition: all .5s;
}
.transition { /*优雅降级写法*/
transition: all .5s;
-o-transition: all .5s;
-moz-transition: all .5s;
-webkit-transition: all .5s;
}
+ +

这个解释是完全错误的。实际上任何情况下我们都应该使用前者的 CSS 写法。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

这次更新主要是把工具做成了 WordPress 插件的形式,安装和使用起来都更符合 WordPress 的风格了,也不用再通过改代码去更改配置参数。之所以一次性把版本号提到了 0.1.0,是因为我觉得它虽然功能还不是非常完善,但是已经达到了“至少能用”的程度。

+

工具的主要功能目前为止并没有什么变化,至于这个过程中获得的少许 WordPress 插件开发经验下次再总结,好在没走多少弯路。

+

使用方式:Clone https://github.com/wxsms/baidu-submit-for-wordpress 仓库并上传至主机的

+
/wp-content/plugins
+ +

目录,在 WordPress 插件控制面板中设置启用即可。准入密钥以及域名的配置页面可以在“设置”中找到,其中也包含了手动推送的页面。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

花了两个晚上读了最近挺火的一本书,名曰《解忧杂货店》,同时也是我看过的第一本日本小说。看完以后只有一个感觉:这大概是过期的鸡汤吧。一点味道都没有。与此同时,总觉得有些什么地方不对。现在认真想了想,果然是奇葩。由于不清楚日本文化,也不知道该说是日本人奇葩,还是说仅仅是故事或者作者奇葩。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

作为目前的国内搜索主流,百度的收录规则与国外搜索引擎如谷歌、必应等不太一样,虽然它也有提供普通的Sitemap模式,但是据它自己所言通过这种方式收录效率是最低的。另外还有一种是自动推送,即在网站所有页面都加入一个JS脚本,有人访问时就会自动向百度推送该链接,但实测经常会被浏览器的AD Block插件阻拦。因此还剩下效率最高的一种方式:主动推送。我试过了一些现成的插件,好像都不太好用。因为是一个简单的功能,所以就自己写了一个小工具来实现。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

有剧透。

+

2016年看的第一场电影,昨天,三打白骨精。有点晚了。春节档唯一感兴趣的就是它。

+

美人鱼看了预告片和简介,结合各种评分短评影评,我觉得它远远没有达到期望。个人认为评分7分上下,对于其它电影或许是“值得一看”,对于周星驰的电影来说,只能相当于“不是垃圾”。这是在砸招牌。更不用说很多人的高分只是给的这块招牌。

+

说回到三打白骨精(以下简称三打)。虽然它评分不高,虽然我看过的国产电影不多,虽然它题材滥上加滥,但我还是要说,这是我看过的最好的最值的最不坑的国产爆米花电影,比去年的口碑高峰寻龙诀还要好上不少。国产电影能有这么大的进步,作为一个普通电影爱好者我是觉得很高兴。

+

为什么说三打要比寻龙诀好呢。其实它的特效没有比寻龙诀高,尤其是3D这一方面,但是,三打把电影的使命捡了回来,就是讲故事。寻龙诀根本就没有在讲故事,看的过程中就感觉各种特效乱飞,火花四溅,然后就结束了。然而三打不一样,它做到了特效为故事服务。虽然一路走过来依然很酷炫,但是作为观众我能感受到重要的角色都有它背后的故事,以及正在发生的故事。能感受到角色的立体度。实实在在的角色,而不是只活在大银幕这个平面之上。

+

三打对原故事进行了不少的改编,以往的国产电影很多改编都是坑爹,但是我认为这些改编却偏偏很多都是恰到好处的。为什么呢。因为改编后的电影可以让观众更加关注于主要的故事其本身,另外节省说故事的时间。就比如说,我们都知道师傅是如何收的二师兄以及沙师弟,但是电影就将其极简化了,他俩简单粗暴地一起搭上了大师兄的顺风车。这么做虽然当时看的时候觉得有点怪,但是事后想想是非常妙的。观众不需要导演去告诉他们师傅在白骨精之前是怎么走过来的,90%的观众都知道这背后到底是怎么回事,观众看的电影叫三打白骨精,直入主题。这样的改编在电影中还有不少,我认为都是为了简化故事结构突出主线而生。

+

但是,有几处改编,却又是在“三打白骨精”这个原著故事上做出了扩展。这也是很有意思的一点。电影把无关紧要的剧情都尽量简要交代,然后竭尽所能地拓展主线。原著故事没有吃人血的国君,没有白骨精的前世今生,也没有佛祖亲自收它,白骨精之于大师兄更是蝼蚁之于巨象。但是,电影偏偏在这么一个简单的故事上脱离了纯爆米花的低级趣味:要探讨人性,要探讨佛性,要挖掘黑暗面。其实我觉得如果要更有意思一点的话,其它可以有,白骨精还是不要那么强的好,就保持原著的水平,千年修行,最后被大师兄一棍子打死,然后师父再舍生取义,再打死师傅,更探讨,更黑暗。不过这么搞特效就没法做了。

+

此外,看了那么多的西游电影电视剧,貌似也只有三打在真正地学习老版西游记的精华。不是说它的“二师兄,师傅被妖怪抓走了”之类的吐槽以及片尾曲,而是说只有这只猴子以及老版西游记的猴子是在演猴子。看得出郭天王努力地在向六小龄童大师学习,各种动作都是以猴为基准,而不是人,虽然水平是差了一个筋斗云,但是最起码有认真地去学。要是不说他是郭富城我估计真没多少人能猜得出来,说得夸张些,他的影子里只有猴。如果说老版西游记的猴子是精华,那么师傅就是糟粕。三打不但吸取了精华,还扔掉了糟粕。这里面的师傅,虽然在大圣和妖怪面前看起来依然是手无缚鸡之力,但是,重要的一点,这是一个有主见,有信仰,有觉悟的师傅,是不辱其名的圣僧(吐槽一下电影的圣僧之翻译:Holy monk,上帝的和尚)。多说无益,看过便知。

+

要说缺陷的话,自然还是不少,不然不会只有5+的评分。二师兄和沙师弟是打了整场的酱油,就俩高级步兵,除了会吐槽以外屁用没有。认真想想的话其实有他俩没他俩剧情根本一模一样,即使最后大师兄回家了也不是二师兄给讨回来的。电影的审美过于西化了,比如小白龙的形象,比如白骨精的形象。但是,瑕不掩瑜,还是要说,这是我看过的最好的最值的最不坑的国产爆米花电影。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

安装在阿里云虚拟主机环境下的Wordpress死活都发不出邮件,用户注册的邮件发不出,评论总结也发不出,等等等等,尝试了各种方法都以失败告终。今天用更改代码+SMTP插件终于试成功了,以下是解决方案。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

本文是本站的建站历程记录。每个人都可以使用极少的代价(甚至免费)拥有一个域名独立且完全自主的个人网站或博客,在于怎么选择而已。此类网站的搭建很多情况下并不要求其操作者是一个程序狗,所以个人感觉可玩性还是挺强的。整个过程一共需要准备三种事物:域名托管程序(特殊情况,如果选择国内主机则需要准备第四种,即备案)。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

用 IDEA 撸代码的时候有一个非常恶心的问题,它的滚动条经常会无缘无故地跳动,最常见的就是拖动滚动条之后它会马上跳回到原本的位置,纵向和横向都有此问题,因此基本上每次都至少要拖两次滚动条才能成功,烦不胜烦。升级版本等等都没有用。今天终于找到了真正的解决方法,就是关闭屏幕取词软件或禁用软件的取词功能(比如有道)。完全、彻底地解决此问题。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

大概一年前在看一本介绍JavaScript与jQuery的书籍之时看到了这么一个有趣的章节,当时印象挺深刻的。现在突然回想起来了这回事,于是就重新翻出来做了个笔记。作者将这些材料归结为两类:神奇的知识点以及WTF。这里去除了与浏览器有关的部分,因为那些和JavaScript本身并没有关联。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/page/12/index.html b/page/12/index.html new file mode 100644 index 000000000..c2ac3f4e7 --- /dev/null +++ b/page/12/index.html @@ -0,0 +1,1188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

关于如何使用CSS中的border属性绘制各式各样的三角形。下面有一个国外友人制作的动画,对其原理进行了直观的阐释,我简单地做了点翻译。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

年廿七回家,到今天是第七天。这么快就已经过去了整整一周,马上又要回珠海上班了。

+

回家这么多天来,今天是第一次在家吃晚饭。一直都在亲戚朋友家过节,自己家冷冷清清的时间比较多。因为自己家里没有老人,只有我和爸妈一家三口,所以大概只能往外跑吧。我们很少在广东过年,只是今年可能是因为我的身体还不太好,妈妈也比较累,所以就不想回江西了。其实过节在哪里都无所谓啦,大家在一起开心就好。倒是不能去看看年事已高的奶奶觉得很忧伤。妈妈看起来又老了一些,是照顾我的那段时间太劳累了。

+

今年印象比较深的是,大家都喜欢在茶余饭后玩红包了。尤其是除夕晚上的时候,开着电视,但其实没多少时间去看,大家都忙着摇摇摇咻咻咻,完事以后继续关注下一轮的时刻,至于春晚什么的,谁管呢。当然老人还是在看。腾讯老大给的一块几毛就图个乐(一块几毛是说微信,至于QQ真是太小气了),但这里要吐槽一下支付宝,我一直以为它要么会大量放出稀有卡,要么会给集齐四张卡的同学一些安慰奖,结果也是呵呵,于是我毫不犹豫地就把除了家人以外的加起来的好友都删了。这游戏在春晚打了那么硬的广告,结果让全国99%的玩家都吃了个闭门羹,这么有种也是没谁了。老人一直在问为什么会有奇怪的声音,他们在年夜饭的过程中反而不太受到关注。

+

老人们有时候会问什么时候结婚的事,我都是回答说还早。两个人在一起的压力有时候真要比一个人要大得多,毕竟一个人生活不用考虑什么时候能买房,反正都是自己住。珠海的房价一天比一天高,然而刚工作半年的我也只能看着它高。

+

和小伙伴们谈起工作的时候,发现自己果然是最闲的。突然感觉没有赚加班费的机会也是一件挺忧伤的事。手术的伤依然是还没有好,总是觉得有这个问题在生活中处处都受到了限制。过两天又要回到那个以断网为常态并且每晚跳三四次闸的地方去住,再次回家又不知道是什么时候。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

使用虚拟主机的时候经常会想到一个问题,就是改了代码以后还要手动上传到服务器上,非常麻烦,且不利于保持本地开发代码与服务器上运行代码之间的同步,容易出错。今天突然想着能不能用IDE来完成类似自动同步的事情,如果可以的话开发效率自然是大幅度提高。拜强大到没朋友的IDEA所赐,结果非常可观。

+

首先确保安装好IDEA,测试用IDEA版本为15.0.1,然后我们从FTP服务器上copy一份代码到本地,并创建好存放目录。此时代码应该是完全同步的。以上为准备工作。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

归档页就是一个包含站点所有已发布文章的列表页面,通常默认会根据发布时间来进行排序,然后可能会有一些分页排序页内搜索等功能。实现这个功能可以用Wordpress插件,当然也可以自己写代码,我一开始就是用了一款插件,觉得实现了功能还不错就没管它。后来想要做一些自定义的修改,比如插件是按月份分组然而我想改成年份,就稍微看了看它的代码。一看不得了,莫名地有一种总算见识到了什么叫又烂又臭的代码的感觉涌上心头,做了这么多年伸手党总算是被恶心到了,简直不能忍,于是琢磨着自己写一个简单的模板页,不用它了。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

使用Angular Router可以很方便地构建SPA应用,同时它支持深度链接,支持各种浏览器操作(前进、后退、收藏等),非常有趣。使用过类似模块就会觉得它要比传统的路由方式,比如服务端的Forward,Redirect以及一般的JavaScript Redirect等,好用得多。特别是用户体验这一块,上升了很大的档次。

+

就在不久前我还开发了一个使用iframe与jQuery的SPA项目,当时由于是老板提供的所有前端页面所以也没多想。现在学过了Angular Router真是有些不堪回首的感觉。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

默认的Bootstrap文件上传框在Chrome/Firefox/IE上的表现都不一样,如下所示。 代码:

+
<input class="form-control" type="file">
+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

2015对我来说是有喜有悲的一年。我还清楚地记得十一个月前刚过完年的时候,在上班的第一天迟到了,然后收到同事哥哥姐姐们的一桌子红包的情景,然而不知不觉就已经过去这么久了。最近生活和工作上都遇到了一点瓶颈,中午无聊的时候翻看了一下这一年下来的邮件,于是就想写点东西。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

昨天发现了一个奇怪的问题,一个Web Application Update Entity的功能,在Chrome/Firefox上测试都正常运行,到了IE 11上就不行了,主要表现就是Update成功以后再次读取记录会读取出Update之前的值。功能逻辑就是一些简单的通过RESTful API来执行CRUD操作的Ajax调用。在IE上用控制台仔细调试一番后,发现在打开控制台的时候居然能表现正常,而关掉以后就立刻不行,这明显就是IE爸爸不走寻常路,把API也Cache下来了。于是就有了以下的解决方案。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

MEAN.js解决方案只提供了1级/2级菜单栏的service支持,最近项目中需要用到第3级菜单,所以需要进行一个小的功能扩展。一开始我以为可以很容易地做到无限级,真正做起来以后发现并没有那么简单,所以目前通过这个办法只能达到第3级。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

2048

+

Game: http://wxsms.github.io/jquery-2048/

+

Code:https://github.com/wxsms/jquery-2048

+

几年前还在学校的时候刚学 JS/jQuery,为了找点事情练练手寻思着做点什么,当时又特别沉迷于一个叫 2048 的小游戏,于是就有了这么个东西。刚做出来的时候开心了好一阵子,现在回头看代码觉得简直惨不忍睹,根本不像是一个学过算法的人写出来的,字里行间充斥的都是简单与暴力。那时候主要是为了学一门新语言就没有在意这些东西。以后有时间再来优化一下。 在开始的时候是有记分,重启,排行榜一票功能的,现在为了纯粹一点就把垃圾都去掉了。代码过于恶臭就不说了。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/page/13/index.html b/page/13/index.html new file mode 100644 index 000000000..915c716d9 --- /dev/null +++ b/page/13/index.html @@ -0,0 +1,1228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

+

之前一直以为 MEAN 只是一个概念上的东西,表示以 Mongodb Express AngularJs NodeJs 为基础的全栈应用开发模式。这几天在公司接手相应项目的时候发现已经有人做出来并且维护着一些这样的 App 结构体,用过以后觉得还不错。MEANJS 是一个开源的 JavaScript 全栈应用解决方案,主要用到的技术自然就是以上提到的那些。使用成熟的解决方案可以使自己的项目更加易于开发以及维护,等等好处就不再赘述。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

使用ssh key配置git可以省去每次操作时输入ID/Password的麻烦,操作一旦频繁起来还是很有必要的。实际操作需要添加一些环境变量,或者到git/bin目录下执行。 + +

+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

学生渐渐开学,才意识到毕业以来已经过了一个暑假的时间。公司为期三个月的培训终于快结束了,我也终于有空回家休息一段时间。培训结束后,感觉自己的变化除了学到的知识以外,就是多了一些自信,对很多东西的理解不再是处于未知或一知半解的状态。学习使人进步。

+

挺久没有回过家,上一次应该是在五一的时候,所以比较想念家人。不知道家里现在是怎样的了,应该没有什么变化。前段时间出租屋的椅子坏了,往后靠就会摔倒,想买一把才发现椅子挺贵的,房东不给换,郁闷的时候想到在家里从来没有操心过类似的问题,只要跟爸爸或者妈妈说一句就会有替代品,虽然可能不合己意但却不需要付出任何代价。这些事情可能只有在独立生活后才能发现,饭要自己做,碗要自己洗,衣要自己晾,门要自己锁,下班回来累了一躺就是到半夜,醒来发现灯还亮着,门禁卡还戴着,一看手机早上四点多,这时候就能体会到一些孤独。体会到在家是多么的幸福。感谢爸妈给我的回忆里充满的都是快乐。

+

马上过完今年的生日,我也要24岁了,人生走到了一个过渡期,从学生到打工者,从学校到到职场,时间过得这么快,觉得有一些不适。生活还没有转变过来,以后的路还那么长,对未知的未来充满了恐惧。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

公司要求会用Ext Js,没办法必须学,下面总结了一些学习与使用过程中的经验。 + +

+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

原来的博客太简陋了(虽然现在依然很简陋),一直想改都没有时间,最近在公司培训了三个月的Java也发现自己差不多忘了怎么写C#代码,曾经觉得很顺手的IDE用起来也不习惯了,反正就是改不下去了。鉴于工作以后空闲时间变得捉襟见肘,最终还是放弃了自己动手的想法,直接用了模板博客。虽然没有了一切如己所愿的快感,但毕竟是开源软件,想怎么玩都可以,感觉还是不错的选择。

+

7/25/2019 注:当时是迁移到了 WordPress

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

在家上中学的时候并不知道每天可以有家可归是一种怎样的幸福,直到后来再也没有这样的机会。如今人生已走过三分之一,想到以后都不会有机会在自己熟悉的地方长住,心里很不是滋味。有时候会想,如果目前还是自己一个人在生活的话,我就可以回到家这边来找一份不痛不痒的工作,先做个一两年。然而,看到以往的玩伴也渐渐走上正轨,供车供房,同学纷纷开始工作,读研留学,自己也会有些迷惘。尤其羡慕留学的朋友,从此以往告别这片神奇的土地。后悔大学没有好好学习,不然可以争取一些保研的机会,就不用这么早和自己的学生生涯说再(可以再打两年dota)。

+

三分之一已经过去了,学生时代的一些人和一些事也都应该告一段落了,该奋斗的奋斗,该拼爹的拼爹,都要上路了,也没什么闲暇来和老同学扯淡。一些当年觉得很好的朋友,如今看来也不过如此,以后估计也难再有交集。虽然不知道自己以后还能走多远,但是想到人生过了这么长,没有留下多少美好的记忆,也没有交到很多很好的朋友,满房间的物件,却并没有承载很多过去,觉得自己虚度了很多光阴,却也无法弥补,就总是会觉得很伤感。小学时候的课本笔记等早已不知所踪,只留下一两张泛黄的照片,初中的记忆本该满满却没有珍惜,高中不谈,大学更差,就是dota的一千个日与夜。我以后再也不想通宵玩游戏了,每次看到天亮都十分不安。如果我以后有了小孩,一定会帮她(他)把成长过程中的物件都收拾整理好,待到长大,将会是珍贵的回忆。

+

说到小孩,如今父母也会开始谈及小孩了,真是措手不及,我不还是个孩子吗,怎么就说到我的孩子了。你们一定是在逗我。

+

感谢毕业照那天来看我的同学朋友们,当日一别,更不知道何时再见。

+

大四最后一个学期都是在外居住,并不知道学校里的冷暖。周末回校,也只是洗洗衣服,打打游戏,完全感受不到自己还是一个在校学生。有一次在工作日请假回了学校,中午过了饭点仍自信下楼,才猛然想起原来大家还是要上课的,这会刚下课呢。这几年来我也算是有惊无险的体验了,挂了不少课,还好重修能过,马哲顺利,不然极有可能自信心受到打击从而陷入无尽轮回(需要感谢一下窦庆萍老师)。在知道自己大四上没有挂科,毋须延迟毕业后,心里面真的是很轻松,那么我也算是走过来了。

+

大学的同学里,我并没有与很多人熟识,也没有交到许多朋友,有几位可能甚至四年来都没有说过一句话,不过也不能完全怪我,我们的专业选得好,完全不用与人交流。谢师宴过后,很多人我仍然是只知道名字,其它一无所知,可能再也不会有机会相见了,然而我并不在乎,因为本来就跟不认识一样。对事不对人,自己的大学生活失败,与同学无关。

+

工作的地方在唐家湾的软件园,近期也可能会一直住在这一片,在珠海的同学朋友没事可以来找我玩,有活动也可以带上我,有麻烦如果能帮上忙也请找我。从学校到工作地点的一路上都是海岸,大概有二十多公里,每次经过都觉得很舒服,然而不知道台风会不会封路。

+

有一个正经的女朋友会给自己带来压力与动力,同时也会非常大程度地限制自己的自由,不能想去哪里就去哪里,不能想做什么就做什么,这点让我非常不自在。也许现在到了一个我需要照顾别人的时候,但是我还没有完全准备好。并没有想到大学最后一年能找到女朋友,我也没有准备好。要出外工作,住两三年出租屋,同样没有准备好。 我本来只是一个呆在宿舍每天打dota的大学生,现在生活需要做出这么大的转变,有点不知所措。独生儿习惯了被照顾,现在要开始学会自己打理一切,总有些转不过来。说到底,我就是想呆在家里睡觉。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

之前写过一篇很长的关于异形的东西,不知道为什么发不出来。可能有关键字被和谐了,但我又找不到,后来草稿不知道为什么也没有了,明明保存了的。没有了我也不想再重新写一遍了。 说一下最近看过的一些有点意思的电影。

+

先说印象比较深的,Atonement(中文名《赎罪》),其实这电影发展过程一般吧,主要的亮点是在结局,当然我也不能说太多,否则剧透了就很没意思了。当时我是很惊讶的,一部电影成功真的很靠原著小说以及剧本。这是一个悲剧,西方,二战时期,题材为爱情。另外一个亮点在于打字机的使用,作为配音是很独特的存在,当时也刚好ios有一个打字机应用(好像是靠汤姆汉克斯宣传的),所以印象比较深刻。

+

然后是李安的《色戒》,这部电影出了那么久一直没有看, 可能主要是被大多数人对它的关注点误导了,觉得看这种电影还不如去看爱情动作片。后来发现其实色戒是一部非常优秀的电影吧,只能说非常优秀。也是悲剧,中国,抗战时期,题材为爱情,谍战。其实我推荐家里人看这部电影的,比如爸妈,因为好像大家都比较喜欢看谍战剧,然而关于谍战的电影很少,也少有优秀的作品。色戒的背景内容庞大,但整部电影的视角一直限制得很小,剧情紧凑,毫不拖沓,主题突出,以小窥大,得益于李安深厚的功力。

+

后来有一段时间比较无聊,看完了《异形》系列。由于刚才说的原因,我都没兴趣多写了。反正我感触还是很深的,但电影其实拍的一般般,可能是年代的原因现在看来没什么触动。它的题材很好,期待普罗米修斯的续集。

+

说到科幻片,最近也看过一些科幻片,主要是因为之前看了阿汤哥的《明日边缘》,对科幻片的热情又高涨了。明日边缘真的是一部非常,非常,非常优秀的科幻片,我甚至觉得是我看过的最优秀的科幻片,可能有些夸张了,但就像当年看完盗梦空间以后难以抑制自己激动的心情,一定要给这部电影打满分的感觉,明日边缘也是这样的一部电影。相比于其它的外星人入侵地球的电影,明日边缘对于外星人的设想非常有意思,以至于电影也陷入了一种很特别的节奏。如果仔细想想的话,它的看似狗血的结局也是非常值得推敲的。这部电影的题材是科幻,外星人,四维空间,拯救地球。美国,未来。

+

然后还看了邓肯琼斯导演的《月球》,这部电影相比明日边缘就比较文艺了,但同样有比较特别的剧情,看似科幻片,我觉得其实是一个关于人类道德的电影。另外比较特别的一点是,这是一部独角戏(参考《我是传奇》),一般来说这种电影逼格都比较高,这部电影也是如此啦。然后它除了剧情设定以外并没有其它的比较大的亮点,所以总体感觉一般吧。美国,未来,题材为科幻,克隆。

+

另外看了《机器纪元》 以后,觉得最近的科幻片都比较探讨人性啊,都不是在往科幻片应有的路子在走。这部片子说的东西还是挺有意思的,不过剧本感觉缺少冲击力,就是没什么能让人感到眼前一亮的东西,比较平淡的电影。之前上人工智能课的时候听周密说,“人类需要完成的最后一个发明就是智能机器人”,这部电影说的就是这个啦!为了让智能机器人不成为人类的最后一个发明,人类为其设定了两个原则,一是机器人不能伤害任何生物,二是机器人不能维修自己。但是智能机器人明明比人类要智能得多,又有什么技术制定的原则能限制得了智能机器人呢。美国,未来,题材为科幻,机器人。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

很久没有回过家,也没有关心过家里的情况了。今天和妈妈说了几句话,得知最近有发烧生病,虽然说已经好了,但还是觉得不知道怎么的。五十多岁了,一个人在广州上班,没亲没戚的,生了病也没哪个照顾,也不跟我说。婆婆去世以后我就应该要照顾我妈了,去年在赣州大姨也跟我说过,可惜还没有毕业,分身乏术。唉,真是忧伤。真希望你退休了吧,别干了。

+

天气变得很热很闷,情绪也变得特别容易坏,很可能因为一些琐碎事情发脾气,像今天早上,本来不应该发生这样的事的,我应该更关心你一些,而不是独自生闷气。现在想来确实后悔,不过微信是真的没有必要了。

+

这学期过的很快,感觉是大学这么久以来最快的了吧,已经快十四周了,又要结束了。过得快的原因大概有几方面,一直很忙,基本没停过,到现在也是很多事情在做,都接近尾声但又没有结束,所以有时候会觉得很多事情要做但又不知道要做什么。遗憾的是还没有找到实习,暑假仍没有着落。这学期和web有关的东西做的比较多,不过我不太希望这是最终的方向。

+

找了一个女朋友,很喜欢,希望可以有多远走多远。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

很快就过年了。

+

回了一次赣州,小姑的生意越做越大了,店面越来越多,小时候第一次去广州好像还是在一个住房里做事,应该十多年了吧,跟着老妈参加了他们的年会,很多比我小的男孩都参加工作了,家里的两个弟弟也都在工作,唯独我还在读书,有种说不出的感觉。郭慧姐应该快生小孩了吧,罗云哥嫂子的小孩也快了,不知道会叫什么名字呢。奶奶身体好像还不错,挺好的。

+

在大姨家住了几天,大姨做的菜还是很好吃,比婆婆做的都要好吃,但是有些东西还是婆婆做的更熟悉。谢金宏读五年级了,看起来还是小时候那个样子,不过这次没有看到他哭,大概也算长大了一点。后来感冒了,很不舒服。去水东走了一次,姑奶家养了很多狗,有小狗也有老狗,婆婆家的房子还有人住,不过是租给的别人,记忆已模糊得不可辨识。

+

前天回家,昨天宅掉,今天出去走了一圈,没什么变化。最近发现自己变得犹豫了很多,不喜欢这样,一直觉得自己做起事来都是干净利落的,但这次不知道为什么。真的很抱歉,让你看到一个这么寡断的我。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

首先感谢郭凯瑞同学百忙中抽出时间接受访问,那么事不宜迟,就开始吧。

+

Q: 今年最开心的事?

+

A: 没挂科,有奖励学分。

+

Q: 这么自信能过马哲?

+

A: 那是明年的事好吧。

+

Q: 还有什么值得开心的事吗?

+

A: 身体健康。 家人朋友都健康。

+

Q: 那么最伤心的事?

+

A: 婆婆去世了。

+

Q: 有多伤心?

+

A: 很伤心。

+

Q: 还有其它不开心的事吗?

+

A: 妈妈变老了,身体毛病多,不过今年做手术治好了肠胃。

+

Q: 现在在做什么呢?

+

A: 回答问题。

+

Q: 好吧,在这之前呢?

+

A: 准备音乐鉴赏考试。

+

Q: 会难吗?

+

A: 小菜一碟,相比马哲。

+

Q: 今年的大学生涯,过得如何?

+

A: 无惊无险。

+

Q: 修了些什么课程?

+

A: 多数是专业基础课,少量专业选修和公选课。

+

Q: 感觉掌握了相应知识吗?

+

A: 我只能说没挂科。

+

Q: 有认识新的朋友吗?

+

A: 没有。

+

Q: 为什么?

+

A: 太宅了,没办法。

+

Q: 不改变一下吗?

+

A: 付诸行动总是很难的,明年再来问这个问题吧。

+

Q: 有没有坚持运动?

+

A: 坚持了一段时间,主要是慢跑。说到运动,我想起一件事。

+

Q: 什么事?

+

A: 那个体能测试,引体向上我一个也做不了,然后我让计分的同学给我记了八个。

+

Q: 真的一个也做不了吗?

+

A: 真的一个也做不了。

+

Q: 这真是羞耻啊,这么差劲,怎么找女朋友。

+

A: 我也是这么觉得的。

+

Q: 你还说谎了。

+

A: 要是早知道重在参与,这就不会发生。

+

Q: 回到朋友这个话题上吧,你希望认识多一些朋友吗?

+

A: 自然希望。

+

Q: 希望认识什么样的朋友呢?

+

A: 异性朋友。。。

+

Q: 你没有异性朋友吗?

+

A: 有,太少了。

+

Q: 多少?

+

A: 我拒绝回答这个问题。

+

Q: 好吧,那么,再回到学业上,觉得自己的专业知识水平达到了什么地步呢?

+

A: 中等略偏上吧。

+

Q: 很快就大四了,能找到工作吗?

+

A: 现在肯定是不够的,明年要加把劲,继续努力。

+

Q: 找不到工作怎么办?

+

A: 找原因吧,肯定是自己还不够好。

+

Q: 会有紧张感吗?

+

A: 有一些。

+

Q: 对今年总结一下?

+

A: 肯定是悲伤的一年。

+

Q: 对明年有什么期待?

+

A: 身体健康,六级能过,不要挂科,学习多一些专业知识,认识多一些朋友。

+

Q: 好的,时间差不多了,这次的总结就到这里吧。

+

A: 谢谢。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/page/14/index.html b/page/14/index.html new file mode 100644 index 000000000..845878cbf --- /dev/null +++ b/page/14/index.html @@ -0,0 +1,818 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+
    +
  • 很独特的设定。地球很美丽,宇宙很黑暗。
  • +
  • 镜头经常走得很慢,处于宇宙中的各种物体也看似很慢,其实不然,经常在对比建立起来一瞬间就能感觉到可怕的速度。这样的镜头处理也让我觉得很独特。 而且对主角有很多又长又慢的镜头特写,然而影片的节奏并不慢。
  • +
  • 片头直入主题,片尾紧凑收官,90分钟全无拖沓,难能可贵。
  • +
  • 非常酷炫的3D效果,感觉又是一次突破,太空碎片往荧幕外飞的时候老夫的面部神经抽搐了很多下,从未有过的体验。感觉imax的地心引力会非常精彩!
  • +
  • 女主遇到的连续挫折感觉已经超出了人类所能接受的极限,即使在最后一刻,依然存在挑战。
  • +
  • 太空垃圾真的不会形成一个地球专属的小行星带吗?
  • +
+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

玩了那么多年,经历了大大小小的版本更替,如今已脱胎换骨,与当年小小的一张地图相比已经面目全非,不得不说这也是一种软件的生命周期,或者开发模式。陪伴了老夫孤独寂寞的高中时光,消磨了大量宝贵的大学时间,亦由此对它产生了深厚的感情。如今大家各奔东西,在一起的时间越来越少,于是dota也渐渐变得没什么意思,偶尔上线也只是习惯所趋。

+

我还记得我玩的第一个英雄是胖子,那么第一把自然是坑队友了,然后下一把玩了个传说哥,大家都懂的。不过好在老夫war3功底雄厚,渐渐也有了起色,开始没打算继续下去的,是因为身边的朋友在老夫带领下居然也开始喜欢dota,于是大感欣慰,遂征战至今,主要也是因为当年3C实在前路渺茫。朋友不多不少,刚好足够开一间黑店,可惜连跪几乎已是命中注定的剧本,于是我又要批评一下达Q了,你TM能不能不要裸秘法。徐尘是老夫最喜欢的选手,低调不失华丽,实力与智商兼顾,还会拍马屁。至于贝伦同学尽心尽力辅助了这么多年,只能说辛苦了。椰子同学自从退伍归来后实力大减,简直成为团队毒瘤。。好吧开玩笑的。

+

从最开始的QQ平台开始,也不知道到底耍了多少把了,印象深刻的也没有多少,只能说记性不好。无数个白昼与通宵,就在这上面一点一点的消逝去了,这里却留下不少回忆,比如小林被他不知道什么亲属拽回家去的那晚,以为是个抢劫的,老夫差点就拍案而起。还有一次和徐尘通宵,第二天一早老夫回学校睡觉,下午睡醒吃饭回到网吧看到他居然还坐在那里继续操作,那个哭笑不得。记得那时候的水饺,炒饭,泡面,汽水,和各式各样的FirstBlood。

+

如今大势已去,dota虽然还在发展,却已不适合你我,只能当做茶余饭后之娱乐了。不过也好,人总要成长。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

时间过得真快,转眼就大学三年级了,两年前作为新生的各种场景依然历历在目,像是昨天一样,当年的小软工如今已几乎是大师兄,不得不时时拷问自己两年来到底学到了什么,学到了多少,有什么资格。去年还没有什么感觉,如今比较强烈了。而且也开始想两年后我会在哪里。实在是前路茫茫啊。是工作呢,还是要去读研比较好呢。我个人还是倾向继续读书。唉,不知不觉就大龄青年了。真是岁月催。

+

大学读下来,从前很多选择也慢慢觉得如果再理性一点的话,或许会有变化。不过人还是脚踏实地的好,往事已去不再追。今年婆婆去世了,即使到现在还是很难接受的事实,不过我也知道时间带走一切,不知哪刻自己也将被带走,能做的只有珍惜。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

本来也没有抱很大希望,所以算是乐在其中。最让我开心的是看到了熟悉的人和事物,戒指坠地的声音依旧震慑人心。甘道夫虽然说年轻了六十岁但是看起来更老了,另外就是大招的冷却时间明显缩短了。然后剩下的内容,基本可以用“吃饭睡觉打兽人”概括, 而且可以看出六十年前的兽人智商还不太发达。有点像成龙大哥的风格,相比艰辛,更多的还是幽默。

+

这一次只能说中规中矩,如果想要惊世骇俗吃老本肯定是不行的了,我设想的话,既然都不搞原著了,那么第三部不如来个惊天大逆转,矮人勇者斗巨龙团灭,甘道夫和比尔博灰头土脸踏上归乡之路,这叫道高一尺,魔高一丈!

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

前两天觉得实在无聊,花了50人民币买了个仙5KEY,并且挂一晚上同时下载了5和5前,本来打算都玩玩看的,现在有谁想要玩的吗我给你KEY,不过只有一个哦,而且是5不是最新的5前。不准备购买5前KEY了。

+

老实说我也还没有耍通关,大概是走了一半多一点的剧情,不过确实没什么耍下去的愿望了,于是就广泛观察了一下广大玩家的意见,基本还是毁誉参半。支持者的观点也基本还是那几句,你不懂仙剑,你不懂感动,你不懂传承。反对的人,提出意见的人和建议改革的人一般都会被喷的很惨,尤其是那些用国外优秀游戏作品和仙剑做比较的,反观这个人群却能够更加理性的和广大支持者辩论。当然从四代开始正版的销量也成为了广大玩家所认可的仙剑成功的标志之一,在我个人看来这是一个很可怕的现象,如果仙剑系列的开发者也认为这是他们作品的一个成功标志的话那么这个游戏就彻底完蛋了。中国人开始买正版仙剑和上海软星的解散有直接关联,仔细想想是能够知道为什么的,这是一种民族情怀,不是因为它成功,而是希望它成功,希望终有一天我们的游戏文化也可以走出国门,而不是占山为王固步自封一万年。仙剑是国产游戏业的第一品牌,谁都希望它能够越做越优秀,这点还是统一的。

+

那么我也来说一说对5代的感想。

+

首先仍然是很传统的人设和故事,大大咧咧的男主角,误打误撞认识了一位知书达理的女主角,然后还有一个英俊帅气的男二号,和另外一位蛮横霸道的女主角,一共是四个人。然后就是混杂着各种纠缠不清的关系的剧情发展,到最后男一号自然是打败了为害人间的大魔头,但是却牺牲了其中一名美丽可爱的女主角,于是又引发了各种凄美的爱情故事。恩,至于5代后面的剧情我就是看攻略得来的了,暂时还没有亲身体会。这个主角阵容几乎从它祖宗开始就是这个模样,俊男美女闯六界,所以也没什么好奇怪的,这个剧情嘛也就这个样子,广大人民群众喜闻乐见。然后就是回合制战斗模式加强加强再加强版,仙剑虽然每一代都是回合制,但是又每一代都有新花样,这个新花样也会成为正式发布前的宣传重点之一。至于这一代的亮点,第一,我个人认为是李逍遥的回归,毕竟这一代是姚仙的孩子,给足了逍遥哥戏份,对于一代迷的我来说,很满意,第二,就是它的剧情配音,感情丰富,声调饱满,非常幽默,十分满意,逗笑了我很多次。5前对于角色数量方面似乎有很大创新,可能是基情与百合的发展使得游戏也不得不跟上时代的步伐。

+

然而这次我想说的重点不在这里。

+

一代的画面是仙剑系列永远的痛,于是后代仙剑人从来没有放弃过对画面的追求,从一代的数格子,到二代的线条2D,到三代的方块3D,再到4代5代的真3D。5代的画面在我看来已经非常成功了,各种光影,渲染,迷雾,反射,应有尽有,色彩鲜艳,场景宏大,角色的模型也是很有进步,丝毫没有愧对玩家的期待。问题就出在这里,在这如诗如画的梦幻般的游戏过程里,我完全感觉不到游戏制造者的诚意。

+

就提几点吧。都是些细节。

+

第一,仙剑奇侠传系列的主角们,从1995年至今,嘴巴从来就没有动过,但是他们却会说话。难道这也是特色传统之一吗?不要跟我说以前还没有这样的技术,李逍遥在1995年不用动嘴巴,到了2013年仍然是不用动嘴巴,这说明他天生就不用动嘴巴。腹语术。

+

第二,太空步无处不在,真的又好气又好笑,尤其是当角色上下楼梯的时候,已经不能用不自然来形容了,简直是灵异事件。当然,毫不客气的说,这也是传统之一。

+

第三,这点很重要,游戏角色永远只有屈指可数的动作,然而制作人又想要用这些动作来表达复杂多变的游戏情节,于是后面是怎样的一组情形就不必多言了。这个情况是从仙剑系列踏入3D,也就是第三代开始的,当时由于技术限制,我并没有太大的关注这个问题。可是到了今天游戏制作人仍然没有一丝一毫想要改进的意思,一方面想要让角色尽量生动,一方面又偷工减料不制作实时动作,让我感到非常可笑。一个人进门怎么表现呢?凭空消失呗。一个人给另外一个人一件事物怎么表现呢?手突然平举呗,事物还是腾空的呗。像这样的画面堂而皇之的出现在近距刻画中,在今天我觉得难以接受。

+

第四,历代都在期盼的角色实时换装系统千呼万唤不出来,再飘逸的服饰装备设计也失去了意义。不要说换了武器能体现,如果连这个都不能体现,我早喊QNMLGB了。

+

第五,角色进入居民屋可以翻箱倒柜,顺手牵羊,这个是真正的传统,我不知道姚仙在今天对于这个设计是怎么样的一个看法。

+

还有很多,不列举了,关于这些问题,只希望仙剑开发者有朝一日能发现并解决之,这将是对所有仙剑爱好者极大的鼓舞。这些就是细节,细节就是诚意。

+

至于我为什么半途就失去了将仙5通关的愿望,并不是因为游戏性,仙剑系列每一代的游戏性都半斤八两,只不过由于画面不断提高,所以才显得它的游戏性愈加飘渺,想要体验游戏性的话膝盖中箭才是最佳选择。我只是对这一代的主角全无好感而已。当然,对四代的主角也全无好感。这两代的人设简直就是同一个妈生的。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

开学两周以来身体真的不太好,感觉又和高三后期差不多,不过好在时间没有那么紧,每天可以抽一点两点时间出来锻炼,到现在也感觉有了那么一些好转。霍香正气丸吃了没有用,以后再也不买了。不敢去看医生,实在看不起,重点是看了也白看。

+

本来平常的话也没什么,坏在上学期挂了两科,明天就要补考去,可惜真的没有精力很认真的看书做题所以它要重修就重修吧,大四上多几节课我也不是很在乎。不过从今往后大概真的别挂科了。毕竟没有班长大人的魄力,我还是图样啊。现在饭堂卖的东西也比以前干净很多了,也有面条和粥,而且还很便宜,所以应该不会像以前那样困难,虽然饭吃了可能还是会有点问题。这两周以来嘴巴都感觉特别特别苦,会想喝汽水,会想吃雪糕,可是肚子又胀胀的,不太敢动那些。忌生冷烟酒辛辣油腻,知道的,奶不能喝,青菜少吃,什么什么的,都还记得,所以妈你不用担心我。我有分寸。你要多注意你自己。每次你跟我说晚上痛得睡不着都让我非常揪心。

+

晚上经常会小发烧,可能是炎症吧之前真没考虑到。今天好些,没有。所以最近都穿长袖示街。偶尔还是会感到冷和孤独,不过没有高中那么强烈。今天吃了一天宿舍菜,中间还被小吓一跳,那个新买的电磁炉水都没煲开就怒放两炮,然后随着一丝烧焦的味道就哑火了,还好后来又神奇复活以不至于没东西吃。吃完以后十分想念婆婆和大姨,我想吃炸茄子和葱条和腌菜艾米果…

+

希望可以好起来。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/page/2/index.html b/page/2/index.html new file mode 100644 index 000000000..d42266486 --- /dev/null +++ b/page/2/index.html @@ -0,0 +1,1217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

Php 个人速查笔记。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

博客迁移至 Hexo。主要原因是:

+
    +
  1. Vuepress 有部分 bug 难以忍受,而且 v1 仓库已经停止维护了;
  2. +
  3. Vuepress 的功能对于 blog 来说还是有些弱;
  4. +
  5. Vuepress v1 存在文章数量增加,首屏加载大小不断变多的问题;
  6. +
  7. Vuepress 没有 blog 主题,而我自己写的主题是基于 v1 的,且无法升上 v2 (因为:为了解决问题 3,v2 中 $posts 变量被移除了,而该主题的首页依赖这个变量做渲染);
  8. +
  9. …… (其它难以忍受的问题)
  10. +
+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+ + +
@media (prefers-color-scheme: dark) {
html {
filter: invert(90%) hue-rotate(180deg);
}

img, video, svg, div[class*="language-"] {
filter: invert(110%) hue-rotate(180deg);
opacity: .8;
}
}
+ +

具体效果参考本站(打开系统级别的暗黑模式)。 解释:

+
    +
  1. invert 将所有色值反转,hue-rotate 将黑白以外的其它主色调再反转回来(防止页面主题色出现大的变化);
  2. +
  3. 网上的 invert 通常取值为 100%,但是这样反转得到的黑色往往太过黑,眼睛看起来有点累,因此我觉得 90% 是一个更合理的值;
  4. +
  5. 将图片、视频等其它不需要被反转的元素再反转回来,并加一个透明度,让其不那么刺眼;
  6. +
  7. 如果 html 反转 90%,则图片等元素需要反转 110%
  8. +
  9. div[class*="language-"] 对应的是本站 (VuePress) 上的代码块。
  10. +
+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

我的 golang 学习笔记。好几年前就说要学了,现在终于兑现。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

其实不需要装任何插件,IDE 自带的 Markdown 插件即可支持该操作:

+
    +
  1. 使用任意截图软件截图到剪贴板;
  2. +
  3. Ctrl + V 复制到编辑器中;
  4. +
  5. IDE 会自动生成图片文件 img.png(如果已存在,则会加自增后缀),以及相应的 Markdown 标签 ![img.png](img.png)
  6. +
+

但是,默认的插件不能配置保存路径(只能是 markdown 文件所在的路径),也不能配置命名规则,因此找了一个插件来增强这个功能。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

关于 React Hooks 与 Vue Composite API:

+ +

二者为了共同的目的,在接近的时间点,以非常相似但是又带有本质区别的方式,推出了各自对于未来前端代码结构发展的新思路。本文在对二者做一些简单介绍的同时,也会重点关注二者之间的统一与区别。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

在不想全局 vpn 的情况下,可以用 host 加速。

+

该方法主要利用 github.com/ineo6/hosts 的 hosts 文件,国内镜像 gitee.com/ineo6/hosts

+

方法一:手动

手动复制 hosts 的内容,并粘贴至对应操作系统的 hosts 文件内。

+

方法二:自动

    +
  1. 下载开源的 host 切换软件 SwitchHosts
  2. +
  3. 新建一条规则:
      +
    1. 方案名:随便
    2. +
    3. 类型:远程
    4. +
    5. URL 地址:https://gitee.com/ineo6/hosts/raw/master/hosts
    6. +
    7. 自动更新:随便,或 1 小时
    8. +
    +
  4. +
  5. 保存,保存后可以先手动刷新一次
  6. +
  7. 启用即可
  8. +
+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+
+

Assertions include boundaries, which indicate the beginnings and endings of lines and words, and other patterns indicating in some way that a match is possible (including look-ahead, look-behind, and conditional expressions).

+
+

断言是正则表达式组成的一部分,包含两种断言。本文记录了一些常用断言。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

微信小程序单元测试的可查资料少得可怜,由于微信官方开发的自动化测试驱动器 miniprogram-automator 不开源,唯一靠谱的地方只有这 一份简单的文档。然而实际使用下来发现文档介绍的方式有不少问题。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

今年疫情原因,本来不是很想回家过年的,想着工作累了,在珠海(中山)做几天废人也不错。但是现在回想起来,虽然家里比较小也比较无聊,逢年过节还是应该回家看看。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/page/3/index.html b/page/3/index.html new file mode 100644 index 000000000..db86179bb --- /dev/null +++ b/page/3/index.html @@ -0,0 +1,1250 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

有数据格式如下:

+
{
"id": "745",
"knownName": {
"en": "A. Michael Spence",
"se": "A. Michael Spence"
},
"familyName": {
// 结构同上,下同
// ..
},
"orgName": {
// orgName 当获奖者为组织时出现
// ..
},
"gender": "male",
"nobelPrizes": [
{
"awardYear": "2001",
// ...
"affiliations": [
{
"name": {
"en": "Stanford University",
// ...
},
"city": {
// ...
},
"country": {
// ...
},
// ...
}
]
}
]
}
+ +

想要实现:

+
    +
  1. 查找名为 CERNaffiliation 的所在国家
  2. +
  3. 查找获奖次数大于等于 5 次的 familyName
  4. +
  5. 查找 University of California 的不同所在位置总数
  6. +
  7. 查找至少一个诺贝尔奖授予组织而非个人的年份总数
  8. +
+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

上一篇博文 Integrate Renovate with GitLab 中介绍了为私有代码仓库与私有源提供依赖自动检测更新并发起 Merge Request 的方式。Renovate 可以自动通过 Release Notes 获取到版本之间的更新日志,并在 MR 中展示,这为执行合并的评审人提供了极大的便利。

+

接下来需要解决另一个问题:如何为分散在各处的私有依赖自动生成更新日志?

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

企业项目群中往往会有部分代码逻辑需要公用,将其抽离作为公共包发布到私有源的做法是比较优雅的解决方式。但是这么做的话后期需要面临一个问题:当一个公共依赖包的使用者数量逐渐庞大的时候,如何保证当此包发布新版本时,所有使用者都能尽可能快地得到更新?

+

传统的解决方案:

+
    +
  1. 手工对所有项目逐个升级。这种办法相当繁琐,且容易产生遗漏,当项目数量足够庞大的时候,发布一次将会是相当痛苦的体验;
  2. +
  3. 在依赖安装时指定版本为 latest。这种办法虽然能保证每次安装时都能得到最新版本,但是却有诸多弊端,如:
      +
    1. 无法保证依赖的安全性,有可能一次更新不慎造成大面积的瘫痪;
    2. +
    3. 对「依赖锁」不友好,如 yarn.lock 等。
    4. +
    +
  4. +
+

因此,如何使这个过程变得优雅,是一个亟待解决的问题。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

本文是一些 GitHub Actions 常用发布动作的总结。

+

强烈建议将所有 Publish actions 分开执行,不要集中到一个 Workflow 内。原因是如果其中一个动作因为某些原因失败了,GitHub 目前只能重启整个 Workflow,而如果 Workflow 内某个 Job 已经成功了,那么该 Job 下一次执行必然是失败(因为此类任务一般不能对同一个版本号执行两次,发布成功一次以后第二次尝试将会被拒绝发布),因此这一个提交的 Workflow 将永远不可能成功。

+

需要注意的是,以下所提到的 secrets.GITHUB_TOKEN 均是 GitHub Action 内置的 Access Token,无需自行创建。而其它 secrets 则需要在 项目主页 -> Settings -> Secrets 处创建。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

应该 JetBrains 家的所有 IDE 都有这个配置。习惯了用 Markdown 写博客的人每次都要手动点一下 SoftWrap 挺烦的。后来发现了一个配置可以帮我省去这一步:

+

打开设置,找到:Editor > General > Soft Wraps,将 Soft-wrap files 选项勾上即可。IDE 默认已经填上了 *.md; *.txt; *.rst; *.adoc,因此不需要再做别的事情。

+

image

+

这样一来,每次只要打开以上格式的文件,编辑器就会自动开启 SoftWrap,一劳永逸。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

这是一次关于本博客的 Debug 经历,过程非常曲折。关键词:Vue / SSR / 错配

+

不知道从哪篇博文开始,博客在直接从内页打开时,或者在内页刷新浏览器时,会报以下错误:

+
app.73b8bd4d.js:8
DOMException: Failed to execute 'appendChild' on 'Node':
This node type does not support this method.
+ +

该错误:

+
    +
  1. 只会在 build 模式出现;
  2. +
  3. 只会在发布上 GitHub Pages 后出现;
  4. +
  5. 只会在某些博文中出现;
  6. +
  7. 只会在直接从链接进入该博文,或者在该博文页面刷新时出现。
  8. +
+

该错误带来的影响,会导致页面上的所有 JavaScript 功能全部失效,具体来说是与 Vue.js 相关的功能。如:导航链接(因为使用了 Vue-Router),评论框,一些依赖于 Vue.js 的 VuePress 插件,等等。

+

screenshot

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

如果公司有专业运维,项目的部署上线过程一般来说开发者都不会接触到。但是很不幸,我所在的团队没有独立的运维团队,所以一切都得靠自己(与同事)。

+

以下都只是工作中逐步优化得到的经验总结,并且只以 Node.js 程序部署为例。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

package.json

Change webpack related devDependencies versions:

+
    +
  1. webpack to ^4
  2. +
  3. webpack-dev-server to ^3
  4. +
  5. Add webpack-cli
  6. +
  7. Replace extract-text-webpack-plugin with mini-css-extract-plugin
  8. +
  9. Replace uglifyjs-webpack-plugin with terser-webpack-plugin
  10. +
+
{
"devDependencies": {
"mini-css-extract-plugin": "^1",
"terser-webpack-plugin": "^4",
"webpack": "^4",
"webpack-cli": "^3",
"webpack-dev-server": "^3"
}
}
+ +

webpack.base.conf.js

Add mode option.

+
// ...

module.exports = {
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
context: path.resolve(__dirname, '../'),
// ...
}
+ +

webpack.prod.conf.js

    +
  1. Add performance and optimization option
  2. +
  3. Replace ExtractTextPlugin with MiniCssExtractPlugin
  4. +
  5. Remove UglifyJsPlugin and all webpack.optimize.CommonsChunkPlugin
  6. +
+
// ...
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const TerserPlugin = require('terser-webpack-plugin');
// ...
const webpackConfig = merge(baseWebpackConfig, {
// ...
performance: {
hints: false
},
optimization: {
runtimeChunk: {
name: 'manifest'
},
minimizer: [
new TerserPlugin(),
new OptimizeCSSPlugin({
cssProcessorOptions: config.build.productionSourceMap
? { safe: true, map: { inline: false } }
: { safe: true }
}),
],
splitChunks: {
chunks: 'async',
minSize: 30000,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
name: false,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'initial',
priority: -10
}
}
}
},
// ...
plugins: [
// new UglifyJsPlugin({
// uglifyOptions: {
// compress: {
// warnings: false
// }
// },
// sourceMap: config.build.productionSourceMap,
// parallel: true
// }),
// new ExtractTextPlugin({
// filename: utils.assetsPath('css/[name].[contenthash].css'),
// // Setting the following option to `false` will not extract CSS from codesplit chunks.
// // Their CSS will instead be inserted dynamically with style-loader when the codesplit chunk has been loaded by webpack.
// // It's currently set to `true` because we are seeing that sourcemaps are included in the codesplit bundle as well when it's `false`,
// // increasing file size: https://github.com/vuejs-templates/webpack/issues/1110
// allChunks: true,
// }),
new MiniCssExtractPlugin({
filename: utils.assetsPath('css/[name].css'),
chunkFilename: utils.assetsPath('css/[name].[contenthash].css')
}),
// split vendor js into its own file
// new webpack.optimize.CommonsChunkPlugin({
// name: 'vendor',
// minChunks (module) {
// // any required modules inside node_modules are extracted to vendor
// return (
// module.resource &&
// /\.js$/.test(module.resource) &&
// module.resource.indexOf(
// path.join(__dirname, '../node_modules')
// ) === 0
// )
// }
// }),
// extract webpack runtime and module manifest to its own file in order to
// prevent vendor hash from being updated whenever app bundle is updated
// new webpack.optimize.CommonsChunkPlugin({
// name: 'manifest',
// minChunks: Infinity
// }),
// This instance extracts shared chunks from code splitted chunks and bundles them
// in a separate chunk, similar to the vendor chunk
// see: https://webpack.js.org/plugins/commons-chunk-plugin/#extra-async-commons-chunk
// new webpack.optimize.CommonsChunkPlugin({
// name: 'app',
// async: 'vendor-async',
// children: true,
// minChunks: 3
// }),
],
// ...
})
+ +

That’s it, enjoy. 🎉

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

由于 Gitlab CE 做代码评审时缺少了关键的评审员功能(详情参考此 issue),因此在使用 CE 的同时又想要做代码评审的话,就必须要自己想办法了。

+

网上能找到的最多的解决方案就是在 Gitlab 前面再部署一套 Gerrit,通过拦截推送的代码以及同步两个库来实现。但是这种方案有诸多弊端。比如:

+
    +
  1. 割裂的用户体验。原本习惯了使用 Gitlab 系统的人,要开始学习晦涩难懂的 Gerrit;
  2. +
  3. 代码同步的不稳定性和不确定性。系统每增加一层逻辑,可靠性就降低一些;
  4. +
  5. 复杂的使用方式:代码必须要从 Gerrit clone,同时 push 时分支名必须加上 refs 前缀,否则无法进入评审
  6. +
  7. +
+

总体来说,以上的种种原因让我觉得 Gerrit 并不是最好的解决方案。对于凡事追求完美的处女座的我来说,我想要的东西大概应该具备以下几点:

+
    +
  1. 最好是能直接在 Gitlab 上面进行评审。因为 CE 可以说是万万事俱备,只差流程;
  2. +
  3. 最好是对原 Git 和 Gitlab 使用流程、习惯没有任何更改和侵入,仅增加评审流程;
  4. +
  5. 最好是可以可以自动化整个流程(评审人自动分配、评审完自动合并,等等)。
  6. +
+

好在,Gitlab 有一套完备的 Web hook 以及 API 系统,可以支撑起我的想法。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/page/4/index.html b/page/4/index.html new file mode 100644 index 000000000..75e4caf2c --- /dev/null +++ b/page/4/index.html @@ -0,0 +1,1200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

Linux 的命令行与构建工具一般来说要比 Windows 好用,但 Windows 的用户界面毫无疑问要比 Linux 好用。以往在 Windows 10 上安装 Linux,要么是使用虚拟机,要么是使用双系统,总是无法做到两头兼顾。现在 Windows 10 有了 WSL 技术,使得「二者合一」成为了可能。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

前端项目使用 lodash 时需要注意,一不小心就会把整个库引入进来,大大增加最终打包体积。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

这次出院本来应该是很开心的,一切都如(甚至超出了)我所愿,但是不知道怎么的,就是觉得很平淡。什么都不想做,朋友圈都不想发,只想安安静静地躺在家里或者工作一段时间。

+

15 号住院之前,我一直在担心,这次检查到底会怎么样,会不会被要求手术,会不会还是全结肠切除+造口的结局,会不会……

+

因为我真的是被打击到了,近一年来一直在承受打击。害怕了,就像是一直在被突破底线,刚刚才鼓起勇气接受这个它,突然又来说,这样不行,还得再往下一点。如此往复了好几次好几次,以致我实在是没有信心了。

+

所以,觉得这次的住院经历太突然了,太不常规了。有点没缓过来的感觉。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

由于微信小程序中的 JavaScript 运行环境与浏览器有些许区别,因此在引用某些 npm lib 时会发生问题。这时候需要对源码做出一些改动。

+
+

小程序环境比较特殊,一些全局变量(如 window 对象)和构造器(如 Function 构造器)是无法使用的。

+
+

在小程序中直接 import lodash 会导致以下错误:

+
Uncaught TypeError: Cannot read property 'prototype' of undefined
+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

如何进行条件渲染是一个 MVx 框架最基础的问题之一,但是它在 React 中总是会给人提出各种各样的问题。要么「不够优雅」,要么「不够可靠」,要么「不够好用」,现有的各种各样的方法之中,总是逃不过这三种问题的其中之一。至于 React-Native,虽然它与 React 「原则上一致」,但它存在的问题实际上就是要比 React 更多一些。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

FirewallD (firewall daemon) 作为 iptables 服务的替代品,已经默认被安装到了 CentOS7 上面。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+
+

克罗恩病是一种原因不明的肠道炎症性疾病,在胃肠道的任何部位均可发生,但好发于末端回肠和右半结肠。本病和慢性非特异性溃疡性结肠炎两者统称为炎症性肠病(IBD)。本病临床表现为腹痛、腹泻、肠梗阻,伴有发热、营养障碍等肠外表现。病程多迁延,反复发作,不易根治。本病又称局限性肠炎、局限性回肠炎、节段性肠炎和肉芽肿性肠炎。目前尚无根治的方法,许多病人出现并发症,需手术治疗,而术后复发率很高。本病的复发率与病变范围、病症侵袭的强弱、病程的延长、年龄的增长等因素有关,死亡率也随之增高。

+
+

现在是 2019 年 10 月,大约是我患克罗恩病(CD)的第 10 个年头。我写这篇记录的目的是记录自己的治疗过程,同时也为他人提供参考。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

Hooks 是 React 在 v16.8.0 版本所支持的一个新特性,允许开发者在 Functional Component 中实现「状态」以及「生命周期」等原本只能在 Class Component 中实现的特性。

+

Vue Function-based API 是将来会出现在 Vue.js 3.0 大版本中的一个 API 变革的整体预览,二者(至少)在形式上保持了高度统一,而 yyx 也在文章中直言是受到了 React Hooks 的启发,二者分别解决了自身框架的一些痛点,并允许开发写编者更加「纯粹」的函数式组件。也许可以认为是未来前端框架发展的一个大方向?

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

-webkit-overflow-scrolling CSS 属性可以让滚动元素在 ios 设备上获得接近原生的平滑滚动以及滚动回弹效果。

+

支持的值:

+
    +
  • auto 普通滚动行为,当手指离开屏幕时,滚动会立即停止(默认)
  • +
  • touch 基于动量的滚动行为,当手指离开屏幕时,滚动会根据手势强度以相应的速度持续一段时间,同时会赋予滚动回弹的效果
  • +
+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

博客正式迁移到了 VuePress,有以下两点原因:

+
    +
  1. 想做一个极简化改版,但懒得折腾了
  2. +
  3. 希望以后重心放在写文章,而不是维护博客上
  4. +
+

共勉。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/page/5/index.html b/page/5/index.html new file mode 100644 index 000000000..b06133dfd --- /dev/null +++ b/page/5/index.html @@ -0,0 +1,1220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

4S 店之所以敢贪得无厌、明目张胆,我想很大一部分原因是来自于普通人想要维权实在是太过困难了。想要维权,那就相当于:

+
    +
  1. 你得放弃很大一部分的工作时间(甚至丢掉工作)
  2. +
  3. 你得付出前期的诉讼成本
  4. +
  5. 你得面对来自各方的压力(家庭与社会)
  6. +
  7. 你得面对可能最终维权失败的结果
  8. +
+

再结合最近热议的 996,再来看这件事,对于普通人来说实在是太难了。生活与工作本身已经如此不易,要是再来这么一出,谁顶得住啊。也难怪绝大多数人在受到欺负之后最终只能无奈选择忍气吞声。毕竟大多数国人都是很「精明」的,就算维权成功,带来的收益也可能远不如其负面影响,那么为什么要维权呢?

+

996 其实也是同样的道理。公司敢于非法压榨员工,员工却无可奈何,只能通过在 Github 发声聊以自慰。近期互联网大佬频频发声,大谈创业艰难史,可是始终是避重就轻,你想奋斗没有人拦着你,但逼别人奋斗是怎么回事呢?问题的关键是「强制」而不是「996」,没有一人提及。最可笑的是马云的「你要来谈法律,那法律有规定这么齐全的设备吗?有规定这么好的食堂吗?」,可以看出这些站在企业顶端的人都是些什么嘴脸。求求你把这些都撤了,给我发合法的加班费好吗?当然这是不可能的,大佬们会跟你谈梦想,谈兄弟,这些都不成,那您请滚吧。

+

可是有多少人经受得住这种后果呢?一旦维权,即使成功,你也可能被列入行业黑名单,就算他们无法可依。这种事情不是没有先例,我印象中见过好多了。说了一堆废话,最终问题的根源到底在哪里,相信大家都懂的。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

Node.js 界大名鼎鼎的 koa,不需要多废话了,用了无数次,今天来拜读一下它的源码。

+

Koa 并不是 Node.js 的拓展,它只是在 Node.js 的基础上实现了以下内容:

+
    +
  • 中间件式的 HTTP 服务框架 (与 Express 一致)
  • +
  • 洋葱模型 (与 Express 不同)
  • +
+

一统天下级别的框架,只包含了约 500 行源代码。极致强大,极致简单。大概这就是码农与码神的区别,真正的代码的艺术吧。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

Parcel Bundler 发布了这么久,终于有机会体验了一次。在一个新的基于 jQuery 的小项目中尝试了这个打包器。结合它的宣传点,整体来说最大的感受是:

+
    +
  1. 确实比 Webpack 快很多
  2. +
  3. 确实「基本上」不需要配置
  4. +
+

虽然没有太多其它的亮点,但这不妨碍它用起来就是比 Webpack 「爽」。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

在 ReactNative App 的 WebView 中接入支付宝与微信支付其实很简单。首先前提是:使用 H5 网页提前做好了支付相关的动作,ReactNative 方面只负责展示 H5 页面,以及调起相应的 App 来完成支付,不需要接入底层相关的 SDK 或其它代码。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

在 Mac OSX 终端里面由于默认 Home 下面的文件夹都是大写开头,如 Downloads / Desktop 等,cd 的时候比较烦。解决方法:

+
$ echo "set completion-ignore-case On" >> ~/.inputrc
+ +

然后重启终端即可。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

京东网页端登录有时候需要输入滑动验证码,就像这样:

+

jd-verify

+

在做自动签到脚本的时候遇到这个很不舒服,如果不处理的话就只能每次弹出浏览器手动登录,因此稍微研究了下。下面是一个非常简单,但成功率很高(达到80%)的自动识别并输入方案,使用 puppeteer 实现。

+

总体思路:通过图像特征识别出滑块缺口的位置,然后通过模拟用户点击将滑块拖动到该处。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

出于某种需求搭建了一个非常简单的、基于 React / Node / Express / MongoDB 的 starter 工程:wxsms/react-node-starter,旨在简化小型或中小型项目开发流程,关注实际业务开发。

+

目前所实现的内容有:

+
    +
  • 前后端完全分离
  • +
  • 热重载
  • +
  • 用户注册、登录
  • +
+

+

麻雀虽小,五脏俱全。下面记录搭建过程。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

最近遇到一个问题:在做字符串截取操作时,如果字符串中包含了 emoji 字符(一个表情占多个 unicode 字符),而碰巧又把它截断了,程序会出错。在 ReactNative App 下的具体表现就是崩溃。由于以前做的是网页比较多,基本没有输入表情字符的案例,而在手机上就不一样了,因此这个问题还是第一次发现。

+

比如说:

+
'😋Emoji😋'.substring(0, 2) // 😋
+ +

因此,如果对这个字符串做 substring(0, 1) 操作,就会截取到一个未知字符。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

今年的 TI 本子到目前为止已经充了 ¥850 左右,770 级。

+
    +
  • 不朽 1 没有开到极其珍稀(PA),其它齐全
  • +
  • 不朽 2 齐全,一件极其珍稀(黑鸟)
  • +
  • 不朽 3 没有开到极其珍稀(巫医),其它齐全
  • +
  • 宝瓶 1 一轮,一件稀有额外(术士)
  • +
  • 宝瓶 2 一轮,一件稀有额外(大屁股)
  • +
+

战绩可以说非常不尽人意。虽然中途 V 社承认自己失误(被迫?)发了一次补偿,但依然没我。

+

现在每周就肝肝幽穴风云,肝肝代币,箱子开了马上又是一次轮回,感觉除了中看不中用的等级以外什么都没留下。想要的东西永远开不到,除了失望以外说不出别的感受来。

+

今天中午又开了一个箱子,依然是熟悉的啥都没有,突然就觉得好累,有点不想肝了。人生啊。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

Gitlab 有一套内置的 CI 系统,相比集成 Jenkins 来说更加方便一些,用法也稍为简单。以下是搭建过程。

+

前置准备:须要准备一台用来跑 CI 任务的机器(可以是 Mac / Linux / Windows 之一)。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/page/6/index.html b/page/6/index.html new file mode 100644 index 000000000..3b94df15b --- /dev/null +++ b/page/6/index.html @@ -0,0 +1,1223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

《我不是药神》是一部好电影。

+

影片最打动我的一段,是小吕请勇哥去他家吃饭的那几分钟。这些小人物倾家荡产,拼了命地活着,到底只不过就是为了一些「小事」而已。不然何苦呢?得了绝症的小吕幸福吗?从某个角度看,他非常幸福:有一个至死都不离不弃的爱人,还有一个至少到现在为止都健健康康的孩子。但生活就是这样残酷。

+

吃不起特效药的人,去抗议药厂卖天价药,对于不幸的患者来说,我命都快没了,管你是对是错呢?影片故意刻画了一个近乎反派立场的药厂,是不得已而为之,但我们要记住:真正对人类社会的发展做出贡献的是药厂。它卖天价药,卖任何价格,都没有问题,你永远不知道药厂为了第一片药付出了多少。至于吃不起,那是你的问题。就像影片说的一样:穷病,没法治。

+

影片从「病」这个角度,揭露出了绝大部分人生活在这个社会上的一些无奈。除非你有钱到刘强东这种程度,否则这个世界上总有你付不起的代价,这一刻是公平的。

+

这部电影好就好在,它选取了一个能够引发共鸣,但又值得深思的角度,同时把故事给讲好了。其实真的不难,真心希望它能够赚一笔大的,让大家以后都有样学样,多拍点有营养的东西。

+

PS. 毕导可以出来点评一下了,我猜这绝对又是境外势力的阴谋?

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

微信没有为 Linux 提供桌面客户端,可用的替代方式有:

+
    +
  1. 使用网页版微信
  2. +
  3. 使用第三方客户端,如 electronic-wechat
  4. +
  5. 自己动手,将网页版微信封装为桌面应用程序
  6. +
+

但是每种方式都有不尽人意的地方。网页版总是嵌入在浏览器中,用起来不太方便;第三方客户端安全性无法保证;自己做一个客户端又太麻烦。

+

然而,实际上还有一种更简单的方式:通过 Chrome 将网页直接转化为桌面应用。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

原文地址(需科学上网):React Native Text Inline Image

+

RN 版本:0.49

+

图文混排(在文字中插入图片,并保持正确换行)是客户端普遍的需求,但在 RN 中它有一点问题,具体表现在 Android 平台下图片显得异常的小,并且相同系统不同设备之间的表现也不尽一样,而 ios 则表现正常。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

使用 ReactNative 开发半年有余,本文是作为一些简单的感想。

+

官网简介:

+
+

Build native mobile apps using JavaScript and React.

+
+

简约,不简单。看着很牛逼,但实际用起来总是差了点意思。

+

总而言之:帮你节省时间的同时,隐藏着无处不在的坑。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

自动高的 Webview 实现方式其实跟 iframe 无二,无非是计算其内容高度后再赋值给容器样式。但是普通的办法实际上用起来差强人意,其问题主要体现在页面加载过慢,需要整个页面(包括图片)加载完成后才能计算出高度。而实际想要的效果往往是跟普通“网页”的表现一致,即:先加载文字,图片等内容异步加载、显示。在尝试了多款开源解决方案后,问题均没有得到解决,因此有了自己动手的想法。

+

不过本方案目前也只适用于自己拼接的 HTML,不适用于直接打开链接的 Webview,应用场景主要是在 ReactNative 应用内打开由 CMS 编辑的类新闻页面。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

Moment.js 是一个流行的基于 JavaScript 的时间处理工具库。应该是一个从 2011 年开始启动的项目,至今它的 Github repo 也有了 3w+ 的星星,可以说在前端界人尽皆知了。反正我自从用了它基本上就没再接触过其它的相关库。

+

但最近我却对它的看法却产生了些许改变。原因是,它的 API 设计给使用者埋下了巨大无比的坑,简单来说:“名不副实”。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

因为各种烦人的原因,公司搬家后到新办公室第一件事先把老电脑格了。犹豫了一下,最终还是放弃了重装 Windows,支持我做出选择的原因有几:

+
    +
  • 不需要进行(纯)MS 系开发
  • +
  • 没有必须使用的 Windows 软件
  • +
  • Windows 上跑 Android emulator 卡得头疼
  • +
  • NVIDIA 已有支持 Linux 的官方显卡驱动
  • +
  • Linux 开发效率更高
  • +
  • Linux 学习价值更高
  • +
+

本文是办公室适用(对我来说)的安装记录。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

今天是 2017 年的最后一个(法定)工作日。做个简单的总结。

+

先对比一下去年的自己与目标:

+
    +
  • 关于工作,年初就换了。现在到了一个游戏公司(西山居)上班。对于去年吐槽最多的「业务」问题来说,如今算是彻底解决了。
  • +
  • 关于学习,感觉自己从某些方面来说,是有一点进步的。
  • +
  • 关于生活,今年入了两台主要设备,一台 RMBP,以及一台游戏主机。感觉都很值。
  • +
+

大概就这些。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

我个人非常喜欢冯导的这部电影。

+

我的理解,这部电影的内容、主题,就跟它的名字一样,芳华。虽然我不是生活在那个年代的人,但是我也许可以理解那些都是什么。电影把一代人最美的形象,最好的年华,最真的梦想,展示给了我们看。相信这一点没有争议,不用过多解释。

+

至于其它的,我觉得都不重要。

+

有些人在这个故事里看到的更多是人的「恶」。如林丁丁,如红二代,如政委。认为所谓的「战友情」不过是镜花水月。但是,生活不就是这样的吗?

+

在电影里面,最终没有任何事情被追究,就连「迫害」了刘峰的林丁丁,最后也能被拿来给受害人打趣,然而我并没有觉得有任何反感之处。

+

人不就是这样的吗?当你对形势做出了错误的判断,就理应承担造成的后果。认真就输了,可谓一语成谶。既然是自己酿成的错,有什么好追究的呢?

+

百年以后,没有人会记得这些人当年的那些点点滴滴的琐事,善也好,恶也罢,大概都已经如萧穗子散落的情书一般,仿佛从来就没有存在过。即使是残酷至极的战争,也终究会被人遗忘。

+

也许能留下来的,也不过存在于现实与记忆中的,一代又一代人的最美的芳华吧。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

使用 vue-cli 创建的脚手架项目,目前最大的问题是创建后无法自动地进行升级。虽然 3.0 版本已经计划将其作为头等大事来进行改善 (#589),但是现行的版本依然要面对它。以下基于 webpack template 来进行升级时的一些要点解析。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/page/7/index.html b/page/7/index.html new file mode 100644 index 000000000..c455e8803 --- /dev/null +++ b/page/7/index.html @@ -0,0 +1,1227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

在基于 Webpack 的 Vue 项目中添加 JSX 支持:

+
$ yarn add babel-plugin-syntax-jsx babel-plugin-transform-vue-jsx babel-helper-vue-jsx-merge-props --dev
+ +

各依赖的作用:

+
    +
  • babel-plugin-syntax-jsx 提供基础的 JSX 语法转换
  • +
  • babel-plugin-transform-vue-jsx 提供基于 Vue 的 JSX 特殊语法
  • +
  • babel-helper-vue-jsx-merge-props 是可选的,提供对类似 <comp {...props}/> 写法的支持
  • +
+

然后在 .babelrc 中,增加:

+
{
...
"plugins": [
"transform-vue-jsx",
...
]
...
}
+ +

注意如果有其它 env 也要如此加上 transform-vue-jsx 插件。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

命令 (2.4.0+):

+
$ pm2 serve <path> <port>
+ +

举例:

+
$ pm2 serve /dist 80
+ +

默认情况下,如果页面未找到,它将显示 404.html 目录中的文件 (无法配置)。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

Personal common-used commands list, including windows, osx, git, etc.

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

Prerender SPA Plugin 是一个可以将 Vue 页面预渲染为静态 HTML 的 webpack 插件,对静态小站(比如博客)来说很棒棒。但是最近用的时候总发现一个问题:它的 build 失败率越来越高,尤其是在 CI 上。后来在其 repo 的一个 issue 中发现了问题所在,就是它没有限制 PhantomJS workers 的数量,导致页面一多就直接全部卡死不动,然后超时。

+
+

(Workers) Default is as many workers as routes.

+
+

虽然有人已经发了 PR 来修复这个问题,然而好几个月过去了也没有 merge,不知道是什么情况。于是我在自己的尝试中找到了一种可以接受的解决方案:虽然我不能限制你插件 workers 的数量,但是可以限制每个插件渲染的 route 数量呀。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

这篇文章记录了我是如何一步步地把 https://github.com/wxsms/uiv 这个项目的用户文档变得更优雅的。实际上,如何以一种高效又优雅的方式编写实例文档一直是我的一个疑惑,比如主要的问题体现在:

+
    +
  • 如何使文档更易读?
  • +
  • 如何使文档更易于维护?
  • +
  • 如何减少编写文档的工作量?
  • +
  • 实例代码无可避免地需要手工维护吗?
  • +
+

最后一点是让我最头疼的地方。举个例子,我想要给用户展示一个组件的使用方式,以下代码可以在页面上创建一个 Alert:

+
<alert type="success"><b>Well done!</b> You successfully read this important alert message.</alert>
+ +

那么,我总要给用户一个相对应的实例吧。我要在我的文档上面就创建一个这样的 Alert,同时告诉用户说你可以这么用。这是一个很普遍的展示方式,那么问题就在这里了,我是否要将同样的代码写两次呢?

+

一开始我确实就是这么做的,虽然我知道这不科学,不高效,更不优雅。但我实在是想不到更好的办法了。

+

但是,现在,我已经(几乎)把以上的问题都解决了。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

CORS HTTP Header 是解决 Ajax 跨域问题的方案之一。详情查看:MDN

+

这篇文章主要是记录使用过程中遇到的问题以及解决方案。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

最近事情有点多,导致好久没有更新过博客。过完后天终于要到国庆假期了,希望可以多点时间在家休息(睡觉)。经常加班到 10 点,周末也时常单休,连续下来还是挺累人的。

+

公司的饭菜开始吃腻了,每天都能找到不想吃的菜(或者找不到想吃的菜)。

+

假期一定要抽空把这几个月学到的东西总结一下。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+
+

Koa是一个类似于 Express 的 Web 开发框架,创始人也是同一个人。它的主要特点是,使用了 ES6 的 Generator 函数,进行了架构的重新设计。也就是说,Koa的原理和内部结构很像 Express,但是语法和内部结构进行了升级。

+

—— 阮一峰博客

+
+

想要达到使用 Koa2 的完整体验,需要将 Node 版本升级到 v7.6+ 以支持 async 语法。

+

为什么是 Koa 而不是 Express 4.0?

+

因为 Generator 带来的改动太大了,相当于推倒重来。

+

以下内容基于 Koa2

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

其实这部分代码主要是参考着 element ui 和 iview 做的(iview 又是抄的 element),对关键代码进行了一些简化。主要需要实现的需求有:

+
    +
  1. 用户可以更改、切换组件库使用的语言(应用级别)
  2. +
  3. 用户可以自定义组件使用的措辞
  4. +
  5. 兼容 vue-i18n 这个库
  6. +
+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/page/8/index.html b/page/8/index.html new file mode 100644 index 000000000..b0294f4c5 --- /dev/null +++ b/page/8/index.html @@ -0,0 +1,1240 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

Travis CI 是一款免费的持续集成工具,可以与 Github 无缝集成。能够自动完成项目代码的日常测试、编译、部署等工作。现在,我把它应用到了我的两个项目中。

+

首先,要在这个平台上做持续集成的前提是到它上面 https://travis-ci.org/ 去注册个账号。实际上直接用 Github 账号进行 OAuth 登录就行了。登录以后可以在首页找到自己的所有仓库,在需要进行持续集成的项目前面的开关打开即可。开启后,Travis CI 会监听项目的代码推送与 PR,当发生改变时会立刻进行相应操作。

+

至于具体操作内容,由项目根目录的 .travis.yml 文件决定。这个文件的简单用法由下面两个具体例子来说明。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

CSS Transition 中的高度从 0 到 auto 以及从 auto 到 0 是个艰难的任务(相比于其它属性的 transition 而言),原因也很简单:就是浏览器不支持此类 CSS 动画,无论在何种情况下,它都不会成功。

+

但是高度渐变是个很常用的动画效果,如果绕过纯 CSS height 属性,有如下方式来实现:

+
    +
  • 使用 max-height 属性,为元素设置一个不可能达到的最大高度,然后将 transition 转换为 max-height 从 0 到某个固定的值;
  • +
  • 使用 transform: scaleY 实现;
  • +
  • 使用 JavaScript 动画。
  • +
+

上面的解决方案都从某种程度上解决了问题,但是,各有各的限制于缺点:

+
    +
  • 使用 max-height 会造成动画效果与预期有些许出入(加速与延迟),实际体验是,它与实际 height 区别越大,这种感觉就会越明显,原因也很容易想到,因为 transition 的起点与终点均不在实际的起点与终点上;
  • +
  • 使用 scaleY 有两个问题:一是动效与高度渐变不一样,元素的内容看上去是被压缩了(而不是被收起或展开),这个倒可以忍耐。可恶的是第二点,它虽然看起来是渐变了,然而高度却并没有被渐变!意思是,在它下面的元素会在动画结束后”跳”到另一个位置而不是平滑地渐变到这个位置;
  • +
  • 使用 JavaScript 动画其实已经可以完美地实现高度渐变了,然而,问题是我们需要引入额外的 lib 来做成这件事,我可没心情纯 js 手写动画。
  • +
+

所以,我的目标是:

+
    +
  1. 使用 css transition 完成动画;
  2. +
  3. 动画效果必须完美;
  4. +
  5. 与 vue transition 组件集成。
  6. +
+

这实际上是一个很艰难的任务。经过了大量的失败尝试,最终还是 google 救了我。。下面先直接上解决方案。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

OSX

Use brew to install polipo via socks proxy:

+
$ ALL_PROXY=socks5://127.0.0.1:9500 brew install polipo
+ +

Create polipo.config file under Document:

+
socksParentProxy = "127.0.0.1:9500"
socksProxyType = socks5
proxyAddress = "::0"
proxyPort = 8123
+ +

Start polipo server:

+
$ polipo -c ~/Documents/polipo.config
Established listening socket on port 8123.
+ +

Verify it at http://localhost:8123.

+

Windows

Use privoxy tool. Download: http://www.privoxy.org/sf-download-mirror/Win32/

+

Install it, find the config file at \Privoxy\config.txt, append following to the bottom of it:

+
forward-socks5 / 127.0.0.1:9500 .
+ +

(Mind the dot at the end)

+

The default port is 8118, search from the config file to replace it.

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

Egret Engine 的学习笔记。

+

Egret Engine 是一款基于 JavaScript 的游戏制作引擎,支持 2D 与 3D 模式,支持 Canvas 与 WebGL 渲染,目前使用 TypeScript 编写。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

如题,经过长期痛苦的观察以及 debug 过程,以下原因被一一排除:

+
    +
  • 浏览器差异问题
  • +
  • 数据更新问题
  • +
  • ng-repeat 没有添加 track by key 导致的性能问题
  • +
  • Angular 版本问题
  • +
  • MEAN.js 架构问题
  • +
+

实际原因却是因为 MEAN.js 在全局引入了 ngAnimate 依赖。(也算是一个架构问题?)

+

因此解决办法:

+
    +
  • 要么将全局依赖去掉,改为各自添加依赖
  • +
  • 要么使用 transition: none !important
  • +
+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

React 学习笔记(基础篇)。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

刚刚过完了一个非常无聊的元旦。虽然像是很快过了一年的样子,但是又感觉过了很久。因为我已经想不起来年初我在做些什么了。

+

这次主要说些职业生涯和工作上的东西。其它的,想到再说。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

经过一些努力,把博客迁移到了 Github Pages,将域名改成了自定义,并且成功启用了 SSL,以下是步骤(就不截图了)。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

d3-interpolate 是 D3 的核心模块之一,与比例尺有些类似,interpolate (插值)所做的也是一些数值映射的工作。区别是,interpolate 的定义域始终是 0 ~ 1,并且始终为线性的。所以,更多时候它用来与 D3 的一些其他模组集成使用(如 transition, scale 等)。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/page/9/index.html b/page/9/index.html new file mode 100644 index 000000000..cf86055e5 --- /dev/null +++ b/page/9/index.html @@ -0,0 +1,1278 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +wxsm's pace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

之前做的柱状图例子:

+
let data = [250, 210, 170, 100, 190]

let rectWidth = 25

svg.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('y', (d, i) => height - d)
.attr('x', (d, i) => i * rectWidth)
.attr('height', d => d)
.attr('width', rectWidth - 2)
.attr('fill', 'steelblue')
+ +

有一个严重的问题,就是没有比例尺的概念,柱状图的高度完全由数据转换成像素值来模拟。这明显是不科学的:如果数据的值过小或过大,作出来的图就会很奇怪,同时也无法做到非线性的映射。

+

就跟地图需要比例尺一样,绝大多数的数据图表也需要比例尺。

+
+

Scales are a convenient abstraction for a fundamental task in visualization: mapping a dimension of abstract data to a visual representation.

+
+

比例尺 - Scale - “将某个维度的抽象数据做可视化映射”

+

至于可视化映射的具体实现,d3-scale 模块提供了许多方案,大致可以分为两类:

+
    +
  • Continuous Scales(连续映射)
  • +
  • Ordinal Scales(散点映射)
  • +
+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

博客再次迁移,这次是从 Wordpress 转向静态博客(自建)。

+

技术栈:

+
    +
  • 前端:vue + vue-router + vuex + bootstrap + webpack
  • +
  • 服务端:没有
  • +
  • 数据库:没有
  • +
+

整站打包后,一次加载所有资源(HTML + CSS + JS + DATA)300K 不到(gzip 后 80K+),秒速渲染,与先前真的是天差地别。

+

图片资源从本地服务器搬迁到免费云。 写作使用 Markdown,从此 IDE 写博客不是梦。

+

代码地址:https://github.com/wxsms/wxsms.github.io/tree/src

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

在 D3 的使用过程中,我们见得最多的应当是类似如下的代码:

+
let div = d3.select('body')
.selectAll('p')
.data([3, 6, 9, 12, 15])
.enter()
.append('p')
.text(d => d);
+ +

将得到:

+
<body>
<p>3</p>
<p>6</p>
<p>9</p>
<p>12</p>
<p>15</p>
</body>
+ +

光看代码完全不能理解 D3 到底做了些什么,其实这里关键是 enter 的使用。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+
    +
  1. goto ‘File | Settings | Appearance & Behavior | System Settings’;
  2. +
  3. uncheck ‘Use save write’ option
  4. +
+

+

Problem solved.

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

HTTP 是一种无状态协议,服务器与客户端之间储存状态信息主要靠 Session,但是,Session 在浏览器关闭后就会失效,再次开启先前所储存的状态都会丢失,因此还需要借助 Cookie

+

一般来说,网络爬虫不是浏览器,因此,只能靠手动记住 Cookie 来与服务器“保持联系”。

+

Cookie 是 HTTP 协议的一部分,处理流程为:

+
    +
  • 服务器向客户端发送 cookie
      +
    • 通常使用 HTTP 协议规定的 set-cookie 头操作
    • +
    • 规范规定 cookie 的格式为 name = value 格式,且必须包含这部分
    • +
    +
  • +
  • 浏览器将 cookie 保存
  • +
  • 每次请求浏览器都会将 cookie 发向服务器
  • +
+

因此,爬虫要做的工作就是模拟浏览器,识别服务端发来的 Cookie 并保存,之后每次请求都带上 Cookie 头。

+

在 Node.js 中有很多与 Cookie 处理相关的 package,就不再赘述。

+

Session

Cookie 虽然方便,但是由于保存在客户端,可保存的长度有限,且可以被伪造。因此,为了解决这些问题,就有了 Session

+

区别:

+
    +
  • Cookie 保存在客户端
  • +
  • Session 保存在服务端
  • +
+

Cookie 与 Session 储存的都是客户端与服务器之间的会话状态信息,它们之间主要靠一个秘钥来进行匹配,称之为 SESSION_ID ,如 express 中默认为 connect.sid 字段。只要浏览器发出的 SESSION_ID 与服务器储存的字段匹配上,那么服务器就将其认作为一个 Session,只要 SESSION_ID 的长度足够大,几乎是不可能被伪造的。因此,敏感信息储存在 Session 中要比 Cookie 安全得多。

+

常见的 Session 存放媒介有:

+
    +
  • RAM
  • +
  • Database
  • +
  • Cache (e.g. Redis)
  • +
+

Session 不是爬虫可以接触到的东西。

+

AJAX 页面

对于静态页面(服务端渲染),使用爬虫不需要考虑太多,把页面抓取下来解析即可。但对于客户端渲染,尤其是前后端完全分离的网站,一般不能直接获取页面(甚至没有必要获取页面),而是转而分析其实际请求内容。

+

请求分析

通过一些请求拦截分析工具(如 Chrome 开发者工具)可以截获网站向服务器发送的所有请求以及相应的回复。

+

包括(不限于)以下信息:

+
    +
  • 请求地址
  • +
  • 请求方法(GET / POST 等)
  • +
  • 所带参数
  • +
  • 请求头
  • +
+

只要把信息尽数伪造,那么爬虫发出的请求照样可以从服务器取得正确的结果。

+

秘钥处理

一些请求中会带有秘钥(token / sid / secret),可能随除了请求方法外的任一个位置发出,也可能都带有秘钥。更可能不止一个秘钥。

+

理论上来说,正常客户端取得秘钥有两种方式:

+
    +
  • 服务端提供
  • +
  • 客户端自行计算,由服务端校对
  • +
+

对于服务端提供给客户端的秘钥,只要仔细分析 HTML 或服务端返回的 Cookie Header 就一定能发现。

+

而对于客户端自行计算的秘钥则比较麻烦了,尤其是在 JS 代码加密、混淆的情况下。这种时候,只能自己去用开发者工具调试原始站点代码,找出加密代码段,并在爬虫中实现。这里面有许多技巧,如各种断点、单步调试等。

+

表单处理

表单实际上也是 HTTP 请求,使用 GET / POST 等方法即可模拟表单提交。然而这不是重点。重点是表单常常伴随着验证码而存在。

+

验证码的识别暂未涉及。

+

浏览器模拟

爬虫的下下策才是使用浏览器完全模拟用户操作。实在是属于无奈之举。Nodejs 可以驱动 Chrome 与 Firefox 浏览器,存在相应的 Package,但是,更方便的是使用各种 E2E Testing 工具。

+

比如 Night Watch JS:

+
module.exports = {
'Demo test Google' : function (client) {
client
.url('http://www.google.com')
.waitForElementVisible('body', 1000)
.assert.title('Google')
.assert.visible('input[type=text]')
.setValue('input[type=text]', 'rembrandt van rijn')
.waitForElementVisible('button[name=btnG]', 1000)
.click('button[name=btnG]')
.pause(1000)
.assert.containsText('ol#rso li:first-child',
'Rembrandt - Wikipedia')
.end();
}
};
+ +

在这种模式下,Cookie / Session / 请求等各种细节都不用关心了。只需要按部就班地执行操作即可。模拟浏览器的代价是效率太低,内存开销大,但在某些特定需求情况下,却比一般爬虫要简单得多。

+

 

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

D3 (Data-Driven Documents) 是一个 JavaScript Library,用来做 Web 端的数据可视化实现以及各种绘图。

+
+

D3.js is a JavaScript library for manipulating documents based on data. D3 helps you bring data to life using HTML, SVG, and CSS.

+
+

学习 D3 需要很多预备知识:

+
    +
  1. HTML / DOM
  2. +
  3. CSS
  4. +
  5. JavaScript (better with jQuery)
  6. +
  7. SVG
  8. +
+

HTML / CSS 不必多说,因为 D3 含有大量链式操作函数以及选择器等,因此如果有 jQuery 基础将轻松很多。此外,由于一般采用 SVG 方式进行绘图,所以 SVG 基础知识也需要掌握。

+

虽然必须的预备知识如此之多,但 D3 的定位其实是 Web 前端绘图的底层工具,所谓底层,即是操作复杂而功能强大者。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

忍无可忍,长期更新。

+

(其实我很想自己重新做一个 blog,但是太麻烦,也没什么实践价值了,无非 CRUD,而且维护起来很容易忽略 blog 本身的目的所在)

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

项目地址:https://github.com/wxsms/zhihu-spider

+

简介:使用 Node.js 实现的一个简单的知乎爬虫,可以以一个用户为入口,爬取其账号下的一些基本信息,关注者,关注话题等。再通过关注者的 ID 继续爬取其他用户,以此循环。

+

实现功能:登录知乎(因为调用一些知乎 API 需要保存 session),解析页面,访问 AJAX API,保存到数据库。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

知乎上有一个黑 JavaScript 的段子,大概是说:

+
+

N 年后,外星人截获了 NASA 发射的飞行器并破解其源代码,翻到最后发现好几页的 }}}}}}……

+
+

这是因为 NASA 近年发射过使用 JavaScript 编程的飞行器,而 Node.js 环境下的 JavaScript 有个臭名昭著的特色:Callback hell(回调地狱的意思)

+

JavaScript Promise 是一种用来取代超长回调嵌套编程风格(特指 Node.js)的解决方案。

+

比如:

+
getAsync("/api/something", (error, result) => {
if(error){
//error
}
//success
});
+ +

将可以写作:

+
let promise = getAsyncPromise("/api/something"); 
promise.then((result) => {
//success
}).catch((error) => {
//error
});
+ +

乍一看好像并没有什么区别,依然是回调。但最近在做的一个东西让我明白,Promise 的目的不是为了干掉回调函数,而是为了干掉嵌套回调函数。

+ +
+ + 阅读全文 » + +
+ + + +
+ + + + + +
+
+ +
+
+
+ + + + + + + +
+ + + +
+ + + + + + + +
+

+ +

+ + +
+ + + + +
+

2016 主题设置里没有提供是否使用摘要的选项,因此如果文章不做任何操作,首页以及归档页都会显示全文,导致页面非常地长。但是,一番机缘巧合,我发现只要在文章里面插入了 more 标签,主题就会自动检测到并且切换到摘要模式。

+

妄我在 Google 上苦苦探索,搜集到一堆垃圾代码,然而并没有什么用。

+ + +
+ + + + + +
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+ + 0% +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/ribbon.js b/plugins/ribbon.js new file mode 100644 index 000000000..c72cf8d51 --- /dev/null +++ b/plugins/ribbon.js @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2016 hustcc + * License: MIT + * Version: %%GULP_INJECT_VERSION%% + * GitHub: https://github.com/hustcc/ribbon.js + **/ +/*jshint -W030 */ +! function() { + function attr(node, attr, default_value) { + return Number(node.getAttribute(attr)) || default_value; + } + + // get user config + var scripts = document.getElementsByTagName('script'), + script = scripts[scripts.length - 1]; // 当前加载的script + config = { + z: attr(script, "zIndex", -1), // z-index + a: attr(script, "alpha", 0.6), // alpha + s: attr(script, "size", 90), // size + }; + + var canvas = document.createElement('canvas'), + g2d = canvas.getContext('2d'), + pr = window.devicePixelRatio || 1, + width = window.innerWidth, + height = window.innerHeight, + f = config.s, + q, t, + m = Math, + r = 0, + pi = m.PI*2, + cos = m.cos, + random = m.random; + canvas.width = width * pr; + canvas.height = height * pr; + g2d.scale(pr, pr); + g2d.globalAlpha = config.a; + canvas.style.cssText = 'opacity: ' + config.a + ';position:fixed;top:0;left:0;z-index: ' + config.z + ';width:100%;height:100%;pointer-events:none;'; + // create canvas + document.getElementsByTagName('body')[0].appendChild(canvas); + + function redraw() { + g2d.clearRect(0, 0, width, height); + q = [{x: 0, y: height * 0.7 + f}, {x: 0, y: height * 0.7 - f}]; + while(q[1].x < width + f) draw(q[0], q[1]); + } + function draw(i, j) { + g2d.beginPath(); + g2d.moveTo(i.x, i.y); + g2d.lineTo(j.x, j.y); + var k = j.x + (random()*2-0.25)*f, n = line(j.y); + g2d.lineTo(k, n); + g2d.closePath(); + r -= pi / -50; + g2d.fillStyle = '#'+(cos(r)*127+128<<16 | cos(r+pi/3)*127+128<<8 | cos(r+pi/3*2)*127+128).toString(16); + g2d.fill(); + q[0] = q[1]; + q[1] = {x: k, y: n}; + } + function line(p){ + t = p + (random() * 2 - 1.1) * f; + return (t > height || t < 0) ? line(p) : t; + } + + redraw(); +}(); \ No newline at end of file diff --git a/search.xml b/search.xml new file mode 100644 index 000000000..a913eb403 --- /dev/null +++ b/search.xml @@ -0,0 +1,7463 @@ + + + + 06/23/2020 + /2020/06-23-2020/ + 这次出院本来应该是很开心的,一切都如(甚至超出了)我所愿,但是不知道怎么的,就是觉得很平淡。什么都不想做,朋友圈都不想发,只想安安静静地躺在家里或者工作一段时间。

+

15 号住院之前,我一直在担心,这次检查到底会怎么样,会不会被要求手术,会不会还是全结肠切除+造口的结局,会不会……

+

因为我真的是被打击到了,近一年来一直在承受打击。害怕了,就像是一直在被突破底线,刚刚才鼓起勇气接受这个它,突然又来说,这样不行,还得再往下一点。如此往复了好几次好几次,以致我实在是没有信心了。

+

所以,觉得这次的住院经历太突然了,太不常规了。有点没缓过来的感觉。

+]]>
+ + personal + +
+ + 服务端开发一月记 + /2023/1-month-of-backend-dev/ + 5 月份休完陪产假,再回到公司,发现原本的小组已经整体重组,只有我一人还在工位上了。后来跟 TL 聊了一下,最后反正就是几个选项,要么跟着原来的同事一起去新的部门继续写前端,要么就做点别的事情。

+

当时我还是挺头疼的,主要是那会事情太多了,一是小满还在月子里,二是新房子还在装修,三是那段时间身体状态有一点波动(其实主要可能还是因为这个)。继续搞前端肯定是最稳的,但是我自己其实已经有点厌倦了,属于是看到前端代码就已经有点不耐烦的程度。但是对前端以外的东西又还是很感兴趣,偶尔自己写点非前端的玩意都会感到很愉快。犹豫再三,也是跟朋友家人都聊了一下,最后选择了转成服务端开发。(其实当时还有一个可能是 C++ 客户端的方向,但是因为太过陌生,加上前面说的那些现实情况,实在是有点绷不住)

+

其实我在刚毕业那会做过一段时间(半年左右?)的 Java 开发,但是那段时间基本上来说还是处于比较懵的状态,也没学到什么东西,加上后来很快就转型(基本上)全职前端,Java 服务端就荒废了。现在也算是一个从头来过。

+

到现在 7 月份,大概算下来时间过去一个多月了,也简单说下转型后的感想。

+ + +

技术栈

小组主要使用的语言大概是 Go 和 Python。Web 服务来说是 Go 居多,但是由于是在一个跟 AI 业务相关的小组,所以 Python 服务也不少。这一个多月下来 Go 的工作量大概占八成,python 二成的样子。

+

至于别的目前暂时就还没有接触太多,也只是粗略了解了一下业务项目的整个开发发布流程以及链条上的各种工具,还没有来得及深入学习。

+

学习方式

我大概在两年前的时候找同事借过一门视频课,学过一次 go 语言(算是入门)。但是到了要用的时候,年久失修,感觉大部分都已经还给老师了。周围同事朋友问了一圈,有个同事推荐了一个网站的课程,看了下各种知识的覆盖面还算可以,也没想太多,就花了四位数充了个年会(这大概是我毕业后对自己最大的一笔投资)。

+

充值后我就马不停蹄地开始学 Tony Bai 老师的《Go 语言第一课》,说实话这门课确实写得很好,绝对的物超所值。学习的过程中新的需求也很快就安排到了我这里来,我基本是在边学边做的情况下学完了这门课。Go 语言这门课一个星期差不多就肝完了。学完了 Go 语言后,我就打算根据工作需要,按部就班地把所有基本知识都过一遍,包括数据库、消息队列、缓存,等等。由于工作并不空闲,下班后也经常要带娃或者游戏肝日常,每天如果能学个一两章、两三章,我觉得也是不错的。

+

当然我毕竟因为花了四位数的钱,我必须对得起这笔开销。于是在学完这门课程后,就用刚学的 Go 语言写了一个命令行工具,它可以帮我把一门课程完整地下载到本地(包括文字里的图片!)并保存为 Markdown 格式,这样我就可以不用受年费会员的时间限制,提前下载好所有感兴趣的课程,无限期地学习了。项目地址在这里(当然,这不是一个盗版工具,要使用它的前提是使用者有年会帐号)。

+

开发体验

以下内容为练习时间一个半月的菜鸟程序员的一家之言。先说结论:Go 语言的开发体验明显优于 JavaScript,但服务端开发的心智模型远比前端复杂,并不是一句 CRUD 可以概括的。

+

为什么说体验明显优于 JavaScript 呢,首先毫无疑问 JS 是一门历史包袱很重的语言,即使发展到今天已经做了很多优化,但依然属于是带着脚镣跳舞,没有办法随心所欲。但 Go 不一样,它比较新。另外 Go 语言的哲学是“简单”,这种哲学内涵也体现在它语言设计的方方面面。举几个例子:

+
    +
  1. 首先 Go 是强类型,你可能会说 TypeScript 也是强类型,但 TS 的类型体操太复杂了,Go 语言的强类型跟它相比属于是非常简单的那种,首先这一点就能够给开发体验带来质的飞跃
  2. +
  3. 包含了很多 JavaScript 的优点,比如闭包、第一公民函数、值类型&引用类型
  4. +
  5. 也包含了一些 TypeScript 的优点,比如类型自动推断,类型后置
  6. +
  7. 文件夹即包,同包内不需要写 import,跨包不需要写 export,大写属性即默认 export
  8. +
  9. 没有 class,没有继承,只有 ducktype
  10. +
  11. ……
  12. +
+

至于为什么说心智模型复杂呢,主要还是在多线程处理这件事情上。

+

前端这个领域(包括 Node.js 等一系列 JS runtime),再复杂的模块也是单个线程,代码都是线性执行的,不存在资源抢占。浏览器就不用说了,即使是 Node.js 中也没有“锁”的概念(主要是指线程锁)。Node.js 最多就是开个 Cluster 模式,多启动几个线程占满 CPU,但是每个线程之间都是独立的,完全没有交集。所以写 JS 代码的时候完全不用考虑资源抢占,一路莽到底。

+

(不过话说回来我也是现在才意识到 Node.js 到底有多牛:这家伙居然能用一条线程实现那么高的吞吐量!)

+

但是 Go 不一样:包括 Go 在内的众多服务端技术,它们有多线程(在 Go 中则是线程更轻量级的“协程 goroutine”)。任何一个变量,哪怕是一个 int,一个 bool,只要涉及到多线程读写,就会有问题。要么传统方案“加锁”,要么使用 Go 独有的 channel 方式。总之就是绕不开这个话题。

+

另外说到 channel,相比“锁”,这确实是一种很有意思的设计,也给 Go 语言增加了一些趣味。将本来复杂且易错的“锁”替换成了另一种更为直观的心智模型。这也算是一种强大的体验优化。

+

至于 Python 的体验,感觉跟写 Node.js 大差不差,以及大家永远都在吐槽的性能问题,这个就没什么好说的。

+

我的工作

由于我所在的小组不是重业务组,属于是 AI 研究团队,工作更多是各类配合 AI 同学的基础设施建设和维护,当然也会有一部分 AI 相关的业务。所以到现在为止我甚至还没写过正经的 CRUD。

+

目前接手过的工作,比如说从零开始开发某个微服务项目(Go 或 Python)并部署上线,配置网关和 K8S,开发一些实用脚本,给某个 Web 服务添加一些功能,维护某个内部使用的数据工具,以及开发它的 Go 语言版 sdk等。比较杂。有时候新的工作来了,当我没有理解它要怎么做的时候也会比较焦虑(毕竟转行)。但是在这些过程中,我还是比较快乐的,包括编码的过程都很快乐。我有很强的动力去重构我写的或者前人写的代码,可能会在不断的删代码写代码、删代码写代码循环中度过好几天,并在这过程中从各个方面反复地体验一门新语言的设计艺术,试图寻找一件事情最最优雅的解决方案。这跟两个月前写前端的时候完全不一样。感觉我又回到了大三大四那段学习前端技术的时间的充满热情、废寝忘食的状态。

+

总的来说,我还是喜欢编码这件事情的。在一个方向上呆久了可能会有点腻,但对于编码本身的兴趣目前来说依然没有任何变化。很高兴我有勇气做出了这个转行的决定,希望两年后可以成长为一个不那么菜的后端工程师,顺便多赚点钱。

+]]>
+ + go + python + +
+ + 2013 年终总结 + /2013/2013-annual-summary/ + 首先感谢郭凯瑞同学百忙中抽出时间接受访问,那么事不宜迟,就开始吧。

+

Q: 今年最开心的事?

+

A: 没挂科,有奖励学分。

+

Q: 这么自信能过马哲?

+

A: 那是明年的事好吧。

+

Q: 还有什么值得开心的事吗?

+

A: 身体健康。 家人朋友都健康。

+

Q: 那么最伤心的事?

+

A: 婆婆去世了。

+

Q: 有多伤心?

+

A: 很伤心。

+

Q: 还有其它不开心的事吗?

+

A: 妈妈变老了,身体毛病多,不过今年做手术治好了肠胃。

+

Q: 现在在做什么呢?

+

A: 回答问题。

+

Q: 好吧,在这之前呢?

+

A: 准备音乐鉴赏考试。

+

Q: 会难吗?

+

A: 小菜一碟,相比马哲。

+

Q: 今年的大学生涯,过得如何?

+

A: 无惊无险。

+

Q: 修了些什么课程?

+

A: 多数是专业基础课,少量专业选修和公选课。

+

Q: 感觉掌握了相应知识吗?

+

A: 我只能说没挂科。

+

Q: 有认识新的朋友吗?

+

A: 没有。

+

Q: 为什么?

+

A: 太宅了,没办法。

+

Q: 不改变一下吗?

+

A: 付诸行动总是很难的,明年再来问这个问题吧。

+

Q: 有没有坚持运动?

+

A: 坚持了一段时间,主要是慢跑。说到运动,我想起一件事。

+

Q: 什么事?

+

A: 那个体能测试,引体向上我一个也做不了,然后我让计分的同学给我记了八个。

+

Q: 真的一个也做不了吗?

+

A: 真的一个也做不了。

+

Q: 这真是羞耻啊,这么差劲,怎么找女朋友。

+

A: 我也是这么觉得的。

+

Q: 你还说谎了。

+

A: 要是早知道重在参与,这就不会发生。

+

Q: 回到朋友这个话题上吧,你希望认识多一些朋友吗?

+

A: 自然希望。

+

Q: 希望认识什么样的朋友呢?

+

A: 异性朋友。。。

+

Q: 你没有异性朋友吗?

+

A: 有,太少了。

+

Q: 多少?

+

A: 我拒绝回答这个问题。

+

Q: 好吧,那么,再回到学业上,觉得自己的专业知识水平达到了什么地步呢?

+

A: 中等略偏上吧。

+

Q: 很快就大四了,能找到工作吗?

+

A: 现在肯定是不够的,明年要加把劲,继续努力。

+

Q: 找不到工作怎么办?

+

A: 找原因吧,肯定是自己还不够好。

+

Q: 会有紧张感吗?

+

A: 有一些。

+

Q: 对今年总结一下?

+

A: 肯定是悲伤的一年。

+

Q: 对明年有什么期待?

+

A: 身体健康,六级能过,不要挂科,学习多一些专业知识,认识多一些朋友。

+

Q: 好的,时间差不多了,这次的总结就到这里吧。

+

A: 谢谢。

+]]>
+ + personal + +
+ + 2014-01-29 + /2014/2014-01-29/ + 很快就过年了。

+

回了一次赣州,小姑的生意越做越大了,店面越来越多,小时候第一次去广州好像还是在一个住房里做事,应该十多年了吧,跟着老妈参加了他们的年会,很多比我小的男孩都参加工作了,家里的两个弟弟也都在工作,唯独我还在读书,有种说不出的感觉。郭慧姐应该快生小孩了吧,罗云哥嫂子的小孩也快了,不知道会叫什么名字呢。奶奶身体好像还不错,挺好的。

+

在大姨家住了几天,大姨做的菜还是很好吃,比婆婆做的都要好吃,但是有些东西还是婆婆做的更熟悉。谢金宏读五年级了,看起来还是小时候那个样子,不过这次没有看到他哭,大概也算长大了一点。后来感冒了,很不舒服。去水东走了一次,姑奶家养了很多狗,有小狗也有老狗,婆婆家的房子还有人住,不过是租给的别人,记忆已模糊得不可辨识。

+

前天回家,昨天宅掉,今天出去走了一圈,没什么变化。最近发现自己变得犹豫了很多,不喜欢这样,一直觉得自己做起事来都是干净利落的,但这次不知道为什么。真的很抱歉,让你看到一个这么寡断的我。

+]]>
+ + personal + +
+ + 2014-11-11 + /2014/2014-11-11/ + 之前写过一篇很长的关于异形的东西,不知道为什么发不出来。可能有关键字被和谐了,但我又找不到,后来草稿不知道为什么也没有了,明明保存了的。没有了我也不想再重新写一遍了。 说一下最近看过的一些有点意思的电影。

+

先说印象比较深的,Atonement(中文名《赎罪》),其实这电影发展过程一般吧,主要的亮点是在结局,当然我也不能说太多,否则剧透了就很没意思了。当时我是很惊讶的,一部电影成功真的很靠原著小说以及剧本。这是一个悲剧,西方,二战时期,题材为爱情。另外一个亮点在于打字机的使用,作为配音是很独特的存在,当时也刚好ios有一个打字机应用(好像是靠汤姆汉克斯宣传的),所以印象比较深刻。

+

然后是李安的《色戒》,这部电影出了那么久一直没有看, 可能主要是被大多数人对它的关注点误导了,觉得看这种电影还不如去看爱情动作片。后来发现其实色戒是一部非常优秀的电影吧,只能说非常优秀。也是悲剧,中国,抗战时期,题材为爱情,谍战。其实我推荐家里人看这部电影的,比如爸妈,因为好像大家都比较喜欢看谍战剧,然而关于谍战的电影很少,也少有优秀的作品。色戒的背景内容庞大,但整部电影的视角一直限制得很小,剧情紧凑,毫不拖沓,主题突出,以小窥大,得益于李安深厚的功力。

+

后来有一段时间比较无聊,看完了《异形》系列。由于刚才说的原因,我都没兴趣多写了。反正我感触还是很深的,但电影其实拍的一般般,可能是年代的原因现在看来没什么触动。它的题材很好,期待普罗米修斯的续集。

+

说到科幻片,最近也看过一些科幻片,主要是因为之前看了阿汤哥的《明日边缘》,对科幻片的热情又高涨了。明日边缘真的是一部非常,非常,非常优秀的科幻片,我甚至觉得是我看过的最优秀的科幻片,可能有些夸张了,但就像当年看完盗梦空间以后难以抑制自己激动的心情,一定要给这部电影打满分的感觉,明日边缘也是这样的一部电影。相比于其它的外星人入侵地球的电影,明日边缘对于外星人的设想非常有意思,以至于电影也陷入了一种很特别的节奏。如果仔细想想的话,它的看似狗血的结局也是非常值得推敲的。这部电影的题材是科幻,外星人,四维空间,拯救地球。美国,未来。

+

然后还看了邓肯琼斯导演的《月球》,这部电影相比明日边缘就比较文艺了,但同样有比较特别的剧情,看似科幻片,我觉得其实是一个关于人类道德的电影。另外比较特别的一点是,这是一部独角戏(参考《我是传奇》),一般来说这种电影逼格都比较高,这部电影也是如此啦。然后它除了剧情设定以外并没有其它的比较大的亮点,所以总体感觉一般吧。美国,未来,题材为科幻,克隆。

+

另外看了《机器纪元》 以后,觉得最近的科幻片都比较探讨人性啊,都不是在往科幻片应有的路子在走。这部片子说的东西还是挺有意思的,不过剧本感觉缺少冲击力,就是没什么能让人感到眼前一亮的东西,比较平淡的电影。之前上人工智能课的时候听周密说,“人类需要完成的最后一个发明就是智能机器人”,这部电影说的就是这个啦!为了让智能机器人不成为人类的最后一个发明,人类为其设定了两个原则,一是机器人不能伤害任何生物,二是机器人不能维修自己。但是智能机器人明明比人类要智能得多,又有什么技术制定的原则能限制得了智能机器人呢。美国,未来,题材为科幻,机器人。

+]]>
+ + personal + +
+ + 2015年度总结 + /2016/2015-annual-summary/ + 2015对我来说是有喜有悲的一年。我还清楚地记得十一个月前刚过完年的时候,在上班的第一天迟到了,然后收到同事哥哥姐姐们的一桌子红包的情景,然而不知不觉就已经过去这么久了。最近生活和工作上都遇到了一点瓶颈,中午无聊的时候翻看了一下这一年下来的邮件,于是就想写点东西。

+ + +

实习

2014年11月的时候我找到了大学以来的第一份正经工作(实习),岗位是前端开发,一做就是近九个月。这九个月中我从一个什么都不懂的应届生成长为了一个对社会生活有初步了解的普通人。至今当时的Leader还在拿我的语录打趣,她问我说觉得公司怎么样,答曰:“我觉得挺正规的”。可见我是有多不会说话。虽然现在也没有变得很能说,但是在大家的帮助和教导下(感激不尽),至少是有一些进步了。

+

实习阶段工作内容非常简单,只需要根据哥哥姐姐们提出的需求去完成一系列相应的任务即可。通常都是一些编写或者维护Web页面以及通用控件的任务。作为一个实习生,很多情况下我都不用对所做的东西负很多的责任,质量有大师傅们把关着呢,就算一时间做得不好也不会被责怪,师傅们会尽心尽力地进行指导。所以现在回想起来,这真是一段无忧无虑的时光。工作上没有太多的压力,不用接触业务,根据详细的需求去实现不会太难的东西,同时也有着大量学习与实践的时间。

+
+

Hi Kairui, Jessie,

+

Welcome to join UCD as intern.

+
+

这是我在公司收到的第一封邮件,来自当时的Leader,这其中还有一个故事。一开始我并没有给公司投递简历,而是女朋友投了,并且收到了面试通知,于是我就抱着看看的心态,跟着一起去了。后来觉得“这间公司好像还挺正规”,于是也就提出了希望试一试的请求。好在HR比较宽容,给了我一次机会,也让我做了笔试与面试。几天后就有了上面的结果,我们都被录取了。但是女朋友她同时也面试了另一间公司的实习岗,也通过了,她权衡利弊以后觉得那个地方要更适合自己,所以最后我们只好各自入职。为这件事我郁闷了好一段时间。

+

毕设

因为全职实习的缘故,大学最后的半年时间我基本上都没有在学校度过,很久很久都没有听到过上课的钟声,也懒得跟任课老师请假。一开始还能够从学校宿舍早出晚归,后来直接就住在公司旁边了,每周回那么一两次学校。因此,我只想选一个最简单的,最不动脑的毕设,能过就行,实在是没有很多时间能投入进去。学校的毕设选题网站同样也是一个毕设,没有做完善的安全防卫,因此为了达到以上目的我还不择手段地写了一段浏览器脚本来抢题,大概就是每隔多少毫秒就给服务器发送“我要选这道题”的消息。后来比较后悔的事情是我没有及时地把这个主意分享给室友(因为是在开放选题前一两个小时的时间里想到的,而且我也不确定是不是真的能用),不然我们就都可以选到自己喜欢的题目了,而不是周末回去后发现大家都怨声载道的。

+

我的毕设内容是做一个供师生发布和选择实验课题的网站平台,实在是太简单了,导师说这种题目做出花来也就是七八十分。因此为了增加点技术含量,我使用了当时流行度还不是特别高的技术来制作它。整个过程当然是真金白银,这两个月期间我还直接或者间接地帮助了一些认识或者不认识的同学完成了他们的毕设,说请我吃饭的人最后都不知道哪里去了。后来论文得了八十六分,差点就能评优。

+

做完毕设以后,大学本科生涯就算结束了。曾经的舍友同学各奔东西。但是我觉得还不够过瘾,其实我还想读研。然而我没有得到保研的机会。也许工作一两年以后,我会有兴趣再去考一次研。

+

培训

大概在实习的第六个月的时候,我终于通过了公司的转正考核并且拿到OFFER,这段时间非常开心,一块大石终于落下,不至于在毕业以后回家打游戏。等待我的是毕业后长达三个月的入职培训。

+

培训的主要内容是Java以及Java Web,也是公司一贯以来的技术主流,然而并不是我非常熟悉的东西。除了在大二还是大三的时候选过一门不知所谓的Java课程,以及可以用Java来秒杀一些ACM中简单的大数题目以外,我对它几乎是没有任何印象。公司的培训有它的淘汰机制,所以我也怕被淘汰。所幸后来顺利地过去了。

+

培训进行的节奏非常快,基本是每周都要写一个对我们来说较大的Project,不睡觉也得写完。某天老师还说了一句听起来很污的话,“周六你们一起睡嘛”。然而我觉得现在好像也只有在这么紧张的节奏中才能专注于一件事情了。公司给我们提供午餐,目的就是让我们能够从早到晚呆在十七楼顶,就只专注一件事情。这段时间好像没有谁敢请假,好像一请假就要脱不知道多少节的样子。

+

在这段时间里面我认识了很多新同事,印象最深的一位同学是来自政法大学的研究生,专业是刑法。一份计算机的题目我做六十分,他能做一百分。他对知识的热情,真的让我有些无地自容。当然也还有其它非常优秀的同学,和这样的同学在一起参加培训,除了真的能学到东西以外,也能够鞭策自己。当然也还有厉害的老师,来自中科院的老师们,感觉在他们在Java世界的造诣已经成仙了。

+

培训结束后不久,我经历了人生的第一次手术,同时我觉得也会是整个人生历程中较为痛苦的一次。具体过程就不提了,前前后后已经经历了近四个月,至今仍未痊愈。这段时间也是一整年中情绪最为低落的时间,我有点不知道自己在做什么以及该做什么。因为在病急时选择了一间非医保定点医院,医保不给报销,到现在不仅我自己没有赚到钱不说,还花掉了妈妈的大把积蓄。妈妈为了照顾我把工作也辞掉了,她自己心力交瘁看起来也老了不少。我是真的觉得很惭愧啊,可是又不知道自己能做些什么。做一份工作赚着微薄的工资,还得了这么个折磨人的病,总是不痊愈也不知道什么时候是个头,妈妈为了我再苦再累也不会说,每当想起都觉得好忧伤。

+

工作

因为疾病的关系,我在培训结束正式入职后不久就请了一个多月的病假。这对我来说又是一个打击。本来应该是迅速熟悉业务进入工作状态的时间,我却不得不缺席。回来以后发现当时的同事们都已经轻车熟路,而自己依然是一问三不知。后来我被调离了原项目组,进入到了一个新的环境,重新开始一个新的项目。好在新项目使用的技术相对来说是我比较熟悉的,而且没有涉及到大量的业务逻辑,情况才慢慢好起来。

+

一开始做起来觉得没什么问题,然而功能越加越多,慢慢就觉得有些力不从心,需要重构。重构的前提是要对相关技术有相当的熟悉程度与足够的项目结构经验,然而我发现自己对稍复杂的项目就已经没有了头绪。现在看来除了写代码以外,我要学的东西真的还有很多很多。很多事情我虽然知道怎样做是不好或者不够好的,但是我却不知道应该如何把它们变得更好。

+

因为工作的缘故,能够回家的时间越来越少了。从前还有寒暑假,现在也没有了。本来我是很喜欢冬天的,但是这个冬天过得一点都不安分。时不时就成了南风天,又下雨又潮湿,气温还异常高,以至于在珠海一月份穿短袖也不是一件过分的事。

+

以上总结。

+]]>
+ + personal + +
+ + 2016-08-25 + /2016/2016-08-25/ + 今天下班路上看到一对情侣,一路欢声笑语,走在没有信号灯的斑马线上,也要往马路上面站,车流离他们不到十米吧,女生不时地尝试走向对面又马上退回,脸上带着无忧无虑笑容,男友也丝毫没有阻止的意思。

+

我在后面看着,有种奇怪的感觉。觉得,这世界上也许有些人就是注定要死得早些吧。不过,又有点希望自己也能做一个这样的人。

+]]>
+ + personal + +
+ + 2016 + /2017/2016/ + 刚刚过完了一个非常无聊的元旦。虽然像是很快过了一年的样子,但是又感觉过了很久。因为我已经想不起来年初我在做些什么了。

+

这次主要说些职业生涯和工作上的东西。其它的,想到再说。

+ + +

工作

真的要翻看以前写的每周 report 才能想起来自己上半年做了些什么,不过也都是些没多大意思的项目。比较可喜的是,目前我已经从对内支持工具开发转为了对外产品开发。也算是公司对我的一种肯定吧。

+

公司下半年好像一直在寻找传统商业模式下的新方向,也尝试了一些新的项目,我基本上都有参与。开发任务基本都可以按时保质完成,然而,目前来说,有些事情于我而言还是比较困难的。比如说思考行业未来,探索可能性等等。说到底,我对公司的业务方向(航运)还是不太感兴趣(或者说完全不感兴趣),再加上本身就不够了解,因此这种事情都是一头雾水。

+

我们干的是互联网的活,实际却跟这互联网世界相差甚远。这也是我不太满意的地方。在互联网公司工作,做的事情往往比较贴近生活,自己也能有些想法,然而,在这样一家业务深度极大的公司则不然。有种感觉就是,做什么都完全看 PM 怎么说,做完了也不清楚它的价值在哪里,工作就变成了纯粹的开发开发开发。一个项目开发完了紧接着就是下一个项目。

+

这样子,对公司来说也许足够了,但对个人来说感觉不是一种好的发展模式。不过,最大的问题还是在于兴趣。对业务不感兴趣。

+

毕业以来,加上实习期,在公司呆了也两年多了,感觉自己在渐渐成长。但,长久呆在一个地方,思维必会僵化。有时候也会想,是不是该换个环境,去看看更大的世界。

+

学习

因为工作关系,主攻 Web 开发,因此,2016 年,我认为自己最大的进步是在 ES6 的学习以及组件化开发思想的学习上。

+

上半年我还是在用传统的 NG 1.x 做前端开发的,Node 也是写 ES5,那时候我还觉得,Angular 是世界上最好的前端框架,ES5 已经足够优秀,完全没有必要再去学新的东西了,一套技术可以用到死。然而拜公司强制所赐,我在一次新项目里面不得不使用 Vue 这个更轻量的东西。

+

说起来,这事也得感谢公司,人还是太容易满足。就像我现在也开始觉得,Vue 是世界上最优雅的东西,没有必要再用其它的一样。

+

不过话说回来,我这么喜欢 Vue,以至于远胜谷歌大爹的 Angular,不是空穴来风,最主要的原因是:Vue-loader 实在是非常优秀,将组件化思想以及 ES6 的使用均发挥到了目前看来较高的程度。组件化的开发体验是非常优秀的,这点真的是亲身用过才会有所感悟。

+

此外,我个人觉得,有所加分的是,Vue 是一个单人项目:只有一个作者,维护者也是作者本人。我认为,一个能力超强的人,远胜五个能力优秀的人。这也是 Vue 能够保持优雅的原因之一。

+

而 ES6,作为下一代的 JavaScript,虽然看起来大多是语法糖,但是不得不说用还是挺好用。代码量缩减了不少,莫名其妙的问题也少了许多。我个人还是比较希望 Async / Await 早日进入标准,早日实现,这样 JS 也能真正变成优雅且好用的编程语言。如此一来,JavaScript 的天下,必将更大了。

+

生活

今年就年底一件大事:买房。把自己的积蓄花光了,父母的积蓄也花了不少,如今过上了吃土的日子。从前都是别人向我借钱,今天也到我跟别人借钱度日的时候。

+

不过,压力其实不大,就是这俩月会较为难过,能不能活下去就看公司按不按时发工资。

+

毕业工作一年,我好像也变成了一个无趣的人。曾经日夜鏖战天梯,如今也是要等死了才能醒悟自己身上还有一枚芒果。不过不得不承认的是,很多事情我确实没有天赋,再怎么做,也不过是熟练,熟悉。

+

前两天元旦假期的时候,过于无聊,把几件有些价值的装备卖了,到 steam 去买了几款好评游戏。100M 的网速下载是快,但我进入游戏后放弃得更快。提不起劲。

+

有时候也会觉得好笑,我从小就饱受垃圾电脑和网络所带来的痛苦,简直是苦不堪言。可为什么我到今天,进入游戏第一件事依然是把所有配置调到最低呢?

+

展望

新的一年,首先希望自己可以继续学习,更甚者,换个紧张些的工作环境去学习。我对目前自己的评价是:基础还行,但知识面不够广。像 React / RN 等东西,我将标注为上半年重点学习对象。虽然 JSX 脱离了历史的进程,我不喜欢,但用的人越来越多,说明它自有好处,因此有必要深入了解一番。

+

学到的东西多了起来,我也会想自己做点什么。目前来说,Vue 的组件库太少,且质量也非常一般,是阻碍其被广泛使用的一大因素,尤其是 Vue2,因此,我有较大的冲动去开发一个基于 Boostrap 风格或 Material 风格的 Vue2 组件库。来弥补自己对开源社区贡献为 0 的尴尬。

+

CSS3 某些前沿属性是我目前的基础短板,Codepen 上面很多酷炫的作品如此亮瞎,希望可以找个时间好好学习一番。

+

希望可以多些涉及移动端开发。不要再在 Web 领域固步自封(当然,也许是通过 RN 或者其它基于 JS 的工具)。

+

希望新的一年可以赚到更多的钱,还起房贷来没那么大压力,欠父母的钱早日还清。如果还有余钱,考虑买个代步工具。

+]]>
+ + personal + +
+ + 2017 + /2017/2017/ + 今天是 2017 年的最后一个(法定)工作日。做个简单的总结。

+

先对比一下去年的自己与目标:

+
    +
  • 关于工作,年初就换了。现在到了一个游戏公司(西山居)上班。对于去年吐槽最多的「业务」问题来说,如今算是彻底解决了。
  • +
  • 关于学习,感觉自己从某些方面来说,是有一点进步的。
  • +
  • 关于生活,今年入了两台主要设备,一台 RMBP,以及一台游戏主机。感觉都很值。
  • +
+

大概就这些。

+ + +

职业

现在回头想想,自己通过公交车上下班已经快一年了,每天两个小时在路上,时间真的过得很快(好在公司马上要搬)。今年全勤,加上加班时间,略有困倦。

+

今年:

+
    +
  • Vue 基本上已经轻车熟路;
  • +
  • 接触了 Electron 开发框架,用它负责并完成了公司的一个 H5 + Canvas 视频工具客户端项目的开发;
  • +
  • 接触了 React 全家桶以及 ReactNative,目前开发时间也有一月余了,略知一二,找个时间写个系统性的学习与使用总结(算是个人技术栈上的一个突破);
  • +
  • 前端工具链有了更加深入的理解;
  • +
  • 等等……
  • +
+

此外,工作之余,还拥有了两个(真正意义上的)开源项目:

+ +

第一个,组件库,是我一直想做的,今年终于算是略有小成了。从一开始非常简陋的东西,变成了现在这副模样。在用户数量逐渐增加的同时,也得到了越来越多的反馈与支持。同时,在设计与改善的过程中,我也从其它的开源项目中得到了很多启发(如 Element / iView / Bootstrap-Vue 等)。

+

第二个,将 Markdown 转换为 Vue 组件的 Webpack loader,是为了解决组件库项目的一个问题(文档撰写)而产生的。这个功能很普通,但它能帮我(或者跟我有类似需求的人)解决一个突出的问题。详情请看:Better Documents

+

开源项目的乐趣在于,开发者能够实实在在地从社区获得一些「认同感」。也就是,有人真的在使用我的项目。他们会给我提改进意见,给我贡献代码,还会对我说「谢谢」。虽然我做的事情根本微不足道,但每天看到项目的星星在一个个地变多,还是让我非常开心的事情。

+

生活

今年去了两次女朋友的家(五一、十一),见了家长。第二次还是跟自己这边的亲戚们一起去的,很多人。现在双方应该说是「认识了」。

+

如今很多亲戚经常会问我的事情是:结婚了没,什么时候结婚,怎么还不结婚。也许这就是顺理成章的事情,很正常。但我并没有觉得目前有这个必要(静纯应该也是这么认为的吧),现在这样不就挺好的吗。房子买了(虽然还未落定),工作正常,生活富足,过得开心,可以说现在我真的是无欲无求。所以我根本没想过「要结婚」这件事情。

+

有一点遗憾的是,公司原定的「塞班岛」旅行,后来取消了。本来我跟静纯纠结了好久才决定要去,很多东西都在准备了,结果因为工作原因不得不取消。后来补数的旅行线路,我觉得都没有这个好了,所以就干脆就没有再参与。

+

公司即将搬迁到唐家,这样一来我的生活圈子就又回到唐家了,而且还变得比一年前还更小了。不知道到时候我还能不能有买车的动力。我觉得车还是挺有必要的,没有它,不要说一线城市,在珠海这个二线城市都有点力不从心,去哪里都要担心回家的事情。而且,回家也不方便,东西带不了多,不能说走就走。

+

今年又有一些朋友从别的城市来看我了,我希望等到明年工作闲下来的时候,我也能去看看朋友。

+

老妈今年跟亲友到处游玩,我也觉得挺好的,有机会的时候就是要去玩。可惜的是我没有时间参与。前段时间比较冷,不想呆在房子里被冻僵,就买了个取暖器,后来想到家里没有,就给老妈也买了个。其实我为家里做过的事情真的很少,从现在起我要养成这样的习惯。

+

对了,如果年底发的奖金足够多,那么我今年就能还清借父母的首付了!

+

爱好

今年买的最值的一样东西就是 MacBook Pro (Retina, 13-inch, Early 2015),基本替代了我办公室的台式电脑。原因有几:

+
    +
  • 对于非 MS 系开发者来说,比 Windows 简单、易用、好用;
  • +
  • ReactNative 开发时可以进行 Emulator 调试;
  • +
  • 软件齐全,且非常「干净」;
  • +
  • 速度飞快;
  • +
  • 屏幕好看。
  • +
+

13 寸也是我非常喜欢的尺寸,极度轻便,搭配 Retina 屏幕,不要太完美。

+

此外,自己动手组装了一台游戏主机,花了大概 6k,自从那时开始,打开游戏第一件事,分辨率 MAX,特效 MAX,毫无压力(当然是对我的垃圾 1080P 来说)。再此外,买了一个 XBOX ONE 手柄。

+

有了设备,自然就有软件。在有限的游玩时间内,今年我最喜欢的两个游戏:

+
    +
  • DARK SOULS™ III「黑暗之魂 3」
  • +
  • NieR:Automata™「尼尔:机械纪元」
  • +
+

之后我会单独写两篇文来记录感想。

+

下半年开始就没怎么玩 DOTA 了,一是工作繁忙,二是状态有点迷,跟不上节奏。

+

接下来

希望可以:

+
    +
  • 大家都身体健康
  • +
  • 在现有基础上继续钻研 React / ReactNative,至少达到「熟练」的程度
  • +
  • ReactNative 做多了以后,少不了要接触原生开发,希望至少可以「入门」
  • +
  • 学习 GO 技术栈,希望至少可以「入门」
  • +
  • 玩(买)更多的游戏!
  • +
+]]>
+ + personal + +
+ + 2021 春节 + /2021/2021-spring-festival/ + 今年疫情原因,本来不是很想回家过年的,想着工作累了,在珠海(中山)做几天废人也不错。但是现在回想起来,虽然家里比较小也比较无聊,逢年过节还是应该回家看看。

+ + +

说是回家过年,其实除了:

+
    +
  1. 除夕夜聚餐一次
  2. +
  3. 初一早茶聚餐一次
  4. +
  5. 初二集体出游一次
  6. +
+

以外,并没有其它特别的活动。基本就是宅在家里吃饭、睡觉、看电视。我一直觉得在广东过年比较冷清,今年好像又更冷清了一点。

+

妈妈老了,马上要 60 岁了。有两个瞬间让我特别有感触:

+
    +
  1. 回家的时候,看到妈妈在洗手间挂了一个四连排的洗漱架,上面还特意写上了名字;
  2. +
  3. 临走时跟家人在一桌吃饭,我爸给我拿了一双金属筷子,妈妈默默地给换成了木头筷子。我就说,我用勺子就行了,不需要筷子,她又笑了一下放了回去。
  4. +
+

第一件事,家虽然又小又老旧,但是家就是家。妈妈始终希望我过年能够回家,并且也做了很多准备。如果因为私欲就不想回家,未免太伤人了些。

+

第二件事,是因为我曾经说过我不喜欢金属筷子,它如果碰到我补过牙的地方会产生电流的感觉。但是她可能又忘了我现在不需要筷子。

+

我总感觉老了的人能记住特别多的细节,我喜欢什么,不喜欢什么,都记得特别清楚。而且会变得特别小心,我曾经说过不喜欢的东西,或者为此发过火的东西,就感觉以后再也没有碰到了。就好像以前妈妈很喜欢敲我的房间门叫我吃饭,我就有一次特别生气,说就让我好好睡一下吧,之类的。从那以后,只要我关着房门,妈妈就再也没有叫过我。

+

以前小时候孩子是弱势群体,大人总是表现得很强势。现在大人老了,也即将变为弱势群体,轮到孩子来照顾大人了。

+

另外,这次回家还得知了一些弟妹的近况,具体就不说了,但是,有一种我这一代已经成为了被拍在岸上的人的感觉,已经跟不上时代的潮流了。

+]]>
+ + personal + +
+ + 2022/05/20 + /2022/2022-05-20/ + 很久没更新了,最近有点懒。也没什么想写的。

+

在新公司(金山办公)上班一年了,工作量并不大,但是干得感觉比之前更累了。主要可能有两个原因:一是之前的负责人在我入职不久后就走了,结果我又变成了负责人(离开西山居的原因之一就是不想做不责人)。二是,做的项目比较偏探索向,不是常规的业务项目,整天要思考这个那个,很累。有时候(经常)也会想放弃。不过看在去年刚来半年就给我 3.75 的份上,还是再干一段时间吧。

+

最近理财跌得不要不要的,3 个月已经把之前 3 年的收益都跌完了。好在我买的不是很多。现在也不怎么看了。

+

可能是因为理财亏得太多了,我开始到各种平台薅羊毛,然后又开始把梦幻西游捡起来玩了。家产全部变卖以后转到了朋友所在的区,每天就当作一个打发时间的消遣,分散一下亏钱的注意力。

+]]>
+ + personal + +
+ + jQuery 写的 2048 小游戏 + /2015/2048-game-base-on-jquery/ + 2048

+

Game: http://wxsms.github.io/jquery-2048/

+

Code:https://github.com/wxsms/jquery-2048

+

几年前还在学校的时候刚学 JS/jQuery,为了找点事情练练手寻思着做点什么,当时又特别沉迷于一个叫 2048 的小游戏,于是就有了这么个东西。刚做出来的时候开心了好一阵子,现在回头看代码觉得简直惨不忍睹,根本不像是一个学过算法的人写出来的,字里行间充斥的都是简单与暴力。那时候主要是为了学一门新语言就没有在意这些东西。以后有时间再来优化一下。 在开始的时候是有记分,重启,排行榜一票功能的,现在为了纯粹一点就把垃圾都去掉了。代码过于恶臭就不说了。

+]]>
+ + jquery + javascript + +
+ + MongoDB Aggregate 4 例 + /2021/4-examples-of-mongodb-aggregate/ + 有数据格式如下:

+
{
"id": "745",
"knownName": {
"en": "A. Michael Spence",
"se": "A. Michael Spence"
},
"familyName": {
// 结构同上,下同
// ..
},
"orgName": {
// orgName 当获奖者为组织时出现
// ..
},
"gender": "male",
"nobelPrizes": [
{
"awardYear": "2001",
// ...
"affiliations": [
{
"name": {
"en": "Stanford University",
// ...
},
"city": {
// ...
},
"country": {
// ...
},
// ...
}
]
}
]
}
+ +

想要实现:

+
    +
  1. 查找名为 CERNaffiliation 的所在国家
  2. +
  3. 查找获奖次数大于等于 5 次的 familyName
  4. +
  5. 查找 University of California 的不同所在位置总数
  6. +
  7. 查找至少一个诺贝尔奖授予组织而非个人的年份总数
  8. +
+ + +

查找名为 CERN 的 affiliation 的所在国家

需要注意的是 affiliationsnobelPrizes 下的数组(嵌套数组结构),因此需要分两次展开:

+
db.laureates.aggregate(
[
// 展开 nobelPrizes
{ $unwind: '$nobelPrizes' },
// 展开 nobelPrizes 下面的 affiliations
{ $unwind: '$nobelPrizes.affiliations' },
// 找到名为 CERN 的记录
{ $match: { 'nobelPrizes.affiliations.name.en': 'CERN' } },
// 将结果限制为 1 条
{ $limit: 1 },
// 映射输出
{ $project: { '_id': 0, 'country': '$nobelPrizes.affiliations.country.en' } }
]
);

// output:
// { "country" : "Switzerland" }
+ +

查找获奖次数大于等于 5 次的 familyName

这里需要用到 $group 操作,根据 familyName 来进行分组,并且需要提前计算好每条记录所获奖的数量:

+
db.laureates.aggregate(
[
// 映射每条记录的 nobelPrizes 长度为 nobelPrizesLength,familyName.en 为 familyName
{ $project: { nobelPrizesLength: { $size: "$nobelPrizes" }, familyName: "$familyName.en" } },
// 找到 familyName 存在的记录(非组织获奖)
{ $match: { familyName: { $exists: !0, $ne: null } } },
// 以 familyName 为依据进行分组,并累加 nobelPrizesLength 作为 count
{ $group: { _id: "$familyName", count: { $sum: "$nobelPrizesLength" }, familyName: { $first: "$familyName" } } },
// 找到 count 大于等于 5 的记录
{ $match: { count: { $gte: 5 } } },
// 映射输出
{ $project: { familyName: "$familyName", _id: 0 } }
]
);

// output:
// { "familyName" : "Smith" }
// { "familyName" : "Wilson" }
+ +

查找 University of California 的不同所在位置总数

一个相比上个查询更简单的 group 查询:

+
db.laureates.aggregate(
[
// 展开 nobelPrizes
{ $unwind: "$nobelPrizes" },
// 展开 nobelPrizes 下面的 affiliations
{ $unwind: "$nobelPrizes.affiliations" },
// 找到名为 University of California 的记录
{ $match: { "nobelPrizes.affiliations.name.en": "University of California" } },
// 以 city 名作为依据分组
{ $group: { _id: "$nobelPrizes.affiliations.city.en" } },
// 输出分组后的总记录数
{ $count: "locations" }
]
);

// output:
// { "locations" : 6 }
+ +

查找至少一个诺贝尔奖授予组织而非个人的年份总数

这里注意 group 之前先把授予个人的记录筛除掉:

+
db.laureates.aggregate(
[
// 找到 orgName 存在的记录(组织获奖)
{ $match: { orgName: { $exists: !0, $ne: null } } },
// 展开 nobelPrizes
{ $unwind: "$nobelPrizes" },
// 以获奖年份分组
{ $group: { _id: "$nobelPrizes.awardYear" } },
// 输出分组后的总记录数
{ $count: "years" }
]
);

// output:
// { "years" : 26 }
]]>
+ + mongodb + +
+ + 记一次艰难的 Debug + /2020/a-difficult-debug-note/ + 这是一次关于本博客的 Debug 经历,过程非常曲折。关键词:Vue / SSR / 错配

+

不知道从哪篇博文开始,博客在直接从内页打开时,或者在内页刷新浏览器时,会报以下错误:

+
app.73b8bd4d.js:8
DOMException: Failed to execute 'appendChild' on 'Node':
This node type does not support this method.
+ +

该错误:

+
    +
  1. 只会在 build 模式出现;
  2. +
  3. 只会在发布上 GitHub Pages 后出现;
  4. +
  5. 只会在某些博文中出现;
  6. +
  7. 只会在直接从链接进入该博文,或者在该博文页面刷新时出现。
  8. +
+

该错误带来的影响,会导致页面上的所有 JavaScript 功能全部失效,具体来说是与 Vue.js 相关的功能。如:导航链接(因为使用了 Vue-Router),评论框,一些依赖于 Vue.js 的 VuePress 插件,等等。

+

screenshot

+ + +

主题?

初次看到这个报错时,我的第一想法是:是不是我不经意间在哪里调用了 appendChild 这个方法导致的?因为我的博客使用了我自己发布的主题,有可能是我哪个地方写得不好导致了这个问题。

+

但是,再三检查主题代码 vuepress-theme-mini 后,我并没有发现任何可疑之处。

+

实际上我也不太相信这个错误是由我的主题导致的,因为查看错误抛出处的源码时发现这些代码都不像是我写的。

+

插件?

另一个可疑之处,则是插件。具体来说,是以下 3 个插件:

+
    +
  1. valine 用于展示评论
  2. +
  3. vuepress-plugin-flowchart 用于绘制流程图
  4. +
  5. vuepress-plugin-right-anchor 用于显示浮动的目录
  6. +
+

我 Google 了很多次,最后在 vuepress/issues/1692 里,有一句话引起了我的注意:

+
+

Just a hint: Another common mistake is to use dynamic vue components that should render client side but forget to ignore them in static builds… 99% of those issues in our projects were missing <ClientOnly>. So try:

+
<ClientOnly>
<NonSSRFriendlyComponent/>
</ClientOnly>
+
+

这个 99% 的表述让我不得不引起重视,审视上述插件的代码以后,发现其确实没有加入 <ClientOnly>,难道这就是问题所在?

+

然而经过实践,并不行。再三确认所有 fork 版本里面已经正确使用了 <ClientOnly>,错误依然存在。

+

该 issue 里面提到的另一点:

+
+

I had the same problem, and then I found out it had to do with document

+

enhanceApp.js

+
if (typeof window !== 'undefined') { // add this line
window.document.xxx
}
+
+

我同样再三确认已经修正,依旧不能解决错误。

+

YAML?

以上两处都无法找到问题,我有点迷惘了。因此开始漫无目的地寻找出错页面的共同点,以试图定位问题。最后,我发现:出现错误的页面貌似大多数都有 yaml 高亮的代码块。当然只是大多数,依然存在其它个例。

+

我尝试将 yaml 格式去除,即将:

+
```yaml
# ...
```
+ +

写为:

+
```
# ...
```
+ +

然后部署上线,错误神奇地消失了!

+

但是,我还没有开心过一分钟,立即发现:错误是消失了,但其带来的副作用依然存在:Vue.js 依然处于崩溃状态,任何功能都无法使用

+

这让我感到很沮丧:这种 SPA 带来的体验还不如最原始的 <a> 标签。

+

因此,我尝试对主题做出了一些小改动,将导航栏的跳转链接全部换成了普通的 <a> 标签。这件事情如果做到这一步,在某种层面上来说也算是解决了吧。除了以下一些问题持续地让我感到难受:

+
    +
  1. 不能使用 yaml 高亮;
  2. +
  3. 不能使用 Vue-Router
  4. +
  5. 无法追根朔源的痛苦。
  6. +
+

路径?

虽然从使用性的层面上来说问题算是解决了,但是我还是很在意以上几点。因此仍在持续地探究问题根源。

+

后来,我在 netlify 上看到了这样一个帖子:VuePress deployment on Netlify succeeds, but experience errors when reloading specific pages

+

作者所提到的问题基本跟我一模一样:

+
+

Hi, I have a VuePress generated static website deployed on Netlify, I am currently running into errors like:

+
Failed to execute 'appendChild' on 'Node': This node type does not support this method. only when reloading inner pages (i.e, not homepage.).
+ +

I have searched for similar issues on GitHub and it seems that it is related to Vue’s failing hydration as described here: nuxt.js/issues/1552, and here: vuepress/issues/1692.

+

However, I didn’t come across these issues when I’m in my local environment (both in dev mode and in production mode), I only run into these issues when I deploy my site to Netlify.

+

Confusing…

+
+

这简直有一种抓住了救命稻草的感觉!激动地往下翻,作者还说他找到了问题所在:

+
+

New update! So I converted all of my file names and directory names to lowercase and it actually solved the problem!

+
+

我立刻开始检查出问题的页面是否存在类似问题。然而,很遗憾,我的页面所有 url 都是小写的,不存在任何大写字符。

+

我又想,是不是有任何加载的资源里面出现了大写字符,导致了加载失败,因而产生错误?

+

结果再次让人感到遗憾:从我的域名中加载的所有资源,均没有出现大写字符的情况,更没有任何一个资源加载失败。

+

调查再次陷入僵局。

+

SSR?

Google 之余,偶然看到了这样一个 issue: vue/issues/6874,作者提到,当 SSR 发生「错配」时,Vue.js 应用会出现类似「宕机」的表现。他希望可以通过参数控制这个行为,即当 SSR 出现「错配」时,允许用户选择自己想要的行为。如忽略 SSR 的结果并以客户端渲染结果覆盖,或仅提出 warning 而不是整个挂掉。

+

而 yyx 则认为,只要不是白屏 (white screen),则都能接受。

+

此时我不想深入探讨这个行为。我只想知道,我的问题到底是不是跟这个有关?

+

VuePress 有一个插件 vuepress-plugin-dehydrate,可以实现禁用所有 JavaScript,将页面作为纯静态 HTML 使用。在测试中我发现,禁用「客户端接管」以后页面确实没有问题了。但我觉得这不是废话吗?这个实验完全没有任何意义啊。

+

后面,我想到一个办法,即通过创建一个全新的仓库,不加任何主题与插件,看看 yaml 高亮是否会出现问题。

+

复现

我在 Github 新建了一个仓库,用最少的配置搭建出来了一个 VuePress 程序。尝试:

+
    +
  1. 添加一个包含 yaml 高亮的页面,没有出现问题
  2. +
  3. 将出现问题的博文整篇添加进去,没有出现问题
  4. +
  5. 将博客的所有主题、插件、博文均导入到新仓库中,依然没有出现问题
  6. +
+

走到这一步我头都大了,好像只剩下最后一个区别了,即自定义域名。要是再不能复现,干脆我把博客迁移过来算了。

+

然而,最出乎意料的是,在添加自定义域名,并通过域名访问后,问题复现了!后来,我逐步将所有东西复原到步骤 1 的状态,即最简 VuePress + 一个 yaml 高亮的页面,问题持续复现。

+

到这里,问题就很明朗了:

+

这就是 Cloudflare 的锅!(我的域名托管在 Cf)

+

解决

最终定位到问题以后,解决似乎变得顺利成章了起来。我将 Google 关键词换成了 Cloudflare + VuePress,没有发现有价值的信息。再换成 Cloudflare + Vue + SSR,找到了这篇博文:Cloudflare and Vue SSR; Client Hydration Failures,里面详细地描述了作者遇到的问题(基本跟我一致)以及解决思路。

+

按照他的说法,他的应用之所以出现这个问题,是因为 Cloudflare 启用了一种叫 AutoMin 的优化,会自动对静态资源 (JavaScript / CSS / HTML) 再做进一步的压缩。然而 HTML 中被去掉了的 <!-- --> 注释则是问题的关键所在:这是 v-if 节点用来成功挂载的重要组成部分。

+

至于如何发现,由于错误提示基本没有调试价值,尝试了各种办法后,最终通过将本地编译的静态 HTML 与服务器上面的 HTML 进行逐行比对,最终发现区别。

+

知道了这点我立马就打开 Cloudflare 控制台试图关闭该配置,但经过一番寻找后发现,该配置从来就没有打开过!

+

不过没关系,既然如此,那我也来对比一下。最终得到的有意义的区别,我本地编译的版本是:

+
ssh user@host
+ +

Cloudflare 返回的版本是:

+
ssh <a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="b6c3c5d3c4f6ded9c5c2">[email&#160;protected]</a>
+ +

西巴。原来它还有这个功能。淦!

+

这个功能在 CloudFlare 上叫 Email Address Obfuscation,会自动保护出现在 HTML 上的邮箱地址,相当于打码处理。但是,正是因为它,导致了 Vue.js 出现 SSR 错配,进而导致网站崩溃。

+

我把该功能关掉后,问题就消失了。

+

总结

追根朔源,问题的根本与我先前的猜想大相庭径:与主题、插件、yaml 等均无关,而是因为 HTML 中出现了类似邮件地址的文本,被 Cloudflare 转换了。

+

当然,我承认这是因为我在 SSR 方面的经验不足,才走了这么多弯路。以后再发现这种问题,我一定第一步就做比对。

+

不过,在这个过程中,我也确实感受到了 Vue.js 在 SSR 方面的一些不足(我认为的):

+
    +
  1. 错配即崩溃,Vue.js 直接放弃接管;
  2. +
  3. 错误提示过于晦涩;
  4. +
  5. 用户对于该行为没有选择权。
  6. +
+

从另一层面来说,能够最终找到问题根源并通过最简单的方式将其解决,这种感受很爽。在这个过程中我有很多理由去放弃,但我没有。对于这点我感到很开心。

+]]>
+ + javascript + vue + ssr + +
+ + 值得纪念的时刻 + /2021/a-memorable-moment/ + 昨天正式受邀(实际上是我申请的)进入了 vuejs 组织。虽然目前只是 doc team,但是我相信以后可以做更多的事情。

+

638f7ff6d334b2d7616039a3787efe6.png

+]]>
+ + personal + +
+ + 比较简单的 GitHub 加速方式 + /2021/a-simple-way-to-speed-up-github-connection/ + 在不想全局 vpn 的情况下,可以用 host 加速。

+

该方法主要利用 github.com/ineo6/hosts 的 hosts 文件,国内镜像 gitee.com/ineo6/hosts

+

方法一:手动

手动复制 hosts 的内容,并粘贴至对应操作系统的 hosts 文件内。

+

方法二:自动

    +
  1. 下载开源的 host 切换软件 SwitchHosts
  2. +
  3. 新建一条规则:
      +
    1. 方案名:随便
    2. +
    3. 类型:远程
    4. +
    5. URL 地址:https://gitee.com/ineo6/hosts/raw/master/hosts
    6. +
    7. 自动更新:随便,或 1 小时
    8. +
    +
  4. +
  5. 保存,保存后可以先手动刷新一次
  6. +
  7. 启用即可
  8. +
+]]>
+ + github + gfw + +
+ + 静态文件 Docker 镜像问题一则 + /2023/a-static-file-docker-image-issue/ + 今天想要打包一个 Docker 镜像,里面只包含一些静态的前端文件。为了使体积足够小,想到的方案是把命令全部集中在一个 RUN 上,类似这样:

+
FROM node

WORKDIR /usr/src/app
COPY . .

RUN yarn --frozen-lockfile --check-files --ignore-engines && \
yarn build && \
rm -rf node_modules
+ +

但是打包出来的镜像,死活都是 2.2G,node 镜像自身 900MB,静态文件总共才 10MB+,run container 进去查看 node_modules 也确实删掉了,百思不得其解。一度以为是 Docker 出了 bug,遂升级 Docker,但仍不能解决。

+

折腾了一下午后,尝试去掉 rm -rf node_modules,观察到打出来的镜像 2.8G,突然觉得是不是还有什么东西没删干净,然后很快就想到了 yarn 的缓存。添加 yarn cache clean 后,打出来的镜像来到 910MB。世界终于清净了。

+]]>
+ + javascript + docker + +
+ + Angular Router 学习笔记 + /2016/angular-router-note/ + 使用Angular Router可以很方便地构建SPA应用,同时它支持深度链接,支持各种浏览器操作(前进、后退、收藏等),非常有趣。使用过类似模块就会觉得它要比传统的路由方式,比如服务端的Forward,Redirect以及一般的JavaScript Redirect等,好用得多。特别是用户体验这一块,上升了很大的档次。

+

就在不久前我还开发了一个使用iframe与jQuery的SPA项目,当时由于是老板提供的所有前端页面所以也没多想。现在学过了Angular Router真是有些不堪回首的感觉。

+ + +

传统路由方式

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Server Forward + + Server Redirect + + Client Redirect +
+ Request(s) + + 1 + + 2 + + 1 +
+ Browser URL Change + + NO + + YES + + YES +
+ Page Refresh + + YES + + YES + + YES +
+ Maintainable + + YES + + YES + + NO +
+ Browser Actions + + NO + + YES + + YES +
+
+ +

以上是一个简单的对比,从请求次数、显示URL是否变化、页面是否刷新、是否可维护、是否支持浏览器动作这5个方面进行,可以看到彼此都有一些遗憾。由于Server Forward以及Client Redirect的限制实在太大,很多情况下我们用到的都是Server Redirect,但是两次请求是硬伤。并且以上所有方式都需要强制刷新页面。Wordpress博客使用的就都是Redirect方式。

+

前端路由

Angular Router支持两种使用方式:

+
    +
  • #锚点
  • +
  • HTML5 API
  • +
+

其实在我看来很多情况下第一种较为朴素的方式已经足够用了。以下是一个简单的Demo:

+

独立页面链接:http://wxsm.space/others/examples/angular-router/

+

点击Edit/Delete/Add/Show几个链接,可以发现下面的内容发生了变化。同时地址栏的URL也相应地变了。也可以尝试前进、后退、收藏等操作(在独立页面进行)。 代码如下:

+
<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8"/>
<title>$routeProvide example</title>
</head>

<body>
<div ng-app="pathApp">

Choose your option:
<br/>
<br/>
<a href="#/Book/Edit">Edit</a> |
<a href="#/Book/Delete">Delete</a> |
<a href="#/Book/Add">Add</a> |
<a href="#/Book/Show">Show</a>

<div ng-view></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.7/angular.min.js"></script>
<script>
angular.module('pathApp', [], function ($routeProvider, $locationProvider) {
$routeProvider
.when('/Book/Edit', {
template: '<div>Edit</div>',
})
.when('/Book/Delete', {
template: '<div>Delete</div>',
})
.when('/Book/Show', {
template: '<div>Show</div>',
})
.when('/Book/Add', {
template: '<div>Add</div>',
})
.otherwise({
redirectTo: '/Book'
});
});
</script>
</body>

</html>
+ +

这个Demo为了简便使用了直接的HTML字符串来作为一个页面的内容,我们在实际使用的时候可以把它换成一个实际页面的地址,这样在点击一个链接的时候Angular Router就会异步地加载相应的页面并且填充到相应的ng-view节点中去。整个过程无需全局刷新。由此带来的好处是非常多的。我们不需要重新加载和渲染一些固定的板块(比如通常情况下一个网站的Header和Footer部分都是不会发生变化的,当然如果需要变化Angular Router也能做到),同时也可以真正地给页面切换添加一些酷炫的动画(CSS或者JS)。并且由于加载一个新页面只需要从服务器读取它的HTML内容而不需要如JS/CSS等静态文件(这些都将会在页面第一次打开的时候加载完毕),因此速度将会非常快。

+

需要注意的是,这里使用的是比较旧的AngularJS版本。在新版本中,Angular Router从AngularJS的核心代码内分离了出来,成为了一个叫做ngRoute的独立模块。我们需要同时引入AngularJS和ngRoute模块来启用Angular Router路由器。

+

关于$routeProvider与$route

这个路由功能是由Angular的一个服务提供者(service provider)实现的,它的名字就叫做$routeProvider 。Angular服务是由服务工厂创建出来的一系列单例对象,而工厂则是由服务提供者来创建。服务提供者必须实现一个$get方法,它就是该服务的工厂方法了。 当我们使用AngularJS的依赖注入给控制器注入一个服务对象的时候,Angular会使用$injector来查找相应的注入器。一旦找到了,它就会调用相应$get方法来或取服务对象的实例。有时候服务提供者在实例化服务对象之前需要其调用者提供一些参数。

+

Angular路由功能是由$routeProvider声明的,同时它也是$route服务的提供者。

+]]>
+ + angularjs + +
+ + Auto Changelog with GitLab + /2020/auto-changelog-with-gitlab/ + 上一篇博文 Integrate Renovate with GitLab 中介绍了为私有代码仓库与私有源提供依赖自动检测更新并发起 Merge Request 的方式。Renovate 可以自动通过 Release Notes 获取到版本之间的更新日志,并在 MR 中展示,这为执行合并的评审人提供了极大的便利。

+

接下来需要解决另一个问题:如何为分散在各处的私有依赖自动生成更新日志?

+ + +

工具

首先需要说明,自动生成 Changelog 的前提条件是使用 约定式提交 ,这样各类程序才能从 git 仓库的提交记录中提取出有价值的信息并加以整理归类。

+

可供选择的程序有很多,可以按需选择。这里选用的是 lob/generate-changelog

+

时机

一个合适的生成 Changelog 的时机是创建新 Tag 的时候。如果是一个 npm package,那么执行 npm version xxx 命令的时候就会自动得到一个 Tag,将其推送到远端即可。

+

也可以使用预定义的脚本:

+
"release:major": "npm version major && git push origin && git push origin --tags",
"release:minor": "npm version minor && git push origin && git push origin --tags",
"release:patch": "npm version patch && git push origin && git push origin --tags",
+ +

CI

如何驱使 GitLab 来完成 Release Note 的创建,有很多方式。

+

1. 使用 .gitlab-ci.yml

从 GitLab 13.2 开始,runner 可以使用以下镜像直接操作 Release:

+
release_job:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
rules:
# 只有当 Tag 被创建的时候才执行该任务
- if: $CI_COMMIT_TAG
script:
- echo 'running release_job'
# 使用命令行生成 Changelog
# 该命令行可以根据需求自定义
- export EXTRA_DESCRIPTION=$(changelog)
release:
name: 'Release $CI_COMMIT_TAG'
# 将得到的 Changelog 填入 description 字段
description: '$EXTRA_DESCRIPTION'
tag_name: '$CI_COMMIT_TAG'
ref: '$CI_COMMIT_TAG'
+ +

这是最简单的方式。但是由于我司的 GitLab 版本过低,不支持此操作。因此需要另外想办法。

+

2. bash script

GitLab CI 可以执行一个 bash script,因此可以利用 GitLab 提供的 API,结合一个 Access Token 向 GitLab 发起请求,最终得到 Changelog。

+

这种方式应该是大多数老版本 GitLab 所使用的。但是它存在一些我认为无法接受的问题:

+
    +
  1. 每个项目都需要有此脚本(不过这一点实际上可以通过 npx 绕过);
  2. +
  3. 每个项目的 .gitlab-ci.yml 都需要修改,这点是无法避免的(实际上方式 1 也存在此问题);
  4. +
  5. 每个项目都需要配置 Secret Token( Access Token 不可能直接暴露在代码中)。
  6. +
+

因此,我觉得这个办法不够优雅。

+

3. webhook

为了解决以上问题,我决定继续改造之前的博文 Gitlab CE Code-Review Bot 中介绍的评审机器人,让它可以

+
    +
  1. 识别 Tag 事件;
  2. +
  3. 自动拉取仓库代码;
  4. +
  5. 自动生成 Changelog;
  6. +
  7. 调用 GitLab API 完成 Release Note 的创建。
  8. +
+

首先在入口处加多一个事件监听:

+
module.exports = async (ctx) => {
try {
const { object_kind, object_attributes } = ctx.request.body

// ...
} else if (object_kind === 'tag_push') {
// tag 事件
await tag(ctx)
}
// ...
} catch (e) {
console.error(e)
}
}
+ +

GitLab 并没有区分 Tag 创建与删除的事件,因此需要通过代码判断:

+
const { after, ref, project_id, project: { git_http_url } } = ctx.request.body
if (after === '0000000000000000000000000000000000000000') {
// 该事件是 tag 删除事件,不作处理
return
}
+ +

使用 simple-git 来拉取 Git 仓库,注意这里需要使用 oauth2:Access Token 来完成授权:

+
const simpleGit = require('simple-git')
const git = simpleGit()

await git.clone(git_http_url.replace('https://', `https://oauth2:${process.env.GITLAB_BOT_ACCESS_TOKEN}@`), projectPath)
+ +

生成 Changelog:

+
const Changelog = require('generate-changelog')
const simpleGit = require('simple-git')

/**
* 为 projectPath 的 tag 生成 Changelog
* @param projectPath
* @param tag
* @returns {Promise<String|null>}
*/
async function generateChangelog (projectPath, tag) {
// 旧的当前路径
const oldPath = process.cwd()
try {
// 生成之前先要切换路径
process.chdir(projectPath)
const git = simpleGit()

// 获取 Git 仓库下所有的 Tags
const tagsString = await git.raw(['for-each-ref', '--sort=-creatordate', '--format', '%(refname)', 'refs/tags'])
const tags = tagsString.trim().split(/\s/)

for (let i = 0; i < tags.length - 1; ++i) {
if (tags[i] !== tag) {
// 循环找到目标 Tag
continue
}
if (!tags[i] || !tags[i + 1]) {
// 第一个 Tag(往往)不需要 Changelog
break
}
// 找到 Tag 的哈希值
const hash0 = (await git.raw(['show-ref', '-s', tags[i]])).trim()
const hash1 = (await git.raw(['show-ref', '-s', tags[i + 1]])).trim()
// 使用哈希值范围来生成 Changelog
// 为什么不直接使用 Tag:
// 因为 Tag 中如果包含了某些特殊字符串,会造成无法识别问题
return await Changelog.generate({ tag: `${hash0}...${hash1}` })
}
} catch (e) {
console.error(e)
return null
} finally {
// 任务结束后将当前路径切换回原来的
process.chdir(oldPath)
}
}
+ +

最后,使用 GitLab API 将得到的 Changelog 更新上去即可:

+
/**
* 为 Tag 增加 Release note
* @param projectId
* @param tagName
* @param body
* @returns {IDBRequest<IDBValidKey> | Promise<void>}
*/
function addReleaseNote (projectId, tagName, body) {
return agent.post(`${BASE}/${projectId}/repository/tags/${tagName}/release`, {
tag_name: tagName,
description: body
})
}
+ +

最后的最后,删除之前拉取下来的仓库,这个任务就算完成了。

+

这么做最大的好处是:仓库启用与否,只需要在 Webhook 处多勾选一个 Tag push Event 即可,无需任何其他操作。

+

但是它也有一个不好的地方:如果原仓库特别大的话,拉取可能会非常耗时。不过考虑到 GitLab 和 Bot 一般都会处在同一个内网环境下,这点基本可以忽略。

+]]>
+ + gitlab + devops + +
+ + Auto-height Webview of ReactNative + /2018/auto-height-webview-of-react-native/ + 自动高的 Webview 实现方式其实跟 iframe 无二,无非是计算其内容高度后再赋值给容器样式。但是普通的办法实际上用起来差强人意,其问题主要体现在页面加载过慢,需要整个页面(包括图片)加载完成后才能计算出高度。而实际想要的效果往往是跟普通“网页”的表现一致,即:先加载文字,图片等内容异步加载、显示。在尝试了多款开源解决方案后,问题均没有得到解决,因此有了自己动手的想法。

+

不过本方案目前也只适用于自己拼接的 HTML,不适用于直接打开链接的 Webview,应用场景主要是在 ReactNative 应用内打开由 CMS 编辑的类新闻页面。

+ + +

主要思路为:通过 Webview 提供的 postMessage 交互方式,不断地从 HTML 页面把自己计算好的高度抛送给 APP 端。但是这里其实有个问题,ReactNative Webview 的 postMessage 必须在页面加载完成以后才会注入,因此可以先加载一个空白页,待 postMessage 注入完成以后,再将实际文章内容插入到 body 中。

+

但是这么做有一个问题就是,页面将无法知道真正的内容“是否已加载完”,因为 window.onload 事件在加载开始之前就已经结束了。因此它只能不停地抛送高度信息,直到页面被销毁。

+

核心代码(HTML):

+
<html>
<head>
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon">
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
<script>
var inserted = false;
var interval = setInterval(function () {
var body = document.body, html = document.documentElement;
var height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight);
if (window.postMessage) {
if (!inserted) {
document.body.innerHTML = '${valueParsed}';
inserted = true;
}
window.postMessage(height + '');
}
if (document.readyState === 'complete') {
//clearInterval(interval)
}
}, 200);
</script>
</head>
<body></body>
</html>
+ +

核心代码(App):

+
export default class AutoHeightWebview extends PureComponent {
constructor (props) {
super(props);
this.state = {
webviewHeight: 0
};
}

assembleHTML = (value) => {
// 组装HTML,略
};

onMessage = (event) => {
const webviewHeight = parseFloat(event.nativeEvent.data);
if (!isNaN(webviewHeight) && this.state.webviewHeight !== webviewHeight) {
this.setState({webviewHeight});
}
};

render () {
const HTML = this.assembleHTML(this.props.html);
const onLoadEnd = this.props.onLoadEnd || function () {};
// 防止 postMessage 与页面原有方法冲突
const patchPostMessageFunction = function () {
var originalPostMessage = window.postMessage;
var patchedPostMessage = function (message, targetOrigin, transfer) {
originalPostMessage(message, targetOrigin, transfer);
};
patchedPostMessage.toString = function () {
return String(Object.hasOwnProperty).replace('hasOwnProperty', 'postMessage');
};
window.postMessage = patchedPostMessage;
};

const patchPostMessageJsCode = '(' + String(patchPostMessageFunction) + ')();';

return (
<WebView
injectedJavaScript={patchPostMessageJsCode}
source={{html: HTML, baseUrl: 'http:'}}
scalesPageToFit={Platform.OS !== 'ios'}
bounces={false}
scrollEnabled={false}
startInLoadingState={false}
automaticallyAdjustContentInsets={true}
onMessage={this.onMessage}
onLoadEnd={onLoadEnd}
/>
);
}
}
+ +]]>
+ + javascript + react-native + +
+ + 自动化部署: 从脚本到 K8s + /2020/automatic-cd-from-shell-scripts-to-k8s/ + 如果公司有专业运维,项目的部署上线过程一般来说开发者都不会接触到。但是很不幸,我所在的团队没有独立的运维团队,所以一切都得靠自己(与同事)。

+

以下都只是工作中逐步优化得到的经验总结,并且只以 Node.js 程序部署为例。

+ + +

部署上线的原始版本

流程图

举例:服务器使用 PM2 管理部署。纯手工操作:

+
+ +

总结

整个过程耗时 10~30 分钟不等。

+

优点:

+
    +
  1. 不依赖任何工具/系统
  2. +
  3. 适用于任何分支
  4. +
+

缺点:

+
    +
  1. 麻烦、耗时
  2. +
  3. 易出错
  4. +
  5. 无法持续部署
  6. +
  7. 多节点怎么办?
  8. +
+

基于 CI 系统的全自动版本

一般来说企业都会有一套 CI 系统,可能是传统的 Jenkins,也可能是 Gitlab CI / Github Actions / Travis CI / Circle CI 等等。它们之间大同小异,都是通过某种方式写好若干份配置文件,当某些操作(如 git push)触发时以及满足某种条件(如当前分支为发布分支,或提交了 tag 等)执行某些任务。

+

这里使用 Gitlab CI 举例,CI/CD 通过 Gitlab Runner 完成,服务器使用 PM2 管理部署。

+

流程图

+ +

技术细节

配置文件 .gitlab-ci.yml:

+
image: node

before_script:
- yarn --frozen-lockfile

stages:
- test
- build
- deploy

# 代码测试
test:
stage: test
script:
- npm run lint
- npm run test
tags:
- node

# 前端代码构建测试
build-frontend:
stage: build
script:
- npm run build
tags:
- node

# 发布 dev 环境,其他环境略
deploy-dev:
stage: deploy
# 仅在 release-dev 分支上执行改任务
only:
- release-dev
script:
- npm run build:dev
- scp -r . user@host:~/path/to/deploy
- ssh user@@host "
pm2 delete -s frontend || true &&
pm2 delete -s server || true &&
pm2 serve /path/to/deploy/frontend/ 8080 --name frontend &&
yarn --cwd /path/to/deploy/server/ &&
pm2 start /path/to/deploy/server/server.js --name server"
tags:
- node

# ...
+ +

总结

优点:

+
    +
  1. 全过程仅依赖 Gitlab 与 Gitlab Runner (基于或不基于容器)
  2. +
  3. 全自动测试,提交到发布分支则全自动部署,测试失败的代码不会被部署
  4. +
+

缺点:

+
    +
  1. 需要在 Gitlab 上配置远程机器的登录凭证(账号/密码),或在 Runner 机器上配置 ssh key
  2. +
  3. Runner 会拥有部署机器的访问权限
  4. +
  5. 多节点?运维?
  6. +
+

Gitlab 与 Agent 平台结合的半自动版本

为了解决上述 Runner 机器权限过高的问题,这个版本引入了 Agent 平台的概念。每个企业使用的平台可能有所区别,有可能是自研的(如我司),也有可能是外部提供的的(如「宝塔」)。但大体功能基本一致。

+

该版本中:

+
    +
  1. CI 通过 Gitlab Runner 完成。任务完成后会将代码打包,并放置于服务器上的某个位置,该位置通过 Nginx 暴露(仅对内)
  2. +
  3. CD 通过 Agent 平台完成。Agent 从上一步暴露的地址中下载代码,解压缩并放置到指定位置,重启 PM2 服务
  4. +
+

流程图

+ +

总结

这一个版本中,CI 系统的配置简化了,去除部署部分的任务即可。至于 Agent 平台的配置方式,可能是一个完整的 bash 脚本,也可能是其它配置,就不在此展开了。

+

优点:

+
    +
  1. CI 过程全自动
  2. +
  3. CI/CD 权限解耦
  4. +
  5. 适用于各种分支
  6. +
+

缺点:

+
    +
  1. 非线上发布过程也需要手动完成,麻烦
  2. +
  3. 严重依赖 Agent 平台
  4. +
  5. 运维?
  6. +
+

Gitlab 与 k8s 结合的全自动版本 v1

k8s (kubernetes) 是一个容器集群部署管理系统。

+

容器基础知识:

+
    +
  1. 镜像 Image
  2. +
  3. 容器 Container
  4. +
+

k8s 基础知识:

+
    +
  1. 工作单元 pod
  2. +
  3. 服务 service
  4. +
  5. 节点 node
  6. +
  7. Kustomize
  8. +
+

流程图

+ +

技术细节

Dockerfile:

+
FROM alpine

RUN apk add --no-cache --update nodejs nodejs-npm yarn
RUN adduser -u 1000 -D app -h /data

USER app

COPY --chown=app start.sh /data/start.sh

WORKDIR /data

EXPOSE 8000
ENTRYPOINT [ "sh", "/data/start.sh" ]
+ +

start.sh:

+
#!/usr/bin/env sh

# 从文件服务器获取该版本包
VERSION_DEPLOY_HTTPCODE=`curl -s "https://xxx/${VERSION}" -o pkg.tgz -w "%{http_code}"`
if [ "$VERSION_DEPLOY_HTTPCODE" == "200" ]; then
echo "using version: ${VERSION}"
tar zxf pkg.tgz
else
echo "version package not exist: ${VERSION}"
exit 1
fi

sh deploy.sh
+ + +

kill 实现:

+
// koa
router.all('/api/kill', async (ctx, next) => {
if (!IS_PROD) {
ctx.body = 'ok'
process.exit(0)
} else {
next()
}
})
+ +

总结

优点:

+
    +
  1. CI 过程全自动
  2. +
  3. 非线上环境 CD 全自动,线上环境 CD 手动指定版本,兼顾方便与安全
  4. +
  5. 无需配置远程机器权限
  6. +
+

缺点:

+
    +
  1. k8s 使用原始镜像启动 pod,拉取代码与安装依赖的过程非常耗时(每次启动都是全新镜像,无缓存)。
  2. +
  3. CD 过程不确定性较多,存在代码文件服务器故障、依赖安装故障等风险。
  4. +
  5. kill 指令发出后服务会暂时不可用。
  6. +
  7. 多节点?
  8. +
+

Gitlab 与 k8s 结合的全自动版本 v2

为了解决上面的问题 3/4,引入 Consul 对 CI/CD 过程做出了改进。

+

Consul 是为基础设施提供服务发现和服务配置的工具,包含多种功能,这次用到了其中两个功能:

+
    +
  1. 服务发现
  2. +
  3. 健康检查
  4. +
+

流程图

+ +

技术细节

这个流程里面涉及到几个问题:

+

关于“逐个发送 kill 指令”

虽然通过 Consul 可以获取到所有运行中 pod 的 ip 及端口,但是如果集中发送 kill 命令仍然会造成服务不可用。目前我司服务端的 CI 就有这个问题,他们虽然每个 kill 会有一段固定时间的 sleep 间隔,但无法保证下一个 kill 发出时上个服务时候已重启完毕。

+

为了解决这个问题,我写了一个脚本。

+

流程:

+
+ +

代码:

+
#! /usr/bin/env node

/**
* 此脚本在gitlab-runner中作为CI的最后一步执行
* 在非线上环境中可以对容器进行逐个重启,尽量减少downtime
*/

// 确保线上环境不执行此脚本
if (process.env.NODE_ENV === 'production') {
return
}

const http = require('http')

function request (host, port, path) {
return new Promise(((resolve, reject) => {
const req = http.request({
hostname: host,
port: port,
path: path,
method: 'GET',
timeout: 1,
headers: {
'Content-Type': 'application/json'
}
}, function (res) {
res.setEncoding('utf8')
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => {
resolve({
status: res.statusCode,
data
})
})
})
req.on('error', e => {
console.log(e.message)
resolve()
})
req.end()
}))
}

async function sleep (time) {
return new Promise((resolve) => {
setTimeout(resolve, time)
})
}

(async () => {
// 各环境的consul请求地址
// 具体使用时需要填入指定 host 与 port
const apiMap = {
development: ['host', 'port'],
qa: ['host', 'port'],
pp: ['host', 'port']
}
// 从consul获取已注册pod列表
const api = apiMap[process.env.NODE_ENV]
const res = await request(api[0], api[1], api[2])
if (!res || !res.data) {
console.log('consul find service failed, exiting...')
return
}
console.log('consul raw:', JSON.stringify(res.data))
const pods = JSON.parse(res.data).map(v => [v.Service.Address, v.Service.Port])
console.log('consul services:', JSON.stringify(pods))
// 循环杀进程
for (let i = 0; i < pods.length; i++) {
const pod = pods[i]
console.log('-------------')
console.log(pod[0], pod[1])
// 重启一个pod
const killRes = await request(pod[0], pod[1], `/api/kill`)
if (killRes && killRes.status === 200) {
// kill返回成功,这个节点原本是活着的才继续监测它的状态
console.log(pod[0], 'killed.')
if (i === pods.length - 1) {
// 已经杀完了最后一个pod,无需继续等待重启,直接退出
process.exit(0)
}
let isServerUp = false
// let isFrontendUp = false
// 每个pod最多检测12次(2分钟),超过时间则放弃,直接重启下一个pod
let tryTimes = 12
// 循环检测是否重启成功
do {
console.log(pod[0], 'waiting for pod up...', tryTimes)
// 每10秒检测一次
await sleep(10 * 1000)
console.log(pod[0], 'check if pod up...')
// 如果返回200表示已服务已重新启动
const serverRes = await request(pod[0], pod[1], `/api/health-check`)
isServerUp = !!(serverRes && serverRes.status === 200)
console.log(pod[0], 'pod server up:', isServerUp)
// 等待 k8s readinessProbe 开始,节点被认为已存活,则可以对外访问
if (isServerUp) {
const waitSecondsStr = process.env.WAIT_FOR_PROBE_SECONDS || '10'
const waitSeconds = parseInt(waitSecondsStr)
if (isNaN(waitSeconds) || waitSeconds >= 120 || waitSeconds < 0) {
// 最大等待120秒,超过视为参数错误
console.log(pod[0], `WAIT_FOR_PROBE_SECONDS is invalid, skip waiting for prob.`)
} else {
console.log(pod[0], `pod is up internally, but need to wait for live probe (${waitSeconds}s)...`)
await sleep(waitSeconds * 1000)
}
}
} while (!isServerUp && --tryTimes > 0)
} else {
console.log(pod[0], 'kill failed, skip.')
}
}
})()

+ +

在 gitlab-ci 的最后一步执行此脚本:

+

.gitlab-ci.yml:

+
# ...
deploy-dev:
stage: deploy
only:
- release-dev
script:
- ./build.sh
- NODE_ENV=development SERVICE_NAME=some-name npm_config_registry=http://private.registry.com npx restart-project-via-consul-script@latest
tags:
- node
# ...
+ +

关于“解注册所有同名服务”与“注册自己”

项目内部使用 https://www.npmjs.com/package/consul 来与 Consul 通信。

+

需要“解注册所有同名服务”的原因:

+

Consul 在注册服务时并没有类似“主键”的概念,一个 Consul 有多个 Agent,也就是说相同 ip、相同 id 的服务可能会在不同 Agent 上被注册多次,并且由于 pod 的 ip 是短暂的,每次重启 pod 获得的 ip 可能会有差异,因此如果不进行解注册就会导致从 Consul 上获得的服务与现实正在运行的不一致。

+

流程:

+
+ +

总结

优点:

+
    +
  1. CI 过程全自动
  2. +
  3. 非线上环境 CD 全自动,线上环境CD手动指定版本,兼顾方便与安全
  4. +
  5. 无需配置远程机器权限
  6. +
  7. 支持多节点
  8. +
  9. 高可用性的重启
  10. +
+

缺点:

+
    +
  1. k8s 使用原始镜像启动 pod,拉取代码与安装依赖的过程非常耗时(每次启动都是全新镜像,无缓存)。
  2. +
  3. CD 过程不确定性较多,存在代码文件服务器故障、依赖安装故障等风险。
  4. +
+

为什么会做成这个样子呢,因为我司的服务端使用 golang 是走的这一套流程,但是使用 golang 打包编译出的是一个二进制文件,镜像直接拉取就可以启动,不需要依赖安装等步骤。因此他们使用这种方式的缺点并不明显。但是对于 Nodejs 程序来说,这依然是一个较大缺陷。

+

Gitlab 与 k8s 结合的全自动版本 v3

为了解决上面的问题 1/2,这个版本更改了代码部署到 k8s 的方式:使用完整的预构建镜像,而不是空白镜像。

+

流程图

+ +

技术细节

build.sh:

+
echo "Process: 构建 Docker 镜像..."
docker build -t wohx-${PROJECT_NAME}:${COMMIT_SHA} .
docker tag wohx-${PROJECT_NAME}:${COMMIT_SHA} private.registry.com/${PROJECT_NAME}:${COMMIT_SHA}
docker tag wohx-${PROJECT_NAME}:${COMMIT_SHA} private.registry.com/${PROJECT_NAME}:${BRANCH}-latest

echo "Process: 上传 Docker 镜像..."
docker push private.registry.com/${PROJECT_NAME}:${COMMIT_SHA}
docker push private.registry.com/${PROJECT_NAME}:${BRANCH}-latest
+ +

Dockerfile:

+
FROM alpine
RUN apk add --no-cache --update nodejs nodejs-npm yarn
RUN adduser -u 1000 -D app -h /data
USER app
WORKDIR /data
EXPOSE 8000
# server 确保在 yarn.lock 与 package.json 没有改变的情况下,此层能被缓存
# frontend 的 node_modules 已在 .dockerignore 中忽略,无需关注
RUN mkdir ./server && mkdir -p ./frontend/dist
COPY --chown=app server/yarn.lock server/package.json ./server/
RUN yarn --ignore-engines --cwd /data/server/
# 启动脚本
COPY --chown=app ./start.sh ./
# server 源代码层
COPY --chown=app ./server ./server/
# frontend dist 层
COPY --chown=app ./frontend/dist ./frontend/dist
# entry
# CMD [ "node", "./server/server.js" ]
# start.sh 允许 k8s 自定义启动逻辑
ENTRYPOINT [ "sh", "/data/start.sh" ]
+ +

总结

优点:

+
    +
  1. CI过程全自动
  2. +
  3. 非线上环境CD全自动,线上环境CD手动指定版本,兼顾方便与安全
  4. +
  5. 无需配置远程机器权限
  6. +
  7. 支持多节点
  8. +
  9. 高可用性的重启
  10. +
  11. 预构建的镜像,CD 阶段开箱即用,无任何依赖
  12. +
+

缺点:

+
    +
  1. 为了使每个 pod 能够获得一个稳定的名称(用于在 Consul 中注册、解注册),部署类型使用了 Statefulset,因此带来了一些本来不需要的特性。
  2. +
+]]>
+ + devops + k8s + +
+ + Baidu Submit for WordPress update 0.1.0 + /2016/baidu-submit-for-wordpress-update-0-1-0/ + 这次更新主要是把工具做成了 WordPress 插件的形式,安装和使用起来都更符合 WordPress 的风格了,也不用再通过改代码去更改配置参数。之所以一次性把版本号提到了 0.1.0,是因为我觉得它虽然功能还不是非常完善,但是已经达到了“至少能用”的程度。

+

工具的主要功能目前为止并没有什么变化,至于这个过程中获得的少许 WordPress 插件开发经验下次再总结,好在没走多少弯路。

+

使用方式:Clone https://github.com/wxsms/baidu-submit-for-wordpress 仓库并上传至主机的

+
/wp-content/plugins
+ +

目录,在 WordPress 插件控制面板中设置启用即可。准入密钥以及域名的配置页面可以在“设置”中找到,其中也包含了手动推送的页面。

+]]>
+ + php + wordpress + +
+ + WordPress 百度主动提交工具 + /2016/baidu-submit-for-wordpress/ + 作为目前的国内搜索主流,百度的收录规则与国外搜索引擎如谷歌、必应等不太一样,虽然它也有提供普通的Sitemap模式,但是据它自己所言通过这种方式收录效率是最低的。另外还有一种是自动推送,即在网站所有页面都加入一个JS脚本,有人访问时就会自动向百度推送该链接,但实测经常会被浏览器的AD Block插件阻拦。因此还剩下效率最高的一种方式:主动推送。我试过了一些现成的插件,好像都不太好用。因为是一个简单的功能,所以就自己写了一个小工具来实现。

+ + +

主动推送规则

通过调用百度的一个接口,并给它传送要提交的链接,即完成了主动推送的过程,根据接口返回的信息可以判断提交的结果如何(成功/部分成功/失败)。

+

核心代码如下(由百度提供):

+
$urls = array(
'http://www.example.com/1.html',
'http://www.example.com/2.html',
);
$api = 'http://data.zz.baidu.com/urls?site=xxx&token=xxx';
$ch = curl_init();
$options = array(
CURLOPT_URL => $api,
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => implode("\n", $urls),
CURLOPT_HTTPHEADER => array('Content-Type: text/plain'),
);
curl_setopt_array($ch, $options);
$result = curl_exec($ch);
echo $result;
+ +

$urls 就是我们需要推送的 url 数组了,除此之外还有两个需要修改的地方,都在第5行。一是自己站点的域名,二是准入密钥。密钥会由百度站长工具提供。域名则有一点需要注意,必须填写在百度站长平台注册的域名,比如注册的时候是带有 www 的,则这里也必须带 www,否则会返回域名不一致的错误。

+

API返回的 $result 是一个 JSON 对象,若推送成功可能包含以下字段:

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段是否必选参数类型说明
successint成功推送的url条数
remainint当天剩余的可推送url条数
not_same_sitearray由于不是本站url而未处理的url列表
+ not_valid + + 否 + + array + + 不合法的url列表 +
+
+ +

示例:

+
{
"remain":4999998,
"success":2,
"not_same_site":[],
"not_valid":[]
}
+ +

推送失败可能返回的字段:

+
+ + + + + + + + + + + + + + + + + + + +
+ 字段 + + 是否必选 + + 参数类型 + + 说明 +
+ error + + 是 + + int + + 错误码,与状态码相同 +
+ message + + 是 + + string + + 错误描述 +
+
+ +

示例:

+
{
"error":401,
"message":"token is not valid"
}
+ +

实测小站点一天只能推500条链接,超过了就会报错。不过目前来说是绝对够用了。

+

实现逻辑

关于这个工具,我能想到的比较合理的使用逻辑是这样的:

+
    +
  • 建站已有一段时间,但是从来没用过百度主动推送,需要能够选择以往的链接并推送之
  • +
  • 旧的链接都已推送过,需要在有新页面发布时自动将其推送
  • +
+

需要推送的页面包括但不限于:

+
    +
  • 首页
  • +
  • 文章(Post)
  • +
  • 页面(Page)
  • +
  • 目录
  • +
  • 标签
  • +
+

由于是第一版,目前这个工具的逻辑就是这样的:

+
    +
  1. 首先获取到Wordpress站点下所有的正常页面(已发布,无密码)
  2. +
  3. 让用户选择哪些页面需要被推送
  4. +
  5. 用户点击按钮,请求经由AJAX发回后台
  6. +
  7. 后台调用百度接口,实行推送
  8. +
  9. 返回并显示结果
  10. +
+

实际的效果就像这样(点此参观 2016-03-10更新:由于已更换为插件模式,原页面失效):

+

+

这个工具目前还很简陋。当然,如果你没有登录或者已登陆但没有管理员权限的话,点击Submit是会被拒绝的。

+

未来的目标

虽然是一个简单的东西,但我觉得它可以变得更好:

+
    +
  1. 用户应该可以填写自定义的链接
  2. +
  3. 它应该记住哪些链接已经被提交过了,这个状态应该显示在页面上,并且不再自动勾选
  4. +
  5. 自动触发推送的功能尚未实现,这个也是很重要的
  6. +
  7. 表格可以以一种更好的形式展现
  8. +
  9. Log可以写得更友好一些
  10. +
  11. 做成插件
  12. +
  13. ……
  14. +
+

如无意外,这些都将在之后的版本更新。

+

GitHub: https://github.com/wxsms/baidu-submit-for-wordpress

+]]>
+ + php + wordpress + +
+ + 奔驰事件与 996 + /2019/benz-and-996/ + 4S 店之所以敢贪得无厌、明目张胆,我想很大一部分原因是来自于普通人想要维权实在是太过困难了。想要维权,那就相当于:

+
    +
  1. 你得放弃很大一部分的工作时间(甚至丢掉工作)
  2. +
  3. 你得付出前期的诉讼成本
  4. +
  5. 你得面对来自各方的压力(家庭与社会)
  6. +
  7. 你得面对可能最终维权失败的结果
  8. +
+

再结合最近热议的 996,再来看这件事,对于普通人来说实在是太难了。生活与工作本身已经如此不易,要是再来这么一出,谁顶得住啊。也难怪绝大多数人在受到欺负之后最终只能无奈选择忍气吞声。毕竟大多数国人都是很「精明」的,就算维权成功,带来的收益也可能远不如其负面影响,那么为什么要维权呢?

+

996 其实也是同样的道理。公司敢于非法压榨员工,员工却无可奈何,只能通过在 Github 发声聊以自慰。近期互联网大佬频频发声,大谈创业艰难史,可是始终是避重就轻,你想奋斗没有人拦着你,但逼别人奋斗是怎么回事呢?问题的关键是「强制」而不是「996」,没有一人提及。最可笑的是马云的「你要来谈法律,那法律有规定这么齐全的设备吗?有规定这么好的食堂吗?」,可以看出这些站在企业顶端的人都是些什么嘴脸。求求你把这些都撤了,给我发合法的加班费好吗?当然这是不可能的,大佬们会跟你谈梦想,谈兄弟,这些都不成,那您请滚吧。

+

可是有多少人经受得住这种后果呢?一旦维权,即使成功,你也可能被列入行业黑名单,就算他们无法可依。这种事情不是没有先例,我印象中见过好多了。说了一堆废话,最终问题的根源到底在哪里,相信大家都懂的。

+]]>
+ + personal + +
+ + Better Documents + /2017/better-documents/ + 这篇文章记录了我是如何一步步地把 https://github.com/wxsms/uiv 这个项目的用户文档变得更优雅的。实际上,如何以一种高效又优雅的方式编写实例文档一直是我的一个疑惑,比如主要的问题体现在:

+
    +
  • 如何使文档更易读?
  • +
  • 如何使文档更易于维护?
  • +
  • 如何减少编写文档的工作量?
  • +
  • 实例代码无可避免地需要手工维护吗?
  • +
+

最后一点是让我最头疼的地方。举个例子,我想要给用户展示一个组件的使用方式,以下代码可以在页面上创建一个 Alert:

+
<alert type="success"><b>Well done!</b> You successfully read this important alert message.</alert>
+ +

那么,我总要给用户一个相对应的实例吧。我要在我的文档上面就创建一个这样的 Alert,同时告诉用户说你可以这么用。这是一个很普遍的展示方式,那么问题就在这里了,我是否要将同样的代码写两次呢?

+

一开始我确实就是这么做的,虽然我知道这不科学,不高效,更不优雅。但我实在是想不到更好的办法了。

+

但是,现在,我已经(几乎)把以上的问题都解决了。

+ + +

Stage-1

写文档这件事,实际上跟写文章差不多,写作体验很重要。

+

在最开始的时候,项目文档是直接用 Vue 文件编写的,没有经过任何处理,没有经验的我甚至还作死地加入了 i18n,可以说是非常有趣了。以至于到最近,在没有发生这次重构之前,我根本不想动它们。

+

可以想象,我给关键字句加个粗要手写 <b>...</b>,标记一点代码要用 <code>...</code>,每写一段话都要注意标签标签标签,文档里充斥这些东西,烦不胜烦。

+

这阶段的文档,存在的问题主要有:

+
    +
  • 难以编写
  • +
  • 无法在网站以外的地方阅读(因为是 Vue 源码)
  • +
  • 给项目增加了许多额外代码
  • +
  • 手工维护的实例代码
  • +
+

Stage-2

以上提到的写作体验令人作呕,经过了漫长的时间后,在这一阶段得到了解决。某次机缘巧合,我发现了这样一个工具,它可以通过 webpack 将 Markdown 格式的文本直接转换成为 Vue 组件:vue-markdown-loader

+

比如:

+
module.exports = {
module: {
rules: [
{
test: /\.md$/,
loader: 'vue-markdown-loader'
}
]
}
};
+ +

这样一来,就可以通过 import [*].md 的方式,得到一个内含 Markdown 内容(已转 HTML)的 Vue 组件。可以直接在页面上用了!

+

如果不考虑实例部分的话,这就已经完美了。准确地说,如果一开始就不需要实例这种东西,那么我肯定会直接用 Gitbook 了。也不需要这个 markdown to vue 来做什么。

+
+

经过了长时间的折磨的我身心疲惫,最终还是决定尝试一下。

+

然而,就在这个尝试的过程中惊喜地发现:它居然还可以执行 Markdown 中的 Code block 中的代码!

+

这是什么鬼。一开始发现这个的时候我还是很惊讶的。仿佛打开了新世界的大门。

+

在后来的不断尝试 - 失败 - 尝试的过程中,我发现了它更多的 Feature:

+
    +
  • 可以执行 Code blocks 中的代码(<script>
  • +
  • 可以执行 Code blocks 中的样式(<style>
  • +
  • 可以通过插件给文档 header 加锚点
  • +
+

但是,也发现了以下问题:

+
    +
  • 多个 Code blocks 中的 <style> 可以合并,但 <script> 不行,它始终只会执行所找到的第一段 <script>
  • +
+

通过查阅 vue-loader 的文档发现,这是 .vue 文件本身的限制:支持多个 <template>,多个 <style>一个 <script>

+

也就是说,如果页面上有多个实例需要展示的话,给给。

+

如果这个问题能够解决的话,再结合我本身的需求,以下内容也需要实现:

+
    +
  • 将实例代码中的 <template> 模板插入到其代码块之前,让其成为 Markdown 文件的一部分,然后 Vue 就会自动将它们统统实例化
  • +
+
+

其实到了这里,也就是这两个问题需要解决了。

+

首先是模板插入的问题。这个其实不难,在 Markdown 完成渲染前,通过一些手段找到这些需要渲染的模板,然后手动插入。幸而 loader 提供了 preprocess 钩子,让我能直接完成这件事情。

+

然后,关于 <script> 这块,我尝试了好久好久,实在是没办法。但是又真的舍不得因为这仅仅一个问题丢弃以上的那么多的好处。于是就想到了一个折中的办法:禁用 loader 的自动执行代码功能,并手动组装代码块。然而一个悲催的问题又出现了:禁用自动代码执行后,<style> 也无法自动执行了。

+

解决方案:我需要在 preprocess 中将 Code blocks 里面的 <style> 块全部切出来,贴到 code blocks 的外面(比如文件结尾处)去。一开始我还尝试了将它们的内容合并成为一个 <style>,后来发现其实不需要,因为 vue-loader 本身就支持一个文件多个 <style> 节点。

+

最后的最后,轮到了 <script> 的组装。我尝试了很久的自动合并,比如将它们的 export 内容转为 object 再 merge 啦,function 转为 object 再 merge 啦,toString 再 merge 啦,等等等等,然而各种方式都以失败告终。结论是:我无法将数个字符串代码块直接合并,也无法转为 object 再合并再转回字符串。实在的实在是没办法了,hard code 吧。

+
+

至此,一个新的解决方案就出现了。简单来说,编写一篇文档,我需要做以下的事情:

+
    +
  • 用 Markdown 写文档以及实例代码
  • +
  • 实例代码块中加入约定的标志
  • +
  • 注意同一个 Markdown 中的实例代码块的 <script> 不能相互冲突
  • +
  • 做完所有事情以后,用我自己的智商和爱将所有的实例代码合并成一份
  • +
+

大功告成。

+

虽然依然有些麻烦,但相比与 Stage-1,我至少解决了以下的大事:

+
    +
  • 文档编写体验大幅度提升!
  • +
  • 文档可以在网站以外的地方被阅读(如 Github)
  • +
  • 实例的 <template><style> 代码无需再有特殊照顾
  • +
  • 维护工作量大大减少
  • +
+

依然存在的问题是:

+
    +
  • 实例的 <script> 代码需要维护两份,而且不能彼此冲突
  • +
+

Stage-3

虽然解决了 80% 的问题,但 Stage-2 依然不完美。我始终想要解决最后一个问题:无需特殊照顾的实例 <script>

+

想要达到这个目标,有一个完美的办法就是:将实例也作为子组件来插入到 Markdown 父组件中去。这样一来,同一页面的实例代码无法冲突的问题也就一并解决了。

+

显然,通过目前的 loader 无法达到我想要的效果,它只能够简单地将代码插入 Markdown,并不能构建子组件。因此,要解决这个问题,我需要自己造轮子

+

……

+
+

于是就有了:

+

https://github.com/wxsms/vue-md-loader

+

关于这个轮子,它是原有 markdown-loader 的一个替代品,并且能够解决以上提出的所有问题

+

除了完善的原有 Markdown 转换功能以外,它还可以将 Markdown 中的实例代码,比如:

+
<template>
<div class="cls">{{msg}}</div>
</template>
<script>
export default {
data () {
return {
msg: 'Hello world!'
}
}
}
</script>
<style>
.cls {
color: red;
background: green;
}
</style>
<!-- some-live-demo.vue -->
+ +

变成类似这样的结构:

+
<some-live-demo/>
<pre><code>...</code></pre>
+ +
+

A Vue component with all it’s <template>, <script> and <style> settled will be inserted before it’s source code block.

+
+

毫无疑问,它支持同一文件中的多个代码块

+

关于这个插件,其实就是一个典型的、简单的 webpack loader,将一个 markdown 文件转换成了可以被 vue-loader 识别并加载的 vue 文件。

+

它的实现思路主要有:

+
    +
  • 将实例代码块中的 <style> 直接截取,并放到 Markdown 组件下
  • +
  • 将实例代码块中的 <script>export default 的内容截取,并作为各自的 Component options
  • +
  • 加上相应代码块中的 <template> 中的内容,稍微组装一下,它就成为了一个 Vue component
  • +
  • 在 Markdown 组件中局部注册该 component,并将它插入到代码块的前面去
  • +
  • 对于 export default 外部的内容,把它们抽取出来,集中放到 Markdown 组件下
  • +
+

以上这些操作,全部通过字符串与正则操作就足以完成了。

+

然而可以发现,这里面仍有一些有待解决的问题:

+
    +
  • <style> 有可能冲突
  • +
  • export default 之外的内容有可能冲突
  • +
+

这两个问题目前也还没有想到有效的解决办法。但是,就目前来说,满足我的需求已经完全足够了。遗留问题通过后续的开发来逐步解决吧。

+
+

至此,优雅地编写项目文档的全部要素就齐备了:

+
    +
  • 纯文档编写体验(Markdown)
  • +
  • 文档可以在网站以外的地方被阅读(如 Github)
  • +
  • 实例代码均无需特殊照顾,所有过程自动完成
  • +
  • 没有维护压力
  • +
+

Enjoy!

+]]>
+ + vue + webpack + markdown + +
+ + BFC 原理及应用 + /2016/bfc-theory-and-applications/ + 什么是 BFC

BFC(Block formatting context)是 CSS 中的一个概念,先来看一下定义 (By MDN):

+
+

A block formatting context is a part of a visual CSS rendering of a Web page. It is the region in which the layout of block boxes occurs and in which floats interact with each other.

+
+

大意就是,BFC 是 Web 页面通过 CSS 渲染的一个块级(Block-level)区域,具有独立性。

+

BFC 对浮动元素的定位与清除都很重要:

+
    +
  • 浮动元素的定位与清除规则只适用于同一 BFC 中的元素
  • +
  • 不同 BFC 中的浮动元素不会相互影响
  • +
  • 浮动元素的清除只适用于同一 BFC 中的元素
  • +
+ + +

如何生成 BFC

一个元素要成为 BFC,必须具备以下特征之一:

+
    +
  • 根元素,或者包含根元素的元素
  • +
  • 浮动元素(float 属性不为none
  • +
  • 绝对定位元素(position 属性为 absolutefixed
  • +
  • 行内块级元素(display 属性为 inline-block
  • +
  • 表格单元格或者标题(display 属性为 table-celltable-caption
  • +
  • 元素的 overflow 属性不为 visible
  • +
  • Flex 元素(display 属性为 flexinline-flex
  • +
+

BFC 的应用

自适应双栏布局

之前一直困扰我的一个问题是,如何使用 CSS 实现一个双栏布局,其中一栏宽度固定,另一栏则自动根据父节点剩余宽度填满容器呢?

+

因为 CSS 2.x 是不支持计算的,所以不使用 calc 的话,还真的好像没什么办法的样子。

+

然而,通过使用 BFC 却可以很容易地达到效果。

+

先来看一个没有 BFC 的例子:

+
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>BFC Example</title>
<style>
.aside {
width: 100px;
height: 150px;
float: left;
background: #f66;
}

.main {
height: 200px;
background: #fcc;
}
</style>
</head>
<body>
<div class="aside"></div>
<div class="main"></div>
</body>
</html>
+ +

效果:

+

+

在这种情况下,二者共享了同一个 BFC,即 body 根元素,因此,右边元素的定位受到了浮动的影响。

+

我们给 .main 添加一个属性,让它成为独立的 BFC:

+
.main {
overflow: hidden;
}
+ +

效果:

+

+

这就是一个自适应两栏布局了。主栏的宽度是随父节点而自动变化的。

+

清除内部浮动

写 CSS 代码的时候经常会遇到一个问题:如果一个元素内部全部是由浮动元素组成的话,那么它经常会没有高度,即“不能被撑开”。

+

我们可以通过在所有浮动元素的最后清除浮动来解决问题,但通过 BFC 的方式其实更简单。

+

只需要通过任意方式将浮动元素的容器转换为 BFC(比如添加 overflow: hidden 属性),即使不清除浮动,其依然能被正常“撑开”。

+

就像这样:

+
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>BFC Example</title>
<style>
body {
width: 660px;
}

.parent {
background: #fcc;
overflow: hidden;
}

.child {
border: 5px solid #f66;
width: 200px;
height: 200px;
float: left;
}
</style>
</head>
<body>
<div class="parent">
<div class="child"></div>
<div class="child"></div>
</div>
</body>
</html>
+ +

效果:

+

+

清除 margin 重叠

场景:我们连续定义两个 div,并且都给予 margin: 100px 属性,实际上它们之间的距离也将是 100px 而非 200px,因为 margin 重叠了。

+

如果不想让 margin 出现这种重叠的情况,依然可以使用 BFC:给二者都各自套上一个 BFC 容器(或者其中之一),因为 BFC 的独立性,内部布局不会产生对外影响,外部也不会产生对内影响,所以二者的 margin 属性都能生效,最终就能得到 200px 的间距。

+

这个就不举实际例子了。

+]]>
+ + css + +
+ + 博客迁移至 Hexo + /2021/blog-migrate-to-hexo/ + 博客迁移至 Hexo。主要原因是:

+
    +
  1. Vuepress 有部分 bug 难以忍受,而且 v1 仓库已经停止维护了;
  2. +
  3. Vuepress 的功能对于 blog 来说还是有些弱;
  4. +
  5. Vuepress v1 存在文章数量增加,首屏加载大小不断变多的问题;
  6. +
  7. Vuepress 没有 blog 主题,而我自己写的主题是基于 v1 的,且无法升上 v2 (因为:为了解决问题 3,v2 中 $posts 变量被移除了,而该主题的首页依赖这个变量做渲染);
  8. +
  9. …… (其它难以忍受的问题)
  10. +
+]]>
+ + personal + +
+ + Blog Migrated to VuePress + /2019/blog-migrated-to-vuepress/ + 博客正式迁移到了 VuePress,有以下两点原因:

+
    +
  1. 想做一个极简化改版,但懒得折腾了
  2. +
  3. 希望以后重心放在写文章,而不是维护博客上
  4. +
+

共勉。

+]]>
+ + personal + +
+ + 博客迁移 + /2015/blog-migration/ + 原来的博客太简陋了(虽然现在依然很简陋),一直想改都没有时间,最近在公司培训了三个月的Java也发现自己差不多忘了怎么写C#代码,曾经觉得很顺手的IDE用起来也不习惯了,反正就是改不下去了。鉴于工作以后空闲时间变得捉襟见肘,最终还是放弃了自己动手的想法,直接用了模板博客。虽然没有了一切如己所愿的快感,但毕竟是开源软件,想怎么玩都可以,感觉还是不错的选择。

+

7/25/2019 注:当时是迁移到了 WordPress

+]]>
+ + personal + +
+ + Bootstrap file input in Firefox + /2016/bootstrap-file-input-in-firefox/ + 默认的Bootstrap文件上传框在Chrome/Firefox/IE上的表现都不一样,如下所示。 代码:

+
<input class="form-control" type="file">
+ + + +

Chrome:

+

+

Firefox:

+

+

IE:

+

+

先忽略掉文字表述的差异(由浏览器所使用语言引起),可以看到File input在Chrome和FF下的表现比较相似,IE则差距略大。但是至少Chrome和IE是可以正常显示其样式的,FF则出现了奇怪的样式问题,好像因为按钮太大而超出了输入框。 解决方法也很简单,最快捷的:

+
.form-control {
height: auto;
}
+ +

但是这个方法可能会影响到其它输入框组,可以稍作修改:

+
.form-control[type=file] {
height: auto;
}
+ +

这样CSS就会自动选择类型为file的输入框并且添加以上样式。虽然IE家族对CSS属性选择器的支持有限制(7/8)或者完全不支持(6),但是实际上并不影响。因为Bootstrap最低也只能支持到IE 9或IE 8(添加额外库),所以这个方法已经足够了。 修改后的Firefox(Chrome/IE无变化):

+

+]]>
+ + bootstrap + firefox + +
+ + 做了一个 b 站视频下载与 mp3 转换工具 + /2023/bv2mp3/ + b 站上的歌姬,很多歌只发布在 b 站。比如说直播时唱的歌,或者一些发布到正经音乐平台上会有版权问题的歌。然而,对于爱听歌的人来说,b 站的听歌体验实在是太差了,这里就不展开细说。

+

我习惯用网易云听歌。网易云虽然版权方面很惨,但有它一个很好用的功能:云盘。每个用户有 60G 的云盘容量,基本用不完,不管是什么歌,有没有版权,只要上传上去了就能随时随地听。因此,我的目标是,希望可以有一个自动化的工具,帮我把 b 站上的歌以 mp3 的格式下载下来,让我可以上传到云盘,这样我就可以用网易云听歌了。

+

综上所述,我就做了这么一个小工具:bv2mp3 ,这是一个开源工具,完整的代码可以在代码仓库中找到。下面,我主要讲一下这个工具的实现思路以及优化过程。

+

126f0ee04db59831d6a9820ac89c471.jpg

+ + +

在这之前

起初,我还是不愿意自己造轮子的,毕竟我觉得这应该是个非常 common 的需求,应该会有不少东西可以拿来直接用。

+

最开始的时候,我尝试使用了网络上的一些随处可见的在线服务,搜索“b 站视频下载”可以找到很多相关的在线网站。此类服务通常来说是能用的,但是当需要下载的量一旦大起来以后,它就显得非常麻烦了。比如说它需要看广告,或者下载的质量参差不齐,并且需要下载后手动再转一次MP3,非常麻烦。

+

在受够了此类工具以后,我开始转向一些付费工具。我找到了一款比较强大的“哔哩哔哩助手”浏览器插件。只要付费,它就能提供将视频直接下载为 mp3 的功能。并且可以直接在 b 站页面上操作,相对来讲比较好用。但是用了几个月下来以后,仍然觉得还不够好:

+
    +
  1. 首先,当然是因为它要付费;
  2. +
  3. 其次,我要下载的一般都是整个播放列表,而这款插件只能一次下载一个分集,这意味着我要将分集一个一个地打开,再一个一个地打开助手菜单,找到并点击MP3下载按钮;
  4. +
  5. 有时候,我甚至需要一次性下载多个播放列表,这种体验的痛苦就成倍增加了。
  6. +
+

总的来说,它虽然能用,但是用户体验依旧很原始。

+

我想要的是:可以一次性下载整个列表里面的所有视频,甚至一次性下载多个列表,然后将他们批量转为 mp3 的工具。

+

找了一圈 GitHub,虽然有类似的工具,但都不能完美契合我的需求。因此我决定:这次还是自己来吧!

+

程序主框架

我希望我做的工具可以尽可能地简单(无论是从使用还是开发层面):

+
    +
  1. 它是高度定制化的,可以只为我服务(当然如果可以帮助到其它人就更好了);
  2. +
  3. 它不需要界面,因为写界面是很麻烦的事情,只需要一个命令行就好了;
  4. +
  5. 它可以一键帮我完成上述所有事情:
      +
    1. 下载一个或多个列表里面的所有视频
    2. +
    3. 将视频转为mp3
    4. +
    5. 拥有批量化自动命名、自动失败重试等其它基本功能
    6. +
    7. 上传到网易云盘这一步,由于没有找到网易云的可用接口,因此这一步仍需手动
    8. +
    +
  6. +
+

从“尽可能地简单”为出发点,总体技术栈自然是选择我最熟悉的 Node.js,并且是 v16+,以此直接开启 type=module,舍弃 cjs 的裹脚布。

+

然后,首选 tj 的 commander.js 来实现命令行程序。

+

那么,问题来了,将一个 b 站视频下载下来并且转为mp3,要分几步?

+

爬取网页

https://www.bilibili.com/video/BV1wV411t7XQ 为例。这个列表里面共有 336 首歌,我需要把它们全部下载下来。

+

点击列表中的视频,仔细观察可以发现,它们的 url 是有类似的模式的,比如:

+ +

可以发现它们前面的格式都是一样的,区别只在后面的 ?p=x

+

那么我需要做的事就是:

+
    +
  1. 程序接收一个链接
  2. +
  3. 找到链接里面共有多少集
  4. +
  5. 然后分别组装每一集的 url 并下载
  6. +
+

从国际惯例来讲,为了实现步骤2,我需要去爬取这个网页,解析里面的 HTML,找到跟集数有关的节点。但是 b 站是个特例,它有更方便的办法。它的 HTML 网页上挂载了一个 __INITIAL_STATE__ 对象,下面就有准确的信息。

+
+ +

因此,这一步变得非常简单,只需要解析这个对象即可。

+
export async function getDataByUrl(url) {
const { data } = await agent.get(url);
// console.log(data)
const initialStateStr = data.match(/__INITIAL_STATE__=(.*?);/)[1];
return JSON.parse(initialStateStr);
}

const data = await getDataByUrl(`https://www.bilibili.com/video/BV1wV411t7XQ`);
console.log(data.videoData.pages);
+ +

这样一来就拿到了这个列表的分集信息,接下来要解决下载问题。

+

下载视频文件

之前的网页上没有找到视频下载地址的信息,因此这部分需要单独的接口。通过在 GitHub 上寻找类似项目得到了一个可用方案:

+
const params = `cid=${cid}&module=movie&player=1&quality=112&ts=1`;
const params =`appkey=iVGUTjsxvpLeuDCf&cid=${cid}&otype=json&qn=112&quality=112&type=`;
const sign = crypto.createHash("md5").update(params + "[apikey]").digest("hex");
const playUrl = `https://interface.bilibili.com/v2/playurl?${params}&sign=${sign}`;
+ +

其中这个 cid 在之前的 pages 变量中是存在的,然后需要将 [apikey] 替换为实际的 apikey(大家都把它放在仓库中了,我也不例外,取之 GitHub 用之 GitHub,反正不是我泄密的就不算泄密),并且用 md5 算法做签名。至于其它变量,不太明白它们的实际意义。

+

这个工具主攻 mp3 转换,因此也不需要关心视频质量、水印等问题,突出一个能用就行。

+

调用接口,可以得到一个 flv 的下载链接:

+

+

然后就是简单粗暴的下载环节:

+
agent({
url,
method: 'GET',
responseType: 'stream',
headers: {
// 表示从第 0 个字节开始下载,直到最后一个字节,即完整的文件
Range: `bytes=${0}-`,
'User-Agent': 'PostmanRuntime/7.28.4',
// 实际调用时发现该接口还需要加上 referer 才能正常调用
Referer: 'https://www.bilibili.com/',
},
})
.then(({ data, headers }) => {
const writeStream = fs.createWriteStream(filename);
const total = parseInt(headers['content-length'], 10);
// 下载到的数据写入文件流
data.pipe(writeStream);
data.on('data', (chunk) => {
// todo 下载进度
});
data.on('end', () => {
// todo 下载结束
});
data.on('error', (err) => {
// todo 下载出错
});
})
.catch((err) => {
// todo 接口请求出错
});
+ +

转换mp3

在最初的版本,我能直接想到的工具就是 ffmpeg ,代码里面简单粗暴地直接调用 ffmpeg:

+
import { exec } from 'child_process';

export async function flv2mp3 (filename) {
return new Promise((resolve, reject) => {
const mp3 = filename.replace('.flv', '.mp3');
exec(`ffmpeg -i ${filename} -q:a 0 ${mp3}`, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
+ +

这么做,能用。但是用起来不太舒心。因为我毕竟要先下载一个 C 语言版本的 ffmpeg,并且把它设置到系统 Path 中,这样程序才能正常调用到它。这个对我来说是个一次性的工作,倒也没啥,但是对于想要使用这个工具的其它用户来说就很可能劝退了。但是不管怎么说,主流程到现在已经结束了。我已经完成了下载整个视频列表并且转换成 mp3 的程序,它能正常工作了。

+

但是,它现在还非常粗糙,需要在细节上做进一步的打磨。

+

细节优化

并行下载

我最早想到的并行方案,是用 lodash.chunk 将视频分割成一个个的小块(如:10个视频一块),处理完一个块再处理下一个块。

+
const pageChunks = chunk(pages, 10);
for (const c of pageChunks) {
await Promise.all(c.map(download2mp3));
}
+ +

这种方法好处是确实能实现并行下载,并且实现起来非常简单。至于它存在的问题,后面还会提到。

+

展示进度条

进度条对于一个下载工具来说至关重要,尤其是并行下载多个任务的时候。好在我不用自己实现,即使是命令行界面也有优秀的现成工具:progress 以及 multi-progress

+

而我只需要对它做一点简单的封装即可:

+
import Progress from 'multi-progress';

const multi = new Progress(process.stderr);

export function createProgressBar(index, title, total) {
// index 代表本次下载的序号
// [:bar]是进度条本体
// percent 进度百分比
// eta 预估剩余时间
// status 下载或转换进度
// title 下载文件的标题
return multi.newBar(`${index} [:bar] :percent :etas :status ${title}`, {
complete: '=',
incomplete: ' ',
width: 30,
total: total,
// renderThrottle: 1000,
});
}
+ +
agent({
url,
method: 'GET',
responseType: 'stream',
headers: {
Range: `bytes=${0}-`,
'User-Agent': 'PostmanRuntime/7.28.4',
Referer: 'https://www.bilibili.com/',
},
})
.then(({ data, headers }) => {
const writeStream = fs.createWriteStream(filename);
const total = parseInt(headers['content-length'], 10);
// 创建一个进度条,给它总字节长度
const bar = createProgressBar(index, title, total);
data.pipe(writeStream);
data.on('data', (chunk) => {
// 下载进度,本次传输的 chunk 字节长度,进度条将自动计算百分比
bar.tick(chunk.length, { status: 'downloading' });
});
data.on('end', () => {
writeStream.close();
// 将 bar resolve 出去,后面还要用到
resolve(bar);
});
data.on('error', (err) => {
// 出错了,进度条到底吧那就
bar.tick(total);
// 显示下载出错
bar.tick({ status: 'error' });
writeStream.close();
reject(err);
});
})
.catch((err) => {
reject(err);
});
+ +

失败重试

b 站的视频下载地址有一定的失败概率,因此做了一个简单粗暴的失败重试逻辑:

+
export async function download2mp3({ url, index }) {
let b;
try {
// 下载文件
const { filename, bar } = await download(url, offsetIndex);
b = bar;
// 开始转换了,设置一下进度条状态
bar.tick({ status: 'converting' });
// 转换 mp3
await flv2mp3(filename);
// 转换结束了,可以删掉下载的视频文件
await fs.promises.unlink(filename);
// 设置进度条状态为 done
bar.tick({ status: 'done' });
} catch (err) {
// 失败了
b?.tick({ status: 'error' });
// 等待 2 秒后,重新开始下载+转换
await sleep(2000);
await download2mp3({ url, index });
}
}
+ +

这个方法永远不会抛错,只要出错了就一直重试下去。对于我这种脚本程序来说,非常好用。

+

自定义命名

我希望下载下来的文件可以按照我想要的规则去命名,这样不管是从哪里下载的文件,最后都不会显得杂乱无章。

+

这个功能的实现部分参考了“哔哩哔哩助手”这个浏览器插件的做法,使用了 pattern 命名法:

+
export function getName(index, title, author, date) {
const argv = program.opts();
return (
argv.naming
.replace('INDEX', index)
.replace('TITLE', title)
.replace('AUTHOR', author)
.replace('DATE', date)
);
}
+ +

这个 naming 参数的默认值是 TITLE-AUTHOR-DATE,也就是 视频标题-视频作者-视频上传日期。它会将这个命名模式套用到具体的文件上。这个东西的用法是很灵活的。比如说,我希望我下载的文件要有序号,另外视频的上传者并不是演唱者,我希望显示演唱者。那么我常用的命名模式是 INDEX-TITLE-yousa-DATE。注意这里第三个坑位变成了 yousa,它并不在支持的 pattern 中,代表的含义就是它将固定为 yousa,不会再被替换了。

+

ffmpeg 优化

做开源软件的好处,除了得到用户的肯定外,我永远可以从别人那里学到新的东西,比如:

+

+

原来 ffmpeg 已经有了 wasm 版本:ffmpeg.wasm 。将 ffmpeg 替换为 ffmpeg.wasm 后,使用时使就不再需要预先安装 C 语言版本的 ffmpeg,也无需设置 path,用户体验可以得到大幅度的提升。

+

修改后的转换代码(大概长这样):

+
import { fetchFile, createFFmpeg } from '@ffmpeg/ffmpeg';
import * as fs from 'fs';
import { resolve } from 'path';

export async function flv2mp3 (filename) {
const after = filename.replace('.flv', '.mp3');
const ffmpeg = createFFmpeg({ log: false });
await ffmpeg.load();
ffmpeg.FS('writeFile', 'before.flv', await fetchFile(resolve(process.cwd(), filename)));
// ffmpeg -y -i ${filename} -q:a 0 ${mp3}
await ffmpeg.run('-y', '-i', 'before.flv', '-q:a', '0', 'after.mp3');
await fs.promises.writeFile(resolve(process.cwd(), after), ffmpeg.FS('readFile', 'after.mp3'));
}
+ +

看起来很美好。但实际运行起来后,发现一个令人哭笑不得的问题:

+
Rejection (Error): ffmpeg.wasm can only run one command at a time
+ +

它一次居然只能跑一个命令!也就是说,它跟我的多线程下载并不能很好地兼容。当多个文件同时下载完成后,如果即刻开始多个转换,程序就报错了。因此我需要做一个流程控制:当转换正在进行的时候,其它下载完的视频文件需要先进入排队状态:mp3 转换的过程得一个一个来。

+

根据上述思想修改一下 flv2mp3,用一个全局变量来控制同一时间只有一个任务能进来转换:

+
// 将 ffmpeg 实例提到外面来,全局共用
let ffmpeg = createFFmpeg({ log: false });
ffmpeg.load();
let isRunning = false;

export async function flv2mp3(filename, bar) {
while (!ffmpeg.isLoaded() || isRunning) {
// 有其它任务正在进行中,那么就排队等待吧...
bar.tick({ status: 'queueing' });
await sleep(1000);
}
bar.tick({ status: 'converting' });
isRunning = true;
// 这里是具体的转换任务...
isRunning = false;
}
+ +

虽然缺点有点夸张,但思考再三,我觉得跟 C 语言 ffmpeg 的使用体验比起来,这种方式还是更优一点。

+

并行优化

前面有提到我一开始设计的并行方案:

+
+

用 lodash.chunk 将视频分割成一个个的小块(如:10个视频一块),处理完一个块再处理下一个块

+
+

这种办法好处是实现简单,但缺点也很明显:b 站似乎对每个下载地址做了限速,因此有时候一个块里面一两个文件特别大,其它文件都下载完了,它还在慢悠悠的下载,让人感到浪费生命。实际上,它可以立即开始余下的其它任务的,只要保证正在运行的总的任务数量不超过设定的数值即可。

+

也许你会问:既然这么麻烦,为什么不全部同时开始下载呢?这个方式实际上我也试过,但是一旦同时下载的任务太多了(比如上面的一个链接,有 300 多个视频,更何况我们还能支持一次输入多个链接),下载的出错率会陡增。这样反而会导致效率急剧下降。因此是不可取的。

+

为了解决这个问题,我将并行控制的代码又优化了一下,摒弃了 chunk 的做法:

+
// 最大的进程数,默认为 10
let maxThreads = argv.threads;
// 当前进行中的进程数
let currentThreads = 0;
// 已完成的任务数
let finished = 0;

for (const page of pages) {
while (currentThreads === maxThreads) {
// 运行中的线程数量已达最大值,先排会队吧
await sleep(100);
}
// 开始新的线程
currentThreads += 1;
// 注意这里不用 await 了
download2mp3(page).finally(() => {
// 一个线程结束了
currentThreads -= 1;
finished += 1;
if (finished === pages.length) {
// 所有的任务都已完成,可以退出进程了
process.exit(0);
}
});
}
+ +

这样一来,任务并行的效率得到了极大的提升:同时进行中的任务数量会始终保持在最接近允许的最大数量的水平。充分利用上电脑的带宽。

+

ffmpeg.wasm 优化

上面我们解决了 C 语言 ffmpeg 带来的不适,但是同时引入了新的问题:同时只能运行一个转换任务,其它的下载完的视频要排队。这个问题在并发问题得到优化后被极大地放大了:经常是所有文件都已下载完,却仍有一大批文件在等待转换。这让人感到非常痛苦:我写的程序不应该这么蠢的。

+

因此,我又开始考虑这个问题的解决方案了:既然一个 ffmpeg.wasm 进程只能跑同时跑一个任务,那我为每个下载任务都单独开一个进程行不行?

+

Node.js 提供了一个 child_process.exec 函数,可以用来运行一个命令行任务。在最初的版本中,调用 C 语言的 ffmpeg 的任务也是通过这个完成的。现在,我能否利用它来调用 ffmpeg.wasm 呢?

+

先将 flv2mp3 的函数,抽离为一个独立的文件,在这个文件中直接运行转换:

+
(async () => {
// 要转换的文件名
let filename = process.argv[2];
let ffmpeg = createFFmpeg({ log: false });
await ffmpeg.load();
try {
// 转换...
process.exit(0);
} catch (err) {
// 出错了
process.exit(1);
}
})();
+ +

然后改造原来的 flv2mp3 文件:

+
export function flv2mp3(filename) {
return new Promise((resolve, reject) => {
exec(
`node ${join(__dirname, '_flv2mp3.js')} "${filename}"`,
{ cwd: process.cwd() },
(error) => {
if (error) {
reject(error);
} else {
resolve();
}
}
);
});
}
+ +

改造完后实测,这确实将该问题解决了。现在多个文件同时下载完后,也可以同时开始转换了!虽然同时开启多个转换进程后 CPU 的利用率会飙涨、风扇狂飙,但相比节约的时间来说,这都不是问题。

+

写在最后

这个工具现在看起来很简单,并没有什么过人之处。但是我在实现它的时候还是花了非常多的时间去调试。比如说,在加上了进度条后,整个 stdout 都会被进度条占据,console API 难以再打印出错误信息。因此我只能给它设计了一套基于文件的日志系统用来 debug。又比如说,项目早期在下载文件的时候,程序经常莫名奇妙地、没有任何征兆地就退出了,我需要非常细心地去寻找每一个可能抛错的点,尽可能优雅地捕获所有错误。

+

至今为止我已经用它下载了一千多首 yousa 的歌。这种自己开发工具来解决自己的问题,并且一步步地将它变得完美以及节约生命的愉快感真的是非常棒的。

+
126f0ee04db59831d6a9820ac89c471.jpg
46ff0c8d1022eaf087e0e42b7cd0319.jpg
+ +

最后更开心的当然是,我做的工具同时也能给其它素不相识的人带来愉悦:

+

+]]>
+ + nodejs + bilibili + +
+ + Cache Yarn in Github Actions + /2020/cache-node-modules-in-github-actions/ + 在 CI 中缓存安装下来的依赖项是提速的关键,Github Actions 官方文档 提供了如下方案 (NPM):

+ + +
jobs:
build:
# ...
- name: Cache node modules
uses: actions/cache@v2
env:
cache-name: cache-node-modules
with:
# npm cache files are stored in `~/.npm` on Linux/macOS
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Install Dependencies
run: npm install
# ...
+ +

Yarn 则复杂,多了一步操作(文档):

+
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v1
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
+ +

这些方案可以说是又臭又长,我只想简单做个 cache,何必让我关心那么多东西?项目多的话,简直疯了。看看人家 Gitlab 的方案:

+
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
+ +

简单、明确。

+

因此,我找到了这个 action c-hive/gha-yarn-cache 作为替代,现在代码可以简化为:

+
jobs:
build:
# ...
- uses: c-hive/gha-yarn-cache@v1
- run: yarn --frozen-lockfile
# ...
+ +

一行解决。

+]]>
+ + github + devops + nodejs + yarn + +
+ + Case insensitive auto-complete in OSX Terminal + /2018/case-insensitive-auto-complete-in-oxs-terminal/ + 在 Mac OSX 终端里面由于默认 Home 下面的文件夹都是大写开头,如 Downloads / Desktop 等,cd 的时候比较烦。解决方法:

+
$ echo "set completion-ignore-case On" >> ~/.inputrc
+ +

然后重启终端即可。

+]]>
+ + osx + shell + +
+ + CentOS7 Firewalld + /2019/centos7-firewall-commands/ + FirewallD (firewall daemon) 作为 iptables 服务的替代品,已经默认被安装到了 CentOS7 上面。

+ + +

管理

服务启动/停止

启动服务并设置自启动:

+
sudo systemctl start firewalld
sudo systemctl enable firewalld
+ +

停止服务并禁用自启动:

+
sudo systemctl stop firewalld
sudo systemctl disable firewalld
+ +

检查运行状态

sudo firewall-cmd --state
sudo systemctl status firewalld
+ +

服务重启

有两种办法可以重启 FirewallD:

+
    +
  1. 重启 FirewallD 服务
  2. +
+
sudo systemctl restart firewalld
+ +
    +
  1. 重载配置文件(不断开现有会话与连接)
  2. +
+
sudo firewall-cmd --reload
+ +

建议使用第二种方法。

+

配置

FirewallD 使用两个配置集:「运行时配置集」以及「持久配置集」。

+
    +
  1. 在 FirewallD 运行时:
      +
    1. 对运行时配置的更改即时生效
    2. +
    3. 对持久配置集的更改不会被应用到本次运行中
    4. +
    +
  2. +
  3. 在 FirewallD 重启(如系统重启或服务重启)或重载配置时:
      +
    1. 运行时配置集的更改不会被保留
    2. +
    3. 持久配置集的更改作为新的运行时配置而应用
    4. +
    +
  4. +
+

默认情况下,使用 firewall-cmd 命令对防火墙做出的更改都将作用于运行时配置集,但如果添加了 permanent 参数则可以将改动持久化。如果要将规则同时添加到两个配置集中,有两种方法:

+
    +
  1. 将规则同时添加到两个配置集中
  2. +
+
sudo firewall-cmd --zone=public --add-service=http --permanent
sudo firewall-cmd --zone=public --add-service=http
+ +
    +
  1. 将规则添加到持久配置集中,并重载
  2. +
+
sudo firewall-cmd --zone=public --add-service=http --permanent
sudo firewall-cmd --reload
+ +

区域

区域(Zone)是 FirewallD 的核心特性,其它所有特性都与 Zone 相关,Zone 可以理解为场景、位置等,我们可以给不同的 Zone 定义不同的规则集。

+

FirewallD 的默认配置中预定义了几个 Zone,按照可信度作升序排序依次为:drop -> block -> public -> external -> dmz -> work -> home -> internal -> trusted,其中 public 是默认值。

+

相关指令:

+
# list all zones
sudo firewall-cmd --get-zones

# get & set default zone
sudo firewall-cmd --get-default-zone
sudo firewall-cmd --set-default-zone=external

# interfaces
sudo firewall-cmd --zone=public --add-interface=wlp1s0
sudo firewall-cmd --zone=public --change-interface=wlp1s0

# get a list of all active zones
sudo firewall-cmd --get-active-zones

# print information about a zone
sudo firewall-cmd --info-zone public
+ +

端口

使用 --add-port 参数来打开一个端口以及指定它的协议,zone 如果不指定的话则为当前的默认值。例如,通过以下命令来允许 HTTP 以及 HTTPS 协议的网络流量进入:

+
sudo firewall-cmd --zone=public --permanent --add-port=80/tcp --add-port=443
sudo firewall-cmd --reload
+ +

通过 info 指令可以查看刚才添加的端口:

+
sudo firewall-cmd --info-zone public
+ +

使用 --remove-port 参数来阻止或关闭一个端口:

+
sudo firewall-cmd --zone=public --permanent --remove-port=80/tcp --remove-port=443/tcp
+ +

服务

使用 --add-service 以及 --remove-service 来启用、禁用服务。

+
# enable
sudo firewall-cmd --zone=public --permanent --add-service=http
sudo firewall-cmd --reload

# disable
sudo firewall-cmd --zone=public --permanent --remove-service=http
sudo firewall-cmd --reload
+ +

端口转发

# 启用 ip masquerade
sudo firewall-cmd --zone=public --add-masquerade

# 在同一台服务器上将 80 端口的流量转发到 8080 端口
sudo firewall-cmd --zone="public" --add-forward-port=port=80:proto=tcp:toport=8080

# 将本地的 80 端口的流量转发到 IP 地址为 :1.2.3.4 的远程服务器上的 8080 端口
sudo firewall-cmd --zone="public" --add-forward-port=port=80:proto=tcp:toport=8080:toaddr=1.2.3.4

# 删除规则
sudo firewall-cmd --zone=public --remove-masquerade
+ +]]>
+ + linux + +
+ + Change SOCKS Proxy to HTTP + /2017/change-socks-proxy-to-http/ + OSX

Use brew to install polipo via socks proxy:

+
$ ALL_PROXY=socks5://127.0.0.1:9500 brew install polipo
+ +

Create polipo.config file under Document:

+
socksParentProxy = "127.0.0.1:9500"
socksProxyType = socks5
proxyAddress = "::0"
proxyPort = 8123
+ +

Start polipo server:

+
$ polipo -c ~/Documents/polipo.config
Established listening socket on port 8123.
+ +

Verify it at http://localhost:8123.

+

Windows

Use privoxy tool. Download: http://www.privoxy.org/sf-download-mirror/Win32/

+

Install it, find the config file at \Privoxy\config.txt, append following to the bottom of it:

+
forward-socks5 / 127.0.0.1:9500 .
+ +

(Mind the dot at the end)

+

The default port is 8118, search from the config file to replace it.

+]]>
+ + osx + windows + proxy + +
+ + Common-used Commands + /2017/common-used-commands/ + Personal common-used commands list, including windows, osx, git, etc.

+ + +

Git

Clone

Full clone

+
$ git clone [url]
+ +

Fast clone

+
$ git clone --depth=1 [url]
$ git fetch --unshallow
+ +

Fetch

$ git fetch [origin] [branch]
+ +

Pull

$ git pull [origin] [orinin-branch]:[local-branch]
+ +

Push

$ git push [origin] [orinin-branch]:[local-branch]
+ +

Force push

+
$ git push --force origin 
+ +

Tags push

+
$ git push --tags origin 
+ +

Config

Show

+
$ git config user.name
wxsm

$ git config --list
user.name=wxsm
user.email=wxsms@foxmail.com
+ +

Set

+

Repo level:

+
$ git config user.name [name]
$ git config user.email [email]
$ git config http.proxy [proxy]
$ git config https.proxy [proxy]
+ +

Supports socks & http proxy.

+

Global level:

+
$ git config --global user.name [name]
$ git config --global user.email [email]
+ +

Unset

+
$ git config --unset user.email
$ git config --global --unset user.email
+ +

Remote

$ git remote -v
origin https://github.com/wxsms/uiv.git (fetch)
origin https://github.com/wxsms/uiv.git (push)

$ git remote set-url origin git@github.com:wxsms/uiv.git

$ git remote -v
origin git@github.com:wxsms/uiv.git (fetch)
origin git@github.com:wxsms/uiv.git (push)
+ +

NVM

nvm ls

nvm install [version]
NVM_NODEJS_ORG_MIRROR=https://npm.taobao.org/mirrors/node/ nvm install [version]

nvm use [version]
nvm alias default [version]
+ +

OSX

Keys

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameSymbol
command
option
shift
caps lock
control
return
enter
+

Shortcuts

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameSymbol
search⌘ + space
switch input⌃ + space
delete⌘ + delete
Lock screen⌘ + ⌃ + Q
Screen shot (full)⌘ + ⇧ + 3
Screen shot (custom)⌘ + ⇧ + 4
Screen shot (window)⌘ + ⇧ + 4 + space
Screen shot & copy (full)⌘ + ⇧ + ⌃ + 3
Screen shot & copy (custom)⌘ + ⇧ + ⌃ + 4
Screen shot & copy (window)⌘ + ⇧ + ⌃ + 4 + space
Hide window⌘ + H
Minimize window⌘ + M
Quit⌘ + Q
+

Proxy command

$ ALL_PROXY=socks5://127.0.0.1:9500 brew update
+ +

Toggle hidden files

$ defaults write com.apple.finder AppleShowAllFiles YES
$ defaults write com.apple.finder AppleShowAllFiles NO
+ +

Open files

$ open nginx.conf
$ open -a TextEdit nginx.conf
+ + +]]>
+ + shell + +
+ + Conditional Rendering in React + /2019/conditional-rendering-in-react/ + 如何进行条件渲染是一个 MVx 框架最基础的问题之一,但是它在 React 中总是会给人提出各种各样的问题。要么「不够优雅」,要么「不够可靠」,要么「不够好用」,现有的各种各样的方法之中,总是逃不过这三种问题的其中之一。至于 React-Native,虽然它与 React 「原则上一致」,但它存在的问题实际上就是要比 React 更多一些。

+ + +

if 语句与三元表达式

在 JSX 世界中,用 if 语句以及三元表达式去完成条件渲染是最直观的方式。

+
function Greeting(props) {
const isLoggedIn = props.isLoggedIn;
// the 'if' way
if (isLoggedIn) {
return <UserGreeting />;
}
return <GuestGreeting />;
// or the 'conditional operator' way
// return isLoggedIn ? <UserGreeting /> : <GuestGreeting />;
}
+ +

这种方式确实足够优雅,也足够可靠:毕竟它完美地沿用了语言本身的逻辑和语法,没有创造其它累赘的东西。但是它真的好用吗?我相信许多重度 React 使用者都会对此表示无奈:现实工程中诸如此类需要做条件渲染的地方多如牛毛,如果每一处我们都得给它写 if-else(三元表达式虽然相对来说更好用一些,但是它的应用场景毕竟更有限)并且将条件渲染体抽离主体作为子组件来做,那我真的好绝望,这感觉就好像是在现代社会躬行刀耕火种一样。况且不是所有的项目都需要「完美地优雅」,更多时候我们这种开发者只想尽快把工作完成,仅此而已。

+

实际上这种方案已经足以应付 100% 场景的需求了,并且你可能已经意识到,本质上来说这就是唯一的方案。但其存在的问题实在过于让人沮丧,因此才有了下面的一些「拓展」方案。

+

使用变量

React 官方文档提出的第二种方式是使用变量,通过将元素暂存在变量中,可以让开发者控制组件中的一部分而不影响其它内容。

+
class LoginControl extends React.Component {
//...
render() {
const isLoggedIn = this.state.isLoggedIn;
let button;

if (isLoggedIn) {
button = <LogoutButton onClick={this.handleLogoutClick} />;
} else {
button = <LoginButton onClick={this.handleLoginClick} />;
}

return (
<div>
<Greeting isLoggedIn={isLoggedIn} />
{button}
</div>
);
}
}
+ +

这样做的好处是能够更灵活地控制组件内部的渲染,而不必去创建更多的子组件。但是其问题非常明显:创建了一个「多余」的变量,非常地不「优雅」。所谓「如无必要,勿增实体」,放在代码世界同样适用。它所做的事情也仅仅是将原本在 JSX 内部的条件判断挪到了外面,仅此而已。

+

想象一下这种场景:一个导航栏组件,其中的每个菜单、每个按钮都要根据某种条件去决定是否渲染,一个多余的变量就会变成几十个,最终导致代码中充斥着这样重复的、没有实际意义的垃圾,这是一个有追求的码农绝对无法忍受的。

+

这种方式有一个变体,就是通过创建一个类的 getter 来代替创建一个变量:

+
class LoginControl extends React.Component {
//...
get _button () {
const isLoggedIn = this.state.isLoggedIn;
if (isLoggedIn) {
return <LogoutButton onClick={this.handleLogoutClick} />;
} else {
return <LoginButton onClick={this.handleLoginClick} />;
}
}

render() {
return (
<div>
<Greeting isLoggedIn={isLoggedIn} />
{this._button}
</div>
);
}
}
+ +

这是一个小变通,好处是可以让 render 函数在条件渲染的数量上来以后不那么臃肿,易于维护。它与使用变量的方式并没有本质区别,同样是创建了一个无必要的 getter,但是确实是看起来更「优雅」了一些。但它只能在类组件中使用,无法在函数式组件中使用。

+

行内表达式

行内三元表达式可以用来解决一些小的 case,但由于其本身存在着巨大的限制,不可能被广泛使用。

+
render() {
const isLoggedIn = this.state.isLoggedIn;
return (
<div>
The user is <b>{isLoggedIn ? 'currently' : 'not'}</b> logged in.
</div>
);
}
+ +

它的自身限制,一是只能做是非判断,如果要在是非的结果中继续判断就要再套一层三元表达式,那将会相当臃肿;二是它本身的语法就只适用于「小」的东西,像这种案例:

+
render() {
const isLoggedIn = this.state.isLoggedIn;
return (
<div>
{isLoggedIn ? (
<LogoutButton onClick={this.handleLogoutClick} />
) : (
<LoginButton onClick={this.handleLoginClick} />
)}
</div>
);
}
+ +

我是绝对写不出来的。实在是太不优雅、太难以阅读了。想象一下充斥着 && / || / ? / : 的 JSX 代码。:smile:

+

至于行内的 if-else,虽然不能直接写 if-else,但有一个利用了语言本身特性的方案,即利用逻辑操作符:

+
function Mailbox(props) {
const unreadMessages = props.unreadMessages;
return (
<div>
<h1>Hello!</h1>
{unreadMessages.length > 0 &&
<h2>
You have {unreadMessages.length} unread messages.
</h2>
}
</div>
);
}
+ +

这种方式适合用来做单方面的条件渲染,即条件成立则渲染,否则不渲染,或者反之。使用场景依然有限,同样可读性较差,只适合用来渲染较小的代码块。

+

而且,很多人也许不知道,这种写法在 React-Native 中是一个陷阱。由于 JavaScript 本身的特性允许(或者说鼓励)truthyfalsy 形式的条件判断,很多情况下我们不会去刻意将它显式转换为一个 Boolean 值。当然在大多数情况下这都没有问题,除非它是一个空字符串:

+
render() {
const isLoggedIn = this.state.isLoggedIn;
// when it is ''
return (
<View>
{isLoggedIn && <LogoutButton/>}
</View>
);
}
+ +

这种代码在运行的时候会抛出一个 Error,导致应用崩溃:

+
Error: Text strings must be rendered within a <Text> component.
+ +

比如说,当你用某个 API 返回的数据中的某个值去进行条件渲染的时候,正常来说没有问题,但某一天服务突然出错了,这个字段返回了一个空字符串,那么应用就会突然面临大规模的崩溃。数据来源不可靠且没有进行显式 Boolean 转换的条件判断就像一个地雷,随时随地都可能会爆炸。

+

封装的方法

以上的几种形式其实都与刀耕火种无异,因此我们还有更高级的方案,比如封装一个方法:

+
function renderIf (flag) {
return function _renderIf (componentA, componentB = null) {
return flag ? componentA : componentB;
};
}
+ +

这样一来代码就可以简洁多了:

+
class LoginControl extends React.Component {
//...
render() {
const isLoggedIn = this.state.isLoggedIn;
return (
<div>
<Greeting isLoggedIn={isLoggedIn} />
{renderIf(isLoggedIn)(<LogoutButton/>, <LoginButton/>)}
</div>
);
}
}
+ +

类似的方案还有封装组件:

+
function RenderIf ({flag, children}}) {
return flag ? children : null;
}

class LoginControl extends React.Component {
//...
render() {
const isLoggedIn = this.state.isLoggedIn;
return (
<div>
<Greeting isLoggedIn={isLoggedIn} />
<RenderIf flag={isLoggedIn}>
<LogoutButton/>
</RenderIf>
<RenderIf flag={!isLoggedIn}>
<LoginButton/>
</RenderIf>
</div>
);
}
}
+ +

这种方案避免了在渲染函数体内出现大量的逻辑控制语句,取而代之的是可读性更强,也更易于使用的方法或组件,可以认为是在 React 世界中比较好的条件渲染解决方案了,「优雅」与「好用」都得到了较好的兼顾。但是,问题就出在,它不够「可靠」。

+

比如说以下的代码:

+
class NavBar extends React.Component {
//...
render() {
const user = this.state.user;
return (
<div>
{renderIf(user)(<Welcome name={user.name}/>)}
</div>
);
}
}
+ +

跟以上提及过的所有方案都不一样的是,当 usernullundefined,或者其它任何在执行 user.name 会报错的 value 时,这一段代码就会报错。如果是 React-Native 应用,很不幸它就崩溃了。

+
TypeError: Cannot read property 'name' of null
+ +

导致这个区别的原因是,JavaScript 函数在执行之前会先对它的参数进行解析,而非等到真正运行时才解析。所以当 user 为以上 value 之一的时候,虽然按照函数的流程来说应该会对第一个参数直接忽略,但是实际上它还是被解析了。而 if-else 等内置条件判断语句则不同,假值条件之后的代码块会被完全忽略。

+

说到这里我已经开始怀念 ng-ifv-if 了:我只是一个普普通通的开发者,为什么我需要在如此基础的事情上考虑这么多?大家都是前端 MVx 框架,为什么 Vue.js 和 Angular.js 从来没有提出过这种问题?

+

大概这就是 React,这就是 Facebook 吧!

+

其实在这个方案出现问题之后,我已经找不出别的更好的方案了,除非某一天 EcmaScript 有了新的提案,新的语法,否则都将无解。因为无论怎么做,最终都无法绕过传参必然会被事先解释这一障碍。也就是说,在目前的大环境下,我无法得到一个在各种场景下都同时兼具「优雅」、「可靠」、「好用」的条件渲染解决方案,只能通过以上各种方案在不同场景下的混用来达到一个(有追求的码农的内心的)平衡。

+]]>
+ + javascript + react-native + react + +
+ + CORS Headers Note + /2017/cors-headers-note/ + CORS HTTP Header 是解决 Ajax 跨域问题的方案之一。详情查看:MDN

+

这篇文章主要是记录使用过程中遇到的问题以及解决方案。

+ + +

客户端

客户端正常情况无需特殊配置。但有一些需要注意的地方。

+

请求预检

CORS 请求与非跨域请求不一样的是,它会将请求分成两种类型:Simple Request(简单请求)Preflighted Request(预检请求)

+

Simple Request

满足所有条件的请求为简单请求。

+

看了文档以后发现跟普通请求别无二致。

+

Preflighted Request

满足任一条件的请求为预检请求。

+

与简单请求不同,预检请求要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求,以避免跨域请求对服务器的用户数据产生未预期的影响。

+

预检请求示意图

+

所以,实际上这种跨域请求会产生两次 HTTP Request:一个预检请求,以及预检成功后的真正的请求。由于预检请求使用 OPTIONS 方法而不是常见的 POST 等,因此服务器必须为跨域 API 提供能够正确返回的相应方法。

+

身份验证

如果需要进行 Cookie / Session / HTTP Authentication 等操作,则必须在进行 Ajax 请求时带上一个 withCredentials 参数。至于如何带这个参数,每个 Lib 应该都有自己的配置方式,下面是两个例子。

+

Raw Ajax Example:

+
var invocation = new XMLHttpRequest();
var url = 'http://bar.other/resources/credentialed-content/';

function callOtherDomain(){
if(invocation) {
invocation.open('GET', url, true);
invocation.withCredentials = true;
invocation.onreadystatechange = handler;
invocation.send();
}
}
+ +

Using Axios Example:

+
let corsAgent = axios.create({
withCredentials: true
})
+ +

服务端

服务端的配置并不是只需要给请求响应加个 Access-Control-Allow-Origin Header 这么简单,还有其它需要处理的地方。因此自己做远不如直接使用相关 Lib 来得方便。比如:

+ +

withCredentials

当启用 withCredentials 参数后,Access-Control-Allow-Origin 将不能设置为 * (允许所有域名),必须指定为唯一的域名,否则预期的效果将无法达到。由于这个规则不会产生 Warning 或 Error,出了问题不了解情况的话还是比较难发现的。

+

可以预见(事实)的是,当 Access-Control-Allow-Origin 指定了唯一域名后,使用其它域名访问该 API 也会出现无效的问题。不过相应地也有一个取巧的办法,就是将它设置为 Request 的 Origin Header,这样一来问题就解决了。

+]]>
+ + nodejs + ajax + +
+ + CSS 绘制三角形 + /2016/css-triangle/ + 关于如何使用CSS中的border属性绘制各式各样的三角形。下面有一个国外友人制作的动画,对其原理进行了直观的阐释,我简单地做了点翻译。

+ + + + +

 

+

要点:

+
    +
  • 元素不能有宽高(当然也可以稍作变化来绘制梯形)
  • +
  • 只有一边border显示颜色,其宽度即为三角形的高
  • +
  • 与其相邻的border设置为透明色,它们将决定三角形的形状
  • +
+

更多的例子:

+
<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8"/>
<title>CSS Triangle</title>
<style>
.arrow-up {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid black;
}

.arrow-down {
width: 0;
height: 0;
border-left: 20px solid transparent;
border-right: 20px solid transparent;
border-top: 20px solid #f00;
}

.arrow-right {
width: 0;
height: 0;
border-top: 60px solid transparent;
border-bottom: 60px solid transparent;
border-left: 60px solid green;
}

.arrow-left {
width: 0;
height: 0;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-right: 10px solid blue;
}
</style>
</head>
<body>
<div class="arrow-up"></div>
<div class="arrow-down"></div>
<div class="arrow-left"></div>
<div class="arrow-right"></div>
</body>
</html>
+ +

效果:

+

以上的例子都是使用实体元素来绘制三角形,其实实际情况下使用伪元素的(before,after)会更多一些。

+]]>
+ + css + +
+ + D3 Note - Basis + /2016/d3-note-basis/ + D3 (Data-Driven Documents) 是一个 JavaScript Library,用来做 Web 端的数据可视化实现以及各种绘图。

+
+

D3.js is a JavaScript library for manipulating documents based on data. D3 helps you bring data to life using HTML, SVG, and CSS.

+
+

学习 D3 需要很多预备知识:

+
    +
  1. HTML / DOM
  2. +
  3. CSS
  4. +
  5. JavaScript (better with jQuery)
  6. +
  7. SVG
  8. +
+

HTML / CSS 不必多说,因为 D3 含有大量链式操作函数以及选择器等,因此如果有 jQuery 基础将轻松很多。此外,由于一般采用 SVG 方式进行绘图,所以 SVG 基础知识也需要掌握。

+

虽然必须的预备知识如此之多,但 D3 的定位其实是 Web 前端绘图的底层工具,所谓底层,即是操作复杂而功能强大者。

+ + +

关于 SVG

SVG (Scalable Vector Graphics) 是一种绘图标准,已经被绝大多数的现代浏览器所支持。SVG 采用 XML 语法定义图像,可直接嵌入 HTML 中使用。

+

SVG 的特点是矢量绘图(与 Canvas 不同),除了预设样式以外同时也支持 CSS 样式。

+

比如,画一个园圈,坐标为 (100, 50),半径为 40px,拥有 2px 的黑色 border,以及红色填充:

+
<svg>
<circle cx="100" cy="50" r="40" stroke="black" stroke-width="2" fill="red"/>
</svg>
+ +

SVG 有一些预定义的形状元素,可被开发者使用和操作:

+
    +
  • 矩形 <rect>
  • +
  • 圆形 <circle>
  • +
  • 椭圆 <ellipse>
  • +
  • 线 <line>
  • +
  • 折线 <polyline>
  • +
  • 多边形 <polygon>
  • +
  • 路径 <path>
  • +
+

其中,path 是功能最强大者,使用 path 可以构成所有图形。

+

选择器

选择元素

D3 使用与 jQuery 类似的选择器来获取 HTML 元素。常用的方法有:

+
    +
  • d3.select(selector)
  • +
  • d3.selectAll(selector)
  • +
+

(参数既可以传 selector 也可以直接传 HTML Element )

+

顾名思义,selectAll 就是选择所有符合条件的元素了,那么 select 选择的是符合条件的第一个元素。如:

+
d3.select('body') //选择 body 元素

d3.selectAll('p') //选择所有 p 元素

d3.selectAll('.className') //选择所有 class 包含 className 的元素
+ +

更多就不说了。

+

操作选择

选择器返回的是一组选择(selection),这组选择可以进行一些操作,如:

+
    +
  • 在此选择的基础上继续选择;
  • +
  • 改变属性;
  • +
  • 改变样式;
  • +
  • 绑定事件;
  • +
  • 插入、删除;
  • +
  • 绑定数据。
  • +
+

大多数操作都与 jQuery 十分类似,同时也支持链式操作,不再赘述。只是这个“绑定数据”操作稍有特别。

+

数据绑定

通过 D3 可以把数据“绑定”到 HTML 元素上,绑定的目的主要是为了方便一些需要相应数据才能进行的元素操作(如:更改元素大小、位置等)。

+

绑定数据有两个方法:

+
    +
  • datum: 将一个数据绑定到选择上;
  • +
  • data: 将一个数组绑定到选择上,数组的各项分别与选择的各项一一对应。
  • +
+

下面引用一个例子来说明这二者的不同。假设有如下三个节点:

+
<p>Apple</p>
<p>Pear</p>
<p>Banana</p>
+ +

datum

执行以下代码:

+
let str = 'datum';
let p = d3.selectAll('p');

p.datum(str);
p.text((d, i) => `Element ${i} bind with ${d}`);
+ +

将得到:

+
Element 0 bind with datum
Element 1 bind with datum
Element 2 bind with datum
+ +

在对选择进行操作时,传入的可以是,也可以是函数。当传入函数时,D3 会向函数注入两个参数,分别是 d (data) 与 i (index),代表当前元素绑定的数据与其索引。

+

data

执行以下代码:

+
let strArr = ['data0', 'data1', 'data2'];
let p = d3.selectAll('p');

p.data(strArr);
p.text((d, i) => `Element ${i} bind with ${d}`);
+ +

将得到:

+
Element 0 bind with data0
Element 1 bind with data1
Element 2 bind with data2
+ +

可以看到,数组中的 3 个项分别与 3 个 p 元素绑定到了一起。因此,可以将 datum 看作是 data 函数的一个特例,实际开发中使用更多的是 data 函数。

+

实践:简单柱状图

先定义一个 SVG 画布,并将它插入到 HTML 的 body 中去:

+
let width = 300,
height = 300

let svg = d3.select('body')
.append('svg')
.attr('width', width)
.attr('height', height);
+ +

在这里,画布的宽高都为 300 像素。

+

然后,定义一组数据:

+
let data = [250, 210, 170, 100, 190];
+ +

最后使用以上数据画出柱状图,柱子使用 SVG 预定义的 rect 元素:

+
let rectWidth = 25;

svg.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('y', (d, i) => height - d)
.attr('x', (d, i) => i * rectWidth)
.attr('height', d => d)
.attr('width', rectWidth - 2)
.attr('fill', 'steelblue');
+ +

rectWidth 表示柱子的宽度,至于坐标、宽高则分别通过 x / y 以及 height / width 属性来控制,效果如下:

+

+

可以发现,这里并没有指定需要插入的 rect 个数,但 D3 却根据数据量自动地把图画出来了,这个工作是通过 enter 语句完成的。关于其工作原理,下回分解。

+]]>
+ + d3 + +
+ + D3 Note - Enter, Update and Exit + /2016/d3-note-enter-update-and-exit/ + 在 D3 的使用过程中,我们见得最多的应当是类似如下的代码:

+
let div = d3.select('body')
.selectAll('p')
.data([3, 6, 9, 12, 15])
.enter()
.append('p')
.text(d => d);
+ +

将得到:

+
<body>
<p>3</p>
<p>6</p>
<p>9</p>
<p>12</p>
<p>15</p>
</body>
+ +

光看代码完全不能理解 D3 到底做了些什么,其实这里关键是 enter 的使用。

+ + +

Overall

enter 其实是一个选择集(selection),与其对应的还有 updateexit,选择集中的元素由原始选择集与绑定的数据决定。

+

+

selection.enter

+

Returns the enter selection: placeholder nodes for each datum that had no corresponding DOM element in the selection. The enter selection is determined by selection.data, and is empty on a selection that is not joined to data.

+

The enter selection is typically used to create “missing” elements corresponding to new data.

+
+

简述就是,enter 会根据现有 selection 与绑定的数据量,自动“补齐”所缺失的元素。

+

比如,例子中,如果 .selectAll('p') 返回的 selection 中包含 3 个元素,那么因为 data 的长度为 5,enter 就会补齐缺失的 2 个元素,并返回包含这三个补齐元素的 selection,接下来的操作,就是针对这个 selection 进行的。

+

因此,在进行 enter 操作时,一般会事先把相关现有元素尽数清除,以免出现漏操作的情况。

+

至于再对 enter 选择集进行 append 操作时为什么会追加到 body 节点上去,这里就涉及到另一个概念:selection.update

+

selection.update

理解了 enterupdate 就很简单了,顾名思义,所指就是已有的,能够与绑定 data 一一对应上的元素的选择集。

+

因此,实际上并没有 selection.update 这个方法,因为没有必要,当前选到的就是 update 集了。

+

至于为什么例子中的 enter 集能够追加到 body 中去,根据 D3 文档:

+
+

If the specified type is a string, appends a new element of this type (tag name) as the last child of each selected element, or the next following sibling in the update selection if this is an enter selection.

+
+

当进行 selection.append 操作时,如果 selection 是一个 enter 集,那么 append 就会向相应 update 集的末尾追加。那么,自然,如果 update 集为空,就会往父元素内追加。

+

selection.exit

+

Returns the exit selection: existing DOM elements in the selection for which no new datum was found.

+
+

对于已有 DOM 元素但没有 data 与之绑定的集合,使用 selection.exit 来获取。

+

如果集合没有绑定 data,则返回空集合。如果多次调用 exit,之后的 exit 会返回空集合。

+

通常,对于 exit 集的操作,都是 remove

+
selection.exit().remove()
+ +

 

+]]>
+ + d3 + +
+ + D3 Note - Interpolate + /2016/d3-note-interpolate/ + d3-interpolate 是 D3 的核心模块之一,与比例尺有些类似,interpolate (插值)所做的也是一些数值映射的工作。区别是,interpolate 的定义域始终是 0 ~ 1,并且始终为线性的。所以,更多时候它用来与 D3 的一些其他模组集成使用(如 transition, scale 等)。

+ + +

举个例子:

+
let i = d3.interpolateNumber(10, 20); // 10 as a, and 20 as b
i(0.0); // 10
i(0.2); // 12
i(0.5); // 15
i(1.0); // 20
+ +

返回的函数 i 称作 interpolator (插值器)。给定值域 ab,并且传入 [0, 1] 这个闭区间内的任意值,插值器将返回对应的结果。通常情况下,a 对应参数 0,b 对应参数 1

+

跟比例尺一样,插值器也可以接受其他类型的参数,如:

+
d3.interpolateLab("steelblue", "brown")(0.5); // "rgb(142, 92, 109)"
+ +

甚至对象、数组:

+
let i = d3.interpolate({colors: ["red", "blue"]}, {colors: ["white", "black"]});
i(0.0); // {colors: ["rgb(255, 0, 0)", "rgb(0, 0, 255)"]}
i(0.5); // {colors: ["rgb(255, 128, 128)", "rgb(0, 0, 128)"]}
i(1.0); // {colors: ["rgb(255, 255, 255)", "rgb(0, 0, 0)"]}
+ +

d3.interpolate

interpolate 模块提供了很多子方法,然而,大多数情况下,直接调用这个就足够了。因为 D3 会根据传入的数据类型自动匹配子方法(注意:是基于参数 b 的数据类型)。

+

决定算法:

+
    +
  1. 如果 b 是 null, undefinedboolean,则函数返回的是常量 b
  2. +
  3. 如果 b 是数字,则使用 interpolateNumber 方法
  4. +
  5. 如果 b 是颜色或者可以转换为颜色的字符串,则使用 interpolateRgb 方法
  6. +
  7. 如果 b 是时间,则使用 interpolateDate 方法
  8. +
  9. 如果 b 是字符串,则使用 interpolateString 方法
  10. +
  11. 如果 b 是数组,则使用 interpolateArray 方法
  12. +
  13. 如果 b 可以强转为数字,则使用 interpolateNumber 方法
  14. +
  15. 使用 interpolateObject 方法
  16. +
  17. 基于 b 的类型,将 a 强转为相同类型
  18. +
+

各个方法可以直接查看文档获取用法,大同小异。比较有趣的是 interpolateString,它可以检测字符串中的数字,并且做类似这样的事情:

+
+

For example, if a is “300 12px sans-serif”, and b is “500 36px Comic-Sans”, two embedded numbers are found. The remaining static parts of the string are a space between the two numbers (“ “), and the suffix (“px Comic-Sans”). The result of the interpolator at t = 0.5 is “400 24px Comic-Sans”.

+
+

至于插值函数的用处,比较多,举一个例子:d3-transition 有一些平滑动画的实现函数需要用到插值,比如说地球的动画滚动效果:

+
d3.transition()
.duration(1000)
.tween('rotate', () => {
let r = d3.interpolate(projection.rotate(), [-geo[0], -geo[1]])
return (t) => {
rotateGlobeByAngle(r(t))
}
})
.on('end', () => {
// do something...
})
+]]>
+ + d3 + +
+ + D3 Note - Scale + /2016/d3-note-scale/ + 之前做的柱状图例子:

+
let data = [250, 210, 170, 100, 190]

let rectWidth = 25

svg.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('y', (d, i) => height - d)
.attr('x', (d, i) => i * rectWidth)
.attr('height', d => d)
.attr('width', rectWidth - 2)
.attr('fill', 'steelblue')
+ +

有一个严重的问题,就是没有比例尺的概念,柱状图的高度完全由数据转换成像素值来模拟。这明显是不科学的:如果数据的值过小或过大,作出来的图就会很奇怪,同时也无法做到非线性的映射。

+

就跟地图需要比例尺一样,绝大多数的数据图表也需要比例尺。

+
+

Scales are a convenient abstraction for a fundamental task in visualization: mapping a dimension of abstract data to a visual representation.

+
+

比例尺 - Scale - “将某个维度的抽象数据做可视化映射”

+

至于可视化映射的具体实现,d3-scale 模块提供了许多方案,大致可以分为两类:

+
    +
  • Continuous Scales(连续映射)
  • +
  • Ordinal Scales(散点映射)
  • +
+ + +

Continuous Scales

Continuous Scales(连续映射)将连续的、定量的 Input Domain(定义域)映射为一个连续的 Output Range(值域)。如果 Range 也是一个数值范围,那么映射操作可以被反转(即从值域到定义域)。

+

连续值映射是一个抽象的概念,不能直接构造。因此,d3-scale 提供了一些具体实现,如线性、次方、对数等。

+

一个简单的例子:

+
let x = d3.scaleLinear()
.domain([10, 130])
.range([0, 960]);

x(20); // 80
x(50); // 320
+ +

这里构造了一个 scaleLinear (线性比例尺),并设置了输入及输出范围。构造器将会返回一个函数,这个函数接受输入值,并且返回对应的输出值。

+

如果输入值超出了预定义的范围,那么自然而然地,函数返回的输出值也会超出范围。但是,D3 提供了一个选项 clamp, 可以将输出范围保持在定义值内:

+
x.clamp(true);
x(-10); // 0, clamped to range
+ +

Output Domain 除了可以为数字,也可以是其它东西。比如颜色:

+
let color = d3.scaleLinear()
.domain([10, 100])
.range(["brown", "steelblue"]);

color(20); // "#9a3439"
color(50); // "#7b5167"
+ +

同时,Continuous Scales 也支持插值(interpolate)操作,这是 D3 的另一个模块。

+

Linear Scales

线性比例尺,顾名思义,输出值对于输入值而言是线性变化的。

+
+

y = ax + b

+
+

Power Scales

次方比例尺,与 Linear Scales 类似,但是需要多加一个参数:exponent (次方)

+
+

y = mx^k + b

+
+
pow.exponent([exponent]) // default 1
+ +

在需要做次方根的时候,使 exponent = 0.x 就可以了,对于 0.5 这个特值,D3 还提供了快捷方式:d3.scaleSqrt(),这将直接构造 exponent = 0.5 的 Power Scale

+

Log Scales

对数比例尺,与 Power Scales 类似,参数变为 base (底数)

+
+

y = m log(x) + b

+
+

因为 log(0) = -∞,Log Scales 的 Input Domain 不能够跨越 0,即要么全为正,要么全为负

+

Identity Scales

全等比例尺,特殊的线性比例尺。定义域与值域完全相等。因此,它的 invert 方法也就是它本身。

+
+

y = x

+
+

Time Scales

时间比例尺,线性比例尺的变体。例子:

+
let x = d3.scaleTime()
.domain([new Date(2000, 0, 1), new Date(2000, 0, 2)])
.range([0, 960]);

x(new Date(2000, 0, 1, 5)); // 200
x(new Date(2000, 0, 1, 16)); // 640
x.invert(200); // Sat Jan 01 2000 05:00:00 GMT-0800 (PST)
x.invert(640); // Sat Jan 01 2000 16:00:00 GMT-0800 (PST)
+ +

Sequential Scales

Sequential ScalesContinuous Scales 类似,区别是,这个比例尺的值域是由 interpolator 决定的,不可控制。同时,invert, range, rangeRound 以及 interpolate 都不可用。

+

D3 提供了一系列的颜色插值器,因此其应用场景多与连续的颜色值域有关。

+

Quantize Scales

Quantize ScalesLinear Scales 类似,区别是,其值域是离散的。定义域将基于值域元素的个数被切割为相等的线段,输出值为线段到值域的一对一映射

+
+

y = m round(x) + b

+
+

例子:

+
let color = d3.scaleQuantize()
.domain([0, 1])
.range(["brown", "steelblue"]);

color(0.49); // "brown"
color(0.51); // "steelblue"
+ +

Quantile Scales

Quantile scalesQuantize Scales 类似,区别是,其值域是“离散连续的”,即“离散的连续片段”。

+

首先,构造器会对定义域进行排序操作,然后根据值域元素的个数切分为相等的片段。如果无法等分,多余的元素将被加入到最后一组。

+

如:

+
let quantile = d3.scaleQuantile()
.domain([1, 1, 2, 3, 2, 3, 16])
.range(['blue', 'white', 'red']);

quantile(3) // will output "red"
quantile(16) // will output "red"
+ +

解析:

+

其定义域将先被排序,而后被切分为 3 个片段:[1, 1], [2, 2], [3, 3, 16]

+

此时,如果执行 quantile.quantiles(),将得到一个数组 [2, 3],长度为值域长度减一。假设将其赋值为 quantiles,其含义为:

+
    +
  • 定义域中小于 quantiles[0] 的值的,将被划分到第一个片段
  • +
  • 大于等于数组元素 quantiles[0] 的值但是小于数组元素 quantiles[1] 的值的,将被划分到第二个片段
  • +
  • 以此类推
  • +
+

划分线段后,定义域就与值域成为一一对应的关系了。因此就有了以上结果。

+

Threshold Scales

Threshold scalesQuantile Scales 类似,区别是,我们将往 domain 中 直接传入与前者类似的 quantiles,也就是说,真正的定义域不做限制,限制的是它划分片段的方式

+

例子:

+
let color = d3.scaleThreshold()
.domain([0, 1])
.range(["red", "white", "green"]);

color(-1); // "red"
color(0); // "white"
color(0.5); // "white"
color(1); // "green"
color(1000); // "green"
+ +

Ordinal Scales

Ordinal Scales(散点映射)

+

与连续映射不同,散点映射接受离散的定义域与值域。比如在一个博客中把不同的标签映射到一组颜色上去等。如果值域的元素量比定义域少,那么值域会“重复使用”。如:

+
let ordinal = d3.scaleOrdinal()
.domain(["apple", "orange", "banana", "grapefruit"])
.range([0, 100]);

ordinal("apple"); // 0
ordinal("orange"); // 100
ordinal("banana"); // 0
ordinal("grapefruit"); // 100
+ +

Band Scales

Band ScalesOrdinal Scales 类似,区别是,其值域是连续的数值。例子:

+
let band = d3.scaleBand()
.domain(["apple", "orange", "banana", "grapefruit"])
.range([0, 100]);

band("apple"); // 0
band("orange"); // 25
band("banana"); // 50
band("grapefruit"); // 75
+ +

Band Scales 提供了一些实用方法,用于控制映射的结果。比如获取 Band Width,强制转换整数,添加 Padding 等。

+

Point Scales

Point ScalesBand Scales 的特例,它的 Band Width 始终为 0

+
let point = d3.scalePoint()
.domain(["apple", "orange", "banana", "grapefruit"])
.range([0, 100]);

point("apple"); // 0
point("orange"); // 33.333333333333336
point("banana"); // 66.66666666666667
point("grapefruit"); // 100
+]]>
+ + d3 + +
+ + 删除了一个游戏 + /2016/deleted-a-game/ + 今天,在N连跪呕心沥血终于推倒对面一座塔赢了以后,我把皇室战争这个游戏删了。

+

通常来说,删除一个游戏的原因无非几种:没兴趣,不好玩,玩腻了,等等。但是,皇室战争这个游戏,却比较特别。

+

要说它不好玩呢,其实挺有意思的,也挺符合现代游戏的节奏,不需要长时间在线,有空抽几分钟玩一局即可。

+

要说玩腻了呢,其实也没有,虽说已经玩了好几个月,但是很多酷炫的卡我依然还没有开到。

+

然后,问题在哪里呢?

+ + +

我在这个游戏里面完全找不到快乐。

+

很奇葩啊。一个在其它方面如此优秀的游戏,却完全忽视了一个游戏应该拥有的最基本的要素:它至少要能够让人感到快乐,哪怕是历尽八十一难以后的快乐。

+

然而,这个游戏,除了给予痛苦,愤怒,以及绝望以外,完全没有其它产出。

+

痛苦

+

一个人在这个游戏里能够获得的成就,由什么来决定呢?很不幸,我给出的答案是,5% 的技术,35% 的运气,以及 60% 的氪金。简单来说,这是一个氪金决定一切的游戏,因此,人民币战士就不讨论了,重点说一下像我这样的零氪玩家。

+

零氪玩家群体,在游戏中就是一个平等的群体了吗?很不幸,依然不是。在这种前提下,运气就是决定成就的最终因素。因为,皇室战争是一款卡牌对战游戏,卡牌在前,对战在后,你连卡都没有,拿什么对战呢?手上一堆屌丝卡牌,想凭借过人的技术和风骚的走位屹立在零氪之巅?我只能说,这个梦做得可以。

+

于此同时,更加残酷的现实是,游戏并没有零氪专区,就好像现实世界也没有设置单身狗专区一样。我除了要与运气比我好得多的零氪玩家斗智斗勇以外,时不时还要充当一下游戏本身为人民币玩家提供的服务。

+

愤怒

+

如果这个游戏仅仅是由以上内容,那它并不会使我感到愤怒,因为至少我还可以安静地玩我自己的游戏。

+

然而,很遗憾,游戏有一项特色机制,我觉得,应该称之为嘲讽机制。

+

在对战过程中,对手可以不断地发表情对我进行调戏,其实认真地说,都是一些很普通的表情,但是在某些场景下,就一定会让人觉得是一种嘲讽。

+

就很像 DOTA 中的“技不如人,甘拜下风”。

+

也许你会说,这不是很正常吗,任何对抗类游戏都会存在嘲讽这样的东西吧。

+

但是,皇室战争中的嘲讽,比较蛋疼。面对对手的嘲讽,97% 的情况都是无能为力的。为什么是 97% 呢?因为刚才说了,选手技术对游戏胜负只有 5% 的决定作用,而我对自己比较自信,给自己多加 1% 不过分吧。

+

也就是说,开局第一波交锋以后发现怼不过对面并且受到了嘲讽,97% 的情况下我都做不了任何改变。只能选择默默地被继续嘲讽,或者直接退出游戏。

+

这种情况,一开始遇到也许会觉得有趣,但是认真玩下去以后就会使人感到愤怒。

+

绝望

+

我痛苦着,我愤怒着,我卧薪尝胆总可以吧,前期痛苦越大,后面不就会获得更强烈的成就感吗?这样一来,它也可以稳稳地留住我这样的玩家。

+

然而并不会。

+

这也就是我说“绝望”的原因。

+

前期运气差,通过长时间的收集,总能够获得一些稀有卡片的。但是,有意义吗?卡片变好了就会进入更高阶的竞技场,结果是继续被更高阶的玩家吊打。就算我冲到了零氪玩家之巅,又如何?依然要被人民币战士吊打。

+

我要氪金呢?很遗憾,这个游戏,小氪完全不能给玩家带来任何改变,因为除了氪金,还有 35% 的运气成分在啊!而且,就算我疯狂氪金,在吊打一波渣渣以后,我也还是得继续被比我更有钱的人吊打。这个游戏带给我的东西不会有任何变化。

+

至于嘲讽,自然而然,我也可以嘲讽别人。但是问题在于,唯一能让我感到有趣的嘲讽是在首先受到了对手的嘲讽并成功翻盘的案例。这种情况,聊胜于无。

+

那么,我可以故意降杯去欺负新人吗?

+

啊,终于,发现了一条可行的路子。但是,我依然要保持一定的负场率,不然还是会在吊打菜狗的过程中不知不觉爬回到原本的竞技场,继续被吊打。

+

其实有很多人也确实是这么做了。我经常玩着玩着看到一些人掏出几个 9 级甚至 10 级的卡片,就会想,你这么厉害,还在这里干什么啊。然后就只能等死。这真是一个忧伤的故事。

+

其实这游戏有一个缺陷:它只有一种游戏模式。虽然它对游戏性把握得比较好,不容易感到疲倦,但是,这也就导致了我完全无法回避上面所提出的种种问题。最近新出的一个锦标赛模式就不谈了,我连加入都从来没有成功过,谈何体验。

+]]>
+ + personal + +
+ + 失望 + /2018/disappointed/ + 今年的 TI 本子到目前为止已经充了 ¥850 左右,770 级。

+
    +
  • 不朽 1 没有开到极其珍稀(PA),其它齐全
  • +
  • 不朽 2 齐全,一件极其珍稀(黑鸟)
  • +
  • 不朽 3 没有开到极其珍稀(巫医),其它齐全
  • +
  • 宝瓶 1 一轮,一件稀有额外(术士)
  • +
  • 宝瓶 2 一轮,一件稀有额外(大屁股)
  • +
+

战绩可以说非常不尽人意。虽然中途 V 社承认自己失误(被迫?)发了一次补偿,但依然没我。

+

现在每周就肝肝幽穴风云,肝肝代币,箱子开了马上又是一次轮回,感觉除了中看不中用的等级以外什么都没留下。想要的东西永远开不到,除了失望以外说不出别的感受来。

+

今天中午又开了一个箱子,依然是熟悉的啥都没有,突然就觉得好累,有点不想肝了。人生啊。

+]]>
+ + personal + +
+ + 《中国药神》 + /2018/dying-to-survive/ + 《我不是药神》是一部好电影。

+

影片最打动我的一段,是小吕请勇哥去他家吃饭的那几分钟。这些小人物倾家荡产,拼了命地活着,到底只不过就是为了一些「小事」而已。不然何苦呢?得了绝症的小吕幸福吗?从某个角度看,他非常幸福:有一个至死都不离不弃的爱人,还有一个至少到现在为止都健健康康的孩子。但生活就是这样残酷。

+

吃不起特效药的人,去抗议药厂卖天价药,对于不幸的患者来说,我命都快没了,管你是对是错呢?影片故意刻画了一个近乎反派立场的药厂,是不得已而为之,但我们要记住:真正对人类社会的发展做出贡献的是药厂。它卖天价药,卖任何价格,都没有问题,你永远不知道药厂为了第一片药付出了多少。至于吃不起,那是你的问题。就像影片说的一样:穷病,没法治。

+

影片从「病」这个角度,揭露出了绝大部分人生活在这个社会上的一些无奈。除非你有钱到刘强东这种程度,否则这个世界上总有你付不起的代价,这一刻是公平的。

+

这部电影好就好在,它选取了一个能够引发共鸣,但又值得深思的角度,同时把故事给讲好了。其实真的不难,真心希望它能够赚一笔大的,让大家以后都有样学样,多拍点有营养的东西。

+

PS. 毕导可以出来点评一下了,我猜这绝对又是境外势力的阴谋?

+]]>
+ + personal + +
+ + 高效 CSS 与 Reflow & Repaint + /2016/efficient-css-and-reflow-repaint/ + 高效 CSS

如何编写高效 CSS 其实是一个过时的话题。

+

这方面曾经存在许多真知灼见,比如说 CSS 选择器的解析方向是从子到父,比如说 ID 选择器是最快的,不应该给 Class 选择器加上 Tag 限制,尽量避免使用后代选择器等。但是,随着浏览器解析引擎的发展,这些都已经变得不再那么重要了。MDN 上阐述高效 CSS 的文章也已经被标记为过时。

+

Antti Koivisto 是 Webkit 核心的贡献者之一,他曾说:

+
+

My view is that authors should not need to worry about optimizing selectors (and from what I see, they generally don’t), that should be the job of the engine.

+
+

因此,如果把“高效 CSS”的含义限制为“高效 CSS 选择器”的话,那么实际上现在它已经不是开发者需要关心的问题了。我们需要做的事情变得更“政治正确”:保证功能与结构的良好可维护性即可。

+

那么 CSS 的性能还能通过什么方式提升呢?这就是下面的内容。

+ + +

Reflow & Repaint

概览

Reflow (回流)和 Repaint(重绘)是浏览器的两种动作。

+
    +
  • Repaint 会在某个元素的外观发生变化,但没有影响布局时触发。比如说 visibility / outline / background-color 等 CSS 属性的变化将会触发 Repaint
  • +
  • Reflow 在元素变化影响到布局时触发
  • +
+

显然,Reflow 的代价要比 Repaint 高昂得多,它影响到了页面部分(或者所有)的布局。一个 元素的 Reflow 动作同时也会触发它的所有后代 / 祖先 / 跟随它的 DOM 节点产生 Reflow

+

比如说:

+
<body>
<div>
<h4>Hello World</h4>
<p><strong>Welcome: </strong>......</p>
<h5>......</h5>
<ol>
<li>......</li>
<li>......</li>
</ol>
</div>
</body>
+ +

对这一小段 HTML 来说,如果 <p> 元素上产生了 Reflow,那么 <strong> 将会受到影响(因为它属于前者的后代元素),当然也跑不了 <div><body> (祖先元素),<h5><ol> 则躺枪:没事跟在别人后面干啥呢。

+

因此,大多数的 Reflow,其实都导致了整个页面重新渲染。这对于计算能力稍低的设备(如手机)来说是非常困难的。我经常发现桌面计算机上运行良好的动画效果到了手机上就看起来很痛苦。

+

Reflow 的触发点

    +
  • Window resizing
  • +
  • 改变字体
  • +
  • 增删样式表
  • +
  • 内容改变,比如用户在输入框中输入
  • +
  • 触发 CSS 伪类,比如 :hover
  • +
  • 更改 class 属性
  • +
  • 脚本操作 DOM
  • +
  • 计算 offsetWidthoffsetHeight
  • +
  • 更改 style 属性
  • +
  • ……
  • +
+

如何优化

恩。。。看了这么多发现,要完全避免 Reflow 还是比较困难的。那么我们至少可以有一些办法去减少它们的影响吧。

+

以下的方法都是收集于一些国外作者的博客。

+

尽量选择 DOM 树底层的元素去修改 Class

比如说,不要选择类似 Wrapper 这样的元素去修改 Class,而尽量找更加底层的元素。因为 Reflow 会影响所有后代祖先以及后邻,这么干可以尽量地减少 Reflow 的影响,从而提高 CSS 渲染性能。

+

避免设置多个内联样式

这里的意思其实是说不要使用 JS 来给元素按部就班地设置样式 —— 因为每一次样式变化都会引起一次 Reflow,最好把样式整合为一个 Class 然后一次性加到元素上面去。

+

还有另外一种解决办法是在设置样式前先将其脱离正常文档流,比如 display 属性设为 none,然后所有设置都完成后再变回来。这样也是可以接受的。

+

如果要使用动画尽量选择 Position 为 Fixed 或 Absolute 的元素

动画的每一帧都会引起 Repaint 或者 Reflow,最好是可以让它脱离正常文档流,这样就绝对不会引起大规模持续的 Reflow

+

不要选用 Table 布局

虽然我们已经有很多理由不去使用 table 布局了,但这又是另外一个 —— 任意一个单元格的小改动都很有可能触发整个布局所有节点的变化,带来巨大的性能开销。

+

 

+]]>
+ + css + +
+ + Egret Note + /2017/egret-note/ + Egret Engine 的学习笔记。

+

Egret Engine 是一款基于 JavaScript 的游戏制作引擎,支持 2D 与 3D 模式,支持 Canvas 与 WebGL 渲染,目前使用 TypeScript 编写。

+ + +

显示对象

“显示对象”,准确的含义是可以在舞台上显示的对象。可以显示的对象,既包括可以直接看见的图形、文字、视频、图片等,也包括不能看见但真实存在的显示对象容器。

+

在Egret中,视觉图形都是由显示对象和显示对象容器组成的。

+

对象树

    +
  • 根:舞台 DisplayObjectContainer:Stage
  • +
  • 茎:主容器(文档类) DisplayObjectContainer
  • +
  • 树枝:容器 DisplayObjectContainer
  • +
  • 树叶:显示对象 DisplayObject
  • +
+

对象类型

    +
  • DisplayObject 显示对象基类,所有显示对象均继承自此类
  • +
  • Bitmap 位图,用来显示图片
  • +
  • Shape 用来显示矢量图,可以使用其中的方法绘制矢量图形
  • +
  • TextField 文本类
  • +
  • BitmapText 位图文本类
  • +
  • DisplayObjectContainer 显示对象容器接口,所有显示对象容器均实现此接口
  • +
  • Sprite 带有矢量绘制功能的显示容器
  • +
  • Stage 舞台类
  • +
+

基本概念

二维坐标系。原点位于左上角

+
var shape:egret.Shape = new egret.Shape();
shape.x = 100;
shape.y = 20;
+ +

支持的操作:

+
    +
  • alpha:透明度
  • +
  • width:宽度
  • +
  • height:高度
  • +
  • rotation:旋转角度
  • +
  • scaleX:横向缩放
  • +
  • scaleY:纵向缩放
  • +
  • skewX:横向斜切
  • +
  • skewY:纵向斜切
  • +
  • visible:是否可见
  • +
  • x:X 轴坐标值
  • +
  • y:Y 轴坐标值
  • +
  • anchorOffsetX:对象绝对锚点 X
  • +
  • anchorOffsetY:对象绝对锚点 Y
  • +
+

锚点

Display Object 显示在舞台上的的位置需要通过 Anchor 来计算(初始值位于 Display Object 的左上角),可以通过 anchorOffsetXanchorOffsetY 方法来改变对象的锚点(比如移至中点)。

+

定位

Display Object 的初始坐标为 (0, 0),即位于容器的左上角(而非舞台)。

+
    +
  • 相对于容器的位置可以类比作 position: relative
  • +
  • 相对于舞台的位置可以类比作 position: absolute
  • +
+

如果要获取绝对位置,需要调用 container.globalToLocal(x, y) 方法,参数代表舞台坐标,返回值为容器坐标。

+

至于 z-index 则跟 svg 的处理类似。

+

尺寸

两种方法更改尺寸:

+
    +
  • height / width
  • +
  • scaleX / scaleY
  • +
+

斜切

斜切可以造成类似矩形变形为平行四边形的效果。

+
    +
  • skewX:横向斜切
  • +
  • skewY:纵向斜切
  • +
+

对象容器

DisplayObjectContainerDisplayObject 的子类。

+

向 Container 中添加 DisplayObject:

+
container.addChild(displayObject);
+ +
+

同一个显示对象无论被代码加入显示列表多少次,在屏幕上只绘制一次。如果一个显示对象 A 被添加到了 B 这个容器中,然后 A 又被添加到了 C 容器中。那么在第二次执行 C.addChild(A) 的时候,A 自动的从 B 容器中被删除,然后添加到 C 容器中。

+
+

移除:

+
container.removeChild(displayObject);
+ +

深度管理

DisplayObject 的 z-index 由其插入到容器中的顺序决定。后插入的显示在上层。

+

插入到指定位置使用 container.addChildAt(object, index) 方法。

+

同时也有 container.removeChileAt(index) 方法。

+

删除全部对象使用 container.removeChildren() 方法。

+

交换 DisplayObject 的位置有两个方法:

+
    +
  • container.swapChildren(object, object)
  • +
  • container.swapChildrenAt(index, index)
  • +
+

手动设置 z-index 使用 container.setChildIndex( object, index ) 方法。

+

子对象选择

通过 z-index 获取:container.getChildAt(index)

+

通过 name 获取(需要预先给 DisplayObject 设置 name 属性):container.getChildByName(name)

+
+

通过 z-index 获取子对象性能更佳。

+
+

矢量绘图

+

Egret中可以直接使用程序来绘制一些简单的图形,这些图形在运行时都会进行实时绘图。要进行绘图操作,我们需要使用 Graphics 这个类。但并非直接使用。 一些显示对象中已经包含了绘图方法,我们可以直接调用这些方法来进行绘图。 Graphics 中提供多种绘图方法。

+
+

已有的绘图方法包括:矩形、圆形、直线、曲线、圆弧。

+

以下的 shp 代表 shape,即一个 Shape 对象的实例。

+

shp.graphics.clear() 是通用的清楚绘图方法。

+

基本图形

矩形

var shp:egret.Shape = new egret.Shape();
shp.graphics.beginFill( 0xff0000, 1); //color and alpha
shp.graphics.drawRect( 0, 0, 100, 200 ); // x y width height
shp.graphics.lineStyle( 10, 0x00ff00 ); // border-width and border-color
shp.graphics.endFill();
this.addChild( shp );
+ +

圆形

shp.graphics.lineStyle( 10, 0x00ff00 );
shp.graphics.beginFill( 0xff0000, 1);
shp.graphics.drawCircle( 0, 0, 50 ); // x y r
+ +
+

此处需要注意的是,圆形的X轴和Y轴位置是相对于Shape对象的锚点计算的。

+
+

直线

shp.graphics.lineStyle( 2, 0x00ff00 );
shp.graphics.moveTo( 10,10 ); // 起点
shp.graphics.lineTo( 100, 20 ); // 终点(可以多次执行 lineTo)
+ +

曲线

shp.graphics.lineStyle( 2, 0x00ff00 );
shp.graphics.moveTo( 50, 50);
shp.graphics.curveTo( 100,100, 200,50); // 控制点 x y ,终点 x y
+ +

圆弧

drawArc( x:number, y:number, radius:number, startAngle:number, endAngle:number, anticlockwise:boolean ):void
+ +

前面的参数跟前面绘制圆形的一样,圆弧路径的圆心在 (x, y) 位置,半径为 radius 。后面的参数表示根据 anticlockwise : 如果为 true,逆时针绘制圆弧,反之,顺时针绘制。

+
+

需要注意是传入的 startAngle 和 endAngle 均为弧度而不是角度。

+
+

遮罩

DisplayObject 有一个 mask 属性,简单来说,就是类似蒙版上面的一个洞。但这个 mask 是洞而不是蒙版。如果添加了 mask 属性,则 Object 只能显示这个“洞中”的内容。

+

用作遮罩的显示对象可设置动画、动态调整大小。遮罩显示对象不一定需要添加到显示列表中。但是,如果希望在缩放舞台时也缩放遮罩对象,或者如果希望支持用户与遮罩对象的交互(如调整大小),则必须将遮罩对象添加到显示列表中

+
+

不能使用一个遮罩对象来遮罩另一个遮罩对象。

+
+

通过将 mask 属性设置为 null 可以删除遮罩。

+
mySprite.mask = null;
+ +

碰撞检测

    +
  • 非精确:var isHit:boolean = shp.hitTestPoint( 10, 10 );
  • +
  • 精确:shp.hitTestPoint( 10, 10,ture);
  • +
+

非精确大概可以看做面积相交,精确则是边缘相交。

+
+

大量使用精确碰撞检测,会消耗更多的性能。

+
+

文本

Egret 提供三种不同的文本类型,不同类型具有以下特点:

+
    +
  • 普通文本:用于显示标准文本内容的文本类型
  • +
  • 输入文本:允许用户输入的文本类型
  • +
  • 位图文本:借助位图字体渲染的文本类型
  • +
+

样式

var label:egret.TextField = new egret.TextField(); 
label.text = "这是一个文本";
label.size = 20; // 全局默认值 egret.TextField.default_size,下同
label.width = 70;
label.height = 70;
label.textAlign = egret.HorizontalAlign.RIGHT; // CENTER LEFT
label.verticalAlign = egret.VerticalAlign.BOTTOM; // MIDDLE TOP
label.fontFamily = "KaiTi"; // default_fontFamily
label.textColor = 0xff0000; // default_textColor

//设置粗体与斜体
label.bold = true;
label.italic = true;

//设置描边属性
label.strokeColor = 0x0000ff;
label.stroke = 2;
this.addChild( label );
+ +

支持格式混排:

+
// JSON 模式
label.textFlow = <Array<egret.ITextElement>>[
{text: "妈妈再也不用担心我在", style: {"size": 12}}
, {text: "Egret", style: {"textColor": 0x336699, "size": 60, "strokeColor": 0x6699cc, "stroke": 2}}
, {text: "里说一句话不能包含各种", style: {"fontFamily": "楷体"}}
, {text: "五", style: {"textColor": 0xff0000}}
, {text: "彩", style: {"textColor": 0x00ff00}}
, {text: "缤", style: {"textColor": 0xf000f0}}
, {text: "纷", style: {"textColor": 0x00ffff}}
, {text: "、\n"}
, {text: "大", style: {"size": 36}}
, {text: "小", style: {"size": 6}}
, {text: "不", style: {"size": 16}}
, {text: "一", style: {"size": 24}}
, {text: "、"}
, {text: "格", style: {"italic": true, "textColor": 0x00ff00}}
, {text: "式", style: {"size": 16, "textColor": 0xf000f0}}
, {text: "各", style: {"italic": true, "textColor": 0xf06f00}}
, {text: "样", style: {"fontFamily": "楷体"}}
, {text: ""}
, {text: "的文字了!"}
];

// HTML 模式 (标签与属性部分支持)
label.textFlow = (new egret.HtmlTextParser).parser(
'没有任何格式初始文本,' +
'<font color="#0000ff" size="30" fontFamily="Verdana">Verdana blue large</font>' +
'<font color="#ff7f50" size="10">珊瑚色<b>局部加粗</b>小字体</font>' +
'<i>斜体</i>'
);
+ +

事件与链接

tx.textFlow = new Array<egret.ITextElement>(
{ text:"这段文字有链接", style: { "href" : "event:text event triggered" } }
,{ text:"\n这段文字没链接", style: {} }
);
tx.touchEnabled = true;
tx.addEventListener( egret.TextEvent.LINK, function( evt:egret.TextEvent ){
console.log( evt.text );
}, this );
+ +

也可以直接将 href 设置为 url,这样不需要事件监听,将直接打开链接。但只适用 Web 端

+

文本输入

关键代码是设置其类型为 INPUT。

+
var txInput:egret.TextField = new egret.TextField;
txInput.type = egret.TextFieldType.INPUT;
+ +

绘制输入背景可以用其它 DisplayObject,目前没有内置实现。

+

获取焦点使用 textIput.setFocus(); 方法。

+

除此以外,还有 inputType 属性表示输入内容的区别,这个主要用于移动端弹出相应的键盘。

+

事件处理

事件类:egret.Event

+

执行流程

+

事件机制包含4个步骤:注册侦听器,发送事件,侦听事件,移除侦听器。这四个步骤是按照顺序来执行的。

+
+

事件类

其构建器可以传 3 个参数:事件类型、是否冒泡、是否可取消(什么是取消?)。

+
class DateEvent extends egret.Event
{
public static DATE:string = "约会";
public _year:number = 0;
public _month:number = 0;
public _date:number = 0;
public _where:string = "";
public _todo:string = "";
public constructor(type:string, bubbles:boolean=false, cancelable:boolean=false)
{
super(type,bubbles,cancelable);
}
}
+ +

监听器

跟常见的情况不太一样,Egret 的事件绑定在发送者上(而不是接收者)。

+

监听器函数

+

一个侦听器必须是函数,它可以是一个独立函数,也可以是一个实例的方法。侦听器必须有一个参数,并且这个参数必须是 Event 类实例或其子类的实例, 同时,侦听器的返回值必须为空(void)。

+
+

注册与移除事件监听

注册侦听器

+
eventDispatcher.addEventListener(eventType, listenerFunction, this);
+ +

移除侦听器

+
eventDispatcher.removeEventListener(eventType, listenerFunction, this);
+ +

检测侦听器

+
eventDispatcher.hasEventListener(eventType);
+ +

优先级

public addEventListener(type:string, listener:Function, thisObject:any, useCapture:boolean = false, priority:number = 0)
+ +
+

该属性为一个number类型,当数字越大,则优先级越大。在触发事件的时候优先级越高。

+
+]]>
+ + javascript + egret + +
+ + IDEA 为 Markdown 文件默认启用 SoftWrap + /2020/enable-soft-wrap-for-markdown-files-in-idea-by-default/ + 应该 JetBrains 家的所有 IDE 都有这个配置。习惯了用 Markdown 写博客的人每次都要手动点一下 SoftWrap 挺烦的。后来发现了一个配置可以帮我省去这一步:

+

打开设置,找到:Editor > General > Soft Wraps,将 Soft-wrap files 选项勾上即可。IDE 默认已经填上了 *.md; *.txt; *.rst; *.adoc,因此不需要再做别的事情。

+

image

+

这样一来,每次只要打开以上格式的文件,编辑器就会自动开启 SoftWrap,一劳永逸。

+]]>
+ + markdown + idea + +
+ + Ext 使用总结 + /2015/ext-usage-summary/ + 公司要求会用Ext Js,没办法必须学,下面总结了一些学习与使用过程中的经验。

+

从一开始就使用英文文档

这点是我认为最重要的,有人也许会觉得很奇怪,不是有国人翻译的中文文档吗?而且很全,为什么要强行用原版呢?

+

两种文档我都参考与使用过,最终还是选择了直接使用原版文档。最重要的原因是Ext本身自定义的名字与行为很多,不去看原版的话很难将两个或多个多个地方出现的同一个词语联系起来,另外这些词语也与代码有最直接的联系,看翻译版的话也许很容易就会出现被翻译误导的情况,翻译能正确地指出一个词的意思,但不能建立像原版那样名词之间的联系。

+

另一个原因是,英文文档中自带了很多基本与扩展使用的例子,我们在完成一些日常功能的时候都可以去参考文档内的例子而不用自己去根据API文档苦思冥想。

+

需要大量使用Ext文档的时候我们一般可以把它下载回来到本机搭建一个本地文档,这样就可以解决国内访问国外网站延迟较高的问题。需要注意的是文档需要部署在Http服务器(如Tomcat,Apache等)上方可正常访问。

+

下载文档的链接可以在http://docs.sencha.com/extjs/6.0/找到,如图所示:

+

+

关于是否使用Sencha Architect

Sencha Architect的所有功能都可以无限期试用,基本上就是一款免费软件,它可以帮助Ext Js开发者使用可视化界面快速开发复杂结构与逻辑的页面,以及添加自定义插件,缺点就是做出来的页面在build之前体积非常大(一个页面大概100至200 MB),build以后也经常会出现问题。用不用它是一个很纠结的问题。

+

公司开发部门的老师们看起来都不太喜欢这款工具,主要原因大概是使用工具会妨碍自己对代码的理解,担心在脱离工具以后不能很好地掌控代码的行为(准确地说应该是不如从一开始手打代码掌控得好)。但我觉得这对于一个有一定经验的Ext开发者应该完全不成问题,不知道为什么老师们不喜欢这款工具。手撸Ext界面是一件再痛苦不过的事,一个复杂的Ext页面动辄成千上万个括号,根本不是普通代码的形式,可阅读性与可修改性都非常差。而且Ext号称完全兼容IE,但这是基于我们的代码不出错的情况,IE浏览器相比于其它浏览器最常见的问题是Js数组最后面的元素多了一个逗号后导致报错,很不幸Ext简直就是这种情况的代言人,更致命的是一个逗号就能够导致整个页面崩溃而且IE调试工具根本找不到这个错误所在,于是才出现了神奇的“二分调试法”。

+

我个人的建议是,Ext Js的初学者与有经验的开发者都可以尝试使用Sencha Architect去搭建View页面,但最好仅限于View页面,搭建完最初始的结构后就可以将代码Copy到自己的项目中去,然后转为自己编码控制。毕竟Ext的工具性这么强,也不在乎再强一点,Ext的Layout学到飞起来又怎样,对其它形式的Web开发没有任何帮助,然并卵。

+

Config, Property, Method与Event

Ext的所有类一开始对于这几个概念的理解比较模糊,尤其是Config与Property之间的关系,后来用多了才慢慢开始感觉到其中的区别。

+

Config是在创建一个Ext类的时候给予的参数,相当于Java中Constructor的参数,有些时候某些config是必要的,有时候又有默认值,每个类所需要以及能配置的config都不太一样,所以在我习惯在使用之前查看一遍文档,顺便吐槽一下,个人觉得在Ext的世界里只有文档和源码是可靠的,经验往往不可靠。Config在配置以后不能通过直接的方式修改,修改必须重新配置该类,使用 Ext.apply 或者 Ext.applyIf,最后再说一遍,不要直接去修改config中的变量,这样往往会导致预想之外的结果。

+

Property很容易就能联想到成员变量,这里的值在类创建后就会存在,可以被修改(与config的区别),不过在Method中一般能找到与其对应的Getter和Setter方法,所以一般情况下也不会直接去修改Property的值。

+

Method自然而然就是一个类中的成员方法了,一般是在controller里面使用。Ext的文档中会列举所有该类本身以及继承的方法,在使用任何方法之前都强烈建议先查看文档,可以找到方法接受的参数,行为以及返回类型,在Ext的世界里面游戏规则最重要,一切按部就班才不会出问题。

+

Event是该类可以触发的事件,并不是所有类都能触发所有类型的事件,比如panel并不能触发Ext的Click事件,所以,在绑定事件之前也有必要查看文档,文档会介绍事件的触发条件以及返回参数,十分有用,千万不要自己去想像。当然这里可以有些许的变通,我们知道Js本身可以对任何DOM节点绑定任何事件,所以当我们需要做一些Ext不打算帮我们做好的事情的时候也并不是没有办法。

+

HTML Element, Element与Component

HTML Element

HTML Element就是Ext对HTML世界中最普通的DOM节点的称呼,后两位是Ext自己创造的东西,也是其精华所在(精华与糟粕并存)。不过,就算Ext没有了Element与Component,它依然是一个出色的MVC框架。

+

Element

+

Encapsulates a DOM element, adding simple DOM manipulation facilities, normalizing for browser differences.

+
+

正如其文档所言,Element是Ext对DOM节点进行了一次封装以后产生的对象。该对象的主要目的是为其添加一些简单的DOM操作,以及标准化浏览器之间的差异。构造这个对象的目的与jQuery对DOM封装的目的非常相似,不过对Ext来说,它主要的目的应该还是为更上层的建筑服务。

+

Component

Ext的精华与糟粕所在地,吐槽重灾区。

+

Ext的Component基本上涵盖了Web开发平常会用到的所有控件与功能,极大地简化了Web开发的流程,我们只需要在用的时候创建一个相应的component,Ext就会为我们完成所有事情,其中Grid(表格)控件更是登峰造极,让人不得不服,基本上一个人一般情况下能想到的所有功能都能被一一满足。同时,Ext所创建的十数种Layout类型更是为这些Component提供了强大的定位支持,再也不用担心做不出像样的页面。同时,Ext考虑到开发者可能不一定能在任何情况下满足,也提供了插件与自定义控件的开发方式,至此,component已经超神了。

+

但是这些强大且丰富的component也带来了或多或少的问题。

+

表面上看起来强大的东西,它背后一定更强大,所以,Ext真的非常强大,2 MB走起的Js库文件,远远超出了前端所能容忍的极限。搭一个本机环境跑一个debug库,页面刷新一次居然要超过两秒,复杂的页面更是四五秒,可见一斑。所以这东西注定只适合在内网用。

+

Ext 4.0开始,构建一个component所用到的HTML元素变得非常臃肿,一个普通的panel创建出来少说要七八层结构,这些都是为了高度可定制性做出的妥协,而且我们为了一些布局的需要常常会大量使用容器类 component,其后果就是浏览器不堪重负,渲染效率大打折扣。结合上一点来看更是惨不忍睹。

+

Ext的component虽说可以自定义,但是实际上一些复杂度高的需求自定义起来会比较困难。首先它必须继承某一个component,因为我们生产中不可能从头开始写,所以需要熟悉原来的component有哪些东西。然后我们在自定义的过程中也不能脱离Ext世界的游戏规则,否则这个新的东西很可能就会与其他已有的component脱节。一种常见的情况就是在操作component的一个element或者HTML element的时候一定要使用Ext提供的方法,否则就会产生预料之外的结果。

+

版本更替与版权问题

Ext不是免费工具,商业使用需要购买,即使是个人使用也只能将项目开源才能享有免费版的Ext Js,虽然Js代码都是想用就能用,但是作为公司的话还是要考虑一下这个问题。这个原因也导致了Ext的网络资源非常缺乏,系统的教程非常少,基本上遇到了问题都只能在文档里面打滚。

+

Ext大版本之间基本不兼容,改动代价也非常高,所以我目前见过的都是选择了一个版本就只能一个版本用到死的情况,另外再补一刀,就算购买了低版本的使用权,再想购买升级版依然需要付费。

+

总结

个人不太喜欢使用Ext作为前端框架,虽然是作为非常适合搭建内网系统的技术存在,抛开千遍一律的UI不谈,用过Ext的人应该都有一种被包养的感觉。Ext帮我们做好了所有事情,久而久之我们就会忘记事情原本应该是怎样的,从而走上一条不归路。

+]]>
+ + extjs + +
+ + 使用 Puppeteer 自动输入京东滑动验证码 + /2018/fill-jd-slider-captcha-by-puppeteer/ + 京东网页端登录有时候需要输入滑动验证码,就像这样:

+

jd-verify

+

在做自动签到脚本的时候遇到这个很不舒服,如果不处理的话就只能每次弹出浏览器手动登录,因此稍微研究了下。下面是一个非常简单,但成功率很高(达到80%)的自动识别并输入方案,使用 puppeteer 实现。

+

总体思路:通过图像特征识别出滑块缺口的位置,然后通过模拟用户点击将滑块拖动到该处。

+ + +

首先,我们能够看到这个滑块缺口是一个黑色半透明的遮罩区域,通过代码分析并不能得到它的具体色值。因此只能自行比对尝试。通过肉眼测试与对比,可以得到这个遮罩的色值大约为 rgba(0,0,0,0.65)

+

img

+

得到这个色值的目的是:通过判断相邻像素点 a, b 的色值之差,来决定像素点 b 的色值是否是像素点 a 的色值加上遮罩之后的结果,以此来推断遮罩所在的位置。

+

下面介绍两个函数:

+
    +
  • combineRgba rgba 色值相加,返回相加结果
  • +
  • tolerance rgba 色值比对,通过传入一个「容忍值」,返回颜色是否相似
  • +
+
/**
* combine rgba colors [r, g, b, a]
* @param rgba1 底色
* @param rgba2 遮罩色
* @returns {number[]}
*/
export function combineRgba (rgba1: number[], rgba2: number[]): number[] {
const [r1, g1, b1, a1] = rgba1
const [r2, g2, b2, a2] = rgba2
const a = a1 + a2 - a1 * a2
const r = (r1 * a1 + r2 * a2 - r1 * a1 * a2) / a
const g = (g1 * a1 + g2 * a2 - g1 * a1 * a2) / a
const b = (b1 * a1 + b2 * a2 - b1 * a1 * a2) / a
return [r, g, b, a]
}

/**
* 判断两个颜色是否相似
* @param rgba1
* @param rgba2
* @param t
* @returns {boolean}
*/
export function tolerance (rgba1: number[], rgba2: number[], t: number): boolean {
const [r1, g1, b1] = rgba1
const [r2, g2, b2] = rgba2
return (
r1 > r2 - t && r1 < r2 + t
&& g1 > g2 - t && g1 < g2 + t
&& b1 > b2 - t && b1 < b2 + t
)
}
+ +

接下来就可以写出距离算法了,通过传入包含缺口的验证码图片的 base64 编码,以及图片的实际宽度,返回缺口位置 x 值。具体思路是:通过自左而右,自上而下的逐列像素分析,找出第一个跟上个像素的色值与遮罩色值相加后的结果相似的像素点,就认为是遮罩的 x 位置。

+

img

+
function getVerifyPosition (base64: string, actualWidth: number): Promise<number> {
return new Promise((resolve, reject) => {
const canvas = createCanvas(1000, 1000)
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
const width: number = img.naturalWidth
const height: number = img.naturalHeight
ctx.drawImage(img, 0, 0)
const maskRgba: number[] = [0, 0, 0, 0.65]
const t: number = 10 // 色差容忍值
let prevPixelRgba = null
for (let x = 0; x < width; x++) {
// 重新开始一列,清除上个像素的色值
prevPixelRgba = null
for (let y = 0; y < height; y++) {
const rgba = ctx.getImageData(x, y, 1, 1).data
if (prevPixelRgba) {
// 所有原图中的 alpha 通道值都是1
prevPixelRgba[3] = 1
const maskedPrevPixel = combineRgba(prevPixelRgba, maskRgba)
// 只要找到了一个色值匹配的像素点则直接返回,因为是自上而下,自左往右的查找,第一个像素点已经满足"最近"的条件
if (tolerance(maskedPrevPixel, rgba, t)) {
resolve(x * actualWidth / width)
return
}
} else {
prevPixelRgba = rgba
}
}
}
// 没有找到任何符合条件的像素点
resolve(0)
}
img.onerror = reject
img.src = base64
})
}
+ +

得到 x 位置后,就可以使用 puppeteer 操纵滑块来实现验证了:

+
// 验证码图片(带缺口)
const img = await page.$('.JDJRV-bigimg > img')
// 获取缺口左x坐标
const distance: number = await getVerifyPosition(
await page.evaluate(element => element.getAttribute('src'), img),
await page.evaluate(element => parseInt(window.getComputedStyle(element).width), img)
)
// 滑块
const dragBtn = await page.$('.JDJRV-slide-btn')
const dragBtnPosition = await page.evaluate(element => {
// 此处有 bug,无法直接返回 getBoundingClientRect()
const {x, y, width, height} = element.getBoundingClientRect()
return {x, y, width, height}
}, dragBtn)
// 按下位置设置在滑块中心
const x: number = dragBtnPosition.x + dragBtnPosition.width / 2
const y: number = dragBtnPosition.y + dragBtnPosition.height / 2

if (distance > 10) {
// 如果距离够长,则将距离设置为二段(模拟人工操作)
const distance1: number = distance - 10
const distance2: number = 10
await page.mouse.move(x, y)
await page.mouse.down()
// 第一次滑动
await page.mouse.move(x + distance1, y, {steps: 30})
await page.waitFor(500)
// 第二次滑动
await page.mouse.move(x + distance1 + distance2, y, {steps: 20})
await page.waitFor(500)
await page.mouse.up()
} else {
// 否则直接滑到相应位置
await page.mouse.move(x, y)
await page.mouse.down()
await page.mouse.move(x + distance, y, {steps: 30})
await page.mouse.up()
}
// 等待验证结果
await page.waitFor(3000)
+ +

img

+

大概就这样。

+]]>
+ + nodejs + puppeteer + +
+ + 毕业后的第一个中秋 + /2015/first-mid-autumn-after-graduation/ + 学生渐渐开学,才意识到毕业以来已经过了一个暑假的时间。公司为期三个月的培训终于快结束了,我也终于有空回家休息一段时间。培训结束后,感觉自己的变化除了学到的知识以外,就是多了一些自信,对很多东西的理解不再是处于未知或一知半解的状态。学习使人进步。

+

挺久没有回过家,上一次应该是在五一的时候,所以比较想念家人。不知道家里现在是怎样的了,应该没有什么变化。前段时间出租屋的椅子坏了,往后靠就会摔倒,想买一把才发现椅子挺贵的,房东不给换,郁闷的时候想到在家里从来没有操心过类似的问题,只要跟爸爸或者妈妈说一句就会有替代品,虽然可能不合己意但却不需要付出任何代价。这些事情可能只有在独立生活后才能发现,饭要自己做,碗要自己洗,衣要自己晾,门要自己锁,下班回来累了一躺就是到半夜,醒来发现灯还亮着,门禁卡还戴着,一看手机早上四点多,这时候就能体会到一些孤独。体会到在家是多么的幸福。感谢爸妈给我的回忆里充满的都是快乐。

+

马上过完今年的生日,我也要24岁了,人生走到了一个过渡期,从学生到打工者,从学校到到职场,时间过得这么快,觉得有一些不适。生活还没有转变过来,以后的路还那么长,对未知的未来充满了恐惧。

+]]>
+ + personal + +
+ + Git SSH key 生成与 GitExtension 配置 + /2015/git-ssh-key-gen-and-gitextension-configuration/ + 使用ssh key配置git可以省去每次操作时输入ID/Password的麻烦,操作一旦频繁起来还是很有必要的。实际操作需要添加一些环境变量,或者到git/bin目录下执行。

+

设置Git的默认username和email

这一步没有验证过是否可以省略。

+
$ git config --global user.name "xxx"
$ git config --global user.email "xxx@xxx.xxx"
+ +

本地生成SSH Key

查看是否已有密钥

有的教程说通过 $ cd ~/.ssh 查看目录是否存在,不过我的机器上测试无论有没有这一步的结果都是不存在。所以我的方法是到c:/users/username/下查看是否存在.ssh文件夹,存在则将里面的内容删除。

+

生成密钥

执行 $ ssh-keygen,连续回车确认,到最后 ssh key 就会在 .ssh 文件夹下生成,带 .pub 后缀的为公钥。遇到找不到路径的情况则需要手动指定 .ssh 文件夹的正确位置,我尝试把它放在 D 盘结果 server 不认,还是要指定 c:/users/username/.ssh 这个目录去生成,密钥名字为 id_rsa

+

上传到server

生成结束后需要将公钥上传到相应 server,以 Github 为例:

+

+

将公钥文件中的所有内容copy到key输入框中,添加保存即可。

+

配置Git Extension(windows)

以上步骤执行完后可以使用命令行执行推拉等操作,但是在Git Extension就死活不行,后来发现这个工具安装的时候默认使用了putty作为ssh代理,需要手动换成git自带的ssh工具,如图所示:

+

+]]>
+ + git + +
+ + Github Pages and SSL + /2016/github-pages-and-ssl/ + 经过一些努力,把博客迁移到了 Github Pages,将域名改成了自定义,并且成功启用了 SSL,以下是步骤(就不截图了)。

+ + +

部署代码

Github Pages 支持两种级别的部署:

+
    +
  1. user / organization 方式
      +
    • repo 名字必须为 <user-name or org-name>.github.io
    • +
    • pages build branch 固定为 master
    • +
    • 部署后的发布域名即为 repo 名
    • +
    +
  2. +
  3. project 方式
      +
    • repo 名字没有限制
    • +
    • pages build branch 可以任意指定
    • +
    • 发布域名为 <user-name or org-name>.github.io/<project-name>
    • +
    +
  4. +
+

因此,如果要做个人页面则必然选择第一种方式。

+

因为 build branch 限制为 master,因此我一开始选择了重建 repo,实际上没有必要,可以直接 rename 旧的 repo

+

rename 后,到 repo settings -> options -> Github Pages,即可发现自动部署已经开始了。即刻访问 <user-name or org-name>.github.io 可以看到部署结果。

+

自定义域名

    +
  1. 在 repo settings -> options -> Github Pages -> Custom Domain 中,填入自己的域名,如 wxsm.space,保存
  2. +
  3. 在自己的域名供应商处修改域名解析,添加两条 A 记录(此处可以参考最新文档):
      +
    • 192.30.252.153
    • +
    • 192.30.252.154
    • +
    +
  4. +
  5. 等待记录生效
  6. +
+

过一会就可以发现,使用自定义域名可以访问网站了,并且原 <user-name or org-name>.github.io 会重定向到自定义域名。

+

启用 SSL

这里是最麻烦的。虽然 Github Pages 原生支持 SSL,但是只针对 *.github.io 域名,对于自定义域名,无法直接启用。因此需要找一个支持 SSL 的 CDN 供应商。考虑到免费这个关键因素,选择了 CloudFlare(以下简称 CF)

+
    +
  1. 前往 CF 官网,注册账号,填入自己的域名,点几个 Continue
  2. +
  3. 到注册的最后一步时,需要将域名的 DNS 服务器切换为 CF 服务器(CF 会提供两个,两个都要填上),到原域名供应商处修改域名 DNS 服务器即可,24 小时内生效
  4. +
  5. 生效后可以打开网站查看是否正常。控制台页面上方有一个 Crypto Tab,点开,SSL 选择 Flexible 或者 Full,同样需要等待一段时间生效
  6. +
  7. 生效后即可以通过 https 访问网站了,如果需要强制 SSL,可以到 Page Rules Tab,添加一些记录,为某些域名段设置强制 SSL Aways use https
  8. +
+]]>
+ + personal + github + +
+ + Gitlab CI Setup + /2018/gitlab-ci-setup/ + Gitlab 有一套内置的 CI 系统,相比集成 Jenkins 来说更加方便一些,用法也稍为简单。以下是搭建过程。

+

前置准备:须要准备一台用来跑 CI 任务的机器(可以是 Mac / Linux / Windows 之一)。

+ + +

创建 .gitlab-ci.yml 文件

和 Github CI 一样,Gitlab CI 也使用 YAML 文件来定义项目的整个构建任务。只要在需要集成 CI 的项目根目录下添加这份文件并写入内容,默认情况下 Gitlab 就会为此项目启用构建。

+

配置文档:https://docs.gitlab.com/ee/ci/yaml/README.html

+

一份较为完整的配置文件样例:

+
#指定 docker 镜像
image: node:9.3.0

#为 docker 镜像安装 ssh-agent 以执行部署任务
before_script:
- 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
- mkdir -p ~/.ssh
- echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config
- chmod 700 ~/.ssh

#定义构建的三个阶段
stages:
- build
- test
- deploy

#定义可缓存的文件夹
cache:
paths:
- node_modules/

#构建任务
build-job:
stage: build
script:
- "npm install"
- "npm run build"
tags:
- node

#测试任务
test-job:
stage: test
script:
- "npm install"
- "npm run lint"
- "npm test"
tags:
- node

#部署任务
deploy-job:
stage: deploy
only:
- release
script:
- "npm install"
- "npm run build"
- ssh user@host "[any shell commands]"
tags:
- node
+ +

整个构建过程基本上一目了然,比 Jenkins 简便很多。Gitlab CI 会按顺序执行 build / test / deploy 三个 stage 的任务,遇到出错即中止,并不再往下执行。同个 stage 中的多个任务会并发执行。需要注意的是,各个 stage 的工作空间是独立的。

+

其中 $SSH_PRIVATE_KEY 是在相应 Gitlab 项目中配置的一个 Secret Value,是构建机的 ssh 私钥。后面再谈。

+

.gitlab-ci.yml 文件推送到服务器后,打开项目主页,点击 Commit 记录,会发现构建任务启动并处于 pending 状态:

+

img

+

点击构建图标,则可以进入到 CI 详情页面:

+

img

+

点击具体任务查看 log 则提示项目没有配置相应的 runner 来执行构建任务。也就是下一步要做的事情。

+

搭建 Gitlab runner

Gitlab runner 是用来执行 CI 任务的客户端,它可以在一台机器上搭建,并且同时为多个项目服务。安装教程

+

安装好 runner 后,还要为机器安装 Docker,用来作为具体构建的容器。

+

以上均安装完成后,就可以开始配置 runner 了。配置过程中需要用到的一些信息可以在下图位置找到(项目主页 -> Settings -> CI / CD -> Runners settings)。

+

img

+
$ sudo gitlab-runner register

Please enter the gitlab-ci coordinator URL (e.g. https://gitlab.com )
(填写上图位置的地址)

Please enter the gitlab-ci token for this runner
(填写上图位置的token)

Please enter the gitlab-ci description for this runner
[hostame] my-runner

Please enter the gitlab-ci tags for this runner (comma separated):
node

Please enter the executor: ssh, docker+machine, docker-ssh+machine, kubernetes, docker, parallels, virtualbox, docker-ssh, shell:
docker

Please enter the Docker image (eg. ruby:2.1):
node:latest
+ +

其中 description 与 tags 将来都可以在 Gitlab UI 中更改。注意 tag 必须与 .gitlab-ci.yml 中各个 job 指定的 tag 一致,这个 job 才会分配到这个 runner 上去。

+

如此一来则大功告成,回到 Gitlab UI,在 Runner settings 内可以看到配置好的 runner,并且可以执行任务了。

+

img

+

遇到的问题

其实本地构建基本上都没什么问题,遇到的坑基本集中在 deploy 阶段,即远程到服务器上去发布的这一步。按照 Gitlab 提供的文档,走完了所有的步骤后,构建机总是无法使用 private key 直接登录,而是必须输入密码登录。尝试了查看 ssh 日志,重启服务器 sshd 服务,修改文件夹权限等等,debug 了半天还是没有解决该问题。后来偶然发现在部署服务器上使用 sshd 开启一个新的服务,用新的端口即可顺利登录,也不知道是为什么。

+

更新:另外一个方法,可以使用 sshpass 命令来进行登录。用法:

+
    +
  1. 在 docker 镜像中安装 sshpass
    $ which sshpass || ( apt-get update -y && apt-get install sshpass -y )
    +其中 -y 是为了防止安装过程中出现需要选择的项目,一律选 YES
  2. +
  3. 在项目 CI 变量中设置 ssh 密码
  4. +
  5. 使用 sshpass 复制文件,或登录远程服务器
    # scp
    $ SSHPASS=$YOUR_PASSWORD_VAR sshpass -e scp -r local_folder user@host:remote_folder"
    # ssh
    $ SSHPASS=$YOUR_PASSWORD_VAR sshpass -e ssh user@host
  6. +
+]]>
+ + gitlab + devops + +
+ + Gitlab CE Code-Review Bot + /2020/gitlab-ce-code-review-bot/ + 由于 Gitlab CE 做代码评审时缺少了关键的评审员功能(详情参考此 issue),因此在使用 CE 的同时又想要做代码评审的话,就必须要自己想办法了。

+

网上能找到的最多的解决方案就是在 Gitlab 前面再部署一套 Gerrit,通过拦截推送的代码以及同步两个库来实现。但是这种方案有诸多弊端。比如:

+
    +
  1. 割裂的用户体验。原本习惯了使用 Gitlab 系统的人,要开始学习晦涩难懂的 Gerrit;
  2. +
  3. 代码同步的不稳定性和不确定性。系统每增加一层逻辑,可靠性就降低一些;
  4. +
  5. 复杂的使用方式:代码必须要从 Gerrit clone,同时 push 时分支名必须加上 refs 前缀,否则无法进入评审
  6. +
  7. +
+

总体来说,以上的种种原因让我觉得 Gerrit 并不是最好的解决方案。对于凡事追求完美的处女座的我来说,我想要的东西大概应该具备以下几点:

+
    +
  1. 最好是能直接在 Gitlab 上面进行评审。因为 CE 可以说是万万事俱备,只差流程;
  2. +
  3. 最好是对原 Git 和 Gitlab 使用流程、习惯没有任何更改和侵入,仅增加评审流程;
  4. +
  5. 最好是可以可以自动化整个流程(评审人自动分配、评审完自动合并,等等)。
  6. +
+

好在,Gitlab 有一套完备的 Web hook 以及 API 系统,可以支撑起我的想法。

+ + +

实现原理

首先,所有评审流程要基于一些分支使用原则:

+
    +
  1. 分支分为主干分支与特性分支,另外还有一些额外的分支(如发布分支);
  2. +
  3. 除业务分支外,其它分支均为保护分支,不允许直接推送,只能通过 Merge Request 添加代码;
  4. +
  5. 特性分支可以任意使用、推送
  6. +
+

因此,代码评审的环节就设计在 Merge Request (以下简称 MR)中,这是一个合理的时机。

+

整个评审流程如下所示:

+
+ +

可以看到,除了最后的「合并」操作外,评审系统只是作为一个「旁观者」的角色,帮助我们完成了整个评审流程,并没有任何侵入性的操作。

+

实现细节

评审系统的实现,我选择的是一个用 Node.js 和 Koa2 搭建的普通 web 服务器。它会做两件事情:

+
    +
  1. 监听从 web hook 进入到系统的请求,分析请求参数,实现具体逻辑;
  2. +
  3. 调用 Gitlab API,完成诸如评论、合并等操作。
  4. +
+

因为 Gitlab web hook 访问时仅存在参数区别,因此服务器入口只需要一个路径监听就够了:

+
// gitlab.route.js
const gitlab = require('./gitlab.controller')

module.exports = (router) => {
// 所有请求都进入到 gitlab controller
router.all('(.*)', gitlab)
}
+ +
// gitlab.controller.js
const mr = require('./merge-request.handler')
const mrc = require('./merge-request-comment.handler')

module.exports = async ctx => {
try {
const { object_kind, object_attributes } = ctx.request.body
if (object_kind === 'merge_request' && object_attributes.action === 'open') {
// 新的 merge request
await mr(ctx)
} else if (object_kind === 'note' && object_attributes.noteable_type === 'MergeRequest') {
// merge request 收到评论
await mrc(ctx)
}
} catch (e) {
console.error(e)
}
// 这里的返回并不重要
ctx.body = 'gitlab-bot'
}
+ +

MR 创建时通知到评审系统

在上面的 mr(ctx) 中,可以实现新 MR 创建时的逻辑:

+
    +
  1. 从预先配置好的小组名单(可以是写死在代码中的,也可以是储存在 db 中的)中,随机抽取 N 位成员(假设为 B/C);
  2. +
  3. 通过 Gitlab API 向 MR 添加评论,说明意图,并且 @B @C。
  4. +
+

至于如何向 API 发出请求,开源世界有许多现成的解决方案,也可以直接参考 API 文档,这里不再赘述。

+
// pid 为 projectId,mid 为 mergeRequestId,webhook 调用内均会携带。下同
await service.addMergeRequestComment(pid, mid, `请 [@${ra}] 与 [@${rb}] 评审`)
+ +

这里面有几个问题:

+
    +
  1. 如何防止小组成员略过评审系统,主动合并?
  2. +
  3. 不是所有分支合并都需要评审(如主干分支到发布分支),如何避免?
  4. +
+

如何防止手动合并

Gitlab 提供了一种方式:WIP (work in progress),只要标记了 WIP 的 MR 就无法直接点击合并。使用方式也很简单,只需要在原 MR 的标题前面加上 WIP: 字符串即可:

+
await service.updateMergeRequest(pid, mid, {
title: `WIP:${object_attributes.title}`
})
+ +

效果如下图所示:

+

wip

+

可以看到,WIP 并不是一个强制状态。在 Web UI 上点击 Resolve WIP status 或手动去除标题中的 WIP: 都可以解除 WIP 状态,从而允许手动合并。也就是说,这是一个「防君子不防小人」的状态。如果是在一个团队内的成员中使用,我觉得这样已经足够了。

+

如何兼容不需要评审的场景

这里其实可以利用保护分支的规则,作出一个共识:凡是已合并到保护分支上的代码,都是已经过评审的「安全」代码,无需再次评审。

+

因此可以得出结论:只有从非保护分支(特性分支)往保护分支合并的场景需要评审,其它场景均无需评审。

+
const targetBranch = await service.getBranchInfo(pid, object_attributes.target_branch)
const sourceBranch = await service.getBranchInfo(pid, object_attributes.source_branch)
if (sourceBranch.protected || !targetBranch.protected) {
// do something
return
}
+ +

通过评论实现评审流程

mrc(ctx) 中,可以实现 MR 收到新评论时的逻辑,如下图所示:

+
+ +

部分关键代码:

+
// 获取 mr 下的所有评论
const notes = await service.listCommentsOfMergeRequest(pid, mid)
// 找出邀请评论
const inviteNote = _.find(notes, v => v.author.username === 'bot' && /请.+?@.+?评审/.test(v.body))
// 找出邀请了的人
const inviters = inviteNote.body.match(/\[@.+?]/g).map(v => v.replace('[@', '').replace(']', ''))
// 找出没有 lgtm 的人
const notReviewPeople = []
inviters.forEach((uid, index) => {
const regex = new RegExp('lgtm')
if (!_.find(notes, v => v.author.username === uid && regex.test(v.body))) {
notReviewPeople.push(uid)
}
})
+ +

后记

以上评审流程,基本就是来自现在 Github 各大仓库流行的 bot 系统。可以发现,这套系统对比 Gerrit 等实现方案,除了对现有 Gitlab 用户十分友好之外,其最大的好处之一,就是控制权完全在自己手里。除了以上说到的逻辑以外,还可以自己实现任意想要的东西。如:

+
    +
  1. 为 MR 打标签。如评审人标签、评审状态标签、MR Change Size 标签,等等;
  2. +
  3. 检查 MR 内的 Commit Msg 是否合法;
  4. +
  5. 检查 MR 的 CI 是否通过;
  6. +
  7. 实现管理员用户,拥有更高的权限,通过特殊评论可以略过其它评审员直接合并,或完成其他功能
  8. +
  9. +
+

限制你的只有想象力。

+

在实现了以上的一些逻辑后,目前我司评审系统的代码量加起来也没有超过 300 行。可以说相比于购买 Gitlab EE 来说,性价比还是相当高的。

+]]>
+ + gitlab + devops + nodejs + +
+ + Go 语言性能调试与分析工具:pprof 用法简介 + /2023/go-pprof-note/ + pprof 是 Google 开发的一款用于数据分析和可视化的工具。

+

最近我编写的 go 程序遇到了一次线上 OOM,于是趁机学习了一下 Go 程序的性能问题排查相关知识。其基本路线是:先通过内置的 net/http/pprof 模块生成采集数据,然后在使用 pprof 命令行读取并分析。Go 语言目前已经内置了该工具。

+

本文不会介绍 pprof 的太多细节,只关注主要流程(主要的是太细的我现在也不会)。

+ + +

数据采集

由于 Go 语言内置了对 pprof 的支持,因此无需额外安装其它依赖,只需要在程序入口处引入相关包,并且启动一个服务即可:

+
package main

import (
"net/http"
_ "net/http/pprof"
// ...
)

func main() {
go func() {
// pprof 服务器,将暴露在 6060 端口
if err := http.ListenAndServe(":6060", nil); err != nil {
panic(err)
}
}()

// ...
}
+ +

需要注意的是 pprof 服务需要使用独立的协程运行,否则会阻塞代码运行。添加这段代码后,程序除了运行原本的逻辑外,还将额外监听一个端口(此处为 6060),在本地运行程序,打开 http://localhost:6060/debug/pprof/,将看到如下界面:

+

+

数据采集服务即启动成功。

+

我刚开始看到这段代码的时候有点好奇:为什么它的 handler 传了 nil,但是却能够启动一个 debug 服务呢?

+

后来仔细看了一下源码后就发现,handler 如果传 nil 的话,即默认为 DefaultServeMux

+
// net/http/server.go
type Server struct {
// ...
Handler Handler // handler to invoke, http.DefaultServeMux if nil
// ...
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
// ...
}

// HandleFunc registers the handler function for the given pattern
// in the DefaultServeMux.
// The documentation for ServeMux explains how patterns are matched.
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, handler)
}
+ +

net/http/pprof/pprof.go 在 init 函数中,向 DefaultServeMux 注册了几个路径:

+
// net/http/pprof/pprof.go

func init() {
http.HandleFunc("/debug/pprof/", Index)
http.HandleFunc("/debug/pprof/cmdline", Cmdline)
http.HandleFunc("/debug/pprof/profile", Profile)
http.HandleFunc("/debug/pprof/symbol", Symbol)
http.HandleFunc("/debug/pprof/trace", Trace)
}
+ +

因此,如果启动一个 handler 为 nil 的 http 服务,即默认会添加上 pprof 的一系列路由。

+
+ + +

这个 web 界面上面有一些链接,每个链接代表一个监控项目,页面下方也对它们做了一些解释。直接点进去的话,会展示一些数据,但是目前这些数据比较原始,可读性不佳。但好消息是,后面可以通过 pprof 命令行对它们进行进一步的分析。

+

指标解释

除了 cmdlinetrace 以外,上面的每一个链接都代表一种指标。常用指标如下:

+
    +
  • profile:CPU 占用率
  • +
  • heap:当前时刻的内存使用情况
  • +
  • allocs:所有时刻的内存使用情况,包括正在使用的及已经回收的
  • +
  • goroutine:目前的 goroutine 数量及运行情况
  • +
  • mutex:锁争用情况
  • +
  • block:协程阻塞情况
  • +
+

pprof 命令行工具

程序运行一段时间后,我们就可以通过 pprof 命令行来进行数据分析了。打开一个终端环境,输入 go tool pprof http://localhost:6060/debug/pprof/allocs 并按下回车,就能看到如下界面:

+

+

此时即连接成功。接下来在终端中操作,将展示命令行启动时刻的监控数据。输入 help 可以展示所有的命令,输入 help [command] 可以展示具体命令的帮助界面。

+

现在回到上面那个链接 http://localhost:6060/debug/pprof/allocs,观察一下它的构成:

+
    +
  1. 首先,是一个常规的 host+port 组合,由于我们在本地启动服务且指定监听 6060 端口,因此这里填写 localhost:6060。但是它同样也支持远程连接。即对部署在服务器上的程序进行分析。只要遵循同样的格式,以及保证路径可访问即可;
  2. +
  3. 然后跟随的是一个 /debug/pprof/ 路径,此为固定值;
  4. +
  5. 最后是一个 allocs,这个代表某一种监控指标。回到刚刚的那个 web 界面,除了 cmdline 以外,每一个链接都代表一种指标,可以在此处直接填入,即可更换分析目标。
  6. +
+

命令行实际上读取的是一个由 pprof web 服务提供的 .pb.gz 文件,它是一个通过 gzip 压缩的 protocol buffer 数据。其源码在 runtime/pprof 包中。

+
type profileBuilder struct {
// ...
zw *gzip.Writer
pb protobuf
// ...
}

func newProfileBuilder(w io.Writer) *profileBuilder {
zw, _ := gzip.NewWriterLevel(w, gzip.BestSpeed)
b := &profileBuilder{
// ...
}
b.readMapping()
return b
}
+ +

如果要退出 pprof,可以输入 exit 并回车。

+

下面介绍几个常用命令。

+

top:列出数据

要列出当前资源的占用情况,可以在 pprof 中使用 top 命令:

+

+

默认会按照资源占用率从高到低,显示 10 条数据。

+

它上面的每项指标(flat/flat%/sum/cum/cum%)大致理解就是数值越大则资源占用情况越严重。结果默认按照 flat 排序。其指标含义的详细解释可以参考 pprof 文档

+
+

flat: the value of the location itself.
cum: the value of the location plus all its descendants.

+
+

cum 是 cumulative(累积) 的缩写。

+
+ +

我的理解是:

+
    +
  • flat:函数内所有直接语句的时间或内存消耗;
  • +
  • cum:函数内所有直接语句,以及其调用的子函数的时间或内存消耗;
  • +
  • sum:没有在文档中找到对应解释,但是通过观察可以发现,它是 flat% 的累加值。
  • +
+

通过一个例子来解释:

+
func foo(){
a() // step1,假设消耗 1s
b() // step2,假设消耗 2s
time.Sleep(3 * time.Second) // step3,消耗 3s
c() // step4,假设消耗 4s
}
+ +

这个函数总共将花费 1 + 2 + 3 + 4 = 10 秒,其中:

+
    +
  • flat 等于 3,因为该函数的直接操作只有 step3
  • +
  • cum 包含所有直接语句以及子函数的消耗,即 step1 + step2 + step3 + step4
  • +
  • step 4 的 sum% 为 step1、step2、step3、step4 的 flat% 总和
  • +
+
+ +

list:显示详情

当发现某个函数资源占用情况可疑时,可以通过 list 函数名 定位到具体的代码位置。举例:

+

+

该案例显示,在第 666 行处,dw 占用了 8.81MB 的内存,667 行占用 5.95MB 内存,该函数合计占用 14.76 MB。

+

此处也可以对应到上面提及的 flat/cum 含义:666 行计入 flat,666 + 667 行计入 cum。

+
+ +

web:可视化分析

在使用 web 命令之前需要做一个准备工作:安装 Graphviz 工具,并将它添加到系统 path 中。

+

直接在命令行输入 web,pprof 将打开一个浏览器,并展示一个可视化的分析界面:

+

+

在 web 界面上将显示函数的完整调用链路,界面可以通过鼠标拖拽、缩放。其图形详细的解释(来自 pprof 文档):

+
    +
  • 节点颜色与 cum 值有关:
      +
    • 正值大的为红色
    • +
    • 负值大的为绿色(负值通常在 profile 对比时出现)
    • +
    • 值接近 0 的为灰色
    • +
    +
  • +
  • 节点的字号与 flat 的绝对值有关:值越大则字号越大
  • +
  • 边的粗细与该路径下的资源使用有关:资源使用越多则线条越粗
  • +
  • 边的颜色与节点颜色类似
  • +
  • 边的形状:
      +
    • 虚线:两个节点之间的部分节点被移除了(间接调用)
    • +
    • 实现:两个节点之间存在直接调用关系
    • +
    +
  • +
+

粗略地来说,每个节点的方块越大、线条越粗、颜色越红,则代表资源占用情况(相对来说)越严重,需要重点关注。

+
+ +

对应上图的例子来说:

+
    +
  • (*Rand).Read 的 flat 值较小(字号较小、灰色)
  • +
  • (*compressor).deflate 的 flat 值与 cum 值均较大(字号较大、红色)
  • +
  • (*Writer).Flush 的 flat 值较小(字号较小),但 cum 值较大(红色)
  • +
  • (*Writer).Write(*compressor).write 之前的线条是较粗、红色的虚线,因此它们之间的某些节点被移除了,且使用的资源较多
  • +
  • (*Rand).Readread 之前的线条是较细、灰色的虚线,因此它们之间的某些节点被移除了,且使用的资源较少
  • +
  • read(*rngSource).Int63 之前的线条是较细、灰色的实线,因此它们之间存在直接调用关系,且使用的资源较少
  • +
+

sample_index:切换采样值

某些监测类型会拥有多种采样值,可以通过 help sample_index 查看当前可用的采样值:

+
(pprof) help sample_index
Sample value to report (0-based index or name)
Profiles contain multiple values per sample.
Use sample_index=i to select the ith value (starting at 0).
Or use sample_index=name, with name in [alloc_objects alloc_space inuse_objects inuse_space].
+ +

通过 sample_index=i 可以切换采样方式。切换后再次使用 top 命令,展示的结果将会有些区别。

+

pprof 的 Web 界面

可以通过 go tool pprof -http=:8888 http://localhost:6060/debug/pprof/allocs 命令直接打开一个 web 界面,这个 web 界面将拥有与命令行类似的功能,并且可以显示火焰图。同样,这个命令需要先安装 Graphviz 工具。

+

+
    +
  • View 菜单展示的几项功能:
      +
    • Top 与命令行的 top 类似
    • +
    • Graph 与命令行的 web 类似
    • +
    • Peek/Source 与 list 命令类似:在 Top 选中一行,或者 Graph 选中一个节点后,切换到 Peek 或 Source 界面,将展示该行/节点的代码详情
    • +
    • Frame Graph 为火焰图
    • +
    • Disassemble:查看汇编代码
    • +
    +
  • +
  • Sample 菜单与命令行的 sample_index 类似
  • +
+

火焰图

此处以 Frame Graph (new) 举例。

+

+

解释:

+
    +
  • 节点的颜色是由它的包名决定的,相同包名的节点将拥有相同的颜色
  • +
  • 节点的字号可能会有区别,但是与上面的图形不同的是,此处的字号仅为适应其节点大小,并无其它含义
  • +
  • 上方的节点为调用者,下方的节点为被调用者
  • +
  • 节点的宽度表示资源使用情况,越宽则资源使用越多
      +
    • 其总宽度代表 cum
    • +
    • 去除其子节点后,剩余的宽度代表 flat
    • +
    +
  • +
  • 如果上下节点之间没有边框,则表示这两个函数被“内联”了。关于内联的具体含义,可以参考 Go: Inlining Strategy & Limitation
  • +
+

一个函数可能会被多个不同的函数调用,因此 pprof 对传统的火焰图进行了改良:点击任意函数将显示所有最终导向该函数的调用栈,而非仅当前点击节点的调用栈。

+
]]>
+ + go + pprof + +
+ + “渐进增强”与“优雅降级” + /2016/graceful-degradation-versus-progressive-enhancement/ + “渐进增强”与“优雅降级”是 Web 页面两种不同的开发理念,为了简单起见,先给出定义(By W3C):

+
+

Graceful degradation Providing an alternative version of your functionality or making the user aware of shortcomings of a product as a safety measure to ensure that the product is usable. Progressive enhancement Starting with a baseline of usable functionality, then increasing the richness of the user experience step by step by testing for support for enhancements before applying them.

+
+

翻译:“优雅降级”的目的是为你的功能模块提供一种替代方案,或者让用户意识到某种产品(浏览器)的缺陷来保证你的产品的可用性。“渐进增强”是在一个最基本的可用功能之上,通过在拓展功能前检测(浏览器的)支持性逐步地提升用户体验。

+

这两种方案看起来好像没有什么太大区别,并且最终的结果貌似也是一样的。但是看完后面更多的解释和示例,就会更明白一些,其实这里面是真的有区别的。

+

一些博文将其简单地归结为如下内容:

+
.transition {   /*渐进增强写法*/
-webkit-transition: all .5s;
-moz-transition: all .5s;
-o-transition: all .5s;
transition: all .5s;
}
.transition { /*优雅降级写法*/
transition: all .5s;
-o-transition: all .5s;
-moz-transition: all .5s;
-webkit-transition: all .5s;
}
+ +

这个解释是完全错误的。实际上任何情况下我们都应该使用前者的 CSS 写法。

+ + +

在不断变化的环境中开发

稍微接触过 Web 开发的同学都能发现,我们目前所面对的最大难题不是如何实现强大的功能,而是如何保证即使是不那么强大的功能也能够被所有(退一步说,大多数)用户正常地使用。

+
+

The web was invented and defined to be used with any display device, in any language, anywhere you want. The only thing expected of end users is that they are using a browsing device that can reach out to the web and understand the protocols used to transmit information — http, https, ftp and so on.

+
+

正是因为可以访问 Web 的设备、语言、环境等因素太多太复杂,在极大促进 Web 发展的同时,也导致了以上困境。我们开发者无法对用户所使用的设备有任何期待。因此,我们无法保证 Web 应用上的所有功能都能够正确运行。

+

我们需要在未知中寻找出路,这就是“渐进增强”与“优雅降级”出现的原因。

+

二者概览

上面有对二者有一个简单的定义,这里再作一些扩展。

+

优雅降级:

+

首先确保使用现代浏览器的用户能够获得最好的用户体验,然后为使用老旧浏览器的用户优雅降级。降级的结果可能会导致功能不再那么美好,但是,只要能够为用户保留住访问网站的最后一点价值就算是达到了目的。让他们至少能看到正常的页面。

+

渐进增强:

+

相似,但又不一样。首先为所有浏览器都提供一个能够正常渲染并工作的版本,然后为更高级的浏览器构建高级功能。

+

换句话说,“优雅降级”是从复杂的现状开始,尝试去修复一些问题;而“渐进增强”则从最基础入手,为更好的环境提供扩展。“渐进增强”可以让你的基础更加牢固,并且始终保持向前看的姿态。

+

一个例子

光说真的很难理解,我们来看个例子。(By W3C)

+

“打印此页”链接

+

有时候我们会想让用户可以点击一个链接或按钮以打印整个页面,于是拍脑袋就有了如下代码:

+
<p id="printthis">
<a href="javascript:window.print()">Print this page</a>
</p>
+ +

这段语句在启用 JavaScript 并且支持 print 方法的浏览器中非常完美。然而,悲催的是,万一浏览器禁用了 JavaScript,或者根本就不支持,那么点击它就完全没有任何反应了。这就造成了一个问题,作为站点开发者,你写出这个链接就相当于向用户保证了这项功能,然而并没有,用户会感到困惑、被欺骗,并且责怪你提供了如此差的用户体验。

+

为了减轻问题的严重程度,开发者通常会使用优雅降级的策略:

+

告诉用户这个链接可能会不起作用,或者提供替代方案。一般来说我们会使用 noscript 元素来达到目的,就像这样:

+
<p id="printthis">
<a href="javascript:window.print()">Print this page</a>
</p>
<noscript>
<p class="scriptwarning">
Printing the page requires JavaScript to be enabled.
Please turn it on in your browser.
</p>
</noscript>
+ +

这就是优雅降级的一种体现 —— 我们告诉用户发生了错误并且如何去修复。但是,这有一个前提,用户必须是:

+
    +
  • 知道什么是 JavaScript
  • +
  • 知道怎么启用它
  • +
  • 有权限去启用它
  • +
  • 愿意去启用它
  • +
+

下面这种方式可能会更好些:

+
<p id="printthis">
<a href="javascript:window.print()">Print this page</a>
</p>
<noscript>
<p class="scriptwarning">
Print a copy of your confirmation.
Select the "Print" icon in your browser,
or select "Print" from the "File" menu.
</p>
</noscript>
+ +

这样上面所说的问题就都解决了。然而它的前提是所有浏览器都提供了“打印”功能。并且,事实依然没有任何改变:我们提供了一些可能完全没用的功能,并且需要做出解释。实际上这个“打印此页”链接完全就是没有必要存在的。

+

如果我们换一种方式,使用“渐进增强”法,则步骤如下。

+

首先我们考虑是否有一种方式可以不用写脚本实现打印功能,事实上并没有,因此我们从一开始就不应该选择“链接”这种 HTML 元素来使用。如果一项功能依赖 JavaScript 来实现,那就应该用 button

+

第二步,告诉用户去打印这个页面,就这么简单:

+
<p id="printthis">Thank you for your order. Please print this page for your records.</p>
+ +

注意,这无论在什么情况下都一定是适用的。接下来,我们使用“循序渐进”的 JavaScript 来给支持此功能的浏览器添加一个打印按钮:

+
<p id="printthis">Thank you for your order. Please print this page for your records.</p>
<script type="text/javascript">
(function(){
if(document.getElementById){
var pt = document.getElementById('printthis');
if(pt && typeof window.print === 'function'){
var but = document.createElement('input');
but.setAttribute('type','button');
but.setAttribute('value','Print this now');
but.onclick = function(){
window.print();
};
pt.appendChild(but);
}
}
})();
</script>
+ +

注意到何为“循序渐进”了吗:

+
    +
  • 使用自执行匿名函数包装法,不留下任何影响
  • +
  • 测试 DOM 方法的支持性,并且尝试获取节点
  • +
  • 测试节点是否存在,window 对象以及 print 方法是否存在
  • +
  • 如果全都没问题,我们就创建这个功能按钮
  • +
  • 把按钮添加到需要的位置上去
  • +
+

我们永远不给用户提供不能工作的 UI —— 只在它真正能工作时才显示出来。

+

适用场景

优雅降级适用场景:

+
    +
  • 当你在改进一个旧项目但时间有限的时候
  • +
  • 当你没有足够的时间使用渐进增强法去完成项目的时候
  • +
  • 当你要做的产品比较特别,比如说对性能要求特别高的时候
  • +
  • 当你的项目必须要有一些基础功能的时候(地图,邮箱等)
  • +
+

其余的所有情况下,渐进增强都能让用户与你都更开心:

+
    +
  • 不管环境如何,你的产品都能工作
  • +
  • 当新技术或浏览器发布的时候,你只需在原有基础上扩展功能,而无需修改最基础的解决方案
  • +
  • 技术可以更“用得其所”,而不仅仅是为了实现功能
  • +
  • 维护方便
  • +
+

总结

“渐进增强”与“优雅降级”最终都是为了实现一个目标:让所有用户都能够使用我们的产品。“渐进增强”方案看起来更优雅,但它需要更多的时间与资源去达成。“优雅降级”可以看成是现有产品的补丁:易于开发但难以维护。

+

注:本文大部分内容来自 W3C

+]]>
+ + css + +
+ + 毕业 + /2015/graduation/ + 在家上中学的时候并不知道每天可以有家可归是一种怎样的幸福,直到后来再也没有这样的机会。如今人生已走过三分之一,想到以后都不会有机会在自己熟悉的地方长住,心里很不是滋味。有时候会想,如果目前还是自己一个人在生活的话,我就可以回到家这边来找一份不痛不痒的工作,先做个一两年。然而,看到以往的玩伴也渐渐走上正轨,供车供房,同学纷纷开始工作,读研留学,自己也会有些迷惘。尤其羡慕留学的朋友,从此以往告别这片神奇的土地。后悔大学没有好好学习,不然可以争取一些保研的机会,就不用这么早和自己的学生生涯说再(可以再打两年dota)。

+

三分之一已经过去了,学生时代的一些人和一些事也都应该告一段落了,该奋斗的奋斗,该拼爹的拼爹,都要上路了,也没什么闲暇来和老同学扯淡。一些当年觉得很好的朋友,如今看来也不过如此,以后估计也难再有交集。虽然不知道自己以后还能走多远,但是想到人生过了这么长,没有留下多少美好的记忆,也没有交到很多很好的朋友,满房间的物件,却并没有承载很多过去,觉得自己虚度了很多光阴,却也无法弥补,就总是会觉得很伤感。小学时候的课本笔记等早已不知所踪,只留下一两张泛黄的照片,初中的记忆本该满满却没有珍惜,高中不谈,大学更差,就是dota的一千个日与夜。我以后再也不想通宵玩游戏了,每次看到天亮都十分不安。如果我以后有了小孩,一定会帮她(他)把成长过程中的物件都收拾整理好,待到长大,将会是珍贵的回忆。

+

说到小孩,如今父母也会开始谈及小孩了,真是措手不及,我不还是个孩子吗,怎么就说到我的孩子了。你们一定是在逗我。

+

感谢毕业照那天来看我的同学朋友们,当日一别,更不知道何时再见。

+

大四最后一个学期都是在外居住,并不知道学校里的冷暖。周末回校,也只是洗洗衣服,打打游戏,完全感受不到自己还是一个在校学生。有一次在工作日请假回了学校,中午过了饭点仍自信下楼,才猛然想起原来大家还是要上课的,这会刚下课呢。这几年来我也算是有惊无险的体验了,挂了不少课,还好重修能过,马哲顺利,不然极有可能自信心受到打击从而陷入无尽轮回(需要感谢一下窦庆萍老师)。在知道自己大四上没有挂科,毋须延迟毕业后,心里面真的是很轻松,那么我也算是走过来了。

+

大学的同学里,我并没有与很多人熟识,也没有交到许多朋友,有几位可能甚至四年来都没有说过一句话,不过也不能完全怪我,我们的专业选得好,完全不用与人交流。谢师宴过后,很多人我仍然是只知道名字,其它一无所知,可能再也不会有机会相见了,然而我并不在乎,因为本来就跟不认识一样。对事不对人,自己的大学生活失败,与同学无关。

+

工作的地方在唐家湾的软件园,近期也可能会一直住在这一片,在珠海的同学朋友没事可以来找我玩,有活动也可以带上我,有麻烦如果能帮上忙也请找我。从学校到工作地点的一路上都是海岸,大概有二十多公里,每次经过都觉得很舒服,然而不知道台风会不会封路。

+

有一个正经的女朋友会给自己带来压力与动力,同时也会非常大程度地限制自己的自由,不能想去哪里就去哪里,不能想做什么就做什么,这点让我非常不自在。也许现在到了一个我需要照顾别人的时候,但是我还没有完全准备好。并没有想到大学最后一年能找到女朋友,我也没有准备好。要出外工作,住两三年出租屋,同样没有准备好。 我本来只是一个呆在宿舍每天打dota的大学生,现在生活需要做出这么大的转变,有点不知所措。独生儿习惯了被照顾,现在要开始学会自己打理一切,总有些转不过来。说到底,我就是想呆在家里睡觉。

+]]>
+ + personal + +
+ + 终于要放假了 + /2017/holiday-soon-finally/ + 最近事情有点多,导致好久没有更新过博客。过完后天终于要到国庆假期了,希望可以多点时间在家休息(睡觉)。经常加班到 10 点,周末也时常单休,连续下来还是挺累人的。

+

公司的饭菜开始吃腻了,每天都能找到不想吃的菜(或者找不到想吃的菜)。

+

假期一定要抽空把这几个月学到的东西总结一下。

+]]>
+ + personal + +
+ + WordPress 博客搭建 + /2016/how-to-build-a-wordpress-blog/ + 本文是本站的建站历程记录。每个人都可以使用极少的代价(甚至免费)拥有一个域名独立且完全自主的个人网站或博客,在于怎么选择而已。此类网站的搭建很多情况下并不要求其操作者是一个程序狗,所以个人感觉可玩性还是挺强的。整个过程一共需要准备三种事物:域名托管程序(特殊情况,如果选择国内主机则需要准备第四种,即备案)。

+ + +

域名

搭建一个网站首先必不可少的就是自己的域名,比如本站的域名是wxsm.space,域名可以在任意服务商处购买,不需要与托管的服务商一致。根据域名后缀的不同,价格在几十元至百元每年之间不等,大多数服务商都会提供首年优惠,少部分首年的价格甚至可以达到个位数(比如万网的 .top 域名购买首年只需4软妹币)。

+

域名的选择没什么技术含量,主要就是自己喜欢。本站的域名是在万网购买。域名购买以后可以解析到托管的IP地址。

+

需要注意的地方:

+
    +
  1. 如果打算购买国内(特指大陆)主机,就一定要确定自己购买的域名后缀是可以备案的类型。具体可以在工信部网站(公共查询 ⇒ 域名类型)查询,能查到的则是可备案后缀。切记切记。
  2. +
  3. 为自己的安全着想,最好不要使用 .cn 类型的域名。
  4. +
+

托管

托管有很多种选择,首先从大的方向说,尽量选择靠谱的供应商。淘宝小商家之类的虽然便宜,但是很多时候我们需要的更多是稳定,毕竟是把代码和数据都放在别人家,还是很要命的一件事。当然如果免费的话又另当别论。至于配置就纯看个人需求了。如果是玩玩个人小网站,一般选最低的那些都没问题。

+

国内/香港/国外

国内主机理论上来说从国内访问速度最快,其最大的特点是需要备案,其二是会被GFW限制,主机上的程序将会无法访问到Google等公司提供的服务。

+

香港主机速度可媲美国内主机,是主攻国内但是又不想备案的不二之选。

+

国外主机在国内的访问速度可能会非常之慢,尤其是欧美地区,稍快一些的可以选择新加坡。其特点是很多都相对国内以及香港主机较为便宜,以及有部分免费(比如AppHarbor,免费空间,免费SQL Server,无限流量,同步Github仓库,自动部署,简直不要太良心,可惜只能跑.Net)。

+

本站目前使用的是万网提供的国内主机。

+

虚机/VPS/其它

虚机即虚拟主机。该类型主机一般都是共享系统资源,如CPU,内存,带宽,IP等。虚机好像只有ASP.Net和PHP两种类型(反正我是没见过其它的),并且所有功能都是由外部配置好的,用户只能使用服务商所提供的功能,超出范围则无能为力,因此它的限制会比较大。操作方式就是通过FTP访问其储存空间,然后将写好的程序上传,马上就能在浏览器看到结果,不需要考虑部署、环境等。如果使用CMS(如Wordpress)的话,这种主机已经足够用了。

+

VPS相当于一台属于自己的计算机,用户可以通过各种方式登录并且对它操作,安装环境,部署网站等。Java,Node.js等类型的程序好像只能跑在VPS上。因为没有用过所以不太了解。其价格要普遍比虚机贵一些。

+

还有一些是类似Github Pages的静态服务器,它们只能够作为静态网页的托管。这些可能会很便宜,但是需要一定的技巧才能玩出花样来。

+

本站目前使用的是万网提供的虚机,免费版。每个人都可以申请,使用期限为两年。运行Wordpress完全没有问题。传送门:http://wanwang.aliyun.com/hosting/free/

+

程序

有了域名和托管,我们需要的最后一件事物就是程序。程序才是真正运行着的东西。

+

程序可以自己写,也可以用开源软件。自己写的好处就是完全控制,以及比较有意思,但如果更多的是想要做内容的话还是用开源软件比较好,这样就可以更好地关注于网站的内容本身而不是实现。

+

本站使用的是开源Wordpress CMS,我们要做的事情很简单,在中文官网https://cn.wordpress.org/首页把Wordpress程序下载回来,解压,然后将文件夹上传到服务器根目录,通过访问其任意页面来配置站点的基本信息,如名字,描述,数据库连接等,就可以非常方便地搭建好整个网站。数据库表等其它事物都会由程序自动生成。关于这个软件的安装和使用方法网络上有非常多的详细教程,遇到问题多用搜索就好啦。

+

WordPress的主题和插件简直数不过来,我觉得满足98%个人网站用户的需求完全是没问题的。加之它有强大的缓存插件,可以自动将所有的动态页面都缓存成HTML然后301之,所以访问性能也不在话下。当然如果想要实现某些特别的自定义功能的话,还是要懂一点点编程技巧才行。

+

备案

如果使用的是国内的托管,则需要进行最麻烦的一步:备案。

+

备案一般是由代理商完成,不需要我们自己直接与工信部沟通。

+

整个流程有两个地方稍微麻烦,一是初审填表,需要下载、打印、填表、扫描、上传这么多的步骤。其次是“当面核验”,其实就是拍个照上传,但是需要它专用的背景幕布,可以到指定的拍照地点免费拍摄,或者代理商以免费或者到付的形式快递幕布给申请人,然后自行拍照上传。

+

管局审核需要的时间从从两个小时到三十天不等,主要看运气。反正我是这个时间段内的都遇到过(广东)。

+

备案通过以后,需要把备案号以链接的形式加到网站的底部,然后就可以该干嘛干嘛了。

+]]>
+ + wordpress + +
+ + IDEA 滚动条问题 + /2016/idea-scrolling-issue/ + 用 IDEA 撸代码的时候有一个非常恶心的问题,它的滚动条经常会无缘无故地跳动,最常见的就是拖动滚动条之后它会马上跳回到原本的位置,纵向和横向都有此问题,因此基本上每次都至少要拖两次滚动条才能成功,烦不胜烦。升级版本等等都没有用。今天终于找到了真正的解决方法,就是关闭屏幕取词软件或禁用软件的取词功能(比如有道)。完全、彻底地解决此问题。

+]]>
+ + idea + +
+ + IE Cache Issue + /2016/ie-cache-issue/ + 昨天发现了一个奇怪的问题,一个Web Application Update Entity的功能,在Chrome/Firefox上测试都正常运行,到了IE 11上就不行了,主要表现就是Update成功以后再次读取记录会读取出Update之前的值。功能逻辑就是一些简单的通过RESTful API来执行CRUD操作的Ajax调用。在IE上用控制台仔细调试一番后,发现在打开控制台的时候居然能表现正常,而关掉以后就立刻不行,这明显就是IE爸爸不走寻常路,把API也Cache下来了。于是就有了以下的解决方案。

+ + +

前端解决方案

既然是因为Cache产生的问题,那么就很容易解决,在API调用(主要是GET)中都添加一个随机数或者时间戳就行了,强制浏览器刷新。比如,原本请求的应该是这样的地址:

+
var url = '/api/metadata/entity/list?type=car&name=qq'
+ +

可以通过添加一个时间戳修改成这样:

+
var url = '/api/metadata/entity/list?type=car&name=qq&_t=' + new Date().getTime()
+ +

其中添加的 _t 参数如果服务端没有定义的话就会自然而然地被扔掉(如果是有意义的参数就换个key,或者不写key也行),浏览器缓存也会因为每次请求的URL实际上都不一样而失效,这样问题就解决了。但是,对于一个大型项目来说,如果每个URL都要怎么来一遍,那么用软件工程界的专业术语来说,叫做不好维护。很有可能什么时候漏掉了一个URL没有加时间戳,就埋下了一个BUG的种子。

+

服务端解决方案

此处以使用ExpressJS搭建的NodeJS服务器为例,其它代码也可以使用类似的方法达到同样的效果。

+

以下是一本万利的解决思路:

+
// No cache for RESTful APIs
app.use('/api/*', function (req, res, next) {
res.header("Cache-Control", "no-cache, no-store, must-revalidate");
res.header("Pragma", "no-cache");
res.header("Expires", 0);
next();
});
+ +

这段代码所做的事情是,对于所有进来的以 /api 开头为路由的请求,都执行以下操作:

+
    +
  • 给响应头添加 "Cache-Control": "no-cache, no-store, must-revalidate" 键值对
  • +
  • 给响应头添加 "Pragma": "no-cache" 键值对
  • +
  • 给响应头添加 "Expires": 0 键值对
  • +
  • 将请求交给下游中间件,继续处理,该干嘛干嘛
  • +
+

Cache-Control :

+
    +
  • no-cache:指示请求或响应消息不能缓存
  • +
  • no-store:用于防止重要的信息被无意的发布。在请求消息中发送将使得请求和响应消息都不使用缓存。
  • +
  • must-revalidate:字面理解,必须重新验证
  • +
+

Pragma :

+
    +
  • no-cache:在HTTP/1.1协议中,它的含义和Cache- Control:no-cache相同
  • +
+

Expires:

+
    +
  • 自然就是缓存的过期时间了
  • +
+

那么通过以上方法,只要浏览器是支持基本HTTP协议的,它就应该能够做出相应的操作,从而不对API进行缓存。很显然这段代码应该在所有API的具体方法执行之前被执行,对于Express来说我们只需要把它放在其他路由代码之前就可以了。

+

总结

经过验证,两种方法都可以达到预期的效果。至于实际使用哪一种,可能还要视具体需求而定。

+]]>
+ + nodejs + express + ie + +
+ + Integrate Renovate with GitLab + /2020/integrate-renovate-with-gitlab/ + 企业项目群中往往会有部分代码逻辑需要公用,将其抽离作为公共包发布到私有源的做法是比较优雅的解决方式。但是这么做的话后期需要面临一个问题:当一个公共依赖包的使用者数量逐渐庞大的时候,如何保证当此包发布新版本时,所有使用者都能尽可能快地得到更新?

+

传统的解决方案:

+
    +
  1. 手工对所有项目逐个升级。这种办法相当繁琐,且容易产生遗漏,当项目数量足够庞大的时候,发布一次将会是相当痛苦的体验;
  2. +
  3. 在依赖安装时指定版本为 latest。这种办法虽然能保证每次安装时都能得到最新版本,但是却有诸多弊端,如:
      +
    1. 无法保证依赖的安全性,有可能一次更新不慎造成大面积的瘫痪;
    2. +
    3. 对「依赖锁」不友好,如 yarn.lock 等。
    4. +
    +
  4. +
+

因此,如何使这个过程变得优雅,是一个亟待解决的问题。

+ + +

关于 Renovate

Renovate 是一个专注于解决依赖问题的库,使用 Node.js 编写,因此它也许会更适合于使用 NPM 或 Yarn 作为依赖管理的项目。我最早从 zexo.dev/使用 renovate 监控第三方依赖更新 这篇博文中得知了这个工具,在 GitHub 上托管的个人项目上尝试了一段时间后发现它非常好用。

+

如何工作?

复杂的流程就不讲了。总的来说,它会对启用了它的项目做以下几件事情:

+
    +
  1. 发起一个 Onboard PR(示例),将它的配置文件以 PR 的形式合并到项目中。在这个 PR 被合并前,不会有任何后续操作。
  2. +
  3. 在 Onboard 被合并后,发起一个 Pin PR(示例),将项目中用到的依赖的版本锁定,对于 package.json 来说,即去除任何模糊的通配符,如 ^ / ~ 等,改用精确的版本号。在这个 PR 被合并前,不会有任何后续操作。
  4. +
  5. Pin PR 被合并后,开始周期性地检索依赖。当发现有更新时,为每个依赖(或依赖群)更新发起一个 PR(示例),内容包含依赖定义文件(如 package.json) 与依赖锁文件(如 yarn.lock)。
  6. +
  7. 如果用户想要做本次升级,将其合并即可。将来如果该依赖再次有更新可用,会再次生成新的 PR;
  8. +
  9. 如果用户不想做本次升级,不理会或将其关闭即可:
      +
    1. 若不理会,在将来该依赖再次升级时,Renovate 会更新该 PR 至新版本;
    2. +
    3. 若关闭,Renovate 将忽略该版本,不再发起 PR。
    4. +
    +
  10. +
+

以上只是大致流程,实际上 Renovate 还有非常多的配置项可以发掘,可以提供高度定制化的使用体验。

+

如何使用?

如果是在 GitHub 上使用,只需到应用市场安装 Renovate 并为它提供想要开启服务的项目的访问权限即可,过几分钟就能在项目内收到 Onboard PR。但这部分不是本文的重点。

+

本文重点是如何在私有环境中使用它,即 Self-hosted 环节,与我司的自建 GitLab 进行集成。

+

根据 官方文档,自建 Renovate 服务有以下几种方式:

+

方式 1:npm install -g renovate

该方式最简便,只需要安装了 Node.js 环境以后,通过以上 cli 工具即可实现所有功能。但是官方文档对他的描述十分简略,几乎没有,勉强通过 --help 才试出了用法:

+
GITHUB_COM_TOKEN=your-github-token renovate \
--platform=gitlab \
--endpoint=https://gitlab.cpmpany.com/api/v4/ \
--token=your-gitlab-token \
--onboarding=true \
--onboarding-config="{\"extends\": [\"config:base\"]}" \
--log-level=debug \
--yarnrc="registry \"http://npm-registry.cpmpany.com\"" \
--npmrc="registry=\"http://npm-registry.cpmpany.com\"" \
path/to/project
+ +

解释:

+
    +
  1. GITHUB_COM_TOKEN 是用来从 GitHub 上获取 Changelog 时要用到的。如果没有提供这个 token,则 Renovate 不会尝试去获取 Changelog;
  2. +
  3. platform / endpoint / token 分别对应目标平台的参数;
  4. +
  5. onboarding 表示项目必须先接受 Onboard PR 才会执行后续操作;
  6. +
  7. onboarding-config 为 Onboard PR 所提供的默认配置文件;
  8. +
  9. log-leveldebug 时才能得到详细的日志,方便调试;
  10. +
  11. 项目内一般自带了 yarnrc 与 npmrc,如果项目内自带的已经覆盖了私有源则无需配置,否则需要配置。需要注意的是,如果使用 npm 则只需要提供 npmrc,但如果使用 yarn 则需要同时提供 yarnrcnpmrc,缺一不可。
  12. +
+

命令行可以作为本地调试工具,最终部署的话还是直接使用打包好的镜像更好用一些。

+

方式2:使用 Docker 镜像

Renovate 提供了构建好的 renovate/renovate 镜像,可以直接使用。

+
docker run --rm -v "/path/to/your/config.js:/usr/src/app/config.js" renovate/renovate
+ +

这个镜像有多个版本,其中大体区分为 slim 版与完整版。它们之间的区别是:

+
    +
  1. 完整版包含了所有可能要用到的构件工具,如 Python 等,约 1.3GB;
  2. +
  3. slim 版仅包含 Renovate 自身,约 130MB。
  4. +
+

可以使用 GitLab CI 与该镜像直接集成,image 指定 renovate/renovate 即可。但是,最终我选择了使用 k8s 集成。

+

方式3:使用 Kubernetes

官方文档 贴心地提供了 k8s 的配置样例,基本上复制粘贴就能完成配置了:

+
apiVersion: v1
kind: Secret
metadata:
name: renovate-env
type: Opaque
stringData:
GITHUB_COM_TOKEN: 'any-personal-user-token-for-github-com-for-fetching-changelogs'
# set to true to run on all repos you have push access to
RENOVATE_AUTODISCOVER: 'false'
RENOVATE_ENDPOINT: 'https://github.company.com/api/v3'
RENOVATE_GIT_AUTHOR: 'Renovate Bot <bot@renovateapp.com>'
RENOVATE_PLATFORM: 'github'
RENOVATE_TOKEN: 'your-github-enterprise-renovate-user-token'
---
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: renovate
spec:
schedule: '@hourly'
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
containers:
- name: renovate
# Update this to the latest available and then enable Renovate on
# the manifest
image: renovate/renovate:23.19.2
args:
- user/repo
# Environment Variables
envFrom:
- secretRef:
name: renovate-env
restartPolicy: Never
+ +

但是,这里有一个地方比较坑的是,像 RENOVATE_AUTODISCOVER 这种环境变量的命名,官方并没有提供一个文档明确说明到底是以何种规则得到的(或者是我没有找到)。不过经过一番搜索,我找到了它的具体实现:

+

lib/config/env.ts:

+
export function getEnvName(option: Partial<RenovateOptions>): string {
if (option.env === false) {
return '';
}
if (option.env) {
return option.env;
}
const nameWithUnderscores = option.name.replace(/([A-Z])/g, '_$1');
return `RENOVATE_${nameWithUnderscores.toUpperCase()}`;
}
+ +

也就是,将普通配置名(驼峰命名):

+
    +
  1. 全部转为大写字母;
  2. +
  3. 单词首字母前加上 _
  4. +
  5. 前面加上 RENOVATE_
  6. +
+

就得到了环境变量的命名。

+

但是,经过实践发现这里有一个特例:logLevel 这个配置并不是转换为 RENOVATE_LOG_LEVEL,而仅仅是 LOG_LEVEL 而已。

+

另外,官方提供的样例对于所有 secrets 都使用了 stringData 来储存,不提倡这种做法。我建议将 token 类密钥信息做 base64 编码储存在 data 中:

+
apiVersion: v1
kind: Secret
metadata:
name: renovate-secret
type: Opaque
data:
GITHUB_COM_TOKEN: ...
RENOVATE_TOKEN: ...
stringData:
RENOVATE_AUTODISCOVER: 'false'
RENOVATE_ENDPOINT: 'https://gitlab.company.com/api/v4/'
RENOVATE_PLATFORM: 'gitlab'
RENOVATE_ONBOARDING: 'true'
RENOVATE_ONBOARDING_CONFIG: '{...}'
RENOVATE_SEMANTIC_COMMITS: 'enabled'
RENOVATE_YARNRC: 'registry "http://npm-registry.company.com"'
RENOVATE_NPMRC: 'registry="http://npm-registry.company.com"'
LOG_LEVEL: 'debug'
+ +

集成 GitLab

在之前的博文 Gitlab CE Code-Review Bot 中,我介绍了 GitLab CE 评审机器人的实现。由于 Renovate 也是基于 Merge Request 实现的,因此它们能够很好地相处:

+
    +
  1. Renovate 发起 MR
  2. +
  3. 评审机器人随机分派评审人
  4. +
  5. 评审通过,合并
  6. +
+

但是有几点需要注意:

+
    +
  1. 由于评审机器人使用了 WIP 来阻止 MR 被手动合并,因此 Renovate 的配置中也需要将 MR 设置为 draft 状态,这样才能维持 MR 的 WIP 标记。否则,Renovate 会在发起 MR 后的第二次扫描中尝试去除 MR 的 WIP 标记;
  2. +
  3. 最好给 Renovate 开设一个独立的账号。如果与其他用户或程序共用账号,Renovate 可能会在 force-push 的过程中使某些由其它用户做出的改动丢失;
  4. +
  5. 因为 Renovate 的设计中存在一些高危操作(分支删除,强制推送等),因此最好只赋予 Developer 权限。实际上如果不启用自动合并,它也只需要 Developer 权限。
  6. +
+

符合我需求的最终配置 renovate.json

+
{
"extends": [
"config:base",
// 除了 peerDependencies 以外所有依赖都 pin,
// 注意仅适用于业务项目,在 library 中不要这样做
":pinAllExceptPeerDependencies"
],
// 仅启用 npm 依赖管理,项目里有其它依赖项不想被 Renovate 管理的,
// 如:Docker / Gradle / Cocoa Pod 等
"enabledManagers": [
"npm"
],
// 仅对 @company/ 开头的私有包启用依赖管理,其它外部依赖一律禁用
"packageRules": [
{
"packagePatterns": [
"*"
],
"excludePackagePatterns": [
"^@company/"
],
"enabled": false
}
],
// 将 MR 标记为 draft,即 WIP
"draftPR": true
}
+ +

遇到的问题

    +
  1. 将 Renovate 部署上 Kubernetes 的时候,要注意能够分配的节点是否都有私有源的访问权限。如果 CronJob 被分配到了无权访问的节点会导致私有包 Lookup Failed,从而更新失败。如果只有部分节点拥有访问权限,可以用 nodeSelectornodeName 指定节点;
  2. +
  3. Changelog 在 GitLab (10.3.2) 上面会丢失,并且格式错乱,如图所示:screenshot
    这个问题猜测是由于我司的 GitLab 版本过低导致的。因为 gitlab.com (13.x) 上不存在这个问题。但是因为 GitLab 不在我的管辖范围内,因此目前没有找到很好的解决方案,后续如果解决了会更新。
  4. +
+

解决 Changelog 问题

我在 GitHub 上提了一个 issue,但是作者表示这是老版本 GitLab 出现的问题,建议升级 GitLab,不会为其做出改动及修复。不过他建议可以修改源码内的某些文件并自己构建一个 Docker 镜像来达到目的:

+
+

You could perhaps try building your own image with a modified version of that file, or even just sed replace parts of it at runtime. You can find it at dist/workers/pr/changelog/hbs-template.js in the built/distributed version.

+
+

但是我不是很喜欢这种做法,这样的话会更新镜像会比较麻烦。不过如他所说,也可以选择在运行时进行替换。由于之前开发过一款 评审机器人,机器人的执行逻辑刚好适合用来做这一块的热修复。只需要在 MR 创建逻辑内加多一个判断,如果是来自 renovate 的 MR 则执行修复操作;

+
    +
  1. 解决格式错乱问题:读取 MR 的 description 字段,并将 <details> 节点去除;
  2. +
  3. 解决 changelog 丢失问题:调用 GitLab API 获取 Changelog,并粘贴到 description 中;
  4. +
  5. Renovate 更新 MR 时会丢失 description 中的更改,为了保险起见,再将 Changelog 作为输出到评论中去。
  6. +
+

大致代码:

+
if (isRenovateMR && enableRenovateFix) {
try {
// renovate 在旧版 gitlab 上有问题,此处为修复逻辑
const { description } = object_attributes
// 根据 mr 内容获取项目名与 tag 名
// getStringBetween 函数:截取头尾字符串中间的内容
const projectName = getStringBetween(description, '<summary>', '</summary>')
const tagName = getStringBetween(description, '[Compare Source]', ')').split('...')[1]
// 获取 release note
const tag = await service.getTagInfo(projectName, tagName)
const releaseNote = _.get(tag, 'release.description', '')
// 移除 details 标签,并添加 release note
const _desc = description.replace('<details>', '').replace('</details>', releaseNote)
// 更新 mr 内容
await service.updateMergeRequest(pid, mid, {
description: _desc
})
// 添加评论
if (!!releaseNote) {
await service.addMergeRequestComment(pid, mid, `
更新日志:

${releaseNote}
`)
}
} catch (e) {
console.error(e)
}
}
+ +

效果如图所示:

+

screenshot

+]]>
+ + gitlab + devops + +
+ + JavaScript 事件代理 + /2016/javascript-event-delegation/ + jQuery 曾经存在 3 种绑定事件的方法:bind / live / delegate,后来 live 被砍掉了,只留下 bind 与 delegate,它们之间的区别是,通过 bind 方法绑定的事件,只对当前存在的元素生效,而通过 delegate 则可以绑定“现在”以及“将来”的所有元素。

+

为“将来”元素绑定事件的适用场景还是挺多的。比如一个列表,或者一个表格,它可能会动态地被插入或者移除一些子元素,然后每个元素都需要有一个点击事件,这样的话我们就需要保证“现在”已存在的元素以及“将来”可能被添加进去的元素都能够正常工作。怎么办呢,我们总不能每插入一个元素就给它绑一次事件吧(事实上我以前没少干这事),因此 jQuery 就为我们提供了后者的方法。

+

一开始我觉得很奇怪,像 delegate 这样的方法是怎么实现的呢?通过监听 DOM 树变化吗?性能开销会不会特别大?后来知道了 JavaScript 有一种机制叫事件代理(event delegation),也就是本文要说的东西,才明白,原来一切都很简单。

+ + +

事件代理及其工作原理

何为代理呢,大概就是,你把你要做的事情告诉我,我帮你做,我就是你的代理。

+

那么事件代理,顾名思义,在一个元素上触发的事件,由另一个元素去处理,后者就是前者的事件代理。

+

大概就是这么回事。那么,如何实现呢?

+

这里就涉及两个关于 JavaScript 事件的知识:事件冒泡(event bubbling)以及目标元素(target element):

+
    +
  • 当一个元素上触发事件的时候,比如说鼠标点击了一个按钮,同样的事件将会在它的所有祖先元素上触发。这就是所谓的事件冒泡。
  • +
  • 所有事件的目标元素都将是最原始触发它的那个特定元素,就比如说那个按钮,其引用将被储存在事件对象内。
  • +
+

因此,我们可以做到:

+
    +
  • 给一个节点绑定事件
  • +
  • 等待其子孙节点的冒泡事件
  • +
  • 判断事件实际来源
  • +
  • 做出相应处理
  • +
+

这就是事件代理的工作原理。

+

有什么用

一个典型的场景是,如果一个表格有 100 行 100 列,你需要给每一个单元格都添加点击事件,怎么办?

+

当然可以说一次性把它们全选出来,绑定事件不就完了。但是,内存 BOOM,浏览器 BOOM

+

用事件代理就简单多了,给 table 绑一次事件,然后等它们冒泡上来就行了。

+

还有就是动态添加的元素。比如某一时刻 table 被添加了一行,那么新的一行其事件同样能冒泡并且被 table 上的事件处理器接收到。

+

代码

Talk is cheap, show me the code.

+
//Some browser diff issue handler
function getEventTarget(e) {
e = e || window.event;
return e.target || e.srcElement;
}

//Easy event handler on 'table' element
function editCell(e) {
var target = getEventTarget(e);
if(target.tagName.toLowerCase() === 'td') {
// DO SOMETHING WITH THE CELL
}
}
+ +

 

+]]>
+ + javascript + +
+ + JavaScript Promise + /2016/javascript-promise/ + 知乎上有一个黑 JavaScript 的段子,大概是说:

+
+

N 年后,外星人截获了 NASA 发射的飞行器并破解其源代码,翻到最后发现好几页的 }}}}}}……

+
+

这是因为 NASA 近年发射过使用 JavaScript 编程的飞行器,而 Node.js 环境下的 JavaScript 有个臭名昭著的特色:Callback hell(回调地狱的意思)

+

JavaScript Promise 是一种用来取代超长回调嵌套编程风格(特指 Node.js)的解决方案。

+

比如:

+
getAsync("/api/something", (error, result) => {
if(error){
//error
}
//success
});
+ +

将可以写作:

+
let promise = getAsyncPromise("/api/something"); 
promise.then((result) => {
//success
}).catch((error) => {
//error
});
+ +

乍一看好像并没有什么区别,依然是回调。但最近在做的一个东西让我明白,Promise 的目的不是为了干掉回调函数,而是为了干掉嵌套回调函数。

+ + +

定义

MDN 定义:

+
+

The Promise object is used for asynchronous computations. A Promise represents a value which may be available now, or in the future, or never.

+
+

意思大概就是,Promise 是专门用于异步处理的对象。一个 Promise 代表着一个值,这个值可能已经获得了,又可能在将来的某个时刻会获得,又或者永远都无法获得。

+

简单地说,Promise 对象就是值的代理。经纪人。

+

简单用法

创建一个 Promise:

+
let promise = new Promise((resolve, reject) => {
//success -> resolve(data)
//error -> reject(data)
});
+ +

使用 new Promise 来创建 Promise 对象,构造器中传入一个函数,同时对该函数传入 resolvereject 参数,分别代表异步处理成功与失败时将要调用的方法。

+

处理 Promise 结果:

+
promise.then(onFulfilled, onRejected)
+ +

使用 then 方法来注册结果函数,共可以注册两个函数,其中 onFulfilled 代表成功,后者代表失败。两个参数都是可选参数。

+

不过,对于失败处理,更加推荐的方式是使用 catch 方法:

+
promise.catch(onRejected)
+ +

这两个方法可以进行链式操作。组合示例:

+
function asyncFunction() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Async Hello world');
}, 1000);
});
}

asyncFunction()
.then((value) => {
console.log(value); //Async Hello world
})
.catch((error) => {
console.log(error);
});
+ +

这里使用了定时器来模拟异步过程,实际上其它异步过程(如 XHR)也大概都是这么个写法。

+

状态

Promise 对象共有三种状态:

+
    +
  • Fulfilled (成功)
  • +
  • Rejected (失败)
  • +
  • Pending (处理中)
  • +
+

有两条转换路径:

+
    +
  • Pending -> Fulfilled -> then call
  • +
  • Pending -> Rejected -> catch call
  • +
+

Promise 对象的状态,从 Pending 转换为 Fulfilled 或 Rejected 之后, then 方法或者 catch 方法就会被立即调用,并且这个 promise 对象的状态不会再发生任何变化。也就是说,调用且只调用一次。

+

链式操作

链式操作是 Promise 对象的一大亮点。

+

本节引用一些 Promise Book 的内容。

+

例如:

+
function taskA() {
console.log("Task A");
}
function taskB() {
console.log("Task B");
}
function onRejected(error) {
console.log("Catch Error: A or B", error);
}
function finalTask() {
console.log("Final Task");
}

var promise = Promise.resolve();
promise
.then(taskA)
.then(taskB)
.catch(onRejected)
.then(finalTask);

//Task A
//Task B
//Final Task
+ +

该代码块实际流程如图所示:

+

+

 

+

可以看到,这个 onRejected 并不仅仅是 TaskB 的失败处理函数,同时它也是 TaskA 的失败处理函数。而且当 TaskA 失败(reject 被调用或者抛出异常)时,TaskB 将不会被调用,直接进入失败处理。熟悉 express 的玩家应该能看出来了,这简直就和中间件一模一样嘛。

+

比如说,TaskA 出现异常:

+
function taskA() {
console.log("Task A");
throw new Error("throw Error @ Task A")
}
function taskB() {
console.log("Task B");// 不会被调用
}
function onRejected(error) {
console.log(error);// => "throw Error @ Task A"
}
function finalTask() {
console.log("Final Task");
}

var promise = Promise.resolve();
promise
.then(taskA)
.then(taskB)
.catch(onRejected)
.then(finalTask);
+ +

这里的输出应该就是:

+
//Task A
//Error: throw Error @ Task A
//Final Task
+ +

需要注意的是,如果在 onRejectedfinalTask 中出现异常,那么这个异常将不会再被捕捉到。因为并没有再继续注册 catch 函数。

+

借助 Promise 链式操作的特点,复杂的 JavaScript 回调简化将不再是梦。

+

递归

Promise 可以实现递归调用,在用来一次性抓取所有分页内容的时候有用。例:

+
function get(url, p) {
return $.get(url + "?page=" + p)
.then(function(data) {
if(!data.list.length) {
return [];
}

return get(url, p+1)
.then(function(nextList) {
return [].concat(data.list, nextList);
});
});
}

get("urlurl", 1).then(function(list) {
console.log(list);//your full list is here
});
+ +

实用方法

Promise.all

Promise.all 接受一个 promise 对象的数组作为参数,当这个数组里的所有promise对象全部变为 resolve 或 reject 状态的时候,它才会去调用 then 方法。

+

例:

+
function taskA() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('TaskA resolved!');
resolve();
}, 1000);
});
}

function taskB() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('TaskB resolved!');
resolve();
}, 2000);
});
}

function main() {
return Promise.all([taskA(), taskB()]);
}

main()
.then((value) => {
console.log('All resolved!');
})
.catch((error) => {
console.log(error);
});

//TaskA resolved!
//TaskB resolved!
//All resolved!
+ +

Promise.race

Promise.all 类似,略有区别,从名字就能看出来,只要有一个 Task 执行完毕,整个 Promise 就会返回。但是需要注意的是,返回以后并不会取消其它未完成的 Promise 的执行。

+
function taskA() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('TaskA resolved!');
resolve();
}, 1000);
});
}

function taskB() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('TaskB resolved!');
resolve();
}, 2000);
});
}

function main() {
return Promise.race([taskA(), taskB()]);
}

main()
.then((value) => {
console.log('All resolved!');
})
.catch((error) => {
console.log(error);
});

//TaskA resolved!
//All resolved!
//TaskB resolved!
+ +

支持性

由于是 ES6 语法,目前在浏览器端支持不是特别好,很多移动端浏览器以及 IE 家族均不支持(具体可查看 MDN)。如果要在浏览器端使用需要借助 Babel 编译器。

+

至于 Node.js 环境则毫无问题。

+]]>
+ + javascript + +
+ + JSX in Vue.js + /2017/jsx-in-vuejs/ + 在基于 Webpack 的 Vue 项目中添加 JSX 支持:

+
$ yarn add babel-plugin-syntax-jsx babel-plugin-transform-vue-jsx babel-helper-vue-jsx-merge-props --dev
+ +

各依赖的作用:

+
    +
  • babel-plugin-syntax-jsx 提供基础的 JSX 语法转换
  • +
  • babel-plugin-transform-vue-jsx 提供基于 Vue 的 JSX 特殊语法
  • +
  • babel-helper-vue-jsx-merge-props 是可选的,提供对类似 <comp {...props}/> 写法的支持
  • +
+

然后在 .babelrc 中,增加:

+
{
...
"plugins": [
"transform-vue-jsx",
...
]
...
}
+ +

注意如果有其它 env 也要如此加上 transform-vue-jsx 插件。

+ + +

Difference from React JSX

render (h) {
return (
<div
id="foo"
domPropsInnerHTML="bar"
onClick={this.clickHandler}
nativeOnClick={this.nativeClickHandler}
class={{ foo: true, bar: false }}
style={{ color: 'red', fontSize: '14px' }}
key="key"
ref="ref"
refInFor
slot="slot">
</div>
)
}
+ +

需要注意的是,事件绑定中,还有另外一个跟 react 不一样的地方:onMouseEnter 是不起作用的,只能写 onMouseenter 或者 on-mouseenter,以此类推。

+

Vue directives

除了 v-show 以外,所有的内置指令都不能在 JSX 中工作。

+

自定义指令可以使用 v-name={value} 的写法,但是这样会缺少修饰符以及参数。如果需要完整的指令功能,可以这么做:

+
const directives = [
{ name: 'my-dir', value: 123, modifiers: { abc: true } }
]

return <div {...{ directives }}/>
+]]>
+ + vue + webpack + jsx + +
+ + 代码的艺术:koa 源码精读 + /2019/koa-js-art-of-code/ + Node.js 界大名鼎鼎的 koa,不需要多废话了,用了无数次,今天来拜读一下它的源码。

+

Koa 并不是 Node.js 的拓展,它只是在 Node.js 的基础上实现了以下内容:

+
    +
  • 中间件式的 HTTP 服务框架 (与 Express 一致)
  • +
  • 洋葱模型 (与 Express 不同)
  • +
+

一统天下级别的框架,只包含了约 500 行源代码。极致强大,极致简单。大概这就是码农与码神的区别,真正的代码的艺术吧。

+ + +

源码结构如下:

+
lib
├── application.js
├── context.js
├── request.js
└── response.js
+ +

一共就这四个文件(当然,还包含了发布在 npm 上面的其它 package,后面会说到),一目了然。

+
    +
  1. application.js 是应用的入口,也就是 require('koa') 所得到的东西。它是一个继承自 events 的 Class
  2. +
  3. context.js 就是对应每一个 req / res 的 ctx
  4. +
  5. request.js / response.js 就不用说了
  6. +
+

下面从最基础的看起。

+

request.js

request.js 大概的样子如下:

+
// request.js
module.exports = {
get header() {
return this.req.headers;
},
set header(val) {
this.req.headers = val;
},
get headers() {
return this.req.headers;
},
set headers(val) {
this.req.headers = val;
},
// 其它 getter & setter......
}
+ +

这里的 this.req 实际上是 http.IncomingMessage,创建的时候传入的,后面会提到。

+

这个文件绝大多数是 helper 方法,把本来已经存在在 http.IncomingMessage 中的属性通过更方便的方式(getter / setter)来存取,以达到通过同一个属性来读写的目的。比如想要获取一个 request 的 header 时,通过 ctx.request.heder,而想写入 header 时,可以通过 ctx.request.heder = xxx 来实现。这也是 koa 的友好特性之一。

+

其中有一个特殊的是 ip

+
// request.js
const IP = Symbol('context#ip');

module.exports = {
// ...
get ip() {
if (!this[IP]) {
this[IP] = this.ips[0] || this.socket.remoteAddress || '';
}
return this[IP];
},
set ip(_ip) {
this[IP] = _ip;
},
// ...
}
+ +

Symbol('context#ip')request 对象唯一一个来自自身的 key,我猜测它的目的是:

+
    +
  1. 允许开发者对真实请求 ip 进行改写
  2. +
  3. 同时利用 Symbol 不等于任何值的特性,使它成为私有属性,对外不可见,只可通过 getter 获取
  4. +
+

response.js

response.jsrequest.js 类似,不同之处在于,response.js 重点更多在 setter 上面,很好理解,因为 response 的重点是一个服务器向用户返回内容的过程。

+

koa 的一大特性是在于,只需要向 ctx.response.body 赋值就能完成一次请求响应。代码:

+
module.exports = {
// ...
get body() {
return this._body;
},
set body(val) {
const original = this._body;
this._body = val;

// no content
if (null == val) {
if (!statuses.empty[this.status]) this.status = 204;
this.remove('Content-Type');
this.remove('Content-Length');
this.remove('Transfer-Encoding');
return;
}

// set the status
if (!this._explicitStatus) this.status = 200;

// set the content-type only if not yet set
const setType = !this.header['content-type'];

// string
if ('string' == typeof val) {
if (setType) this.type = /^\s*</.test(val) ? 'html' : 'text';
this.length = Buffer.byteLength(val);
return;
}

// buffer
if (Buffer.isBuffer(val)) {
if (setType) this.type = 'bin';
this.length = val.length;
return;
}

// stream
if ('function' == typeof val.pipe) {
onFinish(this.res, destroy.bind(null, val));
ensureErrorHandler(val, err => this.ctx.onerror(err));

// overwriting
if (null != original && original != val) this.remove('Content-Length');

if (setType) this.type = 'bin';
return;
}

// json
this.remove('Content-Length');
this.type = 'json';
},
// ...
}
+ +

可以看到,在 body 的 setter 里面,分别对传入的值为 null / string / buffer / stream / json 的情况进行了处理,并完成了向客户端返回的其它逻辑(设置各种响应头以及状态码),以达到上述目的。

+

为了达到「至简」目的,koa 对外暴露的 API 基本都是通过 getter / setter 的方式实现的,值得借鉴。

+

context.js

Context 「上下文」(通常简写为 ctx)是 koa 的核心之一,它代表了一次用户请求,每个请求都对应着一个独立的 context,实际上它就是 requestresponse 的结合体,通过「委托模式」实现。它的作用是,开发者对于每一个请求,只需要拿到它的 ctx,就能获取到所有请求的相关信息,亦能做出任何形式的响应。

+

它的核心代码如下:

+
'use strict';
const delegate = require('delegates');
const Cookies = require('cookies');

const COOKIES = Symbol('context#cookies');

const proto = module.exports = {
// ...
get cookies() {
if (!this[COOKIES]) {
this[COOKIES] = new Cookies(this.req, this.res, {
keys: this.app.keys,
secure: this.request.secure
});
}
return this[COOKIES];
},
set cookies(_cookies) {
this[COOKIES] = _cookies;
}
};

delegate(proto, 'response')
.method('attachment')
.method('redirect')
.method('remove')
.method('vary')
.method('set')
.method('append')
.method('flushHeaders')
.access('status')
.access('message')
.access('body')
.access('length')
.access('type')
.access('lastModified')
.access('etag')
.getter('headerSent')
.getter('writable');

delegate(proto, 'request')
.method('acceptsLanguages')
.method('acceptsEncodings')
.method('acceptsCharsets')
.method('accepts')
.method('get')
.method('is')
.access('querystring')
.access('idempotent')
.access('socket')
.access('search')
.access('method')
.access('query')
.access('path')
.access('url')
.access('accept')
.getter('origin')
.getter('href')
.getter('subdomains')
.getter('protocol')
.getter('host')
.getter('hostname')
.getter('URL')
.getter('header')
.getter('headers')
.getter('secure')
.getter('stale')
.getter('fresh')
.getter('ips')
.getter('ip');
+ +

可以看到里面的主要内容有:

+
    +
  1. 实现 Cookies 的 getter / setter(因为 koa 把 req 和 res 的 cookies 结合在一起了,所以它须要在 ctx 内实现)
  2. +
  3. 将 request / response 的逻辑代理到 ctx 上面
  4. +
+

关于这个「委托模式」的具体实现,TJ 把它放到了一个独立的 NPM Package delegates 中。它的功能是:将一个类的子类中的方法与属性,暴露到父类中去,而暴露在父类上的方法可以看做真实方法的「代理」。koa 使用了其中的三种模式,分别是:

+
    +
  1. method 代理方法
  2. +
  3. access 代理 getter 与 setter
  4. +
  5. getter 仅代理 getter
  6. +
+

其主要源码:

+
module.exports = Delegator;

function Delegator(proto, target) {
if (!(this instanceof Delegator)) return new Delegator(proto, target);
this.proto = proto;
this.target = target;
this.methods = [];
this.getters = [];
this.setters = [];
this.fluents = [];
}

Delegator.prototype.method = function(name){
var proto = this.proto;
var target = this.target;
this.methods.push(name);

proto[name] = function(){
return this[target][name].apply(this[target], arguments);
};

return this;
};

Delegator.prototype.access = function(name){
return this.getter(name).setter(name);
};

Delegator.prototype.getter = function(name){
var proto = this.proto;
var target = this.target;
this.getters.push(name);

proto.__defineGetter__(name, function(){
return this[target][name];
});

return this;
};

Delegator.prototype.setter = function(name){
var proto = this.proto;
var target = this.target;
this.setters.push(name);

proto.__defineSetter__(name, function(val){
return this[target][name] = val;
});

return this;
};
+ +

依然非常简洁。method 代理使用 Function.apply 实现,getter / setter 代理使用 object.__defineGetter__object.__defineSetter__ 实现。

+

application.js

去除兼容、校验、实用方法等逻辑,精简过后,该文件的主要内容如下:

+
'use strict';
const onFinished = require('on-finished');
const response = require('./response');
const compose = require('koa-compose');
const context = require('./context');
const request = require('./request');
const Emitter = require('events');
const util = require('util');

/**
* Expose `Application` class.
* Inherits from `Emitter.prototype`.
*/

module.exports = class Application extends Emitter {

constructor() {
super();

this.middleware = [];
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}

listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}

use(fn) {
this.middleware.push(fn);
return this;
}

callback() {
const fn = compose(this.middleware);

const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};

return handleRequest;
}

handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

createContext(req, res) {
const context = Object.create(this.context);
const request = context.request = Object.create(this.request);
const response = context.response = Object.create(this.response);
context.app = request.app = response.app = this;
context.req = request.req = response.req = req;
context.res = request.res = response.res = res;
request.ctx = response.ctx = context;
request.response = response;
response.request = request;
context.originalUrl = request.originalUrl = req.url;
context.state = {};
return context;
}

onerror(err) {
if (!(err instanceof Error)) throw new TypeError(util.format('non-error thrown: %j', err));

if (404 == err.status || err.expose) return;
if (this.silent) return;

const msg = err.stack || err.toString();
console.error();
console.error(msg.replace(/^/gm, ' '));
console.error();
}
};
+ +

不到一百行,Koa 主要功能已经全在里面了。

+

现在可以梳理一下当我们创建一个 koa 服务器的时候,实际上都干了些什么吧:

+
    +
  1. 调用 constructor,初始化 ctx / req / res,以及最重要的 middleware 数组(不过不理解的是,为什么命名没有加 s 呢?)
  2. +
  3. 对于各种业务场景,调用 app.use,这一步只是一个简单的向 middleware 数组 push 的过程
  4. +
  5. 调用 app.listen,启动 HTTP 服务器
  6. +
  7. 对于每一个进来的请求,调用 callback 方法,这个方法做了三件事:
      +
    1. 通过 koa-compose 将中间件数组组合为一个「洋葱」模型
    2. +
    3. 调用 createContext 方法,为请求创建 ctx 上下文,同时挂载 req / res
    4. +
    5. 调用 handleRequest 方法,按洋葱模型的顺序执行中间件,并最终返回或报错
    6. +
    +
  8. +
+

这里面最重要的一步就是「洋葱」模型的构建。实际上这个过程也非常简单,以下是 koa-compose 的源码(为了精简,已去除校验等逻辑):

+
function compose (middleware) {
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
+ +

它是一个递归:

+
    +
  1. 首先约定,每一个 middleware 都是一个 async 函数(即 Promise),接受两个参数 ctxnext
  2. +
  3. 当 middleware 内部调用 next 函数时,实际上是递归调用了 dispatch.bind(null, i + 1) 函数,也就是,将 index + 1 的中间件取出来并执行了。因为中间件都是 Promise,所以能够被 await
  4. +
  5. 递归执行步骤 2,直到调用到最后一个 middleware 时,最后被调用的 middleware 会最先结束,然后到上一个,再到上上一个,如此往复就形成了「洋葱」模型
  6. +
  7. 最终所有 middleware 都执行完毕,compose 函数返回 Promise.resolve(),即退出递归
  8. +
+

「洋葱」模型构建完毕后,compose 函数返回一个 Promise,所有 middleware 都已经被有序串联,只需要直接执行该 promise 实例即可。

+

让人不禁感叹:大道至简

+

end

至此,koa 的最主要的功能实现都已过了一遍了。

+

总结一下它做了的事情:

+
    +
  1. 通过 getter / setter 方法简化 Node.js HTTP 的使用方式
  2. +
  3. 通过 ctx 简化开发者访问 req / res 的方式
  4. +
  5. 通过「洋葱」模型简化 HTTP 请求的处理流程
  6. +
+

大概就这样。

+]]>
+ + javascript + koa + +
+ + Koa Note + /2017/koa-note/ + +

Koa是一个类似于 Express 的 Web 开发框架,创始人也是同一个人。它的主要特点是,使用了 ES6 的 Generator 函数,进行了架构的重新设计。也就是说,Koa的原理和内部结构很像 Express,但是语法和内部结构进行了升级。

+

—— 阮一峰博客

+ +

想要达到使用 Koa2 的完整体验,需要将 Node 版本升级到 v7.6+ 以支持 async 语法。

+

为什么是 Koa 而不是 Express 4.0?

+

因为 Generator 带来的改动太大了,相当于推倒重来。

+

以下内容基于 Koa2

+ + +

应用

一个 Koa Application(以下简称 app)由一系列 generator 中间件组成。按照编码顺序在栈内依次执行,从这个角度来看,Koa app 和其他中间件系统(比如 Ruby Rack 或者 Connect / Express )没有什么太大差别。

+

简单的 Hello World 应用程序:

+
const Koa = require('koa');
const app = new Koa();

// response
app.use(ctx => {
ctx.body = 'Hello Koa';
});

app.listen(3000);
+ +

级联代码

Koa 中间件以一种非常传统的方式级联起来。

+

在以往的 Node 开发中,频繁使用回调不太便于展示复杂的代码逻辑,在 Koa 中,我们可以写出真正具有表现力的中间件。与 Connect 实现中间件的方法相对比,Koa 的做法不是简单的将控制权依次移交给一个又一个的中间件直到程序结束,而有点像“穿越一只洋葱”。

+

图示 Koa 中间件级联

+

下边这个例子展现了使用这一特殊方法书写的 Hello World 范例。

+
const Koa = require('koa');
const app = new Koa();

// x-response-time
app.use(async function (ctx, next) {
// (1) 进入路由
const start = new Date();
await next();
// (5) 再次进入 x-response-time 中间件,记录2次通过此中间件「穿越」的时间
const ms = new Date() - start;
// (6) 返回 this.body
ctx.set('X-Response-Time', `${ms}ms`);
});

// logger
app.use(async function (ctx, next) {
// (2) 进入 logger 中间件
const start = new Date();
await next();
// (4) 再次进入 logger 中间件,记录2次通过此中间件「穿越」的时间
const ms = new Date() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}`);
});

// response
app.use(ctx => {
// (3) 进入 response 中间件
ctx.body = 'Hello World';
});

app.listen(3000);
+ +

也许刚从 Express 过来的同学会一脸懵逼,实际上我们可以把它想象成这样的一个流程(类似 LESS 代码):

+
.x-response-time {
// (1) do some stuff
.logger {
// (2) do some other stuff
.response {
// (3) NO next yield !
// this.body = 'hello world'
}
// (4) do some other stuff later
}
// (5) do some stuff lastest and return
}
+ +

这便是 Koa 中间件的一大特色了。另一点也能在例子中找到:即 Koa 支持 async 以及 await 语法,可以在中间件中进行任意方式的使用(比如 await mongoose 操作),这样对比起来 Express 其优点就十分明显了。

+

常用的中间件

+

(等等)

+

错误处理

除非 app.silent 被设置为 true,否则所有 error 都会被输出到 stderr,并且默认的 error handler 不会输出 err.status === 404 || err.expose === true 的错误。可以自定义「错误事件」来监听 Koa app 中发生的错误,比如一个简单的例子:记录错误日志

+
app.on('error', err =>
log.error('server error', err)
);
+ +

应用上下文

Koa 的上下文(Context)将 request 与 response 对象封装至一个对象中,并提供了一些帮助开发者编写业务逻辑的方法。

+

每个 request 会创建一个 Context,并且向中间件中传引用值。

+
app.use(async (ctx, next) => {
ctx; // is the Context
ctx.request; // is a koa Request
ctx.response; // is a koa Response
});
+ +

需要注意的是,挂载在 Context 对象上的并不是 Node.js 原生的 Response 和 Request 对象,而是经过 Koa 封装过的。Koa 提供另外的方法来访问原生对象,但是并不建议这么做!

+

为了使用方便,许多上下文属性和方法都被委托代理到他们的 ctx.requestctx.response,比如访问 ctx.typectx.length 将被代理到 response 对象,ctx.pathctx.method 将被代理到 request 对象。

+]]>
+ + nodejs + koa + +
+ + Limit Prerender Plugin Workers By Webpack + /2017/limit-prerender-plugin-workers-by-webpack/ + Prerender SPA Plugin 是一个可以将 Vue 页面预渲染为静态 HTML 的 webpack 插件,对静态小站(比如博客)来说很棒棒。但是最近用的时候总发现一个问题:它的 build 失败率越来越高,尤其是在 CI 上。后来在其 repo 的一个 issue 中发现了问题所在,就是它没有限制 PhantomJS workers 的数量,导致页面一多就直接全部卡死不动,然后超时。

+
+

(Workers) Default is as many workers as routes.

+
+

虽然有人已经发了 PR 来修复这个问题,然而好几个月过去了也没有 merge,不知道是什么情况。于是我在自己的尝试中找到了一种可以接受的解决方案:虽然我不能限制你插件 workers 的数量,但是可以限制每个插件渲染的 route 数量呀。

+ + +

具体思路就是:

+
    +
  1. 将所有的 route chunk 成小组,比如 10 个一组
  2. +
  3. 针对每一个 chunk 创建一个 prerender 插件
  4. +
  5. 将所有插件都加入到 webpack plugin 中去
  6. +
+

这样一来,就可以保证每个 plugin 最多同时创建 10 个 worker,全部渲染完成后再由下一个 plugin 接着工作。

+

简单的代码示例:

+
// Generate url list for pre-render
exports.generateRenderPlugins = () => {
let paths = [] // the routes
let chunks = _.chunk(paths, 10) // using lodash.chunk
let plugins = []
let distPath = path.join(__dirname, '../dist')
let progress = 0
chunks.forEach(chunk => {
plugins.push(new PrerenderSpaPlugin(distPath, chunk, {
postProcessHtml (context) {
// need to log something after each route finish
// or CI will fail if no log for 10 mins
console.log(`[PRE-RENDER] (${++progress} / ${paths.length}) ${context.route}`)
return context.html
}
}
))
})
return plugins
}
+]]>
+ + vue + ssr + devops + webpack + +
+ + golang 学习笔记 + /2021/learn-golang/ + 我的 golang 学习笔记。好几年前就说要学了,现在终于兑现。

+ + + +

开发环境

安装

https://studygolang.com/dl

+
go version
go env
+ +

国内镜像

https://goproxy.cn/

+
go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.cn,direct
+ +

goimports

go get -v golang.org/x/tools/cmd/goimports
+ +

IDE

    +
  • IDEA 安装 go 和 file watcher 插件
  • +
  • 新建项目使用 goimports 模板
  • +
  • filewather 增加 goimports,配置默认
  • +
  • 快速生成变量快捷键:ctrl+alt+v
  • +
+

基础语法

变量定义

    +
  • 变量类型写在后面,名字写在前面,形似 typescript
  • +
  • 类型可以推断
  • +
  • 没有 char,只有 rune
  • +
  • 原生支持复数类型
  • +
+

var

var a ,b, c bool
var s1, s2 string = "hello", "world"
+ +
    +
  • 可放在包内或函数内
  • +
  • 可以用 var() 集中定义变量
  • +
  • 类型可以自动推断
  • +
+

:=

只能在函数内使用

+

内建变量类型

    +
  • bool, string
  • +
  • (u)int, (u)int8, … (u)int64, uintptr (指针)
  • +
  • byte, rune (char)
  • +
  • float32, float64, complex64, complex128 (复数)
  • +
+

类型转换是强制的,没有隐式转换。

+
var c int = int(math.Sqrt(float64(a*a + b*b)))
+ +

常量

const

const filename = "abc.txt"
+ +

常量数值可以作为各种类型使用:

+
const (
a, b = 3, 4
)
var c int = int(math.Sqrt(a*a + b*b))
+ +

枚举

    +
  • 用 const 定义枚举。
  • +
  • 可以是固定值,也可以用 iota 自增。iota 可以参与运算。
  • +
+
const (
b = 1 << (10 * iota)
kb
mb
gb
tb
pb
)
+ +

条件

if

    +
  • if 不需要括号
  • +
  • 条件内可以定义变量,变量的作用域局限于 if
  • +
+
const filename = "abc.txt"
if contents, err := ioutil.ReadFile(filename); err != nil {
fmt.Println(err)
} else {
fmt.Printf("%s\n", contents)
}
+ +

switch

    +
  • switch **默认 break**,除非加 fallthrough
  • +
  • switch 可以没有表达式,条件写在 case
  • +
+
func grade(score int) string {
g := ""
switch {
case score < 0 || score > 100:
panic(fmt.Sprintf("Wrong score: %d", score))
case score < 60:
g = "F"
case score < 80:
g = "C"
case score < 90:
g = "B"
case score <= 100:
g = "A"
}
return g
}
+ +

循环

for

    +
  • for 不需要括号
  • +
  • for 可以省略初始条件(相当于 while)、结束条件、递增表达式
  • +
+
for n := 100 ; n > 0; n /= 2 {
// todo
}
+ +
for scanner.Scan() {
fmt.Println(scanner.Text())
}
+ +

函数

    +
  • 返回值类型写在最后面(类似 typescript)
  • +
  • 函数可以返回多个值(一般用法为第二个参数返回 error)
  • +
  • 函数返回多个值时可以起名
  • +
  • 函数可以作为参数
  • +
  • 有可变参数列表
  • +
  • 有匿名函数
  • +
  • 没有默认参数、可选参数、函数重载等
  • +
+
func eval(a, b int, op string) (int, error) {
switch op {
case "+":
return a + b, nil
case "-":
return a - b, nil
case "*":
return a * b, nil
case "/":
q, _ := div(a, b)
return q, nil
default:
return 0, fmt.Errorf("unsupported operation: %s", op)
}
}

func div(a, b int) (q, r int) {
return a / b, a % b
}

func sum(numbers ...int) int {
s := 0
for i := range numbers {
s += numbers[i]
}
return s
}

func apply(op func(int, int) int, a, b int) int {
p := reflect.ValueOf(op).Pointer()
opName := runtime.FuncForPC(p).Name()
fmt.Printf("Calling func %s with args (%d, %d)\n", opName, a, b)
return op(a, b)
}

fmt.Println(apply(func(a int, b int) int {
return int(math.Pow(float64(a), float64(b)))
}, 3, 4))
+ +

指针

    +
  • go 指针不能运算
  • +
  • 相比于其它语言的基础类型值传递、复杂类型引用传递,go 语言只能进行值传递,引用传递要显式声明
  • +
+
func swap(a, b *int) {
*b, *a = *a, *b
}

swap(&a, &b)
+ +

+

+

内建容器

Array 数组

    +
  • [10]int[20]int不同的类型
  • +
  • 数组传入函数中的是,不是引用,值会进行拷贝
  • +
  • go 语言中一般不直接使用数组,而是使用 slice 切片
  • +
+

定义

数量写在类型前

+
var arr1 [5]int
arr2 := [3]int{1, 2, 3}
arr3 := [...]int{2, 4, 6, 8, 10}

var grid [4][5]int
+ +

遍历

for i := 0; i < len(arr3); i++ {
fmt.Println(arr3[i])
}

for i, v := range arr3 {
fmt.Println(i, v)
}
+ +

Slice 切片

    +
  • slice 不是值类型,它是 array 的一个视图 (view),对 slice 的改动会反映到 array
  • +
  • slice 可以向后扩展,但不能向前扩展
  • +
  • s[i] 不可以超越 len(s),向后拓展可以超越 len(s) 但不能超越 cap(s)
  • +
+
arr := [...]int{0, 1, 2, 3, 4, 5, 6, 7}

fmt.Println("arr[2:6] =", arr[2:6])
fmt.Println("arr[:6] =", arr[:6])
fmt.Println("arr[2:] =", arr[2:])
fmt.Println("arr[:] =", arr[:])

func updateSlide(s []int) {
s[0] = 100
}
updateSlide(s1)
+ +
fmt.Println("Extending slide")
arr[0], arr[2] = 0, 2
fmt.Println("arr =", arr)
s1 = arr[2:6]
s2 = s1[3:5]
//fmt.Println(s1[4])
fmt.Printf("s1=%v, len(s1)=%d, cap(s1)=%d\n", s1, len(s1), cap(s1))
fmt.Printf("s2=%v, len(s2)=%d, cap(s2)=%d\n", s2, len(s2), cap(s2))
+ +

+

+

create

var s []int // nil
+ +

这种方式创建的 slice,初始值等于 nil

+

往里面添加元素时,lencap 是动态的。

+

+

另一种方法:

+
// 指定初始 len
s2 := make([]int, 16)
// 指定初始 len cap
s3 := make([]int, 10, 32)
+ +

append

有内建函数:

+
s = append(s, val)
+ +

添加元素时如果超越了 cap,系统会重新分配更大的底层数组

+

由于值传递的关系,必须接收 append 的返回值

+

copy

copy(s2, s1)
+ +

delete

删除下标为 3 的元素:

+
s2 = append(s2[:3], s2[4:]...)
+ +

shift/pop

// shift
front := s2[0]
s2 = s2[1:]
// pop
tail := s2[len(s2)-1]
s2 = s2[:len(s2)-1]
+ +

Map

操作

    +
  • 创建:make(map[string]int)
  • +
  • 获取:m[key],字符不存在返回 zero value
  • +
  • 判断 key 是否存在:value, ok := m[key]
  • +
  • 删除:delete(m, key)
  • +
  • 遍历:for k, v := range m,无序的
  • +
  • 获取长度:len(m)
  • +
  • map 使用哈希表,key 必须可以比较相等,除了 slice map function 以外的内建类型都可以作为 key,不包含上述字段的 struct 也可以
  • +
+

例:寻找最长的不含有重复字符的子串

func longest(s string) int {
lastOccurred := make(map[byte]int)
start := 0
maxLength := 0
for i, c := range []byte(s) {
if last, ok := lastOccurred[c]; ok && last >= start {
start = last + 1
}
if (i - start + 1) > maxLength {
maxLength = i - start + 1
}
lastOccurred[c] = i
}
return maxLength
}
+ +

String

    +
  • for i, b := range []byte(s) 得到的是 8 位 byte
  • +
  • for i, b := range []rune(s) 得到的是 utf8 解码后的字符
  • +
  • 获取 utf8 字符串长度:utf8.RuneCountInString(s)
  • +
  • 字符串操作库:strings.ToUpper / strings.xxx
  • +
+

面向对象

    +
  • 仅支持封装,不支持继承和多态
  • +
  • 没有 class,只有 struct
  • +
+

struct

    +
  • 无需关注结构体是储存在栈还是堆上
  • +
  • 知识点:nil 指针也调用方法
  • +
+

定义

    +
  • 值定义与成员方法的定义方式与传统方式有区别
  • +
  • 成员方法定义只有使用指针接收者(引用传递)才能改变结构的内容
  • +
  • 结构过大要考虑使用指针接收者(拷贝成本)
  • +
  • 注意方法的一致性:最好要么都是指针接收者,要么都是值接收者
  • +
+
type TreeNode struct {
value int
left, right *TreeNode
}

// 这里是值传递
func (node TreeNode) print() {
fmt.Println(node.value)
}

// 这里是引用传递
func (node *TreeNode) setValue(value int) {
// 不是 node->value
node.value = value
}

root.print()
root.setValue(1)
+ +

创建

var root TreeNode
root.left = &TreeNode{}
// 指针也可以直接“点”
root.left.right = &TreeNode{4, nil, nil}
root.right = &TreeNode{value: value}
+ +
func createNode(value int) *TreeNode {
return &TreeNode{value: value}
}

root.right = createNode(3)
+ +

例子:遍历树

func (node *TreeNode) travel() {
if node == nil {
return
}
// 即使 node.left 是 nil,它也能调用方法!
node.left.travel()
node.print()
node.right.travel()
}
+ +

包与封装

+

    +
  • 每个目录是一个包
  • +
  • “main 包”包含可执行入口
  • +
  • 为结构定义的方法必须放在同一个包内,可以是不同的文件
  • +
+

封装

    +
  • 名字 CamelCase
  • +
  • 首字母大写代表 public
  • +
  • 首字母小写代表 private
  • +
+
// node.go
package tree

import "fmt"

type Node struct {
Value int
Left, Right *Node
}

func CreateNode(value int) *Node {
return &Node{Value: value}
}

// ...
+ +
// travalsal.go
package tree

func (node *Node) Travel() {
// ...
}
+ +
// main.go
package main

import (
"fmt"
"learngo/tree"
)

func main() {
var root tree.Node
// ...
}
+ +

扩展

方法一:别名(简单)
package queue

type Queue []int

func (q *Queue) Push(value int) {
*q = append(*q, value)
}

func (q *Queue) Pop() int {
pop := (*q)[0]
*q = (*q)[1:]
return pop
}

func (q *Queue) IsEmpty() bool {
return len(*q) == 0
}
+ +
方法二:组合(常用)

与 js 的 {node: ...node} 类似,没有其它处理:

+
type myTreeNode struct {
node *tree.Node
}

func (node *myTreeNode) postOrder() {
if node == nil || node.node == nil {
return
}

left := myTreeNode{node.node.Left}
left.postOrder()

right := myTreeNode{node.node.Right}
right.postOrder()

node.node.Print()
}

myNode := myTreeNode{&root}
myNode.postOrder()
+ +
方法三:内嵌(少写代码)
    +
  • 其实是一个语法糖,编译器自动将字段以 Node 命名了。并且:Node 的属性和方法会自动提升到顶层。
  • +
  • 与继承类似,可以看作继承行为的模拟,但有本质区别。
  • +
  • 可以重写方法,重写的方法称作 shallowed method,而非 override,调用原 struct 方法使用 root.Node.xxx,相当于 super
  • +
+
type myTreeNodeEmbedded struct {
*tree.Node
}

func (node *myTreeNodeEmbedded) postOrder() {
// 必须!
if node == nil || node.Node == nil {
return
}

left := myTreeNodeEmbedded{node.Left}
left.postOrder()

right := myTreeNodeEmbedded{node.Right}
right.postOrder()

node.Print()
}
+ +

依赖管理

三个阶段 GOPATH/GOVENDOR/go mod

+

GOPATH

+
    +
  • 默认在 ~/go (linux, unix) %USERPROFILE%\go (windows)
  • +
  • 目录下面必须有 src 文件夹,作为所有依赖与项目的根目录(Google 将 20 亿行代码,9 百万个文件放在了一个 repo 里)
  • +
  • GOPATH 可以更改
  • +
  • GOPATH 内的两个项目无法依赖同一个库的不同版本
  • +
+
export GOPATH=/path/to/go
export GO111MODULE=off
// in src/proj folder
go get -u go.uber.org/zap
+ +

GOVENDER

+

GOPATH 内的两个项目无法依赖同一个库的不同版本

+
+
    +
  • 为了解决这个问题诞生了 GOVENDER,只需要在 project 里面新建 vender 文件夹,依赖就会从首先 vender 文件夹内查找
  • +
  • 有许多配套的依赖管理工具
  • +
+

+

GO MOD

go 命令统一管理,不必关心目录结构

+

初始化

相当于 npm init

+
go mod init modname
+ +

安装/升级依赖

与 GOPATH 一样:

+
go get -u go.uber.org/zap@1.12.0
+ +

依赖管理 go.mod

相当于 package.json

+
module learngo

go 1.16

require (
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.7.0 // indirect
go.uber.org/zap v1.19.0 // indirect
)
+ +

依赖锁 go.sum

相当于 package-json.lock

+
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
// ...
+ +

依赖锁瘦身

(也许相当于 npm uninstall

+

命令:

+
go mod tidy
+ +

构建

注意后面是 3 个点

+
go build ./...
+ +

旧项目迁移到 gomod

go mod init
go build ./...
+ +

接口

type retriever interface {
Get(string) string
}
+ +

duck typing

    +
  • “长得像鸭子,那么就是鸭子”
  • +
  • 描述事物的外部行为,而非内部结构
  • +
  • go 属于结构化类型系统,类似 duck typing(但本质上不是)
  • +
  • 同时具有 python c++ 的 duck typing 灵活性
  • +
  • 又具有 java 的类型检查
  • +
+

接口的定义

    +
  • 接口由使用者定义
  • +
  • 接口的实现是隐式的,只要实现了里面的内容即可
  • +
+

接口变量

    +
  • 接口变量自带指针
  • +
  • 接口变量同样采用值传递
  • +
  • 指针接收者实现只能以指针方式使用,值接收者都可
  • +
  • 接口变量里面有实现者的类型和值。
  • +
+
fmt.Printf("%T %v\n", r, r)
// test.Retriever {EMT}
+ +

接口的真实类型可以通过 switch 获取:

+
switch v := r.(type) {
case *infra.Retriever:
fmt.Println(v.TimeOut)
case test.Retriever:
fmt.Println(v.Content)
}
+ +

也可以通过 type assertion 获取:

+
// type assertion
if realRetriever, ok := r.(*infra.Retriever); ok {
fmt.Println(realRetriever.UserAgent)
} else {
fmt.Println("type incorrect")
}
+ +

实现者的值也可以换成实现者的指针:

+
type Retriever struct {
UserAgent string
TimeOut time.Duration
}

func (r *Retriever) Get(url string) string {
// ...
}

type retriever interface {
Get(string) string
}

var r retriever = &infra.Retriever{
TimeOut: time.Minute,
UserAgent: "Mozilla",
}

fmt.Printf("%T %v\n", r, r)
// *infra.Retriever &{Mozilla 1m0s}
+ +

表示任意类型的接口

类似 any

+
interface{}

// for example
type Queue []interface{}
+ +

接口的强制类型转换

type Queue []interface{}

func (q *Queue) Push(value interface{}) {
*q = append(*q, value.(int))
}
+ +

接口的组合

type retriever interface {
Get(string) string
}

type poster interface {
Post(string, map[string]string) string
}

type retrieverPoster interface {
retriever
poster
}
+ +

常用内置接口

stringer

相当于 toString

+
type Retriever struct {
Content string
}

func (r *Retriever) String() string {
return fmt.Sprintf("Test Retriever: {Content=%s}", r.Content)
}

fmt.Printf("%T %v\n", r, r)
// *test.Retriever Test Retriever: {Content=EMT}
+ +

reader/writer

func printFile(filename string) {
file, err := os.Open(filename)
if err != nil {
panic(err)
}
printFileContent(file)
}

func printFileContent(reader io.Reader) {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
fmt.Println(scanner.Text())
}
}

printFile("./basic/abc.txt")

printFileContent(strings.NewReader(`EMT
YES!
`))
+ +

函数式编程

+

例子:累加器

func adder() func(v int) int {
sum := 0
return func(v int) int {
sum += v
return sum
}
}

func main() {
add := adder()
for i := 0; i < 10; i++ {
fmt.Println(add(i))
}
}
+ +

例子:斐波那契数列

func fib() func() int {
before, after := 0, 1
return func() int {
fmt.Printf("before=%d, after=%d\n", before, after)
before, after = after, after+before
return after
}
}

func main() {
f := fib()
for i := 0; i < 10; i++ {
f()
}
}
+ +

例子:二叉树遍历

func (node *Node) TravelFunc(f func(*Node)) {
if node == nil {
return
}

node.Left.TravelFunc(f)
f(node)
node.Right.TravelFunc(f)
}

func (node *Node) Count() int {
count := 0
node.TravelFunc(func(node *Node) {
count++
})
return count
}
+ +

错误处理和资源管理

defer 调用

    +
  • defer 调用确保在函数结束时发生
  • +
  • defer 先进后出(栈)
  • +
  • defer 参数在语句时计算(非结算时)
  • +
+

何时使用 defer:

+
    +
  • open/close
  • +
  • lock/unlock
  • +
  • print header/footer
  • +
+

错误处理

file, err := os.OpenFile(filename, os.O_EXCL|os.O_CREATE, 0666)
if err != nil {
if e, ok := err.(*os.PathError); !ok {
fmt.Println(err)
} else {
fmt.Printf("Op: %s, Path: %s, Err: %s\n", e.Op, e.Path, e.Err)
}
}
+ +

统一的错误处理

以 http server 为例,思路是让 controller 可以直接返回 error,而 error 在外层的包裹函数内统一处理:

+
type handler func(w http.ResponseWriter, r *http.Request) error

func errWrapper(h handler) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
err := h(w, r)
if err != nil {
log.Printf("%s\n", err.Error())
code := http.StatusInternalServerError
switch {
case os.IsNotExist(err):
code = http.StatusNotFound
}
http.Error(w, http.StatusText(code), code)
}
}
}

func main() {
http.HandleFunc("/list/", errWrapper(controller.ListFile))

err := http.ListenAndServe(":8888", nil)
if err != nil {
panic(err)
}
}
+ +

panic

    +
  • 停止当前函数执行
  • +
  • 一直向上返回,执行每一层的 defer
  • +
  • 如果没有遇见 recover,程序退出
  • +
+

recover

    +
  • 仅在 defer 中使用
  • +
  • 获取 panic 的值
  • +
  • 如果无法处理,可以重新 panic
  • +
+
defer func() {
e := recover()
if err, ok := e.(error); ok {
fmt.Println("catch error: ", err)
} else {
panic(errors.New("don't know what to do: " + fmt.Sprintf("%v", e)))
}
}()

//panic(errors.New("..."))
//panic(123)
b := 0
a := 5 / b
fmt.Println(a)
//catch error: runtime error: integer divide by zero
+ +

error vs. panic

    +
  • 尽量使用 error
  • +
  • 意料之中的:error。如:文件打不开
  • +
  • 意料之外的:panic。如:数组越界
  • +
+

自定义错误

type UserError string

func (u UserError) Error() string {
return u.Message()
}

func (u UserError) Message() string {
return string(u)
}

func ListFile(writer http.ResponseWriter, request *http.Request) error {
if strings.Index(request.URL.Path, prefix) < 0 {
// 外部可以使用 type assertion 判断,并输出自定义 message
return UserError("path muse starts with " + prefix)
}

// ...
}
+ +

统一的错误处理(进阶)

func errWrapper(h handler) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
if err, ok := r.(error); ok {
log.Printf("%s\n", err.Error())
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} else {
log.Printf("%v\n", r)
}
}
}()

err := h(w, r)
if err != nil {
log.Printf("%s\n", err.Error())
if userError, ok := err.(controller.UserError); ok {
http.Error(w, userError.Message(), http.StatusBadRequest)
return
}

code := http.StatusInternalServerError
switch {
case os.IsNotExist(err):
code = http.StatusNotFound
}
http.Error(w, http.StatusText(code), code)
}
}
}
+ +

测试

单元测试

+
$ go test .
ok learngo/basic/basic 0.087s
+ +

单元测试文件以 _test.go 结尾,如 some_function_test.go,ide 可自动识别单元测试文件

+

如果要引用私有方法,需要跟方法在同一个 package

+

命令行执行:go test .

+
package main

import (
"testing"
)

func TestTriangle(t *testing.T) {
tests := []struct{ a, b, c int }{
{3, 4, 5},
{5, 12, 13},
{8, 15, 17},
{12, 35, 37},
{30000, 40000, 50000},
}
for _, tt := range tests {
if c := calcTriangle(tt.a, tt.b); c != tt.c {
t.Errorf("calcTriangle(%d,%d), expected %d, got %d", tt.a, tt.b, tt.c, c)
}
}
}
+ +

覆盖率

+

go 自带覆盖率工具

+
# 生成报告
go test . -coverprofile=c.out
# 查看报告
go tool cover -html=c.out
+ +

性能测试

性能测试方法需要以 Benchmark 开头:

+
func BenchmarkNonRepeating(b *testing.B) {
// 运行一秒钟,具体次数由 go 决定
for i := 0; i < b.N; i++ {
assert.Equal(b, 8, longest("黑化肥挥发发灰会花飞灰化肥挥发发黑会飞花"))
}
}
+ +
go test -bench .
+ +

pprof

+

web 报告需要安装 https://www.graphviz.org/download/

+
go test -bench . -cpuprofile cpu.out
go tool pprof cpu.out
# 打开基于网页的性能报告
(pprof) web
+ +

+

http 单元测试

func errorPanic(w http.ResponseWriter, r *http.Request) error {
panic(123)
}

func errorNotExist(w http.ResponseWriter, r *http.Request) error {
return os.ErrNotExist
}

// ...

var tests = []struct {
h handler
code int
message string
}{
{errorNotExist, http.StatusNotFound, http.StatusText(http.StatusNotFound)},
// ...
}
+ +

方式一:测代码逻辑

使用 fake req\res mock 测试:

+
func TestErrorWrapper(t *testing.T) {
for _, test := range tests {
h := errWrapper(test.h)
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "https://google.com", nil)
h(res, req)
all, _ := ioutil.ReadAll(res.Body)
assert.Equal(t, test.message, strings.TrimSpace(string(all)))
assert.Equal(t, test.code, res.Code)
}
}
+ +

方式二:真实服务器测试

func TestServer(t *testing.T) {
for _, test := range tests {
h := errWrapper(test.h)
s := httptest.NewServer(http.HandlerFunc(h))
res, _ := http.Get(s.URL)
all, _ := ioutil.ReadAll(res.Body)
assert.Equal(t, test.message, strings.TrimSpace(string(all)))
assert.Equal(t, test.code, res.StatusCode)
}
}
+ +

生成文档

go get golang.org/x/tools/cmd/godoc
// 启动文档服务器
godoc -http :6060
+ +

+

编写注释

// Queue is a FIFO queue
// q := Queue{1,2,3}
type Queue []interface{}

// Push add an item into queue
func (q *Queue) Push(value interface{}) {
*q = append(*q, value)
}

// Pop remove an item from queue
func (q *Queue) Pop() interface{} {
pop := (*q)[0]
*q = (*q)[1:]
return pop
}

// IsEmpty check if queue is empty
func (q *Queue) IsEmpty() bool {
return len(*q) == 0
}
+ +

编写样例

样例代码也是一种单元测试,以 Example 开头,并且以 Output 表示输出。输出不正确时 test 会不通过。

+

(我在想,go 的约定大于配置是不是做得有点太激进了?)

+
func ExampleQueue_Push() {
s := Queue{1, 2}

s.Push(3)
fmt.Println(s.Pop())
fmt.Println(s.Pop())
fmt.Println(s.IsEmpty())
fmt.Println(s.Pop())
fmt.Println(s.IsEmpty())

// Output:
// 1
// 2
// false
// 3
// true
}
+ +

goroutine

    +
  • go 语言使用 goroutine 来实现并发编程。
  • +
  • 任何函数加入 go 关键字就能送给调度器运行
  • +
  • 不需要在定义时区分是否是异步函数
  • +
  • 调度器在合适的时机自动进行切换
  • +
  • 开多少个线程、goroutine 分布在哪个线程上是由调度器自动决定的
  • +
+

+

协程 Coroutine

    +
  • 轻量级“线程”
  • +
  • 非抢占式多任务处理,由协程主动交出控制权
  • +
  • 编译器、解释器、虚拟机层面的多任务
  • +
  • 多个协程可以在一个或多个线程上运行
  • +
+

+

race condition

检查数据读写冲突:

+
go run -race gorouting.go
+ +

goroutine 可能的切换点

只是参考,不能保证切换,不能保证在其他地方不切换

+
    +
  • io, select
  • +
  • channel
  • +
  • waiting for lock
  • +
  • function call
  • +
  • runtime.Gosched()
  • +
+

channel

+

不要通过共享内存来通信——通过通信来共享内存。

+
+
    +
  • channel 是 goroutine 之间通信的桥梁。
  • +
  • channel 可以定义可收发,也可以定义仅收、仅发
  • +
  • channel 收值可以用死循环,也可以用 range,也可以用条件。但是用死循环的话要注意:channel 关闭后依然会不断发送消息
  • +
  • channel 可以关闭 close(c)
  • +
  • channel 可以定义缓冲区,make(chan int, 3)
  • +
  • 注意:channel 收发是同步的,也就是说:
  • +
  • 当发消息的时候,发送方要等待消息被接受才会继续执行
  • +
  • 当收消息的时候,接收方要等待消息被发送才会继续执行
  • +
  • 如果在 goroutine 之间只发不收或只收不发,会出现死锁
  • +
+

+
func worker(id int, c chan int) {
// 当 channel 收到值,且未关闭时
for n := range c {
fmt.Printf("worker %d receive %c\n", id, n)
}
}

// 返回值为只收 channel
func createWorker(id int) chan<- int {
c := make(chan int)
go worker(id, c)
return c
}

func channelDemo() {
var cs = make([]chan<- int, 10)
for i := 0; i < 10; i++ {
cs[i] = createWorker(i)
}
for i := 0; i < 10; i++ {
// 向 channel 发送数据
cs[i] <- 'a' + i
}
for i := 0; i < 10; i++ {
cs[i] <- 'A' + i
}
time.Sleep(time.Millisecond)
}
+ +

等待任务结束

方式一:使用 channel

type worker struct {
id int
in chan int
done chan bool
}

func doWork(w worker) {
for n := range w.in {
fmt.Printf("worker %d receive %c\n", w.id, n)
w.done <- true
}
}

func createWorker(id int) worker {
w := worker{
in: make(chan int),
done: make(chan bool),
id: id,
}
go doWork(w)
return w
}

func channelDemo() {
var w = make([]worker, 10)
for i := 0; i < 10; i++ {
w[i] = createWorker(i)
}
for i, worker := range w {
worker.in <- 'a' + i
}
for _, worker := range w {
<-worker.done
}
for i, worker := range w {
worker.in <- 'A' + i
}
for _, worker := range w {
<-worker.done
}
}

func main() {
channelDemo()
}
+ +

方式二:使用 WaitGroup

type worker struct {
id int
in chan int
done func()
}

func doWork(w worker) {
for n := range w.in {
fmt.Printf("worker %d receive %c\n", w.id, n)
w.done()
}
}

func createWorker(id int, wg *sync.WaitGroup) worker {
w := worker{
in: make(chan int),
done: wg.Done,
id: id,
}
go doWork(w)
return w
}

func channelDemo() {
var w = make([]worker, 10)
wg := sync.WaitGroup{}

for i := 0; i < 10; i++ {
w[i] = createWorker(i, &wg)
}
//wg.Add(20)
for i, worker := range w {
wg.Add(1)
worker.in <- 'a' + i
//wg.Add(1) 错误!应该先 Add 再发数据
}
//wg.Wait()
for i, worker := range w {
wg.Add(1)
worker.in <- 'A' + i
}
wg.Wait()
}

func main() {
channelDemo()
}
+ +

使用 channel 进行树遍历

func (node *Node) TravelFunc(f func(*Node)) {
// 普通的回调式遍历...
}

func (node *Node) TravelWithChannel() chan *Node {
c := make(chan *Node)
go func() {
defer close(c)
node.TravelFunc(func(node *Node) {
c <- node
})
}()
return c
}

func main() {
var root tree.Node
// ...
max := 0
node := root.TravelWithChannel()
for n := range node {
if n.Value > max {
max = n.Value
}
}
fmt.Println("Max: ", max)
}
+ +

使用 select 进行调度

    +
  • select 的使用,可以不加锁控制任务的执行
  • +
  • 定时器的使用:定时器返回的也是 channel
  • +
  • 在 select 中使用 nil channel:nil channel 永远不会被 select
  • +
+
tm := time.After(time.Second * 10)
tick := time.Tick(time.Second)
for {
var activeChannel chan<- int
var activeValue int
if len(values) > 0 {
activeChannel = w.in
activeValue = values[0]
}

select {
case <-tm:
fmt.Println("program exit")
return
case n := <-c1:
values = append(values, n)
//fallthrough
case n := <-c2:
values = append(values, n)
case activeChannel <- activeValue:
values = values[1:]
case <-tick:
fmt.Println("len(values)=", len(values))
case <-time.After(time.Millisecond * 800):
fmt.Println("timeout")
}

}
+ +

传统的同步机制

    +
  • WaitGroup
  • +
  • Mutex
  • +
  • Cond
  • +
+

如果不加锁,使用 -race 执行会发生 data race:

+
type atomicInt struct {
Value int
Lock sync.Mutex
}

func (i *atomicInt) add(v int) {
i.Lock.Lock()
defer i.Lock.Unlock()
i.Value += v
}

func (i *atomicInt) getValue() int {
i.Lock.Lock()
defer i.Lock.Unlock()
return i.Value
}

func main() {
i := atomicInt{}
i.add(1)
go func() {
i.add(1)
}()
time.Sleep(time.Millisecond)
fmt.Println(i.getValue())
}
+ +

并发编程模式

生成器

func msgGen(id string) <-chan string {
c := make(chan string)
i := 0
go func() {
for {
time.Sleep(time.Duration(rand.Intn(2000)) * time.Millisecond)
c <- fmt.Sprintf("generator %s sended %d", id, i)
i++
}
}()
return c
}
+ +

fanIn

将多个 channel 的消息合并为一个输出以避免阻塞:

+
func fanIn(channels ...<-chan string) <-chan string {
c := make(chan string)
for _, ch := range channels {
go func(ch <-chan string) {
for {
c <- <-ch
}
}(ch)
}
return c
}

func main() {
m := fanIn(
msgGen("svc1"),
msgGen("svc2"),
msgGen("svc3"),
msgGen("svc4"),
msgGen("svc5"),
)
for {
fmt.Println(<-m)
}
}
+ +

fanIn by select

func fanInBySelect(c1 <-chan string, c2 <-chan string) <-chan string {
c := make(chan string)
go func() {
for {
select {
case n := <-c1:
c <- n
case n := <-c2:
c <- n
}
}
}()
return c
}
+ +

任务的控制

非阻塞等待

func noneBlockingWait(c <-chan string) (string, bool) {
select {
case n := <-c:
return n, true
default:
return "", false
}
}
+ +

超时机制

func timeoutWait(c <-chan string, timeout time.Duration) (string, bool) {
select {
case n := <-c:
return n, true
case <-time.After(timeout):
return "", false
}
}
+ +

优雅退出

当消息的内容无所谓时,channel 可以用空的 struct,体积比 boolean 更小。

+
func msgGen(id string, done chan struct{}) <-chan string {
c := make(chan string)
i := 0
go func() {
for {
select {
case <-done:
fmt.Println("cleaning...")
time.Sleep(time.Second * 2)
fmt.Println("clean done.")
done <- struct{}{}
return
case <-time.After(time.Duration(rand.Intn(2000)) * time.Millisecond):
c <- fmt.Sprintf("generator %s sended %d", id, i)
i++
}
}
}()
return c
}

func main() {
done := make(chan struct{})
m1 := msgGen("svc1", done)
for i := 0; i < 5; i++ {
if msg, ok := timeoutWait(m1, 1*time.Second); ok {
fmt.Println("msg from svc1: ", msg)
} else {
fmt.Println("timeout")
}
}
done <- struct{}{}
<-done
}
//msg from svc1: generator svc1 sended 0
//timeout
//msg from svc1: generator svc1 sended 1
//timeout
//msg from svc1: generator svc1 sended 2
//cleaning...
//clean done.
+ +

http

标准库

简单访问

resp, err := http.Get("https://www.imooc.com")
response, err := httputil.DumpResponse(resp, true)
+ +

自定义 Header

request, err := http.NewRequest(http.MethodGet, "http://www.imooc.com", nil)
request.Header.Add("User-Agent", "xxx")
client := http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
fmt.Println("Redirect:", req)
return nil
}}
resp, err2 := client.Do(request)
response, err3 := httputil.DumpResponse(resp, true)
+ +

性能监测

导入 pprof 后,可以在 web 端查看调试界面(端口为 web 服务端口):

+

http://localhost:8888/debug/pprof/

+
import (
// ...
_ "net/http/pprof"
)
+ +

+

也可以在控制台查看 cpu 与 内存信息:

+
$ go tool pprof http://localhost:6060/debug/pprof/heap
$ go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
+]]>
+ + go + +
+ + MEAN.js Menu Service Extension + /2016/mean-js-menu-service-extension/ + MEAN.js解决方案只提供了1级/2级菜单栏的service支持,最近项目中需要用到第3级菜单,所以需要进行一个小的功能扩展。一开始我以为可以很容易地做到无限级,真正做起来以后发现并没有那么简单,所以目前通过这个办法只能达到第3级。

+ + +

修改Menu服务

初始的Menu Service中为使用者写了两个添加菜单项的方法:

+
// Add menu item object
this.addMenuItem = function (menuId, options)
+ +

以及

+
// Add submenu item object
this.addSubMenuItem = function (menuId, parentItemState, options)
+ +

第一个方法很显然就是用来添加顶级菜单了,第二个在没有看代码以前我曾经天真地以为它可以无限嵌套,然而并没有,它做的事情仅限于添加第2级菜单。所以现在我需要自己写第三个方法来完成添加第三级菜单。考虑到三级循环的效率问题,虽然一般来说菜单项不会有太多,但看起来就是非常不爽,所以我给每个Menu项都添加了一个哈希表来储存其下面所有菜单项的引用,这样多花费一点点内存就可以不用写循环嵌套了。由于使用了哈希表,对原2级菜单做了一些修改:

+
// Add submenu item object
this.addSubMenuItem = function (menuId, parentItemState, options) {
options = options || {};

// Validate that the menu exists
this.validateMenuExistance(menuId);

// Search for menu item
for (var itemIndex in this.menus[menuId].items) {
if (this.menus[menuId].items[itemIndex].state === parentItemState) {
// Push new submenu item
var newSubmenuItem = {
title: options.title || '',
state: options.state || '',
disabled: options.disabled || false,
roles: ((options.roles === null || typeof options.roles === 'undefined') ? this.menus[menuId].items[itemIndex].roles : options.roles),
position: options.position || 0,
shouldRender: shouldRender,
items: []
};
this.menus[menuId].items[itemIndex].items.push(newSubmenuItem);
this.menus[menuId].menuHash[newSubmenuItem.state] = newSubmenuItem;

if (options.items) {
for (var i in options.items) {
this.addSubMenuItemToSubMenu(menuId, options.state, options.items[i]);
}
}
}
}

// Return the menu object
return this.menus[menuId];
};
+ +

然后是新的添加3级菜单的方法:

+
//For level 3 menu items
this.addSubMenuItemToSubMenu = function (menuId, parentItemState, options) {
options = options || {};
this.validateMenuExistance(menuId);
for (var itemIndex in this.menus[menuId].menuHash) {
if (this.menus[menuId].menuHash[itemIndex].state === parentItemState) {
// Push new submenu item
var newSubMenuItem = {
title: options.title || '',
state: options.state || '',
disabled: options.disabled || false,
roles: ((options.roles === null || typeof options.roles === 'undefined') ? this.menus[menuId].menuHash[itemIndex].roles : options.roles),
position: options.position || 0,
shouldRender: shouldRender,
items: []
};
this.menus[menuId].menuHash[itemIndex].items.push(newSubMenuItem);
this.menus[menuId].menuHash[newSubMenuItem.state] = newSubMenuItem;
}
}
return this.menus[menuId];
};
+ +

修改Header模板

原Header模板中嵌套了两层Angular循环来遍历菜单项,我们给它加一层就好了,改完以后就像这样:

+
<nav class="collapse navbar-collapse" uib-collapse="!isCollapsed" role="navigation">
<ul class="nav navbar-nav" ng-if="menu.shouldRender(authentication.user);">
<li ng-repeat="item in menu.items | orderBy: 'position'" ng-if="item.shouldRender(authentication.user);"
ng-switch="item.type"
ng-class="{ active: $state.includes(item.state), dropdown: item.type === 'dropdown',disabled:item.disabled }"
class="{{item.class}}" uib-dropdown="item.type === 'dropdown'">
<a ng-switch-when="dropdown" class="dropdown-toggle" uib-dropdown-toggle role="button">{{::item.title}}&nbsp;<span
class="caret"></span></a>
<ul ng-switch-when="dropdown" class="dropdown-menu">
<li ng-repeat="subitem in item.items | orderBy: 'position'" ng-if="subitem.shouldRender(authentication.user);"
ui-sref-active="active" ng-class="{'dropdown-submenu':subitem.items.length>0,disabled:subitem.disabled}">
<a ui-sref="{{subitem.state}}" ng-bind="subitem.title" ng-if="subitem.items.length===0"></a>
<a href="javascript:;" ng-bind="subitem.title" ng-if="subitem.items.length>0"></a>
<ul class="dropdown-menu" ng-if="subitem.items.length>0">
<li ng-repeat="i in subitem.items | orderBy: 'position'" ng-if="i.shouldRender(authentication.user);"
ui-sref-active="active" ng-class="{disabled:i.disabled}">
<a ui-sref="{{i.state}}" ng-bind="i.title"></a>
</li>
</ul>
</li>
</ul>
<a ng-switch-default ui-sref="{{item.state}}" ng-bind="item.title"></a>
</li>
</ul>
<ul class="nav navbar-nav navbar-right" ng-hide="authentication.user">
<li ui-sref-active="active">
<a ui-sref="authentication.signup">Sign Up</a>
</li>
<li class="divider-vertical"></li>
<li ui-sref-active="active">
<a ui-sref="authentication.signin">Sign In</a>
</li>
</ul>
<ul class="nav navbar-nav navbar-right" ng-show="authentication.user">
<li class="dropdown" uib-dropdown>
<a class="dropdown-toggle user-header-dropdown-toggle" uib-dropdown-toggle role="button">
<img ng-src="{{authentication.user.profileImageURL}}" alt="{{authentication.user.displayName}}"
class="header-profile-image"/>
<span ng-bind="authentication.user.displayName"></span> <b class="caret"></b>
</a>
<ul class="dropdown-menu" role="menu">
<li ui-sref-active="active">
<a ui-sref="settings.profile">Edit Profile</a>
</li>
<li ui-sref-active="active">
<a ui-sref="settings.picture">Change Profile Picture</a>
</li>
<li ui-sref-active="active" ng-show="authentication.user.provider === 'local'">
<a ui-sref="settings.password">Change Password</a>
</li>
<!--li ui-sref-active="active">
<a ui-sref="settings.accounts">Manage Social Accounts</a>
</li-->
<li class="divider"></li>
<li>
<a href="/api/auth/signout" target="_self">Signout</a>
</li>
</ul>
</li>
</ul>
</nav>
+ +

修改CSS

为了让菜单看起来更自然些,这里修改的是 core.css,添加以下内容:

+
.dropdown-submenu {
position: relative;
}

.dropdown-menu {
-webkit-border-radius: 0;
-moz-border-radius: 0;
border-radius: 0;
}

.dropdown-submenu > .dropdown-menu {
top: 0;
left: 100%;
margin-top: -6px;
margin-left: -1px;
}

.dropdown-submenu:hover > .dropdown-menu {
display: block;
}

.dropdown-submenu > a:after {
display: block;
content: " ";
float: right;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
border-width: 5px 0 5px 5px;
border-left-color: #333;
margin: 5px -10px 0;
}

.dropdown-submenu:hover > a:after {
border-left-color: #333;
}

.dropdown-submenu.pull-left {
float: none;
}

.dropdown-submenu.pull-left > .dropdown-menu {
left: -100%;
margin-left: 10px;
-webkit-border-radius: 6px 0 6px 6px;
-moz-border-radius: 6px 0 6px 6px;
border-radius: 6px 0 6px 6px;
}
+ +

 

+

使用方法

3级菜单的定义方法与2级菜单一模一样,除了直接调用 addSubMenuItemToSubMenu 以外,还可以通过在2级菜单内定义 items 来实现添加子菜单,示例如下,高亮部分则为3级菜单:

+
Menus.addMenuItem('topbar', {
title: '...',
state: '...',
type: 'dropdown',
position: 0,
roles: ['*'],
items: [{
title: '...',
state: '...',
roles: ['*']
}, {
title: '...',
state: '...',
roles: ['*'],
items: [{
title: '...',
state: '...',
roles: ['*']
}, {
title: '...',
state: '...',
roles: ['*']
}]
}]
});
+ +

实现无限级

目前看来菜单层级的限制不是在于Service代码,而在于模板。如何在模板中让Angular做一个DFS搜索才是重点。Angular貌似没有提供类似的API,要做的话比较好的办法应该是自己写一个指令。以后有时间再来实现。

+]]>
+ + angularjs + +
+ + MEAN.js 学习笔记 + /2015/mean-js-note/ +

+

之前一直以为 MEAN 只是一个概念上的东西,表示以 Mongodb Express AngularJs NodeJs 为基础的全栈应用开发模式。这几天在公司接手相应项目的时候发现已经有人做出来并且维护着一些这样的 App 结构体,用过以后觉得还不错。MEANJS 是一个开源的 JavaScript 全栈应用解决方案,主要用到的技术自然就是以上提到的那些。使用成熟的解决方案可以使自己的项目更加易于开发以及维护,等等好处就不再赘述。

+ + +

关于MEAN

本文主要关注 MEANJS本 身,对于MEAN之中的种种技术就不再多做介绍。下面贴MEANJS给出的一些链接。

+ +

安装依赖

作为一个集大成者,MEANJS 需要的运行环境还是挺多的,但是相对 Java 项目来说简直不值一提。

+
    +
  • NodeJs - 没有 Node 谈何 MEAN,注意的是目前的 MEANJS 版本(0.3.x)还不支持最新的 5.x NodeJs,我就中了这招
  • +
  • MongoDB - 和 NodeJs 的集成比较好,下载安装包一路 next 即可
  • +
  • Ruby/Python/.Net 2+ - 一些 Node 的模块需要用到这些东西,毕竟 Node 只是一个 runtime,在服务器端一些稍微底层的操作还是要用到其他东西(12-14-2015更新:Python必须是 2.x 版本)
  • +
  • Bower - 前端包管理器,和 npm 组成一前一后的完整管理体系,相当于 Java 的 Maven
  • +
  • Grunt/Grunt CLI - JavaScript 世界的自动化工具,重复工作全靠它。CLI 是 Grunt 的命令行工具。
  • +
  • Sass/Less - MEANJS 用到了 Sass 去编译 CSS,所以也要添加它的支持。其实我个人感觉这个有点多余了。
  • +
+

启动 MEANJS APP

安装依赖模块

完成以上安装以及相应环境变量的配置以后,就可以准备启动 MEANJS 服务器了。首先需要在文件夹根目录运行 $ npm install 指令,根据 Readme 中的说法,这条指令做了以下的事情:

+
    +
  1. 安装运行所需的 Node 模块
  2. +
  3. 如果是测试环境则安装开发测试所需的 Node 模块
  4. +
  5. 最后执行 bower 安装前端模块
  6. +
+

不过我在最后一步有时候会遇到问题,需要手动再进行一次 $ bower install,另外,npm 的官方源在大陆访问并不稳定,可以使用 淘宝镜像 替代,Ruby 也是同理:Ruby镜像

+

12-14-2015 更新:这一步容易出现问题,一般仔细看 Log 都能找到问题所在,无非是哪个依赖没有配置环境变量/版本不对等,重新配置好以后删除 Node_modules 文件夹再重新运行命令。

+

启动 Mongodb

因为 MEANJS 默认为我们做了一个简单的用户注册登录模块,里面有一些数据库的增删查改,所以在启动服务器之前需要先启动数据库。随便找一个地方打开控制台输入 $ mongod --dbpath ***\*** 处填写一个路径,mongod 就能够在指定位置创建一个文件型数据库并连接之,如果该位置已存在数据库文件则会直接打开连接。

+

启动服务器

在以上都准备完成以后,我们就可以在项目根目录通过一条简单的指令 $ grunt 来启动服务器了,启动成功后可以在 http://localhost:3000 看到项目主页。

+

关于Grunt

$ grunt 这条指令会读取项目目录下的 gruntfile.js** **文件,并执行文件中定义的 task。MEANJS 的文档中并没有对其功能进行说明,以下是我的解读:

+

插件配置

以下都是 grunt task 中用到的插件的相关配置,具体插件以及相关文档都可以在 Grunt插件页面 找到。

+
env: {
test: {
NODE_ENV: 'test'
},
dev: {
NODE_ENV: 'development'
},
prod: {
NODE_ENV: 'production'
}
}
+ +

env 定义了三个服务器的运行环境:测试,开发,以及产品,在文件的最后会用到。

+
//......
defaultAssets = require('./config/assets/default'),
testAssets = require('./config/assets/test'),
//......
watch: {
serverViews: {
files: defaultAssets.server.views,
options: {
livereload: true
}
},
//......
}
+ +

watch 指定了动态监听的目录/文件,可以看到在每一个 View/Js/Css 监听列表中都加入了 livereload 选项,这个选项的作用是当被监听的文件发生变化时,浏览器会自动刷新。不过 watch 会再创建一个监听端口(默认为 35729),打开 http://localhost:35729/ 可以发现。被加载的首先是配置文件 ./config/assets/default.js 与相应的 test.js 等,然后再配置文件内可以找到文件列表,其中已经包括已经用到的以及将来会加入的文件(通过通配符实现),只要我们在开发时把文件放在相应结构位置上,grunt 就会自动监听。

+
nodemon: {
dev: {
script: 'server.js',
options: {
nodeArgs: ['--debug'],
ext: 'js,html',
watch: _.union(defaultAssets.server.gruntConfig, defaultAssets.server.views, defaultAssets.server.allJS, defaultAssets.server.config)
}
}
}
+ +

nodemon 配置了服务器自动重启。当 server 端的 config/views/js 文件发生变化时,server.js 脚本就会自动执行。由于只是服务器的重启而不是重新执行 grunt,所以几乎是秒速。以前用过一些类似的 node module 叫 supervisor 和 forever,不过这个集成到了 grunt task 中。写过 JavaEE project 的人再用这个才能体会到时间的宝贵。

+
concurrent: {
default: ['nodemon', 'watch'],
debug: ['nodemon', 'watch', 'node-inspector'],
options: {
logConcurrentOutput: true
}
}
+ +

concurrent 插件可以使任务并发执行,让前端与服务器端监听同时在一个终端窗口中执行/ Log

+

cshint/csslint 这两个插件主要是为了在 build 的时候顺便检查一下 js/css 文件中有没有常见的 warning / error,存在 error 时会停止 build task 并给出提示,不过控制台输出用户体验不是很好,开发过程中作用不大,我们都有 IDE,需要作为产品上线时跑一遍可能会更有参考价值。

+

后面的 ngAnnotae 插件可以在build的过程中对 angular js 的 annotation 进行简化以减少代码量,提高效率,属于锦上添花型。uglify/cssmin 则相应地执行 js/css 代码压缩任务。至于 sass/less 很明显就是 css 编译器了。再之后的多是 debug / test 插件。

+

注册任务

grunt.registerTask('taskName', ['***', '***']);
+ +

类似像这样的代码就是向grunt注册一个任务,第二个数组参数则是注册任务的内容,里面可以填另一个任务的名字或者是插件的名字,或者直接填写 function 取代该数组。通过在控制台输入 $ grunt taskName 执行任务,而不输入 taskName 的话则是执行 default 任务,当前 gruntfile.js 中的 default task 如下:

+
// Run the project in development mode
grunt.registerTask('default', ['env:dev', 'lint', 'mkdir:upload', 'copy:localConfig', 'concurrent:default']);
+ +

这个任务里面包含了一些子任务,就不一一说明了,有兴趣的可以自行查看,到这里终于可以说说 $ grunt 指令到底做了什么:

+
    +
  1. 设置运行环境为 dev,即开发
  2. +
  3. 执行 js/css 等文件的语法检查
  4. +
  5. 确保上传路径存在(MEANJS 默认带了一个用户上传头像的功能)
  6. +
  7. 加载一个自定义配置文件(里面可以填写 db 以及一些 api key 等信息)
  8. +
  9. default 模式启动 concurrent 前后端热部署
  10. +
+

可以看到这里面并没有启动服务器的指令,其实在nodemon中已经配置了服务器入口即 server.js。于是在所有准备工作完成后,开发环境的服务器就启动起来了。

+

当然 gruntfile 中也包含了 dev 以及 tes t环境的 task,需要切换运行环境的时候只需要在 grunt 命令中加入相应参数即可,还是比较方便的。

+

项目结构

根目录结构

├── bower.json
├── config
├── gruntfile.js
├── modules
├── package.json
└── server.js
+ +

以上是精简过后的根目录组成,不包括node_modules和public文件夹,以及一些optional和test相关的文件。

+
    +
  • bower.json/package.json - 前端/后端依赖说明文件,需要添加依赖时在文件里指定 ID /版本,再运行 $ bower install 或者 $ npm install 就会将指定包下载到 node_modules/public 文件夹中
  • +
  • gruntfile.js - grunt 任务配置文件
  • +
  • server.js - 服务器启动文件
  • +
  • config - 配置文件
  • +
  • modules - App 模块,也就是需要我们大量写代码的地方了,可以看到 MEANJS 项目已经包含了若干模块,我们可以在这基础之上添加自己的业务逻辑,或者推到重来
  • +
+

由于 MEANJS 的目录原则是模块优先,所以前后端的 MVC 会在相应模块目录内得到体现,这点与使用 express js 创建的目录结构有所区别。不过之前公司一位 STE share ExtJs 的时候提到其实都是大同小异,反正到最后目录结构都会变得臃肿。

+

模块结构

modules
│ └── moduleName
│ ├── client
│ │ ├── config
│ │ ├── controllers
│ │ ├── css
│ │ ├── img
│ │ ├── services
│ │ └── views
│ └── server
│ ├── config
│ ├── controllers
│ ├── models
│ ├── policies
│ ├── routes
│ └── templates
+ +

一个模块一般包含以上目录,首先从前端/后端分开,然后是各自的配置/ MVC,非常科学。值得一提的是每个模块各自用到的独立 css / image 等资源也是分开存放的,grunt 会在 build 的时候把它们全部读取并且载入,如果是 production 环境更会将同类压缩到一个文件中去,所以我们并不需要写很多的 include 之流。

+

总结

相对于手动使用 MEAN 各项技术结合写程序来说,使用 MEANJS 解决方案可以让我们更方便且快速地搭建项目,并且使我们不用太过于关注业务逻辑以外的问题,开发效率在全栈统一的保证下又提高了不少,不得不说确实是值得中小型项目去研究并且尝试使用一下。至于企业级大型项目,不知道有没有研究或者什么公司尝试过,不太清楚是否适合。

+]]>
+ + mongodb + angularjs + nodejs + express + +
+ + Linux Setup for Work + /2018/linux-setup-for-work/ + 因为各种烦人的原因,公司搬家后到新办公室第一件事先把老电脑格了。犹豫了一下,最终还是放弃了重装 Windows,支持我做出选择的原因有几:

+
    +
  • 不需要进行(纯)MS 系开发
  • +
  • 没有必须使用的 Windows 软件
  • +
  • Windows 上跑 Android emulator 卡得头疼
  • +
  • NVIDIA 已有支持 Linux 的官方显卡驱动
  • +
  • Linux 开发效率更高
  • +
  • Linux 学习价值更高
  • +
+

本文是办公室适用(对我来说)的安装记录。

+ + +

System installation

这次选择的发行版是 Ubuntu 16.04 LTS,从 https://www.ubuntu.com/download/desktop 下载镜像安装包,复制到 u 盘后启动。

+

这里有一点问题是,我这台机器必须选择 UEFI 安装,Ubuntu 才能正常安装与启动。如果选择了 Legacy 安装,Ubuntu 可以正常安装,但启动后会一直停留在黑屏光标闪烁的状态,原因未知。

+

NVIDIA driver setup

系统默认安装了一个第三方的显卡驱动,基本上没什么可用性,在桌面上都有点卡。因此官方驱动是必须的。但如果安装不正确,会导致系统重启后无限卡在登录界面。如果不幸已经发生了这种情况,可以按 Ctrl + Alt + F1 进入纯命令行操作界面进行修复。

+

(以下步骤应该在纯命令行界面下执行)

+

首先禁用开源驱动:

+
$ sudo vim /etc/modprobe.d/blacklist.conf
+ +

添加以下内容:

+
blacklist amd76x_edac
blacklist vga16fb
blacklist nouveau
blacklist nvidiafb
blacklist rivatv
+ +

然后,依次执行(注意先到 NVIDIA 官网查询适用自己显卡的版本号,比如我的辣鸡 GTX650 是适用 384):

+
$ sudo apt-get remove  --purge nvidia-*
$ sudo add-apt-repository ppa:graphics-drivers/ppa
$ sudo apt-get update
$ sudo service lightdm stop
$ sudo apt-get install nvidia-384 nvidia-settings nvidia-prime
$ sudo nvidia-xconfig
$ sudo update-initramfs -u
+ +

最后重启系统:

+
$ sudo reboot
+ +

如此,显卡驱动就装好了。

+

Chrome setup

虽然 Ubuntu App Store 有提供开源版本的 Chromium,但是经过实测它在有些情况下并不能完全替代 Chrome(比如有些工具会调用 google-chrome 来打开一个浏览器页,如果安装的是 Chromium 就会失败)。因此,还是建议到 Google Chrome Downloads 下载适用于 Linux 平台的 Chrome 完全体。

+

Secondary drive mount

两块硬盘已经不是什么新鲜事了。痛苦的是系统盘以外的另一块硬盘需要手动挂载。

+

首先,使用 sudo fdisk -l 命令来显示目前可用的所有硬盘。假设 /dev/sdb 是未分区并且想要挂载的一块硬盘:

+

执行 sudo fdisk /dev/sdb

+
    +
  1. Press O and press Enter (creates a new table)
  2. +
  3. Press N and press Enter (creates a new partition)
  4. +
  5. Press P and press Enter (makes a primary partition)
  6. +
  7. Then press 1 and press Enter (creates it as the 1st partition)
  8. +
  9. Finally, press W (this will write any changes to disk)
  10. +
+

然后,执行 sudo mkfs.ext4 /dev/sdb1

+

现在新硬盘就已经被分区并格式化了。接下来让系统在启动的时候自动挂载它,执行 sudo gnome-disks 打开一个 GUI 界面。

+

img

+

选择刚才添加的那块硬盘,点击配置按钮,选择目标挂载点,并点击 OK 即可。

+

img

+

需要注意的是,目前硬盘是只有读权限的,使用以下命令来给用户赋予读写权限:

+
$ cd /mount/point
$ sudo chmod -R -v 777 *
$ sudo chown -R -v username:username *
+ +

Input method setup

https://pinyin.sogou.com/linux/ 下载合适的输入法包,并安装之。然后从 Settings -> Language Support 中将 Keyboard input method system 从 iBus 切换为 fcitx(有可能会遇到语言包安装不完全的情况,输入 sudo apt-get install -f 可以修复),然后重启。

+

重启后,右键桌面右上角的 fcitx 图标,选择 ConfigureFcitx,点击 + 号添加输入法,去掉 Only show current language 的勾,然后输入 sogou 搜索即可看到安装好的搜狗输入法。添加即可。

+

Email setup

因为我的公司邮箱是用的 Ms Exchange,所以设置步骤很简单:

+
    +
  1. Ubuntu 自带 Mozilla Thunderbird 邮件客户端,直接用这个就行了。
  2. +
  3. 它本身是不支持 Exchange 配置的,需要添加一个插件 ExQuilla for Microsoft Exchange 以支持。
  4. +
  5. 安装好插件后,从菜单栏的 Tools -> ExQuilla for Microsoft Exchange -> Add Microsoft Exchange Account 进入配置入口,然后就是正常的邮件配置了。
  6. +
+

Screenshot

以下安装截图工具 Shutter,并设置快捷键:

+
$ sudo add-apt-repository ppa:shutter/ppa
$ sudo apt-get update
$ sudo apt-get install shutter
+ +

打开 Settings -> Keyboard -> Shortcuts -> Custom Shortcuts,点击 + 添加,输入 Name (Shutter Select) Command (shutter -s),保存。然后点击刚才添加的项目,在快捷键那里按下 Ctrl + Alt + A 即可。

+

shutter-1

+

shutter-2

+

JDK setup

JDK 可以到 Oracle 网站下载,也可以通过 apt-get 安装 openjdk,以下是安装 openjdk 的过程:

+
$ sudo apt-get install openjdk-8-jdk
$ apt-cache search jdk
$ export JAVA_HOME=/usr/lib/jvm/java-8-openjdk
$ export PATH=$PATH:$JAVA_HOME/bin
+ +

注意 JAVA_HOME 的 folder 可能有所变化,注意使用实际目录。

+

Node.js setup

Node.js 不直接安装,而是选择使用 nvm 进行管理。

+
$ wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.33.8/install.sh | bash
$ export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" # This loads nvm
$ command -v nvm
+ +

使用方法:https://github.com/creationix/nvm#usage

+

MongoDB setup

这里其实参照官方文档就行了。

+
$ sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 2930ADAE8CAF5059EE73BB4B58712A2291FA4AD5
$ echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu xenial/mongodb-org/3.6 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-3.6.list
$ sudo apt-get update
$ sudo apt-get install -y mongodb-org
$ sudo service mongod start
+ +

Change launcher position

Ubuntu 默认的 Launcher 设置在了屏幕的左边,但是如果有三屏的话,那用起来其实并不方便。可以通过一个简单的命令将其下置:

+
$ gsettings set com.canonical.Unity.Launcher launcher-position Bottom
+ +

这样 Launcher 就到了屏幕下方了,就像 Windows 默认的任务栏一样。Ubuntu 会记住这个设定,所以下次登录时也无需重新输入。

+

Enable workspace

Ubuntu 16.04 默认关闭了 Workspace (即类似 OSX 的全屏切换功能),其实挺好用的。可以手动开启:

+
Settings -> Appearance -> Behavior -> Enable workspaces
+ +

enable-workspace

+

如果有配置双屏的话,一般会想固定副屏的内容,只需在副屏标题栏右键,选择 Always on Visible Workspace 即可。

+

always-on-visible-workspace

+

默认的切换屏幕快捷键是 Ctrl + Alt + Arrow,跟 Intellij 的快捷键冲突了,并且与 OSX 上的不一致。可以手动修复:

+
Settings -> Keyboard -> Shortcuts -> Navigation
+ +

找到 Switch workspace to left / right / up / down, 各自改成相应的 Ctrl + Arrow 即可。

+

Other apps

+

Linux 下可玩的 Steam 游戏还是挺多的。玩 DOTA2 感觉跟 Windows 也没什么差别。

+]]>
+ + linux + +
+ + MEAN.JS 搭配 forever 使用以防止 app crash + /2016/mean-js-use-forever-to-prevent-app-crash/ + MEANJS 预设的 Grunt task 中没有提供类似出错自动重启的任务,因此当实际使用它搭建了一个 app 部署到服务器上后发现经常有一些奇怪的问题导致其崩溃挂掉。然而根据 log 来看问题应该不是由于项目代码导致的,可能是 MEANJS 本身的问题,也可能是某些 Lib 的问题。这种情况下,我能想到的暂时性解决方案就是使用 forever 了。

+

个人觉得 MEANJS 在 production mode 中也使用 nodemon 来跑 watch 任务有些鸡肋,因为我们并不需要在产品服务器上频繁地更改代码。因此,我直接把它替换掉了。

+ + +

这里需要注意的是,我们不能直接用 forever 去跑 server.js 脚本,因为这样的话下层代码拿不到 env settings,就会把启动模式设置为默认的开发模式。

+

因为 MEANJS 中已经自带了 forever 模块,所以就不用装它本身了,但是要安装 forever 的 grunt 插件:grunt-forever

+
npm install grunt-forever -save
+ +

在 tasks(initConfig) 中加多一项:

+
forever: {
server: {
options: {
index: 'server.js',
logFile: 'log.log',
outFile: 'out.log',
errFile: 'err.log'
}
}
}
+ +

这里指定了 forever 执行的对象,以及 log 文件名,路径可以不指定,默认为项目根目录下的 forever 文件夹。因为这个插件生成的是守护进程,所以 log 只能输出到文件啦。

+

最后更改一下 prod task:

+
// Run the project in production mode
grunt.registerTask('prod', ['build', 'env:prod', 'mkdir:upload', 'copy:localConfig', 'forever:server:start']);
+ +

OK,大功告成。

+

启动 production 服务器方式:

+
grunt prod
+ +

重启方式:

+
grunt forever:server:restart
+ +

停止服务器:

+
grunt forever:server:stop
+ +

 

+]]>
+ + angularjs + nodejs + +
+ + MEAN.JS 在 0.5 版本下发现的 NG-REPEAT 闪动问题 + /2017/meanjs-5-x-ng-repeat-flashing/ + 如题,经过长期痛苦的观察以及 debug 过程,以下原因被一一排除:

+
    +
  • 浏览器差异问题
  • +
  • 数据更新问题
  • +
  • ng-repeat 没有添加 track by key 导致的性能问题
  • +
  • Angular 版本问题
  • +
  • MEAN.js 架构问题
  • +
+

实际原因却是因为 MEAN.js 在全局引入了 ngAnimate 依赖。(也算是一个架构问题?)

+

因此解决办法:

+
    +
  • 要么将全局依赖去掉,改为各自添加依赖
  • +
  • 要么使用 transition: none !important
  • +
+]]>
+ + angularjs + nodejs + +
+ + 克罗恩病患病与治疗记录 + /2019/my-crohns-disease-and-treatment-records/ + +

克罗恩病是一种原因不明的肠道炎症性疾病,在胃肠道的任何部位均可发生,但好发于末端回肠和右半结肠。本病和慢性非特异性溃疡性结肠炎两者统称为炎症性肠病(IBD)。本病临床表现为腹痛、腹泻、肠梗阻,伴有发热、营养障碍等肠外表现。病程多迁延,反复发作,不易根治。本病又称局限性肠炎、局限性回肠炎、节段性肠炎和肉芽肿性肠炎。目前尚无根治的方法,许多病人出现并发症,需手术治疗,而术后复发率很高。本病的复发率与病变范围、病症侵袭的强弱、病程的延长、年龄的增长等因素有关,死亡率也随之增高。

+ +

现在是 2019 年 10 月,大约是我患克罗恩病(CD)的第 10 个年头。我写这篇记录的目的是记录自己的治疗过程,同时也为他人提供参考。

+ + +

患病经历

大约在 10 年前,我高二在读,那时候第一次出现以肠胃为主的身体不适,主要表现为发热、腹泻、腹胀、腹痛,但是由于缺乏经验与警觉,以为是普通的肠胃炎,并没有引起重视。秉着能拖就拖的精神,可能持续了一到两个月,拖到实在是受不了了,就回家进行了第一次就医、住院。诊治医院为广东省四会市万隆医院(二甲),做了胃镜、肠镜,诊断为胃溃疡+肠溃疡(年代久远,记不太清了,大概是这样)。静脉注射了一种我记得叫雷尼替丁的药物,辅以口服抗生素,经过约 1~2 周的治疗,病情好转,大便成形,即转出院。

+

之后经过了较长一段时间的缓解期,期间几乎与常人无异,直到高三末期,炎症复发。

+

那段时间的印象比较深刻,也是因为症状达到了一种相当严重的地步,午后(大概六点过后)高热不退,频繁腹泻(几乎每节课下课后我都必须在第一时间上厕所),腹痛等等。但由于临近高考,一直没有请假治疗。直到高考前一个月左右,我实在是无以为继了,于是请假,继续来到上述医院住院诊治。这次医生排除了肿瘤、结核等疾病,最后诊断为慢性溃疡性结肠炎(UC),治疗方式大致同上,同样在经过约 1~2 周的治疗以后,病情好转,大便成形,即转出院。出院后我依然在遵医嘱口服大量抗生素,书桌里几乎塞满了药物。持续到高考结束。

+

但是这一次治疗以后状态的持续明显没有第一次那么久,症状很快又回来了。

+

大学四年时期是我病症拖延最严重的时期,午后发热、频繁腹泻腹痛,期间也经历了一次住院,但由于个人原因产生了一些消极情绪,不配合检查,诊断也没有进展,依旧按照之前的方法在治疗。然而这一次治疗效果持续时间则更短,让我感到极其负面,也逐渐开始拒绝就医,觉得医生也没办法治好我。开始自作主张服用先前医生开过的抗生素,自然是毫无疗效,让我越发沮丧。四年期间病情大约一半时间处于缓解期,另一半则处于活动期,断断续续,时好时坏。苦不堪言。即使在这种时候,我依然觉得我得的只是普通的慢性肠炎,丝毫没有意识到即使是溃疡性结肠炎(UC)其实也是一种严重的终生不愈的疾病,况且我还不是 UC。

+

可以预见,这种盲目乐观所带来的惨痛代价即将到来。

+

第一次手术

2015 年 9 月某日,我突然觉得屁股开始疼痛,这股疼痛极速发展,很快便到了让人坐立不安的程度。期间我慢慢地感受到肛周某处有一肿块,开始是坐着有点疼,后来是坐着疼走路也疼,最后发展到即使躺着也疼。期间某一天忍无可忍去中大五院看了急诊,急诊医生诊断为肛周脓肿,建议手术。但由于我感到害怕,要求先进行保守治疗试试,于是就打了几天的静脉消炎滴注,然而并没有什么卵用,脓肿依然极速发展,但仍然没有引起我的重视,我总抱着一种它会自愈的天真想法,国庆期间甚至还带病回了趟家。直到假期结束,脓肿发展到让我即使躺着不动也大汗淋漓的时候,我才意识到问题的严重性:手术治疗已经迫在眉睫。由于三甲医院需要等床位,我一刻都等不了了,便找了一家莆田医院。当天的情况时至今日我依然记忆犹新:公交下车走到医院几百米的距离,几乎是要了我的命。

+

外科医生为我做了根治术,即脓肿切除+挂线,一共挂了三根橡皮筋,愈合过程大概持续了两个月,期间苦不堪言。

+

由于愈合时间持续过长,让我感到情绪低落,大概在两个月后我因此又住进了中大五院肛肠外科,也是在这个时期内第一次有医生提到了,我可能是患了克罗恩病(CD)。

+

此时,距我症状初现的时候已经过去了大约六年。

+

我和家属迅速地查阅了 CD 的相关资料,不料其症状竟如此亲切。住院期间在医生建议下进行了 CT 检查,随即确诊为 CD。外科医生开立口服美沙拉秦治疗方案,剂量高达 12 片一天 (6.0g/d)。

+

这个剂量的美沙拉秦我总共服用了大概有两年多,中间有一段时间因为自我感觉良好以及美沙拉秦费用高昂,擅自决定停药长达约一年,后来在 18 年的时候症状复发,在医生建议下做了一次肠镜,发现肠道已呈鹅卵石状,并有狭窄,只能进镜 25cm,便又开始服用。期间一直是找的胃肠外科医生诊治。状况时好时坏,偶尔会出现一些体外症状,如发热,虹膜炎,关节疼痛,牙齿松动,肛周疼痛等,但会自愈,所以也没有太注意。

+

第二次手术

18 年底开始的一年多时间内我大部分时间都处于无症状期,让我再一次误以为我的疾病已经被控制住了。这次我没有停药,坚持服用美沙拉秦,但开始考虑减少剂量。

+

直到 2019 年 8 月,我因持续午后发热与肛周脓肿复发不愈,再次住院手术,此后我才对外科医生的诊治产生了怀疑,开始更深入地学习克罗恩病,发现此病应该看消化内科,外科只能作为内科用药控制失败的辅助治疗。说来也可笑,后来内科医生得知我是克罗恩病时当即就提出了一个疑问,说我为什么一直在外科看病。我也是觉得奇怪,因为我不知道啊,外科医生也没有建议我转诊到内科啊?倒是第二次肛周脓肿手术住院时期,有一个从外地来进修的医生详细询问我的病症后提示我不应该看外科,应该去看免疫科。

+

说来也是奇怪,在手术前一天我还是持续午后发热状态,手术后第一天开始直到一个月后我痊愈上班,期间都再没有发热过。而上班第一天我又开始了午后发热,这立即让我产生警觉。我开始尝试排除一些因素,如中午休息、饮用水、三餐等。后来发现,只要我不喝公司的饮用水,我就不会发热(公司的饮用水是直饮水,可能存在某些不得而知的问题)。后来再经过观察,公司的水如果烧开再引用,也没有问题。

+

第二次手术后我开始变得警觉起来,会观察各种食物对我产生的影响,避免会导致问题的食物。在这期间我开始觉得饮食确实非常重要,可以对疾病控制起到至关重要的作用。

+

在消化内科看完后,发现内科医生与外科医生有许多说法不一致的地方,用药也是天差地别,更是提出我一直在坚持服用 5-ASA 类药物(美沙拉秦,柳氮氨磺吡啶)对此病几乎毫无作用,这才知道之前我到底走了多少弯路,不由感叹。内科医生提示我病情已较严重,建议使用生物制剂治疗(类克),费用高昂但疗效立竿见影,并当即要求住院检查诊治。也就是在这个时间点,我写下了这篇记录。

+

以后的治疗进展,我会持续更新。

+

2019/10/21

在中大五院住院 8 天。

+

住院期间做了肠镜、胃镜、CTE(小肠造影)、MRI(盆腔),肠镜病理没有取到诊断依据,由于 CTE 显示小肠(除回肠末端外)无异常,胃镜大部分正常,让医生的诊断陷入了难处:无法确诊是 UC 还是 CD,经过 MDT 会诊后,主任医生建议:全肠内营养,复查胸部 CT,排除结核后开始用激素治疗。由于我目前处于缓解期,暂时不想接受进一步检查和治疗,医生也表示理解、同意,所以就出院了。

+

从检查结果上看,目前存在的问题是:

+
    +
  1. 部分结肠存在结节样隆起(鹅卵石),导致部分肠腔狭窄,并且已纤维化
  2. +
  3. 肛瘘(1 个内口,1 个外口)
  4. +
+

可能导致的严重结果:

+
    +
  1. 肠梗阻
  2. +
  3. 肠穿孔
  4. +
+

除此以外倒还好。住院期间医生也建议类克,不过我觉得还是等活动期再打吧,毕竟现在打也不好评估疗效。至于肠内营养,每个医生的建议都有区别,有建议全肠内营养的,有建议肠内营养为主的,有建议日常饮食为主的,我目前开始吃安素作为辅助营养,希望能有帮助。

+

2019/12/25

在中大六院住院 7 天。

+

做了肠镜、胃镜、MRE,以及一堆抽血项目,检查结果和上次差不多。由于存在狭窄不能用类克,另外由于营养不良用激素效果预计也不好,所以医生建议禁食+鼻饲三个月后再复查,说是鼻饲的疗效与激素相当,有可能可以让炎症愈合以及让炎性狭窄缓解,于是就插管了。每天 4 瓶百普力 + 12 勺安素,三个月。虽然难受但是也没办法。

+

六院有许多同病相怜的病友,我也从中获得了一些帮助与鼓励。

+

2020/04/07

肠内营养两周后复查了血常规,结果显示 CRP 12.08mg/L,但是血小板达到了 500 多,ESR 依然高位 69 mm/h,血红蛋白有所好转,结果有所好转,但不是很理想。

+

四周后复查了肠道彩超,报告总结:

+
+

6组小肠、回肠末段、升结肠、横结肠、降结肠多发肠壁增厚,血供不丰富,考虑炎症不活动,请结合临床。

+
+

这个报告看起来还不错。

+

三个月后住院大复查,CRP 30+,ESR 40+,血小板接近正常值,血红蛋白等回到正常值。

+

肠镜结果并不理想。因为结肠有多处狭窄,乙状结肠的稍有好转,肠镜能通过了,但降结肠的狭窄肠镜依然无法通过。但医生说炎症有所好转,直观的表现就是以往肠镜报告见得最多的字眼是「充血水肿」,这次没有了,改为「粘膜粗糙」。我猜可能是愈合的表现?

+

CTE 结果同样不理想。提示病变较前区别不大。

+

虽然结果在我看来不太好,但医生说从炎症角度来看好转还是明显的。根据检查结果,医生经过慎重考虑,并且与胡品津教授商定后,建议我做外科手术,把结肠整个切除,造口一段时间,然后把小肠与直肠接上。医生的考虑是我的小肠是好的,所以希望通过完全切除病变部位来达到一个较好的短期效果,同时避免因为病变肠道可能带来的风险:比如说由于狭窄而导致的梗阻,以及狭窄前端肠壁变薄导致的扩张穿孔,以及直肠病变控制不好导致不得不切除直肠,需要终生造口等。

+

但说实话这个结果对我打击很大,思考了很久,也与家人商量了,我们还是不愿意接受手术,希望先维持保守治疗,不想这么快放弃病变肠道。我完全相信医生给的方案是综合各种因素权衡利弊以后给出的最佳方案,医生的目的就是让病人尽快恢复正常生活,可以正常饮食。但是我作为病人我有自己的考虑,我想要的是长期利益。只要还没有到最后关头,我不愿意就这么放弃了。

+

更何况这个病不是切一次就能痊愈的。我不是没见过手术做了十几次肠子已经切无可切的病友。

+

好在医生最后也同意我保守治疗,但是代价就是需要继续严格鼻饲三个月。因此我在定方案以后更换了一条鼻饲管,并在当天(03/18)接受了第一次类克治疗(300mg),第二天就出院了。

+

在注射类克以后的第二天起,我的身体体征就有大幅改善,在注射类克之前平均每天约 4-5 次水便,便量较多。注射以后平均每天 1-2 次稀便,同时大便总量有大幅减少,大概只有以前的 1/4,或者更少。同时肛瘘部位也安静了,不肿不痛不痒不流脓。

+

两周以后(04/01),我在中大五院接受了第二次类克治疗。这一次抽血复查结果有大幅改善。CRP 与 ESR 都已接近正常值,其它血液指标也较好,可以说是我四五年来做过的最好的一次血常规。

+

各种指征是好了很多,但是我现在最担心的就是第四次类克前的复查,万一结果还是不好,那我还是避免不了要考虑手术的事。

+

2020/05/21

在 4 月 29 日接受了第三次类克治疗,各项指标基本正常。

+

5 月份开始没有继续用百普力,换用了较为便宜的瑞素。没想到瑞素可能更适合我,自从用瑞素开始大便就变成了两天一次,而且能成型。经过 20 多天以后,现在大便稳定一天一次。

+

现在就等第四次类克复查了。

+

2020/06/22

6 月 15 (周一)号到中六住院,做了检查(结肠镜、CTE),结果意外地好。于是 18 号(周四)就拔了鼻饲管,打了第四次类克,出院了。

+

这一次的肠镜结果:

+
+

插镜情况: 进镜约65cm顺利达回肠末段。
回肠末段:所见黏膜未见异常。
回 盲 瓣: 所见黏膜未见异常,回盲瓣呈唇状。
阑尾内口: 阑尾口呈弧形。
盲 肠:所见黏膜未见异常。
升 结 肠:所见黏膜未见异常。
结肠肝曲:所见黏膜未见异常。
横 结 肠-乙状结肠:距肛缘约27-55cm见散在直径约0.3-0.6cm结节样增生及疤痕改变,距肛缘约37cm及30cm分别感肠腔狭窄稍固定,直径约10.5mm内镜可勉强通过。
直 肠: 所见黏膜未见异常。
肛  门: 未见异常。

+
+

CTE 结果:

+
+

克罗恩病复查:回肠末段、盲肠、升结肠、横结肠、降结肠多节段病变伴结肠部分狭窄,病变范围较前缩小,炎症程度较前好转,提示缓解期。

+
+

虽然狭窄依然存在,但是黏膜完全愈合,是这么多年来最正常的一次了。因此医生没有建议立刻进一步的治疗,而是说过一段时间再看看需不需要做内镜扩张。另外,主任认为类克对我效果好,建议加依木兰加强疗效。但是这一次出院暂时还不用。说下次门诊再去评估。

+

医生说我可以半肠内营养(也就是可以吃点东西)了。不过我现在并不想立即就恢复饮食。先吃一段时间安素看看吧。毕竟这 6 个月得来的成果,可以说来之不易。

+

2020/10/26

如今我已经打完了 6 次类克,将在 11 月底进行第七次注射,一个疗程也快走完了。

+

6 月份出院以后大约吃了一个月左右的全肠内(安素),然后逐渐转为半肠内 + SCD 食物,到今天已经基本就是 SCD 饮食了。期间没有健康状况没有出过大问题,基本保持在 6 月份的水准:

+
    +
  1. 血液指标基本能够维持在正常范围内;
  2. +
  3. 炎症指标(CRP、血沉)无异常;
  4. +
  5. 体重有所降低,目前大约在 55~56 公斤左右。
  6. +
+

虽然医生一直在建议我加药(硫唑嘌呤),但是我和我的家人都不是很想加。主要原因是它的副作用太大了,尤其是与类克并用的时候,说是毒药也不为过。我们有自信通过食疗来控制病情,因此暂时不想借助这个药物。

+

另外,值得一提的一点是,我以前基本都是腹泻,拉得太多得时候会觉得难受,现在经过了两次便秘以后,我觉得便秘才是真的让人难受。

+

2021/01/22

01/16 打完第 8 次类克。目前仍在 SCD 中,维持良好状态,血检无异常。第 9 次类克需要做大检查,肠镜+影像学。目前虽然体感良好,但仍然感到害怕。

+

2021/09/29

08/22 打了第 12 次类克。目前仍在 SCD 中。

+

状态有起伏,偶尔会出现不舒服(眼睛炎症、鼻炎等),CRP 和血沉偶尔会有小幅度的升高,但总的来说肠胃大部分时间依然维持无症状,偶尔有不舒服也很快可以恢复。猜测是一是增加的食物多了,偶尔会有质量不好的批次。二是从小就有空调过敏症,眼睛和鼻子应该和空调也有关。

+

2022/05/20

很长一段时间没有更新了,前几天刚打完第 17 次类克,目前仍在 SCD 中(第四阶段)。

+

没有更新的话,基本就代表这段时间非常稳定,没什么变化。没有出现过长时间的明显不适。

+]]>
+ + personal + +
+ + 临近端午 + /2014/nearing-the-duan-wu/ + 很久没有回过家,也没有关心过家里的情况了。今天和妈妈说了几句话,得知最近有发烧生病,虽然说已经好了,但还是觉得不知道怎么的。五十多岁了,一个人在广州上班,没亲没戚的,生了病也没哪个照顾,也不跟我说。婆婆去世以后我就应该要照顾我妈了,去年在赣州大姨也跟我说过,可惜还没有毕业,分身乏术。唉,真是忧伤。真希望你退休了吧,别干了。

+

天气变得很热很闷,情绪也变得特别容易坏,很可能因为一些琐碎事情发脾气,像今天早上,本来不应该发生这样的事的,我应该更关心你一些,而不是独自生闷气。现在想来确实后悔,不过微信是真的没有必要了。

+

这学期过的很快,感觉是大学这么久以来最快的了吧,已经快十四周了,又要结束了。过得快的原因大概有几方面,一直很忙,基本没停过,到现在也是很多事情在做,都接近尾声但又没有结束,所以有时候会觉得很多事情要做但又不知道要做什么。遗憾的是还没有找到实习,暑假仍没有着落。这学期和web有关的东西做的比较多,不过我不太希望这是最终的方向。

+

找了一个女朋友,很喜欢,希望可以有多远走多远。

+]]>
+ + personal + +
+ + Node.js Web Spider Note - 1 + /2016/node-js-web-spider-note-1/ + 项目地址:https://github.com/wxsms/zhihu-spider

+

简介:使用 Node.js 实现的一个简单的知乎爬虫,可以以一个用户为入口,爬取其账号下的一些基本信息,关注者,关注话题等。再通过关注者的 ID 继续爬取其他用户,以此循环。

+

实现功能:登录知乎(因为调用一些知乎 API 需要保存 session),解析页面,访问 AJAX API,保存到数据库。

+ + +

执行流程

+

蓝色部分的任一流程出现失败或错误,程序都会直接返回到“从种子队列取出一个用户ID”这一步。因为作为一个完整的知乎用户来说,它应该是包含了个人信息,关注以及话题的,缺失一项会导致其失去很大部分的意义。

+

技术栈

本程序以 Node.js 为实现核心(本机版本 v6.5.0),用到的依赖很少,如下:

+
"dependencies": {
"asyncawait": "^1.0.6", //模拟ES7 async/await语法
"jquery": "^3.1.0", //用来解析HTML
"jsdom": "^3.1.2", //为HTML和jQuery模拟浏览器环境
"lodash": "^4.15.0",
"log4js": "^0.6.38",
"mocha": "^3.0.2", //用于单元测试
"mongoose": "^4.6.0",
"superagent": "^2.2.0", //请求发送
"superagent-promise": "^1.1.0" //请求发送的Promise封装
}
+ +

程序解析

程序源代码在代码仓库的 src 目录下,目录结构:

+
├─config
├─constants
├─http
├─model
├─parsers
│ ├─follow
│ ├─topic
│ └─user
├─services
├─spiders
└─utils
+ +

配置相关以及通用模块

config 目录主要放置一些程序的配置文件,比如用来登录知乎的用户名和密码,想要抓取的用户种子,数据库连接地址,以及 log 的配置。

+

constants 目录下是一些对知乎的特定配置,如 url 地址、规则,以及知乎 API 的一些信息,如表单、请求头格式等。

+

util 目录下目前是放置了一些数据库以及 log4js 的初始化方法,如自动扫描 models 加载以及创建 log 目录等,以便于在程序入口处调用。

+

models 则是 mongoose 的各种 schema了,用于持久化。

+

登录模块

登录虽然不是爬虫的重点,但却是必不可少的前提。因为对于知乎网站来说,不登录的话只能看到单个用户的个人页面,想要再前往关注者页面就不可能了。这就造成一个问题:爬虫无法持续工作。

+

因此,此模块的主要职责是,在爬虫运行的过程当中保证已登录状态。

+

此模块放置在 http/session 中。

+

爬虫模块

程序核心之一,同时也是最容易出现问题的地方(尤其是启用多线程以后),负责发送 Http 请求并接受响应。

+

除了简单的单次请求以外,因为一些特定的原因,里面还涉及到了递归请求。

+

转换模块

程序核心之一,负责将爬取回来的 HTML 文本、API 返回体等转换成 model 对象,没什么技术含量,体力活。

+

Service

其实就是将单次爬取的整个流程定义封装好,供主程序调用。

+

同时也负责爬取结果的储存,以及用户种子队列的管理。

+

重点难点

模拟登录

这次经历让我意识到什么都靠 Google 也有不行的时候,因为爬虫这东西,虽然肯定也有别人做过,但是基本上都过时了,人家网站早更新了,真刀真枪还是得靠自己。

+

整个过程虽然简单,但是由于经验匮乏,还是走了不少弯路。最终总结出来的必须步骤如下:

+

+

代码如下:

+
function login(_user) {
user = _user;
return new Promise((resolve, reject) => {
getCaptcha()
.then(resolveCaptcha)
.then(getLoginCookie)
.then(_getXsrfToken)
.then(() => {
logger.info('Login success!');
resolve();
})
.catch((err) => {
reject('Login failed: ' + err);
})
})
}
+ +

(目前并没有在所有地方都用上 async await,之后改过来)

+

获取验证码很简单,但是要注意的是把 response 里面的 set-cookie 信息保存起来添加到一会要登录的请求上去,因为不这么做的话,知乎服务器不认为登录那一次请求跟这个验证码有什么关系。

+

“解析验证码”这一步目前是这个程序最难看(难看,不是难)的地方,因为用的是土法炼钢:人眼解析。我尝试过用一些通用的验证码识别库去做自动化,但是正确率太低,而且知乎它的验证码有随机两套字体,反正好像训练起来也麻烦,所以就没继续研究下去了,毕竟不是爬虫主体,而且只需要在程序启动的时候输入一次即可。

+

登录请求的模拟,可以在知乎网站上使用浏览器的开发控制台启用“任意 XHR 断点”来截获网站发送的真实登录请求以及服务器返回来进行伪造。实际上就是填一个表单 POST 出去,然后返回的时候把 response 里面的 set-cookie 信息保存起来添加到以后要使用的所有请求的 header 上去就行了,因为服务器它就是靠这一堆 cookie 值来判断客户端所处的会话。

+

最后就是那个所谓的“秘钥”,网站上的名字叫 xsrf ,知乎在这里做了一些手脚。它在 set-cookie 中并没有提供这一串秘钥,但是如果我要请求它的 API,那么 cookie 里面就必须有这个键值对,明显网站是通过 JS 动态加进去的。然而我没有必要这么做,只需要在一开始的时候就把它拿到,然后以后每次请求都带上它就可以了。至于怎么拿也很简单,知乎每个页面都有的隐藏的输入框,里面的值就是。

+
xsrfToken = $('input[name=_xsrf]').val();
+ +

这四步做完以后,程序就成功登录了。

+

获取关注

去到知乎的关注者、已关注页面可以发现,内容是随着页面滚动逐步加载的,因此这里存在一个 API 可以使用,无需爬页面。使用浏览器开发者控制台,我们可以截取到 API 的详细信息,以下是我总结的一个:

+
userFollowers: {
url: () => 'https://www.zhihu.com/node/ProfileFollowersListV2',
pageSize: () => 20,
form: (hashId, offset) => {
offset = typeof offset === 'undefined' ? 0 : offset;
return {
method: 'next',
params: `{"offset":${offset},"order_by":"created","hash_id":"${hashId}"}`
}
},
header: (userName, token) => {
return {
'X-Xsrftoken': token,
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
Referer: zhihu.url.userFollowers(userName)
}
}
}
+ +

form 是 API 需要发送的表单,而 header 则是这次请求额外需要的 header(指包括登录获取的 header)。

+

由于它这个是按 page 来的,每次请求最多只会返回 20 条记录,到底了就会返回空数组,因此我做了一个 Promise 递归来实现这个功能。另外,由于有的用户关注加起来上万条,全部遍历完实在是太慢(不知道会不会一去不回),所以我把抓取的总数限制在了 100 条。至于总数的这个数量,在其它地方可以获取到,不需要通过这里获取,所以无所谓。

+
function resolveByPage(user, offset, apiObj) {
let header = Object.assign(session.getHttpHeader(), apiObj.header(user.id, session.getXsrfToken()));
let form = apiObj.form(user.hashId, offset);
//No more than 100 (too slow)
if (offset + apiObj.pageSize() > 100) {
return Promise.resolve([]);
}
return superagent
.post(apiObj.url())
.set(header)
.send(form)
.end()
.then((res) => {
let data = parser.fromJson(res.text);
if (!data.list.length) {
return [];
}
return resolveByPage(user, offset + apiObj.pageSize(), apiObj)
.then((nextList) => {
return [].concat(data.list, nextList);
})
.catch((e) => {
return [];
});
})
}
+ +

持续工作

爬虫如何持续工作这个问题一开始我是挺头疼的,就是说当一次任务结束以后,要如何自动开始下一次任务。由于全是异步操作,直接 while 1 肯定要炸。

+

后来看到 async await 语法,终于写出了一个可工作的版本。(Node.js v6.5.0 还没有原生支持 async await,所以用到了一个库)

+
let next = async(function (threadId) {
try {
let userId = await(userQueueService.shift());
logger.info(`Thread ${threadId} working on user ${userId}`);
let user = await(userService.resolveAndSave(userId));
await(userQueueService.unshiftAll([].concat(user.followers_sample, user.followees_sample)));
} catch (err) {
if (err.name === 'MongoError') {
err = err.message;
}
logger.error(err);
}
});

let thread = async(function (id) {
while (1) {
await(next(id));
}
});
+ +

程序执行到 await 关键字的地方就会阻塞,直到语句返回再继续。

+

至于多线程,直接简单暴力:

+
let main = async(function () {
await(userService.login());
for (let i = 0; i < 5; i++) {
thread(i)
}
});
+ +

这样,执行 main 方法,程序就有 5 个线程同时工作了。

+

目前的问题

效率

5 条线程还是太慢,一小时大约能抓 1000 个用户的样子,但是我又不能再多开,再开线程请求数就爆了,各种报错、失败,得不偿失。

+

我在想是不是能再开几个 Node.js 进程同时跑这个程序。质量不行数量来补。

+

09/12/2016 更新:这个办法不行。请求数限制是在服务器端做出的。貌似无解。

+

稳定性

这个是目前很头疼的问题。我发现程序在跑一个小时或者两个小时以后,5 条线程就只剩下一条或者两条还在工作,其它的都失踪了,或者干脆全都死在那里了。其实我知道它们没死,只不过不知道为什么卡住了。

+

第一次发现的时候觉得有点逗,感觉就像自己生了五个孩子后来死剩两个一样。

+

09/12/2016 更新:目前发现了一个原因,即有些知乎用户的个人主页被屏蔽了,导致解析失败,但是又没有 catch 导致线程无限挂住。解决这个以后问题依然存在,高度怀疑是因为 session 过期。

+

09/14/2016 更新:果然是 session 过期的原因。在登录的表单中加入一个字段 remember_me: true 以后,线程死掉的问题就解决了!Excited!

+]]>
+ + nodejs + +
+ + Node.js Web Spider Note - 2 + /2016/node-js-web-spider-note-2/ + Cookie & Session

HTTP 是一种无状态协议,服务器与客户端之间储存状态信息主要靠 Session,但是,Session 在浏览器关闭后就会失效,再次开启先前所储存的状态都会丢失,因此还需要借助 Cookie

+

一般来说,网络爬虫不是浏览器,因此,只能靠手动记住 Cookie 来与服务器“保持联系”。

+

Cookie 是 HTTP 协议的一部分,处理流程为:

+
    +
  • 服务器向客户端发送 cookie
      +
    • 通常使用 HTTP 协议规定的 set-cookie 头操作
    • +
    • 规范规定 cookie 的格式为 name = value 格式,且必须包含这部分
    • +
    +
  • +
  • 浏览器将 cookie 保存
  • +
  • 每次请求浏览器都会将 cookie 发向服务器
  • +
+

因此,爬虫要做的工作就是模拟浏览器,识别服务端发来的 Cookie 并保存,之后每次请求都带上 Cookie 头。

+

在 Node.js 中有很多与 Cookie 处理相关的 package,就不再赘述。

+

Session

Cookie 虽然方便,但是由于保存在客户端,可保存的长度有限,且可以被伪造。因此,为了解决这些问题,就有了 Session

+

区别:

+
    +
  • Cookie 保存在客户端
  • +
  • Session 保存在服务端
  • +
+

Cookie 与 Session 储存的都是客户端与服务器之间的会话状态信息,它们之间主要靠一个秘钥来进行匹配,称之为 SESSION_ID ,如 express 中默认为 connect.sid 字段。只要浏览器发出的 SESSION_ID 与服务器储存的字段匹配上,那么服务器就将其认作为一个 Session,只要 SESSION_ID 的长度足够大,几乎是不可能被伪造的。因此,敏感信息储存在 Session 中要比 Cookie 安全得多。

+

常见的 Session 存放媒介有:

+
    +
  • RAM
  • +
  • Database
  • +
  • Cache (e.g. Redis)
  • +
+

Session 不是爬虫可以接触到的东西。

+

AJAX 页面

对于静态页面(服务端渲染),使用爬虫不需要考虑太多,把页面抓取下来解析即可。但对于客户端渲染,尤其是前后端完全分离的网站,一般不能直接获取页面(甚至没有必要获取页面),而是转而分析其实际请求内容。

+

请求分析

通过一些请求拦截分析工具(如 Chrome 开发者工具)可以截获网站向服务器发送的所有请求以及相应的回复。

+

包括(不限于)以下信息:

+
    +
  • 请求地址
  • +
  • 请求方法(GET / POST 等)
  • +
  • 所带参数
  • +
  • 请求头
  • +
+

只要把信息尽数伪造,那么爬虫发出的请求照样可以从服务器取得正确的结果。

+

秘钥处理

一些请求中会带有秘钥(token / sid / secret),可能随除了请求方法外的任一个位置发出,也可能都带有秘钥。更可能不止一个秘钥。

+

理论上来说,正常客户端取得秘钥有两种方式:

+
    +
  • 服务端提供
  • +
  • 客户端自行计算,由服务端校对
  • +
+

对于服务端提供给客户端的秘钥,只要仔细分析 HTML 或服务端返回的 Cookie Header 就一定能发现。

+

而对于客户端自行计算的秘钥则比较麻烦了,尤其是在 JS 代码加密、混淆的情况下。这种时候,只能自己去用开发者工具调试原始站点代码,找出加密代码段,并在爬虫中实现。这里面有许多技巧,如各种断点、单步调试等。

+

表单处理

表单实际上也是 HTTP 请求,使用 GET / POST 等方法即可模拟表单提交。然而这不是重点。重点是表单常常伴随着验证码而存在。

+

验证码的识别暂未涉及。

+

浏览器模拟

爬虫的下下策才是使用浏览器完全模拟用户操作。实在是属于无奈之举。Nodejs 可以驱动 Chrome 与 Firefox 浏览器,存在相应的 Package,但是,更方便的是使用各种 E2E Testing 工具。

+

比如 Night Watch JS:

+
module.exports = {
'Demo test Google' : function (client) {
client
.url('http://www.google.com')
.waitForElementVisible('body', 1000)
.assert.title('Google')
.assert.visible('input[type=text]')
.setValue('input[type=text]', 'rembrandt van rijn')
.waitForElementVisible('button[name=btnG]', 1000)
.click('button[name=btnG]')
.pause(1000)
.assert.containsText('ol#rso li:first-child',
'Rembrandt - Wikipedia')
.end();
}
};
+ +

在这种模式下,Cookie / Session / 请求等各种细节都不用关心了。只需要按部就班地执行操作即可。模拟浏览器的代价是效率太低,内存开销大,但在某些特定需求情况下,却比一般爬虫要简单得多。

+

 

+]]>
+ + nodejs + +
+ + 最近状态不好 + /2012/not-feeling-good-recently/ + 开学两周以来身体真的不太好,感觉又和高三后期差不多,不过好在时间没有那么紧,每天可以抽一点两点时间出来锻炼,到现在也感觉有了那么一些好转。霍香正气丸吃了没有用,以后再也不买了。不敢去看医生,实在看不起,重点是看了也白看。

+

本来平常的话也没什么,坏在上学期挂了两科,明天就要补考去,可惜真的没有精力很认真的看书做题所以它要重修就重修吧,大四上多几节课我也不是很在乎。不过从今往后大概真的别挂科了。毕竟没有班长大人的魄力,我还是图样啊。现在饭堂卖的东西也比以前干净很多了,也有面条和粥,而且还很便宜,所以应该不会像以前那样困难,虽然饭吃了可能还是会有点问题。这两周以来嘴巴都感觉特别特别苦,会想喝汽水,会想吃雪糕,可是肚子又胀胀的,不太敢动那些。忌生冷烟酒辛辣油腻,知道的,奶不能喝,青菜少吃,什么什么的,都还记得,所以妈你不用担心我。我有分寸。你要多注意你自己。每次你跟我说晚上痛得睡不着都让我非常揪心。

+

晚上经常会小发烧,可能是炎症吧之前真没考虑到。今天好些,没有。所以最近都穿长袖示街。偶尔还是会感到冷和孤独,不过没有高中那么强烈。今天吃了一天宿舍菜,中间还被小吓一跳,那个新买的电磁炉水都没煲开就怒放两炮,然后随着一丝烧焦的味道就哑火了,还好后来又神奇复活以不至于没东西吃。吃完以后十分想念婆婆和大姨,我想吃炸茄子和葱条和腌菜艾米果…

+

希望可以好起来。

+]]>
+ + personal + +
+ + Node.js 包管理器发展史 + /2021/npm-history/ + 在没有包管理器之前

正确来说 Node.js 是不存在没有包管理器的时期的。从 A brief history of Node.js 里面可以看到,当 2009 年 Node.js 问世的时候 NPM 的雏形也发布了。当然因为 Node.js 跟前端绑得很死,这里主要谈一谈前端在没有包管理器的时期是怎样的。

+

那时候做得最多的事情就是:

+
    +
  1. 网上寻找各软件的官网,比如 jQuery;
  2. +
  3. 找到下载地址,下载 zip 包;
  4. +
  5. 解压,放到项目中一个叫 libs 的目录中;
  6. +
  7. 想更方便的话,直接将 CDN 链接粘贴到 HTML 中。
  8. +
+

四个字总结:刀耕火种。 模块化管理?版本号管理?依赖升级?不存在的。当然,那时候前端也没有那么复杂,这种模式勉强来说也不是不能用。

+ + + +

npm v1-v2

Npm-logo.svg_.png

+

+

2009 年,Node.js 诞生,npm(Node.js Package Manager)的雏形也正在酝酿。

+

2011 年,npm 发布了 1.0 版本。

+

初版 npm 带来的文件结构,是嵌套结构:

+

npm-history-0.png

+

一切都很美好,除了…

+

+

node_modules 堪比黑洞,图来自 https://github.com/tj/node-prune

+

node_modules 体积过大

显而易见的问题,如果一个库,比如 lodash,被不同的包依赖了,那么它就会被安装两次。这种形式的结构很快就能把磁盘占满。rm -rf node_modules 成为了前端程序员最常用的命令之一。

+

node_modules 嵌套层级过深

只有当找到一片不依赖任何第三方包的叶子时,这棵树才能走到尽头。因此 node_modules 的嵌套深度十分可怕。

+

具体到实际的问题,相信早期 npm 的 windows 用户都见过这个弹窗:

+

+

(node_modules 文件夹无法删除,因为超过了 windows 能处理的最大路径长度)

+

详情见 这个 issue

+

Yarn & npm v3

yarn-logo-F5E7A65FA2-seeklogo.com.png

+

+

2016 年,yarn 诞生了。yarn 解决了 npm 几个最为迫在眉睫的问题:

+
    +
  1. 安装太慢(加缓存、多线程)
  2. +
  3. 嵌套结构(扁平化)
  4. +
  5. 无依赖锁(yarn.lock)
  6. +
+

yarn 带来对的扁平化结构:

+

npm-history-yarn.png

+

扁平化后,实际需要安装的包数量大大减少,再加上 Yarn 首发的缓存机制,因此依赖的安装速度也得到了史诗级提升。

+

依赖锁

相比于扁平化结构,可以说 yarn 更大的贡献是发明了 yarn.lock。而 npm 在一年后的 v5 才跟上了脚步,发布了 package-lock.json。

+

在没有依赖锁的年代,即使没有改动任何一行代码,一次 npm install 带来的实际代码量变更很可能是非常巨大的。 因为 npm 采用 语义化版本 约定,简单来说,a.b.c 代表着:

+
    +
  1. a 主版本号:当你做了不兼容的 API 修改
  2. +
  3. b 次版本号:当你做了向下兼容的功能性新增
  4. +
  5. c 修订号:当你做了向下兼容的问题修正
  6. +
+

问题在于,这只是一个理想化的“约定”,具体到每个包有没有遵守,遵守得好不好,不是为我们所控的。 而默认情况下安装依赖时,得到的版本号是类似 ^1.0.0 这样的。这个语法代表着将安装主版本号为 1 的最新版本。

+

虽然可以通过去掉一级依赖的 ^ 指定精确版本,但是无法指定二级、三级依赖的精确版本号,因此安装依然存在非常大的不确定性。

+

npm-history-semver.png

+

因此,为了解决这个问题,Yarn 提出了“锁”的解决方案:精确地将版本号锁定在一个值,并且在安装时通过计算哈希值校验文件一致性,从而保证每次构建使用的依赖都是完全一致的。

+

一个 yarn.lock 文件示例片段:

+
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


"@babel/code-frame@7.12.11":
version "7.12.11"
resolved "https://registry.npmmirror.com/@babel/code-frame/download/@babel/code-frame-7.12.11.tgz?cache=0&sync_timestamp=1633553739126&other_urls=https%3A%2F%2Fregistry.npmmirror.com%2F%40babel%2Fcode-frame%2Fdownload%2F%40babel%2Fcode-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f"
integrity sha1-9K1DWqJj25NbjxDyxVLSP7cWpj8=
dependencies:
"@babel/highlight" "^7.10.4"

"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.14.5", "@babel/code-frame@^7.15.8":
version "7.15.8"
resolved "https://registry.npmmirror.com/@babel/code-frame/download/@babel/code-frame-7.15.8.tgz?cache=0&sync_timestamp=1633553739126&other_urls=https%3A%2F%2Fregistry.npmmirror.com%2F%40babel%2Fcode-frame%2Fdownload%2F%40babel%2Fcode-frame-7.15.8.tgz#45990c47adadb00c03677baa89221f7cc23d2503"
integrity sha1-RZkMR62tsAwDZ3uqiSIffMI9JQM=
dependencies:
"@babel/highlight" "^7.14.5"

// more...
+ +

“双胞胎陌生人”问题

这个词在英文中是 doppelgangers,意思是它们长得很像,但是除此以外又完全没有其它的关联。

+

+

想象一下有一个 library-a,它同时依赖了 library-b、c、d、e:

+

npm-history (1).png

+

而 b 和 c 依赖了 f@1.0.0,d 和 e 依赖了 f@2.0.0

+

npm-history.png

+

这时候,node_modules 树需要做出选择了,到底是将 f@1.0.0 还是 f@2.0.0 扁平化,然后将另一个放到嵌套的 node_modules 中?

+

答案是:具体做那种选择将是不确定的,取决于哪一个 f 出现得更靠前,靠前的那个将被扁平化。

+

举例,将 f@1.0.0 扁平化的结果:

+

npm-history-3.png

+

f@2.0.0 扁平化的结果:

+

npm-history-4.png

+

无论如何,这个选择必须做,我们必然会在 node_modules 中拥有多份的 library-f,窘境将是无法避免的。因此它们也就成为了“双胞胎陌生人”。

+

其它编程语言没有这种问题,这是 Node.js & npm 独有的。 这种问题会造成:

+
    +
  1. 安装更慢
  2. +
  3. 耗费的磁盘空间更大
  4. +
  5. 某些只能存在单例的库(比如 React 或 Vue)如果被同时安装了两份则会出现问题
  6. +
  7. 当使用依赖 f 使用了 TypeScript 时会造成 .d.ts 文件混乱,导致编译器报错
  8. +
  9. 假设 f 有一个依赖 g,项目里也存在 g 的“双胞胎陌生人”,那么根据 Node.js 的依赖查找原则(从当前目录逐级向上查找),两个 f 有可能会检索到不同版本的 g,这可能导致高度混乱的编译器错误。
  10. +
+

“幽灵依赖”问题

+

假设我们有以下依赖:

+
{
"name": "my-library",
"version": "1.0.0",
"main": "lib/index.js",
"dependencies": {
"minimatch": "^3.0.4"
},
"devDependencies": {
"rimraf": "^2.6.2"
}
}
+ +

理论上来说,我们项目的代码中可以使用的依赖只有 minimatch。但是实际上,以下代码也能运行:

+
var minimatch = require("minimatch")
var expand = require("brace-expansion"); // ???
var glob = require("glob") // ???

// ???
+ +

这是因为扁平化结构将一些没有直接依赖的包也提升到了 node_modules 的一级目录,但是 Node.js 并没有对其校验。所以引用它们也不会报错。

+

npm-history-ghost-deps.png

+

这种情况带来的问题:

+
    +
  1. 在没有显式指定“间接依赖”的版本号的时候,如果它被依赖到它的包做了大版本升级,存在不兼容的 API 变更,那么应用代码很可能就会跑不起来
  2. +
  3. 没有显式指定依赖带来的额外管理成本
  4. +
+

Workspace

Yarn 1.0 带来的另一个特性是 workspace,也是 monorepo 能够发展起来的一个重要原因。

+

假设我们有一个 workspace-a,它依赖了 cross-env:

+
{
"name": "package-a",
"version": "1.0.0",

"dependencies": {
"cross-env": "5.0.5"
}
}
+ +

还有一个 package-b,它依赖了 cross-env 和 package-a:

+
{
"name": "package-b",
"version": "1.0.0",

"dependencies": {
"cross-env": "5.0.5",
"workspace-a": "1.0.0"
}
}
+ +

那么这时候在使用 workspace 模式安装的话,将得到以下结构:

+

npm-history-workspace.png

+

其中,node_modules 中的 package-a 只是实际文件的链接。也就是说,Yarn workspace 模式可以将项目底下的子项目的依赖提升到根目录来进行扁平化安装,这样可以节省更多的磁盘空间,带来更快的安装效率,也可以使得项目管理更方便。

+

但是,结合上面所提到的两个问题,workspace 带来的问题只会更多,不会更少。这里就不详细展开了,应用级 Monorepo 优化方案 这篇文章总结得很好。

+

Lerna

lerna.png

+

由于 Workspace 的特性实在是太过好用,monorepo(multi-package repositories, multi-project repositories)开始迅速发展。许多知名的开源库开始转向 monorepo,还有更激进者将 monorepo 使用在业务项目中。Lerna 顺势而生。

+

但是,Lerna 并不是 Node.js 包管理器的一部分,也没有解决任何已存在的包管理器问题。它所做的只是将 monorepo 的使用体验变得更舒服了,比如:

+
    +
  1. 可以更方便地创建 monorepo
  2. +
  3. 可以更方便地管理 packages 中的依赖项
  4. +
  5. 可以一键发布 packages、自动根据 git commit log 更新每个 package 的 changelog
  6. +
  7. 等等
  8. +
+

仅此而已。按照官网的说法,Lerna 所做的事情是“优化了这个流程(optimizes the workflow)”。

+

pnpm

+

+

P for Performance —— 性能更强的 npm。

+

pnpm 复刻了 npm 的所有命令,同时在安装目录结构上做了大幅改进。

+

善用链接

这里通过一个例子来看 pnpm 的安装结构特点。

+

安装依赖

假设我们要安装一个 foo 包,它依赖了 bar。首先,pnpm 会先将所有直接和间接依赖安装进来,并“摊平”(注意,这里没有扁平化算法,是字面意义上的摊平):

+

npm-history-pnpm1.png

+

你可能注意到,在 xxx@1.0.0 的目录下面,首先是一个 node_modules 目录,然后才是 xxx,这么做的目的是:

+
    +
  1. 允许包引用自己
  2. +
  3. 将包自身和其依赖打平,避免循环结构。在 Node.js 中,这么做其实跟原本的样子并没有太大区别。
  4. +
+

处理间接依赖

然后,在 foo 的平级创建一个 bar 文件夹,链接至 bar@1.0.0 下面的 bar

+

npm-history-pnpm2.png

+

处理直接依赖

在顶层 node_modules 创建一个 foo 硬链接,连接至 foo@1.0.0 中的 foo,以供应用访问:

+

npm-history-pnpm3.png

+

处理更深层次的间接依赖

假设 foobar 都依赖了 qar@2.0.0

+

npm-history-pnpm4.png

+

可以看到,虽然依赖层级变深了,但是文件树并没有变深。这就是 pnpm 的特色结构:通过硬链接创造的依赖“树”。

+

性能对比

由于硬链接的巨大优势加成,在绝大多数情况下,pnpm 的安装速度都要比 yarn 和 npm 更快:

+

+

自动解决锁冲突

pnpm 能够自动解决锁文件的冲突。当冲突发生时,只需要运行一次 pnpm install,冲突就能自动由 pnpm 解决。很人性化。不过,据说 Yarn 从 1.0 版本开始也提供了类似的功能。

+

存在的问题

    +
  1. 并不是所有项目都能“无痛”迁移至 pnpm。由于历史原因(扁平化),我们的应用或者应用的某些依赖并没有很好地遵循“使用到的包必须在 package.json 中声明”这一原则,或者把它当作一项 feature 享受其中。这样的话迁移至 pnpm 会导致原本会被提升到顶层的扁平化依赖重新回到正确的位置,从而无法被找到。如果问题出在应用上,那么只需要将依赖写入 package.json 即可。但是如果出在依赖就比较棘手了。不过官方也提供了解决方案
  2. +
  3. 由于特殊的安装结构,以往一个很有用的打补丁工具 patch-package 用起来就不是那么顺手了。
  4. +
+

Rush

rush.png

+

Rush 是微软出品的一款 monorepo 管理工具。与 Lerna 不同的是:Rush 不仅做了许多“优化流程”的工作,还提供了一套与 pnpm 十分类似的硬链接目录结构方案来解决超大型项目中的依赖管理问题。

+

虽然它声称支持全部的三种包管理工具,但是:

+
    +
  1. 配合高版本 npm 使用时有 bug,只能使用 4.x 版本
  2. +
  3. 配合 Yarn 使用时无法启用 workspace,因为这会跟硬链接方案冲突
  4. +
  5. 只有在配合 pnpm 使用时才能解决“双胞胎陌生人”问题
  6. +
+

很显然,如果想要正常使用,pnpm 几乎是唯一选择。

+

这里简单列出一些 Rush 提供的特色功能:

+
    +
  1. 顺序构建:自动检测包的依赖关系,按照从下至上有序构建
  2. +
  3. 多进程构建:对于可以同时构建的包,开启多个 Node.js 进程同时构建
  4. +
  5. 增量构建:只对发生了变化的包,以及所有受影响的上游或下游包启动构建,支持缓存构建产物
  6. +
  7. 增量发布:自动检测需要发布的包并执行发布,甚至可以将发布任务设置为定时执行
  8. +
  9. 等等……
  10. +
+

+

总的来说,微软的一套理论是:企业的项目(不管是业务还是基础)都应该尽可能地放在一个超大型仓库中来管理。并且微软声称自己确实是这么做的(见 Rush: Why one big repo⁈ )。Rush 的目的也是为了解决这套方法论的后顾之忧,比如:

+
    +
  1. npm 扁平化结构的各种问题
  2. +
  3. 项目逐渐庞大以后的构建速度问题
  4. +
  5. 项目如何发布的问题
  6. +
+

参考链接

+]]>
+ + nodejs + npm + +
+ + Parcel Note + /2019/parcel-note/ + Parcel Bundler 发布了这么久,终于有机会体验了一次。在一个新的基于 jQuery 的小项目中尝试了这个打包器。结合它的宣传点,整体来说最大的感受是:

+
    +
  1. 确实比 Webpack 快很多
  2. +
  3. 确实「基本上」不需要配置
  4. +
+

虽然没有太多其它的亮点,但这不妨碍它用起来就是比 Webpack 「爽」。

+ + +

关于「快」

就我的感受而言,Parcel 的快大多是要基于它的文件系统缓存的,这也是 Webpack 没有的东西(也许将来会有也说不定)。从体验上来说,应用启动以后「热重载」的速度基本上差不多,就算有差距也可以忽略不计,因为基本两者都是秒速。但是「重启」就不一样了。得益于它的文件系统缓存,Parcel 要比 Webpack 快两到三倍,甚至五倍十倍,我觉得一点都不夸张。这在日常开发中体现的优势还是相当明显的。

+

关于「零配置」

说零配置是有些夸张了,应该说 90% 的场景都不需要写配置。比如说 Webpack 必写的 babel-loader / css-loader 等东西,它都已经给内置了。开发者需要做的东西仅仅是把它安装下来而已。比如说:

+
    +
  1. 我需要使用 Babel,则安装 @babel/core 就好了
  2. +
  3. 我需要使用 Less 预编译 CSS,则安装 less 就好了
  4. +
  5. 我需要使用 Pug 预编译 HTML,则安装 pug 就好了
  6. +
+

事实上,你可能甚至不用做「安装」这一步。当它检测到你输入了某个类型的文件而需要安装某种依赖才能进行时,它会自动安装。

+

这种做法给人的感觉是,它并不是真正的零配置,而是所有的配置其实都已经写好了,内置了,然后它会检测你输入的文件类型,去匹配现有的规则,该干嘛干嘛。所以,这并不是什么黑科技,只是「约定大于配置」的一种体现。

+

吐槽点

在日常开发中,确实 90% 的场景下都不需要写配置,那么另外的那 10% 呢?

+

真正用下来会发现,Parcel 在带来方便的同时,也会带来一些问题:任何事物都是有两面性的。某些在 Webpack 下很稀松平常的任务,比如 js 代码混淆,加多一个 loader,配多一个规则就能解决的事情,在 Parcel 的世界里,对不起,做不到。你得自己想办法。

+

当然这也许是我对 Parcel 的了解还不够深入,不知道如何定制。但 Parcel 的文档里面确实没有提及任何相关的可定制化的东西。所以,真的要说到「可靠性」,「安全感」的话,可能我还是往 Webpack 这边站。但是,毋庸置疑的是 Parcel 确实为小项目提供了一个非常棒的选择。

+

另外再吐槽一点,Parcel 的文档强制给我跳转中文版,然而中文版文档更新滞后,缺斤少两,我选了英文以后,下次再打开还是中文,这一点太不友好了。

+]]>
+ + javascript + parcel + +
+ + 在 JetBrains IDE 中向 Markdown 粘贴图片 + /2021/paste-image-into-markdown-in-jetbrains-ide/ + 其实不需要装任何插件,IDE 自带的 Markdown 插件即可支持该操作:

+
    +
  1. 使用任意截图软件截图到剪贴板;
  2. +
  3. Ctrl + V 复制到编辑器中;
  4. +
  5. IDE 会自动生成图片文件 img.png(如果已存在,则会加自增后缀),以及相应的 Markdown 标签 ![img.png](img.png)
  6. +
+

但是,默认的插件不能配置保存路径(只能是 markdown 文件所在的路径),也不能配置命名规则,因此找了一个插件来增强这个功能。

+ + +

插件名:Markdown Image Support。

+

+

配置界面如下:

+

+

不过,这个插件也有个 bug:当取消粘贴时,会回退到 ide 自身的操作,也就是创建 img.png

+]]>
+ + markdown + idea + +
+ + Php Note + /2021/php-note/ + Php 个人速查笔记。

+ + +

基础

字符串长度

strlen($string)
+ +

数组长度

count($arr)
+ +

日期

获取当前时间

$d1 = new DateTime();
+ +

获取指定时间

$d2 = new DateTime('2021-01-01');
+ +

正则匹配

preg_match("/^[A-Za-z]+$/", $Lastname)
// boolean
+ +

获取时间差

$diff = $d2->diff($d1);
// 年份差
echo $diff->y;
+ +

循环

foreach ($posts as $key=>$value) {
// todo
}
+ +

EOT

<?php foreach ($csv as $i => $value) {
$dateToDisplay = date('F d, Y', $value[0]);
echo <<<EOT
<div class="post-preview">
<a href="post.php?author=$value[2]&date=$value[0]&image=$value[1]&content=$value[3]&comment=$value[4]">
<h2 class="post-title">
<img class="Post1" src="./files/$value[1]" alt="farm" height="380px" width="720px">
</h2>
<h3 class="post-subtitle">$value[5]</h3>
</a>
<p class="post-meta">Posted by
<a href="about.php">$value[2]</a>
on $dateToDisplay
</p>
</div>
<hr>
EOT;
} ?>
+ + +

获取请求方法

$request = $_SERVER['REQUEST_METHOD'];
// POST or GET or anything else
+ +

输入过滤

单条

$id  = filter_input(INPUT_POST, 'id', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
+ +

一次性

$_POST = filter_input_array(INPUT_POST, FILTER_SANITIZE_STRING);
+ +

发请求

$json_url = 'https://data.winnipeg.ca/resource/tx3d-pfxq.json';
$json = file_get_contents($json_url);
$list = json_decode($json, true);
+ +

JSON

解码

$json = json_decode(file_get_contents("./member.json"), true);
$points = $json['points']
+ +

编码

$json = json_encode($array);
echo $json;
+ +

MySQLi

连接

session_start();

$host = 'localhost';
$user = 'root';
$password = '';
$db = 'database';

// connect to mysql database
$conn = new mysqli($host, $user, $password, $db);
if ($conn->connect_error) {
// connection error
die($conn->connect_error);
}
+ +

建表

$sql = "CREATE TABLE IF NOT EXISTS tablename (
ID INT AUTO_INCREMENT PRIMARY KEY,
Name varchar(100) NOT NULL,
RefID int,
FOREIGN KEY (RefID) REFERENCES Ref (ID)
)";

if ($conn->query($sql) !== TRUE) {
die("Error creating table: " . $conn->error);
}
+ +

插入

$stmt = $conn->prepare("insert into table (email, date) VALUE (?,?)");
$stmt->bind_param("ss", $_SESSION['user'], $_POST['date']);
if (!$stmt->execute()) {
die($conn->error);
} else {
echo "inserted, id is " . $stmt->insert_id;
}
+ +

更新

$query = $conn->prepare("update User set profile = ?, photo = ? where id = ?");
$query->bind_param('ssi', $_POST['profile'], $photo, $_SESSION['user'][0]);
$query->execute();
+ +

查询 (单条)

$query = $conn->prepare("SELECT * FROM user where email=? and password=?");
$query->bind_param('ss', $email, $password);
$query->execute();
$result = $query->get_result();
$user = $result->fetch_array(MYSQLI_NUM);
// user 是数组,
// 字段从 0 开始排列,没有 named key
+ +

查询 (多条)

$query = $conn->prepare("SELECT * from meal where email=?");
$query->bind_param('s', $_SESSION["email"]);
$query->execute();
$result = $query->get_result()->fetch_all();

// result 是数组,每个元素也是数组。
// 字段从 0 开始排列,没有 named key
+ +

删除

$query = $conn->prepare("delete from Likes where photoId = ? and userId = ?");
$query->bind_param('ii', $_GET['id'], $_SESSION['user'][0]);
$query->execute();
+ +

PDO

连接

define('DB_DSN','mysql:host=localhost;dbname=blog');
define('DB_USER','root');
define('DB_PASS','');
$db = null;
try {
$db = new PDO(DB_DSN, DB_USER, DB_PASS);
} catch (PDOException $e) {
print "Error: " . $e->getMessage();
die();
}
+ +

插入

$query = "INSERT INTO post (title, content) values (:title, :content)";
$statement = $db->prepare($query);
$statement->bindValue(':title', $title);
$statement->bindValue(':content', $content);
$statement->execute();
$insert_id = $db->lastInsertId();
+ +

更新

$query = "UPDATE post SET title = :title, content = :content WHERE id = :id";
$statement = $db->prepare($query);
$statement->bindValue(':title', $title);
$statement->bindValue(':content', $content);
$statement->bindValue(':id', $id);
$statement->execute();
$insert_id = $db->lastInsertId();
+ +

查询

$query = "SELECT * FROM post ORDER BY creation_time DESC LIMIT 5";
$statement = $db->prepare($query);
$statement->execute();
$posts= $statement->fetchAll();
+ +

删除

$query = "DELETE FROM post WHERE id = :id";
$statement = $db->prepare($query);
$statement->bindValue(':id', $id, PDO::PARAM_STR);
$statement->execute();
+ +

授权

登录

// select user from db first
session_start();
$_SESSION['user'] = $user;
header("Location: index.php");
die();
+ +

注销

unset($_SESSION['user']);
session_destroy();
header('Location: login.php');
die();
+ +

检查授权

if (!isset($_SESSION['user'])) {
header("Location: login.php");
die();
}
+ +

密码加密

$hashed_password = hash('ripemd128', $psw);
+ +

Basic Auth

define('ADMIN_LOGIN','wally');
define('ADMIN_PASSWORD','mypass');
if (!isset($_SERVER['PHP_AUTH_USER']) ||
!isset($_SERVER['PHP_AUTH_PW']) ||
($_SERVER['PHP_AUTH_USER'] != ADMIN_LOGIN) ||
($_SERVER['PHP_AUTH_PW'] != ADMIN_PASSWORD)) {
header('HTTP/1.1 401 Unauthorized');
header('WWW-Authenticate: Basic realm="Our Blog"');
exit("Access Denied: Username and password required.");
}
+ +

Memcached

$memcached = new Memcached();
$memcached->addServer('localhost', 11211);
$memcached->set('test', 'testcache');
var_dump($memcached->get('test'));
$memcached->set('test2', '123');
var_dump($memcached->get('test2'));
var_dump($memcached->get('test3'));
+ +

业务场景

为导航设置激活状态

在 page include header 之前:

+
$page = 'home';
+ +

在 header:

+
<li><a class="<?= ($page == 'home') ? "current" : ""; ?>" href="index.php">Home</a></li>
+ +

文件上传

保存至文件系统

// upload photo to images/photos
$photo = '';
$photoExt = pathinfo($_FILES['photo']['name'], PATHINFO_EXTENSION);
$photo = time() . "." . $photoExt;
move_uploaded_file($_FILES['photo']['tmp_name'], "images/photos/" . $photo);

// insert photo to database
$query = $conn->prepare("insert into Photo (photo, description, type, userId) value (?,?,?,?)");
$query->bind_param('sssi', $photo, $_POST['description'], $_POST['type'], $_SESSION['user'][0]);
$query->execute();
$id = $query->insert_id;

// go homepage
header('Location: index.php');
die();
+ +

保存至数据库

$fileContent = file_get_contents($_FILES['fileContent']['tmp_name']);
$contentName = mysql_fix_string($conn, $_POST['contentName']);
$query = $conn->prepare("INSERT INTO files (contentName, fileContent, userId) values (?,?,?)");
$query->bind_param('ssi', $contentName, $fileContent, $user[0]);
$query->execute();
$query->close();
+ +

MySQLi 初始化数据库

$conn = new mysqli($host, $user, $password);
if ($conn->connect_error) {
die($conn->connect_error);
}

// create database
$sql = "CREATE DATABASE if not exists $db";
if ($conn->query($sql) === TRUE) {
echo "Database $db created.";
} else {
echo "Error creating database: " . $conn->error;
}


// connect to database
$conn = new mysqli($host, $user, $password, $db);
if ($conn->connect_error) {
// connection error
die($conn->connect_error);
}

$sql = "
create table if not exists faculty
(
id int not null auto_increment primary key,
name text not null
);
";

if ($conn->query($sql) === TRUE) {
echo "<br/> faculty table created successfully";
} else {
echo "<br/> faculty table create error:" . $conn->error;
}
]]>
+ + php + mysql + +
+ + ReactNative WebView 接入支付宝与微信支付 + /2019/plug-alipay-and-wxpay-with-react-native-webview/ + 在 ReactNative App 的 WebView 中接入支付宝与微信支付其实很简单。首先前提是:使用 H5 网页提前做好了支付相关的动作,ReactNative 方面只负责展示 H5 页面,以及调起相应的 App 来完成支付,不需要接入底层相关的 SDK 或其它代码。

+ + +

对于 Android 平台来说,经过一番研究,发现 ReactNative 方不需要添加任何额外代码即可达成目的,通过 WebView 拉起支付 App 完成相应支付功能,并在支付成功后返回原 App,体验完美。但是有一点要注意的是,不要随意修改 WebView 的 UserAgent,如果需要修改的话最好使用追加的方式,因为支付宝的支付页面如果检测不到 Android 相关的 UserAgent 则不会拉起 App,只能在网页上支付。

+

iOS 使用以下代码来达到拉起 App 的目的。但有一个问题是,从 App 完成支付动作后,系统会打开浏览器来显示支付结果,而不是回到原 App,这个缺陷应该是使用 WebView 方式无法避免的。

+
onShouldStartLoadWithRequest = ({url}) => {
// 实际上应该不需要判断, 因为 onShouldStartLoadWithRequest 只支持 iOS,但是保险起见
if (Platform.OS !== 'ios') {
return;
}
const isAlipay = url && url.startsWith('alipay'); // 支付宝支付链接为 alipay:// 或 alipays:// 开头
const isWxPay = url && url.startsWith('weixin'); // 微信支付链接为 weixin:// 开头
const isPay = isAlipay || isWxPay;
if (isPay) {
// 检测客户端是否有安装支付宝或微信 App
Linking.canOpenURL(url)
.then(supported => {
if (supported) {
Linking.openURL(url); // 使用此方式即可拉起相应的支付 App
} else {
console.log(`请先安装${isAlipay ? '支付宝' : '微信'}客户端`);
}
});
return false; // 这一步很重要
} else {
return true;
}
};
+ +

补充一点:IOS 9.0 以上需要在 info.plist 中添加白名单,否则 canOpenURL 会始终返回 false,不管用户安装与否:

+
<key>LSApplicationQueriesSchemes</key>
<array>
<!-- 微信 URL Scheme 白名单-->
<string>wechat</string>
<string>weixin</string>

<!-- 支付宝 URL Scheme 白名单-->
<string>alipay</string>
</array>
+ +

参考:developer.apple.com

+]]>
+ + react-native + +
+ + Publish using GitHub Action + /2020/publish-using-github-action/ + 本文是一些 GitHub Actions 常用发布动作的总结。

+

强烈建议将所有 Publish actions 分开执行,不要集中到一个 Workflow 内。原因是如果其中一个动作因为某些原因失败了,GitHub 目前只能重启整个 Workflow,而如果 Workflow 内某个 Job 已经成功了,那么该 Job 下一次执行必然是失败(因为此类任务一般不能对同一个版本号执行两次,发布成功一次以后第二次尝试将会被拒绝发布),因此这一个提交的 Workflow 将永远不可能成功。

+

需要注意的是,以下所提到的 secrets.GITHUB_TOKEN 均是 GitHub Action 内置的 Access Token,无需自行创建。而其它 secrets 则需要在 项目主页 -> Settings -> Secrets 处创建。

+ + +

GitHub Pages

发布 GitHub Pages 使用的是 crazy-max/ghaction-github-pages 这个 action:

+
# publish_pages.yaml
name: CD

on:
push:
tags:
- 'v*'

jobs:
deploy_gh_pages:
runs-on: ubuntu-latest
steps:
# checkout & yarn
- uses: actions/checkout@v2
- uses: c-hive/gha-yarn-cache@v1
- run: yarn --frozen-lockfile
# build
- run: npm run build
- name: GitHub Pages
uses: crazy-max/ghaction-github-pages@v2.1.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
target_branch: gh-pages
build_dir: dist
jekyll: false # 禁用 GitHub 默认开启的 jekyll 构建
fqdn: some.domain.com # 自定义域名,需要时填写
+ +

如果想要在 push 到分支时就直接 deploy,可以使用:

+
on:
push:
branches:
- master
+ +

GitHub Release

发布 Release 包含几个动作:

+
    +
  1. 根据提交记录生成 Changelog,使用 ScottBrenner/generate-changelog-action
  2. +
  3. 创建一个 Release,正文填写上一步得到的 Changelog,使用 actions/create-release
  4. +
  5. 为 Release 附加需要的 assets,使用 actions/upload-release-asset
  6. +
+
# publish_release.yaml
name: CD

on:
push:
tags:
- 'v*'

jobs:
deploy_release:
runs-on: ubuntu-latest
steps:
# checkout,由于 changelog 需要读取所有历史记录
# 因此这里 `fetch-depth` 需要填 0,代表所有
- uses: actions/checkout@v2
with:
fetch-depth: 0
ref: dev
# yarn & build
- uses: c-hive/gha-yarn-cache@v1
- run: yarn --frozen-lockfile
- name: Build
run: npm run build
# 生成 changelog
- name: Changelog
uses: scottbrenner/generate-changelog-action@master
id: Changelog
env:
REPO: ${{ github.repository }}
# 创建 release
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
body: |
${{ steps.Changelog.outputs.changelog }}
draft: false
prerelease: false
# 添加 assets
- name: Upload Release Asset
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./dist/some-js.min.js
asset_name: some-js.min.js
asset_content_type: text/javascript
+ +

NPM

发布 npm 有一个预定义的 action: JS-DevTools/npm-publish,但是用过以后我觉得实际上没有自己敲命令行好用,因为它会做一些额外的不必要的动作,可能会导致发布出错。如:第一次发布时,它默认会检查历史包而报找不到 package 的错误,虽然它文档提示可以通过参数关闭该功能,但实测下来并不行。

+

注:下面的 NPM_TOKEN 是需要自行配置的。

+
# publish_npm.yaml
name: CD

on:
push:
tags:
- 'v*'

jobs:
deploy_npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: c-hive/gha-yarn-cache@v1
- uses: actions/setup-node@v1
with:
node-version: 12.x
registry-url: https://registry.npmjs.org
- run: yarn --frozen-lockfile
- name: Build
run: npm run build
# 发布
- name: Publish NPM
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ +

NPM GitHub Registry

GitHub Registry 与 NPM 不一样的是,它要求发布的包必须是以当前仓库所属的 username 为 scope 的。因此如果要同时发布 NPM 和 GitHub Registry,在执行此步骤时 package.json 需要做一点小更改:将包名改为 scoped 的。

+

这里为求简便,使用了 deef0000dragon1/json-edit-action 来执行替换。实际上熟悉 shell 命令的话一行代码也可以完成。

+
# publish_github.yaml
name: CD

on:
push:
tags:
- 'v*'

jobs:
publish_github:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v1
with:
node-version: 12
- uses: actions/checkout@v2
# 将 package.json 中的 name 字段替换
- name: change package name
uses: deef0000dragon1/json-edit-action@v1
env:
KEY: name
VALUE: "@username/some-package"
FILE: package.json
- uses: c-hive/gha-yarn-cache@v1
- run: yarn --frozen-lockfile
# 配置 npmrc
- run: echo //npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }} >> .npmrc
# 发布
- run: npm publish --registry=https://npm.pkg.github.com
+ +

关于 npmrc 这一步,使用 NODE_AUTH_TOKEN 环境变量应该也可以达到相同目的。

+]]>
+ + github + devops + npm + +
+ + 前端 MVC 的未来:浅谈 Hooks 与 VCA 在设计思路上的异同 + /2021/react-hooks-vs-vca/ + 关于 React Hooks 与 Vue Composite API:

+ +

二者为了共同的目的,在接近的时间点,以非常相似但是又带有本质区别的方式,推出了各自对于未来前端代码结构发展的新思路。本文在对二者做一些简单介绍的同时,也会重点关注二者之间的统一与区别。

+ + +

先说结论

共同目的

1. 优化代码复用

以下所说的代码复用,不包含组件复用等内容。两大框架老的复用方式存在的共同问题:

+
    +
  • 变量、参数来源不明确(混乱);
  • +
  • 无命名空间,变量之间可能冲突、覆盖(不可靠);
  • +
+

React

+
    +
  • mixin
  • +
+

因为 mixin 的缺点根本多到数不清,React mixin 是一种已经基本上被废弃了的写法。它在 class 组件中已经不可用了。

+
const TickTock = React.createClass({
mixins: [SetIntervalMixin], // Use the mixin
// ...
});
+ +
    +
  • HOC
  • +
+

HOC 是 Higher-Order Components 的简称。HOC 是通过语言自身的特性实现的,跟 React 本身没有关系。

+
class AdvancedComp extends React.Component { 
render() {
return <BaseComp {...props} text={'someText'} />;
}
}
+ +

HOC 是在 React Hooks 出现之前被广泛使用的代码复用方式,但是它存在自己的问题和局限性:

+
    +
  1. 不能在 render 函数内定义 HOC(会导致组件丢失状态,以及消耗性能)
  2. +
  3. 高阶组件会丢失原组件的静态与实例方法,需要手动复制
  4. +
  5. ref 将无法得到原始组件的引用,必须用 React.forwardRef 处理
  6. +
  7. 复杂的高阶组件跟 mixin 一样,存在参数来源以及去向混乱的问题
  8. +
+
    +
  • 继承(不支持生命周期钩子)
  • +
+

继承这种方式,看起来很符合语言特性,但是 React 对它的支持是不完备的,甚至没有出现在官方推荐的方式里面。最主要的问题是,高阶组件没有办法复用基类的生命周期以及 render 函数,也不能通过形如 super.componentDidMount() 的形式来绕过这个问题。

+
class AdvancedComp extends BaseComp {
// ...
}
+ +

Vue

+
    +
  • Mixin
  • +
+

Vue 的 mixin 跟 React 非常类似,在提供便利的同时,同样带来了多到数不清的问题。摘录一下来自 Vue 官方文档的吐槽:

+
+

在 Vue 2 中,mixin 是将部分组件逻辑抽象成可重用块的主要工具。但是,他们有几个问题:

+
    +
  1. Mixin 很容易发生冲突:因为每个 mixin 的 property 都被合并到同一个组件中,所以为了避免 property 名冲突,你仍然需要了解其他每个特性。

    +
  2. +
  3. 可重用性是有限的:我们不能向 mixin 传递任何参数来改变它的逻辑,这降低了它们在抽象逻辑方面的灵活性。

    +
  4. +
+
+
    +
  • Directive
  • +
+

Directive(指令)是一种特殊的代码复用,它的目的非常局限:操作 DOM 节点。也就是说,它的复用范围仅限于跟 DOM 操作相关的内容。

+

2. 减轻心智负担

在以职能来组织代码的时候,当我们的组件开始变得更大时,逻辑关注点的列表也会增长,举例(一张来自 Vue 文档的图片):

+

+

相信对于这类文件写过 Vue 的同学都深有体会。当我们需要查找跟某项功能相关的代码的时候,需要在文件中不停地搜索、上下跳动。非常难受。

+

3. 干掉 this

this 在 JavaScript 这个大环境下始终存在指向不明确的问题,无论是对初学者还是资深前端工程师来说也始终是一个需要特别注意的地方,同时也不利于静态分析和强类型检查。

+

本质区别

二者的区别,说来非常简单,但是又非常巨大:

+
    +
  1. React Hooks 是 effect (副作用),在组件每次渲染的时候都会执行
  2. +
  3. VCA 是 setup (安装/配置),仅在组件初始化的时候执行一次
  4. +
+

这些区别是由框架本身的特性决定的,而它们具体代表了什么,需要在下文继续阐释。

+

常用场景差异

生命周期

Hooks 移除了生命周期的概念,取而代之的是 effect ,VCA 则近乎完整地保留了生命周期概念与函数。

+

1. Mount / Unmount

hooks

+

React Hooks 摒弃了 mount / unmount / update 等生命周期概念,转而引入了一个新的 useEffect 函数,简而言之:

+
    +
  1. useEffect 接收两个参数,第一个是回调函数 callback,第二个是数组 deps
  2. +
  3. callback 可以没有返回值,也可以返回一个函数,如果返回了函数,那将会是这个 effect 的「清除」函数
  4. +
  5. 当组件初次挂载,或者每当 deps 里面的任意一个元素发生变化的时候(这个时机由 React 判断),回调函数将会被执行一次
  6. +
  7. 特殊情况:
      +
    1. deps 未传:callback 在每次渲染的时候都会执行一次
    2. +
    3. deps 为空数组:callback 当且仅当组件第一次挂载的时候执行一次
    4. +
    +
  8. +
+

因此,可以使用 useEffect 同时模拟 mount 与 unmount 事件:

+
// componentDidMount (mounted)
useEffect(() => {
console.log('[componentDidMount]')

const clicked = (e: MouseEvent) => {
setXy([e.clientX, e.clientY])
}

document.addEventListener('click', clicked)

// componentWillUnmount (beforeUnmount)
return () => {
console.log('[componentWillUnmount]')
document.removeEventListener('click', clicked)
}
}, [])
+ +

这样做有几个好处:

+
    +
  1. 对于通常需要成对出现的,类似注册、解注册的逻辑来说,这么做可以使得逻辑更加内聚
  2. +
  3. 挂载和卸载函数可以读取到同一个作用域下的变量和方法,就比如以上例子中的 clicked 事件
  4. +
+

但是,由此也带来了一个显而易见的问题:callback 没法使用 async 函数了。因为 async 函数必定会返回一个 Promise 实例,而这明显与设计相悖。想要在 useEffect 内部使用 async 函数的话,做法会有点绕:

+
useEffect(() => {
(async () => {
// do something...
})()
})
+ +

vca

+

与 Hooks 大相庭径:

+
    +
  1. VCA 保留了传统的 mounted 与 unmount 事件,只不过换了个形式
  2. +
  3. VCA 不需要写 deps
  4. +
+
export default defineComponent({
setup () {
const xy = reactive({
x: 0,
y: 0
})

const clicked = (e: MouseEvent) => {
xy.x = e.clientX
xy.y = e.clientY
}

onMounted(() => {
document.addEventListener('click', clicked)
})

onBeforeUnmount(() => {
document.removeEventListener('click', clicked)
})

return {xy}
},
})
+ +

2. Update / Watch

hooks

+

useEffectdeps 不为空时,回调函数在组件第一次挂载时,以及后续每次 deps 的其中之一变化时都会执行。

+

这里有一点需要注意的是:**除 useRef 以及 useState 的 setter 以外,其它所有回调函数中用到的变量,都需要写进 deps**,包括 state / memo / callback 等,否则(因为闭包的存在)函数调用时永远会拿到旧的值。

+
// componentDidUpdate (watch / updated)
useEffect(() => {
console.log('[componentDidUpdate]', xy)
}, [xy])

// 错误!fetchData 也需要写进 deps
useEffect(() => {
// useRef
console.log(countRef.current)
// useCallback useState
fetchData(page)
}, [page])
+ +

vca

+

VCA 的 updated 与 watch 与原 API 也基本类似,但是有几个需要注意的点:

+
    +
  1. 增加了一个新的概念 watchEffect,与 useEffect 十分类似,但是不需要写 deps
  2. +
  3. watchwatchEffect不能直接监听 reactive 本身——因为只有 reactive 下面的属性才是真正意义上的 reactive
  4. +
+
onUpdated(() => {
console.log('updated', xy)
})

// 正确
watch(() => xy.y, (y, oy) => {
console.log('watch',y, oy)
})

// 错误,两个参数都将是更新后的值
watch(xy, (y, oy) => {
console.log('watch',y, oy)
})

// 正确
watchEffect(() => {
console.log('watchEffect', xy.y)
})

// 错误,无法触发
watchEffect(() => {
console.log('watchEffect', xy)
})
+ +

变量定义

总体区别:

+
    +
  • 从利于维护的角度出发,hooks 内原则上不允许直接定义任何变量,包括常量、方法等,因为组件每次渲染时都会重新初始化。因此从某种意义上来说,直接定义的变量也是一种响应式变量(当能够正确赋予初始值的时候)。
  • +
  • VCA 无此限制。并且直接定义的变量为常量。
  • +
+

注:关于第一点,社区一直存在争议。争议的关键点在于每次渲染都重新初始化变量到底会不会对性能造成压力。官方文档 的说法是不会,但是从目前的 benchmark 结果来看,React Hooks 确实已经处于下风了(当然这里面也会有其它方面的影响因素):

+

+

1. 变量

hooks

+

hooks 大致提供了以下几种定义变量的方法:

+
    +
  • useState: 响应式变量,不需要 deps
  • +
  • useMemo: 常量(不可变)或计算值,需要写 deps
  • +
  • useRef: 变量(可改变,但不影响渲染),不需要 deps
  • +
  • 直接定义: 通常来说是错误的写法
  • +
+
// state, 影响渲染
const [count, setCount] = useState(0)

// 计算属性,count 发生变化时会改变,影响渲染
const doubleCount = useMemo(() => count * 2, [count])

// 变量,但不影响渲染
const doubleCountRef = useRef(count * 2)

// 直接定义,每次渲染时都会重新计算值,因此也能影响渲染
const renderEveryTime = count * 2
+ +

+

vca

+

VCA 提供了以下几种定义变量的方法:

+
    +
  • ref: 包裹(深度)响应式对象。之所以存在,是因为基础类型目前来说无法做到响应式,所以必须通过一个对象来包裹,通过 xxx.value 访问基础类型才能获得响应式。同时允许 object/array 的重新赋值。
  • +
  • reactive: 与 ref 实现的效果一样,区别是不需要通过 .value 即可访问,同时也不能被重新赋值。
  • +
  • computed: 计算值
  • +
  • 直接定义: 常量
  • +
+

无论哪种方式,VCA 都不需要写 deps

+
// state, 影响渲染
const count = ref(0)

// 计算属性,count 发生变化时会改变,影响渲染
const double = computed(() => count.value * 2)

// 常量,一次性的值,不影响渲染
const doubleCountRef = count.value * 2;
+ +

+

2. 方法

hooks

+
    +
  • 从利于维护的角度出发,方法定义需要使用 useCallback 包裹,某则每次渲染都会被重新创建,并且当方法作为 PureComponent 子组件的参数使用时会触发子组件的重新渲染。
  • +
  • 方法必须正确定义 deps,否则内部取值将得不到变化后的值
  • +
  • 方法的 deps 一旦改变,方法将会被重新创建,闭包也会得到更新
  • +
+
const [count, setCount] = useState(0)

// 正确
const addCount = useCallback(() => {
setCount(v => ++v)
}, [])

// 正确
const addCount = useCallback(() => {
setCount(count + 1)
}, [count])

// 错误, 永远相当于 setCount(0 + 1)
const addCount = useCallback(() => {
setCount(count + 1)
}, [])
+ +

vca

+

没什么限制,可以随心所欲。

+
const addCount = function () {
++count.value
}
+ +

代码复用

二者在代码复用这一块的理念十分类似,最终体现在代码上就像是两兄弟。

+

hooks

+
import React, { useEffect, useState, memo } from 'react'

function useMousePosition () {
const [xy, setXy] = useState([0, 0])
useEffect(() => {
const moved = (e: MouseEvent) => {
setXy([e.clientX, e.clientY])
}

document.addEventListener('mousemove', moved)

return () => {
document.removeEventListener('mousemove', moved)
}
}, [])

return {
x: xy[0],
y: xy[1]
}
}

export default memo(
function () {
const { x, y } = useMousePosition()

return (
<>
<h2>CustomHooks</h2>
<div>Mouse Position: {x},{y}</div>
</>
)
}
)
+ +

+

vca

+
function useMousePosition () {
const xy = reactive({ x: 0, y: 0 })
const moved = (e: MouseEvent) => {
xy.x = e.clientX
xy.y = e.clientY
}

onMounted(() => {
document.addEventListener('mousemove', moved)
})

onBeforeUnmount(() => {
document.removeEventListener('mousemove', moved)
})

return xy
}

export default defineComponent({
setup () {
const xy = useMousePosition()

return { xy }
},
})
+ +

+

存在的问题

简单来说,由于两个框架各自的特性,问题也通常来自于:

+
    +
  1. deps (hook)
  2. +
  3. Proxy (VCA)
  4. +
+

不过有一个好消息是,React 提供了一个插件 eslint-plugin-react-hooks 可以帮忙检测 deps 的缺失,并且后续有计划通过代码静态分析去除掉这个烦人的依赖项。

+

Hook: 忘记写 deps 导致变量不更新

// state
const [count, setCount] = useState(0)

// method
const addCount = useCallback(() => {
setCount(count + 1)
}, [])
+ +

+

Hook: Deps 写不好导致死循环

案例一:在 setState 的同时又依赖了 state

常见于列表加载:

+
    +
  1. 首页数据可以直接 setState
  2. +
  3. 后续分页的数据要在现有基础上追加
  4. +
+
const [count, setCount] = useState(0)

const addCount = useCallback(() => {
setCount(count + 1)
}, [count])

useEffect(() => {
addCount()
}, [addCount])
+ +

+

案例二: useEffect 忘记写 deps

const addCount = useCallback(() => {
setCount(v => v + 1)
}, [])

useEffect(() => {
addCount()
})
+ +

案例三:deps 里面填入了直接定义的引用类型变量

let someValue = []
const [count, setCount] = useState(0)

// 由于 someValue 每次渲染时都会重新初始化,
// 而引用类型重新初始化后其地址是不等的,
// 因此会触发死循环
useEffect(() => {
setCount(v => ++v)
}, [someValue])
+ +

Hooks: 定义位置的限制

因为 Hooks 的实现原理是链表,必须保证每次组件渲染得到的 hooks 及其顺序都是一致的,因此使用 Hook 需要遵循两条额外的规则:

+
    +
  • 只能在 React 函数中调用 Hook,不能在普通的 JavaScript 函数中调用;
  • +
  • 不能在循环,条件或嵌套函数中调用 Hook
  • +
+
// 错误的写法,会直接报错
const [count, setCount] = useState(0)

if (count === 1) {
const [double, doubleCount] = useState(count)
}
+ +

VCA: 解构丢失响应性

包括 props / reactive 在内的所有 Proxy 类型变量都不能解构,否则会丢失响应性。解构必须使用 toRefs 方法

+
export default defineComponent({
setup () {
const count = reactive({ value: 0 })

const addCount = function () {
++count.value
}
const { value } = count

return { count, value, addCount }
},
})
+ +

+

总结

React Hooks

优点:

+
    +
  • 目前为止最好的代码复用方式(之一)
  • +
  • 优秀且精炼的设计理念
  • +
+

缺点:

+
    +
  • 需要写 deps
  • +
  • 由于其每次渲染都执行 (effect) 的特点,目前被业界公认为心智负担极重
  • +
+

Vue Composite API

优点:

+
    +
  • 目前为止最好的代码复用方式(之一)
  • +
  • 作为 setup,上手难度相较 hooks 可谓是极低,心智负担极低
  • +
+

缺点:

+
    +
  • 与 Hooks 相比,API 设计(也许)不够精炼,受制于历史包袱
  • +
  • Proxy 虽然带来了便利,但是也带来了麻烦,经常需要考虑:
      +
    • 一个对象能否解构?
    • +
    • 一个属性到底应该用 ref 还是 reactive
    • +
    • 取值的时候要不要加 .value(常常被忘记)?
    • +
    +
  • +
+

参考资料

    +
  1. React Hooks 官方文档
  2. +
  3. Vue Composite API 官方文档
  4. +
  5. 知乎:Vue3 究竟好在哪里?(和 React Hook 的详细对比)
  6. +
  7. 知乎:新版react中,useCallback 和 useMemo 是不是值得大量使用?
  8. +
  9. Vue 3.2 Released!
  10. +
+]]>
+ + javascript + vue + react + +
+ + React Hooks + /2019/react-hooks/ + Hooks 是 React 在 v16.8.0 版本所支持的一个新特性,允许开发者在 Functional Component 中实现「状态」以及「生命周期」等原本只能在 Class Component 中实现的特性。

+

Vue Function-based API 是将来会出现在 Vue.js 3.0 大版本中的一个 API 变革的整体预览,二者(至少)在形式上保持了高度统一,而 yyx 也在文章中直言是受到了 React Hooks 的启发,二者分别解决了自身框架的一些痛点,并允许开发写编者更加「纯粹」的函数式组件。也许可以认为是未来前端框架发展的一个大方向?

+ + +

以下代码例子大部分来自于官方文档

+

简介

React Hooks 提供了两个基本 Hooks: useStateuseEffect,其中:

+
    +
  • useState hook 赋予了函数式组件保存以及更新「状态」的能力
  • +
  • useEffect hook 赋予了函数式组件在「生命周期」之中执行函数的能力
  • +
+

官网上的一个简单例子:

+
import React, { useState, useEffect } from 'react';

function Example() {
const [count, setCount] = useState(0);

// Similar to componentDidMount and componentDidUpdate:
useEffect(() => {
// Update the document title using the browser API
document.title = `You clicked ${count} times`;
});

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
+ +

以上组件定义了一个函数式组件,并在组件内注册了一个 state count,实现了每当点击按钮的时候,count 会自增 1,视图相应更新,并且页面标题会随着 count 更新而更新的功能。

+

useState

useState 的作用很明显,也很简单:它接受一个参数作为 state 的初始值,返回一个数组,数组第一位是 state 的值,第二位是改变该 state 的方法。以上例子使用了 ES6 的数组解构特性来简化了代码,同时这也是推荐的写法。

+

如果一个组件需要保有多个状态,那么有两种实现方式:

+
    +
  1. 分别定义
    const [age, setAge] = useState(42);
    const [fruit, setFruit] = useState('banana');
    const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);
  2. +
  3. 合并定义
    const [state, setState] = useState({
    age: 42,
    fruit: 'banana',
    todos: [{ text: 'Learn Hooks' }]
    });
  4. +
+

React 并没有明确推荐哪一种形式,但是有一点需要注意的是,如果采用第二种形式,与传统的 Class Component 有所区别的是,setState 不会默认为 state 进行 merge 操作,而是 replace,也就是说如果要达到预期的效果应该这么写:

+
const [state, setState] = useState({
age: 42,
fruit: 'banana',
todos: [{ text: 'Learn Hooks' }]
});

// 将 age 变更为 50 而不影响其它 state
setState(state => ({
...state,
age: 50
}))
+ +

useEffect

useEffect 可以看作是传统生命周期函数 componentDidMount / componentDidUpdate / componentWillUnmount 的结合,不过有一点区别是 useEffect 是异步执行的,不会阻塞渲染。它的用法要比 useState 稍微复杂些。

+

最简单的例子就跟上面的一样:

+
useEffect(() => {
// Update the document title using the browser API
document.title = `You clicked ${count} times`;
});
+ +

接受一个函数作为参数,每当视图重新渲染完成后,函数将会被执行。也就是说,它可以看做是一个 componentDidMountcomponentDidUpdate 的综合。

+

有时候我们需要在 componentDidMount 的时候为组件注册一些事件,然后在 componentWillUnmount 时销毁它,那么这时候可以在函数结束时返回另一个函数,返回的函数就将会作为「清理」函数。

+
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
+ +

不过很明显我们还需要做一件事情:并不是所有 componentDidUpdate 都需要进行注册、销毁这一系列操作,只有在当某个监听的 value 真正发生了变化的时候才需要。因此 useEffect hook 提供了第二个参数。参数为一个数组,数组中传入需要监听的变量。只有当数组中任一参数的值(或引用)发生了改变时,effect 函数才会被执行。

+
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
}, [props.friend.id]); // Only re-subscribe if props.friend.id changes
+ +

(文档中有提到在之后的版本中这个参数可能会在构建阶段自动加入,这样相当于 React 在某种程度上也向 Vue.js 靠近了一点点,或者说相互借鉴)

+

如果想要定义一个只在 componentDidMount 时执行一次的 effect,那么第二个参数可以传一个空数组,它就再也不会在 componentDidUpdate 时被执行。

+

规则

React 为 Hooks 制定了两条规则:

+
    +
  1. 只在顶层调用 Hooks,避免在循环体、条件判断或者嵌套函数中调用。因为 React 对 Hooks 的解释依赖于它们定义的顺序,开发者必须保证每次 Render 的过程中 Hooks 执行的顺序都是一致的,这样 Hooks 才能正确工作。
  2. +
  3. 只在 React Function 中调用 Hooks。
  4. +
+

此外,React 还提供了一个 Eslint 插件 eslint-plugin-react-hooks 来确保各位遵守规则。

+

自定义 Hooks

自定义 Hooks 实际上跟 React Hooks 的初衷有一定关系:为了解决某些与状态绑定的逻辑很难在跨组件中复用的问题。由于 React 并不提倡 Class Component 使用继承的方式来复用高阶逻辑(实际上是因为 React 并没有像 Vue 一样对生命周期函数等做类似 Mixin 的工作,因此会导致一些 Bug),所以这个问题在传统写法中几乎无解。而 Hooks 则是为了解决这个问题而来的。

+

Vue Function-based API 这篇文章中也提到了 Mixin 虽然为 Vue 带来了一些方便,但是同时也存在许多问题,3.x 版本中 Vue 也将使用类似的方式来使逻辑复用更清晰,算是殊途同归)

+

官网上举了一个例子:有多个组件需要根据「用户是否在线」这个标志来显示不一样的东西,而获取这个标志的逻辑是固定的,因此可以写成一个自定义 Hook:

+
import React, { useState, useEffect } from 'react';

function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);

useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
};
});

return isOnline;
}
+ +

而使用它的方式则非常简单:

+
function FriendStatus(props) {
const isOnline = useFriendStatus(props.friend.id);

if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}
+ +

至此可以发现,所谓的自定义 Hooks,其实只是把能复用的逻辑「抽离」了出来当做一个函数用以在各处执行,并没有什么特别之处。React 建议自定义 Hooks 使用 ‘use’ 作为方法名的前缀,这样可以让代码可读性显得更高,同时也可以让 lint 工具自动识别并检测该函数是否符合既定规则。

+]]>
+ + javascript + vue + react + +
+ + React Native Text Inline Image + /2018/react-native-text-inline-image/ + 原文地址(需科学上网):React Native Text Inline Image

+

RN 版本:0.49

+

图文混排(在文字中插入图片,并保持正确换行)是客户端普遍的需求,但在 RN 中它有一点问题,具体表现在 Android 平台下图片显得异常的小,并且相同系统不同设备之间的表现也不尽一样,而 ios 则表现正常。

+ + +

就像这样:

+
import React, { Component } from 'react';
import {
AppRegistry,
StyleSheet,
Text,
View,
Image,
} from 'react-native';

const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 50,
backgroundColor: '#f6f7f8',
},
image: {
width: 80,
height: 80,
},
text: {
backgroundColor: '#dcdcde',
},
});

class App extends Component {

render() {
return (
<View style={styles.container}>
<Text style={styles.text}>
Hello people!
<Image
style={styles.image}
source={{uri: 'http://s3.hilariousgifs.com/displeased-cat.jpg'}}
/>
</Text>
</View>
);
}
}

AppRegistry.registerComponent('App', () => App);
+ +

它在 ios 下看起来是这样的:

+

ios-before

+

而在 Android 下看起来是这样的:

+

android-before

+

可以看到,在 Android 下面这张图异常地小!

+

实际上这与设备的像素比(pixel ratio)有关,是现版本 React Native 在渲染文字内联图片时的一个 Bug,为了解决这个问题,我们可以给图片设定一个基于设备像素比的宽高。

+

就像这样:

+
import React, { Component } from 'react';
import {
AppRegistry,
StyleSheet,
Text,
View,
Image,
Platform,
PixelRatio,
} from 'react-native';

const width = 80 * (Platform.OS === 'ios' ? 1 : PixelRatio.get());
const height = 80 * (Platform.OS === 'ios' ? 1 : PixelRatio.get());

const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 50,
backgroundColor: '#f6f7f8',
},
image: {
width: width,
height: height,
},
text: {
backgroundColor: '#dcdcde',
},
});

class App extends Component {

render() {
return (
<View style={styles.container}>
<Text style={styles.text}>
Hello people!
<Image
style={styles.image}
source={{uri: 'http://s3.hilariousgifs.com/displeased-cat.jpg'}}
/>
</Text>
</View>
);
}
}

AppRegistry.registerComponent('App', () => App);
+ +

结果:

+

android-after

+

如此一来,内联图片在 Android 下就能以正常缩放比显示了。

+

方便起见,可以将这段逻辑封装到组件中去。

+
import React from 'react';
import {
StyleSheet,
Image,
Platform,
PixelRatio,
} from 'react-native';

// This component fixes a bug in React Native with <Image> component inside of
// <Text> components.
const InlineImage = (props) => {
let style = props.style;
if (style && Platform.OS !== 'ios') {
// Multiply width and height by pixel ratio to fix React Native bug
style = Object.assign({}, StyleSheet.flatten(props.style));
['width', 'height'].forEach((propName) => {
if (style[propName]) {
style[propName] *= PixelRatio.get();
}
});
}

return (
<Image
{...props}
style={style}
/>
);
};

// "Inherit" prop types from Image
InlineImage.propTypes = Image.propTypes;

export default InlineImage;
+]]>
+ + react-native + +
+ + React node starter + /2018/react-node-starter/ + 出于某种需求搭建了一个非常简单的、基于 React / Node / Express / MongoDB 的 starter 工程:wxsms/react-node-starter,旨在简化小型或中小型项目开发流程,关注实际业务开发。

+

目前所实现的内容有:

+
    +
  • 前后端完全分离
  • +
  • 热重载
  • +
  • 用户注册、登录
  • +
+

+

麻雀虽小,五脏俱全。下面记录搭建过程。

+ + +

React

整个项目实际上是一个使用 facebook/create-react-app 创建出来的架构。

+
$ npm install create-react-app -g
$ create-react-app react-node-starter
+ +

如此就完事了。创建出来的项目会包含 React 以及 React Scripts,Webpack 等配置都已经包含在了 React Scripts 中。执行 npm start 会打开 http://localhost:3000,但是有一个遗憾之处是,这里提供的热重载不是 HMR,而是整个页面级别的重新加载。

+

Node & Express

要在前端项目的基础上加入 Node 服务端,由于项目的极简性质,需要考虑一个问题是:如何在不跨域、不加入额外反代的情况下完成这个任务。有幸的是 create-react-app 贴心地加入了 Proxying API Requests in Development 功能,只需要给 package.json 加入一对键值,就可以达成目的:

+
"proxy": "http://localhost:3001"
+ +

这样一来,在开发环境下,前端会自动将 Accept Header 不包含 text/html 的请求(即 Ajax 请求)转发到 3001 端口,那么我们只需要将服务端部署到 3001 端口就好了。

+

至于生产环境则无此烦恼,只需要将 npm run build 打包出来的文件当做静态资源,服务器依旧照常启动即可。

+

在项目根目录下新建 server 文件夹,用来存放服务端代码。

+

server/server.js:

+
#!/usr/bin/env node

const app = require('./app');
const http = require('http');

const port = 3001;
app.set('port', port);

const server = http.createServer(app);

server.listen(port);
+ +

server/app.js:

+
const express = require('express');
const session = require('express-session');
const path = require('path');
const logger = require('morgan');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');

const router = require('./router');

const app = express();

app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: false}));
app.use(cookieParser());
app.use(session({
secret: 'test',
resave: false,
saveUninitialized: true
}));
app.use(express.static(path.join(__dirname, '../build')));
app.use('/public', express.static(path.join(__dirname, '../public')));
app.use('/api', router);
app.get('*', (req, res) => {
res.sendFile('build/index.html', {root: path.join(__dirname, '../')});
});

module.exports = app;
+ +

就是一个典型的 Express HTTP 服务器。当处于开发环境时,build 目录只存在于内存中。执行生产构建脚本后,会打包至硬盘,因此上面的代码可以同时覆盖到开发与生产环境,无需再做额外配置。

+

准备完成后,将 start 脚本更新为:

+
"start": "concurrently \"react-scripts start\" \"nodemon server/server.js\""
+ +

即可。其中:

+
    +
  • concurrently 是为了在一个终端窗口中同时执行前端与服务端命令
  • +
  • nodemon 是为了实现服务端热重载
  • +
+

熟悉 Node.js 的应该对这两个工具都不陌生。

+

这里有一个对原项目作出改变的地方是,出于尽可能简化的目的,将 registerServiceWorker.js 文件及其引用移除了,同时使用 Express 来对 public 文件夹做静态资源路由。

+

如此一来,重新执行 npm start 会发现 Express 服务器能够按照预期运行了。

+

MongoDB

建好 Express 整体框架后,加入 MongoDB 的相关支持就非常简单了。安装 mongoose,然后在 server 目录下新建一个 models 文件夹用来存放 Model,然后新建一个 db 初始化文件:

+

server/mongodb.js

+
const mongoose = require('mongoose');
const path = require('path');
const fs = require('fs');

mongoose.connect('mongodb://localhost:27017');

fs.readdirSync(path.join(__dirname, '/models')).forEach(file => {
require('./models/' + file);
});
+ +

最后将此文件在 app.js 中引用即可:

+
require('./mongodb');
+ +

Session Auth

本项目采用 Session 鉴权,那么在前后端分离的项目中,无法通过服务端模板来同步赋值,因此有一个问题就是如何让前端项目获取到当前登录的角色。出于尽可能简单的目的,最终做法是在页面入口初始化时向服务端发起请求获取当前登录角色,获取过程中显示 Loading 界面。用户信息获取成功后才开始真正的路由渲染,如果具体页面鉴权失败则重定向回登录页面。

+

AntD

前端选用 Ant Design 作为 UI 框架,为了更方便地使用它,参考其文档教程,这里做一点小小的配置,首先安装 react-app-rewiredbabel-plugin-import

+
$ yarn add react-app-rewired babel-plugin-import
+ +

修改 package.json 中的脚本,将 react-scripts 全都替换为 react-app-rewired

+
{
"scripts": {
"start": "concurrently \"react-app-rewired start\" \"nodemon server/server.js\"",
"build": "react-app-rewired build",
"test": "react-app-rewired test --env=jsdom",
"eject": "react-app-rewired eject"
}
}
+ +

然后在项目根目录中创建 config-overrides.js 文件:

+
const {injectBabelPlugin} = require('react-app-rewired');

module.exports = function override (config, env) {
config = injectBabelPlugin(
['import', {libraryName: 'antd', libraryDirectory: 'es', style: 'css'}],
config,
);
return config;
};
+ +

这样做的好处是,CSS 可以按需加载,并且引用 AntD 组件更方便了,如:

+
import {Button} from 'antd';
+ +

Redux

安装 Redux 全家桶:

+
$ yarn add redux redux-thunk react-redux immutable
+ +

然后按照 示例项目 插入到项目中去即可。区别是为了在 action 中执行异步操作加入了一个中间件 redux-thunk,以及原示例没有使用 Immutable.js,也在本项目中加入了。

+

src/redux/store.js:

+
import {createStore, applyMiddleware} from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/index';

export default createStore(
rootReducer,
applyMiddleware(thunk)
);
+]]>
+ + javascript + nodejs + react + +
+ + React Note - Basic + /2017/react-note-basic/ + React 学习笔记(基础篇)。

+ + +

安装

npm install -g create-react-app
create-react-app hello-world
cd hello-world
npm start
+ +

实践:create 这一步会同时执行 npm install 因此有失败的可能,多尝试几次就成功了。

+

这个程序跟 vue-loader 很像,会造出一个简单的手脚架,包含了 Babel 编译器以及打包工具等等。但是细看它的 package.json 文件并没有包含上述内容:

+
"devDependencies": {
"react-scripts": "0.8.5"
},
"dependencies": {
"react": "^15.4.2",
"react-dom": "^15.4.2"
}
+ +

因此,跟 vue-loader 不一样的是,react 这个手脚架把无关内容都封装了。这么做我觉得有利有弊:它让人用起来更方便,然而不可能达到直接使用原组件的自由度了。相比之下,这里我更喜欢 vue-loader 的处理方式。

+

Hello World

最简示例:

+
ReactDOM.render(
<h1>Hello, world!</h1>,
document.getElementById('root')
);
+ +

JSX 语法

JSX 是 JavaScript 的一种语法扩展,实际上可以看做是语法糖。通过编译器,以下语法:

+
const element = (
<h1 className="greeting">
Hello, world!
</h1>
);
+ +

相当于:

+
const element = {
type: 'h1',
props: {
className: 'greeting',
children: 'Hello, world'
}
};
+ +

后者就是编译后的结果,JSX 语法块变成了一个对象(称之为 React element)。

+

(JB 家的 IDE 已经对 JSX 语法提供了默认支持,不然这篇笔记就到此为止了)

+

JSX 支持一些稍微高级的用法,如:

+
function formatName(user) {
return user.firstName + ' ' + user.lastName;
}

const user = {
firstName: 'Harper',
lastName: 'Perez'
};

const element = (
<h1>
Hello, {formatName(user)}!
</h1>
);

ReactDOM.render(
element,
document.getElementById('root')
);
+ +

在任何地方使用 JSX:

+
function getGreeting(user) {
if (user) {
return <h1>Hello, {formatName(user)}!</h1>;
}
return <h1>Hello, Stranger.</h1>;
}
+ +

元素

上面有说到 React element(元素),元素的概念与组件不同:元素是组件的组成部分。

+

元素渲染

const element = <h1>Hello, world</h1>;
ReactDOM.render(
element,
document.getElementById('root')
);
+ +

显然,它掌控了 DOM 中一个 ID 为 root 的节点,并往里面插入了元素。

+

元素更新

已创建的元素是无法更新属性的。因此,如果要改变它,只能够重新创建并渲染一次。

+

然而,托虚拟 DOM 的福,重新渲染并不代表重新渲染整个 DOM,React 会查找并只更新有改变的节点。

+

但是一般不回这么做。因为有一点很重要:在设计一个元素的时候就要考虑到它在所有状态下的表现。这个其实在其它框架下也是一样的。

+

组件

React 是组件化框架,因此组件是组成一个应用的基础。组件的特点:独立、可重用。

+

组件有两种定义方法:

+
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}
+ +

或者:

+
class Welcome extends React.Component {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
+ +

组件渲染

一个简单的例子:

+
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}

const element = <Welcome name="Sara" />;

ReactDOM.render(
element,
document.getElementById('root')
);
+ +

可以看到元素组成了组件,组件又组成了元素,最后渲染在 DOM 上的是元素。

+

这个跟 Vue 很像了,区别是 Vue 没有区分所谓的“元素”跟“组件”,通通都是组件。

+

需要注意的是,在 React 世界中有个约定:自定义控件以大写字母打头。这是为了跟 HTML 元素有所区分。

+

组件使用与拆解

一个简单的例子:

+
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}

function App() {
return (
<div>
<Welcome name="Sara" />
<Welcome name="Cahal" />
<Welcome name="Edite" />
</div>
);
}

ReactDOM.render(
<App />,
document.getElementById('root')
);
+ +

需要注意的是,组件只能有一个根节点。(如例子中的 3 个 Welcome 必须包裹在 div 中)

+

参数只读

简单地说,React 不允许在控件内修改参数(包括值的修改以及对象修改)。允许修改的称之为“状态”(约等于 Vue 中的 component data)

+

状态管理与生命周期

添加状态管理

组件的更新依赖于状态,因此需要实时更新的组件应在其内部建立状态管理机制(低耦合高内聚)。

+

需要状态管理机的组件,必须使用 ES6 方式声明,如:

+
class Clock extends React.Component {
constructor(props) {
super(props);
this.state = {date: new Date()};
}

render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}

ReactDOM.render(
<Clock />,
document.getElementById('root')
);
+ +

但是,此时,组件是无法更新的:因为状态在创建时就已经被决定了。

+

添加生命周期

代码有注释:

+
class Clock extends React.Component {
constructor(props) {
super(props);
this.state = {date: new Date()};
}

// 组件渲染到 DOM 后调用
componentDidMount() {
this.timerID = setInterval(
() => this.tick(),
1000
);
}

// 组件将销毁后调用
componentWillUnmount() {
clearInterval(this.timerID);
}

tick() {
this.setState({
date: new Date()
});
}

render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}

ReactDOM.render(
<Clock />,
document.getElementById('root')
);
+ +

整个流程很简单清晰了:

+
    +
  1. ReactDOM 渲染 Clock,并对 state 做第一次初始化
  2. +
  3. render 方法被调用,插入 DOM
  4. +
  5. componentDidMount 方法被调用,计时器启动,tick 每秒钟执行一次
  6. +
  7. 每次 tick 执行都调用 setState 方法去更新状态,这样 React 就知道需要更新 DOM 了
  8. +
  9. 当组件被从 DOM 移除后,componentWillUnmount 执行
  10. +
+

正确使用状态

直接更改 state 属性是不会触发 UI 更新的。因此,有一些规则需要遵守。

+

不直接修改状态

在组件内进行修改状态操作,使用 setState 方法:

+
// Wrong
this.state.comment = 'Hello';

// Correct
this.setState({comment: 'Hello'});
+ +

关于异步更新

// Wrong
this.setState({
counter: this.state.counter + this.props.increment,
});

// Correct
this.setState((prevState, props) => ({
counter: prevState.counter + props.increment
}));
+ +

状态合并

在进行 setState 的时候,只关心需要更改的属性即可,没有传入的属性会被保留。就好像新的状态被“合并”进入旧状态一样。

+

数据流

在 React 世界,组件与组件之间的状态传递是单向的,传值的方式就是将 state 当做 prop 传给子组件。

+

事件处理

跟 DOM 操作很像,区别:

+
    +
  1. 事件命名使用驼峰式
  2. +
  3. 直接向 JSX 中传入方法
  4. +
  5. 不支持 return false 操作
  6. +
+

例:

+
// DOM
<button onclick="activateLasers()">
Activate Lasers
</button>

// React
<button onClick={activateLasers}>
Activate Lasers
</button>

// A prevent default sample
function ActionLink() {
function handleClick(e) {
e.preventDefault();
console.log('The link was clicked.');
}

return (
<a href="#" onClick={handleClick}>
Click me
</a>
);
}

// A class sample
class Toggle extends React.Component {
constructor(props) {
super(props);
this.state = {isToggleOn: true};

// This binding is necessary to make `this` work in the callback
this.handleClick = this.handleClick.bind(this);
}

handleClick() {
this.setState(prevState => ({
isToggleOn: !prevState.isToggleOn
}));
}

render() {
return (
<button onClick={this.handleClick}>
{this.state.isToggleOn ? 'ON' : 'OFF'}
</button>
);
}
}
+ +

注意:这个 e 是 React 封装过的,但遵循 W3C 标准,因此无需做浏览器差异化处理。

+

另外,this.handleClick.bind 方法是为了保证在 onClick 中调用了正确的 this,但使用箭头函数可以避免这个累赘的方法:

+
class LoggingButton extends React.Component {
// This syntax ensures `this` is bound within handleClick.
// Warning: this is *experimental* syntax.
handleClick = () => {
console.log('this is:', this);
}

render() {
return (
<button onClick={this.handleClick}>
Click me
</button>
);
}
}
+ +

条件渲染

例子:

+
render() {
const isLoggedIn = this.state.isLoggedIn;

let button = null;
if (isLoggedIn) {
button = <LogoutButton onClick={this.handleLogoutClick} />;
} else {
button = <LoginButton onClick={this.handleLoginClick} />;
}

return (
<div>
<Greeting isLoggedIn={isLoggedIn} />
{button}
</div>
);
}
+ +

行内判断

例子:

+
function Mailbox(props) {
const unreadMessages = props.unreadMessages;
return (
<div>
<h1>Hello!</h1>
{unreadMessages.length > 0 &&
<h2>
You have {unreadMessages.length} unread messages.
</h2>
}
</div>
);
}

const messages = ['React', 'Re: React', 'Re:Re: React'];
ReactDOM.render(
<Mailbox unreadMessages={messages} />,
document.getElementById('root')
);
+ +

这段代码的工作方式跟 JavaScript 一致:

+
    +
  • true && expression -> expression
  • +
  • false && expression -> false
  • +
+

因此,当 unreadMessages.length > 0 为真时,后面的 JSX 会被渲染,反则不会。

+

除此以外还有三元表达式:

+
render() {
const isLoggedIn = this.state.isLoggedIn;
return (
<div>
The user is <b>{isLoggedIn ? 'currently' : 'not'}</b> logged in.
</div>
);
}
+ +

阻止渲染

在组件的 render 方法内 return null 会阻止组件的渲染,但是其生命周期不受影响。

+

循环

一个简单的例子:

+
const numbers = [1, 2, 3, 4, 5];
const listItems = numbers.map((number) =>
<li>{number}</li>
);

ReactDOM.render(
<ul>{listItems}</ul>,
document.getElementById('root')
);
+ +

循环组件

一个列表组件示例:

+
function NumberList(props) {
const numbers = props.numbers;
const listItems = numbers.map((number) =>
<li key={number.toString()}>
{number}
</li>
);
return (
<ul>{listItems}</ul>
);
}

const numbers = [1, 2, 3, 4, 5];
ReactDOM.render(
<NumberList numbers={numbers} />,
document.getElementById('root')
);
+ +

注意,这里对列表项添加了一个 key 属性。

+

Key

Key 是 React 用来追踪列表项的一个属性。跟 angular 以及 vue 中 track-by 的概念一样。

+

如果列表项没有唯一标识,也可以用索引作为 key (不推荐):

+
const todoItems = todos.map((todo, index) =>
// Only do this if items have no stable IDs
<li key={index}>
{todo.text}
</li>
);
+ +

注意:Key 只能直接在数组循环体内定义。如:

+
function ListItem(props) {
const value = props.value;
return (
// Wrong! There is no need to specify the key here:
<li key={value.toString()}>
{value}
</li>
);
}

function NumberList(props) {
const numbers = props.numbers;
const listItems = numbers.map((number) =>
// Wrong! The key should have been specified here:
<ListItem value={number} />
);
return (
<ul>
{listItems}
</ul>
);
}
+ +]]>
+ + react + +
+ + 正则断言 + /2021/regex-assertions/ + +

Assertions include boundaries, which indicate the beginnings and endings of lines and words, and other patterns indicating in some way that a match is possible (including look-ahead, look-behind, and conditional expressions).

+ +

断言是正则表达式组成的一部分,包含两种断言。本文记录了一些常用断言。

+ + +

边界类断言

^

匹配输入的开头。在多行模式匹配中,^ 在换行符后也能匹配。

+
/^A/.test('Apple');
// true

/^B/.test('Apple\nBanana');
// false

/^B/m.test('Apple\nBanana');
// true
+ +

$

匹配输入的结尾。在多行模式匹配中,$ 在换行符前也能立即匹配。

+
/e$/.test('Apple');
// true

/e$/.test('Apple\nBanana');
// false

/e$/m.test('Apple\nBanana');
// true
+ +

其它断言

x(?=y)

向前断言。x 被 y 跟随时匹配 x,匹配结果不包括 y。

+

举例:

+
/Jack(?=Sprat)/.exec('JackSprat');
// ["Jack", index: 0, input: "JackSprat", groups: undefined]

/Jack(?=Sprat)/.exec('Jack Sprat');
// null
// 因为多了一个空格,无法匹配

/Jack(?=\s?Sprat)/.exec('Jack Sprat');
// ["Jack", index: 0, input: "Jack Sprat", groups: undefined]
// 加上空格后匹配成功

/Jack(?=Sprat|Frost)/.exec('JackFrost');
// ["Jack", index: 0, input: "JackFrost", groups: undefined]
+ +

x(?!y)

向前否定断言。x 没有被 y 紧随时匹配 x,匹配结果不包括 y。

+

举例,匹配小数点后的数字:

+
/\d+(?!\.)/.exec('3.1415926');
// ["1415926", index: 2, input: "3.1415926", groups: undefined]
+ +

(?<=y)x

向后断言。x 跟随 y 的情况下匹配 x,匹配结果不包括 y。

+

举例:

+
/(?<=Jack)Sprat/.exec('JackSprat');
// ["Sprat", index: 4, input: "JackSprat", groups: undefined]

/(?<=Jack\s?)Sprat/.exec('Jack Sprat');
// ["Sprat", index: 5, input: "Jack Sprat", groups: undefined]
+ +

(?<!y)x

向后否定断言。x 不跟随 y 时匹配 x,匹配结果不包括 y。

+

举例,匹配小数点前的数字:

+

综合举例

匹配二级域名

匹配某个完整域名中的二级域名:

+
/\w+(?=\.daily\.xoyo)/.exec('https://tg.daily.xoyo.com/');
// ["tg", index: 8, input: "https://tg.daily.xoyo.com/", groups: undefined]

/\w+(?=\.daily\.xoyo)/.exec('https://tg.service.daily.xoyo.com/');
// ["service", index: 11, input: "https://tg.service.daily.xoyo.com/", groups: undefined]
+ +

社交场景

比如,某条 ugc 内容包含以下规则:

+
    +
  1. @某人 表示 @
  2. +
  3. #某话题 表示话题
  4. +
  5. [某表情] 表示表情
  6. +
+

某个字符串如下:

+
const str = '@大吧主 @小吧主 你们好,什么时候能把我的号解封[微笑][微笑] #狗管理 #玩不了了';
+ +

获取所有 @ 人

str.match(/(?<=@).+?(?=\s|$)/g)
// ["大吧主", "小吧主"]
+ +

获取所有话题

str.match(/(?<=#).+?(?=\s|$)/g);
// ["狗管理", "玩不了了"]
+ +

获取所有表情

str.match(/(?<=\[).+?(?=\]|$)/g);
// ["微笑", "微笑"]
+ +

一次性获取所有特殊内容

str.match(/(?<=@).+?(?=\s|$)|(?<=#).+?(?=\s|$)|(?<=\[).+?(?=\]|$)/g);
// ["大吧主", "小吧主", "微笑", "微笑", "狗管理", "玩不了了"]
]]>
+ + javascript + regex + +
+ + 《意外之旅》 + /2013/review-of-an-unexpected-journey/ + 本来也没有抱很大希望,所以算是乐在其中。最让我开心的是看到了熟悉的人和事物,戒指坠地的声音依旧震慑人心。甘道夫虽然说年轻了六十岁但是看起来更老了,另外就是大招的冷却时间明显缩短了。然后剩下的内容,基本可以用“吃饭睡觉打兽人”概括, 而且可以看出六十年前的兽人智商还不太发达。有点像成龙大哥的风格,相比艰辛,更多的还是幽默。

+

这一次只能说中规中矩,如果想要惊世骇俗吃老本肯定是不行的了,我设想的话,既然都不搞原著了,那么第三部不如来个惊天大逆转,矮人勇者斗巨龙团灭,甘道夫和比尔博灰头土脸踏上归乡之路,这叫道高一尺,魔高一丈!

+]]>
+ + personal + +
+ + 仙五感想 + /2013/review-of-cp5/ + 前两天觉得实在无聊,花了50人民币买了个仙5KEY,并且挂一晚上同时下载了5和5前,本来打算都玩玩看的,现在有谁想要玩的吗我给你KEY,不过只有一个哦,而且是5不是最新的5前。不准备购买5前KEY了。

+

老实说我也还没有耍通关,大概是走了一半多一点的剧情,不过确实没什么耍下去的愿望了,于是就广泛观察了一下广大玩家的意见,基本还是毁誉参半。支持者的观点也基本还是那几句,你不懂仙剑,你不懂感动,你不懂传承。反对的人,提出意见的人和建议改革的人一般都会被喷的很惨,尤其是那些用国外优秀游戏作品和仙剑做比较的,反观这个人群却能够更加理性的和广大支持者辩论。当然从四代开始正版的销量也成为了广大玩家所认可的仙剑成功的标志之一,在我个人看来这是一个很可怕的现象,如果仙剑系列的开发者也认为这是他们作品的一个成功标志的话那么这个游戏就彻底完蛋了。中国人开始买正版仙剑和上海软星的解散有直接关联,仔细想想是能够知道为什么的,这是一种民族情怀,不是因为它成功,而是希望它成功,希望终有一天我们的游戏文化也可以走出国门,而不是占山为王固步自封一万年。仙剑是国产游戏业的第一品牌,谁都希望它能够越做越优秀,这点还是统一的。

+

那么我也来说一说对5代的感想。

+

首先仍然是很传统的人设和故事,大大咧咧的男主角,误打误撞认识了一位知书达理的女主角,然后还有一个英俊帅气的男二号,和另外一位蛮横霸道的女主角,一共是四个人。然后就是混杂着各种纠缠不清的关系的剧情发展,到最后男一号自然是打败了为害人间的大魔头,但是却牺牲了其中一名美丽可爱的女主角,于是又引发了各种凄美的爱情故事。恩,至于5代后面的剧情我就是看攻略得来的了,暂时还没有亲身体会。这个主角阵容几乎从它祖宗开始就是这个模样,俊男美女闯六界,所以也没什么好奇怪的,这个剧情嘛也就这个样子,广大人民群众喜闻乐见。然后就是回合制战斗模式加强加强再加强版,仙剑虽然每一代都是回合制,但是又每一代都有新花样,这个新花样也会成为正式发布前的宣传重点之一。至于这一代的亮点,第一,我个人认为是李逍遥的回归,毕竟这一代是姚仙的孩子,给足了逍遥哥戏份,对于一代迷的我来说,很满意,第二,就是它的剧情配音,感情丰富,声调饱满,非常幽默,十分满意,逗笑了我很多次。5前对于角色数量方面似乎有很大创新,可能是基情与百合的发展使得游戏也不得不跟上时代的步伐。

+

然而这次我想说的重点不在这里。

+

一代的画面是仙剑系列永远的痛,于是后代仙剑人从来没有放弃过对画面的追求,从一代的数格子,到二代的线条2D,到三代的方块3D,再到4代5代的真3D。5代的画面在我看来已经非常成功了,各种光影,渲染,迷雾,反射,应有尽有,色彩鲜艳,场景宏大,角色的模型也是很有进步,丝毫没有愧对玩家的期待。问题就出在这里,在这如诗如画的梦幻般的游戏过程里,我完全感觉不到游戏制造者的诚意。

+

就提几点吧。都是些细节。

+

第一,仙剑奇侠传系列的主角们,从1995年至今,嘴巴从来就没有动过,但是他们却会说话。难道这也是特色传统之一吗?不要跟我说以前还没有这样的技术,李逍遥在1995年不用动嘴巴,到了2013年仍然是不用动嘴巴,这说明他天生就不用动嘴巴。腹语术。

+

第二,太空步无处不在,真的又好气又好笑,尤其是当角色上下楼梯的时候,已经不能用不自然来形容了,简直是灵异事件。当然,毫不客气的说,这也是传统之一。

+

第三,这点很重要,游戏角色永远只有屈指可数的动作,然而制作人又想要用这些动作来表达复杂多变的游戏情节,于是后面是怎样的一组情形就不必多言了。这个情况是从仙剑系列踏入3D,也就是第三代开始的,当时由于技术限制,我并没有太大的关注这个问题。可是到了今天游戏制作人仍然没有一丝一毫想要改进的意思,一方面想要让角色尽量生动,一方面又偷工减料不制作实时动作,让我感到非常可笑。一个人进门怎么表现呢?凭空消失呗。一个人给另外一个人一件事物怎么表现呢?手突然平举呗,事物还是腾空的呗。像这样的画面堂而皇之的出现在近距刻画中,在今天我觉得难以接受。

+

第四,历代都在期盼的角色实时换装系统千呼万唤不出来,再飘逸的服饰装备设计也失去了意义。不要说换了武器能体现,如果连这个都不能体现,我早喊QNMLGB了。

+

第五,角色进入居民屋可以翻箱倒柜,顺手牵羊,这个是真正的传统,我不知道姚仙在今天对于这个设计是怎么样的一个看法。

+

还有很多,不列举了,关于这些问题,只希望仙剑开发者有朝一日能发现并解决之,这将是对所有仙剑爱好者极大的鼓舞。这些就是细节,细节就是诚意。

+

至于我为什么半途就失去了将仙5通关的愿望,并不是因为游戏性,仙剑系列每一代的游戏性都半斤八两,只不过由于画面不断提高,所以才显得它的游戏性愈加飘渺,想要体验游戏性的话膝盖中箭才是最佳选择。我只是对这一代的主角全无好感而已。当然,对四代的主角也全无好感。这两代的人设简直就是同一个妈生的。

+]]>
+ + personal + +
+ + 《地心引力》 + /2013/review-of-gravity/ + +
  • 很独特的设定。地球很美丽,宇宙很黑暗。
  • +
  • 镜头经常走得很慢,处于宇宙中的各种物体也看似很慢,其实不然,经常在对比建立起来一瞬间就能感觉到可怕的速度。这样的镜头处理也让我觉得很独特。 而且对主角有很多又长又慢的镜头特写,然而影片的节奏并不慢。
  • +
  • 片头直入主题,片尾紧凑收官,90分钟全无拖沓,难能可贵。
  • +
  • 非常酷炫的3D效果,感觉又是一次突破,太空碎片往荧幕外飞的时候老夫的面部神经抽搐了很多下,从未有过的体验。感觉imax的地心引力会非常精彩!
  • +
  • 女主遇到的连续挫折感觉已经超出了人类所能接受的极限,即使在最后一刻,依然存在挑战。
  • +
  • 太空垃圾真的不会形成一个地球专属的小行星带吗?
  • + +]]>
    + + personal + +
    + + 《西游记之孙悟空三打白骨精》 + /2016/review-of-the-monkey-king-2/ + 有剧透。

    +

    2016年看的第一场电影,昨天,三打白骨精。有点晚了。春节档唯一感兴趣的就是它。

    +

    美人鱼看了预告片和简介,结合各种评分短评影评,我觉得它远远没有达到期望。个人认为评分7分上下,对于其它电影或许是“值得一看”,对于周星驰的电影来说,只能相当于“不是垃圾”。这是在砸招牌。更不用说很多人的高分只是给的这块招牌。

    +

    说回到三打白骨精(以下简称三打)。虽然它评分不高,虽然我看过的国产电影不多,虽然它题材滥上加滥,但我还是要说,这是我看过的最好的最值的最不坑的国产爆米花电影,比去年的口碑高峰寻龙诀还要好上不少。国产电影能有这么大的进步,作为一个普通电影爱好者我是觉得很高兴。

    +

    为什么说三打要比寻龙诀好呢。其实它的特效没有比寻龙诀高,尤其是3D这一方面,但是,三打把电影的使命捡了回来,就是讲故事。寻龙诀根本就没有在讲故事,看的过程中就感觉各种特效乱飞,火花四溅,然后就结束了。然而三打不一样,它做到了特效为故事服务。虽然一路走过来依然很酷炫,但是作为观众我能感受到重要的角色都有它背后的故事,以及正在发生的故事。能感受到角色的立体度。实实在在的角色,而不是只活在大银幕这个平面之上。

    +

    三打对原故事进行了不少的改编,以往的国产电影很多改编都是坑爹,但是我认为这些改编却偏偏很多都是恰到好处的。为什么呢。因为改编后的电影可以让观众更加关注于主要的故事其本身,另外节省说故事的时间。就比如说,我们都知道师傅是如何收的二师兄以及沙师弟,但是电影就将其极简化了,他俩简单粗暴地一起搭上了大师兄的顺风车。这么做虽然当时看的时候觉得有点怪,但是事后想想是非常妙的。观众不需要导演去告诉他们师傅在白骨精之前是怎么走过来的,90%的观众都知道这背后到底是怎么回事,观众看的电影叫三打白骨精,直入主题。这样的改编在电影中还有不少,我认为都是为了简化故事结构突出主线而生。

    +

    但是,有几处改编,却又是在“三打白骨精”这个原著故事上做出了扩展。这也是很有意思的一点。电影把无关紧要的剧情都尽量简要交代,然后竭尽所能地拓展主线。原著故事没有吃人血的国君,没有白骨精的前世今生,也没有佛祖亲自收它,白骨精之于大师兄更是蝼蚁之于巨象。但是,电影偏偏在这么一个简单的故事上脱离了纯爆米花的低级趣味:要探讨人性,要探讨佛性,要挖掘黑暗面。其实我觉得如果要更有意思一点的话,其它可以有,白骨精还是不要那么强的好,就保持原著的水平,千年修行,最后被大师兄一棍子打死,然后师父再舍生取义,再打死师傅,更探讨,更黑暗。不过这么搞特效就没法做了。

    +

    此外,看了那么多的西游电影电视剧,貌似也只有三打在真正地学习老版西游记的精华。不是说它的“二师兄,师傅被妖怪抓走了”之类的吐槽以及片尾曲,而是说只有这只猴子以及老版西游记的猴子是在演猴子。看得出郭天王努力地在向六小龄童大师学习,各种动作都是以猴为基准,而不是人,虽然水平是差了一个筋斗云,但是最起码有认真地去学。要是不说他是郭富城我估计真没多少人能猜得出来,说得夸张些,他的影子里只有猴。如果说老版西游记的猴子是精华,那么师傅就是糟粕。三打不但吸取了精华,还扔掉了糟粕。这里面的师傅,虽然在大圣和妖怪面前看起来依然是手无缚鸡之力,但是,重要的一点,这是一个有主见,有信仰,有觉悟的师傅,是不辱其名的圣僧(吐槽一下电影的圣僧之翻译:Holy monk,上帝的和尚)。多说无益,看过便知。

    +

    要说缺陷的话,自然还是不少,不然不会只有5+的评分。二师兄和沙师弟是打了整场的酱油,就俩高级步兵,除了会吐槽以外屁用没有。认真想想的话其实有他俩没他俩剧情根本一模一样,即使最后大师兄回家了也不是二师兄给讨回来的。电影的审美过于西化了,比如小白龙的形象,比如白骨精的形象。但是,瑕不掩瑜,还是要说,这是我看过的最好的最值的最不坑的国产爆米花电影。

    +]]>
    + + personal + +
    + + 用 PM2 代理静态文件 + /2017/serve-static-with-pm2/ + 命令 (2.4.0+):

    +
    $ pm2 serve <path> <port>
    + +

    举例:

    +
    $ pm2 serve /dist 80
    + +

    默认情况下,如果页面未找到,它将显示 404.html 目录中的文件 (无法配置)。

    +]]>
    + + nodejs + pm2 + +
    + + 简单 CSS 实现暗黑模式 + /2021/simple-css-dark-mode/ + + +
    @media (prefers-color-scheme: dark) {
    html {
    filter: invert(90%) hue-rotate(180deg);
    }

    img, video, svg, div[class*="language-"] {
    filter: invert(110%) hue-rotate(180deg);
    opacity: .8;
    }
    }
    + +

    具体效果参考本站(打开系统级别的暗黑模式)。 解释:

    +
      +
    1. invert 将所有色值反转,hue-rotate 将黑白以外的其它主色调再反转回来(防止页面主题色出现大的变化);
    2. +
    3. 网上的 invert 通常取值为 100%,但是这样反转得到的黑色往往太过黑,眼睛看起来有点累,因此我觉得 90% 是一个更合理的值;
    4. +
    5. 将图片、视频等其它不需要被反转的元素再反转回来,并加一个透明度,让其不那么刺眼;
    6. +
    7. 如果 html 反转 90%,则图片等元素需要反转 110%
    8. +
    9. div[class*="language-"] 对应的是本站 (VuePress) 上的代码块。
    10. +
    +]]>
    + + css + +
    + + Simplest Wechat Client on Linux + /2018/simplest-wechat-client-on-linux/ + 微信没有为 Linux 提供桌面客户端,可用的替代方式有:

    +
      +
    1. 使用网页版微信
    2. +
    3. 使用第三方客户端,如 electronic-wechat
    4. +
    5. 自己动手,将网页版微信封装为桌面应用程序
    6. +
    +

    但是每种方式都有不尽人意的地方。网页版总是嵌入在浏览器中,用起来不太方便;第三方客户端安全性无法保证;自己做一个客户端又太麻烦。

    +

    然而,实际上还有一种更简单的方式:通过 Chrome 将网页直接转化为桌面应用。

    + + +

    步骤:

    +
      +
    1. 使用 Chrome 打开网页版微信
    2. +
    3. 右上角设置,More tools -> Create shortcut...
    4. +
    5. 然后就可以在 Chrome Apps 中找到微信了
    6. +
    +

    通过此方式创建的 Apps 同时拥有桌面应用的表现以及网页版的功能,并且可以将它固定到 Dock 栏,以及独立于浏览器运行,只能用「完美」两个字形容。

    +

    除微信外,其它缺少 Linux 客户端但有网页客户端的应用亦可如法炮制,如有道云笔记等。

    +]]>
    + + linux + +
    + + Windows 无法删除 Node_modules 文件夹的解决方案 + /2016/solution-to-windows-cant-remove-node-modules-folder/ + 在 Windows 操作系统下开发 NodeJS 项目的时候经常会遇到无法删除 Node_modules 文件夹的尴尬(因为依赖过多,文件路径长度爆炸),解决办法如下。

    + + +

    全局安装 rimraf 模块到系统下:

    +
    npm install -g rimraf
    + +

    CD 到相应文件夹,执行如下指令:

    +
    rimraf node_modules
    + +

    等待其完成即可。

    +

    其实这个模块也可以用来删除其它无法正常删除的东西,挺好用的。Node 用习惯了以后可以为系统提供许多便利,比如说现在我都不怎么使用系统自带的计算器了,直接 WIN + R + NODE 就可以得到一个 Node 环境下的计算器,非常快捷。

    +]]>
    + + nodejs + windows + npm + +
    + + 留念 + /2013/some-memory/ + 玩了那么多年,经历了大大小小的版本更替,如今已脱胎换骨,与当年小小的一张地图相比已经面目全非,不得不说这也是一种软件的生命周期,或者开发模式。陪伴了老夫孤独寂寞的高中时光,消磨了大量宝贵的大学时间,亦由此对它产生了深厚的感情。如今大家各奔东西,在一起的时间越来越少,于是dota也渐渐变得没什么意思,偶尔上线也只是习惯所趋。

    +

    我还记得我玩的第一个英雄是胖子,那么第一把自然是坑队友了,然后下一把玩了个传说哥,大家都懂的。不过好在老夫war3功底雄厚,渐渐也有了起色,开始没打算继续下去的,是因为身边的朋友在老夫带领下居然也开始喜欢dota,于是大感欣慰,遂征战至今,主要也是因为当年3C实在前路渺茫。朋友不多不少,刚好足够开一间黑店,可惜连跪几乎已是命中注定的剧本,于是我又要批评一下达Q了,你TM能不能不要裸秘法。徐尘是老夫最喜欢的选手,低调不失华丽,实力与智商兼顾,还会拍马屁。至于贝伦同学尽心尽力辅助了这么多年,只能说辛苦了。椰子同学自从退伍归来后实力大减,简直成为团队毒瘤。。好吧开玩笑的。

    +

    从最开始的QQ平台开始,也不知道到底耍了多少把了,印象深刻的也没有多少,只能说记性不好。无数个白昼与通宵,就在这上面一点一点的消逝去了,这里却留下不少回忆,比如小林被他不知道什么亲属拽回家去的那晚,以为是个抢劫的,老夫差点就拍案而起。还有一次和徐尘通宵,第二天一早老夫回学校睡觉,下午睡醒吃饭回到网吧看到他居然还坐在那里继续操作,那个哭笑不得。记得那时候的水饺,炒饭,泡面,汽水,和各式各样的FirstBlood。

    +

    如今大势已去,dota虽然还在发展,却已不适合你我,只能当做茶余饭后之娱乐了。不过也好,人总要成长。

    +]]>
    + + personal + +
    + + JavaScript 的一些古怪之处 + /2016/some-oddities-about-javascript/ + 大概一年前在看一本介绍JavaScript与jQuery的书籍之时看到了这么一个有趣的章节,当时印象挺深刻的。现在突然回想起来了这回事,于是就重新翻出来做了个笔记。作者将这些材料归结为两类:神奇的知识点以及WTF。这里去除了与浏览器有关的部分,因为那些和JavaScript本身并没有关联。

    + + +

    数据类型与定义

    NULL是一个对象

    不同于C或者Java之类的语言,JavaScript的 null 值是一个对象。也许你会说“null 应该定义为一个完全没有意义的值”,也许你是对的,然并卵,事实是:

    +
    alert(typeof null); //object
    + +

    尽管如此,null 并不是任何对象的一个实例(补充:JavaScript中的所有“值”都是基本对象的实例,比如说数字是 Number 对象的实例,字符串是 String 对象的实例,所有对象都是 Object 对象的实例,等等)。于是我们可以理智地认为:如果 null 代表的是没有值,那么它就不能是任何对象的实例。因此下面的表达式应该返回 false

    +
    alert(null instanceof Object); //evaluates false
    + +

    NAN是一个数字

    你以为 null 是一个对象已经够离谱了吗,too young too simple!NaN→ Not a Number → 它是一个数字。还有更过分的呢,它甚至不等于它自身。我受到了伤害。

    +
    alert(typeof NaN); //alerts 'Number'
    alert(NaN === NaN); //evaluates false
    + +

    事实上,NaN 不与任何值相等。如果想要判断一个值是不是 NaN,唯一的办法是通过调用 isNaN() 函数。

    +

    空数组==FALSE

    这个特性其实很受欢迎的呢:

    +
    alert(new Array() == false); //evaluates true
    + +

    要弄明白这里面到底发生了什么事,首先要知道在JavaScript世界中真假相的概念。它在逻辑上有一些简化。

    +

    作者认为最简单的理解方式是:在JavaScript的世界中,所有非布尔类型的值,它们都存在有一个内置的布尔类型标志位,当该非布尔值在要求做出布尔类型的比较时,实际上调用的是它的标志位。

    +

    (我觉得理解为JavaScript有内置的比较逻辑表也是可以的吧)

    +

    因为苹果没办法和梨比较,猫不能和狗比较,因此当JavaScript需要比较两种不同类型的数值时,它要做的第一件事必然是将其强转为通用的可比较的类型。FalsenullundefinedNaN,空字符串以及零到最后全都会变成 false。不过这当然不是永久的,这种转换只在特定的表达式(布尔表达式)中生效。

    +
    var someVar = 0;
    alert(someVar == false); //evaluates true
    + +

    以上就是一个强转的例子。

    +

    至此还没有开始讨论数组的行为呢。空数组是一件非常奇特的事物,它们实际上是表示真,但如果你拿它来做布尔运算,它又是假的。我总觉得这里面隐藏着什么不可告人的秘密 (¬_¬)

    +
    var someVar = []; //empty array
    alert(someVar == false); //evaluates true
    if (someVar) alert('hello'); //alert runs, so someVar evaluates to true
    + +

    为了避免类似的困扰,我们可以使用全等操作符(三个等号,同时比较类型与值):

    +
    var someVar = 0;
    alert(someVar == false); //evaluates true – zero is a falsy
    alert(someVar === false); //evaluates false – zero is a number, not a boolean
    + +

    这个问题十分广泛,这里也就不过多介绍了。如果想要深入了解其内部原理,可以阅读ECMA-262标准之11.9.3章节文档。

    +

    正则表达式

    REPLACE()可以接受回调函数

    这绝对是JavaScript最为隐秘的特性之一,从1.3版本之后加入。绝大多数人都是这么用它的:

    +
    alert('10 13 21 48 52'.replace(/\d+/g, '*')); //replace all numbers with *
    + +

    (原文中有一些疏忽,比如使用了 d+ 而非 \d+,这里均做出了修正)

    +

    简单的替换,字符串,星号。但如果我们想要更进一步的控制呢?比如我们只想替换30以下的数字?这个逻辑通过正则来实现会较为困难,毕竟它不是数学运算,我们可以这样:

    +
    alert('10 13 21 48 52'.replace(/\d+/g, function(match) {
    return parseInt(match) < 30 ? '*' : match;
    }));
    + +

    这段代码的意思是,如果匹配到的字符串转换为整型数值后小于30,则替换为星号,否则原样返回。

    +

    不仅仅是比较和替换

    通常情况下我们都只用到了正则表达式的比较和替换功能,但其实JavaScript提供的方法远远不止两个。

    +

    比如说 test() 函数,它和比较十分类似,但它不反回比较值,只确认字符串是否匹配。这样代码可以更轻一些。

    +
    alert(/\w{3,}/.test('Hello')); //alerts 'true'
    + +

    以上表达式判断了字符串是否有3个或以上的字符。

    +

    还有就是 RegExp 对象,通过它我们可以构建动态的正则表达式。一般情况下正则表达式都是通过短格式声明的(封闭在斜杠中,就像上面所用到的)。这么做的话,我们不能在其中插入变量。当然,我们还有 RegExp

    +
    function findWord(word, string) {
    var instancesOfWord = string.match(new RegExp('\\b'+word+'\\b', 'ig'));
    alert(instancesOfWord);
    }
    findWord('car', 'Carl went to buy a car but had forgotten his credit card.');
    + +

    这里我们基于 word 参数构建了一个动态的正则表达式。这个函数会返回car作为独立单词在字符串中出现的次数。本例只有一次。

    +

    由于 RegExp 使用字符串来表示正则表达式,而非斜杠,因此我们可以在里面插入变量。但是,与此同时,需要注意的是,表达式中特殊符号前的反斜杠我们也要写两次(转义处理)。

    +

    函数与作用域

    你可以伪造作用域

    作用域决定了变量可以在哪些地方被访问。独立(即不在函数内部)的JavaScript可以在全局作用域(对浏览器来说是 window 对象)下访问,函数内部定义的变量则只能在内部访问,其对外部不可见。

    +
    var animal = 'dog';
    function getAnimal(adjective) { alert(adjective+' '+this.animal); }
    getAnimal('lovely'); //alerts 'lovely dog';
    + +

    这里,我们的变量和函数都是在全局作用域下定义的(比如 window)。因为 this 总是指向当前作用域,因此在本例中它指向了 window.animal,于是就找到了。一切看起来都没问题。但是,我们可以骗过函数本身,让它认为自己执行在另一个作用域下,并无视其原本的作用域。我们通过调用内置的 call() 函数来达到目的:

    +
    var animal = 'dog';
    function getAnimal(adjective) { alert(adjective+' '+this.animal); };
    var myObj = {animal: 'camel'};
    getAnimal.call(myObj, 'lovely'); //alerts 'lovely camel'
    + +

    在这里,函数不在 window 而在 myObj 中运行 — 作 为 call 方法的第一个参 数。本质上说 call 方法将函数 getAnimal 看成 myObj 的一个方法(如果没看懂这是什么意思, 你可能需要去看一下 JavaScrip t的原型继承系统相关内容)。注意,我们传递给 call 的第一个参数后面的参数都会被传递给我们的函数 — 因此我们将 lovely 作为相关参数传递进来。尽管好的代码设计不需要采用这种伪造手段,这依然是非常有趣的知识。apply 函数与 call 函数作用相似,它的参数应该被指定为数组。所以,上面的例子如果用 apply 函数的话如下:

    +
    getAnimal.apply(myObj, ['lovely']); //func args sent as array
    + +

    函数可以自执行

    显然:

    +
    (function() { alert('hello'); })(); //alerts 'hello'
    + +

    这个语法非常简单:我们定义了一个函数,然后立刻就调用了它,就像调用其它函数一样。也许你会觉得这有些奇怪,函数包含的代码一般都是在之后执行的,比如我们想在某个时刻调用它,既然它需要立即执行,那为什么要把代码放在函数体内呢?

    +

    自执行函数的一大用处就是将变量的当前值绑定到将来要被执行的函数中去。就比如说回调,延迟或者持续运行:

    +
    var someVar = 'hello';
    setTimeout(function() { alert(someVar); }, 1000);
    var someVar = 'goodbye';
    + +

    这段代码有一个问题,它的输出永远都是goodbye而不是hello,这是因为timeout中的函数在真正执行之前永远不会去关心里面的变量发生了什么变化,到那时候,someVar 早就被goodbye覆盖了。

    +

    (JavaScript新手经常会犯的一个错误就是在循环中定义事件,并且将index作为参数传入,到最后发现真正绑上了事件的只有最后的那个元素,这也是同理)

    +

    解决办法如下:

    +
    var someVar = 'hello';
    setTimeout((function(someVar) {
    return function() { alert(someVar); }
    })(someVar), 1000);
    var someVar = 'goodbye';
    + +

    在这里,被传入函数中的相当于是一个快照,而不是真正的变量本身。

    +

    其它

    0.1 + 0.2 !== 0.3

    其实这是计算机科学中的一个普遍问题,我已经在很多编程语言中都发现了它的影子,它是由浮点数不能做到完全精确导致的。实际的计算结果是0.30000000000000004

    +

    如何解决,归根到底取决于计算需求:

    +
      +
    • 转换成整型计算,而后再转回浮点
    • +
    • 允许某个范围内的误差
    • +
    +

    因此,与其:

    +
    var num1 = 0.1, num2 = 0.2, shouldEqual = 0.3;
    alert(num1 + num2 == shouldEqual); //false
    + +

    不如:

    +
    alert(num1 + num2 > shouldEqual - 0.001 && num1 + num2 < shouldEqual + 0.001); //true
    + +

    这就是一个简单的允许误差的办法。

    +

    UNDEFINED可以被DEFINED

    这个看起来有点蠢了。undefined在JavaScript中其实不是一个关键字,尽管它一般是用来表示一个变量是否未被定义。就像这样:

    +
    var someVar;
    alert(someVar == undefined); //evaluates true
    + +

    然而也可以这样:

    +
    undefined = "I'm not undefined!";
    var someVar;
    alert(someVar == undefined); //evaluates false!
    + +

    看起来很有趣的样子……

    +]]>
    + + javascript + +
    + + 记一次项目经历 + /2016/some-project-memo/ + 前几天收到一个项目请求,其实是某人希望做个简单的毕设代码实现。因为去年毕业季的时候帮同学的一些朋友做过毕设项目,因此找到了我,希望继续帮忙。因为这种东西一般都比较简单,所以我也没想很多就答应了。

    + + +

    这位同学通过 QQ 联系上了我。她的具体需求是一个基于 C# 的 ASP.NET 网站,就几个页面,非常简单,但是相比去年做的那几个稍复杂些,因此我提高了要价,开价 500,为了让对方确信我没有在坐地起价,我还把她师姐们的需求文档都发了过去,让她自己对比。

    +

    然而,对方依然觉得太贵了,说想要“友情价”。我觉得挺搞笑的,脸皮很厚嘛。不过我也不想扯皮,就当你是个穷困潦倒的学生吧,大家都经历过,我也当做好事了,于是就降到了 400,说实话这个价格我是真不想做。虽然只需要一天,但是没意思,就跟上班一天一样,而且还是加班,还没有双倍工资。

    +

    其实除了这些,更让我觉得难过的是,对方是“几乎什么都不懂”,因此我后期可能还有非常多的工作需要做。在对方论文完成以前,我可能会成为免费技术顾问,而且她会觉得这是我理所当然应该做的。

    +

    约定的交货期限大概是十天的样子。于是就开始了。

    +

    大概在第五天的时候,对方想找我要数据库的截图,我说干嘛呢,她说贴论文里,要交初稿了。我去,合着我还帮你写论文呢。然而这时间我还没开始动手,我也没有义务提前交货,于是说想要就给加班费。对方就放弃了。

    +

    后来几天没有联系过。到了约定日期我把项目通过 QQ 发送给了对方,第二天早上就发现自己给删了好友。

    +

    其实故事到这里本该结束了。我没花多少时间,也不在乎这点钱。爱给不给吧。

    +

    然而,好在我有一个责任感强烈的“经纪人”。

    +

    就是那位给我介绍这个项目的同学,我简单地说明了一下情况,她深感自己把我坑了,于是千辛万苦帮我追数。其实我也挺过意不去的,这说到底是我的疏忽,现在反而要麻烦别人。我一直强调没事算了吧,然而“经纪人”始终不肯善罢甘休。

    +

    在这期间,有一些不知道是与项目主人何种关系的人来联系我,希望通过支付部分款项以息事宁人等。然而这些人的交流方式让我略感奇怪,三句不离同情,说得我跟个要饭的一样,因此没有同意。

    +

    最终,在“将作弊行为告知导师”的压力下,项目主人现身道歉,并且支付了全款。

    +

    后来,我反思了一下。我是以在校学生的思维方式来对待这件事情的,其实最近我也越发觉得这种思维方式让自己在社会中非常吃亏。现在来说,至少我也应该尊重自己的劳动力吧。同时,自己的错误也给别人带来了不必要的麻烦。

    +

    至于项目主人那边的几位,我只能说“人各有志”。收到钱的第二分钟,她们就全在我的黑名单里面了。

    +]]>
    + + personal + +
    + + Static Blog Built with Vue + /2016/static-blog-built-with-vue/ + 博客再次迁移,这次是从 Wordpress 转向静态博客(自建)。

    +

    技术栈:

    +
      +
    • 前端:vue + vue-router + vuex + bootstrap + webpack
    • +
    • 服务端:没有
    • +
    • 数据库:没有
    • +
    +

    整站打包后,一次加载所有资源(HTML + CSS + JS + DATA)300K 不到(gzip 后 80K+),秒速渲染,与先前真的是天差地别。

    +

    图片资源从本地服务器搬迁到免费云。 写作使用 Markdown,从此 IDE 写博客不是梦。

    +

    代码地址:https://github.com/wxsms/wxsms.github.io/tree/src

    +]]>
    + + personal + +
    + + 过期鸡汤 + /2016/tasteless-chicken-soup/ + 花了两个晚上读了最近挺火的一本书,名曰《解忧杂货店》,同时也是我看过的第一本日本小说。看完以后只有一个感觉:这大概是过期的鸡汤吧。一点味道都没有。与此同时,总觉得有些什么地方不对。现在认真想了想,果然是奇葩。由于不清楚日本文化,也不知道该说是日本人奇葩,还是说仅仅是故事或者作者奇葩。

    + + +

    这本书大概讲述了这么一件事:

    +

    三名无业青年爆了一个老板娘的格,跑路途中车抛锚了无奈躲进一个荒废的屋子里,后发现有信投入,内容是吐槽烦恼。回信后立马又收到了回信,终此往复。由于咨询者不知手机为何物,因此闹洞大开认为这屋子大概是个时空机器BlaBlaBla,迷途的少年感觉找到了人生的价值,摇身一变成为烦恼终结者。接下来就是各种各样奇葩的往事,各种咨询,然后通过一个孤儿院把大家都联系在了一起,最后回到现实少年发现刚爆的可怜老板娘就是最后一个与自己通信的人。少年们随即决定重新做人,义无反顾地开始捡肥皂生涯。

    +

    故事的核心是“一间能够连接时空的杂货店”,因此它本身是一个和时空有关的故事。这种故事太多太多了,现在读起来已经不是特别有趣,同时也非常容易出BUG,然而我想吐槽的东西不在这里。简单地说,故事里的主角们,基本都是重度自私+自恋狂。当然这里要除去杂货店主那爷俩,他俩没有任何特别之处,普通人。以下的吐槽也不是针对他俩。

    +

    什么意思呢,大概就是脑子里想的永远都只有自己,无论别人发生什么事,只要一切按照自己的意愿来就行。什么爱人,什么父母,都不在考虑范围内。有些故事的角色看起来是在非常无私地处处替别人考虑,比如运动员的男友,比如富二代的老爸,然而这却是更可怕的自私。他们都有如下特点:完全不考虑别人的感受,完全不给人选择的余地,然后依然是一切按照自己的意愿来就行。

    +

    看这本书的时候就觉得,那些人做那么多事情,完全都不需要理由的啊,或者自己觉得这么做有理就一条路走到黑做下去了,根本就不管对身边的人有什么影响,反正老子喜欢就要干。此外,也看不出角色们有什么心理活动,感觉大家都只有一根筋。

    +

    难道这就是日本人的特征吗?

    +

    除此以外,基本上所有故事都是主流鸡汤文,以挫折、烦恼和梦想为主题。最后大家通过自己的努力(辅以杂货店的建议),都达到了自己所有或者部分的目标,或者干脆啥都没做成,但最后还是觉得自己得到了升华,甚至干脆整个人升华。整本书没有任何起伏跌宕,所有故事就那么以第三人称视觉丝毫不带感情地展开了。所以说它无味。

    +

    作者说,希望读者在合卷的时候能够喃喃自语曰“从来没有读过这样的小说”。我想说的倒是,从来没有读过这么无聊的鸡汤。赵国的鸡汤起码能给人打鸡血,你国连鸡血都打不了。

    +

     

    +]]>
    + + personal + +
    + + 对 ReactNative 的一些想法 + /2018/thoughts-of-react-native/ + 使用 ReactNative 开发半年有余,本文是作为一些简单的感想。

    +

    官网简介:

    +
    +

    Build native mobile apps using JavaScript and React.

    +
    +

    简约,不简单。看着很牛逼,但实际用起来总是差了点意思。

    +

    总而言之:帮你节省时间的同时,隐藏着无处不在的坑。

    + + +

    关于框架本身

    一个东西要辩证地看:ReactNative 的伟大之处在于它的定位,再一次验证了一句古老的预言:一切能用 JavaScript 实现的东西,终将被 JavaScript 实现。然而就目前的状态来看,还有许多问题。

    +

    使用 ReactNative 的目的是:让不会写原生 App 的人,通过 JavaScript 以及 React 也能编写出原生 App,并且跨 ios / Android 平台,乍一看相当美好。然而实际用过以后会发现,这其实是一个悖论,因为:

    +

    如果开发者真的完全对原生开发一窍不通,那么他根本不应该使用 ReactNative,因为他一旦遇到问题将完全没有任何解决能力,除了 Google -> Try -> Fail -> Google 直到成功(大部分时候也许是“看起来成功”)以外毫无办法。

    +

    使用一项技术的前提是,至少对其有所了解。而普通 JavaScript 用户使用 ReactNative,简直就像是在对着一个黑盒子编程,没有任何可靠性可言。这也是我在开发初期的真实情况:出 bug 了,不知道为什么,解决了,也不知道为什么。处于一种非常恐慌的状态。

    +

    也许你会说,Electron 不也是这种模式吗?那我为什么没有吐槽它呢?是的,他们俩“看起来”是一样的,但是实际上又完全不一样:

    +
      +
    • Electron App 实际上是一个 Hybrid App,开发者写的 HTML 代码不需要经过任何处理,直接使用浏览器内核解析、显示,整个过程是透明的、可控的
    • +
    • ReactNative App 是一个真正的 Native App,开发者写的任何组件都会先被转化为原生组件,然后才显示给用户,而这个转化过程是一个黑盒子,是不可控的
    • +
    +

    因此,理想与现实总是存在差距。ReactNative 开发者不能闭门造车,一定要不断地深入底层,才能真正明白自己“在干嘛”以及“该怎么干”。这也正是悖论所在:既然如此,我为什么不从一开始就使用原生方式编写 App 呢?当然,使用 ReactNative 还有另一个重要原因,即提供跨平台开发的可能性。但要知道,它在节省大量时间的同时,也给项目组带来了大量的限制和坑。

    +

    关于这个项目

    ReactNative 毫无疑问是一个相当庞大的项目。

    +

    目前 ReactNative 还没有发布 1.0 版本,也就是说项目依旧在发展期。目前来说,我觉得最大的一个问题是项目升级问题。项目保持快速发展当然很棒,但是如何能够让现有的版本升级到最新版本呢?这对于实力不强的开发者来说几乎是不可能事件。

    +

    主要原因:

    +
      +
    • MINOR version 会包含大量 breaking changes,无痛升级不存在的
    • +
    • 也许要同时升级 React 版本
    • +
    • 第三方库不一定兼容,尤其是涉及底层的
    • +
    +

    另一方面,向 ReactNative 提 PR 可要比向其它 JavaScript 项目提 PR 门槛要高得多:JavaScript / ios / Android 你至少得会其中两个才行。

    +

    我说这个不是为了别的。我在 issues 下面最常看到的一句话就是:

    +
    +

    Hi there! This issue is being closed because it has been inactive for a while. Maybe the issue has been fixed in a recent release, or perhaps it is not affecting a lot of people. Either way, we’re automatically closing issues after a period of inactivity. Please do not take it personally!

    +
    +

    可以说相当无情了。关闭一个 issue 的原因,可以是 maybe,可以是 perhaps,极少有 resolved,这就是现状。

    +

    我理解它是一个开源项目,开发者的时间有限,更没有义务。但这可以为使用者提供一些参考。ReactNative 存在超大量诸如此类的 issue,没有被 fix,更没有 fix 计划,有很大一部分其实是非常基础的诉求,比如图文混排,这在 Android 平台下已然是不可能事件。

    +

    因此,综上来说,如果你对 ios/ Android 并不精通,那你一旦遇到棘手的问题,只能祈祷:

    +
      +
    • 还有更多、非常多的人遇到了跟你同样的问题
    • +
    • 并且引发了激烈的讨论
    • +
    • 并且成功地被开发者修复了
    • +
    • 并且没有跨很多版本
    • +
    +

    否则还是歇着吧。

    +

    掉坑总结

    正如上文所言,ReactNative App 最主要的功能之一是 Layout 绘制,而它自带的黑盒子属性,也正是最大的坑之所在

    +

    简单来说,你写了一个控件,如果不经过测试的话:

    +
      +
    1. 你不知道它是否能在 ios 下正常表现、工作
    2. +
    3. 你也不知道它是否能在 Android 下正常表现、工作
    4. +
    5. 你更不知道它是否能在两个平台之间保持一致
    6. +
    +

    总而言之,如果你不真的去试试,那你什么都不知道。也许它在 ios 下完全正常,在 Android 就直接崩溃了。

    +

    ReactNative 提供了许多基础的跨平台组件,但是他们基本上都各有各的坑,更有组合坑。比如:

    +
      +
    • <Text> 中不能有 <View> (Android 崩溃)
    • +
    • <Text> 中不能有 <Image> (Android 显示异常)
    • +
    • <Image> 不能同时使用 borderRadiusbackgroundColor 样式 (Android 显示异常)
    • +
    • overflow 样式在 Android 下无效,始终表现为 hidden
    • +
    • 等等…
    • +
    +

    (冰山一角)

    +

    以上所说的“异常”,是无法通过适配得到解决的异常,也就是说你一定不能这么用。这些有的在文档里会标为“已知问题”,有的则没有,如果你是一个新手,那么处处都存在着惊喜等待你去发掘。

    +

    除此以外,还有一个显著问题就是,在 ReactNative 的世界中,Debug 是不完全可靠的。因为它在 Debug 时用的是开发电脑上的 chrome 附带的 JavaScript 引擎,而在真正运行时则使用手机内置浏览器的 JavaScript 引擎。虽然大部分时候你感觉不到差异,但是一旦出现了差异则往往是致命的。

    +

    更新

    2020/10/05

    +

    MINOR version 会包含大量 breaking changes,无痛升级不存在的

    +
    +

    关于这一点,目前我理解了:因为 React Native 至今还是 0.x 版本,没有发布正式版。也就是说,不保证 minor 版本号能够兼容。

    +

    时隔两年,再回来看当年写的这篇文章,感觉写得还是挺对的。当然上面提到的一小部分问题,在今天的版本已经被修复了。不过总体的问题依然存在,在享受双端开发的快感同时,就必须要接受它带来的诸多问题和限制。

    +]]>
    + + javascript + react-native + +
    + + 对 Moment.js 的一些想法 + /2018/thougths-about-momentjs/ + Moment.js 是一个流行的基于 JavaScript 的时间处理工具库。应该是一个从 2011 年开始启动的项目,至今它的 Github repo 也有了 3w+ 的星星,可以说在前端界人尽皆知了。反正我自从用了它基本上就没再接触过其它的相关库。

    +

    但最近我却对它的看法却产生了些许改变。原因是,它的 API 设计给使用者埋下了巨大无比的坑,简单来说:“名不副实”。

    + + +

    具体看图吧:

    +

    strange-moment-js

    +

    很明显,调用 Moment.js 的 API 产生了预期之外的副作用。函数在带有返回值的同时却又对原始值进行了修改,违反了基本的 OO 设计原则。

    +
    +

    Command–query separation (CQS) is a principle of imperative computer programming. It was devised by Bertrand Meyer as part of his pioneering work on the Eiffel programming language. It states that every method should either be a command that performs an action, or a query that returns data to the caller, but not both. In other words, Asking a question should not change the answer. More formally, methods should return a value only if they are referentially transparent and hence possess no side effects.

    +
    +

    也就是说,设计一个函数,它应该:

    +
      +
    • 要么进行操作(Mutable);
    • +
    • 要么进行返回(Immutable);
    • +
    • 但,以上两点不能同时进行。
    • +
    +

    这里的“返回”,我的理解不是所有类型的返回,而是特指与原始值相对应的返回。

    +

    比如说,在 JavaScript 世界中 array.slice 是一个 Immutable 类型的函数,它不会对输入值进行改变,而是返回一份 copy:

    +
    +

    The slice() method returns a shallow copy of a portion of an array into a new array object selected from begin to end (end not included). The original array will not be modified.

    +
    +

    array.splice 则不同(它虽然也有返回值,但跟输入值并不是对应的关系了):

    +
    +

    The splice() method changes the contents of an array by removing existing elements and/or adding new elements. Return value: An array containing the deleted elements.

    +
    +

    同理还有 array.push / array.pop 等。

    +

    而 Moment.js 是如何设计的呢?

    +

    这里有一个 issue,通过它,基本可以看出来 Moment 有哪些 API 是有问题的:make moment mostly immutable #1754

    +

    比如一个简单的 add 方法,对日期进行“加”操作(比如日期加一天)。那么它应该是这样的:

    +
      +
    • 要么直接对输入进行“加”操作;
    • +
    • 要么产生一份复制值,对复制进行“加”操作并返回。
    • +
    +

    但是,Moment 真正的做法是,直接对输入进行“加”操作,并且返回。这样就很让人头疼了。

    +

    更过分的就是上图的例子,名如 startOf / endOf 这样的方法,看起来像是 Immutable 操作,实际上却还是 Mutable 的。所以说,如果用户使用了 Moment,那么所有的原始输入值基本上都是无法得到任何保证。你根本不知道输入值在什么时候就被修改了。

    +

    值得欣慰的是,在 Moment 发展了三年以后的 2014 年,终于有人提出了上述问题,并且被维护者认可并加入版本计划中了。但是,三年之后又三年,如今已经到了 2018,问题依旧没有得到解决。在 ES 发展如此迅速的时代,一个基本上处于垄断地位的流行库,以及一个三年都没能解决的问题,不知道是否还有救?

    +

    不过也许它已经完成曲线救国了(推倒重来总是比较简单):https://github.com/moment/luxon

    +
    +

    Features: Immutable, chainable, unambiguous API.

    +
    +

    不可否认 Moment.js 确实帮助开发者解决了很多问题,节省了大量时间。但是有一个问题:一个质量如此的库,是如何做到流行,如何拿到 3w 个 stars 的呢?是不是包括我在内的这些开发者,从根本上就存在软件开发基础知识的不足呢。

    +]]>
    + + javascript + +
    + + 令牌软件使用体验 + /2016/token-apps-usage-experiences/ + 所谓令牌,就是说,一个账号在登录的时候,除了要提供常规密码外,还要提供一组动态密码。而动态密码的来源,可以是实体设备,也可以是软件。

    +

    这里就说两个手机 APP:Steam 令牌与网易将军令。

    +

    APP 的功能很简单:在用户需要登录的时候提供动态密码。

    +
      +
    • Steam 令牌会在用户需要的时候主动推送动态密码到通知;
    • +
    • 而网易将军令需要用户手动打开软件查看动态密码。
    • +
    +

    哪一种设计更好呢?

    + + +

    我在用的时候就觉得,为什么将军令这么笨,不懂得直接把密码推送给我呢?每次要自己去打开烦不烦。

    +

    网易招回去的研究生、博士们,真的就想不到这一点吗?

    +

    后来仔细想了想,这里面大概还是有原因的!

    +

    除了是否要主动打开 APP 以外,手机软件还有一个隐藏区别:用户是否已解锁。显然,使用推送方式谁都能看到敏感的动态密码,而打开 APP 则必须要有用户手机已解锁的前提。

    +

    这样一来,万一用户的账号在使用令牌的情况下被盗,责任划分可就不一样了。Steam 不好说,可能要扯皮,反正网易将军令肯定是 100% 免责:用户不设手机密码,手机密码被盗或破解,越狱,ROOT 等情况或行为,均与网易无关。

    +

    (不过这些可能在服务条款里声明也没什么事,毕竟银行提供的还是实体设备,其安全与否就只能看用户是否持有设备。难道网易是真的没考虑过主动推送?)

    +]]>
    + + personal + +
    + + Travis CI in GitHub + /2017/travis-ci-in-github/ + Travis CI 是一款免费的持续集成工具,可以与 Github 无缝集成。能够自动完成项目代码的日常测试、编译、部署等工作。现在,我把它应用到了我的两个项目中。

    +

    首先,要在这个平台上做持续集成的前提是到它上面 https://travis-ci.org/ 去注册个账号。实际上直接用 Github 账号进行 OAuth 登录就行了。登录以后可以在首页找到自己的所有仓库,在需要进行持续集成的项目前面的开关打开即可。开启后,Travis CI 会监听项目的代码推送与 PR,当发生改变时会立刻进行相应操作。

    +

    至于具体操作内容,由项目根目录的 .travis.yml 文件决定。这个文件的简单用法由下面两个具体例子来说明。

    + + +

    wxsms.github.io

    该项目就是这个博客了。因为它是静态博客,所以代码上线前都要进行一次打包过程,在之前这个工作是手动完成的,主要的流程如下:

    +
      +
    1. src 分支上进行代码编辑,
    2. +
    3. src 分支上 push
    4. +
    5. src 分支上运行 npm run postnpm run build 分别生成文章与博客代码
    6. +
    7. 切换到 master 分支,将上一步打包编译出来的东西覆盖到相应目录下
    8. +
    9. master 分支上 push
    10. +
    11. 切换回 src 分支
    12. +
    +

    这些步骤看似简单却又容易出错,每次想要刷博客都必须做这么多事情,烦不胜烦。而且,Github 仓库会因为充斥了无意义的 master 历史记录而变得臃肿与难看。

    +

    现在有了 Travis CI,一切都将变得简单。

    +

    .travis.yml 文件内容:

    +
    language: node_js
    cache:
    directories:
    - node_modules
    node_js:
    - "node"
    script:
    - npm run post
    - npm run build
    - npm run dist-config
    deploy:
    - provider: pages
    skip_cleanup: true
    github_token: $GITHUB_TOKEN
    local_dir: dist
    target_branch: master
    on:
    branch: src
    + +

    说明:

    +
      +
    • language 指项目代码的语言,这里使用 node_js
    • +
    • cache 是 Travis CI 会缓存的内容,比如一些依赖文件无需每次都完全安装。这里缓存了 npm_modules 这个目录
    • +
    • node_js 这里指定 node 的版本,node 的意思是使用最新版
    • +
    • script 则是 Travis CI 具体会去完成的工作,是有顺序关系的,如果没有指定,则默认是 npm run test,这里依次执行了 3 个脚本:
        +
      • npm run post 打包文章
      • +
      • npm run build 打包代码
      • +
      • npm run dist-config 生成配置文件以及 Readme 等。前两步显而易见,至于第三步,因为 Travis 部署会是一个 force push 的过程,会删除原有分支上的所有内容,因此需要手动生成 Github 的 README.md 文件以及 Github Page 的 CNAME 文件。
      • +
      +
    • +
    • deploy 则是项目在所有脚本执行完成后会进行的部署操作,部署只会在脚本全部执行成功(返回 0)后进行
        +
      • 这里使用 page 即 Github Page 方式部署。
      • +
      • skip_cleanup 这个参数用来防止 Travis 删除脚本生成的文件(删掉了就没意义了)
      • +
      • github_token 是我们 Github 账号的 Access Token,因为私密原因不能写在代码文件里,因此可以在此写一个变量 $GITHUB_TOKEN,然后在 Travis 相应的仓库设置中添加 GITHUB_TOKEN 环境变量,Travis 会在运行时自动替换
      • +
      • local_dir 是指需要部署的打包出来的目录,设置为 dist 目录
      • +
      • target_branch 即目标分支,Travis 会将为 dist 目录整个部署到 master 分支上去
      • +
      • on 则是附加条件。这里的含义应该是只监听 src 分支上的更改
      • +
      +
    • +
    +

    因此,Travis 可以帮我完成以下工作

    +
      +
    • 监听 src 分支上的改动
    • +
    • 出现改动时,自动执行所有 build 步骤
    • +
    • 如果 build 成功则将相应文件部署到 master 分支上去
    • +
    +

    如此一来,我自己需要做的事情就只剩下简单的两步了:

    +
      +
    1. src 分支上进行代码编辑
    2. +
    3. src 分支上 push
    4. +
    +

    在我无需关注发布过程的同时,Travis 还能帮我保持整个代码仓库的整洁(master 分支始终进行的都是 force push,不存在无用的历史记录),简直完美!

    +

    uiv

    这个项目其实也差不多,有些许变化:

    +
      +
    • 脚本变为:
        +
      • npm run test 执行测试
      • +
      • npm run build 打包代码
      • +
      • npm run build-docs 打包文档
      • +
      +
    • +
    • 需要将代码部署到 npm,而文档部署到 Github Page
    • +
    • 代码与文档都只在版本发布时(Tagged)才进行部署
    • +
    +

    .travis.yml 文件内容:

    +
    language: node_js
    cache:
    directories:
    - node_modules
    node_js:
    - "node"
    script:
    - npm run test
    - npm run build
    - npm run build-docs
    after_success: 'npm run coveralls'

    deploy:
    - provider: npm
    skip_cleanup: true
    email: "address@email.com"
    api_key: $NPM_TOKEN
    on:
    tags: true
    branch: master
    - provider: pages
    skip_cleanup: true
    github_token: $GITHUB_TOKEN
    local_dir: docs
    on:
    tags: true
    branch: master
    + +

    这个配置文件多了一些内容:

    +
      +
    • after_success: 'npm run coveralls' 这个是在所有脚本成功以后执行的,目的是与 Coveralls 集成来在项目仓库上添加测试覆盖率的集成,这个在后面说
    • +
    • deploy 中增加了 npm 一项,配置内容跟 pages 基本一致,其中不同的:
        +
      • email 是用来发布的 npm 账户邮箱名
      • +
      • api_key 是用来发布的 npm 账户 token,可以在本地 ~/.npmrc 文件中找到(前提是本地电脑的 npm 已登录)
      • +
      • on -> tags: true 这个标志是说只在带有标签的 Commit 推送时才进行 deploy
      • +
      +
    • +
    • Github Page 的部署配置中也加入了 on -> tags: true,起的是一样的作用。这里的 Github Page 是从 master 分支的 docs 文件夹 deploy 到 gh-pages 分支(gh-pages 是 Github Page 的默认分支,所以不用配置 target_branch 项)
    • +
    +

    这样一来,Travis 就可以:

    +
      +
    • 在日常 push 的时候执行 test and build 脚本,但不发布
    • +
    • 在版本 push 的时候执行 test and build 脚本,全部成功则将内容分别发布到 NPM 与 Github Pages
    • +
    +

    完美!

    +

    关于 Coveralls

    Coveralls https://coveralls.io/ 是一个将代码测试覆盖率集成到 Github 的工具,在 Travis 的加持下,算是锦上添花的一项。同样,到相应网站注册账号是第一步。

    +

    由于 vue-cli 生成的项目默认已经附带了代码测试覆盖率的检测,我要做的只是把这个结果上传而已。

    +

    步骤:

    +
      +
    1. npm install coveralls --save-dev
    2. +
    3. "coveralls": "cat test/unit/coverage/lcov.info | ./node_modules/.bin/coveralls" 添加到 npm scripts 中。注意:cat 的路径是随项目不同而改变的
    4. +
    5. .travis.yml 中添加 after_success: 'npm run coveralls' 配置项
    6. +
    +

    它可以:

    +
      +
    1. 在测试完成后生成覆盖率文件(这一步 vue-cli 已经做了)
    2. +
    3. 将文件内容传给 coveralls,这个模块可以将结果从 Travis 上传到 Coveralls 平台
    4. +
    5. Github 上会 by commit 地显示测试率是增加还是降低了
    6. +
    +

    总结

    持续集成的好处无需多言,反正 Travis 就是一个免费的、能与 Github 集成的持续集成工具(实际上其它开源平台也可以,以及可以付费为私有项目提供服务)。简单、易用。

    +

    这些配置看似简单,却花费了我大量时间去摸索。由于只能通过不断推送 commit 的方式来触发 build 并验证配置的正确性,其过程异常繁琐,但是现在看来是十分值得的!

    +

    BTW:测试用的 commit 事后可以用本地 reset 与 force push 干掉。

    +]]>
    + + github + devops + +
    + + 基于 Vue 2 与 Bootstrap 3 的组件库 uiv 发布啦 + /2017/uiv-release/ + 一点微小的工作。

    +

    Demo: https://uiv.wxsm.space

    +

    Github: https://github.com/wxsms/uiv

    +

    NPM: https://www.npmjs.com/package/uiv

    +

    项目使用 MIT 许可,随便用。

    + + +

    简单介绍

    做这个东西的初衷是,想要一些简单的、基础的、常用的基于 Vue 2 与 Bootstrap 3 的可重用组件。因为我还有一个目标:一个灵活健壮的、类似 MEAN.js 这样的 Vue + Node.js + MongoDB 的 Seed 项目。没有一个简单的组件库,项目无法进行。

    +

    其实现在社区有很多开源作品了,但是简单来说,就是觉得不是很满意,怎么说呢:

    +
      +
    • VueStrap 这个作品虽然出现的比较早也比较全,然而貌似止步在 Vue 1 了,更新比较慢,不客气地说,里面很多组件其实是不好用的。只要稍稍对比下 Angular UI Bootstrap 就能发现差距,有些东西从设计上就有问题。
    • +
    • Bootstrap-Vue 这个作品是基于 Bootstrap 4 的,不知道为什么,就是不太喜欢。
    • +
    • Material Design 的作品有两三个,但实际使用上,感觉还是 Bootstrap 的应用场景更多,也更轻量。
    • +
    • 至于 ElementUI,做得非常好非常全,然而是自立门户做的,跟 Bootstrap 与 Material 都没有关联。
    • +
    +

    我想要的是:

    +
      +
    • 能够完全使用到 Bootstrap CSS
    • +
    • 很多方面只要像 Angular UI Bootstrap 靠齐就行,毕竟经过了 Angular 1 时代的考验,事实证明它是最好用的
    • +
    • 最小的体积
    • +
    • 纯净的依赖,没有除了 Vue 与 Bootstrap CSS 以外的东西
    • +
    • 主流浏览器支持
    • +
    +

    好吧,说白了就是想自己做。跟前辈们做的东西好与不好无关。反正开源作品,人畜无害。

    +

    做着做着,于是就有了这个东西。感谢静纯的参与,帮我完成了一部分工作。

    +

    项目现状

    目前已完成的组件有:

    +
      +
    • Alert (警告)
    • +
    • Carousel (轮播)
    • +
    • Collapse (收缩与展开)
    • +
    • Date Picker (日期选择)
    • +
    • Dropdown (下拉)
    • +
    • Modal (模态框)
    • +
    • Pagination (分页)
    • +
    • Popover (弹出框)
    • +
    • Tabs (标签页)
    • +
    • Time Picker (时间选择)
    • +
    • Tooltip (提示)
    • +
    • Typeahead (自动补全)
    • +
    +

    共 12 个。

    +

    依赖只有 Vue 2 与 Bootstrap 3,最终打包压缩 + Gzip 后体积约 9 KB,应该算是比较轻比较小的啦。

    +

    所有组件在主流浏览器(Chrome / Firefox / Safari)与 IE 9 / 10 / 11 下都经过了测试,暂时没有发现问题。当然,由于 IE 9 不支持 Transition 属性,因此是没有动画效果的,不过功能正常,不影响使用流程。

    +

    当然,除了以上的浏览器环境测试以外,还进行了完善的单元测试,组件代码测试覆盖率达到 99%(Github 与项目主页上的测试率标签显示为 97%,因为其中包括了文档源码,与实际组件无关)。可以保证在大多数情况下正常工作。

    +

    Road Map

    接下来要做的事:

    +
      +
    • 把自动化的 E2E 测试搞起来,目前项目使用的自动测试只有单元测试,无法自动测试不同的浏览器,这个很重要,保证项目在跨浏览器上的质量
    • +
    • 收集一些意见与反馈,完善一下现有的东西
    • +
    • 将 Date Picker 与 Time Picker 组合
    • +
    • Multi Select (多选组件)
    • +
    • 等等等等……
    • +
    +

    有问题请提 issue,一定尽快解决。同时也欢迎 PR

    +

    最后,欢迎使用。

    +]]>
    + + vue + bootstrap + +
    + + Unicode Substring + /2018/unicode-substring/ + 最近遇到一个问题:在做字符串截取操作时,如果字符串中包含了 emoji 字符(一个表情占多个 unicode 字符),而碰巧又把它截断了,程序会出错。在 ReactNative App 下的具体表现就是崩溃。由于以前做的是网页比较多,基本没有输入表情字符的案例,而在手机上就不一样了,因此这个问题还是第一次发现。

    +

    比如说:

    +
    '😋Emoji😋'.substring(0, 2) // 😋
    + +

    因此,如果对这个字符串做 substring(0, 1) 操作,就会截取到一个未知字符。

    + + +

    中间的探索过程就不谈了,Google 了一下解决方案,以及咨询同事们以后,发现最简单的办法是通过 lodash 自带的 toArray 方法,先将它转为数组,然后将整个逻辑改为数据的截取操作,最后再转回字符串。

    +
    export function safeSubStr (str, start, end) {
    const charArr = _.toArray(str);
    return _.slice(charArr, start, end).join('');
    }
    + +

    实际上解决问题的是 _.toArray,它帮我们把表情字符正确地截了出来:

    +
    _.toArray('😋Emoji😋') // ["😋", "E", "m", "o", "j", "i", "😋"]
    + +

    其实我也比较好奇它是怎么做的,通过观察源码,发现了真正的解决方案:

    +
    // lodash/_unicodeToArray.js
    /** Used to compose unicode character classes. */
    var rsAstralRange = '\\ud800-\\udfff',
    rsComboMarksRange = '\\u0300-\\u036f',
    reComboHalfMarksRange = '\\ufe20-\\ufe2f',
    rsComboSymbolsRange = '\\u20d0-\\u20ff',
    rsComboRange = rsComboMarksRange + reComboHalfMarksRange + rsComboSymbolsRange,
    rsVarRange = '\\ufe0e\\ufe0f';

    /** Used to compose unicode capture groups. */
    var rsAstral = '[' + rsAstralRange + ']',
    rsCombo = '[' + rsComboRange + ']',
    rsFitz = '\\ud83c[\\udffb-\\udfff]',
    rsModifier = '(?:' + rsCombo + '|' + rsFitz + ')',
    rsNonAstral = '[^' + rsAstralRange + ']',
    rsRegional = '(?:\\ud83c[\\udde6-\\uddff]){2}',
    rsSurrPair = '[\\ud800-\\udbff][\\udc00-\\udfff]',
    rsZWJ = '\\u200d';

    /** Used to compose unicode regexes. */
    var reOptMod = rsModifier + '?',
    rsOptVar = '[' + rsVarRange + ']?',
    rsOptJoin = '(?:' + rsZWJ + '(?:' + [rsNonAstral, rsRegional, rsSurrPair].join('|') + ')' + rsOptVar + reOptMod + ')*',
    rsSeq = rsOptVar + reOptMod + rsOptJoin,
    rsSymbol = '(?:' + [rsNonAstral + rsCombo + '?', rsCombo, rsRegional, rsSurrPair, rsAstral].join('|') + ')';

    /** Used to match [string symbols](https://mathiasbynens.be/notes/javascript-unicode). */
    var reUnicode = RegExp(rsFitz + '(?=' + rsFitz + ')|' + rsSymbol + rsSeq, 'g');

    /**
    * Converts a Unicode `string` to an array.
    *
    * @private
    * @param {string} string The string to convert.
    * @returns {Array} Returns the converted array.
    */
    function unicodeToArray(string) {
    return string.match(reUnicode) || [];
    }

    module.exports = unicodeToArray;
    + +

    一大堆正则就不谈了,也不知道它是从哪里找来的这些值,最后组装了一个 reUnicode 正则来实现 unicode 转数组。话又说回来,这么做会不会有性能问题呢?我表示比较担忧。好在项目里面需要这么做的场景不多,字符串也不长,可以如此暴力解决。如果换个场景,还真不好说。也许又需要一种更高效的解决方案了。

    +]]>
    + + javascript + +
    + + 小程序单元测试最佳实践 + /2021/unit-test-best-practice-of-mini-program/ + 微信小程序单元测试的可查资料少得可怜,由于微信官方开发的自动化测试驱动器 miniprogram-automator 不开源,唯一靠谱的地方只有这 一份简单的文档。然而实际使用下来发现文档介绍的方式有不少问题。

    + + +

    关于单元测试如何启动的问题

    官方推荐的方式

    官方推荐通过 Jest 来组织单元测试,这点我是认可的。文档上面标注的步骤是:

    +
      +
    1. 启动并连接工具
    2. +
    3. 重新启动小程序到首页
    4. +
    5. 断开连接并关闭工具
    6. +
    +

    代码:

    +
    const automator = require('miniprogram-automator')

    describe('index', () => {
    let miniProgram
    let page

    beforeAll(async () => {
    miniProgram = await automator.launch({
    projectPath: 'path/to/miniprogram-demo'
    })
    page = await miniProgram.reLaunch('/page/component/index')
    await page.waitFor(500)
    }, 30000)

    afterAll(async () => {
    await miniProgram.close()
    })
    })
    + +

    乍一看没什么特别的问题,然而实际上跑了几次以后发现,它存在一个巨大的缺陷:就是 automator.launch 这一操作相当耗时,启动一次至少要 30 秒

    +

    举例:

    +
      +
    1. 项目定义了 10 套单元测试,每套测试都得重新走 launchclose 流程;
    2. +
    3. 使用 watch 方式启动 Jest,每次触发执行都要重新走 launchclose 流程;
    4. +
    5. 等等……
    6. +
    +

    以上场景都将带来巨大的时间损耗,完全无法容忍。

    +

    因此,如何缩短这里的耗时将是重中之重。

    +

    Jest 全局共享连接实例?

    既然每个单元测试都要用到连接实例 miniProgram,那么大家共享同一个实例自然是我能想到的第一个办法。

    +

    globalSetup / globalTeardown

    第一个办法是通过 globalSetupglobalTeardown 参数,为单元测试提供一个全局 setup 函数,并且它支持 async,看起来非常完美。

    +

    但是,在尝试过后发现并不起作用,在 setup 过程中挂载到 global 的属性无法从单元测试中读取,后来查阅文档才发现这个 setup 函数有一个致命的缺陷:

    +
    +

    Note: Any global variables that are defined through globalSetup can only be read in globalTeardown. You cannot retrieve globals defined here in your test suites.

    +
    +

    原来,单元测试是运行在「沙盒」环境下的,彼此隔离,因此它们无法读取到来自外部的 global 变量。

    +

    facebook/jest/issues/7184 中,有人提到可以在 setup 函数中用 process.env.FOO = 'bar' 这种方式来达成目的。经测试确实可以,但是问题在于:

    +
      +
    1. process 下面只能挂载基础类型,不能挂载对象实例;
    2. +
    3. 实际上「它能工作」本身是一个 Bug,在 issue 内有人提到,它可能会在任意时间被修复。
    4. +
    +

    因此,这个方案不可行。

    +

    setupFiles

    第二个办法是使用 setupFiles 参数。

    +

    该方式支持 global 挂载,但是很遗憾,我不用尝试也知道,setupFiles 目前仅支持同步执行,无法满足需求。

    +

    见:facebook/jest/issues/11038

    +

    testEnvironment

    找到的第三个办法是使用 testEnvironment 参数。

    +

    该方式支持:

    +
      +
    1. global 挂载
    2. +
    3. 异步执行
    4. +
    +

    但是,依旧很遗憾,经过查阅文档发现,testEnvironment 并不是作用全局的,也就是说,它在每个单元测试执行时都会走一遍创建、销毁流程,跟最初的方式并没有本质区别。

    +

    总结

    由于 Jest 本身的设计问题,全局共享连接实例这个方案基本(至少目前)不可行。

    +

    将单元测试挪到一个文件下?

    既然跨单元测试的变量共享不可行,那么第二个方向就是:将所有单元测试集合起来,共享一套环境。这样一来,大家自然就可以共享一个连接了。

    +
    // package.json
    {
    "scripts": {
    // 注:指定了只执行 ./tests 下的测试
    "test": "node node_modules/jest-cli/bin/jest.js ./tests --runInBand --verbose",
    "test:watch": "npm run test -- --watchAll=true"
    },
    // ...
    }

    + +
    // index.spec.js
    const automator = require('miniprogram-automator')
    const path = require('path')

    let mp

    const launchOptions = {
    // ...
    }

    beforeAll(async () => {
    mp = await automator.launch({ ...launchOptions })
    global.mp = mp
    }, 60000)

    afterAll(async () => {
    await mp.disconnect()
    })

    require('path/to/test1')
    require('path/to/test2')
    // more...
    + +

    这样一来,各单元测试可以通过 global.mp 得到连接实例。实测也确实可行,仅需要启动一次就可以跑完所有单元测试。

    +

    但是,这种实现方式存在一些问题:

    +
      +
    1. 所有单元测试被归总到了一个 test suit 内,测试结果的打印慢了许多,需要等到所有测试跑完才能看到结果;
    2. +
    3. 同理,无法利用好 Jest 的 watch 功能,无法做到开发时仅执行某个 test suit
    4. +
    5. 添加或删除了测试,需要手动更改 require 列表,相当麻烦
    6. +
    7. 该方式并未解决 watch 触发需要重启连接实例的问题,依旧相当耗时
    8. +
    +

    Launch or Connect?

    由于官方文档给出的例子是使用 Launch 实现的,所以自然而然会从这方面入手寻找解决方案,但是走了这么多弯路以后还是不行,我开始考虑:是否可以绕过 Launch?

    +

    查看 文档 以后发现,除了 launch 以外,automator 还提供了一个 connect 方法。

    +
    +

    automator.connect

    +

    连接开发者工具。

    +

    automator.connect(options: Object): Promise<MiniProgram>

    +
    +

    因此,我想到了一个办法:如果不通过 launch,直接 connect 至现有窗口,应该会快很多吧。

    +

    但是尝试后发现,即使在开发者工具中打开了服务端口,connect 也无法连接上,始终报错「端口未打开」。 后来通过搜索才发现,此「端口」非彼「端口」,如果要用过 websocket 连接,开发者工具就必须以 cli 方式加 --auto 参数启动才行。

    +

    因此,我也想到了最终解决方案:

    +
      +
    1. 先尝试 connect,如果成功则进入测试
    2. +
    3. 如果失败,则执行 launch(该方式启动默认开启自动化)
    4. +
    5. 测试结束时,不调用 close,而是调用 disconnect
    6. +
    +

    这样一来,第一次单元测试启动时会启动开发者工具,测试完成以后,连接会断开,但是开发者工具不会关闭。等到第二次启动时,automator 就能直接连上,无需再次启动。

    +

    编写 setup 文件(该文件不以 spec 结尾,只作为 mixin 使用):

    +
    // setup.js
    const automator = require('miniprogram-automator')
    const path = require('path')

    let mp

    const launchOptions = {
    // ...
    }

    beforeAll(async () => {
    try {
    mp = await automator.connect({
    wsEndpoint: 'ws://localhost:9420',
    })
    } catch (err) {
    console.error(err)
    try {
    mp = await automator.launch({ ...launchOptions })
    } catch (err) {
    console.error(err)
    }
    }

    global.mp = mp
    }, 60000)

    afterAll(async () => {
    await mp.disconnect()
    })
    + +

    在每个单元测试中引用 setup:

    +
    // some-test.spec.js
    describe('some-test', () => {
    require('path/to/setup')

    let page = null

    // ...
    })
    + +

    如此一来,以上发现的所有问题都能很好地解决:

    +
      +
    1. 单元测试极大地提速
    2. +
    3. test suit 按照正常方式组织,无需额外操作
    4. +
    5. watch 模式也能正常使用,速度极快
    6. +
    +

    但是,该方式同样带来了一个问题:即 test suit 不再拥有独立运行环境,每个 suit 要注意清理自己带来的影响。

    +

    不过,权衡利弊来说,肯定是好处远远大于坏处。

    +

    关于如何进行页面导航的问题

    通过实例方法导航

    miniProgram 实例提供了一系列的导航方法,如 navigateTonavigateBack 等,经实践,能够正常使用。但是,它们有一个通病:耗时明显(又来了)。

    +

    经测试,在导航开始前记录时间,await 至导航结束,打印时间差,每次导航耗时大概在 3000 毫秒以上。具体表现为,页面虽然已跳转到位,但方法就是没有返回。由于驱动框架不开源,也并不知道在这段时间内它究竟做了什么。

    +
    // 耗时在 3000ms 以上
    page = await global.mp.navigateTo('/pages/index/index')
    + +

    单元测试少的话可以容忍,但是一旦多起来了,也是非常浪费生命的。

    +

    通过页面元素导航

    通过模拟页面内的导航元素点击来达到效果,这种方式耗时极短,500 毫秒内即可完成。虽然相比实例方法来说较为繁琐,但胜在量大的时候节省时间效果非常明显。

    +
    // 耗时在 500ms 左右
    const btn = await page.$('#some-nav-btn')
    await btn.tap()
    // 实际上所有耗时几乎都发生在这里,等待导航动画结束
    await page.waitFor(500)
    page = await global.mp.currentPage()
    + +

    关于如何与原生元素交互问题

    由于驱动不支持选择原生元素,也不支持对其进行交互,因此唯一的办法是通过 mock 修改其定义。

    +

    举例,要模拟 wx.showModal确定 点击:

    +
    await global.mp.mockWxMethod('showModal', {
    confirm: true,
    cancel: false
    })
    + +

    如此一来,当 showModal 被调用时,会直接进入 confirm 流程。

    +

    当然,测试结束后要记得 restore:

    +
    await global.mp.restoreWxMethod('showModal')
    +]]>
    + + javascript + miniprogram + test + +
    + + 无题 + /2016/untitled-2/ + 年廿七回家,到今天是第七天。这么快就已经过去了整整一周,马上又要回珠海上班了。

    +

    回家这么多天来,今天是第一次在家吃晚饭。一直都在亲戚朋友家过节,自己家冷冷清清的时间比较多。因为自己家里没有老人,只有我和爸妈一家三口,所以大概只能往外跑吧。我们很少在广东过年,只是今年可能是因为我的身体还不太好,妈妈也比较累,所以就不想回江西了。其实过节在哪里都无所谓啦,大家在一起开心就好。倒是不能去看看年事已高的奶奶觉得很忧伤。妈妈看起来又老了一些,是照顾我的那段时间太劳累了。

    +

    今年印象比较深的是,大家都喜欢在茶余饭后玩红包了。尤其是除夕晚上的时候,开着电视,但其实没多少时间去看,大家都忙着摇摇摇咻咻咻,完事以后继续关注下一轮的时刻,至于春晚什么的,谁管呢。当然老人还是在看。腾讯老大给的一块几毛就图个乐(一块几毛是说微信,至于QQ真是太小气了),但这里要吐槽一下支付宝,我一直以为它要么会大量放出稀有卡,要么会给集齐四张卡的同学一些安慰奖,结果也是呵呵,于是我毫不犹豫地就把除了家人以外的加起来的好友都删了。这游戏在春晚打了那么硬的广告,结果让全国99%的玩家都吃了个闭门羹,这么有种也是没谁了。老人一直在问为什么会有奇怪的声音,他们在年夜饭的过程中反而不太受到关注。

    +

    老人们有时候会问什么时候结婚的事,我都是回答说还早。两个人在一起的压力有时候真要比一个人要大得多,毕竟一个人生活不用考虑什么时候能买房,反正都是自己住。珠海的房价一天比一天高,然而刚工作半年的我也只能看着它高。

    +

    和小伙伴们谈起工作的时候,发现自己果然是最闲的。突然感觉没有赚加班费的机会也是一件挺忧伤的事。手术的伤依然是还没有好,总是觉得有这个问题在生活中处处都受到了限制。过两天又要回到那个以断网为常态并且每晚跳三四次闸的地方去住,再次回家又不知道是什么时候。

    +]]>
    + + personal + +
    + + 无题 + /2016/untitled-3/ + 最近,工作地所在的园区推出了一款 App,宣传的主要功能是获取园区动态以及扫码付款,感觉这样吃饭可以方便一些,因此就下载了。

    +

    应用的名字叫“园圈”,在 App Store 上搜索出来,底下是一个没有见过的开发商。我觉得还算正常吧,一般这种小范围应用,不都是外包的吗。只是,这使我对于这个应用的使用埋下了一丝戒心。(后来我搜索了一下这家公司,网站充斥着强烈的国企风)

    +

    进入应用,首先要我注册,这很简单,手机号码验证码啪啪啪就输完了。然后,它要求我输入一个密码。我毫不犹豫地就输入了常用密码,在即将要点下一步的时候,却犹豫了一下。

    + + +

    我在想的是:

    +
      +
    1. 它是一个不知名的小公司
    2. +
    3. 它已经知道了我的手机号码
    4. +
    5. 它即将要知道我的常用密码
    6. +
    +

    虽然我已经在不知道多少地方用过这个密码,但是这一次我就是不想在这用了。其它很多手机应用,提供了手机号码就会让用户直接登录,然而这个应用却强制要我再填一个密码。这对于它来说太简单了,获得一个用户的手机号以及常用密码,它可以用来做任何事情。并且,更可怕的是,没有人会关注它。

    +

    于是我清除输入并换了一组密码。

    +

    接下来,它要求我输入一个手势密码。

    +

    我从来没有用过手势密码,但它没有提供跳过选项,因此就画了个圈以示敬意。

    +

    但是,我想,如果是在其它地方用过手势密码的用户,这里应该是毫不犹豫的吧。虽然得到这个貌似用处并没有像手机号加密码那么明显,但是,不论怎么说,这家公司又获得了一项用户信息。

    +

    并且,这个以园区动态发布以及小额支付为主要(或者说唯二)功能的应用,有什么必要同时使用密码与手势密码呢?不得而知。

    +

    历尽千辛,终于来到了主界面,界面其实就是支付宝和咸鱼的结合体,没什么特别的。动态都是一些领导视察之类的文章,于是我就点开了“付款”功能。

    +

    首先,我要同意一个用户协议。

    +

    然后,输入六位数的支付密码。

    +

    至此,这是它要求我输入的第三个密码。并且是较为敏感的六位数密码。

    +

    这样就很不好了。

    +

    然而,不知道为什么,我当时还是如实填写了。

    +

    一切都填好以后,我终于可以仔细查看一下它的付款功能。很简单,支付宝或者微信充值,然后二维码扫码支付,却缺少了重要的一项:余额转出。

    +

    这真 TM 是一个忧伤的故事,说到底还是跟充饭卡没什么区别,只要你用不完,那么充进去的钱就算是捐了。

    +

    唯一的区别是,我向这家公司无偿提供了我的手机号码,以及密码,手势密码以及支付密码。

    +

    我突然觉得,这是一波实实在在的送温暖行动。

    +

    这让我想起几个月前在一家米粉店付款的时候,我选择使用支付宝,从店家的微信公众号直接跳转到了一个要求输入账号密码的,UI 跟支付宝一模一样的页面。几乎是本能反应的我点了“使用浏览器打开”,果然这不是一个 alipay 域名下的网站。

    +

    然而,如果用户不是一个程序员,或者不熟悉互联网的点点滴滴,他有多大几率能注意到并且发现诸如此类的事情呢?

    +]]>
    + + personal + +
    + + 无题 + /2013/untitled/ + 时间过得真快,转眼就大学三年级了,两年前作为新生的各种场景依然历历在目,像是昨天一样,当年的小软工如今已几乎是大师兄,不得不时时拷问自己两年来到底学到了什么,学到了多少,有什么资格。去年还没有什么感觉,如今比较强烈了。而且也开始想两年后我会在哪里。实在是前路茫茫啊。是工作呢,还是要去读研比较好呢。我个人还是倾向继续读书。唉,不知不觉就大龄青年了。真是岁月催。

    +

    大学读下来,从前很多选择也慢慢觉得如果再理性一点的话,或许会有变化。不过人还是脚踏实地的好,往事已去不再追。今年婆婆去世了,即使到现在还是很难接受的事实,不过我也知道时间带走一切,不知哪刻自己也将被带走,能做的只有珍惜。

    +]]>
    + + personal + +
    + + Upgrade Projects Built by vue-cli + /2017/upgrade-projects-scaffolded-by-vue-cli/ + 使用 vue-cli 创建的脚手架项目,目前最大的问题是创建后无法自动地进行升级。虽然 3.0 版本已经计划将其作为头等大事来进行改善 (#589),但是现行的版本依然要面对它。以下基于 webpack template 来进行升级时的一些要点解析。

    + + +

    依赖

    项目整体升级的一个重要目的体现在依赖的升级,如 webpack 从老版本 2 升级到 3,以及 babel / eslint 等各种配套工具的升级(至于 Vue 反倒不是什么大问题)。

    +

    在对依赖进行升级的时候主要有两个参考:

    + +

    outed version 如果是 MINOR / PATCH 更新,直接 upgrade 即可。如果是 MAJOR 更新则需要到相应项目主页上确认一下 breaking changes 是否对自己有影响。

    +

    以下列举一些主要的依赖。

    +

    Webpack

    Webpack 2 -> 3 其实是无痛升级的。也就是说基本不用更改什么配置。

    +

    ESLint

    ESlint 及其相关库的升级也没什么需要特别注意的地方,因为它并不参与最终构建。只不过升级以后可能会有 lint failed cases (因为新版本一般会添加新的 rules),注意修复即可。

    +

    Babel

    Babel 相关的升级是最麻烦(也是最头疼)的一部分。其主要问题体现在:

    +
      +
    • 其直接参与代码构建,影响巨大,需要特别谨慎
    • +
    • Babel 作为一个重要工具有一定的学习成本
    • +
    • Babel 相关库变更较为频繁,典型的如 babel-preset-latest 库废弃并被 babel-preset-env 替代,而后者在最新的版本中又变成了 @babel/preset-env,甚至 babel-core 也废弃了,变成了 @babel/core
    • +
    +

    在经过了几次的迁移尝试后,建议目前的方案是:

    +
      +
    • 进行 MINOR 升级,如果还在使用 babel-preset-latest 可以将其替换为 babel-preset-env(注意两者的配置大致一样,但略有不同,需要仔细比对)
    • +
    • 暂时不要将 babel 升级至 7.x-beta
    • +
    • 暂时也不要使用 @babel 类型的依赖(实测中出现奇怪的报错,难以追踪、搜索)
    • +
    • 等待 Vue.js 社区给出解决方案
    • +
    +

    AutoPrefixer

    autoprefixer 从 6.x 升级到 7.x 时,注意将 package.json 中的 browserlist 改成 browserslist (一个 s 的区别)

    +

    配置文件

    这里说的配置文件主要有两方面:Babel 以及 Webpack

    +

    Babel

    最简单的操作是,直接到 vuejs-templates/webpack 找到最新的 babel 文件,复制更新的内容下来即可。当然要注意自己已经更改过的内容不要被覆盖。

    +

    Webpack

    Webpack 配置稍微麻烦一些,主要体现在 webpack.base.conf.js 以及 webpack.prod.conf.js,个人总结的升级步骤:

    +
      +
    1. 先升级 Webpack 相关工具到最新版本
    2. +
    3. 打开官方项目,对文件进行比对并更新相应内容(一般 webpack.prod.conf.js 会有较多内容更新,而且主要是 plugins 配置项)
    4. +
    5. 如果遇到目前没有安装的依赖则安装之
    6. +
    +

    当然这只适用于 Webpack 2 -> 3 的升级,至于 1 -> 2 或者 1-> 3 没试过,不好说。

    +

    做完以上操作,跑过所有 npm scripts 一切正常的话,项目脚手架升级就基本完成了。这个过程说难不难,但是如果对 Webpack / Babel 不熟悉的话还是挺痛苦的,期待 vue-cli 3.0 可以带来更优秀的脚手架解决方案,达到类似 Nuxt.js 的效果,彻底解决升级烦恼。

    +]]>
    + + vue + webpack + +
    + + Upgrade Webpack of Vue-Cli Projects from 3 to 4 + /2020/upgrade-webpack-of-vue-cli-projects-from-3-to-4/ + package.json

    Change webpack related devDependencies versions:

    +
      +
    1. webpack to ^4
    2. +
    3. webpack-dev-server to ^3
    4. +
    5. Add webpack-cli
    6. +
    7. Replace extract-text-webpack-plugin with mini-css-extract-plugin
    8. +
    9. Replace uglifyjs-webpack-plugin with terser-webpack-plugin
    10. +
    +
    {
    "devDependencies": {
    "mini-css-extract-plugin": "^1",
    "terser-webpack-plugin": "^4",
    "webpack": "^4",
    "webpack-cli": "^3",
    "webpack-dev-server": "^3"
    }
    }
    + +

    webpack.base.conf.js

    Add mode option.

    +
    // ...

    module.exports = {
    mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
    context: path.resolve(__dirname, '../'),
    // ...
    }
    + +

    webpack.prod.conf.js

      +
    1. Add performance and optimization option
    2. +
    3. Replace ExtractTextPlugin with MiniCssExtractPlugin
    4. +
    5. Remove UglifyJsPlugin and all webpack.optimize.CommonsChunkPlugin
    6. +
    +
    // ...
    const MiniCssExtractPlugin = require('mini-css-extract-plugin')
    const TerserPlugin = require('terser-webpack-plugin');
    // ...
    const webpackConfig = merge(baseWebpackConfig, {
    // ...
    performance: {
    hints: false
    },
    optimization: {
    runtimeChunk: {
    name: 'manifest'
    },
    minimizer: [
    new TerserPlugin(),
    new OptimizeCSSPlugin({
    cssProcessorOptions: config.build.productionSourceMap
    ? { safe: true, map: { inline: false } }
    : { safe: true }
    }),
    ],
    splitChunks: {
    chunks: 'async',
    minSize: 30000,
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    name: false,
    cacheGroups: {
    vendors: {
    test: /[\\/]node_modules[\\/]/,
    name: 'vendor',
    chunks: 'initial',
    priority: -10
    }
    }
    }
    },
    // ...
    plugins: [
    // new UglifyJsPlugin({
    // uglifyOptions: {
    // compress: {
    // warnings: false
    // }
    // },
    // sourceMap: config.build.productionSourceMap,
    // parallel: true
    // }),
    // new ExtractTextPlugin({
    // filename: utils.assetsPath('css/[name].[contenthash].css'),
    // // Setting the following option to `false` will not extract CSS from codesplit chunks.
    // // Their CSS will instead be inserted dynamically with style-loader when the codesplit chunk has been loaded by webpack.
    // // It's currently set to `true` because we are seeing that sourcemaps are included in the codesplit bundle as well when it's `false`,
    // // increasing file size: https://github.com/vuejs-templates/webpack/issues/1110
    // allChunks: true,
    // }),
    new MiniCssExtractPlugin({
    filename: utils.assetsPath('css/[name].css'),
    chunkFilename: utils.assetsPath('css/[name].[contenthash].css')
    }),
    // split vendor js into its own file
    // new webpack.optimize.CommonsChunkPlugin({
    // name: 'vendor',
    // minChunks (module) {
    // // any required modules inside node_modules are extracted to vendor
    // return (
    // module.resource &&
    // /\.js$/.test(module.resource) &&
    // module.resource.indexOf(
    // path.join(__dirname, '../node_modules')
    // ) === 0
    // )
    // }
    // }),
    // extract webpack runtime and module manifest to its own file in order to
    // prevent vendor hash from being updated whenever app bundle is updated
    // new webpack.optimize.CommonsChunkPlugin({
    // name: 'manifest',
    // minChunks: Infinity
    // }),
    // This instance extracts shared chunks from code splitted chunks and bundles them
    // in a separate chunk, similar to the vendor chunk
    // see: https://webpack.js.org/plugins/commons-chunk-plugin/#extra-async-commons-chunk
    // new webpack.optimize.CommonsChunkPlugin({
    // name: 'app',
    // async: 'vendor-async',
    // children: true,
    // minChunks: 3
    // }),
    ],
    // ...
    })
    + +

    That’s it, enjoy. 🎉

    +]]>
    + + vue + webpack + +
    + + 使用 Eslint 来禁止 Lodash 的整体引入 + /2020/use-eslint-to-forbid-entire-import-of-lodash/ + 前端项目使用 lodash 时需要注意,一不小心就会把整个库引入进来,大大增加最终打包体积。

    + + +

    两种真正可以实现按 method 引入的方式,一是:

    +
    import get from 'lodash/get'
    + +

    二是:

    +
    // yarn add lodash.get
    import get from 'lodash.get'
    + +

    除此以外,其它所有方式都会导致整体引入。如:

    +
    import { get } from 'lodash'
    import _ from 'lodash'
    import * as _ from 'lodash'
    + +

    虽然我知道这件事,但有时候我的队友不知道,辛辛苦苦改了半天的成果可以被别人一行代码就摧毁。因此我决定找一个方法来永久杜绝这件事的发生:

    +
    // http://eslint.org/docs/user-guide/configuring
    module.exports = {
    'rules': {
    // ...
    'no-restricted-imports': [2, {
    'paths': [
    {
    'name': 'lodash',
    'message': '仅允许类似 import get from \'lodash/get\' 的引入方式'
    }
    ]
    }]
    }
    }
    + +

    这样的话,只要代码里面一出现 import ... from 'lodash',eslint 就会报错,提示他去改代码。就算提交了,CI 也通过不了。岂不美哉。

    +]]>
    + + javascript + eslint + +
    + + 在微信小程序中使用 lodash + /2019/use-lodash-in-wechat-mini-programs/ + 由于微信小程序中的 JavaScript 运行环境与浏览器有些许区别,因此在引用某些 npm lib 时会发生问题。这时候需要对源码做出一些改动。

    +
    +

    小程序环境比较特殊,一些全局变量(如 window 对象)和构造器(如 Function 构造器)是无法使用的。

    +
    +

    在小程序中直接 import lodash 会导致以下错误:

    +
    Uncaught TypeError: Cannot read property 'prototype' of undefined
    + + + +

    解决方案:

    +
      +
    1. 安装独立的 lodash method package,如 lodash.get
    2. +
    +
    yarn add lodash.get
    import get from 'lodash.get'
    + +
      +
    1. 修改 lodash 源码
    2. +
    +

    找到:

    +
    var root = freeGlobal || freeSelf || Function('return this')();
    + +

    替换为:

    +
    var root = {
    Array: Array,
    Date: Date,
    Error: Error,
    Function: Function,
    Math: Math,
    Object: Object,
    RegExp: RegExp,
    String: String,
    TypeError: TypeError,
    setTimeout: setTimeout,
    clearTimeout: clearTimeout,
    setInterval: setInterval,
    clearInterval: clearInterval
    };
    +]]>
    + + javascript + miniprogram + +
    + + WP 2016 主题使用摘要 + /2016/using-excerpt-in-wp-2016-theme/ + 2016 主题设置里没有提供是否使用摘要的选项,因此如果文章不做任何操作,首页以及归档页都会显示全文,导致页面非常地长。但是,一番机缘巧合,我发现只要在文章里面插入了 more 标签,主题就会自动检测到并且切换到摘要模式。

    +

    妄我在 Google 上苦苦探索,搜集到一堆垃圾代码,然而并没有什么用。

    +]]>
    + + wordpress + +
    + + 使用 IDEA 配置自动同步到FTP服务器 + /2016/using-idea-to-config-ftp-auto-deployment/ + 使用虚拟主机的时候经常会想到一个问题,就是改了代码以后还要手动上传到服务器上,非常麻烦,且不利于保持本地开发代码与服务器上运行代码之间的同步,容易出错。今天突然想着能不能用IDE来完成类似自动同步的事情,如果可以的话开发效率自然是大幅度提高。拜强大到没朋友的IDEA所赐,结果非常可观。

    +

    首先确保安装好IDEA,测试用IDEA版本为15.0.1,然后我们从FTP服务器上copy一份代码到本地,并创建好存放目录。此时代码应该是完全同步的。以上为准备工作。

    + + +

    然后我们打开IDEA,选择File -> Open,打开代码根目录。

    +

    打开Tools -> Deployment -> Configuration

    +

    +

    在弹出的界面中点击 + 按钮,添加一个服务器。

    +

    +

    如下图所示,填写主机地址,端口(如果不一样),用户名与密码以后,就可以点 Test FTP connection 按钮进行连接测试,如果连接成功,IDEA会有相应的提示。以下的步骤需要以此为前提。

    +

    点击 Autodetect 按钮后,选择服务器的根目录,一般选择最顶端的文件夹就OK了。即使代码并不是在根目录,我们也还有后面的配置来选择代码所处的实际目录。

    +

    最下面的 Web server root URL 字段可以填写网站的实际访问地址,这样在使用IDEA的实时预览功能时,浏览器就会以该Domain为基准进行路由。

    +

    +

    切换到 Mappings 标签,我们需要填写的字段也如下图。

    +

    Local path 即本地代码根目录,IDEA已经自动设置好了。

    +

    Deployment path则是FTP服务器上实际同步的位置,在此选择代码所处的文件夹即可。以上都填好后点击 OK 按钮。

    +

    +

    现在就大功告成了。我们可以选中一些文件或者文件夹,右键,然后就可以看到 Deployment 菜单,其子菜单有 Upload,Download,Compare,Sync四个。其中 Sync 就是我们所期望的功能,IDEA 会帮我们完成文件比较,与 VCS 的文件比较系统非常相似,确认无误后点击绿色的向右箭头按钮,代码就同步到服务器上去了。如下所示。

    +

    +

    当然每次都要右键然后找到 Sync 选项可能会有点太麻烦。我们可以把这个功能放到主工具栏上去,以后每次点它就行了。

    +

    +

    接下来就可以享受愉快的开发体验了。唯一需要注意的是在网络不是非常理想的情况下,Sync 的时候不要选择项目根目录,选择真正有改变的文件或者文件夹即可,因为它毕竟不是 VCS,所有文件一个个比对的话实在是太慢。当然我们也可以配合 VCS 使用,效果更佳,这里就不再赘述。

    +]]>
    + + idea + +
    + + 为 Vue 组件库实现国际化支持 + /2017/vue-components-i18n/ + 其实这部分代码主要是参考着 element ui 和 iview 做的(iview 又是抄的 element),对关键代码进行了一些简化。主要需要实现的需求有:

    +
      +
    1. 用户可以更改、切换组件库使用的语言(应用级别)
    2. +
    3. 用户可以自定义组件使用的措辞
    4. +
    5. 兼容 vue-i18n 这个库
    6. +
    + + +

    关键代码

    src/locale/lang/en-US.js

    首先是 Locale 文件,把措辞映射到一个 key 上面去,比如说英文:

    +
    export default {
    uiv: {
    datePicker: {
    clear: 'Clear',
    today: 'Today',
    month: 'Month',
    month1: 'January',
    month2: 'February',
    // ...
    }
    }
    }
    + +

    对应的中文文件只需要把相应的 Value 翻译成中文即可。这里有一个最基本的设想就是,如果需要增加一种语言,应该是只需要增加一个这样的文件即可

    +

    src/locale/index.js

    import defaultLang from './lang/en-US'
    let lang = defaultLang

    let i18nHandler = function () {
    const vuei18n = Object.getPrototypeOf(this).$t
    if (typeof vuei18n === 'function') {
    return vuei18n.apply(this, arguments)
    }
    }

    export const t = function (path, options) {
    let value = i18nHandler.apply(this, arguments)
    if (value !== null && typeof value !== 'undefined') {
    return value
    }
    const array = path.split('.')
    let current = lang

    for (let i = 0, j = array.length; i < j; i++) {
    const property = array[i]
    value = current[property]
    if (i === j - 1) return value
    if (!value) return ''
    current = value
    }
    return ''
    }

    export const use = function (l) {
    lang = l || lang
    }

    export const i18n = function (fn) {
    i18nHandler = fn || i18nHandler
    }

    export default {use, t, i18n}
    + +

    这段代码乍一看挺复杂,其实弄明白后就很简单:

    +
      +
    1. i18nHandler 是用来检测并套用 vue-i18n 的,如果用户安装了这个插件,则会使用绑定在 Vue 实例上的 $t 方法进行取值
    2. +
    3. t 方法是用来取值的。首先看能否用 i18nHandler 取到,如果能取到则直接用,取不到就要自行解决了。最后返回取到(或者取不到,则为空)的值。
    4. +
    5. usei18n 这两个方法是在整个组件库作为插件被 Vue 安装的时候调用的,主要用来让用户自定义语言等等。
    6. +
    +

    原版的 t 方法有一个与之配合的模板字符串替换的方法(比如说处理 My name is ${0} 这种 Value),这里简洁起见把它删掉了,实际上也暂时用不到。

    +

    src/mixins/locale.js

    一个 mixin,很简单:

    +
    import { t } from '../locale'

    export default {
    methods: {
    t (...args) {
    return t.apply(this, args)
    }
    }
    }
    + +

    就是给组件加上一个 t 方法。那么现在组件在需要根据语言切换的地方,只要加入这个 mixin 并在输出的地方使用 t(key) 即可,比如 t('uiv.datePicker.month1') 在默认的配置下会使用 January,而如果用户配置了中文则会使用 一月

    +

    src/components/index.js

    最后一步:将上述的两个方法 usei18n 写入到组件库入口的 install 方法中去。

    +
    const install = (Vue, options = {}) => {
    locale.use(options.locale)
    locale.i18n(options.i18n)
    // ...
    }
    + +

    如何使用

    简单用法

    切换中文:

    +
    import Vue from 'vue'
    import uiv from 'uiv'
    import locale from 'uiv/src/locale/lang/zh-CN'

    Vue.use(uiv, { locale })
    + +

    显然, 如果对预设的措辞不满意,我们还可以自定义, 只需要创造一个 locale 对象并替换之即可。

    +

    配合 Vue I18n 使用

    只要跟着 vue-i18n 的文档把自己的 App 配好就行,不用管组件库,会自动适配。但有一点要注意:需要先将 组件库的语言包合并到 App 语言包中去。比如:

    +
    import uivLocale from 'uiv/src/locale/lang/zh-CN'

    let appLocale = Object.assign({}, uivLocale, {
    // ...
    })

    // 接下来该干嘛干嘛
    +]]>
    + + vue + i18n + +
    + + Vue-Router Note + /2017/vue-router-note/ + Vue Router (https://github.com/vuejs/vue-router) 使用笔记。虽然官方文档比较详尽,但实际用起来依然有些地方需要特别注意的(其实主要是我的个人需求)。

    + + +

    Scroll Behaviours

    文档上有 scroll behaviours 的示例,但实际上用起来不太完美,还需要自己改造一下。需要注意的是 scrollBehavior 必须搭配 history 模式,否则代码无效且无任何错误信息。

    +

    上面说到不完美的地方主要是在模拟 ‘scroll to anchor’ 这一行为时,文档的代码是不够好的:

    +
    scrollBehavior (to, from, savedPosition) {
    if (to.hash) {
    return {
    selector: to.hash
    }
    }
    }
    + +

    这里实际上会调用 querySelector(to.hash) 来实现滚动,但是用起来会发现有些时候这段会报错,因为类似 #1-anything 这样的数字(或者其他非字母字符)打头的 hash 作为 selector 是 Invalid 的。但是要修复只需要稍微改动一下就好了:

    +
    if (to.hash) {
    return {
    selector: `[id='${to.hash.slice(1)}']`
    }
    }
    + +

    所以一段完善的 scroll behaviour 代码应该是:

    +
    scrollBehavior (to, from, savedPosition) {
    if (to.hash) {
    return {
    selector: `[id='${to.hash.slice(1)}']`
    }
    } else if (savedPosition) {
    return savedPosition
    } else {
    return {x: 0, y: 0}
    }
    }
    + +

    它可以做到在路由变化时:

    +
      +
    1. 有锚点时滚动到锚点
    2. +
    3. 有历史位置时滚动到历史位置
    4. +
    5. 都没有时滚动到页头
    6. +
    +

    Lazy Loading

    官方的 Lazy load 示例代码换了很多茬,比如之前有类似 require('...', resolve) 的,还有用 System.import 的,但是它们并不能向后兼容,所以如果用的是新版本的话,并不能够直接 copy 旧项目的方式。目前感觉会稳定下来的方式是:

    +
    const Foo = () => import('./Foo.vue')
    + +

    但是这里又有一个注意点,以上语法必须引入一个 babel 插件 syntax-dynamic-import 才行:

    +
    npm install --save-dev babel-plugin-syntax-dynamic-import
    + +

    .babelrc

    +
    {
    "plugins": ["syntax-dynamic-import"]
    }
    + +

    以上就可以在 webpack + babel 的环境下实现代码分块了。

    +

    Progress

    经常会有这样的需求(尤其是使用 lazy load 时):路由跳转时提供一个进度条(像 Github 头部那种),然而 Vue Router 没有提供这方面的示例。经过实际使用发现,并不需要刻意使用 Vue 封装的进度条,比如说轻量级的 nprogress 也可以很好地搭配使用。

    +

    但是需要注意的是,Vue Router 会将 hash 跳转也视为一次 route 跳转,因此如果在全局钩子中注册 progress 方法的话,那么它也会在 hash 跳转中出现,实际上应该是不需要的。所以需要一点点判断:

    +
    import NProgress from 'nprogress'

    // ...

    router.beforeEach((to, from, next) => {
    // not start progressbar on same path && not the same hash
    // which means hash jumping inside a route
    if (!(from.path === to.path && from.hash !== to.hash)) {
    NProgress.start()
    }
    next()
    })

    router.afterEach((to, from) => {
    // Finish progress
    NProgress.done()
    })
    + +

    以上就是一个简单的页面跳转进度条示例,它会在除了同页 hash 跳转以外的所有页面跳转行为发生时,在页头显示一个简单的进度条。

    +

    Active Style

    当使用 <router-link> 的时候,Vue Router 会自动给当前路由的 link 加一个 active class,用来做 nav menu 时非常方便。但是有一点需要注意的是,它默认并不是一个精确匹配的模式,而是一个 matchStart,比如说 <router-link to="/a"> 会被一个 /a/b 的路由激活,更甚者,<router-link to="/"> 会被所有路由激活(真的)。然而这一般来说都不会是想要的结果。

    +

    在老旧版本(0.x)的 Vue-Router 中这个问题是无解的,现在则可以使用 <router-link exact> 来将它转换为精确匹配

    +

    Route Reuse

    当使用 <router-view> 时,默认会启用组件复用,也就是说在可能的情况下,作为路由页面的组件不会被销毁重建,而是直接复用。

    +

    就好像一个博客的文章页面,一般来说会是给出这样的路由配置:/post/:id,那么在从 /post/1 跳转到 /post/2 的时候,实际上路由组件是不会重建的。

    +

    有时候我们会想要避免这样的事情发生,因为一个路由可能在创建的时候有比较多的逻辑(如数据动态获取、判断等),如果它在路由变化的时候直接复用的话,那么 mount 方法将不再被调用,我们还要为 update 再写一套类似的逻辑。更过分的是,其所用到的所有子组件也不再会执行 mount 方法,那么我们要为所有子组件编写 update 方法。非常麻烦。

    +

    不知道为什么,老版本的文档是有为这种情况提供解决方案的,但是在现在的文档里面找不到了。实际上很简单:

    +
    <router-view :key="$route.path"></router-view>
    + +

    就这样就可以了。如此一来,只要在 $route.path 变化的时候,路由组件就会被销毁重建。用一点点的性能损耗,节省大量冗余代码。

    +

    当然这里也可以使用定制化逻辑来控制,比如使用 computed value 来实现更复杂的复用逻辑。

    +]]>
    + + vue + +
    + + 使用 Vue Transition 实现高度渐变动画 + /2017/vue-transition-height/ + CSS Transition 中的高度从 0 到 auto 以及从 auto 到 0 是个艰难的任务(相比于其它属性的 transition 而言),原因也很简单:就是浏览器不支持此类 CSS 动画,无论在何种情况下,它都不会成功。

    +

    但是高度渐变是个很常用的动画效果,如果绕过纯 CSS height 属性,有如下方式来实现:

    +
      +
    • 使用 max-height 属性,为元素设置一个不可能达到的最大高度,然后将 transition 转换为 max-height 从 0 到某个固定的值;
    • +
    • 使用 transform: scaleY 实现;
    • +
    • 使用 JavaScript 动画。
    • +
    +

    上面的解决方案都从某种程度上解决了问题,但是,各有各的限制于缺点:

    +
      +
    • 使用 max-height 会造成动画效果与预期有些许出入(加速与延迟),实际体验是,它与实际 height 区别越大,这种感觉就会越明显,原因也很容易想到,因为 transition 的起点与终点均不在实际的起点与终点上;
    • +
    • 使用 scaleY 有两个问题:一是动效与高度渐变不一样,元素的内容看上去是被压缩了(而不是被收起或展开),这个倒可以忍耐。可恶的是第二点,它虽然看起来是渐变了,然而高度却并没有被渐变!意思是,在它下面的元素会在动画结束后”跳”到另一个位置而不是平滑地渐变到这个位置;
    • +
    • 使用 JavaScript 动画其实已经可以完美地实现高度渐变了,然而,问题是我们需要引入额外的 lib 来做成这件事,我可没心情纯 js 手写动画。
    • +
    +

    所以,我的目标是:

    +
      +
    1. 使用 css transition 完成动画;
    2. +
    3. 动画效果必须完美;
    4. +
    5. 与 vue transition 组件集成。
    6. +
    +

    这实际上是一个很艰难的任务。经过了大量的失败尝试,最终还是 google 救了我。。下面先直接上解决方案。

    + + +

    css 部分非常简单,因为它不可以完成从 0 到 auto 的渐变,却可以完成从 0 到固定值的渐变,因此,思路是渐变仍由 css 完成,但会通过钩子给元素做些魔法操作:

    +
    .collapse {
    transition: height .3s ease-in-out;
    overflow: hidden;
    }
    + +

    下面是重点 vue transition 部分:

    +
    on: {
    enter (el) {
    el.style.height = 'auto'
    // noinspection JSSuspiciousNameCombination
    let endWidth = window.getComputedStyle(el).height
    el.style.height = '0px'
    el.offsetHeight // force repaint
    // noinspection JSSuspiciousNameCombination
    el.style.height = endWidth
    },
    afterEnter (el) {
    el.style.height = null
    },
    leave (el) {
    el.style.height = window.getComputedStyle(el).height
    el.offsetHeight // force repaint
    el.style.height = '0px'
    },
    afterLeave (el) {
    el.style.height = null
    }
    }
    + +

    这些钩子大概是这样的:

    +
      +
    • enter 会在元素从无到有的时候触发,即我们期望的高度从 0 到 auto 的时候;
    • +
    • afterEnter 会在 enter 结束后触发;
    • +
    • leave 会在元素从有到无,即高度从 auto 到 0 的时候触发;
    • +
    • afterLeave 同理
    • +
    +

    这些钩子内的代码真的很魔性,大概是这样的:

    +

    enter

    +

    这个方法被调用的时候,元素实际上已经被插入到 dom 中( v-if )或者 display 属性不为 none 了( v-show ),因此,是可以获取到它的实际高度的。

    +
      +
    1. 先将其高度设置为 auto,然后通过 getComputedStyle 方法来获取其实际高度;
    2. +
    3. 将其高度设置为 0
    4. +
    5. 将其高度设置为第一步取得的实际高度。
    6. +
    +

    但是!这么做有个致命问题,我是在同一个方法内同步完成这些步骤的,因此,第二步和第三步执行的结果看起来就像跳过了第二步而只执行了第三步一样,这样就没有高度从 0 到某个值的过程,自然也就没有渐变动画了。

    +

    重点!

    +

    这里说的魔法,实际上就是那一句看似啥都没做的 el.offsetHeight,它使浏览器强制进入了一个 repaint 流程。至于它为什么能实现这个功能,真的不太清楚,google 一波也只是知其然不知其所以然,我们甚至不用给它赋值,只要引用一次就行了。可以看做一个非常神奇的技巧。实测在 IE 10 以上 / Chrome / Firefox / Safari 上均能工作。

    +

    因此,enter 的流程变为:

    +
      +
    1. 先将其高度设置为 auto,然后通过 getComputedStyle 方法来获取其实际高度;
    2. +
    3. 将其高度设置为 0
    4. +
    5. 强制浏览器重绘;
    6. +
    7. 将其高度设置为第一步取得的实际高度。
    8. +
    +

    这样动画就成功执行了!

    +

    理解了 enter 过程,剩下的 afterEnter / leave / afterLeave 钩子,里面的内容就很容易理解了。

    +

    效果演示:https://uiv.wxsm.space/collapse

    +

    回过头来看一下实现原理其实很简单粗暴,因此,除了在 vue 上面可以这么玩,其实其他支持 css transition 的框架肯定也是可以的(如 angular 中有 ngAnimate 可以实现),最终达到的动画效果十分完美,并且没有借助主框架以外的任何额外 js 库。

    +]]>
    + + javascript + vue + css + +
    + + 从零开始实现 Vue3 响应式 + /2023/vue3-reactive/ + Vue3 与 Vue2 的最大不同点之一是响应式的实现方式。众所周知,Vue2 使用的是 Object.defineProperty,为每个对象设置 getter 与 setter,从而达到监听数据变化的目的。然而这种方式存在诸多限制,如对数组的支持不完善,无法监听到对象上的新增属性等。因此 Vue3 通过 Proxy API 对响应式系统进行了重写,并将这部分代码封装在了 @vue/reactivity 包中。

    +

    本文将参照 Vue3 的设计,从零开始实现一套响应式系统。注意本文引用的代码与实际的 Vue3 实现方式有所出入,Vue3 需要更多地考虑高效与兼容各种边界情况,但此处以易懂为主。 文中提到的大部分代码可以在 https://github.com/wxsms/learning-vue 找到。

    + + +

    什么是响应式

    Evan 经常举的一个例子是电子表格(如:Excel)。当我们需要对某一列或行求和,将计算结果设置在某个单元格中,并且在该列(行)的数据发生变化时,求和单元格的数据实现实时更新。这就是响应式。

    +

    以代码来表达的话:

    +
    let col = [1, 2, 3, 4, 5]
    let s = sum(col)
    + +

    我们就可以的得到一个求和值 s

    +

    不同的是,以上代码是命令式的。也就是说,当 col 发生变化时,s 的值并不会随之改变。我们需要再次调用 s = sum(col) 才能得到新的值。

    +

    响应式就是要解决这个问题:当 col 发生变化时,我们可以自动地得到基于变化后的 col 计算而来的 s

    +

    我们的目标:

    +
    graph LR
    +
    +A[依赖变更] -->|自动触发| B[响应函数]
    +B -->|自动监听| A
    + +

    依赖与依赖监听

    当要实现一个响应式系统的时候,我们实际需要的是什么?

    +

    答案是依赖依赖的监听

    +

    用上面的例子来说,col 是依赖,s=sum(col) 是监听依赖做出的反应。当依赖发生变化时,反应可以自动执行,这件事情就完成了。

    +

    依赖

    那么我们先来实现依赖。 一个依赖:

    +
      +
    1. 代表了某个对象下面的某个值;
    2. +
    3. 当值发生变化时,需要触发跟它有关的作用(effect)。
    4. +
    +

    以下是实现代码:

    +
    // Dep -> Dependency 依赖
    // 比如上面提到的 `col` 是一个 dep,
    // `c = a.b + 1` 中,a.b 是一个 dep。
    export class Dep {
    constructor () {
    // 储存与这个依赖有关的“作用”
    // 这里使用 Set,可以利用其天然的去重属性,
    // 因为作用无需重复添加
    this._effects = new Set();
    }

    // 取消作用对此依赖的追踪
    untrack (e) {
    this._effects.delete(e);
    }

    // 作用开始追踪此依赖
    track (e) {
    this._effects.add(e);
    }

    // 触发追踪了此依赖的所有作用
    trigger () {
    for (let e of this._effects) {
    e.run();
    }
    }
    }
    + +

    除此以外,我们还需要一个变量,用来存储所有的依赖:

    +
    // target (object) -> key (string) -> dep
    export const depsMap = new Map();
    + +

    depsMap 是一个嵌套的 Map:

    +
      +
    1. 它的 key 是一个 Object,如 c = a.b + 1 中,key 是 a 这个对象;
    2. +
    3. 它的 value 又是一个 Map:
        +
      1. 它的 key 是一个键名,如 c = a.b + 1 中,key 是 b
      2. +
      3. 它的 value 是一个 Dep 实例。
      4. +
      +
    4. +
    +
    mindmap
    +depsMap
    +    A[Key: Object]
    +    B[Value: Map]
    +      C[Key: string]
    +      D[Value: dep]
    + +

    在实际的 Vue3 代码中,这里的第一层使用的是 WeakMap 而非 Map。原因是 WeakMap 对 key 是弱引用,当 key 在代码中的其它地方已经不存在应用时,它 (key) 以及对应的 value 都会被 GC。而如果使用 Map 的话,保有的是强引用,就会导致内存泄漏。

    +

    依赖监听

    一个依赖监听模块大致需要以下内容:

    +
    import { Dep, depsMap } from './dep';

    /**
    * 当前正在运行的 effect
    */
    let currentEffect;

    export class ReactiveEffect {
    // todo
    }

    /**
    * 创建一个作用函数,并将自动追踪函数内的依赖
    * @param fn 接收的函数
    */
    export function effect (fn) {
    // todo
    }

    /**
    * 当前正在运行的作用追踪一个依赖
    * @param target 目标对象
    * @param prop 目标对象的属性名
    */
    export function track (target, prop) {
    // todo
    }

    /**
    * 触发一个依赖下的所有作用
    * @param target 目标对象
    * @param prop 目标对象的属性名
    */
    export function trigger (target, prop) {
    // todo
    }
    + +

    trigger

    触发作用的代码非常简单,只需直接拿到对应的 dep,并调用它的 trigger 函数:

    +
    export function trigger (target, prop) {
    depsMap.get(target)?.get(prop)?.trigger();
    }
    + +

    track

    trigger 相反:trigger 是将 dep 取出来并触发里面的 effects,而 track 是将 effect 保存到 dep 中去。

    +

    需要注意的是,因为 depsMap 一开始是空的,所以取 dep 会包含一个初始化的过程:

    +
    function getDep (target, prop) {
    // 从 depsMap 中找到本 target 的 Map
    let deps = depsMap.get(target);
    if (!deps) {
    // 没找到,需要初始化
    deps = new Map();
    depsMap.set(target, deps);
    }
    // 从第二级的 Map 中找到本 prop 的 dep
    let dep = deps.get(prop);
    if (!dep) {
    // 没找到,需要初始化
    dep = new Dep();
    deps.set(prop, dep);
    }
    return dep;
    }
    + +

    下面是 track 函数的具体实现:

    +
    export function track (target, prop) {
    if (!currentEffect) {
    // 当前没有正在运行中的作用,无需追踪,可直接退出
    return;
    }
    let dep = getDep(target, prop);
    // 追踪正在运行中的作用
    dep.track(currentEffect);
    }
    + +

    effect

    effect 作为一个工厂函数,只需完成 ReactiveEffect 实例的创建并立即运行:

    +
    export function effect (fn) {
    let e = new ReactiveEffect(fn);
    // 直接运行
    e.run();
    return e;
    }
    + +

    ReactiveEffect

    最后来实现 ReactiveEffect 这个类。从上面的其它函数可以看出,这个类需要以下功能:

    +
      +
    1. 接收一个 fn 函数;
    2. +
    3. 包含一个 run 成员方法,可以运行一次该作用;
    4. +
    +

    下面我们来分别实现它们。

    +

    1. 构造器

    +

    简单赋值即可:

    +
    constructor (fn) {
    this.fn = fn;
    }
    + +

    2. run

    +

    run 函数的关键在于 currentEffect 的赋值:我们在这里默认在 fn 函数运行的过程中,会发起对相应依赖的 track(),而 track 函数中会使用到 currentEffect。这也是为什么它需要作为一个全局变量单独抽离出来,成为 track 与 effect 之间的纽带:

    +
    run () {
    // 赋值 currentEffect
    currentEffect = this;
    // 运行用户传入的函数
    this.fn();
    // 取消赋值
    currentEffect = null;
    };
    + +

    仔细看的话会发现,这里每一次调用 run 都会给 currentEffect 赋值,可以理解为发起了依赖收集的流程。换而言之,每次执行这个作用都会收集依赖。为什么要这么做?举个例子:

    +
    effect(() => {
    if (a.b && a.b.c) {
    d = a.b.c;
    // ...
    }
    })
    + +

    如果依赖收集只执行一次,并且第一次执行的时候 a.bfalsely 的,那么第一次执行就只收集到了 a.b 这个依赖,而 a.b.c 没有收集到。那么后续当只有 a.b.c 发生变化时,d 将不会被重新赋值,这是不符合预期的。因此,目前来说依赖收集需要在每次作用函数运行时都进行。

    +

    小结

    目前为止,我们定义了两个类以及一些工具函数:

    +
      +
    1. Dep 表示一个“依赖”,它内部含有一个 effects 集合,用来触发与它有关的作用;
    2. +
    3. ReactiveEffect 表示一个“作用”;
    4. +
    5. tracktrigger 函数,分别用来追踪依赖与触发作用。
    6. +
    +

    它们可以实现如下效果:

    +
    let obj = { a: 1, b: { c: 2 } };
    let fn = jest.fn(() => {
    // 用户的函数中发起了依赖追踪,
    // 后续该行为将被自动化
    track(obj, 'a');
    track(obj.b, 'c');
    });
    effect(fn);
    // fn 总共被调用了 1 次
    expect(fn).toHaveBeenCalledTimes(1);

    obj.a = 2;
    // 依赖发生了变化,后续该行为将被自动化
    trigger(obj, 'a');
    // fn 总共被调用了 2 次
    expect(fn).toHaveBeenCalledTimes(2);

    obj.b.c = 3;
    // 依赖发生了变化,后续该行为将被自动化
    trigger(obj.b, 'c');
    // fn 总共被调用了 3 次
    expect(fn).toHaveBeenCalledTimes(3);
    + +

    看起来好像是那么回事了,但还有点抽象,距离我们的最终目标还有一定距离。

    +

    响应式变量

    现在我们有了依赖与依赖追踪,是时候来实现第二个关键组件 reactive 了。 它将帮我们完成“在作用函数内部自动调用 track()”以及“依赖变化时自动调用 trigger()”的工作。

    +

    众所周知,Vue3 使用了 Proxy 来实现响应式:

    +
    import { track, trigger } from './effect.js';

    export function reactive (obj) {
    return new Proxy(obj, {
    get (target, p) {
    // todo
    },
    set (target, p, value) {
    // todo
    }
    });
    }
    + +

    我们需要做的两件事:

    +
      +
    1. 实现 getter:当 get 触发时,追踪依赖
    2. +
    3. 实现 setter:当 set 触发时,触发作用
    4. +
    +
    graph LR
    +
    +A[reactive] -->|发生读取| B[track]
    +A[reactive] -->|发生变更| C[trigger]
    +B --> D[ReactiveEffect]
    +C --> D[ReactiveEffect]
    + +

    get

    get 的第一版实现:

    +
    get (target, p) {
    // 追踪依赖!
    track(...arguments);
    // 获取值并返回
    let value = Reflect.get(...arguments);
    return value;
    }
    + +

    Reflect 通常是与 Proxy 成对出现的 API,这里的 Reflect.get(...arguments) 约等于 target[p]

    +

    但是,这么做有个问题!因为 Proxy 代理的是浅层属性,举个例子,当我取 a.b.c 时,实际上分了两步:

    +
      +
    1. 先取 a.b,这里 a 是 reactive 对象,能够触发 getter,没问题;
    2. +
    3. 再取 b.c,注意这里如果不做任何操作的话,b 将是一个普通对象,也就是说取值到这里响应性就丢失了。
    4. +
    +

    为了解决这个问题,我们需要做一点小小的改造:

    +
    get (target, p, receiver) {
    // 追踪依赖!
    track(...arguments);
    // 获取值
    let value = Reflect.get(...arguments);
    if (value !== null && typeof value === 'object') {
    // 如果 value 是一个对象,需要递归调用 reactive 将它再次包裹
    return reactive(value);
    }
    return value;
    }
    + +

    set

    实现 setter 需要注意的点是:

    +
      +
    1. 触发作用要在设置新值后进行;
    2. +
    3. 需要判断新旧值是否相等以避免死循环。
    4. +
    +
    set (target, p, value, receiver) {
    // 先取值
    let oldValue = Reflect.get(...arguments);
    if (oldValue === value) {
    // 如果新旧值相等,无需触发作用
    return value;
    }
    // 设置新的值,约等于 `target[p] = value`
    let newValue = Reflect.set(...arguments);
    // 触发作用!这里是在设置新值后才进行的。
    trigger(...arguments);
    // 注意这里返回的是 Reflect.set 的返回值
    return newValue;
    }
    + +

    大功告成!

    +

    小结

    我们现在可以:

    +
      +
    1. 定义响应式变量;
    2. +
    3. 定义作用函数;
    4. +
    5. 响应式变量发生变化时,函数将自动执行。
    6. +
    +
    let a = reactive({ value: 1 });
    let b;

    effect(() => {
    b = a.value * 2;
    });
    expect(b).toEqual(2);

    a.value = 100;
    // b 自动更新了!
    expect(b).toEqual(200);

    a.value = 300;
    // b 自动更新了!
    expect(b).toEqual(600);
    + +

    实际上当进行到这里的时候,响应式的两大基石就已经完成了。因此下面其它的 API 实现我决定都通过 reactiveeffect 来实现。当然实际上 Vue3 考虑的更多,做的也会更复杂一些,但是原理是类似的。

    +

    其它响应式 API

    mindmap
    +   Reactive & Effect
    +      A)ref(
    +      A)computed(
    +      A)watch(
    +      A)watchEffect(
    +      A)...(
    + +

    ref

    上面的 reactive API 可以对对象和数组这样的复杂类型完成监听,但对于字符串、数组或布尔值这样的基本类型,它是无能为力的。因为 Proxy 不能监听这种基本类型。因此,我们需要对它进行一层包裹:先将它包裹到一个对象中,然后通过 a.value 来访问实际的值(这实际上是 Vue3 目前仍在致力于解决的问题之一)。

    +

    下面,我们将以惊人的效率实现 ref

    +
    export function ref (value) {
    return reactive({ value: value });
    }
    + +

    这种方式非常简单直接,并且能够完美地运行:

    +
    let a = ref(1);
    let b;

    effect(() => {
    b = a.value * 2;
    });
    expect(b).toEqual(2);

    a.value = 100;
    expect(b).toEqual(200);
    + +

    当然,实际上 Vue3 不是这么干的:它实现了一个 RefImpl 类,并且与 reactive 类似地,通过 getter 与 setter 完成对 value 的追踪。

    +

    computed

    计算属性 (computed) 是经典的 Vue.js API,它能够接受一个 getter 函数,并且返回一个实时更新的值。

    +

    仅 getter

    我们先来实现一个最常见的版本:

    +
    export function computed (getter) {
    // 计算属性返回的是一个 ref
    let result = ref(null);
    // 调用 getter 函数,更新 ref 的值
    effect(() => {
    result.value = getter();
    });
    return result;
    }
    + +

    这是一个只包含 getter 函数的计算属性,它可以这么用:

    +
    let a = ref(1);
    let b = computed(() => a.value + 1);
    expect(b.value).toEqual(2);

    a.value = 100;
    expect(b.value).toEqual(101);

    a.value = 300;
    expect(b.value).toEqual(301);
    + +

    getter & setter

    复杂的计算属性可以同时拥有 getter 和 setter:

    +
    let a = ref(1);
    let b = computed({ get: () => a.value + 1, set: (val) => a.value = val - 1});
    + +

    为了优雅起见,我们先对 computed 内部的函数做一下封装,首先是 getterEffect,它与上面的实现一样,接受一个 ref 与一个 effect 函数:

    +
    function getterEff (computedRef, eff) {
    effect(() => {
    computedRef.value = eff();
    });
    }
    + +

    然后是 setterEffect:

    +
    function setterEff (computedRef, eff) {
    effect(() => {
    eff(computedRef.value);
    });
    }
    + +

    与 getterEffect 不同的是,setter 是将 ref 值作为参数传入到 effect 函数内,而 getterEffect 是将 effect 函数的返回赋值给 ref。

    +

    最后,我们就可以得到完整的 computed 函数了:

    +
    export function computed (eff) {
    let result = ref(null);
    if (typeof eff === 'function') {
    // 这是一个简单的 getter 函数
    getterEff(result, eff);
    } else {
    // 同时传入了 getter 和 setter
    getterEff(result, eff.get);
    setterEff(result, eff.set);
    }
    return result;
    }
    + +

    watch

    除了经典的 watch API 以外,Vue3 还带来了一个新的 watchEffect API。与 watch 不同的是,它可以:

    +
    +

    立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。

    +
    +

    也就是说,watchEffect 无需指定它监听的值,可以完成自动的追踪,且会立即执行。

    +

    然而在实现这两个 API 之前,需要先对 ReactiveEffect 做一点小小的扩展改造。

    +

    effect 改造 - 允许停止运行

    Vue3 的 watch/watchEffect API 会返回一个 stop 函数,该函数可以将侦听器停止,停止后即不再自动触发。而我们目前的 ReactiveEffect 尚不支持停止。因此我们需要给它加一个 stop 函数。

    +

    想要停止一个 effect,我们需要做的事情就是把它从所有的 dep 中移除,这样一来 effect 就不能被 dep 触发了。比如:

    +
    for (let deps of depsMap) {
    for (let dep of deps) {
    dep.untrack(thisEffect);
    }
    }
    + +

    但是,这样做有几个问题:

    +
      +
    1. 太过暴力,性能堪忧;
    2. +
    3. 由于 Vue3 实际上在第一层使用了 WeakMap,而 WeakMap 是不支持遍历的。
    4. +
    +

    因此,我们需要对 ReactiveEffect 做一些改造,将这些相关的 dep 存下来。

    +
    export class ReactiveEffect {
    constructor (fn) {
    this.fn = fn;
    // 新增一个 deps Set,表示所有与本 effect 相关的依赖
    this.deps = new Set();
    // 新增一个 active 属性,表示是否已停止
    this.active = true;
    }

    run () {
    // effect 已经停止了,无需再收集依赖。
    // 但既然 run 被调用了,还是运行并返回一下吧!
    if (!this.active) {
    return this.fn();
    }
    // 与之前一样
    // ...
    };

    /**
    * 新增的 stop 函数,用来停止 effect
    */
    stop () {
    // 将 active 设置为 false,表示已停止
    this.active = false;
    // 将本 effect 实例从所有 deps 中移除
    for (let dep of this.deps) {
    dep.untrack(this);
    }
    // 清空 deps
    this.deps.clear();
    }
    }
    + +

    同时,我们需要在追踪依赖时,将依赖添加到 effect 的 deps 中(双向追踪):

    +
    export function track (target, prop) {
    if (!currentEffect) {
    return;
    }
    let dep = getDep(target, prop);
    dep.track(currentEffect);
    // 新增!将 dep 也添加到 currentEffect 的 deps 中
    currentEffect.deps.add(dep);
    }
    + +

    watchEffect

    加入 stop 函数后,watchEffect 实现如下:

    +
    export const watchEffect = (cb) => {
    let e = effect(cb);
    // 注意这里要 bind(e),否则 this 指针会错乱
    return e.stop.bind(e);
    };
    + +

    非常地“水到渠成”!

    +
    let a = ref(1);
    let b = ref(0);
    let stop = watchEffect(() => {
    b.value = a.value * 2;
    });
    expect(b.value).toEqual(2);

    a.value = 100;
    expect(b.value).toEqual(200);

    // 调用停止函数,后续 a.value 再变化时,函数将不再执行
    stop();
    + +

    effect 改造 - 加入 scheduler

    与 watchEffect 不同的是,watch 有更多特性:

    +
      +
    1. watch 方法接收两个参数:source 和 callback,分别代表监听的对象和 effect 函数;
    2. +
    3. effect 函数接收两个参数,value 和 oldValue,分别代表新的值和变化后的值;
    4. +
    5. 初次定义时,effect 函数不会运行;
    6. +
    7. 只有 source 的改变才能触发 effect。
    8. +
    +

    为了实现第 3&4 点,我们需要给 ReactiveEffect 加入一个 scheduler 的概念:它将决定 run 函数何时执行。

    +

    首先我们需要修改一下 Dep 类:

    +
    export class Dep {
    // 同上...

    trigger () {
    for (let e of this._effects) {
    if (e.scheduler) {
    // 如果存在 scheduler,则执行 scheduler
    e.scheduler();
    } else {
    // 否则直接 run
    e.run();
    }
    }
    }
    }
    + +

    然后修改 effect:

    +
    export class ReactiveEffect {
    constructor (fn, scheduler) {
    // 同上...
    // 但添加一个 scheduler 选项
    this.scheduler = scheduler;
    }

    // 同上...
    }

    // 添加了一个 scheduler 参数
    export function effect (fn, scheduler) {
    let e = new ReactiveEffect(fn, scheduler);
    e.run();
    return e;
    }
    + +

    OK,完成了。实际上只是添加了一个可以自由更改 run 执行时机的选项。但 scheduler 非常强大,Vue 的另一个核心功能 nextTick 也是基于它实现的,此处先不展开。

    +

    watch

    watch 的函数重载非常多,为了简单起见,我们只实现其中一种形式:

    +
      +
    1. getter:函数,返回监听的值;
    2. +
    3. cb:回调函数
    4. +
    +
    export function watch (getter, cb) {
    let oldValue;

    let job = () => {
    // 获取新值
    let newVal = e.run();
    // 调用回调函数
    cb(newVal, oldValue);
    // 设置“新的旧值”
    oldValue = newVal;
    };
    // 定义 effect
    // 注意 effect 的本体是 getter,
    // 也就是说只有 getter 可以触发依赖收集
    // 而 job 将作为 scheduler 传入
    let e = effect(getter, job);
    // 首次运行,完成第一个旧值的获取
    oldValue = e.run();
    // 与 watchEffect 一样返回 stop 函数
    return e.stop.bind(e);
    }
    + +

    至此,watch 函数也实现完了。

    +
    let a = reactive({ value: 1 });
    let fn = jest.fn();
    let stop = watch(() => a.value, fn);
    // 没有初次调用
    expect(fn).not.toBeCalled();

    a.value = 2;
    // 值改变后自动调用
    expect(fn).toBeCalledWith(2, 1);

    stop();

    a.value = 3;
    // 停止后,不再调用
    expect(fn).toHaveBeenCalledTimes(1);
    + +

    小结

    在本节中,我们使用现成的 effectreactive API 实现了 refcomputed,并且通过对 effect 扩展的两个功能(stop、scheduler)分别实现了 watchEffectwatch

    +

    至此,Vue3 响应式的核心功能已全部实现完!

    +

    响应式 UI

    现在既然已经实现了响应式,那么我们回到最初的问题:

    +
    let col = [1, 2, 3, 4, 5]
    let s = sum(col)
    + +

    我们如何将这段代码变成响应式的,或者说,是否可以更进一步,直接将它变成响应式的 UI?

    +
    graph LR
    +
    +A[数据变更] -->|自动触发| B[界面渲染]
    +B -->|自动监听| A
    + +

    那么我们直接来定义一个(似曾相识的)组件:

    +
    const App = {
    // 一个最原始的 render 函数,接收 ctx 参数
    // 返回一个 HTML 节点
    render (ctx) {
    let div = document.createElement('div');
    // 使用 reduce 获得累加的和
    div.textContent = ctx.col.reduce((a, b) => a + b, 0);
    return div;
    },
    // 模仿组合式 API 的 setup 函数...
    setup () {
    const col = reactive([1, 2, 3, 4, 5])
    return { col }
    }
    }
    + +

    然后,我们编写一个(似曾相识的) createApp 函数:

    +
    function createApp (Component) {
    return {
    // 挂载函数
    mount (root) {
    // 一点兼容代码,获取挂载的根节点
    let rootNode = typeof root === 'string' ? document.querySelector(root) : root;
    // 调用 setup 获取 context
    let context = Component.setup();
    // 每当 context 发生变化时,effect 都将自动执行
    effect(() => {
    rootNode.innerHTML = '';
    let node = Component.render(context);
    rootNode.append(node);
    });
    }
    };
    }
    + +

    最后,我们将组件挂载到 #app 上:

    +
    createApp(App).mount('#app')
    + +

    (当然我们还需要一个 HTML 文件):

    +
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <title>Title</title>
    </head>
    <body>
    <div id="app"></div>
    <script src="index.js" type="module"></script>
    </body>
    </html>
    + +

    完成了!虽然还非常简陋,但我们已经用与 Vue 类似的方式实现了一个响应式的前端页面:当 col 更新时,页面上将显示新的求和值。

    +

    再次强调,本文使用的实现思路与 Vue 大致相同,但简化了许多。对此感兴趣的同学,欢迎阅读 vuejs/core 源码。

    +]]>
    + + javascript + vue + +
    + + -webkit-overflow-scrolling + /2019/webkit-overflow-scrolling/ + -webkit-overflow-scrolling CSS 属性可以让滚动元素在 ios 设备上获得接近原生的平滑滚动以及滚动回弹效果。

    +

    支持的值:

    +
      +
    • auto 普通滚动行为,当手指离开屏幕时,滚动会立即停止(默认)
    • +
    • touch 基于动量的滚动行为,当手指离开屏幕时,滚动会根据手势强度以相应的速度持续一段时间,同时会赋予滚动回弹的效果
    • +
    + + +

    一个例子:

    +
    <template>
    <section>
    <div class="scroll-touch">
    <p>
    This paragraph has momentum scrolling
    </p>
    </div>
    <div class="scroll-auto">
    <p>
    This paragraph does not.
    </p>
    </div>
    </section>
    </template>

    <style scoped>
    div {
    width: 100%;
    overflow: auto;
    }

    p {
    width: 200%;
    background: #f5f9fa;
    border: 2px solid #eaf2f4;
    padding: 10px;
    }

    .scroll-touch {
    -webkit-overflow-scrolling: touch; /* Lets it scroll lazy */
    }

    .scroll-auto {
    -webkit-overflow-scrolling: auto; /* Stops scrolling immediately */
    }
    </style>
    + +

    但是,这个属性在当容器内有 position: fixed 元素时会产生冲突,fixed 元素会在平滑滚动结束时才回到正确的位置,解决方案通常是重新整理组件树,使 fixed 元素不出现在滚动容器之内即可。

    +

    ref:

    +
      +
    1. https://stackoverflow.com/questions/29695082/mobile-web-webkit-overflow-scrolling-touch-conflicts-with-positionfixed
    2. +
    3. https://stackoverflow.com/questions/25963491/position-fixed-and-webkit-overflow-touch-issue-ios-7
    4. +
    +]]>
    + + css + +
    + + Webpack HMR Not Work in IDEA + /2016/webpack-hmr-not-work-in-idea/ + +
  • goto ‘File | Settings | Appearance & Behavior | System Settings’;
  • +
  • uncheck ‘Use save write’ option
  • + +

    +

    Problem solved.

    +]]>
    + + webpack + idea + +
    + + 在 Windows 中使用 Cygwin + /2021/windows-idea-cygwin/ + 之前在 WSL on Windows 10 中尝试了 WSL,但是几经周折最后发现问题比较多,用得有点难受。最后还是换回了 windows。

    + + +

    下载

    https://www.cygwin.com/

    +

    设置 Windows Terminal

    注意,后面的 C:\cygwin64 换成实际安装路径。

    +

    增加 cygwin 配置

    +

    效果

    +

    设置 IDEA

    修改 Shell path

    注意里面填的是 C:\cygwin64\bin\env.exe CHERE_INVOKING=1 /bin/bash -l,这样才能在项目目录打开终端:

    +

    +

    效果

    +

    设置 ssh keys

    可以建一对新的 key pair,也可以直接使用 windows 下面建好的。

    +

    如果要使用 windows 的,只需将 .ssh 文件夹下面的内容复制到 C:\cygwin64\home\user\.ssh 即可。

    +]]>
    + + shell + windows + +
    + + WordPress 文章归档页面实现 + /2016/wordpress-archives-page-implementation/ + 归档页就是一个包含站点所有已发布文章的列表页面,通常默认会根据发布时间来进行排序,然后可能会有一些分页排序页内搜索等功能。实现这个功能可以用Wordpress插件,当然也可以自己写代码,我一开始就是用了一款插件,觉得实现了功能还不错就没管它。后来想要做一些自定义的修改,比如插件是按月份分组然而我想改成年份,就稍微看了看它的代码。一看不得了,莫名地有一种总算见识到了什么叫又烂又臭的代码的感觉涌上心头,做了这么多年伸手党总算是被恶心到了,简直不能忍,于是琢磨着自己写一个简单的模板页,不用它了。

    + + +

    吐槽区

    首先来说说为什么这个插件的代码又烂又臭,在后面我再对它进行针对性的改进。哦对了它的名字叫Clean Archives Reloaded,作者叫Viper007Bond,来自美国俄勒冈州,没错就是点名批评,看来鬼佬的编码水平也不是普遍的高啊,这坨屎简直是开源界的耻辱。去到各搜索引擎搜索“Wordpress归档”关键字还有很多文章推荐使用该插件,看来大家都不太关心代码质量,只要能用就行。 该插件的主要设计思路如下:

    +
      +
    1. 从WP数据库中抓取文章
    2. +
    3. 根据用户配置分组并排序
    4. +
    5. 组织并输出HTML到页面相关位置
    6. +
    +

    OK,就这么三步,实际上我们也只需要这么点东西。暂且不讨论步骤是否可以简化,我们先来看看它有着怎样的内心世界。

    +
    // A direct query is used instead of get_posts() for memory reasons
    $rawposts = $wpdb->get_results( "SELECT ID, post_date, post_date_gmt, comment_status, comment_count FROM $wpdb->posts WHERE post_status = 'publish' AND post_type = 'post' AND post_password = ''" );
    + +

    这个是它的唯一一条SQL语句,可以看到作者为了给我们节省内存真是殚精竭力,本着够用就行的精神,放弃使用Wordpress自带的API,直接使用查询语句从数据库中查询出来了非常有限的一些字段。值得称赞。 按照插件的思路,紧接着就是分组啦:

    +
    // Loop through each post and sort it into a structured array
    foreach( $rawposts as $post ) {
    $posts[ mysql2date( 'Y.m', $post->post_date ) ][] = $post;
    }
    $rawposts = null; // More memory cleanup
    + +

    排序啦:

    +
    ( 'new' == $atts['monthorder'] ) ? krsort( $posts ) : ksort( $posts );

    // Sort the posts within each month based on $atts
    foreach( $posts as $key => $month ) {
    $sorter = array();
    foreach ( $month as $post )
    $sorter[] = $post->post_date_gmt;

    $sortorder = ( 'new' == $atts['postorder'] ) ? SORT_DESC : SORT_ASC;

    array_multisort( $sorter, $sortorder, $month );

    $posts[$key] = $month;
    unset($month);
    }
    + +

    分组的思路就是根据一篇文章的年以及月来将原本的一维数组重新组织到一个新的二维数组中去,以方便后面的循环。排序有点复杂,首先大局上它是能够根据配置按月份从新到旧或者反方向的排序,然后在每个月份里面也能够根据配置从新到旧或者反方向的排序,这个设定简直蛋疼,谁这么无聊正着排一遍在里面反着又排一遍,即折磨自己又折磨读者,不过存在即合理,这里也不说它。**我想吐槽的是,既然你都把SQL写出来了,你也知道至少要排一次序了,又何必费尽周章在查出来以后排呢,我们直接在SQL里面排不比这一大串代码优雅吗?不快速吗?不节省内存吗?此外,这个分组也是萌萌哒,我们就不能在SQL里面先把组给分好吗,非要写个循环来调用 **mysql2date,这样真的好吗?当然如果作者没有学过 ORDER BY,也不知道SQL都有各自的内置日期函数,这些也就算了。我们接着往下看。 接下来的步骤是组织HTML:

    +
    // Generate the HTML
    $html = '<div class="car-container';
    if ( 1 == $atts['usejs'] ) $html .= ' car-collapse';
    $html .= '">'. "\n";

    // 此处省略n行

    $html .= "</ul>\n</div>\n";
    return $html;
    + +

    看到这里我已经瞎了。。。尤其是高亮的那一行。。。省略的N行中充斥着的都是如此的代码。它还不止有 . "\n" 之流,在省略的内容中甚至连HTML的编码器缩进作者都保留得很好很好。WTF??这TM都是些什么鬼??作者的这些杠N和缩进是写给鬼看的吗???字符串拼凑各种内容这种事我自己不懂事的时候也干过不少也就不说了,但这作者这一种原汁原味的拼法真是我有屎以来见过的最特立独行的行为艺术。

    +

    让我们接着来看生成HTML之中的一部分核心代码。显然其中会有一些循环用来生成列表,并且在每个内层循环之前应该输出一个标题之类的东西用来指示以下的内容属于哪一年哪一个月。代码如下:

    +
    $firstmonth = TRUE;
    foreach( $posts as $yearmonth => $posts ) {
    list( $year, $month ) = explode( '.', $yearmonth );

    $firstpost = TRUE;
    foreach( $posts as $post ) {
    if ( TRUE == $firstpost ) {
    $html .= ' <li><span class="car-yearmonth">' . sprintf( __('%1$s %2$d'), $wp_locale->get_month($month), $year );
    if ( '0' != $atts['postcount'] ) $html .= ' <span title="' . __('Post Count', 'clean-archives-reloaded') . '">(' . count($posts) . ')</span>';
    $html .= "</span>\n <ul class='car-monthlisting'>\n";
    $firstpost = FALSE;
    }

    $html .= ' <li>' . mysql2date( 'd', $post->post_date ) . ': <a href="' . get_permalink( $post->ID ) . '">' . get_the_title( $post->ID ) . '</a>';

    // Unless comments are closed and there are no comments, show the comment count
    if ( '0' != $atts['commentcount'] && ( 0 != $post->comment_count || 'closed' != $post->comment_status ) )
    $html .= ' <span title="' . __('Comment Count', 'clean-archives-reloaded') . '">(' . $post->comment_count . ')</span>';

    $html .= "</li>\n";
    }

    $html .= " </ul>\n </li>\n";
    }
    + +

    第5-12行代码,第一眼看到的时候马上就能闻到一股弱者的气息。作者想要在循环开始之前先输出一个列表标题,所以想到了一个使用标志位的办法,但是我们明明可以直接在循环前面做这件事的,根本不需要这个萌萌哒标志位。

    +

    还有第14行。作者明明一直在标榜自己是如何节省时间节省内存的,结果在这里却使用了内置函数 get_the_title 以及 get_permalink,后者很正常,因为 wordpress 的文章链接是可以改变的,不能直接写死,必须查,那前者这个函数是做什么的呢?很明显,根据一篇文章的 ID 来获取它的标题。要如何根据 ID 来获取标题呢,我们能用算法算出来吗?显然不能,这里面显然需要一次数据库查询,至少也是一次缓存查询,而且它这个函数写在循环里面,我的天,这里面是多少条 SQL,你直接在一开始把 Title 也给查出来不就万事大吉了吗。。。

    +

    插件的核心功能大概就到此为止,为了实现让用户可以点击收起与展开每个内层列表的功能,作者还添加了一些 JavaScript 代码,就不吐槽了吧,我已经好累了。

    +

    改进

    赶紧把这插件删了,删个干净,然后我们来改代码。因为我并不需要配置什么什么的,也不需要什么JS,怎么个分组怎么个排序的需求很明确,所以直接 HARD CODE。原插件还有一个缓存查询出来的数据的功能,由于我已经用了更强大的缓存,直接将动态页面缓存成纯 HTML,所以也不需要。以上内容通通砍掉,核心代码就很简单了。 首先是 SQL 查询:

    +
    global $wpdb;
    $rawposts = $wpdb->get_results("SELECT ID, year(post_date) as post_year, post_date, post_date_gmt, post_title FROM $wpdb->posts WHERE post_status = 'publish' AND post_type = 'post' AND post_password = '' order by post_date_gmt desc");
    + +

    这里按照发布时间降序排序,为什么要用GMT时间而不直接用本地时间呢,我猜可能是为了防止我在这边发了一篇文章然后马上飞到美国又发一篇,可能会乱套吧,反正这么写更严谨,虽然不太可能发生。然后除了多选择一个post_title字段以外,还使用MySQL的一个内置函数选择了这篇文章发布时的年度,这样就不用在分组的时候使用N多遍 mysql2date 函数了。节省了大量步骤。 然后是分组:

    +
    foreach ($rawposts as $post) {
    $posts[$post->post_year][] = $post;
    }
    $rawposts = null;
    + +

    然后是HTML部分:

    +
    $html = '<div class="archives-container"><ul class="archives-list">';
    foreach ($posts as $year => $posts_yearly) {
    $html .= '<li><div class="archives-year">' . $year . '年</div><ul class="archives-sublist">';
    foreach ($posts_yearly as $post) {
    $html .= '<li>';
    $html .= '<time datetime="' . $post->post_date . '">' . mysql2date('m月d日 D', $post->post_date, true) . '</time>';
    $html .= '<a href="' . get_permalink($post->ID) . '">' . $post->post_title . '</a>';
    $html .= "</li>";
    }
    $html .= "</ul></li>";
    }
    $html .= "</ul></div>";
    return $html;
    + +

    两个字:简洁。

    +

    使用方法

    我们复制一份主题目录下的 page.php 文件,然后重命名为 template-archives.php,主要是给它加上以上的代码并且调用之。 对于我正在使用的主题来说,文件内容如下:

    +
    <?php
    /*
    Template Name: archives
    */


    function _PostList($atts = array())
    {
    global $wpdb;
    $rawposts = $wpdb->get_results("SELECT ID, year(post_date) as post_year, post_date, post_title FROM $wpdb->posts WHERE post_status = 'publish' AND post_type = 'post' AND post_password = '' order by post_date desc");
    foreach ($rawposts as $post) {
    $posts[$post->post_year][] = $post;
    }
    $rawposts = null;
    $html = '<div class="archives-container"><ul class="archives-list">';
    foreach ($posts as $year => $posts_yearly) {
    $html .= '<li><div class="archives-year">' . $year . '年</div><ul class="archives-sublist">';
    foreach ($posts_yearly as $post) {
    $html .= '<li>';
    $html .= '<time datetime="' . $post->post_date . '">' . mysql2date('m月d日 D', $post->post_date, true) . '</time>';
    $html .= '<a href="' . get_permalink($post->ID) . '">' . $post->post_title . '</a>';
    $html .= "</li>";
    }
    $html .= "</ul></li>";
    }
    $html .= "</ul></div>";
    return $html;
    }

    function _PostCount()
    {
    $num_posts = wp_count_posts('post');
    return number_format_i18n($num_posts->publish);
    }

    get_header(); ?>

    <div id="primary" class="content-area">
    <main id="main" class="site-main" role="main">

    <article <?php post_class(); ?>>
    <header class="entry-header">
    <h1 class="entry-title"><?php the_title(); ?></h1>
    </header>
    <!-- .entry-header -->

    <div class="entry-content">
    <?php
    echo _PostList();
    ?>
    </div>
    <!-- .entry-content -->
    </article>
    <!-- #post-## -->


    </main>
    <!-- #main -->
    </div>
    <!-- #primary -->

    <?php get_sidebar(); ?>
    <?php get_footer(); ?>
    + +

    然后我们把它上传到主机的主题目录下,来到wordpress管理控制台新建一个page,模板选择 archives,什么也不用输入(可以加个标题),保存,就可以看到效果了。当然这里没有涉及到CSS样式,可以在主题的 style.css 中自定义,也可以直接写在 template-archives.php 内,爱写哪写哪。本站使用的CSS如下所示:

    +
    .archives-year {
    color: #777;
    border-bottom: 1px solid #e8e8e8;
    margin: 40px 0 10px 0;
    padding-bottom: 7px;
    }

    .archives-list {
    list-style: none;
    margin: 20px 0!important;
    }

    .archives-sublist {
    list-style: none;
    font-size: 90%;
    margin-left: 0 !important;
    }

    .archives-sublist li time {
    color: #777;
    width: 140px;
    min-width: 140px;
    max-width: 140px;
    display: table-cell;
    vertical-align: top;
    }

    .archives-sublist li a {
    display: table-cell;
    vertical-align: top;
    }
    + +

    点此查看实际效果 (可能因为网站更新而不符合)

    +]]>
    + + php + wordpress + mysql + +
    + + WordPress 更改后台字体为雅黑 + /2016/wordpress-change-admin-panel-font/ + 这个问题其实困扰了我很久。默认的后台字体实在是惨不忍睹。今天终于发现了一个很好的方案,完美解决。

    +

    在当前主题的 functions.php 中,加上如下代码:

    +
    /**
    * 更改后台字体为雅黑
    */
    function change_admin_font(){
    echo '<style type="text/css">.wp-admin{font-family: \'Helvetica Neue\', Helvetica, \'Microsoft Yahei\', \'Hiragino Sans GB\', \'WenQuanYi Micro Hei\', sans-serif;}</style>';
    }
    add_action('admin_head', 'change_admin_font');
    + +

    顺便提供一下更改 Twenty Sixteen 主题字体的代码吧,要改的地方挺多的。

    + + +
    /* reset font */
    body,
    button,
    input,
    select,
    textarea,
    button,
    button[disabled]:hover,
    button[disabled]:focus,
    input[type="button"],
    input[type="button"][disabled]:hover,
    input[type="button"][disabled]:focus,
    input[type="reset"],
    input[type="reset"][disabled]:hover,
    input[type="reset"][disabled]:focus,
    input[type="submit"],
    input[type="submit"][disabled]:hover,
    input[type="submit"][disabled]:focus,
    .post-password-form label,
    .main-navigation,
    .post-navigation,
    .post-navigation .post-title,
    .pagination,
    .image-navigation,
    .comment-navigation,
    .site .skip-link,
    .logged-in .site .skip-link,
    .widget .widget-title,
    .widget_recent_entries .post-date,
    .widget_rss .rss-date,
    .widget_rss cite,
    .tagcloud a,
    .site-title,
    .entry-title,
    .entry-footer,
    .sticky-post,
    .page-title,
    .page-links,
    .comments-title,
    .comment-reply-title,
    .comment-metadata,
    .pingback .edit-link,
    .comment-reply-link,
    .comment-form label,
    .no-comments,
    .required,
    .site-footer .site-title:after,
    .widecolumn label,
    .widecolumn .mu_register label {
    font-family: 'Helvetica Neue', Helvetica, 'Microsoft Yahei', 'Hiragino Sans GB', 'WenQuanYi Micro Hei', sans-serif;
    }

    ::-webkit-input-placeholder {
    font-family: 'Helvetica Neue', Helvetica, 'Microsoft Yahei', 'Hiragino Sans GB', 'WenQuanYi Micro Hei', sans-serif;
    }

    :-moz-placeholder {
    font-family: 'Helvetica Neue', Helvetica, 'Microsoft Yahei', 'Hiragino Sans GB', 'WenQuanYi Micro Hei', sans-serif;
    }

    ::-moz-placeholder {
    font-family: 'Helvetica Neue', Helvetica, 'Microsoft Yahei', 'Hiragino Sans GB', 'WenQuanYi Micro Hei', sans-serif;
    }

    :-ms-input-placeholder {
    font-family: 'Helvetica Neue', Helvetica, 'Microsoft Yahei', 'Hiragino Sans GB', 'WenQuanYi Micro Hei', sans-serif;
    }
    + +

     

    +]]>
    + + wordpress + +
    + + WordPress 掉坑记录 + /2016/wordpress-hole-record/ + 忍无可忍,长期更新。

    +

    (其实我很想自己重新做一个 blog,但是太麻烦,也没什么实践价值了,无非 CRUD,而且维护起来很容易忽略 blog 本身的目的所在)

    + + +

    关于代码高亮

    本站目前(截至 09/20/2016)使用的是 Crayon 插件,这个插件配合 TinyMCE Advanced 简直神了,用户的数据对它们来说都不是什么东西,反正就随着各自的意愿来搞。其实这样还好,关键是,他俩意愿不一致。这 TM 就很尴尬了。以至于我很多文章,编辑再保存以后,格式出现各式各样的问题。

    +

    最终解决方案:

    +
      +
    1. 禁用 TinyMCE Advanced 的 keep p & br 功能;
    2. +
    3. 禁用 Crayon 的所有其它扫描功能,只保留 pre 扫描,即只保留块级代码高亮,同时禁用移除 code 标签的相关功能;
    4. +
    5. 关于行内代码的解决继续看下面。
    6. +
    +

    那么行内代码怎么办呢。这个 Crayon 太奇葩,如果用它自带的工具插入行内标签(原始是 span),会被它自己扫描出来认为是过时标签,然后强行转为 pre,关键是这一转它自己认得倒还好,然而 TinyMCE 不认为它仍然是行内元素,强行给它换行,套 p 元素。

    +

    然后文章的格式就完了,而且是全完。

    +

    所以,解决办法是,不要使用 Crayon 的行内模式,也不要让它扫描行内代码,直接使用 code 标签,然后去改 style,改得跟块级代码差不多就行了。

    +

    注:写完这些我就把 Crayon 这插件给删了。一个乱搞用户数据库,而且不用标准标签的东西,不要也罢。就直接用 codepre,还方便以后向其它平台转移。

    +]]>
    + + wordpress + +
    + + WSL on Windows 10 and Node.js + /2020/wsl-on-windows-10-and-node-js/ + Linux 的命令行与构建工具一般来说要比 Windows 好用,但 Windows 的用户界面毫无疑问要比 Linux 好用。以往在 Windows 10 上安装 Linux,要么是使用虚拟机,要么是使用双系统,总是无法做到两头兼顾。现在 Windows 10 有了 WSL 技术,使得「二者合一」成为了可能。

    + + +

    WSL

    安装

    关于如何安装 WSL,可以参考 适用于 Linux 的 Windows 子系统安装指南 (Windows 10),总的来说:

    +
      +
    1. 将 Windows 10 系统版本升到最高,如果需要安装 WSL 2 则目前来说需要比高更高(体验版);
    2. +
    3. 在「启用或关闭 Windows 功能」中,开启「适用于 Linux 的 Windows 子系统」,如果要安装 WSL 2 还需要开启「Hyper-V」;
    4. +
    5. 在 Windows 10 应用商店中搜索关键字「Linux」,并选择自己喜欢的发行版下载,比如我选择了「Ubuntu」;
    6. +
    7. 下载完成后,在开始菜单中找到它,并点击,会继续安装,过程大概需要几分钟;
    8. +
    9. 安装完成后,会提示输入 username 与 password,此即为 Linux 的用户凭据,至此 WSL 已安装完毕。
    10. +
    +

    权限

    为新增加的用户赋予 root 权限:

    +
    $ sudo vim /etc/sudoers
    + +

    在:

    +
    # User privilege specification
    root ALL=(ALL:ALL) ALL
    + +

    下面增加一行:

    +
    username	ALL=(ALL:ALL) ALL
    + +

    这里的 username 即是刚才创建的用户名,:wq! 退出即可。

    +

    测试

    安装完毕后,可以通过在终端输入 wsl 来进入已安装的 Linux 子系统。Linux 与 Windows 共享文件系统,Windows 的文件可以在 /mnt 下找到:

    +
    $ ls /mnt/
    c d e f
    + +

    这里的 c d e f 就分别代表 C/D/E/F 盘。

    +

    查看发行版本:

    +
    $ lsb_release -a
    No LSB modules are available.
    Distributor ID: Ubuntu
    Description: Ubuntu 20.04 LTS
    Release: 20.04
    Codename: focal
    + +

    Terminal

    Windows 10 自带的 CommandLine 和 PowerShell 都不好用,而且丑。可以下载 Windows 新推出的 Windows Terminal,直接在 Windows 10 应用商店就能找到。

    +

    Github: Microsoft/Terminal

    +

    同样,打开 Windows Terminal 后可以输入 wsl 来进入 Linux 子系统。

    +

    Git

    安装

    有了 WSL 后,开发相关工具环境都不需要在 Windows 下安装了。可以直接使用 Linux 内的程序。以 Git 为例:

    +
      +
    1. 打开 C:\Users\[username]\AppData\Roaming\
    2. +
    3. 在这里新建一个 bin 文件夹;
    4. +
    5. 在文件夹内新建一个 git.cmd 文件,输入内容:
      @echo off
      %WINDIR%\System32\bash.exe -c "git %*"
    6. +
    7. 在 Path 内加入刚刚设置的文件:C:\Users\[username]\AppData\Roaming\bin\git.cmd
    8. +
    +

    这样一来,就可以直接在 Windows 内访问到安装在 WSL 内的 git 了:

    +
    $ git --version
    git version 2.25.1
    + +

    除了 git 以外,其它程序也都可以如法炮制。

    +

    SSH Key

    $ git config --global user.name "username"
    $ git config --global user.email "email@example.com"
    $ ssh-keygen -trsa -C "email@example.com"
    $ cat ~/.ssh/id_rsa.pub
    + +

    如此可以得到公钥。

    +

    Node.js

    使用 apt get 之前,先替换一下镜像源:

    +
    $ sudo vim /etc/apt/sources.list

    # 将文件内容替换为以下:
    # 默认注释了源码镜像以提高 apt update 速度,如有需要可自行取消注释
    deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal main restricted universe multiverse
    # deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal main restricted universe multiverse
    deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-updates main restricted universe multiverse
    # deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-updates main restricted universe multiverse
    deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-backports main restricted universe multiverse
    # deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-backports main restricted universe multiverse
    deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-security main restricted universe multiverse
    # deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-security main restricted universe multiverse

    # 预发布软件源,不建议启用
    # deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-proposed main restricted universe multiverse
    # deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ focal-proposed main restricted universe multiverse
    + +

    这里使用的是 清华大学镜像源

    +

    Node.js & NPM

    替换完以后,安装 Node.js 与 NPM:

    +
    $ sudo apt-get update
    $ sudo apt-get upgrade
    $ sudo apt-get install nodejs
    $ sudo apt-get install npm
    + +

    n

    安装 Node.js 版本管理工具 n:

    +
    $ sudo npm install -g n
    # 查看所有已安装版本
    $ n ls
    # 安装最新的 LTS 版本并切换
    $ n lts
    + +

    nrm

    安装 NPM 源管理工具 nrm:

    +
    $ sudo npm install -g nrm
    # 查看所有源
    $ nrm ls
    # 切换至 taobao 镜像源
    $ nrm use taobao
    + +

    Yarn

    安装 Yarn:

    +
    $ sudo npm install -g yarn
    + +

    WebStorm

    WebStorm 可以直接与 WSL 完美集成。

    +
      +
    1. Terminal: File | Settings | Tools | Terminal,将 Shell path 设置为 "cmd.exe" /k "wsl.exe",这样 Terminal 打开就直接进入了 WSL
    2. +
    3. Git: File | Settings | Version Control | Git,将 Path to Git excutable 设置为 C:\Users\[username]\AppData\Roaming\bin\git.cmd
    4. +
    5. Node.js: File | Settings | Languages & Frameworks | Node.js and NPM,Node interpreter 这里选择 Add 可以直接添加 WSL 内的 Node.js,NPM 在 \\wsl$\Ubuntu\usr\local\lib\node_modules\npm,Yarn 在 \\wsl$\Ubuntu\usr\local\lib\node_modules\yarn
    6. +
    +

    这样一来,就可以实现 「Windows 的开发界面,Linux 的开发工具」了。

    +
    +

    update:

    +

    目前发现 Git 的 Commit 功能会报错:

    +
    Commit failed with error
    0 file committed, 2 files failed to commit: update theme
    could not read log file 'C:UsersedisoAppDataLocalTempgit-commit-msg-.txt': No such file or directory
    + +

    Push 正常。

    +

    解决办法:

    +
      +
    1. 直接在 Terminal 内使用 git commit
    2. +
    3. 升级到 2020.2 版本(目前是 EAP),但是经测试该版本要求 WSL2 才能正常工作,也就是 Windows 也要升级到 EAP 才行。不推荐。
    4. +
    +]]>
    + + nodejs + shell + linux + windows + wsl + +
    + + WordPress 在阿里云虚拟主机下无法发送邮件 + /2016/wordpress-unable-to-send-email-under-aliyun-virtual-host/ + 安装在阿里云虚拟主机环境下的Wordpress死活都发不出邮件,用户注册的邮件发不出,评论总结也发不出,等等等等,尝试了各种方法都以失败告终。今天用更改代码+SMTP插件终于试成功了,以下是解决方案。

    + + +

    更改主机设置

    首先阿里云虚拟主机发邮件相关的函数只开放了一个,即 fsockopen,默认情况下还是禁用的,所以我们要去控制台打开它(主机管理 ⇒ 站点信息 ⇒ 高级环境设置 ⇒ PHP.ini设置)。如图所示。

    +

    +

    更改Wordpress代码

    找到代码安装路径下的 wp-includes/class-smtp.php 文件,搜索以下代码段:

    +
    $this->smtp_conn = @stream_socket_client(
    $host . ":" . $port,
    $errno,
    $errstr,
    $timeout,
    STREAM_CLIENT_CONNECT,
    $socket_context
    );
    + +

    将其替换成:

    +
    $this->smtp_conn = fsockopen($host, $port, $errno, $errstr);
    + +

    注意:升级Wordpress可能会导致这段修改过的代码丢失,因此可能每次Wordpress主程序升级后都要再次修改此段代码!

    +

    安装SMTP插件

    改完代码以后,到Wordpress控制台搜索插件Easy WP SMTP(其它类似插件应该也行,这里以它为例),安装并启用。如图所示配置好。

    +

    +

    配置好后点击Save,保存成功后下方会有一个测试发送的表单。可以用它来测试SMTP是否已经可以正确工作。

    +

    如果测试邮件已经可以正常发送接收,则说明Wordpress的其它邮件也都可以正常收发了。

    +

    另外,本人测试过使用QQ和126的SMPT服务器,均以失败告终,原因未知。

    +

    问题待解决

    现在Wordpress主程序是可以正常发邮件了,但是BackWPup插件的邮件依然是完全发不出去,使用它提供的所有方式都不行,使用同样的SMTP配置也是不行,它也没有提供什么有用的错误信息,完全摸不着头脑。

    +

    为什么需要这个插件发邮件呢。因为它是网站的备份主力,但是因为邮件发不出去的关系,不能通过邮件发送备份,只能备份到主机的文件夹下。这样就很没安全感了,要完蛋都是一锅端的感觉,有无备份没什么区别。它提供的其它方案也好像都被墙了,比如Dropbox什么的。

    +

    这个问题待解决。

    +]]>
    + + php + wordpress + +
    + + 《芳华》 + /2017/youth/ + 我个人非常喜欢冯导的这部电影。

    +

    我的理解,这部电影的内容、主题,就跟它的名字一样,芳华。虽然我不是生活在那个年代的人,但是我也许可以理解那些都是什么。电影把一代人最美的形象,最好的年华,最真的梦想,展示给了我们看。相信这一点没有争议,不用过多解释。

    +

    至于其它的,我觉得都不重要。

    +

    有些人在这个故事里看到的更多是人的「恶」。如林丁丁,如红二代,如政委。认为所谓的「战友情」不过是镜花水月。但是,生活不就是这样的吗?

    +

    在电影里面,最终没有任何事情被追究,就连「迫害」了刘峰的林丁丁,最后也能被拿来给受害人打趣,然而我并没有觉得有任何反感之处。

    +

    人不就是这样的吗?当你对形势做出了错误的判断,就理应承担造成的后果。认真就输了,可谓一语成谶。既然是自己酿成的错,有什么好追究的呢?

    +

    百年以后,没有人会记得这些人当年的那些点点滴滴的琐事,善也好,恶也罢,大概都已经如萧穗子散落的情书一般,仿佛从来就没有存在过。即使是残酷至极的战争,也终究会被人遗忘。

    +

    也许能留下来的,也不过存在于现实与记忆中的,一代又一代人的最美的芳华吧。

    +]]>
    + + personal + +
    + + 小满 + /2023/xiao-man/ + 我和静纯的孩子在 2023 年 5 月 22 日出生,当天并不是小满,而是小满的次日。然而犹豫再三,最后我们还是给孩子取名为“小满”。

    +

    “小满”的含义,在于小满,而非大满,满而未盈。我们本打算如果孩子能在小满当日出生就叫他“小满”,却偏偏差了一日。但是转念想想,这一点点偏差,不是刚好对应上了“小满”的内在涵义吗?再者,虽然小满不是在当天出生的,但是妈妈却是在小满那天进的产房,生产过程除了手术室,我基本是全程陪着妈妈,这多少也能代表我们的一点回忆。

    +

    另外,除了这个结果以外,生孩子的过程也出现了偏差。但好在最后的结果是好的。孩子目前为止很健康,妈妈也恢复得很好,这样就足够了。这就是我这个小家庭的“小满”。

    + + +

    最后,放几张孩子的照片吧!

    +
    +]]>
    + + personal + +
    +
    diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 000000000..394cc2943 --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,1635 @@ + + + + + https://wxsm.space/tags/index.html + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/youth/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/about/index.html + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2023/xiao-man/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/tasteless-chicken-soup/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/thoughts-of-react-native/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/thougths-about-momentjs/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/token-apps-usage-experiences/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/travis-ci-in-github/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/uiv-release/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/unicode-substring/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/unit-test-best-practice-of-mini-program/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/untitled-2/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/untitled-3/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2013/untitled/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/upgrade-projects-scaffolded-by-vue-cli/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2020/upgrade-webpack-of-vue-cli-projects-from-3-to-4/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2020/use-eslint-to-forbid-entire-import-of-lodash/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2019/use-lodash-in-wechat-mini-programs/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/using-excerpt-in-wp-2016-theme/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/using-idea-to-config-ftp-auto-deployment/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/vue-components-i18n/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/vue-router-note/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/vue-transition-height/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2023/vue3-reactive/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2019/webkit-overflow-scrolling/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/webpack-hmr-not-work-in-idea/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/windows-idea-cygwin/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/wordpress-archives-page-implementation/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/wordpress-change-admin-panel-font/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/wordpress-hole-record/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2020/wsl-on-windows-10-and-node-js/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/wordpress-unable-to-send-email-under-aliyun-virtual-host/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2019/react-hooks/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/react-native-text-inline-image/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/react-node-starter/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/react-note-basic/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/regex-assertions/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2013/review-of-an-unexpected-journey/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2013/review-of-cp5/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2013/review-of-gravity/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/review-of-the-monkey-king-2/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/serve-static-with-pm2/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/simple-css-dark-mode/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/simplest-wechat-client-on-linux/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/solution-to-windows-cant-remove-node-modules-folder/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2013/some-memory/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/some-oddities-about-javascript/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/some-project-memo/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/static-blog-built-with-vue/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2019/parcel-note/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/paste-image-into-markdown-in-jetbrains-ide/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/php-note/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2019/plug-alipay-and-wxpay-with-react-native-webview/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2020/publish-using-github-action/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/react-hooks-vs-vca/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/limit-prerender-plugin-workers-by-webpack/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/mean-js-menu-service-extension/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2015/mean-js-note/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/linux-setup-for-work/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/mean-js-use-forever-to-prevent-app-crash/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/meanjs-5-x-ng-repeat-flashing/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2019/my-crohns-disease-and-treatment-records/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2014/nearing-the-duan-wu/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/node-js-web-spider-note-1/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/node-js-web-spider-note-2/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2012/not-feeling-good-recently/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/npm-history/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/javascript-event-delegation/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/javascript-promise/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/jsx-in-vuejs/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2019/koa-js-art-of-code/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/koa-note/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/learn-golang/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/graceful-degradation-versus-progressive-enhancement/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2015/graduation/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/holiday-soon-finally/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/how-to-build-a-wordpress-blog/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/idea-scrolling-issue/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/ie-cache-issue/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2020/integrate-renovate-with-gitlab/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/d3-note-enter-update-and-exit/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/d3-note-interpolate/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/d3-note-scale/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/deleted-a-game/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/disappointed/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/dying-to-survive/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/efficient-css-and-reflow-repaint/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/egret-note/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2020/enable-soft-wrap-for-markdown-files-in-idea-by-default/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2015/ext-usage-summary/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/fill-jd-slider-captcha-by-puppeteer/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2015/first-mid-autumn-after-graduation/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2015/git-ssh-key-gen-and-gitextension-configuration/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/github-pages-and-ssl/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/gitlab-ci-setup/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2020/gitlab-ce-code-review-bot/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2023/go-pprof-note/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2020/cache-node-modules-in-github-actions/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/case-insensitive-auto-complete-in-oxs-terminal/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2019/centos7-firewall-commands/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/change-socks-proxy-to-http/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/common-used-commands/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2019/conditional-rendering-in-react/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/cors-headers-note/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/css-triangle/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/d3-note-basis/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2020/06-23-2020/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2023/1-month-of-backend-dev/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2013/2013-annual-summary/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2014/2014-01-29/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2014/2014-11-11/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/2015-annual-summary/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/2016-08-25/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/2016/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/2017/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/2021-spring-festival/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2022/2022-05-20/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2015/2048-game-base-on-jquery/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/4-examples-of-mongodb-aggregate/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2020/a-difficult-debug-note/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/a-memorable-moment/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/a-simple-way-to-speed-up-github-connection/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2023/a-static-file-docker-image-issue/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/angular-router-note/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2020/auto-changelog-with-gitlab/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2018/auto-height-webview-of-react-native/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2020/automatic-cd-from-shell-scripts-to-k8s/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/baidu-submit-for-wordpress-update-0-1-0/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/baidu-submit-for-wordpress/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2019/benz-and-996/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2017/better-documents/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/bfc-theory-and-applications/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2021/blog-migrate-to-hexo/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2019/blog-migrated-to-vuepress/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2015/blog-migration/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2016/bootstrap-file-input-in-firefox/ + + 2023-12-03 + + monthly + 0.6 + + + + https://wxsm.space/2023/bv2mp3/ + + 2023-12-03 + + monthly + 0.6 + + + + + https://wxsm.space/ + 2023-12-03 + daily + 1.0 + + + + + https://wxsm.space/tags/personal/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/go/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/python/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/jquery/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/javascript/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/mongodb/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/vue/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/ssr/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/github/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/gfw/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/docker/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/angularjs/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/gitlab/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/devops/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/react-native/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/k8s/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/php/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/wordpress/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/webpack/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/markdown/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/css/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/bootstrap/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/firefox/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/nodejs/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/bilibili/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/yarn/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/osx/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/shell/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/linux/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/windows/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/proxy/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/react/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/ajax/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/d3/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/egret/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/idea/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/extjs/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/puppeteer/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/git/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/pprof/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/express/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/ie/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/jsx/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/koa/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/npm/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/parcel/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/mysql/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/regex/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/pm2/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/miniprogram/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/test/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/eslint/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/i18n/ + 2023-12-03 + weekly + 0.2 + + + + https://wxsm.space/tags/wsl/ + 2023-12-03 + weekly + 0.2 + + + + + diff --git a/tags/ajax/index.html b/tags/ajax/index.html new file mode 100644 index 000000000..a62dceb12 --- /dev/null +++ b/tags/ajax/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: ajax | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    ajax + 标签 +

    +
    + + +
    + 2017 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/angularjs/index.html b/tags/angularjs/index.html new file mode 100644 index 000000000..71bc623f3 --- /dev/null +++ b/tags/angularjs/index.html @@ -0,0 +1,417 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: angularjs | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    angularjs + 标签 +

    +
    + + +
    + 2017 +
    + + +
    + 2016 +
    + + + + + + +
    + 2015 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/bilibili/index.html b/tags/bilibili/index.html new file mode 100644 index 000000000..ffa0145f8 --- /dev/null +++ b/tags/bilibili/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: bilibili | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    bilibili + 标签 +

    +
    + + +
    + 2023 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/bootstrap/index.html b/tags/bootstrap/index.html new file mode 100644 index 000000000..3734b9d3e --- /dev/null +++ b/tags/bootstrap/index.html @@ -0,0 +1,354 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: bootstrap | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    bootstrap + 标签 +

    +
    + + +
    + 2017 +
    + + +
    + 2016 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/css/index.html b/tags/css/index.html new file mode 100644 index 000000000..75b4831d3 --- /dev/null +++ b/tags/css/index.html @@ -0,0 +1,460 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: css | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    css + 标签 +

    +
    + + +
    + 2021 +
    + + +
    + 2019 +
    + + +
    + 2017 +
    + + +
    + 2016 +
    + + + + + + + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/d3/index.html b/tags/d3/index.html new file mode 100644 index 000000000..5bb055390 --- /dev/null +++ b/tags/d3/index.html @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: d3 | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    d3 + 标签 +

    +
    + + +
    + 2016 +
    + + + + + + + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/devops/index.html b/tags/devops/index.html new file mode 100644 index 000000000..68279d0f6 --- /dev/null +++ b/tags/devops/index.html @@ -0,0 +1,497 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: devops | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    devops + 标签 +

    +
    + + +
    + 2020 +
    + + + + + + + + + + + + +
    + 2018 +
    + + +
    + 2017 +
    + + + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/docker/index.html b/tags/docker/index.html new file mode 100644 index 000000000..aef224a54 --- /dev/null +++ b/tags/docker/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: docker | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    docker + 标签 +

    +
    + + +
    + 2023 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/egret/index.html b/tags/egret/index.html new file mode 100644 index 000000000..11f59ae17 --- /dev/null +++ b/tags/egret/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: egret | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    egret + 标签 +

    +
    + + +
    + 2017 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/eslint/index.html b/tags/eslint/index.html new file mode 100644 index 000000000..8dc9a9c2a --- /dev/null +++ b/tags/eslint/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: eslint | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    eslint + 标签 +

    +
    + + +
    + 2020 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/express/index.html b/tags/express/index.html new file mode 100644 index 000000000..7c76e0a6a --- /dev/null +++ b/tags/express/index.html @@ -0,0 +1,354 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: express | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    express + 标签 +

    +
    + + +
    + 2016 +
    + + +
    + 2015 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/extjs/index.html b/tags/extjs/index.html new file mode 100644 index 000000000..0fb8f2cb6 --- /dev/null +++ b/tags/extjs/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: extjs | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    extjs + 标签 +

    +
    + + +
    + 2015 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/firefox/index.html b/tags/firefox/index.html new file mode 100644 index 000000000..4da5ce79d --- /dev/null +++ b/tags/firefox/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: firefox | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    firefox + 标签 +

    +
    + + +
    + 2016 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/gfw/index.html b/tags/gfw/index.html new file mode 100644 index 000000000..8bca6381d --- /dev/null +++ b/tags/gfw/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: gfw | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    gfw + 标签 +

    +
    + + +
    + 2021 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/git/index.html b/tags/git/index.html new file mode 100644 index 000000000..4e03a722b --- /dev/null +++ b/tags/git/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: git | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    git + 标签 +

    +
    + + +
    + 2015 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/github/index.html b/tags/github/index.html new file mode 100644 index 000000000..9c77e3c84 --- /dev/null +++ b/tags/github/index.html @@ -0,0 +1,420 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: github | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    github + 标签 +

    +
    + + +
    + 2021 +
    + + +
    + 2020 +
    + + + + +
    + 2017 +
    + + +
    + 2016 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/gitlab/index.html b/tags/gitlab/index.html new file mode 100644 index 000000000..9745a7a14 --- /dev/null +++ b/tags/gitlab/index.html @@ -0,0 +1,394 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: gitlab | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    gitlab + 标签 +

    +
    + + +
    + 2020 +
    + + + + + + +
    + 2018 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/go/index.html b/tags/go/index.html new file mode 100644 index 000000000..fc8add00e --- /dev/null +++ b/tags/go/index.html @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: go | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    go + 标签 +

    +
    + + +
    + 2023 +
    + + + + +
    + 2021 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/i18n/index.html b/tags/i18n/index.html new file mode 100644 index 000000000..9a2b265bd --- /dev/null +++ b/tags/i18n/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: i18n | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    i18n + 标签 +

    +
    + + +
    + 2017 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/idea/index.html b/tags/idea/index.html new file mode 100644 index 000000000..bf8f4104a --- /dev/null +++ b/tags/idea/index.html @@ -0,0 +1,417 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: idea | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    idea + 标签 +

    +
    + + +
    + 2021 +
    + + +
    + 2020 +
    + + +
    + 2016 +
    + + + + + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/ie/index.html b/tags/ie/index.html new file mode 100644 index 000000000..7acae73d3 --- /dev/null +++ b/tags/ie/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: ie | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    ie + 标签 +

    +
    + + +
    + 2016 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/index.html b/tags/index.html new file mode 100644 index 000000000..fa56fc2c5 --- /dev/null +++ b/tags/index.html @@ -0,0 +1,326 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签 | wxsm's pace + + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + + +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/javascript/index.html b/tags/javascript/index.html new file mode 100644 index 000000000..01e900482 --- /dev/null +++ b/tags/javascript/index.html @@ -0,0 +1,523 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: javascript | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    javascript + 标签 +

    +
    + + +
    + 2023 +
    + + + + +
    + 2021 +
    + + + + + + +
    + 2020 +
    + + + + +
    + 2019 +
    + + + + + + + +
    +
    + + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/javascript/page/2/index.html b/tags/javascript/page/2/index.html new file mode 100644 index 000000000..3ad8af82a --- /dev/null +++ b/tags/javascript/page/2/index.html @@ -0,0 +1,523 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: javascript | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    javascript + 标签 +

    +
    + + +
    + 2019 +
    + + + + +
    + 2018 +
    + + + + + + + + + + +
    + 2017 +
    + + + + +
    + 2016 +
    + + + +
    +
    + + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/javascript/page/3/index.html b/tags/javascript/page/3/index.html new file mode 100644 index 000000000..de2c50043 --- /dev/null +++ b/tags/javascript/page/3/index.html @@ -0,0 +1,377 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: javascript | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    javascript + 标签 +

    +
    + + +
    + 2016 +
    + + + + +
    + 2015 +
    + + + +
    +
    + + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/jquery/index.html b/tags/jquery/index.html new file mode 100644 index 000000000..b0de22176 --- /dev/null +++ b/tags/jquery/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: jquery | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    jquery + 标签 +

    +
    + + +
    + 2015 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/jsx/index.html b/tags/jsx/index.html new file mode 100644 index 000000000..03006c814 --- /dev/null +++ b/tags/jsx/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: jsx | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    jsx + 标签 +

    +
    + + +
    + 2017 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/k8s/index.html b/tags/k8s/index.html new file mode 100644 index 000000000..6cad64882 --- /dev/null +++ b/tags/k8s/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: k8s | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    k8s + 标签 +

    +
    + + +
    + 2020 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/koa/index.html b/tags/koa/index.html new file mode 100644 index 000000000..edf404d46 --- /dev/null +++ b/tags/koa/index.html @@ -0,0 +1,354 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: koa | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    koa + 标签 +

    +
    + + +
    + 2019 +
    + + +
    + 2017 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/linux/index.html b/tags/linux/index.html new file mode 100644 index 000000000..d7d500d51 --- /dev/null +++ b/tags/linux/index.html @@ -0,0 +1,397 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: linux | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    linux + 标签 +

    +
    + + +
    + 2020 +
    + + +
    + 2019 +
    + + +
    + 2018 +
    + + + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/markdown/index.html b/tags/markdown/index.html new file mode 100644 index 000000000..9e97f0bcf --- /dev/null +++ b/tags/markdown/index.html @@ -0,0 +1,377 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: markdown | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    markdown + 标签 +

    +
    + + +
    + 2021 +
    + + +
    + 2020 +
    + + +
    + 2017 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/miniprogram/index.html b/tags/miniprogram/index.html new file mode 100644 index 000000000..39639def0 --- /dev/null +++ b/tags/miniprogram/index.html @@ -0,0 +1,354 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: miniprogram | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    miniprogram + 标签 +

    +
    + + +
    + 2021 +
    + + +
    + 2019 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/mongodb/index.html b/tags/mongodb/index.html new file mode 100644 index 000000000..c751963d5 --- /dev/null +++ b/tags/mongodb/index.html @@ -0,0 +1,354 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: mongodb | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    mongodb + 标签 +

    +
    + + +
    + 2021 +
    + + +
    + 2015 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/mysql/index.html b/tags/mysql/index.html new file mode 100644 index 000000000..66942640e --- /dev/null +++ b/tags/mysql/index.html @@ -0,0 +1,354 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: mysql | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    mysql + 标签 +

    +
    + + +
    + 2021 +
    + + +
    + 2016 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/nodejs/index.html b/tags/nodejs/index.html new file mode 100644 index 000000000..8b1178e1f --- /dev/null +++ b/tags/nodejs/index.html @@ -0,0 +1,526 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: nodejs | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    nodejs + 标签 +

    +
    + + +
    + 2023 +
    + + +
    + 2021 +
    + + +
    + 2020 +
    + + + + + + +
    + 2018 +
    + + + + +
    + 2017 +
    + + + + + + + +
    +
    + + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/nodejs/page/2/index.html b/tags/nodejs/page/2/index.html new file mode 100644 index 000000000..baed3c465 --- /dev/null +++ b/tags/nodejs/page/2/index.html @@ -0,0 +1,460 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: nodejs | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    nodejs + 标签 +

    +
    + + +
    + 2017 +
    + + +
    + 2016 +
    + + + + + + + + + + +
    + 2015 +
    + + + +
    +
    + + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/npm/index.html b/tags/npm/index.html new file mode 100644 index 000000000..f1f190af5 --- /dev/null +++ b/tags/npm/index.html @@ -0,0 +1,377 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: npm | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    npm + 标签 +

    +
    + + +
    + 2021 +
    + + +
    + 2020 +
    + + +
    + 2016 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/osx/index.html b/tags/osx/index.html new file mode 100644 index 000000000..59415dda2 --- /dev/null +++ b/tags/osx/index.html @@ -0,0 +1,354 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: osx | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    osx + 标签 +

    +
    + + +
    + 2018 +
    + + +
    + 2017 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/parcel/index.html b/tags/parcel/index.html new file mode 100644 index 000000000..ac15f16af --- /dev/null +++ b/tags/parcel/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: parcel | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    parcel + 标签 +

    +
    + + +
    + 2019 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/personal/index.html b/tags/personal/index.html new file mode 100644 index 000000000..8ce620117 --- /dev/null +++ b/tags/personal/index.html @@ -0,0 +1,529 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: personal | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    personal + 标签 +

    +
    + + +
    + 2023 +
    + + +
    + 2022 +
    + + +
    + 2021 +
    + + + + + + +
    + 2020 +
    + + +
    + 2019 +
    + + + + + + +
    + 2018 +
    + + + +
    +
    + + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/personal/page/2/index.html b/tags/personal/page/2/index.html new file mode 100644 index 000000000..bbb00cf9d --- /dev/null +++ b/tags/personal/page/2/index.html @@ -0,0 +1,520 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: personal | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    personal + 标签 +

    +
    + + +
    + 2018 +
    + + +
    + 2017 +
    + + + + + + + + +
    + 2016 +
    + + + + + + + + + + + +
    +
    + + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/personal/page/3/index.html b/tags/personal/page/3/index.html new file mode 100644 index 000000000..f316ef1a2 --- /dev/null +++ b/tags/personal/page/3/index.html @@ -0,0 +1,520 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: personal | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    personal + 标签 +

    +
    + + +
    + 2016 +
    + + + + + + + + + + + + +
    + 2015 +
    + + + + + + +
    + 2014 +
    + + + +
    +
    + + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/personal/page/4/index.html b/tags/personal/page/4/index.html new file mode 100644 index 000000000..b9dfc2b2a --- /dev/null +++ b/tags/personal/page/4/index.html @@ -0,0 +1,500 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: personal | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    personal + 标签 +

    +
    + + +
    + 2014 +
    + + + + +
    + 2013 +
    + + + + + + + + + + + + +
    + 2012 +
    + + + +
    +
    + + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/php/index.html b/tags/php/index.html new file mode 100644 index 000000000..5480b5751 --- /dev/null +++ b/tags/php/index.html @@ -0,0 +1,414 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: php | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    php + 标签 +

    +
    + + +
    + 2021 +
    + + +
    + 2016 +
    + + + + + + + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/pm2/index.html b/tags/pm2/index.html new file mode 100644 index 000000000..eb9d66bdf --- /dev/null +++ b/tags/pm2/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: pm2 | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    pm2 + 标签 +

    +
    + + +
    + 2017 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/pprof/index.html b/tags/pprof/index.html new file mode 100644 index 000000000..7b255fa83 --- /dev/null +++ b/tags/pprof/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: pprof | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    pprof + 标签 +

    +
    + + +
    + 2023 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/proxy/index.html b/tags/proxy/index.html new file mode 100644 index 000000000..3c5e8d4af --- /dev/null +++ b/tags/proxy/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: proxy | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    proxy + 标签 +

    +
    + + +
    + 2017 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/puppeteer/index.html b/tags/puppeteer/index.html new file mode 100644 index 000000000..bdf9bb5dc --- /dev/null +++ b/tags/puppeteer/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: puppeteer | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    puppeteer + 标签 +

    +
    + + +
    + 2018 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/python/index.html b/tags/python/index.html new file mode 100644 index 000000000..5fbd40df6 --- /dev/null +++ b/tags/python/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: python | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    python + 标签 +

    +
    + + +
    + 2023 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/react-native/index.html b/tags/react-native/index.html new file mode 100644 index 000000000..4fbf1c5f9 --- /dev/null +++ b/tags/react-native/index.html @@ -0,0 +1,414 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: react-native | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    react-native + 标签 +

    +
    + + +
    + 2019 +
    + + + + +
    + 2018 +
    + + + + + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/react/index.html b/tags/react/index.html new file mode 100644 index 000000000..82dab3bbd --- /dev/null +++ b/tags/react/index.html @@ -0,0 +1,420 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: react | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    react + 标签 +

    +
    + + +
    + 2021 +
    + + +
    + 2019 +
    + + + + +
    + 2018 +
    + + +
    + 2017 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/regex/index.html b/tags/regex/index.html new file mode 100644 index 000000000..d3dc238d5 --- /dev/null +++ b/tags/regex/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: regex | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    regex + 标签 +

    +
    + + +
    + 2021 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/shell/index.html b/tags/shell/index.html new file mode 100644 index 000000000..5dbf15eda --- /dev/null +++ b/tags/shell/index.html @@ -0,0 +1,400 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: shell | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    shell + 标签 +

    +
    + + +
    + 2021 +
    + + +
    + 2020 +
    + + +
    + 2018 +
    + + +
    + 2017 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/ssr/index.html b/tags/ssr/index.html new file mode 100644 index 000000000..b2b8411f7 --- /dev/null +++ b/tags/ssr/index.html @@ -0,0 +1,354 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: ssr | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    ssr + 标签 +

    +
    + + +
    + 2020 +
    + + +
    + 2017 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/test/index.html b/tags/test/index.html new file mode 100644 index 000000000..589ff7439 --- /dev/null +++ b/tags/test/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: test | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    test + 标签 +

    +
    + + +
    + 2021 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/vue/index.html b/tags/vue/index.html new file mode 100644 index 000000000..3156439fe --- /dev/null +++ b/tags/vue/index.html @@ -0,0 +1,526 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: vue | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    vue + 标签 +

    +
    + + +
    + 2023 +
    + + +
    + 2021 +
    + + +
    + 2020 +
    + + + + +
    + 2019 +
    + + +
    + 2017 +
    + + + + + + + + + + + +
    +
    + + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/vue/page/2/index.html b/tags/vue/page/2/index.html new file mode 100644 index 000000000..4581ee7a9 --- /dev/null +++ b/tags/vue/page/2/index.html @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: vue | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    vue + 标签 +

    +
    + + +
    + 2017 +
    + + + + + + + +
    +
    + + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/webpack/index.html b/tags/webpack/index.html new file mode 100644 index 000000000..de01cee45 --- /dev/null +++ b/tags/webpack/index.html @@ -0,0 +1,437 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: webpack | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    webpack + 标签 +

    +
    + + +
    + 2020 +
    + + +
    + 2017 +
    + + + + + + + + +
    + 2016 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/windows/index.html b/tags/windows/index.html new file mode 100644 index 000000000..1298a5cd7 --- /dev/null +++ b/tags/windows/index.html @@ -0,0 +1,400 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: windows | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    windows + 标签 +

    +
    + + +
    + 2021 +
    + + +
    + 2020 +
    + + +
    + 2017 +
    + + +
    + 2016 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/wordpress/index.html b/tags/wordpress/index.html new file mode 100644 index 000000000..128f3eab2 --- /dev/null +++ b/tags/wordpress/index.html @@ -0,0 +1,471 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: wordpress | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    wordpress + 标签 +

    +
    + + +
    + 2016 +
    + + + + + + + + + + + + + + + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/wsl/index.html b/tags/wsl/index.html new file mode 100644 index 000000000..0a071f32a --- /dev/null +++ b/tags/wsl/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: wsl | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    wsl + 标签 +

    +
    + + +
    + 2020 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tags/yarn/index.html b/tags/yarn/index.html new file mode 100644 index 000000000..b06833a55 --- /dev/null +++ b/tags/yarn/index.html @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +标签: yarn | wxsm's pace + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + + + + + +
    +
    +
    +

    yarn + 标签 +

    +
    + + +
    + 2020 +
    + + + +
    +
    + + + + +
    +
    + + + + + + +
    + + 0% +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +