普通视图

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

对话奔驰高管:AI 上车之后,豪华品牌如何重新定义智能化?

2026年4月30日 21:31

几乎每个上周去过北京车展的朋友,都会跟我说起,这次在梅赛德斯-奔驰的展区,感受到了一些特殊的气质。

这种气质首先来自审美。在当下新能源市场竞争空前激烈的背景下,过于追求原创和先锋设计几乎等同于和市场对着干。在这种环境下,奔驰带来的那些经典车型的设计,仍然会让人由衷地觉得百看不腻。

而在经典设计之外,今年梅奔在智能化领域更是带来了一系列突破。

在高阶辅助驾驶领域,全新交付的纯电 CLA 现已支持城区及高速辅助驾驶全国可用,年内力争实现「车位到车位」功能,一举从外界印象里的「传统燃油车」,变成一个也能上桌做压轴题的选手。

在座舱层面,梅奔中国研发团队主导在全新后排娱乐系统部署的 VLM 大模型,将带来更丰富的车内多模态感知和理解能力。用用梅赛德斯-奔驰集团股份公司首席软件官欧孟宇(Magnus Östberg)先生的话来说,这是一种「从容不迫」的智能化体验——不需要主动发出指令,技术也能通过主动且不打扰的方式服务用户。

据了解,这套由中国团队主导研发的全新后排娱乐系统,未来也将会输出到全球市场。

更为难得的是,无论是公开发布会还是私下媒体沟通里,梅奔内部从不讳言这些科技背后有来自中国的「供应链技术」——无论智驾合作伙伴 Momenta,还是座舱领域的伙伴字节、高德、清华、腾讯。这在如今强调「自研」和技术标签的车圈竞争里,也算是一股清流。

在北京车展媒体日,极客公园作者和其他 4 家媒体一起,和梅赛德斯-奔驰两位核心技术高管进行了一场对谈。我们从技术谈起,话题涉及产品功能、市场竞争、产业合作甚至关于 AI 时代商业合作范式的变化推演,以下为本场媒体对话的文字版。

其中,Q 代表媒体提问,M 代表两位梅奔高管的回答。两位受访者分别为:

  • 梅赛德斯-奔驰集团股份公司首席软件官欧孟宇(Magnus Östberg)
  • 梅赛德斯-奔驰(中国)投资有限公司执行副总裁、梅赛德斯-奔驰中国研发自动驾驶及车联网负责人王忻

问答均在保留原意的基础上,经过极客公园编辑整理。

梅奔首席软件官欧孟宇(Magnus Östberg) | 图片来源:梅赛德斯-奔驰

 

不拼参数的智能化体验

 

Q:AI 怎样落实到日常出行体验中?如何理解智能化和豪华之间的关系?

M:所谓的「数字豪华」,应该交付给用户一种「从容不迫」的体验。我们希望用户和车之间的交互形态,不仅是主动发出指令,技术也能以更自然、更主动、不打扰的方式服务用户。

举个例子,我们和清华大学联合打造的端侧 VLM 大模型将被应用在新一代 S 级轿车的智能座舱里。在那样一台长轴距的行政轿车后排,用户操控屏幕并不方便。系统可以通过对手势、视觉信息和多模态信号的理解,让交互变得更自然、更优雅。

 

Q:梅奔在车展宣布,新一代的 S 级轿车和新一代迈巴赫 S 级轿车上都搭载了城区领航辅助驾驶。这两款车的用户对舒适度要求可能比智能驾驶还高,你们怎么平衡这些需求?

M:这里其实有两个核心问题:第一,在燃油车上搭载高阶辅助驾驶;第二,在豪华车型上同时满足智能化和舒适化。

首先,燃油车和纯电动车在车辆动态特性上有很大不同,电机和内燃机的动力响应不同。因此,我们确实为此做了很多额外工作,确保燃油车上的辅助驾驶体验同样平顺、自然。

此外,燃油车的变速箱也会带来挑战。我们尽可能通过软件,把电动车上已经形成的调校曲线迁移过来。不可能完全一样,但我们会让它尽可能接近电动车上的体验。

所以,如果用户喜欢 V12 发动机的体验、喜欢 AMG,他们不需要在「智能」和「性能」之间做选择。这将是奔驰的一个重要差异。

新一代 S 级轿车上将搭载奔驰和 Momenta 共同开发的城区及高速领航辅助驾驶 | 图片来源:极客公园

 

中国主导,服务全球

 

Q:在中国市场,速度很重要;但奔驰又是一家对安全和标准非常谨慎的公司。这两者怎么平衡?

M:我们一直说的是「中国速度,奔驰标准」。中国市场变化很快,所以我们会更早地和本土战略合作伙伴共创,把新的想法做出来。但这些功能最终上车前,仍然要经过奔驰标准的测试和认证,确保按时、按质交付给用户。

 

Q:作为一家全球车企,梅奔中国和德国总部之间是如何分工以及配合的?

M:我们的标准很简单:如果某项功能在中国开发能够做得最快、最好,那就由中国团队引领。比如泊车功能就是由中国团队主导全球研发,全新高端后排娱乐系统也是先由中国团队完成,再服务全球市场。

由中国团队主导研发的全新高端后排娱乐系统 | 图片来源:梅赛德斯-奔驰

 

Q:但外界有一种担心:德国标准会不会让奔驰在中国市场显得慢半拍?

M:德国标准不是死板的规则,关键是理解它为什么存在。有些标准是安全红线,不能跨;但有些具体体验,可以结合中国道路和用户场景调整。比如辅助驾驶限速,我们既会遵守规则,也会参考高德提供的经验速度,以及车辆通过环境感知看到的车流速度,让系统更符合真实道路环境。

在研发过程里,我们会讨论什么样的「节奏」是最合适的。对于梅赛德斯-奔驰而言,安全承诺永远是第一位。因此,有时我们会有意把某些功能发布得稍晚一些,因为我们必须确保它符合奔驰的安全承诺。

 

Q:中国和欧洲在 AI 等新技术上的「时间差」,会不会给奔驰带来压力?

M:这是我们之所以要在中国设立研发团队的原因——我们喜欢中国的这种速度。

中国团队可以帮助我们把在中国实现的想法带向全球。当然,我们也可以把全球其他市场的优秀能力带到中国,这是双向的。

梅奔中国执行副总裁、研发自动驾驶及车联网负责人王忻 | 图片来源:梅赛德斯-奔驰

 

Q:过去 5 年,汽车智能化层面出现了非常多新的变量和名词。你们认为最核心的变化是什么?

M:过去几年最核心的变化也许不在产品功能,而是数字化。

我们在全球各个市场都建立了完整的数字化基础架构。有了这个基础架构,梅赛德斯-奔驰可以更全面了解客户如何使用车辆。

例如,我们会关注功能的使用率。如果某项功能使用率很高,说明它确实有价值;如果使用率很低,我们要判断到底是我们没做好,还是这个功能本身并不是用户真正需要的。我们希望每次提供的新功能,都是用户日常能用到的真需求。

 

AI 上车,不是 Token 竞赛

 

Q:奔驰有 140 年的历史,经历过很多技术变革。您认为这轮 AI 带来的技术变革,可以和汽车史上的哪一次重要技术变革进行类比?例如发动机电喷,或者汽车电子化?

M:我认为这不是只发生在汽车工业内部的变革,而是整个行业的变革。我认为 AI 革命的重要性,相当于人类第一次利用电力。而且这一次变化发生的速度,可能还要快 10 倍。

 

Q:过去几年,在中国市场谈到汽车「智能化」时,往往率先会提到一些智能驾驶公司。但今天奔驰提到了腾讯、字节跳动等互联网巨头。你们认为,随着 AI 爆发,下一步的商业形态和合作关系会发生哪些变化?

M:我认为现在是围绕 Token 的疯狂竞赛(mad race of Tokens)阶段。所以,大模型公司和硬件/芯片公司在这轮 Token 竞赛中获益最多。

但我预计,未来会有一些更聪明的解决方案出现,打破这种 Token 竞赛模式。奔驰关心的是技术能否真正给客户带来价值,而不是模型能生成多少 Token。因此,也许我们也会与一些新的 AI 初创公司合作。

 

Q:如果不是单纯拼模型和 Token,下一阶段车企和科技公司的合作核心会是什么?

M:数据可能会变得更重要。无论是语音、座舱,还是辅助驾驶,技术路线变化都会带来合作伙伴变化。

谁能提供更准确、更有用的数据,谁就可能改变合作方式。未来的合作关系,可能不只是采购一个模型或一个硬件,而是围绕数据掌握、数据使用和数据运营重新组织。

智度股份:两家海外子公司涉诉事项仍在司法程序中,陆宏达已辞任公司董事长、董事

2026年4月30日 20:38
36氪获悉,智度股份公告,公司关注到网络媒体发布海外子公司涉诉报道,现澄清说明:下属三家海外子公司于2025年11月被起诉,2026年2月法院已认可管辖权异议并驳回原告起诉。原告随后对其中两家公司重新提起诉讼,目前仍在司法程序中。该诉讼与公司生产经营无关,原告未提出具体金额的损害赔偿请求,未达临时公告披露标准。公司各项经营活动正常,不存在应披露而未披露的重大信息。陆宏达已辞任公司董事长、董事,经核实,关于其辞任,公司不存在应披露而未披露的重大事项。

