普通视图

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

图结构完全解析:从基础概念到遍历实现

作者 颜酱
2026年2月9日 21:58

图结构完全解析:从基础概念到遍历实现

图是计算机科学中最核心的数据结构之一,它能抽象现实世界中各类复杂的关系网络——从地图导航的路径规划,到社交网络的好友推荐,再到物流网络的成本优化,都离不开图结构的应用。本文将从图的基础概念出发,逐步讲解图的存储方式、通用实现,以及核心的遍历算法(DFS/BFS),帮助你彻底掌握图结构的核心逻辑。

一、图的核心概念

1.1 基本构成

图由节点(Vertex)边(Edge) 组成:

  • 节点:表示实体,有唯一ID标识;

  • 边:表示节点间的关系,可分为:

    • 有向/无向:有向边(如A→B)仅表示单向关系,无向边(如A-B)等价于双向有向边;

    • 加权/无权:加权边附带数值(如距离、成本),无权边可视为权重为1的特殊情况。

1.2 关键属性

(1)度
  • 无向图:节点的度 = 相连边的条数;

  • 有向图:入度(指向该节点的边数)+ 出度(该节点指向其他的边数)。

(2)稀疏图 vs 稠密图

简单图(无自环、无多重边)中,V个节点最多有 V(V1)/2V(V-1)/2 条边:

  • 稀疏图:边数E远小于 V2V^2 (如社交网络);

  • 稠密图:边数E接近 V2V^2 (如全连接网络)。

1.3 子图相关

  • 子图:节点和边均为原图的子集;

  • 生成子图:包含原图所有节点,仅保留部分边(如最小生成树);

  • 导出子图:选择部分节点,且包含这些节点在原图中的所有边。

1.4 连通性

  • 无向图:

    • 连通图:任意两节点间有路径可达;

    • 连通分量:非连通图中的最大连通子图;

  • 有向图:

    • 强连通:任意两节点间有双向有向路径;

    • 弱连通:忽略边方向后为连通图。

1.5 图与树的关系

图是多叉树的延伸:树无环、仅允许父→子指向,而图可成环、节点间可任意指向。树的遍历逻辑(DFS/BFS)完全适用于图,仅需增加「标记已访问节点」的逻辑避免环导致的死循环。

二、图的存储方式

图的存储核心是「记录节点间的连接关系」,主流方式有邻接表邻接矩阵两种,二者各有适用场景。

2.1 邻接表

核心结构

以节点ID为键,存储该节点的所有出边(包含目标节点+权重):

  • 数组版:graph[x] 存储节点x的出边列表(适用于节点ID为连续整数);

  • Map版:graph.get(x) 存储节点x的出边列表(适用于任意类型节点ID)。

特点
  • 空间复杂度: O(V+E)O(V+E) (仅存储实际存在的边,适合稀疏图);

  • 时间复杂度:增边 O(1)O(1) ,删/查边 O(E)O(E) (E为节点出边数),获取邻居 O(1)O(1)

2.2 邻接矩阵

核心结构

二维数组 matrix[from][to]

  • 无权图:true/false 表示是否有边;

  • 加权图:数值表示权重,null 表示无边(避免0权重歧义)。

特点
  • 空间复杂度: O(V2)O(V^2) (需预分配所有节点组合,适合稠密图);

  • 时间复杂度:增/删/查边/获取权重均为 O(1)O(1) ,获取邻居 O(V)O(V)

2.3 存储方式对比

特性 邻接表 邻接矩阵
空间效率 稀疏图更优 稠密图更优
增删查边 增边快、删/查边慢 所有操作均快
节点ID支持 支持任意类型(Map版) 仅支持连续整数
适用场景 大多数稀疏图场景 节点少、需快速查边

三、图的通用实现

基于邻接表/邻接矩阵,我们实现支持「增删查改」的通用加权有向图类,可灵活适配无向图(双向加边)、无权图(权重默认1)。

3.1 邻接表(数组版):适用于连续整数节点ID


/**
 * 加权有向图(邻接表-数组版)
 * 核心:节点ID为0~n-1的连续整数,二维数组存储出边
 */
class WeightedDigraphArray {
    constructor(n) {
        if (!Number.isInteger(n) || n <= 0) {
            throw new Error(`节点数必须是正整数(当前传入:${n})`);
        }
        this.nodeCount = n;
        this.graph = Array.from({ length: n }, () => []); // 邻接表初始化
    }

    // 私有方法:校验节点合法性
    _validateNode(node) {
        if (!Number.isInteger(node) || node < 0 || node >= this.nodeCount) {
            throw new Error(`节点${node}非法!合法范围:0 ~ ${this.nodeCount - 1}`);
        }
    }

    // 添加加权有向边
    addEdge(from, to, weight) {
        this._validateNode(from);
        this._validateNode(to);
        if (typeof weight !== 'number' || isNaN(weight)) {
            throw new Error(`边${from}${to}的权重必须是有效数字`);
        }
        // 避免重复加边
        if (this.hasEdge(from, to)) {
            this.removeEdge(from, to);
        }
        this.graph[from].push({ to, weight });
    }

    // 删除有向边
    removeEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        const originalLength = this.graph[from].length;
        this.graph[from] = this.graph[from].filter(edge => edge.to !== to);
        return this.graph[from].length < originalLength;
    }

    // 判断边是否存在
    hasEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        return this.graph[from].some(edge => edge.to === to);
    }

    // 获取边权重
    getEdgeWeight(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        const edge = this.graph[from].find(edge => edge.to === to);
        if (!edge) throw new Error(`不存在边${from}${to}`);
        return edge.weight;
    }

    // 获取节点所有出边
    getNeighbors(v) {
        this._validateNode(v);
        return [...this.graph[v]]; // 返回拷贝,避免外部修改
    }

    // 打印邻接表(调试用)
    printAdjList() {
        console.log('=== 加权有向图邻接表 ===');
        for (let i = 0; i < this.nodeCount; i++) {
            const edges = this.graph[i].map(edge => `${edge.to}(${edge.weight})`).join(', ');
            console.log(`节点${i}的出边:${edges || '无'}`);
        }
    }
}

3.2 邻接表(Map版):适用于任意类型节点ID


/**
 * 加权有向图(邻接表-Map版)
 * 核心:支持动态添加节点,节点ID可为任意可哈希类型(数字/字符串等)
 */
class WeightedDigraphMap {
    constructor() {
        this.graph = new Map(); // key: 节点ID,value: 出边列表
    }

    // 添加加权有向边
    addEdge(from, to, weight) {
        if (typeof weight !== 'number' || isNaN(weight)) {
            throw new Error('边的权重必须是有效数字');
        }
        if (!this.graph.has(from)) {
            this.graph.set(from, []);
        }
        this.removeEdge(from, to); // 去重
        this.graph.get(from).push({ to, weight });
    }

    // 删除有向边
    removeEdge(from, to) {
        if (!this.graph.has(from)) return false;
        const edges = this.graph.get(from);
        const filtered = edges.filter(edge => edge.to !== to);
        this.graph.set(from, filtered);
        return filtered.length < edges.length;
    }

    // 判断边是否存在
    hasEdge(from, to) {
        if (!this.graph.has(from)) return false;
        return this.graph.get(from).some(edge => edge.to === to);
    }

    // 获取边权重
    getEdgeWeight(from, to) {
        if (!this.graph.has(from)) {
            throw new Error(`不存在节点${from}`);
        }
        const edge = this.graph.get(from).find(edge => edge.to === to);
        if (!edge) throw new Error(`不存在边${from}${to}`);
        return edge.weight;
    }

    // 获取节点所有出边
    getNeighbors(v) {
        return this.graph.get(v) || [];
    }

    // 获取所有节点
    getNodes() {
        return Array.from(this.graph.keys());
    }
}

3.3 邻接矩阵版:适用于节点数少的场景


/**
 * 加权有向图(邻接矩阵版)
 * 核心:二维数组存储边权重,null表示无边
 */
class WeightedDigraphMatrix {
    constructor(n) {
        if (!Number.isInteger(n) || n <= 0) {
            throw new Error('节点数必须是正整数');
        }
        this.nodeCount = n;
        this.matrix = Array.from({ length: n }, () => Array(n).fill(null));
    }

    // 校验节点合法性
    _validateNode(node) {
        if (!Number.isInteger(node) || node < 0 || node >= this.nodeCount) {
            throw new Error(`节点${node}非法!合法范围:0 ~ ${this.nodeCount - 1}`);
        }
    }

    // 添加加权有向边
    addEdge(from, to, weight) {
        this._validateNode(from);
        this._validateNode(to);
        if (typeof weight !== 'number' || isNaN(weight)) {
            throw new Error(`边${from}${to}的权重必须是有效数字`);
        }
        this.matrix[from][to] = weight;
    }

    // 删除有向边
    removeEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        if (this.matrix[from][to] === null) return false;
        this.matrix[from][to] = null;
        return true;
    }

    // 判断边是否存在
    hasEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        return this.matrix[from][to] !== null;
    }

    // 获取边权重
    getEdgeWeight(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        return this.matrix[from][to];
    }

    // 获取节点所有出边
    getNeighbors(v) {
        this._validateNode(v);
        const neighbors = [];
        for (let to = 0; to < this.nodeCount; to++) {
            const weight = this.matrix[v][to];
            if (weight !== null) {
                neighbors.push({ to, weight });
            }
        }
        return neighbors;
    }

    // 打印邻接矩阵(调试用)
    printMatrix() {
        console.log('=== 邻接矩阵 ===');
        process.stdout.write('    ');
        for (let i = 0; i < this.nodeCount; i++) process.stdout.write(`${i}   `);
        console.log();
        for (let from = 0; from < this.nodeCount; from++) {
            process.stdout.write(`${from} | `);
            for (let to = 0; to < this.nodeCount; to++) {
                const val = this.matrix[from][to] === null ? '∅' : this.matrix[from][to];
                process.stdout.write(`${val}   `);
            }
            console.log();
        }
    }
}

3.4 适配无向图/无权图

  • 无向图:添加/删除边时,同时操作 from→toto→from

  • 无权图:复用加权图类,addEdge 时权重默认传1。

四、图的核心遍历算法

图的遍历是所有图论算法的基础,核心为深度优先搜索(DFS)广度优先搜索(BFS),二者的核心区别是「遍历顺序」:DFS先探到底再回溯,BFS逐层扩散。

4.1 深度优先搜索(DFS)

核心思想

从起点出发,沿着一条路径走到头,再回溯探索其他分支,需通过 visited/onPath 数组避免环导致的死循环。

场景1:遍历所有节点(visited数组)

visited 标记已访问的节点,确保每个节点仅遍历一次:


/**
 * DFS遍历所有节点
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} s - 起点
 * @param {boolean[]} visited - 访问标记数组
 */
function dfsTraverseNodes(graph, s, visited) {
    if (s < 0 || s >= graph.nodeCount || visited[s]) return;
    // 前序位置:标记并访问节点
    visited[s] = true;
    console.log(`访问节点 ${s}`);
    // 递归遍历所有邻居
    for (const edge of graph.getNeighbors(s)) {
        dfsTraverseNodes(graph, edge.to, visited);
    }
    // 后序位置:可处理节点相关逻辑(如统计、计算)
}

// 调用示例
const graph = new WeightedDigraphArray(3);
graph.addEdge(0, 1, 1);
graph.addEdge(0, 2, 2);
graph.addEdge(1, 2, 3);
dfsTraverseNodes(graph, 0, new Array(graph.nodeCount).fill(false));
场景2:遍历所有路径(onPath数组)

onPath 标记当前路径上的节点,后序位置撤销标记(回溯),用于寻找所有路径:


/**
 * DFS遍历所有路径(从src到dest)
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} src - 起点
 * @param {number} dest - 终点
 * @param {boolean[]} onPath - 当前路径标记
 * @param {number[]} path - 当前路径
 * @param {number[][]} res - 所有路径结果
 */
function dfsTraversePaths(graph, src, dest, onPath, path, res) {
    if (src < 0 || src >= graph.nodeCount || onPath[src]) return;
    // 前序位置:加入当前路径
    onPath[src] = true;
    path.push(src);
    // 到达终点:记录路径
    if (src === dest) {
        res.push([...path]); // 拷贝路径,避免后续修改
        path.pop();
        onPath[src] = false;
        return;
    }
    // 递归遍历邻居
    for (const edge of graph.getNeighbors(src)) {
        dfsTraversePaths(graph, edge.to, dest, onPath, path, res);
    }
    // 后序位置:回溯(移出当前路径)
    path.pop();
    onPath[src] = false;
}

// 调用示例
const res = [];
dfsTraversePaths(graph, 0, 2, new Array(graph.nodeCount).fill(false), [], res);
console.log('所有从0到2的路径:', res); // [[0,1,2], [0,2]]
场景3:有向无环图(DAG)遍历

若图无环,可省略 visited/onPath,直接遍历(如寻找所有从0到终点的路径):


var allPathsSourceTarget = function(graph) {
    const res = [];
    const path = [];
    const traverse = (s) => {
        path.push(s);
        if (s === graph.length - 1) {
            res.push([...path]);
            path.pop();
            return;
        }
        for (const v of graph[s]) traverse(v);
        path.pop();
    };
    traverse(0);
    return res;
};

4.2 广度优先搜索(BFS)

核心思想

从起点出发,逐层遍历所有节点,天然适合寻找「最短路径」(第一次到达终点的路径即为最短)。

基础版:记录遍历步数

/**
 * BFS遍历(记录步数)
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} s - 起点
 */
function bfsTraverse(graph, s) {
    const nodeCount = graph.nodeCount;
    const visited = new Array(nodeCount).fill(false);
    const q = [s];
    visited[s] = true;
    let step = -1; // 初始-1,进入循环后++为0(起点步数)

    while (q.length > 0) {
        step++;
        const sz = q.length;
        // 遍历当前层所有节点
        for (let i = 0; i < sz; i++) {
            const cur = q.shift();
            console.log(`访问节点 ${cur},步数 ${step}`);
            // 加入所有未访问的邻居
            for (const edge of graph.getNeighbors(cur)) {
                if (!visited[edge.to]) {
                    q.push(edge.to);
                    visited[edge.to] = true;
                }
            }
        }
    }
}
进阶版:State类适配复杂场景

通过State类封装节点和步数,适配不同权重、不同遍历目标的场景:


// 封装节点状态
class State {
    constructor(node, step) {
        this.node = node;
        this.step = step;
    }
}

/**
 * BFS遍历(State版)
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} s - 起点
 */
function bfsTraverseState(graph, s) {
    const nodeCount = graph.nodeCount;
    const visited = new Array(nodeCount).fill(false);
    const q = [new State(s, 0)];
    visited[s] = true;

    while (q.length > 0) {
        const state = q.shift();
        const cur = state.node;
        const step = state.step;
        console.log(`访问节点 ${cur},步数 ${step}`);

        for (const edge of graph.getNeighbors(cur)) {
            if (!visited[edge.to]) {
                q.push(new State(edge.to, step + 1));
                visited[edge.to] = true;
            }
        }
    }
}

4.3 遍历算法总结

