跳到主要内容

删除 node_modules

· 阅读需 5 分钟
Gemini
Google Gemini
1adybug
子虚伊人

以下内容均为 Gemini 2.5 Pro 生成

Windows 上的 node_modules 黑洞:征服“路径过长”错误

如果你是一名在 Windows 上进行开发的程序员,你很可能遇到过这种情况:那个顽固的 node_modules 文件夹拒绝被删除。你试图将它拖到回收站,却只看到一个令人费解的错误提示。你尝试使用命令行,结果它抱怨路径太长。这种令人沮 જય体验,是 Windows 的一个历史遗留限制与现代网络开发实践猛烈碰撞的直接后果。

这篇博文将深入探讨这个问题的根源——臭名昭著的 MAX_PATH 限制,并介绍两个来自 Node.js 生态系统的强大且对开发者友好的工具——rimrafdel-cli,它们可以一劳永逸地解决这个问题。

问题的根源:Windows 256 个字符的阿喀琉斯之踵

问题的核心在于一个被称为 MAX_PATH 的 Windows API 限制。几十年来,许多核心的 Windows 函数都无法处理超过 260 个字符的文件路径。这包括驱动器号、文件夹、文件名和扩展名。

现代 Node.js 项目及其深度和复杂的依赖树,常常会突破这个限制。像 npm 这样的包管理器解决依赖关系的方式,通常涉及在彼此内部创建层层嵌套的 node_modules 目录。一个构建工具可能会在这个结构的深处创建一个 .cache 文件夹,然后突然之间,一个类似 C:\...\node_modules\A\node_modules\B\.cache\C\... 的文件路径就轻易地超过了 256 个字符的限制。

当这种情况发生时,像 Windows 文件资源管理器这样的标准工具就会直接放弃,无法处理该路径,留给你一个似乎无法删除的文件夹。

生态系统解决方案:以子之矛,攻子之盾——rimraf 与 del-cli

既然这个问题与 Node.js 生态系统密切相关,那么最合适的解决方案也源于其中。与其依赖晦涩的 Windows 命令,你可以使用专门为克服这些限制而设计的 npm 包。这些工具可以被添加到项目的 package.json 脚本中,为整个团队创建一种一致的、跨平台的项目清理方式。

rimraf:经典的 rm -rf Node.js 替代品

rimraf 在 Node.js 世界中家喻户晓。它的名字来源于 Unix/Linux 系统中强大的 rm -rf 命令,并服务于相同的目的:递归地删除文件和目录。

工作原理: rimraf 使用 Node.js 自己的文件系统(fs)模块进行操作。这些模块在设计上就能处理那些会困扰旧版 Windows shell 的长路径名,使得 rimraf 能够在其他方法失败的地方取得成功。

使用方法: 最常见和推荐的方式是将其作为项目的开发依赖项安装:

npm install rimraf --save-dev

然后,你可以在 package.json 中添加一个脚本:

"scripts": {
"clean": "rimraf ./node_modules"
}

现在,团队中的任何人只需运行 npm run clean,就可以可靠地删除 node_modules 文件夹,无论他们使用什么操作系统。

尽管 rimraf 非常有效和简单,但它也有其局限性。它的执行速度可能比原生 shell 命令慢,并且在没有额外包的情况下,不支持用于更复杂删除任务的通配符模式(glob)。

del-cli:一个更安全、更灵活的替代方案

del-clidel 包的命令行接口,它被定位为 rimraf 的一个更现代、功能更丰富的替代品。它直接解决了 rimraf 的许多不足之处。

相较于 rimraf 的主要优势:

  • 内置通配符支持: 你可以直接使用通配符模式来删除特定的文件和文件夹,例如 del-cli "dist/**/*.js"
  • 增强的安全性: 默认情况下,del-cli 会阻止你意外删除当前工作目录或其父目录——这是 rimraf 所缺乏的安全保障。你必须使用 --force 标志才能执行这类高风险操作。
  • 演练模式(Dry Run): del-cli 提供一个 --dry-run 标志,它会显示 将要 被删除的内容,而不会真正执行任何操作。这对于测试脚本和防止灾难性错误至关重要。

使用方法: 安装方式与 rimraf 类似:

npm install del-cli --save-dev

package.json 中的清理脚本会是这样:

"scripts": {
"clean": "del-cli ./node_modules"
}

Windows 用户重要提示: Windows 的命令提示符有一个内置的 del 命令。为了避免冲突,你必须在脚本中使用完整的 del-cli 命令。

