普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月27日掘金 前端

9.BTC-比特币脚本-北大肖臻老师客堂笔记

作者 前端涂涂
2026年1月27日 12:00

北京大学肖臻老师《区块链技术与应用》公开课第 9 讲的主题是**“比特币的脚本”**。本课深入探讨了比特币交易的底层结构、输入输出的关联以及脚本执行的逻辑。

以下是根据视频内容及你提供的图片进行的详细总结:

一、 比特币交易的宏观结构

比特币的交易并非简单的“ A 转账给 B ”,而是一个包含多个字段的复杂 JSON 对象。

  • 基础元数据:包括交易 ID (txid)、版本号、大小 (size) 和锁定时间 (locktime)。
  • 确认数 (confirmations):表示该交易所在区块之后已经产生了多少个区块,确认数越多,交易越不可篡改。
  • 输入 (vin) 与输出 (vout):这是交易的核心,每一笔交易都是将之前的输出作为现在的输入,并产生新的输出。

二、 交易的输入与输出细节

比特币通过脚本系统实现了资金的转移和锁定。

1. 交易输入 (vin)

  • 引用来源:每个输入必须指明资金来源,即前一笔交易的 ID (txid) 和对应的输出索引 (vout)。
  • 解锁脚本 (scriptSig):包含发送者的数字签名和公钥,用于证明对这笔资金的所有权。

2. 交易输出 (vout)

  • 金额 (value):该输出对应的比特币数量。
  • 锁定脚本 (scriptPubKey):定义了花费这笔钱的条件,通常要求提供能与特定公钥哈希匹配的签名。
  • 地址 (addresses):虽然代码中显示为地址,但本质上是公钥哈希的 Base58 编码。

三、 脚本执行逻辑:P2PKH

比特币最常见的交易类型是 P2PKH (Pay-to-Pubkey-Hash)。其验证过程是将当前交易的 scriptSig 和前一笔交易的 scriptPubKey 拼接在一起执行。

执行步骤如下(基于栈的操作):

  1. PUSHDATA(Sig):将签名压入栈。
  2. PUSHDATA(PubKey):将公钥压入栈。
  3. DUP:复制栈顶的公钥。
  4. HASH160:将栈顶公钥进行哈希处理。
  5. PUSHDATA(PubKeyHash):将预期的公钥哈希(来自锁定脚本)压入栈。
  6. EQUALVERIFY:比较两个哈希值是否一致。如果不一致,脚本执行失败。
  7. CHECKSIG:利用栈中的公钥验证签名的有效性。如果验证通过,返回 TRUE,交易合法。

四、 全节点在脚本系统中的角色

全节点是脚本执行的实际操作者:

  • 验证合法性:全节点通过执行上述脚本,验证网络上每一笔交易的输入是否真实引用了有效的 UTXO,以及签名是否正确。
  • 维护 UTXO 集合:为了提高验证效率,全节点在内存中维护 UTXO 集合,这样在收到新交易时,无需扫描整个硬盘账本即可确认资金是否已被花费。
  • 决定打包逻辑:全节点在验证脚本通过后,才会将交易放入交易池,并决定是否将其打包进下一个区块。

五、 核心名词总结

  • ScriptSig:解锁脚本,证明“我有权花这笔钱”。
  • ScriptPubKey:锁定脚本,规定“谁能花这笔钱”。
  • Stack-based Language:比特币脚本是一种简单的、非图灵完备的、基于栈的编程语言,这种设计是为了安全,防止死循环攻击。
  • UTXO (Unspent Transaction Output):尚未被花费的交易输出,是比特币账本的基本组成单位。

9.BTC-比特币脚本.png

用bin-home定位全局npm cli命令的来源包

作者 火车叼位
2026年1月27日 11:48

你是否曾经在终端里输入一个命令,然后疑惑它到底来自哪个 npm 包?

想象一下:你几个月前装了一个很好用的 CLI 工具,叫 codex。现在你想更新它,于是习惯性地输入:

npm i -g codex

结果却返回:

npm ERR! code E404
npm ERR! 404 Not Found - GET https://registry.npmjs.org/codex

等等,我明明之前安装过啊?于是你开始翻找 bash 历史、查看全局 node_modules,甚至怀疑自己是不是记错了命令名……

别担心,你并不孤单。这正是 bin-home 诞生的原因。

当命令名“背叛”了包名

在 npm 的世界里,大多数情况下,包的名称和它提供的 CLI 命令名称是一致的。比如 create-react-app 这个包,它的命令也是 create-react-app

但近年来,随着工具链的复杂化,出现了越来越多“名不副实”的情况:

  • @openai/codex 这个包,提供的命令是 codex
  • @microsoft/opencode 这个包,提供的命令可能是 opencode

这样一来,当你想更新一个全局安装的 CLI 工具时,就得先回答一个哲学问题:“我当初到底安装的是什么包?”

引入 bin-home:你的全局命令侦探

bin-home 是一个轻量级 CLI 工具,专门解决这个痛点。它的工作很简单:告诉你某个命令来自哪个 npm 包。

快速上手

安装只需一瞬间:

npm install -g bin-home

使用更是简单到令人发指:

bin-home <命令名>

实战演示

让我们看看 bin-home 如何解决开头的难题:

bin-home codex

输出:

npm: @openai/codex
npm url: https://www.npmjs.com/package/@openai/codex
github: https://github.com/openai/codex

恍然大悟!原来 codex 命令来自 @openai/codex 这个包。现在你可以正确地更新它了:

npm i -g @openai/codex

再试一个:

bin-home opencode

输出可能类似于:

npm: @microsoft/opencode
npm url: https://www.npmjs.com/package/@microsoft/opencode
github: https://github.com/microsoft/opencode

高级技巧:一键直达

如果你懒得复制链接,bin-home 还贴心地提供了 --open 选项:

bin-home codex --open

执行后,它会自动在浏览器中打开该包的 npm 页面,让你直接查看文档、版本信息或进行更新。

为什么 bin-home 如此有用?

1. 拯救“金鱼记忆”开发者

我们都会忘记几个月前安装的包名。bin-home 就像你的 CLI 记忆外置硬盘,随时提醒你“当初装的是什么”。

2. 统一团队工具链

当团队成员使用不同的命令别名时,bin-home 可以帮助快速确认这些命令背后的实际包,减少沟通成本。

3. 探索新工具

看到别人使用一个很酷的命令但不知道是什么工具?bin-home 帮你反向查找,快速找到工具源头。

4. 清理全局环境

想知道 node_modules/.bin/ 里那一堆命令都是什么来头?bin-home 帮你一一查明,方便做“大扫除”。

技术实现简介

bin-home 的工作原理其实很直观:

  1. 定位命令:在系统的 PATH 环境变量中查找指定命令的实际位置
  2. 追溯来源:通过符号链接(symlink)追溯到 npm 全局安装目录
  3. 解析元数据:读取对应包的 package.json,提取包名、仓库等信息
  4. 智能输出:格式化展示信息,并可选择打开相关页面

整个过程快速、无副作用,不会影响你现有的任何环境配置。

现在就试试!

告别“我记得命令但忘了包名”的尴尬时刻。安装 bin-home,让你对全局 CLI 工具了如指掌:

npm install -g bin-home

然后尝试检查你常用的命令:

bin-home eslint
bin-home prettier
bin-home yarn

你可能会发现一些意想不到的“真身”!


bin-home:因为每个命令,都该有个清晰的“出身证明”。

GitHub 仓库:[你的仓库地址]
npm 包:www.npmjs.com/package/bin…

问题反馈或功能建议?欢迎在 GitHub 上提交 Issue 或 PR!

CSS选择器 - 认识选择器

作者 GinoWi
2026年1月27日 11:14

CSS选择器 - 认识选择器

选择器就是用于查找或选取需要设置CSS样式的HTML标签。

标签选择器

  • 作用:根据指定的标签名称,在当前界面中找到所有该名称的标签,然后设置属性。
  • 格式:
标签名称{
  属性名称: 属性名称值;
}
  • 注意点:

    1. 标签选择器选中的是当前界面中所有的标签,而不能单独选中某一个标签。
    2. 标签选择器无论标签嵌套多深都能选中。
    3. 只要是HTML中的标签就可以作为标签选择器。

id选择器

  • 作用:根据指定的id名称找到对应的标签,然后设置属性。
  • 格式:
#id名称{
  属性名称: 属性值;
}
  • 注意点:

    • 每个HTML标签都有一个属性叫做id,也就是说每个标签都可以设置id
    • 在同一个界HTML页面中id的名称是不可以重复的。
    • 在编写id选择器的时候一定要在id名称前面加上#
    • id名称是有一定的规范的:(1)id名称只能由字母/数字、下划线组成;(2)id名称不能以数字开头;(3)id名称不能是HTML标签的名称。
    • 在开发过程中,一般情况下如果仅仅是为了设置样式,我们不会专门使用id选择器,因为id属性一般是留给js使用的。

类选择器

  • 作用:根据指定的类名称找到对应的标签,然后设置属性。
  • 格式:
.类名{
  属性名称: 属性值;
}
  • 注意点:

    • 每个HTML标签都一个属性叫做class,也就是说每个标签都可以设置class
    • 在同一个HTML界面中class的名称是可以重复的。
    • 在编写class选择器的时候一定要在class名称前面加上.
    • 类名的命名规范和id的命名规范一样。
    • 类名就是专门用来给某个特定的标签设置样式的。
    • 在HTML中每个HTML标签可以同时绑定多个类名,eg:<标签名称 class=“类名1 类名2 ……“>;错误的写法:<p class="1" class="2"></p>

补充内容:

  1. idclass的区别?

    • id相当于人的身份证,不能重复;而class相当于人的名称不能重复。
    • 一个HTML标签只能绑定一个id名称,而一个HTML标签可以绑定多个class名称。
  2. id选择器和class选择器的区别?

    • id选择器是以#开头,而class选择器以.开头。
  3. 在开发中到底选择id选择器还是class选择器?

    • id一般情况下是给js使用的,所以除非特殊情况,否则不要用id选择器去设置样式。

后代选择器

  • 作用:找到指定标签的所有后代标签设置属性。(组合器)
  • 格式:
/*先找到选择器1所选中的标签,然后再在这个标签下面去查找所有选择器2所选中的标签,然后再设置属性。*/
选择器1 选择器2{
    属性: 值;
}
  • 注意点:

    • 后代选择器必须用空格隔开。
    • 后代不仅仅是子元素,也包括孙子元素或者重孙子元素,只要最终是放到指定标签中的都是后代。
    • 后代选择器不仅仅可以使用标签名称,还可以使用其他选择器(id选择器、类选择器)。
    • 后代选择器可以无限延伸,无限向下寻找。

子元素选择器

  • 作用:找到指定标签中所有直接子元素,然后设置属性。
  • 格式:
/*先通过选择器1找到选中的标签,然后在这个标签中查找所有通过选择器2选中的直接子元素。*/
选择器1 > 选择器2{
  属性: 值;
}
  • 注意点:
  1. 子元素选择器只会查找儿子,不会查找其他被嵌套的标签
  2. 子元素选择器之间只能由大于号链接,并且不能加空格
  3. 子元素选择器不仅仅可以使用标签名称,还可以使用其他选择器。
  4. 子元素选择器可以通过>一直延续下去。

补充内容:

后代选择器和子元素选择器之间的区别?

  • 后代选择器使用空格作为连接符号,子元素选择器使用>作为连接符号。
  • 后代选择器会选中指定标签中所有的特定后代标签,也就是会选中儿子、孙子……,只要是被放到指定标签中的特定标签都会被选中。
  • 子元素选择器只会选中指定标签中所有特定的直接子元素,也就是只会选中特定的儿子标签。

后代选择器和子元素选择器的共同点?

  • 后代选择器和子元素选择器都可以使用标签名称/id名称/class名称来作为选择器。
  • 后代选择器和子元素选择器都可以通过各自的连接符号一直延续下去。

在后续开发中如何使用?

  • 如果想选中指定标签中所有特定标签,那么就使用后代选择器
  • 如果只想选中指定标签中特定的儿子标签,那么选择子元素选择器

交集选择器

  • 作用:给所有选择器选中的标签中,共同包含的那部分标签设置属性。
  • 格式:
选择器1选择器2{
  属性: 值;
}
  • 注意点:

    • 选择器和选择器之间没有任何连接符号。
    • 选择器可以使用标签名称/id名称/class名称。

并集选择器

  • 作用:给我们所有选择器选中的标签设置属性。
  • 格式:
选择器1, 选择器2{
  属性: 值;
}
  • 注意点:

    • 并集选择器必须使用,来连接。
    • 选择器可以使用标签名称/id名称/class名称。

兄弟选择器

兄弟就是同级关系。

  1. 相邻兄弟选择器(CSS2)

    • 作用:给指定选择器后面紧跟的那个选择器选中的标签设置属性。

    • 格式:

      选择器1 + 选择器2{
        属性: 值;
      }
      
    • 注意点:

      • 相邻兄弟选择器必须通过加号连接。
      • 相邻兄弟选择器只能选中紧跟其后的那个标签,不能选中被隔开的标签。
  2. 通用兄弟选择器(css3)

    • 作用:给指定选择器后面所有选择器选中的所有标签设置属性。

    • 格式:

      选择器1 ~ 选择器2{
        属性: 值;
      }
      
    • 注意点:

      • 通用兄弟选择器必须用波浪线来连接。
      • 通用兄弟选择器选中的是指定选择器后面某个选择器选中的所有标签,无论有没有被隔开都可以选中。

序选择器

CSS3新增选择器中最具代表性的就是序选择器。

  • 格式:
标签名称序选择器{
  属性: 值;
}
  1. 同级别中的第几个

    • :first-child:选中同级别中的第一个标签

    • :last-child:选择同级别中最后一个标签

    • :nth-child(n):选择同级别中第n个标签

    • :nth-last-child(n):选择同级别中倒数第n个标签

    • 注意点:

      • 排序时不会区分类型。
  2. 同类型中的第几个

    • :first-of-type:选中同级别中同类型的第一个标签
    • :last-of-type:选中同级别中同类型的最后一个标签
    • :nth-of-type(n): 选中同级别中同类型的第n个标签
    • :nth-last-of-type(n):选择同级别中同类型的倒数第n个标签
  3. 特殊:

    • :only-child:选中父元素中唯一的元素
    • :only-of-type:选中父元素中唯一类型的元素
    • :nth-child(odd):选中同级别中的奇数元素
    • :nth-child(even):选中同级别中的偶数元素
    • :nth-of-type(odd): 选中同级别中同类型的奇数元素
    • :nth-of-type(even):选中同级别中同类型的偶数元素。
    • :nth-child(xn+y): x和y时用户自定义的,而n是一个计数器,从0开始统计。

属性选择器

  • 作用:根据指定的属性名称找到对应的标签,然后设置属性

  • 格式:

    标签名称[attribute]{
        属性: 值;
    }
    
  • 不同类型属性选择器:

    • 找到有指定属性,并切指定属性等于value的标签:

      标签名称[attribute=value]{
          属性: 值;
      }
      
    • 选取属性取值是以value开头的:

      /*CSS2*/
      标签名称[attribute|=value]{
          属性: 值;
      }
      /*CSS3*/
      标签名称[attribute^=value]{
          属性: 值;
      }
      
      • 区别:

        • CSS2开头只能找到value开头,并且value是用-与其他内容隔开的。
        • CSS3中的只要是以value开头的都可以找到,无论有没有被-隔开。
    • 属性的取值是以value结尾的:

      /*CSS3*/
      标签名称[attribute$=value]{
          属性: 值;
      }
      
    • 属性的取值是否包含某个特定的value

      /*CSS2*/
      标签名称[attribute~=value]{
          属性: 值;
      }
      /*CSS3*/
      标签名称[attribute*=value]{
          属性: 值;
      }
      
      • 区别:

        • CSS2的只能找到value是独立的单词,value是被空格隔开的。
        • CSS3中只要包含value就可以被找到。
  • 最常见的场景:用于区分input属性。

通配符选择器

  • 作用:给当前界面上所有标签设置属性。
  • 格式:
*{
  属性: 值;
}
  • 注意点:

    • 由于通配符选择器是设置界面上所有标签的属性,所以在设置之前会遍历所有标签,如果当前界面上的标签比较多,性能就会比较差,所以一般开发过程中不会使用。

参考链接:

W3School官方文档:www.w3school.com.cn