算法 核心数据结构 核心标记 适用场景 时间复杂度
DFS 递归栈 visited(遍历节点)/onPath(遍历路径) 遍历所有节点、所有路径 O(V+E)O(V+E)
BFS 队列 visited 寻找最短路径、逐层遍历 O(V+E)O(V+E)

五、总结

图结构的核心是「节点+边」的关系抽象,掌握以下关键点即可应对绝大多数场景:

  1. 存储选择:稀疏图用邻接表(省空间),稠密图用邻接矩阵(查边快);

  2. 遍历逻辑:DFS适合遍历所有路径,BFS适合找最短路径,均需标记已访问节点避免环;

  3. 扩展适配:无向图=双向有向图,无权图=权重为1的加权图,可复用通用图类;

  4. 核心思想:图是树的延伸,遍历的本质是「穷举+剪枝」(标记已访问避免死循环)。

从基础的遍历到进阶的最短路径(Dijkstra)、最小生成树(Kruskal/Prim)、拓扑排序,图论算法的核心都是「基于遍历的优化」,掌握本文的基础内容,后续学习进阶算法会事半功倍。

语音问一问上线,小红书为何发力问搜?

2026年2月9日 21:35

文 | 陈曦

从搜索到问搜

在搜索产品的功能上,小红书又往前进了一步。

据媒体报道,1月27日,小红书正式上线语音问一问功能,用户可以在搜索发起页点击“按住提问、有问必答”的图标,通过语音提问。 

具体来说,当用户打开小红书,找到搜索栏,会发现在以往的文字检索下方,多出了一个语音提问按键,只要点击,就能像日常对话一般,随问随搜随答。搜索结果不仅会呈现相关笔记,还会在搜索页顶部显示,由问一问根据真人经验笔记总结的答案。 

例如,按住语音提问,“怎么和理发师说,只剪一点点”——这是个相当有难度的提问,每个人的头发长度不同,对“一点点”的理解也不同。 

图片来源:小红书

但问一问会告诉你,在提要求时,得先把“一点点”说具体,尤其是明确长度、范围和动作。剪发过程充满变数,问一问还会友情提醒,作为顾客的沟通技巧,是让理发师听懂你的话,例如要分阶段确认、中间一旦发现不对劲,则要大胆喊停。 

它还会给出一份避坑指南,提醒你避开意料不到的雷区——这些具体、细节的回答,则源于平台内大量真人剪发经验笔记的总结。 

为了迅速传递问搜心智,小红书还为问一问做起了地广。 

在上海,地铁大屏上,知名演员成龙在多个场景下,向小红书随时发问,主打“有事就问小红书,活人答案靠得住”的心智。甚至成龙还为问一问拍摄了一支品牌片,反复传递“语音问,就有活人答案”这一Slogan。 

图片来源:小红书

据36氪了解,目前问一问功能已经全量上线。春节期间,小红书的搜索入口下方,还会上线春节指南等活动,通过设置各种问搜场景玩法,和小红盒等奖品激励,引导用户使用语音问一问。 

作为小红书搜索产品的又一新动作,又选择在春节期间重点发力这一功能。它传递的业务信号似乎也很明显——据36氪了解,问搜或将成为小红书社区产品下一个重要方向。 

事实上,小红书早在2025年就曾上线问一问这一产品功能。用户在搜索框输入问题后,可以进入右上方的问一问入口,得到结构化的总结回答。在客观回答问题的基础上,问一问还能筛选小红书经验贴,总结推荐性建议。 

到了2025年底,问一问还接入了小红书评论区,即只要在评论区@问一问,就能直接总结评论区要点,或在评论区内总结笔记、视频的重点内容。 

但从产品维度上讨论,语音问一问与此前又有什么不同? 

截止目前,语音问一问最大的迭代主要集中在两大方向。第一,以语音作为载体,主打问搜心智。在过去,小红书的搜索功能只能用文字实现,而大部分的文字搜索词,几乎都是3-4个字的短词。 

但语音提问则不同,作为一种天然的对话形式,语音输入无论在表达意愿、话语长度上,都优于文字,它指向的是更具体、详细、准确的问题。这与需要清晰、具体指令的AI经验总结功能,在用户体验和搜索效率上,都更适配。 

从实际体验来看,哪怕对着问一问连说三分钟,它也能接住你的“聊天式发问”,甚至还能“听懂”外语、方言甚至各类声线。 

图片来源:小红书

其二,问搜的迭代,意味着小红书旨在实现全场景问题的承接。在小红书以往的搜索场景中,回答多依赖于用户分享的真人经验,虽然真实,但更多指向非标的特点。 

“语音问一问”则能将站内的海量真人经验,进行全面、高效的信息整合。这种结构化的问答服务,也有助于覆盖医疗、教育等过往并非小红书强势垂类的搜索需求。 

一言蔽之,语音问一问既是搜索产品的一种迭代,也是小红书巩固和发力搜索场域的下一个锚点。而据36氪了解,小红书内部之所以把“问搜”作为重要的发力点之一,是意在抢占更多的决策场景和人群,希望借此进一步扩张有用心智的护城河。 

基于“真人经验”的差异化搜索

为什么小红书能够发力问搜? 

回答这个问题之前,先理解小红书做问搜的差异化。作为向来以真实、有用分享见长的社区,小红书搜索最大的优势一直是“UGC真人经验”。 

这也意味着,与市场上多数AI产品不同,小红书问搜生成的答案,并非依赖通用知识库,而是对平台内庞大、真实的UGC经验提炼,其AI所做的能力也是基于真实经验的提效——本质是AI辅助真人经验,而非取代关系。这也是小红书问搜与其他同类产品最核心的区别。 

而差异化优势的背后,是小红书多年来积累的海量社区真人经验与分享内容。当人们在发问时,他们究竟需要什么样的答案? 

或许,除去事实性的答案能够被标准化之外,用户使用搜索,不仅是为了获取信息,更是为了满足个体的情感联结、认同需求与社交价值等。人们希望看到,那些“拥有同样问题的人”究竟是如何解决问题的,从而建立一种心理安全感。 

所以,小红书所积累与聚集的大量社区真人经验和分享,是它的问搜能提供多元、细分回答的核心原因,这也是它最大的差异化心智。 

其二,“问答式”的经验场景,在小红书上实际早有体现,只是此前尚未有语音化产品承接,这也是今天小红书能够发力、承接交互提问的基础。 

过往小红书站内就有大量提问和求助的笔记,去年下半年,AMA在小红书的自发走红,也再一次验证小红书上存在强烈的问答交互心智。 

AMA即“Ask me anything”,顾名思义,这是一种发帖人对提问者“有问必答”的玩法,小红书AMA最早的“发起者”是来自清华大学交叉信息研究院的助理教授许华哲,他发布一条AMA笔记之后,迅速引发一众学生围绕论文、就业与前沿技术集中发问。很快,这股发问潮迅速在科技、人文、高校等圈层引发传播和效仿,李开复、莫言、李银河等大批知名人物下场参与,一度成为2025年互联网问答新现象。 

图片来源:小红书

换个角度说,AMA的流行也意味着,小红书用户本身就有强烈的“提问”与“解答”需求,“问”早已是用户生态的一部分,问搜的出现,正是将这种自发的社区行为,用产品功能,实现了“心智落地”。 

其三,从心智到产品的落地,背后也需要更广度、更深度的语料库支撑。小红书以UGC起家,其特性也决定了小红书站内的经验帖一定更加长尾、细分、细颗粒度,这自然为AI理解提问、精准回复提供了更详细、差异化的语料。 

另外,近年来,小红书社区用户和内容的扩张、泛化,都让这个平台的经验分享已经突破了最初的生活指南维度,走向了更广阔的生活兴趣社区。尤其2025年,二次元、游戏、科技等领域内容在小红书迎来爆发,在丰富社区生态的同时,也让小红书的语料库持续扩容,只有涵盖的场景与领域越来越广,才能够承接用户更细分的问搜需求。 

更长远的想象空间

今天中国人的搜索习惯已经悄然发生改变,AI搜索已经逐渐渗透到人们的日常生活中,部分AI对话工具的用户规模甚至达到亿量级。 

这些AI对话工具还展现出惊人的黏性,并不断蚕食用户在搜索引擎上的时间——QuestMobile数据显示,中国AI原生App用户的使用习惯逐步养成,截至2025年三季度,月人均使用时长约为117.7分钟。 

尽管在现有用户规模上,AI对话应用虽未及传统搜索的覆盖面,但其短时间内跑出的过亿规模和强大黏性,已经形成不容忽视的生态。 

因此,小红书选择发力语音问搜,既是顺应搜索场景发生的新变化,也能与原有的搜索心智、场景互为补充,更重要的是,它还意味着“有用”心智对新人群的触达和覆盖。 

例如,传统搜索适合用户有明确目标、能提炼关键词的场景,但并非所有用户都能精准表达、甚至就连那些需求本身,也可能是模糊、难以捉摸的。 

相比文字,语音问搜则是一种更低门槛的交互方式,也能覆盖到此前不擅长文字搜索的人群,进一步扩大用户边界,让更多人感受到小红书的有用性。 

而从更广阔的维度来看,这也是小红书在搜索领域的一次主动出击。 

图片来源:小红书

原因就在于,小红书的问一问明显区别于市场上其他AI产品,今天多数的AI问答,都聚焦在信息的集合和通用问题的回答,但小红书能够在细分生活场景和非标准化的问题上,提供更多的认知、经验和情感共振。 

在某些普世问题上,那些来源于真人经验的分享,拥有AI永远无法替代的共情和温度。人类的社会化行为,从来不是黑与白,那些更加弹性、流动的空间,都是AI无法处理的微妙地带。 

所以,在小红书上,那些活人感的答案之所以重要,是因为真人认知、经验和背景的不同,带来的答案和启发将会千人千面。 

另外,值得注意的是,搜索场景也是小红书商业化的核心入口之一,用户的搜索行为背后,往往隐藏着明确的需求与消费潜力。而问搜的方向如果能够落地,则也有可能进一步放大这种需求潜力——用户的提问比传统关键词搜索更精准、更具体,能更清晰地暴露其核心需求,所对应的商业化效率也更高。 

从搜索到问搜,小红书这一社区关键产品动作并非偶然,而是基于自身社区基因、用户需求与行业趋势的选择。某种意义上,这个社区的“执念”一直没变过,持续放大自己的“有用”心智,是其作为一个3.5亿月活APP的核心护城河之一。 

前端性能优化:图片懒加载的三种手写方案

2026年2月9日 21:01

前言

在电商、社交等图片密集型应用中,一次性加载所有图片会导致首屏白屏时间(FP)过长,消耗用户大量流量。图片懒加载的核心思想就是: “按需加载” ——只有当图片进入或即将进入可视区域时,才真正发起网络请求。

一、 核心原理

  1. 占位图:初始化时,图片的 src 属性指向一张极小的 base64 图片或 loading 占位图。
  2. 存储地址:将真实的图片 URL 存放在自定义属性中(如 data-src)。
  3. 触发判断:通过 JS 监听位置变化,当图片进入可视区,将 data-src 的值赋给 src

二、 方案对比与实现

方案 1:传统滚动监听(Scroll + OffsetTop)

这是最基础的方案,通过计算绝对位置来判断。

公式: window.innerHeight (可视窗口高) + document.documentElement.scrollTop (滚动条高度) > element.offsetTop (元素距离页面顶部高度)

代码实现:

function lazyLoad() {
  const images = document.querySelectorAll('img[data-src]');
  const clientHeight = window.innerHeight;
  const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;

  images.forEach(img => {
    // 判断是否进入可视区
    if (clientHeight + scrollTop > img.offsetTop) {
      img.src = img.dataset.src;
      img.removeAttribute('data-src'); // 加载后移除属性,防止重复执行
    }
  });
}

// 注意:必须添加节流(Throttle),防止滚动时高频触发导致卡顿
window.addEventListener('scroll', throttle(lazyLoad, 200));

方案 2:现代位置属性(getBoundingClientRect)

此方法通过getBoundingClientRect获取元素相对于浏览器视口的位置来判断,逻辑更简洁。

判断条件: rect.top(元素顶部距离视口顶部的距离) < window.innerHeight

代码实现:

function handleScroll() {
  const img = document.getElementById('target-img');
  const rect = img.getBoundingClientRect();
  const viewHeight = window.innerHeight || document.documentElement.clientHeight;

  // 元素顶部出现在视口内,且没有超出视口底部
  if (rect.top >= 0 && rect.top < viewHeight) {
    console.log('图片进入可视区,开始加载');
    img.src = img.dataset.src;
    window.removeEventListener('scroll', handleScroll); // 加载后卸载监听
  }
}

window.addEventListener('scroll', handleScroll);

方案 3:最优解方案(IntersectionObserver API)

IntersectionObserver这是目前最推荐的方案,它是异步的,不会阻塞主线程,且不需要手动计算位置,性能最高。

使用语法:const observer = new IntersectionObserver(callback, options)

  • callback:是元素可见性发生变化时的回调函数,接收两个参数:
    • entries:观察目标的对象数组。对象中存在isIntersecting属性(布尔值),代表目标元素是否与根元素交叉(即进入视口)。
  • options:配置对象(该参数可选)。其中 root表示指定一个根元素,默认是浏览器窗口、rootMargin表示控制根元素的外边距、threshold 为目标元素与根元素中的可见比例,可以通过设置值来触发回调函数

代码实现:

const observerOptions = {
  root: null, // 默认为浏览器视口
  rootMargin: '0px 0px 50px 0px', // 提前 50px 触发加载,提升用户体验
  threshold: 0.1 // 交叉比例达到 10% 时触发
};

const handleIntersection = (entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      console.log('IntersectionObserver 推送:图片加载成功');
      observer.unobserve(img); // 停止观察该元素
    }
  });
};

const observer = new IntersectionObserver(handleIntersection, observerOptions);
// 观察页面中所有带 data-src 的图片
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));

方案 1 和 2 在获取 offsetTopgetBoundingClientRect 时会强制浏览器重新计算布局(回流),在长列表场景下可能导致掉帧。方案 3 不受此影响


三、总结

特性 方案 1 (OffsetTop) 方案 2 (Rect) 方案 3 (Intersection)
计算复杂度 较高 (需计算累计高度) 中等 极低 (引擎原生实现)
性能消耗 高 (频繁触发回流) 高 (触发回流) 低 (异步非阻塞)
兼容性 极好 (所有浏览器) 好 (IE9+) 一般 (现代浏览器, IE 需 Polyfill)

四、补充

  • 现代浏览器(Chrome 76+)已原生支持 <img loading="lazy">,如果是简单场景,一行 HTML 属性即可搞定。
  • 务必给图片设置固定的宽高比或底色占位。否则图片加载前高度为 0,加载瞬间高度撑开会引发剧烈的页面抖动。

克罗格任命沃尔玛前高管为新任首席执行官

2026年2月9日 20:54
克罗格于本周一任命沃尔玛前高管格雷格・福兰为新任首席执行官,距离上一任CEO突然辞职已过去11个月。福兰曾执掌沃尔玛美国业务六年,直至2019年离任。克罗格是美国最大的独立连锁超市,拥有2731家门店、40.9万名员工,而沃尔玛正是其主要竞争对手之一。(新浪财经)

