普通视图

发现新文章,点击刷新页面。
昨天 — 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: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,加载瞬间高度撑开会引发剧烈的页面抖动。

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 只负责展示当前这一小段数据,二者完全解耦。
<script>
// 渲染虚拟数据
const renderVirtualData = (data)=>{
    tableData.value = data;
}
</script>

<VertualListTable :list-data="largeData" @change="renderVirtualData">
    <el-table :data="tableData">
        <el-table-column prop="name" label="Name" />
        <el-table-column prop="address" label="Address" />
    </el-table>
</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-…

微前端路由设计方案 & 子应用管理保活

2026年2月9日 16:59

版本: v2.0(sync 模式重构)
日期: 2026-02-09
框架: wujie(无界)— alive + sync 模式
适用范围: CMCLink 微前端主应用 + 7 个子应用


一、架构总览

1.1 系统拓扑

┌─────────────────────────────────────────────────────────────────┐
│                    CMCLink 微前端架构                             │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │              主应用 @cmclink/main (:3000)                  │  │
│  │  ┌─────────┐  ┌──────────────┐  ┌─────────────────────┐  │  │
│  │  │ Router  │  │ AuthLayout   │  │     App.vue         │  │  │
│  │  │ (Vue)   │  │ (Header/Tab) │  │ (WujieVue 容器)     │  │  │
│  │  └────┬────┘  └──────┬───────┘  └──────────┬──────────┘  │  │
│  │       │              │                      │             │  │
│  │       │     wujie bus (EventEmitter)        │             │  │
│  │       │    ┌─────────┴──────────┐           │             │  │
│  └───────┼────┼────────────────────┼───────────┼─────────────┘  │
│          │    │                    │           │                 │
│  ┌───────┴────┴────────────────────┴───────────┴─────────────┐  │
│  │                    子应用沙箱层 (wujie)                      │  │
│  │                                                           │  │
│  │  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────────┐   │  │
│  │  │  doc    │ │  mkt    │ │ common  │ │ ibs-manage   │   │  │
│  │  │ :3003   │ │ :3001   │ │ :3006   │ │    :3007     │   │  │
│  │  └─────────┘ └─────────┘ └─────────┘ └──────────────┘   │  │
│  │  ┌─────────┐ ┌─────────┐ ┌─────────┐                    │  │
│  │  │commerce │ │operation│ │ general │                    │  │
│  │  │-finance │ │ :3004   │ │ :3005   │                    │  │
│  │  │ :3002   │ │         │ │         │                    │  │
│  │  └─────────┘ └─────────┘ └─────────┘                    │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │              共享包 @cmclink/micro-bridge                   │  │
│  │  registry.ts │ url.ts │ types.ts │ bridges                │  │
│  └───────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘

1.2 子应用注册表

所有子应用在 packages/micro-bridge/src/registry.ts 中统一注册:

子应用名称 端口 activeRule entry 状态
mkt 3001 /mkt /mkt/ 待迁移
commerce-finance 3002 /commerce-finance /commerce-finance/ 待迁移
doc 3003 /doc /doc/ ✅ 已迁移
operation 3004 /operation /operation/ 待迁移
general 3005 /general /general/ 待迁移
common 3006 /common /common/ 待迁移
ibs-manage 3007 /ibs-manage /ibs-manage/ ✅ 已迁移

1.3 Monorepo 项目结构

微前端/
├── apps/
│   ├── cmclink-web-micro-main/   # 主应用 @cmclink/main
│   ├── doc/                       # 子应用 @cmclink/doc
│   └── ibs-manage/                # 子应用 @cmclink/ibs-manage
├── packages/
│   ├── micro-bridge/              # 微前端通信 SDK
│   ├── micro-bootstrap/           # 子应用启动器
│   ├── tsconfig/                  # 共享 TS 配置
│   └── vite-config/               # 共享 Vite 配置
├── pnpm-workspace.yaml
└── turbo.json

二、路由设计方案

2.1 URL 设计原则

核心原则:URL 对用户无感,子应用路径直接拼接在主应用路径后。

主应用 base:  /micro-main/
子应用路由:   /micro-main/{appName}/{子应用内部路径}

示例:
  /micro-main/                                          → 主应用首页
  /micro-main/ibs-manage/operation/enterpriseMgmt       → ibs-manage 子应用
  /micro-main/doc/document/blManage                     → doc 子应用
  /micro-main/profile                                   → 主应用个人中心

2.2 主应用路由配置

主应用 Vue Router 采用 createWebHistory(VITE_BASE_PATH) 模式,路由分为三层:

路由树:
├── /login                          # 公开路由(无需登录)
├── /forget-password                # 公开路由
├── /resetPwd                       # 公开路由
├── /                               # AuthenticatedLayout(需登录)
│   ├── /                           # 主应用首页
│   ├── /profile                    # 个人中心
│   ├── /message-center             # 消息中心
│   ├── /mkt/:pathMatch(.*)*        # 子应用占位路由(render null)
│   ├── /commerce-finance/:pathMatch(.*)*
│   ├── /doc/:pathMatch(.*)*
│   ├── /ibs-manage/:pathMatch(.*)*
│   ├── /common/:pathMatch(.*)*
│   ├── /operation/:pathMatch(.*)*
│   └── /general/:pathMatch(.*)*
└── /:pathMatch(.*)*                # 404 兜底

关键设计点

  • 子应用路由使用 /:pathMatch(.*)* 通配符,确保 /ibs-manage/operation/xxx 等深层路径都能匹配
  • 子应用路由的 component 设为 { render: () => null },不渲染任何主应用组件
  • 子应用路由的 meta.appName 标识所属子应用,用于 Tab 管理
  • 所有子应用路由都是 AuthenticatedLayout 的 children,确保 Header/Tab 始终显示

2.3 主应用显隐控制

App.vueAuthenticatedLayout.vue 协同控制主应用内容与子应用容器的显隐:

App.vue:
├── <RouterView>                    # 始终渲染(AuthenticatedLayout 被 keep-alive 缓存)
│   └── <AuthenticatedLayout>
│       ├── <LayoutHeader>          # 始终显示
│       ├── <SiderMenu>             # 始终显示
│       └── <main-app-content>      # v-show="!findAppByRoute(route.path)"
│           └── <RouterView>        # 主应用页面
│
└── <child-app-container>           # v-if="userStore.isSetUser && isChildRoute"
    └── <WujieVue v-for>            # v-show="route.path.startsWith(app.activeRule)"
        ├── doc
        ├── ibs-manage
        └── ...

显隐判断逻辑

场景 main-app-content child-app-container 说明
/ (首页) ✅ 显示 ❌ 隐藏 findAppByRoute 返回 undefined
/profile ✅ 显示 ❌ 隐藏 主应用页面
/ibs-manage/xxx ❌ 隐藏 ✅ 显示 子应用路由,前缀匹配
/doc/xxx ❌ 隐藏 ✅ 显示 子应用路由

2.4 子应用 URL 生成

开发环境和生产环境使用不同的 URL 生成策略:

// packages/micro-bridge/src/url.ts
function getAppUrl(app: MicroAppConfig, envGetter): string {
  // 环境变量 key: VITE_${APP_NAME}_APP_URL
  // 例如: VITE_IBS_MANAGE_APP_URL=http://localhost:3007
  const envKey = `VITE_${app.name.toUpperCase().replace(/-/g, '_')}_APP_URL`
  const envUrl = envGetter(envKey)

  if (envUrl) {
    // 开发环境: http://localhost:3007 + /ibs-manage/ = http://localhost:3007/ibs-manage/
    return `${envUrl}${app.entry}`
  }
  // 生产环境: 直接使用 entry(相对路径,由 nginx 反向代理)
  return app.entry
}