网格中得分最大的路径

2026年4月24日 10:12

方法一:动态规划

思路与算法

题目要求我们在总花费不超过 $k$ 的情况下,找到一条从 $\textit{grid}[0][0]$ 到 $\textit{grid}[m-1][n-1]$ 的路径,使得获得的分数最大。这种有限制的最优化问题结构类似背包问题,可以用动态规划解决。

定义状态 $\textit{dp}[i][j][c]$ 表示到达位置 $(i,j)$,当前花费为 $c$ 时的最大得分。

我们从当前格子向后转移,即从 $(i,j)$ 出发,可以向下或向右移动,将下一个格子的代价和分数加入:

  • 向下:转移到 $(i+1,j)$
  • 向右:转移到 $(i,j+1)$

状态转移为:

$$
\begin{aligned}
\textit{dp}[i+1][j][c + \textit{cost}(i+1,j)] &= \max(\textit{dp}[i+1][j][c + \textit{cost}(i+1,j)],\textit{dp}[i][j][c] + \textit{grid}[i+1][j]) \
\textit{dp}[i][j+1][c + \textit{cost}(i,j+1)] &= \max(\textit{dp}[i][j+1][c + \textit{cost}(i,j+1)],\textit{dp}[i][j][c] + \textit{grid}[i][j+1])
\end{aligned}
$$

其中:

$$
\textit{cost}(i,j) =
\begin{cases}
1, & \textit{grid}[i][j] \neq 0 \
0, & \textit{grid}[i][j] = 0
\end{cases}
$$

初始状态为 $\textit{dp}[0][0][0] = 0$(起点不计入得分和花费)。

最终答案为:

$$
\max\limits_{0 \le c \le k} \textit{dp}[m-1][n-1][c]
$$

代码

###C++

class Solution {
public:
    int maxPathScore(vector<vector<int>>& grid, int k) {
        int m = grid.size();
        int n = grid[0].size();
        vector<vector<vector<int>>> dp(
            m, vector<vector<int>>(n, vector<int>(k + 1, INT_MIN)));
        dp[0][0][0] = 0;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                for (int c = 0; c <= k; c++) {
                    if (dp[i][j][c] == INT_MIN)
                        continue;
                    if (i + 1 < m) {
                        int val = grid[i + 1][j];
                        int cost = (val == 0 ? 0 : 1);
                        if (c + cost <= k) {
                            dp[i + 1][j][c + cost] =
                                max(dp[i + 1][j][c + cost], dp[i][j][c] + val);
                        }
                    }
                    if (j + 1 < n) {
                        int val = grid[i][j + 1];
                        int cost = (val == 0 ? 0 : 1);
                        if (c + cost <= k) {
                            dp[i][j + 1][c + cost] =
                                max(dp[i][j + 1][c + cost], dp[i][j][c] + val);
                        }
                    }
                }
            }
        }
        int ans = INT_MIN;
        for (int c = 0; c <= k; c++) {
            ans = max(ans, dp[m - 1][n - 1][c]);
        }
        return ans < 0 ? -1 : ans;
    }
};

###Java

class Solution {
    public int maxPathScore(int[][] grid, int k) {
        int m = grid.length;
        int n = grid[0].length;

        int[][][] dp = new int[m][n][k + 1];
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                Arrays.fill(dp[i][j], Integer.MIN_VALUE);
            }
        }

        dp[0][0][0] = 0;

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                for (int c = 0; c <= k; c++) {
                    if (dp[i][j][c] == Integer.MIN_VALUE) continue;

                    if (i + 1 < m) {
                        int val = grid[i + 1][j];
                        int cost = (val == 0 ? 0 : 1);
                        if (c + cost <= k) {
                            dp[i + 1][j][c + cost] = Math.max(
                                dp[i + 1][j][c + cost],
                                dp[i][j][c] + val
                            );
                        }
                    }

                    if (j + 1 < n) {
                        int val = grid[i][j + 1];
                        int cost = (val == 0 ? 0 : 1);
                        if (c + cost <= k) {
                            dp[i][j + 1][c + cost] = Math.max(
                                dp[i][j + 1][c + cost],
                                dp[i][j][c] + val
                            );
                        }
                    }
                }
            }
        }

        int ans = Integer.MIN_VALUE;
        for (int c = 0; c <= k; c++) {
            ans = Math.max(ans, dp[m - 1][n - 1][c]);
        }

        return ans < 0 ? -1 : ans;
    }
}

###Python3

class Solution:
    def maxPathScore(self, grid, k):
        m, n = len(grid), len(grid[0])

        INF = float('-inf')
        dp = [[[INF] * (k + 1) for _ in range(n)] for _ in range(m)]
        dp[0][0][0] = 0

        for i in range(m):
            for j in range(n):
                for c in range(k + 1):
                    if dp[i][j][c] == INF:
                        continue

                    if i + 1 < m:
                        val = grid[i + 1][j]
                        cost = 0 if val == 0 else 1
                        if c + cost <= k:
                            dp[i + 1][j][c + cost] = max(
                                dp[i + 1][j][c + cost],
                                dp[i][j][c] + val
                            )

                    if j + 1 < n:
                        val = grid[i][j + 1]
                        cost = 0 if val == 0 else 1
                        if c + cost <= k:
                            dp[i][j + 1][c + cost] = max(
                                dp[i][j + 1][c + cost],
                                dp[i][j][c] + val
                            )

        ans = max(dp[m - 1][n - 1])
        return -1 if ans < 0 else ans

###C

int maxPathScore(int** grid, int m, int* gridColSize, int k) {
    int n = gridColSize[0];

    int*** dp = (int***)malloc(m * sizeof(int**));
    for (int i = 0; i < m; i++) {
        dp[i] = (int**)malloc(n * sizeof(int*));
        for (int j = 0; j < n; j++) {
            dp[i][j] = (int*)malloc((k + 1) * sizeof(int));
            for (int c = 0; c <= k; c++) {
                dp[i][j][c] = INT_MIN;
            }
        }
    }

    dp[0][0][0] = 0;

    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            for (int c = 0; c <= k; c++) {
                if (dp[i][j][c] == INT_MIN) continue;

                if (i + 1 < m) {
                    int val = grid[i + 1][j];
                    int cost = val == 0 ? 0 : 1;
                    if (c + cost <= k) {
                        int* target = &dp[i + 1][j][c + cost];
                        if (*target < dp[i][j][c] + val)
                            *target = dp[i][j][c] + val;
                    }
                }

                if (j + 1 < n) {
                    int val = grid[i][j + 1];
                    int cost = val == 0 ? 0 : 1;
                    if (c + cost <= k) {
                        int* target = &dp[i][j + 1][c + cost];
                        if (*target < dp[i][j][c] + val)
                            *target = dp[i][j][c] + val;
                    }
                }
            }
        }
    }

    int ans = INT_MIN;
    for (int c = 0; c <= k; c++) {
        if (dp[m - 1][n - 1][c] > ans)
            ans = dp[m - 1][n - 1][c];
    }

    return ans < 0 ? -1 : ans;
}

###Golang

func maxPathScore(grid [][]int, k int) int {
    m, n := len(grid), len(grid[0])

    const INF = math.MinInt32

    dp := make([][][]int, m)
    for i := range dp {
        dp[i] = make([][]int, n)
        for j := range dp[i] {
            dp[i][j] = make([]int, k+1)
            for c := range dp[i][j] {
                dp[i][j][c] = INF
            }
        }
    }

    dp[0][0][0] = 0

    for i := 0; i < m; i++ {
        for j := 0; j < n; j++ {
            for c := 0; c <= k; c++ {
                if dp[i][j][c] == INF {
                    continue
                }

                if i+1 < m {
                    val := grid[i+1][j]
                    cost := 0
                    if val != 0 {
                        cost = 1
                    }
                    if c+cost <= k {
                        if dp[i+1][j][c+cost] < dp[i][j][c]+val {
                            dp[i+1][j][c+cost] = dp[i][j][c] + val
                        }
                    }
                }

                if j+1 < n {
                    val := grid[i][j+1]
                    cost := 0
                    if val != 0 {
                        cost = 1
                    }
                    if c+cost <= k {
                        if dp[i][j+1][c+cost] < dp[i][j][c]+val {
                            dp[i][j+1][c+cost] = dp[i][j][c] + val
                        }
                    }
                }
            }
        }
    }

    ans := INF
    for c := 0; c <= k; c++ {
        if dp[m-1][n-1][c] > ans {
            ans = dp[m-1][n-1][c]
        }
    }

    if ans < 0 {
        return -1
    }
    return ans
}

###C#

