普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月13日首页

为什么 Memo Code 先做 CLI:以及终端输入框到底有多难搞

作者 mCell
2026年2月12日 23:58

如果你对我的 Code Agent项目感兴趣,可以看这里:

Github Repo: Memo Code - Github

站点:Memo Web Site

大概四年前,我刚接触编程。学的是 C 语言,第一个程序当然是 hello world。

很简单,几行就写完。run 一下,弹出来一个 terminal(我已经忘了当时用的是什么:cmd?PowerShell?反正不重要),然后打印了一行:

“hello, world!”

从那以后,在我还没接触前端之前,我写的所有程序几乎都靠终端完成输入输出:猜数字、九九乘法表,再到后来刷算法,基本就是——写、跑、看终端、改、再跑。

那时候倒也没觉得有什么问题,只是偶尔会突然有点空虚: 难道以后我工作的成果,就是一直对着这个黑框框吗?

第一种认知:终端就是编程的全部

一开始我对“程序结果形态”的理解非常单一:

输入 → 运行 → 终端输出

终端就是一切:日志、交互、结果展示,全在那儿。 它很直接,很原始,也很“学生气”。

第二种认知:页面才是“程序结果”的另一种世界

后来我接触了前端。直到靠前端拿到第一份实习、第一份工作,我才对“程序结果形态”形成了第二种认知:

不仅仅是终端里几行日志,也可以是页面效果、动画、交互。 之后陆续做过小程序、App、桌面程序……那段时间里,终端更像“开发过程的工具”,而不是“产品本体”。

也就是从那时候开始,我对 terminal 的误解更深了: 它好像就应该是黑框框 + 命令 + 日志,仅此而已。

第三种认知:原来 terminal 也可以玩得这么花

25 年下半年,自从 Gemini CLI 这类东西开始出现之后,我对“程序结果形态”有了第三种认知:

原来 terminal 也可以玩得这么花。

彩色输出、输入框、选择框、进度条……该有的都有。 当时我没怎么深入研究,只是隐约意识到:以前我对 terminal 的理解偏了,它并不等于“只能打印”。

现实把我拉回来了:写 Agent,界面到底做什么?

后面开始做值班,又不得不把 Linux 命令捡起来:从 pwd / cat / tail / find,到 vi / vim……慢慢也熟练了。

直到最近两个月,我开始认真做我的 code agent:memo github.com/minorcell/m…

等我写好了 MVP,写好了 runtime,写好了 toolrouter、tools 等;下一步突然被一个看起来很“产品”、但本质上很“工程”的问题卡住了:

界面到底做什么? 传统 Web 页面?还是终端交互?

如果做传统 Web UI,我其实很拿手:加一个 HTTP server 包,再来个 Web UI 包就够了。 但现实是,市面上大多数 code agent(比如 claude code cli、codex cli)都是从终端交互做起的。后续再补 VSCode 插件、桌面版,甚至浏览器插件。

题外话:这里也不得不感慨一下——原来我大前端确实挺“六”的:只要界面能画出来,基本都能做。也更坚定一个想法:AI 时代,大前端技术只会更普及。

最终,可能是“理所当然”,也可能是对陌生技术栈的兴趣使然,我决定: memo code 的第一种产品形态,先做终端 CLI。

选 Ink:看起来都正常,直到输入框

调研开源的 Gemini CLI 时,我发现他们用的是 Ink(React for CLI)。我也就直接跟了:选 Ink。

一开始真的很顺:

  • 会话记录渲染没问题
  • slash 指令也能做
  • 封装组件库也舒服

似乎都挺好……直到我碰到最难的一块:输入框。

以前做 Web app:

  • 单行用 input
  • 多行用 textarea

天然、顺滑、毫无心理负担。

但在终端里,多行输入并不是默认就“应该支持”的体验。甚至 Ink 的 input 组件,也只有单行。

这时候你才会意识到:在终端里,“输入框”不是 UI 控件,它更像是一个小型编辑器。

我以为我解决了,结果只是解决了“最简单的部分”

我一开始尝试的方案很朴素,比如:

  • Shift + Enter 插入换行符

表面看起来能用了。 但很快更真实的问题出现了:粘贴文本。

粘贴一段文本时,你会遇到:

  • 显示残缺
  • 粘贴后光标位置不对
  • 输入状态偶尔乱跳

这时候我才明白:我不是在做“多行 input”,我是在终端里硬写一个“半个 textarea”。

如果对照二八法则:掌握 20% 的技术,就能做出 80% 的功能。

但要把剩下 20% 做好,往往需要补齐另外 80% 的细节。

终端交互就是这样:你很快能做出一个“能用”的 CLI;但要做得像样,细节多到离谱。

于是我最后认真设计了一套方案(写在这个 issue 里): github.com/minorcell/m…

解决方案:在 Ink 里做一个“可控的多行编辑器内核”

我最后没有继续纠结“有没有更好的 input 组件”,而是换了一个思路:

把多行输入当成一个小型编辑器来做。 在 Ink 的限制里,把“编辑状态”和“渲染”解耦,然后在输入事件层做适配。

整个方案我拆成三个核心模块。

编辑器状态管理层

我不再把输入框当成“一个字符串”,而是当成一个状态机。

核心结构就是:

  • value: string(当前文本)
  • cursor: number(光标在文本中的位置)

听起来很简单,但一旦涉及多行、上下移动、终端折行,坑就开始密集出现:

  • 光标移动要能跨行
  • 上下键移动不能乱跳(要记住“我想待在哪一列”)
  • Unicode 也得小心:emoji / 代理对如果按字符串下标移动,光标很容易卡在“半个字符”上
  • 所以要做 clamp,保证 cursor 永远落在合法边界

这一层的目标只有一个: 不管 Ink 怎么渲染,我内部都能稳定得到“当前文本是什么 + 光标在哪里”。

粘贴检测

真正让我没绷住的,其实是粘贴。

终端里粘贴一坨文本时,底层输入事件会被拆成很多个 keypress,然后 Ink / 渲染层每次都会触发更新。你会遇到一种非常诡异的现象:

你粘贴的是 A,但 UI 看起来像是 A 的碎片; 光标也像在“追不上输入”,最后漂到一个你完全无法理解的位置。

所以我做了一个“粘贴 burst 检测”:用启发式规则把粘贴从普通输入里识别出来,然后改成 缓冲 + 批量插入

  • 时间间隔规则(主机制):字符到达间隔 < 8ms 基本视为粘贴(人不可能这么快)
  • 字符数量规则(备用机制):连续字符 ≥ 16 时也按粘贴处理(对中文/emoji 路径更稳)
  • 识别到粘贴后进入状态机:pending → active → flush 先塞 buffer,等“粘贴结束”再一次性写入 value,避免每个字符都触发一轮复杂计算

这一步做完之后,“粘贴残缺 / 光标乱跳”基本从玄学变成可控问题了。

输入处理适配器:快捷键 + 换行策略 + 视觉换行

终端输入要像编辑器,光靠“插入字符”是不够的,你还得补齐肌肉记忆:

  • 支持常见快捷键(Ctrl+A / Ctrl+E / Ctrl+U / Ctrl+K / Ctrl+W 这类)
  • 换行与提交要分开:
    • Shift + Enter 永远插入新行
    • Enter 默认提交
    • 但如果处在粘贴期间(或粘贴后的短窗口期),Enter 当作插入新行
      • 防止用户“粘贴完顺手一回车”直接把消息提交出去了(这个真的很常见)

还有一个关键点:逻辑行 vs 视觉行分离

  • 逻辑行:真正的 \n
  • 视觉行:终端宽度导致的自动折行

编辑用逻辑行,展示按视觉行计算,这样长段落在不同宽度终端也能保持一致体验。

同时,视觉换行还要能响应终端 resize(不然窗口一变宽/变窄,光标又漂移)。

这一层本质上就是: 把终端输入从“能打字”推到“像个 textarea”。

念头通达,交给 codex 快速帮我实现了一个版本。

结果:我解决了剩下 20% 里最烦的 15%

这套方案不可能一把梭把所有边界问题抹平。 不同终端模拟器、不同输入法路径、极端大文本性能……仍然需要持续打磨。

但至少到这里,我觉得我把剩下 20% 里最难受、最影响体验的那 15% 解决掉了:

  • 多行输入稳定
  • 粘贴不再玄学
  • 光标不再乱飞
  • Enter / Shift+Enter 行为可控

收尾:终端不只是输入输出,它可以是简易版 Web App

memo 的这段实践,让我对终端交互有了更清晰的认知:

它不再只是我最开始学编程时那种“输入输出 + 打日志”。 它完全可以是简易版本的 Web App:有组件、有状态、有布局,甚至能长出一点“编辑器”的味道。

这感觉有点像当年最早的 HTML 刚出来时:朴素、克制,但足够表达。 而我现在做的,就是在这个黑框框里,把“能表达的东西”再往前推一点点。

如果你对我的 Code Agent项目感兴趣,可以看这里:

Github Repo: Memo Code - Github

站点:Memo Web Site

构建无障碍组件之Dialog Pattern

作者 anOnion
2026年2月12日 22:59

Dialog (Modal) Pattern 详解:构建无障碍模态对话框

模态对话框是 Web 应用中常见的交互组件,用于在不离开当前页面的情况下展示重要信息或获取用户输入。本文基于 W3C WAI-ARIA Dialog Pattern 规范,详解如何构建无障碍的模态对话框。

一、Dialog 的定义与核心功能

Dialog(对话框)是覆盖在主窗口或其他对话框之上的窗口。模态对话框会阻断用户与底层内容的交互,直到对话框关闭。底层内容通常会被视觉遮挡或变暗,以明确当前焦点在对话框内。

与 Alert Dialog 不同,普通 Dialog 适用于各种需要用户交互的场景,如表单填写、信息展示、设置配置等。它不强调紧急性,用户可以自主决定是否与之交互。

二、Dialog 的关键特性

模态对话框具有以下核心特性:

焦点限制:对话框包含独立的 Tab 序列,Tab 和 Shift+Tab 仅在对话框内循环,不会移出对话框外部。

背景禁用:对话框背后的内容处于 inert 状态,用户无法与之交互。尝试与背景交互通常会导致对话框关闭。

层级管理:对话框可以嵌套,新的对话框覆盖在旧对话框之上,形成层级结构。

三、WAI-ARIA 角色与属性

3.1 基本角色

role="dialog" 是对话框的基础角色,用于标识模态或非模态对话框元素。

aria-modal="true" 明确告知辅助技术这是一个模态对话框,背景内容当前不可用。

