lizheming 发布于 06月03, 2021

SameSite 那些事

在《Web 安全漏洞之 CSRF》中我们了解到,CSRF 的本质实际上是利用了 Cookie 会自动在请求中携带的特性,诱使用户在第三方站点发起请求的行为。除了文中说的一些解决方式之外,标准还专门为 Cookie 增加了 SameSite 属性,用来规避该问题。Chrome 于 2015 年 6 月支持了该属性,Firefox 和 Safari 紧随其后也增加了支持。SameSite 属性有以下几个值:

  • SameSite=None:无论是否跨站都会发送 Cookie
  • SameSite=Lax:允许部分第三方请求携带 Cookie
  • SameSite=Strict:仅允许同站请求携带 Cookie,即当前网页 URL 与请求目标 URL 完全一致

该属性适合所有在网页下的请求,包括但不限于网页中的 JS 脚本、图片、iframe、接口等页面内的请求。可以看到 None 是最宽松的,和之前的行为无异。而 Lax 和 Strict 都针对跨站的情况下做了限制。其中 Strict 最为严格,不允许任何跨站情况下携带该 Cookie。Lax 则相对宽松一点,允许了一些显式跳转后的 GET 行为携带。以下是一个带有 SameSite 属性的标准 Cookie 响应示例:

Set-Cookie: name=lizheming; SameSite=None; Secure

需要注意的是,浏览器做了仅针对 HTTPS 域名才支持 SameSite=None 配置。所以如果你要设置 SameSite=None 的话,则必须还要携带 Secure 属性才行。

Same Site

Same Site 直译过来就是同站,它和我们之前说的同域 Same Origin 是不同的。两者的区别主要在于判断的标准是不一样的。一个 URL 主要有以下几个部分组成:

可以看到同域的判断比较严格,需要 protocol, hostname, port 三部分完全一致。相对而言,Cookie 中的同站判断就比较宽松,主要是根据 Mozilla 维护的公共后缀表(Pulic Suffix List)使用有效顶级域名(eTLD)+1的规则查找得到的一级域名是否相同来判断是否是同站请求。

例如 .org 是在 PSL 中记录的有效顶级域名,imnerd.org 则是一级域名。所以 https://blog.imnerd.org 和 https://www.imnerd.org 是同站域名。而 .github.io 也是在 PSL 中记录的有效顶级域名,所以 https://lizheming.github.io 和 https://blog.github.io 得到的一级域名是不一样的,他们两个是跨域请求。

在类似 GitHub/GitLab Pages, Netlify, Vercel 这种提供子域名给用户建站的第三方服务中,eTLD 的这种同站判断特性往往非常有用。通过将原本是一级域的域名添加到 eTLD 列表中,从而让浏览器认为配有用户名的完整域名才是一级域,有效解决了不同用户站点的 Cookie 共享的问题。

eTLD

eTLD 的全称是 effective Top-Level Domain,它与我们往常理解的 Top-Level Domain 顶级域名有所区别。eTLD 记录在之前提到的 PSL 文件中。而 TLD 也有一个记录的列表,那就是 Root Zone Database。RZD 中记录了所有的根域列表,其中不乏一些奇奇怪怪五花八门的后缀。

eTLD 的出现主要是为了解决 .com.cn, .com.hk, .co.jp 这种看起来像是一级域名的但其实需要作为顶级域名存在的场景。这里还可以分享一个有趣的事情,2020年5月份出现了一起阿里云所有 ac.cn 后缀网站解析全部挂掉的事件。原因就是 ac.cn 是中科院申请在册的 eTLD 域名。而阿里云的检测域名备案的脚本不了解规范,没有使用 PSL 列表去查找一级域名,而是使用了.分割的形式去查找的。最终所有 *.ac.cn 的域名由于 ac.cn 这个域名没有进行备案导致解析全部挂掉。而我们现在知道 ac.cn 这个域名是 eTLD 域名,它肯定是无法备案的。

Schemeful Same Site

在 Chrome 86/Firefox 79 中,浏览器增加了一个 Schemeful Same Site 的选项,将协议也增加到了 Same Site 的判断规则中。但是并不是完全的不等判断,可以理解是否有 SSL 的区别。例如 http:// 和 https:// 跨站,但 wss:// 和 https:// 则是同站,ws:// 和 http:/ 也算是同站。

Chrome 可以浏览器输入 chrome://flags/#schemeful-same-site 找到配置并开启。

Lax

我们知道互联网广告通过在固定域 Cookie 下标记用户 ID,记录用户的行为从何达到精准推荐的目的。随着全球隐私问题的整治,同时也是为了更好的规避 CSRF 问题,在 Chrome 80 中浏览器将默认的 SameSite 规则从 SameSite=None 修改为 SameSite=Lax。设置成 SameSite=Lax 之后页面内所有跨站情况下的资源请求都不会携带 Cookie。由于不会为跨站请求携带 Cookie,所以 CSRF 的跨站攻击也无从谈起,广告商也无法固定用户的 ID 来记录行为。

对用户来说这肯定是一件好事。但是对我们技术同学来说,这无疑是上游给我们设置的一个障碍。因为业务也确实会存在着多个域名的情况,并且需要在这些域名中进行 Cookie 传递。例如多站点使用 SSO 登录、接入统一的验证码服务、前端和服务端接口属于两个域名等等情况,都会因为这个修改受到影响。

这个修改影响面广泛,需要网站维护者花大量的时间去修改适配。而 Chrome 80 于 2020 年 2 月发布后全球就开始面临新冠疫情的影响。考虑到疫情问题后续的版本里又暂时先回退了这个特性(相关链接),最终是在 Chrome 86 进行了全量操作。

针对因为此次特性受到影响的网站,可以选择以下一些适配办法:

  1. 使用 JWT 等其它非 Cookie 的通信方式
  2. 为 Cookie 增加 SameSite=None;Secure 属性配置
  3. 所有的跨域接口增加 Nginx 代理,使其和页面保持同域

每一种方法都需要一些取舍。第一种更换 Cookie 的方式改造成本非常高,特别是在有外部业务对接的情况下基本不可能。第三种方式通过将跨域变为同域的转发方式可能会带来线上流量的成倍增加,也是需要考虑的因素。第二种设置成 None 看起来是比较简单的办法,不过也有着诸多的限制。

  1. SameSite=None;Secure 由于仅支持 HTTPS 页面,所以如果有 HTTP 的场景需要考虑跳转至 HTTPS 或者选择其他方案;
  2. 由于 SameSite 属性是后来才加入的,一些老浏览器(其实就是 IE)会忽略带有这些属性的 Cookie,所以需要同时下发未配置 SameSite 属性和配置 SameSite 属性的两条 Set-Cookie 响应头,这样支持和不支持的会各取所需;
  3. 在 Safari 的某些版本中会将 SamteSite=None 等同于 SameSite=Strict 所以部分 Safari 场景需要特殊处理不进行下发(相关链接);

综上使用代理转发的方式是我比较推荐的方式,除了不那么绿色之外兼容问题处理还是不错的。

SameParty

SameSite=Lax 断了我们跨站传递 Cookie 的念想,但实际业务上确实有这种场景。例如 Google 自己就有非常多的域名,这些域名如果都需要共享登录 Cookie 的话可能就会非常困难了。针对这种某个实体拥有多个域名需要共享 Cookie 的情况,就有人(那其实就是 Google 的同学)提出了 SameParty 的概念。

该提案提出了 SameParty 新的 Cookie 属性,当标记了这个属性的 Cookie 可以在同一个主域下进行共享。那如何定义不同的域名属于同一主域呢?主要是依赖了另外一个特性 first-party-set 第一方集合。它规定在每个域名下的该 URL /.well-known/first-party-set 可以返回一个第一方域名的配置文件。在这个文件中你可以定义当前域名从属于哪个第一方域名,该第一方域名下有哪些成员域名等配置。

当然使用固定 URL 会产生额外的请求,对页面的响应造成影响。也可以直接使用 Sec-First-Party-Set 响应头直接指定归属的第一方域名。

不过 W3C TAG 小组已经强烈拒绝了该提案(来源)。W3C 认为该提案重新定义了网站沙箱的边界,带来的影响可能不仅仅只是 Cookie 共享这么简单,包括麦克风、摄像头、地理信息等隐私设置都需要去重新评估影响。

同时该提案可能会和用户的预期不一致,如果 Google 和 Youtube 被定义成第一方网站进行共享的话,那 Google 就能很轻松的获取到用户在 Youtube 上的行为,可能用户并不想要这样。

W3C TAG 小组全称是 Technical Architecture Group,即 W3C 技术架构组。TAG 是 W3C 专注于 Web 架构管理的特殊小组。其使命是为 Web 架构的设计原则寻求共识,且在必要时梳理并澄清这些设计原则,帮助协调 W3C 内部及外部跨越不同技术的架构定义与研发工作。基本可以认为它是 Web 基础规范定义的小组。另外万维网之父 Tim Berners-Lee 也在 TAG 小组中。

不过 W3C 说的有理没理,都阻挡不了 Chrome 去实现这个功能。在 Chrome 89 中已经增加了 SameParty 的相关逻辑,只是目前没有默认开启。目前在 DevTools 中是可以看到 Cookie 的 SameParty 属性列的。Edge 由于使用了 Chromium 也在同版本支持了该功能。只掌管了规范,没有掌管实现,当某一方浏览器实现了“霸权”的情况下,W3C 的处境就变得尴尬了起来。

FLoC

SameSite 除了影响单实体多域名共享 Cookie 的情况,最大的问题其实就是互联网广告获取用户行为了。由于广告挂载页面和广告不在同域,所以广告无法获得用于标记用户 ID 从而对用户行为进行聚类。为了解决这个问题,有人(其实也是 Google 的同学)提出了 Federated Learning of Cohorts 同盟学习队列提案。

有别于之前使用 Cookie ID 标记直接将用户行为数据传递到广告商网站处理的方式。它提出了 document.interestCohort() 这个新的 API,将用户的行为在本地转换成了不带个人隐私的关键词,既规避了用户隐私问题,同时又解决了广告的精准投放问题。

不过这看似美好的东西却遭到了各大网站和浏览器的强力抵制,brav、Vivaldi、duckduckgo、GitHub 以及 Edge,Firefox,Safari(来源)都纷纷发表了拒绝支持的观点和行动。

社区主要的担心点在于,新的特性的增加可能会增加特征值为隐私嗅探提供了更广阔的入口。而且通过该 API 能获取到之前碍于权限无法程序获取的用户浏览数据。目前 Chrome 已经支持了这个功能,不过需要开启 Flag 才能支持。amIFLoCed 是一个用来检测你的浏览器是否开启了 FLoC 追踪特性的网站,可以使用它检测你的浏览器是否应用该特性。

后记