几种依赖注入的使用场景 - InversifyJS为例

作者 irises
2026年1月27日 11:12

依赖注入不仅仅是一个让代码看起来“高级”的工具,它的核心价值在于解耦。通过将对象的“创建权”从业务逻辑中剥离并交给容器,我们能获得极高的灵活性。

关于依赖注入相关概念可参考依赖注入的艺术:编写可扩展 JavaScript 代码的秘密

以下是依赖注入最具代表性的五个使用场景。


1. 单元测试 (Unit Testing)

痛点: 当业务类直接 new 依赖时,测试该类就必须执行依赖的真实逻辑(如真实扣款、真实写库),导致测试缓慢且危险。

❌ 方式 A:不使用 InversifyJS (强耦合)

OrderService 内部强行依赖了 RealPayment。要测试 checkout 方法,你必须真的发起支付,无法轻松 Mock。

TypeScript

// 具体的支付实现
class RealPayment {
    pay(amount: number) {
        console.log(`$$$ 调用银行接口扣款: ${amount}`); // 真实副作用
    }
}

class OrderService {
    private payment: RealPayment;

    constructor() {
        // 😱 致命缺陷:硬编码依赖,测试时无法替换!
        this.payment = new RealPayment();
    }

    checkout(amount: number) {
        this.payment.pay(amount);
    }
}

✅ 方式 B:使用 InversifyJS (依赖抽象)

业务类只依赖接口 IPayment。在单元测试中,我们可以通过容器绑定一个 MockPayment,轻松隔离副作用。

TypeScript

// 1. 定义接口
interface IPayment { pay(amount: number): void; }

// 2. 业务逻辑 (只依赖接口)
@injectable()
class OrderService {
    constructor(
        @inject(TYPES.Payment) private payment: IPayment // 注入接口
    ) {}

    checkout(amount: number) {
        this.payment.pay(amount);
    }
}

// --- 单元测试文件 spec.ts ---
const testContainer = new Container();

// 🧪 测试时:绑定 Mock 实现
const mockPayment = { 
    pay: jest.fn() // 使用 Jest 等测试库的 Mock 函数
}; 
testContainer.bind(TYPES.Payment).toConstantValue(mockPayment);
testContainer.bind(OrderService).toSelf();

const service = testContainer.get(OrderService);
service.checkout(100);

// 断言:验证是否调用了 mock 方法,而不是真的扣款
expect(mockPayment.pay).toHaveBeenCalledWith(100);

2. 可替换的组件 (Swappable Components)

痛点: 同一个接口有多种实现(例如:存储策略既有本地存储,又有云存储)。传统写法通常伴随着大量的 if-else 或工厂模式代码。

❌ 方式 A:不使用 InversifyJS (工厂模式/条件判断)

调用者需要知道具体的实现类,且扩展新策略时需要修改工厂代码。

TypeScript

class LocalStorage { save() { console.log("存硬盘"); } }
class CloudStorage { save() { console.log("存 AWS S3"); } }

class FileManager {
    private storage: any;

    constructor(type: string) {
        // 😱 违反开闭原则:每次加新策略都要改这里
        if (type === 'local') {
            this.storage = new LocalStorage();
        } else {
            this.storage = new CloudStorage();
        }
    }
}

✅ 方式 B:使用 InversifyJS (命名绑定)

使用 @named 标签,可以在不修改业务逻辑代码的情况下,灵活注入不同的策略。

TypeScript

@injectable()
class FileManager {
    constructor(
        // ✨ 优雅:同时注入两种策略,按需使用
        @inject(TYPES.Storage) @named("local") private local: IStorage,
        @inject(TYPES.Storage) @named("cloud") private cloud: IStorage
    ) {}

    backup() {
        this.local.save(); // 先存本地
        this.cloud.save(); // 再存云端
    }
}

// --- 容器配置 ---
container.bind<IStorage>(TYPES.Storage).to(LocalStorage).whenTargetNamed("local");
container.bind<IStorage>(TYPES.Storage).to(CloudStorage).whenTargetNamed("cloud");

3. 跨环境运行 (Cross-Environment Execution)

痛点: 开发环境用 SQLite,生产环境用 PostgreSQL。如果不使用 DI,代码中会充斥着 process.env.NODE_ENV 的判断,导致代码混乱。

❌ 方式 A:不使用 InversifyJS (环境判断污染逻辑)

TypeScript

class DatabaseService {
    constructor() {
        // 😱 环境配置逻辑泄漏到了业务类中
        if (process.env.NODE_ENV === 'production') {
            this.connection = new PostgresConnection();
        } else {
            this.connection = new SqliteConnection();
        }
    }
    
    query() {
        return this.connection.exec("SELECT * FROM users");
    }
}

✅ 方式 B:使用 InversifyJS (容器模块化配置)

业务代码完全干净,环境切换的逻辑被移到了容器配置层(Composition Root)。

TypeScript

// 1. 业务代码 (完全不知道当前是什么环境)
@injectable()
class DatabaseService {
    constructor(@inject(TYPES.DbConnection) private conn: IDbConnection) {}
}

// 2. 环境配置模块 (config.ts)
const devModule = new ContainerModule((bind) => {
    bind<IDbConnection>(TYPES.DbConnection).to(SqliteConnection);
});

const prodModule = new ContainerModule((bind) => {
    bind<IDbConnection>(TYPES.DbConnection).to(PostgresConnection);
});

// 3. 入口文件 (index.ts)
const container = new Container();
if (process.env.NODE_ENV === 'production') {
    container.load(prodModule); // 🏭 加载生产模块
} else {
    container.load(devModule);  // 🛠️ 加载开发模块
}

4. 插件式架构 (Plugin Architecture)

痛点: 系统核心需要加载第三方插件。如果不使用 DI,核心系统必须手动 import 并实例化插件,这使得动态扩展变得极其困难。

❌ 方式 A:不使用 InversifyJS (手动列表)

核心代码必须“认识”每一个插件。

TypeScript

import { GitPlugin } from "./plugins/git";
import { DockerPlugin } from "./plugins/docker";

class App {
    private plugins: any[] = [];

    constructor() {
        // 😱 扩展性差:想加个插件,还得改核心代码的构造函数
        this.plugins.push(new GitPlugin());
        this.plugins.push(new DockerPlugin());
    }

    run() {
        this.plugins.forEach(p => p.exec());
    }
}

✅ 方式 B:使用 InversifyJS (多重注入 Multi-Injection)

核心系统定义接口,插件自行注册到容器。核心系统自动获取所有符合接口的插件。

TypeScript

// 核心系统
@injectable()
class App {
    private plugins: IPlugin[];

    constructor(
        // ✨ 魔法:自动把容器里所有绑定为 TYPES.Plugin 的实例都注入进来,形成数组
        @multiInject(TYPES.Plugin) plugins: IPlugin[]
    ) {
        this.plugins = plugins;
    }
}

// 插件 A (独立文件)
bind<IPlugin>(TYPES.Plugin).to(GitPlugin);

// 插件 B (独立文件)
bind<IPlugin>(TYPES.Plugin).to(DockerPlugin);

// 这种模式下,新增插件只需要 bind 一下,不需要修改 App 类的任何代码。

5. 复杂的生命周期管理 (Singleton vs Transient)

痛点: 某些对象(如缓存、数据库连接池)必须是全局单例,而某些对象(如 HTTP 请求上下文)必须每次新建。手动管理这些单例模式非常容易出错。

❌ 方式 A:不使用 InversifyJS (手动单例模式)

开发者必须手动实现 Singleton 模式,代码啰嗦且难以维护。

TypeScript

class CacheService {
    private static instance: CacheService;
    
    // 😱 样板代码:每个单例类都要写这一坨逻辑
    private constructor() {} 

    public static getInstance(): CacheService {
        if (!CacheService.instance) {
            CacheService.instance = new CacheService();
        }
        return CacheService.instance;
    }
}

// 使用时必须小心
const cache = CacheService.getInstance();

✅ 方式 B:使用 InversifyJS (声明式生命周期)

类本身不需要知道自己是不是单例,全靠容器配置。

TypeScript

@injectable()
class CacheService {
    constructor() { console.log("CacheService Created"); }
}

@injectable()
class RequestHandler {
    constructor() { console.log("RequestHandler Created"); }
}

// --- 容器配置 ---
// 1. 单例:整个应用只创建一次
container.bind(CacheService).toSelf().inSingletonScope();

// 2. 瞬态:每次请求都创建新的
container.bind(RequestHandler).toSelf().inTransientScope();

// --- 运行结果 ---
const cache1 = container.get(CacheService);
const cache2 = container.get(CacheService);
// 输出: "CacheService Created" (只输出一次,cache1 === cache2)

const handler1 = container.get(RequestHandler);
const handler2 = container.get(RequestHandler);
// 输出: "RequestHandler Created" (输出两次,handler1 !== handler2)

【JS逆向】webpack入门之某医保平台

作者 hanjor
2026年1月27日 11:06

声明

本文章所有内容仅供学习交流使用,不用于其他任何目的,其中的抓包内容、数据接口、敏感网址等均已做脱敏处理,严禁用于商业用途和非法用途,否则,由此产生的一切后果均与作者无关,若有侵权,请联系作者立即删除!

  • 目标网址:aHR0cHM6Ly9mdXd1Lm5oc2EuZ292LmNuL25hdGlvbmFsSGFsbFN0LyMvc2VhcmNoL3BoYXJtYWNpZXM/Y29kZT0xNzQwMDAmbWVzc2FnZT1zZXJ2ZXJVcmwlMjBpcyUyMG51bGwmZ2JGbGFnPXRydWU=
  • 目标数据接口:L3F1ZXJ5UnRhbFBoYWNCSW5mbw==

image.png

第一步:抓包分析,查看接口参数、返回值

  • header中可以看到有多个疑似加密字段,不确定是否需要处理,暂且放置。
  • 经过多次重放请求发现cookie值内容一样,所以cookie可以写死不用管。

image.png

  • 请求参数中appCode是不变的,我们疑似需要处理的是encData、signData,优先处理encData

image.png

  • 这样看请求参数是变化的且加密的,所以这部分内容是必须处理的,那么就可以先处理请求参数后再去验证header中的参数是否必须处理,如果请求参数处理完发现header写死也可以正常返回数据,那么皆大欢喜,如果还是无法正常返回则再尝试分析处理请求头。
  • 返回结果中看到返回值也是密文,encData也是必处理项

image.png

第二步:定位请求参数

通过关键字搜索在几个疑似位置打上断点,刷新页面发现在一处断住

image.png

发现第一次断住不是我们目标接口,放行断点继续查看,第二次断住是我们的目标请求,并且找到了参数字段。分页参数是动态的,regnCode是地区编码可以根据需要传入固定值,queryDataSource固定值es,其他字段值都是空

image.png

向下调试发现在该处返回了密文,可以确定这里就是加密函数位置

image.png

往上翻翻明显看到这是webpack结构,我们的加密逻辑就在7d92这个模块里

image.png

进入n函数,也就是核心加载器函数o

image.png

把整个加载器文件抠到本地补上window环境,加上模块调用日志输出

image.png

尝试运行,发现少document环境

image.png

补上环境发现还少其他环境,那么我们分析加载器代码,是否存在初始化自执行模块来进行环境检测,下面发现自执行了o(o.s = 0),直接注释掉,再次运行,不再报环境错误。

image.png

把加载器挂在到全局,方便在外部调用,打印加载器测试

image.png

我们看到webpack在导出时把f函数和g函数重新命名为a、b导出,所以我们在外部使用时需要使用导出的模块函数名,f函数就是我们加密所在函数,所以我们使用window.loader("7d92").a作为加密函数。

image.png

image.png

最后在本地构建参数对象,测试后发现其他相关字段和header值都一同出来了

image.png

image.png

原来是f加密函数中做了所有的事情,后续关于请求部分我们就不用再分析header和signData了,现在请求部分已经完成,接下来我们同样的方式找到解密模块,最终发现就是g函数,也就是导出的b。最后在本地封装js函数和python代码实现完整流程:调用js获取加密参数 → 请求接口获取加密结果 → 调用js解密函数解出明文数据。

#.....扣下来的加载器部分js代码略


// ================== 初始化 ==================
const mod = window.loader("7d92")

const buildReq = mod.a        // 构建请求
const decodeData = mod.b     // 解密函数

// ================== 构建请求 ==================
function buildRequest(pageNum) {
    const req = {
        headers: {},
        data: {
            addr: "",
            regnCode: "330100",
            medinsName: "",
            businessLvOutMedOtp: "",
            pageNum: pageNum,
            pageSize: 10,
            queryDataSource: "es"
        }
    }
    return buildReq(req)
}

// ================== 解密 ==================
function decode(encDataStr) {
    const resp = JSON.parse(encDataStr);  // 把完整 JSON 字符串解析成对象
    return decodeData("SM4", resp);
}



// ================== CLI 入口 ==================
const mode = process.argv[2]

if (mode === "encrypt") {
    const pageNum = parseInt(process.argv[3]) || 1
    const result = buildRequest(pageNum)
    console.log(JSON.stringify(result))
}
else if (mode === "decrypt") {
    const encData = process.argv[3] || ""
    const result = decode(encData)
    console.log(JSON.stringify(result))
}
else {
    console.error("Usage:")
    console.error("  node crypto.js encrypt <pageNum>")
    console.error("  node crypto.js decrypt <encData>")
    process.exit(1)
}
import subprocess
import json
import logging
import requests
from typing import Dict, Any


# =========================
# 日志配置
# =========================
LOG_FORMAT = (
    "[%(asctime)s] "
    "[%(levelname)s] "
    "[%(filename)s:%(lineno)d] "
    "%(message)s"
)

logging.basicConfig(
    level=logging.INFO,
    format=LOG_FORMAT,
)

logger = logging.getLogger(__name__)


# =========================
# JS:生成加密参数
# =========================
def get_encrypted_params(page_num: int) -> Dict[str, Any]:
    """
    调用 Node.js 获取接口加密参数
    """
    logger.info("生成加密参数 | page=%s", page_num)

    try:
        result = subprocess.run(
            ["node", "loader.js", "encrypt", str(page_num)],
            capture_output=True,
            text=True,
            encoding="utf-8",
            timeout=10,
        )
    except subprocess.TimeoutExpired:
        raise RuntimeError("JS 加密执行超时")

    if result.returncode != 0:
        logger.error("JS 加密失败: %s", result.stderr)
        raise RuntimeError("JS 加密失败")

    stdout = result.stdout.strip()
    logger.info("JS encrypt 输出: %s", stdout)

    try:
        data = json.loads(stdout)
    except json.JSONDecodeError:
        raise RuntimeError("JS encrypt 输出不是合法 JSON")

    if "headers" not in data or "data" not in data:
        raise RuntimeError("JS encrypt 返回结构异常")

    return data


# =========================
# JS:解密 encData
# =========================
def decrypt_enc_data(enc_data: str) -> Dict[str, Any]:
    """
    调用 Node.js 解密接口返回的 encData
    """
    logger.info("开始解密 encData | length=%s", len(enc_data))

    try:
        result = subprocess.run(
            ["node", "loader.js", "decrypt", enc_data],
            capture_output=True,
            text=True,
            encoding="utf-8",
            timeout=15,
        )
    except subprocess.TimeoutExpired:
        raise RuntimeError("JS 解密执行超时")

    if result.returncode != 0:
        logger.error("JS 解密失败: %s", result.stderr)
        raise RuntimeError("JS 解密失败")

    stdout = result.stdout.strip()
    logger.debug("JS decrypt 输出: %s", stdout)

    try:
        decrypted = json.loads(stdout)
    except json.JSONDecodeError:
        raise RuntimeError("JS decrypt 输出不是合法 JSON")

    return decrypted


# =========================
# 请求接口
# =========================
def fetch_page_data(page_num: int) :
    """
    请求接口获取加密响应
    """
    encrypted = get_encrypted_params(page_num)

    headers = {k: str(v) for k, v in encrypted["headers"].items()}
    headers.update({
        "Cache-Control": "no-cache",
        "Pragma": "no-cache",
        "channel": "web",
    })

    cookies = {
        "amap_local": "330100",
    }

    body = encrypted["data"]
    if isinstance(body, str):
        body = json.loads(body)

    url = "https://xxxxx"

    logger.info("请求接口 | page=%s", page_num)
    logger.info("请求 body: %s", body)

    resp = requests.post(
        url,
        headers=headers,
        cookies=cookies,
        json=body,
        timeout=30,
    )

    resp.raise_for_status()

    return resp.text