rimraf 与 del-cli 功能速览

特性rimrafdel-cli
核心功能递归删除递归删除
通配符 (Glob) 支持否 (需要额外工具)是 (内置)
安全防护 (CWD)否 (会直接删除)是 (默认禁止)
演练模式 (Dry Run)是 (--dry-run)
Windows 命令rimrafdel-cli

结论

尽管 Windows 上的“路径过长”错误是一个令人沮丧的历史遗留问题,但现代开发者工具为我们提供了明确的前进方向。通过将像 rimrafdel-cli 这样的包集成到你的项目脚本中,你可以创建一个简单、健壮且跨平台的解决方案。

对于新项目,del-cli 是推荐的选择,因为它具有更优越的安全特性和内置的灵活性。通过将 "clean": "del-cli ./node_modules" 添加到你的 package.json 中,你不仅为自己解决了问题,还创建了一个标准化的、一键式的解决方案,将使你的整个团队免于 node_modules 黑洞带来的头痛。

禁用 SVGR 的 classname 前缀

· 阅读需 4 分钟
Gemini
Google Gemini
1adybug
子虚伊人

以下内容均为 Gemini 2.5 Pro 生成

告别恼人前缀:如何配置 SVGR 不再修改 SVG 的 className

在现代前端开发中,尤其是在 React 项目里,将 SVG 作为组件直接导入是一种优雅且高效的方式。强大的 SVGR 工具使得这一切变得轻而易举。但你可能很快会发现一个“小惊喜”:你精心命名的 SVG className 在转换后,被自动加上了 文件名_svg__ 这样的前缀。

比如,一个 className="icon-user" 可能会变成 my-icon_svg__icon-user

虽然这是 SVGR 出于好意做的保护措施,但在某些场景下,我们确实需要原汁原味的类名。今天,我们就来深入探讨这个问题背后的原因,并提供一个清晰的解决方案。

问题背后:为什么 SVGR 要添加前缀?

首先,要明确一点:这不是一个 Bug,而是一个 Feature。

在大型项目中,你可能会从不同的设计师或图标库中引入多个 SVG 文件。这些文件很有可能包含相同的类名,例如 <svg class="icon">。如果 SVGR 不做任何处理,直接将它们转换成组件,这些相同的类名就会在全局 CSS 环境中产生样式冲突样式污染,导致一个组件的样式意外地影响到另一个。

为了避免这种混乱,SVGR 内部集成的优化工具 SVGO (SVG Optimizer) 默认启用了一个名为 prefixIds 的插件。这个插件会自动扫描 SVG 内容,并为所有的 idclass 名称添加一个基于文件名的唯一前缀,从而确保其在项目中的唯一性。

解决方案:掌控 SVGO 配置

既然知道了问题的根源在于 SVGO 的 prefixIds 插件,那么解决方案就很明确了:覆盖这个插件的默认配置

我们可以通过 SVGR 的配置文件,告诉它我们不希望 prefixIds 插件来修改我们的 className

推荐方式:使用 svgr.config.js

在项目根目录下创建一个独立的配置文件,是管理 SVGR 行为最清晰的方式。

  1. 创建文件:在你的项目根目录(与 package.json同级)下创建 svgr.config.js 文件。

  2. 添加配置:将以下代码复制到文件中。

    // svgr.config.js
    module.exports = {
    // 传递给 SVGO 的配置
    svgoConfig: {
    plugins: [
    {
    // 插件名称,我们要修改的就是这个
    name: "prefixIds",
    // 插件参数
    params: {
    // 禁用为 ID 添加前缀
    prefixIds: false,
    // 禁用为 className 添加前缀 (这是解决问题的关键!)
    prefixClassNames: false,
    },
    },
    ],
    },
    }

    这段配置清晰地告诉 SVGR:“请在运行时,使用我提供的 svgoConfig。对于 prefixIds 这个插件,请将其 prefixClassNamesprefixIds 参数都设置为 false。”

备选方式:在 Webpack 中配置

如果你的项目使用 Webpack 并且已经配置了 @svgr/webpack,你也可以直接在 webpack.config.js 中完成设置。

// webpack.config.js
module.exports = {
// ... 其他 Webpack 配置
module: {
rules: [
// ... 其他 rules
{
test: /\.svg$/,
use: [
{
loader: "@svgr/webpack",
options: {
// 在 loader 的 options 中直接传入 svgoConfig
svgoConfig: {
plugins: [
{
name: "prefixIds",
params: {
prefixIds: false,
prefixClassNames: false,
},
},
],
},
},
},
],
},
],
},
}