为了解决 CSRF 问题,Chrome 强推了 SameSite=Lax 作为默认配置。随之而来的,不仅是全球开发者的配合修改,还造成了已有场景的无法满足。而为了满足现有场景,又提出了 SameParty 和 FLoC 两个方案。这种行为不知能否成为浏览器的内卷行为?

SameSite 属性本身是没有什么问题的,但个人认为它应该是一种 CSRF 问题的选择方案,浏览器将其默认修改成 SameSite=Lax 就有点难受了。大部分企业项目里都已经采用其他 CSRF 防范方式规避了该问题,而 Lax 配置又存在着兼容性问题,不能让我们完全免顾 CSRF 之忧。

随着全球隐私问题的白热化,不知道还有什么新的提案搞出来需要我们全球开发者为其买单。

参考资料:

阅读全文 »

lizheming 发布于 04月06, 2021

南京行之游后感

前言

清明来了一次说走就走的旅行,去南京溜达了一圈。具体的每日见闻游记可见之前的几篇文章:

虽然过程有喜有乐,不过从行程上来看准备确实做的不充足,吃了很多亏。好在我们也是比较佛系的人,景点进不去那就不打卡了吧,人多排队那就换别家吧。这趟出行虽然有囧事,有累点,不过整体来看还是不错的。

旅游

这次出门获得的宝贵经验,那就是出门旅行之前务必需要先搜索一下当地的景点是否需要预约,是否支持网上购票,提前做好准备避免到了景区发现无法进入的尴尬。

这里列举一下我掌握的南京的一些旅游景点的相关讯息,如果大家有要去南京玩的可参考一二:

  1. 南京大屠杀纪念馆不需要门票,但是需要提前预约,使用微信公众号”侵华日军南京大屠杀遇难同胞纪念馆“可以查看预约情况并预约。
  2. 总统府需要门票,可以使用携程等网上购票,具体链接可查看总统府官网: http://www.njztf.cn/business/index.sh。
  3. 玄武湖公园不需要门票,可以直接在携程上预约。
  4. 雨花台烈士陵园不需要门票,需要在携程等平台上提前预约。预约的时候有两个选项,分别是陵园预约和陵园+纪念馆套票预约。纪念馆还是值得一看的,第一次去建议选择套票预约。

吃住

我们住的地方是南京机场宾馆夫子庙店,设备比较新,房间内东西齐全,周边环境也不错。交通比较方便,离这些景区都不算太远。价格也还算 OK,最重要的是二楼就有南京当地的淮扬菜连锁店小厨娘,吃饭极其方便。除了隔音效果一般之外,给我的体验非常不错。推荐给有需要的同学。

这次说老实话也没有特地去吃一些经典的菜,毕竟说实话大部分品类南京大牌档都有了,而且品种多都能吃到。盐水鸭确实还不错,鸭子做的不膻不腻肉质紧实多汁。另外鸭血粉丝汤里面的鸭肠可真是好吃呀~

出行

南京水杉很多,所以看起来树木都很笔直高大,看起来很养眼。比起其他地方,南京路上的红绿灯极其的多,这点可能和台湾类似,基本是没两步路就有斑马线和红绿灯配套。这个对行人比较友好,对开车的人就不知道是否方便了。

南京的路不算宽,路上的车辆也比较多,堵车情况从这几天来看似乎比起北京不逞多让,再加上在修地铁有些路口更是雪上加霜。到了景区附近更是车子动不动不了。

不过可能因为我们住的离去的地方都不远的缘故,打车还挺省钱的。小伙伴用美团打车基本上都只要几块钱就能搞定了,真是惊呆我了。

阅读全文 »

lizheming 发布于 04月05, 2021

南京行-Day 3

今天是行程的最后一天,我们放弃了之前找的攻略路线,准备把之前两天没有逛到的总统府和雨花台补完下。这两个在小伙伴的努力下都已经提前预约了,应该是不能再出什么幺蛾子了。

例行早起收了一波蚂蚁森林的能量之后把行李收拾好和小伙伴一块把房退了。不过因为我们还要出去玩,行李就暂时先寄存在宾馆了。吸取了昨天总统府旁边的早餐店排队盛况的教训,我们决定在宾馆附近吃完早餐再出发。附近找了一家看着还算干净的早餐店,豪放的狂点了一番后发现总价只有昨天早饭的一半,而且还比它更好吃,惹得小伙伴们纷纷对我们今天的机智点赞!

二战总统府

打车去总统府,沿途依旧是熟悉的风景。可能是因为我们来的稍微晚了点,今天排队买票的人没有昨天那么多了。不过我们也没有功夫去称赞自己的机智了,我们昨天预定的时间是8:30-10:30,到地方一看就已经9:45了,赶紧汇入进去的人群。好在突然又开放了一个入口分流,让我们能按时进入景区,成功打卡南京行第二个景区。

注:人不要太多的总统府

本来小伙伴想着这种人文景点,豪放的请一个人工讲解会更有意思一点,奈何天不遂人愿,去咨询台一看人工讲解已然售罄。无奈的我们悄咪咪的尾随了一位讲解听了半路,有些讲解听起来着实有些意思。令我印象深刻的是太平天国的一幅画,洪秀全的妹妹站在比洪秀全更高处,表示了当时太平天国男女平等的观念,这个在那个年代还真是难得。

注:总统府天下为公匾额

参观完前院就到了蒋介石办公的地方了,这块人多地方窄,所有人比肩继踵的往前艰难行走着。上楼梯的时候前面的人踢到后面人的膝盖是在正常不过了,听小伙伴说甚至有小孩摔倒了一度造成了交通的堵塞。其实就是老式的办公室,说实在的没什么可看的,也不知为何吸引了这么多人。

出来后就是一片花园。看着花园的白色围墙,想着昨天只能在墙外面眼巴巴的望着游园的人群心生羡慕的我们,再次感慨提前做攻略的重要性。花园的风景真是不错,成荫的绿树和似锦繁花相得益彰。中间还参观了一些国父孙中山的纪念馆,发现他居然只做了三个月的大总统,但是却给我们带来了巨大的影响,不由心生佩服。

注:革命尚未成功,同志仍需努力

不得不说总统府真的很大,后面的后花园之前是乾隆下江南的行宫,每栋建筑都古色古香。特别是春天各种鲜花争群夺艳,让这三十元的门票花的真值。参观完继续遵循昨天的不要在景区旁吃饭定律,我们找了些稍微远一丢丢的地方,可能是对距离没有把控好,结果也是都要等位。一气之下我们想着干脆吃火锅吧,最后定了距离不太远的马路边边串串香。步行过去的时候发现路边的风景再次熟悉起来,原来定位的地方竟然就是昨天我们沿着珍珠河溜达去总统府路过的地方!真是造化弄人。

注:好看的绣球

据小伙伴说这家马路边边似乎少了很多经典菜色,不过他们家特有的糖蒜牛肉获得了我们的一致好评。酒足饭饱过后,我们打车朝雨花台出发了。路上司机师傅和我们一路吐槽旅游景点,比如夫子庙的南京特色小吃都是安徽人开的,南京只有小笼包压根没有汤包之类的,让我们大吃一惊。

雨花台烈士陵园

雨花台烈士陵园恢弘大气,我们没有完全逛完,驻足比较久的是雨花台烈士纪念馆。里面陈列了大量的烈士事迹,包括我们在《觉醒年代》中看到的邓中夏。每位烈士的事迹都一一驻足瞻仰后,一个多小时已经过去了。大部分人都是在二十几岁人生最美好的年华中去世的,有些甚至只有十几岁,看照片都只是孩子模样。他们基本都是集中在几个时间过世的,有些可能是因为同一事件被抓,有些则是因为同一事件被处决。看到那些年纪轻轻就英勇就义的烈士,有种说不清道不明的感觉油然而生,或疑惑,或震撼,或感慨,或哀叹。

注:雨花台烈士陵园

纪念馆的尽头有个漆黑的小房间,里面陈列了一些烈士相关的物品,扫描他们的二维码可以查看对应的烈士事迹。特别的是每个小物品都使用火焰灯光照射着,通过漆黑的镜面效果将这几簇火焰生生演化出燎原之势,观感效果极好,同时也非常具有寓意。

注:只有9簇火焰的燎原效果

雨花台烈士陵园又非常肃穆庄严。花了一个多小时浏览完纪念馆,我们来到了纪念碑前。小伙伴买了朵小菊花代表我们的心意在纪念碑前为烈士献上了鲜花。看着时间也差不多要离开了,草草的游览了一下就离开了雨花台了。

返程

打车到宾馆拿了行李之后还有一点时间,就在附近找了个生煎包店解决晚饭。路上还碰到个叫”甜星“的好利来李鬼,本来以为只是个小面包店,结果为了防止火车上饿着买了点面包发现小票上居然印着苏州好利来的字样。怪不得在店里看到了好利来的半熟芝士,这就说得通了。回来一搜发现原来好利来苏州的店都改名叫甜星了,其它地方的也纷纷都改成当地的品牌名了。

打了出租车去车站,结果快到站计程车的表跳了一下。师傅应该是跳表钱看的,和我说 25。我看了下表疑惑的和他说:”不是 26 么?“真是老实人碰到老实人,分外尴尬。上车后还用手机写了一会游记,奈何太累了开始还是小憩,后来就沉沉的睡过去了。

到北京后已经是晚上十一点半了,如何回家顿时成为了难题。地铁停运只剩下增开的 4 号线,出租车早已人多车少车站工作人员已经不推荐排队等候了。无奈的我们只能想着出站走一段看看能不能打到车,结果滴滴打车等位都已经超过 200 位了。

出来车站没多远就有一些黑车在等着载客,随口问了下说是要 150 还得拼车,看这坐地起价的口气就不想坐。往前走了段我鬼使神差的建议在一个十字路口逆着人流方向走了一段,碰到一趟公车,想着人走估计是走不出这片拥堵区了,干脆坐公车坐几站出去了再打车吧。结果好家伙也不知是不是运气好,公车还真就能到住的地方附近。不过晚上太冷,最后一公里不太好解决,遂还是按照之前的思路尝试在后几站的公车站打车。

每过一个站我就重新更新下出发地点为后几站,就这么尝试了七八次之后,在我都已经不抱希望的情况下,居然被我打到车了!当时激动的心,颤抖的手,喜悦的心情估计周边人都能感受到了。而且非常幸运的是到站没多久师傅也到了,时间完美匹配!经过这么一个多小时的折腾,总算是顺利到家了。至此清明南京之行顺利结束!

阅读全文 »

lizheming 发布于 04月04, 2021

南京行-Day 2

总统府

本以为昨晚吃一堑长一智,详细的查看了路线攻略,发现没有需要预约才能参观的地点后能开开心心的开启新的一天。没想到我们还是太低估了国内旅游的困难程度。早上把小伙伴催促起来之后已然过了九点,匆忙打了个车就去了本日的第一站—总统府。来了之后发现本日第一囧,那就是排队买票的人实在是太多了,我们是万万没戏了。

