目录

  1. 实体卡对比
  2. giffgaff 使用姿势
    1. 购买渠道
    2. 激活流程
    3. 插入手机
    4. 保号
  3. 注意事项
    1. 如何关闭 iMessage
    2. 语音信箱关闭方式
    3. 无法拨打中国电话
    4. 招行 visa 无法充值
    5. 常用查询代码与工具
    6. 实体卡片激活期限是多久?
    7. 为什么手动挂断电话也收费?
    8. 可以注册 WeChat 吗?
    9. 可以注册 WhatsApp 吗?
    10. 可以注册 TikTok 吗?
  4. 资料

由于 ChatGPT 和 Claude 的风控越来越严格,手机号验证被触发的概率也更高;但因为风控策略不稳定,很难预测具体会在什么时候触发验证。

这个时候自己维护一个稳定的海外手机号就比较重要了,避免使用那种接码平台导致手机号变动并丢失后无法通过后续验证。

这两家还都不能更换手机号,任何方式都不行,很无语

实体卡对比

来源:https://fan-crossborder.com/global-sim-esim-inchina

这里列举了美国、香港、英国等地区 SIM 卡的申请方式。

简单说下:美国和香港的 SIM 卡月费比较贵,基本每个月要 3–5 美元;目前只有英国的 giffgaff 免年费,并且支持 eSIM。但是需要一比 10 英镑的激活费用,相比其他的来说已经很不错了。而且收短信是不收费的,非常适合用来注册各类海外账号。

保号流程也很简单,180 天内产生余额变动即可,给自己主号发个短信就好了,每次 0.3 英镑,一年成本 5 块钱。

缺点是号码只能是英国号;如果某些软件对手机号归属地和 IP 限制很严格,使用起来会比较困难。不过目前看起来影响不大:海外旅游和跨境流动很常见,很多服务并不会做特别严格的限制。

giffgaff 使用姿势

使用方式有很多种,也支持 eSIM;但 eSIM 对设备要求更高,所以这里主要介绍实体 SIM 卡的使用方式。

开始之前,最好准备一台可以常年开机的备用机,会方便很多。

购买渠道

通常来说可以直接在官网购买,前提是你在英国,但我们身在国内只能通过代购了。

代购会提供以下两种方式:

  • 直接购买未激活的白卡,自己手动激活,要求较高,需要你能有国际支付的手段,优点就是便宜,通常在 79-119 之间

  • 直接购买激活卡,买来联系客服进行激活和充值,无需有任何麻烦操作,但比较看代购的信誉,也不清楚后续充值需不需要继续联系代购。价格通常在 200-250 之间吧。

个人建议有能力的就自己搞,没能力可以买带服务的。下文以白卡的方式介绍

至于代购商家,有能力的自己去 X 上找吧,一搜一大把。

激活流程

SIM 卡可以先不用插入手机

  • 输入 SIM 卡正面的 6 位激活码,或者背面 0069 开头三行 13 位数字
  • 输入邮箱地址
  • 设置密码:需包含大写字母 + 小写字母 + 数字,且长度不少于 12 位
  • 选择 No,输入框可以不填
  • 选择套餐,拉到最下面
  • 选择充 10 镑,点击 “Pay now”
  • 姓名、地址随便填
  • 输入银行卡信息。如果购买失败,可以看后续的解决方案
  • 完成后会出现号码
  • 也可返回首页查看

插入手机

上面激活完成之后,就可以考虑插入到手机里了,不过需要注意以下内容:

  • 关闭数据漫游,避免产生漫游流量(非常贵):英国以外流量约 0.2 英镑/MB,折合将近 2000 元人民币/GB(部分地区可能更贵)。手机一般默认关闭该功能,非必要千万别开启。

  • 尽量保持连接 Wi‑Fi,避免不小心走漫游流量

  • 关闭 iMessage / FaceTime。iPhone 开启 iMessage/FaceTime,或部分安卓手机开启“查找手机 / 网络短信”等功能时,插入新卡可能会自动发送一条短信。为避免产生额外费用,建议提前关闭。

  • 优先选择中国移动

因为是国际电话卡,所以通讯过程比较久,等待几分钟后,信号就会出来。

首次激活并插入手机后,通常会收到一条短信,告知你的号码。到这里基本就完成了,可以用来接收短信并注册各类账号。

保号

giffgaff 的保号过程非常简单,充值当天(购买代充套餐的为发货当天)算起每 180 天内需要有余额变动。

最方便的是发一条短信,给自己这个号码本身发就行,0044+7开头10位号码(7前面的0去掉)。

建议在第 170 天左右发送,手机上多设置几个提醒;超过期限后号码和余额会被回收,且无法找回。发送后会重新开始计算 180 天。

注意事项

如何关闭 iMessage

  1. iPhone 关闭 iMessage:设置 ⇥ 信息 ⇥ iMessage 信息右侧开关

  2. 关闭 FaceTime:设置 ⇥ FaceTime 通话 ⇥ FaceTime 通话右侧开关

  3. 安卓手机请自行搜索对应品牌的查找手机与网络短信的关闭方式

语音信箱关闭方式

联系官方客服协助关闭语音信箱。

  1. 登录 giffgaff联系客服页面https://support2.giffgaff.com/app/ask/International-and-Roaming/Accessing-voicemail-while-abroad/form/

  2. 输入以下内容:

1
I am unable to disable my voicemail at the moment. I have tried multiple times. Please assist me in disabling it. Thank you!
  1. 点击 “Ask an agent”(黄色按钮)提交即可!

一般 24 小时内,客服会通过邮件和短信回复你,告知你操作结果。

无法拨打中国电话

拨打国内号码前面要加0086或者+86,如果加了还是打不通就是被国内运营商拦截了,目前大部分号码都默认开通了拦截国外号码,如有需要请联系运营商取消。

招行 visa 无法充值

招行的 visa 实测无法用于充值 giffgaff,推荐使用 o2 充值卡,直接淘宝搜索 o2 就行或者京东,价格通常 110-130 之间

常用查询代码与工具

除了在手机 App 上查看,也可以直接用实体手机在拨号盘输入以下指令:

查询本机号码: 发送短信 NUMBER 到 43430

查询话费余额: 拨打 *100# (屏幕会直接弹窗显示余额,非常方便)

实体卡片激活期限是多久?

官方的答复通常是「你好,激活 SIM 卡没有严格的截止日期,因此您可以随时激活。我们确实建议在一年内激活它,只是为了安全起见。」

所以建议还是买到白卡之后尽早激活。

为什么手动挂断电话也收费?

在漫游区域,手动挂断电话可能会产生费用,所以收到陌生电话静音即可,等待它自行挂断。

可以注册 WeChat 吗?

可以注册,对 IP 和环境要求较高,容易变成 Read-Only 账号

可以注册 WhatsApp 吗?

可以注册,IP 干净点就行

可以注册 TikTok 吗?

可以,同上。

资料

非常感谢以下大佬分享的资料,可以让我成功注册并使用。

注释和共享

目录

  1. 离开字节
    1. 预兆
    2. 得知结果
    3. 面试淘天
  2. 旅行,自驾游!
  3. 加入淘宝
    1. 入职
    2. 师兄?师姐?武侠来了
    3. 到处都是熟悉的人
    4. 不一样了,感觉来到了国企
    5. 来晚了,开源已成陌路
    6. 走出舒适区,主动社交
    7. 基建残缺,开发迭代困难
    8. 信息闭塞,异步沟通效率低
    9. 钉钉难用,办公体验差
  4. 对我的改变
  5. 后记

从 19 年 3 月加入字节飞书开始,到 24 年 5 月份拿到礼包离开,正好满 5 年。

而在 24 年 7 月,我正式加入了阿里,距今也刚好满一年。

每次换工作,我都喜欢写一篇总结文说说前公司,上次从网易离开便是如此。但这次,我却不想说前公司,而是想说说现公司是怎样的。

这中间发生了许多事情,但我一直没有去写,一方面是说我觉得对阿里的了解还是有些片面,另一方面是说一直也没有合适的机会。这不,正好趁着一周年之际,是时候讲讲这中间的事情了。

离开字节

24 年年初绩效期,飞书终于开启了大范围裁员,之前字节多少次大范围裁员一直没有轮到飞书。而我,也在这次裁员后彻底离开了呆了 5 年多的字节。

预兆

其实在开启裁员之前我就已经有预感了,一方面是说我负责的小程序引擎在飞书内部已经停止维护挺久了。另一方面就是这期间我其实不太愿意去做非常业务的工作。

所以早在 23 年底,我就尝试转岗,恰巧我两个舍友一个在 Weex 团队,一个在 WebInfra 团队,都是我感兴趣的部门,于是就托他们搭线聊了聊。

先聊了 Weex 的团队,整体聊的还是很投机的,毕竟我在飞书就是做小程序相关,而 Weex 整体也是基于小程序的技术方案。但最后卡在了绩效,因为那个时候要求至少要 M+ 才能转。但问题是,我都 M+ 了我还转个 p 啊,我在原先的部门呆着不好么。反正不懂公司啥想法,反正 Weex 那边最终作废。

后续又和 WebInfra 里面的 Rspack 的团队聊了聊,也就是张磊的团队,整体聊下来,我确实更喜欢他们团队,那个时候 rspack 刚开源没多久,外加我本身也比较喜欢做开源,对我的吸引力其实是最大的,但就是技术方向不太 match。而且这个时候他们更想要相关技术领域的人,我过去了大概率也是打杂,最终还是不了了之了。

说起来也是蛮可惜的,感觉那边就像是围城一样,外面的人想进去,而里面的人想出来。

得知结果

在正式通知之前,还是先聊了绩效,而这次绩效沟通的时候拿了一个 M-,这一下子我就确定了,大概率就是我了,不过好在年终奖没有缩水太多。

聊完之后我就开始准备简历和看工作了。没过多久,就正式通知我裁员拿 N+1 了,真的是又开心又难过的。

开心的是自然是,N+1 赔偿真的很多,相当于半年全额工资,不收税。而且我的电脑也刚好这个时候可以百元回购了,五周年的司龄礼物也在这个时候拿到了,送的股票基本上归属的七七八八了,可以说此刻的我离开这家公司几乎没有什么牵挂了。

难过的是,杭州实在是没有第二家我非常愿意去的公司了,而且我真的已经很适应字节的生活节奏和氛围了,越来越多的大佬也来到字节了,我觉得字节是在一个上升期的公司,离开多少还有点不舍得。

当然,字节也给了选项,你可以选择转岗而非离职,并且转岗只要对面要就不会有绩效阻拦。但之前转岗的经历让我已经放弃这条路了,最终我还是放弃了转岗,选择了离开。

于是在 2024 年 4 月 30 日,我选择彻底离开字节,从我 2019 年 3 月 12 日加入字节开始,历时 5 年。

面试淘天

从离职到最终找到工作,差不多经历了 3 个月。怎么说呢?就业市场确实是不景气,我从一开始各种挑剔,比如不做业务,不面试卷的公司。到最后毫无脾气,管你卷不卷,进了再说,最后连上海的公司都要考虑了,小红书,米哈游啥的。

这其中最离谱的是最开始的 Zoom,他在我离职之前就已经面好了,HR 也发了口头 offer,我想着基本手拿把掐了,就后续没再看了,结果 HR 一直不发 offer,一段时间后告诉我他们其实更想要专业对口的人,我不是太合适于是就 GG 了。真的是挺无语的,至少浪费了我半个月的时间。

这中间体验最好的其实是米哈游,他的 HR 和负责人都让人感觉非常靠谱的样子,而且面试完还主动加了我的微信,说其实很想要我来,只不过没想好我过来可以负责哪一部分的内容。虽然我觉得可能有一部分客套的因素,但至少这个态度让我感觉他们是愿意去培养或者发掘人才的。可惜了,他们只能在上海,要不然我感觉过去纯做业务也能接受了。

有的时候就是这么戏剧性,当我觉得找不到合适工作的时候,突然发现淘天放出来了一个小程序跨端岗位,这可太棒了,不仅仅和我经历匹配,还算是的大厂。于是我就赶紧投递,最终得偿所愿,面试进了淘宝。

旅行,自驾游!

之前一直有个想法就是,我找到工作,就去自驾游。但情况就是那么个情况,一直没有找到,自然我这个计划也就一直搁置了。面上阿里之后,首要做的便是这件事

距离我入职一直还有不到两周的时间,环疆环东海岸是不太现实了,正好之前有个朋友在西安,于是我们连夜决定,第一站,西安。

直接准备好行李,出发,1300 公里!

西安确实很不错,尤其是面食,我的最爱。本篇不是旅行游记,这里就不过多分享了。

由于时间不足,我就简单去了一下重庆、长沙,各呆了一两天,然后就回杭了,转了这一圈,总共 4000 公里,一下子把我刚买还不到一年的车给干到 2 万公里的保养里程去了。

自驾的快乐,可能只有喜欢的自驾的人懂了。

加入淘宝

18 年,我刚从杭电毕业,也可谓是阿里的黄金时期,也是我毕业梦寐以求的地方,可惜当初能力不足,没法进入这样的大厂。

现在 24 年,兜兜转转来到了曾经梦想的地方,而此时我的心态也变得不太一样了,成长了,也经历了,阿里的光环也褪色了,他不再像以前那样高不可攀。

于是我就带着一种平静的心态开启了在阿里的第一步,入职流程

入职

说实话,每次我的入职总会遇到一些小插曲。

入职字节的时候是因为突然想拉屎,结果发现厕所没纸只能让 hr 找男同事送进来,让其他一起入职的同时等我许久。

入职阿里则是自作聪明,觉得走高架会更快,结果被堵成狗了,然后就迟到了,别人都已经开始讲入职相关的内容了,结果我中途走了进去了。

至于入职内容,我觉得没啥好讲的了,基本上就是带着大家把该下载的软件下载了,该了解的内容了解了。

师兄?师姐?武侠来了

入职流程完成之后,并不是由 hr 带着你回到自己的工位,而是由师兄师姐带你回去,一股武侠味扑面而来。而在字节中,这个本质上就是 mentor,负责带你快速了解业务和工作的人。

除了这个,你会发现各个会议室的名字也都取的很武侠,比如灵鹫宫、桃花岛、燕子坞啥的。

但,大部分人依旧更喜欢用职场的习惯,例如用会议室编号描述,用几楼几楼描述。

到处都是熟悉的人

虽然我是第一次进入阿里,但我其实对里面的人并不完全陌生,毕竟阿里也是曾经的许多大佬的出生地。

只是我没想到,原来我这个部门能够如此近距离接触如此多的大佬们,比如:

  • 卓凌,在 Kraken 群里见过但是从来没聊过的人,现在是我的 +1

  • 染陌,曾经在大搜车实习的时候的同事,也是资深讲师,现在在我这个组里

  • 死月,这个大佬不用过多介绍了,虽然他已经离开了阿里,但他的王者事迹依旧流传在这里

  • 吞吞,在学校社团一起工作学习过,我以为他也在,进来之后发现已经走了,真的可惜了,但也替他高兴,去了英国依旧可以做自己喜欢的标准规范的制定

  • 苏千、扑灵、狼叔、元彦,这些熟悉又陌生的人

如今再回看 D2 的论坛,才发现身边竟然有这么多人我曾经听过或者看过他们的分享。

不一样了,感觉来到了国企

我感觉阿里几乎和字节完全不一样,这种不一样可以说是方方面面的,不论从生活习惯还是工作习惯,都有着翻天覆地的变化。

虽然阿里算是互联网,但随着时代的发展,阿里已经可以算是互联网里面的国企了。这种体现是方方面面的,比如工作习惯上

  • 没有线上、远程办公,开会就找个会议室直接线下聊

  • 文档建设残缺,几乎靠口口相传

  • 基建冗杂老旧混乱,自成一套,上手难度高

  • 中台工具及其难用,甚至连一个像样的内网版阿里云都没有

  • 钉钉难用到炸,内部工具错综复杂,后文再吐槽

  • OKR 没有长远规划,超过 1 年的已经堪称奇迹

  • 信息不流通,强依赖各个小群或者老板之间的沟通

  • 总是喜欢大叙事,而不愿意做好当前的小事情

再比如生活上:

  • 停车虽然免费,但是你根本抢不到车位,而且对新能源不友好

  • 餐食不免费,虽然有补助,但也就刚好让你吃饱,但完全做不到字节那种免费不限量自助餐的水平。我甚至已经开始中午自己带饭吃了,你在字节敢想象?

  • 没有免费纸巾、免费咖啡,甚至茶水间都不提供洗碗洗杯子的海绵都没有,但却提供了洗洁精,只能纯靠手擦

  • 没有下午茶,也没有免费零食,但 9 点钟有夜宵,不过夜宵都是些健康食品,比如面包、玉米、牛奶、茶叶蛋、包子啥的,连烧烤、可乐都没有,有地楼里会有写炸货。内部都戏称为低保

  • 没有带薪病假,只有陪伴假

你能很明显的感受到,阿里似乎有一种历史的沧桑感,他内部的很多东西在现今看来,其实有着更好的解决方案,而阿里很明显体量太大而难以掉头。