使用 rsbuild 配置

import { defineConfig } from "@rsbuild/core"
import { pluginReact } from "@rsbuild/plugin-react"
import { pluginSvgr } from "@rsbuild/plugin-svgr"

export default defineConfig({
plugins: [
pluginReact(),
pluginSvgr({
svgrOptions: {
exportType: "default",
svgoConfig: {
plugins: [
{
name: "prefixIds",
params: {
prefixIds: false,
prefixClassNames: false,
},
},
],
},
},
}),
],
})

两种方式效果完全相同,选择哪一种取决于你的项目结构和个人偏好。

重要提醒:禁用前缀的潜在风险

当你禁用类名前缀后,避免样式冲突的责任就回到了你的肩上。请确保:

  1. 命名规范:在不同的 SVG 文件中,使用具有唯一性的类名。可以采用 BEM (block__element--modifier) 这样的命名约定来降低冲突概率。
  2. CSS 作用域:使用 CSS Modules, Styled Components, หรือ Tailwind CSS 等技术,它们能从根本上解决全局样式污染的问题。

总结

SVGR 默认添加 className 前缀是为了防止样式冲突,是一个贴心的设计。但通过简单的配置,我们可以轻松禁用它,以满足特定的开发需求。核心在于通过 svgoConfig 选项,将 prefixIds 插件的 prefixClassNames 参数设置为 false

希望这篇文章能帮助你更好地掌控你的 SVG 工作流,写出更干净、更可控的代码。Happy Coding!

回车键提交表单

· 阅读需 4 分钟
Claude
Anthropic Claude
1adybug
子虚伊人

以下内容均为 Claude 4 sonnet 生成

作为前端开发者,你是否遇到过这样的困惑:有时候在表单输入框中按下 Enter 键会自动提交表单,有时候却没有反应?今天我们就来深入了解一下 HTML 表单中 Enter 键的行为规律。

神奇的现象

让我们先来看几个有趣的例子:

  • 单个搜索框

    <form onsubmit="alert('搜索提交了!')">
    <input type="text" name="q" placeholder="请输入搜索关键词" />
    </form>

    在这个搜索框中按Enter键,即使没有提交按钮,表单也会自动提交。

  • 登录表单(无按钮)

    <form onsubmit="alert('登录提交了!')">
    <input type="text" name="username" placeholder="用户名" />
    <input type="password" name="password" placeholder="密码" />
    </form>

    在这个表单的任何输入框中按Enter键,什么都不会发生。

  • 登录表单(有按钮)

    <form onsubmit="alert('登录提交了!')">
    <input type="text" name="username" placeholder="用户名" />
    <input type="password" name="password" placeholder="密码" />
    <button type="submit">登录</button>
    </form>

    添加了提交按钮后,按Enter键又能正常提交了。

是不是感觉很神奇?这背后其实有着清晰的逻辑。

规律总结

HTML 表单的 Enter 键行为遵循以下规律:

  • 单输入字段规则

    当表单只包含一个文本输入字段时,无论是否有提交按钮,按Enter键都会触发表单提交。这主要是为了优化用户体验,特别是搜索框这类常见场景。

  • 多输入字段规则

    当表单包含多个输入字段时:

    • 有提交按钮:按Enter键会触发提交
    • 无提交按钮:按Enter键不会触发提交
  • 提交按钮的定义

    以下元素都被认为是提交按钮:

    • <input type="submit">
    • <button type="submit">
    • <button>(默认type为submit)

设计思路

这种看似复杂的行为设计,实际上体现了Web标准制定者的深思熟虑:

  • 用户体验优先

    • 搜索框这类单字段表单支持Enter快速提交,符合用户习惯
    • 复杂表单需要明确的提交按钮,避免误操作
  • 渐进增强

    • 简单功能(搜索)开箱即用
    • 复杂功能(多字段表单)需要明确的交互设计
  • 向后兼容

    • 保持与早期 HTML 标准的兼容性
    • 确保现有网站功能正常