环境变量配置.env.base):

VITE_MKT_APP_URL=http://localhost:3001
VITE_COMMERCE_FINANCE_APP_URL=http://localhost:3002
VITE_DOC_APP_URL=http://localhost:3003
VITE_OPERATION_APP_URL=http://localhost:3004
VITE_GENERAL_APP_URL=http://localhost:3005
VITE_COMMON_APP_URL=http://localhost:3006
VITE_IBS_MANAGE_APP_URL=http://localhost:3007

三、路由同步机制

3.1 wujie sync 模式

采用 wujie 官方推荐的 sync 模式,由框架自动完成主应用与子应用之间的路由同步,无需手动 bus 通信、无需防循环标记

<!-- App.vue -->
<WujieVue
  :name="app.name"
  :url="getAppUrl(app)"
  :alive="true"
  :sync="true"    ← 启用 sync 模式
/>

sync 模式原理

  • wujie 内部劫持子应用的 history.pushState / history.replaceState
  • 子应用路由变化时,wujie 自动将子应用路径同步到主应用 URL
  • 主应用 URL 变化时,wujie 自动驱动子应用路由跳转
  • 整个过程由框架内部处理,无循环风险

3.2 数据流设计

设计原则:路由同步完全委托给 wujie sync 模式,业务层只负责 router.push

┌─────────────────────────────────────────────────────────────┐
│                     路由同步数据流                             │
│                                                             │
│  场景 A: 菜单/Tab 点击                                       │
│  ──────────────────────                                     │
│  menuClick / tabClick                                       │
│    └── router.push('/ibs-manage/operation/xxx')             │
│         │                                                   │
│         ▼                                                   │
│  主应用 URL 变化 → wujie sync 自动驱动子应用路由跳转           │
│    └── 子应用 router 自动跳转到 /operation/xxx               │
│                                                             │
│  ✅ 无需 bus 通信,无循环风险                                  │
│                                                             │
│                                                             │
│  场景 B: 子应用内部导航                                       │
│  ──────────────────────                                     │
│  子应用内部点击链接 → router.push('/operation/yyy')           │
│         │                                                   │
│         ▼                                                   │
│  wujie sync 自动同步到主应用 URL                              │
│    └── 主应用地址栏更新为 /ibs-manage/operation/yyy           │
│                                                             │
│  ✅ 无需 bus 通信,无循环风险                                  │
└─────────────────────────────────────────────────────────────┘

3.3 与手动方案对比

维度 sync 模式(当前方案) 手动 bus 双向通信(旧方案)
路由同步 框架自动处理 手动 bus.$emit + bus.$on
防循环 框架内部处理,无风险 _fromMainApp 标记,存在竞态风险
代码量 零额外代码 setupRouterSync + updateQuery + afterEach
子应用改造 无需任何路由同步代码 每个子应用需实现 setupRouterSync
可维护性 高(依赖框架标准能力) 低(自定义逻辑,排查困难)

3.4 路径规范化

menuClick 中对路径进行规范化,兼容不同来源的路径格式:

// tabs.ts → menuClick
const prefix = `/${tab.appName}`;
let fullPath = tab.path || prefix;
// TO_ROUTE 传来的可能是子应用内部路径(如 /operation/xxx),需要补前缀
if (!fullPath.startsWith(prefix)) {
  fullPath = `${prefix}${fullPath.startsWith("/") ? "" : "/"}${fullPath}`;
}
// wujie sync 模式会自动同步子应用路由,只需 push 即可
router.push(fullPath);

四、通信事件协议

4.1 事件总线

使用 wujie 内置的 bus(基于 EventEmitter),所有子应用共享同一个 bus 实例。

4.2 事件清单

注意:路由同步已由 wujie sync 模式自动处理,bus 事件仅用于业务通信

子应用 → 主应用

事件名 触发时机 数据结构 处理方
TO_ROUTE 子应用请求跨应用跳转 { appName, path, query, name } AuthenticatedLayoutmenuClick
ASSETS_404 子应用静态资源加载失败 { appName } AuthenticatedLayout → 弹窗提示刷新
CLOSE_ALL_TABS 子应用请求关闭所有 Tab { appName } AuthenticatedLayoutremoveTab

主应用 → 子应用

事件名 触发时机 数据结构 处理方
CLOSE_ALL_TAB_TO_CHILD 关闭子应用 Tab { appName } 子应用监听 → 重置状态
REFRESH_CHILD 刷新子应用 { appName } 子应用监听 → 重新加载当前路由

已废弃事件(由 sync 模式替代)

事件名 废弃原因
ROUTE_CHANGE 子→主路由同步已由 sync 模式自动处理
ROUTER_CHANGE_TO_CHILD 主→子路由同步已由 sync 模式自动处理

4.3 事件使用原则

  • 路由同步:完全依赖 wujie sync 模式,禁止通过 bus 手动同步路由
  • 业务通信:跨应用跳转(TO_ROUTE)、资源异常(ASSETS_404)等业务场景仍使用 bus
  • 事件过滤:子应用通过 data.appName 过滤非自身事件

五、子应用管理与保活方案

5.1 wujie alive 模式

所有子应用均使用 alive 保活模式

<!-- App.vue -->
<WujieVue
  v-for="app in loadedApps"
  :key="app.name"
  :alive="true"           ← 保活模式
  v-show="route.path.startsWith(app.activeRule)"
  :name="app.name"
  :url="getAppUrl(app)"
  :props="{ token, userInfo }"
/>

alive 模式特性

  • 子应用首次加载后,实例不销毁,切换时仅做 display: none
  • 子应用的 Vue 实例、Pinia Store、DOM 状态全部保留
  • 切换回来时无需重新初始化,体验接近原生 Tab 切换
  • 子应用内部的表单填写、滚动位置、弹窗状态等全部保留

5.2 按需渲染策略

为避免未启动的子应用触发加载错误,采用按需渲染策略:

// App.vue
const visitedApps = reactive(new Set<string>())

// 仅渲染用户已访问过的子应用
const loadedApps = computed(() =>
  microAppRegistry.filter((app) => visitedApps.has(app.name))
)

// 监听路由变化,标记已访问
watch(() => route.path, (path) => {
  const matched = findAppByRoute(path)
  if (matched) {
    visitedApps.add(matched.name)
  }
}, { immediate: true })

生命周期

用户首次访问 /ibs-manage/xxx
  → visitedApps.add('ibs-manage')
  → loadedApps 包含 ibs-manage
  → WujieVue 组件渲染 → 加载子应用 → 挂载
  → v-show=true(当前激活)

用户切换到 /doc/xxx
  → visitedApps.add('doc')
  → ibs-manage: v-show=false(隐藏但保活)
  → doc: WujieVue 渲染 → 加载 → v-show=true

用户切回 /ibs-manage/yyy
  → ibs-manage: v-show=true(瞬间恢复,无需重新加载)
  → doc: v-show=false(隐藏但保活)

5.3 预加载策略

AuthenticatedLayoutonMounted 中触发预加载,分优先级:

// plugins/wujie.ts
export function preloadChildApps() {
  const highPriority = ['doc', 'mkt']  // 高频子应用

  // 高优先级:立即预加载
  highPriorityApps.forEach(app => {
    preloadApp({ name: app.name, url: getAppUrl(app) })
  })

  // 低优先级:延迟 3 秒后预加载
  setTimeout(() => {
    lowPriorityApps.forEach(app => {
      preloadApp({ name: app.name, url: getAppUrl(app) })
    })
  }, 3000)
}