注:酒店风景

想着反正也排不上了,遂大众点评上找了一家旁边的早餐店准备慢悠悠的吃个早饭去下个景点。结果去了店里发现这排队的人也非常多,果断换了旁边一家人不太多的吃了鸭血粉丝汤和汤包,不过质量嘛景区的东西你懂的。吃饭的时候顺手查了一下总统府能否在网上购票,发现居然可以!不过遗憾的是只能定明天的票了,不过我们还是先买了想着明天再过来。

注:鸭血粉丝汤

南京大学

费了半天劲打车来到我们的第二站南京大学后发生了本日的第二囧,南大可能因为疫情的原因目前暂不开放!无奈的我们打卡了下不算好看的校门,在附近溜达了一圈后就灰溜溜的跑去我们的第三站古鸡鸣寺了。

注:槽点满满的南京大学校门

古鸡鸣寺

到了古鸡鸣寺发现人更是多,看到门口的人山人海我们就果断放弃了参观的想法。不过因为人实在是太多了,已经需要一堆城管采用人墙策略划分人流维护秩序了。好巧不巧小伙伴又很想上厕所,而上厕所的标识指向了古鸡鸣寺的路口处。结果小伙伴就不小心被挤入到了入寺的人群中,最后好不容易让城管帮忙给放出来了,这算是本日的第三囧了。万般无奈之下就去了就近的地铁站进站上了个厕所,结果反馈地铁站的厕所居然也是要排队的,真是令人崩溃!

注:人山人海的古鸡鸣寺入口

南京大牌档

一连打卡三个景点都没有去成让我们的兴致不是非常高,临近中午了就想着干脆找一家饭馆好好吃一顿排解一下。怀着好奇心想试试南京的南京大牌档会不会特别点,于是找了一家附近的南京大牌档准备溜达过去。沿着珍珠河我们一边溜达一边拍照,走着走着就发现路边的风景慢慢熟悉了起来。原来我定位的这个南京大牌档居然就在总统府的旁边啊!囧上加囧的事情是,店里排队的人可不比去古鸡鸣寺的人要少,只能果断放弃了。

江宴楼

询问了一遍附近的饭馆发现居然都需要等位之后,我们想着是不是打个车逃离总统府景区就可以正常吃上饭了?于是一番讨论后大众点评上找了家评分比较高且不需要等位的江宴楼。去了之后才发现和我平常吃饭的地方档次高了一个数量级,服(菜)务(品)也特别的周(昂)到(贵)。

注:好吃的江宴楼

夫子庙

在包间我们边吃边聊间解决了随意点的一些菜品,感觉我们又复活了!看到古鸡鸣寺那么多人之后,我们放弃了下一站位于古鸡鸣寺旁边的玄武湖公园,直奔夫子庙秦淮河景区了。吃饭的地方离夫子庙倒也不是很远,就想着溜达溜达消消食。夫子庙旁边的步行街到处都充满了熙熙攘攘的人群。按照导航的定位,我们跑到了夫子庙的出口处买了票并成功进入。进去之后发现原来夫子庙真的就是说的孔夫子和学业相关的事情。这才明白过来为什么去的路上听到有人说”我一个山东人跑南京来参观夫子庙?“这种问句了。

作为我们今日成功打卡的第一个景区,也是我们南京之行成功打卡的第一个景区,我们还是非常认真的游览了一遍。里面按照时间先后展览着一些和学生、学习、考试等相关的藏品。其中最有意思的当属于古代人在袜子上用小字撰写的小抄,怕不是和现在的小抄有着异曲同工之妙。除了这些之外还有很多明国时期的毕业证书、课本教材、护照等有意思的文字材料。夫子庙算是已经很成熟的商业景区了,内部的商业气息非常浓重,付费撞钟祝好运,付费挂许愿树、付费扔币许愿、付费平安福等等活动充斥着景区。

注:古代小抄

参观完后我们从景区的入口处出来,发现来夫子庙的人真的很多,只是我们是从出口处进来的。夫子庙入口前面就是秦淮河,上面有秦淮河画舫可以120(白)/140(夜)元坐船的活动,想着晚上坐船可能有灯光会更好看点于是就先过了。路上有用铜片手动压制铭牌的机器,给小伙伴买了一个。想着景区里打车不方便就慢慢走出了景区,结果发现了我们第五件囧事,我们居然沿着之前路过的时候大家疯狂吐槽的”为什么会有很多人从小区门口出来“的地方回到了我们去夫子庙的原点处!实打实的沿着夫子庙走了一圈,真是欲哭无泪。

二战小厨娘

经历了两个轮回圈之后我们已然是精疲力尽,赶紧打了个车回酒店歇息了。稍作整顿后我去了小伙伴那继续补习《觉醒年代》了。晚上则还是在昨天的小厨娘,甚至还是昨天的那个位置吃的。我们点了松鼠桂鱼和烤鸭,想着试试南京的烤鸭和北京的烤鸭到底有什么区别。吃完后本来我们也没有觉得烤鸭有多好吃,但是因为服务员和我们说我们的烤鸭是最后半只,且吃饭过程中一直听到服务员和顾客说烤鸭早就卖完了,莫名的给这烤鸭增加了一颗星。

注:小厨娘的烤鸭

夜游东水关遗址

吃完沿着宾馆旁边的城墙溜达了一圈,走到城墙尽头上去看了下,发现旁边的秦淮河里也有画舫在游河,发现真要去划船着实是没什么可以看的,画舫游船一事也就作罢。本意是想出来溜达找个水果店买些水果回去窝着看剧的。逛了一圈大家又都有写疲了,就在宾馆旁边的超市买了袋牛奶回宾馆歇息去了。

注:夜游东水关

阅读全文 »

lizheming 发布于 04月03, 2021

南京行-Day 1

清明来了一次说走就走的旅行,突然之间决定和小伙伴一块去南京当回游客。本来我是想出去转转定了上海的,后来想想上海似乎也没有需要待好几天的必要,就在小伙伴的建议下选择了附近的南京。

我和小伙伴花了一个晚上的时间定好了酒店和出行时间,第二天早上用智行买的火车票。其中回程的车票因为只有一等座了就暂时先买了,后来智行还很温馨的提示我们有二等座车票了是否需要更换,果断更换后省了一晚酒店钱,同时也让我第一次体验到了智行这种第三方买票软件的优势。

出发

早早收拾好就出门赶地铁去了,想着给小伙伴带新口味的鸡蛋汉堡还路过了一下超市,结果也不知是起晚还是放假休息的缘故,鸡蛋汉堡的老板居然没有来上班,美好的愿望就这么落了个空。只能路上买了个煎饼留了一半给他们吃。

地铁上出行的人还是很多的,看来清明旅游区的人流量应该不会少。进车站后发现车展特地为清明出行开启了快速进站厅,上火车顿时方便了不少。上车后就在我焦急等待小伙伴的时候,他们总算赶着发车的尾巴上来了。上车后发现隔壁两个小孩在车上写作业,小伙伴也发表了他要带工作旅游的艰辛感慨,而我只能感谢他辛苦百忙之中抽空来陪我们旅游了。

注:火车上努力写作业的小朋友

许久不见分外想念,一路上有的没的聊着聊着就到地方了。当我们一边带着疑惑”不是不用扫健康码了吗“一边制作了苏州健康码后,我们碰到了第一个囧事。为了找出租车,我们居然按照火车站的指示牌绕着南站转了大半圈,也是不得不感慨造物主的脑回路。到了宾馆办好入住之后进屋发现居然还挺不错,东西应有尽有,设施也很整洁(不过隔音一般)。特别还碰到了宾馆服务员给房客发放清明节小礼物,顿时好感上升了不少。

注:路边盛开的景观花

由于已过中饭点,赶紧找了附近一家好评还不错的饭店小厨娘吃饭。它是南京一家连锁店,这家分店也是很神奇,居然开在了宾馆的二楼。我也是到了一楼之后才发现的,只能傻不愣登的赶紧跑二楼去了。为了节约用餐时间,趁着小伙伴还没到的时间我先把菜点了。菜口味还不错,就是忘记点招牌的盐水鸭了。而且糯米类的食物比较多,吃完已然是撑得不行了。

注:逛商场看到的可爱童装

南京大屠杀纪念馆

经过几个人轮番努力,总算打上了车去南京大屠杀纪念馆了。快到的时候我才发现我们的第二个囧事,那就是纪念馆是需要提前一天预约的,而不巧的是清明三天的时间都已经预约满了。在某人惋惜的眼神下,我们只好跑去附近的万达广场逛商场去了。不知是否是放假气息的带动,即使是逛着在帝都也能逛到的品牌店,我们依旧也很开心。甚至每个人都买到了自己称心的衣服,还不止一件!

注:蒙蒙细雨中送入云端的大厦

明瓦廊小吃街

逛完商场已然是到了晚上,打车去了小伙伴之前查到了一家南京很好喝的奶茶店拾叁茶明瓦廊店,想着买杯奶茶再在附近找家吃饭的地方就好了。在中江书香世家酒店下车后发现明瓦廊这条小巷似乎是小吃一条街,里面的小吃可太多了。特别是居然在去奶茶店的路上我们还看到了卖鸡蛋汉堡的!虽然比起在公司旁边吃的那家贵了很多,但看在排队的人那么多以及好奇心的驱使下,组织还是把我派出来排队购买了,而他们继续向奶茶店前进。当我看着门口无序的排队人群头大的时候,一个小妹妹非常优秀的和我达成了帮忙排队代购鸡蛋汉堡,她去其它地方排队帮我买好吃的的交易。结果买奶茶的,买其它好吃的都已经回来了,而我还在等鸡蛋汉堡…好在是没过一会都好了。真是到哪里都有排队、堵车的事情呀!

注:超多人排队的鸡蛋汉堡

等车的路上我们又买了点水果,想着干脆不吃晚饭,窝在宾馆边看剧边吃小吃了。回到宾馆发生了第三件囧事,宾馆只能刷卡到卡对应的楼层。而我和小伙伴不在一个楼层,当我想去他们楼层的时候发现过不去了。到一楼咨询了下之后发现需单独授权一下对应楼层,这里就想吐槽下办入住的时候服务员为什么不帮我们一步到位打通一下。

注:打卡到超好喝的茶饮品拾叁茶

吃了下鸡蛋汉堡发现用料确实要比我们在北京吃的扎实很多,带着一股鸡蛋的鲜嫩口感。不过我个人觉得在口味上还是北京的更胜一筹。北京的鸡蛋汉堡会使用榨菜来增加咸鲜味,同时辣酱口味使用的是剁椒而不是辣椒面也会更加符合我的口味。边吃边刷剧发现宾馆电视可以看《觉醒年代》,遂看了两集,正好在说一战后的巴黎合约事件,边看边搜下又恶补了一段历史知识。

阅读全文 »

lizheming 发布于 03月12, 2021

