跳到主要内容

开遍英国每一条路

· 阅读需 2 分钟

screenshot.jpeg

《极限竞速:地平线4》买了几年,断断续续加起来70h+了,是我目前玩得最久的游戏。我对游戏的热情并不高,很难想象游戏社区那些一个游戏动辄数百小时的玩家怎么做到的...

大多数时候我都只是开着我最爱的保时捷四处转悠,不参加比赛也不完成任务,今天突然发现只剩最后几条公路没有去过了,于是打开并不宏大的世界地图,四处搜寻没有走过的道路。地图的大部分地方我都有印象,但闲逛的时候还是经常有新的体验,打破一些个人记录啦,发现新的车房宝物啦,碰到遗漏的广告牌啦(我一般不会刻意去找)。大路熟悉,小路陌生,风景优美的地图,在同一个地方用同样的姿势漂移翻车。在引擎的轰鸣声中或冲刺或慢摇,开过高山,城市,和大海,这个游戏总让我感到一些生活的艺术性。

今天开遍了游戏地图的每一条公路,成就弹出来的时候有种恍如隔世的感觉,地平线5已经入库了,地平线6也上架了,我还在地平线4流连忘返,还有多少地方我没去过呢。

我是在Steam平台游玩的,地平线5多了手柄扳机震动,体验更好,地图也更大了,还有中文配音,只是最好的永远是初见。

做了个图片压缩小工具

· 阅读需 2 分钟

写博客时不可避免要上传图片,我现在的图片都上传到部署在家里小主机的minio服务。自己当家才知道财米油盐贵呀,每一MB空间都会增加备份负担和流量压力,动辄数MB的图片也很考验服务器带宽。但其实我并不关注图片的局部细节,只是想有个花花绿绿的图而已。

由于我的图大多都是手机拍的,我希望直接在手机上完成压缩。找了现存的图片压缩app,要么臃肿肥大参数繁杂,要么有广告、订阅,我只是想把相册的图变小一点,仅此而已。

github找了一圈没有特别对眼缘的,但我找到了Curzibn/Luban,一个模仿微信的智能压缩库,很符合我的要求,不用自己调参数,不过它是纯工具库,没有app,于是我自己撸了一个鲁班压图

luban_imager.webp

功能单一,打开图片,压缩,保存,仅此而已。

另外推荐一下Flutter,开发安卓app很丝滑,不像ReactNative那样各种奇怪的小问题,而且包体积很膨胀。这个app也有十几MB了,怀念以前几百KB的小app〜

绣球

· 阅读需 2 分钟

某天晚上心情不好,去公园散步。回来的时候路边看到一个巨大的“花球”,很圆,由一朵朵小花组成。我忍不住拍了拍,很紧凑,有弹性。

后来和朋友说起,她说可能是绣球。可绣球哪有那么大的?那朵花球一个人怀抱都抱不住。但其它方面确实很接近。她发了个视频给我,是一株脸盆大的绣球花。尽管我看到的还要更大,但我接受了她的说法。

S60516-15501218_com.ss.android.ugc.aweme.png

今天送猫去绝育,心里好奇,故地重游,再寻巨型绣球花。印象里的路段都走遍了,却没有找到绣球花的踪影。公园里很多人跳交际舞,音乐声嘈杂着交流声,和夜晚的静谧完全不一样。我感到一阵恍惚,难道其实是在梦中遇到的?

我再次搜索路边任何球状的植物。然后我看到了这个。

P20260516-144616.jpg

我无法确定这到底是否是昔日之物,但除此之外再无球形植物。花了好一会我终于接受:我把人工修剪的灌木当成花了。

给用了9年的剃须刀换电池

· 阅读需 2 分钟

大学时买的飞利浦PQ190剃须刀,已经9年了,电池垂垂老矣,充满电用一两次就刮不动了。想重新买一个,又感觉它好好的扔了挺可惜。我为什么不能自己换个电池呢?

想到就做,先拆开看看电池什么样。结构比我想象中简单,一块电路板,一个马达,一块7号充电电池。麻烦的是电池是焊在电路板上的,需要用到烙铁。我有点犹豫,会不会太麻烦了?不过早就想玩焊接了,趁着兴奋劲激情下单了电烙铁和新电池。

第一步是把旧电池拆下来,先用电烙铁融掉引脚上的锡(图是换好之后才拍的):

P20260513-153724(1).jpg

锡真的很好融诶,但是把它弄下来好难!刚关掉烙铁电源,它又“干”掉了,只能靠烙铁一点一点蹭下来,我为什么不买吸锡器@_@