# =========================
# 主流程
# =========================
def main():
    """
    逐页执行:
    加密 → 请求 → 解密 → 立即输出
    """
    for page in range(1, 3):
        logger.info("========== 开始处理第 %s 页 ==========", page)

        try:
            # 1. 请求接口
            encrypted_resp = fetch_page_data(page)

            logger.info("加密结果: %s", encrypted_resp)

            # 2. 解密
            decrypted_data = decrypt_enc_data(encrypted_resp)

            # 3. 输出结果
            result = {
                "page": page,
                "encrypted_response": encrypted_resp,
                "decrypted_data": decrypted_data,
            }

            print(
                json.dumps(
                    result,
                    ensure_ascii=False,
                    indent=2
                )
            )

            logger.info("========== 第 %s 页处理完成 ==========", page)

        except Exception as e:
            logger.exception("第 %s 页处理失败", page)

            print(
                json.dumps(
                    {
                        "page": page,
                        "error": str(e)
                    },
                    ensure_ascii=False,
                    indent=2
                )
            )


if __name__ == "__main__":
    main()
    logger.info("全部任务执行完成")

最终完美输出结果


新人入门水平,大佬们多多交流指教。<抱拳>

从“SQL 民工”到“甩手掌柜”:我是如何用 NoETL 让分析师自己玩数据的

作者 工程师9527
2026年1月27日 11:06

别再写宽表了,兄弟,是时候把 SQL 笔扔了。

一、背景:被“下钻需求”逼疯的日常

兄弟们,不知道你们有没有经历过这种场景:

周一上午 10 点,业务方在群里@你:“大佬,上周销售额环比降了 15%,帮忙看下是哪个地区、哪个品类、哪个门店的问题?”

你打开 BI 报表,只有“大区”维度。行,先跑个 SQL 看看大区情况。

10:30,你回复:“华东区降得最厉害,占了下降额的 80%。”

11:00,业务方追问:“华东哪个城市?哪个门店?”

你心里一沉,因为现有的ads_sales_daily宽表只聚合到城市,没有门店维度。你只能硬着头皮说:“稍等,我重新跑个数。”

然后开始:

  1. dwd_order_detail明细表
  2. JOIN dim_store门店维度表
  3. 写聚合 SQL,跑批(祈祷别 OOM)
  4. 导出 Excel,发邮件

下午 2 点,业务方收到邮件后,又问:“能具体到是哪个店员、哪个 SKU 的问题吗?我们想针对性培训。”

你看着dwd_order_detail里百亿级别的数据,再想想JOIN dim_userdim_product的复杂度,以及业务方那“最好今天能给个结论”的期待……

得了,今晚又得加班写 ETL,明天才能出新的ads_sales_detail宽表。

这就是我们数据开发的日常:业务的需求是发散的,但我们的宽表是收敛的。每来一个新维度需求,就是一次CREATE TABLE AS SELECT ...的轮回。数仓的 ADS 层越建越厚,ETL 任务越排越长,我们成了名副其实的“SQL 民工”。

更崩溃的是,等你的宽表建好,业务的热点早就过了。这种“周级响应”的速度,让数据分析从“主动洞察”变成了“事后解释”。

二、探索:发现“偷懒”新姿势——NoETL 语义编织

直到我在一个技术分享会上听到了“NoETL”和“语义编织”这两个词。第一反应是:“又是个新概念忽悠人吧?”

但听完原理后,我直拍大腿:这不就是我梦寐以求的“偷懒”架构吗?

核心思想就一句话:把业务逻辑(指标、维度、关联关系)从物理表里解耦出来,变成一个虚拟的“语义层”。

什么意思?我举个例子:

以前,业务要“销售额”,我得建个ads_sales_by_region(按大区)、ads_sales_by_city(按城市)……N张宽表。

现在,我只需要在语义层里声明式地定义一次:

  • 指标“销售额” = SUM(dwd_order_detail.order_amount)
  • 维度“城市”来自dim_store.city
  • 维度“门店”来自dim_store.store_name
  • 关联关系:dwd_order_detail.store_id = dim_store.id

定义好了,就完了。不用写 ETL,不用建物理表。

当业务分析师在 BI 工具里拖拽“销售额”,想按“城市”看,再下钻到“门店”看时,系统会自动根据我定义好的语义,实时生成 SQL 去查询底层的dwd_order_detail明细表,然后JOIN dim_store,最后聚合返回结果。

从“预先物化所有结果”变成了“按需实时计算”。

先看一张架构图,逻辑很清晰:

烟囱式宽表开发 vs NoETL 语义编织.png

上边是我们熟悉的“痛苦循环”,下边是理想的“敏捷闭环”。关键是中间那个语义层,它成了“翻译官”和“调度员”。

三、剖析:这玩意儿到底怎么实现的?(技术干货)

光有理念不行,得落地。我研究了一下像 Aloudata CAN 这类指标平台的实现,发现核心是三个技术点:

1. 声明式语义建模(告别手写 SQL)

以前我们这样定义指标:

-- 传统方式:写在ETL脚本里,固化成一张表
CREATE TABLE ads_sales_daily AS
SELECT 
    date,
    region,
    SUM(order_amount) as sales_amount,
    COUNT(DISTINCT user_id) as uv
FROM dwd_order_detail
GROUP BY date, region;

现在在平台上,可能就是点点选选:

  • 选择事实表:dwd_order_detail
  • 选择度量:order_amount (聚合方式:SUM) -> 指标“销售额”
  • 选择维度:date(来自事实表),region(来自关联的dim_store
  • 定义关联:dwd_order_detail.store_id = dim_store.id

平台背后会生成一个逻辑模型,而不是物理表。 这个模型可以用一种 DSL(领域特定语言)或元数据来描述,比如:

metric:
  name: sales_amount
  definition: SUM(order_amount)
  data_source: dwd_order_detail

dimension:
  - name: date
    source: dwd_order_detail.order_date
  - name: region
    source: dim_store.region
    join: 
      left: dwd_order_detail.store_id
      right: dim_store.id

2. 自动 SQL 生成与优化(引擎的活儿)

当分析师在 BI 里拖拽“销售额”和“城市”、“门店”时,BI 工具会向语义层发送一个查询请求(比如一个 JSON 或特定的查询语言)。

语义层引擎收到后:

  1. 解析查询意图:要查“销售额”,按“城市”和“门店”分组。
  2. 查找语义定义:找到“销售额”来自dwd_order_detail.order_amount的 SUM;“城市”和“门店”来自dim_store,需要通过store_id关联。
  3. 生成物理 SQL:
  4. 查询优化:引擎可能会根据条件自动添加分区过滤、选择更优的 JOIN 策略等。

整个过程,分析师和开发都不需要写一句 SQL。

3. 智能物化与透明加速(解决性能问题)

我知道你们在想什么:“直接查百亿明细,JOIN一堆维度表,这查询不得慢死?”

这就是智能物化引擎出场的时候了。它不再是“猜业务需要什么而提前建宽表”,而是“看业务实际查什么,再自动加速”。

管理员配置加速策略:

  • 场景:高管驾驶舱的“核心销售日报”
  • 策略:对“销售额”指标,按“日期、大区、产品线”组合进行预聚合加速。
  • 平台自动创建物化视图任务(比如每天凌晨计算一次)。

查询时的智能路由: 当有查询命中“销售额+日期+大区+产品线”时,引擎会进行 SQL 改写,将查询路由到已经计算好的物化视图上,而不是去扫明细表。

-- 原始查询意图(来自语义层)
SELECT date, region, product_line, SUM(order_amount) ...

-- 引擎自动改写后执行的SQL
SELECT date, region, product_line, sales_amount_precomputed 
FROM mv_sales_daily_region_productline -- 直接查询物化视图
WHERE ...

对用户完全透明,他们只知道“秒出结果”,不知道背后是走了明细还是走了加速表。这比我们手动维护一堆宽表要智能和低成本得多。

四、实战:三步走,把分析师“扶上马”

在我们团队试点时,我们走了这么三步:

  1. 第一步:统一语义,挂载存量。

    • 挑了一个最痛的“销售分析”场景。
    • 把现有的ads_sales_*系列宽表,先“挂载”到语义层,作为数据源之一,让分析师能先用起来,看到统一出口。
    • 在语义层里,重新用声明式的方式,基于dwd_order_detail定义最核心的“销售额”、“订单量”等原子指标。
  2. 第二步:增量需求,禁止宽表。

    • 立下规矩:所有新的、临时的分析需求,不准再提“建一张宽表”的工单。
    • 分析师自己去语义平台,用已经定义好的指标和维度,拖拽组合。如果需要新维度(比如“会员等级”),由数据开发在语义层里关联好dim_user表即可,分钟级完成。
  3. 第三步:逐步替旧,下线宽表。

    • 随着语义层的能力被验证,我们将那些使用频率低、维护成本高的陈年老宽表,一个个下线。查询都引导到语义层的逻辑模型上。
    • 对于高频、核心的查询,配置智能物化加速,性能反而比老宽表更好。

五、结论:真香,但需要适应

搞了大半年,说下感受:

给数据开发带来的变化:

  • 加班少了:告别了“业务一动嘴,开发跑断腿”的循环。临时取数需求下降 90%。
  • 专注度高:从重复的CREATE TABLE工作中解放出来,能更专注于数据质量、模型设计和复杂的业务逻辑实现。
  • 口径统一了:“销售额”只有一个定义,再也不会出现报表 A 和报表 B 数字对不上的灵魂拷问。

给数据分析师带来的变化:

  • 自由了:真正实现了“任意维度下钻”。从日期到地区,到门店,到店员,到具体订单,思路不再被打断。
  • 敢深挖了:因为能直接基于明细做归因分析,他们开始能回答“为什么”而不仅仅是“是什么”。比如能定位到“销售额下降主要是因为华东区 XX 门店的 A 商品缺货导致”,这价值就大了。
  • 地位提升了:从“取数工具人”变成了“业务赋能者”。

当然,也有挑战:

  • 思维转变:开发要习惯从“物理建模”转向“语义建模”;分析师要习惯自己探索,而不是张口要数。
  • 性能调优:智能加速策略需要根据实际查询 Pattern 进行观察和调整,前期有个学习成本。
  • 平台依赖:选一个靠谱的指标平台是关键。自己从零造轮子?我劝你慎重,这里面的水很深(查询优化、多引擎适配、元数据管理等等)。

六、最后说一句

如果你也受够了无休止的宽表开发和业务方的夺命连环 Call,真的可以了解一下 NoETL 语义编织这个方向。它不是什么银弹,但确实是解决“数据供给敏捷性”这个老大难问题的一条务实路径。

感兴趣的兄弟可以去官网白嫖个试用版测一下,自己感受一下从“SQL 苦力”到“架构师”的转变。至少,你的头发能多留几天。

echarts饼图当鼠标移上去后,高亮色怎么设置为扇形本身的颜色?

2026年1月27日 11:05

以下是一个环形图,鼠标未移到饼图上时

图片.png

当鼠标移动到时长异常部分时,因为设置了高亮的原因再加上本身颜色就浅,看起来像换了种颜色

hover.png

先看下优化后的样子

-hover-ok.png

对比明显吧。

如何在设置了emphasis高亮配置时,保持扇形的颜色为本身的颜色呢,换句话说,想要保留高亮的其它配置,比如放大,边框等等,但颜色不要高亮展示,该如何设置呢?


option = {
  series: [
   {
       "name":"运行状态",
       "type":"pie",
       "startAngle":90,
       "radius":[
           "58%",
           "73%"
       ],
       "center":[
           "50%",
           "50%"
       ],
       animation: true,
       "selectedoffset":3,
       //这里设置统一悬停样式
       emphasis:{
               
           "scale": true,
           "scalesize":6,
           itemStyle:{
               // "shadowBlur": 20,
               // "shadowColor":"rgba(0,0,0,1)",
               "borderWidth": 3,
               "borderColor": "#fff",
                
           }
       },
       "label":{
           "padding":[
               15,
               0,
               0,
               0
           ],
           "color":"#4E5969",
           "fontSize":14,
           "formatter":"{b|{b}} {d|{c}%}\n",
           "rich":{
               "b":{
                   "fontSize":14,
                   "color":"#4E5969"
               },
               "d":{
                   "fontSize":16,
                   "fontWeight":600,
                   "fontFamily":"DINAlternate-Bold, DINAlternate",
                   "color":"#4E5969"
               }
           }
       },
       "labelLine":{
           "length2":4
       },
       "data":[
           {
               "name":"时长异常",
               "value":41.63,
                originalColor: "#91caff",
               "itemStyle":{
                   "color":"#91caff",
                   "borderColor":"#fff",
                   "borderWidth":2
               },
               // 答案在这里--->>>鼠标悬停,颜色不高亮,且使用扇形本身颜色
                 emphasis:{
                     "itemStyle":{
                         "color":"#91caff",
                      
                    }
                }
           },
           {
               "name":"时长正常",
               "value":51.58,
               "itemStyle":{
                   "color":"#358EFE",
                   "borderColor":"#fff",
                   "borderWidth":2
               },
               emphasis:{
                   "itemStyle":{
                       "color":"#358EFE",
                      
                   }
               }
           },
           {
               "name":"无时长",
               "value":6.79,
               "itemStyle":{
                   "color":"#12C2C1",
                   "borderColor":"#fff",
                   "borderWidth":2
               },
               emphasis:{
                   "itemStyle":{
                       "color":"#12C2C1",
                      
                   }
               }
           }
       ]
   }
]
};


解决

设置了统一的悬停样式后,在单个data中设置下悬停时样式的color即可~Nice..

当然了,一般不会这么细致,但有时候确实会因为颜色的深浅影响UI视觉效果,这时候去找AI也没给出最简单的答案,AI要在统一悬停那设置color的函数,试了下,并不行。总之记录下,或许有人会用到。

亲测有效!M4芯片Mac安装Node.js 14避坑指南,解决nvm install失败问题

作者 maskie
2026年1月27日 10:59

问题概述:当现代硬件遇见旧版软件

刚从Windows切换到全新的MacBook Pro(M4芯片),准备投入到熟悉的老项目维护中,一切似乎都很顺利——直到我需要运行那个依赖Node.js 14的项目。 在终端中输入 nvm install 14 后,等待我的不是成功的提示,而是一连串令人困惑的错误信息:

image.png

为什么安装会失败?

  1. 架构差异的本质
  • Node.js 14官方预编译包主要针对x86_64架构
  • M4芯片的ARM架构需要对应的ARM原生二进制文件
  • 缺乏官方维护的低版本ARM原生包

2.依赖链断裂问题

  • 部分Node.js 14依赖的本地模块(native addons)无ARM版本
  • npm包中的postinstall脚本可能包含不兼容的命令

解决方案:通过Rosetta 2架起兼容的桥梁

经过多次尝试,Rosetta 2转译方案被证明是最稳定可靠的方法。以下是详细步骤:

# 1. 安装 Rosetta 2(如果还没有)
softwareupdate --install-rosetta

# 2. 在 Rosetta 2 终端中运行
arch -x86_64 zsh

# 3. 在这个终端中安装 nvm(如果需要)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash

# 4. 安装 Node.js v14
nvm install v14.21.3

安装后,每次运行 Node v14 的项目时,需要在 Rosetta 终端中:

arch -x86_64 zsh
nvm use v14

效果图

正常下载并切换 node 14版本,安装 node_modules

image.png

项目正常运行

image.png

升级版js实现图片拖拽、根据鼠标位置放大、缩小功能

2026年1月27日 10:58
const canvasImage = function (path, annotations) {
    let imageUrl = '';
    imageUrl = path;
    let initState = true;
    let scale = 1; let originX = 0; let originY = 0; let isDragging = false; let lastX; let lastY;
    const canvas = document.getElementById('annotationCanvas');

    // 清空画布内容和标注信息
    canvas.innerHTML = '';

    canvas.style.display = 'block';
    const ctx = canvas.getContext('2d');
    const image = new Image();
    function resizeCanvas() {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
      draw();
    }
    // 添加关闭按钮
    const closeButton = document.createElement('button');
    closeButton.innerHTML = 'X';
    closeButton.title = '关闭预览';
    closeButton.className = 'canvasCloseButton';
    document.body.appendChild(closeButton);

    closeButton.addEventListener('click', function () {
      imageUrl = '';
      canvas.style.display = 'none';
      document.body.removeChild(closeButton);
    });
    function draw() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.save();
      ctx.translate(originX, originY);
      ctx.scale(scale, scale);

      if (annotations.length) {
        ctx.drawImage(image, 0, 0);
      } else {
        // 获取 canvas 的宽度和高度
        const canvasWidth = canvas.width;
        const canvasHeight = canvas.height;
        // 获取图片的宽度和高度
        const imageWidth = image.width;
        const imageHeight = image.height;
        // 计算图片在画布上的位置
        const x = (canvasWidth - imageWidth) / 2;
        const y = (canvasHeight - imageHeight) / 2;
        ctx.drawImage(image, x, y);
        initState = false;
      }
      if (annotations.length) {
        annotations.forEach((item) => {
          if (item.range) {
            ctx.lineWidth = 10;
            ctx.beginPath();
            ctx.strokeStyle = 'red';
            ctx.strokeRect(
              item.range.x - 10,
              item.range.y - 10,
              item.range.width + 20,
              item.range.height + 20
            );
          }
          ctx.fillStyle = 'yellow';
          ctx.font = '100px fangsong';
          ctx.fillText(item.text, item.posX, item.posY - 50);
          ctx.fill();
          if (item.rect) {
            ctx.lineWidth = 10;
            ctx.beginPath();
            ctx.strokeStyle = 'yellow';
            ctx.strokeRect(
              item.rect.x - 5,
              item.rect.y - 5,
              item.rect.width + 10,
              item.rect.height + 10
            );
          }
          if (initState) {
            scale = canvas.height / image.height;
            originX = (canvas.width - image.width * scale) / 2;
            initState = false;
            draw();
          }
        });
        ctx.restore();
      }
      ctx.restore();
    }
    image.src = imageUrl;
    image.onload = resizeCanvas;
    window.addEventListener('resize', resizeCanvas);
    ['mousedown', 'mouseup', 'mouseout', 'mousemove', 'wheel'].forEach(eventName => {
      canvas.addEventListener(eventName, (e) => {
        switch (eventName) {
          case 'mousedown':
            isDragging = true;
            lastX = e.offsetX - originX;
            lastY = e.offsetY - originY;
            break;
          case 'mouseup':
          case 'mouseout':
            isDragging = false;
            break;
          case 'mousemove':
            if (isDragging) {
              originX = e.offsetX - lastX;
              originY = e.offsetY - lastY;
              draw();
            }
            break;
          case 'wheel':
            e.preventDefault();
            var mouseX = e.offsetX;
            var mouseY = e.offsetY;
            var wheel = e.deltaY < 0 ? 1.1 : 0.9;
            var newScale = scale * wheel;
            draw();
            if (newScale < 0.1 || newScale > 5) return;
            var newOriginX = mouseX - (mouseX - originX) * wheel;
            var newOriginY = mouseY - (mouseY - originY) * wheel;
            scale = newScale;
            originX = newOriginX;
            originY = newOriginY;

            break;
        }
      });
    });

    // 监听键盘事件
    window.addEventListener('keydown', (e) => {
      if (e.keyCode === 27) { // 检测到 esc 键被按下
        imageUrl = '';
        canvas.style.display = 'none';
        document.body.removeChild(closeButton);
      }
    });
  };