3.2 标签与描述

aria-labelledby 引用对话框标题元素,为对话框提供可访问名称。

aria-describedby 引用包含对话框主要内容的元素,帮助屏幕阅读器用户理解对话框目的。

<dialog
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <h2 id="dialog-title">用户设置</h2>
  <p id="dialog-desc">请配置您的个人偏好设置。</p>
  <!-- 对话框内容 -->
</dialog>

四、键盘交互规范

4.1 焦点管理

打开对话框时:焦点应移动到对话框内的某个元素。通常移动到第一个可聚焦元素,但根据内容不同可能有不同策略:

  • 内容包含复杂结构(列表、表格)时,可将焦点设置在内容的静态元素上,便于用户理解
  • 内容较长时,将焦点设置在标题或顶部段落,避免内容滚动出视野
  • 简单确认对话框,焦点可设置在主要操作按钮

关闭对话框时:焦点应返回到触发对话框的元素,除非该元素已不存在。

4.2 键盘操作

按键 功能
Tab 移动到对话框内下一个可聚焦元素,到达末尾时循环到第一个
Shift + Tab 移动到对话框内上一个可聚焦元素,到达开头时循环到最后一个
Escape 关闭对话框
// 焦点管理示例
function openDialog(dialog, triggerElement) {
  dialog.triggerElement = triggerElement;
  dialog.showModal();

  // 将焦点设置到第一个可聚焦元素或标题
  const focusable = dialog.querySelector(
    'button, [href], input, select, textarea',
  );
  if (focusable) {
    focusable.focus();
  }
}

function closeDialog(dialog) {
  dialog.close();
  // 恢复焦点到触发元素
  if (dialog.triggerElement) {
    dialog.triggerElement.focus();
  }
}

五、实现方式

5.1 原生 dialog 元素

HTML5 <dialog> 元素是推荐实现方式,内置模态行为和无障碍支持:

  • 自动焦点管理showModal() 自动将焦点移动到对话框内第一个可聚焦元素
  • 内置 ESC 关闭:用户按 ESC 键自动关闭对话框
  • 自动模态背景:自动创建背景遮罩,阻止与底层内容交互
  • 焦点循环:Tab 键在对话框内自动循环,不会移出对话框
  • 内置 ARIA 属性:浏览器自动处理 aria-modal 等属性
  • Top Layer 支持:模态对话框显示在浏览器顶层,不受 z-index 限制
<dialog
  id="settings-dialog"
  aria-labelledby="dialog-title">
  <div class="dialog-header">
    <h2 id="dialog-title">设置</h2>
    <button
      onclick="this.closest('dialog').close()"
      aria-label="关闭"></button>
  </div>
  <div class="dialog-content">
    <label>
      用户名
      <input type="text" />
    </label>
  </div>
  <div class="dialog-footer">
    <button onclick="this.closest('dialog').close()">取消</button>
    <button onclick="saveSettings()">保存</button>
  </div>
</dialog>

<button onclick="document.getElementById('settings-dialog').showModal()">
  打开设置
</button>

5.2 div + ARIA 实现

需要手动处理焦点管理和背景交互。这种方式适用于需要自定义动画、复杂布局或旧浏览器兼容的场景:

<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  class="modal-overlay">
  <div class="modal-content">
    <h2 id="dialog-title">确认操作</h2>
    <p>确定要执行此操作吗?</p>
    <button>取消</button>
    <button>确认</button>
  </div>
</div>

六、最佳实践

初始焦点策略和键盘交互的详细规范请参考 4.1 焦点管理。在实际应用中,建议遵循以下策略:

  • 信息展示:焦点设置在标题或内容开头,便于屏幕阅读器顺序阅读
  • 表单输入:焦点设置在第一个输入框
  • 确认操作:焦点设置在主操作按钮或取消按钮(视风险而定)

6.1 关闭方式

提供多种关闭方式提升用户体验:

  • ESC 键关闭(原生 dialog 自动支持)
  • 关闭按钮
  • 点击背景遮罩关闭(可选)
  • 明确的取消/确认按钮

6.2 嵌套对话框

支持多层对话框嵌套,每层新对话框覆盖在上层:

<dialog id="layer1">
  <button onclick="document.getElementById('layer2').showModal()">
    打开第二层
  </button>
</dialog>

<dialog id="layer2">
  <p>第二层对话框</p>
</dialog>

6.3 避免滥用

对话框会中断用户流程,应谨慎使用:

  • 优先使用非模态方式展示非关键信息
  • 避免对话框内再嵌套复杂导航
  • 保持对话框内容简洁,避免过多滚动

七、Dialog 与 Alert Dialog 的区别

特性 Dialog Alert Dialog
用途 一般交互、表单、配置 紧急确认、警告、错误
紧急性 非紧急 紧急,需立即响应
关闭方式 多种方式 通常只有确认/取消
角色 role="dialog" role="alertdialog"
系统提示音 可能有

八、总结

构建无障碍的模态对话框需要关注三个核心:正确的 ARIA 属性声明、合理的焦点管理、完整的键盘交互支持。原生 <dialog> 元素简化了实现,但开发者仍需理解无障碍原理,确保所有用户都能顺利使用。

遵循 W3C Dialog Pattern 规范,我们能够创建既美观又包容的对话框组件,为不同能力的用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

二叉搜索树(BST)

作者 NEXT06
2026年2月12日 22:46

1. 引言:为什么我们需要二叉搜索树?

在计算机科学中,数据存储的核心诉求无非两点:高效的查找高效的修改(插入/删除) 。然而,传统的线性数据结构很难同时满足这两点:

  • 数组(Array) :支持 O(1)的随机访问,查找效率极高(配合二分查找可达 O(log⁡n)),但插入和删除元素往往需要移动大量后续元素,时间复杂度为 O(n)

  • 链表(Linked List) :插入和删除仅需修改指针,时间复杂度为 O(1) (已知位置的前提下),但由于无法随机访问,查找必须遍历链表,时间复杂度为 O(n)

二叉搜索树(Binary Search Tree, BST)  的诞生正是为了解决这一矛盾。它结合了链表的高效插入/删除特性与数组的高效查找特性,在平均情况下,BST 的所有核心操作(查找、插入、删除)的时间复杂度均能维持在 O(log⁡n) 级别。

2. 核心定义与数据结构设计

2.1 严格定义

二叉搜索树(又称排序二叉树)或者是一棵空树,或者是具有下列性质的二叉树:

  1. 若它的左子树不空,则左子树上所有节点的值均小于它的根节点的值。
  2. 若它的右子树不空,则右子树上所有节点的值均大于它的根节点的值。
  3. 它的左、右子树也分别为二叉搜索树。

注意:本文讨论的 BST 默认不包含重复键值。在工程实践中,若需支持重复键,通常是在节点中维护一个计数器或链表,而非改变树的拓扑结构。

2.2 数据结构设计 (JavaScript)

JavaScript

class TreeNode {
    constructor(val) {
        this.val = val;
        this.left = null;
        this.right = null;
    }
}

3. 核心操作详解与代码实现

3.1 查找(Search)

查找是 BST 最基础的操作。其逻辑类似二分查找:比较目标值与当前节点值,若相等则命中;若目标值更小则转向左子树;若目标值更大则转向右子树。

递归实现与风险

递归实现代码简洁,符合树的定义。但在深度极大的偏斜树(Skewed Tree)中,可能导致调用栈溢出(Stack Overflow)。

迭代实现(推荐)

在生产环境或对性能敏感的场景下,推荐使用迭代方式,将空间复杂度从 O(h) 降至 O(1)

JavaScript

/**
 * 查找节点 - 迭代版
 * @param {TreeNode} root 
 * @param {number} val 
 * @returns {TreeNode | null}
 */
function searchBST(root, val) {
    let current = root;
    while (current !== null) {
        if (val === current.val) {
            return current;
        } else if (val < current.val) {
            current = current.left;
        } else {
            current = current.right;
        }
    }
    return null;
}

3.2 插入(Insert)

插入操作必须保持 BST 的排序特性。新节点总是作为叶子节点被插入到树中。

实现逻辑
利用递归函数的返回值特性来重新挂载子节点,可以避免繁琐的父节点指针维护。

JavaScript

/**
 * 插入节点
 * @param {TreeNode} root 
 * @param {number} val 
 * @returns {TreeNode} 返回更新后的根节点
 */
function insertIntoBST(root, val) {
    if (!root) {
        return new TreeNode(val);
    }
    if (val < root.val) {
        root.left = insertIntoBST(root.left, val);
    } else if (val > root.val) {
        root.right = insertIntoBST(root.right, val);
    }
    return root;
}

3.3 删除(Delete)—— 核心难点

删除操作是 BST 中最复杂的环节,因为删除中间节点会破坏树的连通性。我们需要分三种情况处理:

  1. 叶子节点:没有子节点。直接删除,将其父节点指向 null。

  2. 单子节点:只有一个左子节点或右子节点。“子承父业”,直接用非空的子节点替换当前节点。

  3. 双子节点:既有左子又有右子。

    • 为了保持排序特性,必须从其子树中找到一个节点来替换它。
    • 策略 A(前驱):找到左子树中的最大值
    • 策略 B(后继):找到右子树中的最小值
    • 替换值后,递归删除那个前驱或后继节点。

JavaScript

/**
 * 删除节点
 * @param {TreeNode} root 
 * @param {number} key 
 * @returns {TreeNode | null}
 */
function deleteNode(root, key) {
    if (!root) return null;

    if (key < root.val) {
        root.left = deleteNode(root.left, key);
    } else if (key > root.val) {
        root.right = deleteNode(root.right, key);
    } else {
        // 找到目标节点,开始处理删除逻辑
        
        // 情况 1 & 2:叶子节点 或 单子节点
        // 直接返回非空子树,若都为空则返回 null
        if (!root.left) return root.right;
        if (!root.right) return root.left;

        // 情况 3:双子节点
        // 这里选择寻找“后继节点”(右子树最小值)
        const minNode = findMin(root.right);
        
        // 值替换:将后继节点的值复制给当前节点
        root.val = minNode.val;
        
        // 递归删除右子树中的那个后继节点(此时它必然属于情况 1 或 2)
        root.right = deleteNode(root.right, minNode.val);
    }
    return root;
}

// 辅助函数:寻找最小节点
function findMin(node) {
    while (node.left) {
        node = node.left;
    }
    return node;
}

4. 性能瓶颈与深度思考

4.1 时间复杂度分析