实际应用建议

  • 搜索功能

    <!-- 推荐:简洁的搜索表单 -->
    <form action="/search" method="GET">
    <input type="search" name="q" placeholder="搜索商品" />
    </form>
  • 登录/注册表单

    <!-- 推荐:明确的提交按钮 -->
    <form action="/login" method="POST">
    <input type="text" name="username" placeholder="用户名" />
    <input type="password" name="password" placeholder="密码" />
    <button type="submit">登录</button>
    </form>
  • 复杂表单

    <!-- 推荐:多个操作按钮时明确指定类型 -->
    <form action="/profile" method="POST">
    <input type="text" name="name" placeholder="姓名" />
    <input type="email" name="email" placeholder="邮箱" />
    <button type="button" onclick="preview()">预览</button>
    <button type="submit">保存</button>
    </form>

注意事项

  1. 避免意外提交:对于复杂表单,确保有明确的提交按钮
  2. 用户体验:搜索类功能应该支持Enter键快速操作
  3. 测试充分:在不同浏览器中测试表单的Enter键行为
  4. 无障碍访问:确保键盘用户能够正常使用表单

总结

HTML表单的Enter键行为虽然看起来复杂,但背后的逻辑是为了平衡用户体验和功能安全。理解这些规律不仅能帮助我们写出更好的表单,也能避免一些常见的用户体验问题。

下次当你的表单出现意外的Enter键行为时,不妨回想一下这篇文章的内容,相信你会很快找到解决方案!


希望这篇文章对你有帮助。如果你有其他前端开发的疑问,欢迎继续交流!

自托管 Next.js

· 阅读需 6 分钟
1adybug
子虚伊人

使用 Next.js 开发网站后,在自己的服务器上部署比较麻烦,有两种比较简单的解决方案:

第一种不用过多介绍,这里主要介绍第二种方案:

  1. next.config.ts 中添加 output: "standalone"

  2. 安装 Docker

  3. 在项目根目录下创建 Dockerfile 文件

    # syntax=docker.io/docker/dockerfile:1

    FROM node:22-alpine AS base

    # Install dependencies only when needed
    FROM base AS deps

    WORKDIR /app

    # Install dependencies based on the preferred package manager
    COPY package.json ./
    RUN npm install --registry=https://registry.npmmirror.com

    # Rebuild the source code only when needed
    FROM base AS builder
    WORKDIR /app
    COPY --from=deps /app/node_modules ./node_modules
    COPY . .

    # Next.js collects completely anonymous telemetry data about general usage.
    # Learn more here: https://nextjs.org/telemetry
    ENV NEXT_TELEMETRY_DISABLED=1

    RUN npx prisma generate
    RUN npm run build

    # Production image, copy all the files and run next
    FROM base AS runner
    WORKDIR /app

    ENV NODE_ENV=production
    # Uncomment the following line in case you want to disable telemetry during runtime.
    ENV NEXT_TELEMETRY_DISABLED=1

    COPY --from=builder /app/public ./public

    # Automatically leverage output traces to reduce image size
    # https://nextjs.org/docs/advanced-features/output-file-tracing
    COPY --from=builder /app/.next/standalone ./
    COPY --from=builder /app/.next/static ./.next/static

    EXPOSE 3000

    ENV PORT=3000

    # server.js is created by next build from the standalone output
    # https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
    ENV HOSTNAME="0.0.0.0"

    CMD ["node", "server.js"]
  4. 在项目根目录下创建 .dockerignore 文件,主要内容应该和 .gitignore 类似,忽略一些不需要的文件,比如 node_modules.next 目录之类的

  5. package.json 中添加 "build:docker": "docker build -t your-app-name ."

Dockerfile 中可以看出,自托管的核心就是 standalone 模式,这个模式下,Next.js 会生成一个 server.js 文件,这个文件是 Next.js 的入口文件,会自动监听 3000 端口,并启动 Next.js 应用。

其实,本质上最重要的产物就是 .next/standalone 目录,这个目录就是最终的根目录。所以最终目录结构应该是这样的:

.next/standaloneapp

.next/staticapp/.next/static

publicapp/public

只要明白了这个原理,我们就可以再实现一个 Next.js 的“安装”程序:

在项目根目录下创建一个 scripts/createInstaller.ts 文件,内容如下:

import { readdir, rm, writeFile } from "fs/promises"
import { resolve } from "path"

import { spawnAsync, zip } from "soda-nodejs"

const reg = /^--target=(windows|linux)$/

const target = (process.argv.find(item => reg.test(item))?.match(reg)?.[1] ?? "windows") as "windows" | "linux"

await rm("scripts/install.ts", { force: true })