面试复盘:0.1 + 0.2 === 0.3 结果是 ?附精准解决办法

作者 hypoy
2026年1月27日 10:56

面试复盘:0.1 + 0.2 === 0.3 结果是 ?附精准解决办法

最近面试被问到一个超经典的 JS 问题:0.1 + 0.2 === 0.3 对不对?我马上说肯定不对,但面试官接着追问为啥返回 false,我只答得出 “浮点数精度问题” 这个表面原因,再往下说就卡壳了。今天就把这个问题掰开揉碎复盘,从根上的原理到实际能用的解决办法,一次性讲明白。

一、先看现象:反直觉的结果

先执行一段简单的代码,验证这个反直觉的现象:

console.log(0.1 + 0.2); // 输出 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // 输出 false

明明 0.1 加 0.2 数学上等于 0.3,为什么在 JS 里却不是?核心原因藏在 JavaScript 对数字的存储方式里。

二、底层原因:二进制浮点数的 “先天缺陷”

JavaScript 遵循 IEEE 754 标准,所有数字(整数、小数)都以 64 位双精度浮点数(Double-precision floating-point format)存储。这个标准的设计本身,导致了部分十进制小数无法被二进制精确表示。

1. 十进制转二进制的 “无限循环”

我们先回忆十进制转二进制小数的规则:乘 2 取整,直到小数部分为 0。以 0.1 为例:

0.1 × 2 = 0.2 → 取整 0
0.2 × 2 = 0.4 → 取整 0
0.4 × 2 = 0.8 → 取整 0
0.8 × 2 = 1.6 → 取整 1,剩余 0.6
0.6 × 2 = 1.2 → 取整 1,剩余 0.2
... 循环往复,永远无法得到 0

最终,0.1 的二进制是 无限循环小数0.0001100110011...(0011 循环)

同理,0.2 的二进制也是无限循环小数。而 64 位双精度浮点数的存储空间有限,只能截取前 52 位有效数字(尾数部分)进行存储,这就导致了 精度丢失

2. 精度丢失后的计算

0.1 和 0.2 存储时都被 “近似处理” 了,两者相加后,结果自然不是精确的 0.3,而是一个接近 0.3 的数(0.30000000000000004),因此 === 严格相等判断会返回 false。

简单总结:不是 JS 计算错了,而是十进制的 0.1 和 0.2 无法被二进制浮点数精确表示,存储和计算时的近似值导致了结果偏差。

三、解决方案:如何正确比较 0.1 + 0.2 和 0.3?

知道了原因,我们需要对应的解决方案,核心思路是 “规避浮点数精度问题”,常见方案有 3 种:

方案 1:设置误差阈值(最常用)

既然结果是近似值,我们可以允许一个极小的误差范围(通常用 Number.EPSILON,表示 JS 中能表示的最小精度差),只要两个数的差值小于这个阈值,就认为相等。

// 封装通用的浮点数比较函数
function isEqual(a, b, epsilon = Number.EPSILON) {
  return Math.abs(a - b) < epsilon;
}
// 测试
console.log(isEqual(0.1 + 0.2, 0.3)); // true

Number.EPSILON 的值约为 2.220446049250313e-16,是 JS 中两个可表示的浮点数之间的最小差值,完全满足日常精度需求。

方案 2:转整数计算(适合金融场景)

浮点数精度问题在金额计算中尤为致命(比如 0.1 元 + 0.2 元),此时可以将小数转为整数(乘以 10 的 n 次方),计算后再转回来,从根源避免浮点数运算。

// 0.1 + 0.2 转整数计算
const a = 0.1, b = 0.2;
const sum = (a * 10 + b * 10) / 10; // (1 + 2)/10 = 0.3
console.log(sum === 0.3); // true

// 金额计算示例:1.23 元 + 4.56 元
const price1 = 1.23, price2 = 4.56;
const total = (price1 * 100 + price2 * 100) / 100; // 579 / 100 = 5.79

方案 3:使用成熟的数学库(推荐复杂场景)

如果是企业级金融、科学计算等高精度场景,手动处理容易出错,推荐使用成熟的库:

  • decimal.js:功能全面的高精度十进制运算库
  • big.js:轻量、专注于高精度小数运算
  • bignumber.js:兼容 IEEE 754 标准,适合金融场景

big.js 为例:

// 先安装:npm install big.js
const Big = require('big.js');

const a = new Big(0.1);
const b = new Big(0.2);
const sum = a.plus(b); // 0.3
console.log(sum.eq(0.3)); // true

四、面试延伸:常见误区与补充

  1. 误区:“只有 0.1 + 0.2 有问题”—— 其实所有无法被二进制精确表示的十进制小数运算都可能有精度问题,比如 0.7 + 0.1 = 0.7999999999999999。
  2. 补充:整数运算为什么没问题?因为十进制整数可以被二进制精确表示(只要不超过 2^53,超过后也会丢失精度)。

五、总结

回到面试题,完整的回答应该包含 3 个核心点:

  1. 底层原因:JS 遵循 IEEE 754 标准,用 64 位双精度浮点数存储数字,0.1 和 0.2 的二进制是无限循环小数,存储时精度丢失;
  2. 现象本质:精度丢失后的 0.1 + 0.2 结果是 0.30000000000000004,而非精确的 0.3,因此严格相等判断为 false;
  3. 解决方案:日常场景用误差阈值(Number.EPSILON),金融场景转整数计算或使用 decimal.js/big.js 等库。

这个问题看似简单,实则考察对 JS 数字存储底层的理解,记住 “现象 - 原因 - 解决方案” 的逻辑链,面试时就能从容应对了。

如何让同一个 Guard、Interceptor、Exception Filter 在不同类型的服务里复用?

作者 前端付豪
2026年1月27日 10:49

Nest 可以创建 HTTP 、WebSocket ,还有基于 TCP 服务

这都支持 Guard、Interceptor、Exception Filter 功能

问题是不同服务拿到的参数不同,比如 http 服务可以拿到 request、response 对象,而 websocket 没有。

那如何让同一个 Guard、Interceptor、Exception Filter 在不同类型的服务里复用?

使用 ArgumentHost 这个类

新建项目试试看

nest new argument-host

新建 filter

nest g filter aaa --flat --no-spec

image.png

Nest 会 catch 所有未捕获异常,如果是 Exception Filter 声明的异常,那就会调用 filter 来处理

创建一个自定义的异常类

image.png

image.png

使用

image.png

启动服务 可以看到打印

image.png

filter 的第一个参数就是异常对象,第二个参数有这些方法

image.png

.vscode/launch.json 添加调试看看

{
    "type": "node",
    "request": "launch",
    "name": "debug nest",
    "runtimeExecutable": "npm",
    "args": [
        "run",
        "start:dev",
    ],
    "skipFiles": [
        "<node_internals>/**"
    ],
    "console": "integratedTerminal",
}

打断点

image.png

启动

image.png

host.getArgs 拿到 reqeust、response、next 参数

host.getArgByIndex 下标取参数

调用 switchToHttp 切换到 http 上下文,然后再调用 getRequest、getResponse 方法

websocket、基于 tcp 的微服务等上下文,就分别调用 host.swtichToWs、host.switchToRpc 方法

这样,就可以在 filter 里处理多个上下文的逻辑,跨上下文复用 filter

类似这样

import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
import { Response } from 'express';
import { AaaException } from './AaaException';

@Catch(AaaException)
export class AaaFilter implements ExceptionFilter {
  catch(exception: AaaException, host: ArgumentsHost) {
    if(host.getType() === 'http') {
      const ctx = host.switchToHttp();
      const response = ctx.getResponse<Response>();
      const request = ctx.getRequest<Request>();

      response
        .status(500)
        .json({
          value1: exception.value1,
          value2: exception.value2
        });
    } else if(host.getType() === 'ws') {

    } else if(host.getType() === 'rpc') {

    }
  }
}

启动

image.png

ArgumentHost 是用于切换 http、websocket、rpc 等上下文类型的,可以根据上下文类型取到对应的 argument,让 Exception Filter 等在不同的上下文中复用

在 guard 和 interceptor 会是怎样 ?

nest g guard aaa --no-spec --flat

image.png

有这些方法 和上面看到的很类似

image.png

因为 ExecutionContext 是 ArgumentHost 的子类,扩展了 getClass、getHandler 方法

getClass、getHandler有啥作用?

再次调试看看

image.png

image.png

image.png

作用分别是要调用的 controller 的 class 以及要调用的方法

为什么 ExecutionContext 里需要拿到目标 class 和 handler 呢?

因为 Guard、Interceptor 的逻辑可能要根据目标 class、handler 有没有某些装饰而决定怎么处理

比如权限校验例子

nest g decorator roles --flat --no-spec

image.png

image.png

image.png

Guard 里通过 ExecutionContext 的 getHandler 方法拿到了目标 handler 方法。

然后通过 reflector 的 api 拿到它的 metadata。

判断如果没有 roles 的 metadata 就是需要权限,那就直接放行。

如果有,就是需要权限,从 user 的 roles中判断下又没有当前 roles,有的话就放行。

刷新页面,可以看到返回的是 403

image.png

同理,在 interceptor 里也有这个

 nest g interceptor aaa --no-spec --flat

image.png

自然也可以 通过 reflector 取出 class 或者 handler 上的 metdadata

2026 前端新手必装 VS Code 插件|10 个插件提升开发效率(附配置教程)

作者 代码煮茶
2026年1月27日 10:40

VS Code 作为前端开发的「宇宙第一编辑器」,轻量性与强大的插件生态是其核心优势。对新手而言,选对插件能省去重复操作、减少语法错误,让编码效率翻倍。本文精选 10 个高频插件,按「代码高亮/格式化/快捷键辅助」分类,逐一拆解功能、安装及配置步骤,再分享组合使用技巧与冲突解决方法,帮你快速搭建高效开发环境。

一、插件分类与精选推荐

前端开发的核心场景离不开代码识别、格式规范与操作简化,本次推荐插件严格围绕这三大维度,兼顾新手友好度与实用性,避免冗余插件增加学习成本。

(一)代码高亮类:提升代码可读性

这类插件优化语法着色与文件识别,让不同语言、不同类型文件直观区分,降低视觉疲劳,尤其适合长时间编码。

1. One Dark Pro(经典深色主题)

核心功能:提供简洁美观的深色配色方案,对 HTML、CSS、JS、Vue 等前端语言语法高亮精准,支持自定义配色细节,护眼且辨识度高,是全球数百万开发者的首选主题。

安装步骤:打开 VS Code 左侧「扩展」面板(快捷键 Ctrl+Shift+X),搜索「One Dark Pro」,点击「安装」,无需重启自动生效。

基础配置:若需调整配色,按下 Ctrl+, 打开设置,搜索「One Dark Pro」,可自定义编辑器背景色、字体颜色、选中区域颜色等;若想切换主题,按下 Ctrl+Shift+P,输入「Color Theme」,选择「One Dark Pro」系列主题即可。

2. vscode-icons(文件图标美化)

核心功能:为不同类型文件分配专属图标,如 HTML 是网页图标、CSS 是样式图标、JS 是脚本图标,甚至区分 Vue/React 组件文件与普通文件,让项目目录结构一目了然,避免在众多文件中反复查找。

安装步骤:扩展面板搜索「vscode-icons」,点击安装,安装完成后自动启用,无需额外操作。

基础配置:若想切换图标风格,按下 Ctrl+, 打开设置,搜索「Icons Theme」,选择「VSCode Icons」即可;支持自定义部分文件图标,进阶需求可安装「vscode-icons-mac」适配 macOS 风格图标。

(二)代码格式化类:规范代码风格,减少错误

这类插件自动处理代码缩进、符号规范、格式对齐,避免手动调整格式的繁琐,同时统一代码风格,为后续团队协作打下基础。

3. Prettier - Code formatter(全能格式化工具)

核心功能:业界主流的代码格式化工具,支持 HTML、CSS、JS、TS、Vue、React 等几乎所有前端语言,能自动统一缩进宽度、引号类型、分号结尾、尾逗号等格式,解决「代码写得乱」的痛点。

安装步骤:扩展面板搜索「Prettier - Code formatter」,点击安装,建议勾选「启用」选项。

详细配置:按下 Ctrl+, 打开设置,搜索「Prettier」,配置核心参数(新手推荐默认值优化):

  • Prettier: Tab Width:设置缩进宽度,建议设为 2(前端主流规范);
  • Prettier: Semi:是否在语句末尾加分号,建议设为 true(避免语法歧义);
  • Prettier: Single Quote:是否使用单引号,建议设为 true(前端项目常用);
  • Prettier: Trailing Comma:多行属性是否添加尾逗号,建议设为 all(提升代码可维护性);
  • Editor: Format On Save:勾选此选项,保存文件时自动触发 Prettier 格式化,无需手动操作。

进阶配置:在项目根目录创建 .prettierrc 文件,写入配置代码,实现项目级统一格式(团队协作必备):

{
  "tabWidth": 2,
  "semi": true,
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 120
}

4. Auto Close Tag(标签自动闭合)

核心功能:编写 HTML、XML 或 Vue/React 模板时,输入开始标签后自动补全闭合标签,如输入