BST 的操作效率取决于树的高度 h

  • 平均情况:当插入的键值是随机分布时,树的高度接近 log⁡nlogn,此时查找、插入、删除的时间复杂度均为 O(log⁡n)

  • 最坏情况:当插入的键值是有序的(如 1, 2, 3, 4, 5),BST 会退化为斜树(本质上变成了链表)。此时树高 h=n,所有操作的时间复杂度劣化为 O(n)

4.2 平衡性的重要性

为了解决最坏情况下的O(n)

 问题,计算机科学家提出了自平衡二叉搜索树(Self-Balancing BST)

  • AVL 树:通过旋转操作严格保持左右子树高度差不超过 1。
  • 红黑树(Red-Black Tree) :通过颜色约束和旋转,保持“大致平衡”。

在工程实践中(如 Java 的 HashMap、C++ 的 std::map),通常使用红黑树,因为其插入和删除时的旋转开销比 AVL 树更小。

4.3 关键注意事项

  1. 空指针检查(Null Safety) :任何递归或迭代操作前,必须校验根节点是否为空,否则极易引发 Cannot read property of null 错误。
  2. 内存泄漏与野指针:虽然 JavaScript 具有垃圾回收机制(GC),但在 C++ 等语言中,删除节点必须手动释放内存。即便在 JS 中,若节点关联了大量外部资源,删除时也需注意清理引用。

5. 实际应用场景

虽然我们在业务代码中很少直接手写 BST,但它无处不在:

  1. 数据库索引:传统关系型数据库(如 MySQL)通常使用 B+ 树。B+ 树是多路搜索树,是 BST 为了适应磁盘 I/O 特性而演化出的变种。
  2. 高级语言的标准库:Java 的 TreeSet / TreeMap,C++ STL 的 set / map,底层实现通常是红黑树。
  3. 文件系统:许多文件系统的目录结构索引采用了树形结构以加速文件查找。

6. 面试官常考题型突击

在面试中,考察 BST 往往侧重于利用其“排序”特性。

6.1 验证二叉搜索树 (Validate BST)

  • 思路:利用 BST 的中序遍历(Inorder Traversal)特性。BST 的中序遍历结果一定是一个严格递增的序列

  • 解法:记录上一个遍历到的节点值 preVal,若当前节点值 

    ≤≤
    

     preVal,则验证失败。

6.2 二叉搜索树中第 K 小的元素

  • 思路:同样利用中序遍历。

  • 解法:进行中序遍历,每遍历一个节点计数器 +1,当计数器等于 K时,当前节点即为答案。

6.3 二叉搜索树的最近公共祖先 (LCA)

  • 思路:利用 BST 的值大小关系,不需要像普通二叉树那样回溯。

  • 解法:从根节点开始遍历:

    • 若当前节点值大于p和 q,说明 LCA 在左子树,向左走。

    • 若当前节点值小于pq ,说明 LCA 在右子树,向右走。

    • 否则(一个大一个小,或者等于其中一个),当前节点即为 LCA。

7. 总结

二叉搜索树(BST)是理解高级树结构(如 AVL 树、红黑树、B+ 树)的基石。掌握 BST 不仅在于背诵代码,更在于深刻理解其分治思想平衡性对性能的影响。在面试中,能够手写健壮的 Delete 操作并分析其复杂度退化场景,是区分初级与高级候选人的重要分水岭。

JavaScript进阶:深度剖析函数柯里化及其在面试中的底层逻辑

作者 NEXT06
2026年2月12日 22:20

在前端开发的面试环节中,函数柯里化(Currying)是一个高频考点。面试官往往通过它来考察候选人对高阶函数、闭包、递归以及JavaScript执行机制的综合理解。本文将从定义出发,结合工程实践,深入剖析柯里化的实现原理与核心价值。

1. 什么是柯里化:定义与本质

柯里化(Currying)的概念最早源于数学领域,在计算机科学中,它指的是将一个接受多个参数的函数,变换成一系列接受单一参数(或部分参数)的函数的技术。

核心定义:
如果有一个函数 f(a, b, c),柯里化后的形式为 f(a)(b)(c)。

核心特征:

  1. 延迟执行(Delayed Execution):  函数不会立即求值,而是通过闭包保存参数,直到所有参数凑齐才执行。
  2. 降维(Dimensionality Reduction):  将多元函数转换为一元(或少元)函数链。

工程实践中的区分:
在学术定义中,严格的柯里化要求每次调用只接受一个参数。但在 JavaScript 的工程实践中,我们通常使用的是偏函数应用(Partial Application)与柯里化的结合体。即不强制要求每次只传一个参数,而是支持 f(a, b)(c) 或 f(a)(b, c) 这种更灵活的调用方式。这种“宽泛的柯里化”在实际开发中更具实用价值。

2. 为什么要使用柯里化:核心价值

许多初学者认为柯里化只是为了“炫技”,导致代码难以理解。然而,在函数式编程和复杂业务逻辑处理中,柯里化具有显著的工程价值。

2.1 参数复用(Partial Application)

这是柯里化最直接的用途。当一个函数有多个参数,而在某些场景下,前几个参数是固定的,我们不需要每次都重复传递它们。

2.2 提高代码的语义化与可读性

通过预设参数,我们可以基于通用函数生成功能更单一、语义更明确的“工具函数”。

代码对比示例:

假设我们需要校验电话号码、邮箱等格式,通常会封装一个通用的正则校验函数:

JavaScript

// 普通写法
function checkByRegExp(regExp, string) {
    return regExp.test(string);
}

// 业务调用:参数重复,语义不直观
checkByRegExp(/^1\d{10}$/, '13800000000'); 
checkByRegExp(/^1\d{10}$/, '13900000000');
checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/, 'test@domain.com');

使用柯里化重构后:

JavaScript

// 假设 curry 是一个柯里化工具函数
const _check = curry(checkByRegExp);

// 生成特定功能的工具函数:参数复用,逻辑固化
const isPhoneNumber = _check(/^1\d{10}$/);
const isEmail = _check(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/);

// 业务调用:代码极简,语义清晰
isPhoneNumber('13800000000'); // true
isEmail('test@domain.com');   // true

从上述例子可以看出,柯里化实际上是一种“配置化”的编程思想,将易变的参数(校验内容)与不变的逻辑(校验规则)分离开来。

3. 柯里化的通用实现:手写核心逻辑

理解柯里化的关键在于两个机制:闭包(Closure)用于缓存参数,递归(Recursion)用于控制参数收集流程。

实现思路分解

  1. 入口:定义一个高阶函数 curry(fn),接收目标函数作为参数。

  2. 判断标准:利用 fn.length 属性获取目标函数声明时的形参个数。

  3. 递归与闭包

    • 返回一个新的代理函数 curried。
    • 在 curried 内部判断:当前收集到的参数个数 args.length 是否大于等于 fn.length?
    • :说明参数凑齐了,直接调用原函数 fn 并返回结果。
    • :说明参数不够,返回一个新的匿名函数。这个匿名函数将利用闭包,把之前的参数 args 和新接收的参数 rest 合并,然后再次递归调用 curried。

简洁版代码实现(ES6)

JavaScript

function curry(fn) {
    // 闭包空间,fn 始终存在
    return function curried(...args) {
        // 1. 终止条件:当前收集的参数已满足 fn 的形参个数
        if (args.length >= fn.length) {
            // 参数凑齐,执行原函数
            // 使用 apply 是为了防止 this 上下文丢失(虽然在纯函数中 this 往往不重要)
            return fn.apply(this, args);
        }

        // 2. 递归收集:参数不够,返回新函数继续接收剩余参数
        return function(...rest) {
            // 核心:合并上一轮参数 args 和本轮参数 rest,递归调用 curried
            // 这里利用 apply 将合并后的数组传给 curried
            return curried.apply(this, [...args, ...rest]);
        };
    };
}

// 验证
function add(a, b, c) {
    return a + b + c;
}
const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1, 2, 3)); // 6

注:原生的 Function.prototype.bind 方法在某种程度上也实现了偏函数应用(预设 this 和部分参数),其底层原理与柯里化高度一致,都是通过闭包暂存变量。

4. 深度思考:面试官为什么考柯里化?

当面试官要求手写柯里化时,他并非仅仅想看你是否背过代码,而是考察以下四个维度的技术深度:

  1. 闭包的掌握程度:柯里化是闭包最典型的应用场景之一。面试官考察你是否理解函数执行完毕后,其作用域链中的变量(如 args)是如何滞留在内存中不被销毁的。
  2. 递归算法思维:如何定义递归的出口(args.length >= fn.length)以及递归的步进(返回新函数收集剩余参数),这是算法基础能力的体现。
  3. 高阶函数理解:函数作为参数传入,又作为返回值输出,这是函数式编程的基石。
  4. 作用域与 this 绑定:在更严谨的实现中(如上文代码中的 apply),考察候选人是否意识到了函数执行上下文的问题,能否通过 apply/call 正确转发 this。

5. 面试指南:如何回答柯里化题目

如果遇到“请谈谈你对柯里化的理解”或“实现一个柯里化函数”这类题目,建议按照以下模板进行结构化回答:

第一步:下定义(直击本质)

“柯里化本质上是一种将多元函数转换为一元函数链的技术。在工程中,它主要用于实现参数的复用和函数的延迟执行。”

第二步:聊原理(展示深度)

“其核心实现依赖于 JavaScript 的闭包递归机制。

  1. 利用闭包,我们在内存中维护一个参数列表。
  2. 通过 fn.length 获取目标函数的参数数量。
  3. 在调用过程中,如果参数未凑齐,就递归返回新函数继续收集;如果参数凑齐,则执行原函数。”

第三步:聊场景(联系实际)

“在实际开发中,我常用它来封装通用的工具函数。比如在正则校验或日志打点场景中,通过柯里化固定正则表达式或日志级别,生成语义更明确的 checkPhone 或 logError 函数,从而提高代码的可读性和复用性。”

第四步:补充性能视角(体现专业性)

“需要注意的是,由于柯里化大量使用了闭包和递归,会产生额外的内存开销和栈帧创建。但在现代 V8 引擎的优化下,这种开销在大多数业务场景中是可以忽略不计的,我们更多是用微小的性能损耗换取了代码的灵活性和可维护性。”

6. 结语

柯里化不仅仅是一个具体的编程技巧,更是一种函数式编程(Functional Programming)的思维方式。它体现了将复杂逻辑拆解、原子化、再组合的过程。在 React Hooks、Redux 中间件以及 Lodash、Ramda 等流行库中,随处可见柯里化思想的影子。掌握它,是前端工程师突破“API调用工程师”瓶颈,迈向高级架构设计的必经之路。