预加载 vs 按需渲染的区别

维度 预加载 (preloadApp) 按需渲染 (WujieVue)
时机 登录成功后立即 用户首次访问时
作用 提前下载子应用静态资源 创建子应用实例并挂载
资源 仅网络请求 网络 + DOM + JS 执行
目的 减少首次打开延迟 实际渲染子应用

5.4 子应用容器布局

// App.vue
.child-app-container {
  width: 100%;
  height: calc(100vh - 66px);  // 减去 Header 高度
  overflow: hidden;
}

// AuthenticatedLayout.vue
.authenticated-layout {
  display: flex;
  flex-direction: column;
  height: 100vh;
}
.custom-tabs-content {
  flex: 1;
  height: calc(100vh - 66px);
  overflow: hidden;
  position: relative;
}

5.5 子应用 Props 传递

主应用通过 WujieVue 的 :props 向子应用传递共享数据:

<WujieVue
  :props="{ token: userStore.token, userInfo: userStore.userInfo }"
/>

子应用通过 window.__WUJIE.props 读取:

// 子应用 wujie-bridge.ts
export function getWujieProps(): Record<string, any> {
  return (window as any).__WUJIE?.props || {}
}

六、子应用接入规范

6.1 子应用改造清单

每个子应用需要完成以下改造才能接入微前端:

步骤 文件 改动内容
1 vite.config.ts 配置 base: VITE_BASE_PATHserver.headers 添加 CORS
2 .env.dev 设置 VITE_DEV_PORTVITE_BASE_PATHVITE_APP_NAME
3 src/utils/wujie-bridge.ts 新建通信桥接器(环境检测、bus 通信、资源 404 检测)
4 src/main.ts 调用 errorCheck()
5 src/App.vue 移除旧的 iframe postMessage 监听

注意:路由同步由 wujie sync 模式自动处理,子应用无需编写任何路由同步代码。

6.2 子应用 wujie-bridge.ts 标准模板

// 核心导出
export { isInWujie, isInIframe }       // 环境检测
export { notifyMainApp }               // 向主应用发事件
export { onMainAppEvent, offMainAppEvent } // 监听主应用事件
export { errorCheck }                  // 资源 404 检测
export { MESSAGE_TYPE }                // 事件类型常量(TO_ROUTE, ASSETS_404, CLOSE_ALL_TABS)

6.3 主应用注册新子应用

  1. registry.ts 添加子应用配置(childPathList 自动从 registry 派生,无需手动维护)
  2. router/index.ts 添加占位路由 /{appName}/:pathMatch(.*)*
  3. .env.base 添加 VITE_{APP_NAME}_APP_URL
  4. tabs.ts appList 添加 Tab 配置

七、生产环境部署

7.1 Nginx 配置要点

# 主应用
location /micro-main/ {
  try_files $uri $uri/ /micro-main/index.html;
}

# 子应用(以 ibs-manage 为例)
location /ibs-manage/ {
  proxy_pass http://ibs-manage-server/;
  # 或静态文件
  # alias /path/to/ibs-manage/dist/;
  # try_files $uri $uri/ /ibs-manage/index.html;
}

7.2 URL 生成策略

开发环境:
  主应用: http://localhost:3000/micro-main/
  子应用: http://localhost:3007/ibs-manage/  (由环境变量 VITE_IBS_MANAGE_APP_URL 提供)
  WujieVue url = http://localhost:3007/ibs-manage/

生产环境:
  主应用: https://domain.com/micro-main/
  子应用: https://domain.com/ibs-manage/  (由 nginx 反向代理)
  WujieVue url = /ibs-manage/  (相对路径)

八、已知限制与后续规划

8.1 当前限制

限制 说明 影响
子应用使用 WebHistory 子应用 router 使用 createWebHistory(BASE_URL),在 wujie 沙箱中 location 被代理 子应用独立运行和微前端运行行为一致
菜单路径依赖后端 module 字段 buildFullPath 根据 menu.module 拼接 /${appName} 前缀 后端菜单配置需正确设置 module
预加载依赖子应用 dev server 开发环境下子应用未启动时预加载会静默失败 不影响功能,仅影响首次加载速度

8.2 后续规划

阶段 内容 优先级
Phase 3.1 剩余 5 个子应用迁移到 monorepo
Phase 3.2 子应用间直接通信(不经过主应用中转)
Phase 3.3 子应用独立部署 + 版本管理

附录 A:完整数据流时序图

A.1 菜单点击 → 子应用渲染(sync 模式)

用户          SiderMenu      tabs.ts       Vue Router     App.vue        wujie(sync)    子应用
 │               │              │              │             │              │             │
 │──点击菜单──→  │              │              │             │              │             │
 │               │──menuClick──→│              │             │              │             │
 │               │              │──push────────→│             │              │             │
 │               │              │              │──路由变化───→│              │             │
 │               │              │              │             │──标记已访问   │             │
 │               │              │              │             │  (visitedApps)│             │
 │               │              │              │             │──v-show=true  │             │
 │               │              │              │             │              │             │
 │               │              │              │  URL 变化 → wujie sync 自动同步          │
 │               │              │              │             │──────────────→│──replace───→│
 │               │              │              │             │              │             │──渲染页面
 │               │              │              │             │              │             │
 ✅ 无需 bus 通信,无防循环标记,wujie 框架自动处理

A.2 子应用内部导航 → 主应用 URL 同步(sync 模式)

用户          子应用         wujie(sync)    Vue Router     App.vue
 │              │              │              │             │
 │──点击链接──→ │              │              │             │
 │              │──push────────│              │             │
 │              │              │              │             │
 │              │  路由变化 → wujie sync 自动同步到主应用 URL │
 │              │──────────────→│──replace────→│             │
 │              │              │              │──路由变化───→│
 │              │              │              │             │──仅标记已访问
 │              │              │              │             │
 ✅ 无需 bus 通信,地址栏自动更新为 /ibs-manage/operation/yyy

A.3 跨应用跳转(TO_ROUTE 事件)

用户          子应用A        wujie bus     AuthLayout     tabs.ts       Vue Router    wujie(sync)   子应用B
 │              │              │             │              │             │              │             │
 │──操作────→   │              │             │              │             │              │             │
 │              │──emit────────→│             │              │             │              │             │
 │              │  TO_ROUTE     │──收到───────→│              │             │              │             │
 │              │              │             │──menuClick──→│             │              │             │
 │              │              │             │              │──push───────→│              │             │
 │              │              │             │              │             │  URL 变化 → sync 自动同步   │
 │              │              │             │              │             │──────────────→│──replace───→│
 │              │              │             │              │             │              │             │──渲染

浅浅看一下设计模式

作者 ccnocare
2026年2月9日 16:45

前端开发中常用的设计模式主要分为创建型、结构型和行为型三类,下面是针对前端高频使用的模式,逐一讲解其概念、使用场景并提供可直接运行的 Demo。

一、创建型模式

这类模式主要解决「对象创建」相关的问题,让对象创建更灵活、更可控。

1. 单例模式 (Singleton)

核心概念:保证一个类仅有一个实例,并提供一个全局访问点。 使用场景

  • 全局缓存对象 - 弹窗/模态框组件(确保页面中只有一个实例)
  • Vuex/Pinia 的 store 实例 - 全局事件总线 Demo 代码