一道面试题让你更加了解事件队列

今天在群里聊天,突然有人放出了一道面试题。经过群里一番讨论,最终解题思路慢慢完善起来,我这里就整理一下群内解题的思路。

该题定义了一个同步函数对传入的数组进行遍历乘二操作,同时每执行一次就会给 executeCount 累加。最终我们需要实现一个 batcher 函数,使用其对该同步函数包装后,实现每次调用依旧返回预期的二倍结果,同时还需要保证 executeCount 执行次数为1。

let executeCount = 0
const fn = nums => {
  executeCount++
  return nums.map(x => x * 2)
}

const batcher = f => {
  // todo 实现 batcher 函数
}

const batchedFn = batcher(fn);

const main = async () => {
  const [r1, r2, r3] = await Promise.all([
    batchedFn([1,2,3]),
    batchedFn([4,5]),
    batchedFn([7,8,9])
  ]);

  //满足以下 test case
  assert(r1).tobe([2, 4, 6])
  assert(r2).tobe([8, 10])
  assert(r3).tobe([14, 16, 18])
  assert(executeCount).tobe(1)
}

抖机灵解法

拿到题目的第一时间,我就想到了抖机灵的方法。直接面向用例编程,执行完之后重置下 executeCount 就好了。

const batcher = f => {
  return nums => {
    try { return f(nums) } finally { executeCount = 1 }
  }
}

当然除非你不在乎这次面试,否则一般不建议你用这种抖机灵的方法回答面试官(不要问我为什么知道)。由于 executeCount 的值和 fn() 函数的调用次数呈正相关,所以这道理也就换成了我们需要实现 batcher() 方法返回新的包装函数,该函数会被调用多次,但最终只会执行一次 fn() 函数。

setTimeout 解法

由于题干中使用了 Promise.all(),我们自然而然想到使用异步去解决。也就是每次调用的时候会把所以的传参存下来,直到最后的时候再执行 fn() 返回对应的结果。问题在于什么时候触发开始执行呢?自然而然我们想到了类似 debounce 的方式使用 setTimeout 增加延迟时间。

const batcher = f => {
  let nums = [];
  const p = new Promise(resolve => setTimeout(_ => resolve(f(nums)), 100));

  return arr => {
    let start = nums.length;
    nums = nums.concat(arr);
    let end = nums.length;
    return p.then(ret => ret.slice(start, end));
  };
};

这里的难点在于预先定义了一个 Promise 在 100ms 之后才会 resolve。返回的函数本质只是将参数推入到 nums 数组中,待 100ms 后触发 resolve 返回统一执行 fn() 后的结果并获取对应于当前调用的结果片段。

后来有群友反馈,实际上不用定义 100ms 直接 0ms 也是可以的。由于 setTimeout 是在 UI 渲染结束之后才会执行的宏任务,所以理论上来说 setTimeout() 的最小间隔值无法设置为 0。它的最小值和浏览器的刷新频率有关系,根据 MDN 描述,它的最小值一般为 4ms。所以理论上它设置 0ms 和 100ms 效果是差不多的,都类似于 debounce 的效果。

Promise 解法

那么如何能实现延迟 0ms 执行呢?我们知道除了宏任务之外 JS 还有微任务,微任务队列是在 JS 主线程执行完成之后立即执行的事件队列。Promise 的回调就会存储在微任务队列中。于是我们将 setTimeout 修改成了 Promise.resolve(),最终发现也是可以实现同样的效果。

const batcher = f => {
  let nums = [];
  const p = Promise.resolve().then(_ => f(nums));

  return arr => {
    let start = nums.length;
    nums = nums.concat(arr);
    let end = nums.length;
    return p.then(ret => ret.slice(start, end));
  };
};

由于 Promise 的微任务队列效果将 _ => f(nums) 推入微任务队列,待主线程的三次 batcherFn() 调用都执行完成之后才会执行。之后 p 的状态变为 fulfilled 后继续完成最终 slice 的操作。

**2020-03-17:**感谢 @kricsleo 帮忙指出由于存在副作用多次调用会存在问题,并提供了优化版本。

const batcher = (f) => {
  let nums = [];
  let p;
  
  return (nums) => {
    if(!p) {
	  p = Promise.resolve().then(_ => f(nums));
    }
    
    const start = nums.length;
    nums = nums.concat(arr);
    const end = nums.length;
    
    return p.then(ret => {
      nums = [];
      p = null;
      return ret.slice(start, end);
    });
  };
};

后记

最终分析下来,其实这道理的本质就是要通过某些方法将 fn() 函数的执行后置到主线程执行完毕,至于是使用宏任务还是微任务队列,就看具体的需求了。除了 setTimeout() 之外,还有 setInterval(), requestAnimationFrame() 都是宏任务队列。而微任务队列里除了有 Promise 之外,还有 MutationObserver。关于宏任务和微任务队列相关的,感兴趣的可以看看《微任务、宏任务与Event-Loop》这篇文章。

阅读全文 »

lizheming 发布于 02月15, 2021

Hugo 主题 Eureka 自定义

今天有网友邮件我咨询我现在的主题 Eureka 的一些自定义配置,他想参考一下。由于我的博客仓库是私有的,所以就写一篇文章简单整理一下。

Eureka 是前段时间群友推荐给我的,纯白的朴素风格同时提供了暗色模式瞬间我就喜欢上了。将其 clone 到 Hugo 博客目录 themes/hugo-eureka 下,config.toml 中配置 theme = "hugo-eureka" 即可使用上该款主题。为了方便主题的更新,我将我所有自定义的模板都放在了 layouts 目录下。Hugo 会将主题目录和 layouts 目录下的文件进行合并,并优先使用 layouts 目录中的同名文件。这样之后我只需要单纯的更新 thtmes/hugo-eureka 目录即可。

首页

相较于 Eureka 主题的默认首页,我个人还是比较喜欢传统博客的两栏布局,左侧显示模块列表,右侧显示文章列表,所以我需要自定义首页模板。拷贝以下内容创建 layouts/index.html 文件即可实现同款。

{{ define "main" }}
<div class="pl-scrollbar">
  <div class="w-full max-w-screen-xl lg:px-4 xl:px-8 mx-auto">
    <div class="max-w-screen-xl mx-auto" style="padding-top: 3rem">
      <div class="bg-local bg-cover">
        <img class="day" src="/banner-day.png" />
        <img class="dark" src="/banner.png" />
      </div>
    </div>
    <!-- <article class="mx-6 my-7">
      <h1 class="font-bold text-3xl text-primary-text"></h1>
    </article> -->
    <div class="grid grid-cols-2 lg:grid-cols-8 gap-4 lg:pt-12">
      <div class="col-span-2 sidebar">
        <div class="widget bg-secondary-bg rounded p-6">
          <h2 class="widget-title">最新文章</h2>
          <ul class="widget-list">
            {{- $recent := default 5 .Site.Params.numberOfRecentPosts }}
            {{- $posts := where (where .Site.RegularPages "Permalink" "!=" .Permalink) "Type" "in" .Site.Params.mainSections }}
            {{- range first $recent $posts }}
            <li>
              <a href="{{ .RelPermalink }}" class="nav-link">{{ .Title }}</a>
            </li>
            {{- end }}
          </ul>
        </div>
        <div class="widget bg-secondary-bg rounded p-6">
          {{ $walineURL := .Site.Params.comment.waline.serverURL }}
          <h2 class="widget-title ">最近回复</h2>
          <ul class="widget-list recentcomments">
            {{ $resp := getJSON $walineURL "/comment?type=recent&count=10" }}
            {{ range first 10 $resp }}
            <li class="recentcomments">
              <a href="{{.Site.BaseURL}}{{ .url }}">{{ .nick }}</a>:{{ .comment | safeHTML | truncate 22 }}
            </li>
            {{ end }}
          </ul>
        </div>
        <div class="widget bg-secondary-bg rounded p-6">
          <h2 class="widget-title">友情链接</h2>
          <ul class="widget-list">
            {{ range .Site.Menus.friends }}
            <li>
              <a href="{{ .URL }}">{{ .Name }}</a>
            </li>
            {{ end }}
          </ul>
        </div>
        <div class="widget bg-secondary-bg rounded p-6">
          <h2 class="widget-title">管理</h2>
          <ul class="widget-list">
            <li>
              <a href="/admin">🛠 后台管理</a>
            </li>
            <li>
              <a href="{{ .Site.Params.comment.waline.serverURL }}/ui">💬 评论管理</a>
            </li>
          </ul>
        </div>
      </div>
      <div class="col-span-2  lg:col-span-6 bg-secondary-bg rounded px-6 py-8">
        <div class="bg-secondary-bg rounded overflow-hidden px-4 divide-y">
          {{ range .Paginator.Pages }}
          <div class="px-2 py-6">
              {{ partial "components/summary-plain.html" . }}
          </div>
          {{ end }}
        </div>
        {{ template "_internal/pagination.html" . }}
      </div>
    </div>
  </div>
</div>
{{ end }}

其中顶部还增加了一组暗色模式切换的横幅图片,添加以下 CSS 内容至 layouts/partials/custom-head.html 文件中,不存在的话需要新建。

.widget + .widget {
  margin-top: 1rem;
}
.widget-title {
  font-weight: bold;
  margin-bottom: 1rem;
}
.widget-list li {
  font-size: 0.9rem;
}
.bg-cover img {
  opacity: 1;
  transition: all .5s ease-in-out;
}
.bg-cover img.dark {
  opacity: 0;
  height: 0;
}
.dark .bg-cover img.day {
  opacity: 0;
  height: 0;
}
.dark .bg-cover img.dark {
  opacity: 1;
  height: auto;
}

左侧的模块中,评论是使用了本人自研的 Waline 评论系统并进行了一定的改造,具体可参见我之前的文章《静态博客如何高性能插入评论》。当然也可以直接使用 Waline 自带的最近评论挂件。

友情链接则是在 config.toml 中按照如下格式进行配置的。

[[menu.friends]]
name = "童欧巴博客"
url = "https://hungryturbo.com/"
weight = 20

[[menu.friends]]
identifier = "QingXu"
name = "QingXu"
url = "https://blog.qingxu.live"
weight = 19

[[menu.friends]]
identifier = "蜘蛛抱蛋"
name = "蜘蛛抱蛋"
url = "https://blog.zzbd.org/"
weight = 18

后台管理则是使用了 forestry 提供的服务,它支持提供在线后台进行文章、页面和其它配置的管理。评论管理则是链接到了 Waline 服务的后台面板中。

Metadata

Eureka 主题的文章 metadata 显示分为列表页和详情页两个,分别对应 post_metadata.html 和 post_metadata_full.html 两个文件。我们在 layouts/partials/ 目录下新建这两个文件用来覆盖主题默认的文件。

2021-03-13 更新: 更新后的 Eureka 统一使用了 components/post-metadata.html 显示文章的 metadata,代码和之前的 layouts/partials/post_metadata.html 是一致的。