自动生成
,嵌套标签时同步缩进,避免因遗漏闭合标签导致页面布局错乱。

安装步骤:扩展面板搜索「Auto Close Tag」,点击安装后立即生效。

基础配置:默认支持所有标签类型,新手无需额外配置;若需自定义支持的语言,按下 Ctrl+, 搜索「Auto Close Tag: Activation On Language」,添加需要适配的语言(如 vue、jsx)即可。

5. Auto Rename Tag(标签自动重命名)

核心功能:与 Auto Close Tag 配套使用,修改 HTML/XML 标签名时,自动同步修改配对的闭合标签,如将

改为 ,结束标签会同步更新,避免因标签名不一致导致的语法错误。

安装步骤:扩展面板搜索「Auto Rename Tag」,点击安装,无冲突即可启用。

基础配置:默认支持 HTML、Vue、React 等模板语法,新手保持默认即可;进阶需求可搜索「Auto Rename Tag: Ignore Case」,设置是否忽略大小写匹配。

6. Path Intellisense(路径自动补全)

核心功能:导入文件(图片、CSS、JS 组件、第三方库等)时,自动补全文件路径,支持相对路径、绝对路径及 Vue 项目的 @ 别名路径,路径错误时实时提示,避免因路径写错导致资源加载失败。

安装步骤:扩展面板搜索「Path Intellisense」,点击安装。

详细配置:按下 Ctrl+, 搜索「Path Intellisense」,核心配置:

  • Path Intellisense: Auto Slash After Directory:进入目录后自动添加斜杠,设为 true;
  • 适配 Vue 别名:在项目根目录创建 jsconfig.json 文件,配置 @ 别名映射(解决 @ 路径无法补全问题):
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src/**/*"]
}

(三)快捷键辅助类:简化操作,提升编码速度

这类插件通过快捷键或自动操作,替代重复的手动编码、预览、调试步骤,让新手专注于逻辑编写而非操作本身。

7. Live Server(实时预览服务器)

核心功能:启动本地开发服务器,支持代码修改后浏览器自动刷新,实现「保存即预览」,无需手动切换浏览器刷新页面,大幅提升静态页面、Vue 基础项目的开发效率。

安装步骤:扩展面板搜索「Live Server」,点击安装,安装后状态栏会出现「Go Live」按钮。

使用与配置

  • 基础使用:用 VS Code 打开项目文件夹(必须打开文件夹而非单个文件),右键点击 HTML 文件,选择「Open with Live Server」,自动在浏览器打开 http://127.0.0.1:5500 地址,修改代码保存后浏览器自动刷新;
  • 端口配置:按下 Ctrl+, 搜索「Live Server: Port」,可修改默认端口(避免端口占用),新手保持默认 5500 即可;
  • 自动打开浏览器:搜索「Live Server: Open Browser On Start」,设为 true,启动服务器时自动打开默认浏览器。

8. JavaScript (ES6) code snippets(ES6 代码片段)

核心功能:内置大量 ES6+ 语法代码片段,通过快捷键触发,快速生成箭头函数、解构赋值、Promise、数组方法等常用代码,减少重复编码,同时帮助新手记忆 ES6 语法。

安装步骤:扩展面板搜索「JavaScript (ES6) code snippets」,点击安装后立即生效。

常用片段与配置

  • 常用触发词:arr-map 生成数组 map 方法、promise 生成 Promise 模板、const 生成解构赋值、箭头函数 生成 ()=>{} 语法;
  • 自定义片段:按下 Ctrl+Shift+P,输入「Preferences: Configure User Snippets」,选择「javascript.json」,可添加自己常用的代码片段(如接口请求模板)。

9. Open in Browser(浏览器快速预览)

核心功能:补充 Live Server 的场景局限,右键 HTML 文件可快速选择在默认浏览器、Chrome、Firefox 等浏览器中打开,适合无需实时刷新的静态页面预览,操作比手动复制路径打开浏览器更高效。

安装步骤:扩展面板搜索「Open in Browser」,点击安装。

使用配置:右键 HTML 文件,选择「Open In Default Browser」(默认浏览器打开)或「Open In Other Browsers」(选择其他浏览器);若需修改默认浏览器,按下 Ctrl+, 搜索「Open in Browser: Default Browser」,选择对应浏览器即可。

10. CodeGeeX(AI 辅助编码)

核心功能:免费 AI 代码辅助工具,支持代码生成、自动补全、注释生成、代码翻译,适合新手解决语法难题、快速生成基础代码框架,同时可通过 AI 聊天功能询问技术问题,降低学习门槛。

安装步骤:扩展面板搜索「CodeGeeX」,点击安装,需注册账号登录(免费版足够新手使用)。

基础配置与使用

  • 代码补全:编写代码时自动触发补全提示,按 Tab 键确认选用;
  • 注释生成:选中代码块,右键选择「CodeGeeX: Generate Comment」,自动生成中文注释;
  • 快捷键配置:搜索「CodeGeeX」,可自定义补全触发快捷键、注释生成快捷键,新手保持默认即可。

二、插件组合使用技巧

单一插件的作用有限,合理组合能实现「1+1>2」的效果,以下是针对前端新手的高频组合场景:

1. 编码基础组合:Auto Close Tag + Auto Rename Tag + Prettier

编写 HTML/Vue 模板时,Auto 系列插件自动处理标签闭合与重命名,Prettier 同步格式化缩进与格式,无需手动调整标签和格式,专注于页面结构设计;配合「Format On Save」配置,保存文件时自动完成所有格式优化,避免遗漏。

2. 页面开发组合:Live Server + Open in Browser

开发静态页面时,用 Live Server 实现实时预览,快速调试样式和交互;若需在多个浏览器中对比兼容性,右键用 Open in Browser 打开其他浏览器,无需重复启动服务器,兼顾效率与兼容性检查。

3. ES6 编码组合:JavaScript (ES6) code snippets + CodeGeeX

编写 JS 代码时,用 ES6 片段快速生成基础语法,CodeGeeX 补充补全复杂逻辑代码,同时生成注释;遇到不熟悉的语法(如 Promise、async/await),可通过 CodeGeeX 聊天功能询问用法,边编码边学习。

4. 项目管理组合:vscode-icons + Path Intellisense

项目文件较多时,vscode-icons 直观区分文件类型,Path Intellisense 快速补全文件路径,避免在目录中反复查找文件,同时减少路径错误导致的 Bug,提升项目维护效率。

三、避坑指南:插件冲突与问题解决

新手容易因安装过多插件或配置不当导致冲突,以下是常见问题及解决方案:

1. 格式化插件冲突(如 Prettier 与内置格式化工具冲突)

症状:保存文件时格式反复错乱,或提示「多个格式化工具可用」。

解决方案

  • 设置默认格式化工具:按下 Ctrl+Shift+P,输入「Format Document With」,选择「Configure Default Formatter」,勾选「Prettier - Code formatter」,统一格式化工具;
  • 禁用冗余格式化插件:如同时安装了「ESLint」(新手暂不推荐)与 Prettier,可先禁用 ESLint,避免规则冲突;进阶后可安装「eslint-config-prettier」实现两者兼容。

2. Live Server 无法启动或刷新失败

常见原因:未打开项目文件夹、端口被占用、文件路径含中文。

解决方案

  • 必须用 VS Code 打开项目根目录(而非单个 HTML 文件),否则无法识别项目结构;
  • 端口占用:修改 Live Server 端口(设置中搜索「Live Server: Port」,改为 5501、5502 等未占用端口);
  • 路径含中文:重命名项目文件夹和文件,移除中文、空格及特殊字符,避免服务器识别异常。

3. 插件过多导致 VS Code 卡顿

解决方案

  • 新手仅安装本文推荐的 10 个插件,避免盲目安装「热门插件」,如暂不涉及 TypeScript 可无需安装 TS 相关插件;
  • 禁用闲置插件:扩展面板中找到不常用的插件,点击「禁用」,需要时再启用;
  • 定期更新插件:过时插件可能存在性能问题,点击扩展面板右上角「更新」按钮,保持插件版本最新。

4. Path Intellisense 无法识别 Vue @ 别名

解决方案:确保项目根目录存在 jsconfig.jsontsconfig.json 文件,配置 @ 别名映射(前文已给出配置代码);重启 VS Code 后生效,若仍无效,可重新安装 Path Intellisense 插件。

四、总结

对前端新手而言,VS Code 插件的核心价值是「减负提效」,无需追求「越多越好」,掌握本文推荐的 10 个插件及组合技巧,就能覆盖从编码、预览到调试的全流程需求。初期建议按分类逐步安装,熟悉每个插件的功能后再优化配置,后续可根据学习进度(如接触 React、TypeScript)补充对应插件。

记住:插件是辅助工具,核心还是夯实前端基础,合理利用插件节省的时间,投入到语法学习和项目实践中,才能快速提升开发能力。

ESLint 批量抑制:技术债治理的正确打开方式

2026年1月27日 10:38

ESLint 批量抑制:技术债治理的正确打开方式

引子:一个技术 Leader 的困境

作为技术 Leader,你可能遇到过这样的场景:

团队维护着一个两年前的 React 项目,20 万行代码,技术债堆积如山。你想推动代码质量提升,在团队会议上提议:"我们开启 @typescript-eslint/no-explicit-any 规则吧,减少 any 的滥用。"

然后你在本地试运行了一下:

$ eslint .
✖ 1,247 problems (1,247 errors, 0 warnings)

1,247 个错误

你的第一反应可能是:"算了,还是别开了。" 团队成员也松了一口气——毕竟谁也不想在 JIRA 里看到一个"修复 1,247 个 Lint 错误"的任务。

但问题是,如果现在不开启规则,新代码还会继续使用 any,技术债只会越滚越大。这就是典型的"存量问题拖累增量改进"的困境。

今天要介绍的 ESLint 批量抑制(Bulk Suppressions) 功能,就是为了解决这个问题而生的。

核心思路:既往不咎,严控增量

批量抑制的核心理念可以用八个字概括:既往不咎,严控增量

工作机制

当你启用批量抑制后,ESLint 会做三件事:

  1. 记录现状:将当前所有违规记录到 eslint-suppressions.json 文件中
  2. 暂时放行:对于已记录的旧代码违规,ESLint 暂时忽略,CI/CD 正常通过
  3. 严格卡控:对于新增的代码,规则立即生效,任何新违规都会报错

看一个实际的抑制文件示例:

{
  "suppressions": [
    {
      "ruleId": "@typescript-eslint/no-explicit-any",
      "file": "src/components/UserProfile.tsx",
      "count": 12
    },
    {
      "ruleId": "@typescript-eslint/no-explicit-any",
      "file": "src/utils/request.ts",
      "count": 8
    }
  ]
}

这个文件告诉 ESLint:"UserProfile.tsx 文件里有 12 个 any,这是历史遗留问题,暂时放过。但如果出现第 13 个,立即报错。"

监控机制

批量抑制不是"一抑了之",而是有严格的监控:

// src/components/UserProfile.tsx
// 已记录 12 个 any,当前 12 个 →  通过

// 如果开发者新增了一个 any:
function handleData(data: any) {  // 这是第 13 个!
  // ...
}

// ESLint 会立即报告该文件的所有违规:
// ✖ 13 problems in src/components/UserProfile.tsx

一旦违规数超标,ESLint 会暴露该文件的所有问题,倒逼开发者要么修复新问题,要么顺手把旧问题也一起解决。

实战:在 React + TypeScript 项目中落地

场景设定