当然,也不是阿里一无是处,毕竟还是有着「国企」的习惯,鼓励你好好结婚生子去生活,比如

  • 提供 60 万无息购房首付贷款(说起这个就气,你必须入职阿里后买房才行,买了房之后就不能申请了

  • 周末可以带家人来园区吃饭,并且提供餐补

  • 年收入超过 50 万可申请杭州人才,有各种杭州市福利

  • 各种购车福利

但这些基本和我没关系了,所以接下来我接着吐槽看看

来晚了,开源已成陌路

字面意思,从去年还是前年的 1+6+N 拆分之后,开源的成本不再一起由公司承载而是各个集团自己去承载,也正是因为如此,很多项目在平衡利弊之后,就收敛了开源的投入,毕竟没法给自己集团带来很大的正向价值,却消耗着自己大量的人力和资金。

更关键的是,阿里开源的名声已经臭了,大家几乎认定阿里开源已经是一个 KPI 项目了,再追加更多的资金,可能根本无法对公司带来有价值的事情了。

就比如我这个部门之前搞的 ICE(打个广告,有兴趣的可以关注下),也逐渐将重心回归到内部,而基本不再处理社区里的反馈给的 issue。

但字节却正好相反,字节在不停地吸收着大量技术人才,在开源这件事情上做的也越来越好。尤其是 rspack,一举将字节开源的地位拔高了好几层。尤其是大家看到字节不是在搞 KPI 项目,他开源的东西依旧在努力的维护着,得到了社区里面的广泛支持和关注。

对我来说,其实有些伤心的,我还是更喜欢去做一些开源的事情。

不过好在的是,已经开源的软件依旧会在 github 迭代和维护,我也能在其中参与一部分,也算是变相做了一些开源的工作吧。

走出舒适区,主动社交

以前我不理解为什么总会有人说走出舒适区你才能成长,我觉得走出舒适区就像是从有地暖的房子走到室外那么简单。而真正经历之后,才发现并不是的。

当我来到阿里的时候,真的是各方面的不适应,就如上文所说,每天上班都是一种煎熬,真的想分分钟离职。

而生活福利上的差异,到时还好,更重要的还是工作方式上的变化。这主要体现在:

  • 虽然在字节和阿里都是做基建,但阿里很多工作需要依靠个人的沟通能力和社交能力去达成

    • 这里的沟通能力其实并不是说你多能聊,而是如何高效的沟通事情,合力解决问题,达成最终的目标。尤其是多个部门合作的时候,如何让他们愿意和你一起搞,这确实是很需要能力。

    • 这中间也包括需要你对做的事情有更强的 owner 心情,能够主动关注各项指标和数据。

    • 又因为阿里缺乏有效的扩散机制,如果没有足够多的小圈子和认识的人,很容易啥信息也获取不到。

    • 而字节,大部分情况下你只要关注自己这里或者兄弟部门即可,聊业务啥的基本都是 ld 去做的。

  • 在阿里,喜欢用人名来指代某条业务线

    • 例如在开会或者沟通的时候,都喜欢用谁谁谁来指代某条业务线,比如需要谁谁谁的配合,而不是说需要某个部门的配合。可能对于他们工作时间长的人来说,这两者是等价的,但对于刚入职的人来说,根本听不懂,完全不知道谁谁谁是负责啥的。

    • 在字节,大家习惯说需要哪个部门的配合,很少会直接说对方 ld 的名字,如果不知道哪个部门的负责人是谁,通常会再问一句。

  • 虽然改成了 OKR 但依旧是 KPI,与字节相比差距较大

    • 在字节,或者说至少在飞书,OKR 更多的是从上到下的,也就是说上面定制整体的方向,下面根据这些方向进行拆解并完成,总体的核心是服务于最上面的目标的。不关注你是否完整或者超额完成了你自己的目标,而是看最终的目标是否完成。

    • 这也就意味着,对于最底层的牛马来说,其实不需要写 OKR 的,因为你的目标就是完成老板的 OKR,而且就算没完成也关系不大,只要这中间有其他的事情能够证明你的结果绩效依旧不会差。

    • 但是在阿里,这个就不太一样了,OKR 是一条硬线,一切绩效以你定的目标和衡量标准,完成了就完成,没完成就是没完成,而且一定要有准确的可衡量目标。阿里的很大一部分是自下而上的,也就是说你需要想你能做什么,已经完成怎样的目标,然后将这些事情看看能不能靠到上面的一些目标上去。而不是从上面一点点拆解。

    • 我一开始就是继承了字节的开发惯性,定了一堆事情,但没有很看重结果,我更看重过程。结果最后绩效回顾的时候,发现自己看起来做了一堆事情,结果一个可以衡量的目标都没有。

  • 喜欢宏大叙事,喜欢搞新东西,但不考虑长尾成本

    • 在阿里很明显能感受到的一点就是大家在考虑做一些事情的时候,只考虑怎么做的更大、更漂亮、更好用、更快、更方便,如何把事情讲的更加高大,但几乎不考虑后期的维护成本。

    • 例如搞了一个中台产品,弄得很好看,吭哧吭哧搞完之后拿到结果之后就不管了,虽然对外依旧说还在维护,但维护这个工作已经无法写入到你的绩效中了。然后这个中台就废弃了,然后发现还是需要一个这个东西的,然后又开了一个新的项目,再搞一个新的平台,继续这样轮回。

    • 这也就为什么阿里很多开源项目拿到结果之后就不管了的原因。

这些工作方式上的变化,其实就是阿里味,阿里味在外面一直是一个贬义词,但在我这么长时间的接触下,发现其实也是有他可取之处的。所以对我来说,我是不愿意感染到阿里味,但是我能接受其中的优秀的地方。

基建残缺,开发迭代困难

或许这一点有点超出大家的认识,觉得阿里是一个老厂,又有阿里云的加持,为什么基建残缺呢?

这里或许是和字节不一样的地方了,字节由于是后发企业,基建上吸取了非常多社区上的经验,而社区的基建有一个特点就是灵活且通用。几乎所有的社区解决方案在字节内部都有对应的实现,几乎可这么说,你只要对社区熟悉,那么你在字节内几乎可以无缝迁移。

而在阿里,由于发展的早,那个时候很多基建不得不自创,虽然当时很符合阿里的情况,但长远来看会和社区渐行渐远。例如阿里内部前端编译依旧用的物理机,自己模拟了一个独立环境,而不是 docker 环境。服务发现用的也不是和 k8s 更适配的 consul。更不用说内部的部署构建流程,和社区的差异之大,几乎只能重新学。

这也就是我在阿里发现一个很神奇的现象,就是大部分底层的开发对社区的主流方案的了解程度很有限,几乎只直到阿里这套方案。这个其实在别的公司几乎很少见,在别的公司大家甚至宁愿选择社区方案而不会选择公司方案。

那我们来一点点吐槽吧:

  • 没有统一的云资源管理平台

    • 在字节,有一个「字节云」,几乎所有和云资源工作都在这个上面进行,包括域名申请、CDN、对象存储、容器服务、代码构建等等。如果老板说让我去申请个域名,我就在这个平台里面操作就好了。

    • 而阿里,这些能力几乎都是散落的,比如让我申请个域名,我根本找不到申请域名的入口。还是问了同事之后,才给我指了一条路,我才发现是在另一个平台下面。

  • 没有多个独立的测试环境

    • 简单来说就是内部的项目基本上只有一个测试环境,所有人想要测试都需要部署在这一个环境上,大家都需要将自己的需求合并到一个迭代的发布分支上。这就导致一旦有人发布,很容易影响到你的需求场景,或者要花费大量时间解决和别人的冲突。

    • 而字节一般都是一个需求一个环境,通过一个 env header 决定打到那个环境上,前端后端都在这一个独立环境上调试,几乎不被其他人的开发所影响。当需求开发测试完成之后,就合并到主干分支,然后定期发版,QA 质量回归。

    • 其实也有多套环境的能力,但这个能力使用起来非常麻烦,感觉仅仅是为了紧急情况下大需求重构所准备,而普通需求开发几乎没人用这个多套环境能力。

  • 构建流程和仓库强绑定,想要构建一个新的东西只能新建仓库

    • 也就是说一个仓库只能是一个固定的项目类型,例如前端、后端、客户端、全栈等等。每一个类型都绑定了一个固定的构建流程。这就导致如果想把仓库从单包改成多包,操作非常麻烦,几乎只能删除重建应用。

    • 而在字节内,一个仓库可以同时绑定多个固定流程,就和社区里面的 Action 一样。你想怎么组合就可以怎么组合。

    • 这也就导致阿里有个奇怪的事情,就是如果想给一个仓库加一个文档站点,大家的潜意识就是找一个静态文档库,加到依赖里面然后写文档就好了。或许前端是可以的,但对于客户端或者服务端来说,对不起,不行,你能做的的只有新建一个仓库,然后选择静态站点仓库类型,然后在这个新仓库里面写文档才行。

  • 各类中台喜欢互相包装而不是相互合作

    • 例如阿里内部有一个 O2 的构建平台,但是依旧会有很多类似的平台,底层封装了 O2 的能力,然后自己做了一套新的 UI。

    • 这个其实是有点扯淡的,每个平台都类似,但是又都不同,对于开发者来说其实有点灾难的。而字节一般是更喜欢如无必要,不增实体。就算增加了,也是参照 Linux 的哲学,利用流水线的方式将这些能力进行组合,而不是搞一个新的中台。

  • 网络条件极差,很多东西甚至要自备梯子(包括 github,dddd

信息闭塞,异步沟通效率低

这里详细展开说几点:

  • 内部文档系统混乱,且难以互通

    • 淘宝内部主要分为钉钉文档和语雀文档,之前大家也都是用语雀文档的,但后来切成钉钉文档了,但最无语的是切成钉钉文档之后,在钉钉内完全无法搜索语雀文档的内容,这两个就是完全割裂的系统。

    • 相比字节,所有信息都存储在飞书文档上。你不需要考虑这些信息存在那里,而是直接打开飞书文档,然后搜索即可。

  • 权限封闭,几乎无法进行有效的信息检索。

    • 钉钉文档默认权限不是全集团可见,这就导致即使是一个公司,你也无法看到隔壁部门做了那些事情。有的时候,我想看看有没有人曾经解决过某个问题,完全无从下手。

    • 入职当天想找一下环境配置文档,结果一搜啥都没有,只能靠师兄口口相传,npm 要配置那个,git 怎么配置,code 在哪里啥的。这对于一个新人来说是有多么困难么?

    • 而且飞书内部文档几乎是全公开的,例如我想看下 Lynx 针对某个渲染问题是怎么解决的,一搜就能搜到,而且还能看到不同部门对同一个问题的不同的解决方式。包括代码权限也是,默认也都是只读的,字节估计对外越保密,对内越透明。

  • 规则规范散落,没有统一在一起

    • 比如字节所有的规则内容都会以文档的形式组织在一起,你只需要在文档内搜索跳转就能找到你关心的内容。比如我想查一下百元回购的规则

    • 而钉钉这些信息几乎都散落在各地,有的在文档内,有的在小程序内,有的还在自己搭建的独立页面内。虽然公司内有工具将这些信息尽量聚合在了一起,但这依旧不改变这个本质。

  • 没有线上开会的习惯,大部分就线下面对面开完

    • 在字节,我们一般性习惯于有直接关系得人(比如 PM、Owner、Ld)去会议室当面聊,间接关系(干活的牛马)得就在工位远程参会就好,即使是这个会议室距离你只有几步路。

    • 而阿里不是这样的,他们默认你只要在同一栋楼,就会来参会,即使和你关系不大。你想在工位上等着他们开启视频会议,那对不起,如果你不说基本就不会。他们会主动开启会议只有一个情况,就是你很远,过不来。

    • 这就导致,在阿里,大部分会有都是没有会议纪要录屏的,如果有人请假了,根本没有能力去看下他们聊了啥。

  • 缺乏新人入职手册

    • 在字节,基本上有完备的新人入职手册,不仅仅告诉你部门的一些背景,环境如何搭建等等。也包括如何更好的适应园区,比如哪里有食堂,如何就餐,hr 服务台在哪里,it 工作台在哪里等等信息,都是在文档中一一写明。

    • 而阿里内部,这些信息都是散落的,要么找机器人问,要么就找师兄师姐问。

  • 很难找到相关交流群

    • 在字节,我可以很轻松的找到并加入例如 Rust、TypeScript、Deno、Svelte、Solid 这类交流群,也可以找到类似于垃圾佬、PC 装机、各类游戏群。之前张一鸣在原神群里的发言不都还上过热搜。

    • 但是在钉钉,这些几乎都是私有群,而且几乎没人宣传,我还是发了个推,才有同事看到拉我进去的。更别说钉钉这类群小的可怜,飞书这类兴趣群,可以很轻松的扩展到 5000 人或者 2w 人,而钉钉这种兴趣群只能 500。

钉钉难用,办公体验差

想当初大学的时候,也用过一阵子钉钉,但是用的不多,后来来了飞书之后就再也没用过了,虽然我经常嘲讽钉钉很难用,但当我真的使用了之后才发现,真 TMD 难用:

  • 没法在私聊 at 人或者群聊里 at 不存在的人

    • 你知道的,阿里都是用花名的,但是无法用 at 弹出选择人的面板的话,用输入法打那个人的名字真的非常费劲。我相信用过的人一定有同感,我现在已经放弃了,遇到熟悉的我就直接打拼音了,不熟悉的我就找到那个人去复制粘贴
  • 钉钉文档无法自动赋予权限

    • 字节的飞书文档当你通过私聊或者群聊发送的时候,会自动给这个人或者群赋予权限,这很方便,避免每个人找你要权限。

    • 而钉钉没有个能力,如果你发给群聊,如果你不主动给这个群赋予权限,你就等着一堆人给你发申请权限的请求了。

    • (不过好处是,你能直到谁看了,谁没看,没申请权限的肯定是没看的,哈哈哈

  • 视频会议只能投屏,无法投文档,别人也无法申请控制文档

    • 在字节,我们一般都习惯性直接投屏文档,而不是桌面。这个就是说在每个人的会议里面他会自己去打开这个文档,然后将文档的滚动,指针跟随投屏者。这个好处真的非常多,一方面是你可以不想跟随的时候就自己去打断可以浏览文档内的其他内容,另一方面投屏者的桌面不会被别人看到,这样投屏可以同时去处理别的内容而不会打扰其他会议参与者。

    • 更关键的是,对手机或者 pad 非常友好,如果你试着手机入会,你会发现别人投屏的东西你根本看不清,而直接投文档就不一样了。

    • 而在阿里,基本只能投屏桌面,就很难用,当然钉钉也知道这个问题,进入投屏之后钉钉会自动进入隐私模式,别人看到内容。

  • 多设备混合办公体验差

    • 在飞书,你用 ipad 的体验和 PC 几乎没有差别,这时你会发现很多大老板出差或者参加会议都不会带电脑,带个 pad 就来了。但是钉钉在 ipad 几乎不可用,你也不会看到有人拿着 pad 办公。甚至很多车机都能直接连接飞书视频会议而钉钉没有。

    • 大部分内部应用只有移动端版本,PC 要么打不开要么不可用。

还有一些很细节的东西,但是也懒得说了,不得不说,飞书代表先进生产力这句话真的是没错

对我的改变

这就是我这一年所经历的事情,洋洋洒洒吐槽了这么多,感觉阿里实在是不符合我对一个大厂的期望,但回到我自身来说,最大的改变,或许是让我学会如何从 0 认真的做好一件完整的事情吧。

在字节的时候,我基本就作为一个牛马,框框根据其他业务方的诉求或者产品的诉求干活就好了,你可以提出任何想法,但你并不需要对这些想法负责。甚至不需要你来推送一些事情的落地,基本老板会帮你解决,你只需要负责将你思考的那部分给实现了就好了。

但在阿里就不一样了,你不仅仅要提出你的想法,更要考虑如何让你的想法落地,这中间老板只是一个旁观者的身份,几乎不会太干预你的行为,老板只在乎你最终的结果是如何的。而落地,则是最困难的事情。毕竟口嗨谁不会啊。

所以现在对阿里的感情有点奇怪,你说过得舒服么?确实不舒服。那你说我现在还想走么,已经没有当初那么想了。

经过这一年多生活,我对阿里的感觉也逐渐变得平淡了起来,没有多余的兴奋,也没有多余的抱怨。阿里有好的地方,也不不一样的地方。他只是一个普通的公司,我来过,我做过,我认识就足够了。

真的越来越觉得时间过得好快,从毕业到现在,已经 7 年了,之前还觉得我可以先花好几年的事情去精进我的技术,然后再去学习如何做事情,但现在看来,当初的我只是不想逃离舒适区找的理由,做事情和学习技术并不冲突,更何况我也没有很认真的在精进技术,更多的时间拿来打游戏了,哈哈哈。

而我现在发现已经没有时间了,如果在阿里这里还是学不会如何做事情的话,我就真的打算躺平专做高级牛马了,管 TMD 的职级和薪资,开开心心过日子比什么都好,毕竟不同的人适合不同的环境,我更喜欢把代码作为爱好而不是工作。

后记

写完这篇文章的时候,正好看完了长安的荔枝剧版和电影版,说实话,剧版这是我近年来看到最好看的电视剧了,甚至超过了三体。

他几乎完美复刻了职场中遇到的种种问题和困难,更关键的是,这不电视剧没有刻意矫情,一切是那么的真实。每一个正派和反派,都有着自己的智慧和能力,也都有着他们自己的悲情。

而在李善德身上,看到了我自己的影子,平时不愿意去搞那些阿谀奉承的事情,只想专心做好自己喜欢的事情。可就是这样的一个人,却在生活中处处碰壁。

但我觉得我不如李善德的一点是,他遇到自己能力以外看似无解的问题的时候,真的不是破罐子破摔一死了之,而且在有一线生机的情况下,拼命去搏出一条生路。虽然我不会遇到这种生死性命攸关的问题,但我依旧会遇到一些生活中的难题,我每次遇到我不想解决的问题的时候总是习惯性逃避,去不做这些事情。可这些问题并不会因为我的逃避而消失,相反会逐渐变成我前进道路上的绊脚石,不解决他就一直在那里存在。

注释和共享

目录

  1. jsscript
  2. 与 QuickJS 的对照
    1. 启动流程
    2. 数据结构
    3. 错误处理
    4. 更多
  3. CLI
  4. API
    1. 基本使用
    2. Feature
  5. 心里话

不知道有多少前端,曾经像我一样,尝试去学习 V8 让自己可以从底层更好的了解 JS 的实现。但是由于太复杂而放弃,光下载、编译、调试足够让人望而却步了,更别说你需要有足够的 C++ 经验。

幸运的是,Bellard 大佬不久便开源用纯 C 实现的轻量且高性能的 QuickJS,这让我重新燃起了学习的热情,这个项目简单到仅仅有高中的 C 语言知识和数据结构就可以阅读。

无论如何,其还是用 C 实现的,内部会有很多 C 语言的 hack 写法(为了性能),阅读起来也不是那么的流畅。正巧我就是研究小程序的,小程序内部限制了动态脚本的执行,不如顺势把 QuickJS 迁移到 TypeScript 上,这样在小程序中也可以做到动态脚本执行了!

于是,这个项目便诞生了,我将其命名为 jsscripthttps://github.com/XGHeaven/jsscript

jsscript

目前这个项目还在开发中,部分能力缺失,只是我个人能力有限,JS 引擎又是一个很大的内容,很难短时间内完全支持,但最终的目标是不变的。

在立项之初,我了解到社区内有很多类似的项目都可以实现相同的功能,所以在一开始,我就觉得尽量不合他们的特性或者能力重叠,而是将更多的精力放在学习、易用性上:

  • 原生 ES6、ESNext 支持。截止写文时,test262 覆盖率为 25%。

  • 易维护。项目用纯 TypeScript 编写,所有的操作函数和参数都有完整的类型提示。

  • 易阅读。代码核心逻辑和 QuickJS 几乎无异,核心 API 命名大部分也保持一致,数据结构有所不同,使之更加适合于 TypeScript。

  • 易修改。项目内几乎所有能力都是可插拔的,你甚至可以把所有内置的对象(例如 Array、Function)删除,变身成普通的表达式计算器,按需定制。

  • Tree Shaking 友好。代码全部以无副作用和函数的方式提供(部分封装会用 class),只要你不用,就不会被打包进去,体积随你所控。

  • 架构简易。所有的事件循环依赖外部环境(例如 Node、Browser)、部分内置对象的原型方法直接代理到外部环境、只支持严格模式等。

有人好奇,为啥我这里没有列出来性能呢?一般这种项目不都是要做到第一的性能么?

其实,自从我决定将可读性、易用性、灵活性作为首要目标的时候,性能就不再是核心目标了。原因也很简单,这两者很难兼得。当然,性能也会是考虑的点,但不是现在的目标。

与 QuickJS 的对照

启动流程

QuickJS 的基本启动流程可以简单理解为:

  • 创建 Runtime

    • Runtime 提供了执行 JavaScript 代码所需的基础设施和功能,如内存管理、垃圾收集、事件循环等。

    • Runtime 是整个 JavaScript 执行过程的基础,简单理解就是初始化一个引擎。

  • 然后通过 Runtime 创建 Context

    • Context 指的是 JavaScript 代码执行时的执行环境或上下文。

    • 这两者之间的关系是一对多的,也就是说一个 Runtime 可以创建多个 Context,Context 之间可以相互访问。

    • 之所以这样设计,是因为浏览器中有 iframe,如果没有 Context,每次创建一个 iframe,就需要起一个引擎,这是很浪费的。而引入 Context 可以避免创建多个引擎的情况。

  • 初始化 Context 内的对象和属性。会初始化各类内建对象和其原型方法,例如 Array、Boolean、String 等。

  • 通过 Eval 方法执行代码

在 jsscript 中,大部分流程还是一致的,只不过我们将最后 Eval 的过程拆分成了先编译后运行的过程

数据结构

在 QuickJS 中,所有的 JS 的值都会用一个叫做 JSValue 的结构体表示,它不和任何 Context 或者 Value 绑定,是一个独立的存在,具体的结构体定义这里就不再详细解释。而在 jsscript 中,自然也是沿用了一样的设定,唯一的不同是采用了 TypeScript 的 Tagged Union 而非 C 中的 union,并且采用了更加适合前端的命名规则。

1
2
3
4
export type JSSaftHostValue = JSNumberValue | JSBoolValue | JSStringValue | JSUndefinedValue | JSNullValue
export type JSHostValue = JSSaftHostValue | JSSymbolValue
export type JSInstrinsicValue = JSHostValue | JSObjectValue | JSSymbolValue
export type JSValue = JSInstrinsicValue | JSExpectionValue | JSTryContextValue

这里的细节可能会有些多,比如:

  • null 和对象其实是用不同的 tag 表示,虽然 typeof 这两者都返回的是 "object",但在引擎层面是不一样的

  • 所有的 Object 都是使用 JSObjectValue 所表示,不论是普通对象还是函数。当然,在 JSObjectValue 内也会有具体对象类型的细分,但这不在 JSValue 这个层面所讨论的内容。

错误处理

在 QuickJS 中,错误本身和 JS 内的实现是不同的,引擎将错误状态和错误内容拆分成了两部分:

  • 错误状态会通过一个特殊的 JSValue 实现,在 jsscript 内叫做 JSExceptionValue。

  • 错误内容则会直接存储在 runtime 内,也就是说每次运行完想去拿错误信息的时候,需要去 runtime 上拿,而不是直接从返回的 value 中获取。

所以在引擎的实现过程中,如果你想表示某个过程发生了错误,只需要:

  • 创建错误对象,并存储到 runtime 中

  • 返回 JSExceptionValue,告知调用者,这里有错误

所以,当我们尝试调用一个函数的时候,都需要先检查返回值是否为错误,虽然这很麻烦,但这是必要的做法。(Golang 开发者是不是很眼熟,捂脸)

1
2
3
4
5
6
7
8
9
10
11
12
function JSDiv(ctx, leftVal, rightVal) {
const left = JSToNumber(ctx, leftVal)
if (isExceptionValue(left)) {
// 检查错误,如果出错了,就直接返回就好
return left
}
const right = JSToNumber(ctx, rightVal)
if (isExceptionValue(right)) {
return right
}
return createNumberValue(left.value - right.value)
}

更多

更多内容可以看源码,包括如何创建值、新增属性、函数调用等。

进入 https://github.com/XGHeaven/jsscript/tree/main/src 文件夹之后,可以按照入口(Runtime)的方式阅读,也可以按照文件命名的方式,找寻自己好奇的那部分点进去看即可。

CLI

最简单的体验的方式,就是安装 CLI

1
2
3
4
5
# 全局安装
npm install @xgheaven/jsscript -g

# 运行脚本
jsscript run file.js
1
2
3
4
5
// script.js
const fn = () => {
console.log(1 + 1)
}
fn()

不过目前还有些问题还没有处理:

  • 不支持 REPL

  • 不支持模块,只能运行单一脚本

  • 部分 API 缺失,不确保能够运行所有脚本

API

目前 API 的定义都处于 unstable 状态,预计后续会大改,目前仅限于了解和学习即可,请勿在生产环境使用。

基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { Runtime, Features, parseScript, parseBytecode, toHostValue } from '@xgheaven/jsscript'

const runtime = new Runtime({
features: [
// 注入所有在 ECMA262 中定义的方法
new Features.ECMA262Feature(),
// 注入和系统相关,例如 setTimeout 等
new Features.OsFeature(),
],
})

const context = runtime.newContext()

const script = `<your-script>`;

// 直接解析成函数
const fn = parseScript(context, script)
// 或者分成两步,先解析为字节码,然后再将字节码解析成函数。
// 字节码是可序列化的,你可以将其 stringify 之后存储下来,方便下次直接使用
const bc = compileToBytecode(script)
const fn = parseBytecode(context, bc)

// 运行刚才生成的函数
const ret = context.run(fn)

// 返回值是 vm 内的值,需要通过 toHostValue 转换成 JS 可识别的对象
console.log(toHostValue(ret))

Feature

Feature 是灵活性的根本,vm 内的各种行为和功能,都可以通过 Feature 的方式进行组合和定制。

目前提供以下几种:

  • ECMA262Feature 提供 ECMA262 规范中提供的对象,例如 Array/Boolean/String 等构造函数和其原型方法。如果不引用这个,VM 环境内将不会有这些构造函数和相关的原型方法,但是并不影响其字面量的使用。

  • JobSchedulerFeature 用于提供 Promise 的任务调度,如果代码不曾使用 Promise,则无需引用。当然,你也可以定义自己的任务调度方法

  • BrowserFeature 用于一定程度上模拟浏览器的环境,例如 window 对象

  • OsFeature 提供和系统交互的一些方法,例如 setTimeout 等

除了以上几种,也可以自定义 Feature:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Feature, createNumberValue } from '@xgheaven/jsscript'

class DemoFeature extends Feature {
initContext(ctx) {
// Context 初始化回调,你可以注入任何你想要的东西
// 在 VM 内的全局对象中注册一个 xxx 的全局属性
ctx.defineGlobalValue('xxx', createNumberValue(1))
}

initRuntime(rt) {
// Runtime 初始化回调
}
}

const runtime = new Runtime({ features: [new DemoFeature] })

心里话

其实吧,这个项目主要还是以个人学习目的为准,目前没有准确的实用目标。如果你看完之后觉得一般般,我完全表示理解,因为本身就没有特别出众的功能和能力。

但如果你也对 JSVM 感兴趣,又奈何很难、没时间直接学习 QuickJS/V8,可以尝试来一起来学习和实现。

注释和共享

前言

小程序,作为新一代的应用开发方式,虽然在业务上已经证明了巨大的价值,但是在开发者友好性上却非常的差,例如框架难用,调试困难,跨平台兼容性差。本文便是在尝试提高针对视图的开发者友好性和兼容性,同时进一步提升性能。

为了能够更好的理解本文,需要您最好了解以下内容:

  • 有小程序相关的经验,能够理解基本小程序的架构、基本的框架使用方式、主流的跨端三方框架等

  • 有前端相关的经验,能够理解什么是 DOM 接口,以及如何操作 DOM 接口

本文将不再赘述过多的基础概念内容。

背景

目前本人任职飞书开放平台小程序组,负责小程序相关的运行时的工作内容。由于飞书对小程序有着一些特殊的需求:

  1. 飞书大量官方应用都是通过小程序承载的,这些应用对运行体验和开发效率有着远比外界更高的要求。而且由于是官方应用,且只服务于飞书,所以他们对于小程序的跨端兼容性要求并不高。这也给了我们更大的灵活性去设计更好的方案。

  2. 飞书是一个面向全球 B 端的市场,先不论国内是否每个 B 端企业都能有精力去开发小程序,单论海外市场对小程序的认知就非常浅薄。所以如何尽可能的降低小程序的认知学习门槛,降低接入成本,就显得尤为重要。在国内可以通过类似于 Taro 的框架,但是海外这部分的学习成本依旧很高,所以需要有新的方式去降低这部分的成本。

正是因为如此,我们这边决定不再以兼容社区小程序的方案为首要目标,而是转为提高开发者的开发体验和运行性能为主目标去进行设计和优化。

现有架构的「病区」

小程序在诞生之后,其实做了很多的设定,有的设定是合适的,但更多的设定在逐渐的发展过程中变得不再合适,而后人却依旧沿着这个思路和方向去做。接下来,让我们重新思考一下这些设定:

为什么会诞生 XXML 的视图开发方案?

XXML 在这里是对通过模板开发小程序的一种开发范式(也可以叫开发框架或者 DSL)的统称。因为不同宿主内对应的模板文件的后缀不同,但都有 ML 而因此得名。例如微信是 .wxml ,支付宝是 .axml,而飞书和抖音则为 .ttml

在我的认知中,小程序最开始并不是为资深的前端开发而设计的,相反,他是为了客户端乃至一些没有开发经验的人所设计。

所以借鉴了 Vue 这类非常容易入门的社区框架,再结合上小程序自身的需求所定制了一套 DSL。这套 DSL 针对从来没有写过前端的人来说,确实算是非常友好的,几乎不需要学习就可以上手。

其实让开发者更容易的入门上手算是小程序最开始的目标。例如,一开始是不提供 CLI 工具只提供了可视化的 IDE。如果小程序的受众是不懂开发的人,那其实没问题。

但最终事与愿违,开发小程序最多的人其实就是资深的前端开发,在前端开发的视角来看,这套 DSL 可以用一坨屎来形容。而小程序又必须要要用这套 DSL 去开发,就导致每一个写过小程序的人必须吃一次这个屎。

XXML 会带来什么问题?

虽然以微信为首的厂商做了很多能力去补救,尽可能在保证较低入手门槛的情况下,不断丰富能力,提高对前端开发人员的友好程度。但最开始的设计就不是面向这部分人群,即使后面修补的再多也无济于事了。这其中所带来的的问题随着小程序的发展而愈发严重:

  • 由于这套框架的实现是内置在基础库内,其能力和功能的实现必然有滞后性,同时灵活度差。一旦框架概念落后,开发者将不得不继续咬牙学习落后内容。

  • 虽然最开始借鉴自 JS 和 Web,但最终却无法的很好的利用开源社区的能力,几乎所有都要重造轮子。国内已经发展了这么多年,社区才将将都造完稳定的轮子,如果将这一套推向海外,那成本根本无法估量。

  • 规范缺乏一致性。由于 XXML 本身其实是一个业务开发框架,但又作为唯一开发方式,非常容易变得越来越臃肿和随意,可能一个实习生随便定出的接口都将会影响整个社区,能力的实现也缺乏一致性。例如 PageComponent 的生命周期就非常不一样。

  • 为了解决 XXML 这些问题,降低开发者的开发成本,在社区中诞生了许许多多的三方跨端框架。导致开发小程序的人不仅仅要懂得如何写原生的 XXML,更要懂得如何使用这些跨端框架,学习成本陡增。

  • Taro 这类模拟一套 DOM 接口来嫁接社区框架的做法,最终会让运行架构变成 React => Taro DOM => TTML => DOM 这种有点脱裤子放屁的方式。

小程序的视图开发一定要使用 XXML 么?

一个支持通用应用的宿主,可以提供一套它认为合适的模板 DSL 去生成渲染树,但一定也需要提供一套更加灵活的方式去生成渲染树。例如:

  • 安卓默认使用 XML 作为模板,去渲染内容。但如果不满足使用,同时可以使用命令的操作方式去构建一个渲染树。iOS 也是用类似的逻辑

  • Flutter 定义了一套类似于 React 的 Weight 渲染框架。但依旧暴露了底层的 RenderObject 方便开发者绕过 Weight 直接去构建渲染树。

而小程序,却选择了只提供 XXML,并没有提供其他的方式。如果小程序面向的特定需求场景,这倒也无可厚非,可实际上小程序面向的却是通用需求场景。

于是飞书内部在思考,如果我们一定要再提供一套通用的 DSL,会是什么?React、Vue 这类前端框架么?如果提供了,又会面临和 XXML 类似的局面。

其实在 Web 领域中,早就给出了答案,那么就是 DOM。不管是 React 还是 Vue 还是其他的框架,底层都是基于 DOM 接口所存在的。如果我们能够在小程序提供一套底层的 DOM 接口,那么 XXML 所带来的问题是不是就全都迎刃而解了呢?

为什么之前不提供 DOM 接口?

当我们想明白这个点之后,其实就会更加好奇,为什么这么简单的想法为啥一开始乃至后续的人都没做过呢?

我猜测最开始不这么做的原因有这么几个,但我觉得都不是很成立,或者说在飞书的场景中很难成立(如果大家有不同的想法和意见,随时可以和我沟通交流)。

Q: 无法完整实现,成本高。
双线程无法实现完整的 DOM 接口,且在逻辑层实现一套 DOM 接口的成本过高。

确实,如果想要一比一的复刻 DOM 接口,有些依赖渲染能力的接口确实无法实现。但如果只是实现一套子集,能够满足主流框架的使用,那么成本和难度并不高。比如 Taro 就是这么做的,社区也证明了其可行性。

Q: 首屏性能差。
如果实现 DOM 接口,就必须要等待逻辑层完整渲染并操作完 DOM 接口之后,渲染层才能渲染,那么将失去双线程逻辑层和渲染层并行运行的优势。

我觉得这个是一个屁股决定脑袋的事情。在 90% 的场景中,渲染层就是需要等待逻辑层计算完毕之后才能渲染的。就像是你不能说为了前端页面首屏速度快,要求所有网站都用 SSR 吧。

另外,首屏其实分为 FP 和 TTI 的,和 SSR 场景类似,XXML 可以做到 FP 快,但是 TTI 并不如 DOM 快的。飞书场景更在乎的是 TTI 而非 FP,所以影响不大。

再说,用了 DOM 接口之后自然是有对应的优化策略和方式的。

Q: DOM 指令的通讯压力大。
如果采用 DOM 接口,那么逻辑层往渲染层发送的将是 DOM 指令,如果我要渲染 100 个普通的 view,这部分体积明显比直接发送 data 要大。

没错,某些场景下,DOM 指令的数量确实是会比 data 体积大。

但是在飞书的业务场景中,通常都会有着重度的逻辑以及大量国际化文案,这部分会导致 data 的体积飞涨。而且并不是所有的开发都有合理使用 data 的能力的,有些人就喜欢啥都往 data 里面塞,这更加会加剧其体积的膨胀。

所以对于飞书来说,这个的影响其实并不大。

Q: 觉得 TTML 就够用了

懂的都懂,不多说了

Q: 不希望对外认知上小程序就是前端,所以在一定程度上屏蔽了前端所用的东西。

我觉得这其实是微信小程序的烟雾弹,让大家觉得小程序技术很厉害,它底层是 Native 实现,只是借鉴了前端的一些概念而已。

但实际上缺恰恰相反,从设计开始,就是在 Web 上面打补丁实现的,只是借鉴了一点点 Native 中的概念。

所以如果相信了这个烟雾弹,那么才是将自己埋入深坑。

Q: 不希望利用 DOM 接口实现太多灵活的动态更新能力。
例如审核之后通过 API 更改 UI 界面。

这个也是我见过最多的说法了,也是最站不住脚的说法。
XXML 本身就无法避免动态更新,更别说 DOM 了。

Q: DOM 接口就不会有实现上的兼容问题了么?

这是个好问题,虽然 DOM 接口本身是有标准的,但实现上其实各家都会遇见的不一样。

这个自然没有完美的解决方案,但是我觉得以兼容主流框架而不是提供完整兼容的 DOM 接口为目标是更加合理的。不管各家接口实现成什么样子,只要能兼容前端框架就是对的。

至此,其实我们可以看到,DOM 接口本身的引入并不存在什么实质性的阻碍。

而且最重要的事,DOM 本身是作为 XXML 的一种补充开发能力所引入的,每个开发者可以根据自己的架构特点选择 XXML 甚至的 DOM 来作为开发方式。

提供 DOM 接口能带来哪些好处?

最后的最后,我们其实要明白,如果要提供 DOM 接口,将会带来那些好处呢?

  • 能够几乎无缝的兼容市面上主流的前端框架,目前测试下来支持的有 Vue Svelte React18。极大的降低开发者的学习和开发成本。

  • 可以提供极致的性能优化,做到非常细粒度的组件更新。

  • 和前端主流开发方式对齐,不再分割与割裂,提高开发效率和降低接入成本。

  • 针对三方的跨端框架,可以降低其兼容难度和运行时的体积,提高执行效率。

最关键最关键的是,终于可以抛弃掉 XXML 这套恶心的东西了。没有难用的数据绑定,支持传递函数,不需要考虑 data 内数据是否是视图需要的,自定义组件之间的时序能够得到天然的保障,能够支持 CSS-In-JS 等等。

社区中很多优秀的方案都可以拿来使用,总之,就是优雅,优雅,还是优雅。

全新架构

在理解了上文所讲述的内容之后,就该讲解下我们为了实现上面的功能所做的全新架构了:

最简单的理解,就是在新架构中,渲染层和逻辑层之间的通讯不再发送 data 了,而是通过逻辑层实现的一套 DOM 接口,将其转换成 DOM 的操作指令。

如果说渲染的操作是 fn(data) 的过程,那么以前这个过程是在渲染层完成,而新架构下,这个操作将在逻辑层完成。

在新架构中,依旧保留了对原先 XXML 的兼容性,开发者可以通过 renderingModel 控制。控制以 Page 为粒度,保证在开发中可以做到渐进式迁移。另外,DOM 接口与 XXML 只能二选一,两者无法在一个 Page 上同时开启。

我并不认为提供 Page 级别的控制在技术设计上是一个好方案,但这个方案确实是可以最大程度上提供更好的兼容性,方便开发者渐进式的替换。

Document 结构

当开启 DOM 的渲染模型之后,整个小程序的文档模型都将与 Web 有着极其类似的结构:

1
2
3
4
5
6
7
8
9
10
11
<html> <!-- document.documentElement 一般不会用到这个 -->
<head> <!-- document.head -->
<!-- 可以在这里利用 meta 标签配置一些全局内容 -->
</head>
<body> <!-- document.body -->
<page route="xxx"> <!-- Page 实例所对应的渲染节点 -->
<view>Hello World</view>
</page>
<page route="xxx"></page>
</body>
</html>

例如每一个 Page 都对应着 document.body 内的一个 <page/> 元素。页面栈也与 document.body.childNodes 保持一致,页面栈的进出相当于 page 元素的插入和剥离。

当然,这里只是一个类比,实际上会有些许的不同,本文不再展开(其实就是我还没设计好,这是个预期模型)。

小程序内置组件

小程序内置组件在 DOM 接口下名字、属性、使用方式依旧保持不变,并且遵守 Web 的通用规范。提供了对应的 DOM Interface 方便开发者直接操作和使用。例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// tagName 都是和 XXML 内保持一致
const view = document.createElement('view')
const button = document.createElement('button')
const picker = document.createElement('picker')
const pickerView = document.createElement('picker-view')

// 属性名字也与原先的保持一致,只不过当用作属性的时候,需要将其转换成驼峰的格式。
button.openType = 'share'
// or
button.setAttribute('open-type', 'share')

// 原先组件的使用方式依旧不变,例如 picker-view 下面需要插入 picker-view-column
for (let i = 0; i < 3; i++) {
pickerView.appendChild(document.createElement('picker-view-column'))
}

// 当需要赋值非字符串类型的值的时候,尽量使用 property 的方式
picker.range = [['a', 'b'], ['0', '1', '2']]

// 使用 attribute 可能会得到预期以外的行为,不推荐使用
picker.setAttribute('range', [...])

或许有人会问,为什么使用了 DOM 接口缺不能使用前端的组件呢?

这是因为小程序的组件体系确实是比较特殊的,目前暂时没有很好的办法去兼容。但是在未来,可能会选择开放部分 Web 的组件,例如 div span svg 等。

如何使用

目前该方案还在内部开发阶段,外部没有全量发布。下文中提到的所有接口都属于不稳定接口,在没有正式对外前,只能作为参考。

可能看完上面之后大家依旧会是一头雾水,我这里通过一个简单的 DEMO 来让大家有更加清晰的认识。

  1. 打开飞书开发者工具中的新框架开关
  1. 在要开启 DOM 接口的 Page 所对应的 JSON 中添加 renderingModeldom
1
2
3
{
"renderingModel": "dom"
}
  1. 此时可以在 Page 的 onLoad 生命周期内通过 this.pageElement 获取到 Page 的元素实例了,可以将其理解为前端开发中的 <div id="app"></div> 元素。后续就可以按照前端开发流程去操作这个元素了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Page({
onLoad() {
const root = this.pageElement

// 可以直接控制页面渲染
const view = document.createElement('view') // 这里不能使用 Web 组件,只能使用小程序组件
view.style = 'color: blue;' // style 只能设置不能读取,读取到的值是不准确的
view.className = 'root'
view.appendChild(document.createTextNode('Hello world'))
root.appendChild(view)

// 或者使用主流前端框架进行渲染
React.createRoot(root).render(<App/>) // JSX 小程序不支持,需要进行转换,这里只是举例
// 或
Vue.createApp(App).mount(root)
}
})
  1. 其他的例如样式、API 等与普通小程序无异。样式依旧通过 XXSS 使用,API 则继续可以使用 tt 如果在飞书内的话。

性能对比

性能自然是为什么要引入 DOM 最重要的一个因素了,让我们来看一下性能对比测试

测试方式

仓库:https://github.com/XGHeaven/perf-gadget-dom-vs-framework

使用 Vue3 的框架,Demo 使用 mdn/todo-vue,在此基础上使用了 Taro 将代码分别编译到飞书、微信、支付宝。

  • taro-wx Taro 编译到微信,微信版本为 8.0.35

  • taro-alipay Taro 编译到支付宝,开启 2.0 lib,支付宝版本为 10.3.80

  • taro-tt Taro 编译到抖音(Tiktok),抖音版本为 25.5.0

  • taro-lark Taro 编译到飞书,飞书版本为 6.5.3

  • dom-lark Vue 源码直接编译到飞书新架构 DOM 接口上,飞书版本为 6.5.3

每一个测试项目,有 5 次的预热运行,之后会再运行 10 次,取其平均值。同时每次运行之间都会相隔一段时间,保证系统有时机去做别的事情,从而尽可能避免影响到测试结果。

之所以选择 Taro 而非 XXML 原生写法或者其他跨端框架,其一是因为更加接近于开发者的使用体验,因为不可能有人会选择直接去操作 DOM。其二是因为 Taro 已经是最快的跨端框架了,再比较其他的没有什么太多的意义。

除此以外,其他的内容基本一致:

  • 测试设备为华为 P40 Pro,麒麟 990 处理器,鸿蒙 OS 3.0 系统。

  • Taro 框架统一经过 build:xx 命令后运行,Vue 直接通过 Vite 编译后运行

  • 运行方式都是打开对应平台的开发者工具并上传之后,在后台生成对应的二维码扫码预览,避免被认为是开发状态从而添加对结果有影响的能力。

由于 Taro 框架以及飞书新架构的情况下,组件的 mounted 生命周期只能保证在逻辑层侧完成了渲染,不能保证此时组件更新指令已经发送到渲染层,我们需要寻找一个全新的方式去衡量什么时候组件真正渲染上屏。

幸运的是,image 组件有一个特性,当图片被渲染出来并成功加载之后,会触发 load 事件,当此事件触发的时候,可以认为组件已经正确被渲染到屏幕上了。此时,我们只需要在某次更新组件的最后一个组件内,插入一个高宽为 0 的 image 组件作为渲染标记,当事件被触发时,就可以测量出整体的渲染时间。

虽然图片加载会占用一定的时间,但资源本身就是离线保存在代码包内,加载速度很快,且这并不影响最终结果的相对大小。

Taro 默认关闭资源内联功能,避免 Taro 将小资源文件内联成 base64 字符串从而增大通讯成本。

测试内容

这里还需要再补充几点:

  • 不测试应用首屏的性能,由于首屏开始的时间点不同宿主下测量会是不一样的,所以我只在私下里在飞书上通过录屏测试过 Uniapp 对比 DOM,差不多 DOM 可以快 300ms。如果采用 Taro 理论上差距会缩小,但是依旧是快的。

  • 而且首屏性能主要由 JS Parse 和 Execution 的时间组成,这两部分分别由包体积和渲染性能决定,可以分别对比这两项测试从而大体可以得出首屏的性能。

  • 不包含纯静态内容,因为测试使用的是 Taro 框架,其会在逻辑层搞一套虚拟 DOM,会导致最终发送给渲染层的数据量其实基本一致,测试的意义不大。如果有需要,后续可以对比 DOM 和 XXML 的在纯静态内容上的速度差异。

列表组件渲染

渲染 100 个 Todo 项,记录对应的耗时

  • mounted 耗时,指从组件 created 到 mounted 所花费的时间

  • 完整渲染耗时,指从组件 created 到渲染层上屏渲染完毕(图片 onload 触发)所花费的时间

目标 mounted 耗时 完整渲染耗时
taro-wx 141.3 (156%) 1457.4 (443%)
taro-alipay 141.1 (156%) 387.2 (177%)
taro-tt 148.5 (165%) 608.7 (185%)
taro-lark 140.8 (156%) 620.6 (188%)
dom-lark 89.9 (100%) 328.9 (100%)

列表组件内部更新

将 Todo 项内的每一个 checked 进行反转,记录耗时

目标 完整渲染耗时
taro-wx 1374.5 (2220%)
taro-alipay 138.7 (224%)
taro-tt 205.3 (331%)
taro-lark 231.7 (374%)
dom-lark 61.9 (100%)

体积比较

  • 核心逻辑,只包含样式、JS、XXML、SJS 的大小,不包含图片文件,json 等

  • 整包大小,包含图片资源、JS、CSS等整个包的大小

目标 核心逻辑 整包大小
taro-wx 308K (452%) 336K (323%)
taro-alipay 256K (376%) 276K (265%)
taro-tt 248K (364%) 272K (261%)
taro-lark 248K (364%) 272K (261%)
dom-lark 68K (100%) 104K (100%)

测试解读

  • 微信不知为何,性能表现非常差,我也不再倾向于继续和它进行对比,否则数据会非常夸张。

  • 采用 DOM 接口的开发方式,可以简化掉繁重的 Taro runtime 的实现,从而在速度和体积上获取一定的优势。

  • 相比飞书自身,各种情况下速度至少可以提高一倍以上,体积可以降低到原先的 30%。

  • 相比支付宝,体积上依旧保持优势,渲染速度上的优势略低一些,平均不超过 50% 的提升。但这个问题主要是飞书对内置组件(checkboxbutton)实现的性能较差,如果飞书对这部分进行优化后,相信可以获得更好的优势。

新框架会面临那些问题

虽然新架构的 DOM 接口在上面的测试和预期中会带来很多的优势,但这并不意味着一点问题都没有。

  • 新架构虽然保留了对 XXML 的兼容性,但依旧会产生一定的 Break Chagne,具体可以看飞书官方文档

  • 由于双线程的架构,在逻辑层模拟的那一层 DOM 无法实现任何和渲染结果相关的 API,例如 getComutedStyle 。还包括 querySelector 依旧要通过原先的异步方式获取。

  • 由于 DOM 接口某些能力实现难度较高,例如 innerHTML css parser 等,目前与这些相关的能力只有写入没有读取的能力,如果一定要读取,将无法保证一致性。

  • 目前样式还不支持动态插入,后续会支持在 <page> 或者 <head> 内动态插入 <style> 标签来实现。

  • 首屏性能。这个上文也说过,后续会有很多办法去优化和实现的,但优先级不高

对我来说,我觉得整体瑕不掩瑜,未来可期。

未来规划与设计

  • 目前新框架能力的开启和 DOM 接口的使用还在内测状态,对外的时间暂时无法确定

  • 未来 DOM 接口还有许多的优化方向,例如如何降低 DOM 指令的数量,降低一些重复字符串的发送等等

  • 由于 DOM 接口在一些非 Web 标准组件中和前端框架有一些冲突,例如 React 无法很好的支持 picker 这类需要针对某些属性设置一个对象的情况。后续会推出一些兼容手段去解决这些问题。

  • 主流的前端组件组(例如 antd/element/ud)都无法很好的运行在这个上面。一方面是因为这些组件库多多少少都依赖了前端的一些接口,而小程序没有;另一方面,组件库使用的也都是 Web 的标签元素,而不是小程序的组件。后续会尝试推出一定的解决方案去处理这个问题,能够让社区做较少的修改就可以兼容。

  • 会考虑以飞书官方的身份推出一套适配于 DOM 接口的主流前端组件库,根据情况会选择是否需要添加 WebComponent 能力的支持。

  • 未来飞书小程序的规划都是尽可能的向前端标准实现,未来可能会逐渐添加例如 fetch navigator 在内的 BOM 接口能力支持。

为什么要写这篇文章

  • 希望能促进小程序的前进。
    小程序业务上的成功并不是其可以恶心开发者的理由,也不应该放弃对其优化的决心。我希望我这一篇有点颠覆传统小程序风格的优化文章,能够探索更多的思路和想法。

  • 寻求合作。
    由于 DOM 接口的特性,需要更多社区的配合才能将这个发挥到极致,例如 Taro/Uniapp 等三方框架,Vant 等小程序原生组件库等等。

  • 沟通交流,拓展思路。
    希望能够得到更多人对这件事情的想法和思路,也希望能够得到更多的输入。

注释和共享

本文主要是以「飞书」小程序为准,兼容「微信」小程序,如果没有了解过「飞书」的同学,可以点击此处去官网了解

什么是 NFC

近距离无线通信(英语:Near-field communication,NFC),又简称近距离通信近场通信,是一套通信协议,让两个电子设备(其中一个通常是移动设备,例如智能手机)在相距几厘米之内进行通信。

近场通信技术由非接触式射频识别(RFID)演变而来,由飞利浦半导体(现恩智浦半导体,缩写 NXP)、诺基亚和索尼共同于2004年研制开发,其基础是RFID及互连技术。近场通信是一种短距高频的无线电技术,在13.56MHz频率运行于20厘米距离内。其传输速度有106 Kbit/秒、212 Kbit/秒或者424 Kbit/秒三种。

NFC 其实在刚诞生的时候我就一直在关注,但是不仅仅应用少,而且搭载的设备也少,甚至小米还出现过前一代搭载 NFC 后一代却不搭载的神奇情况。除此之外,使用起来也是特别复杂,想当初,要用 NFC 去实现刷公交卡,你需要去换一个特殊的 SIM 卡才能够支持(当初不理解,现在想来大概率是因为安全问题)。

在现在,随着安卓厂商的不懈努力,现在不论是应用还是设备的安装率都已经逐渐普及开来。从最初 NFC 也就能在支付宝中扫银行卡快速输入卡号,到现在的公交刷卡、X Pay,甚至传输文件,华为甚至给这个东西换了个名字叫做一碰系统(率感无语)。

除此之外,还能推进智能化的发展。比如以后家庭中加入了一个新设备,那么不再需要繁杂的联网过程,直接扫一下机器身上的 NFC 识别码就可以直接将设备加入到家庭网络中。或者说华为路由器上的一碰连接 Wifi 我觉得就是一个极好的应用。当家里来客人的时候,就不再需要一个人一个人的输入密码了。

NFC 技术一览

运行模式

NFC 现在主要有三种运行模式,分别是卡模拟模式(Card Emulation Mode)、主机模拟模式(Host Emulation Mode)、读卡写卡模式(Reader/Writer Mode)、P2P 模式(P2P Mode)

卡模拟模式

  • NFC手机可以模拟成为一张非接触卡,通过 POS 机(非接触读卡器)的 RF 来供电,即使 NFC 手机没电也可以工作。

  • 现在很常见的比如 Apple Pay,BYD NFC 钥匙,都是能够实现在断电情况下的刷卡

主机模拟模式

  • 该模式与卡模拟模式很类似,只不过卡模拟无需供电或者说无需 App 的参与就可以完成,但是主机模拟模式是不行的,他是通过将所有的消息转发给应用,由应用去决定该模拟什么内容,也就说该返回什么内容

  • 现阶段很多支付钱包,比如云闪付、京东闪付等等都是通过该模式实现的。

读卡写卡模式

  • NFC手机可以通过触碰NFC标签(Tag),从中读取非接触标签中的内容,采集数据并发送到对应的应用进行处理。

  • 最常见的应用其实就是华为的一碰系列,除此之外,支付宝支持直接读取信用卡、储蓄卡的卡号。

P2P 模式

  • 两个NFC设备可以近距离内互相连接,直接传递数据,实现点对点数据传输。

  • 例如协助快速建立蓝牙连接、交换手机名片和数据通信等。

  • 最常见的是手机互传、Android Beam。

协议标准类型

因为 NFC 的发展过程的原因,曾经出现过多个协议,甚至每家公司都有不同的协议内容。但现在主要是有一下几个协议标准:

ISO / IEC

主要定义了一下几个协议:

  • ISO/IEC 18092 / ECMA-340— (NFCIP-1)

    Near Field Communication Interface and Protocol-1

  • ISO/IEC 21481 / ECMA-352— (NFCIP-2)

    Near Field Communication Interface and Protocol-2

除此以外,还有一个协议标准很常用,是 ISO-14443 协议,其实这个协议是 RFID 的协议,和上面的唯一区别就是上面的多了一些其他模式的标准,比如点对点模式。

ISO-14443 协议有两个子类,分别是 Type-A 和 Type-B,这两个在 Android 也被称为 NFC-A 和 NFC-B。

但不幸的是,这些协议也不能免费看,要花钱的

NFC Forum

NFC Forum 是一个在 2004 年创建的非盈利行业协会,其成员来自NFC生态系统的各个部分。另外我主要关注了下国内公司,比较知名的有小米、中国移动通讯。

但是你想要从该组织获取任何关于 NFC 相关的技术标准,首先你的公司要成为该组织的成员才行,因为字节根本不在该组织,所以没法从这里获得一手的信息。

不过办法也是有的,该协会的创办者 NXP 公司网站上是有相关的数据资源,后文的参考此资源。

其他

不用管.jpg

沟通协议

以 NFC-A 为例

整个 NFC 卡片其实内部就是一个有限状态机,根据当前不同的状态需要不同的操作。这个图看起来很复杂,其实主要额外包含了两个操作:

  • 密码校验

    • 这个是说 NFC 卡是经过加密的,只有在密码校验通过之后,才能够进行相关的操作。

    • 有一点特殊的是,NFC 的密码长度其实是固定的,即 32 位,4 个字节。

  • 防冲突

    • 之所有有这个设计,是因为在使用过程中,可能会出现同时扫描到多个 NFC 设备的情况,此时就需要通过 READY1/READY2 两个状态来选择正确的 NFC 设备进行操作。

    • 每个卡片都有一个唯一 UID,长度为 7 字节,而每次操作只能选择 4 个字节,所以不得不拆分成两个状态两步去操作。

当没有上面两个操作的时候,可以简单的执行 IDLE -> ACTIVE -> HALT 的状态流程,也就是说连接、操作(也就是读写)、关闭。

存储设计

NFC 在存储上设计了页的概念,一个页表示 4 个字节,以页为最小单位进行操作。所以 NFC 卡片的存储容量其实都是 4 的整数倍。

这里以 NTAG213 180 字节的存储结构为例

这里只需要关注两点:

  • 用户数据存储的空间是从第四页开始

  • 用户可存储空间其实只有 144 字节

只需要记住这两点,在开发 NFC 需求的时候,不要去修改非用户空间的数据,不要存储过长的内容。

设备准备

在有了上面的基础之后,别急你还是不能开始开发 NFC,因为你还缺少至关重要的一个东西,设备

遗憾的是,不是所有的设备都有 NFC 硬件的,也不是说有了 NFC 硬件就能用的

  • 苹果设备只有升级到 iOS 13 以上才能具有开发 NFC 读卡器的能力,不能写入,除此之外,几乎没有其他的 NFC 能力可以使用。机圈也会叫做阉割版 NFC。(暂时没有能力去调研 NFC 的能力一定是需要硬件支持还是说只是软件限制)

  • 安卓设备理论上可以使用几乎所有的 NFC 能力,机圈内叫全功能 NFC,包括读卡、写卡、卡模拟、P2P 等模式。但是不同的手机有着不同的操作系统的限制,所以要选择一个合适的操作系统(原生安卓、类原生安卓是最推荐的)。

所以,请准备好一台安卓手机!

NFC 卡片

在有了设备之后,还要选择正确的 NFC 卡片,因为不是任意一个 NFC 卡片都是可以用的,比如工牌、银行卡等等。这是因为 NFC 卡片是带有加密的,在操作之前必须要通过验证才能操作,所以建议去淘宝买一些可读可写无加密的 NFC 贴纸用于测试。

不过你在淘宝上买的可能是写着 NTAG213 的型号,其实这个是 NXP 出的一款设备,但是支持兼容 ISO NFC-A 协议以及 NFC Forum Type 2 协议,所以大家可以放心使用。

当然了,你也可能看到 NTAG215/NTAG216,这两个都没有任何区别,只是存储空间不同而已。

小程序 API

此时在有了上述的基础知识后,别急,还要了解下小程序的 API 才能更好的开发。

就目前来说,所有与 NFC 相关的操作都被封装到了 NfcAdaptar 类中,通过 tt.getNFCAdaptar() 获取 nfcAfaptar 对象。

具体的 API 参数细节请参考「飞书开放平台

NFC 整体流程

  • 注册 NFC 发现事件回调 nfcAdaptar.onDiscovered

  • 开启 NFC 扫描 nfcAdaptar.startDiscovered

  • NFC 卡片贴近设备

  • 触发回调,通过回调可以获得 NFC 支持的协议

    • 回调参数内的 techs 字段可以用于判断当前卡片支持的协议
  • 根据协议去读写 NFC 卡片内容 nfcAdaptar.getNfcA()

  • 关闭 NFC 扫描,关闭事件监听 nfcAdaptar.offDiscovered / nfcAdaptar.stopDiscovered()

可以发现这个流程非常容易理解,也非常容易操作。那么接下来我们看下重头戏

读写 NFC 卡片

这里以 NFC-A 协议为主

通过 nfcAdaptar.getNfcA() 获取操作 NFC-A 卡片的操作类实例 nfca,流程如下

  • 连接卡片 nfca.connect()

  • 读写卡片 nfca.transceive()

  • 读写完成之后关闭连接 nfca.close()

关键点来了,NFC 卡片的读写不和其他的 IO 设备类似,有专门的 read 和 write 函数。对 NFC 来说,通过给 NFC 卡片发送不同的指令来做到完成不用的操作。

这些指令都在 NTAG213 文档中有写,这里简单列一下常用的数据

命令 代码 功能 参数
Read 0x30 一次读取四个页的数据 <Addr:1B>
Write 0xA2 一次写入一个页的数据 <Addr:1B> <Data:4B>
Fast Read 0x3A 一次读取多个页的数据 <StartAddr:1B> <StopAddr:1B>

比如我要读取第四页的数据,可以写如下代码

1
2
3
4
5
6
7
nfca.transceive({
data: new Uint8Array([0x30, 0x04]).buffer, // 必须要传入 ArrayBuffer
success: (res) => {
// res.data 是 ArrayBuffer,转成数组方便查看
console.log(Array.from(new Uint8Array(res.data))
}
})

根据协议,其实还要传 CRC:2B,也就是校验位,不过这个操作已经由 Android 去做掉了,所以就不需要传了,也不需要去了解校验算法

结语

至此,NFC 开发算是入门了,不过这里要注意不同的 NFC 卡片不同的协议会有不同的读写方式,这里要根据你们各自具体的卡片来看。而且有的还有密码保护,还需要额外再走校验的逻辑。

Refs

注释和共享

因为疫情的原因,在家里实在是无聊,外加最近公司里的事情不是很忙,于是我就开始研究捡垃圾事宜。而且之前在学校薅的 vps 羊毛也快到期了,基本上各大平台都薅过了,没法继续薅了,也使我决定了继续捡垃圾去搞一套家庭服务器。

开门见山,直接说我捡垃圾的结果,总价 3000 左右

  • 主板:华硕 z10pa-u8 10G-2S 12 ¥1250

  • CPU:e5 2660 v3 10 核 20 线程 ¥510

  • 内存:2 16 ECC DDR4 2133 ¥250 2

  • 电源:海韵 550W 全模组金牌电源 ¥450

  • 散热器:超微 E5 2011 服务器专用散热器 ¥155

  • 机箱:航嘉 S400 4u 工控机箱 ¥239

  • 系统:Unraid,暂时是试用版,所以不计入总价。等后面磨合好了会购买正版

  • 硬盘:家里淘汰下来的 500G 垃圾机械,不计入总价

整机装好 unraid 系统后空盘待机功耗 35W 左右,CPU 温度 40 度;系统满载在 130W 左右,温度 75 度左右。整体来说我是非常满意的,因为我另外一台 j1900 的 nas 待机也要 15W,虽然高了 20W 但是带来的性能提升可不止 20W 这么一点。

可能你会有很多疑惑,为什么要搞这个,为什么选用这样的配置,那么接下来让我一一来解释下我为啥选用这套配置,也给想要相同想法的朋友一个选择方案。

目标

在具体讲选择配件过程中,我们现在对齐目标,只有我们的目标相同,才能更好的理解我为什么选择这套配置:

  • 需要一台家庭强性能服务器,用于跑我个人的项目以及一些常用的 app,要求 CPU 核心数足够多,方便跑多任务

  • 服务器单核性能也要足够的强,因为会用来游戏开服,比如 minecraft,这个比较吃单核性能

  • 需要能够较好的以虚拟化的方式运行群辉,并且最好能够支持万兆网络,方便有时候心血来潮拷贝素材剪辑视频等

  • 偶尔要做家庭影音啥的,所以最好能够硬件解码的能力

  • 因为要跑群辉,所以要能够有较多的 SATA 接口,或者足够的 PCI 接口

  • 待机功耗要足够的低,毕竟我还是租房子住,不希望电费太贵

  • 服务器体积尽可能不要太大,同时要保证静音,而且家里有宠物,所以会考虑走线,避免宠物触碰到,所以机箱的选择可能不是很适合所有人

  • 最后的最后,价钱要便宜,挑选起来可就简单多了

CPU

一个服务器的核心就是他的 CPU,只要 CPU 定好之后,其他的配件都可以围绕着它展开。

先说一句,因为我是要做高性能服务器,所以什么 j1900 j3455 奔腾啊这些低功耗的 CPU 全部 pass。

其实挑选 CPU 是我最纠结的地方,因为我有两个自相矛盾的,是核心数的数量和单核性能之间的矛盾,众所周知,服务器级别 CPU 核心数多但单核性能羸弱,而消费级 CPU 核心数少但单核性能强。于是我在服务器和消费级之间来回摇摆,虽然消费级一般不支持 ECC,但是核心强更吸引我。我也一直不能下定决心。我目标的是至少 8 核 16 线程,并且单核性能与现有的消费级别处理器差不多。

说道这里,可能就有人会说 AMD 线程撕裂者不香么。确实,当初看到觉得特别符合我的要求,核心多单核强,但问题就在于这玩意上万块啊,就算是线程撕裂者一代,也要 1w,这对于我来说太难以接受了。

逛了一圈,实在是找不到,于是我不得不降低要求,就是放弃消费级别 CPU。一是因为没有核心数合适的,在锐龙以前的时代,intel 一直在四核心徘徊,就算是在锐龙之后,intel 的核心数也少。而锐龙核心够,但这又牵扯出另外一个问题,就是消费级别的 U 实在是贵啊,7700K 都还 1800 块呢,想要搞个便宜的,只能去找 4 代 3 代的 intel U,但这个时代的 U 和 E5 洋垃圾也差不多。所以最后将目光投向了服务器 E5 洋垃圾

而 E5 最难选择的其实就是 v2 系列还是 v3 系列了。v2 系列意味着可以用 DDR3 内存以及更便宜的主板,但是他的待机功耗要大不少。但 v3 系列相比要用更贵的 DDR4 内存和主板,但他的性能更强,待机功耗更低。具体对比可以看图

可以发现,同样是 2660,v3 比 v2 的性能提升了 20% 还要多,单核心性能比 r5 1600 来说才低了 20% 左右,比我想象中的好多了,一般来说同代的服务器都要比同代的消费级性能至少低 30% 多,如果是更高端的消费级可能要低 50%。而且总分更是比万元的 1900x 一代线程撕裂者还要高。

当然了,这里应该拿 intel 的做对比,拿 AMD 不太恰当,AMD 本身同代单核就比 intel 低不少,不过我手上只有 AMD 的 u,所以就拿 AMD 的来对比了。

那我为啥选择 2660 而不是 2650 或者 2678 呢?其实原因很简单,2650 以上基本就符合我的需求了,但是我发现 2660 竟然比 2650 还要便宜,那为啥不用 2660 呢?如果等以后我对性能有更高要求的时候,再换也不迟。

准系统?

在考虑的过程中,我也曾经看过一些准系统,二手服务器 dell r620 r730xd 准系统、二手的塔式服务器准系统,但都被我 pass 掉了,主要原因是:

  • 二手塔式服务器太贵了,光一个准系统就要 3000+ 了,而且还是 v2 的 u。

  • 机架式的服务器虽然便宜,但是噪音功耗都太大,而且体积也很大,放到哪里都不合适,因为租的房子没有专门的机房或者书房。

  • r620 是 v2 的 u,功耗太大。而 r730xd 又太贵了,最后也 pass 了

主板

既然将准系统 pass 掉之后,我不得不开始自选主板的道路。因为我不会用来做把服务器用来做视频渲染,需要核心多,但不需要那么多,所以这里我主要挑选的是单路主板,而且单路的便宜啊。如果小伙伴需要服务器拿来做视频渲染,建议直接上双路主板。PS:其实自从三代锐龙出现之后,建议视频渲染啥的还是直接上 3900x 3950x 这类吧,E5 做视频渲染已经不香了。

支持 V3 的主板基本有两种,一种是国产的寨板,另外一种就是拆机的服务器主板。

寨板有一个最大的好处,就是便宜,基本上五六百就可以搞定,但是缺点就是可扩展性太差了,内存插槽少,SATA 少,PCI-E 更少,而且还容易 BOOM,最终我放弃了寨板

那就只有拆机服务器主板可以选了,这其中就有微星、华硕的可以选,我最后选定了华硕 z10pa-u8 10g-2s 只有一个原因,便宜。微星的拆机件某宝基本上要 2000 左右,而话说的这个只需要 1400 多,运气好的话还能找到 1200 多的,就比如我下单的这个,而且还是湖北店铺,就当支持湖北朋友了。

简答介绍一下我这个主板,大家来感受下这 1200 块到底值不值:

  • 双板载千兆网卡,双板载万兆网卡,一个 IPMI 管理端口(板载万兆啊,普通的万兆扩展卡都要三四百呢,注意,不是所有的板子都有万兆网卡的,不带 10g-2s 的就没有)

  • 8 条内存插槽

  • 10 SATA 接口(足够我的硬盘使用了,而且 4 个侧插,6 个直插,还是比较丰富的)

  • 板载 m.2 NGFF 接口(因为是上年纪的板子,没有 nvme,不过也很不错了)

  • 双 PCI-Ex16,3 个 PCI-Ex8,一个 PCI-Ex1,不过其中一个 x16 是一个 x8 是共用的,当插了一个 x8 之后,x16 会自动变成 x8。

  • 板上搭载一个 USB,方便直接做启动盘

总的来讲,在单路主板里面,我觉得这个算是比较值的,尤其是板载万兆网卡。

机箱

前面也说了,我不想有一个太大的机箱,所以当时就没想直接买个 2u 机架服务器的机箱。而比较符合的是各种 nas 机箱,比如 8 盘位的,但问题依旧是太贵。8 盘位的要上千了,4 盘位的基本也在五百左右。

于是我就去看了看普通的塔式机箱,基本上比较符合我的心意,最多有 10 盘位的,支持 E-ATX 主板,而且价钱也才 300 多块,最主要是能够支持普通的机箱配件,而且还有一定的热插拔能力。简直太完美了,唯一的缺点就是外观不够有范

直到有一天无意间看到 4u 的工控机箱,发现这玩意好帅气,很符合我对一个服务器的定位。虽然只有 7 盘位,但是配合光驱位也能有 10 盘位。最主要的这个带钥匙,就不用怕我家里的猫一不小心碰到开关就给我关机了。而且体积比塔式的还要小巧一点,毕竟是租的方式,能小一点是一点,不过就是损失了热插拔的能力。好在价格更便宜,而且还躺着,于是心血来潮的我就定了这款机箱。

PS:在我实际装机之后,我觉得奉劝大家,还是塔式的好啊,工控机内部走线实在是太难了,没有热插拔能力测试的时候太难了。不过样子很好看,很有感觉,一次装机之后只要是不加硬盘基本不会动他了,也算是能接受吧。

其他配件

其他的配件基本上就是随便买的,内存选了 2133 频率的,为了保证兼容性。

有个好玩的事情就是电源,原本想买个金牌的 450W 直出电源就够了,毕竟就几个硬盘,最多可能外加一个计算卡,其他的也不会需要了。但正好赶上 618 活动,550W 金牌全模比 450W 金牌直出还便宜,于是我就买了 550W 了。但后来经过朋友提醒,想起来有个最佳转换效率区间,如果负载太低的话,就算是金牌,转换效率也不会太高的,理论上搞个 200W 就够了。

哎,就这样吧,买都买了。

使用

一切装好之后,我就安装了 unraid 作为宿主系统,原因很简单:

  • U 盘就能启动

  • 界面友好,EXSI 实在是有点丑

  • Docker 友好,这点太重要了,作为一个开发,深知 Docker 有多好用

  • 插件丰富,很多东西都能安装

  • 虚拟机太好用了,直通啥的一点问题都没有,而且还支持 XML 编辑,真棒

  • 基于 Linux 系统,直接提供了命令行工具,作为一个开发,能搞的东西太多了,太喜欢了

话不多说,直接一个群辉,一个 debian 虚拟机就搞起来了,把我之前在群辉里面跑的那个 Docker 转移到了 unraid 的 Docker 上。

就此,我心心念的服务器算是告一段落了,接下来就是把云服务器上的业务逐渐迁移到本地来,另外还要折腾下本地域名映射,让泛域名直接解析到内网的网关服务器上,这样就可以通过内网域名直接访问服务器上的业务了。就是内网的域名证书不好搞,用自签名的话需要每一台机器上都要安装根证书,用 CA 签名的吧,泛域名证书太贵了。

注释和共享

概要

本文主要讲解了下我平时在工作开发中遇到的关于 Hooks 的一些缺点和问题,并尝试配合 Mobx 解决这些问题的经历。我觉得两者的配合可以极大的降低开发过程中有可能出现的问题以及极大的提高开发体验,而且学习成本也是非常的低。如果你对 Hooks 以及 Mobx 有兴趣,想知道更进一步的了解,那么这篇文章适合你。这篇文章会介绍如下内容,方便你决定是否要仔细阅读,节省时间:

  • 本文不会介绍太过于基础的内容,你需要对 Mobx 以及 Hooks 有基础的了解

  • 本文介绍了平时开发中的一些最佳实践,方便小伙伴们对两者有更加深入的认识

  • 如果你使用过一部分 Mobx,但是不太了解如何和 Hooks 更好的合作,可以尝试来看看

另外 Hooks 本身真的就是一个理解上非常简单的东西,所以本文也不长,我也不喜欢去写什么万字长文,又不是写教程,而且读者看着标题就失去兴趣了。

Hooks 究竟有什么问题?

首先,在这里我不再说 Hooks 的优点,因为他的优点用过的人都清楚是怎么回事,这里主要讲解一下他存在的缺点,以及如何用 Mobx 来进行改进。

  • 依赖传染性 —— 这导致了开发复杂性的提高、可维护性的降低

  • 缓存雪崩 —— 这导致运行性能的降低

  • 异步任务下无法批量更新 —— 这也会导致运行性能的降低

换句话说,造成这种原因主要是因为 Hooks 每次都会创建一个全新的闭包,而闭包内所有的变量其实都是全新的。而每次都会创建闭包数据,而从性能角度来讲,此时缓存就是必要的了。而缓存又会牵扯出一堆问题。

说到底,也就是说没有一个公共的空间来共享数据,这个在 Class 组件中,就是 this,在 Vue3 中,那就是 setup 作用域。而 Hooks 中,除非你愿意写 useRef + ref.current 否则是没有办法找到共享作用域。

而 mobx 和 Hooks 的结合,可以很方便在 Hooks 下提供一个统一的作用域来解决上面遇到的问题,所谓双剑合并,剑走天下。

Hook1 useObserver

在传统的使用 mobx 的过程中,大家应该都知道 observer 这个 api,对需要能够响应式的组件用这个包裹一下。同样,这个 api 直接在 hooks 中依旧可以正常使用。 但是 hooks 并不推荐 hoc 的方式。自然,mobx 也提供了 hookify 的使用方式,那就是 useObserver

1
2
3
4
5
6
const store = observable({})
function App() {
return useObserver(() => {
return <div>{store.count}</div>
})
}

看到这里,相信使用过 mobx 的应该可以发现,useObserver 的使用几乎和 Class 组件的 render 函数的使用方式一致。事实上也确实如此,而且他的使用规则也很简单,直接把需要返回的 Node 用该 hooks 包裹后再返回就可以了。

经过这样处理的组件,就可以成功监听数据的变化,当数据变化的时候,会触发组件的重渲染。至此,第一个 api 就了解完毕了

Hook2 useLocalStore

简单来讲,就是在 Hooks 的环境下封装的一个更加方便的 observable。就是给他一个函数,该函数返回一个需要响应式的对象。可以简单的这样理解

1
2
3
const store = useLocalStore(() => ({key: 'value'}))
// equal
const [store] = useState(() => observable({key: 'value'}))

然后就没有了,极其简单的一个 api 使用。而后面要讲的一些最佳实践更多的也是围绕这个展开,后文简化使用 local store 代指。

这两个 API 能带来什么?

简单来讲,就是在保留 Hooks 的特性的情况下,解决上面 hooks 所带来的问题。

第一点,由于 local store 的存在,作为一个不变的对象存储数据,我们就可以保证不同时刻对同一个函数的引用保持不变,不同时刻都能引用到同一个对象或者数据。不再需要手动添加相关的 deps。由此可以避免 useCallback 和 useRef 的过度使用,也避免很多 hooks 所面临的的闭包的坑(老手请自动忽略)。依赖传递性和缓存雪崩的问题都可以得到解决

直接上代码,主要关注注释部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 要实现一个方法,只有当鼠标移动超过多少像素之后,才会触发组件的更新
// props.size 控制移动多少像素才触发回调
function MouseEventListener(props) {
const [pos, setPos] = useState({x: 0, y: 0})
const posRef = useRef()
const propsRef = useRef()
// 这里需要用 Ref 存储最新的值,保证回调里面用到的一定是最新的值
posRef.current = pos
propsRef.current = propsRef

useEffect(() => {
const handler = (e) => {
const newPos = {x: e.xxx, y: e.xxx}
const oldPos = posRef.current
const size = propsRef.current.size
if (
Math.abs(newPos.x - oldPos.x) >= size
|| Math.abs(newPos.y - oldPos.y) >= size
) {
setPos(newPos)
}
}
// 当组件挂载的时候,注册这个事件
document.addEventListener('mousemove', handler)
return () => document.removeEventListener('mousemove', handler)
// 当然这里也可以监听 [pos.x, pos.y],但是性能不好
}, [])

return (
props.children(pos.x, pos.y)
)
}

// 用 mobx 改写之后,这种使用方式远比原生 hooks 更加符合直觉。
// 不会有任何 ref,任何 current 的使用,任何依赖的变化
function MouseEventListenerMobx(props) {
const state = useLocalStore(target => ({
x: 0,
y: 0,
handler(e) {
const nx = e.xxx
const ny = e.xxx
if (
Math.abs(nx - state.x) >= target.size ||
Math.abs(ny - state.y) >= target.size
) {
state.x = nx
state.y = ny
}
}
}), props)

useEffect(() => {
document.addEventListener('mousemove', state.handler)
return () => document.removeEventListener('mousemove', state.handler)
}, [])

return useObserver(() => props.children(state.x, state.y))
}

第二,就是针对异步数据的批量更新问题,mobx 的 action 可以很好的解决这个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 组件挂载之后,拉取数据并重新渲染。不考虑报错的情况
function AppWithHooks() {
const [data, setData] = useState({})
const [loading, setLoading] = useState(true)
useEffect(async () => {
const data = await fetchData()
// 由于在异步回调中,无法触发批量更新,所以会导致 setData 更新一次,setLoading 更新一次
setData(data)
setLoading(false)
}, [])
return (/* ui */)
}

function AppWithMobx() {
const store = useLocalStore(() => ({
data: {},
loading: true,
}))
useEffect(async () => {
const data = await fetchData()
runInAction(() => {
// 这里借助 mobx 的 action,可以很好的做到批量更新,此时组件只会更新一次
store.data = data
store.loading = false
})
}, [])
return useObserver(() => (/* ui */))
}

不过也有人会说,这种情况下用 useReducer 不就好了么?确实,针对这个例子是可以的,但是往往业务中会出现很多复杂情况,比如你在异步回调中要更新本地 store 以及全局 store,那么就算是 useReducer 也要分别调用两次 dispatch ,同样会触发两次渲染。而 mobx 的 action 就不会出现这样的问题。// 如果你强行 ReactDOM.unstable_batchedUpdates 我就不说啥了,勇士受我一拜

Quick Tips

知道了上面的两个 api,就可以开始愉快的使用起来了,只不过这里给大家一下小 tips,帮助大家更好的理解、更好的使用这两个 api。(不想用而且也不敢用「最佳实践」这个词,感觉太绝对,这里面有一些我自己也没有打磨好,只能算是 tips 来帮助大家拓展思路了)

no this

对于 store 内的函数要获取 store 的数据,通常我们会使用 this 获取。比如

1
2
3
4
5
6
7
8
9
const store = useLocalStore(() => ({
count: 0,
add() {
this.count++
}
}))

const { add } = store
add() // boom

这种方式一般情况下使用完全没有问题,但是 this 依赖 caller,而且无法很好的使用解构语法,所以这里并不推荐使用 this,而是采用一种 no this 的准则。直接引用自身的变量名

1
2
3
4
5
6
7
8
9
const store = useLocalStore(() => ({
count: 0,
add() {
store.count++
}
}))

const { add } = store
add() // correct,不会导致 this 错误
  • 避免 this 指向的混乱

  • 避免在使用的时候直接解构从而导致 this 丢失

  • 避免使用箭头函数直接定义 store 的 action,一是没有必要,二是可以将职责划分的更加清晰,那些是 state 那些是 action

source

在某些情况下,我们的 local store 可能需要获取 props 上的一些数据,而通过 source 可以很方便的把 props 也转换成 observable 的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
function App(props) {
const store = useLocalStore(source => ({
doSomething() {
// source 这里是响应式的,当外界 props 发生变化的时候,target 也会发生变化
if (source.count) {}
// 如果这里直接用 props,由于闭包的特性,这里的 props 并不会发生任何变化
// 而 props 每次都是不同的对象,而 source 每次都是同一个对象引用
// if (props.count) {}
}
// 通过第二个参数,就可以完成这样的功能
}), props)
// return Node
}

当然,这里不仅仅可以用于转换 props,可以将很多非 observable 的数据转化成 observable 的,最常见的比如 Context、State 之类,比如

1
2
3
4
5
6
7
const context = useContext(SomeContext)
const [count, setCount] = useState(0)
const store = useLocalStore(source => ({
getCount() {
return source.count * source.multi
}
}), {...props, ...context, count})

自定义 observable

有的时候,默认的 observable 的策略可能会有一些性能问题,比如为了不希望针对一些大对象全部响应式。可以通过返回自定义的 observable 来实现。

1
2
3
4
5
6
7
const store = useLocalStore(() => observable({
hugeObject: {},
hugeArray: [],
}, {
hugeObject: observable.ref,
hugeArray: observable.shallow,
}))

甚至你觉得自定义程度不够的话,可以直接返回一个自定义的 store

1
const store = useLocalStore(() => new ComponentStore())

类型推导

默认的使用方式下,最方便高效的类型定义就是通过实例推导,而不是通过泛型。这种方式既能兼顾开发效率也能兼顾代码可读性和可维护性。当然了,你想用泛型也是可以的啦

1
2
3
4
5
6
7
8
9
// 使用这种方式,直接通过对象字面量推导出类型
const store = useLocalStore(() => ({
todos: [] as Todo[],
}))

// 当然你可以通过泛型定义,只要你不觉得烦就行
const store = useLocalStore<{
todos: Todo[]
}>(() => ({todos: []}))

但是这个仅仅建议用作 local store 的时候,也就是相关的数据是在本组件内使用。如果自定义 Hooks 话,建议还是使用预定义类型然后泛型的方式,可以提供更好的灵活性。

memo?

当使用 useObserver api 之后,就意味着失去了 observer 装饰器默认支持的浅比较 props 跳过渲染的能力了,而此时需要我们自己手动配合 memo 来做这部分的优化

另外,memo 的性能远比 observer 的性能要高,因为 memo 并不是一个简单的 hoc

1
2
3
4
5
6
export default memo(function App(){
const xxx = useLocalStore(() => ({}))
return useObserver(() => {
return (<div/>)
})
})

不再建议使用 useCallback/useRef/useMemo 等内置 Hooks

上面的这几个 Hooks 都可以通过 useLocalStore 代替,内置 Hooks 对 Mobx 来说是毫无必要。而且这几个内置 api 的使用也会导致缓存的问题,建议做如下迁移

  • useCallback 有两种做法

    • 如果函数不需要传递给子组件,那么完全没有缓存的必要,直接删除掉 useCallback 即可,或者放到 local store 中也可以

    • 如果函数需要传递给子组件,直接放到 local store 中即可。

  • useMemo 直接放到 local store,通过 getter 来使用

useEffect or reaction?

经常使用 useEffect 知道他有一个功能就是监听依赖变化的能力,换句话说就是可以当做 watcher 使用,而 mobx 也有自己的监听变化的能力,那就是 reaction,那么究竟使用哪种方式更好呢?

这边推荐的是,两个都用,哈哈哈,没想到吧。

1
2
3
useEffect(() =>
reaction(() => store.count, () => console.log('changed'))
, [])

说正经的,针对非响应式的数据使用 useEffect,而响应式数据优先使用 reaction。当然如果你全程抛弃原生 hooks,那么只用 reaction 也可以的。

组合?拆分?

逻辑拆分和组合,是 Hooks 很大的一个优势,在 mobx 加持的时候,这个有点依旧可以保持。甚至在还更加简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function useCustomHooks() {
// 推荐使用全局 Store 的规则来约束自定义 Hooks
const store = useLocalStore(() => ({
count: 0,
setCount(count) {
store.count = count
}
}))
return store
}

function App() {
// 此时这个 store 你可以从两个角度来思考
// 第一,他是一个 local store,也就是每一个都会初始化一个新的
// 第二,他可以作为全局 store 的 local 化,也就是你可以将它按照全局 store 的方式来使用
const store = useCustomHook()
return (
// ui
)
}

App Store

Mobx 本身就提供了作为全局 Store 的能力,这里只说一下和 Hooks 配合的使用姿势

当升级到 mobx-react@6 之后,正式开始支持 hooks,也就是你可以简单的通过这种方式来使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function App() {
return (
<Provider sa={saStore} sb={sbStore}>
<Todo/>
</Provider>
)
}

export function Todo() {
const {sa, sb} = useContext(MobxProviderContext)
return (
<div>{sa.foo} {sb.bar}</div>
)
}

Context 永远是数据共享的方案,而不是数据托管的方案,也就是 Store

这句话怎么理解数据共享和组件通讯呢?举个例子

  • 有一些基础的配置信息需要向下传递,比如说 Theme。而子组件通常只需要读取,然后做对应的渲染。换句话说数据的控制权在上层组件,是上层组件共享数据给下层组件,数据流通常是单向的,或者说主要是单向的。这可以说是数据共享

  • 而有一些情况是组件之间需要通讯,比如 A 组件需要修改 B 组件的东西,这种情况下常见的做法就是将公共的数据向上一层存放,也就是托管给上层,但是使用控制权却在下层组件。其实这就是全局 Store,也就是 Redux 这类库做的事情。可以看出来数据流通常是双向的,这就可以算作数据托管

曾经关注过 Hooks 的发展,发现很多人在 Hooks 诞生的时候开始尝试用 Context + useReducer 来替换掉 Redux,我觉得这是对 Context 的某种曲解。

原因就是 Context 的更新问题,如果作为全局 Store,那么一定要在根组件上挂载,而 Context 检查是否发生变化是通过直接比较引用,那么就会造成任意一个组件发生了变化,都会导致从 Provider 开始的整个组件树发生重新渲染的情况。

1
2
3
4
5
6
7
8
9
10
function App() {
const [state, dispatch] = useReducer(reducer, init)
return (
// 每次当子组件调用 dispatch 之后,会导致 state 发生变化,从而导致 Provider 的 value 变化
// 进而让所有的子组件触发刷新
<GlobalContext.Provider value={{...state, dispatch}}>
{/* child node */}
</GlobalContext.Provider>
)
}

而如果你想避免这些问题,那就要再度封装一层,这和直接使用 Redux 也就没啥区别了。

主要是 Context 的更新是一个性能消耗比较大的操作,当 Provider 检测到变化的时候,会遍历整颗 Fiber 树,比较检查每一个 Consumer 是否要更新。

专业的事情交给专业的来做,使用 Redux Mobx 可以很好的避免这个问题的出现。

如何写好一个 Store

知道 Redux 的应该清楚他是如何定义一个 Store 吧,官方其实已经给出了比较好的最佳实践,但在生产环境中,使用起来依旧很多问题和麻烦的地方。于是就诞生了很多基于 Redux 二次封装的库,基本都自称简化了相关的 API 的使用和概念,但是这些库其实大大增加了复杂性,引入了什么 namespace/modal 啥的,我也记不清了,反正看到这些就自动劝退了,不喜欢在已经很麻烦的东西上为了简化而做的更加麻烦。

而 Mobx 这边,官方也有了一个很好的最佳实践。我觉得是很有道理,而且是非常易懂易理解的。

但还是那个问题,官方在有些地方还是没有进行太多的约束,而在开发中也遇到了类似的问题,所以这里在基于官方的框架下有几点意见和建议:

  • 保证所有修改 store 的操作都只能在 store 内部操作,也就是说你要通过调用 store 上的 action 方法更新 store,坚决不能在外部直接修改 store 的 property 的值。

  • 保证 store 的可序列化,方便 SSR 的使用以及一些 debug 的功能

    • 类构造函数的第一个参数永远是初始化的数据,并且类型保证和 toJSON 的返回值的类型一致

    • 如果 store 不定义 toJSON 方法,那么要保证 store 中的数据不存在不可序列化的类型,比如函数、DOM、Promise 等等类型。因为不定义默认就走 JSON.stringify 的内置逻辑了

  • store 之间的沟通通过构造函数传递实现,比如 ThemeStore 依赖 GlobalStore,那么只需要在 ThemeStore 的构造参数中传入 GlobalStore 的实例即可。不过说到这里,有的人应该会想到,这不就是手动版本的 DI 么。没错,DI 是一个很好的设计模式,但是在前端用的比较轻,就没必要引入库来管理了,手动管理下就好了。也通过这种模式,可以很方便的实现 Redux 那种 namespace 的概念以及子 store

  • 如果你使用 ts 开发,那么建议将实现和定义分开,也就是说分别定义一个 interface 和 class,class 继承 Interface,这样对外也就是组件内只需要暴露 interface 即可。这样可以很方便的隐藏一些你不想对外部暴露的方法,但内部却依旧要使用的方法。还是上面的例子,比如 GlobalStore 有一个属性是 ThemeStore 需要获取的,而不希望组件获取,那么就可以将方法定义到 class 上而非 interface 上,这样既能有良好的类型检查,又可以保证一定的隔离性。

是的,基本上这样就可以写好一个 Store 了,没有什么花里胡哨的概念,也没有什么乱七八糟的工具,约定俗成就足以。我向来推崇没有规则就是最大的规则,没有约束就是最大的约束。很多东西能约定俗成就约定俗成,落到纸面上就足够了。完全没必要做一堆 lint/tools/library 去约束,既增加了前期开发成本,又增加了后期维护成本,就问问你司内部有多少 dead 的工具和库?

俗话说的话,「秦人不暇自哀而后人哀之,后人哀之而不鉴之,亦使后人而复哀后人也」,这就是现状(一巴掌打醒)

不过以上的前提是要求你们的开发团队有足够的开发能力,否则新手很多或者同步约定成本高的话,搞个库去约束到也不是不行(滑稽脸)

缺点?

说了这么多,也不是说是万能的,有这个几个缺点

  • 针对一些就带状态的小组件,性能上还不如原生 hooks。可以根据业务情况酌情针对组件使用原生 hooks 还是 mobx hooks。而且针对小组件,代码量可能相应还是增多。因为每次都要包裹 useObserver 方法。

  • mobx 就目前来看,无法很好在未来使用异步渲染的功能,虽然我觉得这个功能意义不大。某种程度上说就是一个障眼法,不过这个思路是值得一试的。

  • 需要有一定 mobx 的使用基础,如果新手直接上来写,虽然能避免很多 hooks 的坑,但是可能会踩到不少 mobx 坑

总结

Mobx 在我司的项目中已经使用了很久了,但 Hooks 也是刚刚使用没多久,希望这个能给大家帮助。也欢迎大家把遇到的问题一起说出来,大家一起找解决办法。

我始终觉得基于 Mutable 的开发方式永远是易于理解、上手难度最低的方式,而 Immutable 的开发方式是易维护、比较稳定的方式。这两者没必要非此即彼,而 Mobx + React 可以认为很好的将两者整合在一起,在需要性能的地方可以采用 Immutable 的方式,而在不需要性能的地方,可以用 Mutable 的方式快速开发。

当然了,你就算不用 Mobx 也完全没有问题,毕竟原生的 Hooks 的坑踩多了之后,习惯了也没啥问题,一些小项目,我也会只用原生 Hooks 的(防杠声明)。

注释和共享

目录

  1. A Primer on Element Resize
  2. overflow & underflow
  3. 大大大
  4. 小一点
  5. bomb!撞在一起
  6. 你看的见我,又看不见我了,偷偷摸摸搞点事情
  7. ResizeObserver
  8. 还有别的办法么?
  9. 结语
  10. Refs

A Primer on Element Resize

监听元素尺寸的变化一直以来,都是一个很常见的需求,但是又不那么容易去实现。因为浏览器都有实现针对窗口变化的监听,而却没有针对元素变化的监听。这个常常发生在一些内部元素大小变化的情况。

比如飞书 Admin 的管理页面,当左侧侧边栏收起或者展开的时候,右侧的宽度会发生变化,而浏览器的窗口并没有变化。

如果我们从实际角度出发,监听元素的变动其实大部分是为了监听因为窗口变化而导致的大小变化,此时最简单的方案就是直接监听浏览器窗口的 resize 事件,这个就不细说了。

其次针对上文说道的情况,社区有很多的实现方案,但很多基于 JQuery 实现的,而且性能较差,因为是使用计数器去拉取元素宽度。不过会有很多优化方案,比如当点击某些可能会导致宽度发生变化的时候才启动定时器去检查,并且检查一段时间发现没有变化的时候,就停止检查。但是不管怎样,性能都是不太好的。

overflow & underflow

于是,社区中有另外一种检测方案,那就是基于事件检测,用的就是 overflowunderflow 事件。不知道这两个事件是什么的,可以去看 MDN 或者这篇博客

简单来讲,就是当内容超出外部容器的时候,会触发 overflow 事件,当内容又小于容器宽度的时候,会触发 underflow 事件。

那怎么检测呢?也很简单,先从扩大变化检测说起

假设有这样一个容器,长度宽度为 100px,内部有一个元素,长宽分别为 101px,也就是比父容器各大 1px。那么如果你将外部容器扩大的一瞬间,长宽至少会增加一个像素,也就是说至少比内部元素大,≥ 101px,此时会触发 underflow 事件。扩大检测就实现完成了,在检测完成之后重置内部元素大小依旧比外部容器大即可。

同理缩小检测也很简单,外部容器不变,内部元素宽度和父容器一直,当缩小的时候,父容器肯定会小于内部元素,于是触发 overflow。缩小检测也完成了,再重置宽度即可。

看似一切美好,但实际上,这两个事件的兼容到现在都巨差无比。但别担心,还有别的办法

大大大

我们现在考虑下扩大的时候,如果父容器有滚动条,有一样东西是会发生变化的。那就是可滚动区域的大小——这不是废话么。别急,先想想可滚动区域变化的话,什么也会跟着变化呢?

没错,就是父元素的 scrollTopscrollLeft。具体怎么讲呢,这边我们先考虑 scrollTop 的情况。

假设有这样一个容器(绿色),高宽为 100px,内部有一个元素(红色),高 200px,宽 100px,也就是比容器固定大 100px。容器滚动到最底部,此时容器的 scrollTop: 100px,如左图所示。如果此时容器扩大 10px,很明显随着滚动区域的变大,scrollTop 也会自动变化成 90px,如右图。

那么问题来了,scrollTop 怎么监听呢?

神奇就神奇在这里,因为元素尺寸的变化而导致 scrollTop 的变化竟然会触发容器的 scroll 事件,而且这个事件触发还是跨平台的,兼容性甚至可以下探到 ie7

小一点

这个时候你会发现,想要变小一点,那么问题就麻烦了。你会发现上面那个方式,你不管怎么搞,都无法让他在缩小的时候触发 scrollTop 的改变。

但是换个思路来考虑,你会发现另外一种让 scrollTop 发生改变的情况。依旧从上面那个方式来讲,如果容器缩小了 10px,那么内容至少要缩小 11px 才能导致滚动发生变化,那么我们会想到什么呢?

百分比对不对?这里假设设置内容高度为 200%,就可以做到当容器缩小 10px 的时候,内部元素可以缩小 20px,也就是 200px(100px 200%) ⇒ 180px(90px 200%)。如图所示

bomb!撞在一起

把上面两个结合起来,就可以实现监听 resize 事件了。但实际上使用需要处理一些样式问题,不过这个问题很简单,不单独说了。

如果前面你能看懂,那么到这里你心中有没有考虑一个情况,就是在重置的时候,会导致 scrollTop/Left 又发生了修改,从而又导致的 scroll 事件的触发,而这个怎么消除呢?

有这么两种办法:

  1. 通过检测元素是否发生变动,如果有变动那么就是因为元素缩放导致的,而如果没有缩放,那么就是通过重置导致的。

  2. 通过 raf,在同一帧内,先触发因为元素变动导致的 scroll,再触发因为重置导致的事件,然后将对事件的处理统一推迟到下一阵开始的时间。从而消除循环事件

你看的见我,又看不见我了,偷偷摸摸搞点事情

众所周知,当你给一个元素设置 display:none; 的时候,元素不会渲染,同理也不会监听外部容器尺寸的变化。那么这就给了被监听元素可乘之机,他可以让 DOM 先隐藏,然后再改变大小,再显示 DOM,此时尺寸就乱了,于是就无法很好的监听 resize 了。

不过,也不是没有办法。

当 DOM 显示在浏览器的时候,会执行一个操作——播放动画

而动画事件是可以被监听到的,于是骚操作来了,给 DOM 元素添加一个 1ms 的动画,然后监听动画开始的事件,然后重置并检测大小变化。

ResizeObserver

这么通用的需求,怎么能逃过 w3c 的双眼,于是就推出了 ResizeObserver,方便做 resize 的监听。

而这个 api 所能做的远比我们想象的强大很多:

  • 能够监听不同 box-sizing 的变化,比如 border-box/content-box

  • 能够监听元素挂载和卸载

  • 能够监听非容器类型的 DOM,比如 input canvas 等等

正好说到了这个,顺便讲下一个有意思的事情。自古监听,从来没逃过循环计算的问题,大家熟悉的 angular 脏检查的次数限制,而 ResizeObserver 是怎么解决这个问题的呢。

这里先说明几个概念:

  • observer 每一个 ResizeObserver 实例

  • observation 当 observer 实例调用 observe 方法之后创建的每一个监听,每个 observer 可能包含多个 observation

  • target 被监听的 DOM 元素

  • depth 深度,表示一个 DOM 元素距离根元素的距离,在网页中,也就是距离 html 标签的距离。

在每一次 Event Loop 中,会检查每一个 observationtargetdepth ,并取一个最小值。然后顺便检查有那些 observation 产生了变化,并创建对应的 entity ,最后作为参数传给 observer 的回调。当上面这一操作之后,就完成了一轮检测,然后会再重复一遍这样的操作,只不过这次有个要求,不仅仅要求 observation 有变化,还要要求对应的 depth 比上次检查的最小值还要大,才可以创建 entity。就这样一直一直循环检测跑下去,直到没有任何东西被检测到发生变化。

用一点通俗的话来说,除了第一轮的检查外,其他的每一轮检查都要求元素的高度要大于在上一轮检查元素的高度最小值,从而保证每一次检查,深度都会越来越大,直到达到最小的根节点,进而检查结束。

不过也许你会好奇,难道有些元素就会被跳过不检查么?其实不会的,对于那些深度小于上一次的最小深度的 observation 会自动到下一个 Event Loop 的时机去检查。

以上内容确实有点绕,我自己写的再看第二遍都看不懂了,原本好像补一个图仔细讲讲,但是发现上图也讲不清,算了弃疗,大家有兴趣的看看 Spec 吧。还有就是兼容性有点差,微软家的和苹果家的都不支持,摊手

还有别的办法么?

当然有了,还有我们 IntersectionObserver 呀。

怎么用?和最上面使用 scroll 的差不多,但是要略微简单一些,其实也没简单多少,因为该 api 判断的是相交的面积,也就是不能通过一个哨兵来判断,而是一个方向需要有一个哨兵。

至于兼容性么,除了 IE 不支持,其他都支持,这就很完美了么,对吧。(诡异的双眼,实际上坑很多,不建议尝试)

结语

本文没有特别深入的去讲解一些细节,只是讲了一下我觉得有意思的地方,如果你觉得哪里不清楚或者想了解更多,可以尝试去看源码。/ 我又没说包教包会 /

另外,我也是没懂为啥各大浏览器厂商这次这么一致,针对容器大小变化导致的 scrollTop 变化会触发 scroll 事件。可能是我太年轻了吧,这种上古事件的在各大浏览器之间的爱情纠葛我实在是无从考证。

另外,上面说 HC 很多,要多多推荐人,我也不喜欢专门打广告这种事情,但……杭州 Lark 部门招人,前端后端设计产品,应该(因为我也不确定,逃)是都要的。另外我觉得字节跳动是个好公司,如果你有其他部门或者其他城市的部门想要内推或者了解详情的,我也可以帮忙引荐下,只要进了字节我觉得就是对字节最大的贡献,不一定要来我们部门的,滑稽脸。

Refs

注释和共享

自从 Hooks 诞生以来,官方就有考虑到了性能的问题。添加了各种方法优化性能,比如 memo、hooks deps、lazy initilize 等。而且在官方 FAQ 中也有讲到,Function 组件每次创建闭包函数的速度是非常快的,而且随着未来引擎的优化,这个时间进一步缩短,所以我们这里根本不需要担心函数闭包的问题。

当然这一点也通过我的实验证实了,确实不慢,不仅仅是函数闭包不慢,就算是大量的 Hooks 调用,也是非常快的。简单来说,1 毫秒内大约可以运行上千次的 hooks,也就是 useState useEffect 的调用。而函数的创建,就更多了,快的话十万次。

很多人都觉得既然官方都这么说了,那我们这么用也就好了,不需要过分担心性能的问题。我一开始也是这样想的。但是直到最近有一次我尝试对公司项目里面一个比较复杂的组件用 Hooks 重写,我惊奇的发现重渲染时间竟然从 2ms 增长到了 4ms。业务逻辑没有任何变化,唯一的变的是从 Class 变成了 Hooks。这让我有点难以相信,我一直觉得就算是慢也不至于慢了一倍多吧,怎么着两者差不多吧。于是我开始无差别对比两个写法的性能区别。

懒人阅读指南

我相信肯定很多懒人不想看下面的分析,想直接看结果。没问题,满足你们,直接通过目录找到最后看「总结」就好了,如果你觉得有问题或者觉得我说的不对,可以重新仔细阅读一下文章,帮我指出哪里有问题。

为什么有这篇文章

其实我原本不是很想写一篇文章的,因为我觉得这个只是很简单的一个对比。于是我只是在掘金的沸点上随口吐槽了两句,结果……我决定写一篇文章。主要是觉得这群人好 two,就算是质疑也应该先质疑我的测量方式,而不是说我的使用方式。都用了这么多年了,还能用错)滑稽脸

不过既然要写,就写的完备一些,尽量把一些可能的情况都覆盖了,顺便问问大家是否有问题。如果大家对下面的测试方法或者内容有任何问题的话,请大家正常交流哦,千万不要有一些过激或者偏激的言论。因为性能测试这东西,一人一个方法,一人一个想法。

既然说道这里,其实有一点我要说,沸点里面说到的 50% 这个测量数据确实有些问题。主要有这么几个原因,第一,我当初只是想抱着试试的心态,于是就直接在开发模式下运行的。第二,平时写代码写习惯了,就直接用了 Date.now() 而没有使用精度更高 performance.now() 从而导致了误差略微有点大。虽然误差略大,但是大方向还是没错的

后文的测试中,我也将这些问题修复了,尽量给大家一个正确的数据。

开始之前,我们要知道……

假设现在有 HookCompClassComp 两个组件分别表示函数式组件和类组件,后文用 Hook(HC) 和 Class(CC) 代替。

功能定义

为了更加贴近实际,这里假设两个组件都要完成相同的一个功能。那就是用户登录这个流程:

  • 有用户名输入框和密码输入框

  • 有一个登录按钮,点击之后校验用户名是否为 admin 且密码为 admin

  • 如果校验成功,下方提示登录成功,否则提示用户名或者密码错误

  • 每次输入内容,都将清空内容

  • 另外为了消除误差,额外添加一个按钮,用于触发 100 次的 render,并 log 出平均的渲染时间。

具体的业务逻辑的实现,请看后面的 DEMO 地址。

另外因为 Class 组件有 setState 可以自动实现 batch 更新,但是 Hook 不行,所以这里实现的时候把所有的更新操作都放在 React 事件中同步更新,众所周知,React 的事件是自带 batch 更新的,从而保证只有一次渲染。保证两者功能效果一致。

对比常量

  • 2018 款 15 寸 MacBook Pro 入门款,i7-8750H 6 核 12 线程 + 16g + 256g

  • Chrome Stable 79.0.3945.117

  • react 16.12.0 PS: 其实我从 16.8.0 就开始测试了,懒癌发作一直没有继续搞

  • react-dom 16.12.0

    React 全家桶版本全部使用生产模式,降低开发模式的影响。

衡量标准:从函数调用到渲染到 DOM 上的时间

这个时间其实当组件量非常大的时候其实是不准的,因为大家调用的时间是不同的,但是渲染到 DOM 上的时间基本是一致的,就会导致在组件树越浅越前的组件测量出来的时间就会越长。但是这里的情况是页面只有一个对比组件,所以可以暂时用这个作为衡量标准。

针对 HC 来说

  • 在组件运行的一开始就记录为开始时间

  • 使用 useLayoutEffect 的回调作为结束时间。该 Hook 会在组件挂载或者更新 DOM 之后同步调用。而 useEffect 会在下一个 tick 调用,如果使用该 hook 就会导致最终测量出来的结果普遍慢一些。

1
2
3
4
5
function Hooks() {
const now = performance.now()
useLayoutEffect(() => console.log(performance.now() - now))
return (/* ui */)
}

针对 CC 来说

  • 当运行 render 方法的时候,记录时间

  • 当运行 componentDidUpdate 或者 componentDidMount 的时候,打印耗时。这两个钩子都是在组件挂载或者更新 DOM 之后同步调用,与 useLayoutEffect 调用时机一致。

1
2
3
4
5
6
7
8
9
class Class extends Component {
componentDidMount = () => this.log()
componentDidUpdate = () => this.log()
log = () => console.log(performance.now() - this.now)
render() {
this.now = performance.now()
return (/* ui */)
}
}

测试流程和结果计算

  • 页面刷新,此时要针对测试内容先进行 5 轮预热测试。目的是为了让 Chrome 对热区代码进行优化,达到最高的性能。

  • 每一轮包含若干次的渲染,比如 100 次或者 50 次,对于每一轮测试,都会抛弃 5% 最高和最低一共 10% 的数据,只保留中间的值,并对这些值计算平均值得到该轮测试结果

  • 然后进行 5 轮正常测试,记录每次的结果,统计平均值。

  • 将此时的值计算作为最终的数据值

DEMO 地址

PS: CodeSandBox 似乎不能以生产模式运行,不过你可以将它一键部署到 ZEIT 或者 netlify 上面,查看生产环境的效果。

开胃菜-重渲染测试结果

最为开胃菜,用一个最常见的场景来测试实在是最合适不过了,那就是组件的重渲染。话说不多,直接上测试结果

Avg. Time(ms) Hook Slow Hook(Self) Class Class(Self) Hook Self
第五次平均时间 0.171808623414 0.04126375367107627 0.1941208809532307 0.024725271102327567 0.22747252228577713 0.668889837468
第四次平均时间 0.1696739222 0.04082417709159327 0.18879122377096952 0.02120880942259516 0.22082417118516598 0.924868873031
第三次平均时间 0.160409555184 0.04109888910674132 0.1931868181410399 0.022967028748858104 0.22417582970644748 0.789473490728
第二次平均时间 0.130965058748 0.045824176785382593 0.2072527365001676 0.02346153545019391 0.23439560331158585 0.95316188416
第一次平均时间 0.216216175927 0.04549450906259673 0.20939560484263922 0.02357143663115554 0.2546703217776267 0.93006942148

简单解释下数据,Hook 和 Class 是通过上面规定的方式统计出来的数据,而 Hook(Self) Class(Self) 是计算了 HC 和 CC 函数调用的时间,最后的 Self 和 Hook Slow 则是 Hook 相比 Class 慢的百分比。这里只需要关注不带 Self 的数据即可。

让我们来细细「品味」一下,Hook 比 Class 慢了 16%。

等等??? 16%,emmm……乍一听这是一个多么惊人的数字,5 % 的性能降低都很难接受了,何况是 16%。如果你的页面中有上百个这样组件,想想都知道……咦~~~那酸爽

Wait!!! 或许有人会说了,抛开数值大小谈相对值,这根本就是耍流氓么。每个组件组件都是毫秒级别的渲染,这么小的级别作比较误差也会很大。而且你的测试的测量方式真的很对么?为啥看到很多文章说 Hooks 性能甚至比 Class 组件还高啊。而且你这个测量真的准确么?

这里先回答一下测量的问题,上面也说了,useLayoutEffect 和 CDU/CDM 基本是一致的,而且为了佐证,这里直接上 Performance 面板的数据,虽然只能在开发模式下才能看到这部分数据,但依旧具有参考意义

当然因为我这里只是截取了一个样例,没法给大家一个平均的值,但是如果大家多次尝试可以发现就算是 React 自己打的标记点,Class 也会比 Hook 快那么一点点。

而针对更多的疑问,这里我们就基于这个比较结果,引申出更多的比较内容,来逐步完善:

  • 挂载性能如何?也就是第一次渲染组件

  • 大量列表渲染性能如何?没准渲染的组件多了,性能就不会呈现线性叠加呢?

  • 当 Class 被很多 HOC 包裹的时候呢?

其他对比

挂载性能

通过快速卸载挂载 40 次计算出平均时间,另外将两者横向布局,降低每次挂载卸载的时候 Chrome Layout&Paint 上的差异。话不多说,直接上结果

Avg. Time(ms) Hook Slow(%) Hook(Self) Class(Self) Hook Class
第三次平均时间 0.100681682204 0.04797298587053209 0.024729729252489837 0.5672973001728187 0.5154054158845464
第二次平均时间 0.137816482105 0.041216209128096294 0.02486483395301007 0.6013513618224376 0.5285134916571347
第四次平均时间 0.009446076914 0.04378377736822979 0.025405410073093465 0.5343243404216057 0.5293243023491389
第五次平均时间 0.05774346214 0.041081066671255474 0.025540552529934292 0.5371621495263802 0.5078378347428264
第一次平均时间 0.036722530281 0.04027024968653112 0.025810805980015446 0.5608108209295047 0.5409459180727199

通过交替运行连续跑 5 轮 40 次测试,可以得到上面这个表格。可以发现,不管那一次运行,都是 Class 时间会少于 Hook 的时间。通过计算可得知,Hook 平均比 Class 慢了 (0.53346 - 0.49811) / 0.49811 = 7%,绝对差值为 0.03535ms。

这个的性能差距可以说是很少了,如果挂载上百个组件的时候,两者差距基本是毫秒内的差距。而且可以看出来,绝对值的差距可以说是依旧没有太多的变化,甚至略微微微微减少,可以简单的认为其实大部分的时间依旧都花费在一些常数时间上,比如 DOM。

大列表性能

通过渲染 100 个列表的数据计算出平均时间。

Avg. Time(ms) Hook(500) Hook Hook Slow(%,500) Hook Slow(%) Class(500) Class
第二次平均时间 9.59723405143682 2.6090425597701934 0.10286063613 0.104480973312 8.702127664211266 2.3622340473168073
第三次平均时间 9.64329787530005 2.5888297637488615 0.10438723417 0.104028668798 8.731808533218313 2.344893603684737
第一次平均时间 9.55063829376818 2.5251063647026077 0.085798601307 0.081415992606 8.795957447604296 2.335000020313136
第五次平均时间 9.597553207756992 2.571702087694343 0.10075770158 0.15273472846 8.719042523149797 2.230957413012994
第四次平均时间 9.604468084673615 2.567340426662184 0.095974553092 0.0995534837 8.76340427574642 2.334893631570517

我们先不计算下慢了多少,先看看这个数值,100 次渲染一共 2ms 多,平均来说一次 0.02ms,而而我们上面测试的时候发现,单独渲染一个组件,平均需要 0.2ms,这中间的差距是有点巨大的。

而如何合理解释这个问题呢?只能说明在组件数小的时候,React 本身所用的时间与组件的时间相比来说比例就会比较大,而当组件多了起来之后,这部分就变少了。

换句话说,React Core 在这中间占用了多少时间,我们不得而知,但是我们知道肯定是不少的。

HOC

Hook 的诞生其实就是为了降低逻辑的复用,简单来讲就是简化 HOC 这种方式,所以和 Hook 对线的其实是 HOC。最简单的例子,Mobx 的注入,就需要 inject 高阶组件包裹才可以,但是对于 Hook 来讲,这一点完全不需要。

这里测试一下 Class 组件被包裹了 10 层高阶组件的情况下的性能,每一层包裹的组件做的事情非常简单,那就是透传 props。

啥?你说根本不可能套 10 层?其实也是很容易的,你要注意这里我们所说的 10 层其实是指有 10 层组件包裹了最终使用的组件。比如说大家都知道 mobx inject 方法或者 redux 的 connect 方法,看似只被包裹了一层,其实是两层,因为还有一层 Context.Consumer。同理你再算上 History 的 HOC,也是一样要来两层的。再加上一些其他的东西,再加一点夸张不就够了,手动滑稽)

Avg. Time(ms) Class With 10 HOC
第五轮 0.25384614182697546
第四轮 0.27269232207602195
第二轮 0.2821977993289193
第三轮 0.278846147951189
第一轮 0.2710439444898249

这结果也就是很清楚了吧,在嵌套较多 HOC 的时候,Class 的性能其实并不好,从 0.19855ms 增加到 0.27173ms,时间接近有 26% 的增加。而这个性能不好并不是因为 Class,而是因为渲染的组件过多导致的。从另一个角度,hook 就没有这种烦恼,即使是大量 Hook 调用性能依旧在可接受范围内。

量化娱乐一下?

有了上面的数据,来做一个有意思的事情,将数据进行量化。

假设有如下常数,r 表示 React 内核以及其他和组件数无关的常数,h 表示 hook 组件的常数,而 c 表示 Class 组件的常数,T 表示最终所消耗的时间。可以得知这四个参数肯定不为负数。

通过简单的假设,可以得到如下等式:

1
2
3
T(n,m) = hn + cm + r
// n 表示 hook 组件的数量
// m 表示 class 组件的数量

想要计算得到 r h c 参数也很–简单–,简单个鬼,因为数据不是准确的,不能直接通过求解三元一次方程组的方式,而是要通过多元一次拟合的方式求得,而我又不想装 matlab,于是千辛万苦找到一个支持在线计算多元一次方程的网站算了下,结果如下:

1
2
3
4
5
h = 0.0184907294
c = 0.01674766395
r = 0.4146159332
RSS = 0.249625719
R^2 = 0.9971412136

这个拟合的结果有那么一点点差强人意,因为如果你把单个 Class 或者 Hook 的结果代入的话,会发现偏差了有一倍多。所以我上面也说道只是娱乐娱乐,时间不够也没法细究原因了。不过从拟合的结果上来看,也能发现一个现象,那就是 h 比 c 要大。

另外观察最后的拟合度,看起来 0.99 很大了,但实际上并没有什么意义。而且这里数据选取的也不是很好,做拟合最好还是等距取样,这样做出来的数据会更加准确。这里只是突然奇想想要玩玩看,所以就随便做了下。

总结

不管你是空降过来的还是一点点阅读到这里的,我这边先直接说基于上面的结论:

  • 当使用 Hook 的时候,整体性能相比 Class 组件会有 10 - 20% 的性能降低。

  • 当仅仅使用函数式组件,而不使用 Hook 的时候,性能不会有降低。也就是说可以放心使用纯函数式组件

  • Hook 的性能降低不仅仅体现在渲染过程,就算是在第一次挂载过程中,也相比 Class 有一定程度的降低

  • Hook 的性能降低有三部分

    • 第一部分是 Hook 的调用,比如说 useState 这些。但是这里有一点需要注意的是,这里的调用指的是有无,而不是数量。简单来说就是从 0 到 1,性能降低的程度远远高于 从 1 到 n。

    • 第二部分是因为引入 Hook 而不得不在每次渲染的时候创建大量的函数闭包,临时对象等等

    • 第三部分是 React 对 Hook 处理所带来的额外消耗,比如对 Hook 链表的管理、对依赖的处理等等。随着 Hook 的增加,这些边际内容所占用的时间也会变得越来越大。

  • 但 Hook 有一点很强,在逻辑的复用上,是远高于 HOC 方式,算是扳回一局。

所以 Hook 确实慢,慢的有理有据。但究竟用不用 Hooks 就全看,我不做定夺。凡事都有两面,Hooks 解决了 Class 一些短板,但是也引入了一些不足。如果一定要我推荐的话,我推荐 Hooks+Mobx。

Refs

One More

以上内容是我花了快一个月一点点整理出来的,甚至还跨了个与众不同的「年」。性能测试本身就是一个很有争议的东西,不同的写法不同的测试方式都会带来不同的结果。我也是在这期间一点点修改我的测试内容,从最开始只有单组件测试,到后来添加了组件列表的测试,以及挂载的测试。另外对数据收集也修改了很多,比如多次取平均值,代码预热等等。每一次修改都意味着所有测试数据要重新测试,但我只是想做到一个公平的对比。

就在现在,我依旧会觉得测试里面有很多内容依旧值得去改进,但是我觉得拖的时间太长了,而且我认为把时间花在从源码角度分析为什么 Hook 比 Class 慢上远比用数据证明要有意义的多。

注释和共享

作为一个喜欢折腾的人,个人搞了很多东西放在自己的服务器上,但是为了方便,能够在世界各地随时随地的打开查看和使用,我将服务器暴露到了公网中,当然了有些在公有云上的本来就暴露出来的。

那么这里就有一个问题,我如何保护我的信息只能我来查看呢?

  • 最简单的方法就是通过 HTTP Basic Auth + HTTPS。记住一定要上 https,否则你的密码也是会泄漏的。为什么说简单呢?因为只需要在 Nginx 或 Traefik 上配置下就可以了。但是这个方案有一个非常麻烦的问题,就是过一段时间之后就要输入用户名和密码。时间短了,到无所谓,时间一长就会觉得很烦。

  • 构建一套 token 验证体系,不管是使用 oauth 也好还是 jwt 也好,都是可以的。安全性也是可以保证的,而且设置好 token 的时间长度,也能保证避免频繁的输入密码。但是这有一个问题就是实现起来太过于复杂,都快赶上公司的一套系统了。而且还要有各种登录页面,想想都烦。

  • 与上面类似,不过验证方式使用 Two Auth,也就是基于时间的 6 位数组。但是依旧比较复杂。

  • 使用 OpenVPN 的方式。这在一定程度上也能使用,但是对于我来说,OpenVPN 的限制还是比较大的。首先安卓手机无法开启两个 VPN,而且我也不能一直连着 VPN,因为我会部署一些经常用的服务。而且我不是为了能够连接到内网,而是想对外网使用的服务添加验证。

我想了许久,有没有一种不需要输入密码,就可以验证安全的呢?因为是我一个人使用的,所以我根本不需要多用户系统,也就是说验证方式只需要一个密码就可以了。这我突然想起了之前在写 gRPC 的时候有一个双向验证的参数,也可以验证客户端可以不可以。当时觉得只是他们基于 h2 改的协议,结果我一查发现这原来就包含在 https 里面,准确说是 SSL 规范里面。(怪自己当初上计算机网络的时候没好好学这部分,竟然连这个都不知道)

那么至此,思路就很清晰了,给我的所有个人服务都添加 https 客户端校验。只要我的证书够安全,我的页面就是安全的(反正都是我个人的东西,直接拿着 U 盘到处拷贝,手机 Pad 用数据线发送,我就不信这样谁还能盗走我的证书,傲娇脸)

关于 SSL 证书的一些知识

  • 生成证书我们主要采用 openssl 具体的安装教程我就不讲解了,有兴趣的小伙伴自行查阅,主要有下面几个步骤:

    • openssl genrsa:生成 Private Key,用于生成请求文件使用,这里用 .key 后缀。

    • openssl req:依赖上面生成的 Key 去生成 CSR,也就是证书请求文件。使用 .csr 后缀。这期间要填写一些信息,前面的几个大写字母是缩写,后面在命令行使用的时候会用到。

      • C(Country) 国家

      • ST(State/Province) 州或者省

      • L(Locality) 地区,国内写区即可

      • O(Organization) 组织

      • OU(Organization) 组织单位

      • CN(Common Name) 通用名,这个是非常重要的,影响了证书的显示名称和 HTTPS 的域名。

    • openssl x509:根据 x509 规范,利用 CA 的证书和私钥将 CSR 文件加密成真正可以使用到的证书。使用 .crt 后缀

  • SSL 证书必须要采用 sha-2 加密算法。2015 年 12 月 31 日前,CA 机构还会颁发 SHA-1 签名的证书,但是之后只会签发 SHA-2 签名的证书了。Chrome 也会对 SHA-1 签名的证书提示不安全。在 openssl 中用的是 -sha-256 参数。

  • CRTPEM 的关系,大家可以简单的认为 PEM 是将证书 base64 之后的文件,而 CRT 是既能 base64 也能 binary 的一种文件格式。但是通常 openssl 产出的是 base64 的文件,你可以通过 -outform 参数控制产出的类型。

CA 的生成

有了 CA 我们才能去给其他的证书签名,生成 CA 的过程很简单

创建根钥

💡 这个秘钥非常重要,任何获得了这个秘钥的人在知道密码的情况下都可以生成证书。所以请小心保存

1
openssl genrsa -des3 -out root.key 4096
  • -des3 标明了私钥的加密方式,也就是带有密码。建议添加密码保护,这样即使私钥被窃取了,依旧无法对其他证书签名。你也可以更换其他的加密方式,具体的请自行 help。

  • 4096 表示秘钥的长度。

创建自签名证书

因为是 CA 证书,所以没法让别人去签名,只能自签名。这里可以认为是生成 CSR 和签名两部合成一步走。

1
openssl req -x509 -sha256 -new -key root.key -sha256 -days 1024 -out root.crt

服务端证书生成

生成证书私钥

1
openssl genrsa -out your-domain.com.key 2048

和 CA 证书不同,这个私钥一般不需要加密,长度也可以短一些。

生成证书请求文件

1
openssl req -new -key your-domain.com.key -out your-domain.com.csr

这期间要填入一些信息,注意 CN 的名字一定要是你的域名。

使用 CA 对 CSR 签名

在 Chrome 58 之前,Chrome 会根据 CN 来检查访问的域名是不是和证书的域名一致,但是在 Chrome 58 之后,改为使用 SAN(Subject Alternative Name) 而不是 CN 检查域名的一致性。

而 SAN 属于 x509 扩展里面的内容,所以我们需要通过 -extfile 参数来指定存放扩展内容的文件。

所以我们需要额外创建一个 your-domain.com.ext 文件用来保存 SAN 信息,通过指定多个 DNS 从而可以实现多域名证书。

1
2
3
4
5
6
subjectAltName = @alt_names

[alt_names]
DNS.1 = your-domain.com
DNS.2 = *.your-domain.com
DNS.3 = *.api.your-domain.com

以此类推,如果域名较少,还可以用另外一种简写方案。

1
subjectAltName = DNS: your-domain.com, DNS: *.your-domain.com

关于语法的更多内容请查看官方文档。在有了 ext 文件之后就直接可以开始签名了。

1
openssl x509 -req -sha256 -in your-domain.com.csr -CA root.crt -CAkey root.key -CAcreateserial -out your-domain.com.crt -days 365 -extfile your-domain.com.ext

CAcreateserial 这个参数是比较有意思的,意思是如果证书没有 serial number 就创建一个,因为我们是签名,所以肯定会创建一个。序列号在这里的作用就是唯一标识一个证书,当有两个证书的时候,只有给这两个证书签名的 CA 和序列号都一样的情况下,我们才认为这两个证书是一致的。除了自定生成,还可以通过 -set_serial 手动指定一个序列号。

当使用 -CAcreateserial 参数之后,会自动创建一个和 CA 文件名相同的,但是后缀是 .srl 的文件。这里存储了上一次生成的序列号,每次调用的时候都会读取并 +1 。也就是说每一次生成的证书的序列号都比上一次的加了一。

现在,只需要将 your-domain.com.crtyour-domain.com.key 放到服务端就可以使用了。别忘了将 CA 添加系统当中,要不然浏览器访问会出现问题。

客户端证书生成

服务端有了之后,就需要生成客户端的证书,步骤和服务端基本一致,但是不需要 SAN 信息了。

1
2
3
4
5
6
7
openssl genrsa -out client.key 2048
# 这里也可以采用非交互形式,方便制作成命令行工具
openssl req -new \
-key client.key \
-subj "/C=CN/ST=Zhejiang/O=X/CN=*.your-domain.com" \ # 这里的缩写就是文章一开始所说的那些缩写
-out client.csr
openssl x509 -req -in client.csr -CA root.crt -CAkey root.key -out client.crt -days 365

只不过客户端验证需要的是 PKCS#12 格式,这种格式是将证书和私钥打包在了一起。因为系统需要知道一个证书的私钥和公钥,而证书只包含公钥和签名,不包含私钥,所以需要这种格式的温江将私钥和公钥都包含进来。

1
openssl pkcs12 -export -clcerts -in client.crt -inkey client.key -out client.p12

这期间会提示你输入密码,用于安装的时候使用。也就是说不是什么人都可以安装客户端证书的,要有密码才行,这无疑又增加了一定的安全性。当然了,我试过不输入密码,但是好像有点问题,有兴趣的同学可以自己尝试下。

客户端校验证书的使用

这里以 Node.js 举例。使用 https 模块,在创建的时候和普通的创建方式基本一致,但是需要额外指定 requestCertca 参数来开启客户端校验。

1
2
3
4
5
6
7
8
https.createServer({
key: fs.readFileSync('your-domain.com.key'),
cert: fs.readFileSync('your-domain.com.crt'),
requestCert: true,
ca: [fs.readFileSync('root.crt')], // 校验客户端证书的 CA
}, (req, resp) => {
// blahblah
})

这样只要客户端没有安装合法的证书,那么整个请求就是失败的。而且根本不会进入请求处理的回调函数中,这也意味着显示的错误是浏览器的默认错误。那么这对用户来讲其实不太友好。

那么我们可以通过在参数中添加 rejectUnauthorized: false 来关闭这个功能,也就是说不管客户端证书校验是正确还是失败,都可以进入正常的回调流程。此时我们只需要通过 req.client.authorized 来判断这个请求是否通过了客户端证书的校验,可以给予用户更详尽的错误提示。

另外我们还可以通过 resp.connection.getPeerCertificate() 获取客户端证书的信息,甚至可以根据不同的信息选择给予不同的用户权限。

这里有一个 DEMO: https://www.xgheaven.net.cn:3443,大家打开之后应该会看到一个证书校验失败的提示。这里要说下,我这里的 DEMO 没有使用自签名的服务端证书,只是使用了自签名的 CA 去检查客户端证书。因为用自己签名的服务端证书的话,浏览器会提示不安全,因为用户么有安装自签名的 CA。

可以点击下载客户端证书按钮,安装客户端证书。因为客户端证书是有密码保护的,请输入页面上提示的密码。

再次刷新,如果是 Mac 系统,会提示你要使用哪个客户端证书登录,此时就说明安装成功了。

点击确认,可能还要输入一个系统密码允许 Chrome 访问 Keychain,一劳永逸的话在输入密码之后选择 Always Allow,从此就不需要再输入密码了。

按照道理,你就可以看到这个页面了。

结语

有了这个功能,我就可以将我的所有内容全盘私有化而且还能直接暴露在公网中。配合之前毕设搞的微服务化,简直不要美滋滋。如果之前是使用账号密码登录的,也可以接入这个方案。就是将登录页面替换成证书校验就可以了。

Refs

注释和共享

XGHeaven

一个弱弱的码农


杭州电子科技大学学生一枚


Weifang Shandong, China