废了九牛二虎之力,终于清理掉了老锡。又碰到新问题:新电池的电极片比旧电池宽一点!电路板上的卡槽插不进去...事已至此断无放弃的理由,用小刀慢慢把口子磨大,总算塞进去。

最后一步是重新焊接电极片,防止松动。但此刻我的烙铁针头已经因为使用不当有点损坏了,针头的一部分融不掉锡,用根部一点才成功融化锡丝。

锡丝要很靠近电极片才行!因为锡凝固得很快,前几次锡都在锡丝上直接凝固成球了=_= 好在经过几次失败,最终还是成功焊住了电极片。虽然焊成一坨很丑陋啦(俗称鸡屎焊)

搞定收工!装上盖子,拧上螺丝,再战十年!

P20260513-153823.webp

谨以此文纪念第一次用电烙铁。

冰室花园

· 阅读需 1 分钟

终于把博客从肥大的java后端迁移到了Docusaurus静态生成,内存占用从几百M降到不到10M,很是欣喜。

大学毕业后的几年博客网站几乎被我遗忘了,只是坚定地维持着运行。年初换了台服务器,配置很低,博客网站被我迁移到了家里的服务器节约宝贵的内存空间。

今年公司开始提供充足的AI报销额度。AI带来的生产力提升让我热情高涨,开始折腾集群,整理我的家庭服务器,做app,部署各种服务。博客迁移,这个在todo里躺了几年的待办终于被提上日程。

迁移过程出奇的顺利,AI帮我导出数据、创建新项目、生成logo,创建服务部署清单,不到半小时便替换掉了原来的Halo服务。此刻正在看假面骑士创骑,有个角色叫作冰室幻德,于是我给新的博客网站取名为:

冰室花园

记一次误操作删除800G数据的经历

· 阅读需 4 分钟

前因后果

2021年2月5日,我正在尝试运行一份示例代码。该脚本类似这样:

# 这里有检查$REC_ROOT,但本脚本内并未处理,所以只会输出缺少环境变量$REC_ROOT,但继续执行
./config.sh
if [ ! -d $WAV_ROOT ]; then
echo "Cannot find wav directory $WAV_ROOT"
exit 1
fi

data="$REC_ROOT/data"

# 其他代码