金螳螂:联合中标2.42亿元装修及智能化工程

2026年2月9日 20:52
36氪获悉,金螳螂公告,近日,公司作为联合体牵头人,与联合体成员朗捷通成功中标苏州融置房地产开发有限公司“DK20210188地块项目装修及智能化工程”项目。上述项目中标金额为2.42亿元,其中,公司承接的装修工程暂估价为2.06亿元,占公司2024年度经审计营业收入的1.12%。

AI 应用工程化实战:使用 LangChain.js 编排 DeepSeek 复杂工作流

作者 NEXT06
2026年2月9日 20:31

在 2024 年至 2025 年的技术浪潮中,大语言模型(LLM)的应用开发已经从“尝鲜”阶段迈向了“工程化”阶段。对于开发者而言,仅仅调用 fetch 接口获取模型回复是远远不够的。在构建复杂的生产级应用时,我们面临着提示词管理混乱、模型切换成本高、上下文处理复杂以及任务编排困难等诸多痛点。

LangChain 的出现,正是为了解决这些工程化难题。它不是一个模型,而是一个框架,旨在将 LLM 的能力封装成可维护、可复用的组件。

本文将通过四个循序渐进的代码示例,演示如何利用 LangChain.js 结合当下热门的 DeepSeek(深度求索)模型,完成从基础调用到复杂工作流编排的进阶之路。

第一阶段:标准化的开始——适配器模式的应用

在没有任何框架之前,调用 LLM 通常意味着处理各种非标准化的 HTTP 请求。OpenAI、DeepSeek、Claude 的 API 格式各不相同。LangChain 的第一个核心价值在于标准化

以下是基于 main.js 的基础调用示例:

JavaScript

// main.js
import 'dotenv/config'; // 加载环境变量
import { ChatDeepSeek } from '@langchain/deepseek';

// 1. 实例化模型
const model = new ChatDeepSeek({
    model: 'deepseek-reasoner', // 使用 DeepSeek 的推理模型
    temperature: 0, // 设定温度,0 代表最确定性的输出
    // apiKey 自动从 process.env.DEEPSEEK_API_KEY 读取
});

// 2. 执行调用
const res = await model.invoke('用一句话解释什么是RAG?');
console.log(res.content);

深度解析:适配器模式 (Adapter Pattern)

这段代码看似简单,却蕴含了 AI 工程化的第一块基石:适配器模式

在软件工程中,适配器模式用于屏蔽底层接口的差异。ChatDeepSeek 类就是一个适配器(Provider)。

  • 统一接口:无论底层使用的是 DeepSeek、OpenAI 还是 Google Gemini,在 LangChain 中我们都统一调用 .invoke() 方法,invoke(英文:调用)。
  • 配置解耦:开发者无需关心 baseURL 配置、鉴权头部的拼接或请求体格式。
  • 参数控制:temperature: 0 是一个关键参数。在开发代码生成或逻辑推理(如使用 deepseek-reasoner)应用时,我们将温度设为 0 以减少随机性;而在创意写作场景,通常设为 0.7 或更高,这是决定你的大模型输出的内容严谨还是天马行空的关键因素之一。

通过这种方式,我们实现了业务逻辑与模型实现的解耦。如果未来需要更换模型,只需修改实例化部分,业务代码无需变动。

第二阶段:提示词工程化——数据与逻辑分离

直接在 .invoke() 中传入字符串(Hardcoding)在 Demo 阶段可行,但在实际项目中是反模式。因为提示词(Prompt)往往包含静态的指令和动态的用户输入。

下面这段代码展示了如何使用 PromptTemplate(对prompt设计一个模板,只需要提供关键的参数) 进行管理:

JavaScript

// 1.js
import { PromptTemplate } from '@langchain/core/prompts';
import { ChatDeepSeek } from '@langchain/deepseek';

// 1. 定义模板:静态结构与动态变量分离
const prompt = PromptTemplate.fromTemplate(`
你是一个{role}。
请用不超过{limit}字回答以下问题:
{question}
`);

// 2. 格式化:注入数据
const promptStr = await prompt.format({
    role: '前端面试官',
    limit: '50',
    question: '什么是闭包'
});

// 3. 调用模型
const model = new ChatDeepSeek({
    model: 'deepseek-reasoner',
    temperature: 0.7
});

const res = await model.invoke(promptStr);
console.log(res.content);

深度解析:提示词模板的意义

这里体现了关注点分离(Separation of Concerns)的设计原则。

  1. 复用性:同一个 prompt 对象可以生成“前端面试官”、“后端面试官”甚至“测试工程师”的问答场景,只需改变 format 的入参。
  2. 维护性:当需要优化 Prompt(例如增加“请使用中文回答”的系统指令)时,只需修改模板定义,而不用在代码库的各个角落查找字符串拼接逻辑。
  3. 类型安全:虽然 JavaScript 是弱类型,但在 LangChain 的 TypeScript 定义中,模板的输入变量(Variables)是可以被静态分析和校验的。

然而,上述代码仍显得有些“命令式”:我们需要手动格式化,拿到字符串,再手动传给模型。这依然是两步操作。

第三阶段:链式流转——LCEL 与声明式编程

LangChain 的核心精髓在于 Chain(链) 。通过 LangChain 表达式语言(LCEL),我们可以通过管道(Pipe)将组件连接起来,形成自动化的工作流。

下面的这段代码展示了这一范式转变:

JavaScript

// 2.js
import { ChatDeepSeek } from '@langchain/deepseek';
import { PromptTemplate } from '@langchain/core/prompts';

const model = new ChatDeepSeek({
    model: 'deepseek-reasoner',
    temperature: 0.7
});

const prompt = PromptTemplate.fromTemplate(`
  你是一个前端专家,用一句话解释: {topic}  
`);

// 核心变化:构建 Chain
// prompt (模板节点) -> model (LLM 节点)
const chain = prompt.pipe(model);

// 执行 Chain
const response = await chain.invoke({
    topic: '闭包'
});
console.log(response.content);

深度解析:LCEL 与声明式编程

这段代码引入了 .pipe() 方法,它深受 Unix 管道思想的影响。

  1. 声明式编程 (Declarative)
    我们不再编写“如何做”(先格式化,再调用),而是定义“是什么”(链条是 Prompt 流向 Model)。LangChain 运行时会自动处理数据的传递。
  2. Runnable 接口
    在 LangChain 中,Prompt、Model、OutputParser 甚至整个 Chain 都实现了 Runnable 接口。这意味着它们具有统一的调用方式(invoke, stream, batch)。
  3. 自动化数据流
    当我们调用 chain.invoke({ topic: '闭包' }) 时,对象 { topic: '闭包' } 首先进入 Prompt,Prompt 将其转化为完整的提示词字符串,然后该字符串自动流入 Model,最终输出结果。

这是构建 Agent(智能体)的基础单元。

第四阶段:编排复杂工作流——任务拆解与序列化

在真实业务中,单一的 Prompt 往往难以完美解决复杂问题。例如,我们希望 AI 既能“详细解释原理”,又能“精简总结要点”。如果试图在一个 Prompt 中完成,模型往往会顾此失彼。

更好的工程化思路是任务拆解。下面的这段代码展示了如何使用 RunnableSequence 串联多个任务:

JavaScript

// 3.js
import { ChatDeepSeek } from '@langchain/deepseek';
import { PromptTemplate } from '@langchain/core/prompts';
import { RunnableSequence } from '@langchain/core/runnables';

const model = new ChatDeepSeek({
    model: 'deepseek-reasoner',
    temperature: 0.7
});

// 任务 A:详细解释
const explainPrompt = PromptTemplate.fromTemplate(`
    你是一个前端专家,请详细介绍以下概念: {topic}
    要求:覆盖定义、原理、使用方式,不超过300字。
`);

// 任务 B:总结核心点
const summaryPrompt = PromptTemplate.fromTemplate(`
    请将以下前端概念总结为3个核心要点 (每点不超过20字):
    {explanation}
`);

// 创建两个独立的子链
const explainChain = explainPrompt.pipe(model);
const summaryChain = summaryPrompt.pipe(model);

// 核心逻辑:编排序列
const fullChain = RunnableSequence.from([
    // 第一步:输入 topic -> 获取详细解释 text
    (input) => explainChain.invoke({ topic: input.topic }).then(res => res.content),
    
    // 第二步:接收 explanation -> 生成总结 -> 组合最终结果
    (explanation) => summaryChain.invoke({ explanation }).then(res => 
        `知识点详情:\n${explanation}\n\n精简总结:\n${res.content}`
    )
]);

const response = await fullChain.invoke({
    topic: '闭包'
});
console.log(response);

深度解析:序列化工作流

这是一个典型的 Sequential Chain(顺序链)  模式。

  1. 输入/输出对齐
    第一步的输出(详细解释)通过函数传递,直接成为了第二步的输入变量 { explanation }。这种数据流的自动衔接是复杂 AI 应用的关键。
  2. DeepSeek Reasoner 的优势
    在这个场景中,我们使用了 deepseek-reasoner。对于解释原理和归纳总结这类需要逻辑分析(Reasoning)的任务,DeepSeek 的 R1 系列模型表现优异。通过拆解任务,我们让模型在每个步骤都专注于单一目标,从而大幅提升了输出质量。
  3. 可观测性与调试
    将长任务拆分为短链,使得我们在调试时可以单独检查 explainChain 的输出是否准确,而不必在一个巨大的黑盒 Prompt 中盲目尝试。

总结

到此为止我们见证了 AI 代码从“脚本”到“工程”的进化:

  1. 适配器模式:解决了模型接口碎片化问题。
  2. 提示词模板:实现了数据与逻辑的分离。
  3. LCEL 管道:将原子能力组装成自动化流程。
  4. 序列化编排:通过任务拆解解决复杂业务逻辑。
  5. **要想拿到大模型输出的结果,别忘了配置APIKEY和环境变量

LangChain.js 结合 DeepSeek,不仅仅是调用了一个 API,更是为您提供了一套构建可扩展、可维护 AI 系统的脚手架。作为前端开发者,掌握这种“搭积木”的思维方式,是在 AI 时代保持竞争力的关键。

AI流式交互:SSE与WebSocket技术选型

2026年2月9日 19:11

前言

在开发 AI 对话产品(如 ChatGPT、豆包)时,如何实现那种“一个字一个字蹦出来”的流畅打字机效果?SSE(Server-Sent Events) 凭借其轻量级、基于 HTTP 的特性,成为了目前流式输出的主流方案。本文将从基础概念到企业级实战,带你彻底掌握 SSE。

一、 核心概念:什么是 SSE?

SSE (Server-Sent Events) 是一种基于 HTTP 协议的服务端推送技术。它利用 HTTP 的长连接特性,在客户端与服务器之间建立一条持久化通道,服务器通过这条通道实现数据的实时单向推送。一个基于SSE的聊天返回接口如下:

image.png

1. 核心特点

  • 单向通信:仅支持服务器 → 客户端的数据推送。
  • 数据格式:原生仅支持文本(默认 UTF-8 编码)。
  • 自动重连:浏览器原生支持断线自动重连。
  • 传统限制:原生 EventSource 仅支持 GET 方法,无法满足 AI 场景中复杂的 POST 参数需求。

二、 基础用法:原生 EventSource

EventSource为浏览器提供的原生接口,适用于简单的单向推送场景。具体使用如下:

// 1. 创建实例连接接口
const eventSource = new EventSource('http://localhost:3000/sse');

// 2. 监听消息
eventSource.onmessage = (event) => {
  console.log('收到服务器数据:', event.data);
};

// 3. 监听自定义事件 (需与后端约定事件名)
eventSource.addEventListener('custom-event', (event) => {
  console.log('自定义事件消息:', event.data);
});

// 4. 状态监听
eventSource.onopen = () => console.log('SSE 连接已建立');
eventSource.onerror = (err) => console.error('SSE 出错:', err);

// 5. 主动关闭
// eventSource.close();

注意事项:原生 EventSource 仅支持 GET 方法,咱不能在向后端发送聊天内容时总是将参数拼在地址里面吧


三、 企业级实战:突破限制的 Fetch 方案

在 AI 场景下,用户的问题和上下文通常需要通过 POST 传输,此时原生 EventSource 就显得力不从心。推荐使用微软开源的 @microsoft/fetch-event-source 库。

1. 该方案的优势

  • 支持 POST 及其他所有 HTTP 方法。
  • 支持自定义请求头(Headers) (方便携带 Token 等鉴权信息)。
  • 更好的错误重试策略和超时配置。

2.代码封装

以下是我在不同项目中调用流式接口时封装的请求函数:

import { fetchEventSource } from '@microsoft/fetch-event-source'
import { ElMessage } from 'element-plus'

let controller //用于中止请求

//请求流式接口数据
export const requestStream = (url, headers, data, handleMessage) => {
  controller = new AbortController()
  fetchEventSource(url, {
    method: 'POST',
    signal: controller.signal,
    headers: headers,
    body: JSON.stringify(data),
    openWhenHidden: true, // 允许在页面隐藏时继续发送请求
    async onopen(response) {
      //建立连接的回调
      console.log('建立连接的回调')
    },
    //接收一次数据段时回调,因为是流式返回,所以这个回调会被调用多次
    onmessage(msg) {
      handleMessage(msg)
    },
    //正常结束的回调
    onclose() {
      controller.abort() //关闭连接
    },
    //连接出现异常回调
    onerror(err) {
      ElMessage({ message: err, type: 'error' })
      // 必须抛出错误才会停止
      throw err
    },
  })
}

// 停止生成数据
export const stopRequest = () => {
  if (controller) {
    controller.abort()
    controller = new AbortController()
  }
}

