Reading view

There are new articles available, click to refresh the page.

重构博客友链页面 & 友链朋友圈开源

先来看看效果:友情链接 - PRIN BLOG

自我感觉还是不错的,友链的博客们有什么更新都可以实时展示在页面上,一目了然。作为博主,不用打开 RSS 阅读器就可以查看新文章;作为访客,也可以快速找到更多自己感兴趣的内容,比起原来全是链接的页面,看起来也让人更有点击欲望了。

从临时起意到开发完成总共两个晚上,最速传说就是我!(误)

缘起

前段时间看到有个博客用了这样的一个东西:

当时就感觉卧槽好高端,很有想法。

这种聚合订阅的形式有个名字,叫做 Planet(社区星球)。Planet 通常用于聚合某个领域的博客,然后展示在一个页面上,方便用户一站式阅读,比如:

这种形式在开源社区里比较常见,不过用在博客的友链上我倒还是第一次看到。


就像我在本站友链页里说的一样,独立博客之间的联系基本上就是靠的链接交换和评论互访。一个博客的访客看到了其他博客的链接,点过去看了,然后从对面的友链中,又导航到新的博客……如此往复,我们就依靠着这种从现在看来显得十分古老的方式,维系着这些信息孤岛之间的纽带。原始又浪漫。

不过这里就会涉及到一个用户点击率的问题。我自己之前在维护友链页面的时候,总感觉只放标题和链接看起来效果不怎么好。就算加上描述、头像这些元素,也总觉得差点意思。因为一个博客最重要的其实还是它的内容,仅靠一个网站标题,可能很难吸引到其他用户去点击。

而「友链朋友圈」的这种形式,就像微信朋友圈一样,作为一个聚合的订阅流,展示了列表中每个博客的最新文章。

比起干巴巴的链接,这显然会更加吸引人。虽然我写博客到现在也已经 9 年了,早就佛系了,主打一个爱看不看。不过对于和我交换了友链的博主们,还是希望他们能够获得更多的曝光和点击(虽然我这破地方也没多少流量就是啦……),也希望我的访客们也可以遇到更多有价值的博客。


然而在准备接入的时候,我发现这玩意儿不就是一个小型的 RSS 阅读器么……其实等于是自己又实现了一套订阅管理、文章爬取、数据保存之类的功能。

于是我就寻思,可能直接复用已有 RSS 阅读器 API 的思路会更好,让专业的软件做专业的事。友链的管理也可以直接复用 RSS 阅读器的订阅管理功能,这样增删改也不需要了,我们就只需要封装一下查询的 API,提供一个精简的展示界面就 OK。

技术栈选择

作为行动力的化身,咱们自然是说干就干,下班回家马上开工!

首先是 RSS 后端的选择。

市面上的 RSS 阅读器有很多,我自己主要用的是 Inoreader。然而我看了下,Inoreader API 只面向 $9.99 一个月的 Pro Plan 开放,而且限制每天 100 个请求……这还玩个屁。Feedly 也是差不多一个尿性,可以全部 PASS 了。我也不知道该说他们什么好,也许做 RSS 真的不挣钱,只能这样扣扣搜搜了吧。

另外一个选择就是各种支持 self-host 的 RSS 阅读器,比如 Tiny Tiny RSSMiniflux。我之前部署过 TTRSS,说实话感觉还是太重了。Miniflux 则是使用 Go 编写的,该有的功能都有,非常轻量级,部署也很方便。就决定是它了!

技术栈方面选择了之前一直比较心水的 Hono,部署在 Cloudflare Workers 上。前端方面没有使用任何框架,连客户端 JS 都没几行,基本上是纯服务端渲染。有时候不得不感叹技术的趋势就是个圈,以前那么流行 SPA,现在又都在搞静态生成了。

cf-workers-usage

页面渲染使用了 Hono 提供的 JSX 方案,可以在服务端用类似 React 的语法返回 HTML,挺好用的。不过 CSS 没有用 Hono 的那一套 CSS-in-JS,因为要允许用户覆盖样式,所以要用语义化的类名。最后选了 Less,还是熟悉的味道。

前端文件的构建使用了 tsup,配置文件就几行,爽。

实现

实现思路很简单,就是做一个 Proxy 层,把:

这两个 Miniflux 的 API 包一下。这里要注意不能暴露实际的 API Endpoint,避免可能的恶意攻击。API 缓存也要在我们这一层做好,防止频繁刷新把服务打爆。

缓存策略上使用了 SWR (Stale-While-Revalidate):

  1. 拿到 API 响应后,放到 KV 中,同时把时间戳放入 metadata;
  2. 后续从 KV 读取缓存时,对比当前时间和 metadata 中的时间戳;
  3. 如果经过的时间没有超过设置的 TTL,说明缓存有效,直接返回前端;
  4. 如果经过的时间超过了 TTL,则标记缓存为 stale 状态,依然返回前端
  5. 此时,后端在后台重新请求 API,并将最新的响应写入 KV 中;
  6. 下一次再从 KV 读取时,拿到的缓存就是最新的了。

这样可以保证最快的响应速度,以及相对及时的更新速度,比较适合这种场景。

最后的交付形式其实就是两个 HTML 页面,通过 <iframe> 的形式嵌入到网页中。另外参考 giscus 提供了一个脚本,可以设置参数并自动完成 iframe 的初始化,用户只需要引入一个 <script> 标签即可,非常方便:

<script  async  data-category-id="28810"  src="https://blog-friend-circle.prin.studio/app.js"></script>

friends-page-demo

当然也可以作为独立页面打开,有做双栏布局适配:

blog-friend-circle.prin.studio/category/2/entries

开源

新版博客友链朋友圈的所有代码都开源在 GitHub 上,欢迎使用:

👉 prinsss/blog-friend-circle

这个方案和 hexo-circle-of-friends 并没有孰优孰劣之分,只是侧重点和实现方式不同。不过我这个的一个好处是,如果你已经在用 Miniflux 了,那么可以直接复用已有的大部分能力,不需要再起一个 Python 服务和数据库去抓取、保存 RSS,相对来说会更轻量、稳定一些。

如果你选择使用 Miniflux 官方提供的 RSS 服务,甚至可以无需服务器,部署一下 CF Workers 就行了,像我这样的懒人最爱。

真的不可以在 React 组件内部嵌套定义子组件吗?

最近在 Code Review 时,看到有同事写了这样的代码:

function TodoList() {  const [list, setList] = useState([]);  const TodoItem = useCallback((props) => {    return <li>{props.text}</li>;  }, []);  return <ul>{list.map((item, index) => <TodoItem key={index} text={item} />)}</ul>;}

有经验的 React 开发者肯定一下子就看出问题了:在组件内部嵌套定义组件,会导致子组件每次都重新挂载。因为每次渲染时,创建的函数组件其实都是不同的对象。

但是他又有包了 useCallback 让引用保持一致,好像又没什么问题……?

这波骚操作让我突然有点拿不准了,所以今天咱们一起来验证一下,用 useMemo 或者 useCallback 包裹嵌套定义的子组件,对 React 渲染会有什么影响。以及如果有影响,应该如何用更合适的方法重构。

TL;DR

先说结论:

永远不要在 React 组件内部嵌套定义子组件。

如果你有类似的代码,请使用以下方法替代:

  1. 把子组件移到最外层去,将原有的依赖项作为 props 传入
  2. 把子组件改为「渲染函数」,通过调用函数插入 JSX 节点

为什么?请接着往下看。

组件重新挂载会造成的问题

来看这段代码(可以在 StackBlitz Demo 中运行):