假设你负责一个电商项目,技术栈是 React + TypeScript,团队 8 人,代码库现状:

  • 150+ 个组件文件
  • TypeScript 配置较宽松(strict: false
  • 大量使用 any、非空断言、隐式类型转换
  • 团队对"突然冒出的几百个 Lint 错误"非常抵触

你的目标是:在不影响现有开发节奏的前提下,逐步提升代码质量

第一步:评估现状

先看看如果直接开启严格规则会怎样:

// eslint.config.js
export default [
  {
    files: ["**/*.{ts,tsx}"],
    rules: {
      "@typescript-eslint/no-explicit-any": "error",
      "@typescript-eslint/no-non-null-assertion": "error",
      "@typescript-eslint/strict-boolean-expressions": "error"
    }
  }
];

运行检查:

$ eslint .

src/components/ProductList.tsx
  12:15  error  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any
  23:8   error  Forbidden non-null assertion              @typescript-eslint/no-non-null-assertion
  ...

src/utils/formatPrice.ts
  5:22   error  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any
  ...

✖ 847 problems (847 errors, 0 warnings)

847 个错误。如果强行推进,结果可想而知:

  • 团队成员抱怨:"又要加班修 Bug 了"
  • CI/CD 构建失败,阻塞所有人的开发
  • 最后可能不得不回滚配置

第二步:使用批量抑制

冷静下来,换个思路——用批量抑制:

# 1. 开启规则并生成抑制文件
$ eslint . --suppress-all --fix

# 自动修复了 142 个问题
# 剩余 705 个问题记录到 eslint-suppressions.json

✔ Suppressions written to eslint-suppressions.json

这个命令做了两件事:

  1. 自动修复:能自动修复的问题(比如添加类型注解)直接修复
  2. 记录存量:无法自动修复的问题记录到抑制文件

现在再运行 eslint .

$ eslint .
✔ No problems found

完美通过!关键是,新代码必须符合规则

第三步:验证增量卡控

让我们验证一下,新代码是否真的会被拦截。

某个开发者在新功能中写了这样的代码:

// src/components/NewFeature.tsx (新文件)
import React from 'react';

interface Props {
  data: any;  // x 新代码使用 any
}

export default function NewFeature({ data }: Props) {
  return <div>{data.name}</div>;
}

运行检查:

$ eslint src/components/NewFeature.tsx

src/components/NewFeature.tsx
  4:9  error  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any

✖ 1 problem (1 error, 0 warnings)

立即报错!因为这是新文件,不在抑制列表中。

开发者必须修复才能提交:

// 修复后
interface ProductData {
  name: string;
  price: number;
}

interface Props {
  data: ProductData;  //  使用明确的类型
}

第四步:建立日常工作流

1. 定期清理已修复的抑制

当团队修复了一些旧代码后,使用 --prune-suppressions 清理:

$ eslint . --prune-suppressions

✔ Removed 23 suppressions that are no longer needed

这个命令会:

  • 检查每个抑制项是否还存在对应的违规
  • 如果违规已修复,从抑制文件中移除该记录

2. Code Review 关注点

在代码评审时,增加一个检查项:

## Code Review Checklist

- [ ] 代码逻辑正确
- [ ] 测试覆盖充分
- [ ] **技术债趋势**  - [ ] 没有新增 eslint-suppressions.json 中的抑制项
  - [ ] 如果修改了旧文件,是否顺手修复了部分 Lint 问题

进阶技巧

技巧 1: 按模块分批抑制

如果你想先对核心模块严格要求,可以这样操作:

# 先对 utils 和 hooks 目录开启严格模式
$ eslint src/utils src/hooks --suppress-all --fix

# 其他目录暂时不检查

技巧 2: 结合 Git Hooks

推荐使用 lint-staged 来精确控制只检查暂存区的文件,比手写脚本更稳健:

  1. 安装依赖:

    npm install --save-dev lint-staged husky
    
  2. 配置 package.json

    {
      "lint-staged": {
        "*.{ts,tsx}": [
          "eslint"
        ]
      }
    }
    
  3. .husky/pre-commit 中调用:

    #!/bin/sh
    npx lint-staged
    

这样,每次提交时,ESLint 只会检查你修改的文件。如果有新增的违规(且未被抑制),提交将被拦截。

技巧 3: 制定“童子军规则”

在团队中推行童子军规则(Boy Scout Rule):

"离开时,让代码比你来时更干净一点。"

具体做法:

  • 如果你修改了某个旧文件,顺手修复 1-2 个 Lint 问题
  • 在 Code Review 时,给予"技术债修复"额外的认可

总结:技术债治理的三个原则

通过 ESLint 批量抑制功能,我们学到的不仅是一个工具的使用,更是一种技术债治理的方法论

原则 1: 不要让存量问题阻碍增量改进

传统思维:"要么全修,要么不修"。 批量抑制思维:"存量暂时冻结,增量严格卡控"。

原则 2: 渐进式改进优于一次性重构

一次性重构的风险:

  • 周期长,风险高
  • 影响业务交付
  • 团队抵触情绪大

渐进式改进的优势:

  • 每次改进可见、可控
  • 不影响正常迭代
  • 团队负担小

原则 3: 用工具和流程保障,而非靠自觉

不要指望团队成员"自觉地"提升代码质量。应该:

  • 工具拦截:ESLint 自动检查新代码
  • CI 监控:自动检测技术债趋势
  • 流程保障:Code Review 检查清单

参考资料


如果这篇文章对你有帮助,欢迎点赞收藏!也欢迎在评论区分享你在技术债治理方面的经验和困惑。

Git Bash 实用操作笔记(自用知识点笔记)

作者 丘耳
2026年1月27日 10:37

文档说明

本文档为Windows系统下Git Bash的通用操作指导,覆盖Git Bash基础使用、高频Git操作、专属实用技巧及常见问题排查,适配开发日常的代码仓库管理、文件操作等场景,适用于Git新手及需要标准化操作的开发人员,可作为团队内部分享、个人学习记录的参考手册。

适用人群

  1. Windows系统下使用Git进行代码管理的开发人员
  2. 刚接触Git Bash,对Linux基础命令不熟悉的新手
  3. 需标准化Git操作、排查日常使用问题的团队成员

Git Bash 核心优势

  1. 兼容Linux/Unix基础命令,弥补Windows CMD/PowerShell的命令差异
  2. Git原生集成,无需额外配置,直接支持所有Git命令
  3. 完美适配SSH、密钥认证等Git高级操作,无Windows系统兼容问题
  4. 支持命令行快捷键、别名配置,提升开发操作效率

一、Git Bash 基础准备

1.1 打开Git Bash

  1. 方式1:桌面空白处右键 → 选择「Git Bash Here」(推荐,直接定位到当前目录)
  2. 方式2:开始菜单搜索「Git Bash」→ 点击打开(默认定位到用户主目录)
  3. 方式3:Git安装目录下找到git-bash.exe(默认路径:C:\Program Files\Git\git-bash.exe)双击打开

1.2 界面基础认知

  1. 命令提示符:默认显示用户名@电脑名 MINGW64 ~~代表用户主目录(Windows路径:C:\Users\你的用户名
  2. 输入区域:提示符后可直接输入命令,按Enter执行
  3. 快捷键基础
    • 复制:选中内容后按Ctrl+Insert或右键选择「复制」
    • 粘贴:按Shift+Insert或右键选择「粘贴」
    • 清屏:按Ctrl+L或执行命令clear
    • 终止当前命令:按Ctrl+C(命令执行卡死/需中断时使用)

1.3 核心基础命令(Linux兼容,高频使用)

Git Bash支持绝大多数Linux基础命令,是Windows下操作文件/目录的高效方式,以下为日常必用命令,带示例且适配Windows场景:

命令 功能说明 常用示例
pwd 查看当前所在绝对路径(Git Bash格式,/分隔) pwd → 输出:/c/Users/ZhangSan/Desktop
ls 查看当前目录下的文件/文件夹 普通查看:ls;详细查看:ls -l;显示隐藏文件:ls -a(如.ssh文件夹)
cd 切换目录(核心命令) 进入桌面:cd ~/Desktop;进入上级目录:cd ..;进入根目录:cd /;回到主目录:cd ~
mkdir 创建新文件夹 桌面创建test文件夹:mkdir ~/Desktop/test
touch 创建空文件 当前目录创建README.md:touch README.md
rm 删除文件/文件夹 删除文件:rm 文件名.txt;强制删除文件夹:rm -rf 文件夹名(谨慎使用)
cp 复制文件/文件夹 复制文件:cp 源文件 目标路径;复制文件夹:cp -r 源文件夹 目标路径
mv 移动/重命名文件/文件夹 重命名:mv 旧名 新名;移动:mv 源文件 目标路径

关键说明:路径转换(Windows ↔ Git Bash)

Git Bash中使用斜杠/ 作为路径分隔符,与Windows的反斜杠\区分,核心规则:

  1. Windows本地路径(如C:\Users\ZhangSan\Desktop)→ Git Bash路径:/c/Users/ZhangSan/Desktop(盘符小写+:/替换:)
  2. Git Bash中~等价于Windows用户主目录:C:\Users\你的用户名
  3. 桌面快捷路径:~/Desktop(无需写完整路径,推荐使用)

二、Git Bash 核心Git操作(高频开发场景)

Git Bash是Git的原生操作终端,支持所有Git命令,以下为开发日常必用Git操作,按「仓库配置→日常开发→远程协作」分类,命令带示例、步骤清晰,适配团队开发流程。

2.1 基础配置:Git全局用户信息(首次使用必做)

首次使用Git需配置全局用户名和邮箱,与代码仓库(GitLab/GitHub/Gitee)的账号信息一致,一次配置,所有仓库通用

# 配置全局用户名(替换为你的仓库账号名)
git config --global user.name "你的Git用户名"
# 配置全局邮箱(替换为你的仓库登录邮箱)
git config --global user.email "你的Git登录邮箱@xxx.com"
# 查看全局配置信息(验证是否配置成功)
git config --global --list

2.2 仓库操作:克隆/初始化本地仓库

2.2.1 克隆远程仓库(从GitLab/GitHub拉取已有仓库)

支持SSHHTTPS两种方式,推荐SSH(无需重复输密码,避开SSL问题),命令示例:

# SSH方式(推荐,需提前配置SSH密钥,参考后续说明)
git clone git@git.yfb.sunline.cn:yfb/aps/test/mone-test-ui.git
# HTTPS方式(需输账号/令牌,内网可能报SSL错误)
git clone https://git.yfb.sunline.cn/yfb/aps/test/mone-test-ui.git

2.2.2 初始化本地仓库(本地新建项目,推送到远程)

本地新建项目后,初始化Git仓库,生成.git隐藏文件夹(仓库核心配置):

# 进入本地项目根目录
cd ~/Desktop/你的项目名
# 初始化Git仓库
git init
# 关联远程仓库(后续可推送代码)
git remote add origin 远程仓库地址(SSH/HTTPS)

2.3 日常开发:代码提交(工作区→暂存区→版本库)

开发核心流程,三步提交法,命令固定,按顺序执行:

# 步骤1:查看代码修改状态(红色=未暂存,绿色=已暂存)
git status
# 步骤2:将所有修改的文件添加到暂存区(推荐)
git add .
# 可选:单独添加指定文件,避免提交无关内容
git add 文件名1 文件夹名/文件名2
# 步骤3:将暂存区代码提交到本地版本库,必须写提交备注(清晰描述修改内容)
git commit -m "提交备注:如修复登录页bug、新增首页功能"

2.4 分支操作:创建/切换/合并/删除(团队开发核心)

分支是Git的核心特性,团队开发中一人一分支,避免主分支代码冲突,以下为高频分支命令:

# 查看所有分支(*标记当前所在分支)
git branch
# 查看本地+远程所有分支
git branch -a
# 创建新分支(基于当前分支创建,如feature/登录功能)
git branch 分支名
# 切换到指定分支
git checkout 分支名
# 快捷操作:创建并切换到新分支(推荐)
git checkout -b 分支名
# 合并分支(如将开发分支合并到主分支,先切换到主分支)
git checkout main/master  # 先切到主分支
git merge 开发分支名      # 合并开发分支到当前主分支
# 删除本地分支(分支合并后,无用分支可删除)
git branch -d 分支名
# 强制删除本地分支(未合并的分支,谨慎使用)
git branch -D 分支名
# 删除远程分支(推送到远程后,删除远程无用分支)
git push origin -d 远程分支名

2.5 远程协作:拉取/推送/更新远程仓库

团队开发中,需频繁与远程仓库同步代码,避免冲突,核心命令:

# 查看当前仓库关联的远程地址
git remote -v
# 拉取远程仓库最新代码(合并到当前分支,推荐)
git pull origin 分支名
# 拉取远程仓库最新代码(仅下载,不合并,需手动merge)
git fetch origin 分支名
# 将本地代码推送到远程仓库指定分支(首次推送需加-u,关联本地与远程分支)
git push -u origin 分支名
# 非首次推送,直接执行即可
git push origin 分支名
# 关联远程仓库(本地仓库未关联远程时使用)
git remote add origin 远程仓库地址(SSH/HTTPS)
# 修改远程仓库地址(如HTTPS改SSH,解决SSL问题)
git remote set-url origin 新的远程仓库地址(SSH/HTTPS)

2.6 实用操作:撤销/回滚/查看日志

开发中难免出现提交错误、代码写错,以下命令快速解决问题:

# 撤销工作区的修改(未add的文件,恢复到最近一次commit状态)
git checkout -- 文件名
# 撤销暂存区的修改(已add未commit,回到工作区)
git reset HEAD 文件名
# 查看提交日志(按时间倒序,显示提交人、备注、版本号)
git log
# 简洁查看提交日志(只显示版本号前7位和提交备注)
git log --oneline
# 回滚到指定版本(根据git log的版本号,谨慎使用,会覆盖后续修改)
git reset --hard 版本号前7位

三、Git Bash 专属实用技巧(提升效率)

结合Windows系统特性和Git Bash的功能,整理开发高频实用技巧,覆盖快捷键、别名配置、中文兼容、SSH免密等,大幅提升操作效率。

3.1 常用快捷键(Git Bash专属,比鼠标操作快10倍)

快捷键 功能说明
Ctrl+L 快速清屏,无需输入clear命令
Ctrl+C 终止当前执行的命令(命令卡死/错误时使用)
Ctrl+Insert 复制选中的命令/内容
Shift+Insert 粘贴复制的内容到Git Bash
Tab 命令/路径自动补全(输入前几个字符,按Tab快速补全,多次按切换候选)
↑/↓ 切换历史命令(无需重复输入相同命令,按上下键选择)
Ctrl+U 清空当前输入行的所有内容(命令输错时快速重置)

3.2 配置Git命令别名(简化长命令,自定义快捷命令)

将高频长命令配置为短别名,如git statusgit stgit checkoutgit co一次配置,永久生效

# 配置别名(全局生效,执行以下命令即可)
git config --global alias.st status
git config --global alias.co checkout
git config --global alias.ci commit
git config --global alias.br branch
git config --global alias.pulln pull origin main
git config --global alias.pushn push origin main
# 配置后,快捷使用示例
git st  # 等价于git status
git co  # 等价于git checkout
git ci -m "提交备注"  # 等价于git commit -m "提交备注"
git br  # 等价于git branch
git pulln  # 等价于git pull origin main

3.3 解决Git Bash中文乱码问题

Windows下Git Bash默认可能出现中文文件名乱码、git log中文乱码,执行以下3条命令,永久解决:

git config --global core.quotepath false
git config --global gui.encoding utf-8
git config --global i18n.commit.encoding utf-8
git config --global i18n.logoutputencoding utf-8
export LESSCHARSET=utf-8  # 临时生效,永久生效需配置环境变量(可选)

3.4 SSH免密配置(核心,远程仓库操作无需输密码)

适配GitLab/GitHub/Gitee,配置后clone/pull/push无需输入账号密码,彻底避开HTTPS的SSL证书问题,一次配置,所有该账号有权限的仓库通用,步骤如下:

步骤1:生成SSH密钥对

# 生成RSA格式密钥对,邮箱替换为仓库登录邮箱(留空也可)
ssh-keygen -t rsa -C "你的仓库登录邮箱@xxx.com"
# 全程按Enter回车,默认路径保存,不设置密钥密码(新手推荐)

生成成功后,Windows用户主目录下会生成.ssh文件夹(路径:C:\Users\你的用户名\.ssh),内含:

  • id_rsa:私钥,本地保存,绝对不可泄露/删除
  • id_rsa.pub:公钥,需复制到仓库网页端配置

步骤2:查看并复制公钥

# 查看公钥内容并完整复制
cat ~/.ssh/id_rsa.pub

复制以ssh-rsa开头、以邮箱(若填)结尾的完整字符,无多余空格、无字符缺失

步骤3:仓库网页端配置公钥(以GitLab为例)

  1. 登录GitLab网页端 → 右上角头像 → 「Settings」→ 左侧「SSH Keys」
  2. 「Title」自定义(如Windows-办公电脑),「Key」粘贴复制的公钥
  3. 点击「Add key」,配置完成

步骤4:测试SSH连通性

# 替换为仓库域名(如GitLab:git@git.yfb.sunline.cn,GitHub:git@github.com)
ssh -T git@git.yfb.sunline.cn
# 首次执行输入yes,出现「Welcome to GitLab, @你的用户名!」即配置成功

3.5 快速打开当前目录的文件管理器

Git Bash中执行explorer .,可直接打开当前目录的Windows文件资源管理器,无需手动查找路径,高效切换图形界面:

# 打开当前目录的文件管理器
explorer .
# 打开指定目录的文件管理器(如桌面)
explorer ~/Desktop

四、常见问题排查(Git Bash 高频报错)

整理Git Bash日常使用中最常见的报错及解决方案,按「命令执行失败、Git操作报错、SSH配置报错」分类,快速定位问题、解决问题。

4.1 基础命令问题:命令执行无反应/报错command not found

问题现象

输入命令后按Enter,Git Bash无任何反应;或报错command not found: xxx

解决方案

  1. 确认按Enter键执行命令(新手最易犯,只输入不回车);
  2. 切换为英文/半角输入法(中文标点/字符会导致命令解析失败);
  3. 确认Git Bash获得窗口焦点(点击窗口使其标题栏亮,输入才会生效);
  4. 检查命令拼写正确(如ssh-keygen勿写成sshkeygengit checkout勿写成git check);
  5. 重启Git Bash(终端阻塞时,关闭重新打开即可);
  6. 验证Git安装完整(执行git --version,无输出版本则重新安装Git)。

4.2 Git操作问题:克隆/拉取报SSL证书错误

问题现象

HTTPS方式克隆/拉取仓库,报错SSL certificate problem: unable to get local issuer certificate(内网仓库自签名证书常见)。

解决方案

  1. 推荐方案:切换为SSH方式克隆/操作(配置SSH免密,一劳永逸,参考3.4节);
  2. 临时方案:关闭本次Git操作的SSL验证(仅单次有效);
    git clone -c http.sslVerify=false 仓库HTTPS地址
    
  3. 永久方案:配置仓库SSL根证书(适合必须用HTTPS的场景)。

4.3 SSH配置问题:测试连通性报错Permission denied (publickey)

问题现象

执行ssh -T git@xxx.com,报错Permission denied (publickey),SSH认证失败。

解决方案

  1. 确认公钥完整复制(重新执行cat ~/.ssh/id_rsa.pub,复制完整字符,无首尾空格);
  2. 确认公钥添加至个人账号设置(仓库网页端,公钥是绑定个人账号,而非单个仓库);
  3. 确认本地私钥路径为默认路径~/.ssh/id_rsa,非默认路径需手动配置);
  4. 重新生成密钥对(若公钥/私钥损坏,执行ssh-keygen -t rsa重新生成,全程回车)。

4.4 路径问题:切换目录/克隆报错No such file or directory

问题现象

执行cd/git clone,报错No such file or directory,路径不存在。

解决方案

  1. 确认路径分隔符为/(Git Bash中禁用Windows的\,如/c/Users/张三/Desktop);
  2. 确认路径拼写正确(使用Tab键自动补全路径,避免手动拼写错误);
  3. 确认目录实际存在(执行ls查看当前目录下的文件/文件夹,验证路径)。

4.5 提交问题:git commit报错Please tell me who you are

问题现象

首次提交代码,报错Please tell me who you are,Git未配置用户信息。

解决方案

执行2.1节的Git全局用户信息配置命令,配置用户名和邮箱即可。

五、总结

  1. Git Bash是Windows系统下Git操作的最佳终端,兼容Linux命令,原生支持所有Git操作,无系统兼容问题;
  2. 基础操作核心:掌握cd/ls/pwd等Linux基础命令,理解Windows与Git Bash的路径转换规则(/替换\);
  3. Git操作核心:遵循**「全局配置→仓库操作→提交→远程协作」** 流程,分支操作是团队开发的关键;
  4. 效率提升核心:熟练使用快捷键、命令别名、SSH免密配置,减少重复操作,提升开发效率;
  5. 问题排查核心:日常报错多为路径错误、输入法错误、SSH公钥不完整,按文档步骤逐一排查即可解决。

Git Bash的使用核心是多练多用,基础命令和Git操作固定,熟悉后可大幅提升代码仓库管理的效率,建议将本文档收藏,日常使用中随时查阅。

Windows环境下GitLab仓库SSH方式克隆配置操作步骤(知识点笔记)

作者 丘耳
2026年1月27日 10:28

文档说明

本文档适用于Windows系统下,通过Git Bash采用SSH方式操作公司内网GitLab仓库的配置与使用,可彻底解决HTTPS方式的SSL证书验证问题,且配置后无需重复输入账号密码,实现clone/pull/push等操作的一键执行,适合团队内统一参考使用。

适用范围

  1. Windows系统+Git Bash工具(Git安装后自带)
  2. 公司内网GitLab仓库(自签名SSL证书,HTTPS方式克隆报SSL错误)
  3. 拥有GitLab账号及对应仓库的克隆/读写权限

核心优势

  1. 避开SSL证书验证问题,无需配置证书或临时关闭验证
  2. 一次配置,所有该账号有权限的GitLab仓库通用
  3. 后续操作无需输入账号密码/令牌,提升开发效率
  4. Git Bash原生支持,无需额外安装SSH工具

前提条件

  1. 已在Windows系统安装Git(自带Git Bash,建议官网下载最新版)
  2. 能正常登录公司GitLab网页端(如:https://git.xxx.xxxxxx.cn
  3. 个人GitLab账号拥有目标仓库的克隆/读写权限(权限不足请联系仓库管理员添加)

操作步骤

步骤1:在Git Bash中生成SSH密钥对

SSH密钥对包含私钥(id_rsa)公钥(id_rsa.pub),私钥本地保存,公钥配置到GitLab账号,实现身份认证。

  1. 打开Git Bash工具(桌面/开始菜单搜索Git Bash即可)
  2. 执行以下命令生成RSA格式密钥对,邮箱替换为个人GitLab登录邮箱(留空也可,不影响使用):
    ssh-keygen -t rsa -C "你的GitLab登录邮箱"
    
  3. 命令执行后会出现3次交互提示,全程直接按回车键即可(新手不建议设置密钥密码,否则后续每次操作都需输入密码):
    • 提示1:Enter file in which to save the key (/c/Users/你的用户名/.ssh/id_rsa): → 回车(默认保存路径,Git自动识别)
    • 提示2:Enter passphrase (empty for no passphrase): → 回车(无密钥密码)
    • 提示3:Enter same passphrase again: → 回车(确认无密码)
  4. 生成成功后,Windows用户目录下会自动创建.ssh文件夹(路径:C:\Users\你的用户名\.ssh),内含2个核心文件:
    • id_rsa:私钥,本地唯一,绝对不可泄露、删除或上传至仓库
    • id_rsa.pub:公钥,需复制至GitLab网页端,可随意复制

步骤2:查看并复制本地SSH公钥

执行命令查看公钥完整内容,并完整复制(首尾无多余空格、无字符缺失):

  1. Git Bash中执行以下命令:
    cat ~/.ssh/id_rsa.pub
    
  2. 命令执行后会输出一长串以ssh-rsa开头、以填写的邮箱(若有)结尾的字符,即为公钥内容
  3. 复制方式(Git Bash中):
    • 快捷键:选中公钥内容后按Ctrl+Insert复制,Shift+Insert粘贴
    • 右键菜单:选中内容后右键选择「复制」即可

步骤3:将公钥配置到个人GitLab账号

公钥与GitLab个人账号绑定,一次配置所有有权限仓库通用,无需为单个仓库单独配置。

  1. 浏览器登录公司GitLab网页端:https://git.xxx.xxxxxx.cn
  2. 点击页面右上角个人头像 → 选择「Settings」(设置)
  3. 在左侧菜单栏找到「SSH Keys」(SSH密钥),点击进入配置页面
  4. 配置页面填写信息:
    • Title:自定义名称(如Windows-办公电脑/笔记本-开发机),方便区分不同设备的密钥
    • Key:将步骤2复制的公钥内容完整粘贴至输入框(请勿修改任何字符)
  5. 点击页面底部「Add key」(添加密钥),验证成功后即配置完成(若弹出账号密码验证,输入个人GitLab账号密码即可)

步骤4:测试本地SSH与GitLab服务器的连通性

配置公钥后,必须测试连通性,确认身份认证成功后再进行仓库克隆。

  1. 回到Git Bash,执行以下测试命令(域名与公司GitLab一致,请勿修改):
    ssh -T git@git.xxx.xxxxxx.cn
    
  2. 首次执行会出现安全提示,输入yes并回车即可:
    The authenticity of host 'git.xxx.xxxxxx.cn (xxx.xxx.xxx.xxx)' can't be established... Are you sure you want to continue connecting (yes/no/[fingerprint])?
    
  3. 验证成功:出现类似Welcome to GitLab, @你的GitLab用户名!的提示,说明SSH配置完成,可正常操作仓库
  4. 验证失败:若报错,优先排查「公钥是否完整复制」「GitLab账号是否有仓库权限」

步骤5:通过SSH方式克隆GitLab仓库

连通性测试成功后,即可使用SSH地址克隆目标仓库,无任何额外参数。

5.1 获取仓库的SSH地址

  1. 浏览器打开目标仓库网页端(示例:https://git.xxx.xxxxxx.cn/.../仓库名称.git
  2. 页面顶部点击「Clone」按钮,弹出地址选择框,选择SSH并复制地址
  3. SSH地址格式:git@git.xxx.xxxxxx.cn:仓库完整路径.git 示例仓库SSH地址:git@git.xxx.xxxxxx.cn:.../仓库名称.git

5.2 执行SSH克隆命令

Git Bash中执行以下命令,替换为目标仓库的SSH地址即可:

git clone git@git.xxx.xxxxxx.cn:.../仓库名称.git

克隆过程与HTTPS方式一致,且后续对该仓库执行git pull/git push时,无需输入任何账号密码

额外实用操作:将已克隆的HTTPS仓库改为SSH方式

若已通过HTTPS方式克隆仓库(无论是否克隆成功),无需重新克隆,直接修改仓库的远程地址为SSH即可,后续操作自动走SSH协议。

  1. Git Bash中进入已克隆的仓库根目录(示例为桌面仓库,替换为实际路径):
    cd /c/Users/你的用户名/Desktop/mone-test-ui
    
  2. 执行命令修改远程origin的地址为SSH地址:
    git remote set-url origin git@git.xxx.xxxxxx.cn:.../仓库名称.git
    
  3. 验证修改是否成功,执行后显示SSH地址即为配置完成:
    git remote -v
    

重要注意事项

  1. 私钥安全:本地.ssh/id_rsa为私钥,是身份认证的核心,不可泄露、删除、修改,也不可上传至代码仓库;若私钥丢失/泄露,立即在GitLab「SSH Keys」页面删除对应公钥。
  2. 多设备配置:不同电脑/设备需单独生成SSH密钥对,分别添加至GitLab「SSH Keys」页面,设备间互不影响。
  3. 权限管控:SSH仅负责身份认证,能否操作仓库取决于GitLab账号的仓库权限;若克隆/推送报权限错误,非SSH配置问题,联系仓库管理员为账号分配权限(如开发者、访客)。
  4. SSH地址规则:公司GitLab所有仓库的SSH地址格式统一,可由HTTPS地址直接转换:https://git.xxx.xxxxxx.cn/git@git.xxx.xxxxxx.cn:(将HTTPS的//域名/替换为git@域名:)。
  5. 命令输入规范:Git Bash中执行命令时,需切换为英文/半角输入法,避免中文标点导致命令执行失败。

常见问题排查

问题1:执行ssh-keygen命令后,Git Bash无任何反应

原因及解决

  1. 未按回车键执行命令:输入命令后必须按Enter键,终端才会执行;
  2. 输入法为中文:存在中文标点/字符,切换为英文半角输入法,重新输入命令;
  3. Git Bash失去焦点:点击Git Bash窗口使其获得焦点,重新输入命令;
  4. 终端阻塞:关闭当前Git Bash,重新打开后执行命令(直接复制命令避免拼写错误)。

问题2:测试连通性时,提示Permission denied (publickey)

原因及解决

  1. 公钥复制不完整:重新复制.ssh/id_rsa.pub的完整内容,在GitLab中重新添加公钥;
  2. 公钥添加错误:确认添加至「个人Settings-SSH Keys」,而非仓库级别的密钥配置;
  3. 本地私钥路径异常:未使用默认路径生成密钥,需手动配置SSH私钥路径(极少出现,新手建议默认路径)。

问题3:克隆仓库时,提示「仓库不存在」

原因及解决

  1. SSH地址错误:检查仓库路径是否正确,避免将/写成:(SSH地址中仅域名后有一个:);
  2. 账号无仓库权限:联系仓库管理员,将个人GitLab账号添加至仓库并分配权限。

总结

  1. SSH方式是公司内网GitLab仓库的首选操作方式,一次配置所有有权限仓库通用;
  2. 核心流程:生成密钥对 → 复制公钥 → GitLab配置公钥 → 测试连通性 → SSH克隆
  3. 关键验证命令:ssh -T git@git.xxx.xxxxxx.cn,出现欢迎语即配置成功;
  4. 权限问题与SSH无关,由GitLab账号层面管控,权限不足联系管理员即可。

前端性能优化:巧用请求锁解决列表渲染中的字典请求风暴

作者 大包子
2026年1月27日 10:25

一、问题背景:当公共组件变成性能瓶颈

最近在做流程管理时,发现一个看似"优化"的公共组件,反而成了性能瓶颈。

场景还原
一个列表页面,每行数据都需要显示流程状态、类型等字典项。我们写了一个"聪明"的公共组件,它会自动查询并缓存字典数据——这本是为了减少重复请求。但现实很骨感:当列表有12条数据时,首次加载竟然会发起12次完全相同的接口请求image.png

痛点分析

  • 每个组件都认为自己是"第一个"请求者
  • 接口未返回前,其他组件看不到缓存
  • 网络请求呈指数级增长,页面加载缓慢

二、传统方案的局限性

首先想到的解决方案是 Vuex/Pinia + 状态管理

// 理想很美好...
const store = useDictStore()
const dictData = await store.fetchDict(params)

但现实是:这个组件属于公共组件库,没有自己的状态管理,依赖业务项目的store。如果要改造,需要修改所有接入的业务项目——显然不可行。

三、技术探索:四种无状态缓存方案

经过调研和AI辅助,我们发现了四种纯前端缓存方案:

方案 核心思路 适用场景
浏览器存储 + 请求锁 localStorage + 请求标识锁 多标签页共享缓存
闭包缓存 + 请求队列 JavaScript闭包管理内存缓存 单页面应用
sessionStorage共享缓存 会话级缓存 + 分布式锁 当前会话内共享
防抖请求方案 全局变量管理请求状态 简单场景

四、最终方案:localStorage + 请求锁

结合我们的实际需求,选择了方案一进行改造。关键思路:用请求锁保证同一时间只有一个请求,用localStorage做数据共享

4.1 问题诊断:原有代码为什么失效?

先看原有实现的核心问题:

export async function dictQueryByFlag({ appCode, flag, orgId }) {
  // 问题1:每个组件都独立读取缓存
  let DictStorage = JSON.parse(localStorage.getItem('DictStorage-' + orgId) || '{}')
  
  // 问题2:请求前不检查是否有人正在请求
  if (!appDcit) {
    await asyncDictQueryByFlag({ appCode, flag, orgId }, DictStorage)
  }
}

根本原因:组件A发起请求 → 组件B看不到缓存 → 组件B也发起请求 → 造成请求风暴。

4.2 解决方案:三级缓存 + 请求锁

我们的字典数据有三层结构:

  • orgId:组织层级
  • appCode:所属应用
  • flag:具体字典项

因此,请求锁的key必须包含最小粒度

// 关键:锁的key要精确到最小维度
const loadingKey = 'DictStorageLoading-' + orgId + '-' + appCode + '-' + flag

4.3 核心实现:双保险机制

保险1:请求锁机制

const waitForLoading = async () => {
  // 轮询检查锁状态,每500ms检查一次
  await new Promise<void>((resolve) => {
    const interval = setInterval(() => {
      if(!localStorage.getItem(loadingKey)) {
        clearInterval(interval)
        resolve()
      }
    }, 500)
    // 超时保护:5秒后强制释放
    setTimeout(() => {
      clearInterval(interval)
      resolve()
    }, 5000)
  })
}

保险2:智能缓存查询

const queryAndCache = async (queryFlags: string[]) => {
  // 上锁
  localStorage.setItem(loadingKey, 'true')
  
  // 关键改进:移除DictStorage参数,实时读取最新缓存
  await asyncDictQueryByFlag({ 
    appCode, 
    flag: queryFlags.join(','), 
    orgId 
  })
  
  // 释放锁
  localStorage.setItem(loadingKey, '')
}

4.4 完整优化代码

// 根据flag查询字典数据,自动缓存
export async function dictQueryByFlag({ appCode, flag, orgId }: DictQueryParams) {
  // 1. 读取当前缓存
  let DictStorage = JSON.parse(localStorage.getItem('DictStorage-' + orgId) || '{}')
  const flags = (flag || '').split(',')
  const appDict = DictStorage[appCode || '']
  
  // 2. 生成请求锁key
  const loadingKey = 'DictStorageLoading-' + orgId + '-' + appCode + '-' + flag

  // 3. 封装等待逻辑
  const waitForLoading = async () => {
    await new Promise<void>((resolve) => {
      const interval = setInterval(() => {
        if(!localStorage.getItem(loadingKey || '')) {
          clearInterval(interval)
          resolve()
        }
      }, 500)
      setTimeout(() => {
        clearInterval(interval)
        resolve()
      }, 5000)
    })
  }

  // 4. 封装查询逻辑
  const queryAndCache = async (queryFlags: string[]) => {
    localStorage.setItem(loadingKey, 'true')
    // 移除DictStorage参数,内部会重新读取最新缓存
    await asyncDictQueryByFlag({ appCode, flag: queryFlags.join(','), orgId })
    localStorage.setItem(loadingKey, '')
  }

  // 5. 智能请求决策
  if (!appDict) {
    // 整个appCode都没有缓存
    if (localStorage.getItem(loadingKey || '') === 'true') {
      await waitForLoading()  // 等待其他请求完成
    } else {
      await queryAndCache(flags)  // 发起新请求
    }
  } else {
    // 部分flag没有缓存
    const noCacheFlag = flags.filter(item => !appDict[item])
    if (noCacheFlag.length > 0) {
      if (localStorage.getItem(loadingKey || '') === 'true') {
        await waitForLoading()  // 等待其他请求完成
      } else {
        await queryAndCache(noCacheFlag)  // 只请求缺失的部分
      }
    }
  }
  
  // 6. 返回最终数据
  return getDictStorage(DictStorage, flags, appCode, orgId)
}

4.5 asyncDictQueryByFlag的改进

export async function asyncDictQueryByFlag(params, storageData?) {
  try {
    const data = await http.request({
      url: `${prefix}/dictdata`,
      method: 'get',
      params
    })
    
    // 关键改进:每次请求都重新读取最新缓存
    const currentStorage = JSON.parse(
      localStorage.getItem('DictStorage-' + params.orgId) || '{}'
    )
    
    if (!currentStorage[params.appCode]) {
      currentStorage[params.appCode] = {}
    }
    
    // 按flag更新缓存
    const flags = params.flag.split(',')
    flags.forEach(flagItem => {
      if (data[flagItem]) {
        currentStorage[params.appCode][flagItem] = data[flagItem]
      }
    })
    
    localStorage.setItem('DictStorage-' + params.orgId, 
      JSON.stringify(currentStorage))
    
    return Promise.resolve(data)
  } finally {
    // 清理逻辑...
  }
}

五、优化效果对比

指标 优化前 优化后
请求次数(10条列表) 10次 1次
页面加载时间 2-3秒 500ms以内
缓存命中率 0%(首次) 100%(后续)
代码侵入性 无(纯前端方案)

六、方案优势

  1. 零侵入:不依赖框架状态管理,不改业务代码
  2. 高兼容:支持多标签页、多组件实例
  3. 智能缓存:按flag粒度更新,减少无效请求
  4. 超时保护:防止死锁,保证系统稳定性
  5. 性能显著:从O(N)请求优化到O(1)请求

七、适用场景

✅ 适合:

  • 公共组件的字典查询
  • 多实例共享数据的场景
  • 无法使用状态管理的遗留系统

❌ 不适合:

  • 数据实时性要求极高的场景
  • 数据量极大的缓存管理
  • 需要服务端推送更新的场景

八、总结与思考

这个优化案例给我们几点启示:

  1. 缓存不只是存储,更是同步机制:要考虑多实例间的数据一致性
  2. 最小化锁粒度:锁的粒度越细,并发性能越好
  3. 防御式编程:超时机制、异常处理必不可少
  4. 度量驱动优化:用数据说话,量化优化效果

技术选型的核心:没有最好的方案,只有最适合当前约束条件的方案。在无法改变架构的情况下,我们通过巧妙的前端缓存+锁机制,用最小的代价解决了最大的性能问题。


优化永无止境,但每一次优化都让用户体验更好一点点。如果你的项目也遇到类似问题,不妨试试这个方案。欢迎交流讨论!

CSS-动画进阶:从 @keyframes 到 Animation 属性全解析

2026年1月27日 10:21

前言

相比传统的 transition(过渡),animation(动画)提供了更强大的控制能力。它不需要触发条件,可以自动播放,并且通过“关键帧”能够实现极其复杂的视觉效果。

一、 动画的“两步走”战略

实现一个 CSS 动画通常分为两步:

  1. 定义动画:使用 @keyframes 规定不同时间点的样式状态。
  2. 调用动画:在目标元素上使用 animation 属性。

二、 Animation 属性详解(简写语法)

为了提高效率,我们通常使用简写方式:

animation: name duration timing-function delay iteration-count direction fill-mode;

1. 核心属性表

属性 描述 默认值
animation-name 关键帧动画的名称
animation-duration 动画周期时长(必须带单位,如 2s 0
animation-timing-function 速度曲线(平滑度控制) ease
animation-delay 动画延迟启动时间 0
animation-iteration-count 播放次数(数字或 infinite 1
animation-direction 是否反向播放(如 alternate normal
animation-fill-mode 动画结束后的状态(如 forwards 停在终点) none

2. 关键属性深度解析

  • animation-timing-function (速度曲线)

    • ease:默认值,先慢后快再慢。
    • linear:匀速运动,适合背景滚动或旋转。
    • steps(n):逐帧动画,适合做像素画或 Loading 效果。
  • animation-iteration-count (循环次数)

    • n:播放 nn 次。
    • infinite:无限循环播放。

三、 实战案例:方块平移旋转

需求:方块在 3s 内匀速向右平移400px并旋转360°,无限循环。

.card {
  width: 100px;
  height: 100px;
  background: #ff4757;
  /* 针对 width 和 transform 设置过渡 */
  animation: moves 3s ease infinite;
}

@keyframes moves {
  0% {
    transform: translate(0,0) rotate(0deg);
  }
  100% {
    transform: translate(400px,0px) rotate(360deg);
  }
}

四、 进阶技巧与避坑指南

  1. 性能优化(优先使用 Transform)

    在动画中修改 margin-leftleft 会触发浏览器的重排 (Reflow) ,消耗大量性能。推荐使用 transform: translateX() ,它通过 GPU 加速,动画更加丝滑。

  2. 动画状态保持

    如果你想让动画播完后停留在最后一帧,请加上 forwardsanimation-fill-mode)。

  3. 单位不能省

    animation-duration 即使是 0 秒也必须带单位 0s,否则部分浏览器会判定为语法错误导致动画不生效。

为什么你始终看不懂JavaScript?

作者 wuhen_n
2026年1月27日 10:20

看似简单的语法背后,隐藏着令人费解的行为逻辑——这是无数前端开发者共同的困惑,本文将深入探讨 JavaScript 的两面性与其“诡异行为”。

JavaScript的“两面性”陷阱

作为一名前端工程师,我经常听到有人抱怨:“JavaScript 的语法明明很简单,为什么写起来总是踩坑?”这正是 JavaScript 最迷人的地方,也是它最令人困惑的地方——简单语法与复杂行为的强烈反差。

让我们先从一个经典的“诡异”例子开始:

console.log(1 + "2");        // "12" 还是 3?
console.log(2 - "1");        // 1 还是 报错?
console.log(true + false);   // 1?还是true?
console.log([] + []);        // ""?还是[]?
console.log([] + {});        // "[object Object]"?为什么?

这些看似简单的表达式,结果却常常出人意料。接下来,我们将从JavaScript的设计哲学入手,揭示这种“双重人格”背后的秘密。

语言设计哲学:妥协与进化

诞生背景:10天创造的“应急语言”

JavaScript 诞生于1995年,网景公司为了在浏览器中添加简单的交互功能,仅用10天就设计了这门语言。这种“速成”背景决定了它的一些特性:

1. 向后兼容的代价

typeof null === "object"  // 著名的设计错误,但已无法修改

2. 弱类型带来的灵活性和混乱

let x = 10;    // 现在是数字
x = "hello";   // 突然变成字符串
x = function() { return 42; };  // 又变成函数

双重身份:函数式与面向对象的混合体

在 JavaScript 中,它同时支持两种编程范式,这既是优势,也是产生困惑的源头:

面向对象的方式

class Person {
  constructor(name) {
    this.name = name;
  }
  greet() {
    return `Hello, ${this.name}`;
  }
}

函数式的方式

const createPerson = (name) => ({
  name,
  greet: () => `Hello, ${name}`
});

函数式与面向对象混用

const person = {
  name: "zhangsan",
  greet() {
    return `Hello, ${this.name}`;
  },
  // 函数式的方法
  toUpperCase: function() {
    return this.name.toUpperCase();
  }
};

编译-执行双阶段模型:理解诡异行为的关键

这是本文的核心重点!JavaScript 的行为之所以令人困惑,很大程度上是因为它的双阶段执行模型。

编译阶段(预解析)

在这个阶段,JavaScript引擎会做三件重要的事情:

1. 变量提升

我们先来看一段简单的代码:

console.log(a);
var a = 10;
console.log(b);
let b = 20;

上述代码在编辑阶段会发生什么呢?

var a;  // var声明被提升,初始化为 undefined
// let b; // let声明也被提升,但不会被初始化(暂时性死区)

在部分资料中提到 var 与 let/const 的区别,中间会有一点:let/const不会出现变量提升。这种说法是不准确的。其实 let/const 也会出现变量提升,只是在提升后并不会被初始化,在这个阶段,直接调用变量程序会报错,因此被称为:暂时性死区

2. 函数提升

sayHello();  // 可以正常调用!

function sayHello() {
  console.log("Hello!");
}

我们可以看到函数提升是可以正常调用的,这又是为什么呢?原来,在 JavaScript 中,函数提升,会把整个函数声明(包括函数体)都提升到顶部,其实际执行过程如下:

function sayHello() {  // 整个函数声明(包括函数体)提升到顶部
  console.log("Hello!");
}
sayHello();  // "Hello!"
函数提升的变种:函数表达式

在函数定义时,我们也可以将函数复制给一个变量,即函数表达式,这种情况下,又会产生新的问题:

sayHello();  // TypeError: foo is not a function

var sayHello = function() {
  console.log("Hello!");
}

可以看到这种情况下,又出现了新的问题。其本质仍然在于关键字 var,将整个函数作为了变量进行处理。

3. 作用域链建立

function outer() {
  var x = 10;
  function inner() {
    // 编译时就知道可以访问x
    console.log(x);
  }
  return inner;
}

关于作用域链,在后面的文章中会详细讲解!

执行阶段

执行阶段按顺序运行代码,但此时作用域、变量状态都已确定。还是看看变量提升的例子:

console.log(a);
var a = 5;
console.log(a);

其执行过程是什么样的呢?

var x;          // 编译阶段:声明提升,初始化为 undefined
console.log(x); // 执行阶段:输出 undefined
x = 5;          // 执行阶段:赋值 5
console.log(x); // 执行阶段:输出 5

执行上下文与闭包的秘密

理解执行上下文是掌握JavaScript的关键:

function createCounter() {
  let count = 0;  // 这个变量会被"闭包"捕获
  
  return {
    increment: function() {
      count++;
      return count;
    },
    decrement: function() {
      count--;
      return count;
    }
  };
}

const counter = createCounter();
console.log(counter.increment());  // 1
console.log(counter.increment());  // 2
// count变量"神奇地"被记住了,即使createCounter已经执行完毕

关于执行上下文与闭包的相关内容,在后面的文章中,会详细讲解!

解释型语言的动态特性

运行时类型检查与转换

JavaScript 的类型系统在运行时动态工作,这导致了许多“魔幻”般的行为。还记得我们文章开头的那个例子吗,其正确的输出结果是:

console.log(1 + "2");        // "12"
console.log(2 - "1");        // 1 
console.log(true + false);   // 1
console.log([] + []);        // ""
console.log([] + {});        // "[object Object]"

这中间其实存在许多隐式转换规则:

console.log(0 == false);    // true
console.log("" == false);   // true
console.log([] == false);   // true
console.log(null == undefined); // true
console.log("0" == false);  // true

因此,我们在实际开发中,推荐使用 === 进行判断,防止类型转换带来的问题。

console.log([] + {}); 为什么输出结果是 [object Object] 呢?这又涉及到 Object 对象的原型链方法。这段代码等价于:console.log([].toString() + ({}).toString()); 。其中:[] 会被转成空串 ""{}会被当做一个对象,被转成 [object Object] 。(这是 Object.prototype.toString() 的默认实现)

原型链:JavaScript的继承机制

这是JavaScript最独特也最令人困惑的特性之一:

// 原型链示例
function Animal(name) {
  this.name = name;
}

Animal.prototype.speak = function() {
  console.log(`${this.name} makes a noise`);
};

function Dog(name) {
  Animal.call(this, name);  // 调用父类构造函数
}

// 设置原型链
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.speak = function() {
  console.log(`${this.name} barks`);
};

const dog = new Dog("Rex");
dog.speak();  // "Rex barks"

原型链查找

上述过程存在一个原型链查找过程:

  1. dog.hasOwnProperty('name'):true,直接调用
  2. dog.hasOwnProperty('speak'):false,往原型上查找
  3. dog.__proto__.hasOwnProperty('speak'):true

关于原型和原型链的内容,在后面的文章中会详细讲解。

异步编程模型:事件循环

JavaScript 的单线程异步模型,这又是另一个难点了,我们先来看一道经典的面试题:

console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

Promise.resolve().then(() => {
  console.log("3");
});

console.log("4");

上述代码的输出结果是:1 4 3 2

我们再看一道事件循环的微观队列与宏观队列的代码:

console.log("start");

setTimeout(() => {
  console.log("setTimeout");
}, 0);

Promise.resolve()
  .then(() => {
    console.log("P1");
  })
  .then(() => {
    console.log("P2");
  });

console.log("end");

上述代码的输出结果是:start end P1 P2 setTimeout

关于 JavaScript 异步编程的相关内容,在我的另外一个专栏里: Promise详解 有详细介绍!

实战:理解一道经典面试题

让我们用今天学到的知识解析一道经典面试题:

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 100);
}

上述代码的输出结果是:5 5 5 5 5 。为什么不是 0 1 2 3 4 呢?

  1. var 声明的变量 i 是函数作用域(或全局作用域)
  2. 所有 setTimeout 共享同一个 i
  3. setTimeout 回调执行时,循环已结束,i=5 ,所以输出都是 5 。

这种问题应该如何解决呢?

  1. 使用let(块级作用域):
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);  // 0,1,2,3,4
  }, 100);
}
  1. 使用闭包:
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j);  // 0,1,2,3,4
    }, 100);
  })(i);
}