{{/* layouts/partials/components/post_metadata.html */}}
<div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
  <div class="mr-6 my-2">
      <i class="fas fa-calendar mr-1"></i>
      <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span>
  </div>

  {{- $slug := printf "/%s.html" .Slug}}
  {{- $commentsData := (partialCached "utils/get-comments.html" .)}}
  
  {{- $comments := slice }}
  {{- range where $commentsData "url" "==" $slug}}
    {{$comments = $comments | append .}}
  {{- end}}
  {{- $count := len $comments}}
  <div class="mr-6 my-2">
    <a href="{{ .Permalink }}#waline-comments" title="{{ .Title }}">
      <i class="fas fa-comment mr-1"></i>
      <span>{{- if gt $count 0}}{{$count}} 条评论{{else}}暂无评论{{end -}}</span>
    </a>
  </div>

  <div class="mr-6 my-2">
      <i class="fas fa-clock mr-1"></i>
      <span>{{ i18n "readingTime" . }}</span>
  </div>
  
  {{ with .GetTerms "categories" }}
  <div class="mr-6 my-2">
      <i class="fas fa-folder mr-1"></i>
      {{ range $index, $value := . }}
      {{ if gt $index 0 }}
      <span>, </span>
      {{ end -}}
      <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a>
      {{ end }}
  </div>
  {{ end }}

  {{ with .GetTerms "series" }}
  <div class="mr-6 my-2">
      <i class="fas fa-th-list mr-1"></i>
      {{ range $index, $value := . }}
      {{ if gt $index 0 }}
      <span>, </span>
      {{ end -}}
      <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a>
      {{ end }}
  </div>
  {{ end }}
</div>

post_metadata.html 主要是增加了评论条数的显示,而 post_metadata_full.html 中还增加了 Markdown 原文链接的显示。关于如何生成 Markdown 原文链接,可以参考我之前的文章《Hugo 之旅》。

{{/* layouts/partials/post_metadata_full.html */}}
<div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
  <div class="mr-6 my-2">
      <i class="fas fa-calendar mr-1"></i>
      <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span>
  </div>

  {{$resp := getJSON "https://imerd.comment.lithub.cc/comment?type=count&url=/" .Slug ".html" }}
  <div class="mr-6 my-2">
    <a href="{{ .Permalink }}#waline-comments" title="{{ .Title }}">
      <i class="fas fa-comment mr-1"></i>
      <span>{{- if gt $resp 0}}{{$resp}} 条评论{{else}}暂无评论{{end -}}</span>
    </a>
  </div>

  {{ if eq .Type "posts" -}}
  {{ with .OutputFormats.Get "MarkDown" -}}
  <div class="mr-6 my-2">
    <a href="{{ .Permalink }}">
      <i class="fas fa-book mr-1"></i>
      <span>阅读Markdown格式</span>
    </a>
  </div>
  {{- end }}
  {{ end }}

  <div class="mr-6 my-2">
    <a href="{{ .Permalink }}">
      <i class="fas fa-pen mr-1"></i>
      <span>{{ .WordCount }} 字</span>
    </a>
  </div>

  <div class="mr-6 my-2">
      <i class="fas fa-clock mr-1"></i>
      <span>{{ i18n "readingTime" . }}</span>
  </div>
  
  {{ with .GetTerms "categories" }}
  <div class="mr-6 my-2">
      <i class="fas fa-folder mr-1"></i>
      {{ range $index, $value := . }}
      {{ if gt $index 0 }}
      <span>, </span>
      {{ end -}}
      <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a>
      {{ end }}
  </div>
  {{ end }}

  {{ with .GetTerms "series" }}
  <div class="mr-6 my-2">
      <i class="fas fa-th-list mr-1"></i>
      {{ range $index, $value := . }}
      {{ if gt $index 0 }}
      <span>, </span>
      {{ end -}}
      <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a>
      {{ end }}
  </div>
  {{ end }}
</div>

搜索框

搜索也是博客比较重要的功能,为了方便我在顶部增加了搜索框。创建 layouts/partials/header.html 文件用来覆盖默认的头部模板。

{{/* layouts/partials/header.html */}}
<script>
    let storageColorScheme = localStorage.getItem("lightDarkMode")
    {{- if eq .Site.Params.colorScheme "light" }}
    if ((storageColorScheme == 'Auto' && window.matchMedia("(prefers-color-scheme: dark)").matches) || storageColorScheme == "Dark") {
        document.getElementsByTagName('html')[0].classList.add('dark')
    }
    {{- else if eq .Site.Params.colorScheme "dark" }}
    if ((storageColorScheme == 'Auto' && window.matchMedia("(prefers-color-scheme: light)").matches) || storageColorScheme == "Light") {
        document.getElementsByTagName('html')[0].classList.remove('dark')
    }
    {{- else }}
    if (((storageColorScheme == 'Auto' || storageColorScheme == null) && window.matchMedia("(prefers-color-scheme: dark)").matches) || storageColorScheme == "Dark") {
        document.getElementsByTagName('html')[0].classList.add('dark')
    }
    {{- end }}
</script>
<nav class="flex items-center justify-between flex-wrap px-4 py-4 md:py-0">
    <a href="{{ "/" | relLangURL }}" class="mr-6 text-primary-text text-xl font-bold">{{ .Site.Title }}</a>
    <button id="navbar-btn" class="md:hidden flex items-center px-3 py-2" aria-label="Open Navbar">
        <i class="fas fa-bars"></i>
    </button>

    <div id="target"
        class="hidden block md:flex md:flex-grow md:justify-between md:items-center w-full md:w-auto text-primary-text z-20">
        <div class="md:flex md:h-16 text-sm md:flex-grow pb-4 md:pb-0 border-b md:border-b-0">
            {{- $relPermalink := .RelPermalink }}
            {{- range .Site.Menus.main }}
            {{- $url := .URL | relLangURL }}
            <a href="{{ $url }}" class="block mt-4 md:inline-block md:mt-0 md:h-(16-4px) md:leading-(16-4px) box-border md:border-t-2 md:border-b-2 {{ if hasPrefix $relPermalink $url }} selected-menu-item {{ else }} border-transparent {{ end }} mr-4">{{ .Name }}</a>
            {{- end }}
        </div>

        <div class="flex">
            <div class="search-container relative pt-4 md:pt-0">
                <div class="search">
                    <form role="search" class="search-form" action="/search.html" method="get">
                    <label>
                        <input name="q" type="text" placeholder="搜索 ..." class="search-field">
                    </label>
                    <button>
                        <i class="fas fa-search"></i>
                    </button>
                    </form>
                </div>
            </div>

            <div class="relative pl-4 pt-4 md:pt-0">
                <div class="cursor-pointer hover:text-eureka" id="lightDarkMode">
                    {{- if eq .Site.Params.colorScheme "dark" }}
                    <i class="fas fa-moon"></i>
                    {{- else if eq .Site.Params.colorScheme "light" }}
                    <i class="fas fa-sun"></i>
                    {{- else }}
                    <i class="fas fa-adjust"></i>
                    {{- end }}
                </div>
                <div class="fixed hidden inset-0 opacity-0 h-full w-full cursor-default z-30" id="is-open">
                </div>
                <div class="absolute flex flex-col left-0 md:left-auto right-auto md:right-0 hidden bg-secondary-bg w-48 rounded py-2 border border-tertiary-bg cursor-pointer z-40"
                    id='lightDarkOptions'>
                    <span class="px-4 py-1 hover:text-eureka" name="Light">{{i18n "light"}}</span>
                    <span class="px-4 py-1 hover:text-eureka" name="Dark">{{i18n "dark"}}</span>
                    <span class="px-4 py-1 hover:text-eureka" name="Auto">{{i18n "auto"}}</span>
                </div>
            </div>

            {{- if .IsTranslated }}
            <div class="relative pt-4 pl-4 md:pt-0">
                <div class="cursor-pointer hover:text-eureka" id="languageMode">
                    <i class="fas fa-globe"></i>
                    <span class="pl-1">{{ .Language.LanguageName }}</span>
                </div>
                <div class="fixed hidden inset-0 opacity-0 h-full w-full cursor-default z-30" id="is-open-lang">
                </div>
                <div class="absolute flex flex-col left-0 md:left-auto right-auto md:right-0 hidden bg-secondary-bg w-48 rounded py-2 border border-tertiary-bg cursor-pointer z-40"
                    id='languageOptions'>
                    <a class="px-4 py-1 hover:text-eureka" href="{{ .Permalink }}">{{ .Language.LanguageName }}</a>
                    {{- range .Translations }}
                    <a class="px-4 py-1 hover:text-eureka" href="{{ .Permalink }}">{{ .Language.LanguageName }}</a>
                    {{- end }}
                </div>
            </div>
            {{- end }}
        </div>
    </div>

    <div class="fixed hidden inset-0 opacity-0 h-full w-full cursor-default z-0" id="is-open-mobile">
    </div>

</nav>
<script>
    let element = document.getElementById('lightDarkMode')
    {{- if eq .Site.Params.colorScheme "light" }}
    if (storageColorScheme == 'Auto') {
        element.firstElementChild.classList.remove('fa-sun')
        element.firstElementChild.setAttribute("data-icon", 'adjust')
        element.firstElementChild.classList.add('fa-adjust')
        document.addEventListener('DOMContentLoaded', () => {
            switchMode('Auto')
        })
    } else if (storageColorScheme == "Dark") {
        element.firstElementChild.classList.remove('fa-sun')
        element.firstElementChild.setAttribute("data-icon", 'moon')
        element.firstElementChild.classList.add('fa-moon')
    }
    {{- else if eq .Site.Params.colorScheme "dark" }}
    if (storageColorScheme == 'Auto') {
        element.firstElementChild.classList.remove('fa-moon')
        element.firstElementChild.setAttribute("data-icon", 'adjust')
        element.firstElementChild.classList.add('fa-adjust')
        document.addEventListener('DOMContentLoaded', () => {
            switchMode('Auto')
        })
    } else if (storageColorScheme == "Light") {
        element.firstElementChild.classList.remove('fa-moon')
        element.firstElementChild.setAttribute("data-icon", 'sun')
        element.firstElementChild.classList.add('fa-sun')
    }
    {{- else }}
    if (storageColorScheme == null || storageColorScheme == 'Auto') {
        document.addEventListener('DOMContentLoaded', () => {
            switchMode('Auto')
        })
    } else if (storageColorScheme == "Light") {
        element.firstElementChild.classList.remove('fa-adjust')
        element.firstElementChild.setAttribute("data-icon", 'sun')
        element.firstElementChild.classList.add('fa-sun')
    } else if (storageColorScheme == "Dark") {
        element.firstElementChild.classList.remove('fa-adjust')
        element.firstElementChild.setAttribute("data-icon", 'moon')
        element.firstElementChild.classList.add('fa-moon')
    }
    {{- end }}

    document.addEventListener('DOMContentLoaded', () => {
        getcolorscheme();
        switchBurger();
        {{- if .IsTranslated }}
        switchLanguage()
        {{- end }}
    });