注意事项:如果想前端主动停止流式输出的话,可以通过AbortController配置(不过在AI对话场景中不建议这样使用,因为这个方案为前端主动停止接收数据,而后端实际上是无感知的,在查看历史消息时会造成数据不一致!!!建议还是让后端创造一个停止接口

3、 关键排坑:Nginx 代理配置

在实际场景中我们发现前端代码没问题,但输出却是“一大块一大块”地蹦,没有打字机效果。这通常是因为 Nginx 开启了响应缓冲

所以在配置 Nginx 代理时,必须显式关闭 proxy_buffering

location ^~/api/v1/chat/ {
      proxy_pass http://backend_server;
      proxy_set_header X-Forwarded-For $remote_addr;
      proxy_read_timeout   3600s; // 防止长连接超时断开
      
      # 核心配置:关闭代理缓冲,实现流式即时传输
      proxy_buffering off; 
      # 开启分块传输编码
      chunked_transfer_encoding on; 
}

四、SSE和WebSocket区别

关于WebSocket的概念与具体使用事项可以看这个文章:深度拆解 WebSocket

特性 SSE WebSocket
通信方向 单向(Server → Client) 全双工(双向互发)
协议基础 标准 HTTP/1.1 独立的 WS 协议(需握手升级)
数据格式 纯文本/JSON 二进制或文本
复杂度 轻量级,接入成本低 较重,逻辑复杂

五、 深度思考:为何 AI 对话多选择 SSE 而非 WebSocket?

  1. 功能契合度:AI 场景中前端只发一次请求(问题),后端持续输出(回答)。双向通信的 WebSocket 属于“功能过剩”。
  2. 兼容性与成本:SSE 基于标准 HTTP 协议,无需协议升级,天然支持流式输出,对 Nginx、网关的穿透性更好。

Flutter ——flutter_screenutil 屏幕适配

作者 Haha_bj
2026年2月9日 18:18

flutter_screenutil 是 Flutter 生态中最常用的屏幕适配库,核心原理是基于设计稿尺寸做等比例缩放,支持宽度 / 高度适配、字体适配、边框适配等,能快速解决不同设备的 UI 适配问题。下面从安装配置、核心用法、高级技巧、避坑指南四个维度全面解析。

一、基础准备:安装与初始化

1. 安装依赖

pubspec.yaml 中添加最新版本依赖:

dependencies:
  flutter:
    sdk: flutter
  flutter_screenutil: ^5.9.0 # 推荐使用最新稳定版

执行安装命令:

flutter pub get

2. 全局初始化(关键步骤)

必须在 APP 入口处初始化,指定设计稿的基准尺寸(通常以 iPhone 14/15 的 390×844 或 iPhone 13 的 375×812 为例):

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // 初始化ScreenUtil
    return ScreenUtilInit(
      // 设计稿尺寸(单位:px),根据你的UI设计稿修改
      designSize: const Size(390, 844), 
      // 适配规则:默认宽度适配,也可设为BoxConstraints.tightFor(height: ...)
      minTextAdapt: true, // 开启字体适配
      splitScreenMode: true, // 支持分屏适配
      builder: (context, child) {
        return MaterialApp(
          title: 'ScreenUtil示例',
          theme: ThemeData(primarySwatch: Colors.blue),
          home: child, // 传递子页面
          builder: (context, widget) {
            // 解决字体缩放导致的适配问题
            ScreenUtil.init(context);
            return MediaQuery(
              data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
              child: widget!,
            );
          },
        );
      },
      child: const HomePage(), // 你的首页
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('屏幕适配示例')),
      body: const Center(child: AdaptingWidget()),
    );
  }
}

二、核心用法:适配各种 UI 属性

flutter_screenutil 提供了两种常用适配方式:扩展方法(推荐)和静态方法,下面是最常用的适配场景:

1. 尺寸适配(宽 / 高 / 边距 / 圆角)

class AdaptingWidget extends StatelessWidget {
  const AdaptingWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Padding(
      // 边距适配:设计稿上的20px → 自动缩放
      padding: EdgeInsets.all(20.w), 
      child: Container(
        // 宽度适配:设计稿上的300px
        width: 300.w, 
        // 高度适配:设计稿上的150px
        height: 150.h, 
        // 圆角适配:设计稿上的16px
        decoration: BoxDecoration(
          color: Colors.blue,
          borderRadius: BorderRadius.circular(16.r), 
          // 边框宽度适配:设计稿上的2px
          border: Border.all(width: 2.w, color: Colors.white), 
        ),
        // 内边距适配
        child: Padding(
          padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              // 字体适配:设计稿上的18px
              Text(
                '适配后的文字',
                style: TextStyle(fontSize: 18.sp), 
              ),
              SizedBox(height: 10.h), // 间距适配
              // 图标大小适配
              Icon(Icons.phone, size: 24.sp, color: Colors.white),
            ],
          ),
        ),
      ),
    );
  }
}

2. 核心扩展方法说明

扩展方法 作用 示例
num.w 宽度适配(等比例缩放) 300.w → 设计稿 300px 宽度适配到当前设备
num.h 高度适配(等比例缩放) 150.h → 设计稿 150px 高度适配到当前设备
num.r 圆角 / 半径适配(优先按宽度适配) 16.r → 设计稿 16px 圆角适配
num.sp 字体大小适配(支持最小字体限制) 18.sp → 设计稿 18px 字体适配
num.sw 屏幕宽度占比(0-1) 0.8.sw → 占屏幕宽度 80%
num.sh 屏幕高度占比(0-1) 0.5.sh → 占屏幕高度 50%

3. 静态方法(无上下文时使用)

如果在工具类、初始化代码等无context的场景下,可使用静态方法:

// 获取适配后的尺寸
double width = ScreenUtil().setWidth(300);
double height = ScreenUtil().setHeight(150);
double fontSize = ScreenUtil().setSp(18);

// 获取屏幕信息
double screenWidth = ScreenUtil().screenWidth; // 设备实际宽度
double screenHeight = ScreenUtil().screenHeight; // 设备实际高度
double pixelRatio = ScreenUtil().pixelRatio; // 设备像素比

三、高级技巧:适配优化与特殊场景

1. 字体适配优化

默认的sp适配可能在小屏设备上字体过小,可设置最小字体限制:

// 方式1:全局初始化时设置
ScreenUtilInit(
  designSize: const Size(390, 844),
  minTextAdapt: true,
  // 自定义字体适配规则
  builder: (context, child) {
    // 设置最小字体为12sp
    ScreenUtil.init(context, minTextSize: 12);
    return child!;
  },
  child: const HomePage(),
);

// 方式2:单独设置字体(强制最小字体)
Text(
  '最小12sp的文字',
  style: TextStyle(
    fontSize: 10.spMin, // 即使适配后小于12sp,也显示12sp
  ),
);

2. 横竖屏适配

处理横竖屏切换时的适配更新:

class OrientationPage extends StatefulWidget {
  const OrientationPage({super.key});

  @override
  State<OrientationPage> createState() => _OrientationPageState();
}

class _OrientationPageState extends State<OrientationPage> {
  @override
  Widget build(BuildContext context) {
    // 横竖屏切换时重新初始化
    ScreenUtil.init(context);
    return Scaffold(
      body: Center(
        child: Text(
          '屏幕宽度:${ScreenUtil().screenWidth.w}\n屏幕高度:${ScreenUtil().screenHeight.h}',
          style: TextStyle(fontSize: 16.sp),
        ),
      ),
    );
  }

  // 监听横竖屏切换
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final orientation = MediaQuery.of(context).orientation;
    print('当前方向:$orientation');
  }
}

3. ListView/GridView 适配

避免滚动组件因适配导致的布局异常:

// ListView 适配示例
ListView.builder(
  // 高度适配
  itemExtent: 80.h, 
  itemCount: 10,
  itemBuilder: (context, index) {
    return ListTile(
      leading: Icon(Icons.person, size: 32.sp),
      title: Text('列表项 $index', style: TextStyle(fontSize: 16.sp)),
      subtitle: Text('副标题', style: TextStyle(fontSize: 12.sp)),
      contentPadding: EdgeInsets.symmetric(horizontal: 16.w),
    );
  },
);

// GridView 适配示例
GridView.count(
  crossAxisCount: 2, // 列数
  crossAxisSpacing: 10.w, // 列间距
  mainAxisSpacing: 10.h, // 行间距
  childAspectRatio: 16/9, // 宽高比(无需适配)
  padding: EdgeInsets.all(10.w),
  children: List.generate(4, (index) {
    return Container(
      width: (ScreenUtil().screenWidth - 30.w) / 2, // 手动计算宽度
      height: 100.h,
      color: Colors.grey,
    );
  }),
);

4. 自定义适配规则

针对特殊 UI(如固定宽高比的图片),可混合使用sw/sh

// 保持16:9比例的图片
Container(
  width: 0.9.sw, // 占屏幕宽度90%
  height: (0.9.sw) * 9 / 16, // 按16:9计算高度
  child: Image.network(
    'https://example.com/image.jpg',
    fit: BoxFit.cover,
  ),
);

四、避坑指南:常见问题与解决方案

1. 适配后布局变形

问题 解决方案
部分 UI 元素过大 / 过小 检查设计稿尺寸是否设置正确(如把 dp 当成 px);优先用w/h而非sw/sh
字体缩放导致布局错乱 MaterialAppbuilder中设置textScaleFactor: 1.0,禁用系统字体缩放
圆角适配不一致 统一使用r而非w/hr会自动适配宽高比

2. 无上下文时初始化失败

  • 错误场景:在main函数中直接调用ScreenUtil().setWidth(300)

  • 解决方案:先初始化WidgetsFlutterBinding,再手动初始化ScreenUtil

    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
      // 手动初始化(指定设计稿尺寸)
      await ScreenUtil.ensureScreenSize();
      ScreenUtil.init(
        BoxConstraints(
          maxWidth: WidgetsBinding.instance.window.physicalSize.width / WidgetsBinding.instance.window.devicePixelRatio,
          maxHeight: WidgetsBinding.instance.window.physicalSize.height / WidgetsBinding.instance.window.devicePixelRatio,
        ),
        designSize: const Size(390, 844),
      );
      runApp(const MyApp());
    }
    

3. 分屏 / 折叠屏适配

  • 开启splitScreenMode: true(初始化时);

  • 避免使用固定的sh(屏幕高度占比),优先用MediaQuery获取可用高度:

    double availableHeight = MediaQuery.of(context).size.height - MediaQuery.of(context).padding.top - kToolbarHeight;
    

总结

  1. 核心流程:先在ScreenUtilInit中指定设计稿尺寸,再通过w/h/r/sp扩展方法实现 UI 适配,覆盖 90% 的适配场景;
  2. 最佳实践:字体用sp(开启最小字体限制)、尺寸用w/h、圆角用r、占比用sw/sh
  3. 避坑要点:禁用系统字体缩放、横竖屏切换时重新初始化、无上下文时先初始化WidgetsFlutterBinding

如果需要针对某一特殊场景(如适配折叠屏、自定义设计稿尺寸、国际化多语言适配)的代码示例,我可以进一步补充。

Flutter ——device_info_plus详解

作者 Haha_bj
2026年2月9日 18:01

device_info_plus 是 Flutter 生态中最权威的设备信息获取库(官方维护),替代了已废弃的 device_info,支持 Android、iOS、Web、Windows、macOS、Linux、Android TV、watchOS 等几乎所有 Flutter 支持的平台。 库地址 pub.dev/packages/de…

安装

  1. 在项目的 pubspec.yaml 中添加依赖:
dependencies:
  flutter:
    sdk: flutter
    device_info_plus: ^12.3.0 # 建议使用最新版本
  1. 执行命令安装:
flutter pub get

一、核心基础:包架构与初始化

1. 核心类与初始化

device_info_plus 的核心是 DeviceInfoPlugin 单例,通过它可以获取不同平台的设备信息对象:

import 'package:device_info_plus/device_info_plus.dart';

// 1. 创建插件实例(全局唯一)
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();

// 2. 初始化(非必需,但异步操作建议在WidgetsFlutterBinding初始化后执行)
void initDeviceInfo() async {
  WidgetsFlutterBinding.ensureInitialized(); // 无上下文时必须调用
  // 后续获取设备信息的操作...
}

2. 平台与信息类对应表

平台 信息获取方法 核心信息类 适用场景
Android await deviceInfoPlugin.androidInfo AndroidDeviceInfo 获取安卓设备型号、系统版本、厂商等
iOS await deviceInfoPlugin.iosInfo IosDeviceInfo 获取 iOS 版本、设备型号、UUID 等
Web await deviceInfoPlugin.webBrowserInfo WebBrowserInfo 获取浏览器、操作系统、用户代理等
Windows await deviceInfoPlugin.windowsInfo WindowsDeviceInfo 获取 Windows 系统版本、处理器、设备名等
macOS await deviceInfoPlugin.macOsInfo MacOsDeviceInfo 获取 Mac 系统版本、机型、内存等
Linux await deviceInfoPlugin.linuxInfo LinuxDeviceInfo 获取 Linux 发行版、内核版本等
Android TV await deviceInfoPlugin.androidTvInfo AndroidTvDeviceInfo 区分安卓电视 / 普通安卓设备
watchOS await deviceInfoPlugin.watchOsInfo WatchOsDeviceInfo 获取苹果手表信息
Wear OS await deviceInfoPlugin.wearOsInfo WearOsDeviceInfo 获取安卓手表信息
Fuchsia await deviceInfoPlugin.fuchsiaInfo FuchsiaDeviceInfo 适配 Fuchsia 系统(暂未普及)

二、全平台核心 API 详解