public class Solution {
    public int MaxPathScore(int[][] grid, int k) {
        int m = grid.Length, n = grid[0].Length;

        int[,,] dp = new int[m, n, k + 1];

        for (int i = 0; i < m; i++)
            for (int j = 0; j < n; j++)
                for (int c = 0; c <= k; c++)
                    dp[i, j, c] = int.MinValue;

        dp[0, 0, 0] = 0;

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                for (int c = 0; c <= k; c++) {
                    if (dp[i, j, c] == int.MinValue) continue;

                    if (i + 1 < m) {
                        int val = grid[i + 1][j];
                        int cost = val == 0 ? 0 : 1;
                        if (c + cost <= k) {
                            dp[i + 1, j, c + cost] = Math.Max(
                                dp[i + 1, j, c + cost],
                                dp[i, j, c] + val
                            );
                        }
                    }

                    if (j + 1 < n) {
                        int val = grid[i][j + 1];
                        int cost = val == 0 ? 0 : 1;
                        if (c + cost <= k) {
                            dp[i, j + 1, c + cost] = Math.Max(
                                dp[i, j + 1, c + cost],
                                dp[i, j, c] + val
                            );
                        }
                    }
                }
            }
        }

        int ans = int.MinValue;
        for (int c = 0; c <= k; c++) {
            ans = Math.Max(ans, dp[m - 1, n - 1, c]);
        }

        return ans < 0 ? -1 : ans;
    }
}

###JavaScript

var maxPathScore = function(grid, k) {
    const m = grid.length, n = grid[0].length;

    const INF = -Infinity;
    const dp = Array.from({ length: m }, () =>
        Array.from({ length: n }, () => Array(k + 1).fill(INF))
    );

    dp[0][0][0] = 0;

    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            for (let c = 0; c <= k; c++) {
                if (dp[i][j][c] === INF) continue;

                if (i + 1 < m) {
                    const val = grid[i + 1][j];
                    const cost = val === 0 ? 0 : 1;
                    if (c + cost <= k) {
                        dp[i + 1][j][c + cost] = Math.max(
                            dp[i + 1][j][c + cost],
                            dp[i][j][c] + val
                        );
                    }
                }

                if (j + 1 < n) {
                    const val = grid[i][j + 1];
                    const cost = val === 0 ? 0 : 1;
                    if (c + cost <= k) {
                        dp[i][j + 1][c + cost] = Math.max(
                            dp[i][j + 1][c + cost],
                            dp[i][j][c] + val
                        );
                    }
                }
            }
        }
    }

    let ans = Math.max(...dp[m - 1][n - 1]);
    return ans < 0 ? -1 : ans;
};

###TypeScript

function maxPathScore(grid: number[][], k: number): number {
    const m = grid.length, n = grid[0].length;

    const INF = -Infinity;
    const dp: number[][][] = Array.from({ length: m }, () =>
        Array.from({ length: n }, () => Array(k + 1).fill(INF))
    );

    dp[0][0][0] = 0;

    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            for (let c = 0; c <= k; c++) {
                if (dp[i][j][c] === INF) continue;

                if (i + 1 < m) {
                    const val = grid[i + 1][j];
                    const cost = val === 0 ? 0 : 1;
                    if (c + cost <= k) {
                        dp[i + 1][j][c + cost] = Math.max(
                            dp[i + 1][j][c + cost],
                            dp[i][j][c] + val
                        );
                    }
                }

                if (j + 1 < n) {
                    const val = grid[i][j + 1];
                    const cost = val === 0 ? 0 : 1;
                    if (c + cost <= k) {
                        dp[i][j + 1][c + cost] = Math.max(
                            dp[i][j + 1][c + cost],
                            dp[i][j][c] + val
                        );
                    }
                }
            }
        }
    }

    const ans = Math.max(...dp[m - 1][n - 1]);
    return ans < 0 ? -1 : ans;
}

###Rust

impl Solution {
    pub fn max_path_score(grid: Vec<Vec<i32>>, k: i32) -> i32 {
        let m = grid.len();
        let n = grid[0].len();
        let k = k as usize;

        let inf = i32::MIN / 2;
        let mut dp = vec![vec![vec![inf; k + 1]; n]; m];

        dp[0][0][0] = 0;

        for i in 0..m {
            for j in 0..n {
                for c in 0..=k {
                    if dp[i][j][c] == inf {
                        continue;
                    }

                    if i + 1 < m {
                        let val = grid[i + 1][j];
                        let cost = if val == 0 { 0 } else { 1 };
                        if c + cost <= k {
                            dp[i + 1][j][c + cost] =
                                dp[i + 1][j][c + cost].max(dp[i][j][c] + val);
                        }
                    }

                    if j + 1 < n {
                        let val = grid[i][j + 1];
                        let cost = if val == 0 { 0 } else { 1 };
                        if c + cost <= k {
                            dp[i][j + 1][c + cost] =
                                dp[i][j + 1][c + cost].max(dp[i][j][c] + val);
                        }
                    }
                }
            }
        }

        let mut ans = inf;
        for c in 0..=k {
            ans = ans.max(dp[m - 1][n - 1][c]);
        }

        if ans < 0 { -1 } else { ans }
    }
}

复杂度分析

  • 时间复杂度:$O(mnk)$,其中 $m$,$n$ 分别是矩阵 $\textit{grid}$ 的行数和列数。
  • 空间复杂度:$O(mnk)$。

安车检测:2025年归母净亏损1.78亿元

2026年4月30日 20:32
36氪获悉,安车检测发布2025年业绩报告。报告显示,2025年实现营业收入4.39亿元,同比下降2.03%;归属于上市公司股东的净利润亏损1.78亿元,上年同期亏损2.13亿元;基本每股收益-0.88元。公司拟向全体股东每10股派发现金红利0元(含税),送红股0股(含税),以资本公积金向全体股东每10股转增0股。

片仔癀:控股股东增持计划实施完毕,累计增持约5亿元

2026年4月30日 20:11
36氪获悉,片仔癀公告,公司控股股东九龙江集团自2026年2月1日至4月30日,通过集中竞价交易方式累计增持公司股份317.13万股,占总股本0.53%,累计增持金额约5亿元,达到增持计划下限要求。本次增持计划已实施完毕,完成后九龙江集团合计持股3.11亿股,持股比例51.50%。

深入 HTML-in-Canvas:当 Canvas 学会了渲染 DOM,前端图形生态要变天了

2026年4月30日 20:08

你有没有想过一个问题:为什么 Canvas 里的文字永远那么丑?为什么游戏里的 UI 只能用 Canvas API 手画,而不能直接写个 <div> 上去?为什么每次做图表都要在 ctx.fillText 和 CSS 字体之间反复拉扯?今天,一个正在 Chromium 中孵化的 WICG 提案,要彻底终结这个延续了二十年的困局。

📋 目录

背景:Canvas 二十年的「盲人」困境

2004 年,Apple 在 Safari 中引入了 <canvas> 元素,随后被 WHATWG 和 W3C 标准化。二十多年来,Canvas 成为 Web 上最强大的 2D/3D 图形基元——游戏、图表、数据可视化、创意工具、图像编辑……几乎一切"像素级别的操作"都跑在 Canvas 上。

但它有一个致命的短板:渲染不了真正的 HTML 内容

这听起来像是一个不应该存在的问题——我明明有一个 <div>,为什么不能把它「画」到 Canvas 上?但现实是,开发者们二十年来的解决方案只有这些:

方案 原理 问题
ctx.fillText() 手写文字排版 不支持复杂文本、RTL、国际化排版
html2canvas / dom-to-image 用 JS 重绘整个渲染树 慢(JS 模拟渲染引擎)、不完整、不全准
SVG <foreignObject> 在 SVG 中嵌入 HTML 无法与 Canvas 2D API 交互、不支持 WebGL/WebGPU
截图上传(你没看错) 手动截图当纹理 根本不能算方案

这些问题带来的连锁反应非常具体:

  • 可访问性灾难:Canvas fallback 内容和实际画出来的像素,本质上没有约束关系。开发者写了一个披着 aria-label 外衣的 <canvas>,但里面到底画了什么,屏幕阅读器和实际视觉内容完全是两套。
  • 国际化短板ctx.fillText 不处理 RTL(阿拉伯语/希伯来语)、竖排文字、复杂脚本连字。如果你的图表需要显示阿拉伯语标签,要么自己实现排版引擎,要么放弃。
  • 游戏 UI 的割裂:3D 场景中的 2D 界面(菜单、对话气泡、HUD),要么用 3D 引擎的内置 UI 系统(学习成本高),要么用 Canvas 手绘(质量差),要么用 DOM 覆盖层(无法与 3D 场景融合)。

HTML-in-Canvas 提案的目标就是:让开发者能把真正的 DOM 元素渲染到 Canvas 上,用浏览器的原生排版引擎干活

前传:为什么 Canvas 一直渲染不了 HTML

在深入 API 之前,先搞明白一个核心问题:为什么这件事以前做不到?

浏览器的渲染流水线大致是这样的:

JS → Style → Layout → Paint → Composite
  • Style:计算 CSS 规则
  • Layout:计算盒模型位置
  • Paint:生成绘制指令列表(display list)
  • Composite:合成图层

