MoreRSS

site icon4Ark修改

一名 Web 前端开发者,但更倾向于自己是软件开发者。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

4Ark的 RSS 预览

多 Mac 设备配置同步方案

2025-12-10 09:46:00

前言

过去几年,我的主力机一直是那台 14 英寸的 M1 Pro,虽然也有 dotfiles 仓库,通过手工软链来同步,但充其量只是为了版本控制。

直到我入手了 Mac mini M4,才有了要同步两台设备配置的需求。因为我已经不止一次:在 A 机上装了某个命令行工具,到了 B 机又得重新安装、重新配置……折腾几次后,还是得找一种方案。

我的目标很简单:只要在任意一台机器上安装新软件或修改配置,其他设备可以快速做到 1:1 同步。

核心就两条命令:

  • 离开当前机器前,运行 macup —— 将所有改动备份到仓库
  • 到另一台机器前,执行 macdown —— 从仓库里还原

以下全是细节。

为什么不用这些方案

  • chezmoi / yadm:有点重
  • Nix / Home Manager:更重
  • Mackup + iCloud/Dropbox:确实方便,但更希望放在 Git 里

目前方案

最终核心就三板斧:

  • Mackup + file_system:所有应用配置都放在 dotfiles 仓库
  • Brew 的 formula 和 cask 列表,并且有前缀 + 表示需要同步安装
  • 两个脚本:macup 负责备份并更新列表,macdown 负责还原

仓库结构示例如下:

dotfiles/
├── .mackup.cfg
├── mackup/                     # mackup 导出的所有配置
├── mackup/brew-formulae.txt
├── mackup/brew-casks.txt
├── bin/macup → ../mackup-backup.sh
├── bin/macdown → ../mackup-restore.sh
├── bin/xxx                     # 任何可执行文件,会自动软链到 ~/.bin
├── mackup-backup.sh
├── mackup-restore.sh
└── init.sh                     # 新机器第一步运行,自动软链 .mackup.cfg

.mackup.cfg 示例:

[storage]
engine    = file_system
path      = /Users/4ark/projects/dotfiles
directory = mackup
[applications_to_sync]
Bash
Charles
Cursor
claude-code
dig
git-hooks
homebrew
Htop
Itsycal
custom-kitty
nvm
PicGo
Pnpm
ripgrep
SourceTree
yazi
Zsh
Mercurial
p10k
vim
neovim
ssh
starship
[applications_to_ignore]
adium

核心文件说明

  • mackup/:mackup 导出的配置/偏好,直接放在仓库里版本化
  • mackup/brew-formulae.txt、mackup/brew-casks.txt:Brew 安装列表,使用 +包名 表示需要安装,注释以 # 开头;脚本会去重、排序并保留注释
  • mackup-backup.sh / bin/macup:备份脚本,负责合并列表、执行 mackup backup 并同步 bin/
  • mackup-restore.sh / bin/macdown:还原脚本,负责差异预览、执行 mackup restore 并按列表安装软件
  • init.sh:新机器上第一步运行,为 ~/.mackup.cfg 创建软链

备份流程:macup

  • 收集当前机器的 formula/cask,与列表合并并展示差异
  • 确认后写回 brew-formulae.txt 和 brew-casks.txt
  • 运行 mackup backup --force,将所有配置导出到 mackup/
  • 将仓库的 bin/ 软链到 ~/.bin

恢复流程:macdown

  • 读取 mackup/brew-*.txt,与本机已安装列表对比,并展示「待安装/可移除」项
  • 确认后执行 mackup restore --force,将 mackup/ 中的配置恢复到对应位置
  • 按列表逐个安装缺失的 formula/cask(已安装的会自动跳过)
  • 同步 bin/ 到 ~/.bin(与 macup 流程相同)

OpenSpec 使用心得

2025-11-04 08:00:00

一、引言

如果在 2025 年,你还没有在工作中借助 AI,要么你的水平已经超越 AI,要么你就是被 AI 代替的部分。大多数人都不是前者,也不愿成为后者。如何高效、可靠地利用 AI,是每位开发者的必修课。

我个人的探索大致经历了以下几个阶段,从最初的简单补全,到如今的规范驱动开发——OpenSpec。

二、AI 工具演进阶段

  1. 洪荒时代:AI 仅作“补全” • 编辑器里集成 Copilot 插件,或者 ChatGPT 网页窗口复制粘贴,AI 只能做最基础的代码补全,效率提升有限。

  2. 集成时代:对话式编辑 • Cursor 等工具将聊天窗口直接搬进 IDE,上下文无缝传递,减少复制粘贴的步骤。

  3. 增强时代:MCP 协议崛起 • Model Context Protocol 赋予 AI Agent 文件读写、命令执行、API 调用能力,自动化水平大幅跃升。

  4. 智能时代:自主驱动 • AI Agent 能理解复杂任务,分解步骤,自主调用工具链完成工作,不再是简单的问答助手,而是智能协作伙伴。

三、现有困境与解决方案

尽管 AI 功能越来越强,但在团队协作中,仍面临几大痛点:

  • 上下文串台:长对话中不同任务相互干扰
  • 信息丢失:超出上下文窗口后,需反复重新说明
  • 难以复用:项目约定无法在新会话中继承

过去,我常让 AI Agent 先输出思路再动手,但每次改动都像黑盒测试,改错了只能重来。

这也是大家遇到的问题,所以最近出现了一系列工具:spec-kit、OpenSpec 等。

OpenSpec 核心思路

  1. “每次改动都是一个提案” • 在动手之前,先形成结构化的 proposal.md,明确 Why / What / How / Impact,我们 Review 通过后再执行。

  2. 规范驱动、约定可持久化 • 所有决策、设计、验收标准都以文件形式保存在仓库里,新工具或新同事都能快速“秒懂”项目规范。

通过将项目规范、架构决策、功能需求以结构化文档记录,OpenSpec 保证了上下文一致性、变更可追溯,也让 AI Agent 在任何时刻都能准确执行。

四、角色转变

  • 之前:在微观层面指挥 AI Agent “写这段代码”“改那个函数”
  • 现在:从宏观角度当“产品经理”或“团队 Leader”
    1. 我来讲需求
    2. AI Agent 起草提案
    3. 我 Review 并反馈
    4. AI Agent 修改提案
    5. 我确认后,AI Agent 执行代码落地