1. Android 平台(AndroidDeviceInfo

最常用的属性(覆盖 90% 场景):

AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo;
print('=== Android 设备信息 ===');
print('设备型号:${androidInfo.model}'); // 例:Pixel 8 Pro、Redmi K70
print('系统版本:${androidInfo.version.release}'); // 例:14、13
print('API等级:${androidInfo.version.sdkInt}'); // 例:34(Android 14)、33(Android 13)
print('厂商:${androidInfo.manufacturer}'); // 例:Google、Xiaomi、Huawei
print('品牌:${androidInfo.brand}'); // 例:google、xiaomi、huawei
print('设备ID:${androidInfo.id}'); // 设备唯一标识(Android 10+ 无权限获取)
print('产品名:${androidInfo.product}'); // 例:cheetah(Pixel 8 Pro)
print('是否真机:${androidInfo.isPhysicalDevice}'); // true=真机,false=模拟器
print('指纹:${androidInfo.fingerprint}'); // 系统指纹(用于版本判断)
print('硬件名称:${androidInfo.hardware}'); // 例:qcom(高通)
print('支持的ABIs:${androidInfo.supportedAbis}'); // 支持的CPU架构(armeabi-v7a、arm64-v8a等)

2. iOS 平台(IosDeviceInfo

IosDeviceInfo iosInfo = await deviceInfoPlugin.iosInfo;
print('=== iOS 设备信息 ===');
print('设备名称:${iosInfo.name}'); // 例:iPhone 15 Pro
print('系统版本:${iosInfo.systemVersion}'); // 例:17.2
print('设备型号:${iosInfo.model}'); // 例:iPhone
print('具体机型:${iosInfo.utsname.machine}'); // 例:iPhone16,1(对应iPhone 15 Pro)
print('是否真机:${iosInfo.isPhysicalDevice}'); // true=真机,false=模拟器
print('设备UUID:${iosInfo.identifierForVendor}'); // 应用厂商唯一标识(卸载重装会变)
print('系统名称:${iosInfo.systemName}'); // 例:iOS、iPadOS
print('电池电量:${iosInfo.batteryLevel}'); // 0.0-1.0(需权限)
print('是否充电:${iosInfo.isCharging}');

3. Web 平台(WebBrowserInfo

WebBrowserInfo webInfo = await deviceInfoPlugin.webBrowserInfo;
print('=== Web 浏览器信息 ===');
print('浏览器名称:${webInfo.browserName}'); // BrowserName.chrome/edge/firefox/safari等
print('浏览器版本:${webInfo.browserVersion}'); // 例:120.0.0.0
print('操作系统:${webInfo.platform}'); // 例:Windows、macOS、Android
print('用户代理:${webInfo.userAgent}'); // 完整UA字符串
print('语言:${webInfo.language}'); // 例:zh-CN
print('是否移动设备:${webInfo.isMobile}'); // true/false

4. Windows 平台(WindowsDeviceInfo

WindowsDeviceInfo windowsInfo = await deviceInfoPlugin.windowsInfo;
print('=== Windows 设备信息 ===');
print('设备名称:${windowsInfo.computerName}'); // 电脑名
print('系统版本:${windowsInfo.systemVersion}'); // 例:10.0.22631
print('处理器名称:${windowsInfo.processorName}'); // 例:Intel(R) Core(TM) i7-12700H
print('内存大小(MB):${windowsInfo.memorySizeInMb}');
print('系统架构:${windowsInfo.systemArchitecture}'); // 例:X64
print('构建号:${windowsInfo.buildNumber}'); // 例:22631

5. macOS 平台(MacOsDeviceInfo

MacOsDeviceInfo macInfo = await deviceInfoPlugin.macOsInfo;
print('=== macOS 设备信息 ===');
print('系统版本:${macInfo.osVersion}'); // 例:14.1.1
print('机型:${macInfo.model}'); // 例:MacBook Pro
print('处理器:${macInfo.processorName}'); // 例:Apple M3 Pro
print('内存大小(GB):${macInfo.memorySizeInGb}');
print('UUID:${macInfo.systemGUID}'); // 系统唯一标识

三、高级用法:封装与实战场景

1. 封装通用工具类(推荐)

将设备信息获取逻辑封装为全局工具类,避免重复代码:

import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

class DeviceInfoUtil {
  static final DeviceInfoPlugin _plugin = DeviceInfoPlugin();
  static bool _isInitialized = false;

  // 初始化(全局调用一次即可)
  static Future<void> init() async {
    if (!_isInitialized) {
      WidgetsFlutterBinding.ensureInitialized();
      _isInitialized = true;
    }
  }

  // 通用:获取设备类型(手机/平板/PC/浏览器)
  static Future<String> getDeviceType() async {
    if (kIsWeb) {
      return "Web浏览器";
    }
    // defaultTargetPlatform 获取 设置的类型 
    switch (defaultTargetPlatform) {
      case TargetPlatform.android:
        final info = await _plugin.androidInfo;
        return info.model.contains("Tablet") ? "安卓平板" : "安卓手机";
      case TargetPlatform.iOS:
        final info = await _plugin.iosInfo;
        return info.model.contains("iPad") ? "iPad" : "iPhone";
      case TargetPlatform.windows:
        return "Windows PC";
      case TargetPlatform.macOS:
        return "Mac电脑";
      case TargetPlatform.linux:
        return "Linux设备";
      default:
        return "未知设备";
    }
  }

  // 通用:获取系统版本(如 "Android 14"、"iOS 17.2")
  static Future<String> getSystemVersion() async {
    if (kIsWeb) {
      final info = await _plugin.webBrowserInfo;
      return info.platform;
    }
    switch (defaultTargetPlatform) {
      case TargetPlatform.android:
        final info = await _plugin.androidInfo;
        return "Android ${info.version.release}";
      case TargetPlatform.iOS:
        final info = await _plugin.iosInfo;
        return "iOS ${info.systemVersion}";
      case TargetPlatform.windows:
        final info = await _plugin.windowsInfo;
        return "Windows ${info.systemVersion}";
      case TargetPlatform.macOS:
        final info = await _plugin.macOsInfo;
        return "macOS ${info.osVersion}";
      default:
        return "未知系统";
    }
  }

  // Android专属:判断是否为鸿蒙系统(鸿蒙伪装成Android)
  static Future<bool> isHarmonyOS() async {
    if (defaultTargetPlatform != TargetPlatform.android) return false;
    final info = await _plugin.androidInfo;
    // 鸿蒙系统的fingerprint或brand会包含harmony相关字段
    return info.fingerprint.contains("harmony") || info.brand.contains("Harmony");
  }
}

// 使用示例
void main() async {
  await DeviceInfoUtil.init();
  String deviceType = await DeviceInfoUtil.getDeviceType();
  String sysVersion = await DeviceInfoUtil.getSystemVersion();
  bool isHarmony = await DeviceInfoUtil.isHarmonyOS();
  print("设备类型:$deviceType");
  print("系统版本:$sysVersion");
  print("是否鸿蒙:$isHarmony");
}

2. 实战场景 1:版本适配

根据系统版本执行不同逻辑(如 Android 权限适配):

// Android 13+ 通知权限适配
Future<void> requestNotificationPermission() async {
  final androidInfo = await deviceInfoPlugin.androidInfo;
  if (androidInfo.version.sdkInt >= 33) { // Android 13 (API 33)
    // 请求POST_NOTIFICATIONS权限
    final status = await Permission.notification.request();
    if (status.isGranted) {
      print("通知权限已授予");
    }
  }
}

3. 实战场景 2:埋点统计

收集设备信息用于用户行为分析(合规前提下):

// 生成埋点用的设备信息JSON
Future<Map<String, dynamic>> getTrackDeviceInfo() async {
  Map<String, dynamic> info = {
    "platform": kIsWeb ? "web" : defaultTargetPlatform.name,
    "device_type": await DeviceInfoUtil.getDeviceType(),
    "system_version": await DeviceInfoUtil.getSystemVersion(),
    "is_emulator": false,
  };

  if (defaultTargetPlatform == TargetPlatform.android) {
    final androidInfo = await deviceInfoPlugin.androidInfo;
    info["manufacturer"] = androidInfo.manufacturer;
    info["model"] = androidInfo.model;
    info["is_emulator"] = !androidInfo.isPhysicalDevice;
  } else if (defaultTargetPlatform == TargetPlatform.iOS) {
    final iosInfo = await deviceInfoPlugin.iosInfo;
    info["model"] = iosInfo.utsname.machine;
    info["is_emulator"] = !iosInfo.isPhysicalDevice;
  }

  return info;
}

四、避坑指南:常见问题与解决方案

1. 权限问题

问题 解决方案
Android 10+ 无法获取androidId 改用identifierForVendor(iOS)或自定义 UUID(如flutter_secure_storage存储)
iOS 获取电池信息失败 需要在Info.plist中添加NSBatteryUsageDescription说明用途
Web 无法获取精准设备型号 Web 受限于浏览器沙箱,只能通过 UA 解析(可结合第三方库ua_parser

2. 兼容性问题

问题 解决方案
模拟器与真机返回值差异 isPhysicalDevice判断,避免模拟器数据干扰
不同 Android 厂商定制系统字段差异 优先使用model/manufacturer,避免依赖fingerprint等非标字段
macOS 14+ 获取内存信息失败 升级device_info_plus到最新版本(>=10.0.0)

3. 性能与异步问题

  • 设备信息获取是异步操作,不要在build方法中直接调用,建议在initState或全局初始化时获取;
  • 避免频繁调用(如每次点击按钮都获取),建议缓存结果;
  • 无上下文时必须先调用WidgetsFlutterBinding.ensureInitialized(),否则会崩溃。

总结

  1. 核心定位device_info_plus 是 Flutter 多平台设备信息获取的官方首选,替代废弃的device_info,覆盖全平台;
  2. 使用原则:按平台调用对应info方法,优先封装为工具类,异步操作需处理异常和缓存;
  3. 避坑要点:注意权限合规、模拟器 / 真机差异、Android 10+ 设备标识限制,无上下文时先初始化WidgetsFlutterBinding

如果需要针对某一特殊场景(如鸿蒙系统识别、iOS 广告 ID 获取、Web 端设备指纹)的详细代码,我可以进一步补充。

JavaScript数据结构深度解析:栈、队列与树的实现与应用

作者 wuhen_n
2026年2月9日 17:53

当函数调用层层嵌套,JavaScript 引擎如何管理这些调用?当事件循环处理异步任务时,背后的数据结构是什么?本文将深入探讨栈、队列和树在前端开发中的核心应用。

前言:从函数调用说起

// 一个简单的函数调用栈示例
function funcA() {
    console.log('进入函数A');
    funcB();
    console.log('离开函数A');
}

function funcB() {
    console.log('进入函数B');
    funcC();
    console.log('离开函数B');
}

function funcC() {
    console.log('进入函数C');
    console.log('离开函数C');
}

funcA();

这段代码的输出顺序是什么?进入函数A 进入函数B 进入函数C 离开函数C 离开函数B 离开函数A

这种"先进后出"的执行顺序,正是的典型特征。

栈(Stack)的实现与应用

栈的基本概念

是一种后进先出(LIFO)的数据结构,就像一摞盘子,我们只能从最上面取放盘子。

栈的操作示意图

      push(3)      push(5)      pop() → 5
┌───┐    →    ┌───┐    →    ┌───┐    →    ┌───┐
│   │         │ 3 │         │ 5 │         │ 3 │
├───┤         ├───┤         ├───┤         ├───┤
│   │         │   │         │ 3 │         │   │
└───┘         └───┘         └───┘         └───┘
 空栈          栈底:3        栈顶:5        栈底:3

用数组实现栈

class ArrayStack {
  constructor(capacity = 10) {
    this.items = new Array(capacity);  // 底层数组
    this.top = -1;                     // 栈顶指针
    this.capacity = capacity;          // 栈容量
  }

  // 入栈操作
  push(element) {
    if (this.isFull()) {
      throw new Error('栈已满');
    }
    this.top++;
    this.items[this.top] = element;
    return this;
  }

  // 出栈操作
  pop() {
    if (this.isEmpty()) {
      throw new Error('栈为空');
    }
    const element = this.items[this.top];
    this.items[this.top] = undefined;  // 清理引用
    this.top--;
    return element;
  }

  // 查看栈顶元素(不弹出)
  peek() {
    if (this.isEmpty()) {
      return null;
    }
    return this.items[this.top];
  }

  // 判断栈是否为空
  isEmpty() {
    return this.top === -1;
  }

  // 判断栈是否已满
  isFull() {
    return this.top === this.capacity - 1;
  }

  // 获取栈大小
  get size() {
    return this.top + 1;
  }

  // 清空栈
  clear() {
    this.items = new Array(this.capacity);
    this.top = -1;
  }
}

用链表实现栈

class ListNode {
  constructor(value, next = null) {
    this.value = value;
    this.next = next;
  }
}

class LinkedListStack {
  constructor() {
    this.top = null;    // 栈顶节点
    this.length = 0;    // 栈长度
  }

  // 入栈操作
  push(element) {
    // 创建新节点,指向原来的栈顶
    const newNode = new ListNode(element, this.top);
    this.top = newNode;
    this.length++;
    return this;
  }

  // 出栈操作
  pop() {
    if (this.isEmpty()) {
      throw new Error('栈为空');
    }
    const value = this.top.value;
    this.top = this.top.next;  // 移动栈顶指针
    this.length--;
    return value;
  }

  peek() {
    if (this.isEmpty()) {
      return null;
    }
    return this.top.value;
  }

  isEmpty() {
    return this.top === null;
  }

  get size() {
    return this.length;
  }

  clear() {
    this.top = null;
    this.length = 0;
  }
}

数组栈 vs 链表栈

  • 数组栈:内存连续,CPU缓存友好,访问速度快
  • 链表栈:动态扩容,无容量限制,但内存不连续
  • 实际选择:JavaScript 数组经过V8引擎优化,通常数组栈性能更好

栈的实际应用

应用1:函数调用栈模拟

class CallStackSimulator {
  constructor() {
    this.callStack = new ArrayStack(50);
    this.currentDepth = 0;
    this.maxDepth = 0;
  }

  // 函数调用
  callFunction(funcName) {
    const frame = {
      funcName,
      timestamp: Date.now(),
      localVars: {}
    };

    this.callStack.push(frame);
    this.currentDepth++;
    this.maxDepth = Math.max(this.maxDepth, this.currentDepth);

    console.log(`调用: ${funcName} (深度: ${this.currentDepth})`);
    this.printStack();

    return frame;
  }

  // 函数返回
  returnFromFunction() {
    if (this.callStack.isEmpty()) {
      console.log('调用栈已空');
      return null;
    }

    const frame = this.callStack.pop();
    const duration = Date.now() - frame.timestamp;
    this.currentDepth--;

    console.log(`返回: ${frame.funcName} (耗时: ${duration}ms)`);
    console.log(`剩余深度: ${this.currentDepth}`);

    return frame;
  }
}

队列(Queue)与双端队列(Deque)

队列(Queue)的基本概念

队列 是一种先进先出(FIFO)的数据结构,就像排队买票,先来的人先服务。

队列的操作示意图

      enqueue(3)    enqueue(5)    dequeue() → 3
┌───┬───┐  →  ┌───┬───┐  →  ┌───┬───┐  →  ┌───┬───┐
│   │   │     │ 3 │   │     │ 3 │ 5 │     │   │ 5 │
└───┴───┘     └───┴───┘     └───┴───┘     └───┴───┘
front rear   front↑rear    front rear↑     ↑front rear

双端队列(Deque)的基本概念

双端队列 允许在两端进行插入和删除操作,结合了栈和队列的特性。

双端队列的实现

class Deque {
  constructor(capacity = 10) {
    this.items = new Array(capacity);
    this.capacity = capacity;
    this.front = 0;
    this.rear = 0;
    this.count = 0;
  }

  // 前端添加
  addFront(element) {
    if (this.isFull()) {
      this._resize();
    }

    this.front = (this.front - 1 + this.capacity) % this.capacity;
    this.items[this.front] = element;
    this.count++;

    console.log(`前端添加: ${element}, front: ${this.front}`);
    return this;
  }

  // 后端添加
  addRear(element) {
    if (this.isFull()) {
      this._resize();
    }

    this.items[this.rear] = element;
    this.rear = (this.rear + 1) % this.capacity;
    this.count++;

    console.log(`后端添加: ${element}, rear: ${this.rear}`);
    return this;
  }

  // 前端删除
  removeFront() {
    if (this.isEmpty()) {
      throw new Error('队列为空');
    }

    const element = this.items[this.front];
    this.items[this.front] = undefined;
    this.front = (this.front + 1) % this.capacity;
    this.count--;

    console.log(`前端删除: ${element}, front: ${this.front}`);
    return element;
  }

  // 后端删除
  removeRear() {
    if (this.isEmpty()) {
      throw new Error('队列为空');
    }

    this.rear = (this.rear - 1 + this.capacity) % this.capacity;
    const element = this.items[this.rear];
    this.items[this.rear] = undefined;
    this.count--;

    console.log(`后端删除: ${element}, rear: ${this.rear}`);
    return element;
  }

  // 查看前端
  peekFront() {
    if (this.isEmpty()) {
      return null;
    }
    return this.items[this.front];
  }

  // 查看后端
  peekRear() {
    if (this.isEmpty()) {
      return null;
    }
    const index = (this.rear - 1 + this.capacity) % this.capacity;
    return this.items[index];
  }

  // 扩容
  _resize() {
    const newCapacity = this.capacity * 2;
    const newItems = new Array(newCapacity);

    // 复制元素到新数组
    for (let i = 0; i < this.count; i++) {
      const index = (this.front + i) % this.capacity;
      newItems[i] = this.items[index];
    }

    this.items = newItems;
    this.front = 0;
    this.rear = this.count;
    this.capacity = newCapacity;

    console.log(`队列扩容: ${this.capacity / 2}${newCapacity}`);
  }

  isEmpty() {
    return this.count === 0;
  }

  isFull() {
    return this.count === this.capacity;
  }

  get size() {
    return this.count;
  }
}

队列的实际应用

应用1:任务调度器

class TaskScheduler {
  constructor() {
    this.taskQueue = new CircularQueue(100);  // 任务队列
    this.currentTask = null;
    this.taskId = 0;
    this.isProcessing = false;
  }

  // 创建任务
  createTask(name, duration, priority = 0) {
    return {
      id: ++this.taskId,
      name,
      duration,      // 任务执行时间(毫秒)
      priority,      // 优先级(数值越小优先级越高)
      status: 'pending',
      createdAt: Date.now(),
      startedAt: null,
      completedAt: null
    };
  }

  // 添加任务到队列
  addTask(task) {
    this.taskQueue.enqueue(task);
    console.log(`任务添加: ${task.name} (ID: ${task.id})`);
    this.printQueueStatus();

    // 如果没有正在处理的任务,开始处理
    if (!this.isProcessing) {
      this.processNextTask();
    }
  }

  // 处理下一个任务
  async processNextTask() {
    if (this.taskQueue.isEmpty()) {
      this.isProcessing = false;
      console.log('所有任务处理完成!');
      return;
    }

    this.isProcessing = true;
    this.currentTask = this.taskQueue.dequeue();
    this.currentTask.status = 'processing';
    this.currentTask.startedAt = Date.now();

    console.log(`\n开始处理任务: ${this.currentTask.name}`);
    console.log(`任务ID: ${this.currentTask.id}, 预计耗时: ${this.currentTask.duration}ms`);
    this.printQueueStatus();

    // 模拟任务执行
    await this.executeTask(this.currentTask);

    // 任务完成
    this.currentTask.completedAt = Date.now();
    this.currentTask.status = 'completed';
    const totalTime = this.currentTask.completedAt - this.currentTask.startedAt;

    console.log(`任务完成: ${this.currentTask.name}`);
    console.log(`实际耗时: ${totalTime}ms`);

    // 处理下一个任务
    setTimeout(() => {
      this.processNextTask();
    }, 0);
  }

  // 模拟任务执行
  executeTask(task) {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve();
      }, task.duration);
    });
  }
}

树(Tree)的实现与应用

树的基本概念

是一种分层的数据结构,由节点和边组成。每个节点有零个或多个子节点,没有父节点的节点称为根节点;没有子节点的节点称为叶子节点

二叉树结构示意图

        A (根节点)
       / \
      B   C
     / \   \
    D   E   F
   /
  G

术语解释:

  • 根节点: A
  • 叶子节点: E, F, G
  • 深度: 节点G的深度是3
  • 高度: 树的高度是3
  • 子树: B、D、E、G构成一个子树

二叉树的实现

class TreeNode {
  constructor(value) {
    this.value = value;    // 节点值
    this.left = null;      // 左子节点
    this.right = null;     // 右子节点
    this.parent = null;    // 父节点(可选)
  }

  // 判断是否为叶子节点
  isLeaf() {
    return this.left === null && this.right === null;
  }

  // 判断是否有左子节点
  hasLeft() {
    return this.left !== null;
  }

  // 判断是否有右子节点
  hasRight() {
    return this.right !== null;
  }

  // 获取高度
  getHeight() {
    const leftHeight = this.left ? this.left.getHeight() : 0;
    const rightHeight = this.right ? this.right.getHeight() : 0;
    return Math.max(leftHeight, rightHeight) + 1;
  }
}

二叉树遍历算法

class BinaryTree {
  constructor() {
    this.root = null;  // 根节点
  }

  // 前序遍历:根 → 左 → 右
  preorderTraversal(callback) {
    const result = [];
    this._preorder(this.root, callback, result);
    return result;
  }

  _preorder(node, callback, result) {
    if (node === null) return;

    // 访问当前节点
    if (callback) callback(node);
    result.push(node.value);

    // 遍历左子树
    this._preorder(node.left, callback, result);

    // 遍历右子树
    this._preorder(node.right, callback, result);
  }

  // 中序遍历:左 → 根 → 右(对二叉搜索树会得到有序序列)
  inorderTraversal(callback) {
    const result = [];
    this._inorder(this.root, callback, result);
    return result;
  }

  _inorder(node, callback, result) {
    if (node === null) return;

    // 遍历左子树
    this._inorder(node.left, callback, result);

    // 访问当前节点
    if (callback) callback(node);
    result.push(node.value);

    // 遍历右子树
    this._inorder(node.right, callback, result);
  }

  // 后序遍历:左 → 右 → 根
  postorderTraversal(callback) {
    const result = [];
    this._postorder(this.root, callback, result);
    return result;
  }

  _postorder(node, callback, result) {
    if (node === null) return;

    // 遍历左子树
    this._postorder(node.left, callback, result);

    // 遍历右子树
    this._postorder(node.right, callback, result);

    // 访问当前节点
    if (callback) callback(node);
    result.push(node.value);
  }

  // 层序遍历:按层级从上到下,从左到右
  levelOrderTraversal(callback) {
    if (this.root === null) return [];

    const result = [];
    const queue = new CircularQueue(100);
    queue.enqueue(this.root);

    while (!queue.isEmpty()) {
      const node = queue.dequeue();

      // 访问当前节点
      if (callback) callback(node);
      result.push(node.value);

      // 将子节点加入队列
      if (node.left) {
        queue.enqueue(node.left);
      }
      if (node.right) {
        queue.enqueue(node.right);
      }
    }

    return result;
  }

  // 深度优先搜索(DFS)
  dfs(targetValue) {
    return this._dfs(this.root, targetValue);
  }

  _dfs(node, targetValue) {
    if (node === null) return null;

    // 检查当前节点
    if (node.value === targetValue) {
      return node;
    }

    // 搜索左子树
    const leftResult = this._dfs(node.left, targetValue);
    if (leftResult !== null) {
      return leftResult;
    }

    // 搜索右子树
    return this._dfs(node.right, targetValue);
  }

  // 广度优先搜索(BFS)
  bfs(targetValue) {
    if (this.root === null) return null;

    const queue = new CircularQueue(100);
    queue.enqueue(this.root);

    while (!queue.isEmpty()) {
      const node = queue.dequeue();

      // 检查当前节点
      if (node.value === targetValue) {
        return node;
      }

      // 将子节点加入队列
      if (node.left) {
        queue.enqueue(node.left);
      }
      if (node.right) {
        queue.enqueue(node.right);
      }
    }
    return null;
  }
}

数据结构选择的关键因素

访问模式

  • 随机访问:数组(O(1))
  • 顺序访问:链表、栈、队列
  • 层级访问:树

操作频率

  • 频繁插入/删除开头:链表
  • 频繁插入/删除两端:双端队列
  • 频繁随机访问:数组

内存考虑

  • 内存连续:数组(缓存友好)
  • 内存分散:链表(无扩容成本)
  • 动态大小:链表、树

结语

本文讲解了栈、队列和树的实现与应用,理解这些数据结构,不仅能帮助我们在面试中表现出色,更能让我们在实际开发中写出更高效、更可靠的代码。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

JavaScript链表与双向链表实现:理解数组与链表的差异

作者 wuhen_n
2026年2月9日 17:47

数组在 JavaScript 中如此方便,为什么我们还需要链表呢?当 V8 引擎处理数组时,链表又在哪些场景下更有优势?本篇文章将深入探讨数据结构的核心差异。

前言:从一道面试题说起

// 面试题:如何高效地从大型数据集合中频繁插入和删除元素?
const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// 场景1:在数组开头插入元素(性能如何?)
data.unshift(0); // 需要移动所有元素!

// 场景2:在数组中间删除元素(性能如何?)
data.splice(5, 1); // 需要移动一半的元素!

// 场景3:只需要顺序访问数据
for (let i = 0; i < data.length; i++) {
    console.log(data[i]); // 数组很快!
}

那么问题来了:有没有一种数据结构,插入和删除快,顺序访问也快?当然有,答案就是:链表! 数组和链表是编程中最基础的两种数据结构,理解它们的差异能帮助我们在不同场景下做出最优选择。

理解数组与链表的本质差异

内存结构对比

我们先通过一个简图,观察一下数组和链表在内存中的存储方式:

数组的内存结构(连续存储)

┌─────┬─────┬─────┬─────┬─────┐
│  0  │  1  │  2  │  3  │  4  │ ← 索引
├─────┼─────┼─────┼─────┼─────┤
│  10 │  20 │  30 │  40 │  50 │ ← 值
└─────┴─────┴─────┴─────┴─────┘
地址: 1000 1004 1008 1012 1016 (假设每个元素占4字节)
数组的存储特点:
  1. 连续的内存空间
  2. 通过索引直接计算地址:地址 = 基地址 + 索引 × 元素大小
  3. 随机访问时间复杂度:O(1)

链表的内存结构(非连续存储)

      ┌─────┐    ┌─────┐    ┌─────┐
头 →  │  10 │ →  │  20 │ →  │  30 │ → null
      └─────┘    └─────┘    └─────┘
地址:  2000       3040       4080  (地址不连续)
链表的特点
  1. 非连续的内存空间
  2. 每个节点包含数据和指向下一个节点的指针
  3. 随机访问需要遍历:O(n)
  4. 插入和删除只需要修改指针:O(1)

时间复杂度对比表

数据结构 访问 插入开头 插入结尾 插入中间 删除开头 删除结尾 删除中间 搜索
数组 O(1) O(n) O(1) O(n) O(n) O(1) O(n) O(n)
单向链表 O(n) O(1) O(n) O(n) O(1) O(n) O(n) O(n)
双向链表 O(n) O(1) O(1) O(n) O(1) O(1) O(n) O(n)
带尾指针的单向链表 O(n) O(1) O(1) O(n) O(1) O(n) O(n) O(n)

关键差异总结

  1. 随机访问元素:数组完胜(O(1) vs O(n))
  2. 插入/删除开头:链表完胜(O(1) vs O(n))
  3. 插入/删除结尾:数组和双向链表都很快
  4. 插入/删除中间:都不快,但链表稍好
  5. 内存使用:数组更紧凑,链表有指针开销

实现单向链表

基础节点类

class ListNode {
  constructor(value, next = null) {
    this.value = value;  // 存储的数据
    this.next = next;    // 指向下一个节点的指针
  }
}

单向链表类

class LinkedList {
  constructor() {
    this.head = null;    // 链表头节点
    this.length = 0;     // 链表长度
  }

  // 获取链表长度
  get size() {
    return this.length;
  }

  // 在链表头部添加节点
  addFirst(value) {
    // 创建新节点,指向原来的头节点
    const newNode = new ListNode(value, this.head);
    // 更新头节点为新节点
    this.head = newNode;
    this.length++;
    return this;
  }

  // 在链表尾部添加节点
  addLast(value) {
    const newNode = new ListNode(value);
    // 如果链表为空,新节点就是头节点
    if (this.head == null) {
      this.head = newNode;
    } else {
      // 找到最后一个节点
      let current = this.head;
      while (current.next != null) {
        current = current.next;
      }
      // 将新节点添加到末尾
      current.next = newNode;
    }
    this.length++;
    return this;
  }

  // 删除头节点
  removeFirst() {
    if (this.head == null) {
      return null;
    }
    const removedValue = this.head.value;
    this.head = this.head.next;
    this.length--;

    return removedValue;
  }

  // 删除尾节点
  removeLast() {
    if (this.head == null) {
      return null;
    }
    // 如果只有一个节点
    if (this.head.next == null) {
      const removedValue = this.head.value;
      this.head = null;
      this.length--;
      return removedValue;
    }
    // 找到倒数第二个节点
    let current = this.head;
    while (current.next.next != null) {
      current = current.next;
    }
    const removedValue = current.next.value;
    current.next = null;
    this.length--;
    return removedValue;
  }
}

单向链表的实际应用

应用1:浏览器历史记录

class BrowserHistory {
  constructor() {
    this.history = new LinkedList();
    this.current = null;
  }

  // 访问新页面
  visit(url) {
    // 如果当前有页面,添加到历史记录
    if (this.current !== null) {
      this.history.addLast(this.current);
    }
    this.current = url;
    console.log(`访问: ${url}`);
  }

  // 后退
  back() {
    if (this.history.size === 0) {
      console.log('无法后退:已经是第一页');
      return null;
    }
    const previous = this.history.removeLast();
    const current = this.current;
    this.current = previous;
    console.log(`后退: ${current}${previous}`);
    return previous;
  }

  // 查看历史记录
  showHistory() {
    console.log('历史记录:');
    this.history.forEach((url, index) => {
      console.log(`  ${index + 1}. ${url}`);
    });
    console.log(`当前: ${this.current}`);
  }
}

应用2:任务队列

class TaskQueue {
  constructor() {
    this.queue = new LinkedList();
  }

  // 添加任务
  enqueue(task) {
    this.queue.addLast(task);
    console.log(`添加任务: ${task.name}`);
  }

  // 执行下一个任务
  dequeue() {
    if (this.queue.isEmpty()) {
      console.log('任务队列为空');
      return null;
    }

    const task = this.queue.removeFirst();
    console.log(`执行任务: ${task.name}`);

    // 模拟任务执行
    try {
      task.execute();
    } catch (error) {
      console.error(`任务执行失败: ${error.message}`);
    }

    return task;
  }

  // 查看下一个任务(不执行)
  peek() {
    if (this.queue.isEmpty()) {
      return null;
    }
    return this.queue.get(0);
  }

  // 清空任务队列
  clear() {
    this.queue = new LinkedList();
    console.log('任务队列已清空');
  }

  // 获取队列长度
  get size() {
    return this.queue.size;
  }

  // 打印队列状态
  printQueue() {
    console.log('当前任务队列:');
    this.queue.forEach((task, index) => {
      console.log(`  ${index + 1}. ${task.name}`);
    });
  }
}

实现双向链表

基础节点类

class ListNode {
  constructor(value, next = null) {
    this.value = value;  // 存储的数据
    this.next = next;    // 指向下一个节点的指针
    this.prev = prev;    // 指向前一个节点的指针
  }
}

双向链表类

class DoublyLinkedList {
  constructor() {
    this.head = null;    // 链表头节点
    this.tail = null;    // 链表尾节点
    this.length = 0;     // 链表长度
  }

  get size() {
    return this.length;
  }

  // 在头部添加节点
  addFirst(value) {
    const newNode = new DoubleListNode(value, null, this.head);

    if (this.head !== null) {
      this.head.prev = newNode;
    } else {
      // 如果链表为空,新节点也是尾节点
      this.tail = newNode;
    }

    this.head = newNode;
    this.length++;
    return this;
  }

  // 在尾部添加节点
  addLast(value) {
    const newNode = new DoubleListNode(value, this.tail, null);

    if (this.tail !== null) {
      this.tail.next = newNode;
    } else {
      // 如果链表为空,新节点也是头节点
      this.head = newNode;
    }

    this.tail = newNode;
    this.length++;
    return this;
  }

  // 删除头节点
  removeFirst() {
    if (this.head === null) {
      return null;
    }

    const removedValue = this.head.value;

    if (this.head === this.tail) {
      // 只有一个节点
      this.head = null;
      this.tail = null;
    } else {
      this.head = this.head.next;
      this.head.prev = null;
    }

    this.length--;
    return removedValue;
  }

  // 删除尾节点
  removeLast() {
    if (this.tail === null) {
      return null;
    }

    const removedValue = this.tail.value;

    if (this.head === this.tail) {
      // 只有一个节点
      this.head = null;
      this.tail = null;
    } else {
      this.tail = this.tail.prev;
      this.tail.next = null;
    }

    this.length--;
    return removedValue;
  }
}

双向链表的实际应用

应用1:浏览器历史记录(增强版)

class EnhancedBrowserHistory {
  constructor() {
    this.history = new DoublyLinkedList();
    this.current = null;
    this.currentIndex = -1;
  }

  // 访问新页面
  visit(url) {
    console.log(`访问: ${url}`);

    // 如果当前位置不在末尾,需要截断后面的历史
    if (this.currentIndex < this.history.size - 1) {
      // 移除当前位置之后的所有历史
      const removeCount = this.history.size - 1 - this.currentIndex;
      for (let i = 0; i < removeCount; i++) {
        this.history.removeLast();
      }
    }

    // 添加新页面到历史记录
    if (this.current !== null) {
      this.history.addLast(this.current);
    }

    this.current = url;
    this.currentIndex = this.history.size;

    this.printHistory();
  }

  // 后退
  back() {
    if (this.currentIndex <= 0) {
      console.log('无法后退:已经是第一页');
      return null;
    }

    this.currentIndex--;
    this.current = this.history.get(this.currentIndex);

    console.log(`后退到: ${this.current}`);
    this.printHistory();

    return this.current;
  }

  // 前进
  forward() {
    if (this.currentIndex >= this.history.size - 1) {
      console.log('无法前进:已经是最后一页');
      return null;
    }

    this.currentIndex++;
    this.current = this.currentIndex === this.history.size ?
      '当前页面' : this.history.get(this.currentIndex);

    console.log(`前进到: ${this.current}`);
    this.printHistory();

    return this.current;
  }

  // 查看历史记录
  printHistory() {
    console.log('历史记录:');
    this.history.forEach((url, index) => {
      const marker = index === this.currentIndex ? '← 当前' : '';
      console.log(`  ${index + 1}. ${url} ${marker}`);
    });

    if (this.currentIndex === this.history.size) {
      console.log(`  当前: ${this.current}`);
    }
  }

  // 跳转到指定历史
  go(index) {
    if (index < 0 || index > this.history.size) {
      console.log(`无效的历史位置: ${index}`);
      return null;
    }

    this.currentIndex = index;
    this.current = index === this.history.size ?
      '当前页面' : this.history.get(index);

    console.log(`跳转到: ${this.current}`);
    this.printHistory();

    return this.current;
  }
}

核心要点总结

数组 vs 链表的本质差异

  • 数组:连续内存,随机访问快(O(1)),插入删除慢(O(n))
  • 链表:非连续内存,随机访问慢(O(n)),插入删除快(O(1))
  • JavaScript数组:是特殊对象,V8引擎会优化存储方式

单向链表 vs 双向链表

  • 单向链表:每个节点只有一个指针(next),内存开销小
  • 双向链表:每个节点有两个指针(prev, next),支持双向遍历
  • 选择:需要反向操作时用双向链表,否则用单向链表

结语

数据结构的选择没有绝对的对错,只有适合与否。理解数组和链表的差异,能帮助我们在实际开发中做出更明智的选择,写出更高效的代码。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

React 19 核心特性与版本优化深度解析

2026年2月9日 17:21

React 19 核心特性与版本优化深度解析

一、React 19 核心新特性概述

React 19 是 React 生态自 18 版本以来的重大里程碑,其核心目标是通过架构革新开发范式升级,解决长期困扰开发者的性能瓶颈代码冗余问题。以下是 React 19 最具标志性的新特性:

1. 服务器组件(Server Components, RSC):生产就绪的架构革命

React 19 将服务器组件从实验性功能升级为稳定特性,彻底改变了 React 应用的渲染模式。服务器组件是运行在服务器端的特殊组件,其代码不会打包到客户端 bundle 中,而是通过流式传输将渲染后的 HTML 发送到客户端。

  • 核心优势

    • 零客户端 bundle 增长:服务器组件的逻辑(如数据获取、复杂计算)完全在服务器端执行,客户端无需下载任何相关代码,显著减少 bundle 体积。
    • 直接数据访问:服务器组件可直接访问数据库、文件系统等服务器端资源,无需通过 API 层转发,简化数据获取流程。
    • 流式渲染(Streaming SSR):服务器组件支持流式传输,即先发送页面的核心内容(如 header、导航),再逐步发送次要内容(如评论、推荐),提升用户感知性能。
  • 示例

    // 服务器组件(app/analytics/page.server.tsx)
    import { db } from '@/lib/database';
    import { AnalyticsChart } from './AnalyticsChart.client';
    
    export default async function AnalyticsPage() {
      // 服务器端直接查询数据库
      const metrics = await db.query(`SELECT date, revenue FROM analytics`);
      const aggregated = processMetrics(metrics);
      return <AnalyticsChart data={aggregated} />;
    }
    
    // 客户端组件(AnalyticsChart.client.tsx)
    'use client';
    export function AnalyticsChart({ data }) {
      // 客户端交互逻辑(如筛选、 tooltip)
    }
    

2. 动作(Actions):简化数据变更的“声明式神器”

React 19 引入Actions API,旨在消除数据变更(如表单提交、API 调用)的样板代码。Actions 是声明式的异步操作,可自动管理加载状态错误处理乐观更新,无需手动编写 useState 或 useEffect

  • 核心优势

    • 自动状态管理:Actions 内置 isPending(加载中)、error(错误)等状态,无需手动维护。
    • 乐观更新支持:通过 useOptimistic Hook,可在请求提交时立即更新 UI(如点赞按钮的“已点赞”状态),提升用户体验。
    • 表单集成<form> 元素可直接绑定 Actions,自动处理表单提交与重置,无需手动调用 preventDefault 或 fetch
  • 示例

    // 动作函数(actions/user.ts)
    'use server';
    export async function updateUserProfile(prevState, formData) {
      try {
        const updated = await db.user.update({ where: { id: session.userId }, data: formData });
        revalidatePath('/profile'); // 重新验证页面数据
        return { success: true, user: updated };
      } catch (error) {
        return { success: false, error: error.message };
      }
    }
    
    // 组件中使用(components/ProfileForm.tsx)
    import { useActionState } from 'react';
    import { updateUserProfile } from '@/actions/user';
    
    function ProfileForm({ user }) {
      const [state, formAction, isPending] = useActionState(updateUserProfile, { user });
      return (
        <form action={formAction}>
          <input name="name" defaultValue={user.name} />
          <button disabled={isPending}>{isPending ? '保存中...' : '保存'}</button>
          {state.error && <p className="error">{state.error}</p>}
        </form>
      );
    }
    

3. React 编译器:自动优化的“性能魔法”

React 19 引入内置编译器,通过静态分析智能记忆(Memoization),自动优化组件渲染,消除手动 useMemo/useCallback 的冗余代码

  • 核心优势

    • 自动依赖追踪:编译器会分析组件的数据流依赖关系,自动识别需要 memo 化的计算(如复杂过滤、排序),无需手动添加 useMemo
    • 减少内存开销:避免过度缓存(如简单计算的 useMemo),减少内存占用。
    • 代码简化:移除大量 useMemo/useCallback 代码,使组件逻辑更清晰。
  • 示例

    // React 18:手动 memo 化
    const filteredProducts = useMemo(() => products.filter(p => matchesFilters(p, filters)), [products, filters]);
    const handleSort = useCallback((sortBy) => { /* 排序逻辑 */ }, []);
    
    // React 19:编译器自动优化
    const filteredProducts = products.filter(p => matchesFilters(p, filters));
    const handleSort = (sortBy) => { /* 排序逻辑 */ };
    

4. 新 Hook:提升开发效率的“利器”

React 19 新增多个实用 Hook,进一步简化开发流程:

  • **use():统一数据获取use() Hook 可在渲染中直接读取 Promise 或 Context**,无需手动处理 useEffect 或 useState。例如:

    function UserProfile({ userId }) {
      const user = use(fetchUser(userId)); // 直接读取 Promise
      return <div>{user.name}</div>;
    }
    
  • **useOptimistic():乐观更新简化useOptimistic() Hook 可自动管理乐观更新的回滚**,无需手动处理错误状态。例如:

    function LikeButton({ postId, initialLikes }) {
      const [likes, addOptimisticLike] = useOptimistic(initialLikes, (current) => current + 1);
      const handleLike = async () => {
        addOptimisticLike(); // 立即更新 UI
        await likePost(postId); // 提交请求
      };
      return <button onClick={handleLike}>❤️ {likes}</button>;
    }
    

二、React 19 与 React 18 的优化对比

React 19 的优化并非增量调整,而是架构级的革新,以下是与 React 18 的关键差异:

1. 性能优化:从“手动调优”到“自动优化”

维度 React 18 React 19 优化效果
初始渲染 依赖手动 React.memo/useMemo 编译器自动 memo 化 初始渲染速度提升 40%
重新渲染 易出现“无效重渲染”(如 props 引用变化) 编译器自动追踪依赖,避免无效重渲染 重新渲染次数减少 66%
Bundle 大小 包含服务器组件代码(如 getServerSideProps 服务器组件零客户端 bundle 增长 Bundle 体积减少 15%-30%
内存占用 过度缓存导致内存开销 按需优化,减少冗余缓存 内存占用减少 33%

2. 开发体验:从“冗余代码”到“简洁逻辑”

  • 数据获取:React 18 需手动编写 useEffect + fetch + useState,而 React 19 通过 use() Hook 与服务器组件,消除数据获取的样板代码
  • 表单处理:React 18 需手动管理 isSubmittingerror 等状态,而 React 19 通过 useActionState Hook,一行代码搞定表单提交
  • 状态管理:React 18 需手动使用 useMemo/useCallback 优化状态,而 React 19 通过编译器自动优化,减少状态管理的复杂度。

3. 架构模式:从“客户端主导”到“服务器优先”

React 18 的架构以客户端为中心,数据获取、计算均在客户端完成;而 React 19 采用服务器优先的架构,通过服务器组件将数据获取、计算移至服务器端,减少客户端负担。这种架构模式的转变,彻底解决了 React 应用的“首屏加载慢”问题

三、React 19 迁移指南与注意事项

1. 迁移步骤

  • 更新依赖:将 reactreact-dom 升级至 19 版本。
  • 移除废弃 API:删除 findDOMNode()Legacy Context APIString refs 等废弃 API。
  • 适配服务器组件:将部分客户端组件迁移至服务器端(如数据密集型组件),使用 'use server' 指令标记服务器组件。
  • 逐步采用新特性:先使用 useActionStateuseOptimistic 等新 Hook,再逐步迁移至服务器组件。

2. 注意事项

  • 服务器组件限制:服务器组件无法使用客户端 API(如 windowlocalStorage),需通过客户端组件封装。
  • 第三方库兼容性:部分第三方库(如 reduxmobx)可能需要更新至支持 React 19 的版本。
  • 性能测试:迁移后需进行性能测试,确保优化效果符合预期。

四、总结:React 19 是“未来 React 开发”的起点

React 19 的核心价值在于将性能优化与开发体验提升到了新高度

  • 通过服务器组件解决了“首屏加载慢”的痛点;
  • 通过Actions API消除了数据变更的样板代码;
  • 通过编译器实现了自动优化,减少了手动调优的工作量。

对于开发者而言,React 19 不是“可选的升级”,而是“必须拥抱的未来”——它将帮助开发者构建更快速、更简洁、更易维护的 React 应用。

axios 请求头封装过程遵循「最小可用 → 逐步增强」

2026年2月9日 17:05

步骤 1:极简版 - 基础实例 + 默认请求头

这是最基础的封装,仅创建 axios 实例并配置固定默认请求头,满足最基本的请求头需求,适合新手入门。

1.1 安装依赖

npm install axios
# 或 yarn add axios

1.2 基础封装代码(src/utils/request.ts)

// 第一步:仅创建axios实例 + 固定默认请求头
import axios from 'axios';

// 1. 创建axios实例,配置基础请求头
const request = axios.create({
  baseURL: 'http://localhost:3000/api', // 接口基础地址(临时写死)
  timeout: 10000, // 请求超时时间
  // 核心:默认请求头配置(所有请求都会携带)
  headers: {
    'Content-Type': 'application/json;charset=utf-8', // 默认JSON格式
    'X-Platform': 'web' // 自定义固定请求头(如平台标识)
  }
});

// 导出实例,直接使用
export default request;

1.3 组件中简单使用

<script setup lang="ts">
import request from '@/utils/request';

// 发起请求(自动携带默认请求头)
const fetchData = async () => {
  try {
    const res = await request.get('/user/list');
    console.log(res);
  } catch (err) {
    console.error(err);
  }
};
</script>

1.4 关键解释

  • axios.create():创建独立的 axios 实例,避免污染全局 axios 配置;
  • headers 配置项:设置所有请求默认携带的固定请求头,比如 Content-Type 是最常用的默认头;
  • 此版本仅满足「固定请求头」需求,无法动态修改(如 Token)。

步骤 2:基础增强 - 动态请求头(Token)+ 请求拦截器

这是实际项目中最核心的需求:根据登录状态动态添加 Token 到请求头,通过请求拦截器实现。

2.1 升级封装代码(src/utils/request.ts)

// 第二步:新增请求拦截器,动态添加Token
import axios, { AxiosRequestConfig, AxiosError } from 'axios';

const request = axios.create({
  baseURL: 'http://localhost:3000/api',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json;charset=utf-8',
    'X-Platform': 'web'
  }
});

// 核心新增:请求拦截器(请求发送前执行,用于修改请求头)
request.interceptors.request.use(
  // 成功回调:修改请求配置(重点是headers)
  (config: AxiosRequestConfig) => {
    // 1. 从本地存储获取Token(登录后存入)
    const token = localStorage.getItem('token');
    // 2. 如果有Token,动态添加到请求头(后端常用Authorization字段)
    if (token) {
      // 注意:TypeScript需先判断headers存在,避免类型报错
      config.headers = config.headers || {};
      config.headers.Authorization = `Bearer ${token}`; // 按后端格式拼接
    }
    return config;
  },
  // 失败回调:捕获请求配置错误
  (error: AxiosError) => {
    console.error('请求头配置失败:', error);
    return Promise.reject(error);
  }
);

export default request;

2.2 组件使用(登录后自动携带 Token)

<script setup lang="ts">
import request from '@/utils/request';

// 模拟登录:登录成功后存储Token
const login = async () => {
  const res = await request.post('/login', { username: 'test', password: '123456' });
  // 存储Token到本地
  localStorage.setItem('token', res.data.token);
};

// 登录后发起请求:自动携带Token请求头
const fetchUserInfo = async () => {
  const res = await request.get('/user/info');
  console.log(res);
};
</script>

2.3 关键解释

  • request.interceptors.request.use():请求拦截器是「动态修改请求头」的核心,请求发送前会执行此回调;
  • Token 处理逻辑:登录后将 Token 存入 localStorage,后续所有请求都会通过拦截器自动添加到 Authorization 头;
  • TypeScript 注意:config.headers 可能为 undefined,需先赋值避免类型报错。

步骤 3:TS 强化 - 类型约束 + 统一响应格式

在 Vue + TS 项目中,需要完善类型定义,避免 any 类型,保证代码的类型安全。

3.1 升级封装代码(src/utils/request.ts)

// 第三步:完善TypeScript类型约束
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';

// 新增1:定义接口返回数据的通用类型(和后端约定)
interface ApiResponse<T = any> {
  code: number; // 业务状态码(如200成功,401未登录)
  message: string; // 提示信息
  data: T; // 实际返回数据
}

// 新增2:扩展axios配置类型(支持自定义业务配置,比如是否需要Token)
interface CustomRequestConfig extends AxiosRequestConfig {
  needToken?: boolean; // 自定义配置:是否需要携带Token(默认true)
}

// 创建实例时指定类型
const request: AxiosInstance = axios.create({
  baseURL: 'http://localhost:3000/api',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json;charset=utf-8',
    'X-Platform': 'web'
  }
});

// 拦截器升级:使用自定义配置类型
request.interceptors.request.use(
  (config: CustomRequestConfig) => {
    // 新增:根据needToken配置决定是否携带Token
    if (config.needToken !== false) {
      const token = localStorage.getItem('token');
      if (token) {
        config.headers = config.headers || {};
        config.headers.Authorization = `Bearer ${token}`;
      }
    }
    return config;
  },
  (error: AxiosError) => {
    return Promise.reject(error);
  }
);

// 新增:响应拦截器(统一处理返回结果,强化类型)
request.interceptors.response.use(
  (response: AxiosResponse<ApiResponse>) => {
    const res = response.data;
    // 统一业务码校验(比如401未登录)
    if (res.code !== 200) {
      if (res.code === 401) {
        console.error('Token过期,请重新登录');
        // 可在此处跳转登录页
        localStorage.removeItem('token');
      }
      return Promise.reject(new Error(res.message || '请求失败'));
    }
    return res; // 直接返回处理后的data,简化组件使用
  },
  (error: AxiosError) => {
    console.error('请求失败:', error.message);
    return Promise.reject(error);
  }
);

export default request;

3.2 组件使用(带类型提示)

<script setup lang="ts">
import request from '@/utils/request';

// 定义返回数据的具体类型
interface UserInfo {
  id: number;
  name: string;
  age: number;
}

// 1. 带Token的请求(默认)
const fetchUserInfo = async () => {
  try {
    // 泛型指定返回数据类型,获得TS类型提示
    const res = await request.get<ApiResponse<UserInfo>>('/user/info');
    console.log(res.data.name); // TS会提示name属性
  } catch (err) {
    console.error(err);
  }
};

// 2. 不需要Token的公开接口(手动关闭)
const fetchPublicData = async () => {
  const res = await request.get('/public/data', {
    needToken: false // 自定义配置:不携带Token
  });
  console.log(res);
};
</script>

3.3 关键解释

  • ApiResponse:统一接口返回类型,避免组件中使用 any,TS 会提示 code/message/data 字段;
  • CustomRequestConfig:扩展 axios 原生配置,支持自定义业务配置(如 needToken),灵活控制是否携带 Token;
  • 响应拦截器:统一处理业务码(如 401 Token 过期),简化组件中的错误处理。

步骤 4:易用性提升 - 封装通用请求方法

封装 get/post 等通用方法,简化组件调用,进一步强化 TypeScript 泛型支持。

4.1 升级封装代码(src/utils/request.ts)

// 第四步:封装get/post通用方法,简化使用
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';

// 复用步骤3的类型定义
interface ApiResponse<T = any> {
  code: number;
  message: string;
  data: T;
}

interface CustomRequestConfig extends AxiosRequestConfig {
  needToken?: boolean;
}

const request: AxiosInstance = axios.create({
  baseURL: 'http://localhost:3000/api',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json;charset=utf-8',
    'X-Platform': 'web'
  }
});