</script>

大部分的内容都是 Eureka 主题提供的,除了增加了 #search-container 搜索框部分。为了让搜索框更美观一点,我在 layouts/partials/custom-head.html 中自定义了一些样式。

.search-container {
  margin-top: -0.3rem;
}
.search-container .search {
  border: 1px solid #e2e8f0;
  border-radius: 4px;
}
.search-container input {
  padding-left: 1rem;
  line-height: 2rem;
  outline: none;
  background: transparent;
}
.search-container button {
  font-size: 0.8rem;
  margin-right: 0.5rem;
  color: #e2e8f0;
}

最终搜索框跳转至单独的搜索页面。关于如何给 Hugo 博客添加搜索功能,可查看我之前的文章 《Hugo 之旅》。我这边提供一下我的搜索结果页模板。

{{/* layouts/_default/search.html */}}
{{ define "main" }}
<div class="w-full max-w-screen-xl lg:px-4 xl:px-8 mx-auto">
  <article class="mx-6 my-8">
      <h1 id="search-count" class="font-bold text-3xl text-primary-text"></h1>
  </article>
  <div id="search-result" class="bg-secondary-bg rounded overflow-hidden px-4 divide-y"> 
    
  </div> 
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/fuse.js/3.2.0/fuse.min.js"></script>
<script>
  document.addEventListener('DOMContentLoaded', async () => {
    const qs = new URLSearchParams(location.search);
    const searchResult = document.querySelector('#search-result');
    const searchCount = document.querySelector('#search-count');

    const fuseOptions = {
      shouldSort: true,
      includeMatches: true,
      threshold: 0.0,
      tokenize: true,
      location: 0,
      distance: 100,
      maxPatternLength: 32,
      minMatchCharLength: 1,
      keys: [{
          name: "title",
          weight: 0.8
        },
        {
          name: "summary",
          weight: 0.5
        },
        {
          name: "tags",
          weight: 0.3
        },
        {
          name: "date",
          weight: 0.3
        },
      ]
    };

    let fuse = null

    async function getFuse() {
      if (fuse == null) {
        const resp = await fetch('/index.json', {
          method: 'get'
        })
        const indexData = await resp.json()
        fuse = new Fuse(indexData, fuseOptions);
      }
      return fuse
    }

    function render(items) {
      console.log(items);
      return items.map(item => {
        item = item.item
        return `
<div class="px-2 py-6">
  <div class="flex flex-col-reverse lg:flex-row justify-between">
    <div class="w-full lg:w-2/3">
      <div class="my-2">
        <div class="mb-4">
          <a href="${item.permalink}" class="font-bold text-xl hover:text-eureka">${item.title}</a>
        </div>
        <div class="content">
          ${item.summary}
          <p class="more">
            <a href="${item.permalink}" title="${item.title}">阅读全文<span class="meta-nav">→</span></a>
          </p>
        </div>
      </div>
      <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
        <div class="mr-6 my-2">
          <i class="fas fa-calendar mr-1"></i>
          <span>${item.date}</span>
        </div>
        <div class="mr-6 my-2">
          <a href="${item.permalink}#waline-comments" title="${item.title}">
            <i class="fas fa-comment mr-1"></i>
            <span>${item.comments > 0 ? item.comments + ' 条评论' : '暂无评论'}</span>
          </a>
        </div>
        <div class="mr-6 my-2">
          <i class="fas fa-clock mr-1"></i>
          <span>${item.time} 分钟阅读时长</span>
        </div>
      </div>  
    </div>
    <div class="w-full lg:w-1/3 mb-4 lg:mb-0 lg:ml-8">
      ${item.featuredImage ? `<img src="${item.featuredImage}" class="w-full" alt="Featured Image">` : ''}
    </div>
  </div>
</div>`;
      }).join('');
    }

    function updateDOM(html, keyword, number) {
      document.title = document.title.replace(/包含关键词.*?文章/, `包含关键词 ${keyword} 的文章`)
      searchResult.innerHTML = html
      searchCount.innerHTML = `共查询到 ${number} 篇文章`
    }

    async function search(searchString) {
      console.log(searchString);
      let result = [];
      if(searchString) {
        const fuse = await getFuse()
        result = fuse.search(searchString)
      }
      const html = render(result)
      updateDOM(html, searchString, result.length)
    }

    document.querySelectorAll('input[name="q"]').forEach(el => el.value = qs.get('q'));
    search(qs.get('q') || '')

    window.blogSearch = function(keyword) {
      if(!keyword) {
        return;
      }

      history.pushState('', '', location.pathname + '?q=' + encodeURIComponent(keyword));
      document.querySelectorAll('input[name="q"]').forEach(el => el.value = keyword);
      search(keyword);
    }
  })
</script>
{{ end }}

归档

之前使用 Typecho 的时候有一个归档插件会按照年月列表展示文章,所以我在 Hugo 中按照之前的格式实现了一下。按照如下内容新建 layouts/_default/archive.html 文件,并新建文章 content/日志.md,文章内容为空即可,在文章的 meta 数据中指定 layout: archive 来映射到该模板。

{{/* layouts/_default/archive.html */}}
{{ define "main" }}
{{ $hasToc := and (in .TableOfContents "<li>" ) (.Params.toc) }}
{{ $hasSidebar := or ($hasToc) (.Params.series) }}
<div class="grid grid-cols-2 lg:grid-cols-8 gap-4 lg:pt-12">
    <div
        class="col-span-2 {{ if not $hasSidebar }} {{- print "lg:col-start-2" -}} {{ end }} lg:col-span-6 bg-secondary-bg rounded px-6 py-8">
        <h1 class="font-bold text-3xl text-primary-text">{{ .Title }}</h1>

        <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
          <div class="mr-6 my-2">
              <i class="fas fa-calendar mr-1"></i>
              <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span>
          </div>
        </div>
        
        {{ $featured := partial "utils/get-featured" . }}
        {{ with $featured }}
        <div class="my-4">
            {{ . }}
        </div>
        {{ end }}

        <div class="content">
          <script type='text/javascript' src="https://lib.baomitu.com/jquery/1.11.1/jquery.min.js"></script>
          <style type="text/css">.car-collapse .car-yearmonth { cursor: s-resize; } </style>
          <script type="text/javascript">
            /* <![CDATA[ */
              jQuery(document).ready(function() {
                jQuery('.car-collapse').find('.car-monthlisting').hide();
                jQuery('.car-collapse').find('.car-monthlisting:first').show();
                jQuery('.car-collapse').find('.car-yearmonth').click(function() {
                  jQuery(this).next('ul').slideToggle('fast');
                });
                jQuery('.car-collapse').find('.car-toggler').click(function() {
                  if ( '展开全部' == jQuery(this).text() ) {
                    jQuery(this).parent('.car-container').find('.car-monthlisting').show();
                    jQuery(this).text('折叠全部');
                  }
                  else {
                    jQuery(this).parent('.car-container').find('.car-monthlisting').hide();
                    jQuery(this).text('展开全部');
                  }
                  return false;
                });
              });
            /* ]]> */
          </script>
          <div class="car-container car-collapse">
            <a href="#" class="car-toggler">展开全部</a>
            
            <ul class="car-list">
              {{ range (.Site.RegularPages.GroupByDate "01月 2006") }}
              <li>
                <span class="car-yearmonth">{{ .Key }} <span title="Post Count">({{ len .Pages }})</span></span>
                <ul class="car-monthlisting">
                  {{ range .Pages }}
                  <li>
                    {{ .Date.Format "02"}}: <a href="{{ .Permalink }}">{{ .Title }} </a> <!--<span title="Comment Count">(0)</span>-->
                  </li>
                  {{ end }}
                </ul>
              </li>
              {{ end }}
            </ul>
          </div>
        </div>
    </div>
</div>
{{ end }}

统计

屈屈的博客中还有一个统计页面,我觉得挺有意思的,于是也在我的博客中复刻了一下。按照如下内容新建 layouts/_default/stats.html 文件,并新建文章 content/统计.md,文章内容为空即可,在文章的 meta 数据中指定 layout: stats 来映射到该模板。

{{/* layouts/_default/stats.html */}}
{{ define "main" }}

{{- $.Scratch.Add "stats" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "stats" (dict "title" .Title "slug" .Slug "year" (.Date.Format "2006") "month" (.Date.Format "2006-01") "hour" (.Date.Format "15") "week" (.Date.Format "Monday") "count" .WordCount) -}}
{{- end -}}

{{ $hasToc := and (in .TableOfContents "<li>" ) (.Params.toc) }}
{{ $hasSidebar := or ($hasToc) (.Params.series) }}
<style>
.chart {
  margin-top: 15px;
  width: 100%;
  height: 350px;
}
</style>
<div class="grid grid-cols-2 lg:grid-cols-8 gap-4 lg:pt-12">
    <div
        class="col-span-2 {{ if not $hasSidebar }} {{- print "lg:col-start-2" -}} {{ end }} lg:col-span-6 bg-secondary-bg rounded px-6 py-8">
        <h1 class="font-bold text-3xl text-primary-text">{{ .Title }}</h1>

        <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
          <div class="mr-6 my-2">
              <i class="fas fa-calendar mr-1"></i>
              <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span>
          </div>
        </div>
        
        {{ $featured := partial "utils/get-featured" . }}
        {{ with $featured }}
        <div class="my-4">
            {{ . }}
        </div>
        {{ end }}

        <div class="content">
          {{ .Content }}
        </div>
    </div>

    {{ if $hasSidebar}}
    <div class="col-span-2">
        {{ if .GetTerms "series" }}
        {{ partial "components/post-series.html" . }}
        {{ end }}
        {{ if $hasToc }}
        {{ partial "components/post-toc.html" . }}
        {{ end }}
    </div>
    {{ end }}
</div>




<script src="https://lib.baomitu.com/echarts/5.0.0/echarts.min.js"></script>
<script>
const data = {{- $.Scratch.Get "stats" -}};
function showChart(id, title, type, d) {
  var chart = echarts.init(document.getElementById(id));
  var xData = [];
  var yData = [];
  d.forEach(function(item) {
      xData.push(item[0]);
      yData.push(item[1]);
  });
  var option = {
    title : { text : title },
    tooltip : { trigger : 'axis' },
    xAxis : [ { type : 'category', data : xData } ],
    yAxis : [ { type : 'value' } ],
    grid : { x : 35, y : 45, x2 : 35, y2 : 35 },
    series : [ { 
      type : 'bar',
      name : type,
      data : yData,
      markLine : {
        data : [ {
          type : 'average',
          name : '平均值'
        }],
        itemStyle : {
          normal : {
            color : '#4087bd'
          }
        }
      },
      itemStyle : {
        normal : {
          color : '#87cefa'
        }
      }
    }]
  };
  chart.setOption(option);
}