这样一来,我们从指令层的操作者,变成了规范层的把关者,放权给 AI Agent 干更多“脏活累活”。

五、OpenSpec 使用案例

下面以“撰写本篇文章”为例,演示 OpenSpec 的完整流程。

1. 安装 OpenSpec

# 检查 Node.js 版本(需要 >= 20.19.0)
node --version
# v24.11.0

npm install -g @fission-ai/openspec@latest

2. 初始化 OpenSpec

cd my-project
openspec init
  • 选择支持的 AI 工具(如 Claude Code、Cursor 等)
  • 自动生成 openspec/project.mdopenspec/AGENTS.mdopenspec/specs/openspec/changes/ 等目录结构

验证初始化成功,只需要执行:

openspec list
# No active changes found.

然后让 AI Agent 帮你完善项目背景:

Populate your project context:
"Please read openspec/project.md and help me fill it out with details about my project, tech stack, and conventions"

3. 创建提案

向 AI Agent 提需求:

“在 src/content/blog 下新建《OpenSpec 使用心得》文章,包含:

  1. 引言:为何要拥抱 AI
  2. AI 演进阶段
  3. 现有困境与解决方案
  4. OpenSpec 流程
  5. 个人实践心得”

AI Agent 自动生成 openspec/changes/complete-openspec-article/proposal.mdtasks.md、相应 specs/

proposal.md 包含了提案的核心信息:

## Why

现有的 `src/content/blog/2025-11-04-openspec.md` 文章内容不完整,
需要补充完整的内容以完成一篇关于 OpenSpec 使用心得的博客文章。

## What Changes

- 完成博客文章 `src/content/blog/2025-11-04-openspec.md` 的写作
- **保留现有内容**:不删除已有的引言、演进阶段和困境解决方案部分
- **文章要求**:
  - 确保内容准确性
  - AI 工具演进阶段需准确描述
  - 精简整篇文章篇幅...
  - 统一名词使用...

## Impact

- 受影响文件:`src/content/blog/2025-11-04-openspec.md`

tasks.md 列出了详细的实施任务清单:

## 1. 内容创作

- [ ] 1.1 完成第一部分:引言:拥抱 AI 工具
- [ ] 1.2 完成第二部分:AI 工具的演进阶段
  - [ ] 1.2.1 以段落形式描述四个阶段...
- [ ] 1.3 完成第三部分:现有困境与解决方案
- [ ] 1.4 完成第四部分:OpenSpec 的特点与使用流程
  - [ ] 1.4.1 准确介绍 spec-kit 和 OpenSpec 工具...
  - [ ] 1.4.5 使用案例:完成这篇文章提案本身...

specs/blog-content/spec.md 定义了规范要求:

## ADDED Requirements

### Requirement: OpenSpec 使用心得博客文章

博客系统 SHALL 包含一篇完整的关于 OpenSpec 使用心得的文章...

#### Scenario: 文章内容完整性

- **WHEN** 用户访问博客文章页面
- **THEN** 文章应包含所有五个主要部分...

#### Scenario: 文章精炼度

- **WHEN** 文章完成
- **THEN** 应遵循以下原则:
  - 去除所有冗余和重复内容...
  - 使用案例部分需要详细描述...

4. 反复打磨

我在对话中提出细化或优化建议,AI Agent 即刻更新提案,直到内容满足需求,再进入“实施”阶段。

更新提案,介绍完现有的困境,以及 spec-kit 和 openspec 这类工具的优势以后,就要开始一个使用案例,我就以本次我是如何使用 openspec 帮我完成这个提案本身,去写这篇文章。

更新提案,文章里面有几个部分需要调整:

  1. AI 工具的演进阶段,是不是足够准确
  2. 精简整篇文章,去除冗余和重复内容

5. 实施与归档

跟 AI Agent 说:

实施这个提案

它会自动帮你执行这个指令:openspec apply complete-openspec-article

归档这个提案

对应这个指令:openspec archive complete-openspec-article --yes

提案及规范文件被归档到 openspec/changes/archive/2025-11-04-complete-openspec-article/,形成完整可追溯的记录。

六、个人实践心得

  • 我只需记住如何初始化 OpenSpec,具体命令让 AI Agent 自动完成。
  • 项目规范文件是最宝贵的资产:新工具、新同事都能无缝衔接,消除了流程断层。

uni-app 多端组件属性与样式透传行为一致性实践

2025-10-28 08:00:00

现状分析

起因是一个以微信小程序为主开发的 uni-app 应用,在编译到 App 端后,出现了各种样式问题。

为了更好地理解这个问题,让我们创建一个最小化的示例项目:

pnpm create uni@latest # 什么都不加

一个经典的嵌套组件,用于测试三个端组件样式表现:

这里可以看到,每个组件都开启了 style scoped,此时三个端的表现是一致的,没有任何问题:

然而,在实际开发中,我们经常会遇到这样一种情况:由于启用了 style scoped,往往会倾向于使用简单的 class 命名,比如大量使用 container 这样的通用类名。让我们看一个具体示例:

在这种情况下,三个端的表现会出现明显的差异:

这是 bug?先说结论,这是 Vue 的 feature

使用 scoped 后,父组件的样式将不会渗透到子组件中。不过,子组件的根节点会同时被父组件的作用域样式和子组件的作用域样式影响。这样设计是为了让父组件可以从布局的角度出发,调整其子组件根元素的样式。

可前面说了,我们是以微信小程序为主进行开发,看到这种情况还是会非常懵逼。

所以,这就不得不先看一下 uni-app 在不同端是如何实现样式隔离的。

样式隔离

App 端的实现方式其实与我们熟悉的 Vue 浏览器端机制一样:它们都是通过为每个标签动态添加 [data-v-scopeId] 属性来实现样式的作用域隔离。

而在微信小程序端,由于小程序的 CSS 不支持属性选择器,uni-app 采用了一种变通方案:为每个标签添加带有 [data-v-scopeId] 的 class 来实现隔离效果。

下面这张图可以帮助我们直观地理解三个端的 HTML 结构差异:

这里我们可以留意到,子组件的根元素会附带上父组件的 scopeId,所以父组件可以影响它的样式。

而在微信小程序,因为多了一层 <components/comp-child> 这样的东西,scopeId 并不在真正的组件根元素,所以它的表现会与其余两个端不一致。

解决方案

因为我们还是希望 App 端可以与微信小程序端的表现保持一致,此时有两条路可以走:

  1. 不要这个 feature
  2. 保留这个 feature

反正我们的目的只有一个:减少开发时的心智负担。

方案一

对于第一条路子,实现方式非常简单粗暴,既然 Vue 只针对单根组件会有这个 feature,那我们强制所有组件变成多根节点就好了,简单说就是实现一个 Vite 插件,利用 transform 钩子往 Vue 组件顶层插入一个 <Fragment /> 元素,这样立马可以让 App 和 H5 的表现与微信小程序保持一致。

然而这样会损失 Vue 特地带来的便利,正如官方文档所说,这个 feature 会无形中减少很多布局实现上的麻烦,因此最终还是决定让微信小程序能够对齐另外两端的实现。

方案二

根据前面的 HTML 结构图可以看到,微信小程序主要是因为多了一层节点(虚拟节点),是不是只要把它干掉就好了?

说干就干,在 uni-app 文档中指出,只要在 Vue 组件中配置 virtualHost: true 并且配合 mergeVirtualHostAttributes 即可合并组件虚拟节点外层属性:

然而,此时 HTML 结构中 scopeId 虽然已经放到组件的根节点上,但样式表现仍然没有发生变化,这是因为 uni-app 默认给微信小程序组件设置的样式隔离是 apply-shared,还需要把 styleIsolation 改成 shared 才能使其影响其它组件。

对应到本文例子就是要给 comp-parent.vue 添加:

defineOptions({
  options: {
    virtualHost: true,
    styleIsolation: "shared", // [!code ++]
  },
});

修改以后,微信小程序上的表现已经完全与其余两端一致:

这个方案的好处在于,可以让微信小程序组件无限接近 Vue 组件的表现,除了样式透传,还包括可以属性透传,比如 id、class、style、v-show 等。

我写了一个 Vite 插件自动注入这个 virtualHost: true 配置,有兴趣可以看:https://github.com/gd4Ark/vite-plugin-uni-virtual-host

页面样式优先级

其实到这里三端表现已经基本一致,但在微信小程序还有一些细微的差别,主要问题是出在样式优先级。

还是上面那个例子,在页面级组件去覆写子组件样式:

由于微信小程序加载 css 顺序的问题,表现与其余两端不一致:

这问题看起来是无解的,只能手动识别这种情况,并添加 !important 提高样式权重。

uni-app 与原生小程序混合开发方案

2025-10-15 08:00:00

前言

在过去两年里,我接手维护了多个原生语法开发的微信小程序项目。由于新项目均采用 uni-app 开发,这些原生项目无法复用在 uni-app 生态积累的工具库、业务组件和 Hooks 等基础设施。

为了解决技术栈割裂的问题,探索并实践了多种混合开发方案,本文将分享相关的技术方案与实践经验。

项目现状分析

或许会好奇,为什么不直接选择用 uni-app 对项目进行全面重构呢?

其实,除了精力有限,更重要的原因在于:

  • 线上存在众多活动页,这些页面相对独立且变动频率低,重构它们的收益并不高
  • 项目已有功能较为复杂,后续开发需求主要是新增页面,而不是对现有功能进行大规模改造

综上,采用混合开发的渐进式方案,无疑更加高效且具性价比。目前项目主要面临两类需求场景:

  • 项目主框架用 uni-app 重构,但需要继续复用原有的众多子页面(如各类活动页)
  • 老项目已趋于稳定,仅需在其基础上新增某些复杂且相对独立的模块

uni-app 官方文档提供了几种与原生小程序混合开发的技术方案

  • 方式 1:把原生小程序转换为 uni-app 源码。有各种转换工具
  • 方式 2:把原生小程序的代码变成小程序组件,进而整合到 uni-app 项目下
  • 方式 3:原生开发的小程序仍保留,部分新功能使用 uni-app 开发

然而,这些方案无法直接满足目前的场景。鉴于涉及的项目较多,决定设计一套更具通用性和可扩展性的混合开发方案,以方便快速适配应用。

uni-app 项目复用原生小程序页面

这种方案核心思路是:将原生小程序页面搬到 uni-app 项目的构建产物中,并注册页面。

为了提升开发效率,需要将这个过程自动化。所以,开发 Vite 插件来做这件事情最适合不过了。

假设项目结构如下:

.
├── src/                # uni-app 项目主目录
│   ├── pages/          # uni-app 页面
│   ├── components/     # uni-app 组件
│   └── ...
├── miniprogram/        # 原生微信小程序代码目录
│   ├── components/     # 需要复用的原生组件
│   ├── pages/          # 需要复用的原生页面
│   └── ...
├── vite.config.ts      # Vite 配置文件
└── ...

该 Vite 插件需要实现以下核心功能:

  • 构建阶段:将 miniprogram 目录中的目标文件自动同步到 dist 构建目录
  • 资源管理:将原生项目的公共模块(如工具库、依赖包等)统一迁移至独立的 shared 命名空间,并自动更新相关模块的引用路径,避免与 uni-app 构建产物发生冲突
  • 配置更新:自动维护 app.json 配置文件,处理页面路由注册和全局组件声明
  • 开发体验:实现文件系统监听,当检测到 miniprogram 目录的文件变更时,自动触发增量构建

伪代码实现,大概流程就是这样:

function uniWxCopyPlugin(options) {
  let publicBasePath = '' // Vite 的 base 配置
  let configPath = ''     // 最终输出目录
  let isDev = false      // 是否开发环境

  return {
    name: 'vite-plugin-uni-wx-copy',

    // Vite 配置解析完成后执行
    configResolved(config) {
      // 判断是否为微信小程序环境
      if (config.define['process.env.UNI_PLATFORM'] !== '"mp-weixin"') {
        return
      }

      publicBasePath = config.base
      isDev = config.mode === 'development'
    },

    // 构建完成后执行
    writeBundle(options) {
      const p = options.dir // 构建输出目录

      // 如果没有输出目录或 base 配置,则不执行
      if (!p || !publicBasePath) {
        return
      }

      // 计算最终输出目录
      configPath = resolve(publicBasePath, p)

      // 1. 复制原生组件到共享目录
      copy(
        'miniprogram/components' ->
        configPath + '/shared/components'
      )

      // 2. 复制页面
      copy(
        'miniprogram/pages/index' ->
        configPath + '/pages/index'
      )

      // 3. 替换页面中的组件引用路径
      replace(
        file: configPath + '/pages/**/*.json',
        from: '/components/',
        to: '/shared/components/'
      )

      // 4. 开发模式下监听文件变化
      if (isDev) {
        watch('miniprogram/**/*', (changedFile) => {
          if (changedFile.includes('pages/')) {
            copy(
              changedFile ->
              configPath + '/' + changedFile
            )
          }
          else if (changedFile.includes('components/')) {
            copy(
              changedFile ->
              configPath + '/shared/' + changedFile
            )
            replace(
              file: configPath + '/pages/**/*.json',
              from: '/components/',
              to: '/shared/components/'
            )
          }
        })
      }
    }
  }
}

有了这个插件,在每个项目通过配置,就能快速实现混合开发:

import uni from "@dcloudio/vite-plugin-uni";
import { defineConfig } from "vite";
import uniWxCopy from "vite-plugin-uni-wx-copy";

export default defineConfig({
  plugins: [
    uni(),
    uniWxCopy({
      rootDir: "../miniprogram",
      // 复制共享资源
      copy: [
        {
          sources: ["components", "static", "utils"],
          dest: "shared/",
          shared: true,
        },
      ],
      // 主包页面
      pages: ["pages/index", "pages/page1", "pages/page2"],
      // 分包配置
      subPackages: [
        {
          root: "subpackages",
          pages: ["detail"],
        },
      ],
      // 重写 app.json 以添加全局组件
      rewrite: [
        {
          file: "app.json",
          write: code => {
            const appJson = JSON.parse(code);
            appJson.usingComponents = {
              ...appJson.usingComponents,
              "app-btn": "/shared/components/app-btn/app-btn",
            };
            return JSON.stringify(appJson, null, 2);
          },
        },
      ],
    }),
  ],
});

还需要解决一个关键问题:原生小程序页面与 uni-app 主体之间的状态共享,包括环境配置、用户信息等运行时数据。

由于不同项目的业务场景各不相同,这里采用了一种可定制的状态共享方案:

  1. 利用小程序全局实例 getApp() 作为跨技术栈的通信桥梁,在 uni-app 项目中实现状态管理和更新的核心逻辑
  2. 在构建过程中,通过字符串匹配,将原生项目中的方法替换成 getApp() 提供的统一接口

举个例子,将原本的鉴权方法改成通过 getApp 使用 uni-app 项目提供的:

// auth.getUserInfo( -> auth.getApp().getUserInfo(
{
  replaceRules: {
    from: /auth.getUserInfo\(/g,
    to: 'getApp().getUserInfo(',
    files: [
      ...pages.map(page => path.join(configPath, page, '**/*.js')),
      ...shared.sources.map(dir => path.join(configPath, shared.dest, dir, '**/*.js')),
    ],
  },
}

再举个例子,动态修改原生项目的开发环境:

{
  rewrite: [
    {
      file: "shared/config/index.js",
      write: code => {
        // eslint-disable-next-line no-param-reassign
        code = code.replace(
          /export const DEV =(.+)/,
          `export const DEV = ${mode === "development" ? "true" : "false"}`
        );

        return code;
      },
    },
  ];
}

有兴趣可以看看这个插件:vite-plugin-uni-wx-copy

uni-app 项目集成到原生小程序

这种方案核心思路是:将 uni-app 项目的构建产物集成到原生小程序项目中,并注册页面。

没错,就是与上面的方案反过来,这也是得益于 uni-app 项目提供了一个打包方式:混合分包。

简单说就是将一个 uni-app 项目打包成小程序的一个分包,满足以下场景:

  • 既可以实现将功能集成到现有的小程序项目中,同时支持分发到 APP、H5 等
  • 微信小程序单个分包限制为 2M,可按需拆分多个分包,且不影响其它平台分发

假设目录结构如下:

.
├── miniprogram/          # 原生微信小程序项目目录
│   ├── app.js
│   ├── app.json
│   └── ...
└── uni-app-project/     # uni-app 项目目录
    ├── src/
    ├── vite.config.ts
    └── ...

在 uni-app 项目的 package.json 中配置分包构建命令(根据情况决定是否需要多个分包):

{
  "scripts": {
    "dev": "run-p 'dev:**'",
    "build": "run-p 'build:**'",
    "dev:pkg-a": "uni -p pkg-a --subpackage=pkg-a",
    "build:pkg-a": "uni build -p pkg-a --subpackage=pkg-a",
    "dev:pkg-b": "uni -p pkg-b --subpackage=pkg-b",
    "build:pkg-b": "uni build -p pkg-b --subpackage=pkg-b"
  },
  "uni-app": {
    "scripts": {
      "pkg-a": {
        "title": "pkg-a",
        "env": {
          "UNI_PLATFORM": "mp-weixin"
        },
        "define": {
          "MP-PKG-A": true
        }
      },
      "pkg-b": {
        "title": "pkg-b",
        "env": {
          "UNI_PLATFORM": "mp-weixin"
        },
        "define": {
          "MP-PKG-B": true
        }
      }
    }
  }
}

在 uni-app 项目的 pages.json 中使用条件编译配置分包页面:

{
  "pages": [
    // #ifdef MP-PKG-A
    {
      "path": "pages/index/index"
    },
    // #endif
    // #ifdef MP-PKG-B
    {
      "path": "pages/detail/index"
    }
    // #endif
  ]
}

该 Vite 插件需要实现以下核心功能:

  • 构建阶段:将 uni-app 的构建产物放到原生小程序项目中
  • 开发体验:uni-app 热更新时,更新差异部分

伪代码实现,大概流程就是这样:

function uniSubpackageCopyPlugin(options) {
  return {
    name: "vite-plugin-uni-subpackage-copy",
    // 在其他插件之后执行
    enforce: "post",

    // 构建完成后执行
    async writeBundle(output) {
      // 1. 如果配置了重写规则,先处理文件重写
      if (options.rewrite) {
        for (const rule of options.rewrite) {
          // 读取文件
          const content = readFile(rule.file);
          // 使用重写函数处理内容
          const newContent = rule.write(content);
          // 写入新内容
          writeFile(rule.file, newContent);
        }
      }

      // 2. 使用 rsync 将分包文件从 uni-app 构建目录同步到原生项目
      rsync({
        from: options.subpackageDir, // uni-app 分包构建目录
        to: options.rootDir, // 原生项目目录
        // 保持文件属性,递归同步,压缩传输
        flags: "avz",
        // 删除目标目录中源目录没有的文件
        delete: true,
      });
    },
  };
}

在 uni-app 项目的 vite.config.ts 中配置插件:

import uni from "@dcloudio/vite-plugin-uni";
import { defineConfig } from "vite";
import uniSubpackageCopy from "vite-plugin-uni-subpackage-copy";

export default defineConfig({
  plugins: [
    uni(),
    process.env.UNI_PLATFORM === "mp-weixin" &&
      uniSubpackageCopy({
        rootDir: "../miniprogram",
        subpackageDir: process.env.UNI_SUBPACKAGE,
      }),
  ],
});

有兴趣可以看看这个插件:vite-plugin-uni-subpackage-copy

我如何自己实现 lint-staged

2025-10-13 08:00:00

前言

都 2025 年了还在聊 lint-staged?确实有点复古。但最近实在闲得慌,博客都快长草了,索性把过去几年踩过的坑和折腾过的轮子翻出来炒冷饭。接下来会陆续更新一系列文章,就当是给自己这几年的搬砖生涯做个总结。

为什么要自己实现 lint-staged?

说来惭愧,我司用的不是 git,而是 Mercurial (hg) 这个「冷门」版本控制工具。所以很自然地,git 生态下的各种神器都用不了。2022 年写过一篇 《Hg hooks 实践历程》,主要就是给 hg 造了个 husky 轮子,结果被领导看上了,在公司内推广使用。既然都做到这份上了,lint-staged 自然也被提上了日程。

简单来说,lint-staged 就是专门处理暂存区(staged)文件的格式化和 lint 操作。这样做有两个好处:一是速度快,二是不会被那些还没提交的本地改动搞出来的 lint 错误打断提交。

但问题来了,hg 压根就没有暂存区这玩意儿!这就是我实现 lint-staged 时遇到的最大挑战。

实现思路

先回忆一下 lint-staged 的标准配置:

{
  "scripts": {
    "lint-staged": "lint-staged"
  },
  "lint-staged": {
    "**/*.{js,vue}": ["eslint --cache --fix", "pnpm dts"],
    "**/*.{css,vue}": "stylelint --cache --fix",
    "**/*.{json,d.ts,md}": "prettier --write"
  }
}

然后在 pre-commit 钩子里执行:

# .husky/pre-commit

npx lint-staged

标准流程是这样的:

  1. pre-commit 阶段执行 lint-staged
  2. 通过 git 命令获取暂存区的文件列表,对这些文件按配置执行各种命令
  3. 把产生的文件改动重新加到暂存区,合并到本次提交

但 hg 没有暂存区,没法像 git 那样直接获取文件列表,只能另辟蹊径。虽然官方 lint-staged 在 pre-commit 阶段执行,但在 hg 中我们可以改到 pretxncommit 阶段,用 hg export tip --template "{file_adds} {file_mods}" 命令获取本次提交涉及的文件列表。

lint-staged 的操作会改动文件,需要把产生的改动合并到本次提交中。在 hg 里可以用 (HUSKY=0 hg commit $files --amend -m "$commit_message") >/dev/null 2>&1 命令把最新改动合并到本次提交。

PS:这里需要 HUSKY=0 是因为本次提交不需要再经过 lint-staged,不然就会死循环。

到这里其实已经可以做到官方 lint-staged 的核心功能,基本够用了。但我们还有个额外需求:有些项目会对 .js 文件执行 pnpm dts 操作,这会产生新文件或删除文件,我们希望把这些 lint-staged 操作导致的文件改动也合并到本次提交中。

举个例子,我们有 a.js,经过 pnpm dts 后会产生 types/a.d.ts,我们想把这个文件也合并到提交中。所以设计了一个硬编码命令 hg commit

"lint-staged": {
   "**/*.{js,vue}": [
     "eslint --fix",
     "pnpm dts",
     "hg commit"
   ],
},

当遇到 hg commit 时,就获取最新的文件改动列表,与之前拿到的文件改动列表做 diff,差异部分就是本次操作产生的改动。把这部分差异存到临时文件里,附加到 $files 中,就能实现想要的效果。

核心代码

最后附上核心实现,首先是 lint-staged.js

#!/usr/bin/env node

/* eslint-disable no-console,no-await-in-loop */

const fs = require("fs");
const { exec, execSync: _execSync } = require("child_process");
const path = require("path");

const glob = require("glob");

const cwd = process.cwd();

// 获取 hg 仓库根目录路径
const ROOT_PATH = execSync("hg root");
const PACKAGE_JSON_PATH = path.join(cwd, "package.json");

// 临时文件路径,用于存储 lint-staged 操作产生的文件改动
const LINT_STAGED_MODIFIED_PATH = path.join(
  ROOT_PATH,
  ".hghusky/_",
  "LINT_STAGED_MODIFIED"
);

// 硬编码的特殊命令,用于触发文件改动检测
const COMMIT_COMMAND = "hg commit";

main();

async function main() {
  try {
    // 组合函数:读取配置 -> 生成命令 -> 执行命令
    const tasks = compose(
      getLintStagedConfig,
      generateCommands,
      executeCommands
    );

    await tasks();
  } catch (error) {
    console.error(error);
    process.exit(1);
  }

  process.exit(0);
}

/**
 * 从 package.json 中读取 lint-staged 配置
 */
function getLintStagedConfig() {
  const packageJsonContent = fs.readFileSync(PACKAGE_JSON_PATH, "utf-8");
  const packageJson = JSON.parse(packageJsonContent);
  const lintStagedConfig = packageJson["lint-staged"];

  if (!lintStagedConfig) {
    throw new Error("No lint-staged config found");
  }

  return lintStagedConfig;
}

/**
 * 根据配置生成需要执行的命令
 * 核心逻辑:匹配本次提交的文件与配置的 glob 模式
 */
function generateCommands(config) {
  // 获取本次提交涉及的文件列表(新增和修改的文件)
  const committedFiles = execSync(
    'hg export tip --template "{file_adds} {file_mods}"'
  ).split(" ");
  const commands = [];

  Object.entries(config).forEach(([pattern, command]) => {
    // 1. 使用 glob 匹配所有符合模式的文件
    // 2. 转换为相对于仓库根目录的路径
    // 3. 过滤出本次提交涉及的文件
    // 4. 转换为相对于当前工作目录的路径
    const matchedFiles = glob
      .sync(pattern, { nodir: true })
      .map(file => path.relative(ROOT_PATH, path.resolve(cwd, file)))
      .filter(file => committedFiles.includes(file))
      .map(file => path.relative(cwd, path.resolve(ROOT_PATH, file)))
      .join(" ");

    if (matchedFiles) {
      commands.push({ command, files: matchedFiles });
    }
  });

  return commands;
}

/**
 * 执行生成的命令列表
 * 关键特性:支持检测命令执行前后的文件变化
 */
async function executeCommands(commands) {
  await Promise.all(
    commands.map(async item => {
      if (!item.files.length) return;

      // 如果命令是数组(包含多个子命令)
      if (Array.isArray(item.command)) {
        // 检查是否包含特殊的 hg commit 命令
        const needCommit = item.command.find(cmd => cmd === COMMIT_COMMAND);

        // 如果需要检测文件变化,先记录当前的文件状态
        const prevModifyFiles = needCommit ? getModifyFiles() : [];

        // 依次执行每个子命令
        for (const subCommand of item.command) {
          if (subCommand === COMMIT_COMMAND) {
            // 遇到 hg commit 命令时,计算文件变化
            const afterModifyFiles = diff(getModifyFiles(), prevModifyFiles);

            // 如果有新产生的文件改动,写入临时文件
            if (afterModifyFiles.length) {
              fs.writeFileSync(
                LINT_STAGED_MODIFIED_PATH,
                afterModifyFiles.join(" ")
              );
            }
          } else {
            await executeCommand(`${subCommand} ${item.files}`);
          }
        }

        return;
      }

      await executeCommand(`${item.command} ${item.files}`);
    })
  );
}

function executeCommand(commandWithFiles) {
  return new Promise(resolve => {
    console.log(`[lint-staged] execute: ${commandWithFiles}`);
    const child = exec(commandWithFiles, (error, _stdout, stderr) => {
      if (error) {
        console.warn(stderr);
        process.exit(1);
      } else {
        resolve();
      }
    });

    // 将子进程的输出重定向到当前进程
    child.stderr.pipe(process.stderr);
    child.stdout.pipe(process.stdout);
  });
}

// eslint-disable-next-line consistent-return
function execSync(command) {
  try {
    const result = _execSync(command, { encoding: "utf-8" });

    return result.trim();
  } catch (error) {
    console.error(`[lint-staged] execute ${command} error: ${error}`);

    process.exit(1);
  }
}

function compose(...functions) {
  return async () => {
    let result;
    for (const func of functions) {
      result = await func(result);
    }

    return result;
  };
}

/**
 * 获取当前工作目录中所有修改过的文件列表
 * 用于检测 lint-staged 操作产生的文件变化
 */
function getModifyFiles() {
  return execSync("hg status | sort")
    .split("\n")
    .map(line => line.replace(/^.*?\s/, ""));
}

function diff(array1, array2) {
  return array1.filter(item => !array2.includes(item));
}

这里最关键的就是会在遇到 hg commit 命令的时候,产生一个临时文件 LINT_STAGED_MODIFIED_PATH,它就是本次操作改动的文件列表,然后在 commit 阶段将其附加到 $files 合并到本次提交:

#!/bin/bash

HUSKY_DIR='.hghusky'

# 在上级目录的 commit 执行之前,先做一些初始化工作
commit_message=$(hg tip --template '{desc}')

# 将 lint-staged 产生的变更重新添加到当前 commit 中
function mergeAutoFixed2Commit() {
  if [ ! -f "$HUSKY_DIR/pretxncommit" ]; then
    exit 0
  fi

  skipLint

  files=$(hg export tip --template '{files}')

  # 提交 lint-staged 阶段改动的文件
  LINT_STAGED_MODIFIED_PATH="$HUSKY_DIR/_/LINT_STAGED_MODIFIED"
  if [ -f "$LINT_STAGED_MODIFIED_PATH" ]; then
    modified_files=$(cat "$LINT_STAGED_MODIFIED_PATH")
    files="$files $modified_files"

    rm "$LINT_STAGED_MODIFIED_PATH"
  fi

  set +e
  # shellcheck disable=SC2086
  hg addremove $modified_files
  # shellcheck disable=SC2086
  (HUSKY=0 hg commit $files --amend -m "$commit_message") >/dev/null 2>&1
  set -e
}

if [ -f "$HUSKY_DIR/pretxncommit" ]; then
  mergeAutoFixed2Commit
fi

2024 年终总结

2024-12-31 08:00:00

前言

“今年是过去十年最糟糕的一年,但可能是未来十年最好的一年。”在 2019 年,这句带着预言性质的话,当时我还未完全理解其深意。但如今站在 2024 年回望,竟显得如此贴切,甚至更具分量。

在经济下行、行业调整的大环境下,谁的日子都不好过。许多公司都在减员,失业的人也越来越多,这并非个人努力所能轻易改变,而是整个行业,整个社会都在经历一段艰难时期。

于我个人而言,今年似乎是凝固的一年,各方面都乏善可陈,几乎没有任何的改变。然而,在这充满不确定性的环境下,或许这种“不变”本身竟也未尝不是一种幸运。它意味着,尽管没有迎来期待中的跃升,但至少也没有滑向更深的低谷,还能维持着现有的状态。

放眼四周,或许这也是许多普通人共同的感受,我们虽不能左右大势,但至少可以努力过好自己的生活,在力所能及的范围内做出改变。我深知自己无力也无意去探讨那些宏大的社会命题,眼下更应关注的是如何在不确定的环境中自洽地生活,努力地生长。所以,我选择记录自己,因为“记录”,本身就是对抗时间流逝、对抗虚无的一种方式,即使是再平凡不过的一年,也值得被认真对待、被仔细书写。

因此,我将从以下几个方面,记录下属于我的 2024 年。

工作

首先从工作方面说起。今年我接触到了不少新的挑战和领域,这无疑是一件好事。毕竟,我从来不是、也不希望自己成为那种“一年经验用十年”的人,持续学习和成长才是我所追求的。然而,大环境的浪潮也波及到了公司,陆陆续续的裁员消息让人倍感无奈。身处其中,除了更加努力地做好本职工作,不断提升自身价值,似乎也没有更好的应对之法。

即便如此,今年在工作中依然遇到了一件颇有意思的事,也算是一种调剂。一位同事向我询问有什么好用的工具推荐,或许这只是他不经意的一问,但却触动了我。那一刻,我意识到自己在他人眼中,或许已然成为了一个善于利用工具提升效率的人,这让我既有些许得意,又感到一丝惭愧。因为被这突如其来的一问,我竟一时语塞,只能随便找了个理由应付过去。但事后,我暗下决心,一定要专门写一篇文章,好好整理一下我正在使用的工具。这不仅仅是为了回应那位同事的期待,更是为了对自己过去一段时间工具使用情况的梳理和总结,因此有了这篇文章《2024 年使用的工具从 A 到 Z》

其实,在日复一日的工作中,类似这样的小事还有很多,若用心留意,便能从中汲取养分。一份工作做得久了,难免会陷入枯燥的循环,热情也随之消磨殆尽。因此,我们需要主动去寻找一些能够点燃内心、让自己持续获得满足感的事情。这种满足感并非仅仅来源于完成日常的任务,而是源于内心的充盈,源于那些真正让自己觉得有意义、有价值,能让自己或他人变得更好的事情。

学习

接下来本应谈谈学习,但老实说,今年我在工作之外的时间里,并没有进行严格意义上的学习。不过,我也并非完全虚度,而是将精力投入到了各种“折腾”之中。而谁又能说,这些看似“不务正业”的折腾,不是一种另类的学习方式呢?

电子族谱

今年清明,回乡扫墓。在老家里,长辈们聊着祖辈们的往事,我翻阅着那本泛黄的族谱,一个念头跃入脑海:我们这一辈,又有几人真正看过族谱,了解祖先的生平呢?纸质族谱固然珍贵,但往往被束之高阁,查阅与补充皆有不便。时代在变,电子族谱或许才是传承家族记忆的更好选择。

我希望能用自己的方式为家族做些什么,这既是出于对历史的热爱,也是对先祖的一种尊敬。毕竟每个人百年之后不过黄土一抔,我们年年祭拜的先祖,又有多少后人能道出他们的姓名和生平?历史的魅力,正在于它记录了那些鲜活的生命,让逝者不至于完全湮没在时间的尘埃中。将族谱电子化,为每位祖先建立生平简介,或许能激发年轻一辈的兴趣。纵然无法让每个人都产生共鸣,至少也遂了我个人的心愿。我更希望,将来我的后代能够通过这份电子族谱,了解自己的血脉源流,知道自己从何处来。

当然,我无法像司马迁那样为每位祖先著书立传。他们世代为农,平凡一生,能记录的或许只有姓名、生卒、婚配和安葬地。然而,这些看似简单的信息,串联起来,便构成了家族的历史长卷,也证明了他们曾经鲜活地存在过。在整理这些信息的过程中,我自身也受益匪浅,了解到了更多姓氏起源、堂号分布、乃至寻根溯源的知识,更对族谱编修有了更深的体会。这并非简单的信息录入,为了撰写先祖的生平,我需要对不同来源的记载进行仔细的考究和推理,辨别真伪,有时还需要翻阅当时的县志,去还原那时的社会风貌。或许有些先祖的生卒年份和源流已不可考,但我仍秉持“成功不必在我”的信念,尽己所能记录现有资料,留待后人去完善。这段经历,让我初窥了历史学家们为研究一个人物生卒年月所做的大量工作,体会到那种求真务实的严谨,也更加坚定了我做好这份电子族谱的决心。

电子阳痿

“电子阳痿”常被用来形容对电子游戏失去兴趣。对我来说,游戏的“阳痿”早已开始。年少时,我曾是个“网瘾少年”,逃课去网吧也是常事。不知何时起,游戏的热情悄然消退。还记得刚工作时,同事们休息时间捧着 Switch 酣战,而我却提不起丝毫兴趣。

与游戏相对的,是我曾经对折腾电子产品的狂热。如果说游戏是我年少时的一项爱好,那么刷机则是另一项。小时候电子设备有限,手机便成了我最好的玩具。为了研究刷机,我废寝忘食,线刷、卡刷、救砖,这些身边人闻所未闻的词汇,于我却充满魔力。后来,能接触的电子产品多了,从手机、电脑到路由器,只要能刷系统的,我都乐此不疲地折腾一遍。

这其中,最让我印象深刻的是在学校参加市级比赛时的一段经历。其中需要练习配置思科网络设备,出于“折腾”的本能,我给工作室的一台无线 AP 升级了固件,结果不幸变砖,无法正常启动。当时的我吓得不轻,担心被开除,于是没有告诉老师。还记得那是一个周五的下午,放学后的我怀着无比沉重的心情回了家。整个周末,我都茶饭不思,泡在论坛里寻找解决方案,可谓“寝食难安”。所幸皇天不负有心人,终于找到了救砖的方法。一回学校,我便第一时间奔向工作室,成功救活了那台 AP。相信每个喜欢折腾的人,都经历过类似的苦与乐。“折腾”的过程总是充满挑战,甚至伴随着痛苦的“翻车”,但当你最终解决问题时,那种成功的喜悦和满足感也无与伦比。

然而近两年,这份对电子产品的热情也逐渐冷却。曾经热衷的钻研和尝试,如今都变得兴致索然。我并非完全不再折腾,只是和年少时相比,少了很多热情。就拿之前折腾的黑群晖来说,硬盘坏了好久,我也一直没去管它。我曾一度认为,继游戏之后,我对电子产品也“阳痿”了。

今年趁着国补,我给住处添置了一台雷鸟电视,但发现观看 YouTube 颇为不便,便萌生了入手一台可玩性更高的路由器的想法。一番精挑细选后,我入手了红米 AX6000。到手后,我便迫不及待地刷入了 OpenWrt 固件,结果却事与愿违,网速极不稳定。我又尝试了两三个不同版本的固件,问题依旧。无奈之下,只得刷回官方固件。所幸我的需求在官方固件下也能得到满足,最终我选择了固化 SSH 并安装了 ShellClash。这次折腾路由器的经历,让我找回了些许久违的乐趣,但也不得不承认,曾经的我享受的是折腾本身,而现在,我只想用最简单高效的方式解决问题。

年少时,有的是时间和精力,肆意挥霍,瞎折腾也是一种乐趣。但随着年龄增长,家庭、工作占满了时间,责任与压力纷至沓来,时间变得宝贵,便不再沉溺于无谓的折腾,不自觉地求稳。这或许并非是坏事,关键点如何在“折腾”与“不折腾”之间找到一个平衡点,找到那个让自己最舒适的姿态,而更重要的是,不要让自己丧失折腾的勇气和能力。

生活

iOS 自动化

iOS 自动化(快捷指令)这玩意儿早就有了,但今年,我才真正体会到它实实在在的用处。

下面就分享几个我常用的自动化操作:

上下班打卡

几年前,我就开始尝试用 iOS 快捷指令自动打卡企业微信。最初的方案是结合通勤时间设置闹钟,关闭闹钟时触发。但此方案常因通勤不稳定或需手动确认而失效。后来,我换了思路,改为特定时间内,手机连接/断开公司 Wi-Fi 即自动打开企业微信打卡。改进后方案出奇地稳定,打卡再也没落下过。

取快递提醒

以前下班回家取快递,总要翻找半天短信,甚至打开购物 APP 才能找到取件码,费时费力,让人心烦。后来我做了个自动化:只要短信里有“包裹”之类的关键词,就自动把取件码提取出来,塞进提醒事项里。我还加了个触发条件,每次刷羊城通出站的时候,自动弹出提醒,如此一来,取快递就方便多了。

复制验证码

受“取快递提醒”的启发,我将这一思路应用到了短信验证码的接收上。每当收到包含“验证码”关键词的短信,iOS 快捷指令会自动提取内容,并推送到 macOS 端的 Bark 软件实现自动复制到剪贴板。如此一来,我只需直接粘贴,省去了所有中间步骤,效率倍增。

更多...

类似的应用还有很多,比如节假日自动关闭闹钟、低电量自动开启省电模式等。这些操作看似微小,但组合起来却能实实在在地提升生活品质。这种化繁为简、自动解决问题的体验,却能让每个普通人都能从科技发展中感受到幸福感的提升。

年度盘点

最喜欢的软件

macOS

我在本站不止一次自诩为 RSS 重度爱好者,痴迷于“万物皆可 RSS”的境界。我甚至幻想过:要是微信朋友圈也能用 RSS 订阅该多好!因此,当集大成者的 RSS 阅读器 Follow 一经推出,我便迫不及待地四处寻求邀请码。最近得知 Follow 已经在推出移动端的 TestFlight 版本,期待明年它能成为我在 iOS 上最喜欢的软件。

iOS

今年,我将用了许久的 Shadowrocket 换成了 Quantumult X,没想到这个决定竟带来了意想不到的惊喜。Quantumult X 不仅拥有强大的去广告能力,更以其极高的可玩性著称。它支持各种自动化脚本,让你可以根据自己的需求定制各种功能,我还自己编写了几个自动签到脚本。综合来看,Quantumult X 绝对是我今年剁手最值的软件,没有之一。

TV

我从小就爱看电影,早年为了满足我对电影的渴求,我折腾了个黑群晖当下载机,全天候下载,最终硬盘也因此“阵亡”。后来,使用阿里云盘存电影,还搭了个 Alist,但为了搞个漂亮的海报墙,还得自己手动刮削,简直麻烦死了。直到后来,朋友给我安利了 Emby 公益服,从此打开新世界的大门!想看的电影基本都有,还都已经刮削好精美的海报墙,再也不用自己瞎折腾了,观影体验蹭蹭往上涨!

最喜欢的电影

原以为《周处除三害》将是我今年的年度最佳,直到年底黄子华和许冠文合作的《破地狱》上映了!说来也巧,香港首映那天我正好就在香港,但愣是给忘了,现在想起来都想抽自己!原以为这部电影无缘大陆银幕,还好 12 月宣布了定档,我赶紧第一时间买了预售票。

这部电影的优秀无需我赘述,票房成绩便是最好的佐证。于我个人,除了黄子华和许冠文这两位我从小就喜欢的演员同台飙戏——尤其黄子华,他是我心中唯一的男神,看到他如今的成就,我由衷地替他开心。更何况,《破地狱》选择了一个华语电影中鲜少触及的题材——直面死亡与殡葬。我相信,每一个经历过亲人离世,并走过整个殡葬流程的人,都会对这部电影感同身受。

最喜欢的电视剧

前段时间,《白夜追凶》第二部上映的消息刷屏了各大平台,但我对这部剧却一无所知,竟从未看过第一部。于是我找出了 2017 年上映的第一部,谁知这一看便入了迷,一口气追完了全剧。然而,备受期待的第二部却槽点满满,仅看一集便让我没了继续追的欲望。

这也难怪,毕竟续集难超前作已成定律,就像最近的《鱿鱼游戏》第二季,评价就褒贬不一。

最喜欢的音乐

今年有一次,在广场上偶然听到一对情侣对唱《偏爱》,这首歌我之前也听过,但那天不知道为什么,特别有感觉。回去后,我便开始单曲循环,怎么听都不腻!

回顾与展望

真正的成长,就是学会与生活和解,在平凡的日子里,活出自己的精彩。2024 年即将过去,新的一年即将到来。愿我们都能在未来的日子里,继续保持热爱,不负自己。也祝愿每一位朋友,新年快乐,万事胜意,好好睡觉,好好生活!