作者: meathill

  • 记一次被 AirTag 跟踪的经历

    记一次被 AirTag 跟踪的经历

    前些天,我们几家人一起去川西自驾游,旅行到第 5 天的时候,我突然收到一条推送:

    发现正在跟随您移动的 AirTag

    此物品的物主可查看其位置。轻点打开“查找”并查看可用操作。

    我是 TestV 的铁丝,对 AirTag 闻名已久,于是这条推送立刻引起了我的注意。我想了想,像我这种家里蹲,应该没人想追踪我。同游的亲戚里,倒是有社会关系比较复杂的;而且话说回来,如今这个年代,家里人互相丢个 AirTag 也不是不可能。

    于是我先找到老婆,本意是让她先找大家试试口风,没想到她立刻就跟所有人都说了,然后全家总动员,开始寻找陌生人放下的 AirTag。

    0. 关于 AirTag

    其实 AirTag 是个不小的发明。它本身不支持联网,无法使用 Wi-Fi 或者 4G/5G;不支持存储;也不支持定制发送的内容。这些“瘦身手段”使得它的构造可以尽量简单,体积小巧;尽量省电,一颗普通纽扣电池可以使用很长时间。

    AirTag 会定时向周围广播位置,只有苹果设备认得这些加密数据,它们收到广播后,如果处于联网状态,就会把这些信息转发给苹果的中心服务器。AirTag 的拥有者可以登录到苹果服务器查看 AirTag 的位置。

    AirTag 可以用来追踪物品,但是也可以拿来追踪人。

    1. 为什么我会收到推送

    正常情况下,这些数据会通过拥有者的设备转发(因为他们会和 AirTag 长期近距离接触);或者在开放区域,就会被一批随机设备转发。

    我们的情况比较特殊:我们在川西自驾,很长一段路程甚至连网络都没有,于是 AirTag 周围就只有屈指可数的几台苹果设备可以作为转发渠道。而这几台设备都不是 AirTag 拥有者的设备,当一个设备反复帮不是同一个 Apple ID 的 AirTag 转发位置信息后,就会触发苹果的风控系统,苹果就要通知 ta:

    小心,可能有人在跟踪你。

    2. 寻找 AirTag

    按照推送,打开”查找“,可以看到这个 AirTag 一路跟着我们跨域几百公里,从昨晚的酒店来到今天的酒店,甚至包含中间去做核酸的当地医院。基本可以排除偶遇同路,一定是有人故意放在车上、或者行李里。

    ”查找“可以让 AirTag 发出声音,于是我在几个房间转了一圈,确认无法连接。猜测 AirTag 应该在车上,于是我带着一群小朋友(其实也不小了)来到停车场,能连接的上,但是没有声音。那么只有两个可能:

    1. 喇叭被拆掉了
    2. 藏的比较深,声音传不出来

    为排除(2),大家开始翻行李。翻来翻去,翻去翻来,始终找不到,多半是喇叭被拆掉了。看来放 AirTag 的人处心积虑不想让人找到。眼看越来越晚,我就把大家拉回去了,今晚先放弃。

    我回去搜了半天,但没找到什么有价值的信息。能够帮被追踪者查找 AirTag 的方式,只有播放声音,或者蓝牙信号定位。前者拆了喇叭就白给,后者则要求环境可控,比如能够关闭或屏蔽大部分蓝牙设备,但现实中很难做到。如果是自己的 AirTag,苹果提供了非常丰富的定位手段;而别人的 AirTag,就没法使用。

    第二天,我尝试了前一晚积累的几个想法,都没成功。于是大家决定先放弃。大家都认为不会是追踪自己,多半跟原车主有关,为了接下来的旅程,就先不管吧。

    3. 答案

    回家之后,经询问,AirTag 是其中一位车主放在自己车上的,为的是好找车。至于为啥拆掉喇叭,没再细问。

    事实就是这么简单,跟吴啊萍一样。

    4. 想法

    这件事情之后,我对 AirTag 的不信任进一步加深了。

    1. AirTag 很小、很薄、很容易藏。攻防两方的地位不对等,被投放的人想找到 AirTag 很困难。
    2. AirTag 并不如设计的那般可靠,拆喇叭很容易,拆掉之后几乎没法寻找。
    3. AirTag 非常容易买到,没有持有成本。
    4. AirTag 的认知度非常低,大外甥告诉我,他两天前就接到过推送,但他根本不了解 AirTag,没多想就直接关掉了。

    如果有个人存心想追踪另外一个人,他可以用很低的成本(rmb 100+)了解到对方的行踪,对方可能长时间都不知道自己处于被跟踪状态。苹果应该设计更好的方案,或者增加 AirTag 的持有和使用成本。


    TestV 视频

  • 纯 CSS 实现优惠券效果

    纯 CSS 实现优惠券效果

    (本文不是广告,因为没给钱。)我厂 code.fun 上线了付费购买与优惠券功能,欢迎各位新老顾客莅临。

    上面是优惠券的视觉效果,本文分享如何使用纯 CSS 实现它,希望对大家有帮助。

    0. 分析

    首先,我们来分析一下这个优惠券的实现方案。

    左边是摘要,右边是详情,这个部分用 display:flex 很容易就能搞定。中间的虚线,使用任意一个容器边框 + 少许 padding 即可实现。其它部分,也就是些字体行高,都不是很复杂。难点在于投影,尤其是左右两个挖空的半圆。

    1. 挖空

    1. 首先,我们给整个优惠券矩形加上投影
    2. 然后,我们给两边加上两个包含内投影的圆形
    3. 这个时候,两边的内投影原型会多出来一块,我们需要把它们盖住。但是,不能让矩形 overflow:hidden,因为投影也会变,如下图。
    .coupon {
      width: 15rem;
      height: 6rem;
      background: white;
      box-shadow: 1px 1px 6px rgba(0,0,0,.15);
      position: relative;
      overflow: hidden;
      
      &::before,
      &::after {
        background-color: white; 
        border-radius: 1rem;
        box-shadow: inset 1px 1px 6px rgba(0, 0, 0, 0.15);
        content: '';
        width: 2rem;
        height: 2rem;
        position: absolute;
        top: 2rem;
        z-index: 1;
      }  
    
      &::before {
        left: -1rem;
      }
      &::after {
        right: -1rem;
      }
    }

    2. 增加父容器

    我的第一反应是增加父容器,让父容器 overflow:hidden 来隐藏多出来的部分。但是不行,会影响投影。

    但是转念一想,我们可以不让父容器限制显示内容,而是在父容器内部增加一些元素,遮蔽多出来的内容。比如用两个纯色圆形,把竖着的阴影遮起来。

    于是我把上面的样式更名为 .coupon-inner,然后增加一个父容器。父容器的 ::before ::after 伪元素都搞成略小一圈的纯色圆形,把竖着的投影挡住,最终效果如下图。

    3. 总结

    到这里,效果就基本让人满意了。

    最终完成的代码可以在 codepen 里看到:

    https://codepen.io/meathill/embed/oNqxOXE?default-tab=html%2Cresult&editable=true

    使用纯 CSS 的好处,在于体积小、加载快,调整起来非常灵活,能用 CSS 最好都用 CSS。

    有任何问题和建议,欢迎评论、讨论。

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

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

    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. 总结

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

  • MongoDB 里实现多表联查

    MongoDB 里实现多表联查

    前些天遇到一个需求,不复杂,用 SQL 表现的话,大约如此:

    SELECT *
    FROM db1 LEFT JOIN db2 ON db1.a = db2.b
    WHERE db1.userId='$me' AND db2.status=1

    没想到搜了半天,我厂的代码仓库里没有这种用法,各种教程也多半只针对合并查询(即只筛选 db1,没有 db2 的条件)。所以最后只好读文档+代码尝试,终于找到答案,记录一下。

    1. 我们用 mongoose 作为连接库
    2. 联查需要用 $lookup
    3. 如果声明外键的时候用 ObjectId,就很简单:
    // 假设下面两个表 db1 和 db2
    export const Db1Schema = new mongoose.Schema(
      {
        userId: { type: String, index: true },
        couponId: { type: ObjectId, ref: Db2Schema },
      },
      { versionKey: false, timestamps: true }
    );
    export const Db2Schema = new mongoose.Schema(
      {
        status: { type: Boolean, default: 0 },
      },
      { versionKey: false, timestamps: true }
    );
    
    // 那么只要
    db1Model.aggregate([
      {
        $lookup: {
          from: 'db2', // 目标表
          localField: 'couponId', // 本地字段
          foreignField: '_id', // 对应的目标字段
          as: 'source',
      },
      {
        $match: [ /* 各种条件 */ ],
      },
    ]);

    但是我们没有用 ObjectId,而是用 string 作为外键,所以无法直接用上面的联查。必须在 pipeline 里手动转换、联合。此时,当前表(db1)的字段不能直接使用,要配合 let,然后加上 $$ 前缀;连表(db2)直接加 $ 前缀即可。

    最终代码如下:

    // 每次必有的条件,当前表的字段用 `$$`,连表的字段用 `$`
    const filter = [{ $eq: ['$$userId', userId] }, { $eq: ['$isDeleted', false] }];
    if (status === Expired) {
      dateOp = '$lte';
    } else if (status === Normal) {
      dateOp = '$gte';
      filter.push({ $in: ['$$status', [Normal, Shared]] });
    } else {
      dateOp = '$gte';
      filter.push({ $eq: ['$$status', status] });
    }
    const results = await myModel.aggregate([
      {
        $lookup: {
          from: 'coupons',
          // 当前表字段必须 `let` 之后才能用
          let: { couponId: '$couponId', userId: '$userId', status: '$status' },
          // 在 pipeline 里完成筛选
          pipeline: [
            {
              $match: {
                $expr: {
                  // `$toString` 是内建方法,可以把 `ObjectId` 转换成 `string`
                  $and: [{ $eq: [{ $toString: '$_id' }, '$$couponId'] }, ...filter, { [dateOp]: ['$endAt', new Date()] }],
                },
              },
            },
            // 只要某些字段,在这里筛选
            {
              $project: couponFields,
            },
          ],
          as: 'source',
        },
      },
      {
        // 这种筛选相当 LEFT JOIN,所以需要去掉没有连表内容的结果
        $match: {
          source: { $ne: [] },
        },
      },
      {
        // 为了一次查表出结果,要转换一下输出格式
        $facet: {
          results: [{ $skip: size * (page - 1) }, { $limit: size }],
          count: [
            {
              $count: 'count',
            },
          ],
        },
      },
    ]);

    同事告诉我,这样做的效率不一定高。我觉得,考虑到实际场景,他说的可能没错,不过,早晚要迈出这样的一步。而且,未来我们也应该慢慢把外键改成 ObjectId 类型。

  • 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,不知道为啥。

  • 永别两位奶奶

    4月中,老婆的奶奶去世了,我们一起回重庆奔丧。

    老婆家很重视葬礼,在殡仪馆设下灵堂,请来道士诵经,还要守夜上香。我们回去的晚,负责守最后一夜。晚上烧最后一道纸时,大家聊起小时候,在老房子里几代人挤在一起的旧事,原本清苦的记忆,竟然也十分令人怀念。尤其讲到我老婆小时候的糗事,一群人哈哈哈大笑,感觉火苗都窜高了几分(考虑到人身安全,具体内容我就不写了)。

    守到第二天早上,出殡、火化、下葬。老婆家颇为讲究,怎么跪、几点钟上香、去火化时开几辆车都很有讲究。不过也好在请了道士,我们配合行动即可,倒也省心。

    回到家,我已将近 30 个小时没合眼,倒头便睡。后来跟老婆聊起来,觉得这样治丧很累,略有微词。


    没想到两个月后,我的奶奶也去世了。那天我照常出门遛狗,顺便买早点。到面包店要了面包拿出手机付钱时,发现爸爸早上发来的信息,原来奶奶已经去世了。

    老婆的奶奶是在家里睡去的,走得很安详。遗体告别的时候,感觉就像睡着了一样,和平时没太大变化。我奶奶前几年突然中风,然后身体一下跨了下去,从一个富态老太太一下变得干瘦。这几年虽然病情略有起伏,但已经完全没有生活质量可言。这下离世,对她对我爸兄妹几人来说,都是好事。

    然而我还是止不住的哭了半晌。

    我爸是老大,一直想给弟妹们做表率,不愿意麻烦爷爷奶奶。所以我小时候和姥姥家这边更亲近,小学快毕业才,去爷爷奶奶家的频率才增多起来。但是我也很喜欢爷爷奶奶家,更城市化、更自由,还有堂哥一起玩。后来,最亲我的姥姥在我初一时就离世了,我去奶奶家的次数就越来越多了。

    我一直觉得大家对我都挺好的,在奶奶家我也收获了很多快乐,尤其是打麻将,虽然我经常都输的很惨。奶奶做饭也挺好吃的,尤其是饺子和红烧肉,可惜最近十年都没怎么吃到了,将来再也吃不到了。

    用我妈的话说,奶奶是个享福之人,没受过什么罪也没吃过什么苦。最后这几年可能是她最难熬的一段时间,如今终于熬到头了,也为她的解脱感到开心。


    最近看朱学恒的直播,聊到台湾疫情,聊到 24 小时火花,终于知道葬礼是怎么回事。

    抛开那些故意敛财的不说,大部分葬礼其实是活人内心的治愈过程。至亲好友突然离世,任何人都很难接受。葬礼,则是反复告诉活人:死者虽然离开了我们,但 ta 去往的地方并非险恶,ta 未来的生活也会和谐美好,离开对 ta 来说会是一件好事;所以我们不需要担心 ta,只要好好继续生活即可。

    回想起在重庆,守夜时候大家聊天,我觉得这种说法很有道理。奶奶的葬礼定在五七,到时候回去跟她告别,希望她在另一个世界可以继续享福。

  • 使用 Vite 建立灵活的外部仓库

    使用 Vite 建立灵活的外部仓库

    0. Vite 与 ESM

    与 Webpack 不同,Vite 以 ESM 为其唯一的模块管理规范。首先,在开发环境,它会把每个文件编译成独立的 ESM 模块,实现非常快速的热加载。其次,编译打包时,它默认的目标环境是 ES2019,支持 ESM,所以模块打包后,也会使用 ESM 加载。

    这给我们带来一个好处:如果用 Vite 开发项目,并且对其进行分包,构建之后放到线上(比如发布到 NPM);接下来我们就可以在其它项目中,使用 ESM 方式加载这个项目的代码。

    举个简单的例子。lodash 有一个同步发布的 lodash-es 包,功能完全一致,只是使用 ESM 构建,我们可以直接在代码中 import forEach from 'https://unpkg.com/lodash-es@4.17.21/forEach.js' 引用。一方面可以节省我们自己的带宽;另一方面如果用户在其它应用里使用过同一个库,就可以提高速度。

    1. Vite 分包

    Vite 把构建过程委托给 Rollup,所以构建时分包需要传参给 rollupOptions

    在某个项目中,我需要整合一批 codepen 上的绘图效果,这些效果都放在 /src/effects/ 目录下,所以我就要检查这个目录,并且生成对应的分包配置。

    export default defineConfig(async () => {
      // 从 v10.10 开始,node.js 的 `fs.readdir()` 函数支持 `withFileTypes` 参数,使用这个参数可以直接返回 `fs.Dirent` 对象,类似使用 `fs.stat()` 得到的 `fs.Stats` 对象。方便我们判断对象类型
      const files = await readdir(effectsDirectory, {withFileTypes: true});
      // 把目录下的内容分为两类,一个是基础类库,一个是不同特效
      const [effects, baseFiles] = files.reduce(([effects, base], file) => {
        const {name} = file;
        if (file.isDirectory()) {
          effects.push(name);
        } else if (file.isFile()) {
          base.push(name);
        }
        return [effects, base];
      }, [[], []]);
    
      return {
        build: {
          rollupOptions: {
            manualChunks(id) {
              // effects/some-effect 下的文件按目录分别打包
              const effect = effects.find(effect => id.includes(`/${effect}/`));
              if (effect) {
                return effect;
              }
              // 效果基类打包成一个文件,因为效果只需要基类,所以从主体剥离
              const baseFile = baseFiles.find(base => id.endsWith(base) && !/p5/i.test(id));
              if (baseFile) {
                return 'BaseEffect';
              }
              // p5 是个很大的效果库,官方不提供 esm 包,只能单独打一个
              if (/p5/i.test(id)) {
                return 'p5';
              }
              // 其它依赖正常打包,只在本项目中使用,不会被引用
              return id.includes('node_modules') ? 'vendor' : 'chuck';
            },
          },
          // 这个第3节会解释
          target: 'es2020',
        },
      },
    }
    

    2. 去掉文件名中的 hash

    Vite 很贴心的帮我们给生成的文件都加上了 hash。在独立项目中,给文件名加 hash 可以有效避免缓存问题;但是作为外部仓库的话,无法确定的 hash 会增加业务项目的开发难度,所以我希望构建时输出到特定版本号的目录里,然后去掉文件名中的 hash。

    这个操作同样需要修改 rollupOptions。rollup 有三个不同的选项分别处理不同的命名,这里我们可以忽略入口文件(entryFileNames),只改剩下两个。

    export default defineConfig(() => {
      return {
        build: {
          rollupOptions: {
            output: {
              // 资源文件,包括 css
              assetFileNames: 'assets/[name].[ext]',
              // 分包文件
              chunkFileNames: '[name].js',
            },
          },
        },
      };
    });

    3. 动态加载 CSS

    使用 Vite 开发时,我们同样可以在代码里 import 样式等非 JS 素材。构建时,Vite 会把它们处理后放在合适的地方。

    可惜的是,Vite 并不会帮我们自动加载分包后的素材。需要我们手动处理。这时就要利用 import.meta.url,它会返回当前模块的 URL,配合前面的的文件名策略,我们就可以完成动态加载,而不需要业务项目的开发者手动处理。

    但是 Vite 默认的版本基线是 ES2019,并不支持 import.meta.url,所以我们需要把 build.target 设置成 ES2020 或以上。

    let isCssLoaded = false;
    
    // 只有未加载且处于生产环境才加载 css。
    if (!isCssLoaded && __IS_PROD__) {
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      // 这一步非常重要,因为 vite/rollup 有 bug,会把 `import.meta.url` 翻译成 `self.location`,导致出错
      const baseUrl = import.meta.url;
      link.href = new URL('./assets/particle-orb.css', baseUrl).toString();
      document.head.appendChild(link);
      link.onload = () => {
        isCssLoaded = true;
      }
    }

    4. 总结

    新的技术选型总能给我们带来新的可能,ESM 之后,我们在项目之间复用代码也有了新的选择,赶紧用起来吧。

    如果你在使用 Vite 或者 ESM 时遇到什么问题,欢迎提问。如果有什么经验,也欢迎分享。

  • Vite 项目里启动 PWA

    Vite 项目里启动 PWA

    很简单,使用 vite-plugin-pwa 插件,Antfu 出品,品质保证。零配置,简单易用。

    0. 安装插件

    pnpm i vite-plugin-pwa -D

    1. 启动插件

    修改 vite.config.ts

    import { VitePWA} from 'vite-plugin-pwa';
    import { definePlugin } from 'vite';
    import vue from '@vitejs/plugin-vue';
    
    export default definePlugin(({ command }) => {
      const isDev = command === 'serve';
      return {
        plugins: [
          vue(),
          new VitePWA({
            disable: isDev, // 开发环境不启动 pwa
            includeAssets: [
              // 非直接加载,但是需要预缓存的内容
            ],
          }),
        ],
      };
    })

    2. 可脱机提示及可更新提示

    原则上来说,Vite、Vite 插件都是开发脚手架,不限定框架。不过我用的最多的还是 Vue。这里以 Vue3 为例示范一下如何使用插件快速实现 PWA 组件:

    1. pwa 完成缓存后,提示可脱机使用
    2. 线上版本更新后,提示有新版本可用
    3. 更新时,给出视觉反馈
    <script setup lang="ts">
    import { useRegisterSW } from 'virtual:pwa-register/vue'
    import {ref} from "vue";
    const {
      offlineReady,
      needRefresh,
      updateServiceWorker,
    } = useRegisterSW({
      immediate: true,
      onRegistered(r) {
        // 每小时自动检查一次,是否有新版本
        r && setInterval(async() => {
          await r.update()
        }, 60 * 60 * 1000)
      },
    });
    const isRefreshing = ref<boolean>(false);
    function doRefresh() {
      isRefreshing.value = true;
      updateServiceWorker();
    }
    const close = () => {
      offlineReady.value = false
      needRefresh.value = false
    }
    </script>
    
    <template lang="pug">
    .pwa-toast.fixed.right-4.bottom-4.p-3.border.border-gray-200.rounded.bg-white.z-index-10.shadow-md(
      v-if="offlineReady || needRefresh"
      role="alert"
    )
      p.message.mb-2
        span(v-if="offlineReady") App ready to work offline
        span(v-else-if="isRefreshing") Refreshing...
        span(v-else) New content available, click on reload button to update.
      button.border.border-gray-200.rounded.py-1.px-2(
        v-if="needRefresh",
        type="button",
        :disabled="isRefreshing",
        @click="doRefresh",
      )
        .spinner(v-if="isRefreshing")
        template(v-else) Reload
      button.border.border-gray-200.rounded.py-1.px-2.ml-2(type="button" @click="close") Close
    </template>

    3. 一些坑

    1. PWA 会拦截所有请求,以便缓存到本地。所以,打开网站,注册完 service worker,再请求其它文件,比如 ads.txt,也可能会看不到。这并不影响广告,因为广告商服务器不会受 PWA 影响;但是广告商运营人员可能只会操作浏览器,她们可能会认为你的广告文件没准备好。此时,请告诉她们使用匿名窗口。
    2. PWA 会自动预缓存 dist 目录内的东西,所以一定要注意 build.emptyOutDir,不要让目录过分膨胀,影响新用户体验。
    3. 有新版本后,上面的组件会提示用户刷新,但是刷新过程可能很慢(清理缓存,下载新内容等),所以点完之后可能没有反应。所以最好加上 spinner。

    4. 总结&扩展阅读

    整体来说,这个插件很好用,没什么特别需求的话,几乎可以零配置。

    建议感兴趣的同学好好阅读下 官方文档,尤其是 examples 目录里的内容,会有很大帮助。

  • 使用 Vite JavaScript API 构建多语言静态网站

    使用 Vite JavaScript API 构建多语言静态网站

    静态化真是爽,不仅操作简单,还有很多羊毛可以薅,比如 Vercel、Digital Ocean、Cloudflare,除去开发成本,运维支持成本几乎为零。我用 Vite 搭建了一个静态网站,然后需要多语言,最简单的做法就是多编译几次,输出不同语言到不同目录。我实操了一下,大体上还算顺利,略有小坑,分享一下。

    Vite JavaScript API

    Vite 除了命令行工具,还提供了插件 API、HMR(模块热加载)API、JS API,方便开发者从各种角度去丰富 Vite 的生态和使用场景。

    这里我们要使用的就是 Vite JavaScript API。从 官网 来看,build,即我们要用到的构建功能,也是开放的,很好。

    这个 API 支持一个参数,即 vite config,然后就能完成构建。不过我实测这里并不能使用 defineConfig() 方法,不知道是否与我的使用方式有关,相关的范例代码也不多、文档也不详细,就先这么着吧。

    Demo code

    经过调试后,完成的代码如下:

    for (const language of languages) {
      // 一些 SEO 相关属性
      const {
        title = '',
        description = '',
        content = '',
      } = data[language];
      if (!language) {
        continue;
      }
      const config = {
        // 注意,`configFile` 属性非常关键,如果不设为 false,vite 还会加载默认配置文件 vite.config.js
        configFile: false,
        root,
        base: language === 'en' ? '/' : `/${language}/`,
        build: {
          // 这个属性也比较关键,不设置的话,vite 会自动清理掉其它语言
          empty: false,
          outDir: resolve(root, language === 'en' ? './dist' : `./dist/${language}`)
        },
        define: {
          // 放一些根据语言自定义的变量
        },
        plugins: [pugPlugin.default({}, {
          title,
          description,
          lang: language,
          version,
          content: marked.parse(content),
        })],
      };
      await build(config);
    }

    注意事项写在上面的代码里了,大家留意一下。

    TailwindCSS 及其它工具

    TailwindCSS 会在当前工作目录(即 cwd)里查找配置文件。如果你像我一样,把构建文件放在 build 目录里,执行的时候可能就会报错,说 TailwindCSS 找不到配置文件。

    此时,只能通过 NPM script 比如 npm run build 执行 ./build/build.js

    总结

    慢慢适应 Vite 之后,我开始逐步把个人小项目向 Vite 迁移。新技术的体验提升很大,不过文档和范例的确有所欠缺。

    下一步要尝试 vitest。

  • 试用 PayPal

    试用 PayPal

    因缘际会,PayPal 里有一些美金,提现手续费很贵,所以尝试使用 PayPal 购物,看看效果。

    直接付款

    1. 【成功】付费 GitHub。我现在是 Evan You 的 sponsor,$5/m,付费顺畅,自动扣费,没问题。
    2. 【失败】充值 Vultr。我的 PayPal 绑了朋友的账号,那个账号用 Yandex 邮箱注册的,长时间不用被回收了,现在没法解绑……所以失败了,不知道该说是谁的问题。
    3. 【失败】YouTube 买碟。国内视频网站竟然没有《千与千寻》,刚好 YouTube 推送,我就想买一个好了。结果付费失败,错误信息告诉我购买区域与 PayPal 所在区域不符,不知道是什么原因。
    4. 【失败】支付给他人。两个中国账户之间不能互转。
    5. 【中止】steam。国区账号没有 PayPal 入口。美区可以买,但是好贵,而且跟国区优惠方式不同,所以没继续。
    6. 【成功】抖内,付费给非中国账户,很顺利。
    7. 【成功】Vultr 的服务器是 Ubuntu 16.04,我用 do-upgrade-release 升级了几次到 18.04,后来就没升过大版本,早就想升级。于是尝试换到 digital ocean。换的过程倒是挺顺利,但是 do 亚洲只有新加坡和班加罗尔,速度都不理想,最后选了美国 SFO,速度明显变差了。凑合一个月,不行下个月找 Vultr 客服解绑然后回归 Vultr。
    8. 【成功】域名续费。域名在 namecheap 上,续费很顺利。但是域名本身似乎没带来过任何收益,有点不想续了……
    9. 【失败】购买 zimaboard。付款的时候失败了,显示为:因为国际法规云云。

    土澳账号

    想办法搞了个土澳账号,尝试了以下方式:

    1. 中国转土澳,必须走商家收款,收手续费,大约 4%
    2. 钱要 21 天才到账,避免退款,艹
    3. 土澳转出,可以走“亲朋好友”,实时到账,无手续费
    4. 【成功】等了若干天,终于到账,尝试购买 zimaboard,成功。

    其它尝试进行中,随时补充。