if [ $stage -le 0 ]; then
echo ""
echo "Stage 0: Preparing data"
rm -rf $data/*
local/chime1_prepare_data.sh || exit 1
fi

由于脚本来自知名开源项目,我并没有仔细审查。另外由于对相关代码并不熟悉,我也没有正确配置相关环境变量,所以脚本中的WAV_ROOTREC_ROOT理所当然是未定义的。

我就这么冒失地执行了脚本,而它在打印两句警告后并没有停止执行,所以我认为环境变量并不是必须的,于是放任它继续执行。由于该脚本执行的是耗时任务,我将控制台隐藏到后台,去完成其他任务。

过了十几分钟,我收到一个应用程序的崩溃报告,因为相关文件不存在。我疑惑地检查,发现数百GB的数据已经不翼而飞。此时我才想起那个正在执行的脚本,切换过去后发现它还没有停止执行,正在疯狂删除我的文件。我赶忙杀掉了脚本,但包括由于没有root权限而删除失败的,原本800GB+的数据只剩5.6G。

让我们来看看发生了什么。

首先,脚本调用config.sh,检测到REC_ROOT环境变量不存在并打印警告。

然后脚本继续执行,if [ ! -d $WAV_ROOT ]; then这里是在检测WAV_ROOT是否是一个目录,如果不是,就退出脚本。按理说我并没有配置任何环境变量,此处应该退出。但bash脚本神奇地,当WAV_ROOT为空或者不存在时,这个检测会认为这是一个目录,从而通过检测。即:

unset NOT_EXIST
if [ -d $NOT_EXIST ]; then
echo "this is a directory"
fi

上面的脚本是会输出的。

或许是因为参数为空时bash默认检测当前目录,以至于目录检测总是通过。

再然后,由于REC_ROOT未定义,$data=/data,然后相当于:

rm -rf /data/*

非常不巧和不幸的是,我将一块1TB的数据盘挂载在了/data上,于是迎来了降维打击。该数据盘中有800G+的数据,文件量大于10万,因此非常耐删,过了十几分钟还给我剩了几个G。而大量读写操作将数据恢复的难度推到了地狱级。

抢救措施

在杀掉脚本之后,我尝试卸载数据盘,但卸载失败,提示正忙。情急之下我忘记了可以通过正在运行的进程恢复它们打开的文件,而是想到先关机避免更多的读写。关机前发现VSCodium还在运行并且有未关闭的文件,于是抢救出几个正在编辑的代码文件。而这成了本次事故中我唯一抢救成功的文件。

之后,通过U盘刻录的系统修改原系统的配置,取消掉自动挂载数据盘,然后系统启动后以只读方式挂载数据盘尝试恢复数据。

正如前面所说,大量的读写操作让我失去了恢复的机会,尝试了不少恢复工具,但都没法扫描出目录结构,唯一可能有效的方法是通过特殊的文件头结构进行特征扫描,但这只能恢复一些特殊格式的文件,而对我最重要的都是纯文本数据,至于一些视频文件,由于尺寸太大数据分散在不同的块,也是基本没戏的。

还好,云端备份让我不至于一无所有,但还是痛失最近一个月的活动数据和大量不可描述之物。

总结

数据无价,谨慎操作。备份得当,也别太浪。

DOM更新与浏览器事件循环

· 阅读需 5 分钟

通常我们说DOM更新发生在一个宏任务执行完之后,但并不是每次宏任务执行后都会进行DOM更新。浏览器会维护一个相对稳定的帧率,根据硬件条件和页面性能表现。那么事件循环如何判断是否应该更新DOM呢?

先上一波HTML规范指定的事件循环处理章程

  1. 从事件循环的任务队列中取一个任务队列taskQueue,这里的任务队列指的是宏任务队列。注意,一个事件循环可能并不只有一个宏任务队列。具体有哪些以及这里怎么选由用户代理决定。
  2. taskQueue中出队一个(宏)任务oldestTask,并设置事件循环的当前执行的任务taskQueue
  3. 执行taskQueue
  4. 设置事件循环的当前执行的任务为null。
  5. 执行微任务队列中的(task)。
  6. 处理DOM更新。

上面的描述省略了一些步骤,完整版参考规范。注意worker环境的事件循环不在本文讨论范畴之内。

可以发现DOM更新确实发生在两个宏任务执行之间,但如果我们深入DOM更新步骤,会发现还有个渲染机会(rendering opportunity的概念。DOM更新时会忽略没有渲染机会的文档。

那么什么时候一个DOM文档(准确说是其浏览上下文)拥有渲染机会呢?前面说过,浏览器会维护一个相对稳定的帧率,例如30fps,那么1秒内应该最多拥有30次渲染机会。浏览器根据这些条件和是否有DOM更新(DOM操作,动画等),决定文档是否应该拥有渲染机会相对稳定的意思是,当硬件条件或者页面性能导致无法维持较高帧率时可能会降帧。虽然这并不是规范规定的,但规范中以此为例,而且主流浏览器应该都是这么实现的。

了解这些有什么用呢?我们可以更精细地把握自己的代码。考虑以下代码:

setTimeout(() => {
//依赖DOM的操作
}, 0);

我们可能会用定时器来等待dom更新后执行一些操作,因为定时器创建宏任务,而DOM会在当前宏任务执行结束后更新(错误)。
有趣的是,这往往也不会出大问题。一来一般这时候已经执行了不少代码,可能正好浏览器在当前宏任务执行完后更新了DOM,二来DOM操作往往会导致DOM强制更新(如读取DOM元素几何相关的数值等,但不绝对触发强制更新),也可能会得到想要的结果。
然后结果是,bug若有若无的,全看人品。啊,我测试的时候好好的,演示的时候怎么就...😅

上面的例子不够具体,来个更实际的。假设要实现一个DOM元素从远处飞回来的动画,我们不用css animation,用transform。
首先我们需要给DOM元素一个初始偏移量,否则起点就是终点,就没有动画效果了。然后给它设置transition样式,并把偏移量还原。

// 获取DOM元素,这里getElement函数未实现
const el = getElement();
// 初始偏移量
el.style.transform = 'translateX(-100px)';
// ...???
el.style.transition = 'transform 100ms linear';
el.style.transform = 'translateX(0)';

为了让动画从初始偏移量开始,我们需要在???前后代码执行之间触发一次DOM更新。

最简单的就是触发DOM强制更新。

// 获取DOM元素,这里getElement函数未实现
const el = getElement();
// 初始偏移量
el.style.transform = 'translateX(-100px)';
el.getBoundingClientRect();
el.style.transition = 'transform 100ms linear';
el.style.transform = 'translateX(0)';

通过getBoundingClientRect()读取元素的几何信息,迫使DOM重新计算元素的属性。但这可能造成额外的运算,带来性能损失。虽然一般场景下问题不大。

如果我们想将???后面的代码延迟到下一帧之后执行呢?靠setTimeout()的话,timeout设置小了就回到bug与人品的故事了,设置大了又会产生卡顿影响体验。这时候我们可以通过一个神奇的API来解决:requestAnimationFrame

// 获取DOM元素,这里getElement函数未实现
const el = getElement();
// 初始偏移量
el.style.transform = 'translateX(-100px)';
requestAnimationFrame(() => {
setTimeout(() => {
el.style.transition = 'transform 100ms linear';
el.style.transform = 'translateX(0)';
}, 0);
});

首先,requestAnimationFrame()的回调会在下一帧绘制之前执行。我的理解是,执行requestAnimationFrame的回调函数列表作为一个宏任务被调度,并且此时该DOM文档获得渲染机会,因此回调执行结束后DOM将更新,而当回调函数中设置的定时器任务被执行的时候已经处于新的宏任务中,此时DOM文档已更新。

注意:对于处理一个浏览上下文的requestAnimationFrame回调时的宏任务和下一个宏任务之间,该浏览上下文一定获得会渲染机会是否成立,尚未找到规范证明,仅作参考。

本文链接:https://blog.mattuy.top/archives/dom-update-and-browser-event-loop

LeetCode之旅──696.计数二进制子串

· 阅读需 2 分钟

题目:给定一个字符串 s,计算具有相同数量0和1的非空(连续)子字符串的数量,并且这些子字符串中的所有0和所有1都是组合在一起的。
重复出现的子串要计算它们出现的次数。

示例:

输入: "00110011" 输出: 6 解释: 有6个子串具有相同数量的连续1和0:“0011”,“01”,“1100”,“10”,“0011” 和 “01”。

请注意,一些重复出现的子串要计算它们出现的次数。

另外,“00110011”不是有效的子串,因为所有的0(和1)没有组合在一起。

最先想用栈来解决,但发现行不通。然后慢慢发现规律了,我们可以用一个指针对准第一个数字的起点(最先是0),然后用另一个指针对准另一个数字的起点(如示例中第一个1的位置),然后两个指针一起跑,只要它们对应位置的值不相等,那答案就加1。值相等后必然是其中一个数字跑完了,可以根据和前一个位置对比得出是跑在前面的指针跑完了还是跑在后面的,如果是前面的先跑完了就让后面的指针直接跑到下一个起点(即前面的指针本次的起点),接着开始下一轮,直到有一个指针到达末尾。

好吧上面一段其实并不用看,我们只需要将相同的数字看作一组,然后遍历每组的数字个数组成的数组,相邻元素取最小值,加起来就得到结果了。比如示例的数组为[2, 2, 2, 2],相邻取最小再加起来就是6。

其实理论上说两个方法是差不多的,后者像是前者的抽象化。我们刷算法题总是容易下意识的用人类思维思考,然后用计算机去“模拟”,所以经常会遇到将思维转化为代码时不流畅和容易出错。我认为所谓算法思想,就是一种将人类思维抽象成计算机思维的能力

当绝对定位遇上层叠上下文

· 阅读需 3 分钟

css中position: absolute可以将元素指定为绝对定位,那么绝对定位元素的几何位置是相对于谁计算呢?

MDN上的描述是:

绝对定位元素相对于最近的非 static 祖先元素定位。当这样的祖先元素不存在时,则相对于ICB(inital container block, 初始包含块)。

也即是说,绝对定位元素应该相对其position为非static的最近祖先定位,或者这样的祖先不存在时相对初始包含快(一般为body)定位。而position默认为static。

但似乎有时候也有例外。

今天想在一个scroller组件中使用绝对定位突破到更顶层,但意外的发现,即使从该元素的父元素到body都没有指定position属性,它竟然仍是相对scroller容器定位而不是body或者更上层的具有position: relative属性的元素。

实验了半天发现是因为scroller使用了css的transform属性来变换滚动元素的位置以提升性能。而这直接导致了具有有效transform属性的元素成了绝对定位元素的基准。

考虑如下html:

<div style="line-height: 50px;">
<style>
.relative-container {
background: lightcoral;
position: relative;
height: 300px;
}
.midware {
background: lightcyan;
height: 200px;
}
.absolute-block {
background: black;
height: 30px;
position: absolute;
top: 0;
width: 30px;
}
</style>
<div class="relative-container">
------把下面的div撑下去一点方便观察差异-------
<div class="midware">
<div class="absolute-block"></div>
</div>
</div>
</div>
------把下面的div撑下去一点-------

如上,absolute-block成功突破了midwaretop: 0是相对于relative-container而不是其直接父元素midware。但是当我们为midware添加transform属性后:

.midware {
background: lightcyan;
height: 200px;
transform: translateZ(0);
}
------把下面的div撑下去一点-------

absolute-block已经相对其父元素midware定位了!但midwareposition并没有被显示指定。transform设置为其他有效值效果也一样。

为了测试transform属性是否隐式改变了position属性,我添加了如下代码:

const el = document.querySelector('.midware');
console.log(getComputedStyle(el).position); // static

并没有。这个现象应该是由其他机制引起的。

最后查了下资料,有人说是由层叠上下文导致的。但又不是所有的层叠上下文都会造成这样的影响,不同内核的浏览器也有不同的表现。所以应该将这个特性当作bug来对待?

参考:
不受控制的 position:fixed

JS省略分号导致的灾难

· 阅读需 4 分钟

挺长一段时间在纠结写JavaScript代码要不要打分号。这是一个个人风格问题,以下观点仅代表个人喜好。

不要省略分号!不要省略分号!不要省略分号!

虽然js引擎执行代码时会自动插入分号,但有些时候省略分号可能导致一些隐蔽的问题。

return语句

return后面会被自动插入分号,所以下面的代码返回undefined而不是一个字符串。

function foo() {
return
"this will return undefined"
}

当然,这完全可以避免,我们可以将返回值写在一行或者用括号包起来。

function foo() {
return (
"this is ok."
)
}

似乎这点也不是不省略分号就能避免的,如果您不清楚这条规则,下面的代码可能会带来疑惑:

function foo() {
return
'a long expresion' +
'another long expresion';
}
foo(); // undefined

OK,这不是重点,事实上现代编辑器足够聪明,这样的情况应该会将return后面的语句给你标注为灰色,你很容易就可以看到它们没有正确的被执行。这里只是顺带一提。我们来看更隐蔽的坑。

函数执行

考虑下面的代码:

var foo = 0
var bar = 1

foobar()

('some condition' && 'another condition') ? foo = -1 : bar = -1

console.log(foo, bar)

function foobar() {
return function () { return false }
}

请问上面的代码输出什么?答案是0 -1

我们的本意是在两个条件满足时给foo变量赋值-1,否则给bar变量赋值-1。这里本该输出-1 1,但由于语句以括号开头,js执行引擎误以为上一行的语句还没有结束,正好foobar()返回了一个函数,于是上面的代码相当于(为了更清晰我引入了一个变量):

var condition = foobar()('some condition' && 'another condition');
condition ? foo = -1 : bar = -1;

foobar()返回的函数总是返回false,所以被赋值的总是bar变量,这完全改变了我们的初衷!而且编辑器不会有提示:没有语法错误。甚至运行时也不会直接出错,而是在某个case下才命中bug。

解构赋值

接下来是我今天亲身经历的的场景。

ES2015(ES6)新增了一个解构赋值的特性,用于交换两个变量的值那是非常的方便。例如交换a,b变量的值:[a, b] = [b, a]。当然解构赋值还有很多其他特性,可以参考MDN

考虑如下代码:

var m = n = 0;
var swap = -1;
var matrix = [
[1, 2],
[3, 4]
];

//do something...

[matrix[m][n], swap] = [swap, matrix[m][n]]
[m, n] = [n, m]

console.log(matrix) // [[0, 2], [3, 4]]

OK,我们不用管代码是要做什么,反正就是想交换matrix[m][n]swap的值,再交换mn。期望的输出应该是[[-1, 2], [3, 4]]。然鹅...

事实上在写这篇博客之前我是赞成省略分号的。前面两种情况我都习惯性会注意避免,也没出过类似的错误。但终于还是不留神栽在了解构赋值上。

还是解释一下,上面的解构赋值语句等价于:

[matrix[m][n], swap] = [swap, matrix[m][n]][m, n] = [n, m]

//不太清晰?由于m == n == 0,逗号表达式m,n的值为0,所以再等价于:
[matrix[m][n], swap] = [swap, matrix[m][n]] [0] = [n, m]
//即
[matrix[m][n], swap] = swap = [n, m]
//即
[matrix[m][n], swap] = [n, m]
//即
maxtrix[0][0] = 0;
swap = 0;

同样,由于没有语法错误,IDE同样不会检测到。

总结

暂时没有遇到其他情况,不过有时候是防不胜防。咱一朝被蛇咬,十年怕井绳。添上分号他不香嘛。