分类: 技术

各种开发心得,包括语言、软件工程、开发工具等

  • 聊聊日本的互联网开发工作

    聊聊日本的互联网开发工作

    2016 年,携点厂余威,我给全家办下来日本五年自由行的签证。接下来以每年一两次的频率去日本旅行,直至疫情爆发。日本是个典型的发达国家,基础建设水平极高,商品质量服务质量也都很好,我们一家都很喜欢,于是萌生了去日本生活的想法。2019年,O 厂遭遇瓶颈,我正好在 V2ex.com 看到一则日本公司的招聘启事,于是先在线聊了一下,然后约定下次去日本的时候拜访一下。

    最后没谈妥,薪资是一方面,还有些其它方面的原因,今天就来分享下。先声明,我没有在日本长期生活过,基本上是旅客视角,最多算是为潜在移民可能做过一些功课,以下内容可能存在不少错误,仅供参考。


    0. 日本的移民政策

    了解最近几十年日本历史的人都知道,从上世纪 90 年代至今,日本经历了失去的三十年,经济停滞、生活水平停滞,各种排名被不断超越。另一方面,医疗水平高,生育意愿低,老龄化严重。所以日本的移民政策很奇葩:五年入籍,十年永居(PR)。

    即只要在日本生活五年,就可以选择加入日本国籍,成为日本公民;但要生活十年,才可以获得永久居留权。相对来说,新加坡只要六个月就可以申请 PR。

    我们知道,我国不支持双国籍,要么是中国人,要么是外国人,所以想长期居留日本,要考虑清楚。

    1. 日本的生活

    日本是上一代基建狂魔,基建暴多,什么山嘎啦里面都有铁路、公路。市里更不用说,东京地铁可以连通整个东京都生活圈。整体来说生活便利,物资充沛,价格合理。

    不过作为发达国家,日本的生活标准比较高,生活费也比较贵,衣食住行的价格无不高昂。

    • 衣。日本上班需要穿正装,工作日大街上全是职业装的男女,周末好一点,一半一半。所以相对来说衣服要投入不少钱。
    • 食。吃东西很贵,一兰拉面 800、900,约合人民币 50(按之前的汇率),居民区的背街小巷拉面也得 500。
    • 住。住倒是跟国内一线大城市差不多。
    • 行。日本地铁基本进站 200,远一点 700、800 很常见,公共通勤一天也要 百多块人民币。打车更别提了,3km 80 CNY。

    所以同样的工资,日本的生活压力更大。

    2. 日本的职业收入结构

    中国互联网人高工资,因为互联网渗透率高。日本是老龄化社会,年轻人少,社会发展相当停滞,比如最近的孤独美食家里,五郎叔还在用翻盖手机;去年日本疫情爆发的时候,日增 300,不是只有 300 人感染,是因为政府的传真机一天上限只能传 300 个单子。

    互联网企业很难在日本形成规模效应,也很难作为测试市场。日本是很大的单一民族国家,有自己独立的语言、独立的历史、各种独特的生活习惯,在日本做出来的成功产品,很难移植到其它国家;反之亦然。在新西兰、澳大利亚做产品,虽然本国只有几百几千万人,但是可以比较容易的扩展到英语世界去;而日本就做不到。

    于是日本互联网人的工资也不高,跟其它服务业差不多。比如运转士,即大巴司机,资深者 800w/年,和高级前端工程师相仿;便利店门口的招工启事,折算过来普通工差不多 8kCNY/月。所以可以想像,大家都是高收入,那么生活费用高企也很正常。

    3. 日本的社会结构

    我们挑了个工作日去迪士尼,希望游客会少一些,没想到遇到大量学生模样的人也在里面逛。后来面试的时候聊了一下,了解到:因为老龄化+生育率低下,社会阶层固化严重,很多人既没有生活压力、也没有向上的动力。不需要努力,家里也有足够的资产供消费;即使努力了,可能也没办法获得阶级跃迁或改变自己的家庭。所以干脆躺平,想玩就玩,逃课去迪士尼也稀松平常。

    我不做道德层面评价,只是觉得在这样的社会,互联网可能真的没什么机会,因为互联网的功能就表现在提效,显然很多日本人不需要效率。

    4. 其它生活方面

    还有一些移民的普遍问题,比如:买房、上学、医疗、对外地人的歧视等等,因为是共性问题,就不说了,各国各地各有千秋,都不会太容易,但是也没什么特别难的地方。

    5. 总结

    最后,工资没谈拢,我觉得我值更多钱,而且搬家需要很多钱,但是他们转型中也需要很多钱,我们的需求并不匹配。另外我关注日本就业市场之后,也觉得不太合适,就放弃了去日本的想法,直至今日。

  • MSI X399 Creation 升级 BIOS 并安装 Windows 11

    MSI X399 Creation 升级 BIOS 并安装 Windows 11

    前几天例行换电脑来用,想起来好久没升级 BIOS 了,就上官网看了眼,发现 MSI 发布了 X399 Creation 的最新 BIOS ROM,虽然是 beta 版,但号称支持 Windows 11,就赶紧下载下来升级。踩了一些坑,记录一下。

    下载最新固件

    从官网下载最新固件,解压后放入 U 盘备用。U 盘需要是 FAT 格式,建议文件名用纯英文,放入根目录,避免出问题。

    我已经记不清最近一次买 U 盘是什么时候了,家里的 U 盘几乎都坏了,有些被我刷了 Linux 启动盘。折腾了半天,终于找到一个能用的。

    刷入 BIOS

    重启,反复敲击 Del 键,直至进入设置页面,选择 M-Flash,找到 ROM 文件,刷机。刷机过程比较顺利,但要等一会儿,不能断电不能拔 U 盘。刷完之后自动重启,然后,我的启动硬盘就认不出来了……

    准备 Windows 11 安装盘

    我的系统盘历史比较悠久,最早应该可以追溯到 Windows 7,然后通过 ghost、升级,直至今天。所以引导方式还是 MBR。刷完 BIOS 之后,只认 GBT,就没法启动了。

    系统盘上没什么特别重要的数据,所以我想干脆重装吧,于是开始准备安装盘。去微软网站下载制备程序,插入 U 盘开始制备(需要至少 8G)。几个小时之后,安装盘准备就绪。

    用安装盘启动系统,不知何故,提示找不到驱动程序,且不告诉我是什么驱动找不到,没办法,只好想办法升级 MBR。(感谢推友提供线索)

    转换 MBR

    同样使用安装盘启动系统,进入维护模式,打开命令行。

    输入 mbr2gbt /validate,验证是否可以转换 MBR 到 GBT。返回有错误,仔细看错误信息,不知道为啥,默认检查的是 Disk 3,为啥是 3?我也不知道,我的系统盘是 3 么?

    输入 diskpart 进去分区工具,然后输入 list volume 显示磁盘分区列表,找到我的系统盘,编号是 4。看来刚才检查的不对。

    输入 exit 退出分区工具,输入 mbr2gbt /validate /disk:4,校验通过。接下来输入 mbr2gbt /convert /disk:4 启动转换,成功。

    改变启动顺序

    重启进入 CMOS 配置,还是找不到 Windows 系统盘。在选项里徘徊了好几遍,发现新 BIOS 把几个 UEFI 启动设备合并到了一起,启动顺序里只能选“硬盘 > U 盘“,然后在硬盘里面,再选择”Windows > Linux“,真奇怪。

    搞定之后,启动,回到熟悉的 Windows 10 界面。

    升级到 Windows 11

    下载 Windows 11 安装器,结果一波三折又一折。刷 BIOS 前,我曾经用 Windows Health Checker 检查过系统,当时告诉我没有 TPM2,所以不能装 Windows 11。没想到它还把信息写入系统,现在升级完 BIOS 还是不给我装。

    而且因为我安装过 Windows Health Checker,再官网点下载会自动跳到系统更新界面,不给我下……以前的安装包早就删了。

    还好我从下载记录里找到 Windows Health Checker 的下载链接,重新下载,运行,检查通过。回到安装器,终于开始安装了。安装过程倒是挺快,也很平滑,很快就完成了。不过 Windows 11 竟然默认用宋体,好丑。

    总结

    这个过程持续了很久,从我刷 BIOS 到最后升级完成,可能有 3、4 天时间。最后虽然完成,但是仍有一些问题:我装了两条 16G 内存,CMOS 里也能看到插槽被占用,但就是只认 16G,不知道为啥。

  • WebRTC 笔记

    WebRTC 笔记

    最近研究了一下 WebRTC,写篇笔记记录下。

    0. WebRTC 简介

    WebRTC 是一种 p2p 技术,它可以在两个不同的浏览器之间建立直连,让它们互相传输数据、视频和音频流。

    这个技术可以充分利用用户自己的上行带宽和网络环境,降低中心服务器的负载,既提升用户体验,又能降低服务提供者的成本。用在一些低成本的场景非常合适,比如视频会议、轻型联网游戏、内容共享网络等。

    由于其低成本的优势,我想用它给 mywordle.org 升级,支持用户 1v1 对战。将来的话,还可以搞一些 FC 模拟器玩玩。

    1. WebRTC 基本概念

    WebRTC 的概念不少,初次连接也很复杂,有些我现在还没完全搞清楚。所以这个部分以后再慢慢更新。另外这里的内容并不是按着规范走的,而是来自我的实践。

    1.1 基本概念

    1.1.1 信令服务器

    这可能是 WebRTC 里最重要的一个概念,每个浏览器 tab 页都是网络中的一个孤岛,必须通过信令服务器才能找到对方,建立连接。

    信令服务器是必须的,不能通过人工方式完成两个节点的互联(或者说很难)。但是信令服务器并不一定要自己建,有不少公共的可以蹭。

    1.1.2 Ice 服务器 RTCIceServer

    同一个局域网内连接很简单,但实际上并不常见。我们日常使用的网络,无论移动网络还是家里的宽带,其实都是位于 NAT 后面,相当于总机分机的概念。两个 NAT 后面的应用想连接就比较困难了,此时就需要 RTCIceServer 帮忙,协商在两个 NAT 上打洞。

    具体的实现逻辑我们不用关心,只要会用就行。目前有两类 RTCIceServer:STUN 和 TURN。前者只负责给双方牵线搭桥,本身不介入连接,有很多公共服务可以蹭;TURN 不光能把两个端连接起来,还能在两端中间网络不通的时候作为 fallback 方案。功能更强,真正生产级别的产品都需要;但是相应的,TURN 需要更高的资源支撑,免费资源也很少。

    1.2 发起连接

    当用户 A 想要跟用户 B 建立 WebRTC 连接时:

    1. A 创建一个 RTCPeerConnectioin 对象
    2. 创建一个 offer,其中包含着 A 的网络信息,其它人通过这个信息可能找到 A
    3. A 把 offer 发给信令服务器
    4. 信令服务器把 offer 发给用户 B
    5. 用户 B 记录下 offer,然后生成 answeransweroffer 其实是一样的,只是用来作为响应。这也是必须有信令服务器的原因。
    6. B 把 answer 发给信令服务器
    7. 信令服务器把 answer 发给 A
    8. A 尝试用 answer 建立连接,如果是局域网,双方可能已经连上了
    9. 如果连不上,则 A 尝试通过 ice server 连接。
    10. A 创建自己的 icecandidate 信息,然后发给信令服务器
    11. 信令服务器将 A 的 icecandidate 发给 B
    12. B 添加后,也创建自己的 icecandidate,发给信令服务器
    13. 信令服务器将 B 的 icecandidate 发给 A
    14. A 添加之,并尝试创建连接
    15. 如果一切正常,这个时候就连上了。

    2. 实操

    我建立了一个项目:meathill/webrtc-playground: learn webrtc (github.com),目前还在升级开发中。

    1. 实现一个 websocket 服务器(基于 socket.io),作为信令服务器,交换信令
    2. 用各种姿势尝试建立连接
    3. 同浏览器多 tab 连接成功
    4. 内网连接成功
    5. 手机开热点,尝试公网连接,也成功
    6. 尝试跟朋友连接,失败。遇到两个问题:
      1. 他家的网络是广州联通,我家是广州电信
      2. 他的手机网络是北京联通
      3. 目前的信令服务器会无条件广播各种信息,当我跟他都多开 tab 的时候,很难保证连接的两端是匹配的
    7. 于是接下来要重构。

    3. 总结

    这次学习过程一波三折。首先,WebRTC 的用户大部分关注音视频传输,毕竟这方面效果最明显;DataChannel 其实只算个添头,偏偏我最关注这个,所以找内容花了不少时间。

    接下来,大部分范例代码都只是抄来抄去,一个 tab 内部来回连,经常会看错。另外,一些概念也不清不楚,比如 ice server,无法主动触发请求。

    终于调通了内网和公网,又遇到联通电信问题……看来将来 TURN 服务器也必不可少。

  • 聊聊当年我设计的“新-用户轨迹产品”

    聊聊当年我设计的“新-用户轨迹产品”

    突然想聊聊当年在 201 做的用户轨迹追踪产品,也算是自己当年产品工程能力的体现吧。

    0. 需求

    大约在 2010 年,作为最大的 IT 资讯垂直门户,201 需要进一步理解用户行为,要增加数据统计的维度和数据挖掘的深度。

    当时的 Google 统计刚刚引入热力图(这里我记不准,可能不是“刚刚”),可以统计用户在页面的交互动作,显示用户最关注哪个区域,跟哪个区域交互最多。见下图,越热的地方,就是用户越关心的地方。

    201 产品部门也想用这款产品,不过面临几个问题:

    1. 需要使用第三方工具,数据安全性存在疑虑,也担心将来很难整合其它数据
    2. 当时普通用户还是 IE 为主,浏览器性能很差,201 的页面本身消耗资源就不少,加入更多统计可能会影响到用户
    3. 热力图的结果其实是可以预期的,如上图;如果差太远,那就是出了问题。所以,要不要用一个大概率没什么用的产品呢?
    4. 自己开发的话,统计量很大,预估实际点击和有效点击可能有 10 倍左右的差距,对我们的统计服务器也是很大的负担。

    1. 现有用户路径统计

    当时我们已经基于服务器 access log 打造了一款用户路径统计产品。我们都会给 每个用户分配一个 sesssion id,当他们访问网站的时候,记录下网页 URL 和 session id,后面就可以分析访问日志,得到每个用户的访问路径。比如:首页 > 手机 > 苹果专区 > iPhone 13,等。

    负责这套系统的朋友找到我,让我帮忙做一套前端工具,给产品部门使用。最初的前端界面很简单,输入一个页面地址,搜索,得到一连串 URL,然后产品经理一个一个点过去看。我接手后,很自然地,将其改造成发散图:

    1. 从输入的节点取出所有以此 URL 为开始的用户路径
    2. 整理节点,按照访问数量排序
    3. 用线路粗细表示用户的数量
    4. 点击 URL,可以跳转到 URL 或者以 URL 进行筛选

    上线后,效果良好,产品经理可以很轻松的看出用户的流向,辅助他们做调整页面的决策。他们给予我们高度好评,并且深入讲述了他们的其它需求,包括前面提到的,更详细记录用户轨迹的需求。

    接下来我开始思考这个问题的解决方案。

    2. 解决方案

    功夫不负有心人,我想到一个方案:

    1. document.body 侦听用户的点击事件,判断点击的目标,如果是链接,则记录下链接的坐标
    2. 然后将坐标记录在 cookie 里面,cookie 会随着 http 请求发给服务器
    3. 生成访问日志时,记录对应的被点击链接的坐标
    4. 根据坐标生成热力图

    这套方案有几个好处:

    1. 完全不需要修改目标页面,也不需要其他部门同事配合,只需要在统计代码中加入几行代码
    2. 不增加页面负担,用户不会感知到任何变化
    3. 不影响其它统计,多出来的 cookie 经过压缩,只需要几个字节

    后来,我们又做了页面快照、合并排重等,用很小的成本,实现了不亚于第三方的热力图方案。还可以跟我们其它统计数据做整合,得到更丰富的数据视图。

    比如,201 的页面有很多广告,这些广告会使内容的位置有变动,普通热力图无法区分这些变动,导致热区不准。而我们可以根据广告尺寸、快照记录等,把不同的点击区域合并到一起,提供更加准确的用户流向。

    3.评价

    当时的产品总监给予这套产品很高评价,说它至少可以提升全站访问量的 5%。

    (更多…)
  • 在 GitHub Actions 里使用 Lighthouse 和 Cypress

    在 GitHub Actions 里使用 Lighthouse 和 Cypress

    GitHub 给我们每人每月 2000 分钟的 Actions 免费额度,可以用来跑 CI,不好好利用就太浪费了。正好最近想学学 Cypress,于是就拿前面说的 mywordle.org 项目练手,加上了自动化 Lighthouse+Cypress。下面主要分享过程,希望对大家有帮助。

    0. 开启 GitHub Action 并完成 hello world

    点击项目里的 Actions 选项卡,如果该项目之前没有 workflow,就会自动进入创建 workflow 的界面。

    项目首页
    新建 workflow

    建议直接选择 node.js 模版,可以节省很多配置成本。因为免费额度只有 2000分钟/月,所以我建议只保留一个 node.js 版本;又因为我要用 pnpm 管理依赖,所以改造后的配置是这样的:

    name: Node.js CI
    
    on:
      push:
        branches: [ master ]
      pull_request:
        branches: [ master ]
    
    jobs:
      build:
    
        runs-on: ubuntu-latest
    
        strategy:
          matrix:
            node-version: [16.x]
    
        steps:
        - uses: actions/checkout@v2
        - uses: pnpm/action-setup@v2.2.1
          with:
            version: 6.32.2
        - name: Use Node.js ${{ matrix.node-version }}
          uses: actions/setup-node@v2
          with:
            node-version: ${{ matrix.node-version }}
            cache: 'pnpm'
        - run: pnpm i
        - run: npm run build --if-present

    这个阶段我只需要确保代码可以完成构建就行了,没有运行更多脚本。

    1. 使用 Lighthouse 校验 CLS

    Lighthouse 是 Google 提供的网页评价工具,可以对网页的众多指标进行打分,帮助我们提升网页的运行效率和用户体验。CLS=Cumulative Layout Shift,即页面初始化阶段,布局变化的统计值,变化越多值就越大,页面抖动越厉害,评分就越低。

    据说这个值很影响 Google 对页面的评价,所以我们必须时时关注。

    首先,使用 pnpm 安装 Lighthouse:pnpm i lighthouse -D

    我们还需要静态服务器提供网页服务。如果你仔细看前面的配置文件,会发现这个环境基于 Ubuntu 搭建。在配置里增加一行 - run: nginx -v,确认镜像里集成了 nginx 1.18,那就好办了,添加 mime.types 和 nginx 配置文件:

    pid logs/travis.nginx.pid;
    
    events {
        accept_mutex off;
    }
    
    http {
    
        server {
            access_log logs/travis.access.log;
            error_log logs/travis.error.log warn;
    
            listen 9000;
    
            include mime.types;
    
            location / {
                alias dist/;
                # 下面这行是 SPA 的关键
                try_files $uri $uri/ /index.php$args;
            }
        }
    }

    放到 workflow 里跑一下,报错了:

    Run nginx -c conf/travis.conf -p `pwd`
    nginx: [alert] could not open error log file: open() "/var/log/nginx/error.log" failed (13: Permission denied)
    2022/03/18 09:38:08 [emerg] 1870#1870: open() "/var/log/nginx/access.log" failed (13: Permission denied)
    Error: Process completed with exit code 1.

    因为 Nginx 一定要把全局错误日志放在 /var/log/nginx/error.log,而又没有权限,所以报错。解决方案有两个:

    1. 升级到 nginx 1.19,便可以使用 -e 参数自定义全局错误日志的路径。不过这意味着我们每次都必须更新镜像,毫无疑问会浪费宝贵的免费额度。基本放弃。
    2. 确保 nginx 可以操作这个文件,这可能需要 root 权限。

    在配置文件里随便加一行 sudo touch /var/log/nginx/error.log,顺利完成,说明我们拥有 root 权限。于是加入以下配置,完成 nginx 配置:

        - run: sudo chmod -R 755 /var/log/nginx
        - run: sudo touch /var/log/nginx/error.log
        - run: sudo chmod 777 /var/log/nginx/error.log
        - run: nginx -c conf/travis.conf -p `pwd`

    接下来就简单了,使用下面的命令可以调用 Lighthouse 给网页打分,并且将数据输出到 lighthouse.json,然后我们写个脚本分析即可:

    lighthouse --chrome-flags="--headless --disable-gpu --no-sandbox" --output json --output-path=lighthouse.json http://localhost:9000

    2. Cypress

    接下来搞 Cypress,时间因素我就不详细介绍 Cypress 的用法了,他们官网有很详细的视频,虽然是英文的,不过我觉得大概也看得懂。建议大家先看一下,了解个大概:Installing Cypress | Cypress Documentation

    这次我打算添加两个测试:过关,和失败。

    尝试 Cypress 的过程并不顺利,主要原因是 Cypress 的语法设计有点难懂。比如下面两行代码:

    cy.visit('http://localhost:9000/');
    cy.get('#header').should('contain', 'Wordle Unlimited');

    看起来它要完成两步操作,其实不然。Cypress 只是暂时把这两步操作存入操作队列,未来满足某个条件才会执行。所以,我们不能将其它操作插入这些步骤中间。比如我的游戏数据存在 localStroage 里,我希望在网页打开后校验这些数据,于是我就后面读取 localStorage,但怎么也读不到。正确的做法是在第二句的后面用 .then(() => {}) ,然后把操作放在里面。

    哦,对了,基于同样的原因,Cypress 的测试函数也不能是 Async Function。

    不知道如果将来需要做条件判断或者循环的话,应该怎么写。

    接下来,只要配置对应的 action 即可。我选择使用官方的 cypress-io/github-action@v2,虽然我也不知道它比自己写多了些什么,不过我觉得能省事总是好的。需要注意的是,因为我使用了 pnpm,所以我不需要官方配置里的 install 步骤。

    3. 使用环境变量

    有时候,我们的项目依赖一些第三方工具,这就需要我们能够在构建或测试的时候配置第三方工具的鉴权方式,比如 access_key,secret_key 之类的东西。很显然,我们不能把这些鉴权信息入库。(实际上,随便搜一搜,能找到大量这么做的代码。)所以我们需要用别的方式来配置这些信息。

    最常见的做法是环境变量。比如我们配置一个 WX_PAY_PRIVATE_KEY,然后我们就可以在代码中使用 process.env.WX_PAY_PRIVATE_KEY。GitHub Actions 可以使用 env: 配置环境变量,但是这些变量一样不适合入库,所以我们需要配置安全信息。

    配置位于上图所示的地方,添加完之后,就可以在配置文件中使用:

    - name: 步骤
      env:
        AIRTABLE_API_KEY: ${{ secrets.AIRTABLE_API_KEY }}
      run: 命令

    4. 完整配置文件

    name: Node.js CI
    
    on:
      push:
        branches: [ master ]
      pull_request:
        branches: [ master ]
    
    jobs:
      build:
    
        runs-on: ubuntu-latest
    
        strategy:
          matrix:
            node-version: [16.x]
    
        steps:
        - uses: actions/checkout@v2
        - uses: pnpm/action-setup@v2.2.1
          with:
            version: 6.32.2
        - name: Use Node.js ${{ matrix.node-version }}
          uses: actions/setup-node@v2
          with:
            node-version: ${{ matrix.node-version }}
            cache: 'pnpm'
        # setup local server
        - run: pnpm i
        - name: build repo
          env: 
            AIRTABLE_API_KEY: ${{ secrets.AIRTABLE_API_KEY }}
          run: npm run build --if-present
        - run: mkdir logs
        - run: sudo chmod -R 755 /var/log/nginx
        - run: sudo touch /var/log/nginx/error.log
        - run: sudo chmod 777 /var/log/nginx/error.log
        - run: nginx -c conf/travis.conf -p `pwd`
        # run lighthouse
        - run: pnpm add lighthouse -g
        - run: lighthouse --chrome-flags="--headless --disable-gpu --no-sandbox" --output json --output-path=lighthouse.json http://localhost:9000
        - run: node tools/lighthouse.js
    
        - name: Cypress run
          uses: cypress-io/github-action@v2
          with:
              install: false

    贴上完整配置,供各位参考。

    5. 总结

    实际体验下来,大约花了 60 分钟,每次运行大约两分半钟,看起来一个月 2000 分钟额度还是蛮充足的。

    建议大家都学习一下,CI/CD 是现代化开发的基础设施,可以大大提升我们的开发效率。Lighthouse 是非常重要的网站评价工具,Cypress 可能是现在最好的 UI e2e 测试工具,结合这几个工具可以保障我们的网站始终可用,始终好用。

    如果你看过我的上一篇文章《在 Code.fun 做 Code Review》,其实偿还技术债最好的办法就是写自动化测试,因为只有自动化测试才能告诉你重构有没有引入新问题、能不能上线,优化有没有成功;以及,能否推进下一步重构与优化。

  • 在 Code.fun 做 Code Review

    在 Code.fun 做 Code Review

    0. Code Review 简介

    Code Review,翻译成中文应该是“代码评审”,是软件开发中非常重要的一个环节。简单来说,就是 A 做完一个功能后,发给 B 审查;B 确认代码符合规范、没有明显的质量问题后,才允许合并入主干,以及上线。

    Code Review 有很多好处:

    1. 提升代码质量,维护代码规范。
    2. 传承知识。Code Reviewe 是非常好的查缺补漏机会,可以针对性补强开发者的知识盲区,纠正不良习惯。
    3. 找出潜在 bug。一个人可能会考虑不周,但多个人同时犯错的几率就会小很多。
    4. 降低安全风险。如果开发者知道自己的代码会被多人审查,那 ta 多半就不会主动引入漏洞和后门。

    那么,Code Review 怎么进行呢?实际上,一次提交少则几十行,多则几百上千行代码,想短时间内完全看明白是很困难的。负责审查代码的人自己也有工作,不可能投入太多时间在看别人代码上。所以,真正的 Code Review 并不要求完全弄明白对方的代码,一般重点在下面几个方面:

    1. 确保开发者遵守了规范
    2. 确保开发者提供了测试用例,且测试用例能覆盖需求场景
    3. 确保代码中没有明显的问题

    1. Code Review 实操

    接下来,我就结合最近两周的 Code Review 工作,分享一下相关经验,希望对大家有所帮助。

    1. 不该使用入口文件

    我司之前很喜欢把组件全 import 到一个入口文件里,然后全部 export。在需要的地方 import { someComponent } from '@/component'。这样做的坏处是,如果想使用路由 lazy-loading,Webpack 打包的时候,很难判断哪些组件是当前路由需要的,哪些是不需要的,导致大部分组件都被打包进入口文件,影响启动速度。

    正确的做法是哪里用组件哪里引用,不要多此一举。

    2. 乱用自定义事件

    截图中的组件是个 <el-input>,写代码的同事在面前给 <el-form> 加上了 submit.native.prevent,侦听并阻止原生 submit 事件;然后在 <el-input> 里侦听用户按下回车键事件,以提交表格。

    这也是很不好的方式。原生的、规范的事件和操作,一般都已得到众多浏览设备的支持,包括浏览器、屏幕阅读器、各种 IoT 设备,等。如果我们禁用原生事件,自己定义新的事件处理函数,可能在我们自己的设备+浏览器里运行正常,在更丰富的环境里则随时有失败的风险。

    3. Boolean 属性应使用单一属性值

    这是个规范问题,Boolean 属性都应该使用单一属性值,遵守 HTML 规范。

    4. 尽量不要写死 style

    我们要适配的设备很多,分辨率千奇百怪,写死的属性值可能在部分环境下运行正常,但是在其它环境下就会失败。再来,CSS 和 JS 的环境是分离的,很难共享数据,很可能 CSS 如果要配置 dark mode、响应式、打印策略时会遭遇问题。

    所以,当我们需要条件渲染时,建议:

    1. 写类名。于是 CSS 可以修改类的属性值。
    2. 写 CSS Variable。CSS 里用表达式来调整。

    5. 谨慎处理循环

    Code Review 时一定要特别注意循环的处理,这里是最容易出问题的地方。比如图上这段代码,应该先筛选再映射(.filter().map()),而不是现在这样。那样的话,可以大大节省 map() 的执行时间。

    6. 仔细查文档,不要对付

    window.postMessage 需要两个参数,但是我的同事只想传一个,但是会过不了 TypeScript 校验,于是他就把 window as any 了。这就是不该偷懒的地方偷懒 。

    2. Code Review 的深度,以及我们应当遵守什么规范

    因为大量其它代码有类似……的做法,可以等之后统一修改验证

    看到我的 changes request 之后,同事这么回复我。其实这也是一个常见问题:已知我们的代码没有严格遵守规范,存在不少历史积累的技术债,我们应当怎么处理?

    有些同事说,我们有一些代码是按照老规范写的,如果我们现在按照新规范,就会在代码仓库里留下两个风格的代码,这样是不是更有问题?

    我的观点是,我们要把新规范分为几个层次:

    1. 有很大优势。那我们应该尽快完成代码升级、重构,把这些优势带到我们的产品当中。
    2. 有明显优势,但可能没大到值得延后交付去处理。我们应该在新代码中使用这些规范、在触及老代码时顺手修改,不需要担心两种规范的冲突。截图中大部分都在这个级别,我们应该开始改进,但不需要刻意追求覆盖率。
    3. 优势不明显。那就可以只在新代码中使用,旧代码可以留着不改。
    4. 几乎只是风格问题。那应该在采纳成新规范时做决策。

    3. 总结

    以上是我在我司最近两周作 Code Review 的经验分享,希望对大家有所帮助。质量和效率、如何还技术债,是软件开发领域永恒的话题,我以后也会继续在这方面展开分享。如果诸位读者老爷有什么想问的、想说的,敬请在评论区与我互动。

    我现在在 code.fun 工作,我们的产品会把设计稿自动转换成代码,期望大大提升前端的开发效率,如果你对这个产品感兴趣,也可以联系我。

  • 代码分享:翻转小动画

    代码分享:翻转小动画

    朋友对我图省事用的 animate.css flipInX 效果不满意,软磨硬泡非要我改成 wordle 原版那种整个翻过来的。于是我就想办法实现了一把,比想象的稍微复杂一些,难点在于我不知道 transform-style: preserve-3d 这个属性。没有这个属性,正反两个图层就被压在一个平面里,怎么翻转都是上面那个图层显示出来。

    我用来调试的代码实现请点 这里,为了方便调试,我这次没用 codepen,用的 Vue SFC Playground,效果挺好,尤其是适合不熟悉属性时一边试一边写。不知道 codepen 是否支持这种玩法。

    实现的代码与之类似:

    <template lang="pug">
    .game-item
      .face G
      .face G
    </template>
    
    <style lang="stylus">
    .game-item
      aspect-ratio 1
      position relative
      // 这个样式很重要,没有它就没有背面一说
      transform-style: preserve-3d;
      user-select none
    
    .face
      position absolute
      top 0
      left 0
      width 100%
      height 100%
      display flex
      justify-content center
      align-items center
    
      &:first-child
        // 把第一层稍微提高一点点,不能太多,不然旋转效果不好看
        transform translateZ(0.1px)
    
      &:last-child
        // 背面提前翻转 180 度,这样转过来才是对的
        transform rotateX(180deg)
    
    @keyframes flip
      from
        transform: perspective(400px) rotate3d(1, 0, 0, 0)
    
      to
        transform: perspective(400px) rotate3d(1, 0, 0, 180deg)
    
    .flip
      animation-name: flip
    </style>

    这种效果平时还是找时间写上一两次,CSS 属性是知识性内容,没办法凭经验推出来,平时得注意积累。

  • 浅尝 Monorepo

    浅尝 Monorepo

    最初听说 Monorepo,是群里的同学问我是否了解 lerna,我还真没听说过,于是去学习了一下。简单来说,就是把好多个软件放在一个大型仓库里一起管理。

    0. Monorepo 简介

    为方便灵活使用,我们一般会把软件包拆散,每个小包只负责某个特定功能,通过组合完成复杂功能。这个时候我们有两个选择:

    1. 每个包放在独立仓库,独立管理、独立发布,然后通过 NPM 等包管理工具安装互为依赖。
    2. 所有包放在一起,统一管理,直接内部引用。即 Monorepo。

    我之前在 OpenResty 一直使用前者,而很多开源项目都选择后者,比如 vue-cli。它的仓库里包含了 vue-cli 主体和大量插件,还有测试套件等。

    这样做有几个显而易见的好处:

    1. 这些软件包通常高度耦合,彼此之间功能关联紧密。A 软件 X 版本依赖 B 软件的 Y 版本,B 软件的 Y 版本又依赖 C 软件的 Z 版本。统一管理切换环境更轻松。
    2. 统一管理依赖,既能减少磁盘占用,也可以保证开发环境统一。
    3. 没有编译、没有黑盒,所有源代码对项目成员公开,遇到问题分析调试都容易很多。

    我觉得在 Google 这样的技术流公司这么搞没问题;在崇尚奉献和高参与质量的开源项目里这么搞也很好,但是对很多技术水平一般,自我要求很低的软件公司这么搞就是乱来了。

    1. 问题

    以我曾经短暂工作过的XX办公(为保护当事人隐私我隐去了“金山”二字)为例,使用 monorepo 带来了几个未曾预料的问题:

    1.1 版本管理混乱

    所有产品代码都放在一个仓库里,数个不同的团队同时进行开发,每天会产生大量的 commit。负责合并分支的人既不懂业务也不懂版本管理,基本就是点一下合并按钮,于是大量未经 rebase squash 的 commit 被用 merged 方式合并入主干。整个提交历史混乱不堪毫无价值。

    同时由于采取 merge 方式,仓库里存在大量分支,git 无法判断版本之间的先后关系,也无法自动解决冲突,于是几乎每两三天就遇到冲突无法合并,需要开发人员解决冲突。

    1.2 代码质量参差不齐,且互相影响

    monorepo 好的一方面是代码对大家公开,所有人都可以学习其他人的写法,互相帮助解决问题;坏的一方面是坏的代码影响的也是整个项目仓库。

    有些同学用 VS Code,这本身不是问题,但 ta 们不研究配置、不研究插件,于是代码中很多低级错误——甚至因为低级错误太多,反而不会报错。比如有个事件侦听函数,参数传进来是 e,但是函数体里用的是 event。结果竟然没有报错,也能通过测试(人肉)。我找到开发人员让他改,他发现竟然有一个全局 event 变量,不知道是谁在哪里带进去的……

    【2022-09-20 更新】这里还真触及到我的知识盲区。这是早期 JS 的兼容性设计,即事件触发时 window 上也会有个全局变量 event,主要方便大家乱来。没想到救了 XX 办公。

    1.3 技术栈升级困难

    架构升级我就不谈了,这东西见仁见智,也不是能经常干的事情。这里说的主要是工具链。

    如,eslint、错字检查、安全性检查。好比说,我发现部分代码存在风格问题,希望引入 eslint 检查,并且把 eslint 加入到集成环境里。按说这个想法并不复杂,这些工具都不影响整体架构,只需要外挂到 pre-commit 之类的钩子上即可。但此时必须征得整个开发团队几百号人的同意,那就不是一句话能解决的。

    于是打开 WebStorm,海量的臭气(smell)扑面而来,难以直视。

    1.4 代码互相耦合

    原本 monorepo 只是方便大家阅读代码、维护代码。但是难免会有人滥用,直接引用、导入别人的代码,或者把自己的代码写入别人的目录里。

    比如我想优化构建过程,希望能拆成 ES6 和 ES5 两个版本(因为厂里明确要求支持 ES5 的只有文档、表格、幻灯片)。然后我发现,虽然名义上大家各自维护自己的目录,但实际上跨目录引用比比皆是。简单按照目录区分基本不可能。

    2. 总结

    当然,我数落这些问题,不是想说 monorepo 不好。分散项目独立管理也会有别的问题,比如技术栈不统一、集成困难、人员变更后难以交接继承等。每种技术都会有适合的场景,各位技术决策者也应该从自己的团队实际出发,选择最适合自己的技术选型。

    现在技术领域各种声音分外吵杂,很多公司会把技术方案作为一种宣传方式,名为推广技术实践,实则宣传自己的团队和品牌。如果不加区分盲目追随,就很容易掉进坑里。

    (更多…)
  • 加入 Code.fun

    其实入职已经两周了,突然发现还没写博客,所以水一篇。

    被金山优化后,我开始找工作,过程还算顺利,并没有因为年龄因素遭遇直接的否决,大家都是头脑清醒的职业人士,不至于被几篇公众号带走脑子。

    但是结果并不理想,毕竟年纪和资历摆在这里,需求找到足够大的坑,比如技术负责人。但是大部分经营顺利愿意招聘的企业,岗位上大多已有合适的人选,招我进去多半是开发组技术经理这样没那么高阶的岗位,待遇就很难谈拢。

    这才是大龄程序员的真正难题:有些能力不够强,与年轻人相比竞争力不足;或者能力够强,但是没有合适的岗位安排。

    Anyway,最后我决定加入光速软件,我们的产品是 Code.fun,一款将设计稿转化成代码的软件,可以对接 Sketch、figma、Photosthop 等设计软件,直接导出 React、Vue、小程序 等代码,帮助前端开发者快速完成任务。

    光速也是技术型创业公司,很能满足我的技术追求,也能够给我足够大的发挥空间,希望我的加入能给双方带来一段美好的合作经历。

    对我厂产品感兴趣的同学可以联系我。我厂也在招人,有志于打造一款效率产品的同学也欢迎。

  • 笔记:使用 Vite+Vue3+TypeScript+Tailwind CSS 开发 Wordle

    笔记:使用 Vite+Vue3+TypeScript+Tailwind CSS 开发 Wordle

    最近有个小游戏很火,叫 Wordle,是个填字游戏,推上随处可见相关分享。各种衍生版也层出不穷。有位朋友让我帮他复刻,反正过年,闲着也是闲着,我就尝试用标题里的新技术栈帮他写了一个。现在已经上线了,对填字游戏感兴趣的同学可以试试:

    My Wordle(特色:1. 支持自定义单词;2. 5~9字母;3. 无限模式,可以一直玩。)

    这篇文章简单记录一些开发过程和经验。

    0. 创建 vite 项目

    npm init vite@latest

    接下来按照提示选择喜欢的配置即可。

    小配置下 vite.config.js,添加 @ 别名:

    // 为了方便后面开发,改为函数式,这样获取状态更容易
    export default defineConfig(({command, mode}) => {
      const isDev = command === 'serve'; // 这里先留个例子,后面骨架屏有用
      return {
        server: {
          host: true, // 允许外部访问,方便手机调试
        },
        plugins: [
          vue(),
        ],
        resolve: {
          alias: {
            '@': resolve(__dirname, './src'),
          },
        },
      };
    });

    注意,上面的配置文件虽然是 ESM,但实际上仍然在 CommonJS 环境里执行,所以有 __dirname

    然后安装依赖:pnpm i

    最后 npm run dev 即可启动开发服务器进行开发。Vite 真是快,空项目 100ms 就启动了。

    1. 安装+配置 Tailwind CSS

    pnpm i -D tailwindcss postcss autoprefixer
    npx tailwindcss init -p

    配置 tailwind.config.js

    module.exports = {
      content: [
        "./index.html",
        // 注意:因为我喜欢用 pug 开发模版,所以一定要在这里添加 `pug`
        "./src/**/*.{vue,js,ts,jsx,tsx,pug}",
      ],
      theme: {
        extend: {},
      },
      plugins: [],
      // 这部分 css 需要通过比较复杂的计算得出,所以要用 `safelist` 保证它们出现在最终 css 里
      safelist: [
        {
          pattern: /w-\d+\/\d+/,
        },
        {
          pattern: /grid-cols-[5-9]/,
        },
      ],
    }

    接下来创建 CSS 入口文件:./src/style/index.css

    @tailwind base;
    @tailwind components;
    @tailwind utilities;

    最后在入口文件里引用它即可:

    import { createApp } from 'vue'
    import App from './App.vue'
    import './index.css'
    
    createApp(App).mount('#app')

    2. 配置 TypeScript

    默认的 TypeScript 就挺好使,这里简单配一下 @ 别名即可:

    {
      "compilerOptions": {
        "paths": {
          "@/*": [
            "./src/*"
          ]
        }
      },
    }

    为开发方便,还需要装一些类型定义:

    pnpm i @types/gtag.js @types/node -D

    3. 配置 eslint

    安装所需依赖:

    pnpm i -D @typescript-eslint/eslint-plugin @typescript-eslint/parser @vue/eslint-config-typescript eslint eslint-plugin-babel eslint-plugin-vue

    配置 .eslintrc.js

    module.exports = {
      "env": {
        "browser": true,
        "es2021": true,
        "node": true
      },
      parser: "vue-eslint-parser",
      parserOptions: {
        parser: "@typescript-eslint/parser",
        sourceType: 'module',
        ecmaVersion: 'latest',
      },
      extends: [
        'eslint:recommended',
        'plugin:vue/vue3-recommended',
        '@vue/typescript/recommended',
      ],
      plugins: [
        'babel',
        'vue',
      ],
      globals: {
        // vue3 <script setup> 工具函数
        withDefaults: true,
        defineProps: true,
        defineEmits: true,
        defineExpose: true,
    
        // 我自己定义的常用类型
        Nullable: true,
        Timeout: true,
    
        $root: true,
      },
      "rules": {
        // vue3 <script setup> 暂时必须关掉这几项
        '@typescript-eslint/no-unused-vars': 0,
        '@typescript-eslint/ban-ts-comment': 0,
        '@typescript-eslint/no-non-null-assertion': 0,
      },
    }

    4. 拆包与懒加载

    Wordle 游戏需要用到词库,5个字母的词库 200+KB,6789的词库都是 300KB,全部放到一起加载太浪费,所以计划按需加载。另外,词库几乎是不变的,但业务逻辑是多变的,如果不拆包的话,每次业务逻辑变更后,用户都要重复下载好几百 KB 的词库,所以要做拆包与懒加载。

    拆包方面,vite build 是基于 rollup 实现的,所以要按照 rollup 的要求,用 rollupOptions 来配置:

    export default defineConfig(({command, mode}) => {
      return {
        ....
        build: {
          rollupOptions: {
            output: {
              manualChunks(id) {
                // fN 是高频词,出题时只用高频词,校验单词时才用全量词库。高频词独立打包。
                if (/[sf]\d\.txt\?raw$/.test(id)) {
                  return 'dict';
                // 分享文案,为方便朋友修改,独立打包
                } else if (/share\d\.txt\?raw$/.test(id)) {
                  return 'share';
                // 其它依赖,打包成 vendor。不知道为什么,必须有这一配置,前面两项才能生效
                } else if (id.includes('node_modules')) {
                  return 'vendor';
                }
              },
            },
          },
        },
      };
    });

    默认只加载高频词,全量词库会在页面准备就绪后,通过 fetch API 异步加载。

    5. 添加骨架屏

    网页里有个静态 footer,因为 CSS 文件加载需要一些时间,所以会先渲染出来,然后再挪到正确的位置,造成抖动。这里有两个选择:1. 隐藏 footer,或挪到 vue 里;2. 添加加载中样式。我选择在(2)上再发展一点,做骨架屏。

    我的思路是:

    1. <div id="app"></div> 里填充只有标题的 header 和填字格
    2. 拆分 tailwind.css,分成
      1. 只包含 reset 和 index.html 所需的 inline css
      2. 包含全部业务样式的外部 css 文件
    3. 编写脚本,build 时往 index.html 里塞入填字格,并把 inline css 写入网页 <head>

    实现方面,首先编辑 vite.config.js

    export default defineConfig(({command, mode}) => {
      // 根据开发、构建环境,使用不同的 tailwind 入口文件
      const tailwind = command === 'serve' ? 'tailwind-sketch.css' : 'tailwind.css';
      return {
        resolve: {
          alias: {
            '@tailwindcss': resolve(__dirname, './src/style/' + tailwind),
          },
        },
      };
    });

    我从 tailwind.css 里移除了 @tailwind base,即 reset 部分。这部分会被 inline 到 index.html 里。然后修改 tailwind.config.js,加入根据环境判断是否需要分析 index.html 的逻辑:

    const isDev = process.env.NODE_ENV ==='development';
    module.exports = {
      content: [
        './src/App.{pug,vue,ts,tsx}',
        './src/**/*.{vue,js,jsx,ts,tsx,pug}',
      ].concat(isDev ? './index.html' : []),
      theme: {
        extend: {},
      },
      plugins: [],
    }
    

    接着修改 build 脚本,增加编译 inline css 和注入功能:

    {
      "name": "aw-woodle-game",
      "version": "0.4.0",
      "scripts": {
        "build": "vue-tsc --noEmit && vite build && npm run build:sketch && npm run build:sketch2 && build/build.js",
        "build:sketch": "tailwind -i src/style/tailwind-sketch.css -o tmp/sketch.css --content index.html --config build/tailwind.config.sketch.js --minify",
        "build:sketch2": "stylus --include-css --compress < src/style/sketch.styl > tmp/sketch2.css",
      },
    }

    6. 总结

    最终我使用这套工具链完成了整个产品的开发。不过仍然有一些问题未解:

    1. Vite 开发环境与构建环境其实是两套工具链,所以能过 dev,未必能过 build,尤其是 TypeScript,会有很多差异。不过只要耐心调试一番,也不难搞。
    2. 不知道是否跟我用 pug 有关,Tailwind 总是慢一拍。即我改了模版,html 变了,但对应的样式并没有出来;过一会儿,就出来了。于是我要么继续改,期待后面它会加上来;要么只有手动刷新。
    3. build 时,rollup 会把 css 放在 <head> 里面,导致浏览器认为这段 CSS 是关键内容,加载完之后才进行第一次渲染(FCP),使得我的骨架屏失效,所以要手动把它挪到 <html> 的最后。(当然在 GA 等代码的前面)

    最终的 Lighthouse 得分比较喜人,贴一下:

    (更多…)