window.addEventListener('load', function() {
  basicInfo();
  yearStats();
  monthStats();
  hourStats();
  weekStats();
});
function basicInfo() {
  const articles = {{ len (where .Site.RegularPages "Section" "posts") }};
  const pages = data.length - articles;
  const comments = data.reduce((count, article) => count + article.comments, 0);
  const words = data.reduce((count, article) => count + article.count, 0);
  document.querySelector('#basic-info').innerHTML = `
    <span>文章:<strong><a href="/">${articles}</a></strong> 篇</span>;<span>页面:<strong><a href="/">${pages}</a></strong> 篇</span>;<span>总字数:<strong>${words}</strong></span>;
  `;
};

function yearStats() {
  const yearGroup = {};
  data.forEach(article => {
    const year = parseInt(article.year);
    if(!yearGroup.hasOwnProperty(year)) {
      yearGroup[year] = 0;
    }
    yearGroup[year] += 1;
  });

  const d = [];
  for(let i = 2009; i <= (new Date().getFullYear()); i++) {
    d.push([i, yearGroup[i] || 0]);
  }
  showChart('year-stat', '文章数 - 按年统计', '文章数', d);
}
function monthStats() {
  const monthGroup = {};
  data.forEach(article => {
    if(!monthGroup.hasOwnProperty(article.month)) {
      monthGroup[article.month] = 0;
    }
    monthGroup[article.month] += 1;
  });
  const d = [];
  for(let year = 2009; year <= (new Date().getFullYear()); year++) {
    for(let month = 1; month < 13; month++) {
      const text = `${year}-${month < 10 ? '0' + month : month}`;
      d.push([text, monthGroup[text] || 0]);
    }
  }
  showChart('month-stat', '文章数 - 按月统计', '文章数', d);
}
function hourStats() {
  const hourGroup = {};
  data.forEach(article => {
    const hour = parseInt(article.hour);
    if(!hourGroup.hasOwnProperty(hour)) {
      hourGroup[hour] = 0;
    }
    hourGroup[hour] += 1;
  });
  const d = [
    ['00:00-01:00'],
    ['01:00-02:00'],
    ['02:00-03:00'],
    ['03:00-04:00'],
    ['04:00-05:00'],
    ['05:00-06:00'],
    ['06:00-07:00'],
    ['07:00-08:00'],
    ['08:00-09:00'],
    ['09:00-10:00'],
    ['10:00-11:00'],
    ['11:00-12:00'],
    ['12:00-13:00'],
    ['13:00-14:00'],
    ['14:00-15:00'],
    ['15:00-16:00'],
    ['16:00-17:00'],
    ['17:00-18:00'],
    ['18:00-19:00'],
    ['19:00-20:00'],
    ['20:00-21:00'],
    ['21:00-22:00'],
    ['22:00-23:00'],
    ['23:00-24:00']
  ].map((item, key) => {
    item[1] = hourGroup[key] || 0;
    return item;
  });
  showChart('hour-stat', '文章数 - 按时段统计', '文章数', d);
}
function weekStats() {
  const weekGroup = {};
  data.forEach(article => {
    if(!weekGroup.hasOwnProperty(article.week)) {
      weekGroup[article.week] = 0;
    }
    weekGroup[article.week] += 1;
  });
  const d = [
    ['星期一', weekGroup.Monday],
    ['星期二', weekGroup.Tuesday],
    ['星期三', weekGroup.Wednesday],
    ['星期四', weekGroup.Thursday],
    ['星期五', weekGroup.Friday],
    ['星期六', weekGroup.Saturday],
    ['星期日', weekGroup.Sunday]
  ];
  showChart('weekday-stat', '文章数 - 按星期几统计', '文章数', d);
}
</script>

{{ end }}

其它

除了以上这些,我的博客中改动最大的当属评论这块,但这块定制型比较高,一般玩家就不推荐了,感兴趣的还是去看我之前的《静态博客如何高性能插入评论》一文。除此之外,我还修改了 footer.html 修改了底部显示文案,增加了网页统计脚本;基于自研的 wxhermit 增加了微信分享自定义相关功能;文章页目录底部增加了个人公众号的展示。由于这些内容都比较简单且定制化内容程度比较高,就不一一展示了,感兴趣的朋友可以自行查看源码查阅。

阅读全文 »

lizheming 发布于 02月03, 2021

不用备案也能支持微信自定义分享

我们知道,在微信中打开网页,使用右上角的 ... 分享给朋友/朋友圈,是可以使用 JS SDK 自定义分享卡片文案的。为了让分享内容能够更好的受到监管,从早期会自动读取网页内第一张大图到后期使用 JS SDK 自定义分享,再到后期需要做域名绑定关联,自定义分享卡片内容的流程变的越来越复杂。

目前如果你的网站想要增加微信自定义分享文案的支持,需要准备以下两件事情:

  1. 确保你的网站域名已备案,并被添加到了一个已认证的公众号的“JS安全域名”中。
  2. 提供服务端支持,用于与微信交互获取 access token 和 jsapi ticket 并计算获得最终的 signature,用于在前端调用微信 JS SDK 时进行校验。

具体的流程可以参见微信开放文档。可以看到要想实现自定义分享文案,除了开发流程之外,你还需要域名备案和公众号认证,这两个做过的人肯定知道会有多头疼了。而最蛋疼的是,我的博客域名后缀 .org 目前是不支持备案的,难道就没有办法了吗?

wxhermit

为了能让未备案网站也能自定义分享文案,我开发了 wxhermit 这个项目。它的原理非常简单,基本就是在已备案域名下 <iframe> 嵌套展示未备案域名,并通过 postMessage 通信,将自定义分享的文案传递到父页面。最终实现了任意网站分享自定义的需求。

当然它的本质还是使用基于最开始的备案域名网站进行分享。由于微信分享需要提供已认证公众号并绑定已备案域名,一个已认证公众号只能绑定至多 5 个安全域名,条件颇为苛刻。针对 5 个以上的域名,部分域名无法备案的情况,要自定义微信分享的文案就非常麻烦。本方案比较好的迂回解决该问题。

如何使用

在服务端使用 Docker 启动服务。其中 WECHAT_ID 和 WECHAT_SECRET 是在微信公众号后台开发-基本配置中获取的“开发者ID”和“开发者密码”。而 ALLOW_HOST_LIST 是为了避免服务被滥用,允许开发者配置允许使用内嵌服务的网站。可以使用逗号拼接多个域名,例如 imnerd.org,eming.li。不在该列表中的域名会直接跳转会源地址。

docker run \
  -e WECHAT_ID=<WECHAT_ID> \
  -e WECHAT_SECRET=<WECHAT_SECRET> \
  -e ALLOW_HOST_LIST=<ALLOW_HOST_LIST> \
  -p 8360:8360
  lizheming/wxhermit

而对于需要使用该服务的网站,需要在页面中增加以下代码用于自定义分享文案。其中 wxhermit 是固定值,其它的为自定义文案内容。

<script>
if (window.parent !== window) {
  window.parent.postMessage({ 
    type: 'wxhermit',
    title: '自定义分享的标题',
    desc: '自定义分享的描述',
    imgUrl: '自定义分享的封面图'
  }, '*');
}
</script>

配置好后就可以在微信使用 <domain>/?url=<url> 来访问了,其中 <domain> 是你的已绑定的安全域名,<url> 则是在 ALLOW_HOST_LIST 中配置的可使用域名下的网址。

后记

通过代理的形式很好的解决了我未备案域名需要自定义分享的问题。为了能让体验更自然,我在我的网站中增加了在微信中自动跳转至该嵌套页面的逻辑。

<script>
if(/micromessenger/i.test(navigator.userAgent) && window.parent === window) {
	location.href = 'https://wechat.75.team/?url=' + encodeURIComponent(location.href);
}
</script>

不过它的缺点也很明显,本质相当于将所有的域名都挂靠在某个安全域名之下。所以在微信下拉显示网站地址的时候都还是显示该安全域名。而且子域如果出现内容问题的话风险也全部在该安全域名上,所以建议是 ALLOW_HOST_LIST 配置个人可控域名。

除了我的这种方案之外,也有配置 <meta> 信息通过 Safari 调用系统的分享功能设置封面图和文案的方式,以及通过 QQ 浏览器分享自动获取页面第一张大图的形式自定义分享卡片。不过它们在可定制和确定性上都要稍微弱一些,可以根据实际情况选择使用。

阅读全文 »

lizheming 发布于 01月11, 2021

React Server Component 可能并没有那么香

前段时间 React 团队发布了一项用于解决 React 页面在多接口请求下的性能问题的解决方案 React Server Components。当然该方案目前还在草案阶段,官方也只是发了视频和一个示例 demo 来说明这个草案。

Server Components

官方在视频和 RFC 中说明了产生这个方案的主要原因是因为大量的 React 组件依赖数据请求才能做渲染。如果每个组件自己去请求数据的话会出现子组件要等父组件数据请求完成渲染子组件的时候才会开始去请求子组件的数据,也就是官方所谓的 WaterFall 数据请求队列的问题。而将数据请求放在一起请求又非常不便于维护。

既然组件需要数据才能渲染,那为什么接口不直接返回渲染后的组件呢?所以他们提出了 Server Components 的解决方案。我们暂且不管这其中的逻辑有没有道理,先来看看该方案的大体流程是怎样的。

方案的大概就是将 React 组件拆分成 Server 组件(.server.tsx)和 Client 组件(.client.tsx)两种类型。其中 Server 组件会在服务端直接渲染并返回。与 SSR 的区别是 Server Components 返回的是序列化的组件数据,而不是最终的 HTML。

可能带来的问题

通过接口将组件和组件的数据一并返回的方式带来了打包体积的优势,但是它真的能像 React Hooks 一样香吗?我觉得并不然。

接口返回

常规做法里前端 JS 中加载组件,接口返回组件需要的数据。而 React Server Components 中则是将二者合二为一,虽然在打包体积上有所优化,但是明显是把这体积转义到了接口返回中。特别是在类似列表这种有分页的请求中,这种劣势会更明显。明明组件只需要在初始的时候进行加载,但是因为被融合进接口里了,每次接口都会返回冗余的组件结构,这样也不知道是好还是不好。可能后续需要优化一下接口二次返回只返回数据会比较好。

服务器成本问题

这里所说的服务器成本有很多,首先是机器本身的成本。将客户端渲染行为迁移到服务端时候势必会增加服务端的压力,用户量上来之后这块的成本是成量级的在增加的。关于这个问题,官方提供的回复是随着服务器的成本降低势必 Server Components 带来的优势会抵消这块的劣势。

Question: This might become more expensive for applications. In the search demo, finding those search results plus rendering them on the server is a more expensive operation than just an API call sent from the client.