// 单例模式实现(通用版) 
class SingletonModal { 
    constructor(content) { 
        // 如果已有实例,直接返回 
        if (SingletonModal.instance) { 
            return SingletonModal.instance; 
        } 
        this.content = content; 
        this.element = null; 
        // 缓存实例 
        SingletonModal.instance = this; 
    } 
    // 创建弹窗 
    DOM render() { 
        if (this.element) 
        return this.element; 

        const modal = document.createElement('div'); 
        modal.style.cssText = `
            position: fixed; 
            top: 50%; 
            left: 50%; 
            transform: translate(-50%, -50%); 
            padding: 20px; 
            background: white; 
            border: 1px solid #ccc; 
            z-index: 9999; 
        `; 
        modal.textContent = this.content; 
        this.element = modal; 
        document.body.appendChild(modal); 
        return modal; 
    } 
    // 关闭弹窗 
    close() { 
        if (this.element) { 
            document.body.removeChild(this.element); 
            this.element = null; 
        } 
    } 
} 
// 测试:多次创建只会得到同一个实例 
const modal1 = new SingletonModal('这是第一个弹窗'); 
const modal2 = new SingletonModal('这是第二个弹窗'); 
console.log(modal1 === modal2); // true(验证单例) 
modal1.render(); // 渲染弹窗(内容是"这是第一个弹窗",因为实例复用) 
// modal1.close(); // 关闭弹窗 

2. 工厂模式 (Factory)

核心概念:定义一个创建对象的接口,让子类决定实例化哪一个类,将对象创建的逻辑封装起来。 使用场景

  • 根据不同参数创建不同类型的组件(如按钮、输入框)
  • 数据格式化工具(根据数据类型返回不同格式)
  • 网络请求适配器(根据环境选择 Axios/fetch)

Demo 代码

// 定义不同类型的组件类 
class Button { 
    constructor(text) { 
        this.text = text; 
        this.type = 'button'; 
    } 
    render() { 
        return `<button>${this.text}</button>`; 
    } 
} 
class Input { 
    constructor(placeholder) { 
        this.placeholder = placeholder; 
        this.type = 'input'; 
    } 
    render() { 
        return `<input type="text" placeholder="${this.placeholder}">`; 
    } 
} 
class Select { 
    constructor(options) { 
        this.options = options; 
        this.type = 'select'; 
    } 
    render() { 
        const optionsHtml = this.options.map(opt => `<option value="${opt.value}">${opt.label}</option>`).join(''); 
        return `<select>${optionsHtml}</select>`; 
    } 
} 
// 组件工厂类 
class ComponentFactory { 
    static createComponent(type, config) { 
        switch (type) { 
            case 'button': 
                return new Button(config.text); 
            case 'input': 
                return new Input(config.placeholder); 
            case 'select': 
                return new Select(config.options); 
            default: 
                throw new Error(`不支持的组件类型:${type}`); 
        } 
    } 
} 
// 测试:通过工厂创建不同组件 
const button = ComponentFactory.createComponent('button', { text: '提交' }); 
const input = ComponentFactory.createComponent('input', { placeholder: '请输入姓名' }); 
const select = ComponentFactory.createComponent('select', { options: [{ label: '男', value: 'male' }, { label: '女', value: 'female' }] }); 
console.log(button.render()); // <button>提交</button> 
console.log(input.render()); // <input type="text" placeholder="请输入姓名">
console.log(select.render()); // <select><option value="male">男</option><option value="female">女</option></select> 

二、结构型模式

这类模式关注对象的组合,优化类或对象的结构,提高代码的复用性和灵活性。

1. 代理模式 (Proxy)

核心概念:为另一个对象提供一个替身或占位符,以控制对这个对象的访问。 使用场景

  • 图片懒加载(代理控制图片加载时机)
  • 权限控制(代理拦截无权限的操作)
  • 数据缓存(代理缓存重复请求的结果)
  • Vue3 的响应式原理(基于 ES6 Proxy 实现) Demo 代码(图片懒加载)
// 真实的图片加载类 
class RealImage { 
    constructor(url) { 
        this.url = url; 
        this.loadImage(); 
    } 
    // 实际加载图片 
    loadImage() { 
        console.log(`开始加载图片:${this.url}`); 
        // 模拟图片加载耗时 
        setTimeout(() => { 
            console.log(`图片 ${this.url} 加载完成`); 
        }, 1000); 
    } 
    // 渲染图片 
    render(container) { 
        const img = document.createElement('img'); 
        img.src = this.url; 
        img.alt = '示例图片'; 
        container.appendChild(img); 
    } 
}
// 图片代理类(懒加载) 
class ImageProxy { 
    constructor(url) { 
        this.url = url; 
        this.realImage = null; 
        // 延迟创建真实图片实例 
        this.placeholder = 'https://via.placeholder.com/100x100?text=Loading'; // 占位图 
    } 
    // 代理渲染逻辑 
    render(container) { 
        // 先渲染占位图 
        const placeholderImg = document.createElement('img');
        placeholderImg.src = this.placeholder; 
        placeholderImg.alt = '加载中'; 
        container.appendChild(placeholderImg); 
        // 模拟滚动到可视区域后加载真实图片 
        setTimeout(() => { 
            this.realImage = new RealImage(this.url); 
            // 替换占位图
            container.removeChild(placeholderImg); 
            this.realImage.render(container); 
        }, 2000); 
    } 
} 
// 测试:使用代理加载图片 
const container = document.getElementById('image-container') || document.body; 
const imageProxy = new ImageProxy('https://picsum.photos/400/300');
imageProxy.render(container); 

2. 装饰器模式 (Decorator)

核心概念:动态地给一个对象添加一些额外的职责,而不改变其原有结构。 使用场景

  • 给组件添加额外功能(如按钮添加防抖、输入框添加校验)
  • 权限装饰(给不同角色的按钮添加不同操作权限)
  • 日志装饰(给函数添加日志记录功能)

Demo 代码(函数装饰器)

// 基础函数:提交表单 
function submitForm(data) { 
    console.log('提交表单数据:', data); 
    return { code: 200, msg: '提交成功' }; 
} 
// 装饰器1:防抖装饰 
function debounceDecorator(fn, delay = 500) { 
    let timer = null; 
    return function(...args) { 
        clearTimeout(timer); 
        timer = setTimeout(() => { fn.apply(this, args); }, delay); 
    }; 
} 
// 装饰器2:日志装饰 
function logDecorator(fn) { 
    return function(...args) { 
        console.log(`[${new Date().toLocaleString()}] 执行函数:${fn.name}`); 
        const result = fn.apply(this, args); 
        console.log(`[${new Date().toLocaleString()}] 函数执行结果:`, result); 
        return result; 
    }; 
} 
// 装饰器3:校验装饰 
function validateDecorator(fn) { 
    return function(...args) { 
        const data = args[0];
        if (!data || !data.username) { 
            console.error('校验失败:用户名不能为空'); 
            return { code: 400, msg: '用户名不能为空' }; 
        } 
        return fn.apply(this, args); 
    }; 
} 
// 组合装饰器:给提交函数添加防抖+日志+校验 
const decoratedSubmit = debounceDecorator(logDecorator(validateDecorator(submitForm)));
// 测试
decoratedSubmit({ username: '' }); // 校验失败
decoratedSubmit({ username: 'zhangsan', age: 20 }); // 防抖后执行,带日志 
decoratedSubmit({ username: 'zhangsan', age: 20 }); // 重复点击被防抖拦截 

三、行为型模式