结语

JavaScript的“诡异”行为并非缺陷,而是其灵活性和强大功能的副产品。理解它的双阶段模型、作用域链、原型系统和事件循环,是掌握这门语言的关键。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

jQuery 4.0 有哪些重大改动?一次面向未来的“减负式升级”

作者 Felix_D
2026年1月27日 10:20

jQuery 4.0 还未正式发布,但官方已经明确了核心方向
本文基于 jQuery 官方讨论、Roadmap 以及 3.x 演进路径进行整理


一、为什么还需要 jQuery 4.0?

在 Vue / React / Svelte 大行其道的今天,很多人会问:

“都 2026 年了,jQuery 还有必要升级吗?”

答案其实很现实:

  • 全球仍有大量存量系统
  • CMS / 后台系统 / 老项目依赖极深
  • 插件生态依然庞大
  • 很多业务并不需要重框架

jQuery 4.0 的目标 不是“重新夺回前端霸主地位” ,而是:

👉 为现代浏览器环境“瘦身 + 规范 + 长期维护”


二、jQuery 4.0 的核心设计目标

官方已经多次明确 4.0 的整体方向:

🎯 核心目标总结

方向 说明
移除历史包袱 不再兼容 IE
对齐 Web 标准 尽量不再“魔改”DOM 行为
减少体积 删除冗余 API
提升一致性 行为更可预测
为未来十年铺路 长期维护版本