// 复用步骤3的拦截器逻辑
request.interceptors.request.use(
  (config: CustomRequestConfig) => {
    if (config.needToken !== false) {
      const token = localStorage.getItem('token');
      if (token) {
        config.headers = config.headers || {};
        config.headers.Authorization = `Bearer ${token}`;
      }
    }
    return config;
  },
  (error: AxiosError) => Promise.reject(error)
);

request.interceptors.response.use(
  (response: AxiosResponse<ApiResponse>) => {
    const res = response.data;
    if (res.code !== 200) {
      if (res.code === 401) {
        localStorage.removeItem('token');
        console.error('请重新登录');
      }
      return Promise.reject(new Error(res.message));
    }
    return res;
  },
  (error: AxiosError) => Promise.reject(error)
);

// 核心新增:封装通用GET方法
export const get = <T = any>(
  url: string,
  params?: Record<string, any>, // URL参数类型
  config?: CustomRequestConfig // 自定义配置
): Promise<ApiResponse<T>> => {
  return request.get(url, { params, ...config });
};

// 封装通用POST方法
export const post = <T = any>(
  url: string,
  data?: Record<string, any>, // 请求体数据类型
  config?: CustomRequestConfig
): Promise<ApiResponse<T>> => {
  return request.post(url, data, config);
};