这类模式关注对象之间的通信和交互,优化对象的行为逻辑。

1. 观察者模式 (Observer)

核心概念:定义对象间的一对多依赖关系,当一个对象状态改变时,所有依赖它的对象都会收到通知并自动更新。 使用场景

  • 事件监听(如 DOM 事件、自定义事件)
  • 发布订阅系统(如 Vue 的 EventBus)
  • 状态管理(如 React 状态更新、Vue 的响应式)
  • 消息通知系统

Demo 代码(自定义事件总线)

// 观察者模式实现(事件总线) 
class EventBus { 
    constructor() { 
        this.events = {}; // 存储事件和对应的回调函数 
    } 
    // 订阅事件 
    on(eventName, callback) { 
        if (!this.events[eventName]) { 
            this.events[eventName] = []; 
        } 
        this.events[eventName].push(callback); 
    } 
    // 取消订阅 
    off(eventName, callback) { 
        if (!this.events[eventName])
            return; 
        if (callback) { 
            // 移除指定回调 
            this.events[eventName] = this.events[eventName].filter(fn => fn !== callback); 
        } else { 
            // 清空该事件所有回调 
            this.events[eventName] = []; 
        }
    } 
    // 发布事件(触发) 
    emit(eventName, ...args) { 
        if (!this.events[eventName]) 
        return; 
        // 执行所有订阅的回调 
        this.events[eventName].forEach(callback => { callback.apply(this, args); });
    } 
    // 一次性订阅 
    once(eventName, callback) { 
        const wrapCallback = (...args) => { 
            callback.apply(this, args); 
            this.off(eventName, wrapCallback); // 执行后取消订阅 
        }; 
        this.on(eventName, wrapCallback); 
    } 
}
// 测试:使用事件总线 
const bus = new EventBus(); 

// 订阅事件 
const handleMsg = (msg) => { 
    console.log('收到消息:', msg); 
};
bus.on('message', handleMsg); 

// 一次性订阅 
bus.once('onceMsg', (msg) => { 
    console.log('一次性消息:', msg); 
});

// 发布事件 
bus.emit('message', 'Hello Observer Pattern'); // 收到消息:Hello Observer Pattern 
bus.emit('onceMsg', 'This is once'); // 一次性消息:This is once 
bus.emit('onceMsg', 'This will not be received'); // 无输出 

// 取消订阅 
bus.off('message', handleMsg); bus.emit('message', 'This will not be received'); // 无输出 

2. 策略模式 (Strategy)

核心概念:定义一系列算法,将每个算法封装起来,并使它们可以互相替换,让算法的变化独立于使用算法的客户端。 使用场景

  • 表单校验规则(不同字段用不同校验策略)
  • 支付方式选择(支付宝/微信/银行卡)
  • 排序算法切换(快速排序/冒泡排序)
  • 价格计算策略(普通用户/VIP/超级VIP)

Demo 代码(表单校验)

// 定义校验策略 
const validateStrategies = { 
    // 非空校验 
    required: (value, errorMsg) => { 
        if (value === '' || value === undefined || value === null) { 
            return errorMsg; 
        } 
    }, 
    // 最小长度校验 
    minLength: (value, length, errorMsg) => { 
        if (value && value.length < length) { 
            return errorMsg; 
        } 
    }, 
    // 手机号校验 
    mobile: (value, errorMsg) => { 
        const reg = /^1[3-9]\d{9}$/; 
        if (value && !reg.test(value)) { 
            return errorMsg; 
        } 
    }, 
    // 邮箱校验 
    email: (value, errorMsg) => { 
        const reg = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; 
        if (value && !reg.test(value)) { 
            return errorMsg; 
        } 
    } 
}; 
// 校验器类 
class Validator { 
    constructor() { 
        this.rules = []; // 存储校验规则 
    } 
    // 添加校验规则 
    add(value, rules) { 
        rules.forEach(rule => { 
            const { strategy, errorMsg, param } = rule; 
            this.rules.push(() => { 
                // 拆分策略(如 minLength:6 拆分为 minLength 和 6) 
                const [strategyName, strategyParam] = strategy.split(':'); 
                return validateStrategies[strategyName](value, strategyParam || param, errorMsg); 
            }); 
        });
    } 
    // 执行校验 
    validate() { 
        for (const rule of this.rules) { 
            const errorMsg = rule(); 
            if (errorMsg) { 
                return errorMsg; // 有错误立即返回 
            } 
        } 
        return ''; // 校验通过 
    } 
} 
// 测试:表单校验 
const formData = { 
    username: '', 
    password: '123', 
    mobile: '1234567890', 
    email: 'test@example' 
}; 
// 创建校验器并添加规则 
const validator = new Validator(); 
validator.add(formData.username, [{ strategy: 'required', errorMsg: '用户名不能为空' }]);
validator.add(formData.password, [ { strategy: 'required', errorMsg: '密码不能为空' }, { strategy: 'minLength:6', errorMsg: '密码长度不能少于6位' } ]); 
validator.add(formData.mobile, [{ strategy: 'mobile', errorMsg: '手机号格式错误' }]);
validator.add(formData.email, [{ strategy: 'email', errorMsg: '邮箱格式错误' }]); 
// 执行校验 
const errorMsg = validator.validate(); 
console.log(errorMsg); // 用户名不能为空(第一个错误) 

总结

前端开发中高频使用的设计模式及核心要点:

  1. 单例模式:保证唯一实例,适用于全局组件/缓存/事件总线,核心是缓存实例并复用。
  2. 工厂模式:封装对象创建逻辑,适用于多类型组件/工具创建,核心是根据参数返回不同实例。
  3. 代理模式:控制对象访问,适用于懒加载/权限控制/缓存,核心是「替身」拦截并处理逻辑。
  4. 装饰器模式:动态扩展功能,适用于函数增强/组件扩展,核心是不修改原对象仅添加职责。
  5. 观察者模式:一对多通知,适用于事件系统/状态管理,核心是发布-订阅的解耦通信。
  6. 策略模式:算法封装与替换,适用于校验/支付/排序,核心是将算法与使用逻辑分离。 这些模式的核心价值是解耦、复用、可扩展,实际开发中不必生搬硬套,而是根据场景灵活运用,比如 Vue/React 框架内部就大量使用了这些模式(如 React 的合成事件用了观察者模式,Vue3 的响应式用了代理模式)。

Next.js 请求最佳实践 - vercel 2026一月发布指南

作者 却尘
2026年2月9日 16:41

你打开商品详情页,转了 3 秒菊花才看到内容——慢在哪?

你点个"加购物车",页面卡了一下才反应——又慢在哪?

你加了几个功能,整个站越来越肥,首屏白得让人以为断网了——还是慢在哪?

大部分人的第一反应是:肯定是代码写得不够优雅,赶紧 useMemouseCallback 一把梭。

错了。

真正把你拖垮的,往往不是"组件重渲染了 2 次还是 3 次",而是两座更致命的大山:

  • 请求瀑布:本来能并行的请求,被你写成了串行,用户白等 600ms 起步
  • 客户端屎山:本来能在服务端干的活,全塞客户端,每个页面多背 300KB JS

Vercel 把这事儿说得很直白:你优化的顺序错了,再怎么抠细节都是白干。

先看两个要命的数字

数字 1:一个瀑布 = 白等 600ms

什么叫"请求瀑布"?看这段代码:

async function loadPage() {
  const user = await fetchUser()      // 等 200ms
  const product = await fetchProduct() // 又等 200ms  
  const inventory = await fetchInventory() // 再等 200ms
}