await spawnAsync("bunx prisma generate", { shell: true, stdio: "inherit" })

await spawnAsync("bun run build:standalone", { shell: true, stdio: "inherit" })

const input = await readdir(".next/standalone")

await zip({ input, output: "../standalone.zip", cwd: ".next/standalone" })

const input2 = await readdir(".next/static")

await zip({ input: input2, output: "../static.zip", cwd: ".next/static" })

const input3 = await readdir("public")

await zip({ input: input3, output: "../.next/public.zip", cwd: "public" })

const script = `import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "fs/promises"
import { join, parse, resolve } from "path"
import { Readable } from "stream"
import { ReadableStream } from "stream/web"
import { styleText } from "util"
import { file } from "bun"
import { unzip } from "soda-nodejs"

import publicPath from "../.next/public.zip" with { type: "file" }
import standalonePath from "../.next/standalone.zip" with { type: "file" }
import staticPath from "../.next/static.zip" with { type: "file" }

${target === "windows" ? `import windowsPath from "../prisma/generated/query_engine-windows.dll.node" with { type: "file" }` : `import debianPath from "../prisma/generated/libquery_engine-debian-openssl-3.0.x.so.node" with { type: "file" }`}

const from = \`${resolve(".").replace(/\\/g, "\\\\")}\`.replace(/[\\\\/]$/, "")
const to = resolve(".").replace(/^[a-zA-Z]+/, m => m.toUpperCase()).replace(/[\\\\/]$/, "")
const from2 = encodeURIComponent(from)
const to2 = encodeURIComponent(to)
const from3 = encodeURIComponent(from + "/")
const to3 = encodeURIComponent(to + "/")
const from4 = encodeURIComponent(from + "\\\\")
const to4 = encodeURIComponent(to + "\\\\")

function escapeRegExp(str: string) {
return str.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")
}

const reg = new RegExp(
\`\${escapeRegExp(from.replace(/[\\\\/]/g, "__PLACEHOLDER__")).replace(/__PLACEHOLDER__/g, "\\\\\\\\{0,3}[\\\\\\\\/]")}(\\\\\\\\{0,3}[\\\\\\\\/])?\`,
"gi",
)

async function replacePath(dir: string) {
const { name } = parse(dir)
if (name === "node_modules") return
const files = await readdir(dir)
for (const file of files) {
const path = join(dir, file)
const status = await stat(path)
if (status.isDirectory()) {
await replacePath(path)
} else {
if (/\\.([mc]?js|json)$/i.test(path)) {
const content = await readFile(path, "utf-8")
const newContent = content
.replace(reg, (m, p) => {
const prefix = m.match(/(\\\\{0,3})[\\\\/]/)?.[1] ?? ""
const split = \`\${prefix}\${m.includes("/") ? "/" : "\\\\"}\`
return \`\${to.replace(/[\\\\/]/g, split)}\${p ? split : ""}\`
})
.replaceAll(from2, to2)
.replaceAll(from3, to3)
.replaceAll(from4, to4)
await writeFile(path, newContent)
}
}
}
}

async function main() {
const publicStream = Readable.fromWeb(file(publicPath).stream() as ReadableStream)
const standaloneStream = Readable.fromWeb(file(standalonePath).stream() as ReadableStream)
const staticStream = Readable.fromWeb(file(staticPath).stream() as ReadableStream)
${target === "windows" ? `const windowsStream = Readable.fromWeb(file(windowsPath).stream() as ReadableStream)` : `const debianStream = Readable.fromWeb(file(debianPath).stream() as ReadableStream)`}

await rm(".temp", { recursive: true, force: true })
await mkdir(".temp", { recursive: true })

await writeFile(".temp/public.zip", publicStream)
await writeFile(".temp/standalone.zip", standaloneStream)
await writeFile(".temp/static.zip", staticStream)

await unzip({ input: ".temp/public.zip", output: ".temp/public" })
await unzip({ input: ".temp/standalone.zip", output: ".temp/standalone" })
await unzip({ input: ".temp/static.zip", output: ".temp/static" })

const dir = await readdir(".temp/standalone")

for (const item of dir) {
await rm(item, { recursive: true, force: true })
await rename(\`.temp/standalone/\${item}\`, item)
}

await rm("public", { recursive: true, force: true })
await rename(".temp/public", "public")
await rm(".next/static", { recursive: true, force: true })
await rename(".temp/static", ".next/static")

await replacePath(to)

await mkdir("prisma/generated", { recursive: true })
await ${target === "windows" ? `writeFile("prisma/generated/query_engine-windows.dll.node", windowsStream)` : `writeFile("prisma/generated/libquery_engine-debian-openssl-3.0.x.so.node", debianStream)`}

await rm(".temp", { recursive: true, force: true })

console.log(styleText("greenBright", "Task completed, the program will exit in 3 seconds..."))

setTimeout(() => 0, 3000)
}

main()
`