Canvas 的渲染是脱离这个流水线的。Canvas 的内容通过 JS 调用 Canvas API(fillRectdrawImage 等)写入一个位图缓冲区,然后直接作为一个纹理交给 GPU。浏览器的渲染引擎(Paint/Composite)对 Canvas 内部发生了什么一无所知。

而 HTML 元素的渲染走的是完整的 Style → Layout → Paint → Composite 管道。

所以,要把 HTML 渲染到 Canvas 上,本质上是要让浏览器的渲染管道和 Canvas 的像素缓冲区之间建立一座桥——而且这座桥不能破坏安全模型,不能引入性能问题,还要支持可访问性。

这比听起来难得多。直到 WICG/html-in-canvas 提出了一套优雅的解决方案。

核心原语一:layoutsubtree——一纸「委任状」

提案的第一个原语是一个 HTML 属性——layoutsubtree

<canvas id="myCanvas" layoutsubtree>
  <div id="myContent">
    <h2>Hello Canvas!</h2>
    <p>我可以在 Canvas 里用 HTML 渲染了!</p>
  </div>
</canvas>

加上这个属性的瞬间,发生了三件关键的事情:

  1. Canvas 的子元素获得了 stacking context,成为了其后代元素的 containing block
  2. Canvas 的子元素拥有了 paint containment(绘制包含)
  3. Canvas 的子元素参与了正常布局和 hit testing

翻译成人话:Canvas 的孩子虽然还在 DOM 树里,但它们的视觉渲染被「截胡」了——浏览器的 Paint 阶段不会再把这些孩子渲染到屏幕上,而是把它们的绘制结果存起来,等着开发者用 API 取走。 同时,它们仍然参与布局、可访问性树和事件命中测试。

这个设计有一个非常精妙的双重角色:同一个元素既是视觉内容(被绘制到 Canvas 中),也是可访问性内容(作为 Canvas fallback)。不像现在的 <canvas> fallback——画出来的东西和 fallback 内容是两套,永远有不同步的风险。在 HTML-in-Canvas 中,它们就是同一个东西。

layoutsubtree 就像是一纸「委任状」:告诉浏览器「这些孩子交给我来画,但请帮我把它们的布局和绘制结果准备好。」

核心原语二:drawElementImage——画布上的「复印机」

有了 layoutsubtree 把布局和可访问性安排好,下一步就是把子元素「印」到 Canvas 上。这就是 drawElementImage()

const ctx = canvas.getContext('2d');

canvas.onpaint = () => {
  ctx.reset();
  // 把 form_element 画到 Canvas 的 (100, 0) 位置
  const transform = ctx.drawElementImage(form_element, 100, 0);
  // 同步 DOM 位置
  form_element.style.transform = transform.toString();
};

核心行为

  • 只接受 Canvas 的直接子元素(这就是 layoutsubtree 标记的那些孩子)
  • 调用时,返回一个 DOMMatrix(CSS transform 矩阵),你需要把这个矩阵应用到元素的 style.transform 上,让 DOM 位置和画上去的位置保持一致
  • Canvas 的当前变换矩阵(CTM,Current Transformation Matrix)会作用于绘制——也就是说,你可以在画布上 ctx.rotate(45) 然后 drawElementImage,元素就会旋转
  • 子元素的 CSS transform 被忽略(原因见下文——如果不忽略会导致双重变换)
  • 溢出内容被裁切到元素的 border box

destination rect 参数(和 drawImage 一模一样):

// 最简形式:在 (x, y) 处以原始尺寸绘制
ctx.drawElementImage(element, x, y);

// 指定目标尺寸
ctx.drawElementImage(element, x, y, width, height);

// 带 source rect 裁剪
ctx.drawElementImage(element, sx, sy, sw, sh, dx, dy, dw, dh);

WebGL 版本的接口texElementImage2D,把元素渲染到纹理:

// 当你需要把 HTML 内容作为 3D 纹理时
gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, myElement);

WebGPU 版本的接口copyElementImageToTexture

queue.copyElementImageToTexture(myElement, destination);

一个 API,覆盖 2D Canvas、WebGL、WebGPU 三大图形上下文。

形象一点理解:drawElementImage 就像是把浏览器的渲染引擎当作一台复印机,你传一个 DOM 元素进去,它返回一页「复印件」——而且附带一个坐标映射表(DOMMatrix),告诉你怎么把这页「复印件」在画布上的位置同步给 DOM。

核心原语三:paint 事件——智能触发器

drawElementImage 画的是快照。问题来了:当元素的内容发生变化时(比如输入框里有文字输入),开发者怎么知道需要重新绘制?

这就是 paint 事件的用武之地:

canvas.addEventListener('paint', (event) => {
  // event.changedElements 包含渲染发生了变化的子元素
  ctx.reset();
  for (const el of event.changedElements) {
    const t = ctx.drawElementImage(el, 0, 0);
    el.style.transform = t.toString();
  }
});

关键特性

  1. 智能触发:只有当子元素的视觉渲染真正发生变化时才会触发,而不是 60fps 无脑循环。省电、省 CPU。
  2. 时机:在浏览器每一帧渲染管线的 update-the-rendering 阶段中,紧跟在 intersection observer 步骤之后、Paint 步骤之前触发。
... → IntersectionObserver → paint event → Paint → Composite → ...
  1. CSS transform 变化不触发:因为 transform 影响的是位置而非渲染内容,改变 transform 不会重新生成 paint 指令,所以不触发 paint 事件。
  2. paint 内的 DOM 改动推迟到下一帧:你在 paint 回调里改了元素的 class/文本,这一帧不会生效,下一帧才会。
  3. requestPaint():如果你需要每帧都重绘(类似游戏循环),可以调用 canvas.requestPaint() 强制触发 paint 事件,行为和 requestAnimationFrame 类似。

Bonus:captureElementImage——通往 Worker 的传送门

有一个问题:上面的所有 API 都依赖 DOM 元素引用,但 Web Worker 中无法访问 DOM

解决方案是 captureElementImage()

// 主线程:捕获快照并传送到 Worker
canvas.onpaint = () => {
  const elementImage = canvas.captureElementImage(form_element);
  worker.postMessage({ elementImage }, [elementImage]);  // Transferable!
};

// Worker:直接绘制
self.onmessage = (e) => {
  if (e.data.elementImage) {
    ctx.drawElementImage(e.data.elementImage, 100, 0);
  }
};

ElementImage 是一个 Transferable 对象,和 ImageBitmapArrayBuffer 一样,支持零拷贝传输。这为 OffscreenCanvas 在 Worker 中高性能渲染 HTML 内容铺平了道路。

整个对象只有三个方法/属性:

  • width / height:快照的尺寸
  • close():释放资源

轻量、简洁、高效。

深水区:事件循环中的时序博弈

如果说前面的 API 是"皮毛",那下面这部分才是 HTML-in-Canvas 最深的设计决策——paint 事件到底应该在哪一刻触发?

规范文档中记录了三种方案,我们逐一分析:

Option A:在 ResizeObserver 时机触发(带循环)

位置:在 update-the-rendering 流程的第 16.2.6 步(Deliver resize observations),如果 paint 事件中又修改了样式,就循环回到第 16.2.1 步(Recalculate styles and update layout)。

问题

  • 需要在这个时间点同步执行 Paint 步骤来生成子元素的绘制快照。Paint 本身很消耗性能,还要可能跑多次。
  • Gecko(Firefox 的渲染引擎)的架构导致了这里实现困难——某些引擎在这个时间点根本拿不到完整的绘制结果。
  • 最致命的问题:WebGL。WebGL 的 gl.getError()gl.getParameter() 等 API 需要触发 GPU 命令缓冲区刷新(flush),如果在 Paint 完成之前调用,会导致死锁或不一致的渲染状态。

Option B:紧接在 Paint 步骤之后触发(带循环)

位置:在浏览器的 Paint 步骤完成后立即触发。

优势:不需要上面那种"同步 Paint Canvas 子元素"的操作——因为 Paint 已经跑完了,每个元素的绘制结果是可用的。

问题:仍然需要循环。如果 paint event 中改动了 DOM,又得回退到 style recalc → layout → paint 的完整循环,可能在一次帧中跑多次,十分昂贵。

Option C:紧接在 Paint 步骤之后触发(循环)—— 被选中的方案

核心思想:paint event 在一帧中只跑一次

如果开发者在 paint event 中修改了 DOM?改了就改了,但这一帧已经锁死了——DOM 修改的效果留到下一帧的渲染管线去处理。

这带来一个非常有趣的对称性:浏览器的 Paint 步骤也是不可循环的——你无法在一个帧内让浏览器画两次。paint event 的行为和浏览器原生的 Paint 步骤完全对齐。

方案  |  循环  |  是否需要同步 Paint  |  兼容 WebGL  |  复杂度
A     |  是    |  是                  |  否(死锁)  |  高
B     |  是    |  否                  |  是          |  高
C     |  否    |  否                  |  是          |  低 ✅