function TodoList() {  const [list, setList] = useState([]);  // 嵌套定义子组件(好孩子不要学哦)  const TodoItem = (props) => {    return <li>{props.text}</li>;  };  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      {/* 渲染刚才定义的子组件 */}      <ul>        {list.map((item, index) => (          <TodoItem key={index} text={item} />        ))}      </ul>    </div>  );}

可能不少初学者都写出过类似的代码:JavaScript 语言可以嵌套定义函数,React 函数式组件就是函数,那 React 组件不也可以嵌套定义?

还真不是这么回事。我们来实际运行一下这段代码看看:

Tips: 这里使用了 useTilg 这个库来展示组件生命周期。

nested-component

可以看到,每次点击 Add 按钮在 <TodoList/> 列表中添加元素时,之前旧的 <TodoItem/> 组件实例就会被卸载 (unmount)、销毁。React 会创建全新的组件实例,然后再挂载 (mount) 上去。

也就是说,这些组件实例全都变成一次性的了

这还只是一个简单的示例,如果是实际应用场景,一个组件和它的子组件中,可能包含了成百上千个 DOM 节点。如果每次状态更新都会导致这些组件和对应的 DOM 节点被卸载、创建、重新挂载……那应用性能可就是「画面太美我不敢看」了。

更严重的是,组件的卸载还会导致其内部的状态全部丢失

那怎么会这样呢?这要从 React 的渲染机制,以及 Reconciliation 流程说起。

React 渲染机制之 Reconciliation

我们知道 React 的渲染大致可以分为两个阶段

  1. Render 阶段:执行组件的渲染方法,找出更新前后节点树的变化,计算需要执行的改动;
  2. Commit 阶段:已经知道了需要执行哪些改动,于是操作真实 DOM 完成节点的修改。

其中,「找出变化 + 计算改动」这个过程就被叫做 Reconciliation (协调)。React 的协调算法可以在保证效率的同时,最大程度复用已有的 DOM,使得对 DOM 做出的修改尽量小。

render-and-commit

▲ 图片来自:Render and Commit – React

那么问题来了,React 怎么知道一个组件对应的 DOM 需要更新呢?

简单来说,React 在比较两棵 Fiber 树时,会从根节点开始递归遍历所有节点:

  1. 如果节点类型和之前一致
    1. 对于 DOM 元素,保持元素实例不变,仅更新有变化的属性
    2. 对于组件元素,需要重渲染的,使用新属性调用组件渲染方法
  2. 如果节点类型有改变
    1. 卸载该节点及其子节点 ⚠️
    2. 将对应的 DOM 元素标记为待删除
    3. 创建新的节点
    4. 将新的 DOM 元素标记为待插入

上面所说的子组件被卸载再挂载、状态丢失等问题,其实都是因为它们被判断为了「节点类型有改变」

引用相等性与组件重渲染

在 JavaScript 中,比较值时有两种相等性:

  • 值相等性 (Value Equality),即两个值类型一致,内容也一致
  • 引用相等性 (Reference Equality),即两个对象的引用在内存中指向同一块区域

举个例子:

// 两个长得一样的对象const a = { name: 'Anon Tokyo' };const b = { name: 'Anon Tokyo' };// "引用相等性" 比较 - falseconsole.log(a === b);console.log(Object.is(a, b));// "值相等性" 比较 - trueconsole.log(lodash.isEqual(a, b));console.log(a.name === b.name);

JavaScript 函数也是对象,所以这对于函数(React 组件)也成立。

到这里问题就比较明朗了。

function TodoList() {  const [list, setList] = useState([]);  // WARN: 这个语句每次都会创建一个全新的 TodoItem 函数组件!  const TodoItem = (props) => {    return <li>{props.text}</li>;  };  return <ul>{list.map((item, index) => <TodoItem key={index} text={item} />)}</ul>;}

在这段代码中,每次 <TodoList/> 组件重渲染时(即 TodoList 函数被调用时),其内部创建的 TodoItem 都是一个全新的函数组件。

虽然它们长得一样,但它们的「引用相等性」是不成立的。

回到上一节介绍的渲染流程中,React 在比较节点的 type,使用的是 === 严格相等。也就是说像上面那样不同的函数引用,会被视作不同的组件类型。进而导致在触发重渲染时,该组件的节点及其子节点全部被卸载,内部的状态也被全部丢弃。

如果我用 useMemo 包一下呢

到这里我们已经介绍了「在组件内部嵌套定义组件」会造成问题的原理。

这时候可能就有小机灵鬼要问了,既然组件每次都被判断为是不同 type 的原因是对象引用不同,那我用 useMemo / useCallback Hooks,让它每次都返回相同的函数对象不就行了?

能考虑到这一层的都是爱动脑筋的同学,点个赞!让我们再来试验一下:

function TodoList() {  const [list, setList] = useState([]);  // useMemo 允许我们缓存一个值,每次重渲染时拿到的缓存是一样的  // 这里我们返回了一个函数组件,让 useMemo 把这个子组件的函数对象缓存下来  const TodoItem = useMemo(    () => (props) => {      return <li>{props.text}</li>;    },    []  );  // 或者用 useCallback 也可以,都一样  // const TodoItem = useCallback((props) => {  //   return <li>{props.text}</li>;  // }, []);  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      <ul>        {list.map((item, index) => (          <TodoItem key={index} text={item} />        ))}      </ul>    </div>  );}

nested-usememo

可以看到,包一层 useMemo 之后,子组件确实不会再被 unmount 了。看起来十分正常呢!

让我们再拿 React.memo 来包一下,在 props 相同时跳过不必要的重渲染:

const TodoItem = useMemo(  () =>    memo((props) => {      return <li>{props.text}</li>;    }),  []);

nested-usememo-memo

OHHHHHHHHH!!

如果一个东西看起来像鸭子,叫起来也像鸭子,那么它就是鸭子。

同理,如果我们通过一系列操作可以让「嵌套定义的 React 组件」在渲染时表现得与「在外层定义的组件」一致,那是不是就意味着这种操作其实也是 OK 的呢?

嗯……答案是:没那么简单。

稍微偏个题,你可能会好奇 Hooks 和 memo 为什么也可以在嵌套定义的子组件内正常使用,因为这看起来和我们平时的用法完全不同。

实际上不管是模块顶层定义的函数组件,还是嵌套定义的函数组件,在 React Reconciler 看来都是独立的组件类型,且在渲染时都有着自己的 Fiber 节点来储存状态,而定义该函数的作用域是什么并不重要。想想看:HOC 高阶组件有时候也会返回内联定义的函数组件,其实是一个道理。

useMemo 的缓存失效策略

第一点,useMemouseCallback 的缓存并非完全可靠。

在某些条件下,缓存的值会被 React 丢弃。如果缓存失效,函数组件就会被重新创建,同样会被判断为是不同的组件类型。React 官方肯定不会推荐你把 Hooks 用于这种歪门邪道的用途。

In the future, React may add more features that take advantage of throwing away the cache—for example, if React adds built-in support for virtualized lists in the future, it would make sense to throw away the cache for items that scroll out of the virtualized table viewport. This should be fine if you rely on useMemo solely as a performance optimization.

Ref: useMemo – React

第二点,useMemouseCallback 都有依赖数组。

虽然上面的示例里嵌套组件定义的依赖数组都是空的,但是我们再想想,什么情况下会想要在组件内部定义子组件,而非将其拆成一个单独的组件呢?最主要的原因就是,这个子组件想要直接访问父组件函数作用域中的某些变量。

function TodoList() {  const [list, setList] = useState([]);  const TodoItem = useMemo(    () =>      memo((props) => {        // 注意看,这里子组件直接使用了父级作用域中的 list 变量        return <li>{`${props.text} of ${list.length}`}</li>;      }),    [list.length]  );  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      <ul>        {list.map((item, index) => (          <TodoItem key={index} text={item} />        ))}      </ul>    </div>  );}

nested-usememo-memo-deps

从实际测试中可以看到,有了依赖项的 useMemo + 嵌套组件,又退化成了最开始的样子,每次都会被当成不同的组件类型,每次都会被 unmount。之前所做的努力全部木大!(顺带一提用 useRef 也是一样的,有依赖就歇菜)

也就是说,只有你的嵌套子组件完全不依赖父组件作用域时,才能保证 useMemo 的缓存一直有效,才能做到完全不影响渲染性能。但既然都已经完全不依赖了,那么又还有什么理由一定要把它定义在父组件内部呢?

重构包含嵌套组件的代码

所以我再重复一遍开头的结论:永远不要在 React 组件内部嵌套定义子组件。

因为这在大部分情况下会造成渲染问题,即使对这种写法做优化也没有意义,因为一不留神就可能掉进坑里,还有可能会误导其他看到你的代码的人。

如果你的代码库中已经有了这样的 💩 代码,可以使用下面的方法重构。

第一种方法,把子组件移到最外层去。

这种方法适用于子组件依赖项不多的情况,如果有之前直接使用的父级作用域中的变量,可以将其改造为 props 传入的方式。

// 组件定义移到模块顶层const TodoItem = memo((props) => {  return <li>{`${props.text} of ${props.listLength}`}</li>;});function TodoList() {  const [list, setList] = useState(['Item 1']);  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      <ul>        {list.map((item, index) => (          // 改造后:从 props 传入原来的依赖项          <TodoItem key={index} text={item} listLength={list.length} />        ))}      </ul>    </div>  );}

第二种方法,把子组件改为渲染函数 (Render Function)。

JSX 的本质就是 React.createElement(type),React 节点的本质其实就是一个 JavaScript 对象。你在组件 return 语句中直接写 JSX,和定义一个函数返回 JSX 然后再调用这个函数,本质上是一样的。

function TodoList() {  const [list, setList] = useState([]);  // 这不是函数组件,只是一个「返回 JSX 的函数」(函数名首字母非大写)  // 所以每次渲染都重新创建也没问题,也可以直接访问作用域内的变量  const renderTodoItem = (key, text) => {    return <li key={key}>{`${text} of ${list.length}`}</li>;  };  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      {/* 调用的时候也和调用普通函数一样,而非组件的标签形式 */}      <ul>{list.map((item, index) => renderTodoItem(index, item))}</ul>    </div>  );}

不过需要注意的是,在使用「渲染函数」时,一定要搞清楚和「函数组件」的区别:

  • 渲染函数虽然和组件一样都返回 JSX,但它不是组件;
  • 渲染函数就是普通 JavaScript 函数,没有状态,也没有对应的 Fiber 节点;
  • 渲染函数只是当前组件的一部分,对于 React 渲染来说没有额外开销;
  • 渲染函数内部不能使用 Hooks,只有组件内部才能使用 Hooks;
  • 渲染函数命名一般以 render 开头,首字母小写(否则容易和组件搞混)。

另外,当渲染函数作为 props 传入其他组件时,它也被叫做渲染属性 (Render Props)。这种设计模式在 React 生态中有着大量的应用,可以放心使用。

结语

最后聊一下,如何避免这类问题的发生。

第一,配置 Lint 规则。

防范于未然,合理的 Lint 配置可以减少起码 80% 的代码规范问题。比如本文介绍的坑,其实完全可以通过 react/no-unstable-nested-components + react-rfc/no-component-def-in-render 规则提前规避。

最好再配合代码提交后的 CI 卡点检查,有效避免因开发者环境配置不当或者偷摸跳过检查,导致规则形同虚设的情况。

第二,定期 Code Review。

代码腐化是难以避免的,但我们可以通过流程和规范提早暴露、纠正问题,减缓腐化的速度。Code Review 同时也是一个知识共享、学习和成长的过程,对于 reviewer 和 reviewee 来说都是。

没有人一开始就什么都会,大家都是在不断的学习中成长起来的。

第三,了解一些 React 的原理与内部实现。

因为我自己就是吃这碗饭的,之前写过 React 的 Custom Renderer,也做过渲染性能优化,所以底层原理看的比较多,自然也就知道什么样的代码对性能会有影响。

我一直以来秉持的观点就是,学习框架时也要学习它「引擎盖下」的东西,知其然且知其所以然。如果你希望在这条路上一直走下去,相信这一定会对你有所帮助。


扩展阅读:

使用 TikZ 在 Hexo 博客中愉快地画图

一转眼就到 2024 年了!大家新年快乐!

前段时间在写文章时,需要一些配图,于是就使用了 TikZ 来绘制。TikZ 是一个强大的 宏包,可以使用代码的形式绘制出各种各样精美的矢量图。

如果你的阅读器看不到上面的 SVG 格式图片,可以点这里查看 PNG 格式。example-tikz-graph

上面的图对应的 TikZ 代码可以在这里找到。然而画是画爽了,想把它贴到博客里时却犯了难——目前竟然没有什么好办法可以直接在博客里使用 TikZ!

TL;DR

咱们废话不多说,直接上结果:我写了一个 Hexo 插件,可以直接把 Markdown 源码里的 TikZ 代码渲染成 SVG 矢量图,然后在博客构建时嵌入到页面 HTML 中,用起来就和 MathJax 写数学公式一样简单。

而且最重要的是渲染完全在构建时完成,浏览器上无需运行任何 JavaScript。同时构建机上也无需安装 环境,因为其底层运行的是 WebAssembly。

👉 prinsss/hexo-filter-tikzjax: Server side PGF/TikZ renderer plugin for Hexo.

npm install hexo-filter-tikzjax

注意:插件安装成功后,需要运行 hexo clean 清除已有的缓存。

安装插件后,只需要在博客文章中添加 TikZ 代码块:

---title: '使用 TikZ 在 Hexo 博客中愉快地画图'tikzjax: true---Markdown text here...```​​tikz\begin{document}  \begin{tikzpicture}    % Your TikZ code here...    % The graph below is from https://tikz.dev/library-3d  \end{tikzpicture}\end{document}```

插件就会自动把代码渲染成对应的图片,非常方便:

TikZ 教程

不做过多介绍。贴几个链接,有兴趣的可以学学:

比如这就是我在写文章时画的图,全部用 TikZ 代码生成,画起来改起来都很方便。

原理

在本插件之前,主流的在网页上渲染 TikZ 绘图的方式是使用 TikZJax。TikZJax 有点类似 MathJax,都是通过 JavaScript 去渲染 语法。

<link rel="stylesheet" type="text/css" href="https://tikzjax.com/v1/fonts.css"><script src="https://tikzjax.com/v1/tikzjax.js"></script><script type="text/tikz">  \begin{tikzpicture}    \draw (0,0) circle (1in);  \end{tikzpicture}</script>

然而这样做的问题是,太重了。在网页上动态加载 TikZJax,需要加载 955KB 的 JavaScript + 454KB 的 WebAssembly + 1.1MB 的内存数据,如果再另外安装一些宏包,最终打包产物大小甚至可以达到 5MB+。

对于一些有加载性能要求的网站,这显然是难以接受的。

那怎么办呢?答案就是 SSR / SSG,把渲染过程搬到服务端/构建时去做。这很适合博客这样的场景,尤其是静态博客,只需要构建时渲染一下,把生成的图片塞到 HTML 里就完事了,完全不需要客户端 JavaScript 参与,加载速度大幅提升。

因为 TikZJax 底层跑的是 WebAssembly,而 Node.js 也支持运行 WebAssembly,所以很自然地我就想,能不能把它的渲染流程直接搬到 Node.js 里面去做?

说干就干。于是就有了 node-tikzjax,一个 TikZJax 的移植版,可以在纯 Node.js 环境下运行,无需安装第三方依赖或者 环境。轻量化的特性很适合拿来做服务端渲染,也支持在 Cloudflare Worker 等 Runtime 上运行,非常好用。

hexo-filter-tikzjax 则是 node-tikzjax 的一个上层封装,主要就是在渲染 Hexo 博客文章时提取 Markdown 源码中的 TikZ 代码,交给 node-tikzjax 执行并渲染出 SVG 图片,然后将其内联插入到最终的 HTML 文件中。

如果是其他博客框架,也可以用类似的原理实现 TikZ 静态渲染的接入。

局限性

因为 node-tikzjax 并不是完整的 环境,所以不是所有宏包都可以使用。目前支持在 \usepackage{} 中直接使用的宏包有:

  • chemfig
  • tikz-cd
  • circuitikz
  • pgfplots
  • array
  • amsmath
  • amstext
  • amsfonts
  • amssymb
  • tikz-3dplot

如果希望添加其他宏包,可以参考 extractTexFilesToMemory 这里的代码添加。

如果在使用插件的过程中 TikZ 代码编译失败了,可以通过 hexo s --debug 或者 hexo g --debug 开启调试模式,查看 引擎的输出排查问题:

This is e-TeX, Version 3.14159265-2.6 (preloaded format=latex 2022.5.1)**entering extended mode(input.texLaTeX2e <2020-02-02> patch level 2("tikz-cd.sty" (tikzlibrarycd.code.tex (tikzlibrarymatrix.code.tex)(tikzlibraryquotes.code.tex) (pgflibraryarrows.meta.code.tex)))No file input.aux.ABD: EveryShipout initializing macros [1] [2] (input.aux) )Output written on input.dvi (2 pages, 25300 bytes).Transcript written on input.log.

或者也可以在这个 Live Demo 中输入你的 TikZ 代码,提交后可在控制台查看报错。

致谢

首先要感谢 @kisonecat 开发的 web2js 项目,这是一个 Pascal 到 WebAssembly 的编译器,使我们可以在 JavaScript 中运行 引擎,也是下面所有项目的基石。

这里有作者关于构建基于 Web 的 引擎的一篇文章,可以拜读一下:Both TEX and DVI viewers inside the web browser

感谢 @drgrice1 对 TikZJax 和 dvi2html 的修改,TA 的 fork 中包含了很多有用的新功能,并且修复了一些原始代码中的问题。

感谢 @artisticat1 对 TikZJax 的修改,这是基于上述 @drgrice1 的 fork 的又一个 fork,也添加了一些有用的功能。本插件依赖的 node-tikzjax,其底层使用的 WebAssembly 二进制和其他文件就是从这个仓库中获取的。

感谢 @artisticat1 开发的 obsidian-tikzjax 插件,这是本项目的灵感来源。本项目和该插件底层共享同一套 引擎,使用语法也很类似,基本可以在 Obsidian 和 Hexo 之间无缝切换(实际上也是我开发这个的原因 😹)。

如有任何问题,请在 GitHub 上提交 issue。祝使用愉快!

详解 PixiJS Filter 中的参数与坐标系

除草啦除草啦,再不更新博客就要变成热带雨林啦!🌿

最近在给一个 PixiJS 程序编写 WebGL Shader,被各种参数和坐标系搞得晕头转向。痛定思痛,整理了一下 PixiJS Filter 系统中的各种概念,以供后续参阅。

在 WebGL 中,我们可以通过编写顶点着色器 (Vertex Shader) 和片元着色器 (Fragment Shader) 来实现各种各样的渲染效果。而在 PixiJS 中,渲染引擎为我们屏蔽了绝大多数的底层实现,通常情况下用户是不需要自己调用 WebGL API 的。如果有编写自定义着色器代码的需求,一般是使用 Filter 来实现。

PixiJS Filter 是什么

PIXI.Filter 其实就是一个 WebGL 着色程序,即一组顶点着色器 + 片元着色器的封装。和 Photoshop 中的滤镜功能类似,它接受一个纹理 (Texture) 输入,然后将处理后的内容输出到 Framebuffer 中。使用滤镜,可以实现模糊、扭曲、水波、烟雾等高级特效

用户只需要定义着色器的 GLSL 代码,传入对应的参数,剩下的工作就全部交给 PixiJS 完成。如果你对这些概念不太熟悉,可以看看:WebGL 着色器和 GLSL

默认的 Filter 着色器代码

在 PixiJS 中,Filter 自带了一组默认的顶点着色器和片元着色器代码。用户在定义 Filter 时,如果省略了其中一个,就会使用默认的着色器代码运行。

new Filter(undefined, fragShader, myUniforms); // default vertex shadernew Filter(vertShader, undefined, myUniforms); // default fragment shadernew Filter(undefined, undefined, myUniforms);  // both default

这是 Filter 默认的顶点着色器代码

attribute vec2 aVertexPosition;uniform mat3 projectionMatrix;varying vec2 vTextureCoord;uniform vec4 inputSize;uniform vec4 outputFrame;vec4 filterVertexPosition(void){    vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;    return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);}vec2 filterTextureCoord(void){    return aVertexPosition * (outputFrame.zw * inputSize.zw);}void main(void){    gl_Position = filterVertexPosition();    vTextureCoord = filterTextureCoord();}

这是默认的片元着色器代码

varying vec2 vTextureCoord;uniform sampler2D uSampler;void main(void){    gl_FragColor = texture2D(uSampler, vTextureCoord);}

看着一脸懵逼不要紧,下面我们逐一解释。

WebGL 知识回顾

先来复习一下 WebGL 基础知识

  • WebGL - 基于 OpenGL ES 的图形渲染 API,可根据代码绘制点、线和三角形
  • 着色器 (Shader) - 运行在 GPU 上的代码,一种将输入转换为输出的程序
  • GLSL - 专门用于编写着色器的语言,语法类似 C 语言
  • 顶点 (Vertex) - 一个 2D/3D 坐标的数据集合,表示空间中的一个点
  • 顶点着色器 (Vertex Shader) - 处理顶点数据,生成裁剪空间坐标值
  • 片元 (Fragment) - 也叫片段,包含了渲染一个像素所需的所有数据
  • 片元着色器 (Fragment Shader) - 计算当前光栅化的像素的颜色值
  • 参数 - 着色器中获取数据的方法,主要有:attribute、uniform、texture、varying

pipeline

▲ OpenGL 图形渲染管线的流程。插图来自 LearnOpenGL

不太熟悉的同学可能会以为 WebGL 是 3D 渲染 API,但其实 WebGL 本身只是一个光栅化引擎,并没有提供什么 3D 功能。如果想要实现 3D 渲染,你需要将模型中的三维坐标点转换为裁剪空间的二维坐标,并提供对应的颜色。WebGL 负责将这些图元绘制到屏幕上,仅此而已。

试想:任何 3D 模型,不管怎样旋转、缩放、平移,最终展示到你的屏幕上,都是 2D 的。同样,不管模型上有什么贴图、光照、阴影、反射,最终改变的其实都是这个像素的颜色值。

再来复习一下 WebGL 中的坐标系统:

  • 局部坐标 (Local Coordinate)
    • 或称模型坐标,对应局部空间
    • 一个物体中的点相对于该物体原点的局部坐标
  • 世界坐标 (World Coordinate)
    • 对应世界空间
    • 局部坐标相对于世界原点的坐标,把物体放到整个世界中去看
  • 观察坐标 (View Coordinate)
    • 对应观察空间
    • 从摄像机/人眼的角度去观察世界,所看到的物体相对于观察者的坐标
    • 同一个世界坐标,从不同的距离、角度、视野去观察,得到的观察坐标也不同
  • 裁剪坐标 (Clip Coordinate)
    • 对应裁剪空间
    • 将观察空间内超出一定范围的坐标点都裁剪掉,只保留一定范围内的坐标
    • 任何超过这个范围的点都不会显示在你的屏幕上
    • 从观察坐标转换为裁剪坐标的过程,称作投影变换 (Projection)
  • 标准化设备坐标 (Normalized Device Coordinate, NDC)
    • 将裁剪空间的坐标值范围映射到 [-1, 1] 范围之间,即为 NDC
    • 坐标 (0, 0) 位于裁剪空间的正中间,左下角为 (-1, -1),右上角为 (1, 1)
  • 屏幕坐标 (Screen Coordinate)
    • 对应屏幕空间
    • 将标准化设备坐标映射到屏幕坐标的过程,称做视口变换
  • 纹理坐标 (Texture Coordinates)
    • 即纹理图像上的坐标
    • 纹理坐标与像素坐标不同,无论纹理是什么尺寸,纹理坐标范围始终是 [0, 1]
    • 纹理图像的左下角坐标为 (0, 0),右上角坐标为 (1, 1)

coordinate-systems

▲ 各种坐标与变换矩阵的关系。插图来自 LearnOpenGL

Filter 中的几种坐标系

下面介绍 PixiJS Filter 中的坐标系,以及它们和 WebGL 坐标系之间的关系。

Input Coordinate

输入坐标,用于表示传入 FilterSystem 的纹理上的坐标。

Normalized Input Coordinate 是标准化之后的输入坐标,即纹理坐标,范围是 [0, 1]

Screen Coordinate

相对于 canvas 视口的屏幕坐标,单位是 CSS 像素。

CSS 像素乘以分辨率 resolution 即为屏幕物理像素。

Filter Coordinate

滤镜坐标,即被 Filter 所覆盖的范围内的局部坐标,单位是 CSS 像素。

Normalized Filter Coordinate 是标准化之后的滤镜坐标,滤镜覆盖范围的左上角坐标是 (0, 0),右下角坐标是 (1, 1)

Sprite Texture Coordinate

额外的图片纹理坐标。可以用于采样其他输入中的纹理。

Demo: DisplacementFilter

Sprite Atlas Coordinate

额外的精灵图集纹理坐标。

Filter 的输入参数

讲完坐标的种类,下面介绍 Filter 着色器代码中传入的各个参数(attributes、uniform、varying)分别代表什么,对应的是什么坐标系,以及它们的取值分别是多少。

⚠️ 以下参数适用于 PixiJS v7 版本,不排除后续版本中有变动的可能。

aVertexPosition

  • 类型:vec2 二维向量
  • 含义:Filter 所覆盖的范围内的标准化坐标
  • 坐标系:Normalized Filter Coordinate
  • 取值范围:0.0 ~ 1.0

假设有一个长宽为 300x300 的矩形 A,其原点左上角位于 (100, 30) 世界坐标处。则有:

  • aVertexPosition (0.0, 0.0) 对应矩形左上角
  • aVertexPosition (1.0, 1.0) 对应矩形右下角

projectionMatrix

  • 类型:mat3 3x3 矩阵
  • 含义:投影矩阵,用于将观察空间坐标变换到裁剪空间坐标

具体是怎么变换的,请阅读本文底部的「投影矩阵」章节。

inputSize

  • 类型:vec4 四维向量
  • 含义:输入 filter 的临时 framebuffer 大小
  • 坐标系:Input Coordinate
  • 取值范围:长宽 > 0,单位 CSS 像素

假设有一个大小为 512x512 的 framebuffer,则 inputSize 的值为:

vec4(512, 512, 0.0020, 0.0020)

其中,前两个分量 x, y 为像素大小,后两个分量 z, w 是像素大小的倒数。倒数可用于将除法转换为乘法,因为乘以一个数的倒数等于除以这个数。

以下两个表达式是等价的:

aVertexPosition * (outputFrame.zw * inputSize.zw);aVertexPosition * (outputFrame.zw / inputSize.xy);

但是在计算机中,乘法比除法的计算速度更快。所以在 GLSL 着色器这类需要高速运行的代码中,通常会尽量避免直接使用除法,而使用倒数乘法替代。

outputFrame

  • 类型:vec4 四维向量
  • 含义:Filter 所覆盖的区域在屏幕坐标中的位置与大小
  • 坐标系:Screen Coordinate
  • 取值范围:位置不限,长宽 > 0,单位 CSS 像素

还是以矩形 A 为例,其 outputFrame 取值为:

vec4(100, 30, 300, 300)

其中,前两个分量 x, y 为输出区域的位置,后两个分量 z, w 为输出区域的长宽。

vTextureCoord

  • 类型:vec4 四维向量
  • 含义:用于采样输入 filter 的临时 framebuffer 的纹理坐标
  • 坐标系:Normalized Input Coordinate
  • 取值范围:0.0 ~ 1.0

uSampler

  • 类型:sampler2D 纹理
  • 含义:输入 filter 的纹理图像,可配合 vTextureCoord 纹理坐标进行采样

inputPixel

  • 类型:vec4 四维向量
  • 含义:输入 filter 的临时 framebuffer 物理像素大小
  • 坐标系:Input Coordinate
  • 取值范围:长宽 > 0,单位物理像素

和 inputSize 类似,但是单位不是 CSS 像素,而是物理像素。以下两个表达式等价:

inputPixel.xyinputSize.xy * resolution

同样地,inputPixel.zwinputPixel.xy 的倒数,用于转换除法为乘法。

inputClamp

  • 类型:vec4 四维向量
  • 含义:用于将纹理坐标钳制 (clamp) 在 framebuffer 的有效范围内
  • 坐标系:Normalized Input Coordinate
  • 取值范围:0.0 ~ 1.0

有效范围指的是临时 framebuffer 中实际包含纹理图像的部分,其余部分可能是透明的(具体原因可阅读文章下方的注意事项)。使用示例:

vec4 color = texture2D(uSampler, clamp(modifiedTextureCoord, inputClamp.xy, inputClamp.zw));

其中,inputClamp.xy 表示有效范围的左上角,inputClamp.zw 表示有效范围的右下角。

resolution

  • 类型:float
  • 含义:分辨率,即 CSS 像素与物理像素的比率,类似 devicePixelRatio

filterArea (legacy)

  • 类型:vec4 四维向量
  • 含义:Filter 所覆盖的区域在屏幕坐标中的位置与大小

注意,filterArea 已经被标记为 legacy,你应该考虑使用其他参数替代。

// 以下语句等价于直接使用 filterArea uniformvec4 filterArea = vec4(inputSize.xy, outputFrame.xy)

filterClamp (legacy)

  • 类型:vec4 四维向量
  • 含义:兼容旧版本的 legacy uniform,与 inputClamp 等价

坐标系之间的转换

Filter 中的各种坐标系直接可以通过公式转换。

参考:v5 Creating filters · pixijs/pixijs Wiki

// Input Coord// 单位:标准化坐标vTextureCoord// Input Coord -> Filter Coord// 单位:标准化坐标 -> CSS 像素vTextureCoord * inputSize.xy// Filter Coord -> Screen Coord// 单位:CSS 像素vTextureCoord * inputSize.xy + outputFrame.xy// Filter Coord -> Normalized Filter Coord// 单位:CSS 像素 -> 标准化坐标vTextureCoord * inputSize.xy / outputFrame.zw// Input Coord -> Physical Filter Coord// 单位:标准化坐标 -> 物理像素vTextureCoord * inputPixel.xy// Filter Coord -> Physical Filter Coord// 单位:CSS 像素 -> 物理像素vTextureCoord * inputSize.xy * resolution // 与上一条语句等价

注意事项

需要注意的是,在应用 Filter 之前,PixiJS 的 FilterSystem 会先把目标的 DisplayObject 渲染到一个临时的 Framebuffer 中。

这个 framebuffer 的大小不一定等于 DisplayObject 的大小,它是一个二次幂纹理 (Power-of-two Texture)。假如你有一个 300x300 的图片要应用滤镜,那么 PixiJS 会将其渲染到一个 512x512 尺寸的 framebuffer 上,然后将这个 framebuffer 作为输入传递给着色器代码。

根据这个 DisplayObject 上的 filters 属性定义,PixiJS 会依次执行数组中的 filter,前一个的输出作为后一个的输入,最后一个输出的将渲染到最终的 render target 中。

不过这个创建临时 framebuffer 的行为可能会在自定义着色器代码中导致一些问题,比如纹理坐标的偏移,有时间后续我会另外发文章讨论。

spector-js

▲ 通过 Spector.js 抓取到的 PixiJS 渲染过程

回顾默认着色器代码

好了,梳理完各种坐标系和参数后,我们再来回头看看上面的默认着色器代码:

// 标准化的「滤镜坐标」,范围是 filter 所覆盖的矩形区域attribute vec2 aVertexPosition;// 投影矩阵uniform mat3 projectionMatrix;// 纹理坐标varying vec2 vTextureCoord;// 输入 filter 的临时 framebuffer 大小uniform vec4 inputSize;// filter 所覆盖的区域在屏幕坐标中的位置与大小uniform vec4 outputFrame;vec4 filterVertexPosition(void){    // position 算出来的是 filter 所覆盖的区域的屏幕坐标    vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;    // 通过投影矩阵,将屏幕坐标转换为裁剪空间 NDC 坐标    return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);}vec2 filterTextureCoord(void){    // 等价于 aVertexPosition * (outputFrame.zw / inputSize.xy)    // 也就是将「滤镜坐标」从 outputFrame 的范围缩放到 inputSize 的范围    // 计算出来的就是 inputSize 范围内的「纹理坐标」    return aVertexPosition * (outputFrame.zw * inputSize.zw);}void main(void){    // 裁剪空间 NDC 坐标传递给 WebGL    gl_Position = filterVertexPosition();    // 纹理坐标传递给片元着色器    vTextureCoord = filterTextureCoord();}
// 纹理坐标varying vec2 vTextureCoord;// 输入 filter 的临时 framebuffer 纹理uniform sampler2D uSampler;void main(void){    // 使用纹理坐标在传入的纹理上采样得到颜色值,传递给 WebGL    gl_FragColor = texture2D(uSampler, vTextureCoord);}

怎么样,是不是感觉清晰了很多呢?

Bonus: 投影矩阵

如果你很好奇上面的投影矩阵是怎么做到乘一下就能把屏幕坐标转换为裁剪空间坐标的,那么这一小节就可以解答你的疑惑。

🤫 偷偷告诉你,CSS 中的 transform: matrix() 也是用了同样的矩阵变换原理。

投影矩阵的默认计算方式如下,代码来自 ProjectionSystem#calculateProjection

// 矩阵表示:// | a | c | tx|// | b | d | ty|// | 0 | 0 | 1 |// 数组表示:// [a, b, 0, c, d, 0, tx, ty, 1]//// 主要参数:// sourceFrame - Filter 所覆盖的区域的世界坐标,长、宽、X、Y,像素单位// root - 控制 Y 轴反转,当渲染到 framebuffer 时投影为 y-flippedcalculateProjection(){    const pm = this.projectionMatrix;    const sign = !root ? 1 : -1;    pm.identity();    pm.a = (1 / sourceFrame.width * 2);    pm.d = sign * (1 / sourceFrame.height * 2);    pm.tx = -1 - (sourceFrame.x * pm.a);    pm.ty = -sign - (sourceFrame.y * pm.d);}

这个投影矩阵主要做了两件事:

  1. 缩放变换,从像素坐标转换到 0.x ~ 2.x 范围
  2. 平移变换,将坐标转换为 -1.0 ~ 1.0 范围内的标准坐标

对于一个长宽为 300x300,原点左上角位于 (100, 30) 世界坐标处的矩形,可得:

  • sourceFrame.width = 300
  • sourceFrame.height = 300
  • sourceFrame.x = 100
  • sourceFrame.y = 30
  • sign = 1 (此处假设为渲染至 framebuffer)

计算出投影矩阵为:

使用矩阵乘法对世界坐标进行变换:

得到如下坐标:

  • 局部坐标:(0, 0) ~ (300, 300)
  • 世界坐标:(100, 30) ~ (400, 330)
  • 缩放变换:(0.67, 0.20) ~ (2.68, 2.21)
  • 平移变换:(-1.0, -1.0) ~ (1.0, 1.0)

即可将世界坐标转换为裁剪空间的标准化设备坐标。数学,很神奇吧!👊

更多关于矩阵变换的资料可参考:


顺便测试一下我的新插件 hexo-filter-tikzjax,别在意~

逆向拼多多上的「关灯神器」,实现蓝牙遥控开关灯

依稀记得以前在某个友链博主那边看到过一篇文章,讲的是因为他们寝室所有人都懒得下床关灯,所以就用树莓派和舵机做了个远程遥控关灯的小玩意儿,当时我就感叹,果然懒才是第一生产力。

自从今年初开始出来租房住,突然就感觉睡前关灯变得好麻烦好麻烦。我的房间里是有好几盏灯的,床头的开关只能控制其中的两盏,剩下的开关在另一个地方,另外还有一个总开关位于进门的门厅处。于是我就陷入了两难之境:

  • 不用总开关:每天睡前把灯一一关掉,第二天回家又得一一开回来;
  • 直接用总开关:开关离床太远,关完灯要摸黑上床,早上起来又得先过去开灯。

不爽,太不爽了!现在都讲究智能家居,我这他喵的是智障家居啊……

作为租房一族,咱们也没法对灯啊开关啥的做电气改造(不然直接换个智能开关就完事儿了),只能使用一些「非侵入式」的方案。首先想到的就是上面提到的开发板 + 舵机,搜了一下似乎已经烂大街了,有不少成熟的方案(ESP8266 居多)。

不过我还是低估了我的懒癌,连动手都不想动了,于是直接去万能的某宝搜索「关灯神器」:

light-switch-products

(为什么不是某宝?别问,问就是消费降级)

哎呀,没想到还真有现成的,竟然还能红外 + 手机遥控,不错哦!

入手「关灯神器」

所谓关灯神器,其实也是一个能接收红外和蓝牙信号的主板,加上一个舵机来控制开关。

product-unboxing

我买的这款是 🐻 卡通款,还带了个小夜灯功能,聊胜于无吧。内置锂电池供电,可以通过 micro USB 接口充电。开模挺精准,普通 86 型墙壁开关完美适配,通过无痕胶和滑槽安装,可以卸下来充电,总体还是挺满意的。

然而,这玩意最操蛋的其实是软件部分……除了附带的红外遥控器,如果想要用手机遥控它开关灯,竟然只能用微信小程序!

微信小程序……小程序……程序……序……

讲道理,我第一次知道微信小程序是还有提供蓝牙能力的,而且还真有人用,我和小伙伴们表示都孤陋寡闻,惊了个呆。

但是这我 TMD 就很不爽了,关个灯我还要打开微信,还得用你的小程序?

rnm

作为一个合格的折腾星人,自然不能如此任人宰割。不就是一个蓝牙设备嘛,小程序能遥控,我难道就不能遥控了?

逆向「关灯神器」小程序

这里主要用到的是 wxappUnpacker 这个工具对小程序解包、反混淆。以 Android 手机为例,小程序的包文件位于:

/data/data/com.tencent.mm/MicroMsg/{hash}/appbrand/pkg/xxxxx.wxapkg

这个目录一般需要 root 权限才能访问,但不巧的是哥已经不折腾 Magisk/Xposed 好多年,手上已经没有 root 过的机器了……不过天无绝人之路,我想起来 MIUI 有自带一个应用数据备份功能,可以备份 App 的 /data 目录。

这玩意儿备份出来的东西其实就是标准的 Android 备份格式 (.ab) 前面加了个自己的文件头,去掉头就可以吃了(划掉),用 Hex Editor 删掉文件头部 414E44 以前的部分,就可以直接当做 .ab 文件处理了。

miui-backup-hex-editor

(谢谢你,雷军!金凡!)

我这里用的是 android-backup-extractor,完整流程如下:

# MIUI 的备份目录adb pull /sdcard/MIUI/backup/AllBackup/20220501_010000/ ./# 去掉 .bak 文件的头部后另存为 .ab 文件java -jar ./abe.jar unpack '微信(com.tencent.mm).ab' mm.tar# 小程序位于 apps/com.tencent.mm/r/MicroMsg/{hash}/appbrand/pkg/*.wxapkgtar xvf mm.tar

目录下可能会有很多小程序的 .wxapkg 包,这里就只能按照时间一个一个试过去了……拿到正确的小程序包以后,使用 wxappUnpacker 解包:

./bingo.sh xxx.wxapkg

解包出来呢,大概就是这样的:

wxapkg-extracted

接下来就是在源码里找控制逻辑和通信值了,看看有没有加密什么的。不得不说,小程序这种前端技术做的东西,确实和裸奔没什么区别,真的能叫做逆向吗……标题党实锤了(作为一个前端仔,看到这些东西就像回家了一样)

随便看了一圈,发现这家制造商的业务线是真的广,光看里面内置的设备类型就有:风扇、茶吧机、干衣机、夜灯、颈椎按摩仪、腰部按摩器、足部按摩器、足浴器、水暖毯、灭蚊器、加湿器、电暖器、按摩椅,感觉像是专门给人生产贴牌智能硬件的,然后遥控模块和小程序用的都是同一套,十分强大。

下面贴几块处理过的关键代码:

// 遥控按钮的入口<i-btn  hover  bindtap="remoteIR"  icon="icon-power"  id="0"  label="大灯"  type="round-big"></i-btn>// 按钮事件处理function remoteIR(e) {  var id = e.currentTarget.id;  // cmd = "01" + "807F" + "12"  // 每种产品都有不同配置,前两个都是固定的,最后的 "12" 代表开关大灯,"08" 为氛围灯  // 还有 "01" 定时十分钟,"03" 定时三十分钟,以及氛围灯亮度等等  var cmd = config.irType + config.irAddr + config.irCMD[id].value;  this.sendCMD("3201", cmd);  this.vibrateLong();}function sendCMD(e, B) {  // format2Byte 函数的作用其实就是补零到 4 位,比如 6 -> 0006  // s = "fe010006320101807F12";  var s = "fe01" + format2Byte(((e.length + B.length) / 2).toString(16)) + e + B;  sendData(s);}

下面的 sendData 也就是实际调用微信小程序 SDK 蓝牙能力的地方:

function sendData(n) {  // ArrayBuffer(10) = FE 01 00 06   32 01 01 80   7F 12  var t = new Uint8Array(    n.match(/[\da-f]{2}/gi).map(function (n) {      return parseInt(n, 16);    })  ).buffer;  wx.writeBLECharacteristicValue({    // 蓝牙设备 ID    deviceId: this.globalData.deviceInfo.deviceId,    // 对应的服务 UUID    serviceId: this.globalData.deviceInfo.serviceId,    // 可写入的特征值 UUID    characteristicId: this.globalData.deviceInfo.writeCharacteristicsId,    // 写入值    value: t,    success: function (n) {},    fail: function (n) {},  });}

简单来说,就是通过 BLE (Bluetooth Low Energy, 蓝牙低功耗) 协议连接开关设备,通过读写对应 Characteristic 的值与其通信,实现设备的控制(如开关灯)。

手动连接设备发送开关灯指令

好了,所有需要的数值现在都已经到手了,下面就尝试跳过微信小程序,手动连接设备发送指令,看看能不能正常操作吧。

这里我用到的是 BLE-调试工具 这个 Android 应用,打开后扫描蓝牙设备,找到并连接「关灯神器」。如果不知道具体是哪个设备,就选看起来比较可疑的。

然后在设备的 Service 中,找到带有 WRITE 属性的特征值 (Characteristic),就是我们用来通信的特征值了。点旁边的写入按钮,把上面逆向出的值填进去……

android-ble-test

见证奇迹的时刻,灯关上了!再次写入同样的值,灯又打开了!

欧耶✌️

还有其他的指令值也可以试一试,比如最后两位改成 08 就是开关氛围灯,等等。

写一个 Android App

想要让这个开关更“智能”,单靠手动操作手机遥控肯定是不够看的。因为手头没有开发板(听说现在树莓派都被炒上天了,不懂),所以还是让闲置的手机发挥余热吧。

好在之前学的那点 Android 开发还没有全忘光,基于 Android-BLE 这个库(其实上面我们用来测试的 App 就是这个库的 demo)和小程序里扒出来的控制逻辑糊了一个遥控 App 出来(代码放在 GitHub):

ble-light-switch

可以看到界面非常简约,不过比什么微信小程序可好用多了。幸福感 UP!

等以后有时间的话,再捣鼓捣鼓接入一下 Home Assistant,加几个自动化,不用动手直接喊 Siri 关灯,岂不美哉?(dreaming)

demo

参考链接

在 M1 Mac 上运行 macOS 虚拟机

Apple M1 芯片问世一年有余,时至今日,在 M1 Mac 上运行 Windows、Linux 虚拟机的方法都已经比较成熟了。然而 macOS 本身的虚拟化却并非如此:直到 Monterey 发布,于 M1 Mac 上运行 macOS 虚拟机才成为可能。

最近有几个小实验需要在 macOS 虚拟机上跑,本来以为去 Parallels Desktop 上开一个就完事了,搜了一下才发现,其实事情没那么简单……实际配置过程中也是踩了几个坑,所以顺带记录一下。

前提

目前想要在 M1 Mac 上运行 macOS 虚拟机,有以下要求:

  • Host OS 和 Guest OS 都必须是 Monterey
  • 安装镜像必须是 IPSW 格式

为什么 Big Sur 不行?因为在 Virtualization framework 中运行 macOS 虚拟机是 Monterey 才加入的功能

那以前怎么就能虚拟呢?因为 ARM 架构的 M1 Mac 在引导上用的其实是 iOS 那一套,不是传统的 UEFI,所以苹果官方没提供 bootloader 的话自然没戏。黑苹果也是一样的道理,只能说且用且珍惜吧。

至于 .ipsw 文件,这玩意其实就是 iOS 固件的格式……真就大号 iPad 呗!

IPSW 镜像文件可以在这里下载:Apple Silicon M1 Full macOS Restore IPSW Firmware Files Database – Mr. Macintosh

Veertu's Anka

这个是我目前最推荐的一种方法,所以放在第一个说。

Anka 是什么?根据官网的介绍,Anka 是一个专门用来管理 macOS 虚拟机的软件,可以与现有的基于容器的 DevOps 工作流集成,为 iOS 应用的构建与测试提供 CI/CD 自动化支持。再看下其开发者 Veertu,也是做 iOS CI 和 macOS 云这一块的。

并且今年十月发布的 Anka 3.0 (beta) 已经支持在 M1 Mac 上创建 macOS 虚拟机了,正是我们所需要的。

下载 Anka M1 beta 版,安装后打开,就可以直接通过图形界面创建虚拟机了:

anka-m1-beta

或者,你也可以使用命令行创建虚拟机(相关文档在这里):

anka create --ram-size 4G --cpu-count 4 --disk-size 80G \  --app ~/Downloads/UniversalMac_12.0.1_21A559_Restore.ipsw 'macOS 12'

运行虚拟机:

anka start -uv

Anka 默认将虚拟机存储在 ~/Library/Application Support/Veertu/Anka 目录下,可以参考这里修改保存位置,或者干脆做个软链接也行。虚拟机的配置文件也在同目录下的 config.yaml 文件中,有些图形界面不提供的配置项可以在这里修改。

也可以使用命令行修改,比如修改虚拟机的分辨率和 DPI:

anka modify 'macOS 12' display -r 2560x1600 -d 220

另外,Anka 提供的 Guest Tool 会自动打开虚拟机内 macOS 的自动登录、SSH 并且阻止系统休眠(应该都是为了自动化服务的),并且提供了剪贴板共享、anka cp 文件复制,以及可以直接在虚拟机内执行命令的 anka run 等功能。

anka-macos-vm

不过有一个需要顾虑的是 License 的问题,在 beta 期间可以免费使用 Anka 没问题,但不知道正式版发布以后如何。不过原本 Veertu 家面向个人开发者的 Anka Develop 就是免费的,所以或许并不需要担心。

或者,你也可以使用本文最后提到的开源方案,体验也是不错的。

Parallels Desktop

毕竟是 Mac 虚拟机行业名声最响的,其实我第一个想到的也是 PD。

查了一下,macOS 作为 Guest OS 是 PD17 才支持的功能(前略,天国的 PD16 用户),然后 17.1 更新添加了 Parallels Tools 的支持,还提了一嘴「虚拟机默认磁盘大小从 32 GB 增加至 64 GB」。

我最开始还不知道这有什么好拿出来说的,后来才知道原因:你在 PD 中甚至无法调整 Mac 虚拟机的磁盘大小。不仅是磁盘,CPU 核心数、内存大小、网络连接方式都不能改,可配置项为零(至少无法在图形界面中配置),完完全全就是个半成品。

如果你确实想安装,这里是官方教程:Install macOS Monterey 12 virtual machine on a Mac with Apple M1 chips

点「新建虚拟机」以后,安装助手里就有直接下载 macOS 的选项。看起来很友好,然而……

pd-vm-installation-failed

啃哧啃哧下载了半天,最后提示「安装系统时出错」,也不知道为什么。查了下官方 Knowledge Base,貌似也不是个例:Inability to create a macOS Monterey 12 VM on Mac computers with Apple M1 chips

后来我找到了这篇文章:Customizing MacOS guest VMs in Parallels 17 on Apple Silicon,按照其中的介绍,通过命令行创建虚拟机,竟然就可以运行了……

/Applications/Parallels\ Desktop.app/Contents/MacOS/prl_macvm_create \  ~/Downloads/UniversalMac_12.0.1_21A559_Restore.ipsw \  /Volumes/xxx/Parallels/macOS\ 12.macvm \  --disksize 80000000000

我之前用 17.0.1 版本的时候也尝试用 prl_macvm_create 创建虚拟机,但是在进度到 90% 的时候失败了,提示「内部虚拟化错误。安装失败」。升级到 17.1.0 后虽然安装助手还是「安装系统时出错」,但命令行是可以正常创建虚拟机的。

命令行启动虚拟机:

/Applications/Parallels\ Desktop.app/Contents/MacOS/Parallels\ Mac\ VM.app/Contents/MacOS/prl_macvm_app \  --openvm /Volumes/xxx/Parallels/macOS\ 12.macvm

安装完成后,在 PD 控制中心可以导入 .macvm 格式的虚拟机文件,导入以后就可以从图形界面启动了。

pd-macos-vm

作为一个商业虚拟机软件,且不说快照、Suspend,连最基本的 VM 管理功能都欠奉,我也是无话可说了。想知道还有哪些功能是目前还不能用的,可以查看 Known issues and limitations

MacVM

MacVM 是一个开源项目,基于 Virtualization framework(当然啦,大家都是用的这个),提供了简单的图形界面用于配置虚拟机。

因为作者并没有提供编译好的程序,所以需要自己使用 Xcode 从源码编译。

下载源码,用 Xcode 打开 MacVM.xcodeproj,在 Signing & Capabilities 中修改为自己的开发者证书:

macvm-xcode

点击运行,会跳出来一个文件选择框,不用管先叉掉。

然后菜单栏 File -> New,新建虚拟机。输入 CPU 核心数、内存和磁盘大小后点菜单栏 File -> Save 保存,会生成一个 .macosvm 包。之后虚拟机的虚拟磁盘镜像也会保存在这个 bundle 中,所以要留意选择保存的位置。

macvm-new-vm

然后点 Select IPSW and Continue 按钮,选择之前下载的镜像文件,点 Install,等它安装完就好了。(最开始的版本还要自己生成磁盘镜像,然后拷贝到应用容器中,还要用 Apple Configurator 2 手动装系统,相比起来现在已经友好很多了)

安装完成后,窗口会整个儿变黑,此时就可以点右上角的启动按钮启动虚拟机了。

macvm-success

用这种方法优点是开源,有啥不爽的都可以自己改,包括没有提供配置项的地方。缺点就是要自己编译,毕竟不是谁都装了 Xcode 的。

跑起来以后和上面两种基本没差别,因为实际的虚拟机创建、安装和运行都是 Virtualization framwork 实现的,整个项目的代码其实并不多。

GitHub 上还有一些类似的项目,这里也列出来供参考:

最后

以上三种方法,其实底层大家都是一样的,就看在此之上谁做得更完善了。综合来看,目前感觉 Anka 的使用体验是最好的。

关于 M1 Mac 运行 macOS 虚拟机的一些参考链接:

UTM 有一个 dev-monterey 分支,我还没有尝试,不知道以后会不会推出支持 macOS Guest 的版本。

听说还有人使用 OSX-KVMDocker-OSX 跨架构在 M1 上运行了 x86 的 macOS,但是性能很糟糕(simulation 嘛)。

另外,以上的这些虚拟机方案都不支持快照恢复,有点麻烦。不过好在我用来放虚拟机的移动硬盘是 APFS 格式的,支持写时复制 (Copy on write),所以直接把镜像整个儿复制一份就好了,很快,也不会占用多余的存储空间。

GitHub 全家桶:Actions 自动构建多架构 Docker 镜像并上传至 Packages (ghcr.io)

前段时间把 GitHub 的用户名修改成了 @prinsss,准备把其他地方的账号也修改一下的时候,却发现 Docker Hub 的 username 不能改,只能砍掉重练(npm 也是)。

想想反正我 Docker Hub 上也没上传什么东西,不如就用 GitHub 自家的 Container registry 来托管镜像吧!

这里有个小插曲:其实我挺早之前就想要改名了,但当时在忙秋招,考虑到改名后可能会有一些后续要处理(擦屁股),所以只是创建了一个 organization 把名字占住,等有时间了再正式改名。然而后来我把组织删了,想要修改 GitHub 账户的用户名时,却提示 prinsss 这个名称 unavailable(我确定它是没被占用的,因为我还能再用这个名字创建组织),不知道是不是触发了内部的什么保留机制。

最后还是发工单找客服解决了,而且等了一个多星期才回复,也是挺无语的。原来的 printempw 这个名字我也保留了,所以 printempw.github.io 这个域名还是可以访问的,目前是两边同步更新,后续再慢慢迁移。

GitHub Packages 介绍

其实最开始知道这个还是因为 Homebrew,看它每次安装软件下载 bottle 时都会从 ghcr.io 这个域名下载。好奇去查了一下,发现原来 GitHub 自己也整了一个软件包仓库,颇有一统天下的味道。

GitHub Packages 支持托管 Docker、npm、Maven、NuGet、RubyGems 等软件包,用起来比较像私有库。比起官方 registry 的好处就是其与 GitHub 完全集成,可以把源代码和软件包整合在一起,包括权限管理都可以用 GitHub 的那一套。

GitHub Packages 对于开源项目完全免费,私有仓库也有一定的免费额度

手动上传镜像

基础用法和 Docker Hub 是一样的,只是 namespace 变为了 ghcr.io。

首先创建一个 PAT (Personal Access Token) 用于后续认证:

  • 打开 https://github.com/settings/tokens/new?scopes=write:packages
  • 创建一个 PAT,勾选 write:packages 权限

注意:如果是在 GitHub Actions 中访问 GitHub Packages,则应该使用 GITHUB_TOKEN 而非 PAT 以提升安全性。后续章节会说明如何在 Actions 中使用 GITHUB_TOKEN

然后我们就可以用这个 Token 登录镜像仓库了:

export CR_PAT=YOUR_TOKENecho $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin

尝试一下推送镜像:

docker tag hello-world:latest ghcr.io/prinsss/hello-world:latestdocker push ghcr.io/prinsss/hello-world:latest

可以看到已经出现在 GitHub 上了:

packages-hello-world

刚上传的镜像默认都是 private,可以在 Package Settings 下方的 Change package visibility 处修改为公开镜像。

自动构建并上传

连镜像都放 GitHub 上了,那怎么好意思不用 GitHub Actions 呢!

下面就使用 Actions 实现代码更新后自动构建多架构 Docker 镜像,打 tag 并发布。

废话不多说,直接贴配置:

# yaml-language-server: $schema=https://json.schemastore.org/github-workflowname: Build Docker Image# 当 push 到 master 分支,或者创建以 v 开头的 tag 时触发,可根据需求修改on:  push:    branches:      - master    tags:      - v*env:  REGISTRY: ghcr.io  IMAGE: prinsss/ga-hit-counterjobs:  build-and-push:    runs-on: ubuntu-latest    # 这里用于定义 GITHUB_TOKEN 的权限    permissions:      packages: write      contents: read    steps:      - name: Checkout        uses: actions/checkout@v2      # 缓存 Docker 镜像以加速构建      - name: Cache Docker layers        uses: actions/cache@v2        with:          path: /tmp/.buildx-cache          key: ${{ runner.os }}-buildx-${{ github.sha }}          restore-keys: |            ${{ runner.os }}-buildx-      # 配置 QEMU 和 buildx 用于多架构镜像的构建      - name: Set up QEMU        uses: docker/setup-qemu-action@v1      - name: Set up Docker Buildx        id: buildx        uses: docker/setup-buildx-action@v1      - name: Inspect builder        run: |          echo "Name:      ${{ steps.buildx.outputs.name }}"          echo "Endpoint:  ${{ steps.buildx.outputs.endpoint }}"          echo "Status:    ${{ steps.buildx.outputs.status }}"          echo "Flags:     ${{ steps.buildx.outputs.flags }}"          echo "Platforms: ${{ steps.buildx.outputs.platforms }}"      # 登录到 GitHub Packages 容器仓库      # 注意 secrets.GITHUB_TOKEN 不需要手动添加,直接就可以用      - name: Log in to the Container registry        uses: docker/login-action@v1        with:          registry: ${{ env.REGISTRY }}          username: ${{ github.actor }}          password: ${{ secrets.GITHUB_TOKEN }}      # 根据输入自动生成 tag 和 label 等数据,说明见下      - name: Extract metadata for Docker        id: meta        uses: docker/metadata-action@v3        with:          images: ${{ env.REGISTRY }}/${{ env.IMAGE }}      # 构建并上传      - name: Build and push        uses: docker/build-push-action@v2        with:          context: .          file: ./Dockerfile          target: production          builder: ${{ steps.buildx.outputs.name }}          platforms: linux/amd64,linux/arm64          push: true          tags: ${{ steps.meta.outputs.tags }}          labels: ${{ steps.meta.outputs.labels }}          cache-from: type=local,src=/tmp/.buildx-cache          cache-to: type=local,dest=/tmp/.buildx-cache      - name: Inspect image        run: |          docker buildx imagetools inspect \          ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.meta.outputs.version }}

自动构建的效果可以在我的 GitHub 上查看(其实就是之前写的那个 使用 Google Analytics API 实现博客阅读量统计)。

另外有几个需要注意的点:

上传时出现 400 Bad Request

这个昨天搞得我真是一脸懵逼,报错是这样的:

#16 exporting to image#16 pushing layers 0.5s done#16 ERROR: unexpected status: 400 Bad Request------ > exporting to image:------error: failed to solve: unexpected status: 400 Bad RequestError: buildx failed with: error: failed to solve: unexpected status: 400 Bad Request

排查了好久,最后发现是我打 tag 的时候忘记加上用户名了,原本是 ghcr.io/prinsss/ga-hit-counter 的,我给打成了 ghcr.io/ga-hit-counter,难怪推不上去(也要吐槽一下这个报错,就一个 400 鬼知道是什么啊)。

上传时出现 403 Forbidden

把上面那个解决了以后,心想这次总该成了吧,结果又来了个 403,我又一脸懵逼:

#16 exporting to image#16 pushing layers 0.7s done#16 ERROR: unexpected status: 403 Forbidden------ > exporting to image:------error: failed to solve: unexpected status: 403 ForbiddenError: buildx failed with: error: failed to solve: unexpected status: 403 Forbidden

再一番排查,发现是需要在 Package Settings 中的 Manage Actions access 处指定可以访问该软件包的源码仓库(也就是 Actions 所在的仓库)。好吧……

manage-actions-access

添加了仓库,这下确实可以了。

元数据自动生成

docker/metadata-action 这是一个比较有意思的 action,它可以从源码以及触发构建的 event 中获取数据,自动生成相应的 Docker 镜像 tag 以及 label。(在 GitHub 文档官方的示例中,这是由一段脚本完成的)

比如默认的效果就是:

EventRefDocker Tags
pull_requestrefs/pull/2/mergepr-2
pushrefs/heads/mastermaster
pushrefs/heads/releases/v1releases-v1
push tagrefs/tags/v1.2.3v1.2.3, latest
push tagrefs/tags/v2.0.8-beta.67v2.0.8-beta.67, latest

也就是我现在使用的:源码推送到 master 分支则自动构建并更新 master tag 的镜像;在 git 上创建以 v 开头的 tag,Docker 那边也会自动创建相应的 tag 并且更新 latest,不错不错。(不过想想我可能保留一个 tag 触发就够了)

比如我 push 了一个 v0.2.0 的 tag 上去,自动生成的元数据就是这样的:

Run docker/metadata-action@v3Context info  eventName: push  sha: 6071f564087d49be48dc318b89fc22ff96cf6a17  ref: refs/tags/v0.2.0  workflow: Build Docker Image  action: meta  actor: prinsss  runNumber: 11  runId: 1495122573Docker tags  ghcr.io/prinsss/ga-hit-counter:v0.2.0  ghcr.io/prinsss/ga-hit-counter:latestDocker labels  org.opencontainers.image.title=google-analytics-hit-counter  org.opencontainers.image.description=Page views counter that pulls data from Google Analytics API.  org.opencontainers.image.url=prinsss/google-analytics-hit-counter  org.opencontainers.image.source=prinsss/google-analytics-hit-counter  org.opencontainers.image.version=v0.2.0  org.opencontainers.image.created=2021-11-23T14:10:35.953Z  org.opencontainers.image.revision=6071f564087d49be48dc318b89fc22ff96cf6a17  org.opencontainers.image.licenses=MIT

如果想要修改为其他方案,action 也提供了丰富的配置项,可以自行修改。

最后

用 GitHub Packages 托管 Docker 镜像,体验还是挺不错的。硬要说有什么缺点的话就是不好配置国内镜像吧,毕竟大部分国内镜像都是对应 Docker Hub 的。

另外多架构镜像的这个构建时间也是挺感人,模拟 arm64 一次要六七分钟,哈人。(所以写 Dockerfile 还是挺讲究的,怎么让缓存效率最大化,这方面还得再学习)

参考链接:

题外话,秋招后这段时间我也折腾了一些东西,有空慢慢发出来吧。

使用 ESLint + Prettier + Commitlint 规范代码风格与提交流程

最近因为课程需要开了几个多人协作的新项目,感觉有必要在团队中强制一下代码规范,免得提交上来的东西对 leader 血压不好。前后端都是 TypeScript 的,所以就用流行的 ESLint + Prettier 组合拳(配合 Standard 规范),EditorConfig 同步编辑器配置,再加上 commitlint 规范提交信息,最后用 Git Hooks 实现自动化检查。

配置虽然不难,但还是有点繁琐的,所以记录一下,如果忘了下次可以翻回来看。

简介

首先来介绍一下这些工具都是啥吧。

ESLint 是一个插件化并且可配置的 JavaScript 语法规则和代码风格的检查工具,能够帮你轻松写出高质量的 JavaScript 代码。

简单来说就是可以静态分析代码中的问题(包括语法问题和代码风格问题,比如未使用的变量,if 和括号之间没有空格),给出提示,并且能够自动修复。

Prettier 是一个“有态度” (opinionated) 的代码格式化工具。它支持大量编程语言,已集成到大多数编辑器中,且几乎不需要设置参数。

什么叫 opinionated?不同于 ESLint 什么都让你配置,Prettier 是一种严格规范的代码风格工具,主张尽量降低可配置性,严格规定组织代码的方式。

Prettier 只提供少量配置项,这么做的原因很简单:既然为停止争论而生,那么为何还需要设置更多选项让人们继续纠结于如何配置呢?

规矩就是这样,不服憋着。

检查你的 Git 提交信息是否符合配置的格式要求。

相信大家或多或少都见过某些人奔放不羁的 commit message,不仅给项目管理带来困难,看着也挺难受的。使用 commitlint 可以实现在提交前检查提交信息是否符合规范,配合 commitzen 食用更佳。


看到这里你可能有些疑问,ESLint 可以自动修复代码风格问题,Prettier 也可以格式化代码,那它们两个不会打架吗?没错,确实会有冲突的情况,而这也是我们后面要解决的。

既然会冲突,那为什么要同时使用它们呢?主要有这几个原因:

  • Prettier 的代码格式化能力更强。它的工作原理是把代码解析成 AST,然后根据规则重新输出,等于帮你整个儿重写了一遍代码。ESLint 的 --fix 自动修复虽然也可以实现一定程度的代码格式化,但没有 Prettier 效果好。
  • Prettier 支持的文件格式更多,比如 HTML、CSS、JSON、Markdown 等等。

当然,如果 ESLint 对你来说已经够用,那么不加入 Prettier 其实也是完全没问题的。

ESLint

这里我们采用 Standard 规范。

以 Vite 新建的 Vue 3 + TS 白板项目为例:

pnpm add -D \    @typescript-eslint/eslint-plugin \    @typescript-eslint/parser \    eslint \    eslint-config-standard-with-typescript \    eslint-plugin-import \    eslint-plugin-node \    eslint-plugin-promise \    eslint-plugin-vue

如果你的项目不使用 TypeScript,可以把 eslint-config-standard-with-typescript 规则替换为 eslint-config-standard

"lint": "eslint \"src/**/*.{vue,ts,js}\" --fix"

.eslintrc.js

module.exports = {  parser: 'vue-eslint-parser',  parserOptions: {    parser: '@typescript-eslint/parser',    project: './tsconfig.json',    extraFileExtensions: ['.vue']  },  extends: [    'plugin:@typescript-eslint/recommended',    'standard-with-typescript',    'plugin:vue/vue3-recommended'  ],  root: true,  env: {    node: true  },  rules: {    'vue/script-setup-uses-vars': 'error',    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',    '@typescript-eslint/explicit-function-return-type': 'off',    '@typescript-eslint/explicit-module-boundary-types': 'off',    '@typescript-eslint/no-explicit-any': 'off',    '@typescript-eslint/strict-boolean-expressions': 'off'  },  globals: {    defineProps: 'readonly',    defineEmits: 'readonly',    defineExpose: 'readonly',    withDefaults: 'readonly'  }}

tsconfig.json

{  "compilerOptions": {    "target": "esnext",    "useDefineForClassFields": true,    "module": "esnext",    "moduleResolution": "node",    "strict": true,    "jsx": "preserve",    "sourceMap": true,    "resolveJsonModule": true,    "esModuleInterop": true,    "lib": ["esnext", "dom"]  },  "include": [    "src/**/*",    ".eslintrc.js",    "vite.config.ts"  ]}

Prettier

需要注意的是,Prettier 和 Standard 规范并不完全兼容。

pnpm add -D \    prettier \    eslint-config-prettier \    eslint-plugin-prettier

.eslintrc.js

 extends: [   'plugin:@typescript-eslint/recommended',   'standard-with-typescript',   'plugin:vue/vue3-recommended',+  'plugin:prettier/recommended' ],

.prettierrc

{  "trailingComma": "none",  "semi": false,  "singleQuote": true}
"lint": "eslint \"src/**/*.{vue,ts,js}\" --fix","format": "prettier --write \"src/**/*.{vue,ts,js}\""

EditorConfig

自古以来,Tab or Space 就是不曾停歇的圣战。

不同成员都有不同的喜好,使用的编辑器/IDE 也不尽相同。那么为了 codebase 的规范,在所有项目成员中使用一个统一的配置是很有必要的。

root = true[*]charset = utf-8indent_style = spaceindent_size = 2end_of_line = lfinsert_final_newline = truetrim_trailing_whitespace = true[*.{js,jsx,ts,tsx,vue}]indent_style = spaceindent_size = 2trim_trailing_whitespace = trueinsert_final_newline = true

Commitlint

比如很多项目都采用的 Conventional Commits 就要求提交信息必须符合以下规范:

<type>[optional scope]: <description>[optional body][optional footer(s)]

为什么要使用 Conventional Commits?

  • 自动生成 CHANGELOG
  • 基于提交类型生成语义化版本号
  • 项目提交历史更清晰

Git Hooks

【鸽了】

反正就是让上面那些工具可以在 Git 提交时自动执行,检查不通过的就打回。

最后

还记得很久以前别人给我发了个 Pull Request,我一看,发现有好多地方的代码风格都和我不一样,比如单双引号、分号的使用,还有我最不能忍的 if(xxx){} 之间不加空格……

但我想想再叫人家改也怪麻烦的,就默默接受了 PR,然后再默默改成自己的代码风格……

现在有了这些东西工作流程就规范多了:

  • 你乱写也行,我直接给你格式化掉;
  • 语法检查,在编写过程中就排除潜在的 BUG;
  • 提交上来的代码必须通过以上验证,不然就拒绝;
  • 提交信息也要规范,不能瞎写乱写。

当然了,规矩是死的人是活的,这一套下来也没法保证一定万无一失。不过相比以前群魔乱舞的场面,已经省心了不少。

不过说实在话,比起配置这些工具,推行一个大家都能接受的规范才更难吧(x)

使用国内镜像加速 Laravel Sail 构建

Laravel Sail 是什么?简单来说就是一个基于 Docker 的开发环境。其核心就是一个 docker-compose.yml 配置文件和 sail 脚本,定义了 PHP、MySQL、Redis 等一系列容器,然后把程序放里面跑。

至于好处嘛,主要就是使用方便、运行环境统一、不会弄乱系统。同样是 Laravel 开发,本机安装 LNMP、Valet、Homestead 这些方法我都用过,但现在我肯定首选 Laravel Sail(容器化是真滴爽)。

不过 Laravel Sail 好是挺好,想要在墙内顺利使用还是要费点功夫的。

创建项目

以下操作均在国内网络环境下运行。其实挂个全局代理这些都不是事啦

安装 Docker 和配置 Docker Hub 国内镜像的步骤可以参考这篇文章

创建项目时,直接用 Laravel 官方的安装脚本大概率会卡在 Updating dependencies 上,为什么你懂的。不过我们还是可以借鉴下官方脚本的内容:

curl -s https://laravel.build/example-app -o install.sh

临时新建一个 composer 容器:

docker run -it --rm -v $(pwd):/opt -w /opt \  laravelsail/php80-composer:latest bash

在容器内配置 composer 镜像,创建 Laravel 项目:

composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/laravel new example-app

安装 Laravel Sail 相关文件(这里可自定义需要使用的服务):

cd example-appphp ./artisan sail:install --with=mysql,redis,meilisearch,mailhog,selenium

导出底层 Dockerfile,方便后续定制:

php ./artisan sail:publish

完成后,可以看到项目下多出了一个 docker 目录和 docker-compose.yml 文件。

定制 Dockerfile

Laravel Sail 有一个主要的容器,用于运行 PHP 主程序。其他比如 composer, artisan, node, npm 命令也是在这个容器里运行的。

后续你可以通过 sail shell 来访问这个容器。

这个主容器的 Dockerfile 就是我们上面导出的那个。可以看到它主要就是在 Ubuntu 的基础上安装了一些软件包,大部分的网络问题都是这里造成的。

使用国内镜像替换之,主要需要替换的软件源有:

  • Ubuntu
  • PPA
  • Composer
  • Node.js
  • Npm

修改后的 Dockerfile 如下:

FROM ubuntu:21.04LABEL maintainer="Taylor Otwell"ARG WWWGROUPWORKDIR /var/www/htmlENV DEBIAN_FRONTEND noninteractiveENV TZ=Asia/ShanghaiENV APT_MIRROR http://mirrors.ustc.edu.cnENV NVM_DIR /usr/local/nvmENV NODE_VERSION 16.9.1ENV NVM_NODEJS_ORG_MIRROR https://mirrors.ustc.edu.cn/nodeENV NVM_SOURCE https://hub.fastgit.org/nvm-sh/nvm.gitSHELL ["/bin/bash", "-o", "pipefail", "-c"]RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezoneRUN echo 'APT::Acquire::Retries "5";' > /etc/apt/apt.conf.d/80-retries \  && sed -i "s|http://archive.ubuntu.com|$APT_MIRROR|g; s|http://security.ubuntu.com|$APT_MIRROR|g" /etc/apt/sources.list \  && sed -i "s|http://ports.ubuntu.com|$APT_MIRROR|g" /etc/apt/sources.list \  && apt-get update \  && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python2 \  && mkdir -p ~/.gnupg \  && chmod 600 ~/.gnupg \  && echo "disable-ipv6" >> ~/.gnupg/dirmngr.conf \  && apt-key adv --homedir ~/.gnupg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys E5267A6C \  && apt-key adv --homedir ~/.gnupg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C300EE8C \  # PHP  && echo "deb https://launchpad.proxy.ustclug.org/ondrej/php/ubuntu hirsute main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \  && apt-get update \  && apt-get install -y php8.0-cli php8.0-dev \    php8.0-pgsql php8.0-sqlite3 php8.0-gd \    php8.0-curl php8.0-memcached \    php8.0-imap php8.0-mysql php8.0-mbstring \    php8.0-xml php8.0-zip php8.0-bcmath php8.0-soap \    php8.0-intl php8.0-readline php8.0-pcov \    php8.0-msgpack php8.0-igbinary php8.0-ldap \    php8.0-redis php8.0-swoole php8.0-xdebug \  # Composer  && curl -so /usr/bin/composer https://mirrors.aliyun.com/composer/composer.phar \  && chmod a+x /usr/bin/composer \  && composer --version \  && composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/ \  # Node.js  && mkdir -p $NVM_DIR \  && curl -so- https://raw.fastgit.org/nvm-sh/nvm/v0.38.0/install.sh | bash \  && source $NVM_DIR/nvm.sh \  && nvm install $NODE_VERSION \  && nvm use $NODE_VERSION \  && node -v && npm -v \  && npm config set registry https://registry.npm.taobao.org \  # Yarn  && npm install --global yarn \  && yarn config set registry https://registry.npm.taobao.org \  && apt-get install -y mysql-client \  && apt-get install -y postgresql-client \  && apt-get -y autoremove \  && apt-get clean \  && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modulesENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATHRUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.0RUN groupadd --force -g $WWWGROUP sailRUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sailCOPY start-container /usr/local/bin/start-containerCOPY supervisord.conf /etc/supervisor/conf.d/supervisord.confCOPY php.ini /etc/php/8.0/cli/conf.d/99-sail.iniRUN chmod +x /usr/local/bin/start-containerEXPOSE 8000ENTRYPOINT ["start-container"]

目前有个不足之处就是 Node.js 的安装,原来用的 NodeSource 现在没有可用的国内镜像源,只能改用 nvm 安装。但是 nvm 在 PATH 的处理上有些问题(它是通过一个脚本修改环境变量,把当前启用的 Node 版本添加到 PATH 里去,但是 Dockerfile 里不能动态设置 ENV),目前只能手动指定要安装的 Node 版本了。

运行容器

使用 Laravel Sail 提供的脚本运行容器:

./vendor/bin/sail up

这个脚本主要的工作就是读取 .env 里配置的环境变量,然后通过 docker-compose 在容器里执行相应命令,所以基本用法和 docker-compose 是一致的:

sail up -d  # 后台运行sail down   # 停止运行

耐心等待镜像构建完成,Laravel Sail 就可以正常运行啦。

Creating network "example-app_sail" with driver "bridge"Creating example-app_redis_1       ... doneCreating example-app_selenium_1    ... doneCreating example-app_meilisearch_1 ... doneCreating example-app_mailhog_1      ... doneCreating example-app_mysql_1       ... doneCreating example-app_laravel.test_1 ... done

laravel-sail-app

参考链接

博客主题可以自动切换深色模式啦

有时候我也很佩服自己,这么简单的一个功能,写写也就几个小时,一年多前就想搞了,竟然给我拖到现在才装上去。拖延症,恐怖如斯!

以前我对深色模式其实不怎么感冒,主要感觉开了也没啥用,就系统界面变黑了,其他 App 里还是白色的,等于没开。不过这几年大部分应用的适配都跟上来了,体验也就好起来了,晚上玩手机看着不那么刺眼,挺好的。

现在浏览器网页也支持检测用户的系统主题色,所以我也凑个热闹,给博客加上了自动切换浅色/深色主题的功能。适配过程还是挺顺利的,记录一下供参考。

原理

就是使用 CSS 的 prefers-color-scheme 媒体查询。

@media (prefers-color-scheme: dark) {  /* dark theme styles go here */}

参考文档:prefers-color-scheme - CSS | MDN

不过需要注意的是,不支持 IE

使用方法

最简单的例子:

body {  background-color: white;  color: black;}@media (prefers-color-scheme: dark) {  body {    background-color: black;    color: white;  }}

这样在亮色模式下是白底黑字,在暗色模式下就是黑底白字。

依样画葫芦,给原主题中颜色相关的 CSS 加上对应的深色样式就差不多了。

使用 mixin 处理颜色

拿我自己写的这个主题举例,在主题中我们一般会用到很多颜色。一个常见的做法就是使用 CSS 预处理器,把这些颜色定义成变量方便后续使用(我用的是 Stylus):

$color-primary        = convert(hexo-config('primary_color'));$color-background     = #fff;$color-text           = #333;$color-text-secondary = #999;

同样,定义这些颜色的深色版本:

$color-primary-dark        = convert(hexo-config('primary_color_dark'));$color-background-dark     = #181a1b;$color-text-dark           = #c8c3bc;$color-text-secondary-dark = #a8a095;

引用之:

body {  background-color: $color-background;  color: $color-text;}a {  color: $color-primary;}@media (prefers-color-scheme: dark) {  body {    background-color: $color-background-dark;    color: $color-text-dark;  }  a {    color: $color-primary-dark;  }}

然而问题来了,这样岂不是要写很多媒体查询语句?麻烦且不说,看着都眼花。如果把不同地方的这些语句集中起来,放在一起,又会破坏模块设计,也不利于后续维护。

想要写得简洁一点,不妨利用 CSS 预处理器的 mixin 特性

定义 mixin(可以理解为可重用的代码片段):

// 根据传入参数拼装变量名color-themed(name) {  color: lookup('$color-' + name);  @media (prefers-color-scheme: dark) {    color: lookup('$color-' + name + '-dark');  }}

这个 mixin 的意思就是我们传一个名称进去,它会根据这个名称去查找对应的颜色变量及其深色版本,然后一起应用。

如此一来,上面的样式就可以简化为:

body {  background-color-themed: 'background';  color-themed: 'text';}a {  color-themed: 'primary';}

使用 CSS 变量处理颜色

用上面那种方法,比原来的是好了不少,但感觉不太直观。

另一种方法,就是用 CSS 原生的变量机制来处理颜色。定义变量:

:root {  --color-primary: #7065a3;  --color-background: #fff;  --color-text: #333;  --color-text-secondary: #999;}@media (prefers-color-scheme: dark) {  :root {    --color-primary: #bb86fc;    --color-background: #181a1b;    --color-text: #c8c3bc;    --color-text-secondary: #a8a095;  }}

使用:

body {  background-color: var(--color-background);  color: var(--color-text);}a {  color: var(--color-primary);}

是不是清爽了很多呢?

不过遗憾的是,IE 浏览器不支持 CSS 变量。(又是你!!!🙃

所以为了兼容性我还是选了预处理器 + mixin 的方法,这样在 IE 上虽然不能自动切换,但至少能保证默认的浅色主题是可以正常显示的。而如果全部使用 CSS 变量的话,在不支持的浏览器上就啥都没有了,得考虑 polyfill 和 fallback,还是算了。

如果不用考虑兼容旧浏览器的话,CSS 变量是最佳选择。

加载外部样式

使用 link 标签加载的外部 CSS 也可以指定媒体查询

比如本主题使用的 highlight.js 代码高亮的样式:

<link rel="stylesheet" href="atom-one-dark.min.css" media="screen and (prefers-color-scheme: dark)"><link rel="stylesheet" href="atom-one-light.min.css" media="screen and (prefers-color-scheme: light)">

这样在浅色模式下会加载 light 样式,在深色模式下会加载 dark 样式。

参考

另外,关于深色模式下的图片要如何处理,其实也是需要考虑的。

不过我懒,就直接不管了。更详细的相关内容可以参考:

最后是自动切换的效果图(视频):

最近考的几个证:软考、N1 和驾照

学习使我快乐。才怪。

要不是为了多少增加点竞争力,我又何必折腾自己去考这些证。快要大学毕业找工作了,最近这段时间明显感觉自己比以前焦虑了不少。俗话说得好,打不过就加入。虽然也不知道这些证以后能不能派上用场,不过总比没有好,万一用上那就是赚了。

这次考的几个其实我都没有花很多时间准备,不过所幸还是都拿到手了。所以记录一下感想,也顺便给博客除除草。在网上说这些其实无异于增加实名上网的风险……不过 fxck it,随他去吧。

软考

软考是什么?

软考也叫软件水平考试,全称计算机技术与软件专业技术资格(水平)考试,是由国家人力资源和社会保障部、工业和信息化部领导的国家级考试。

有什么用?

国企评职称、积分落户、写简历上唬人。

有哪些项目?

软考有高级、中级、初级三种级别,每个级别中又有不同的资格设置。比如高级就有分为信息系统项目管理师、系统分析师、系统架构设计师、网络规划设计师、系统规划与管理师。完整列表可以看这里

据我观察,高级里报的人最多的是信息系统项目管理师(俗称高项),中级的是软件设计师,初级都没啥人报,可能是太简单了没含金量吧。

考试安排?

每年考两次,分别安排在上半年和下半年。上半年考试时间为5月下旬,下半年考试时间为11月上旬。

考试题型?

考试分为上午场和下午场,初级、中级是上午考基础知识,下午考应用技术。高级则是上午综合知识,下午案例分析加论文。以中级为例,上午题型都是选择题,下午是主观问答题。

每科目总分都是 75 分,45 分及格,上午下午都考 45 分以上就算过了。

考试感想?

就说我报考的软件设计师(中级),感觉不难。考的东西基本都是大学计算机专业课教的那些,微机原理、程序设计语言、数据结构、操作系统、软件工程、面向对象、算法、数据库、计算机网络,其他就没啥了,范围广但不深。

说句欠揍的话,我这次考试的准备时间全部加起来可能都没有三天……倒也不是我自大,就纯粹是拖延症犯了,一直拖啊拖,直到考前一天才开始刷真题(我当时都快放弃了)。最后看了一个通宵,第二天下午的时候差点昏睡考场。

个人感觉如果大学 CS 专业课都过了的话,那过个软考中级应该还是没啥压力的。考试当天我在考场逛了一下,来考的除了几位头顶显得有些寂寥的大叔,基本上都是大学生。所以趁着专业课学的东西还没忘,直接考掉正好。

怎么准备?

首先看一下报考科目的考试大纲(比如:2021年软件设计师考试大纲),了解一下大致要考的内容。就像我说的,基本都是大学专业课里教过的东西。

教材的话有官方指定的教材,比如软件设计师就是《软件设计师教程(第5版)》。这本教材我就是大致浏览了一遍,然后等做题碰到有不会的再来翻书了。

最重要的是刷题,做历年的真题(网上资源很多)。

真题看几遍后就能发现很明显的题型规律,比如哪几题会考微机原理,哪几题会考网络、操作系统等等,还有最后 5 题是雷打不动的完形填空,考英语的。至于下午场那就更是换汤不换药了,第一题数据流图,第二题数据库设计,第三题 UML,第四题算法,第五题代码填空 Java/C++ 二选一。

考完了有用吗?

i-dont-know

至少能证明你计算机专业还是学到点东西的,聊胜于无吧。我这次报了中级没报高级,也是寻思一口不能吃成个胖子,毕竟高级要写的论文我完全没练过,肯定是写不来的。

这次中级过了,下次有机会就挑战高级吧。

JLPT N1

N1 是什么?

日本语能力测试(Japanese-Language Proficiency Test,简称 JLPT)是以母语非日语者为主要对象的日语能力考试。目前考试设有 N1 至 N5 级、共 5 个级别,当中以 N1 级难度最高。

据我观察,大部分人都是报的 N1、N2,N3 以下就没多少人报了。

有什么用?

JLPT 是现时最具认受性的日语能力考试。留学、进外企的时候用得到吧,大概。

反正是公认知名度、认可度最高的日语考试,蛮考一个放着。

考试安排?

每年举办两次,于 7 月和 12 月的第一个星期日实施。

然而最近因为新冠疫情导致去年 7 月的考试被取消,后来又取消了部分考点,导致现在 JLPT 报名经常「一座难求」,考场座位都得靠抢(我这次运气不错,捡漏抢到了一个)。微博上甚至有个狠人,因为附近的考点名额已满,最后报了个内蒙古的考点……大老远的坐飞机去考试,只能说是够拼的。

还有,这破考试报名费老他妈贵了,N1、N2 报名费要 550 块,你怎么不去抢!

nihon-shuuryou

考试题型?

分为语言知识、阅读、听力三部分,全是选择题。

N1 总分 180,100 分合格(要求每项分数都大于等于 19 分)。

考试感想?

不考察口语和写作,而且全是选择题,要及格真的挺简单的。何况我从高中开始自学日语,要是 N1 都考不过那我一头撞死算了。而且过了 N1 但实际水平堪忧的人我这几年也见过不少,所以确实没啥好拿出来说的。

考试的话,重要的就是词汇、语法和听力。词汇方面不是我吹牛逼,看了这么久的小说漫画动画片,词汇量总归是累积了不少的。语法的话,不少 N1 文法在日常生活中其实不怎么见得到,所以还是要专门看看。听力就不说了,以前看虚拟管人的那段时间算是给我锻炼出来了,就听力考试那字正腔圆的,给我两倍速都没问题好吧。

考完了有用吗?

不知道,我从来没有想过这个问题.jpg

权当自我满足了,证明下自己吧。

驾照

嗯……这个真没什么好说的了,写上来凑数的。科一到科四都是一把过,爽到。

我考的是手动挡 C1 驾照,感想的话就是汽车比想象中的要好开,挂档踩油门就走,踩刹车就停,方向盘转车也跟着转,说实话还挺爽的。以前我就喜欢看发动机、变速箱原理之类的视频,实际上手理解起来也算快。

不如说学车的时候感觉大部分时间是在学交通规则,比如左转直行右转都有专门车道、红灯也可以右转(除非有右转信号灯)这些都是我学了以后才知道的。另外一点就是发现路上不守交规的人其实挺多的,越实线超车、变道转向不打灯的看到过好多次。以前我也不知道所以没感觉,现在自己开车的时候就看的明白了。

总之,多个证多一种可能性吧,本拿到了以后有机会就能自驾游了,happy 😋

年轻人的第一次救护车:原来中暑真的会晕倒!

今天一天解锁了好几个人生成就:

  • ✅ 中暑昏厥
  • ✅ 被送上救护车
  • ✅ 进急诊室

不开玩笑,以我的经历在此提醒各位,一定不能小瞧中暑的危害啊!!

发生了什么

下午本来我舒舒服服躺在床上玩手机,突然来了一个电话,让我出门办事儿。地方远倒也不远,一公里多,我就寻思弄辆共享单车骑过去,权当锻炼身体了。

当时天气那叫一个热,估计正好是一天里最热的那段时间,才刚从空调房里出来身上就冒了一层汗,走了不到一百米感觉人都要蔫了。为了打起精神,我也特意选了点欢快的歌在路上听。

之后嘛,我也不知道吃错了什么药,上了路越骑越起劲,一辆破自行车蹬得飞快,差不多一首歌的时间就到了。就在我锁好车,准备步行过去的时候……

最开始是轻微的头晕,我也没怎么放在心上。

然后突然视野开始变得昏暗、扭曲,整个人头晕目眩,意识也恍惚起来(有点类似于蹲坑蹲久了,然后突然站起来的那种感觉)。要形容的话就是感觉天一下子变黑了,然后套了个重影 + 色差特效的滤镜,天旋地转,根本分不清到底是世界在摇晃还是我人在摇晃。我凭着剩余的思考能力,心想糟糕,八成是刚才骑太猛,现在脑部血液供应不上了。于是晃晃悠悠地想找个东西扶着缓一下。

结果我下一次睁眼的时候,面前已经是一个不认识的人了。

我分辨出他是在问我「没事吧?」,这才忽然意识到,我刚才应该是失去意识晕过去了,是这位路过的好心人叫醒了我。我赶忙向他道谢,说自己没事,缓一下就好了,于是他也好像稍微放心了的样子。

然而,我再下一次睁眼的时候,眼前又是那位好心人在问我,「你真的没事吗?要不我帮你打 120 吧」。这我真的没想到,我竟然又晕过去了一次……(梅开二度)。这次旁边的人也变多了,一位环卫阿姨和另一位应该也是路过的大姐在担心地看着这边。

不得不说失去意识的感觉很神奇。在我主观感受中,两次昏厥好像都是眼睛一闭一睁的事儿,前一秒我还在锁共享单车,下一秒人就在地上了,再下一秒他就打 120 了。我只能从他停在一旁的电瓶车推断出,他是先走远了,然后发现我又昏倒后再折回来帮我的。我却完全不知道这之间经过了多久,真的很感谢这几位好心人。🙏

在等救护车的途中,我的意识也开始逐渐恢复了:两只耳机都还挂在耳朵上,放着曲调轻快(害人)的爱抖露曲;检查了一下全身,手肘、膝盖都有不少擦伤,应该是倒地的时候留下的;呼吸急促,身体出汗量很大,衣服都湿了。

之后救护车到了,我也清醒得差不多了,不过为了以防万一还是给拉去医院挂了急诊(长这么大,人生中第一次被救护车拉走……),做了一大票检查,血常规呀 CT 啥的,先后排除了:低血压、低血糖、心脏病、脑部等问题,最后医生得出结论:

应该就是中暑了。

中暑真的会昏厥

以前我也有所耳闻,比如军训时有某某同学晕倒了,但没想到有这么夸张。

从医院回来以后我查了点资料,感觉我这种症状可能是属于「热失神」:

  • 原因:多发于直射日光下长时间活动或高温高湿的室内[1]。由于流汗引致的脱水和末端血管的扩张,全身的血液循环降低而导致。
  • 症状:意识在突然之间消失。体温比平常的高,明显地流汗,脉搏呈现徐脉(缓慢的脉搏)。

搜集的一些相关资料:

在我的印象里应该是要重度中暑,比如在阳光下暴晒、高温环境长时间工作那样的人才会晕倒,没想到我这就出门骑个自行车人也倒了。人类的身体真是脆弱啊……

而且我这次算是比较幸运的,下了车走在马路牙子上才晕倒。如果是骑着骑着失去意识,给哪里磕个一下的话,那真是想想都觉得后怕。

反省这次的事件,主要问题应该就是:

  • 之前一直待在空调房里,突然出门温度不适应;
  • 骑车太快,日照、高温下剧烈运动;
  • 听歌导致注意力涣散,没有及时察觉身体异常。

早知道还不如打个车去,屁事没有。

ac-meme

最后

说实在话,我以前确实没觉得中暑有多严重,这次算是给我吃了个教训。

希望各位读者也能以我这次的经历为戒,不要小看中暑,炎热天气出门做好预防呀!

AbemaTV 网页版与客户端强制 1080p

最近在看动画《佐贺偶像是传奇 卷土重来》(ゾンビランドサガ リベンジ),官方的同步网络放送有 Amazon Prime Video 和 AbemaTV。因为我已经订阅 Netflix 了,感觉 Prime Video 不怎么用得到,所以还是用免费的 Abema 吧。

然而 Abema 很不爽的一点就是动态分辨率(不如说很多流媒体网站都这样),就喜欢自己判断你网速够不够,然后给你播放带宽相应的清晰度。初衷估计是为了任何网络环境条件下都能流畅播放,可是讲道理,我就没见过这个自动判断准过几次。

就算在设置里开到最高画质,还是动不动给我跳到 480p 甚至是 360p。我他喵的到底是哪里不行,就只配看你这马赛克画质?

真是叔可忍婶不可忍,于是我去网上找有没有 Abema 强制 1080p 的方法(因为也有个 Chrome 启用 Netflix 1080p 的扩展),正好找到了一个

const open = XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open = function () {  arguments[1] = arguments[1].replace('720/playlist.m3u8', '1080/playlist.m3u8');  open.apply(this, arguments);};

看了一下,没想到原理还挺简单,就是油猴脚本劫持 XHR 请求方法,把其他清晰度的 m3u8 playlist 全部重定向到 1080p 的 playlist 上。这样不管怎样请求,实际返回的都是 1080p 清晰度的源了。想想确实是这个理,高啊!

还好以前捣鼓过一段时间 HLS 相关的东西,所以这些不算陌生。不过实际使用的时候发现 XMLHttpRequest 并没有拦截到 playlist.m3u8 之类的请求,看了一下,原来是 Abema 请求的时候改用了 fetch API。反正大同小异,稍微修改一下就行了。

修改后的脚本同样放在 Gist 上:

// ==UserScript==// @name Abema 强制 1080p// @description 把其他清晰度的播放列表强制指向1080p// @version 1.1.0// @run-at document-start// @namespace Violentmonkey Scripts// @match https://abema.tv/*// @grant none// ==/UserScript==const originalFetch = window.fetchwindow.fetch = (...args) => {  const regex = /\/\d+\/playlist\.m3u8/  if (typeof args[0] === 'string' && args[0].match(regex)) {    args[0] = args[0].replace(regex, '/1080/playlist.m3u8')    console.log(args[0])  }  return originalFetch(...args)}

题外话,好久没追过新番了,但佐贺二期开播后我现在每天都想快进到周四……

啊啊啊!

zombieland-saga-revenge-ep3

好了,网页端的问题解决了,那么客户端是不是也可以用这个方法呢?

抓包看了一下,客户端请求的确实是同样的 URL,直接如法炮制。

不过要修改客户端的请求就不像 Web 浏览器那么简单了,需要用到 MitM(中间人)攻击。目前 iOS 平台上主流的代理软件应该都支持 MitM,比如 Surge/Quantumult X/Shadowrocket。下面以 Quantumult X 为例。

开启 MitM 功能,添加主机名如下:

更新:网页版和手机版请求的是 vod-abematv.akamaized.net,iPad 上请求的是 ds-vod-abematv.akamaized.net,搞不懂,直接通配符算了。

*abematv.akamaized.net

安装并信任证书(过程略),修改配置文件,添加重写规则:

[rewrite_local]abematv\.akamaized\.net/.+/\d+/playlist\.m3u8 url request-header /\d+/playlist.m3u8 request-header /1080/playlist.m3u8

验证强制 1080p 是否成功(开启 HTTP 调试功能查看):

quantumult-x-rewrite

可以看到原本是要加载 720p 的,经过重写后实际加载的是 1080p 的 segment。

爽!

使用 Eagle 管理各种图片素材

不知道各位有没有遇到过这种问题?

上网冲浪存了好多沙雕梗图,有时候突然感觉其中一张图能派上用场,但想找却怎么也找不到,也不知道当初存哪里去了;

大事小事都喜欢截图,玩游戏截图,看动画片也截图,截了一大堆到头来也不怎么用,乱七八糟的混在一起,删了又感觉怪可惜的;

看到喜欢的图总之先右键保存(话说现在是不是已经不兴说「右键保存」了,「长按保存」可能还更普遍一点,大人时代变啦),久而久之图库越来越大,又分散在硬盘的不同角落,整理起来也愈发力不从心……

啊。没有吗。好吧。🥲

说正经的,如果你也有很多图片需要管理,那么你应该试试 Eagle 这款软件。


为什么呢?

如果单纯依靠文件系统整理(用文件夹分类),缺点还是很明显的:

  • 一张图片只能属于一个分类,除非复制多份;
  • 不能给图片加标签、备注;
  • 浏览切换目录不方便,需要借助其他看图软件;
  • 目录下图片太多容易把 Explorer/Finder 卡住。

使用专业的素材管理软件,可以解决绝大多数的这种问题。

当然了,这种类型的软件不只有 Eagle 一个。但是我只用过 Eagle,所以就不推荐其他的啦。如果有更好的选择,欢迎在评论区告诉我。

eagle-website-screenshot

我目前用到的功能基本上有这些:

  • 分类、标签、备注
  • 按颜色、图片尺寸等筛选
  • 瀑布流浏览
  • 重复图片检查
  • 浏览器扩展拖拽保存网页图片

以我自己的体验来说,比起原来用文件管理的时候,真的舒适了太多。相见恨晚啊!

比如下面是我珍藏的(到处偷来的)小叮当图:

eagle-library-memes

还有这几天在看(吹)《佐贺偶像是传奇》

eagle-library-screenshots

不过有一点遗憾的是,目前在手机上还没有浏览 Eagle 资源库的办法。

Eagle 资源库的目录结构是这样的:

我的图库.library├── images│   ├── KEZAL1JG0RN32.info│   │   ├── IMG_1507.png│   │   ├── IMG_1507_thumbnail.png│   │   └── metadata.json│   ├── KEZAL1JG2YXNI.info│   │   ├── 我太菜了 对不起.jpg│   │   └── metadata.json│   ├── KEZAL1JG34XY6.info│   │   ├── 可恶 你们这些被资本主义毒惑到骨子里的猪.jpg│   │   └── metadata.json│   ├── KEZAL1JG4HFBR.info│   │   ├── illust_71093876_20191005_193032.jpg│   │   ├── illust_71093876_20191005_193032_thumbnail.png│   │   └── metadata.json...

每张图片外面都包了一个文件夹,包括分类、文件夹层级等元信息都存在里面的 metadata.json 中,从外界无法直接浏览(这也是为部分人所诟病的一点)。

而官方似乎也没有出移动端 App 的打算……

一个妥协的办法就是使用 Eagle 的导出功能,导出成正常目录结构,然后放到手机上(可以用 Documents 等应用从电脑自动同步)。

eagle-export-sync

这样算是勉强能看,不过到底还是不方便。

前段时间看到 Eagle 公开了 API,本来想弄个简陋的网页客户端,写了个小 demo,倒是确实能浏览图库了。所以功能实现估计是没什么障碍的,奈何后来犯懒,一直搁置到现在,只能有缘再说了。

最后,Eagle 是收费软件,¥199 两台设备,终身授权,学生有教育优惠。

如果你有图片整理需求的话,去试用一下吧,不会亏的。

尝试为本地视频开启 AirPods Pro 空间音频

AirPods 2 之后,前段时间我又没忍住,入手了传说中的豌豆射手 AirPods Pro。

第一次体验到的「主动降噪」功能确实惊艳,虽然感觉对人声的处理还是差了点意思,但开和不开真的是两种完全不同的感受。尤其是乘坐公共交通的时候,感觉是终于夺回了一点耳朵的主导权,不用再一味地被各种噪声强奸了。

用了几个月,总的来说挺满意的,就是这人声降噪实在是普通,近处的人说话大声点就挡不住了(比如室友),只能开音乐盖过去。有人说是后续固件削弱了,不过我感觉买来就这吊样。可惜市面上还是没有能达到聋子模拟器效果的降噪耳机,遗憾。


AirPods Pro 另外一个比较受关注的新功能就是「空间音频」,简单来说就是通过耳机里的各种传感器和一系列计算来调整声音,模拟出影院级(据说)的环绕声效果。到手的时候我就拿爱奇艺上的《复仇者联盟4》测试了一下,确实挺 amazing 的。

为什么要说这些呢?其实是因为我最近下了个 EVA 新剧场版的资源,里面封装的音轨是 6.1 声道的。然而就是这个视频文件,在播放到战斗激烈的场景时,我的所有设备上,无论扬声器还是耳机都会出现爆音(clipping)。

eva-new-film

新剧场版终章真的要上映了,你庵野鸽鸽什么时候骗过你

初步排查应该是多声道音源 downmix 成双声道播放的时候出问题了,因为封装在一起的双声道粤语音轨并不会爆音。感觉挺奇怪的,难道这些播放器在 downmix 的时候默认都没有音量电平限制吗?

就在搜索解决方法时,我突然想到了 AirPods Pro 的「空间音频」功能:既然这音轨本身就是环绕声,那么如果用空间音频来播放,岂不是正好?

查了一下,目前支持为本地视频开启空间音频的播放器不多:

比如我主力使用的 nPlayer 目前就还不支持,遗憾。

另外也发现了个有意思的 App:Surround Sound Speaker Check

貌似原本是用来给家庭影院的环绕声音响系统做测试的,没想到还能拿来测试 AirPods Pro 空间音频,哈哈哈。用这个测试了一下,空间音频确实很有感觉,普通的双声道模拟环绕声的效果相形见绌,推荐各位也试试。

下面对帖子中提到的几个播放器做一下测试。


使用的视频文件如下。

测试文件①:杜比官方宣传片H.264 + AC-3MP4 封装)

Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'dolby-atmos-trailer_amaze_1080.mp4':  Duration: 00:01:03.55, start: 0.000000, bitrate: 9396 kb/s    Stream #0:0(und): Video: h264 (Main) (avc1 / 0x31637661), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], 8946 kb/s, 24 fps, 24 tbr, 24 tbn, 48 tbc (default)    Stream #0:1(und): Audio: eac3 (ec-3 / 0x332D6365), 48000 Hz, 5.1(side), fltp, 448 kb/s (default)

测试文件②:网上摸来的多声道 AACH.264 + AACMP4 封装)

Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'ChID-BLITS-EBU.mp4':  Duration: 00:00:46.63, start: 0.000000, bitrate: 188 kb/s    Stream #0:0(und): Video: h264 (Main) (avc1 / 0x31637661), yuv420p, 800x600, 26 kb/s, 8 fps, 8 tbr, 8 tbn, 16 tbc (default)    Stream #0:1(und): Audio: aac (HE-AAC) (mp4a / 0x6134706D), 44100 Hz, 5.1, fltp, 159 kb/s (default)

测试文件②:上面提到过的 EVA 新剧场版H.264 + FLACMKV 封装)

Input #0, matroska,webm, from 'Evangelion 1.11 You Are (Not) Alone 2007 [BD 1920x1080 23.976fps AVC-yuv420p10 FLAC].mkv':  Duration: 01:40:58.57, start: 0.000000, bitrate: 12663 kb/s    Stream #0:0(jpn): Video: h264 (High 10), yuv420p10le(progressive), 1920x1080, SAR 1:1 DAR 16:9, 23.98 fps, 23.98 tbr, 1k tbn, 47.95 tbc (default)    Stream #0:1(jpn): Audio: flac, 48000 Hz, 6.1, s16 (default)    Stream #0:2(eng): Audio: flac, 48000 Hz, 6.1, s16    Stream #0:3(chi): Audio: flac, 48000 Hz, stereo, s16

测试文件④:对测试文件③进行转码(H.264 + AC-3MP4 封装)

ffmpeg -i 'Evangelion 1.11 You Are (Not) Alone 2007 [BD 1920x1080 23.976fps AVC-yuv420p10 FLAC].mkv' -map 0:v:0 -map 0:a:0 -c:v h264_videotoolbox -c:a ac3 output.mp4
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'output.mp4':  Duration: 01:40:58.57, start: 0.000000, bitrate: 52 kb/s    Stream #0:0(jpn): Video: h264 (High) (avc1 / 0x31637661), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], 559 kb/s, 23.98 fps, 23.98 tbr, 24k tbn, 48k tbc (default)    Stream #0:1(jpn): Audio: ac3 (ac-3 / 0x332D6361), 48000 Hz, 5.1(side), fltp, 448 kb/s (default)

测试结果(✅ 表示可以激活空间音频):

文件1文件2文件3文件4
PlayerXtreme
FE File Explorer❓ *
Plex✅ **

* FE File Explorer 播放 MKV 格式视频需要高级版,我就没测

** 需要修改 Plex Media Server 的配置,参考这篇文章


目前猜测触发空间音频的条件是:

  • 使用系统解码器播放
  • 音频为环绕声 AAC 或 AC-3 (E-AC-3)

由于有服务端转码的配合,Plex 的兼容性表现最好,推荐。

题外话:经过 Plex 转码以后,就算关闭耳机的空间音频,直接播放也不会出现爆音了,不知道是不是转码的时候做了处理。另外空间音频效果确实好,结尾主题曲响起的时候差点以为我没戴耳机,感觉声音真的像是从 iPad 那边发出来的一样,神奇。

日区 Apple Music 优缺点与实际体验

想在一个平台上听到所有喜欢的歌,太难了。

虽说在大版权时代,资源还是存到自己的硬盘里最踏实,但下载党也不是那么好当的。而且习惯了流媒体的便利以后,我是越来越不愿意花那个心思了。然而国内音乐平台版权之争使得听歌体验愈发水深火热,我也不得不面对现实……

在网易云音乐的歌单灰掉一大片后,我终于决定跑路。

这篇文章算是我个人使用了半年多日区 Apple Music 的感受,如果你恰巧也在纠结如何选择,希望对你有所帮助。

理想的流媒体音乐服务

  • 曲库丰富
  • 订阅价格适中
  • 可以自己上传音乐
  • 跨设备同步
  • 个性化推荐
  • 不要有花里胡哨的功能
  • 界面好看
  • 梦里什么都有

我主要听什么

  • Anisong(动画歌曲,俗称二刺螈
  • J-Pop(日本流行音乐)
  • J-Rock(日本摇滚乐)

其实我涉猎挺广泛的,各种类型都听一点。真不是精日

其他候选服务

国内的网易云、QQ 音乐啥的是靠不住了,还是放眼海外吧。

除了外区 Apple Music,还有其他几个也是我考虑过的:

  • Spotify
  • YouTube Music
  • ANiUTa

最后还是选了 AM,原因下面说。

当然,每个人的音乐口味千差万别,最适合我的不一定最适合你。

曲库还算可以

Apple Music 官方说的是「7000万曲聴き放題」,在日本所有音乐流媒体服务中算是数一数二(当然也还是得看你主要听什么)。

同样是日区,Spotify 我也试用过,可以说大部分 AM 没有的,Spotify 也没有;而 Spotify 有的,AM 基本也有,属于旗鼓相当的对手。比如说ヨルシカ、YOASOBI、Official髭男dism、あいみょん这些热门音乐人的歌,就是基本每家都有。

按照我自己使用下来的体验,其实想听的大部分歌都能在 Apple Music 里找到。

(当然这只是我个人的情况,毕竟我也不知道你要听什么)

playlist-top15

iCloud 音乐资料库

按照官方文档的描述:

如果订阅了 Apple Music 或 iTunes Match,您可以使整个 iCloud 音乐资料库在使用同一个 Apple ID 登录的所有设备上都可用。只要接入互联网,即可随时访问您的资料库。

可以理解为网易云那样的音乐云盘,曲库里没有的歌,可以自己上传。

  • 最多可以上传 100,000 首,每首最大 200MB
  • 不占用 iCloud 存储空间

这也是我选择 Apple Music 的最大原因:就算你曲库再大,总有些歌是没有的,比如各种特典 CD、会场限定等等,允许自己上传歌曲就很方便。

据说 Spotify 也可以播放本地音乐,但并不是上传到云端,还是没有 AM 好用。

与 iTunes Store 互通

iTunes Store 作为日本最大的音乐在线配信平台,可以说大部分的歌如果有在网上卖,那肯定有在 iTunes Store 上卖;如果 iTunes Store 上没有,那八成就是没有在网上卖。

有些音乐 Apple Music 上没有提供,但是 iTunes Store 上是有卖的,比如「宇宙よりも遠い場所」「少女終末旅行」的 OP、ED、插入歌专辑。还有 IM@S 偶像大师系列,万南不肯流媒体配信,唯一支持的 ANiUTa 还全都只有 short version,想第一时间听到新曲就只能上 iTunes Store 了。

毕竟都是水果家的服务,iTunes Store 上购买的专辑与单曲,也可以直接在 Apple Music 的应用中播放(废话)。

我实际使用下来,「Apple Music 流媒体提供的音乐」「iTunes Store 购买的音乐」和「自己上传到 iCloud 音乐资料库的音乐」都是一个待遇,播放、整理起来都没差别。比如下面这张图,如果我不说,你能分辨出它们的来源吗?

mixed-music-library

用 iTunes 整理资料库

哦对,现在 iTunes 已经拆成了三个独立应用,Music、Podcast 和 TV。

虽然 iTunes 的手机管理功能屎得不行(现在集成到 Finder 里去了,还是很屎,强力推荐 iMazing),但它的音乐库管理功能还是公认很强大的,metadata 编辑、智能播放列表都很好用。

而且只要添加到了自己的 iCloud 音乐资料库中,就算是 Apple Music 提供的音乐,其 metadata 也可以自由修改(比如专辑封面、艺人等),可以说是最接近本地曲库体验的流媒体音乐服务了。

itunes

没有社交元素

好吧,这对一部分人来说可能是缺点。不过我确实不喜欢那些花里胡哨的东西,不看评论,对「网抑云」也没有兴趣。

这是网易云音乐、QQ 音乐和 Apple Music 的首页对比,见仁见智吧。

netease-qq-apple

另外,虽然没有社交,不过可以在搜索里搜到其他人分享的整理好的播放列表,比如我前段时间找《钢之炼金术师》OP、ED 时就省的自己一个一个找了,也挺方便的。

官方播放列表推荐

Apple Music 的一大特色。

有个人推荐歌单、推荐电台、艺人歌单、音乐回忆歌单,等等。

其中我最中意的是编辑推荐歌单,比如:

各种艺人歌单也是入坑的好机会:

更多可以参考:「用好」Apple Music,这些是你应了解的 - 少数派

跨平台支持

在换 iPhone 之前,我在 Android 上也用过一段时间的 Apple Music(没错,竟然有 Android 客户端……),出乎意料地体验还不错,让人难以相信是那个以「自家生态圈以外的软件都做成一坨屎」闻名的🍎。

Windows 上的 iTunes,体验也马马虎虎,但感觉没人喜欢用这个听音乐吧。

虽然其他平台上也有支持,但 Apple Music 体验最好的肯定还是水果全家桶。如果你手头一部 Apple 设备都没有,那我是不推荐你用 Apple Music 的,因为很可能光是如何付款就得折腾半天……

至于网页版的 Apple Music,说实话我就没有成功打开过。🌚

切换 iTunes 账号会清空下载

说完优点说缺点。

Apple Music 的账号是跟着 iTunes Store/App Store 账号走的,不能独立设置。

也就是说如果你想要一直听日区 AM,那么就得保证 iTunes Store/App Store 的账号一直是日区(相信这对经常切外区账号下载 App 的 iOS 用户并不陌生)。

如果你临时需要下载一个只有国区 App Store 才有的 App,切了一下账号,那 Apple Music 的已下载音乐就会被清空。虽然资料库不会受影响,再登回日区账号就可以了,但又要再下载一遍还是很不爽。

看到有人说切账号会清空资料库,但我实际没有遇到过,只是清空了下载。

所以我现在一般是在 iPad 上切国区账号,下载完需要的 App,然后在电脑上通过 iMazing 安装 App 到手机,这样手机上就不需要切换账号了。

无法手动同步音乐

想要使用 Apple Music,必须开启音乐资料库同步

一旦开了资料库同步,你的设备里的音乐就会被同步成和 iCloud 中的音乐资料库一样。如果想要把一首歌传到 iPhone 里,以前是通过 iTunes 直接同步过去就行了,现在你必须先在电脑上把歌添加到资料库中,等待它上传至 iCloud,然后再等它从 iCloud 下载到手机上,而且你的所有设备里都会出现这首歌。

也就是说,用了 Apple Music,你的所有水果设备就全都共享一个音乐资料库了。想听什么就往里面加,会自动同步,所有设备上都能听。方便是挺方便,但你如果想把一些音乐 locally 放到设备上,而不上传到云端的话,那就不好意思了。

我之前想把《异度神剑 2》的 OST 放到手机里,就因为这个问题,最后只能另寻他法 —— 我可不想把 7GB 的 OST 全部上传到 iCloud 资料库里去,更何况还会被压缩。

一个解决方法就是不要用自带的音乐 App,下个其他的音乐播放器就可以了。我是在手机上下载了 VOX,然后把 OST 传到了 App 对应的文稿存储空间里解决的,手机版 foobar2000 和 Flacbox/Evermusic 也可以。

上传无损音乐会被压缩

iCloud 音乐资料库不能上传无损音乐,ALAC 会被压缩成 256kbps AAC。

如果源文件就是 MP3/AAC 格式,那么上传后不会被压缩。

alac-converted-to-aac

滚动歌词不能自己添加

Apple Music 支持滚动歌词,而且也挺好看的。不过这似乎只能是音乐发行人自己添加,用户是没办法自己添加滚动歌词的,添加的只能是静态歌词。搞不懂。

目前我感觉是 Apple Music 流媒体提供的歌大部分都有滚动歌词(听说有专门的团队负责?),iTunes Store 里买的歌,人气高的那些基本都有,但也有其他很多没有的(没错我说的就是你万代南梦宫)。

对于这些没有滚动歌词的音乐,想自己添加就只能添加静态歌词咯。

scroll-lyrics

部分功能需要科学上网

姑且放到缺点里来吧。下面是我正在使用的代理规则片段:

# 播放自己上传的歌曲DOMAIN-SUFFIX,blobstore.apple.com# iTunes Store 音乐试听DOMAIN-SUFFIX,audio-ssl.itunes.apple.com# iTunes Store 购买后播放DOMAIN-SUFFIX,streamingaudio.itunes.apple.comDOMAIN-SUFFIX,itsliveradio.apple.comDOMAIN-SUFFIX,aodp-ssl.apple.comDOMAIN-SUFFIX,video-ssl.itunes.apple.comDOMAIN-SUFFIX,mvod.itunes.apple.comDOMAIN-SUFFIX,hls-amt.itunes.apple.comDOMAIN-SUFFIX,audio.itunes.apple.comDOMAIN-SUFFIX,genius.itunes.apple.comDOMAIN-SUFFIX,genius-upload.itunes.apple.comDOMAIN-SUFFIX,genius-download.itunes.apple.com

参考:提升国内 Apple Music 体验的代理规则 - 丁丁の店

如何上车?

最后说一下日区 Apple Music 如何上车。目前的 plan 有:

  • 学生订阅,¥480/月
  • 个人订阅,¥980/月
  • 家庭订阅,¥1,480/月

其中最划算的就是家庭订阅,最多可以 6 个人共享,算下来一个人每月只要 250 日元左右。如果加点钱上 Apple One 家庭订阅(1,850円/月),每个月 300 日元,还能另外多出 Apple Arcade 和 200GB iCloud 存储空间。

如果想找人组队家庭订阅的话,推荐去蹲一下 Telegram 上的大型合租群

后记

总的来说缺点也有,瑕不掩瑜吧。

反正我用得是挺爽的,其他人咋样就不关我事了。

在 M1 Mac 上构建 x86 Docker 镜像

今天闲来无事,数了一下服务器上在跑的东西,打算把它们都扔到 Docker 里面去。第一个开刀的就是之前写的 Google Analytics 博客阅读量统计,很简单的一个 Node.js + Express 程序。

写完 Dockerfile 测试好,正准备 push 上去时,我才突然想起来:

我现在用的是 M1 MacBook,丫的默认 build 出来的镜像是 arm64 架构的呀!

好在 M1 Mac 上的 Docker Tech Preview 也支持使用 buildx 构建多架构的镜像,稍微设置一下就可以了。

题外话,M1 MacBook Air 真的很好用,建议早买早享受(

启用实验性功能

Docker 的 buildx 还是实验性功能,需要在 Docker Desktop 设置中开启,具体位于 Preferences > Experimental Features > Enable CLI experimental features

新建 builder 实例

Docker 默认的 builder 不支持同时指定多个架构,所以要新建一个:

docker buildx create --use --name m1_builder

查看并启动 builder 实例:

docker buildx inspect --bootstrap
Name:   m1_builderDriver: docker-containerNodes:Name:      m1_builder0Endpoint:  unix:///var/run/docker.sockStatus:    runningPlatforms: linux/arm64, linux/amd64, linux/riscv64, linux/ppc64le, linux/s390x, linux/arm/v7, linux/arm/v6

其中 platforms 就是支持的架构,跨平台构建的底层是用 QEMU 实现的。

构建多架构 Docker 镜像

使用 buildx 构建:

docker buildx build \  --platform linux/amd64,linux/arm64  --push -t prinsss/google-analytics-hit-counter .

其中 -t 参数指定远程仓库,--push 表示将构建好的镜像推送到 Docker 仓库。如果不想直接推送,也可以改成 --load,即将构建结果加载到镜像列表中。

--platform 参数就是要构建的目标平台,这里我就选了本机的 arm64 和服务器用的 amd64。最后的 .(构建路径)注意不要忘了加。

构建完 push 上去以后,可以查看远程仓库的 manifest:

docker buildx imagetools inspect prinsss/google-analytics-hit-counter
Name:      docker.io/prinsss/google-analytics-hit-counter:latestMediaType: application/vnd.docker.distribution.manifest.list.v2+jsonDigest:    sha256:a9a8d097abb4fce257ae065365be19accebce7d95df58142d6332270cb3e3478Manifests:  Name:      docker.io/prinsss/google-analytics-hit-counter:latest@sha256:bb7f3a996b66a1038de77db9289215ef01b18e685587e2ec4bb0a6403cc7ce78  MediaType: application/vnd.docker.distribution.manifest.v2+json  Platform:  linux/amd64  Name:      docker.io/prinsss/google-analytics-hit-counter:latest@sha256:94ea08ac45f38860254e5de2bae77dee6288dd7c9404d8da8a3578d6912e68e7  MediaType: application/vnd.docker.distribution.manifest.v2+json  Platform:  linux/arm64

可以看到已经是支持多架构的镜像了。

参考链接

React 入门:实现基本登录注册功能

前端老年人复健篇,堂堂连载!

两年多没碰前端,感觉自己已经完全与时代脱节了。作为一个从 jQuery 一把梭时代过来的选手,对于现在那些「大前端」的玩意儿基本都是只懂个皮毛的状态(指看过 Hello world)。四年前我引用过这么一篇文章,《在 2016 年学 JavaScript 是一种什么样的体验?》,放到现在要是出个 2021 版本,估计会更唬人吧。

不过嘛,主要的技术其实还是那么几个:

  • 前端工程化(模块化、组件化、自动化等)
  • TypeScript(支持静态类型检查的 JavaScript)
  • ES6+(箭头函数、模板字符串、async/await 等新语法)
  • 三大框架(Angular、React、Vue)
  • 跨端技术(Electron、React Native、Flutter、小程序)

对于初学者来说可能是有些复杂,但应该还没到「求不要更新了,老子学不动了」的程度。

举个例子,最开始,大家都用原生 HTML、CSS、JavaScript 三板斧来开发网页。后来有人觉得原生的那些方法操作 DOM 啥的太麻烦了,于是弄出了 jQuery 和大家喜闻乐见的 $('#id');觉得代码组织不方便,于是弄出了各种模块化解决方案;觉得 Callback Hell 太傻X了,于是有了 Promise 和 async/await。觉得 JS 没有类型系统太容易出 BUG,于是有了 TS;觉得手动监听事件、操作 DOM 太麻烦了,于是有了数据绑定和三大框架;因为上面这些乱七八糟的最后都得拿去浏览器跑,所以有了 Babel、Webpack 和各种工具链;还有人觉得前端技术这么牛逼,光写网页怎么够,于是有了各种大前端、跨端开发技术……

如此种种,其实没有哪个技术是凭空蹦出来的,都是有需求才有人去做。如果你在实际开发中体会到了这个需求,那么这些新技术的出现也就变得理所当然了。而这也是我推荐新人学一门编程语言不要从框架学起的原因:比如从 Vue 开始学前端,从 Laravel 开始学 PHP,从 Spring 开始学 Java,很容易知其然而不知其所以然。

好了废话就到这里,今天咱们来用 React 实现一个基本的登录注册系统。


【后面的内容鸽了】

使用 GitHub Actions 自动部署 Hexo 博客

联动三年前的文章:使用 Travis CI 自动部署 Hexo 博客

今天更新了一下博客,寻思着好歹也改一下页脚的 Copyright 年份,改完 push 上去以后却发现 GitHub Pages 迟迟没有更新。进去 Travis CI 一看,发现任务一直处于 Queued 状态,半小时了都没开始构建。

查了一下,并不是只有我遇到了类似情况(似乎是因为 Travis CI 正在将 travis-ci.org 迁移至 travis-ci.com):

看了一圈感觉有点悬,干脆换成 GitHub Actions 吧。

我的博客完全托管在 GitHub 上:prinsss.github.io,其中 source 分支放的是源码,master 分支(即 GitHub Pages)是 Hexo 生成的静态博客页面。

要做的也和之前 Travis CI 差不多,当 source 分支有更新时,自动使用 Hexo 构建新页面并更新 GitHub Pages 就可以了。

配置部署密钥

生成一个新的 SSH 密钥,用于 push 至 GitHub Pages 所在的 repo:

ssh-keygen -f hexo-deploy-key -C "prinsss.github.io"

将公钥 hexo-deploy-key.pub 设置为仓库的部署密钥(Settings > Deploy keys):

add-deploy-key

然后在 Settings > Secrets 中新增一个 secret,命名为 DEPLOY_KEY,把私钥 hexo-deploy-key 的内容复制进去,供后续使用。

编写 Workflow

Workflow 就是 GitHub Actions 的配置文件,类似于 .travis.yml

首先新建文件:

mkdir -p .github/workflowstouch .github/workflows/deploy.yml

编辑 deploy.yml

name: Hexo Deploy# 只监听 source 分支的改动on:  push:    branches:      - source# 自定义环境变量env:  POST_ASSET_IMAGE_CDN: truejobs:  build-and-deploy:    runs-on: ubuntu-latest    steps:      # 获取博客源码和主题      - name: Checkout        uses: actions/checkout@v2      - name: Checkout theme repo        uses: actions/checkout@v2        with:          repository: prinsss/hexo-theme-murasaki          ref: master          path: themes/murasaki      # 这里用的是 Node.js 14.x      - name: Set up Node.js        uses: actions/setup-node@v1        with:          node-version: '14'      # 设置 yarn 缓存,npm 的话可以看 actions/cache@v2 的文档示例      - name: Get yarn cache directory path        id: yarn-cache-dir-path        run: echo "::set-output name=dir::$(yarn cache dir)"      - name: Use yarn cache        uses: actions/cache@v2        id: yarn-cache        with:          path: ${{ steps.yarn-cache-dir-path.outputs.dir }}          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}          restore-keys: |            ${{ runner.os }}-yarn-      # 安装依赖      - name: Install dependencies        run: |          yarn install --prefer-offline --frozen-lockfile      # 从之前设置的 secret 获取部署私钥      - name: Set up environment        env:          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}        run: |          sudo timedatectl set-timezone "Asia/Shanghai"          mkdir -p ~/.ssh          echo "$DEPLOY_KEY" > ~/.ssh/id_rsa          chmod 600 ~/.ssh/id_rsa          ssh-keyscan github.com >> ~/.ssh/known_hosts      # 生成并部署      - name: Deploy        run: |          npx hexo deploy --generate

当然,具体步骤还是得根据自己的需求进行相应的修改。

GitHub Pages 相关的具体配置放在了 Hexo 的 _config.yml 里:

deploy:  type: git  repo: git@github.com:prinsss/prinsss.github.io.git  branch: master  name: prinsss  email: prinsss@gmail.com

部署结果

更新 source 分支,push 后 GitHub Actions 就会自动执行。

deploy-result

不到半分钟就 build 完了,只能说微软爸爸还是牛逼。

参考链接

绕过校园网分享限制,构建愉快的宿舍网络环境

最近大一新生们开学了,就有几位学弟来问我说,咱们学校的校园网要怎么才能开 WiFi 热点、能不能用路由器。回想一下当初我也折腾了好一段时间,本来想水篇博客的,后来不知道怎么的就咕咕咕了……正好借此机会分享一下。

其实吧,学校有的是办法搞你。除了强制一人一号、恶心的专用拨号客户端防共享以外,还能通过 IPID、TTL、时钟偏移检测,甚至是 DPI 深度包检测的方法来防私接,就看校方做得够不够绝了。当然破解的方法也有,但基本也不会让你好受。如果碰到这样的校园网,推荐你直接躺平,给这种垃圾网络交钱还不如开个无限流量套餐呢。

免责声明:日后你惹出祸来,不把师父说出来就行了.jpg

校园网的限制

先说一下我们学校校园网恶心的地方。接入网线后,你需要:

  1. 使用【软件A】进行第一次拨号:
    • 【软件A】会进行多网卡检测,如果你的系统里有一个以上的网络适配器,则拒绝拨号;
    • 上述网络适配器包括硬件网卡(USB 网卡、无线网卡)和虚拟网卡(各种虚拟机的虚拟网卡、TAP 网卡等);
    • 认证类型为 802.1x 认证。
  2. 使用【软件B】进行第二次拨号:
    • 【软件B】不检测网卡,但会拒绝在虚拟机中运行,如果宿主机开了 Hyper-V 还会误报;
    • 检测猎豹 WiFi、360 WiFi 等共享软件的进程;
    • 认证类型为 L2TP VPN。

而且这两个软件只能在 Windows 上跑,macOS、Linux 用户就干瞪眼吧。

其实说到这里,有些人应该都心里有数这俩软件是啥。不过我这里就不明说了,懂的都懂(x

接下来来看一下网上常见的破解方案,也就是路由器拨号

据说网上有很多人在卖所谓的「校园网路由器」,其实说白了就是刷了 OpenWRT 的路由器 + 破解校园网的插件。如果有现成的插件能用,那自然是最好,刷个固件也不是什么难事。但不幸的是,目前网上并没有针对【软件A】新版本的拨号脚本,而旧版本的已经不再适用于我们学校的网络了。除非另有大神愿意开发新版本的拨号脚本,否则这条路是行不通的。更何况我们这还要二次拨号,更是难上加难。

taobao-campus-routers

而如果想要在电脑上直接分享热点,第一会被检测多网卡,第二可能会被检测进程。

emmmmm……🤔

那么,让我们的网卡不被检测到不就行了?

使用虚拟机绕过网卡检测

这个方法其实是以前我偶然发现的。

当时我在用虚拟机捣鼓 Kali Linux,用了 VirtualBox 的 USB Passthrough 功能把 USB 外接网卡穿透进虚拟机给 Kali 使用。此时,宿主机操作系统里是看不见这个 USB 网卡的,设备管理器、网络适配器里都没有,就像不存在一样。不存在……嗯?

于是我翻箱倒柜找出了几年前凑单买的小米随身 WiFi,在虚拟机里一通操作:

vm-windows-xp-miwifi

嘿,成了!

如何配置,搜索「VirtualBox USB 网卡」即可。

看来虚拟机的 USB 直通确实可以避开校园网认证客户端的多网卡检测,而且客户端也并没有对 VirtualBox 做什么手脚(后来查了一下,据说【软件A】会干扰 VMware 的 NAT 网络共享服务……)。那么,能做的事情可就多了。

使用有线网卡桥接路由器

上面的方案能用是能用,但效率过于低下。

  1. 虚拟机系统没必要用 Windows,就算是 XP 也是性能浪费;
  2. USB 无线网卡孱弱的 WiFi 性能不足以满足我的需求;
  3. 每次开机都要启动虚拟机,操作繁琐复杂。

既然要用得爽,那肯定得把这些问题解决了。

首先,把 USB 无线网卡换成 USB 有线网卡,下联硬路由作为 AP。同时,把 Guest OS 换成轻量级的 Alpine Linux 并实现开机启动。完成后的网络拓扑图类似这样(综合考虑最后还是选择了两层 NAT):

network-topology

首先在虚拟机内安装 Alpine Linux 和对应的网卡驱动(注意不要用 virt 版本的内核,很多驱动都被精简掉了):

ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:002: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast master br0 state UP qlen 1000    link/ether [mac addr] brd ff:ff:ff:ff:ff:ff3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast master br0 state UP qlen 1000    link/ether [mac addr] brd ff:ff:ff:ff:ff:ff4: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP qlen 1000    link/ether [mac addr] brd ff:ff:ff:ff:ff:ff

添加网桥,把 USB 网卡和虚拟机的虚拟网卡桥接到一起:

brctl addbr br0brctl addif br0 eth0brctl addif br0 eth1brctl show

启动网络:

ip link set dev eth1 upip link set dev br0 upip link show

删除之前分配给虚拟网卡 eth0 的 IP,并启动 DHCP 客户端为 br0 获取 IP 地址:

ip addr flush dev eth0udhcpc -i br0ip addr show

此时应该虚拟机内、有线网卡端都能访问网络了,可以通过 ping 测试一下。

可以用的话就永久保存网络配置:

vi /etc/network/interfaces
auto loiface lo inet loopbackauto br0iface br0 inet dhcp        hostname alpine-vm        bridge-ports eth0 eth1        bridge-stp 0

接下来把 USB 网卡和路由器的 WAN 口用网线连接起来,测试是否工作正常。如果想省一层 NAT,可以连到 LAN 口上并关闭路由器 DHCP 功能,就当个单纯的 AP 使用。不过我为了相对稳定的内网环境,还是选择了前者。

router-interfaces

一切正常的话,就可以愉快地使用 WiFi 啦。

如果想要让 Alpine 虚拟机开机后台运行,可以使用 VBoxHeadlessTray 这个程序。

virtualbox-alpine-booting

当前方案的不足之处

至此,我们的校园网网络共享方案已经算是比较完善了。

幸运的是,我校似乎并没有部署其他什么防私接技术,像这样用了半年多也一直相安无事,省下我不少流量费。

然而,这个方案还是有些不爽的地方。

  1. 作为主机的电脑和其他设备不在一个子网下;
  2. 电脑必须一直开着其他设备才能有网。

在这套方案下,路由器下联的设备对于主机是几乎不可见的(不然也绕不过校园网分享限制了)。你可以在其他设备上访问主机上的网络服务(VirtualBox 的 NAT 网络里宿主机的 IP 一般为 10.0.2.2,子网下的设备可以直接访问,效果和主机上访问 localhost 基本一致),但无法反过来访问子网里的其他设备。

虽然你也可以通过 VirtualBox 的端口映射实现一些变通的解决,比如把路由器的 22、80 端口映射到宿主机上方便访问,但 SMB 这类服务就不行了(Windows 访问 SMB 服务器时强制端口为 445,无法手动指定,要改只能改注册表),所以我完全无法在主机上访问子网下的 NAS 设备。

这也太难受了,继续改进!

既然这些软件都需要跑在一台 Windows 机器上,那我专门弄一台机器来跑校园网相关的东西不就好了吗?

入手双网口工控机软路由

于是我把目光投向了最近几年很火的软路由。

就像计算机有软件和硬件的区别,路由器也有「软」「硬」之分。通常我们在各个电商平台上搜索「路由器」这三个关键词所得到的几乎所有商品都属于硬路由,它是由路由器厂商基于自行开发或是开源的嵌入式设备操作系统,根据特定的硬件设备,设计出来的传统硬件设备。

而与之对应的软路由,是基于软件工具在普通的硬件上来实现传统路由器的功能。我们可以在旧电脑、工控机、开发板、服务器甚至是硬件虚拟机中安装软路由系统,然后通过强大的软件实现各种各样的功能。

——《从听说到上手,人人都能看懂的软路由入门指南》

一番比较后,我在某鱼上入手了一台二手的双网口工控机,安装 Windows 7 系统后,将上面的所有校园网相关的软件都转移到了这台低功耗小主机上。

至于为什么买双网口的机器,虽然 VirtualBox 只能直通 USB 网卡所以还是得外接,不过考虑到以后不用校园网了还可以原地变身软路由,所以不如直接一步到位买个好点的。毕竟现在 CPU 差不多的也就几百块,没必要省那点钱买个电子垃圾。

写在后面

这套方案我用了一年多,基本上没啥问题。24 小时开机、低功耗、子网设备无感知,爽到。

另外,不要怪我写得这么笼统,毕竟每个学校的校园网都不太一样,很难写出一篇普适性的教程。这篇文章充其量算个 PoC,证明一下只要能折腾,还是能捣鼓出舒适的宿舍网络环境的。

如果你是大佬,甚至可以写个软件实现一样的功能,隐藏网卡、软件 NAT 啥的。不过我是菜鸡,也不想在这上面花太多心思,所以就这样吧。又不是不能用.jpg

至于这么折腾值不值得,那就见仁见智了。至少我是愿意的:你不让我开热点我就不开,那我岂不是很没有面子。老子又不是没交钱,凭什么?

smiling-dog

最后,祝各位早日摆脱傻逼校园网。

AirPods 2 到手半个月,我的实际体验如何?

先说好,这不是安利文:我没有推荐任何人购买 AirPods 2 的意思。

这篇文章仅旨在向各位分享我入手 AirPods 2 后的真实使用体验,有吐槽也有真香。

数码圈有个很奇怪的现象,总有那么些人抢着当「厂商孝子」「精神股东」,批评一句产品好像要了他们的命似的(更好笑的是还会互相扣帽子),对此我实在是难以理解。反正我对数码产品的态度一直都是:喜欢就买,开心就好。

如果你恰巧在考虑是否要入手 AirPods 2,希望这篇文章能对你有所帮助。

剁手的缘由

其实直到几个月以前,我对 AirPods 一直都是不怎么感冒的。虽然 AirPods Pro 发布的那段时间各路数码圈 KOL 都在吹,但我也并没有什么感觉。一是手头没有苹果设备,买了也无法发挥其最大能力;第二,就是穷。

就算有拼多多的百亿补贴,现在 AirPods 2 也要八百多好吗!AirPods Pro 更是要一千四百多,我一个穷逼大学生何德何能用这么贵的耳机!反正都是一万以下听个响,只有 QCY T1 和 Redmi AirDots 这种平民 TWS 耳机才是我的朋友呜呜呜。

然而,自从今年入手了 iPad Air 3 后(我买过最值的数码产品之一,以后有机会也会写一写),我却越来越按捺不住心中的欲望,对 AirPods 种的草也越来越高。正好前段时间接的外包项目也小赚了一点……

于是,在一个月黑风高的夜晚,我终究还是败给了欲望,于拼夕夕购入了本文的主角 —— AirPods (第二代)

(¥839 分六期,一个月加手续费 ¥146,一天 ¥5,四舍五入就是不要钱)

airpods-2-unsplash

▲ 配图来自 Unsplash,懒得自己拍了,反正到处都有

在哪里买?

从近年的评论来看,在拼多多百亿补贴购买 AirPods 是个不错的选择。通过关键词在什么值得买、V2EX、酷安等社区搜索,大部分都是安全下车的案例。当然这里要注意必须认准拼多多的「百亿补贴」商品,在其他地方买可能就变成并夕夕了。

另外一个比较靠谱且实惠的渠道是 Apple 官方的教育优惠。今年 Apple 的返校季活动是买 iPad/Mac 送 AirPods,合计起来性价比绝对是不输第三方的。当时看到教育优惠 ¥3701 就能买到 iPad Air 3 64G + AirPods 2 时,我也就只能安慰自己早买早享受了……

如果你只想要耳机,也可以去微博的 #iPad# 超话逛一逛,有很多买了平板的人在出耳机回血。AirPods 2 基本上 ¥800 左右就能收到,商量一下还可以刻字或者加钱上 Pro,就是对方靠不靠谱要自己斟酌了。

到手如何验机

最近的山寨 AirPods 完成度据说已经到了以假乱真的地步,包括开盖弹窗、反磁铰链(就是开盖以后把耳机盒横过来,盖子由于磁铁斥力不会自动合上)、电量显示、三码合一、改名、定位啥的都实现了,只能说华强北还是牛逼。

bilibili-fake-airpods

下面是一些现时点可用的鉴别真伪方法,供各位参考:

  1. 耳机连接上 iPhone/iPad 后,在「设置 > 通用 > 关于本机」中可以看到 AirPods 的序列号,检查其与包装盒、充电盒上盖上的序列号是否一致,以及序列号是否可以在 Apple 官网查询到;

  2. AirPods 2 和 AirPods Pro 都可以在戴耳机时直接呼叫 Siri。把 iPhone/iPad 放在房间里,自己走到一个比较远声音传不过去的地方,说一声「嘿,Siri」,Siri 会回复「嗯?」(我经常在煮面时喊 Siri 帮忙倒计时)

  3. AirPods 2 背部有个黑色长条形的开孔,起到类似音箱导相管的作用,堵住以后耳机声音会明显失去低频,很容易听出来。至于 Pro,毕竟正品有主动降噪,这是目前华强北还做不到的,应该更好区分。

都验证了一遍后,拼爹爹安全下车,可喜可贺。

要不要买 Apple Care+

没错,耳机也有 Apple Care+。简单来说,如果你花了 299 元买了 AC+,就可以享受为期两年的专家技术支持、额外硬件保修服务,以及最多两次的意外损坏保修服务。(每次事件收取 199 元,两只耳机带充电盒全坏了也收 199。而保外维修费用为 549 元一只)

299 都够再买一副普通耳机了……我这么小心的人,真的有必要吗?

众所周知,真无线蓝牙耳机是消耗品。随着长时间的使用,其内置的锂电池不可避免地会被损耗,而且 AirPods 的高集成度也使得电池更换十分困难(当然这可难不倒人民群众,现在淘宝上已经有 AirPods 换电池的服务了)。从其他用户实际体验来看,刚买来充满电可以使用五个小时的续航,使用两年后可能就只有两个小时不到了。

而如果你买了 AC+,那么在这两年内电池健康用到低于 80% 的话,可以免费更换。就算这两年内什么也没发生,到时候花个 199 直接换一副新耳机也未必不值。

所以最终我还是选择了购买 AC+,敞开用就完事儿了。

不过要注意的是,AC+ 的服务范围不包括 AirPods 变成 AirPod 或变成 Air。

airpods-airpod-air

要不要买无线充电盒

你可能并不需要无线充电盒。

买之前我也在纠结,无线充电听起来很诱人,每天回来把耳机盒往那一放就行,不怕忘记充电。但我又一想,我手机又不支持无线充电,难道还要专门再买个无线充电器不成?可是,我下一部手机可能就有无线充电了呀……

纠结良久,最后我还是下单了有线充电盒的版本(主要拼多多百亿补贴也没有无线充电版本)。实际使用下来,我并没有发现有线充电有什么不方便的地方。一天戴耳机五个小时左右的话,耳机盒基本可以做到三天一充,而且一小时就能充满。

当然,有钱人忽视就好,不就多加 300 嘛,不差那点钱。

音质

既然是耳机,音质肯定是个绕不开的话题。

但我这里必须要说,听感是个很玄学的概念。不要全盘相信网上的信息,吹捧也好贬低也好,只有自己耳朵收货才是最靠谱的。

曾经我也很纠结于 AirPods 的音质,毕竟网上的评价都是清一色的「白开水」「音质差」「抬走」。要是买回来真的很难听怎么办,硬着头皮习惯就好?虽然我并不是什么金耳朵 HiFi 玩家,但地摊水平的音质肯定也是难以接受的。

还好,事实证明 AirPods 2 的音质并不烂。至少对我而言,是「耐听」的水平。

我主要听的音乐是 J-pop 和 Anisong,在使用 AirPods 2 的过程中,我并没有感受到三频有明显的短板,乐器人声都挺好听,解析也足够到位。不过听纯音乐就比较拉胯了,总有种不太行的感觉,还比不上我的 AM800。

再说一遍,听感是非常个人的,如果真的很重视音质的话,买之前推荐先借别人的来试听一下。

album-hatsukoi-kumikyoku

降噪与佩戴舒适度

我购买 AirPods 2 的一个重要原因就是,它是半入耳式的耳机。

相比起入耳式耳机,半入耳式在佩戴舒适度上肯定是要领先一大截的。入耳式耳机我佩戴两个小时左右就得摘下来休息一下,不然耳朵堵得慌,而半入耳式可以戴一天也没什么异物感(当然也有部分人的耳朵戴不住 AirPods,我是没这个问题)。

虽然戴起来舒服了,但比起入耳式耳机,AirPods 2 可以说是一点降噪效果也没有。到了嘈杂的环境下,基本就是听个响。当然这也不能完全算缺点,走在街上用入耳式耳机总有种不踏实的感觉,半入耳式相对还更安全一些。

所以在我看来,AirPods 和 AirPods Pro 是互补而非互相替代的关系,预算允许当然是双持最爽啦(非要我选其中一个的话,我会选 AirPods 而非 Pro)。

连接稳定性与延迟

这个可以说完全不用担心了,配合 Apple 自家的设备使用,连接质量和延迟在所有 TWS 耳机中都是数一数二的。

实际体验下来,iPad 放在卧室里,人戴着耳机在家里到处乱走从来没出现过断连现象,这是我之前使用的 Redmi AirDots 完全比不了的。配合我的一加 6 手机会稍微逊色一点,隔两堵墙就不行了,但普通使用情况下也是完全没问题的。

至于延迟,我是真的没想到 AirPods 2 竟然可以拿来打音游,惊了个呆。

ipad-airpods-2-bangdream

▲ 实测关闭 SE,判定调节 +2 左右打邦邦体验非常不错

多设备切换与兼容性

虽然我买了 AirPods,但其实我手头只有 iPad 一个果子家的设备,手机还是用的 Android。实际使用下来,AirPods 2 配合 Android 设备依然有着很 OK 的使用体验,包括开盖弹窗、电量显示、入耳检测、通话、轻点操作等功能都可以实现(有些需要配合第三方软件,我用的是 AndPods)。

airpods-android-compatibility

戴着耳机时可以在已配对的设备中无痛切换,爽到。

至于 Windows 嘛……能用是能用,但是一来 Windows 不支持蓝牙 AAC 音频编码,使用起来音质是可感知地明显下降了一截;二来 Windows 会抢连接:比如你当前正连着手机听音乐,一开电脑就自动给你连到电脑上去了,非常可恶。所以记得在 Windows 上使用完毕后顺手删除配对,不然真的烦人。

不足之处

说完优点说缺点。

首先,这玩意的光面外壳真的很容易刮花。如果平时经常放口袋里摩擦的话,过几个月可能外壳就惨不忍睹了。虽然有 AC+ 到时候可以换新,但磨花的外壳看着也不舒服啊是不是。所以不像手机随便裸奔,AirPods 还是推荐买个保护套。

第二,充电盒边缘容易吸附铁屑。虽然没有这位兄弟这么夸张,但日常使用中还是会脏,看着很不爽,清理起来还麻烦。怪不得某宝上还有卖内盖防尘贴的……

taobao-airpods-sticker

第三,做工差。真的很难想象 AirPods 这感人的接缝竟然是出自 Apple 之手,我他妈一两百块的耳机都没你这么大的接缝好吗!我手上这台充电盒的铰链还能轻微左右晃动,什么品控啊这都。

不过其他倒是真没啥了,不愧是全球 TWS 耳机出货量排名第一的选手。

写在后面

除了 AirPods 系列,近几年各大厂商也推出了不少 TWS 耳机,比如广受好评的 Galaxy Buds,中低端市场无敌的 QCY,以及音质说第二没几个敢说第一的森海塞尔 Momentum True Wireless 2,可以说是选择的余地非常多了。

而我也是看了很多各种各样的测评,最后选择了 AirPods 2。其他我不敢说,不过收到耳机这两个星期以来,我确实用得很快乐。从结果来看,这次的购物我十分满意。

以上就是我的真实体验,如果你正好在种草的话,不妨参考一下。

上大学买游戏本,你可能会后悔

如果让我回到两年前,我肯定会告诉当时的自己:不要买游戏本。

好吧我知道,现在都九月份了,准大学生们电脑估计早就到手了,我这里啰嗦几句也没用。虽然估计也没人看,不过就当给以后的同学做个参考吧。


为什么上大学买游戏本你很可能会后悔?

我不会说游戏本绝对不应该买,毕竟每个人都有自己的需求。我这里就列举几点我这两年大学生活中,作为一个游戏本用户的真实操蛋体验。如果你看完了觉得可以接受,或者是利大于弊,那么再考虑购买游戏本也不迟。

第一,重量。

我现在还记得当年拿到我那台小米游戏本时的震惊:卧槽,这么重的吗!?

型号重量旅行重量
联想拯救者 Y7000P2.35kg2.93kg
华硕天选2.15kg2.7kg
惠普暗影精灵 62.26kg2.69kg
神舟战神 Z72.17kg2.7kg

▲ 京东上销量排名靠前的游戏本

曾经我以为习惯习惯就好,但事实证明我不是一个合格的「习武之人」,快 3kg 的旅行重量(即加上电源适配器),我是真的不想把它背出寝室……

尤其是计算机专业的学生,实验课程经常会需要带电脑去机房(机房电脑太烂,环境配置又不趁手),那叫一个酸爽。

第二,续航。

电池续航可以说是所有游戏本的硬伤。

性能强大,耗电自然也快,就算电池容量更大也撑不住。不知道厂家们是怎么宣传的,反正我自己实际使用的话,不插电源开省电模式,最多只能坚持两个小时。

想带电脑去图书馆/机房?我先佩服一下背着 3kg 来回跑的精力,但如果你抢不到有电源插座的座位,就等着撑完两小时打道回府吧。

如果你的学校晚上熄灯后会断电,那你最好不要把工作拖到那个时候。相信你不会想知道和电量赛跑、看是你电先用完还是我报告先写完是种什么样的体验的。(不要问我是怎么知道的,老子就是死线战士)

第三,噪声。

很多游戏本在性能全开、风扇转速拉满的时候,噪声真的可以用「起飞」来形容。

不知道现在的游戏本怎么样,反正我当时用的小米游戏本 2018 款,那个金属扇叶的高频噪声听多了真的能把人听成神经衰弱……(最关键是当时小米的 BIOS 风扇调度也弱智,啥也没干就 3000rpm。如果你想买小米的笔记本,奉劝你回头是岸)

如果你经常玩大型游戏,那么最好挑选一个噪声控制相对优秀的本子。吵到自己事小,吵到室友就不太好了。

第四,可升级性。

虽然都是追求性能,但游戏本不像台式机,其可升级性通常并不好。顶多就是能换个内存、SSD、硬盘啥的,而 CPU、显卡通常是不可更换的。这也就意味着,如果你没有其他计划,这台游戏本的配置将会伴随你大学四年的时间。

两年过去了,曾经在游戏本中算是第一梯队的 i7-8750H + GTX 1060,现在也成了弟弟。看着最近铺天盖地的「AMD YES!」和老黄新发布的 RTX 30 系真香卡,我默默关闭了页面。


也正是因为上面的几点原因,用了一年多游戏本后,最终我选择了另外再购置一台轻薄本,以解决有时要带电脑出门的需求。而那台小米游戏本也被彻底当成了桌面主机,全年插电源、外接键盘和 2K 显示器使用。

同样是花了七千多块钱,早知道我还不如配个台式机呢……弄个 ITX、mATX 也好呀!

不过当然啦,游戏本也不是一无是处。

相比于轻薄本、超极本,游戏本在性能方面的优势是实打实的。如果你的预算有限,仅能负担起一台电脑,但又有可移动、高性能的刚需的话,那么游戏本还是适合你的。虽然我不怎么经常玩游戏,不过之前有段时间需要剪辑大量的视频,如果只有轻薄本的话,怕是渲染就得渲染个半天了。

所以对我来说,如果预算足够的话,理想方案还是轻薄本 + 台式机的组合最好。台式机就老老实实放寝室里,出门要用电脑的话背个轻薄本出去也方便很多。

如果你正好在考虑大学要买什么电脑的话,希望我的经验能对你有所帮助。

❌