印奇捞到了“搞钱人”

2026年2月12日 22:01

出品|虎嗅汽车组

作者|邢书博

头图|视觉中国


2026年2月12日,千里科技发布公告称,赵明成为公司第六届董事会非独立董事候选人,任职期限与本届董事会任期一致。



同日千里科技另一则公告显示,董事会将新增联席董事长一人。如无意外,这个职位是为赵明增设的。


有媒体报道,称赵明与印奇已接触半年多,他视AI为“下一个20年事业”。今日赵明在微博与印奇互动,称将“一起携手打造AI商业闭环,助力千里腾飞。”



这不是千里科技第一次引入华为系高管。此前已引入前华为车BU总裁王军和自动驾驶负责人陈奇。


王军目前负责华千里科技研发等技术板块,陈奇负责芯片硬件平台。此次赵明加入,技术产品商业化将形成华为系主导的格局。


值得一提的是,三人曾在华为3G/4G时代有过合作经验。王军曾是赵明在欧洲市场的技术搭档。


当然,千里科技最近也释放了诸多将加速商业化的消息。


一个是在去年智博会上,千里科技董事长印奇称将发力“AI+终端”,未来要形成亿级终端规模。


一个是2026年初,印奇说将用12-15个月孵化“有意思”的AI硬件,为“软硬一体”补上拼图。



手机业则完美符合千里科技未来商业化的两个锚点:亿级终端,AI赋能。


因此可以初步认为,赵明履职千里科技,将形成印奇主抓AI技术战略,赵明主攻AI商业模式闭环的内部格局。


荣耀经验能否助力“千里腾飞”?


当下的AI行业普遍存在两个问题,一是产品化高度单一无法实现差异化;二是智能硬件多数只停留在用AI营销而非解决问题。


那么赵明的荣耀经验在AI时代是否有用呢?


印奇认为,当下AI行业陷入了“成本与规模死循环”:规模小则成本高,成本高则客户少,客户少则规模永远起不来。尽管目前千里科技已为吉利系提供了30万智驾设备上车,但距离印奇设想的“亿级终端”还有很远距离,成本居高不下。


其实目前AI面对的问题和10年前手机市场遇到的一样。高端不走量,低端没利润。


手机时代,赵明提出了“高端先行,中端走量”战略。


荣耀在欧洲没有先卖便宜机,而是拿Magic系列砸门面,口碑立住后再用X系列走量,一年时间份额从0做到5%。在千里智驾产品上也可以复制荣耀经验。如L4方案,可以拿Robotaxi作为标杆走高端路线;同时用整车规模摊薄硬件成本,等成本曲线降到甜蜜点,再用中阶方案铺量。


另一个则是千里科技将要推出的AI硬件,则与手机行业高度相似,手机经验可以直接复用。


荣耀做magic AI手机时,当时手机市场陷入了堆参数堆电池的怪圈。赵明则坚定表示不跟风参数竞赛。他的逻辑是“端侧AI是个人工具,任务是让用户变强,不是让参数变高。”


这与印奇的理念很相似。


印奇最近也表示,AI硬件不是先画外观,而是先想清楚“什么AI服务非这个硬件不可”。硬件只是服务的载体,服务不成立,硬件再酷也是电子垃圾。


简单讲,AI硬件不能只是AI+硬件的营销概念,而是要让AI真正服务于硬件,让硬件变得更好用。用车圈举例,隐藏式门把手确实很酷,但有安全隐患,今年已被中国市场禁用。


所以,当印奇认为,“亿级出货是芯片可持续迭代的门槛”。相信赵明带着荣耀十年的经验,能一定程度上为当前AI赛道注入新的活力。


印奇终于等来能把“技术信仰”四个字,翻译成财报的人。琴瑟和鸣。


唯一的问题可能是:面对来自旷视、奔驰、微软、吉利、华为等不同背景的人员,印奇和赵明如何能后弥合团队,或许是当下最要紧的事。


下载虎嗅APP,第一时间获取深度独到的商业科技资讯,连接更多创新人群与线下活动

昨天 — 2026年2月12日首页

引航生物递表港交所

2026年2月12日 20:55
36氪获悉,据港交所,苏州引航生物科技股份有限公司向港交所提交上市申请书,独家保荐人为华泰国际。

沪电股份:拟投资33亿元新建高端印制电路板生产项目

2026年2月12日 20:54
36氪获悉,沪电股份公告,公司于2026年2月11日召开的第八届董事会第十四次会议审议通过《关于新建高端印制电路板生产项目的议案》,同意投资新建“高端印制电路板生产项目”,生产高层数、高频高速、高密度互连、高通流PCB,以满足高速运算服务器、下一代高速网络交换机等对高端印制电路板的中长期增量需求。本项目总投资约为33亿元人民币,建成后预计年新增产能14万平方米高端印制电路板的生产规模,预计年新增营业收入30.5亿元人民币。

你不知道的 JS(上):原型与行为委托

作者 牛奶
2026年2月12日 20:51

你不知道的 JS(上):原型与行为委托

本文是《你不知道的JavaScript(上卷)》的阅读笔记,第三部分:原型与行为委托。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

原型

[[Prototype]]

JS 中的对象有一个特殊的 [[Prototype]] 内置属性,它是对其他对象的引用。几乎所有的对象在创建时都会被赋予一个非空的原型值。

当你试图引用对象的属性时会触发 [[Get]] 操作:

  1. 首先检查对象自身是否有该属性。
  2. 如果没有,则顺着 [[Prototype]] 链向上查找。
  3. 这个过程会持续到找到匹配的属性名或到达原型链顶端(Object.prototype)。如果还没找到,则返回 undefined
Object.prototype

所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype

属性设置和屏蔽

当执行 myObject.foo = "bar" 时:

  1. 如果 foo 已存在于 myObject 中,直接修改它的值。
  2. 如果 foo 不在 myObject 中而在原型链上层:
    • 若原型链上的 foo 不是只读(writable:true),则在 myObject 上创建屏蔽属性 foo
    • 若为只读(writable:false),则无法设置。
    • 若是一个 setter,则调用该 setter。
  3. 如果 foo 既不在 myObject 也不在原型链上,直接添加到 myObject

“类”

JS 和面向类的语言不同,它并没有类作为蓝图,JS 中只有对象。

“类函数”与原型继承

JS 通过函数的 prototype 属性来模仿类。当你调用 new Foo() 时,创建的新对象会关联到 Foo.prototype

注意:在 JS 中,我们并不是将“类”复制到“实例”,而是将它们关联起来。

“构造函数”

Foo.prototype 默认有一个 .constructor 属性指向 Foo。 通过 new 调用的函数并不是真正的“构造函数”,new 只是劫持了普通函数,并以构造对象的形式来调用它。

(原型)继承

常见的“继承”写法:

function Foo(name) {
    this.name = name;
}
function Bar(name, label) {
    Foo.call( this, name );
    this.label = label;
}

// 创建一个新的 Bar.prototype 对象并关联到 Foo.prototype
Bar.prototype = Object.create( Foo.prototype );

Bar.prototype.myLabel = function() {
    return this.label;
};

Object.create(..) 会凭空创建一个新对象并将其 [[Prototype]] 关联到指定的对象。

检查“类”的关系:

  • a instanceof Foo:检查 Foo.prototype 是否出现在 a 的原型链上。
  • Foo.prototype.isPrototypeOf(a):更直观的检查方式。
  • Object.getPrototypeOf(a):获取对象的原型。

对象关联

原型链的本质就是对象之间的关联。Object.create(..) 是创建这种关联的直接方式,它避免了 new 构造函数调用带来的复杂性(如 .prototype.constructor 引用)。

关联关系是备用

比起直接在原型链上查找(直接委托),内部委托往往能让 API 设计更清晰:

var anotherObject = {
    cool: function() { console.log( "cool!" ); }
};
var myObject = Object.create( anotherObject );
myObject.doCool = function() {
    this.cool(); // 内部委托!
};

原型机制小结

JS 的 [[Prototype]] 机制本质上是行为委托。对象之间不是复制关系,而是关联关系。

行为委托

面向委托的设计

类理论 vs. 委托理论
  • 类理论:鼓励继承、重写和多态。将行为抽象到父类,子类实例化时进行复制。
  • 委托理论:认为对象之间是兄弟关系。定义基础对象,其他对象通过 Object.create(..) 关联并委托行为。
委托模式的特点
  1. 更具描述性的方法名:避免使用通用的方法名,提倡使用能体现具体行为的名字。
  2. 状态存储在委托者上:数据通常存储在具体对象上,行为委托给基础对象。

类与对象关联的比较

对象关联风格的代码通常更简洁,因为它省去了模拟类所需要的复杂包装(构造函数、prototype 等)。

更好的语法 (ES6)

ES6 的简洁方法语法让对象关联看起来更舒服:

var AuthController = {
    errors: [],
    checkAuth() { /* .. */ }
};
Object.setPrototypeOf(AuthController, LoginController);

内省 (Introspection)

在对象关联模式下,检查对象关系变得非常简单:

Foo.isPrototypeOf( Bar ); // true
Object.getPrototypeOf( Bar ) === Foo; // true

行为委托小结

行为委托是一种比类更强大的设计模式。它更符合 JS 的原型本质,能让代码结构更清晰、语法更简洁。

ES6 中的 Class

class 语法

ES6 引入了 class 关键字,它解决了:

  1. 不再需要显式引用杂乱的 .prototype
  2. extends 简化了继承。
  3. super 支持相对多态。

class 陷阱

尽管 class 语法更好看,但它仍然是基于原型机制的,存在一些隐患:

  1. 非静态复制:修改父类方法会实时影响所有子类 and 实例。
  2. 成员属性限制:无法在类体中直接定义数据属性(只能定义方法),通常仍需操作原型。
  3. 意外屏蔽:属性名可能屏蔽同名方法。
  4. super 绑定super 是在声明时静态绑定的,而非动态绑定。

结论:静态大于动态吗?

ES6 的 class 试图伪装成一种静态的类声明,但这与 JS 动态的原型本质相冲突。它隐藏了许多底层机制,有时反而会让问题变得更难理解。

ES6 Class 小结

class 很好地伪装了类和继承模式,但它实际上只是原型委托的一层语法糖。使用时应警惕它带来的新问题。

中芯国际:存储器、BCD供不应求都在涨价