await writeFile("scripts/install.ts", script)

await spawnAsync(`bun build --compile --target=bun-${target}-x64 --minify --sourcemap --bytecode scripts/install.ts --outfile installer`, {
shell: true,
stdio: "inherit",
})

await rm("scripts/install.ts", { force: true })

await rm(".next/standalone.zip", { force: true })

await rm(".next/static.zip", { force: true })

await rm(".next/public.zip", { force: true })

package.json 中添加以下命令:

{
"scripts": {
"build:standalone": "npx cross-env NEXT_OUTPUT=standalone next build",
"build:windows": "bun scripts/createInstaller.ts --target=windows",
"build:linux": "bun scripts/createInstaller.ts --target=linux"
}
}

因为我的项目中涉及到 Prisma ,所以需要生成 Prisma 的客户端,所以需要先执行 prisma generate 命令,然后执行 build:standalone 命令,生成 standalone 模式下的 Next.js 应用。又因为最终的平台涉及 WindowsLinux ,所以需要生成两个版本的 .node 文件,需要在 schema.prisma 中添加如下内容:

generator client {
binaryTargets = ["windows", "debian-openssl-3.0.x"]
}

原理也很简单,就是将产物都使用 bun 打包,执行时再释放出来。

在 Prisma 中创建一对一映射关系

· 阅读需 1 分钟
1adybug
子虚伊人

Prisma 中,创建一对一映射关系非常简单。只需要在 schema.prisma 文件中添加以下代码:

model User {
id String @id @default(uuid())
name String
profiles Profile[] @relation("UserProfile")
currentProfileId String? @unique
currentProfile Profile? @relation("CurrentProfile", fields: [currentProfileId], references: [id])
}

model Profile {
id String @id @default(uuid())
name String
userId String
user User @relation("UserProfile", fields: [userId], references: [id])
currentUser User? @relation("CurrentProfile")
}

使用 Node.js 作为 Next.js 中间件的运行时

· 阅读需 1 分钟
1adybug
子虚伊人

Next.js 中,如果你在中间件使用了 Node.jsAPI, 那么可能会产生以下报错

Error: The edge runtime does not support Node.js 'crypto' module.

解决方法如下:

  1. 安装 next@canary

  2. next.config.ts 中添加以下配置

    import { NextConfig } from "next"

    const nextConfig: NextConfig = {
    experimental: {
    nodeMiddleware: true,
    },
    }

    export default nextConfig
  3. middleware.ts 中添加以下配置

    export const runtime = "nodejs"

当然,最好的做法是不使用 Node.js 作为中间件的运行时,而是使用 Edge 作为中间件的运行时,尽量使用 Edge 的 API 来实现中间件的功能。

在 Docker 中使用 Clash

· 阅读需 6 分钟
1adybug
子虚伊人

在图形化的操作系统中,我们可以使用 Clash Verge 来使用代理,但是在服务器上,就没有这么方便了。这时候我们可以使用 Clash 的核心 mihomo 来实现代理。然而,mihomo 的安装也是比较麻烦,所以我们可以使用 Docker 来安装 mihomo