这三个请求明明互不依赖,结果你愣是让用户等了 600ms。改成并行呢?

const [user, product, inventory] = await Promise.all([
  fetchUser(),
  fetchProduct(), 
  fetchInventory()
])
// 总共只等 200ms

省了 400ms,比你优化一百个 useMemo 都管用。

还有更骚的操作:你的代码先 await fetchUserData(),但后面某个分支根本用不到这数据——结果不管走不走那个分支,都得先等完这个请求。白等的典范。

数字 2:每页多背 300KB = 长期税

你今天为了"方便",把数据请求、状态管理、第三方库全塞客户端。爽是爽了,代价是什么?

每个用户每次访问,都要下载这 300KB 的 JS,解析它,执行它。

这不是一次性成本,这是"长期税"——你写一次,所有用户永远买单,直到有人受不了来重构。

明确一点:Vercel 把这两件事排在所有性能优化的最前面,标注为 CRITICAL。

什么 useMemo、组件拆分、虚拟滚动——都得往后稍稍。因为你瀑布多 600ms,用户根本活不到看你"优雅的重渲染控制"。

请求选型

别管什么 RSC、Server Component、Server Action 这些名词吓人。你只需要记住:遇到场景,先问两句话。

第一问:这么干会不会让用户白等(制造瀑布)?
第二问:这么干会不会让包体越滚越大(养长期税)?

两个都不会?那才轮到你聊别的细节。

场景 1:打开页面就要数据 → 用 RSC

典型需求: 商品详情页、列表页、仪表盘

用来预防数据在客户端一层层触发,形成等待链。

// ❌ 错误示范(客户端瀑布)
function ProductPage() {
  const [user, setUser] = useState(null)
  const [product, setProduct] = useState(null)
  
  useEffect(() => {
    fetchUser().then(setUser)  // 先等这个
  }, [])
  
  useEffect(() => {
    if (user) {
      fetchProduct(user.id).then(setProduct) // 再等这个
    }
  }, [user])
  
  return <div>...</div>
}

这代码每一行都在喊"我在制造瀑布"。

正确姿势:RSC 在服务端并行拿数据

// ✅ app/products/[id]/page.tsx
export default async function ProductPage({ params }) {
  // 并行发起,一起等
  const [user, product, inventory] = await Promise.all([
    getUser(),
    getProduct(params.id),
    getInventory(params.id)
  ])
  
  return <ProductDetail user={user} product={product} inventory={inventory} />
}

核心思路: 能并行就并行,别让服务端也写出瀑布。数据拿齐了再渲染,客户端收到的就是带数据的 HTML。

场景 2:用户点按钮要写数据 → 用 Server Action

典型需求: 加购物车、提交表单、点赞、删除

杜绝把一次写操作拆成多段:写完拉数据、拉完算状态、算完再渲染……

// ❌ 错误示范(客户端拉长等待链)
async function handleAddCart() {
  await fetch('/api/cart', { method: 'POST', ... }) // 等
  const newCart = await fetch('/api/cart').then(r => r.json()) // 又等
  setCart(newCart) // 状态更新
  toast.success('已加入购物车') // 最后才提示
}

正确姿势:Server Action 一把梭

// ✅ app/products/actions.ts
'use server'

export async function addToCart(productId: string) {
  const user = await getCurrentUser()
  
  // 并行:写库 + 查库存
  const [cart, stock] = await Promise.all([
    db.cart.create({ userId: user.id, productId }),
    db.inventory.findUnique({ where: { productId } })
  ])
  
  revalidatePath('/cart')  // 刷新相关页面
  return { success: true, stock: stock.quantity }
}
// ✅ Client Component 里直接调
'use client'
import { addToCart } from './actions'

function AddToCartButton({ productId }) {
  return (
    <button onClick={async () => {
      const result = await addToCart(productId)
      toast.success(`已加入!剩余 ${result.stock} 件`)
    }}>
      加入购物车
    </button>
  )
}

核心思路: Server Action 就是"组件内的服务端入口"。该并行并行,写完直接 revalidatePath 刷新,别让客户端再发一圈请求。

场景 3:外部系统要打你 → 用 Route Handler (app/api)

典型需求: Stripe webhook、GitHub 回调、给 App 提供 API

为什么不能用 Action? 因为外部系统不认你的组件树,它只认一个 HTTP URL。

// ✅ app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
  const signature = req.headers.get('stripe-signature')
  
  // 验签
  const event = stripe.webhooks.constructEvent(
    await req.text(),
    signature,
    process.env.STRIPE_WEBHOOK_SECRET
  )
  
  // 并行:写库 + 触发发货
  await Promise.all([
    db.order.update({ where: { id: event.data.object.metadata.orderId }, data: { status: 'paid' } }),
    triggerShipment(event.data.object.metadata.orderId)
  ])
  
  return Response.json({ received: true })
}

核心思路: Route Handler = 对外的 HTTP 边界。但内部逻辑照样要砍瀑布、控包体。

最容易踩的坑:Server Action 里绕圈打自己的 API

很多人会写成这样:

// ❌ 多此一举
'use server'
export async function createOrder(data) {
  const result = await fetch('http://localhost:3000/api/orders', {
    method: 'POST',
    body: JSON.stringify(data)
  })
  return result.json()
}

你人已经在服务端了,为什么还要绕 HTTP 一圈?

这么干的后果:

  • 多一跳网络请求(更容易瀑布)
  • 多一次序列化/反序列化
  • 多一层错误处理

正确姿势:直接调业务逻辑

// ✅ server/order.service.ts
export async function createOrder(data, userId) {
  // 校验、写库、触发通知...
  return db.order.create({ data: { ...data, userId } })
}

// ✅ app/orders/actions.ts
'use server'
import { createOrder } from '@/server/order.service'

export async function createOrderAction(formData) {
  const user = await getCurrentUser()
  return createOrder(parseFormData(formData), user.id)
}

什么时候才该用 /api?

  • 外部系统要调(webhook、移动端)
  • 需要自定义 Response(下载文件、streaming)
  • 需要标准 HTTP 语义(状态码、特殊 header)

内部写操作?直接 Action 调 Service,别绕。

速查表:一张图看完怎么选

场景 用什么 核心原则
打开页面要数据 RSC (Server Component) 并行拿数据,别串行等
页面内写数据 Server Action 别拉长等待链,别推客户端
外部系统回调 Route Handler (/api) 对外边界,内部逻辑照样砍瀑布
交互需要状态 Client Component 只放必须的,别啥都塞客户端

拿不准?回到两句话:

  1. 会不会多等 600ms?(瀑布)
  2. 会不会多背 300KB?(包体税)

实战目录长这样

app/
├── products/
│   ├── page.tsx           # RSC:并行拿数据渲染
│   ├── [id]/page.tsx      # RSC:详情页
│   └── actions.ts         # Server Actions:写操作
│
├── api/
│   └── webhooks/
│       └── stripe/
│           └── route.ts   # Route Handler:外部回调
│
server/                    # 业务逻辑层(Action 和 API 都调这里)
├── product.service.ts     # 组合逻辑、权限、事务
└── product.repo.ts        # 纯数据访问(DB/外部 API)

components/                # UI 组件
├── ProductCard.tsx        # Server Component(默认)
└── AddToCartButton.tsx    # Client Component("use client")

核心思路:

  • app/ 负责路由和 UI 组合
  • server/ 负责业务逻辑(可复用)
  • components/ 负责展示和交互