// 导出封装方法(组件优先用get/post,而非直接用request)
export default request;

4.2 组件使用(更简洁)

<script setup lang="ts">
import { get, post } from '@/utils/request';

interface UserInfo {
  id: number;
  name: string;
}

// 使用封装的get方法(参数更清晰,泛型提示)
const fetchUser = async () => {
  const res = await get<UserInfo>('/user/info', { id: 1 });
  console.log(res.data.name);
};

// 使用封装的post方法
const submitForm = async () => {
  const res = await post<{ success: boolean }>('/user/save', { name: '张三' });
  console.log(res.data.success);
};
</script>

4.3 关键解释

  • 封装 get/post 方法:将「URL、参数、配置」拆分,参数更清晰,符合日常开发习惯;
  • 泛型支持:方法级别的泛型 <T> 让组件能精准指定返回数据类型,TS 提示更友好;
  • 组件无需关注 axios 原生调用方式(如 get 的参数是 { params }),降低使用成本。

步骤 5:进阶优化 - 自动适配 Content-Type + 环境变量

解决「表单 / 文件上传」场景的请求头适配,同时通过环境变量隔离不同环境的接口地址。

5.1 升级封装代码(src/utils/request.ts)

// 第五步:进阶优化(Content-Type自动适配 + 环境变量)
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';