Reply: We are moving some of the rendering to the server–so it's true that your server will be doing more work than before. But server costs are constantly going down, and far more powerful than most consumer devices. I think React Server Components will give you the ability to make that tradeoff and choose where you best want the work to be done, on a per component basis. And that's not something that's easily possible today. via: 《RFC: React Server Components》

不过以目前我所在的业务情况来看,服务器的成本还是非常贵的,为了降低成本大家纷纷将逻辑下发到边缘计算甚至是客户端处理。一方面是为了节省成本,另一方面也是为了降低压力加快处理。

除了机器本身的成本之外,请求的成本也会增加。毕竟除了数据请求之外还要处理组件渲染,而且这块作为组件耦合不好进行拆分。相比较常规方案,使用 JS 文件加载组件到客户端,接口单纯返回数据,这块的时间成本增加了非常多。特别是常规方案中 JS 文件加载完之后是在浏览器中缓存的,后续的成本非常小。

体积问题可能还好,但是请求时间增加了这个可能就非常致命了。

心智负担

这点在 RFC 中也有说明。由于 Server Components 中无法使用 useState, useReduce, useEffect, DOM API 等方法,势必这会给使用者带来大量的心智负担。虽然官方说会使用工具让开发者做到无感,且会提供运行时报错,但是我相信光是想什么时候需要写 Server Componet 什么时候需要写 Client Component 就已经脑壳疼了吧,更别提还有个 Shared Component 了。

另外还有就是增加了跨端的流程之后,调试的成本也会变的非常高。别说很多人没有服务端的经验,就算是有相关经验的同学可能也没办法很好的在服务端进行快速定位。关于这个问题官方提供的说法是可以依赖内部的错误监控和日志服务。

回归问题的本质

让我们回归到问题的本质,React Server Component 的目的其实是为了解决接口请求分散在各组件中带来的子组件的数据请求需要等待父组件请求完成渲染子组件时才能开始请求的数据请求队列问题。那么除了 Server Component 之外没有其它的解决方案了吗?其实不然。

import React, {useState, useEffect} from 'react';
import ReactDOM from 'react-dom';

function App() {
  const [data, setData] = useState([]);
  useEffect(() => {
    fetchData.then(setData);
  }, []);
  
  return (
    <div>
      {!data.length ? 'loading' : null}
      <Child data={data} />
    </div>
  );
}

function Child({data}) {
  const [childData, setData] = useState([]);
  useEffect(() => {
    fetchChildData.then(setData);
  }, []);
  
  if(!data.length) {
	return null;
  }
  
  return (
    <div>{data.length + childData.length}</div>
  );
}

ReactDOM.render(<App />, document.querySelector('#root'));

如示例代码所示,只要加载组件,但是在无数据情况下不返回 DOM 也是可以做到子组件的数据先请求而无需等待的。当然这种需要认为的在写法上进行优化,但我也仍然认为比大费周章的去做 Server Component 要好很多。

至于 Server Component 带来的打包体积优化这个问题,我觉得 RFC 里面的评论说的非常的好。”比起 83KB(gzip 后大概是 20KB)打包体积,我觉得在项目中为了格式化日期使用一个 83KB 的库这才是更大的问题。“

Removing a 83KB (20KB gzip) library isn't a big deal, I would say the bigger problem here is that you're using a 83KB library to format dates. via: 《RFC: React Server Component》

实际上官方列举的两点关于日期处理以及 Markdown 格式处理的库,可以看到都是针对于数据进行处理的需求。针对这种情况如果觉得这块的体积非常”贵“的话完全是可以让服务端将格式化后的数据返回,这样岂不是更小成本的解决了这个问题?

后记

看完 《RFC: React Server Component》 中所有的讨论,大部分人对 Server Component 还是持不赞成的态度的,认为它可能并没有像 React Hooks 那样解决业务中的实际痛点。就目前暴露的提案,我个人也觉得 Server Component 是弊大于利的。目前就期望官方如果要实现的话能解耦实现,不要影响未使用 Server Component 的 React 用户打包体积。

当然该提案我觉得不是没有好处,它最大的好处我个人认为是带来了 React 组件序列化的官方标准。为多端、多机、多语言之间实现 React 组件交流提供了基础。基于这套序列化方案,我们可以实现组件缓存存储,多机器并发渲染组件等。至于多语言实现也是在 RFC 讨论中大家比较关心的问题,通过这套序列化标准让其它语言去实现 React 组件也不是没有可能。

阅读全文 »

lizheming 发布于 12月31, 2020

2020 岁末总结

不知不觉,2020年都要过去了。今年因为疫情的原因,感觉时间过得特别的快,一不留神,一年就这么过去了。而今年发生的很多事情也都围绕着疫情在改变着。

🚑 疫情

每当你想尝试放松的时候,你都会被工作扼住命运的喉咙。今年要说什么对我的影响最大,非疫情莫属了。当我还在家里做着疫情很快会过去的美梦的时候,不知不觉就已经被疫情专题页的工作搞的日夜颠倒了。可能是因为丁香园疫情专题页的高流量,不知道为何我们突然之间也投入了大量的人力去开发疫情专题页了。整整持续了两个多月的高强度工作让我身心俱疲。

看看我当时发的状态,真是太丧了。印象比较深的是有一天搞到凌晨3点才休息,结果8点的时候就被领导电话叫起来说是线上有 Bug 赶紧看看。我… 😓真是棒呢!

截两个图留存下被大 Boss 直接跟项目带来的恐惧,名字就不留了,看头像懂得人应该都懂。

好在三月之后慢慢开始复工,回到公司之后状态慢慢的就恢复过来了。甚至感觉还能再来个疫情呢(大雾! 现在回过头来不得不感慨,之前一天 50 多次上线,每次上线都没走 QA 就上了,我头是有多铁啊!全程面向微信开发,产品微信发需求,设计微信发设计图,开发微信发上线记录。我们都有美好的未来……个鬼啊!

🌎 Drone

看过我之前文章的同学就知道,Drone 是我非常喜欢的一款 CI/CD 的工具。它的可扩展性非常高,适合用来在企业内部进行 CI/CD 服务的接入和部署推广。奈何内部环境限制,Gitlab 版本过低,网段隔离导致推广起来还挺费劲的。

不过两年后环境发生了很大的变化,Gitlab 版本升级上来了,网段隔离的问题也发现了解决方案。顺顺利利的就部署了起来。而且老板也想统一推广 CI/CD 这块,正好顺着这个风推广给大家了。

为了帮助大家能快速的接入 Drone,也开发了很多内部服务相关的插件,包括内部项目、容器上线的,内部 IM 消息通知的插件。而且高兴的是,除了我之外,公司内还有其它团队的同学也有在使用 Drone,写的插件对他们也很有帮助。

自己一直想推的事情总算有一点小小的进展,而且发现还有同好,真是很开心呢。

👩‍🎨 设计云

设计云是今年团队因为蓝湖收费产出的一款类似于蓝湖的设计交付工具。后续转手到了我这里进行开发维护。它给我最大的收获是打开了 Sketch 插件开发的大门,原来开发一款 Sketch 插件其实没有想象中的那么困难。

当然开始的时候还是非常难的,经常碰到问题需要去请教之前做的小伙伴。不过后来基本就驾轻就熟了。然后反向回馈 Sketch 插件社区一些项目中反馈到的问题。成为了插件核心插件 skpm/skpm 的贡献者,想想都还挺激动的呢。

另外我负责了这个项目包括前端、服务端、客户端上的全部重构,所以这块能总结的东西其实也非常多。我是特别喜欢将自己的知识总结成文字分享给大家的,所以那段时间也一连产出了好些文章,都是从这个项目中反馈出来的经验。

🙏 司徒正美

4月1日,惊闻正美老师过逝的消息,一度还以为是假消息,后来经过正美老师的室友确认。正美老师是在前端圈非常有技术声望的人,他的离世震惊了圈内人士。

我和正美老师的交集在于我在业务上使用了正美老师之前开发的类 React 框架 anu.js。选择这个库的原因有以下几点:

  • 满足了我们的 React IE 兼容性要求
  • 它比较小巧,代码清晰易懂我们自己维护也不费劲
  • 正美老师个人在前端框架这块的技术声望

后续该框架也在我们的项目中扮演越来越重要的角色,我也不遗余力的在推广其他业务的小伙伴有类似兼容性需求的时候使用该框架。而正美老师的突然离世则让该框架成了没爹的孩子。

为了保证业务的可维护性,以及不让正美老师的遗作就这么销声匿迹,我慢慢开始了 anujs 项目的权限申请,主要是 npm 模块的权限申请以及 Github 仓库的权限申请。这些由于账号主人的离世,都只能去邮件和网站管理沟通了。

在经历了一段时间的等待之后,5月1日我成功获得了 anujs 的 npm 模块的发布权限。而 Github 的权限则非常可惜的没有申请下来,最后我们采用了 fork 的方式继续维护。

目前我们项目组有一位对 React 框架有经验的同学在负责维护该框架,主要是一些日常的 Bug 修复。感谢正美老师为我们带来这么好的作品,也愿他在天堂安息。

🌋 垃圾评论

2020年的年末,我不是很开心,因为……我被网暴了。起因是我发布了一篇 《基于 Serverless 的 Valine 可能并没有那么香》 的文章。文章里描述了一款第三方评论工具 Valine 存在的一些安全问题,然后在文末介绍了我为了解决该问题开发的高度兼容 Valine 的评论系统 Waline。

也不知是哪位无聊之人使用我的昵称和邮箱在全网使用 Valine 的博客中套用我的身份给它们发送了大量该文章和 Waline 系统的垃圾评论广告。导致大量的博主到我的博客上投诉甚至谩骂我。

这本是有人利用了 Valine 系统本身的漏洞问题制作的一场恶作剧,本来解释一下大家应该也都能理解就这么过去了。不过其中有一位用户说什么也不相信这不是我本人干的。在我的博客上疯狂的辱骂我。本来他发些垃圾评论我觉得也没什么,大不了定时清理下数据就好了。但是他开始回复我的博客的其它评论。而评论是有回复通知的,这无疑对其它博主造成了困扰。

为了阻止他,我紧急增加了发送频率限制、关键词过滤、IP黑名单等常见的反垃圾评论操规则。当他发现我有 IP 黑名单之后,还会尝试换一些 IP 来操作。好在经过几次的 IP 黑名单完善之后,慢慢的发的也就少很多了。不过后续更过分的事情又来了,他又顺着我的友链列表,去到我的友链博客下面使用我的信息伪造我的发言。真是可笑的事情啊,屠龙者终成恶龙!

💻 后记

其实今年发生的事情非常多,组织也发生了很大的变化,但有些事情真的无法用言语表达出来,就让它默默的存在我心里吧。新的一年 Flag 就不立了,希望在新的一年里能够在技术上有更好的突破,折腾一些更有意思的东西吧。

阅读全文 »