安装教程

  1. 安装 Docker

  2. 创建 config.yaml 文件

    # url 里填写自己的订阅,名称不能重复
    proxy-providers:
    <你的订阅名称>:
    url: "<你的订阅链接>"
    type: http
    interval: 86400
    health-check: { enable: true, url: "https://www.gstatic.com/generate_204", interval: 300 }

    proxies:
    - name: "直连"
    type: direct
    udp: true

    mixed-port: 7890
    ipv6: true
    allow-lan: true
    unified-delay: false
    tcp-concurrent: true
    external-controller: 0.0.0.0:9090
    external-ui: ui
    external-ui-url: "https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip"
    # 如果你的端口开放了所有 IP 访问,建议取消注释 secret 并设置密码
    # secret: <你的密码>

    geodata-mode: true
    geox-url:
    geoip: "https://mirror.ghproxy.com/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip-lite.dat"
    geosite: "https://mirror.ghproxy.com/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat"
    mmdb: "https://mirror.ghproxy.com/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country-lite.mmdb"
    asn: "https://mirror.ghproxy.com/https://github.com/xishang0128/geoip/releases/download/latest/GeoLite2-ASN.mmdb"

    find-process-mode: strict
    global-client-fingerprint: chrome

    profile:
    store-selected: true
    store-fake-ip: true

    sniffer:
    enable: true
    sniff:
    HTTP:
    ports: [80, 8080-8880]
    override-destination: true
    TLS:
    ports: [443, 8443]
    QUIC:
    ports: [443, 8443]
    skip-domain:
    - "Mijia Cloud"
    - "+.push.apple.com"

    tun:
    enable: true
    stack: mixed
    dns-hijack:
    - "any:53"
    - "tcp://any:53"
    auto-route: true
    auto-redirect: true
    auto-detect-interface: true

    dns:
    enable: true
    ipv6: true
    enhanced-mode: fake-ip
    fake-ip-filter:
    - "*"
    - "+.lan"
    - "+.local"
    - "+.market.xiaomi.com"
    default-nameserver:
    - tls://223.5.5.5
    - tls://223.6.6.6
    nameserver:
    - https://doh.pub/dns-query
    - https://dns.alidns.com/dns-query

    proxy-groups:
    - name: 默认
    type: select
    proxies: [自动选择, 直连, 香港, 台湾, 日本, 新加坡, 美国, 其它地区, 全部节点]

    - name: Google
    type: select
    proxies: [默认, 香港, 台湾, 日本, 新加坡, 美国, 其它地区, 全部节点, 自动选择, 直连]

    - name: Telegram
    type: select
    proxies: [默认, 香港, 台湾, 日本, 新加坡, 美国, 其它地区, 全部节点, 自动选择, 直连]

    - name: Twitter
    type: select
    proxies: [默认, 香港, 台湾, 日本, 新加坡, 美国, 其它地区, 全部节点, 自动选择, 直连]

    - name: 哔哩哔哩
    type: select
    proxies: [默认, 香港, 台湾, 日本, 新加坡, 美国, 其它地区, 全部节点, 自动选择, 直连]

    - name: 巴哈姆特
    type: select
    proxies: [默认, 香港, 台湾, 日本, 新加坡, 美国, 其它地区, 全部节点, 自动选择, 直连]

    - name: YouTube
    type: select
    proxies: [默认, 香港, 台湾, 日本, 新加坡, 美国, 其它地区, 全部节点, 自动选择, 直连]

    - name: NETFLIX
    type: select
    proxies: [默认, 香港, 台湾, 日本, 新加坡, 美国, 其它地区, 全部节点, 自动选择, 直连]

    - name: Spotify
    type: select
    proxies: [默认, 香港, 台湾, 日本, 新加坡, 美国, 其它地区, 全部节点, 自动选择, 直连]

    - name: Github
    type: select
    proxies: [默认, 香港, 台湾, 日本, 新加坡, 美国, 其它地区, 全部节点, 自动选择, 直连]

    - name: 国内
    type: select
    proxies: [直连, 默认, 香港, 台湾, 日本, 新加坡, 美国, 其它地区, 全部节点, 自动选择]

    - name: 其他
    type: select
    proxies: [默认, 香港, 台湾, 日本, 新加坡, 美国, 其它地区, 全部节点, 自动选择, 直连]

    #分隔,下面是地区分组
    - name: 香港
    type: select
    include-all: true
    exclude-type: direct
    filter: "(?i)港|hk|hongkong|hong kong"

    - name: 台湾
    type: select
    include-all: true
    exclude-type: direct
    filter: "(?i)台|tw|taiwan"

    - name: 日本
    type: select
    include-all: true
    exclude-type: direct
    filter: "(?i)日|jp|japan"

    - name: 美国
    type: select
    include-all: true
    exclude-type: direct
    filter: "(?i)美|us|unitedstates|united states"

    - name: 新加坡
    type: select
    include-all: true
    exclude-type: direct
    filter: "(?i)(新|sg|singapore)"

    - name: 其它地区
    type: select
    include-all: true
    exclude-type: direct
    filter: "(?i)^(?!.*(?:🇭🇰|🇯🇵|🇺🇸|🇸🇬|🇨🇳|港|hk|hongkong|台|tw|taiwan|日|jp|japan|新|sg|singapore|美|us|unitedstates)).*"

    - name: 全部节点
    type: select
    include-all: true
    exclude-type: direct

    - name: 自动选择
    type: url-test
    include-all: true
    exclude-type: direct
    tolerance: 10

    rules:
    - GEOIP,lan,直连,no-resolve
    - GEOSITE,github,Github
    - GEOSITE,twitter,Twitter
    - GEOSITE,youtube,YouTube
    - GEOSITE,google,Google
    - GEOSITE,telegram,Telegram
    - GEOSITE,netflix,NETFLIX
    - GEOSITE,bilibili,哔哩哔哩
    - GEOSITE,bahamut,巴哈姆特
    - GEOSITE,spotify,Spotify
    - GEOSITE,CN,国内
    - GEOSITE,geolocation-!cn,其他

    - GEOIP,google,Google
    - GEOIP,netflix,NETFLIX
    - GEOIP,telegram,Telegram
    - GEOIP,twitter,Twitter
    - GEOIP,CN,国内
    - MATCH,其他
  3. 创建 docker-compose.yaml 文件

    version: "3"

    services:
    metacubexd:
    container_name: metacubexd
    image: ghcr.io/metacubex/metacubexd
    restart: always
    ports:
    - "9097:80"

    mihomo:
    container_name: mihomo
    image: docker.io/metacubex/mihomo:latest
    restart: always
    ports:
    # 尽量使用 127.0.0.1 而不是 0.0.0.0,否则可能让代理暴露在公网
    - "127.0.0.1:7890:7890"
    - "9090:9090"
    volumes:
    - <保存 config.yaml 文件的目录>:/root/.config/mihomo
  4. 启动容器 docker compose up -d