这个决策过程是 HTML-in-Canvas 提案中最精妙的设计之一。它不强求"开发者改了我就立刻刷新",而是承认一帧内做到绝对实时是不现实的,通过"延迟到下一帧"来换取架构的简洁性和跨浏览器兼容性。

就像 React 的虚拟 DOM 不追求"每次修改立刻更新真实 DOM"一样,HTML-in-Canvas 也不追求"每个 CSS 变化都立刻刷新 Canvas 绘制"。延迟带来一致性。

同步公式:CSS Transform 背后的线性代数

前面提到,drawElementImage() 返回一个 DOMMatrix,需要设置到元素的 style.transform 上。为什么要这么做?

因为浏览器的 hit testing(点击命中测试)、intersection observer、可访问性功能都依赖元素的 DOM 位置。如果你把一个 <div> 画到了 Canvas 的 (100, 200) 位置,但它在 DOM 树中还在原始位置,点击 (100, 200) 就命中不了这个元素。

解决方案是:把 DOM 元素通过 CSS transform 移动到与绘制位置匹配。

drawElementImage() 返回的 DOMMatrix 就是按如下公式计算的:

T_sync = T_origin⁻¹ · S_css→grid⁻¹ · T_draw · S_css→grid · T_origin

其中:

  • T_draw:绘制到 Canvas 上的变换矩阵,等于 CTM · T(x, y) · S(destScale)(CTM + 位置偏移 + 缩放)
  • T_origin:元素的 transform-origin 矩阵
  • S_css→grid:CSS 像素到 Canvas 网格像素的缩放矩阵

直观理解:这个公式做的事情就是把"在 Canvas 网格坐标系中的绘制位置"反向映射回"DOM 中的 CSS 像素位置"

对于 WebGL/WebGPU 中的 3D 场景,还有一个辅助方法 canvas.getElementTransform(element, drawTransform),让你传入任意变换矩阵并计算出对应的 CSS transform。

// 2D Canvas 直接返回
const transform = ctx.drawElementImage(element, x, y);

// WebGL/WebGPU 需要手动计算
const drawTransform = new DOMMatrix([...]); // 你自定义的 3D 变换
const cssTransform = canvas.getElementTransform(element, drawTransform);
element.style.transform = cssTransform.toString();

重要提醒:CSS transform 的变化不会触发 paint 事件——因为 transform 只影响位置,不影响绘制内容,所以 paint event 不会因为你在同步 transform 而反复触发。这避免了死循环。

隐私保护:看不见的边界

drawElementImage() 能让 Canvas 读取 DOM 元素的像素,这就带来了一个安全问题:如果 Canvas 能读取任何元素的内容,那跨域保护怎么办?

提案的隐私模型遵循一个核心理念:drawElementImage 不会暴露任何 JavaScript 当前不可访问的信息。 换句话说,它不会打开新的攻击面。

被排除在绘制之外的敏感内容:

排除项 原因
跨域 iframe、跨域图片 同 Canvas drawImage 的跨域保护一致
CSS url() 引用的跨域资源(如 background-image 同上
系统颜色/主题/偏好 否则可通过像素读取猜出系统主题
拼写/语法检查标记 可能暴露用户的拼写习惯
已访问链接的颜色 经典的隐私泄露向量
自动填充(autofill)预览内容 包含敏感个人信息
次像素抗锯齿 可用作浏览器指纹

不被视为敏感(允许绘制)的内容:

保留项 理由
页面查找(Find in Page)高亮 低安全性影响
滚动条和表单控件外观 已可通过 SVG foreignObject 检测
光标闪烁频率 低熵信息
forced-colors 模式 已可通过 CSS media query 获取

注意:这是预防性设计——在提案还处于 WICG 孵化阶段就考虑了完整的安全模型。这与 W3C TAG 审查(issue #1204)和 WHATWG 标准化讨论中的安全关注点保持一致。

生态地图:谁已经上车了

虽然提案还在孵化中,浏览器端只有 Chrome Canary 和 Brave Stable (Chromium 147+) 通过 flag 支持,但开源社区已经在积极适配:

three.js — 原生 WebGL 纹理集成

mrdoob/three.js 已经在 WebGL 和 WebGPU 两个渲染后端中集成了 HTML-in-Canvas:

// three.js 内部实现(简化)
if ('texElementImage2D' in gl) {
  const canvas = gl.canvas;
  if (!canvas.hasAttribute('layoutsubtree')) {
    canvas.setAttribute('layoutsubtree', 'true');
  }
  // HTML 元素直接作为纹理源
  gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, htmlElement);
}

这意味你可以把任意的 HTML 内容作为 three.js 的纹理,直接贴到 3D 模型上。

相关 PR: mrdoob/three.js#31233

PlayCanvas — 3D 产品配置器 + HtmlSync

PlayCanvas 引擎甚至在官方示例中完整实现了基于 HTML-in-Canvas 的交互式 3D 产品配置器。一个 HTML 面板被渲染为 WebGL 纹理,用户点击 3D 场景中的 HTML 按钮时的 hit testing 完全由浏览器的原生 DOM 事件处理——通过 getElementTransform 同步位置。

关键助手类 HtmlSync 被设计为可复用的工具类,处理 canvas ↔ 3D 平面的坐标映射。

VFX-JS — 视觉特效框架

fand/vfx-js 提供了一个优雅的 addHTML() 方法:

const vfx = new VFX();
await vfx.addHTML(element, { shader: 'liquidGlass' });

它内部先检查 supportsHtmlInCanvas(),如果可用就使用原生 API,否则优雅降级到传统的 dom-to-canvas 方案——渐进增强的最佳实践。

three-html-render — 纯 JS Polyfill

最令人兴奋的生态项目之一是 repalash/three-html-render——一个在浏览器不支持原生 API 时的 Polyfill。它通过 CSS matrix3d() 变换和 iframe / embed 技术模拟了 drawElementImage 的核心行为。

即使你的用户没有启用 chrome://flags/#canvas-draw-element,这个 Polyfill 也能工作。这是一个很聪明的策略——用 Polyfill 降低采用门槛,让框架生产环境可用。

未解之谜与未来方向

提案仍处于活跃讨论中(仓库中有 16 个 open issues),以下几个话题值得关注:

Open Issues 选读

Issue 核心问题
#94 — Hit testing and layer ordering draw 多个元素时,z-index 如何与 hit testing 协调?
#85removedElements 当子元素被删除,paint 事件是否需要提供单独的 removedElements 列表?
#82 — 新的指纹向量 onpaint 事件即使不读取像素,也能通过监听事件频率来获取指纹信息(如光标闪烁频率)
#31 — 动图/视频支持 GIF、WebP 动画、视频元素如何支持?
#47mix-blend-modebackdrop-filter 效果在 Canvas 中未正确反映

未来:自动更新 Canvas

规范文档中提到了一个令人兴奋的未来方向——auto-updating canvas

目前的模型是:你在 paint 事件中调用 drawElementImage,浏览器绘制快照。但如果支持了「自动更新模式」,drawElementImage 会在 Canvas 的命令缓冲区中记录一个"占位符",浏览器可以在滚动或动画更新时自动重新执行绘制,无需阻塞 JS 主线程

这意味着 Canvas 中的 HTML 内容可以和原生滚动完美同步,不再受 JS 事件循环的延迟影响。这个模式对 2D Canvas 已可行,对 WebGPU 也只需少量 API 扩展。

标准化进程

提案正处于标准化流程的以下位置:

WICG 孵化(当前)→ WHATWG Stage 2 → WHATWG Standard → 浏览器默认启用
  • WHATWG Spec PR: #11588
  • W3C TAG 早期审查: #1204(2026年3月启动)
  • 跨浏览器共识:Chromium / Gecko / WebKit 已在设计上达成一致(paint 事件 Option C 时序)

总结

HTML-in-Canvas 不只是 Canvas 的一个新功能——它是 Web 图形平台二十年来最重要的一次基础能力补全。

它的核心贡献不是加了几个 API,而是在浏览器的渲染流水线和 Canvas 的像素缓冲区之间,架起了一座精心设计的桥梁

  • layoutsubtree 用属性声明边界
  • drawElementImage 用返回值解决同步
  • paint 用精妙的时序设计避免死循环和性能灾难
  • captureElementImage 用 Transferable 搞定 Worker 并行

Three primitives + one helper,四个接口把"把 DOM 渲染到 Canvas"从不可能变成了可能——而且是在不破坏现有安全模型、不影响性能、保持可访问性的前提下。

三个核心判断:

  1. 技术设计质量很高:从事件时序的选择(Option C)到隐私模型的预防性设计,到 drawElementImage 的返回值用作 style.transform,每个决策都有清晰的权衡分析。这不是一个"先上线再说"的功能。

  2. 生态已经开始拥抱:three.js、PlayCanvas、VFX-JS 等知名图形项目的积极适配远超预期。尤其在 3D 游戏和可视化领域,需求非常强烈。

  3. 还有一段路要走:目前只在 Chromium flag 后可用,Firefox 和 Safari 还没有明确的实现计划。标准化进程仍在 WICG 阶段。

如果你是图形/可视化方向的开发者,建议立刻打开 Chrome Canary,启用 chrome://flags/#canvas-draw-element,跑一下官方 Demo。虽然它还不是正式标准,但方向已经明确——而且这个方向,可能改变前端图形生态的底层逻辑。


关注 【iDao技术魔方】,获取更多全栈到AI可落地的实战干货。

宁德时代:完成H股配售事项

2026年4月30日 20:07
36氪获悉,宁德时代公告,董事会欣然宣布,配售协议所载配售事项的所有先决条件均已获达成,而配售事项已于2026年4月30日完成。合共62,385,000股新H股(相当于经配发及发行配售股份扩大后已发行H股总数约28.58%)已由本公司于2026年4月30日按每股配售股份628.20港元的配售价成功配发及发行予不少于六名承配人,该等承配人及其最终实益拥有人均为独立第三方(就董事作出一切合理查询后所知、所悉及所信)。

LG新能源一季度营业亏损达2078亿韩元

2026年4月30日 20:01
4月30日,LG新能源公布2026年第一季度财报。一季度合并营收达6.6万亿韩元,环比增长1.2%(该数据包含北美生产激励金约1898亿韩元);同期营业亏损达2078亿韩元。受北美主要客户调整库存影响,软包型电动汽车电池(EV)出货量有所下滑。(界面)

React 19 源码主线拆解 04:Fiber 到底是什么,React 为什么需要 Fiber?

作者 倾颜
2026年4月30日 19:47

这是我持续更新的一组 React 源码解读文章,也会尽量控制单篇篇幅,按主线一点点往里拆。
这一篇先不急着进入 beginWorkcompleteWork 和完整 render 流程,而是先把 React 运行时里最关键的工作节点:Fiber,单独理清楚。

前言

上一篇里,我们已经把 React 主线从 ReactElement 推进到了根级更新系统。

简单回顾一下:

  • createRoot(container) 初始化了 Root 系统
  • root.render(element) 把 ReactElement 送进了根级更新流程
  • ReactElement 会被包装成一次 Update
  • 这次 Update 会挂到 HostRoot Fiber 的 updateQueue

也就是说,主线已经走到了这里:

ReactElement → Update → HostRoot Fiber.updateQueue

到这一步,Fiber 这个词已经绕不开了。

因为继续往后看,很快就会遇到这些问题:

  • HostRoot Fiber 到底是什么?
  • FiberRoot 和 Fiber 是一个东西吗?
  • Update 为什么要挂到 Fiber 的 updateQueue 上?
  • 后面的 render 阶段,为什么不是直接处理 ReactElement,而是围绕 Fiber 展开?
  • commit 阶段为什么又要看 Fiber 上的 flags?

这些问题继续往下追,都会回到一个更基础的问题上:

Fiber 到底是什么?React 为什么需要 Fiber?

所以这一篇不急着进入 beginWork,也不急着讲完整的 render work loop,而是先把 Fiber 这个运行时工作节点本身讲清楚。

这篇文章主要想回答几个问题:

  • Fiber 到底是什么,为什么不能把它理解成 ReactElement
  • React 为什么需要 Fiber 这样的运行时工作节点
  • FiberRoot、HostRoot Fiber、FiberNode、Fiber 树分别是什么关系
  • Fiber 树是怎么通过 child / sibling / return 组织起来的
  • alternate 和双缓存树到底解决了什么问题

这里也先说明一下版本口径:这篇文章标题写的是 React 19,因为整体讨论的是 React 19 的主线机制;但在具体源码观察上,我会先以 React 19.1.1 作为基线来展开。


一、先说结论:Fiber 是 React 的运行时工作节点

先把这一篇最核心的结论放在前面:

ReactElement 描述“我要渲染什么”,Fiber 承载“React 怎么处理这次工作”。

这句话是理解 Fiber 的第一层边界。

第二篇里,我们已经讲过 ReactElement。它是 JSX 编译并运行之后产出的描述对象,大致描述了:

  • 这是什么节点
  • 节点的 type 是什么
  • 节点上有什么 props
  • 有没有 key
  • 有没有 ref

但 ReactElement 本身很轻。
它只是描述“我要渲染什么”,并不负责记录后续工作过程。

它不会保存:

  • 当前节点的状态
  • 当前节点的 updateQueue
  • 当前节点有没有副作用
  • 当前节点的调度优先级
  • 当前节点和旧节点之间的复用关系

这些运行时信息,真正会落到 Fiber 上。

所以如果把 React 主线从输入到落地简单画一下,大概是这样:

graph TD
    A["JSX"] --> B["ReactElement:描述要渲染什么"]
    B --> C["Update:进入更新系统"]
    C --> D["Fiber:承载运行时工作"]
    D --> E["DOM:宿主环境真实节点"]

这张图里最重要的不是箭头本身,而是分层:

  • ReactElement 是输入描述
  • Fiber 是运行时工作结构
  • DOM 是最终落到宿主环境里的真实节点

所以这一篇最先要立住的边界就是:

Fiber 不是 ReactElement 的别名,而是 React 运行时用来组织更新、保存状态、标记副作用、支持调度和复用的工作节点。

后面再看 updateQueuelanesflagsalternate,这些东西才不会变成一堆散乱字段。


二、为什么 React 需要 Fiber

理解 Fiber,最好先从它解决的问题开始。
如果只盯着字段看,很容易把 Fiber 理解成一个很大的对象。

但 Fiber 真正重要的地方,不是“字段很多”,而是它改变了 React 组织更新工作的方式。

在旧式同步递归模型里,一次更新更像是:

从根节点开始
一路递归往下处理
直到整棵树处理完

这种方式的问题是:一旦开始处理一棵大树,就容易一口气跑到底。

如果组件树很大,中间又有很多节点需要计算,就可能长时间占用主线程。
而主线程一旦被长时间占用,页面响应、用户输入、动画这些事情都会受到影响。

更关键的是,纯同步递归模型不容易回答这些问题:

  • 工作做到哪里了?
  • 能不能先暂停一下?
  • 能不能稍后继续?
  • 能不能让更高优先级的更新先处理?
  • 能不能复用上一次已经创建过的节点信息?

React 需要的不只是“递归渲染一棵树”,而是要把一次更新拆成一批更容易管理的工作单元。

Fiber 就是在这个背景下出现的。

一个 Fiber 可以理解成一个节点级工作单元。
它不仅知道自己代表什么节点,还能记录:

  • 自己在树里的位置
  • 本轮待处理的 props
  • 上一次保存下来的状态
  • 当前节点上有没有更新
  • 当前节点或子树有没有副作用
  • 当前节点和旧节点之间怎么复用
  • 当前节点上有哪些优先级的工作

所以 Fiber 最大的意义,不是把树换一种写法,而是:

让 React 不只是递归渲染一棵树,而是能管理一批可记录、可复用、可调度的工作单元。

Scheduler、时间切片、Concurrent Rendering 的完整细节可以先放一放。
这一篇先抓住一点就够了:

Fiber 是 React 后续更新、render、commit 能够围绕一套工作节点推进的基础。


三、Fiber、FiberNode、Fiber 树、FiberRoot、HostRoot Fiber 是什么关系

Fiber 这个词很容易让人混,因为它在不同上下文里会指向不同层次的东西。

比如我们会看到:

  • Fiber
  • FiberNode
  • Fiber 树
  • FiberRoot
  • HostRoot Fiber
  • ReactDOMRoot

这些词如果不先拆开,后面源码会越看越乱。

这一节先把它们的关系讲清楚。

1. FiberNode:单个工作节点

从源码结构上看,具体的数据结构通常是 FiberNode。

我们平时说“一个 Fiber”,很多时候指的就是一个 FiberNode。

它可以对应不同类型的 React 节点,比如:

  • 函数组件
  • 类组件
  • 原生 DOM 标签
  • Fragment
  • Suspense

这里不用把所有 tag 类型都列出来。
先把它粗略理解成一句话:

一个 FiberNode,就是 React 运行时里的一个节点级工作单元。

2. Fiber 树:由 FiberNode 连接成的工作树

单个 FiberNode 不是孤立存在的。
很多 FiberNode 会通过指针连接起来,形成一棵 Fiber 树。

这棵树不是 DOM 树,也不是 ReactElement 树。
它是 React 运行时真正用来推进工作的树。

后面的 render 阶段,React 会围绕 Fiber 树进行计算。
commit 阶段,也会根据 Fiber 上记录的副作用标记去执行真实更新。

3. HostRoot Fiber:Fiber 树最顶层的根 Fiber

第三篇里,我们已经见过 HostRoot Fiber。

它是 Fiber 树内部最顶层的根 Fiber。

注意这里的关键词是:Fiber 树内部

HostRoot Fiber 本身是一个 FiberNode,只不过它处在整棵 Fiber 树的最顶层。
根级 updateQueue 也会挂在这个 HostRoot Fiber 上。

所以第三篇里看到:

HostRoot Fiber.updateQueue

其实就是在说:

根更新先挂到 Fiber 树最顶层的那个 Fiber 上。

4. FiberRoot:树外侧的根级状态容器

FiberRoot 和 HostRoot Fiber 不是一个东西。

FiberRoot 更像是整棵树外侧的根级状态容器。
它保存根级别的信息,比如:

  • 宿主容器 container
  • 当前 Fiber 树入口
  • pending lanes
  • 根级调度状态

FiberRoot 会通过 current 指向当前 Fiber 树的 HostRoot Fiber:

FiberRoot.current -> HostRoot Fiber

也就是说:

  • FiberRoot 在树外,管理整棵树的根级状态
  • HostRoot Fiber 在树内,是 Fiber 树的根节点

这个边界非常重要。

5. ReactDOMRoot:对外暴露的 root 句柄

业务代码里写的是:

const root = createRoot(container)

这里拿到的 root,不是 FiberRoot,也不是 HostRoot Fiber,而是 ReactDOMRoot。

ReactDOMRoot 是 React DOM 暴露给业务代码使用的 root 句柄。
它内部再通过 _internalRoot 持有真正的 FiberRoot。

把这几层合起来,大概是这样:

graph TD
    A["ReactDOMRoot:业务代码拿到的 root"] --> B["_internalRoot"]
    B --> C["FiberRoot:树外根级状态容器"]
    C --> D["current"]
    D --> E["HostRoot Fiber:Fiber 树根节点"]
    E --> F["child"]
    F --> G["App Fiber"]

所以这一节最重要的结论是:

FiberRoot 是树外的根级状态容器,HostRoot Fiber 才是 Fiber 树内部的根节点。

只要这个边界分清楚,后面再看到 root.currentroot.stateNode、HostRoot Fiber,就不会混成一团。


四、ReactElement 和 Fiber 到底有什么区别

第二篇里,我们已经讲过 ReactElement。
这一篇从 Fiber 的角度,再把两者边界补完整。

很多人第一次看源码时,会把这条链想得过于简单:

JSX → ReactElement → Fiber

好像 ReactElement 只是换个名字就变成了 Fiber。

但实际上,ReactElement 和 Fiber 是两层完全不同的东西。

1. ReactElement:描述“我要渲染什么”

ReactElement 是输入描述对象。

它主要描述:

  • type
  • key
  • ref
  • props

它的任务是表达:

“我要渲染一个什么东西?”

比如:

<App count={1} />

最终会变成一个 ReactElement。
这个 ReactElement 描述了:这里要渲染一个 App,并且带着 count: 1 这样的 props。

但 ReactElement 本身不负责保存运行时工作信息。

它没有:

  • updateQueue
  • lanes
  • flags
  • subtreeFlags
  • alternate
  • memoizedState

所以它不是后续 render / commit 真正围绕处理的工作节点。

2. Fiber:承载“React 怎么处理这次工作”

Fiber 则不一样。

Fiber 要回答的问题不是“我要渲染什么”,而是:

“这次更新里,这个节点要怎么被处理?”

所以 Fiber 上会保存更多运行时信息,比如:

  • 当前节点在 Fiber 树里的位置
  • 当前节点上一次保存下来的 props / state
  • 当前节点本轮待处理的新 props
  • 当前节点上有没有 updateQueue
  • 当前节点或子树有没有副作用标记
  • 当前节点有哪些 lanes
  • 当前节点和另一棵树里的对应 Fiber 是什么关系

如果用表格对比,会更清楚:

对比项 ReactElement Fiber
定位 输入描述对象 运行时工作节点
来源 JSX 编译后运行时创建 render 过程中创建或复用
是否保存状态 不保存 保存 memoizedState
是否有更新队列 没有 可以有 updateQueue
是否记录副作用 不记录 通过 flags 等字段记录
是否参与调度 不直接参与 通过 lanes / childLanes 参与
是否关联旧节点 不关联 通过 alternate 关联

所以这一节最核心的一句话是:

ReactElement 是输入描述,Fiber 是运行时工作结构。

这一篇先把两者边界立住。
后面再进入 render 阶段时,才能更顺地理解 Fiber 子树是怎么被构建出来的。


五、child / sibling / return 如何组织 Fiber 树

既然 Fiber 是一棵运行时工作树,那这棵树是怎么组织起来的?

为了更直观,可以先看一个很简单的 JSX:

function App() {
  return (
    <>
      <Header />
      <Main />
      <Footer />
    </>
  )
}

从 ReactElement 的角度看,这里描述的是 App 下面有三个子节点:HeaderMainFooter

而到了 Fiber 这层,React 不会简单把它们理解成一个普通数组,而是会用 child / sibling / return 把它们串成一棵 Fiber 树。

FiberNode 里有三个非常关键的树结构字段:

  • child
  • sibling
  • return

它们共同把一个个 FiberNode 连接成 Fiber 树。

1. child:第一个子 Fiber

child 指向当前 Fiber 的第一个子 Fiber。

比如 App 下面有 Header、Main、Footer 三个子节点。
那么 App Fiber 的 child 会指向第一个子节点,也就是 Header Fiber。

2. sibling:下一个兄弟 Fiber

如果 Header 后面还有 Main,那么 Header Fiber 的 sibling 会指向 Main Fiber。
Main 后面还有 Footer,那么 Main Fiber 的 sibling 会指向 Footer Fiber。

也就是说,同一层的兄弟节点,不是都放在一个数组里,而是通过 sibling 串起来。

3. return:父 Fiber

return 指向当前 Fiber 的父 Fiber。

这里的 return 不是 JavaScript 里的 return 语句,而是 FiberNode 上的一个字段。
可以先把它理解成:

“处理完当前节点后,应该回到哪里?”

用一张图看会更直观:

graph LR
    A["App Fiber"] --> B["child:Header Fiber"]
    B --> C["sibling:Main Fiber"]
    C --> D["sibling:Footer Fiber"]

这里的 Header Fiber、Main Fiber、Footer Fiber 的 return 都会指回 App Fiber。
这样 React 在处理完某个子节点或兄弟节点之后,就能继续回到父节点,接着推进后续工作。

所以这一节可以先收成一句话:

child / sibling / return 让 Fiber 树可以用链表式结构表示,也为后面的 work loop 遍历打下基础。

这里先不展开 beginWorkcompleteWork 和完整深度优先遍历。
那是后面讲 render 阶段时要重点看的内容。


六、alternate 和双缓存树:current / workInProgress 是什么

讲 Fiber,绕不开 alternate

因为后面进入 render 阶段时,会不断看到这些词:

  • current
  • workInProgress
  • alternate

如果这里不先建立基本认知,后面看 render 会非常容易乱。

1. current 树:当前已经提交的 Fiber 树

React 已经提交到页面上的那棵 Fiber 树,可以先理解成 current 树。

FiberRoot 的 current 会指向当前这棵树的 HostRoot Fiber。

也就是说,当前页面已经对应着一棵 Fiber 树,这棵树就是 current 树。

2. workInProgress 树:本轮更新正在构建的新树

当一次新的更新开始时,React 不会直接在 current 树上乱改。

它会基于 current 树创建或复用一棵新的工作树,也就是 workInProgress 树。

这棵树可以先理解成:

本轮更新正在计算中的下一版 UI 状态。

也就是说:

  • current 树代表当前页面已经确认的状态
  • workInProgress 树代表本轮更新正在计算的新状态

3. alternate:连接 current Fiber 和 workInProgress Fiber

current 树和 workInProgress 树不是完全孤立的。

两个树里对应的 Fiber,会通过 alternate 关联起来。

可以粗略理解成:

current Fiber  <── alternate ──>  workInProgress Fiber

用图表示就是:

graph LR
    A["current Fiber:当前已提交"] <-->|alternate| B["workInProgress Fiber:本轮正在构建"]

有了这个关联之后,React 就能知道:

  • 旧 Fiber 是谁
  • 新 Fiber 是谁
  • 哪些信息可以复用
  • 哪些信息需要更新
  • 本轮工作和上一轮工作之间是什么关系

create-work-in-progress-alternate.png

这张图不用逐行记。这里最关键的是三件事:

  • React 会先从 current.alternate 上尝试取得对应的 workInProgress Fiber
  • 如果不存在,就创建新的 Fiber,并让 currentworkInProgress 通过 alternate 双向连接
  • 如果已经存在,就复用这个 workInProgress,同时重置本轮更新相关的 flags / subtreeFlags / deletions

所以 alternate 不是一个孤立字段,它正是 current 树和 workInProgress 树之间的连接点。

4. 为什么要有双缓存树

双缓存树可以先用一个很直观的比喻理解:

前台展示一棵已经稳定的树,后台准备另一棵新的树。

React 不直接在 current 树上乱改,而是先构建 workInProgress 树。
等这棵新树处理完成,并且进入 commit 阶段后,再把它切换成新的 current 树。

也就是说:

current tree
    ↓  基于它构建
workInProgress tree
    ↓  commit 后
new current tree

更完整一点可以这样理解:

graph TD
    A["FiberRoot.current"] --> B["current tree"]
    B <-->|alternate| C["workInProgress tree"]
    C --> D["commit 后成为新的 current"]

这一节最重要的结论是:

alternate 把旧 Fiber 和新 Fiber 关联起来,是双缓存树、节点复用和后续可中断渲染的重要基础。

createWorkInProgressreconcileChildren、bailout 这些细节可以先放一放。
这一层先把 current / workInProgress / alternate 的关系理清就够了。


七、FiberNode 的关键字段:为什么它不是普通树节点

如果只把 Fiber 看成一个树节点,就会低估它。

Fiber 不只是有 child / sibling / return 这些树结构字段。
它还会把 React 后续工作需要的信息都组织在一个节点上。

先看一张 FiberNode 构造函数里的源码截图:

fiber-node-fields.png

这张图不用逐行记。重点是先看到几个分组:

  • tag / key / type / stateNode:节点身份和实例连接
  • return / child / sibling:Fiber 树结构
  • pendingProps / memoizedProps / memoizedState:输入和状态
  • updateQueue:更新队列
  • flags / subtreeFlags / deletions:副作用标记
  • lanes / childLanes:调度相关信息
  • alternate:连接另一棵树里的对应 Fiber

如果把这些字段再按功能整理一下,大概是这样:

FiberNode
├── 身份:tag / type / key
├── 树结构:return / child / sibling
├── 输入与状态:pendingProps / memoizedProps / memoizedState
├── 更新:updateQueue
├── 副作用标记:flags / subtreeFlags / deletions
├── 调度:lanes / childLanes
├── 复用:alternate
└── 宿主连接:stateNode

这里不需要逐个背字段,更适合按功能看它们分别解决什么问题。

1. 身份信息:tag / type / key

这一组字段回答的是:

这个 Fiber 代表什么类型的节点?

比如:

  • tag 表示 Fiber 类型
  • type 表示具体组件函数、类、原生标签等
  • key 用于同层节点比较和复用

这决定了 React 后面应该用什么方式处理这个 Fiber。

2. 树结构:return / child / sibling

这一组字段回答的是:

这个 Fiber 在树里的位置在哪里?

前面已经讲过:

  • child 指向第一个子 Fiber
  • sibling 指向下一个兄弟 Fiber
  • return 指向父 Fiber

它们让 Fiber 能够连成一棵可遍历的工作树。

3. 输入与状态:pendingProps / memoizedProps / memoizedState

这一组字段回答的是:

当前节点的新输入是什么,已经保存下来的状态是什么?

大致可以先这样理解:

  • pendingProps:本轮待处理的新 props
  • memoizedProps:上一次已经确认下来的 props
  • memoizedState:当前 Fiber 上保存的状态

这里可以先记住一点:Fiber 不只是描述节点,它还保存运行时状态。

函数组件 Hooks 的状态,后面也会和 memoizedState 这条线有关。
但这里先不展开 Hook 链表,后面讲 Hooks 内部实现时再继续看。

4. 更新:updateQueue

这一组字段回答的是:

这个节点上有没有等待处理的更新?

第三篇里,我们已经看到 HostRoot Fiber 上有 updateQueue
root.render(element) 触发的根更新,就会挂到 HostRoot Fiber 的 updateQueue 上。

到了更一般的组件更新里,Fiber 也会成为更新队列挂载和后续消费的工作节点。

这里先不展开 updateQueue 内部结构。
下一篇进入“一次更新怎么进入系统”时,会继续看 Update、Queue、Lane 和 Schedule。

5. 副作用标记:flags / subtreeFlags / deletions

这一组字段回答的是:

这个节点或它的子树,在 commit 阶段有没有事情要做?

比如:

  • 是否需要插入 DOM
  • 是否需要更新 DOM
  • 是否有节点需要删除
  • 子树里是否存在副作用

这三个字段可以先这样理解:

  • flags:当前 Fiber 自身的副作用标记
  • subtreeFlags:当前 Fiber 子树里的副作用汇总标记
  • deletions:本轮需要删除的子 Fiber

这里有一个边界必须说清楚:

Fiber 上记录的是副作用标记,不是立刻执行副作用。

真正执行 DOM 操作和 effect 的地方,是后面的 commit 阶段。
Fiber 在这里做的是记录和标记,让 commit 阶段知道后面要做什么。

6. 调度:lanes / childLanes

这一组字段回答的是:

这个 Fiber 以及它的子树上,有哪些优先级的工作需要处理?

先不用急着进入位运算细节。
可以粗略理解成:

  • lanes 和当前 Fiber 自身的更新有关
  • childLanes 和子树里的更新有关

它们会帮助 React 判断哪些工作需要被处理,哪些子树里还有待完成的更新。

这一部分也会在后面讲 Update 和 Lane 时继续接上。

7. 复用:alternate

alternate 回到前面第六节讲的双缓存树。

它回答的是:

这个 Fiber 和另一棵树里的对应 Fiber 是什么关系?

有了 alternate,React 才能在 current 树和 workInProgress 树之间建立关联,从而为复用和后续工作推进打基础。

8. 宿主连接:stateNode

stateNode 的含义会随着 Fiber 类型不同而不同。

比如:

  • HostComponent 对应的 stateNode 可能是 DOM 节点
  • ClassComponent 对应的 stateNode 可能是类组件实例
  • HostRoot Fiber 的 stateNode 指向 FiberRoot

这里不用展开所有情况。
先知道 stateNode 是 Fiber 和实际实例 / 宿主对象之间的一条连接即可。

所以这一节最后可以收成一句话:

Fiber 不是普通树节点,而是 React 运行时把状态、更新、副作用标记、调度优先级和复用关系组织在一起的工作节点。


八、把 Fiber 接回 React 主线

到这里,Fiber 这层结构就基本立起来了。

如果把前几篇串起来,现在 React 主线已经可以这样理解:

JSX
→ ReactElement
→ Root / update system
→ Fiber 作为运行时工作节点

也就是说:

  • 第二篇讲清楚了 React 的输入对象是 ReactElement
  • 第三篇讲清楚了 ReactElement 会先进入根级更新系统
  • 这一篇则把 Fiber 这个运行时工作节点立起来了

从这里开始,React 主线就真正进入运行时工作阶段。

后面的更新、render、commit,都会继续围绕 Fiber 展开:

  • 更新会挂到 Fiber 的 updateQueue
  • render 阶段会构建 workInProgress Fiber 树
  • commit 阶段会读取 Fiber 上的 flags 去执行 DOM 更新和副作用

用一张图收一下:

graph TD
    A["JSX"] --> B["ReactElement:输入描述"]
    B --> C["Root / update system:进入更新系统"]
    C --> D["Fiber:运行时工作节点"]
    D --> E["render / commit:围绕 Fiber 展开"]

所以这一篇真正要建立的,不是对某个字段的记忆,而是这个判断:

从这里开始,React 主线真正进入运行时工作阶段。后面的更新、render、commit,都要围绕 Fiber 树继续展开。


结语

React 源码最难的地方,从来都不是某一个字段本身。

真正难的是:如果没有主线,ReactElement、FiberRoot、HostRoot Fiber、alternate、lanes、flags 这些词会看起来彼此割裂。

所以这一篇真正想补上的,不是 Fiber 的所有实现细节,而是先把 Fiber 在 React 主线里的位置立住:

ReactElement 是输入描述,Fiber 是运行时工作节点。

当这个边界立住以后,后面再看 Update、Queue、Lane、render、commit,落点就会稳定很多。

Fiber 这层结构立住以后,下一步就可以看一次真正的组件更新:setState / Hook dispatch 触发后,React 是怎么创建 Update、进入 Queue、分配 Lane,并最终走到调度入口的。

如果这篇对你有帮助,欢迎点个赞支持。后面我也会继续把这组 React 源码文章慢慢补完整。

这组源码解读文章也会同步整理到 GitHub 仓库里,方便集中查看和持续更新:

GitHub:github.com/HWYD/source…

如果觉得这组内容对你有帮助,也欢迎顺手点个 Star。

最近在做的一个 AI 项目

最近我也在持续迭代一个 AI 项目:AI Mind
如果你对 AI 应用工程化方向感兴趣,欢迎来看看:

GitHub:github.com/HWYD/ai-min…

如果觉得还不错,也欢迎顺手点个 Star。

越剑智能:智能验布机产品仍在市场推广的前期阶段

2026年4月30日 19:40
36氪获悉,越剑智能公告,公司了解到智能验布机产品近期市场关注度较高。截至目前,智能验布机产品仍在市场推广的前期阶段。2025年度,公司智能验布机销售收入不足百万元,占主营业务收入远低于小1%,对公司净利润影响极低。智能验布机产品未来能否形成稳定的销售收入及利润贡献均存在重大不确定性。

2026年五一档新片票房破8000万

2026年4月30日 19:34
36氪获悉,据灯塔专业版,2026年五一档新片总票房(含点映及预售)突破8000万,《给阿嬷的情书》《穿普拉达的女王2》《寒战1994》暂列前三位 ​。
❌
❌