Action 和 Route Handler 都不写业务细节,都调 server/ 里的函数。这样:

  • 逻辑不重复
  • 测试更好写
  • 重构不伤筋动骨

最后

架构不是"我用了多少高级名词",是"我让用户少等了多少时间"。

先砍 600ms 的瀑布,再砍 300KB 的包体税。剩下的 useMemo、memo、虚拟滚动——等你把这两座大山移平了再说。

记住:能并行就并行,能服务端就服务端。

别等页面慢得用户骂娘了,才想起来"哦对,我好像写了个瀑布"。

参考:

  1. raw.githubusercontent.com/vercel-labs…
  2. github.com/vercel-labs…

🎬 从标签到屏幕:揭秘现代网页构建与适配之道

作者 Lee川
2026年2月9日 16:29

🎬 从标签到屏幕:揭秘现代网页构建与适配之道

当您在浏览器中流畅地滑动页面时,一场由代码驱动的精密“表演”正在幕后上演。下面,让我们通过您提供的学习材料,一步步拆解这场表演,看看HTML、CSS和JavaScript如何从枯燥的文本,蜕变为您眼前所见、手中所用的交互界面。

1️⃣ 幕后第一步:搭建骨架与“上妆”(DOM + CSSOM)

浏览器首先面对的是一堆文本,它需要先理解结构,再赋予样式。

阶段 做什么 核心比喻 示例代码(简化)
构建DOM树 解析HTML标签,构建树形结构。 搭建房屋的“钢筋骨架”。 1.html中的嵌套结构: <p><span>介绍<span>渲染流程</span></span></p>
构建CSSOM树 解析CSS规则,形成样式规则树。 设计房屋的“装修图纸”。 3.html中的规则: #p7 { color: aqua; } .highlight { color: green; }

当骨架与图纸结合,浏览器就生成了一棵“渲染树”,它清晰地知道每个“房间”(DOM节点)该刷成什么颜色(CSS样式)。

💡 重点知识:CSS选择器优先级

当多个规则冲突时,谁说了算?有一个明确的“权力等级”:

内联样式> ID选择器> 类选择器> 标签选择器

3.html中,<p style="color: red;" id="p7" class="highlight">的颜色最终会是红色,因为内联样式权力最大。

2️⃣ 语义与结构:不只是“能看”,更要“好懂”(语义化 + SEO)

好的代码,不仅是给浏览器看的,也是给搜索引擎和辅助设备“读”的。

🏆 语义化标签是SEO的利器

2.html中,我们看到了清晰的现代网页结构:

<header>网页头部(标题/导航)</header>
<div class="container">
  <main>网页核心内容区</main>
  <aside>侧边栏(导航/广告)</aside>
</div>
<footer>网页底部(版权信息)</footer>

使用 <header>, <main>, <aside>, <footer>, <section>等标签,能明确告诉搜索引擎每个区块的用途。搜索引擎的“蜘蛛”在爬取网页时,能更准确地理解内容,从而提升页面在搜索结果中的排名。

💡 一个关键的SEO与性能实践:源码顺序的重要性

在上面的代码示例中,您会注意到<main>主内容在源码中位于<aside>侧边栏之前。这样做的原因主要有两个:

  1. SEO优化:搜索引擎爬虫抓取网页时,会优先读取和评估代码靠前的内容。将重要的<main>内容放在前面,有助于搜索引擎更快地理解页面的核心主题,对排名有积极影响。
  2. 加载与渲染性能:浏览器是自上而下解析和渲染HTML的。重要的核心内容先行加载和渲染,可以让用户更早地看到页面主体,即使在网络较慢或侧边栏内容(如广告、第三方脚本)加载受阻时,也能保证基本内容的可访问性,提升用户的“首屏加载”体验。

🔄 一个精妙的布局技巧:order属性

虽然源码顺序是<main>在前,<aside>在后,但在大屏幕的视觉呈现上,我们通常希望侧边栏显示在左侧,而主内容在右侧。这看起来是个矛盾,但Flexbox的order属性可以轻松解决:

.container { display: flex; } /* 创建弹性容器 */
.aside-left { order: -1; } /* 将左侧边栏的视觉顺序设为-1 */

实现原理

  • display: flex;将容器设为弹性布局。
  • 默认情况下,弹性项目的order值为0
  • 将左侧边栏的order设为-1,使其在视觉上排到所有order为0的项目(即<main>和右侧边栏)之前,从而实现了“视觉左,代码后”的布局。

3️⃣ 响应式设计:从桌面到掌心的无缝体验

🌊 弹性布局

2.html通过 display: flex;创建了弹性容器。flex: 1;<main>占据所有剩余空间,而 <aside>则保持固定宽度。这是一种非常灵活的自适应布局基础。

📏 全屏空间管理:min-height: calc(100vh - 136px);

这条规则确保了主内容区在任何情况下都有足够的高度:

  • 100vh:代表整个浏览器可视区域的高度。
  • - 136px:减去固定的头部和底部高度(假设分别是80px和56px)。
  • calc() :CSS的计算函数。
  • min-height:最小高度。

作用:即使页面内容很少,也能将页脚推到屏幕底部,避免下方出现大片空白,实现“粘性页脚”效果。

📱 移动端适配:媒体查询

当屏幕变窄(如手机),固定宽度的侧边栏就会显得拥挤。此时,CSS媒体查询出场救援:

@media (max-width: 768px) { /* 当屏幕宽度小于768像素时 */
  .container {
    flex-direction: column; /* 改为垂直堆叠 */
  }
  aside {
    width: 100%; /* 侧边栏占满宽度 */
  }
  .aside-left {
    order: 1; /* 重新调整堆叠顺序,在垂直流中按源码顺序显示 */
  }
}

这样,在手机上,页面元素就会自动从上到下整齐排列,提供优秀的移动端浏览体验。

4️⃣ 总结

您提供的文件清晰地勾勒了现代前端开发的核心路径:

  1. 构建:理解浏览器如何将HTML/CSS“翻译”成DOM/CSSOM树,这是页面渲染的基石。
  2. 赋能:使用语义化标签编写HTML,并遵循“主内容优先”的源码顺序,这对SEO和性能至关重要。再利用Flexbox的order属性等CSS技巧,在视觉上实现所需的布局。
  3. 适配:利用弹性布局、calc()函数和媒体查询,构建出既能优雅利用大屏幕空间,又能在小屏幕上完美自适应的响应式界面。

从代码到屏幕,每一步都蕴含着对性能、语义和用户体验的考量。掌握这些原理,您就能创造出既美观又高效,且能适应万“端”的现代化网页。

Git Cheatsheet

Getting Started

Initialize and configure Git.

Command Description
git init Initialize a new repository
git clone url Clone a remote repository
git status Show working tree status
git config --global user.name "Name" Set global name
git config --global user.email "email" Set global email

Staging and Committing

Add changes and create commits.

Command Description
git add file Stage a file
git add . Stage all changes
git commit -m "message" Commit staged changes
git commit --amend Amend last commit
git revert commit Revert a commit

Branches

Create, list, and switch branches.

Command Description
git branch List branches
git branch name Create a branch
git switch name Switch branch
git switch -c name Create and switch
git branch -d name Delete local branch
git branch -D name Force delete branch
git branch -m new-name Rename current branch

Merge and Rebase

Combine branches and rewrite history.

Command Description
git merge branch Merge branch into current
git merge --no-ff branch Merge with merge commit
git merge --abort Abort merge in progress
git rebase branch Rebase onto branch
git rebase -i HEAD~3 Interactive rebase last 3
git rebase --abort Abort rebase in progress
git cherry-pick commit Apply a single commit

