分类: programming

  • 了解非加密哈希算法

    原始讨论见: https://v2ex.com/t/1145673

    我问 Gemini:

    帮助我理解下面的讨论:

    A:

    总共有关联 6 个问题,没找到答案,于是想一起问。

    1. 对于>256 字节的文件,对全文和尾部 128 位字节分别进行 xxHash3-128 然后拼接得到 256bit 散列和 SHA256 一次得到的 256bit 哪个更抗碰撞?
    2. 用于命名用户上传的文件,另外加盐用拼接还是 XOR 方式更好?
    3. 如果拼接的话,拼接在开头是不是要把原文 byte[]复制一遍,影响性能?
    4. 我知道应用于签名场景的话不能拼接在末尾,因为 SHA256 可以向末尾追加块,但是应用于标题这个场景是否安全(会增加暴露盐值让 hash 值可预测)?
    5. 如果通过将原文头部和盐 XOR 运算效果会不会更好?
    6. xxHash3 的 seed 可以代替盐吗?

    B:

    1. 把 xxHash3-128 和 SHA-256 对比还挺滑稽的,因为 xxHash3 从未为密码学设计,而安全性是全称命题(感性认知上属于不刻意设计就很可能不存在的性质)。
    2. 不理解什么是用 XOR 加盐,但我能想象的最简单的方式( XOR 消息之后散列,或者 XOR 散列值,或者两者都做)都很糟糕。
    3. 大多数 hash 算法都支持流式喂送数据,你只要调用两次名字类似 AppendData / Update 的方法就行了。
    4. 不太确定你想象中的“签名”是什么。如果你说的是 HMAC ,首先这是 MAC 而不是签名,然后 HMAC 和简单 hash 区别很大。
    5. 见 3.
    6. 见 1.
      另外,盐是散列函数索引 (hash key/index, or “seed” sometimes) 的实践表现,每个散列函数使用独立的 seed 从可证明安全角度自然没有“再加盐”的必要。

    Gemini 回答:

    好的,我们来深入解析一下这段对话。

    这段讨论的核心是:用户 A 试图通过“自己动手”的方式,将一个非加密哈希算法 (xxHash) 改造得“足够安全”,用于文件命名。而用户 B 则以一个更专业的安全视角,指出了这种做法的根本性错误和危险性。

    简单来说,A 在问“我该如何用木头造一辆跑车?”,而 B 在回答“你不应该用木头造跑车,你应该直接去买一辆真正的跑车。”

    下面我将逐条为你详细解释,并补充一些背景知识。


    核心思想解析

    • 用户 A 的目标: 为用户上传的文件生成一个唯一的、抗碰撞的、256位的标识符(文件名)。他希望兼顾 xxHash速度SHA256安全性
    • 用户 A 的方法: “自己动手,创造密码学”(Roll Your Own Crypto)。他想通过拼接、XOR、加盐等方式,把一个不安全的东西变得安全。
    • 用户 B 的观点: 这是密码学中的第一大忌。安全性是一个系统性的、需要严格数学证明的属性,不是通过简单的“混合”就能得到的。使用未经安全设计的算法来处理安全问题,从一开始就错了。

    逐条问题详解

    问题 1: xxHash3 拼接 vs. SHA256,哪个更抗碰撞?

    • A 的提议: hash = xxHash3_128(全文) + xxHash3_128(文件尾部)
    • B 的回答: “把 xxHash3-128 和 SHA-256 对比还挺滑稽的…安全性是全称命题。”
    • 专家解读:
      • B 的回答一针见血。这个问题的比较本身就没有意义。
      • SHA256 的抗碰撞性是密码学级别的。意思是,即使有一个拥有巨大算力的恶意攻击者,想主动找出两个不同的文件能产生同一个 SHA256 值,在计算上也是不可行的。
      • xxHash 的抗碰撞性是统计学级别的。意思是,对于随机的、非恶意的输入,它能很好地将它们散列到不同的值,以避免“意外”碰撞。但如果一个攻击者知道你用了 xxHash,他可以非常轻松地、在几毫秒内构造出无数个能产生相同 xxHash 值的文件。
      • A 的拼接方案安全性如何?它的安全性等同于其最薄弱的一环,也就是 xxHash。 攻击者完全可以忽略你的拼接手法,直接针对 xxHash 的弱点进行攻击。因此,A 的方案完全不具备密码学上的抗碰撞性

    问题 2 & 5: 加盐用拼接还是 XOR?

    • A 的提议: hash(盐 + 原文) 还是 hash(盐 XOR 原文)
    • B 的回答: “不理解什么是用 XOR 加盐…(但)都很糟糕。”
    • 专家解读:
      • B 的直觉是完全正确的。在密码学中,处理输入数据的标准方式是拼接,而不是 XOR。
      • 拼接 (Concatenation): hash(salt + message) 是相对标准和安全的方式(尤其是在盐是固定长度且放在前面时)。
      • XOR: hash(salt XOR message) 是非常危险的。如果 saltmessage 短,它会被重复使用,产生密码分析上的弱点。更糟糕的是,如果攻击者能以某种方式控制部分 message,他或许能抵消掉 salt 的作用。例如,如果文件的开头是一段固定的、已知的头部信息,攻击者就可以通过 XOR 操作推断或抵消盐值。永远不要用 XOR 来“混合”盐和消息。

    问题 3: 拼接的性能问题

    • A 的担忧: salt + message 是不是要把 message 的字节数组完整复制一遍,影响性能?
    • B 的回答: “大多数 hash 算法都支持流式喂送数据,你只要调用两次…Update 的方法就行了。”
    • 专家解读:
      • B 的回答是 100% 正确的,并指出了一个关键的工程实践。
      • 所有设计良好的哈希库都提供流式(Streaming)API。你不需要在内存里把盐和整个文件拼接成一个巨大的新对象。
      • 操作流程是这样的:
        1. 初始化一个哈希上下文(hasher)。
        2. 向 hasher “喂”入盐 (hasher.update(salt))。
        3. 接着向同一个 hasher “喂”入文件的内容,可以一块一块地喂,不需要一次性读入内存 (hasher.update(file_chunk_1), hasher.update(file_chunk_2), …)。
        4. 最后,从 hasher 中获取最终的哈希结果 (hasher.digest())。
      • 这种方式内存效率极高,无论文件有多大。

    问题 4: 盐放在末尾与签名

    • A 的担忧: 知道 hash(message + salt) 对于签名场景不安全(因为长度扩展攻击),那么在文件命名场景下是否安全?
    • B 的回答: “不确定你想象中的‘签名’是什么…如果你说的是 HMAC…区别很大。”
    • 专家解读:
      • A 了解到一个真实的攻击——长度扩展攻击 (Length Extension Attack),这值得肯定。这种攻击针对 hash(secret + message) 这种结构。
      • B 再次纠正了 A 的术语和理解。A 想要的不是“数字签名”(通常基于非对称加密),而是消息认证码 (MAC),它用于验证消息的完整性和真实性。
      • HMAC (Hash-based MAC) 是解决这个问题的标准方案。它的结构是 hash( (key XOR opad) + hash( (key XOR ipad) + message) )。这个复杂的结构就是为了彻底防御长度扩展攻击等问题。
      • 对于 A 的文件命名场景,虽然长度扩展攻击本身可能不是直接威胁,但这再次暴露了他试图“自己动手”解决一个已有标准方案的问题。正确做法就是使用 HMAC-SHA256。

    问题 6: xxHash3 的 seed 可以代替盐吗?

    • A 的提议: 用 xxHash 自带的 seed 功能是不是就不用自己加盐了?
    • B 的回答: “见 1…盐是散列函数索引…的实践表现…使用独立的 seed…没有‘再加盐’的必要。”
    • 专家解读:
      • B 的回答分为两层:
        1. “见 1”: 既然 xxHash 本身不安全,讨论如何“安全地”使用它的 seed 就没有意义了。这是根本问题。
        2. 但是,从理论上讲:B 的第二部分解释非常到位。在一个设计良好的哈希函数中,seedkey 参数就是实现“加盐”功能的正确方式。它被设计为算法初始状态的一部分,比手动在外部拼接更安全、更高效。
      • 所以,如果我们在使用一个密码学安全的哈希函数(比如 BLAKE3),那么使用它自带的 key 参数就是最理想的“加盐”方式,不需要再手动拼接。A 的问题本身是好的,只是用错了对象。

    总结与正确方案

    用户 A 的所有问题都源于一个错误的前提:试图用非安全工具解决安全问题。

    解决 A 的原始需求的正确方案是什么?

    他需要一个快速、安全、能加盐的哈希算法来为文件命名。

    方案一:行业标准 (The Gold Standard)

    使用 HMAC-SHA256。它绝对安全,被广泛应用和审查。速度对于单个文件命名来说完全足够。

    # 'secret_key' 应该是你保密的、随机生成的长字符串
    # 'input_file' 是用户上传的文件
    openssl dgst -sha256 -hmac "secret_key" input_file

    方案二:现代、高性能的选择 (The Best of Both Worlds)

    使用 BLAKE3。这是一个现代的加密哈希算法,它:

    1. 极其快速:速度与 xxHash 相当,远超 SHA256。
    2. 密码学安全:由顶级密码学家设计,安全性与 SHA-256 同级。
    3. 内置 keyed hashing 模式:这正是 A 想要的 seed 功能,是实现“加盐”的完美方式。

    这完美地满足了 A 对速度和安全性的所有要求。

  • 排查 duckdb-node-neo 并发查询时的随机段错误

    问题的说明跟复现请看这里,这篇文章主要记录一下如何排查 node addon 代码引发的问题。

    保存进程崩溃的翻车现场

    在 Linux/KDE 环境下,当应用进程崩溃的时候,systemd 会记录下该进程的翻车现场(aka,coredump)。我们可以使用 coredumpctl 这个工具查看当前机器上发生过的 coredump。

      List all captured core dumps:
    
          coredumpctl
    
      List captured core dumps for a program:
    
          coredumpctl list program
    
      Show information about the core dumps matching a program with `PID`:
    
          coredumpctl info PID
    
      Invoke debugger using the last core dump:
    
          coredumpctl debug
    
      Invoke debugger using the last core dump of a program:
    
          coredumpctl debug program
    
      Extract the last core dump of a program to a file:
    
          coredumpctl --output path/to/file dump program
    
      Skip debuginfod and pagination prompts and then print the backtrace when using `gdb`:
    
          coredumpctl debug --debugger-arguments "-iex 'set debuginfod enabled on' -iex 'set pagination off' -ex bt"

    还原翻车现场

    通过上面的步骤获取到 coredump 后,可以搭配 debugger 工具跟 symbol file 定位出问题的代码。我选择的 debugger 工具是 gdb 搭配 CLion 作 UI。这样只要将 node addon 项目用 CLion 打开,并导入 coredump 后,就可以获得一个功能齐全的 GUI Debugger。

    用 CLion 打开 node addon 项目

    以 duckdb-node-neo 为例,这个项目使用 node-gyp 作为构建工具,而 CLion 只支持 CMAKE 工程,所以需要手动创建一份 CMakefile 文件,让 IDE 的智能提示正常工具,方便在 debug 的过程中查看定义、搜索引用等。

    下面的这份 CMake 文件是我跟 Claude 一起编写的,不一定能够通过 IDE 编译 node addon,但是可以提供智能补全。

    cmake_minimum_required(VERSION 3.10)
    project(duckdb_node_neo)
    
    set(CMAKE_CXX_STANDARD 17)
    
    # include node headers
    include_directories(/usr/include/node)
    
    # Node.js addon API headers
    include_directories(${CMAKE_SOURCE_DIR}/bindings/node_modules/node-addon-api)
    
    # DuckDB headers
    include_directories(${CMAKE_SOURCE_DIR}/bindings/libduckdb)
    
    # Add source files
    file(GLOB SOURCE_FILES
        "bindings/src/*.cpp"
    )
    
    # This is just for CLion's code intelligence - not for actual building
    add_library(duckdb_node_bindings SHARED ${SOURCE_FILES})
    
    # Define preprocessor macros used in the code
    add_definitions(-DNODE_ADDON_API_DISABLE_DEPRECATED)
    add_definitions(-DNODE_ADDON_API_REQUIRE_BASIC_FINALIZERS)
    add_definitions(-DNODE_API_NO_EXTERNAL_BUFFERS_ALLOWED)
    
    # Add Node.js specific definitions to help CLion's IntelliSense
    add_definitions(-DNAPI_VERSION=8)
    
    # Tell CLion to treat this as a Node.js addon project
    set_target_properties(duckdb_node_bindings PROPERTIES
        CXX_STANDARD 17
        PREFIX ""
        SUFFIX ".node"
    )

    生成带有 symbol 的二进制

    设置后 debugger 后,还需要提供一个 symbol 文件,帮助 debugger 定位代码位置。对于 node-gyp 而言,只需要在编译时设置 --debug 参数即可生成带有 symbol 的二进制。

    cross-replace node-gyp configure --verbose --arch=$TARGET_ARCH --debug && node-gyp build --verbose --arch=$TARGET_ARCH --debug

    接下来我们只要用带有 symbol 的二进制复现进程崩溃的问题,就可以得到一个方便 debug 的 coredump。

    定位问题

    通过 debug coredump 文件,可以很容易找到抛出错误的地方,但这次很不走运的是, error 变量是 NULL,没有提供任何有价值的信息。另外,可以发现 result_ptr_ 似乎处于一个没有被初始化的状态。

    查看 duckdb 的源码后发现,虽然 duckdb 对外提供的是 C API,但是其本身是用 C++ 开发的,在将 C++ 操作以 C API 的形式暴露时,duckdb 会捕获内部的异常,然后将其中的错误信息写入到 duckdb_result 中。

    目前 coredump 表明 result_ptr_ 未被 duckdb 正常初始化,而异常对象很可能已经在跳出 catch 作用域时被销毁,现在只能在运行时 debug 才能捕获到具体的错误信息了。

    附加到进程上 debug

    CLion 提供了非常好用的附加到未启动进程上 debug 的功能,只需要向 CLion 提供进程的 commandline,它就可以自动将 debugger 附加到该进程上。

    在开始 debug 之前,还需要设置一下 C++ 异常断点:

    当启动问题进程,并复现崩溃后,就可以在 debugger 视图查看到实时的异常对象信息:

    这里的 obj 就是异常对象,但是由于 CLion 没有识别出异常的类型,我们得手动用 gdb 指令查看 obj 的信息。

    通过 gdb 的输出,可以看到异常对象的类型是 duckdb::InvalidInputException

    这时在变量页面输入下面的指令就行:

    *(duckdb::InvalidInputException*)obj

    终于,更加具体的错误信息被挖掘出来了~

  • 跟 LLM 一起折腾 Web Audio

    最近在开发一个与 TTS(Text-to-Speech)相关的功能,其中需要对播放的人声音频进行加速或减速处理。这个领域我之前完全没有接触过,DeepSeek 推荐我使用 Howler.js 和 Tone.js 这样的库来播放音频。

    在实现加速功能时,我发现需要修改 playbackRate,但通常 playbackRate 和音调(Pitch)的修改是联动的。然而,Howler.js 并没有提供直接修改 Pitch 的方法。在折腾了很久之后,DeepSeek 也没能解决 Tone.js 带来的噪音问题。使用在线处理(Pitch Shift 效果器)时,会产生“dadadada”的噪音;而使用离线处理(GrainPlayer)时,又会出现类似回声的杂音。

    最终,我查到 HTML5 的 Audio Element 提供了 preservePitch 的功能,这似乎是一个比较理想的解决方案。

    一些经验总结

    1. 代码生成与抽象
      即使通过 LLM 生成代码的成本较低,也尽量让它生成易于拓展和复用的代码。在我的尝试中,即便切换了多个不同的音频库,我都不需要修改业务代码,这正是因为我在最初就让 LLM 对音频处理部分进行了足够的抽象。
    2. LLM 在处理开放性问题时的局限性
      如果待解决的开放性问题足够普遍,LLM 通常能给出令人满意的答案。但对于像 Web Audio 这样 API 复杂且不太常见的技术领域,LLM 可能就很难解决问题了。例如,在解决 Pitch Shift 效果器的噪音问题时,DeepSeek 很难坚定地指出 Tone.js 的实现本身就存在缺陷(事实上也是如此)。这大概率是因为 LLM 只是一个互联网文本预测器,容易产生“幻觉”,尤其是在处理小众领域的问题时。

    总结

    在开发过程中,抽象和代码复用是非常重要的,尤其是在处理新技术领域时。虽然 LLM 在很多常见问题上表现出色,但在处理复杂或小众领域的问题时,仍需要结合人工经验和深入的技术调研来找到更优的解决方案。

  • 工欲善其事,如何利其器?CLI 篇

    工欲善其事,必先利其器,这篇文章将从 CLI 工具这个方面向你介绍“如何提高在 CLI 环境下的工作效率”。

    Shell

    Shell 是一个用来提供与操作系统内核(Kernel)交互的软件。

    常见的 Shell 分为两种:

    • Command Line Interface (CLI) 命令行界面
    • Graphic User Interface (GUI) 图形用户界面

    通常情况下,Shell 主要是指 CLI 程序,常见的 CLI shell 有 Bash、Zsh、Fish、PowerShell、Cmd 等。

    为什么会有 CLI Shell ?

    早期的计算机主机存放在专门的机房中,用户想要操作计算机需要通过终端(Terminal)连接,终端就是计算机的输入输出设备。最早的终端无法显示图像,只能通过屏幕跟键盘展示、输入字符,所以最早的 Shell 软件就以 CLI 的形式问世了。

    随着技术的进步发展,只能用来传输文本的终端设备几乎都被显示器替代了,GUI 取代了 CLI 的在人机交互方面的主要地位。

    CLI 优势

    CLI 最大的优势在于可编程,CLI shell 通常也是一个脚本语言解释器,可以通过编写 Shell 脚本来自动化完成一些复杂、重复的操作,这是 GUI 难以比拟的。

    在 GUI 成为主要人机交互界面的今天,终端模拟器(Terminal Emulator)是我们操作 CLI 的必备工具。一个优秀的终端模拟器通常会提供下面几个功能:

    • 多 Tab 切换
    • 窗口分屏
    • 搜索命令行输出(ScrollBack)
    • 自动识别链接

    这些功能也是提高 CLI 使用效率的必备功能,如果你的终端模拟器不支持这些功能,我推荐你使用下面的这些软件:

    • Windows
      • Windows Terminal
      • ConEmu
    • macOS
      • iTerm2
    • Linux
      • Konsole
      • Kitty

    我非常建议你设置 Tab 切换跟窗口分屏快捷键,这些操作可以极大地提升你使用 CLI 时的工作效率。

    设置一个用户友好的 CLI Shell

    Fish Shell 是一个智能的、用户友好的 Shell 程序。相比 Bash,它有如下优点:

    • 自动补全
    • 简单易学的脚本语法
    • 基于 Man 的智能提示与补全
    • 开箱即用,只要安装上就能够获得生产力的提升

    安装 Fish

    不建议将默认 Shell 修改为 Fish,因为 Fish 不遵循 POSIX Shell 规范,不一定跟其他的程序兼容,更好的做法是将兼容 POSIX 的 Shell 作为默认 Shell,然后在终端模拟器中将默认启动的 Shell 设置为 Fish。

    CLI 好伙伴

    • man CLI 程序说明书
    • bat 文本文件查看工具
    • tldr man 手册太长不看,就看 TLDR
    • fd 文件搜索工具
    • fuck 智能的命令行纠错工具
    • exa 更加友好的 ls 替代品

    进一步提升 Fish 的易用性

    • Fisher Fish 插件管理器
    • fzf fzf 命令行模糊搜索程序
    • z 智能的目录跳转工具
    • starship 高性能的 Shell 提示符

    fzf

    通过 fisher 安装 PatrickF1/fzf.fish

    • ctrl+r 搜索历史
    • ctrl+alt+f 从当前目录开始搜索文件,按 Tab 可以多选

    更多快捷键可以查看 GitHub Readme

    常用快捷键

    function fish_user_key_bindings
        # 使用 Ctrl + F 补全整行
        bind -M insert \cf accept-autosuggestion
        bind \cf accept-autosuggestion
    end
    • alt+. 将上个命令的最后一部分填到当前命令行末尾
    • alt+s 用 sudo 执行上条命令

    更多快捷键请查看文档

    私人订制,打造合适自己的 CLI 工具

    alias 给常用的命令设置别名,方便快速输入。

    funced/funcsave/function function 比 alias 更加完善,可以用来封装更加复杂的操作。

  • 什么时候应该避免 barrel import

    TL;DR
    不要从导出当前模块的 barrel file 导入其他模块。

    什么是 Barrel file

    用来重新导出当前目录下的子模块,缩短外部 import 路径,通常命名为 index.ts

    DO NOT

    上图存在循环依赖,如果 pluign-c 尝试在顶层访问 PluginB,有可能会得到 undefined,这取决于 plugin-b、plugin-c 在 barrel 中的顺序

    DO

  • 给 cypress 添加 todo

    jest 提供的 test.todo 功能在实践 TDD 的时候非常实用,但是 cypress 却没有提供类似的功能。经过简单的搜索,找到了一个 workaround。

    const test = it;
    test.todo = it.skip;
    global.test = it;

    接着就可以在测试代码中愉快地使用 test.todo 了。

  • 「推敲代码」如何为回调函数设计 API

    TLDR

    在设计回调函数形式的扩展点时,扩展点的默认行为也要提供给调用方,方便基于默认行为进行扩展。达到这一目的的模式有:

    1. 把回调函数提取成单独的类,把默认行为作为基类方法提供给调用方
    2. 把默认行为作为回调函数的参数提供给调用方

    推敲过程

    Alice:Hi,Bob,我有一个重构代码的需求,能帮我看看吗?

    Bob:没问题,Show me your code 。

    Alice:之前的代码是这样的

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params) {
        // do something
        this.fooBehavior();
        // finally
        this.update();
      }
    }

    现在,我需要允许调用方修改 changeState 要执行的行为

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params, behaviorType?: string) {
        // do something
        if (behaviorType === 'A') {
          // do behavior A
        } else if (behaviorType === 'B') {
          // do behavior B
        } else {
          this.fooBehavior();
        }
        // finally
        this.update();
      }
    }

    Bob:根据以往的经验,这里的 if-else 很可能会变成叠 buff 的屎山,建议把 behaviorType 换成一个回调函数,让调用方决定这里应该干些啥。

    Alice:改好了,你再看看?

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params, onChangeState?: () => void) {
        // do something
        if (onChangeState) {
          this.onChangeState()
        } else {
          this.fooBehavior();
        }
        // finally
        this.update();
      }
    }

    Bob:这里还有些问题,调用方只能覆盖默认行为,并不能在默认行为的基础之上做扩展。

    Alice:调用方也可以自己手动调用 fooBehavior 啊,这也是个公共 API 。

    Bob:但是调用方必须得翻看 changeState 的具体实现才知道「fooBehavioronChangeState 的默认实现,这显然是不合理的。

    Alice:在使用这个类时翻看源码很正常,因为这是一个非常高级晦涩的 API,不翻看源码根本不清楚要用它来干什么。

    Bob:OK,就算调用方在拓展这个地方的时候来查看了 onChangeState 的默认实现,然后把这里的代码复制过去来基于默认行为做扩展。但是,随着版本更新迭代,MyClass 中的默认实现可能会发生变更,这个时候,要怎么通知这些依赖默认行为的调用方更新呢?

    Alice:好吧,这样看来确实是个问题。

    Bob:我觉得这个时候可以把 onChangeState 提取成一个单独的类,它是这个扩展点的基类。调用方在进行扩展的时候,可以通过基类的方法来调用默认行为。

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params, changeStateHook: ChangeStateHook = new ChangeStateHook()) {
        // do something
        changeStateHook.after(this);
        // finally
        this.update();
      }
    }
    class ChangeStateHook {
      after(instance: MyClass) {
        instance.fooBehabior();
      }
    }
    class ConsumerChangeStateHook extends ChangeStateHook {
      after(instance: MyClass) {
        if(/* some case */) {
          super.after();
        } else {
          // do something else
        }
      }
    }

    Alice:这样倒是可以满足你在上面提到的需求,但是代码看起来也太阵仗了。为了自定义这个回调函数,还得专门声明一个类!我觉得用个字段来存储默认行为,然后在注释上标明就足够了。

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public defaultOnChangeState() {
        this.fooBehavior();
      }
      /**
       * 默认 onChangeState 是 defaultOnChangeState
       */
      public changeState(params, onChangeState?: () => void) {
        // do something
        if (onChangeState) {
          this.onChangeState();
        } else {
          this.defaultOnChangeState();
        }
        // finally
        this.update();
      }
    }

    Bob:这样做确实轻量很多,但并不是一个好的「模式」。如果后面这种拥有默认行为的回调函数越来越多, MyClass 上就会有很多 defaultOnXxxx ,看起来就很糟心。不如把默认行为作为回调函数的参数提供给调用方。

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params, onChangeState?: (defaultOnChangeState: () => void) => void) {
        // do something
        const defaultOnChangeState = () => {
          this.fooBehavior();
        }
        if (onChangeState) {
          this.onChangeState(defaultOnChangeState);
        } else {
          defaultOnChangeState();
        }
        // finally
        this.update();
      }
    }

    Alice:看起来很不错,跟我写的第一版回调函数形式 API 相比没有多几行代码,但是却有更加灵活的扩展空间。

    Bob:好的,最后来总结一下:

    • 回调函数的默认行为需要作为 API 的一部分提供给调用方,这样调用方可以在不查看具体实现的情况下,基于默认行为做扩展。
    • 把回调函数提取成类,通过类的继承,可以将默认行为作为基类的方法提供给调用方。
    • 一个不使用类的更轻量的做法,将默认行为作为回调函数的参数提供给调用方。
Index