2026年2月12日 20:51
36氪获悉,中芯国际公告,在11月份业绩说明会上,有提到存储大周期对于产业和晶圆代工业的影响。在这样的市场环境下,公司凭借在BCD、模拟、存储、MCU、中高端显示驱动等细分领域中的技术储备与领先优势、客户的产品布局,在本轮行业发展周期中,仍能保持有利位置。公司将积极响应市场的紧急需求,推动2026年收入继续增长。价钱是一种供需的关系。中芯国际的存储器、BCD供不应求,都是在涨价的,友商部分成熟的产能不做了,市场上的供应量是在下降的。

你不知道的JS(上):this指向与对象基础

作者 牛奶
2026年2月12日 20:48

你不知道的JS(上):this指向与对象基础

本文是《你不知道的JavaScript(上卷)》的阅读笔记,第二部分:this 指向与对象基础。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

动态作用域

JS 并不具有动态作用域,它只有词法作用域,但 this 机制在某种程度上很像动态作用域。

主要区别:

  • 词法作用域:在写代码或者说定义时确定的(静态),关注函数在何处声明
  • 动态作用域:在运行时确定的,关注函数从何处调用this 也是在运行时绑定的,这一点与动态作用域类似。

this 词法

var obj = {
    id: "awesome",
    cool: function coolFn() {
        console.log( this.id );
    }
};
var id = "not awesome";
obj.cool(); // awesome
setTimeout( obj.cool, 100 ); // not awesome

关于 this

this 关键字是 JS 中最复杂的机制之一,它被自动定义在所有函数的作用域中。

对 this 的误解

1. 为什么要用 this?

this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将 API 设计得更加简洁并且易于复用。随着使用模式越来越复杂,显式传递上下文对象会让代码变得混乱,而使用 this 则能保持代码整洁。

2. 它的作用域

this 在任何情况下都不指向函数的词法作用域。

function foo() {
    var a = 2;
    this.bar();
}

function bar() {
    console.log(this.a);
}
foo(); // ReferenceError: a is not defined

this 到底是什么

this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式

当一个函数被调用时,会创建一个活动记录(也被称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中被用到。

this 定义小结

this 既不指向函数自身,也不指向函数的词法作用域。this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。

this 全面解析

调用位置

在理解 this 的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的位置(而不是声明的位置)。只有仔细分析调用位置才能回答:这个 this 到底引用的是什么?

绑定规则

1. 默认绑定

独立函数调用:函数调用时应用了 this 的默认绑定,因此 this 指向了全局对象。

function foo () {
    console.log(this.a);
}
var a = 2;
foo(); // 2

如果使用严格模式(strict mode),全局对象将无法使用默认绑定,因此 this 会绑定到 undefined

2. 隐式绑定

这条规则需要考虑调用位置是否有上下文对象,或者说是否被某个对象拥有或包含。

function foo () {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
}
obj.foo(); // 2

当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。

对象属性引用链: 只有最顶层或者说最后一层会影响调用位置。

function foo() {
    console.log( this.a );
}
var obj2 = {
    a: 42,
    foo: foo
};
var obj1 = {
    a: 2,
    obj2: obj2
};
obj1.obj2.foo(); // 42

隐式丢失:

一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,从而应用默认绑定(绑定到全局对象或 undefined)。

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
};
var bar = obj.foo; // 函数别名,实际上引用的是 foo 函数本身
var a = "oops, global"; 
bar(); // "oops, global"

传入回调函数时也容易发生隐式丢失:

function doFoo(fn) {
    fn(); // 调用位置!
}
doFoo(obj.foo); // "oops, global"
3. 显式绑定

JS 提供了 call(..)apply(..) 两个方法来进行显式绑定。它们的第一个参数是一个对象,会把这个对象绑定到 this

function foo () {
    console.log(this.a);
}
var obj = { a: 2 };
foo.call(obj); // 2

装箱: 如果传入的是原始值(字符串、布尔或数字),它会被转换成对象形式(new String(..) 等)。

硬绑定:

function bind(fn, obj) {
    return function() {
        return fn.apply(obj, arguments);
    }
}
4. new 绑定

使用 new 来调用函数时,会自动执行以下操作:

  1. 创建(构造)一个全新的对象。
  2. 这个新对象会被执行 [[Prototype]] 连接。
  3. 这个新对象会绑定到函数调用的 this
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
function foo(a) {
    this.a = a;
}
var bar = new foo(2);
console.log(bar.a); // 2