interface ApiResponse<T = any> {
  code: number;
  message: string;
  data: T;
}

interface CustomRequestConfig extends AxiosRequestConfig {
  needToken?: boolean;
}

// 核心修改1:从环境变量读取baseURL(不再写死)
const request: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL, // Vite环境变量(需以VITE_开头)
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json;charset=utf-8',
    'X-Platform': 'web'
  }
});

request.interceptors.request.use(
  (config: CustomRequestConfig) => {
    // 1. Token逻辑(复用)
    if (config.needToken !== false) {
      const token = localStorage.getItem('token');
      if (token) {
        config.headers = config.headers || {};
        config.headers.Authorization = `Bearer ${token}`;
      }
    }

    // 核心新增2:自动适配Content-Type(表单/文件上传场景)
    if (config.data instanceof FormData) {
      // 文件上传时,自动修改为multipart/form-data(无需手动设置)
      config.headers['Content-Type'] = 'multipart/form-data';
    }

    return config;
  },
  (error: AxiosError) => Promise.reject(error)
);

request.interceptors.response.use(
  (response: AxiosResponse<ApiResponse>) => {
    const res = response.data;
    if (res.code !== 200) {
      if (res.code === 401) {
        localStorage.removeItem('token');
        console.error('Token过期,请重新登录');
      }
      return Promise.reject(new Error(res.message));
    }
    return res;
  },
  (error: AxiosError) => Promise.reject(error)
);

// 封装get/post(复用)
export const get = <T = any>(
  url: string,
  params?: Record<string, any>,
  config?: CustomRequestConfig
): Promise<ApiResponse<T>> => {
  return request.get(url, { params, ...config });
};

export const post = <T = any>(
  url: string,
  data?: Record<string, any> | FormData, // 支持FormData类型
  config?: CustomRequestConfig
): Promise<ApiResponse<T>> => {
  return request.post(url, data, config);
};

export default request;

5.2 配置环境变量(项目根目录)

创建 .env.development(开发环境)和 .env.production(生产环境):

# .env.development(本地开发)
VITE_API_BASE_URL = 'http://localhost:3000/api'

# .env.production(生产环境)
VITE_API_BASE_URL = 'https://api.yourdomain.com'

5.3 组件使用(文件上传示例)

<script setup lang="ts">
import { post } from '@/utils/request';

// 文件上传:自动适配multipart/form-data请求头
const uploadFile = async (file: File) => {
  const formData = new FormData();
  formData.append('file', file);
  
  const res = await post<{ url: string }>('/file/upload', formData);
  console.log('文件地址:', res.data.url);
};
</script>

5.4 关键解释

  • 环境变量:通过 import.meta.env.VITE_API_BASE_URL 读取不同环境的接口地址,避免打包时修改代码;
  • Content-Type 自动适配:检测到 FormData 时,自动将请求头改为 multipart/form-data,无需手动配置;
  • 生产环境隔离:开发 / 生产环境的接口地址通过环境变量区分,符合工程化最佳实践。

总结

从简到繁的封装核心步骤可归纳为:

  1. 基础层:创建 axios 实例 + 固定默认请求头,满足最基本的请求头配置;
  2. 核心层:添加请求拦截器,实现 Token 等动态请求头的按需添加;
  3. 类型层:完善 TypeScript 类型约束,避免 any 类型,保证类型安全;
  4. 易用层:封装 get/post 通用方法,简化组件调用,提升开发效率;
  5. 优化层:自动适配 Content-Type、配置环境变量,适配复杂业务场景。

Vue3 + Element-Plus 自定义虚拟表格滚动实现方案【附源码】

2026年2月9日 17:01

大家好,我是前端架构师,关注微信公众号【程序员大卫】,免费领取精品前端资料。

背景

本文将基于 Vue3 + Element Plus,实现一个完全自定义的虚拟滚动表格方案,支持不等高行、缓存高度、缓冲区渲染,并且与 el-table 解耦。

Element Plus 虽然提供了虚拟滚动,但目前还是测试(beta)阶段,所以暂时先没用了,其实当时写这个组件主要是为了给 Element UI 使用的。

一、如何使用

在实际业务中,如果你正在使用 Element Plus 的 el-table,又遇到了大数据量导致滚动卡顿的问题,那么这个组件可以在不改 el-table 任何代码的前提下,为表格提供一套高性能的虚拟滚动能力

使用方式非常简单:只需要用 VirtualListTable 包一层 el-table,并通过 change 事件接收当前需要渲染的数据即可。

  • VirtualListTable 负责滚动、计算可视区和缓冲区数据,
  • el-table 只负责展示当前这一小段数据,二者完全解耦。
<VertualListTable :list-data="tableBigData" @change="renderVirtualData">
  <TableBigData :data="tableRenderData" />
</VertualListTable>

二、VirtualListTable 核心实现解析

1️⃣ 虚拟滚动的关键变量

const start = ref(0);                 // 当前起始索引
const cacheHeight = new Map();        // 行高缓存
let positions: Positions = [];        // 每一行的位置 & 高度
let scrollTop = 0;

positions 的结构:

{
  id: number | string,
  height: number,
  top: number
}

这是整个虚拟滚动的“地图”。

2️⃣ 可视区数据计算(含 buffer)

const visibleCount = computed(() =>
  Math.ceil(props.height / props.estimatedItemSize)
);

const visibleData = computed(() => {
  const startIndex = Math.max(start.value - props.bufferCount, 0);
  const endIndex = Math.min(
    start.value + visibleCount.value + props.bufferCount,
    props.listData.length,
  );
  return props.listData.slice(startIndex, endIndex);
});

为什么要 buffer?

  • 防止滚动时白屏
  • 提前渲染上下缓冲区域,提升体验

3️⃣ 滚动时如何快速定位起始索引(关键)

这里使用的是 二分查找

const getStartIndex = (list: Positions, scrollTop: number) => {
  let index = null;
  let low = 0;
  let high = list.length - 1;

  while (low <= high) {
    const mid = low + ((high - low) >> 1);
    const midVal = list[mid]?.top;
    if (midVal <= scrollTop) {
      index = mid;
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return index ?? 0;
};

时间复杂度从 O(n) 降到 O(log n),在大数据量下非常关键。

4️⃣ 使用 transform 控制真实 DOM 偏移

const setStartOffset = () => {
  const index = Math.max(start.value - props.bufferCount, 0);
  const offset = positions[index]?.top ?? 0;
  contentRef.value!.style.transform =
    `translate3d(0, ${offset}px, 0)`;
};
  • 不操作 top
  • 使用 transform,避免触发重排
  • GPU 加速,滚动更顺滑

5️⃣ 不等高行的核心:高度缓存 + ResizeObserver

const updateItemsSize = () => {
  getNodes().forEach((node) => {
    const id = getNodeId(node);
    const height = node.getBoundingClientRect().height;
    cacheHeight.set(id, height);
  });
};

结合 ResizeObserver

ro = new ResizeObserver(() => {
  if (ignoreResize) return;
  updateLayout();
});

解决的问题:

  • 表格行高度动态变化
  • 文本换行、slot 变化
  • 不需要强制固定行高

6️⃣ 占位元素撑开滚动条

这是虚拟滚动中最核心的一步:DOM 只渲染几十行,但滚动条看起来像有几千行

<div ref="placeholder" class="placeholder"></div>
const updateTotalHeight = () => {
  const lastItem = positions.at(-1);
  placeholderRef.value!.style.height =
    (lastItem.top + lastItem.height) + 'px';
};

三、与 el-table 的无侵入融合

  • 不改 el-table 源码
  • 只接管滚动容器
  • 所有表格功能照常使用(排序、列、样式)
const initElement = () => {
  const $wrapper = containerRef.value
    ?.querySelector('.el-table__body-wrapper');
  const $tableBody = $wrapper
    ?.querySelector('.el-scrollbar');

  contentRef.value?.appendChild($tableBody);
  $wrapper?.appendChild(scrollBoxRef.value!);
};

四、TableBigData:保持纯展示

<el-table :data="data">
  <el-table-column prop="name" label="Name" />
  <el-table-column prop="email" label="Email" />
</el-table>

五、总结

1. 这个方案适合什么场景

  • 超大数据量表格(1000+)
  • 行高不固定
  • 老项目 + Element Plus
  • 对滚动性能要求高

2. 方案优势

  • 支持不等高行
  • 与 el-table 解耦
  • 二分查找高性能
  • buffer 防白屏
  • ResizeObserver 自动修正

源码: github.com/zm8/wechat-…

❌
❌