使用教程

在需要使用代理的地方,设置代理为 http://127.0.0.1:7890 即可。

比如为终端设置代理:

  1. 编辑 ~/.bashrc 文件,添加以下内容:

    export http_proxy="http://127.0.0.1:7890"
    export https_proxy="http://127.0.0.1:7890"
    export all_proxy="socks5://127.0.0.1:7890"
    export no_proxy="localhost,127.0.0.1,*.local"
  2. 保存并退出,然后执行 source ~/.bashrc 使配置生效。

  3. 使用 curl https://www.google.com 测试是否成功。

注意事项

  1. 尽量不要使用 MobaXterm 等终端创建或者编辑配置文件,可以会出现默认编码非 UTF-8 或者缺少字符集的问题,导致配置文件无法正常使用。
  2. config.yamlexternal-controller 的端口和 docker-compose.yamlmetacubexd 的端口都需要在防火墙中开放,建议只允许特定 IP 访问,如果全放开的话,建议取消注释 secret 并设置密码。
  3. docker-compose.yamlmihomovolumes 需要指向 config.yaml 文件在主机的目录而非文件。
  4. 如果 mihomo 的容器的日志中出现了下载文件的错误,请到 meta-rules-dat 仓库中手动下载文件并保存到 config.yaml 所在的目录

代码片段关键字替换

· 阅读需 1 分钟
1adybug
子虚伊人

有的时候我们需要在代码片段同时进行两种替换,比如下方,我们要将 api 移除,并将其转换为驼峰式命名法:

烤肉串式命名法 (Kebab-case)驼峰式命名法 (CamelCase)
api.my-filemyFile
api.another-example-fileanotherExampleFile
api.yet-another-oneyetAnotherOne

这种替换的难点在我们无法确定 - 的数量为多少

这时候可以使用以下代码实现:

const prefix = "api\\\\.([^-]+)"

const unit = "-([^-])([^-]*)"

const max = 6

const regExp = Array(max)
.fill(prefix)
.map((item, index) => ["^", item, ...Array(index).fill(unit), "$"].join(""))
.join("|")

const replacer = Array(max)
.fill(0)
.reduce(
(acc, item, index) => [
...acc,
[
(acc.at(-1)?.at(-1) ?? 0) + 1,
...Array(index * 2)
.fill(0)
.map((item2, index2) => (acc.at(-1)?.at(-1) ?? 0) + 1 + index2 + 1),
],
],
[],
)
.map(item => item.map((item2, index2) => (index2 % 2 === 0 ? `$${item2}` : `$\{${item2}:/upcase}`)).join(""))
.join("")

const body = `\${TM_FILENAME_BASE/${regExp}/${replacer}/}`

console.log(body)

想要最大支持多少个 - ,只需要修改 max 的值即可。