优先级

  1. new 绑定var bar = new foo()
  2. 显式绑定var bar = foo.call(obj2)
  3. 隐式绑定var bar = obj1.foo()
  4. 默认绑定var bar = foo()(严格模式下绑定到 undefined

绑定例外

被忽略的 this

如果你把 null 或者 undefined 作为 this 的绑定对象传入 callapply 或者 bind,这些值会被忽略,实际应用的是默认绑定规则。

更安全的 this: 使用 Object.create(null) 创建一个彻底的空对象(DMZ)。

var ø = Object.create(null);
foo.apply(ø, [2, 3]);
间接引用

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,调用时会应用默认绑定。

软绑定

硬绑定会降低函数的灵活性,软绑定可以在保留隐式/显式绑定能力的同时,提供一个默认绑定值。

this 词法(箭头函数)

箭头函数不使用 this 的四种标准规则,而是根据外层(函数或全局)作用域来决定 this

function foo() {
    return (a) => {
        // this 继承自 foo()
        console.log( this.a );
    };
}

this 绑定规则小结

判断 this 绑定对象的四条规则:

  1. new? 绑定到新创建的对象。
  2. call/apply/bind? 绑定到指定的对象。
  3. 上下文对象调用? 绑定到该上下文对象。
  4. 默认? 严格模式下 undefined,否则全局对象。

对象

语法

对象可以通过两种形式定义:字面量形式构造形式

// 文字语法(常用)
var myObj = { key: value };

// 构造形式
var myObj = new Object();
myObj.key = value;

类型

JS 的六种主要类型:stringnumberbooleannullundefinedobject

注意typeof null 返回 "object" 是语言本身的一个 bug。

内置对象StringNumberBooleanObjectFunctionArrayDateRegExpError。它们实际上都是内置函数。

内容

可计算属性名

ES6 允许在字面量中使用 [] 包裹表达式作为属性名。

var prefix = "foo";
var myObject = {
    [prefix + "bar"]: "hello"
};
数组

数组也是对象,可以添加属性,但如果属性名看起来像数字,会变成数值下标并修改 length

复制对象
  • 深拷贝:对于 JSON 安全的对象,可以使用 JSON.parse(JSON.stringify(obj))
  • 浅拷贝:ES6 提供了 Object.assign(..)
属性描述符 (Property Descriptors)
  • writable:是否可修改值。
  • configurable:是否可配置(修改描述符或删除属性)。
  • enumerable:是否出现在枚举中(如 for..in)。
不变性
  1. 对象常量writable:false + configurable:false
  2. 禁止扩展Object.preventExtensions(..)
  3. 密封Object.seal(..)(禁止扩展 + configurable:false)。
  4. 冻结Object.freeze(..)(最高级别,密封 + writable:false)。
[[Get]] 与 [[Put]]
  • [[Get]]:查找属性值,找不到返回 undefined
  • [[Put]]:设置属性值,涉及是否有 setter、是否可写等判断。
Getter 和 Setter

通过 getset 改写默认的 [[Get]][[Put]] 操作。定义了 getter/setter 的属性被称为“访问描述符”。

存在性
  • in 操作符:检查属性是否在对象及其原型链中。
  • hasOwnProperty(..):只检查属性是否在对象自身中。

遍历

  • for..in:遍历对象的可枚举属性(包括原型链)。
  • forEach(..)every(..)some(..):数组辅助迭代器。
  • for..of (ES6):直接遍历值(通过迭代器对象)。

混合对象“类”

类是一种设计模式。JS 虽然有 class 关键字,但其机制与传统面向对象语言完全不同。

类的机制

  • 实例化:类通过复制操作变为对象。
  • 继承:子类继承父类。
  • 多态:子类重写父类方法,通过 super 相对引用。

混入 (Mixin)

由于 JS 不会自动执行复制行为,开发者常使用“混入”来模拟类复制。

  • 显式混入:手动复制属性。
  • 寄生继承:显式混入的一种变体。
  • 隐式混入:通过 call(this) 借用函数。

对象与类小结

类意味着复制。JS 中没有真正的类,只有对象,对象之间是通过关联(原型链)而非复制来连接的。

翔鹭钨业:如钨精矿价格剧烈变化而钨制品价格未能同步变动,将对公司经营业绩产生影响

2026年2月12日 20:44
36氪获悉,翔鹭钨业公告,公司生产所需的钨精矿基本通过外购获得,公司产品的销售价格根据钨精矿价格变动情况相应调整,从而降低了原材料价格波动对公司经营业绩的影响,但如果未来钨精矿价格发生剧烈变化而钨制品价格未能同步变动,将会对公司主要产品的毛利率水平及经营业绩产生影响。

你不知道的JS(上):作用域与闭包

作者 牛奶
2026年2月12日 20:43

你不知道的JS(上):作用域与闭包

本文是《你不知道的JavaScript(上卷)》的阅读笔记,第一部分:作用域与闭包。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

作用域是什么

JS 编译原理

JS 的编译流程和传统编译非常相似,程序中的一段源代码在执行之前会经历三个步骤:分词/词法分析(Tokeninzing/Lexing)解析/语法分析(parsing)代码生成。对于 JS 来说,大部分编译发生在代码执行前的几微秒。

分词/词法分析(Tokeninzing/Lexing)

这个过程会将由字符组成的字符串分解为有意义的代码块,这些代码块被称为词法单元(token)。

解析/语法分析(parsing)

这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为”抽象语法树“(AST:Abstract Syntax Tree)。

代码生成

将 AST 转换为可执行代码的过程称被称为代码生成。简单的说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。

理解作用域

想要完全理解作用域,需要先理解引擎。

模块
  • 引擎:从头到尾负责整个 JS 程序的编译及执行过程。
  • 编译器:引擎的好朋友之一,负责语法分析及代码生成等脏活累活。
  • 作用域:引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对标识符的访问权限。
声明

例如 var a = 2; 这段看起来是一个声明,在 JS 里其实是两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理。下面我们看看引擎是如何和编译器、作用域协同工作的。

编译器首先会将这段程序分解成词法单元,然后将词法单元解析为一个树结构。但是当编译器开始执行代码生成的时候,对这段代码的处理方式和预期有所不一样。

我们的预期是:”为一个变量分配内存,将其命名为 a,然后将值 2 保存进这个变量。“

但事实上编译器会进行如下处理:

  1. 遇到 var a,编译器会先询问作用域是否已经有这个变量存在,没有则会在作用域集合声明一个新的变量 a;如果已经有了,则忽略该声明继续编译。
  2. 接下来为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合里是否有变量 a,如果有变量 a,则直接使用;如果没有则向上继续查找。最终如果找到了 a,则进行赋值操作,没有则进行异常抛错。

总结:变量的赋值操作会执行两个动作,首先去在当前作用域中声明一个变量(如果之前没声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就进行赋值。

引用执行

编译器在编译的过程的第二步中生成了代码,引擎执行它时,会通过查找变量 a 来判断它是否已声明过。查找的过程由作用域进行协助,当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧进行 RHS 查询。

RHS 可以理解为 retrieve his source value(取到它的源值)。 例如:console.log(a); 其中对 a 的引用是一个 RHS 引用,这里没有赋予任何值。 相比下 a = 2; 是 LHS 引用,赋值操作找到一个目标。

作用域嵌套

作用域是根据名称查找变量的一套规则。实际情况中,通常要同时查找好几个作用域。当一个块或函数嵌套在另一个块或者函数中时,就发生了作用域的嵌套。在当前作用域无法找到某个变量时,引擎就会在外层嵌套的作用域中进行继续查找,直到查到该变量或者最外层的全局作用域为止。

function foo(a) {
    // 其中b进行的RHS引用无法在函数foo内部完成,但可以在上一级作用域中完成
    console.log(a + b);
}

var b = 2;

foo(3); // 5

引用异常

在变量还没有声明的情况下,LHS 和 RHS 的行为是不一样的。

如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 的异常类型。

如果 RHS 找到了一个变量,但你尝试对这个变量的值进行不合理的操作,比如试图对非函数类型的值进行调用,或者引用 nullundefined 的值中的属性会报 TypeError

function foo(a) {
    // 对b进行RHS查询时是无法找到该变量的
    console.log(a + b);
    b = a;
}

foo(2); // ReferenceError: b is not defined

相比较下,执行 LHS 查询时,如果在全局作用域也无法找到目标变量,全局作用域中会创建一个具有该名称的变量,并返回给引擎(前提是程序运行在非严格模式下)。

function foo(a) {
    // 对b进行LHS查询时,没找到会创建一个全局变量
    b = a;
    console.log(a + b);
}
foo(2); // 4

作用域小结

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。赋值操作符会导致 LHS 查询。= 操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。

词法作用域

作用域有两种主要的工作模型。第一种是最为普遍的词法作用域,另一种是动态作用域。JS 采用的是词法作用域。

词法阶段

编译器第一个工作阶段叫作词法化,词法化的过程会对源代码中的字符进行检查,如果有状态的解析过程,还会赋予单词语义。无论函数在哪里被调用或如何调用,词法作用域只由函数被声明时所处的位置决定。作用域查找会在第一个匹配的标识符时停止,在多层的嵌套作用域中可以定义同名的标识符,这叫作”遮蔽效应“。

欺骗词法

词法作用域完全由书写代码期间函数所声明的位置来定义,怎样才能在运行时来”修改“(欺骗)词法作用域呢?JS 中有两种机制来实现这个目的,使用这两种机制并不是好主意,而且欺骗词法作用域会导致性能下降。

eval

eval(...) 函数可以接受一个字符串为参数,并运行这个字符串来实现修改词法作用域环境。

function foo(str, a) {
    eval( str ); // 欺骗!
    console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

在严格模式下,eval 在运行时有其自己语法作用域,意味着其中的声明无法修改所在的作用域。

function foo(str) {
    "use strict";
    eval( str );
    console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2"); 

JS 中还有一些其他功能效果和 eval 相似的方法,例如 setTimeout(...)setInterval(...) 的第一个参数可以是字符串,可以被解析为一段动态生成的函数代码。这些功能已经过时且并不被提倡。不要使用他们!

with

JS 中另一个难以掌握的用来欺骗词法作用域的功能是 with 关键字。with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

var obj = {
    a: 1,
    b: 2,
    c: 3
};
// 单调乏味的重复”obj“
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with (obj) {
    a = 3;
    b = 4;
    c = 5;
}

但这样的使用方式会有奇怪的副作用,例如:

function foo(obj) {
    with (obj) {
        a = 2;
    }
}

var o1 = { a: 3 };
var o2 = { b: 3 };
foo(o1);
console.log(o1.a); // 2

foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2 --a被泄露到全局作用域上了

当我们将 o2 作为作用域时,其中并没有 a 标识符,因此进行了正常的 LHS 标识符查找,在 o2foo、全局作用域都没有找到时,在非严格模式下自动创建了一个全局变量。

性能

JS 引擎会在编译阶段进行数项性能优化。其中有些优化依赖于代码词法的静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。如果代码中使用了 evalwith,它只能简单地假设关于标识符位置的判断都是无效的,因此代码中的优化就没有了意义。如果大量使用 evalwith,运行起来会非常慢。

词法作用域小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

JavaScript 中有两个机制可以“欺骗”词法作用域:eval(..)with。前者可以对一段包含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。

这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

函数作用域和块作用域

函数中的作用域

函数作用域是指属于这个函数的全部变量都可以在整个函数的范围内使用及复用。这种设计方案是非常有用的,能充分利用 JS 变量根据需要改变值类型的”动态“特性。

隐藏内部实现

把变量和函数包裹在一个函数的作用域中,然后用这个作用域来”隐藏“它们。

function doSomething(a) {
    function doSomeThingElse(a) {
        return a - 1;
    }
    var b;
    b = a + doSomeThingElse(a*2);
    console.log(b*3);
}
doSomething(2); // 15
// b 和doSomeThingElse都无法从外部被访问,只能被doSomething所控制。从设计角度上,内容私有化了

”隐藏“作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。

函数作用域

匿名和具名
setTimeout(function () {
    console.log('I waited 1 second!');
}, 1000);

因为 function 没有名称标识符,这就叫作匿名函数表达式。函数表达式可以匿名,但函数声明则不能省略函数名。

立即执行函数表达式
var a = 2;
(function foo() {
    var a = 3;
    console.log(a); // 3
})();

console.log(a); // 2

函数被包含在一对 ( ) 括号内部,因此成为一个表达式,通过在末尾加上另外一个 ( ) 可以立即执行这个函数,专业术语叫做 IIFE(Immediately Invoked Function Expression)。

IIFE 的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。例如:

var a = 2;
(function IIFE(global) {
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
})(global);

console.log(a); // 2

IIFE 还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 执行之后当作参数传递进去。这种模式在 UMD 项目中被广泛应用。

var a = 2;
(function IIFE(def) {
    def(window)
})(function def(global) {
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
})

块作用域

块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。

with

它不仅是一个难以理解的结构,也是块作用域的一个例子,用 with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效。

try/catch

非常少有人注意到 try/catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

try {
    undefined(); // 执行一个异常
} catch (err) {
    console.log(err); // 能够执行
}
console.log(err); // ReferenceError: err not found
let

let 关键字可以将变量绑定到所在的任意作用域中(通常是 {...} 内部)。

var foo = true;
if (foo) {
    let bar = 2;
}

console.log(bar); // ReferenceError
const

ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。

if (true) {
    var a = 2;
    const b = 3; // 包含在if中的块作用域常量
    a = 3; // 正常
    b = 4; // 错误
}

console.log(a); // 3
console.log(b); // ReferenceError

函数与块作用域小结

函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。

但函数不是唯一的作用域单元. 块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指 { .. } 内部)。

从 ES3 开始,try/catch 结构在 catch 分句中具有块作用域。 在 ES6 中引入了 let 关键字(var 关键字的表亲),用来在任意代码块中声明变量。if (..) { let a = 2; } 会声明一个劫持了 if{ .. } 块的变量,并且将变量添加到这个块中。

作用域提升

先声明还是先赋值

一般来说 JS 代码是在从上到下一行一行执行的,但实际上并不完全正确,存在作用域提升的特殊情况,例如:

a = 2;
var a;
console.log(a); // 2 不是undefined
console.log(a); // undefined
var a = 2;

编译器的作用

引擎会在执行 JS 代码前进行编译,编译阶段的一部分工作就是找到所有的声明,并用合适的作用域关联起来。所以正确的思路是,包括变量和函数在内的所有声明都是在代码执行前先处理。定义声明在编译阶段进行,赋值声明在原地等待执行阶段。上面的例子可以被解析为:

var a;
a = 2;
console.log(a); // 2

第二个解析为:

var a;
console.log(a); // undefined
a = 2;

这个过程就好像变量 and 函数声明从它们的代码中出现的位置被“移动”到了最上面。这个过程就叫作提升。

函数优先

函数声明和变量声明都会被提升,但函数会优先被提升,然后才是变量。

foo(); // 1
var foo;
function foo() {
    console.log(1);
}
foo = function() {
    console.log(2);
}

var foo 尽管出现在 function foo()... 的声明之前,但它是重复的声明,因为函数声明会被提升到普通变量之前。 尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。

foo(); // 3
var foo;
function foo() {
    console.log(1);
}
var foo = function() {
    console.log(2);
}
function foo() {
    console.log(3);
}

尽量避免在块内部声明函数,这个行为并不可靠。

作用域提升小结

无论作用域中的声明在什么地方,都将在代码本身被执行前先进行处理,这一过程被称为提升。同时需要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候,会引起很多危险的问题!

作用域闭包

闭包无处不在

闭包无处不在,你只需要能够识别并拥抱它。

闭包的实质

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会有对原始定义作用域的引用,这就叫作闭包。闭包的神奇之处在于阻止外层作用域被引擎的垃圾回收器回收。 例如: 内部函数调用外部引用

function foo() {
    var a = 2;
    function bar() {
        console.log(a); // 2
    }
    bar();
}
foo(); // 这不是闭包,但包含了闭包的原理

把函数作为返回值,保持对外部的引用

function foo() {
    var a = 2;
    function bar() {
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz(); // 2 这就是闭包的效果

对函数类型的值进行传递,在别处调用

function foo() {
    var a = 2;
    function baz() {
        console.log(a) // 2
    }
    bar(baz);
}

function bar(fn) {
    fn(); // 这也是闭包
}

间接传递函数

var fn;
function foo() {
    var a = 2;
    function baz() {
        console.log(a) // 2
    }
    fn = baz; // 将baz分配
}

function bar() {
    fn(); // 这也是闭包
}
foo();
bar(); // 2

闭包的使用

闭包的使用其实非常广泛,在我们无意中写的代码里有很多闭包。在定时器、事件监听、ajax 处理、通信、异步或同步任务中,只要使用了回调函数,实际上就是在使用闭包! 定时器的使用:

function wait(msg) {
    setTimeout(function timer() {
        console.log(msg)
    }, 1000);
}

wait('hello'); // wait执行1000毫秒后,它的内部作用域并不会消失,timer函数依然保有wait的作用域的闭包

事件监听:

function setupBot(name, selector) {
    $(selector).click(function (){
        console.log('Activating: ' + name);
    })
}

setupBot('name1', '#id1');

循环和闭包

要说明闭包,for 循环是最常见的例子。

for (var i=1; i<=5; i++) {
    setTimeout(function timer() {
        console.log( i );
    }, i*1000);
}

正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。 但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。

因为延迟函数的回调会在循环结束时才执行。事实上,当定时器运行时即使每个迭代中执行的是 setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。

尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i

缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。

IIFE 会通过声明并立即执行一个函数来创建作用域。

for (var i=1; i<=5; i++) {
    (function() {
        var j = i;
        setTimeout( function timer() {
            console.log( j );
        }, j*1000 );
    })();
}

对这段代码进行改进,传入参数

for (var i=1; i<=5; i++) {
    (function(j) {
        setTimeout( function timer() {
            console.log( j );
        }, j*1000 );
    })(i);
}

当然现在可以使用 ES6 的 let 声明,来劫持块作用域:

for (let i=1; i<=5; i++) {
     setTimeout( function timer() {
        console.log( i );
    }, i*1000 );
}

模块

还有其他的代码模式利用闭包的强大威力,但从表面上看,它们似乎与回调无关。下面一起来研究其中最强大的一个:模块。

正如下面代码中所看到的,这里并没有明显的闭包,只有两个私有数据变量 somethinganother,以及 doSomething()doAnother() 两个内部函数,它们的词法作用域(而这就是闭包)也就是 foo() 的内部作用域。

function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join("!") );
    }
    
    return {
        doSomething: doSomething,
        doAnother: doAnother,
    }
}

var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1!2!3

模块模式需要具备两个必要条件。

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

上面的示例是个独立的模块创建器,可以被调用任意多次,每次调用都会创建一个新的模块实例。当只需要一个实例时,可以对这个模式进行简单的改进来实现单例模式:

var foo = (function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join("!") );
    }
    
    return {
        doSomething: doSomething,
        doAnother: doAnother,
    }
})();