三、最重要的变化一览(重点)

1️⃣ 彻底移除 IE 支持(包括 IE11)

这是 jQuery 4.0 最大、最根本的变化

影响:

  • 移除大量兼容性 hack
  • 删除老旧分支代码
  • 体积明显缩小
  • 性能更稳定
IE 相关:
- attachEvent
- ActiveXObject
- 老式事件模型
- 非标准 DOM 行为

👉 如果你的系统还要兼容 IE,请停留在 jQuery 3.x


2️⃣ 删除已废弃 API(Breaking Changes)

jQuery 3.x 已经通过 jQuery Migrate 连续多年“打预防针”,4.0 会真正动刀。

常见被移除 / 行为改变的 API:

API 状态
.size() ❌ 移除(用 .length
.bind() / .unbind() ❌ 移除
.delegate() ❌ 移除
.load(fn) ❌ 移除
$.isArray() ❌ 移除(用 Array.isArray
$.isFunction() ❌ 移除

示例:

// ❌ jQuery 4.0 不再支持
$(el).bind('click', fn)

// ✅ 正确写法
$(el).on('click', fn)

3️⃣ 事件系统更贴近原生 DOM

jQuery 4.0 会减少“二次封装行为差异”,让你更接近浏览器原生体验。

变化趋势:

  • 事件对象字段更接近 Event
  • 减少奇怪的自动兼容
  • 阻止默认行为更清晰
$(el).on('click', e => {
  e.preventDefault() // 行为更标准
})

4️⃣ 动画模块继续瘦身(但不会消失)

官方态度很明确:

动画不是 jQuery 的未来,但不能一刀切

方向:

  • 保留基础动画
  • 不再扩展复杂 easing
  • 鼓励使用 CSS / Web Animations API
// 仍然支持
$('.box').fadeIn()

5️⃣ Deferred / Promise 行为更规范

这是一个非常容易踩坑但很重要的点

问题背景:

  • jQuery Deferred ≠ 原生 Promise
  • then / catch 行为存在差异

jQuery 4.0 方向:

  • 行为尽量对齐 ES Promise
  • 减少“jQuery 特有黑魔法”
$.ajax(...).then(res => {
  // 行为更接近 Promise
})

⚠️ 但 Deferred 不会完全等同 Promise


四、体积 & 性能变化

虽然最终体积要等正式发布,但趋势已经很清晰:

版本 体积趋势
jQuery 1.x 巨大
jQuery 2.x 去 IE6/7
jQuery 3.x 性能优化
jQuery 4.x 明显更小

主要来自:

  • 移除 IE 分支
  • 删除废弃 API
  • 简化内部逻辑

五、jQuery 4.0 适合谁?

✅ 适合:

  • 老系统长期维护
  • CMS / 后台管理
  • 插件依赖 jQuery
  • 不想引入重框架的项目

❌ 不适合:

  • 新项目
  • SPA / 大型交互应用
  • 高状态复杂度业务

六、升级建议(非常重要)

升级路线推荐:

jQuery 3.x
↓
开启 jQuery Migrate
↓
修复所有 warning
↓
等待 4.0 稳定版

重点检查:

  • 是否使用 .bind() / .delegate()
  • 是否依赖 IE 行为
  • 是否使用旧动画插件
  • 是否使用 Deferred 黑科技

七、jQuery 4.0 的真实定位

一句话总结:

jQuery 4.0 不是“复兴”,而是“善终 + 长期维护”

它不会和 Vue / React 抢市场,但会:

  • 让旧系统活得更久
  • 让维护成本更低
  • 让行为更可预测
  • 它不抢风头,只做三件事:
    • 删历史
    • 对标准
    • 保稳定

❌
❌