Remote Repositories

Work with remotes and synchronization.

Command Description
git remote -v List remotes
git remote add origin url Add remote
git remote set-url origin url Change remote URL
git remote remove origin Remove remote
git fetch Fetch from remote
git pull Fetch and merge
git push Push to remote
git push -u origin branch Push and set upstream
git push origin --delete branch Delete remote branch

Stash

Temporarily save uncommitted changes.

Command Description
git stash Stash changes
git stash push -m "message" Stash with message
git stash list List stashes
git stash pop Apply and remove latest
git stash apply stash@{0} Apply without removing
git stash drop stash@{0} Delete a stash
git stash clear Delete all stashes

History and Diff

Inspect history and changes.

Command Description
git log View commit history
git log --oneline --graph Compact graph view
git log -p file History of a file
git show commit Show a commit
git blame file Show who changed each line
git diff Unstaged diff
git diff --staged Staged diff
git diff branch1..branch2 Compare branches

Undo and Cleanup

Discard or reset changes safely.

Command Description
git restore file Discard local changes
git restore --staged file Unstage a file
git reset --soft HEAD~1 Undo commit, keep changes
git reset --hard HEAD Reset to last commit
git clean -fd Remove untracked files/dirs

Tags

Create and manage tags.

Command Description
git tag List tags
git tag v1.0.0 Create tag
git tag -a v1.0.0 -m "msg" Annotated tag
git push --tags Push all tags
git push origin :refs/tags/v1.0.0 Delete remote tag

.gitignore

Exclude files from version control.

Pattern Description
*.log Ignore all .log files
node_modules/ Ignore directory
!important.log Negate ignore rule
**/build Ignore in any directory
git rm --cached file Untrack a tracked file

Iptables Cheatsheet

View Rules

Inspect current firewall rules.

Command Description
sudo iptables -L List rules
sudo iptables -L -n List without resolving names
sudo iptables -L -v Verbose output
sudo iptables -L -n --line-numbers Show rule numbers
sudo iptables -S Show rules as commands
sudo iptables -t nat -L -n -v View NAT rules

Default Policies

Set default policies for chains.

Command Description
sudo iptables -P INPUT DROP Default drop inbound
sudo iptables -P FORWARD DROP Default drop forwarding
sudo iptables -P OUTPUT ACCEPT Default allow outbound

Allow Traffic

Allow common inbound traffic.

Command Description
sudo iptables -A INPUT -i lo -j ACCEPT Allow loopback
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT Allow established
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT Allow SSH
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT Allow HTTP
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT Allow HTTPS
sudo iptables -A INPUT -p icmp -j ACCEPT Allow ping
sudo iptables -A INPUT -s 192.168.1.0/24 -j ACCEPT Allow subnet

Block Traffic

Drop or reject traffic.

Command Description
sudo iptables -A INPUT -s 203.0.113.10 -j DROP Drop IP address
sudo iptables -A INPUT -s 203.0.113.0/24 -j DROP Drop subnet
sudo iptables -A INPUT -p tcp --dport 23 -j DROP Block Telnet
sudo iptables -A INPUT -p tcp --dport 25 -j REJECT Reject SMTP
sudo iptables -A INPUT -m mac --mac-source XX:XX:XX:XX:XX:XX -j DROP Block MAC address

Port Forwarding (DNAT)

Redirect traffic to a different host or port.

Command Description
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j DNAT --to-destination 192.168.1.10:80 Forward port to host
sudo iptables -t nat -A PREROUTING -p tcp --dport 8080 -j REDIRECT --to-port 80 Redirect local port
sudo iptables -A FORWARD -p tcp -d 192.168.1.10 --dport 80 -j ACCEPT Allow forwarded traffic

NAT (Masquerade)

Enable NAT for outbound traffic.

Command Description
sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE NAT for interface
sudo iptables -t nat -A POSTROUTING -s 192.168.1.0/24 -o eth0 -j SNAT --to-source 203.0.113.1 Static NAT
sudo sysctl -w net.ipv4.ip_forward=1 Enable IP forwarding

Rate Limiting

Limit connection rates to prevent abuse.

Command Description
sudo iptables -A INPUT -p tcp --dport 22 -m limit --limit 3/min --limit-burst 3 -j ACCEPT Limit SSH attempts
sudo iptables -A INPUT -p tcp --dport 80 -m connlimit --connlimit-above 50 -j DROP Limit connections per IP
sudo iptables -A INPUT -p icmp -m limit --limit 1/sec -j ACCEPT Limit ping rate

Logging

Log matched packets for debugging.

Command Description
sudo iptables -A INPUT -j LOG --log-prefix "IPT-DROP: " Log dropped packets
sudo iptables -A INPUT -p tcp --dport 22 -j LOG --log-prefix "SSH: " --log-level 4 Log SSH access
sudo iptables -A INPUT -m limit --limit 5/min -j LOG Log with rate limit

Delete and Insert Rules

Manage rule order and removal.

Command Description
sudo iptables -D INPUT 3 Delete rule number 3
sudo iptables -D INPUT -p tcp --dport 80 -j ACCEPT Delete by specification
sudo iptables -I INPUT 1 -p tcp --dport 22 -j ACCEPT Insert rule at top
sudo iptables -R INPUT 3 -p tcp --dport 443 -j ACCEPT Replace rule number 3
sudo iptables -F Flush all rules
sudo iptables -F INPUT Flush INPUT chain only

Save and Restore

Persist rules between reboots.

Command Description
sudo iptables-save > /etc/iptables/rules.v4 Save rules
sudo iptables-restore < /etc/iptables/rules.v4 Restore rules
sudo apt install iptables-persistent Auto-persist on Debian/Ubuntu
sudo service iptables save Save on RHEL/CentOS

Editorial Policy

Linuxize publishes practical Linux tutorials and guides for system administrators, DevOps engineers, and developers. This policy explains how we research, test, update, and correct our content.

Who Writes Our Content

Linuxize was founded by Dejan Panovski , an RHCSA-certified Linux professional with over 20 years of experience as a system administrator, DevOps engineer, and technical writer. The vast majority of articles on this site are written and maintained by Dejan. Guest contributors follow the same editorial standards outlined below.

Editorial Standards

  • Accuracy first — We verify commands, outputs, and configuration examples before publication.
  • Practical focus — Articles are written for real systems and real use cases, not theoretical examples.
  • Clear steps — We present instructions in a logical order with explanations before code blocks.
  • Security awareness — We highlight risky commands and recommend safe defaults.

Testing and Verification

We test commands and workflows on real systems whenever possible. For distro-specific posts, we verify instructions on the named distribution and version. If a procedure changes across versions, we note the difference or provide separate versions.

Updates and Maintenance

We regularly review and update published articles to keep them accurate and current. Updated articles display a “Last updated” date to indicate when they were last revised. Common reasons for updates include:

  • Software version changes or deprecated commands
  • Improved procedures or better practices
  • Reader feedback and error reports
  • New sections such as FAQ, Quick Reference, or Troubleshooting

Corrections

If you find an error, outdated step, or missing detail, please contact us at hello@linuxize.com . We review corrections promptly and update the article when needed.

Contributor Guidelines

Guest submissions follow the same standards for accuracy, testing, and clarity. All submissions are reviewed for technical correctness and editorial consistency before publication.

Transparency

We do not accept sponsored content that compromises the integrity of our tutorials. Advertising is kept separate from editorial decisions.

❌
❌