foo.doSomething(); // cool
foo.doAnother(); // 1!2!3

我们将模块函数转成了 IIFE,立即调用这个函数并将返回值直接赋值给单例的模块实例标识符 foo

现代的模块机制

大多数模块依赖加载器/管理器本质上都是将这种模块定义封装进一个友好的 API。

var MyModules = (function Manager() {
    var modules = {};
    
    function define(name, deps, impl) {
        for (var i = 0; i < deps.length; i++) {
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply(impl, deps);
    } 
    
    function get (name) {
        return modules[name];
    }
    
    return {
        define: define,
        get: get
    }
})();

这段代码的核心是 modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装函数(可以传入任何依赖),并且将返回值,也就是模块的 API,存储在一个根据名字来管理的模块列表中。

// 下面展示了如何使用它来定义模块
MyModules.define("bar", [], function() {
    function hello(who) {
        return "Let me introduce: " + who;
    }
    
    return {
        hello: hello
    }
});

MyModules.define("foo", ["bar"], function(bar) {
    var hungry = "hippo";
    function awesome() {
        console.log(bar.hello(hungry).toUpperCase());
    }
    
    return {
        awesome: awesome
    }
});

var bar = MyModules.get("bar");
var foo = MyModules.get("foo");

console.log(bar.hello("hippo")); // Let me introduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO

foobar 模块都是通过一个返回公共的 API 的函数来定义的。foo 还接受 bar 作为依赖参数,并能相应的使用它。

未来的模块机制

ES6 中为模块增加了一级语法支持。通过模块系统进行加载时,ES6 会将文件当作独立的模块来处理。每个模块可以导入其他模块或特定的 API,同样也可以导出自己的 API 成员。

相比之下,ES6 的模块 API 更加稳定(API 不会在运行时改变)。

// bar.js
function hello(who) {
    return "Let me introduce: " + who;
}
export hello;

// foo.js
// 仅从 "bar" 模块导入 hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
    console.log(hello( hungry ).toUpperCase());
}
export awesome;

// baz.js
// 导入完整的 "foo" 和 "bar" 模块
module foo from "foo";
module bar from "bar";
console.log(bar.hello( "rhino" )); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO

import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量上(在我们的例子里是 hello)。module 会将整个模块的 API 导入并绑定到一个变量上(在我们的例子里是 foobar)。export 会将当前模块的一个标识符(变量、函数)导出为公共 API。这些操作可以在模块定义中根据需要使用任意多次。

闭包与模块小结

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。

《前端架构设计》:除了写代码,我们还得管点啥

作者 牛奶
2026年2月12日 20:35

《前端架构设计》:除了写代码,我们还得管点啥

很多人对“前端架构”这四个字有误解,觉得就是选个 React 还是 Vue,或者是折腾一下 Webpack 和 Vite。

但 Micah Godbolt 在《前端架构设计》里泼了一盆冷水:选工具只是最简单的一步。真正的架构,是让团队在业务疯狂迭代的时候,代码还能写得顺手,系统还能跑得稳当。

这本书的核心其实就是一句话:架构不是结果,而是为了让开发更爽、系统更强而设计的一整套制度。


一、 别把架构师当成“高级打工人”

在很多项目里,前端总是在“下游”接活:UI 给图,后端给口,前端就在中间拼命赶进度。但作者认为,前端架构师应该是项目里的“一等公民”,甚至是“城市规划师”。

1. 搭体系 (System Design)

你得像规划城市一样去规划代码。

  • 组件库的颗粒度:到底是一个按钮算一个组件,还是一个带图标的搜索框算一个组件?这直接决定了复用效率。
  • 样式冲突预防:不同业务线共用一套组件时,怎么保证 A 团队改的样式不会让 B 团队的页面崩掉?
  • 技术选型:不能光看 GitHub 上哪个库星星多,得看它能不能管个三五年。架构师要考虑的是“可持续性”,而不是“时髦值”。

2. 理流程 (Workflow Planning)

大家开发得顺不顺手,全看流程顺不顺。架构师得操心:

  • 环境一致性:是不是每个人的 Node 版本、依赖版本都一样?
  • 构建自动化:代码怎么自动化编译、压缩、分包?
  • 新人上手成本:能不能让新人进来半天就能配好环境开始写业务? 很多时候,一个好的构建工具(比如现在的 Vite 或者以前的 Webpack)配上一套好流程,比写出神仙代码更能救命。

3. 盯着质量 (Oversight)

业务跑得快,脏代码肯定多。架构师得心里有数:

  • 技术债管理:什么时候该去清理那些“为了上线先凑合”的代码?
  • 代码审查 (Code Review):不是为了找茬,而是为了同步思路。
  • 风险预判:业务下个月要搞大促,现在的系统架构能不能扛住高并发和频繁的样式变更? 别等项目成了“屎山”才去想重构,那时候可能连下脚的地方都没了。

二、 架构的四个核心支柱

作者把架构分成了四个板块:代码、流程、测试、文档。这四块拼图凑齐了,项目才算有了魂。

1. 代码 (Code):拒绝“意大利面条”

代码架构的核心就是“解耦”。别让 HTML、CSS 和 JS 粘得太死,要像乐高积木一样可以随时拆卸。

HTML:结构的纯粹性
  • 拒绝过度生成:要在自动化和控制力之间找平衡。别让后端逻辑直接吐一堆乱七八糟的标签出来,那样前端根本没法改。
  • 组件化思维:HTML 只负责描述“这是什么”(比如这是一个导航栏),而不负责“它长什么样”。
  • 语义化:保持 HTML 的简洁和语义化,是架构长青的基石。
CSS:架构的重灾区

这是全书聊得最细的地方,因为 CSS 最容易写乱,也最难维护。

  • OOCSS (面向对象 CSS)
    • 核心是“结构与皮肤分离”。
    • 比如一个按钮的形状、边距是“结构”,它的背景色、投影是“皮肤”。
  • SMACSS
    • 给样式分分类,就像衣柜收纳:
      1. Base:基础样式,比如 body, a 标签的默认外观。
      2. Layout:布局样式,负责页面大框架。
      3. Module:模块样式,这是重头戏,比如导航条、轮播图。
      4. State:状态样式,比如 is-active, is-hidden
      5. Theme:主题样式,换肤全靠它。
  • BEM 命名法
    • 虽然 block__element--modifier 名字长,但它能解决权重冲突。
    • 坚持用单类名(Single Class Selection),重构的时候底气才足,不用担心改个按钮全站崩。
  • 单一来源 (Single Source of Truth)
    • 变量(Variables)是神。颜色、字号、间距,全用变量管起来,改一个地方全站生效。
JavaScript:逻辑的独立性
  • 框架无关性:选框架要冷静。业务逻辑要尽量从 UI 库里抽出来。
  • 纯函数:尽量写不依赖外部环境的函数,这样不开启浏览器也能跑单元测试。
  • 状态管理:清晰的数据流向是大型项目不崩溃的前提。

2. 流程 (Process):别把时间花在重复劳动上

流程决定了代码从你的键盘到用户屏幕的距离。

  • 原型驱动 (Prototyping)

    • 别光看设计稿,先写个 HTML 原型。
    • 为什么?因为原型能让你早点体验到真实的交互,早点发现问题。
    • 改原型永远比改正式代码便宜。
  • 任务自动化

    • 凡是能让机器干的活(编译 Sass、压图、跑 Lint),都别让人干。
    • 现在的 npm scripts 或者是各种 Task Runner 就是为了解放生产力的。
  • 持续集成 (CI)

    • 代码合并前,必须经过一顿“毒打”——编译、Lint 检查、自动化测试。
    • 只有通过了,才能合并。这能保证主干代码永远是健康的。
  • 文档化工作流:让每个步骤都有迹可循,减少沟通成本。


3. 测试 (Testing):你的后悔药

没有测试的重构就是裸奔。

  • 视觉回归测试 (Visual Regression)

    • 这是作者最推崇的黑科技。
    • 重构 CSS 之后,用工具(比如 BackstopJS)自动截图和以前对比,像素级的差异都能找出来。
    • 这是重构老旧系统的“保命符”,有了它,你才敢动那些几年前写的样式。
  • 性能预算 (Performance Budget)

    • 性能不是后期优化的,是前期定好的。
    • 首屏时间不能超过几秒?JS 包体积不能超过多大?
    • 定死这些指标,加第三方库的时候你才会心疼,才会去想有没有更好的替代方案。
  • 自动化单元测试:保证核心逻辑的稳定性,改代码不再提心吊胆。


4. 文档 (Documentation):它是活的吗?

文档不是写给老板交差的,是给团队对齐思路的。

  • 动态样式指南 (Living Style Guides)

    • 静态文档写完就过期。
    • 理想的状态是:文档就是代码的一部分。代码改了,文档自动更新。
  • 模式库 (Pattern Lab)

    • 按照“原子设计”的思路管理组件:
      • 原子 (Atoms):标签、按钮、输入框。
      • 分子 (Molecules):带标签的搜索栏。
      • 有机体 (Organisms):整个导航头部。
      • 模板 (Templates):页面骨架。
      • 页面 (Pages):最终呈现。
    • 这种层级关系理清楚了,组件复用才不会乱成一团。
  • 开发者文档:记录“为什么要这么设计”,比“怎么用”更重要。


三、 Red Hat 的实战经验:怎么干重构?

作者在 Red Hat 负责过一次大规模重构,这部分的实战干货非常多,值得细品:

1. 解决命名空间冲突

在大型公司,不同团队可能都在写 CSS。以前样式到处打架,改个按钮全公司网站都变色。

  • 方案:重构时,他们给所有核心组件加了 rh- 这种命名前缀。

  • 感悟:技术虽然简单,但它建立了“地盘感”,彻底实现了样式隔离。

2. 语义化网格系统 (Semantic Grids)

  • 痛点:别在 HTML 里写死 .col-6 这种类名。如果你想把 6 列改成 4 列,你得改几百个 HTML 文件。

  • 绝招:在 Sass 里用 Mixins 定义布局。

    • HTML 只要保持语义(比如 .main-content)。
    • CSS 里写 @include make-column(8);
  • 结果:改布局只要动一个 CSS 配置文件,HTML 完全不用变。

3. 文档系统的四阶进化

Red Hat 的文档不是一步到位的,他们经历了四个阶段:

  1. 第一阶段:静态页面。写完就没人看了,很快就和代码脱节。
  2. 第二阶段:自动化 Pattern Lab。让文档和代码同步,实现了“活文档”。
  3. 第三阶段:独立组件库。把组件从业务项目里剥离出来,像发 npm 包一样管理,跨项目复用变得极其简单。
  4. 第四阶段:统一渲染引擎
    • 核心:用 JSON Schema 定义数据格式。
    • 效果:不管后端是 PHP 还是 Java,只要按这个 JSON 格式给数据,前端组件就能准确渲染。这彻底解决了前后端对接时的“扯皮”问题,前端成了真正的“界面引擎”。

四、 架构师的心法:BB 鸟与歪心狼

书中提到了两个非常有意思的隐喻,这才是架构设计的最高境界:

1. BB 鸟规则 (Roadrunner Rule)

看过动画片的都知道,BB 鸟跑得飞快,而歪心狼(Coyote)总是背着一堆高科技装备,结果最后都被装备给坑了。

  • 道理:架构要轻量,要解决的是“现在”和“可预见的未来”的问题。
  • 戒律:别为了解决那种万分之一才会出现的特殊情况,把系统搞得无比复杂。别把自己变成那个被装备压垮的歪心狼。

2. 解决“最后一英里”问题

代码写完、测试通过、合并进主干,这就算完了吗?架构师说:还没。

  • 全路径关注
    • 静态资源在 CDN 上刷了吗?
    • 用户的浏览器缓存策略配对了吗?
    • 第三方广告脚本会不会把页面卡死?
    • 在偏远地区、慢网环境下,用户看到的是白屏还是有意义的内容? 架构师得关注从代码仓库到用户浏览器的“每一寸路程”。

五、 写在最后

前端架构不是一个静态的目标,而是一直在变的过程。一个好的架构师得会沟通,在技术理想和业务现实之间找平衡。

说到底,架构就是为了把这四块拼图理顺:

  1. 代码 得够模块化,重构不心慌;
  2. 流程 得够自动化,开发不心累;
  3. 测试 得够全面,上线不背锅;
  4. 文档 得够实时,沟通不扯皮。

如果你能把这几件事干好了,你写的就不只是代码,而是一个能长久活下去、有生命力的系统。这,才是前端架构设计的真谛。

联合光电:与灵智云创签署业务合作框架合同,为其提供机器人产品的组装加工及相关服务

2026年2月12日 20:20
36氪获悉,联合光电公告,公司与武汉灵智云创科技有限公司签署了《业务合作框架合同》,为其提供机器人产品的组装加工及相关服务。合同未约定具体交易金额,按实际业务情况按月结算。交易旨在提升公司产能利用率,符合公司发展战略。

上交所:对双良节能及有关责任人予以监管警示

2026年2月12日 20:06
36氪获悉,上交所发布关于对双良节能系统股份有限公司及有关责任人予以监管警示的决定,其中指出,公司在微信公众号中发布涉及“商业航天”海外订单信息,但未说明相关订单的供货方式、销售规模及对公司整体经营影响较小等具体情况,也未就后续订单的不确定性等情况充分提示风险,可能对投资者决策产生误导,公司直至监管督促后才发布公告予以说明,相关信息发布不准确、不完整,风险提示不充分。上交所决定,对双良节能系统股份有限公司及时任董事会秘书杨力康予以监管警示。

这家机器人公司把“具身数据”塞进1万个背包里

2026年2月12日 20:05

作者丨苏建勋

在具身智能领域,“搞数据”这个事儿,可能是为数不多的共识。

依靠训练巨量数据,大语言模型诞生了Chatgpt,“Scaling Law”也成了AI人的信仰,可在具身智能所属的物理世界,没有互联网上海量的数据参照。不论是人,还是机器人,在现实中的数据量,都不足以复现GPT时刻。

所以,数据怎么搞,能搞到多少,以及要让数据有质量,就成了具身智能从业者当下最重要的工作之一。

最近,就有一家机器人公司想在数据采集上“整点花活儿”。鹿明机器人发布了全球首款背包版UMI数采设备FastUMI Pro(背包版),并计划在2026年投放1万台设备,在工业、家庭、酒店、餐馆、商场、办公等六大真实场景开展系统性数据采集。

全球首款背包版UMI数采设备:鹿明FastUMI Pro(背包版)

简单解释一下“UMI(通用操作接口)”:UMI是斯坦福大学、哥伦比亚大学与丰田研究所联合提出的低成本数据收集与学习框架。区别于市场同行的遥操数采,UMI可以与机器人本体解耦,这就意味着训练出的数据,可以不仅适用于某一家/个机器人形态。

在2026年初的一次媒体交流会上,鹿明机器人创始人兼CEO喻超也聊到UMI和遥操的效率与成本对比:

“同样是像叠衣服这样的事情,遥操作数据采集,需要花 50 秒,成本是3-5元,如果是用FastUMI Pro的方式去采集,只要 10 秒,成本<0.6元,这样的话其实采集的效率能大大提升,成本更低。”

鹿明机器人成立于2024年9月,创始人喻超是前追觅具身机器人业务负责人,拥有近10年具身机器人研发经验,主导了小米CyberDog的研发和千台量产。联席CTO丁琰是大陆最早做UMI的人,首次将UMI从实验室带向工业界。

有量,也要有质

2025年,鹿明通过自建数采中心的方式,已实现10万小时的数据产能。喻超判断,2026年,头部具身模型的数据规模预计100万小时起。

而鹿明在2026年最重要的目标,就是建立年采集百万小时的UMI数据产能。这意味着,鹿明需要用更具规模化的手段,采集更多数据。

“机器人训练数据本不应该如此昂贵和稀缺。人类在物理世界作业过程中产生的数据无处不在,只是没有被很好地收集。”喻超表示。

背包版FastUMI正是为解决这一问题而生——它是一款便携的标准数采工作站,可将真实场景操作高效转化为高质量训练数据。

此前具身数据采集,大多依靠实验室或单一场景采集,这就会导致一个问题:机器人在采集时往往只在一个场景下重复几个动作,这样得到的数据就会缺乏多样性,也会影响模型的泛化能力。

因此,鹿明机器人希望采用更轻便的数据采集方式,将采集工具直接装进背包,让真实场景的数据采集门槛更低。

在具体场景上,鹿明机器人希望覆盖工业、家庭、酒店、餐馆、商场、办公六大核心场景,细分30个小类任务,构建结构化、多维度的操作数据体系。

“采–训–推”一体化闭环能力是鹿明数据基础设施的核心。此次规模化数据采集的启动,正依托于这一已全面打通的基建体系:依托FastUMI Pro,鹿明双臂具身机器人MOS在5小时内完成从“数据采集-策略训练-模型推理”的工厂质检全流程验证;FastUMI Pro在合肥实地部署后,仅用7小时便跑通真实场景下的采集、训练与部署推理。

FastUMI Pro在分拣零部件任务中,完成“数据采集-策略训练-模型推理”闭环

要训模型,数据先行

有了背包式的采集工具以外,鹿明还做了一件事,他们建了一个“数据超市”,把采集到的数据变成了可以流通的标准产品,让客户可以直接在官网上采购标准化操作数据。可以看出,作为一家具身智能公司,鹿明当下的公司战略重点,都围绕“数据”。

鹿明机器人的一系列动作背后,实际上反映了具身智能当下“最痛”的业务需求。

在年初的媒体沟通会上,鹿明机器人联席CTO丁琰就对《智能涌现》等媒体分享了他对于数据和模型的感悟。

“我就是做模型出身的,我之前一直在训模型,当时我们在做的时候就发现一个很大的问题。“丁琰说到,”要想训一个非常好的模型,必须要有一个很好的数据管线,包括数据生产、数据评估、数据筛选,建立的过程本身就需要时间。“

在摸清行业真实发展现状后,丁琰和团队当时就决定,模型和数据二选一的话,肯定先选数据,不能上来就训模型。

”因为模型架构拼到最后,大家拼的不是模型架构,而是模型数据的质量,这是一个行业的共识。“丁琰说到。

具身智能的能力上限高度依赖真实操作数据的规模与质量,当通用数据可以像硬件一样在线下单,行业模型训练门槛被显著拉低,具身智能才能从定制化探索走向工程化生产。

从“万台设备同步开采”到“通用数据电商下单”,鹿明正将“无处不在却未被收集”的物理世界操作数据,变为可规模供给的标准化基础设施,并以此构建数据驱动的生态系统。当数据不再稀缺,机器人才真正走向通用。

end

❌
❌