普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月28日掘金 前端

Next.js SEO 优化完整方案

作者 七淮
2025年11月28日 17:51

适用于 Next.js 15(App Router) 的 SEO 全流程优化指南,包括页面级 SEO、站点级 SEO、组件优化、性能优化、结构化数据、国际化等内容。


1. 页面级 SEO

1.1 使用 metadata 配置页面 SEO

// app/page.tsx
export const metadata = {
  title: "首页标题 | 品牌词",
  description: "页面描述,建议 50~160 字。",
  keywords: ["关键词1", "关键词2"],
  openGraph: {
    title: "OG 标题",
    description: "OG 描述",
    url: "https://xxx.com",
    images: [{ url: "/og.jpg" }],
  },
  alternates: {
    canonical: "https://xxx.com",
  },
};

1.2 动态页面 SEO(如文章详情)

// app/blog/[id]/page.tsx
export async function generateMetadata({ params }) {
  const data = await getPost(params.id);

  return {
    title: data.title,
    description: data.summary,
    openGraph: {
      images: data.cover,
    },
    alternates: {
      canonical: `https://xxx.com/blog/${params.id}`,
    },
  };
}

2. 渲染模式与 SEO

渲染方式 SEO 效果 适用场景
SSR(默认) ⭐⭐⭐⭐ 动态数据页面
SSG ⭐⭐⭐⭐⭐ 静态内容、博客
ISR ⭐⭐⭐⭐⭐ 内容频繁更新页面

ISR 使用示例

export const revalidate = 60; // 页面缓存 60 秒

3. URL 结构优化

  • 使用语义化目录: /blog/xxx
  • 避免 query 作主要结构: /search?q=xxx
  • URL 小写、简短、语义化

4. 站点级 SEO

4.1 robots.txt

// app/robots.ts
export default function Robots() {
  return {
    rules: [{ userAgent: "*", allow: "/" }],
    sitemap: "https://xxx.com/sitemap.xml",
  };
}

4.2 sitemap.xml 自动生成

// app/sitemap.ts
export default async function sitemap() {
  const posts = await getPosts();

  return [
    { url: "https://xxx.com", lastModified: new Date() },
    ...posts.map(p => ({
      url: `https://xxx.com/blog/${p.id}`,
      lastModified: p.updated_at,
    })),
  ];
}

5. 组件级 SEO

5.1 使用语义标签

<main>
<article>
<header>
<footer>
<section>
<nav>

5.2 使用 next/image 优化图片

<Image src="/hero.png" alt="banner" width={800} height={600} />

5.3 延迟加载非关键组件

const Comments = dynamic(() => import('./Comments'), { ssr: false });

6. 性能优化(SEO 强关联)

  • 仅在必要组件使用 use client
  • 使用 next/image(自动压缩、lazyload、webp)
  • 减少 API 延迟:Edge Runtime、Server Actions
  • 打包体积优化(减少第三方库)

7. 国际化 SEO(可选)

export const metadata = {
  alternates: {
    canonical: "https://xxx.com",
    languages: {
      "en-US": "https://xxx.com/en",
      "zh-CN": "https://xxx.com/zh",
    },
  },
};

8. 结构化数据(Rich Snippets)

<script type="application/ld+json">
{JSON.stringify({
  "@context": "https://schema.org",
  "@type": "Article",
  headline: title,
  datePublished: created,
  dateModified: updated,
  author: { "@type": "Person", name: "作者名" }
})}
</script>

9. 上线前 SEO Checklist

项目 状态
页面 metadata 配置完整
sitemap.xml 正常生成
robots.txt 正常访问
canonical 链接填写
OG 信息正常
渲染方式:SSR/SSG/ISR
URL 语义化
图片全部用 next/image
lighthouse ≥ 90
结构化数据(可选)

10. metadata 字段说明

字段 作用
title 页面标题
description SEO 摘要
keywords 关键词(影响极弱,可选)
openGraph 社交媒体分享卡片信息
alternates.canonical 主 URL,用于防止重复页面降权
alternates.languages 多语言 SEO

11. 推荐实践总结

  1. 优先 SSR 或 SSG 渲染关键内容
  2. metadata + canonical + sitemap + robots.txt 配置完整
  3. URL 简短语义化,避免重复
  4. 使用 next/image、语义化标签和动态加载优化性能
  5. 配置 OpenGraph 和结构化数据提升社交分享与搜索展示效果
  6. 国际化站点务必设置语言 alternates
  7. 定期使用 Lighthouse 或 PageSpeed 检测性能

JavaScript 词法作用域与闭包:从底层原理到实战理解

作者 有意义
2025年11月28日 17:41

JS运行机制

词法作用域

“词法”这个词听起来有点抽象,其实它的意思很简单: “词法” = “和你写代码的位置有关”

换句话说,JavaScript 中很多行为在你写代码的时候就已经确定了,而不是等到程序运行时才决定。这种特性也叫静态作用域(static scoping)。

你可以这样理解:

代码怎么写的,它就怎么执行——这非常符合我们的直觉。

比如,letconst 声明的变量之所以不能在声明前使用(会报“暂时性死区”错误),就是因为它们属于词法环境的一部分。而词法环境正是由你在源代码中的书写位置决定的。你把变量写在哪里,它就在哪里生效,不能“穿越”到还没写到的地方去用——这很合理,也很直观。

所以,“词法”本质上就是:看代码结构,而不是看运行过程

看一段关于词法作用域的代码

function bar() {
    console.log(myName);
}
function foo() {
    var myName = '极客邦'
    bar()// 运行时
}
var myName = '极客时间'
foo();

这里输出的是

极客时间 为什么输出的不是 "极客邦"

因为 bar 函数是在全局作用域中声明的,所以它的词法作用域链在定义时就已经固定为:自身作用域 → 全局作用域

JavaScript 查找变量时,遵循的是词法作用域规则——也就是说,它只关心函数在哪里被定义,而不关心函数在哪里被调用

bar 内部访问变量(比如 testmyName)时,引擎会先在 bar 自己的执行上下文中查找;如果找不到,就沿着词法作用域链向外层查找,也就是直接跳到全局作用域,而不会进入 foo 的作用域——尽管 bar 是在 foo 里面被调用的。

因此,bar 根本“看不见” foo 中的 myName = "极客邦",自然也就无法输出它。

image.png

总结:

JavaScript 使用 词法作用域(Lexical Scoping) ,也就是说,函数在定义时就决定了它能访问哪些变量,而不是在调用时

词法作用域链:变量查找的路径

当 JavaScript 引擎执行代码时,会为每一段可执行代码创建一个 执行上下文(Execution Context)
每个执行上下文都包含一个 词法环境(Lexical Environment) ,它不仅保存了当前作用域中声明的变量,还持有一个指向外层词法环境的引用。这些嵌套的词法环境连接起来,就形成了 作用域链(Scope Chain)

  • 全局执行上下文位于调用栈的底部,是程序启动时创建的。
  • 每当调用一个函数,就会创建一个新的函数执行上下文,并将其压入调用栈。
  • 当需要查找某个变量时,JavaScript 会从当前作用域开始,沿着作用域链由内向外逐层查找,直到找到该变量,或最终到达全局作用域为止。

这种机制确保了变量访问遵循词法作用域规则——即“在哪里定义,就看哪里的变量”,而不是“在哪里调用”。

看看这段关于作用域链和块级作用域的代码:

function bar () {
  var myName = "极客世界";
  let test1 = 100;
  if (1) {
    let myName = "Chrome 浏览器"  // 1.先在词法环境查找一下
    console.log(test)
  }
}
function foo() {
  var myName = "极客邦";
  let test = 2;
  {
    let test = 3;
    bar()
  }
}
var myName = "极客时间";
let myAge = 10;
let test = 1;
foo();

这段代码的执行结果不会报错,而是会正常输出:

1

原因正是基于 JavaScript 的 词法作用域(Lexical Scoping) 机制。

虽然 bar() 是在 foo() 内部被调用的,但它的声明位置在全局作用域。因此,当 bar 内部引用变量 test 时,JavaScript 引擎会从 bar 自身的作用域开始查找;找不到时,就沿着词法作用域链向外层查找——也就是直接跳到全局作用域,而不会进入 foo 的作用域。

由于全局作用域中存在 let test = 1;,所以 console.log(test) 最终输出的是 1

image.png

换句话说:变量查找看的是函数“在哪里定义”,而不是“在哪里调用” 。这条由内向外的查找路径,就是我们所说的 作用域链

在 JavaScript 的设计中,每个函数的执行上下文都包含一个内部指针(通常称为 [[Outer]] 或 “outer 引用”),它指向该函数定义时所在的作用域——也就是它的词法外层环境

当你在代码中嵌套定义多个函数时,每个函数都会通过这个 outer 指针,链接到它上一层的词法环境。这样一层套一层,就形成了一条静态的、由代码结构决定的链式结构,我们称之为 词法作用域链(Lexical Scope Chain)

正是这条链,决定了变量查找的路径:从当前作用域开始,沿着 outer 指针逐级向外搜索,直到全局作用域为止。

image.png

这种机制是 JavaScript 闭包、变量访问和作用域行为的核心基础。理解了 outer 指针如何连接各个词法环境,你就真正掌握了词法作用域链的本质。

闭包 ——前面内容的优雅升华

闭包(Closure)是 JavaScript 中一个基于词法作用域的核心机制。掌握它,不仅能写出更灵活、模块化的代码,还能轻松应对面试中的高频问题。下面用通俗易懂的方式,带你彻底搞懂闭包。


一、什么是闭包?

闭包 = 一个函数 + 它定义时所处的词法环境。

换句话说:
当一个函数即使在自己原始作用域之外被调用,仍然能够访问并操作其定义时所在作用域中的变量,这个函数就形成了闭包。

这并不是魔法,而是 JavaScript 词法作用域机制的自然结果


二、闭包形成的两个必要条件(缺一不可)

  1. 函数嵌套:内部函数引用了外部函数的变量;
  2. 内部函数被暴露到外部:比如通过 return 返回、赋值给全局变量、作为回调传递等,并在外部被调用。

只有同时满足这两点,闭包才会真正“生效”。


三、经典示例:直观感受闭包

function foo() {
  var myName = "极客时间";
  let test1 = 1;
  const test2 = 2; // 注意:test2 未被内部函数使用

  var innerBar = {
    getName: function () {
      console.log(test1); // 引用了外部变量 test1
      return myName;      // 引用了外部变量 myName
    },
    setName: function (newName) {
      myName = newName;   // 修改外部变量 myName
    }
  };

  return innerBar; // 将内部对象返回,使内部函数可在外部调用
}

// 执行 foo,获取返回的对象
var bar = foo(); // 此时 foo 已执行完毕,上下文出栈

// 在外部调用内部函数 —— 闭包开始工作!
bar.setName("极客邦");
bar.getName();               // 输出:1
console.log(bar.getName());  // 输出:1 和 "极客邦"

输出结果:

1
1
极客邦

四、关键问题:为什么 foo 的变量没被垃圾回收?

  • 通常情况下,函数执行结束后,其局部变量会被垃圾回收。

  • 但在闭包场景中,只要内部函数仍被外部引用,JavaScript 引擎就会保留该函数所依赖的外部变量

  • 在本例中:

    • getName 和 setName 引用了 myName 和 test1 → 这两个变量被“捕获”并保留在内存中;
    • test2 没有被任何函数使用 → 被正常回收。

📌 重点:闭包不会阻止整个函数上下文销毁,只保留“被引用”的变量
这既保证了功能,又避免了内存浪费。

image.png


五、闭包的本质与词法作用域的关系

1. 闭包的本质

闭包不是某种特殊语法,而是一种运行时行为

函数 + 它出生时的词法环境 = 闭包

你可以把它想象成:函数随身带了一个“背包”,里面装着它定义时能访问的所有外部变量。无论它走到哪里(哪怕在全局调用),都能从背包里取用或修改这些数据。

2. 与词法作用域的关联

  • 词法作用域:变量的作用域由代码书写位置决定(静态的、编译期确定)。
  • 闭包:正是词法作用域在函数被传递到外部后依然生效的体现。

✅ 所以说:闭包不是额外特性,而是词法作用域 + 函数作为一等公民 的必然产物。


💡 记住一句话
闭包不是“不让变量销毁”,而是“还有人用,所以不能销毁”。
它让 JavaScript 实现了私有状态、模块封装、回调记忆等强大能力。

理解闭包,你就真正迈入了 JavaScript 高阶编程的大门。

日本股票市场渲染 KlineCharts K 线图

2025年11月28日 17:35

下面是针对日本股票市场的完整对接方案,包含从获取股票列表渲染 KlineCharts K 线图的详细步骤和代码。

核心流程

  1. 获取日本股票列表:使用 countryId=35 查询日本市场的股票,获取目标股票的 id (即 PID)。
  2. 获取 K 线数据:使用该 pid 请求历史 K 线数据。
  3. 绘制图表:将数据转换为 KlineCharts 格式并渲染。

第一步:获取日本股票 PID (API 调试)

在写代码前,您需要先通过 API 拿到您想展示的日本股票(例如丰田、索尼等)的 id

请求方式:

  • 接口 URL: https://api.stocktv.top/stock/stocks
  • 参数:
    • countryId: 35 (日本)
    • pageSize: 10
    • key: 您的Key

请求示例 (GET):

https://api.stocktv.top/stock/stocks?countryId=35&pageSize=10&page=1&key=您的Key

返回示例 (假设): 您会在返回的 data.records 列表中找到股票信息。

{
  "id": 99999,  <-- 这个是 PID,记下这个数字用于下一步
  "name": "Toyota Motor Corp",
  "symbol": "7203",
  "countryId": 35,
  ...
}

第二步:完整实现代码 (HTML + KlineCharts)

将以下代码保存为 .html 文件。请替换代码顶部的 YOUR_API_KEY 和您在上一步获取到的 JAPAN_STOCK_PID

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>日本股票 K线图 (KlineCharts)</title>
    <script src="https://cdn.jsdelivr.net/npm/klinecharts/dist/klinecharts.min.js"></script>
    <style>
        body { margin: 0; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
        h2 { margin-bottom: 10px; }
        .config-box { 
            background: #f5f5f5; padding: 15px; border-radius: 8px; margin-bottom: 20px; 
            display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
        }
        input, select, button { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
        button { background-color: #007bff; color: white; border: none; cursor: pointer; }
        button:hover { background-color: #0056b3; }
        #chart-container { width: 100%; height: 600px; border: 1px solid #e0e0e0; border-radius: 4px; }
    </style>
</head>
<body>

    <h2>StockTV 日本股票 K线演示 (CountryID=35)</h2>

    <div class="config-box">
        <label>股票PID: <input type="number" id="pidInput" value="953373" placeholder="例如: 953373"></label>
        
        <label>周期: 
            <select id="intervalSelect">
                <option value="P1D">日线 (1 Day)</option>
                <option value="PT1H">1小时 (1 Hour)</option>
                <option value="PT15M">15分钟 (15 Min)</option>
                <option value="PT5M">5分钟 (5 Min)</option>
            </select>
        </label>

        <button onclick="loadChartData()">生成图表</button>
    </div>

    <div id="chart-container"></div>

    <script>
        // 配置您的 API Key
        const API_KEY = '联系我们获取key'; // TODO: 请在此处填入您的真实 Key
        const BASE_URL = 'https://api.stocktv.top';

        // 初始化 KlineCharts
        let chart = klinecharts.init('chart-container');
        
        // 设置一些基础样式
        chart.setStyleOptions({
            candle: {
                tooltip: {
                    labels: ['时间', '开', '收', '高', '低', '成交量']
                }
            }
        });

        chart.createIndicator('VOL'); // 创建成交量指标

        async function loadChartData() {
            const pid = document.getElementById('pidInput').value;
            const interval = document.getElementById('intervalSelect').value;

            if (!pid) {
                alert("请输入股票 PID");
                return;
            }

            console.log(`正在请求日本股票数据: PID=${pid}, Interval=${interval}`);

            try {
                // 构造 StockTV API 请求
                // 文档接口: /stock/kline
                const url = `${BASE_URL}/stock/kline?pid=${pid}&interval=${interval}&key=${API_KEY}`;
                
                const response = await fetch(url);
                const resJson = await response.json();

                if (resJson.code === 200) {
                    const stockData = resJson.data;

                    if (!stockData || stockData.length === 0) {
                        alert("该股票在此周期下无数据");
                        return;
                    }

                    // 数据格式转换
                    // StockTV: { time: 1719818400000, open: 239.42, ... }
                    // KlineCharts: { timestamp: 1719818400000, open: 239.42, ... }
                    const klineData = stockData.map(item => {
                        return {
                            timestamp: item.time, // 直接使用 API 返回的时间戳
                            open: Number(item.open),
                            high: Number(item.high),
                            low: Number(item.low),
                            close: Number(item.close),
                            volume: Number(item.volume)
                        };
                    });

                    // 确保按时间升序排序
                    klineData.sort((a, b) => a.timestamp - b.timestamp);

                    // 渲染数据
                    chart.applyNewData(klineData);
                    console.log("图表渲染成功,数据条数:", klineData.length);
                } else {
                    console.error("API 错误:", resJson);
                    alert("接口报错: " + resJson.message);
                }

            } catch (err) {
                console.error("请求失败:", err);
                alert("网络请求失败,请检查控制台 (F12)");
            }
        }

        // 窗口大小调整时自动调整图表
        window.addEventListener('resize', () => {
            chart.resize();
        });
        
        // 页面加载时自动尝试加载一次(方便测试)
        // 如果您有确定的日本股票PID,可以在 input 的 value 中预设
        // loadChartData(); 
    </script>
</body>
</html>

关键点说明

  1. CountryId=35 的使用

    • countryId=35 主要用于查询列表 (/stock/stocks) 阶段,用于筛选出日本市场的股票及其对应的 PID。
    • 一旦拿到 PID,在请求 K 线数据 (/stock/kline) 时,只需要 PID,不需要再传 countryId。
  2. 数据映射 (Mapping)

    • StockTV 返回的字段是 time, open, high, low, close, volume
    • KlineCharts 要求的字段是 timestamp, open, high, low, close, volume
    • 代码中 timestamp: item.time 这一行完成了关键的转换。
  3. 周期格式

    • 请确保传给 API 的 interval 参数是 P1D (日), PT1H (时) 等 ISO8601 格式,否则 API 可能会报错或返回空数据。

Vue2 通用文件在线预览下载组件:一站式解决多类型文件处理需求(支持视频、文档、图片、Office)

作者 某只天落
2025年11月28日 17:34

在前端开发中,文件预览与下载是高频需求,但不同文件类型(视频、图片、文档、Office)的预览逻辑差异大,且存在 “预览失败降级”“跨浏览器兼容” 等痛点。今天分享一款我封装的 Vue2 文件预览下载组件,无需重复开发,传入文件 URL 即可实现 “能预览则预览,不能预览则下载” 的闭环体验,适配 90%+ 业务场景。

一、组件核心功能一览

组件围绕 “文件预览 + 下载” 核心诉求,拆解为状态管理、分类型预览、错误处理、下载能力四大模块,逻辑闭环且交互友好:

1. 基础状态与信息管理

功能点 实现逻辑 价值
加载状态 初始化显示 “加载中”,延迟 1 秒隐藏(给资源加载留缓冲) 避免用户因文件加载慢误以为 “无内容”,提升交互感知
文件信息解析 从传入的 fileUrl 中分割 URL、解析文件名 / 后缀(如从 http://xxx/test.mp4 提取 test.mp4 和 mp4 自动识别文件属性,无需业务侧手动传入文件名 / 类型
错误状态管理 监听 video/iframe 预览错误,展示针对性提示(如 “视频预览失败”“文件链接无效”) 明确失败原因,引导用户下一步操作(下载)

2. 分类型文件预览(核心核心)

组件按文件类型划分预览策略,覆盖视频、图片、文档、Office 四大类,适配不同文件的原生预览能力:

文件类型 预览方式 特殊处理 支持的格式
视频文件 原生 <video> 标签 + 播放控件 预览失败后自动移除视频格式的预览支持,切换为下载模式 mp4、avi、mov、mkv、flv、wmv
Office 文件(doc/xls/ppt 及新版) iframe 嵌套微软在线预览服务 拼接微软预览 URL(view.officeapps.live.com),解决前端无法直接预览 Office 的痛点 doc、docx、xls、xlsx、ppt、pptx
图片 / 文本 / PDF iframe 直接加载文件 URL 利用浏览器原生渲染能力,无需额外依赖 jpg/png/gif/bmp、txt、html/htm、pdf
不支持的文件(zip/rar/ 未知格式) 无预览,展示下载区域 显示文件图标、类型、提示语,提供下载按钮 zip、rar 及未列入预览列表的格式

3. 错误兜底与降级处理

错误场景 处理逻辑 用户体验
视频预览失败(格式不支持 / 文件损坏) 显示错误提示,同时将视频格式从 “支持预览列表” 中移除,强制切换为下载模式 避免用户看到空白 / 报错的 video 标签,直接引导下载
iframe 预览失败(Office 链接失效 / PDF 损坏) 显示错误提示,补充 “建议下载查看” 的引导 明确失败原因,不阻塞用户获取文件
解析文件信息失败 兜底显示 “未知文件”“未知类型”,仍保留下载功能 兼容异常 URL(如无后缀、URL 格式错误)

4. 轻量化下载功能

通过动态创建<a>标签实现无刷新下载,支持自定义文件名,捕获下载异常并给出友好提示(如 “文件下载失败,请检查链接”)。

5. 友好的视觉与交互

  • 加载状态居中显示 “加载中”,避免用户误以为无内容;
  • 预览区域自适应容器大小,视频采用object-fit: contain防止拉伸;
  • 下载区域用图标 + 文字组合,按钮蓝色强调,提示语浅灰色弱化,视觉层级清晰;
  • 错误提示用红色警示,提升辨识度。

二、应用场景和组件完整代码

该组件适配所有需要 “文件预览 / 下载” 的业务场景,以下是高频落地场景:

1. 后台管理系统(核心场景)
  • 文件管理模块(OA / 企业网盘) :用户上传文件后,列表 / 详情页展示预览,支持在线查看视频 / PDF/Office,压缩包等直接下载;
  • 工单 / 审批系统:审批附件(如报销单 PDF、项目文档 Word)在线预览,无需下载即可审核,提升审批效率;
  • 素材管理系统:运营 / 设计人员上传的视频 / 图片素材在线预览,快速核对内容是否符合要求。
2. 用户中心 / 客户门户
  • 资质审核场景(政务 / 金融) :用户上传的身份证(图片)、营业执照(PDF)在线预览,工作人员无需下载即可审核;
  • 课程 / 培训平台:课程附件(视频、讲义 PDF、课件 PPT)在线预览,学员无需下载即可学习,降低学习门槛;
  • 售后工单系统:用户上传的售后凭证(视频 / 图片)在线预览,客服快速核实问题,提升售后效率。
3. 电商 / 零售系统
  • 商品资料管理:商品视频、说明书 PDF、参数表 Excel 在线预览,运营人员快速核对商品信息;
  • 商家后台:商家上传的资质文件(营业执照、食品经营许可证)在线预览,平台审核人员一键查看。
4. 医疗 / 教育系统
  • 医疗报告预览:检查报告 PDF、医学影像(图片)在线预览,医生 / 患者无需下载即可查看;
  • 在线考试系统:考试附件(试题 PDF、参考资料 Word)在线预览,考生在线答题时可快速查阅。

代码

<template>
  <div class="file-preview-container">
    <!-- 加载状态 -->
    <div v-if="loading" class="loading">加载中...</div>

    <!-- 视频文件:用 video 标签预览 -->
    <video
      v-else-if="isVideo && canPreview"
      :src="fileUrl"
      controls
      class="video-preview"
      @error="handleVideoError"
    >
      您的浏览器不支持视频预览
    </video>

    <!-- 非视频可预览文件:用 iframe 展示 -->
    <iframe
      v-else-if="!isVideo && canPreview"
      :src="iframeSrc"
      width="100%"
      height="100%"
      frameborder="0"
      class="preview-iframe"
      @error="handleIframeError"
    ></iframe>

    <!-- 不支持预览的文件:显示下载按钮 -->
    <div v-else class="download-section">
      <div class="file-icon">
        <i class="el-icon-video-camera" v-if="isVideo"></i>
        <i class="el-icon-document" v-else-if="fileType === 'doc' || fileType === 'docx'"></i>
        <i class="el-icon-table-lines" v-else-if="fileType === 'xls' || fileType === 'xlsx'"></i>
        <i class="el-icon-present" v-else-if="fileType === 'ppt' || fileType === 'pptx'"></i>
        <i class="el-icon-file-pdf" v-else-if="fileType === 'pdf'"></i>
        <i class="el-icon-image" v-else-if="['jpg','jpeg','png','gif','bmp'].includes(fileType)"></i>
        <i class="el-icon-archive" v-else-if="fileType === 'zip' || fileType === 'rar'"></i>
        <i class="el-icon-exclamation" v-else></i>
      </div>
      <div class="file-info">
        <p class="file-name">{{ fileName }}</p>
        <p class="file-type">文件类型:.{{ fileType }}</p>
        <p class="file-tip">
          {{
            isVideo
              ? "视频无法预览,请下载后查看"
              : "该文件类型不支持在线预览,请下载后查看"
          }}
        </p>
        <button class="download-btn" @click="downloadFile">
          <i class="el-icon-download"></i> 下载文件
        </button>
      </div>
    </div>

    <!-- 错误提示 -->
    <div v-if="errorMsg" class="error-message">{{ errorMsg }}</div>
  </div>
</template>

<script>
export default {
  name: "FilePreviewDownload",
  props: {
    // 文件完整链接(如:http://xxx.com/video.mp4、http://xxx.com/image.png)
    fileUrl: {
      type: String,
      required: true,
      validator: (value) => {
        // 简单校验URL格式
        return /^https?://.+/i.test(value) || /^//.+/i.test(value);
      },
    },
    // 自定义文件名(可选,默认从URL提取)
    customFileName: {
      type: String,
      default: "",
    },
  },
  data() {
    return {
      loading: true,
      errorMsg: "",
      fileName: "", // 文件名(如:test.mp4)
      fileType: "", // 文件后缀(如:mp4)
      // 支持的文件类型列表
      previewableTypes: [
        // 视频类
        "mp4", "avi", "mov", "mkv", "flv", "wmv",
        // 文档类
        "pdf", "txt", "html", "htm",
        // 图片类
        "jpg", "jpeg", "png", "gif", "bmp",
        // Office 格式
        "docx", "xlsx", "pptx", "doc", "xls", "ppt",
      ],
      // 视频格式单独区分(用于判断是否用 video 标签)
      videoTypes: ["mp4", "avi", "mov", "mkv", "flv", "wmv"],
    };
  },
  computed: {
    // 判断是否支持预览
    canPreview() {
      return this.previewableTypes.includes(this.fileType);
    },
    // 判断是否为视频文件
    isVideo() {
      return this.videoTypes.includes(this.fileType);
    },
    // iframe 预览地址(处理 Office 文件)
    iframeSrc() {
      // Office 文件用微软在线预览增强兼容性
      if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(this.fileType)) {
        return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(this.fileUrl)}`;
      }
      // 其他文件直接用原链接
      return this.fileUrl;
    },
  },
  created() {
    // 解析文件名和类型
    this.parseFileInfo();
    // 延迟隐藏加载(给资源加载留时间,可通过props自定义延迟)
    setTimeout(() => {
      this.loading = false;
    }, 1000);
  },
  methods: {
    // 解析文件名和后缀
    parseFileInfo() {
      try {
        // 优先使用自定义文件名
        if (this.customFileName) {
          this.fileName = this.customFileName;
          const nameParts = this.customFileName.split(".");
          if (nameParts.length > 1) {
            this.fileType = nameParts[nameParts.length - 1].toLowerCase();
          }
          return;
        }

        // 从URL提取文件名
        const urlParts = this.fileUrl.split("/");
        this.fileName = urlParts[urlParts.length - 1] || "未知文件";
        // 处理URL参数(如:test.pdf?timestamp=123 → test.pdf)
        this.fileName = this.fileName.split("?")[0].split("#")[0];
        // 提取文件后缀
        const nameParts = this.fileName.split(".");
        if (nameParts.length > 1) {
          this.fileType = nameParts[nameParts.length - 1].toLowerCase();
        }
      } catch (err) {
        console.error("解析文件信息失败:", err);
        this.fileName = "未知文件";
        this.fileType = "";
      }
    },

    // 视频预览错误处理
    handleVideoError() {
      this.errorMsg = "视频预览失败,可能格式不支持或文件损坏";
      // 视频预览失败后切换为下载模式
      this.previewableTypes = this.previewableTypes.filter(
        (type) => !this.videoTypes.includes(type)
      );
    },

    // iframe 预览错误处理
    handleIframeError() {
      this.errorMsg = "文件预览失败,可能文件已损坏或链接无效";
      if (this.previewableTypes.includes(this.fileType)) {
        this.errorMsg += ",建议下载文件查看";
      }
    },

    // 下载文件
    downloadFile() {
      try {
        const link = document.createElement("a");
        link.href = this.fileUrl;
        // 解决跨域下载时download属性失效问题(需后端配合设置Content-Disposition)
        link.download = this.fileName;
        document.body.appendChild(link);
        link.click();
        // 触发下载后移除a标签
        setTimeout(() => {
          document.body.removeChild(link);
        }, 100);
      } catch (err) {
        console.error("下载失败:", err);
        this.$message?.error ? this.$message.error("文件下载失败,请检查链接") : alert("文件下载失败,请检查链接");
      }
    },
  },
};
</script>

<style scoped>
.file-preview-container {
  width: 300px;
  min-height: 200px;
  position: relative;
  border: 1px solid #eee;
  border-radius: 4px;
  overflow: hidden;
  box-sizing: border-box;
}

/* 视频预览样式 */
.video-preview {
  width: 100%;
  height: 100%;
  min-height: 200px;
  object-fit: contain;
  background-color: #000;
}

/* iframe 预览样式 */
.preview-iframe {
  min-height: 200px;
  border: none;
}

/* 加载状态 */
.loading {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #666;
  font-size: 14px;
}

/* 下载区域 */
.download-section {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 200px;
  padding: 20px;
  box-sizing: border-box;
}

.file-icon {
  font-size: 60px;
  color: #417aff;
  margin-right: 30px;
}

.file-info {
  max-width: 180px;
}

.file-name {
  font-size: 16px;
  font-weight: 500;
  margin-bottom: 8px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.file-type {
  color: #666;
  font-size: 13px;
  margin-bottom: 8px;
}

.file-tip {
  color: #999;
  font-size: 12px;
  margin-bottom: 16px;
  line-height: 1.4;
}

.download-btn {
  padding: 6px 16px;
  background-color: #417aff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  font-size: 14px;
  transition: background-color 0.3s;
}

.download-btn:hover {
  background-color: #2d62d0;
}

.download-btn i {
  margin-right: 4px;
  font-size: 12px;
}

/* 错误提示 */
.error-message {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #f56c6c;
  text-align: center;
  padding: 0 20px;
  font-size: 14px;
  line-height: 1.5;
}

/* 响应式适配 */
@media (max-width: 768px) {
  .file-preview-container {
    width: 100%;
  }
  .download-section {
    flex-direction: column;
    text-align: center;
  }
  .file-icon {
    margin-right: 0;
    margin-bottom: 16px;
  }
}
</style>

三、快速使用指南

1. 安装依赖(可选)

组件依赖 Element UI 图标,若项目未集成 Element UI,可安装:

npm install element-ui --save

或替换为原生图标(如 Font Awesome),移除 Element UI 依赖。

2. 引入组件

// 在需要使用的页面引入
import FilePreviewDownload from "@/components/FilePreviewDownload.vue";

export default {
  components: {
    FilePreviewDownload,
  },
};

3. 页面中使用

<!-- 基础用法:仅传入文件URL -->
<FilePreviewDownload fileUrl="http://xxx.com/test.pdf" />

<!-- 自定义文件名 -->
<FilePreviewDownload 
  fileUrl="http://xxx.com/123.mp4" 
  customFileName="产品介绍视频.mp4" 
/>

四、组件优势

优势点 说明
通用性强 覆盖视频、图片、文档、Office 等主流文件类型,适配 90%+ 业务场景
体验友好 加载 / 错误 / 降级逻辑完善,用户操作路径清晰(预览→失败→下载)
轻量易集成 基于原生标签 + Vue 开发,仅依赖 Element 图标,接入成本低
解决 Office 预览痛点 借助微软在线预览服务,无需前端集成重型 Office 解析库

1. 通用性强,覆盖全场景

支持视频、图片、文档、Office 等 18 + 常见文件类型,无需为不同文件写专属逻辑,适配后台管理、用户中心、电商等多业务场景。

2. 体验友好,优雅降级

“预览优先,下载兜底” 的逻辑,避免 “无法预览” 的生硬体验;预览失败时给出明确提示,引导用户下一步操作,减少困惑。

3. 轻量无冗余,接入成本低

  • 核心逻辑仅 200 + 行,无重型依赖,打包体积小;
  • 仅需传入fileUrl即可使用,无需配置复杂参数,新手也能快速上手。

4. 适配性强,兼容多端

  • 基于原生 HTML 标签(video/iframe)开发,兼容 Chrome、Firefox、Edge 等主流浏览器;
  • 样式支持响应式,适配移动端 / H5,可直接复用在小程序内嵌页面。

五、局限性与优化方向

局限性 影响场景 优化方向
样式硬编码 容器宽度 300px 固定,适配不同布局(如全屏预览)需修改样式 将宽度 / 高度 / 颜色等作为 props 传入,支持自定义
Office 预览依赖外网 内网环境下微软在线预览失效,Office 文件无法预览 集成开源文件预览服务(如 kkfileview、LibreOffice Online)
视频预览格式有限 小众格式(rmvb、webm)不支持,且无格式转换逻辑 集成 ffmpeg.wasm 实现前端视频格式解码,或后端转码为 mp4
下载功能兼容问题 跨域文件的 download 属性失效,无法直接下载 后端转发文件(前端请求后端接口,后端返回文件流)
加载延迟固定 1 秒 文件加载快时多余显示加载状态,加载慢时提前隐藏 监听 video/iframe 的 onload 事件,动态控制加载状态

1. 现存局限性

  • 样式硬编码:容器宽度默认 300px,适配不同布局需手动修改样式;
  • Office 预览依赖外网:内网环境下微软在线预览服务失效,无法预览 Office 文件;
  • 视频格式支持有限:小众格式(如 rmvb、webm)不支持原生预览;
  • 跨域下载问题:跨域文件的download属性可能失效,需后端配合设置响应头。

2. 扩展方向(按需迭代)

(1)支持自定义样式配置

将宽度、高度、边框、颜色等样式抽离为 props,允许业务侧灵活配置:

<FilePreviewDownload 
  fileUrl="http://xxx.com/test.jpg"
  :styleConfig="{ width: '500px', height: '300px', border: '1px solid #ccc' }"
/>

(2)增加权限控制

支持传入token参数,在预览 URL 中拼接鉴权信息,防止文件链接泄露:

// 扩展iframeSrc计算属性
iframeSrc() {
  let url = this.fileUrl;
  // 拼接鉴权token
  if (this.token) {
    url = `${url}${url.includes("?") ? "&" : "?"}token=${this.token}`;
  }
  if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(this.fileType)) {
    return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(url)}`;
  }
  return url;
},

(3)内网环境 Office 预览适配

集成开源文件预览服务(如 kkfileview、LibreOffice Online),替代微软在线预览,解决内网环境预览失效问题:

// 优化downloadFile方法
downloadFile() {
  // 跨域文件通过后端接口下载
  if (this.isCrossDomain) {
    window.open(`/api/file/download?url=${encodeURIComponent(this.fileUrl)}&fileName=${this.fileName}`);
    return;
  }
  // 非跨域文件直接下载(原逻辑)
  // ...
},

(6)增加批量预览 / 下载

扩展为列表级组件,支持多选文件批量预览、批量下载,适配文件管理系统场景。

六、总结

这款文件预览下载组件以 “通用、轻量、友好” 为核心设计理念,解决了前端文件处理的重复开发问题,是后台管理、用户中心等项目的必备基础组件。它不仅能直接复用,还支持按需扩展,可根据业务场景迭代权限控制、内网适配、批量操作等功能。

黑马喽大闹天宫与JavaScript的寻亲记:作用域与作用域链全解析

作者 AY1024
2025年11月28日 17:34

黑马喽大闹天宫与JavaScript的寻亲记:作用域与作用域链全解析

开场白:一个变量的"无法无天"与它的"寻亲之路"


📖 第一章:黑马喽的嚣张岁月

话说在前端江湖的ES5时代,有个叫var的黑马喽,这家伙简直无法无天!它想来就来,想走就走,完全不顾什么块级作用域的规矩。

// 你们看看这黑马喽的德行
for (var i = 0; i < 3; i++) {
    setTimeout(() => {
        console.log(i); // 你猜输出啥?3,3,3!
    }, 100);
}
// 循环结束了,i还在外面晃荡
console.log(i); // 3,瞧瞧,跑出来了吧!

但今天咱们不仅要扒一扒var的底裤,还要讲讲变量们是怎么"寻亲"的——这就是作用域作用域链的故事。


🔧 第二章:编译器的三把斧——代码的"梳妆打扮"

要说清楚作用域,得先从JavaScript的编译说起。别看JS是解释型语言,它在执行前也要经历一番"梳妆打扮"。

2.1 词法分析:拆解字符串的魔术

想象一下,编译器就像个认真的语文老师,把代码这个长句子拆成一个个有意义的词语:

var a = 1vara=1

注意:空格要不要拆开,得看它有没有用。就像读书时要不要停顿,得看语气!

2.2 语法分析:构建家谱树

拆完词之后,编译器开始理清关系——谁声明了谁,谁赋值给谁,最后生成一棵抽象语法树(AST)

这就像把一堆零散的家庭成员信息,整理成清晰的家谱。

2.3 代码生成:准备执行

最后,编译器把家谱树转换成机器能懂的指令,准备执行。

关键点:JS的编译发生在代码执行前的一瞬间,快到你几乎感觉不到!


💕 第三章:变量赋值的三角恋

var a = 1这么简单的一行代码,背后居然上演着一场"三角恋":

  • 🎯 编译器:干脏活累活的媒人,负责解析和牵线
  • JS引擎:执行具体动作的新郎
  • 🏠 作用域:管理宾客名单的管家

3.1 订婚仪式(编译阶段)

// 当看到 var a = 1;
编译器:管家,咱们这有叫a的变量吗?
作用域:回大人,还没有。
编译器:那就在当前场合声明一个a!

3.2 结婚典礼(执行阶段)

JS引擎:管家,我要找a这个人赋值!
作用域:大人请,a就在这儿。
JS引擎:好,把1赋给a!

这里涉及到两种查找方式:

LHS查询:找容器(找新娘)

var a = 1; // 找到a这个容器装1

RHS查询:找源头(找新娘的娘家)

console.log(a); // 找到a的值
foo();         // 找到foo函数本身

编译过程示意图


🐒 第四章:黑马喽的罪证展示

在ES5时代,var这家伙真是目中无人:

4.1 无视块级作用域

{
    var rogue = "我是黑马喽,我想去哪就去哪";
}
console.log(rogue); // 照样能访问!

4.2 变量提升的诡计

console.log(naughty); // undefined,而不是报错!
var naughty = "我提升了";

这货相当于:

var naughty;          // 声明提升到顶部
console.log(naughty); // undefined
naughty = "我提升了"; // 赋值留在原地

🙏 第五章:如来佛祖的五指山——let和const

ES6时代,如来佛祖(TC39委员会)看不下去了,派出了letconst两位大神:

5.1 块级作用域的紧箍咒

{
    let disciplined = "我在块里面很老实";
    const wellBehaved = "我也是好孩子";
}
console.log(disciplined); // ReferenceError!出不来咯

5.2 暂时性死区的降妖阵

console.log(rebel); // ReferenceError!此路不通
let rebel = "想提升?没门!";

真相let/const其实也会提升,但是被关进了"暂时性死区"这个五指山里,在声明前谁都别想访问!


🧩 第六章:黑马喽的迷惑行为——词法作用域的真相

6.1 一个让黑马喽困惑的例子

function bar(){
    console.log( myName);  // 黑马喽:这里该输出啥?
}

function foo(){
    var myName = "白吗喽";
    bar()
    console.log("1:", myName)   // 这个我懂,输出"白吗喽"
}

var myName = "黑吗喽";
foo()  // 输出:"黑吗喽","白吗喽"

黑马喽挠着头想:"不对啊!bar()foo()里面调用,不是应该找到foo()里的myName = "白吗喽"吗?怎么会是黑吗喽呢?"

6.2 outer指针:函数的"身份证"

原来,在编译阶段,每个函数就已经确定了自己的"娘家"(词法作用域):

// 编译阶段发生的事情:
// 1. bar函数出生,它的outer指向全局作用域(它声明在全局)
// 2. foo函数出生,它的outer也指向全局作用域(它声明在全局)
// 3. 变量myName声明提升:var myName = "黑吗喽"

// 执行阶段:
var myName = "黑吗喽";  // 全局myName赋值为"黑吗喽"
foo();                 // 调用foo函数

黑马喽的错误理解

bar() → foo() → 全局

实际的作用域查找(根据outer指针):

bar() → 全局

如图

C9AE3D8E-F1DA-4767-AE87-AF4B1AF8B94D.png

6.3 词法作用域 vs 动态作用域

词法作用域(JavaScript):看出生地

var hero = "全局英雄";

function createWarrior() {
    var hero = "部落勇士";
    
    function fight() {
        console.log(hero); // 永远输出"部落勇士"
    }
    
    return fight;
}

const warrior = createWarrior();
warrior(); // "部落勇士" - 记得出生时的环境

动态作用域:看调用地(JavaScript不是这样!)

// 假设JavaScript是动态作用域(实际上不是!)
var hero = "战场英雄";
const warrior = createWarrior();
warrior(); // 如果是动态作用域,会输出"战场英雄"

🗺️ 第七章:作用域链——变量的寻亲路线图

7.1 每个函数都带着"出生证明"

var grandma = "奶奶的糖果";

function mom() {
    var momCookie = "妈妈的饼干";
    
    function me() {
        var myCandy = "我的棒棒糖";
        console.log(myCandy);    // 自己口袋找
        console.log(momCookie);  // outer指向mom
        console.log(grandma);    // outer的outer指向全局
    }
    
    me();
}

mom();

7.2 作用域链的建造过程

// 全局作用域
var city = "北京";

function buildDistrict() {
    var district = "朝阳区";
    
    function buildStreet() {
        var street = "三里屯";
        console.log(street);     // 自己的
        console.log(district);   // outer指向buildDistrict
        console.log(city);       // outer的outer指向全局
    }
    
    return buildStreet;
}

// 编译阶段就确定的关系:
// buildStreet.outer = buildDistrict作用域
// buildDistrict.outer = 全局作用域

如图

9625B41F-066C-4BD2-AF8B-44B93C395CF9.png

⚔️ 第八章:作用域链的实战兵法

8.1 兵法一:模块化开发

function createCounter() {
    let count = 0; // 私有变量,外部无法直接访问
    
    return {
        increment: function() {
            count++; // 闭包:outer指向createCounter作用域
            return count;
        },
        getValue: function() {
            return count;
        }
    };
}

const counter = createCounter();
console.log(counter.increment()); // 1
// console.log(count); // 报错!count是私有的

8.2 兵法二:解决循环陷阱

黑马喽的坑
for (var i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 3, 3, 3 - 所有函数共享同一个i
    }, 100);
}
作用域链的救赎
// 方法1:使用let创建块级作用域
for (let i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 0, 1, 2 - 每个i都有自己的作用域
    }, 100);
}

// 方法2:IIFE创建新作用域
for (var i = 0; i < 3; i++) {
    (function(j) {
        setTimeout(function() {
            console.log(j); // 0, 1, 2 - j在IIFE作用域中
        }, 100);
    })(i);
}

8.3 兵法三:正确的函数嵌套

function foo(){
    var myName = "yang";
    
    function bar(){  // 现在bar的outer指向foo了!
        console.log("2:", myName);  // 找到foo的myName
    }
    
    bar()
    console.log("1:", myName)
}

var myName = "yang1";
foo()  // 输出:2: yang, 1: yang

🚀 第九章:现代JavaScript的作用域体系

9.1 块级作用域的精细化管理

function modernScope() {
    var functionScoped = "函数作用域";
    let blockScoped = "块级作用域";
    
    if (true) {
        let innerLet = "内部的let";
        var innerVar = "内部的var"; // 依然提升到函数顶部!
        
        console.log(blockScoped); // ✅ 可以访问外层的let
        console.log(functionScoped); // ✅ 可以访问外层的var
    }
    
    console.log(innerVar); // ✅ 可以访问
    // console.log(innerLet); // ❌ 报错!let是块级作用域
}

9.2 作用域链的新层级

// 全局作用域
const GLOBAL = "地球";

function country() {
    // 函数作用域
    let nationalLaw = "国家法律";
    
    {
        // 块级作用域1
        let provincialLaw = "省法规";
        
        if (true) {
            // 块级作用域2
            let cityRule = "市规定";
            
            console.log(cityRule);     // ✅ 本市有效
            console.log(provincialLaw); // ✅ 本省有效
            console.log(nationalLaw);   // ✅ 全国有效
            console.log(GLOBAL);        // ✅ 全球有效
        }
        
        // console.log(cityRule); // ❌ 跨市无效
    }
}

⚡ 第十章:作用域链的性能与优化

10.1 作用域查找的代价

var globalVar = "我在最外层";

function level3() {
    // 这个查找要经过:自己 → level2 → level1 → 全局
    console.log(globalVar);
}

function level2() {
    level3();
}

function level1() {
    level2();
}

10.2 优化心法

function optimized() {
    const localCopy = globalVar; // 局部缓存,减少查找深度
    
    function inner() {
        console.log(localCopy); // 直接访问,快速!
    }
    
    inner();
}

🏆 大结局:黑马喽的毕业总结

经过这番学习,黑马喽终于明白了作用域的真谛:

🎯 作用域的进化史

  1. ES5的混乱var无视块级作用域,到处捣乱
  2. ES6的秩序let/const引入块级作用域和暂时性死区
  3. outer指针机制:词法作用域在编译时确定,一辈子不变

🧠 作用域链的精髓

  1. outer指针:函数在编译时就确定了自己的"娘家"
  2. 词法作用域:看出生地,不是看调用地
  3. 就近原则:先找自己,再按outer指针找上级
  4. 闭包的力量:函数永远记得自己出生时的环境

💡 最佳实践心法

// 好的作用域设计就像好的家风
function createFamily() {
    // 外层:家族秘密,内部共享
    const familySecret = "传家宝";
    
    function teachChild() {
        // 中层:教育方法
        const education = "严格教育";
        
        return function child() {
            // 内层:个人成长
            const talent = "天赋异禀";
            console.log(`我有${talent},接受${education},知道${familySecret}`);
        };
    }
    
    return teachChild();
}

const familyMember = createFamily();
familyMember(); // 即使独立生活,依然记得家族传承

🌟 终极奥义

黑马喽感慨地总结道:

"原来JavaScript的作用域就像血缘关系:

  • 作用域是家规(在哪里能活动)
  • 作用域链是族谱(怎么找到祖先)
  • outer指针是出生证明(一辈子不变)
  • 词法作用域是家族传承(看出生地,不是看现住地)"

从此,黑马喽明白了:想要在前端江湖混得好,就要遵守作用域的家规,理解作用域链的族谱,尊重outer指针的出生证明!


🐒 黑马喽寄语:记住,函数的作用域是它的"娘家",编译时定亲,一辈子不变!理解了这套规则,你就能驯服任何JavaScript代码!

封装一个支持动态表头与权限控制的通用 VxeTable 组件

作者 GYY_y
2025年11月28日 17:28

项目背景 基础需求: 我这边的需求是要把表格的表头设置为动态的情况、并且允许默写列进行显示隐藏、冻结、排序、默认某几项进行禁止取消勾选。 在这个基础需求上需要兼容按钮权限、以及每一个单元格内的点击事件和toolbar的按钮动态禁止功能

动态表头配置

不同角色、不同场景下,用户对字段的关注点不同,需支持运行时动态调整表头结构,包括列的显示/隐藏、顺序调整、宽度记忆等。 精细化列控制 允许部分业务列(如“备注”“标签”)被用户自由隐藏; 关键列(如首列、操作列、复选框、序号)必须始终可见,禁止在列设置中取消勾选; 支持列冻结(固定左/右)、排序、宽度拖拽等交互能力。 深度交互与权限集成 每个单元格需支持独立点击事件(如跳转详情、编辑内联); 表格工具栏(Toolbar)按钮需按用户权限动态渲染(兼容 v-permission 等指令); 操作列中的按钮同样需支持权限控制与自定义插槽。 配置持久化

用户对表头的个性化设置(如隐藏了哪些列、调整了哪些顺序)应自动保存至服务端,并在下次访问时还原,提升使用体验。

效果图

图片

在这里插入图片描述在这里插入图片描述

视频效果

live.csdn.net/v/503221

版本号

在这里插入代码片
  "vxe-pc-ui": "^4.10.30",
   "vxe-table": "^4.17.20",
   "xe-utils": "^3.7.9",
   "vue": "^3.5.18"

Props 说明

参数 类型 默认值 说明
border Boolean true 是否显示表格边框
stripe Boolean true 是否显示斑马纹
cloumnDrag Boolean false 是否允许拖拽调整列顺序
toolDrag Boolean true 是否显示右上角“列设置”按钮(齿轮图标)
height String / Number '500px' 表格高度,支持 '400px''80%'
code String '' 必填!当前页面唯一标识,用于保存/恢复列配置
showCheckbox Boolean true 是否显示复选框列
showIndex Boolean false 是否显示序号列
showAction Boolean false 是否显示操作列
actionWidth Number 100 操作列宽度(单位:px)
slotsFields Array<String> [] 需要用插槽渲染的字段名,如 ['status', 'name']

双向数据绑定

名称 类型 默认值 说明
data Array [] 表格主体数据,每一项为一行记录
buttons Array [] 左侧工具栏按钮配置,格式如:{ code: 'add', name: '新增' }
column Array [] 表头列配置,每列需包含 field(字段名)、title(标题)、visible(是否显示)等属性

插槽

<!-- 渲染 status 字段 --><template #status="{ row, column, $rowIndex }">  
 <span>{{ row.statusText }}</span></template>  

event方法

事件名 回调参数 说明
cellClick (row, column, value, title) 点击单元格时触发
checkAll (selectedRows: Array) 全选/取消全选时触发
check (selectedRows: Array) 单行勾选状态变化时触发
saveSuccess 用户点击【确定】或【恢复默认】后触发(用于重新加载表格)
leftBar (button: Object) 点击左侧工具栏按钮时触发

完整的使用示例

// 组件
<Table  
 ref="tableRef" 
 v-model:column="columns" 
 v-model:data="tableData" 
 v-model:buttons="buttons" 
 :code="ViewName.CRM_MY_CUSTOMER_LIST" 
 :height="tableHeight" 
 :show-action="true" 
 :stripe="false" 
 action-width="200" 
 :slots-fields="slotsFields" 
 @cell-click="handleCellClick" 
 @check-all="handleSelectChange" 
 @check="handleSelectChange" 
 @save-success="initList" 
 @left-bar="handleLeftBar">  
 // 这里的操作栏是action 名称必须固定为action 需配合show-action属性
 <template #action="{ row }"> 
 <div v-if="shouldShowActions(row)"> 
 <el-button v-permission="['customer:my:edit']" link type="primary" @click="handleTableUpdate(row)" >编辑</el-button>  
 </div> 
 </template>  
 // 这次插槽totalConsumeAmount必须要在slotsFields内填写 不然不允许使用
 <template #phoneNumber="{ row }"> 
 <IconifyIconOnline v-if="row.phoneNumber" icon="ep:copy-document" style="display: inline; cursor: pointer" @click="copy(row.phoneNumber)" />&nbsp; {{ row.phoneNumber }} 
 </template>  
 // 这次插槽totalConsumeAmount必须要在slotsFields内填写 不然不允许使用
 <template #followUpCount="{ row }"> 
 <el-button link type="primary" @click="previewTable(row)" >点击查看</el-button> 
 </template>  
 // 这次插槽totalConsumeAmount必须要在slotsFields内填写 不然不允许使用
 <template #totalConsumeAmount="{ row }"> 
 <span class="custom_high" @click="handleClickConsumptionUserInfo(row)" >{{ row.totalConsumeAmount }</span > 
 </template>
 </Table>

<script setup>
import { computed, ref, onMounted, defineOptions, nextTick } from "vue";

const tableRef = ref(null); // 表格ref
const columns = ref([]); // 列名称list
const tableData = ref([]); // 数据list

// 表格高度
const tableHeight = computed(() => {
  // searchHeight 为form的高度
  const searchHeight = searchBoxHeight.value || 0;
  return window.innerHeight - 280 - searchHeight;
});
// 单元格需要用到的插槽 需要在这里定义才能使用 重点!!!
const slotsFields = ref([
  "customerName",
  "sourceChannelName",
  "phoneNumber",
  "followStatus",
  "totalConsumeAmount",
  "totalRechargeAmount"
]);

const selectListIds = ref([]); // 勾选值id列表
// 勾选事件
const handleSelectChange = val => {
  selectListIds.value = val.map(item => item.id);
};
// toolbar上面的按钮
const rawButtons = [
  {
    code: "addMember", // 必传值
    name: "添加客户",  //  必传值 文本(页面展示)
    icon: "vxe-icon-add",  // icon
    status: "primary", // 状态
    permissionCode: "customer:my:add" // 是否有按钮权限。如果不传或者为值为空字符串 代表所有人可见
  },
  {
    code: "batchImport",
    name: "批量导入",
    status: "default",
    permissionCode: "customer:my:batchImport"
  },
  {
    code: "edit", // 必传值
    name: "编辑", //  必传值 文本(页面展示)
    status: "default", // 状态
    dependsOnSelection: true, // 是否被勾选框控制 如果值为true代表被checkbox勾选有关 不传或者为false则不被checkbox控制
    permissionCode: "customer:my:edit" // 是否有按钮权限。如果不传或者为值为空字符串 代表所有人可见
  }
];

// usePermissionButtons 函数为控制toolbar按钮权限处理
const buttons = usePermissionButtons(rawButtons, selectListIds);

const handleLeftBar = val => {
  switch (val.code) {
    case "addMember":
      break;
    case "batchImport":
      break;
    default:
      break;
  }
};

const getHeader = async () => {
  const res = await getTableHeader(ViewName.CRM_MY_CUSTOMER_LIST);
  columns.value = res.data.map(i => {
    return {
      ...i,
      field: i.key, // 必传
      title: i.label, // 必传
      width: i.width ?? "100px" // 必传
    };
  });
};

const initList = () => {
  getHeader(); // 获取表头数据
  getList(); // 获取列表数据
};
</script>

usePermissionButtons文件

// usePermissionButtons.js文件代码

import { computed } from "vue";
import { useUserStore } from "@/store/modules/user";

// store
const userStore = useUserStore();

/**
 * 根据 rawButtons 和选中状态,生成带权限控制和禁用状态的按钮列表
 * @param {Array} rawButtons - 原始按钮配置数组
 * @param {Ref<number[]> | Ref<any[]>} selectListIds - 选中的 ID 列表(ref)
 * @returns {ComputedRef<Array>} 过滤并处理后的按钮列表
 */
export function usePermissionButtons(rawButtons, selectListIds) {
  return computed(() => {
    const isEmpty = selectListIds.value.length === 0;
    const permissionList = getPermissionCodeList(); // 确保这是响应式的或最新值
    if (permissionList[0] === "*:*:*") {
      // admin
      return rawButtons;
    } else {
      // 非admin
      return rawButtons
        .filter(
          btn =>
            !btn.permissionCode || permissionList.includes(btn.permissionCode)
        )
        .map(btn => ({
          ...btn,
          disabled: btn.dependsOnSelection ? isEmpty : (btn.disabled ?? false)
        }));
    }
  });
}


// 获取登录人所有的按钮权限
export const getPermissionCodeList = () => {
  return userStore.permissions || [];
};

Table组件代码


<template>
  <div class="demo-page-wrapper">
    <vxe-grid
      ref="gridRef"
      v-bind="gridOptions"
      @toolbar-button-click="handleLeftToolbar"
      @checkbox-all="selectAllChangeEvent"
      @checkbox-change="selectChangeEvent"
      @cell-click="handleCellClick"
      @custom="handleColumnCustom"
    >
      <template v-for="(_, name) in $slots" #[name]="slotData">
        <slot :name="name" v-bind="slotData" />
      </template>
    </vxe-grid>
  </div>
</template>

<script setup>
import {
  ref,
  defineProps,
  defineExpose,
  defineModel,
  defineEmits,
  computed
} from "vue";
import XEUtils from "xe-utils";
import { headCancel, headSave } from "@/api/view";
import { ElMessage } from "element-plus";

const emits = defineEmits([
  "cellClick",
  "checkAll",
  "check",
  "saveSuccess",
  "leftBar"
]);
// 定义 props
const props = defineProps({
  // 边框线
  border: {
    type: Boolean,
    default: true
  },
  // 斑马线
  stripe: {
    type: Boolean,
    default: true
  },
  // 列拖拽
  cloumnDrag: {
    type: Boolean,
    default: false
  },
  // 自定义拖拽icon
  toolDrag: {
    type: Boolean,
    default: true
  },
  // 表格高度
  height: {
    type: [String, Number],
    default: "500px" // 也支持%
  },
  // 每个数据的唯一code
  code: { type: String, default: "" },
  // 是否展示复选框
  showCheckbox: { type: Boolean, default: true },
  // 是否展示索引号
  showIndex: { type: Boolean, default: false },
  // 是否展示操作列
  showAction: { type: Boolean, default: false },
  // 操作列宽度
  actionWidth: { type: Number, default: 100 },
  // 需要的插槽 例子: ["name", "id", ....]
  slotsFields: {
    type: Array,
    default: () => {
      return [];
    }
  }
});

// 表格数据
const tableData = defineModel("data", {
  default: []
});

// 左侧操作栏
const buttonsList = defineModel("buttons", {
  default: []
});

// 表头数据
const column = defineModel("column", {
  default: []
});

// 将这些值进行禁用
const disabledKeys = computed(() => {
  return column.value.length
    ? ["checkbox", "seq", "action", column.value[0].field]
    : ["checkbox", "seq", "action"];
});
// 处理slot插槽
const processedColumns = computed(() => {
  return column.value.map(col => {
    // 确保是普通数据列且有 field
    if (!col.type && col.field != null) {
      if (props.slotsFields.includes(col.field)) {
        return {
          ...col,
          slots: { default: col.field }
        };
      }
    }
    return col; // 原样返回(包括无 field 的列、type 列等)
  });
});

// 使用 computed,确保每次都是最新值
const gridOptions = computed(() => {
  const cols = [];

  // 复选框列
  if (props.showCheckbox) {
    cols.push({
      type: "checkbox",
      width: 40,
      fixed: "left",
      visible: true,
      field: "checkbox" // 该值是为了禁用复制的唯一值
    });
  }

  // 序号列
  if (props.showIndex) {
    cols.push({
      type: "seq",
      width: 50,
      title: "序号",
      fixed: "left",
      visible: true,
      field: "seq" // 该值是为了禁用复制的唯一值
    });
  }

  //  只加处理后的业务列(已自动注入 slots)
  cols.push(...processedColumns.value);

  // 操作列
  if (props.showAction) {
    cols.push({
      field: "action",
      title: "操作",
      width: props.actionWidth,
      fixed: "right",
      align: "center",
      visible: true,
      slots: { default: "action" }
    });
  }

  return {
    border: props.border,
    stripe: props.stripe,
    showOverflow: true,
    height: props.height,
    loading: false,
    columnConfig: { drag: props.cloumnDrag, resizable: true },
    rowConfig: { isCurrent: true, isHover: true },
    columnDragConfig: { trigger: "cell", showGuidesStatus: true },
    customConfig: {
      // 该列是否允许选中
      checkMethod({ column }) {
        return !disabledKeys.value.includes(column.field);
      }
    },
    toolbarConfig: {
      custom: props.toolDrag,
      zoom: false,
      buttons: buttonsList.value
    },
    checkboxConfig: { range: true },
    columns: cols,
    // columns: cols.filter(i => i.field !== "checkbox" && i.field !== "seq"),
    data: tableData.value
  };
});

const gridRef = ref(null);

// 选中的项
const selectedRows = ref([]);

// 事件处理
const selectAllChangeEvent = ({ checked }) => {
  selectedRows.value = gridRef.value?.getCheckboxRecords() || [];
  emits("checkAll", selectedRows.value);
};

const selectChangeEvent = ({ checked }) => {
  selectedRows.value = gridRef.value?.getCheckboxRecords() || [];
  emits("check", selectedRows.value);
};

// 清空选中的数据
const clearSelectEvent = () => {
  gridRef.value?.clearCheckboxRow();
};

// 获取选中的数据
const getSelectEvent = () => {
  const records = gridRef.value?.getCheckboxRecords() || [];
  console.log(`已选中 ${records.length} 条数据`);
};

// 选中所有
const selectAllEvent = () => {
  gridRef.value?.setAllCheckboxRow(true);
};

// 设置自定义勾选数据
const setSelectRow = (rows, checked = true) => {
  gridRef.value?.setCheckboxRow(rows, checked);
};

// 单元格点击
const handleCellClick = ({
  row,
  rowIndex,
  $rowIndex,
  column,
  columnIndex,
  $columnIndex,
  triggerRadio,
  triggerCheckbox,
  triggerTreeNode,
  triggerExpandNode,
  $event
}) => {
  emits("cellClick", row, column, row[column.property], column.title);
};

// 自定义筛选icon弹窗事件
const handleColumnCustom = params => {
  switch (params.type) {
    case "open":
      break;
    case "confirm": {
      // 白名单列表 将操作列和复选框、序号列过滤
      const whiteList = new Set(["action", null, undefined]);
      // 获取勾选的
      // const visibleColumn = gridRef.value?.getColumns() || [];
      // 获取所有的 visible来区分是否勾选
      const visibleColumn = gridRef.value?.getFullColumns() || [];
      const result = visibleColumn
        .map(i => {
          return {
            key: i.field,
            fixed: i.fixed,
            visible: i.visible,
            width: i.width,
            title: i.title,
            label: i.label,
            field: i.field
          };
        })
        .filter(k => !whiteList.has(k.key))
        .filter(i => i.field !== "checkbox" && i.field !== "seq");

      headSave(result, props.code)
        .then(() => {
          ElMessage.success("保存成功");
          emits("saveSuccess");
        })
        .catch(e => {
          console.error(e);
        });
      break;
    }
    case "reset": {
      // 恢复默认
      headCancel(props.code).then(() => {
        emits("saveSuccess");
      });
      break;
    }
    case "close": {
      break;
    }
  }
};

const handleLeftToolbar = val => {
  const { code, button, $event } = val;
  emits("leftBar", button);
};

// 暴露方法
defineExpose({
  gridRef,
  clearSelectEvent,
  getSelectEvent,
  selectAllEvent,
  setSelectRow
});
</script>

<style lang="scss" scoped>
.demo-page-wrapper {
  height: 100%;
  padding: 0;
  background-color: #fff;
}
</style>

Git:如何排查非线性历史中被隐秘覆盖的修改(完整实战笔记)

2025年11月28日 17:20

在多人协作开发中,尤其是 i18n 文案文件(如 assets/locales/ja_JP.json)体量庞大时,某一段 key 被突然“消失” ,往往不是故意删除,而是:

  • 同事基于旧分支开发
  • merge commit 采取 ours/theirs 策略自动覆盖
  • 或 merge 的某个 parent 版本较旧,导致新 key 在另一个 parent 中被丢弃
  • 没有线性历史,GitLab UI 的 diff 不一定能看到

这个问题很隐蔽,但可以完全定位。

本文总结排查流程。

第一步:确认问题是否被覆盖(非故意删除)

我们要知道:

这个 Key 是 “被人写代码删掉” 还是 “merge 自动覆盖掉”?

使用:

git log -S "modbus_server" -p -- assets/locales/ja_JP.json

含义:

  • S 搜索文本出现/消失的位置
  • p 展示 diff

结果显示:

✔️ Key 的添加出现在最早的 commit

❌ Key 的删除并未以明显 diff 方式出现

→ 说明不是编辑删除,而是 merge 导致覆盖


2️⃣ 第二步:锁定 key 仍存在时的最后一个 commit

通过定位:

2b86a183de12891ae463bcb941defb8a338d2046

这个版本中 key 仍然存在。


3️⃣ 第三步:查找它的直接 children

因为 develop 历史是非线性的,所以不能只看时间顺序。

使用:

git rev-list --children develop | grep 2b86a183

输出:

2b86a183 ... ce3ba83f ... f8e8dc155ae6...
80e7ca4386... 2b86a183...

含义:

  • commit 2b86a183两个 child

    • ce3ba83f
    • f8e8dc155ae6

只要找到哪个 child 删除了 key,就能定位元凶


4️⃣ 第四步:对比两个 child 与 parent

A. 对比第一个 child:

git diff 2b86a183..ce3ba83f -- assets/locales/ja_JP.json

输出明确显示:

✔️ modbus_server 整段内容被 删掉了

关键:这就是删除 key 的确切 commit!

B. 对比第二个 child:

git diff 2b86a183..f8e8dc155ae6

没有涉及该 key

→ 不是它的问题。


5️⃣ 第五步:在 GitLab 远端查看(UI)

GitLab Compare 页面必须使用格式:

<http://xxxx/-/compare/><base>...<target>

三个点:

2b86a183...ce3ba83f

如果使用两个点或反向,会失败。

例如:

<http://code-oss.sigenpower.com:8090/sigen_app/sigenmain/-/compare/2b86a183de12...ce3ba83f5dce>

即可在 UI 中看到该 diff。


6️⃣ 为什么 GitLab 看不到删除 diff?

因为:

  • 提交 ce3ba83f 是一个 merge commit
  • GitLab 默认显示 merge commit 的 diff 是对所有 parent 的 combined diff
  • 若包含文件完全覆盖,GitLab UI 会“隐藏”这类大块变更
  • JSON 巨文件会触发 GitLab 的 “cut diff” 行为,不展示全部内容

所以:

本地 diff 能看到删掉整段

GitLab UI 不一定展示

很常见。


7️⃣ 最终确认:这个 commit 确实就是删除的来源吗?

✔️ 是的。

判断依据:

  1. git diff parent..child 直接显示删除 → 100% 明确
  2. 另一个 child 没删除
  3. git log -S 没找到显式删除的记录 → merge 覆盖导致
  4. Git DAG 可证明唯一路径包含这个 child

结论:

删除源头 commit 明确为:

ce3ba83f5dce2bcda26d1d2081d9259c904aa8e7


8️⃣ 总结:如何在非线性历史中定位“被覆盖的改动”

流程简化版:

  1. 查找 key 最后出现的 commit

    git log -S "xxx-key"
    
  2. 找它的 children

    git rev-list --children develop | grep <commit>
    
  3. 对比 parent 与 children

    git diff <parent>..<child>
    
  4. 哪个 child 删除了 key → 问题提交

  5. GitLab Compare 使用:

    ...   (三个点)
    

AI生成CAD图纸(云原生CAD+AI让设计像聊天一样简单)

2025年11月28日 17:20

项目概述

本章节探讨AI技术与在线CAD相结合,能否打造一个能让CAD"听懂人话"的智能助手。

核心价值:告别繁琐的手动绘图,用自然语言就能完成CAD设计。无论是建筑工程师、机械设计师,还是CAD开发者,都能通过AI大幅提升工作效率。

为什么选择MxCAD来做CAD智能系统?

1. 原子化API - AI时代的CAD开发利器

传统CAD软件的问题是:你只能用它给你的功能,比如"画直线"、"画圆"这样的整体功能。但MxCAD的API把所有功能都拆得特别细,就像乐高积木一样:

// 传统方式:只能调用drawCircle()
drawCircle(center, radius);
// MxCAD原子化API:AI可以精确控制每个细节
const center = new McGePoint3d(100, 100, 0);  // 精确控制圆心
const circle = new McDbCircle();              // 创建圆对象
circle.center = center;                       // 设置圆心
circle.radius = 50;                           // 设置半径
circle.trueColor = new McCmColor(255, 0, 0);  // 精确控制颜色
entitys.push(circle);                         // 添加到图纸

这对AI意味着什么?

- AI可以像人类工程师一样思考,理解每个几何元素的含义

- 可以精确控制颜色、图层、线型等所有属性

- 能处理复杂的空间变换和几何计算

- 生成的代码质量更高,更符合工程规范

2. 智能体策略 - 让AI像专业工程师一样思考

我们设计了三种AI智能体,各自负责不同的专业领域:

A.建模智能体(ModelingAgent)

专业领域 :CAD图形创建和迭代修改

工作流程

1. 接收自然语言指令(如"画一个带圆角的矩形,长100宽60,圆角半径5")

2. 分析需求,拆解为几何元素

3. 生成精确的MxCAD代码

4. 在沙箱中预览效果

5. 自动修复可能的错误

6. 最终插入到图纸中

技术亮点

- 支持代码迭代修改:"刚才那个矩形,把圆角改成10"

- 自动管理实体数组,避免重复和遗漏

- 智能错误修复:代码执行失败时自动分析错误并修复

- 最多重试3次,确保成功率  

B.通用智能体(DefaultAgent)

专业领域 :CAD图纸操作和查询

典型任务

- "选中所有长度大于100的直线"

- "把图层"标注"的颜色改成红色"

- "计算这个区域的面积"

- "导出选中的实体为DXF"  

技术亮点

- 理解CAD专业术语和概念

- 能操作图层、线型、标注等CAD特有功能

- 支持复杂的选择条件和过滤

C.意图识别智能体(IntentRecognitionAgent)

角色 :智能调度员

工作原理

1. 关键词匹配:快速识别用户意图(如包含"画"、"创建"等词 → 建模智能体)

2. LLM深度分析:复杂请求调用大语言模型分析

3. 智能路由:自动选择最合适的智能体处理

优势:用户无需手动选择模式,系统智能判断

3.安全沙箱-让AI代码安全运行

为什么需要沙箱?

AI生成的代码可能包含错误、无限循环或恶意代码,直接在主应用中执行可能导致崩溃或数据丢失。

我们的解决方案

用户输入 → AI生成代码 → 沙箱预览 → 错误检测 → 自动修复 → 用户确认 → 插入图纸

技术实现

- 在隔离的iframe中执行代码

- 限制访问DOM和本地存储

- 捕获所有错误信息

- 提供详细的错误堆栈分析

- 支持最多3次自动修复尝试  

用户体验

- 实时预览AI绘图效果

- 确认无误后再插入图纸

- 随时取消或重新生成

- 支持撤销操作

AI功能详解

自然语言到CAD代码转换

示例1:绘制复杂机械零件

用户:画一个法兰盘,外径200,内径1006个直径20的螺栓孔均匀分布
AI生成代码:
// 创建外圆
const outerCircle = new McDbCircle();
outerCircle.center = new McGePoint3d(0, 0, 0);
outerCircle.radius = 100;
entitys.push(outerCircle);
// 创建内圆
const innerCircle = new McDbCircle();
innerCircle.center = new McGePoint3d(0, 0, 0);
innerCircle.radius = 50;
entitys.push(innerCircle);
// 创建6个螺栓孔
for (let i = 0; i < 6; i++) {
  const angle = (i * 60) * Math.PI / 180;
  const hole = new McDbCircle();
  hole.center = new McGePoint3d(75 * Math.cos(angle), 75 * Math.sin(angle), 0);
  hole.radius = 10;
  entitys.push(hole);
}

示例2:建筑平面图

用户:画一个10m×8m的房间,墙厚240mm,门宽900mm在右侧墙中央
AI自动计算所有尺寸,生成符合建筑规范的墙体和门窗

智能代码优化

自动补全import语句

// AI生成的代码可能缺少import
const line = new McDbLine(); // 错误:McDbLine未定义
// 系统自动补全
import { McDbLine, McGePoint3d } from "mxcad";
const line = new McDbLine(); // 正确

管理实体数组

// AI可能忘记将实体添加到图纸
const circle = new McDbCircle();
// 缺少 entitys.push(circle);
// 系统自动检测并添加
const circle = new McDbCircle();
entitys.push(circle); // 自动添加

智能修复语法错误

// AI可能生成有语法错误的代码
const point = new McGePoint3d(0, 0, 0) // 缺少分号
// 系统自动修复
const point = new McGePoint3d(0, 0, 0); // 自动添加分号

多AI模型支持

支持的AI提供商

- OpenRouter:统一接口,支持DeepSeek、Llama、Gemini等100+模型

- OpenAI:GPT-4、GPT-3.5等官方模型

- iFlow:国产大模型,包括通义千问、Kimi、DeepSeek等

- 自定义:支持任何OpenAI兼容的API

模型选择策略

- 免费模型:适合测试和简单任务

- 付费模型:适合复杂任务和高质量要求

- 国产模型:适合数据安全要求高的场景

实际应用场景

场景一:建筑工程师 - 快速绘制标准户型

传统方式

1. 打开CAD软件

2. 选择画线工具

3. 输入起点坐标(0,0)

4. 输入终点坐标(10000,0)  // 10米墙

5. 重复步骤3-4,画4面墙

6. 选择偏移工具,偏移240mm生成内墙线

7. 选择修剪工具,修剪墙角

8. 插入门、窗图块

9. 添加尺寸标注

10. 整个过程约15-30分钟  

AI方式

输入:画一个10m×8m的房间,墙厚240mm,门宽900mm在右侧墙中央,窗宽1500mm在左侧墙中央
AI响应:✅ 已生成标准房间平面图
- 外墙:10m×8m,墙厚240mm
- 门:900mm宽,位于右侧墙中央
- 窗:1500mm宽,位于左侧墙中央
- 已添加尺寸标注
用时:10秒

场景二:机械设计师 - 参数化零件设计

传统方式

- 手动计算所有尺寸

- 逐个绘制每个特征

- 容易出错,修改困难

AI方式

输入:生成一个M10螺栓,长度50mm,头部六角对边16mm

AI响应:✅ 已生成M10螺栓模型

  • 螺纹公称直径:10mm
  • 螺栓长度:50mm
  • 六角头对边宽度:16mm
  • 符合GB/T 5782标准 用时:5秒

场景三:图纸修改-智能批量操作

传统方式

- 手动查找需要修改的元素

- 逐个修改,耗时且容易遗漏

AI方式

输入:把所有标注文字的字体改成仿宋,字高改为3.5mm

AI响应:✅ 已修改23个标注对象

  • 字体:仿宋
  • 字高:3.5mm
  • 修改对象:23个尺寸标注 用时:3秒  

技术架构深度解析

代码执行流程

代码执行流程.png

核心模块说明

1. agents/AgentStrategy.ts

- 智能体策略接口定义

- 智能体实例管理

- 智能体选择逻辑

2. agents/ModelingAgent.ts

- CAD建模专用智能体

- 代码生成与修改

- 错误自动修复

3. agents/IntentRecognitionAgent.ts

- 用户意图识别

- 智能体路由调度

- 对话状态管理

4. core/LLMClient.ts

- 多AI提供商支持

- 请求管理与取消

- 错误处理与重试

5. core/codeModificationUtils.ts

- 代码智能修改

- JSON指令解析

- 语法错误修复

6. sandbox.ts

- 沙箱环境初始化

- 代码安全执行

- 错误信息捕获

7. services/openRouterAPI.ts

- AI模型管理

- API配置管理

- 模型缓存机制  

快速体验AI智能体服务

首先打开demo2.mxdraw3d.com:3000/mxcad/, 如下图: 点击使用AI服务.png 打开AI服务会弹出一个胶囊输入框。我们点击设置按钮,如下图: 打开设置按钮.png 我们需要线配置AI的api接口。这里我们选择iflow AI服务 这是目前国内免费的最佳供应商,如下图: 设置apiKey的弹框.png

具有配置如下:

首先我们打开iflow.cn 登录账号,然后我们鼠标移入头像,找到api管理,如下图: iflow找到api管理设置.png

我们把api key填写到MxCAD AI服务中,如下图: iflow复制apikey.png

选择模型商: iFlow

填写API Key: 刚刚复制的key粘贴在这里, 模型选择: 支持很多模型,都可以,甚至稍微差一些的模型都可以,iFlow目前所有的模型都是免费使用。

然后我们点击“保存”按钮。就可以开始在胶囊输入框内输入你的需求了,比如:一个比较抽象的需求, "画一朵花" 然后按下回车键,如下图: 需求:花一朵花.png 等待一会儿, 就把代码生成出来给你看,并且还有预览效果,如果满意的话点击确认就可以把这朵花插入到图元中了。如果不满意,我们可以继续与AI对话进行修改,如下图: 需求:花一朵花的效果.png 比如现在我们觉得这个花不够精致。我们和AI说, “花不够精致”。然后按下回车键,如下图: 需求:花不够精致.png

需求:花不够精致的效果.png 我们可以不断的让AI修改代码,从而达到一个满意的效果。但要真正投入使用,还需要结合具体的需求调整提示词和整个智能体的流程,以上演示的是建模智能体的能力。而通用智能体的能力,目前主要是用于操作一些实体。

比如:"选中实体按照原本比例放大10倍,间距还是原本的间距" image.png 我们点击生成的代码点击运行,效果就出来了,如下图: image-1.png 还有很多操作,只要是代码可以完成的操作,都可以通过AI配合网页CAD完成。

JavaScript 中的 简单数据类型:Symbol——是JavaScript成熟的标志

作者 栀秋666
2025年11月28日 17:08

深入理解 JavaScript 中的 Symbol:不只是“唯一值”的哲学

在 JavaScript 的八种数据类型中,Symbol 是 ES6 引入的新成员。它不像 numberstring 那样直观,也不像 object 那样复杂多变。它安静地存在于语言底层,却承载着一种独特的“身份标识”使命。很多人初识 Symbol 时,往往只记住一句话:“它是唯一的值”,然后便止步于此。但真正理解 Symbol,需要我们跳出“唯一性”这个标签,去思考它的设计哲学、使用场景以及它如何悄然改变了 JavaScript 对象模型的运作方式。


一、Symbol 的本质:不是“值”,而是“身份”

从技术层面看,Symbol 是一个原始数据类型(primitive type),通过调用全局函数 Symbol() 创建:

const sym1 = Symbol();
const sym2 = Symbol('description');

每次调用 Symbol() 都会返回一个全新且独一无二的 symbol 值,即使参数相同也是如此:

Symbol('foo') === Symbol('foo'); // false

这说明,Symbol 不是在比较内容,而是在比较“身份”。就像世界上没有两片完全相同的雪花,也没有两个相同的 symbol —— 它们生来就是不同的个体。

这种特性使得 Symbol 天然适合作为对象的“私有键”或“元属性键”。但它真正的价值,并不在于“不可重复”,而在于 “不可预见”“不可枚举”


二、Symbol 与对象:一场关于“命名冲突”的救赎

JavaScript 的对象是动态的,我们可以随时添加属性。但在大型项目或多团队协作中,这种灵活性反而成了隐患:不同模块可能无意中使用了相同的属性名,导致覆盖和 bug。

传统做法是加前缀,比如 _privateProp$$internal,但这只是“约定俗成”,无法真正避免冲突。

Symbol 提供了一种语言级别的解决方案

// 模块 A
const cacheKey = Symbol('cache');
class MyClass {
  [cacheKey] = new Map();

  setCache(key, value) {
    this[cacheKey].set(key, value);
  }

  getCache(key) {
    return this[cacheKey].get(key);
  }
}

// 模块 B 即使也创建了一个同名 Symbol,也不会影响模块 A
const anotherCacheKey = Symbol('cache'); // 完全无关

这里的 cacheKey 是一个 symbol,作为对象的 key 使用时,不会被外部轻易访问或覆盖。更重要的是,其他代码即使知道你用了 'cache' 这个描述,也无法构造出相同的 key —— 因为 symbol 的唯一性不由描述决定。

这就是 Symbol 的核心优势:提供一种机制,让开发者可以安全地向对象注入元信息,而不必担心名字污染。


三、Symbol 的“隐身性”:for...in 看不见它

Symbol 作为对象 key 时,默认不会出现在常规的属性枚举中:

const obj = {
  name: 'Alice'
};

obj[Symbol('secret')] = 'hidden';

for (let key in obj) {
  console.log(key); // 只输出 'name'
}

console.log(Object.keys(obj));        // ['name']
console.log(JSON.stringify(obj));     // {"name":"Alice"}

甚至连 JSON.stringify 都会忽略 symbol 属性!这是有意为之的设计 —— 表明 symbol 更像是“元数据”而非“业务数据”。

但如果你真的想获取这些“隐藏钥匙”,JavaScript 也提供了专门的方法:

Object.getOwnPropertySymbols(obj); 
// 返回 [Symbol(secret)]

这就形成了一种有趣的分层结构:

  • for...inObject.keys():面向公众的属性
  • Object.getOwnPropertySymbols():面向内部或特定上下文的元属性

这种分离让我们可以在不干扰公共 API 的前提下,附加调试信息、缓存、状态标记等。


四、Symbol 的高级用法:不仅仅是 key

除了作为对象 key,Symbol 还有一些内置的“知名符号”(Well-Known Symbols),用于定制 JavaScript 对象的行为。这些以 Symbol.xxx 形式存在的属性,其实是语言内部的钩子(hooks)。

1. Symbol.iterator:让对象可迭代

const myCollection = {
  items: ['a', 'b', 'c'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        return index < this.items.length ?
          { value: this.items[index++], done: false } :
          { done: true };
      }
    };
  }
};

for (let item of myCollection) {
  console.log(item); // a, b, c
}

通过实现 Symbol.iterator,普通对象也能被 for...of 遍历。这是 JavaScript 迭代协议的核心。

2. Symbol.toStringTag:自定义 toString 输出

const myObj = {
  [Symbol.toStringTag]: 'MySpecialObject'
};

Object.prototype.toString.call(myObj); 
// "[object MySpecialObject]"

原本所有对象 toString 都是 [object Object],现在你可以让它更具体。

3. Symbol.hasInstance:控制 instanceof 行为

class MyClass {
  static [Symbol.hasInstance](instance) {
    return instance.type === 'myclass';
  }
}

const obj = { type: 'myclass' };
console.log(obj instanceof MyClass); // true!

这打破了 instanceof 必须基于原型链的传统认知,赋予我们更大的控制权。

这些内置 Symbol 表明:Symbol 不只是一个“防重命名工具”,更是 JavaScript 开放其内部机制的一种手段 —— 它把原本封闭的语言行为,变成了可扩展的接口。


五、Symbol 的局限与误解

尽管强大,Symbol 并非银弹。我们需要清醒认识它的边界:

❌ Symbol 不是真正的“私有”

虽然 symbol key 不易被访问,但并非绝对私有:

const sym = Object.getOwnPropertySymbols(obj)[0];
console.log(obj[sym]); // 依然能拿到

如果有人拿到了 symbol 引用,就能访问对应属性。真正的私有应使用 #field(ES2022 私有字段)。

❌ Symbol 不能序列化

如前所述,JSON.stringify 会忽略 symbol 属性。因此不适合用于需要持久化的数据结构。

❌ 全局 symbol?可以用 Symbol.for()

如果确实需要跨文件共享同一个 symbol,可以使用全局注册表:

const s1 = Symbol.for('shared');
const s2 = Symbol.for('shared');
s1 === s2; // true

注意:Symbol.for(key) 是查找或创建,而 Symbol(key) 永远新建。


六、Symbol 的哲学意义:从“命名”到“标识”

回顾编程史,我们一直在与“命名”斗争。变量名、函数名、类名……每一个名字都是一次承诺,也可能是一次妥协。当系统越来越大,命名空间就变得拥挤不堪。

Symbol 的出现,某种程度上是对“命名中心主义”的反叛。它告诉我们:有些东西不需要名字,只需要身份。

就像现实世界中,每个人都有身份证号,但平时我们用名字称呼彼此。Symbol 就是那个身份证号 —— 不常提起,但在关键时候能准确识别“你是谁”。

这也反映了现代编程的一个趋势:从“显式命名”转向“隐式标识”。无论是 React 的 fiber 节点、Vue 的响应式依赖追踪,还是 Redux 的 action type,越来越多的系统开始使用 symbol 来管理内部状态,避免对外暴露过多细节。


七、实战建议:何时该用 Symbol?

结合以上分析,以下是使用 Symbol 的典型场景:

场景 示例
✅ 防止属性名冲突 插件系统中挂载私有状态
✅ 添加元信息 给 DOM 元素附加调试标记
✅ 实现语言协议 让对象支持迭代、转换字符串等
✅ 模拟私有成员 类的内部缓存、配置项
❌ 数据存储 需要 JSON 序列化的字段
❌ 真正的私有 应使用 #private 字段

结语:Symbol 是 JavaScript 成熟的标志

Symbol 看似小众,实则是 JavaScript 走向成熟的重要一步。它不再满足于做一个“脚本语言”,而是开始构建更严谨的抽象能力。

它教会我们:有时候,“看不见”比“看得见”更有力量;“唯一”不仅是技术特性,更是一种设计哲学。

当你下次面对对象属性命名纠结时,不妨问自己一句:

“这个属性,真的需要一个名字吗?”

也许答案是:它只需要一个 Symbol

Konvajs实现虚拟表格

2025年11月28日 17:07

这是一个专栏 从零实现多维表格,此专栏将带你一步步实现一个多维表格,缓慢更新中

本文涉及的代码

虚拟表格

虚拟表格(Virtual Table) 是一种优化技术,用于处理大量数据时的性能问题。它只渲染当前可见区域(视口)内的表格单元格,而不是渲染整个表格的所有数据。

实现原理

一个简单的虚拟表格实现主要包括以下两点(注:一个完善的虚拟表格需要关注的方面更多,这里只讨论核心实现,后续的优化项会在本专栏的后续文章中实现)

  1. 按需渲染:只创建和渲染用户当前能看到的数据行和列
  2. 滚动监听:监听容器滚动事件,动态计算新的可见范围

代码大纲

基于上述原理,我们可以写出如下代码:

import Konva from "konva";
import { Layer } from "konva/lib/Layer";
import { Stage } from "konva/lib/Stage";

export type Column = {
  title: string;
  width: number;
};

type VirtualTableConfig = {
  container: HTMLDivElement;
  columns: Column[];
  dataSource: Record<string, any>[];
};

type Range = { start: number; end: number };

class VirtualTable {
  // =========== 表格基础属性 ===========
  rows: number = 20;
  cols: number = 20;
  columns: Column[];
  stage: Stage;
  layer: Layer;
  dataSource: TableDataSource;

  // =========== 虚拟表格实现 ===========
  // 滚动相关属性
  scrollTop: number = 0;
  scrollLeft: number = 0;
  maxScrollTop: number = 0;
  maxScrollLeft: number = 0;
  visibleRowCount: number = 0;
  // 可见行列范围
  visibleRows: Range = { start: 0, end: 0 };
  visibleCols: Range = { start: 0, end: 0 };
  // 表格可见宽高
  visibleWidth: number;
  visibleHeight: number;

  constructor(config: VirtualTableConfig) {
    const { container, columns, dataSource } = config;
    this.columns = columns;
    this.dataSource = dataSource;
    this.visibleWidth = container.getBoundingClientRect().width;
    this.visibleHeight = container.getBoundingClientRect().height;
    this.visibleRowCount = Math.ceil(this.visibleHeight / ROW_HEIGHT);
    this.maxScrollTop = Math.max(
      0,
      (this.rows - this.visibleRowCount) * ROW_HEIGHT
    );

    // 计算总列宽
    const totalColWidth = this.columns.reduce((sum, col) => sum + col.width, 0);
    this.maxScrollLeft = Math.max(0, totalColWidth - this.visibleWidth);

    this.stage = new Konva.Stage({
      container,
      height: this.visibleHeight,
      width: this.visibleWidth,
    });
    this.layer = new Konva.Layer();
    this.stage.add(this.layer);

    // 监听滚动事件
    this.bindScrollEvent(container);
    // 初始化调用
    this.updateVisibleRange();
    this.renderCells();
  }

  // 监听滚动事件
  bindScrollEvent() {
    this.updateVisibleRange();
    this.renderCells();
  }

  // 计算可见行列范围
  updateVisibleRange() {}

  // 渲染可见范围内的 cell
  renderCells() {}
}

export default VirtualTable;

计算可见行列范围

updateVisibleRange() {
    // 计算可见行
    const startRow = Math.floor(this.scrollTop / ROW_HEIGHT);
    const endRow = Math.min(
      startRow + this.visibleRowCount,
      this.dataSource.length
    );
    this.visibleRows = { start: startRow, end: endRow };

    // 计算可见列
    let accumulatedWidth = 0;
    let startCol = 0;
    let endCol = 0;

    // 计算开始列
    for (let i = 0; i < this.columns.length; i++) {
      const col = this.columns[i];
      if (accumulatedWidth + col.width >= this.scrollLeft) {
        startCol = i;
        break;
      }
      accumulatedWidth += col.width;
    }

    // 计算结束列
    accumulatedWidth = 0;
    for (let i = startCol; i < this.columns.length; i++) {
      const col = this.columns[i];
      accumulatedWidth += col.width;
      if (accumulatedWidth > this.visibleWidth) {
        endCol = i + 1;
        break;
      }
    }

    this.visibleCols = {
      start: startCol,
      end: Math.min(endCol, this.columns.length),
    };
  }

滚动事件监听


  /**
   * 绑定滚动事件
   */
  bindScrollEvent(container: HTMLDivElement) {
    container.addEventListener("wheel", (e) => {
      e.preventDefault();
      this.handleScroll(e.deltaX, e.deltaY);
    });

    // 支持触摸滚动
    let lastTouchY = 0;
    let lastTouchX = 0;
    container.addEventListener("touchstart", (e: TouchEvent) => {
      const touch = e.touches?.[0];
      if (touch) {
        lastTouchY = touch.clientY;
        lastTouchX = touch.clientX;
      }
    });

    container.addEventListener("touchmove", (e: TouchEvent) => {
      const touch = e.touches?.[0];
      if (touch) {
        const deltaY = lastTouchY - touch.clientY;
        const deltaX = lastTouchX - touch.clientX;
        this.handleScroll(deltaX, deltaY);
        lastTouchY = touch.clientY;
        lastTouchX = touch.clientX;
      }
    });
  }

  /**
   * 处理滚动
   */
  handleScroll(deltaX: number, deltaY: number) {
    // 更新滚动位置
    this.scrollTop = Math.max(
      0,
      Math.min(this.scrollTop + deltaY, this.maxScrollTop)
    );
    this.scrollLeft = Math.max(
      0,
      Math.min(this.scrollLeft + deltaX, this.maxScrollLeft)
    );

    // 更新可见行列范围
    this.updateVisibleRange();

    // 更新单元格渲染
    this.renderCells();
  }

单元格渲染逻辑


  /**
   * 获取指定行的 Y 坐标
   * @param rowIndex - 行索引
   * @returns Y 坐标值
   */
  getRowY(rowIndex: number): number {
    return rowIndex * ROW_HEIGHT;
  }

  /**
   * 获取指定列的 X 坐标
   * @param colIndex - 列索引
   * @returns X 坐标值
   */
  getColX(colIndex: number): number {
    let x = 0;
    for (let i = 0; i < colIndex; i++) {
      const col = this.columns[i];
      if (col) {
        x += col.width;
      }
    }
    return x;
  }

  renderCell(rowIndex: number, colIndex: number) {
    const column = this.columns[colIndex];
    if (!column) return;
    // 计算坐标时考虑滚动偏移
    const x = this.getColX(colIndex) - this.scrollLeft;
    const y = this.getRowY(rowIndex) - this.scrollTop;
    // 创建单元格
    const group = new Konva.Group({
      x,
      y,
    });
    const rect = new Konva.Rect({
      x: 0,
      y: 0,
      width: column.width,
      height: ROW_HEIGHT,
      fill: "#FFF",
      stroke: "#ccc",
      strokeWidth: 1,
    });

    // 创建文本
    const text = new Konva.Text({
      x: 8,
      y: 8,
      width: column.width - 16,
      height: 16,
      text: this.dataSource[rowIndex][colIndex],
      fontSize: 14,
      fill: "#000",
      align: "left",
      verticalAlign: "middle",
      ellipsis: true,
    });
    group.add(rect);
    group.add(text);
    this.layer.add(group);
  }

  /**
   * 渲染可见范围内的所有单元格
   * 首先清除旧单元格,然后按行列重新渲染
   */
  renderCells() {
    this.layer.destroyChildren();
    // 渲染数据行
    for (
      let rowIndex = this.visibleRows.start;
      rowIndex <= this.visibleRows.end;
      rowIndex++
    ) {
      for (
        let colIndex = this.visibleCols.start;
        colIndex <= this.visibleCols.end;
        colIndex++
      ) {
        this.renderCell(rowIndex, colIndex);
      }
    }
  }

本文涉及的代码

VDOM 编年史

2025年11月28日 17:05

前言

作为前端开发者,你应该对以下技术演进过程并不陌生:jQuery → MVC → React/Vue(VDOM)→ Svelte/Solid/Qwik(无 VDOM)

每一次技术变迁都伴随着性能瓶颈、设计哲学与工程场景的变化。VDOM 是前端史上最具代表性的技术转折点之一,它改变了 Web 开发方式,同时也带来了新的挑战与发展方向。

本文将讲述一段“VDOM 编年史”:从浏览器渲染瓶颈到 VDOM 的诞生,再到 Diff 算法进化及无 VDOM 的崛起。

VDOM 之前

在 jQuery 时代,更新视图只能直接操作 DOM。然而,频繁的 DOM 操作会带来性能瓶颈,从而导致页面卡顿。

为什么会卡顿

要解释这个问题,我们需要从浏览器渲染引擎的工作原理说起:

浏览器首先通过解析代码分别构建 DOM 和 CSSOM,然后将两者结合生成渲染树。渲染树用于计算页面上所有内容的大小和位置。布局完成后,浏览器才会将像素绘制到屏幕上。其中,布局计算/重排(Layout/Reflow)是渲染过程中最核心的性能瓶颈。

在下面这些情况下会触发重排:

  • 修改元素几何属性(width/height...)
  • 内容变化、添加、删除 DOM
  • 获取布局信息(offsetTop/Left/Width...)

根据影响范围重排又可以分为

  1. 只影响一个元素及其子元素的简单重排
  2. 影响一个子树下所有元素的局部重排
  3. 整个页面都需要重新计算布局的全量重排

性能消耗对比

布局计算时间的简化模型可以表示为:Layout 时间 ≈ 基础开销 + Σ(每个受影响元素的复杂度 × 元素数量)。这里的‘基础开销’指每次触发布局的固定开销。

可以自行通过示例实验配合 Chrome DevTools 的 Performance 面板来验证。

<!-- 测试模板 -->
<div class="container">
  <div class="box" id="target"></div>
  <div class="children">
    <div v-for="n in 100"></div>
  </div>
</div>
// 性能测试方法
function measure(type) {
  const start = performance.now();
  
  switch(type) {
    case 'simple-reflow':
      target.style.width = '300px'; 
      break;
    case 'partial-reflow':
      container.style.padding = '20px';
      break;
    case 'full-reflow':
      document.body.style.fontSize = '16px';
      break;
    case 'repaint':
      target.style.backgroundColor = '#f00';
  }
  
  // 强制同步布局
  void target.offsetHeight; 
  
  return performance.now() - start;
}

还可以参考行业权威数据:Google 的 RAIL 模型(web.dev/rail/)和 BrowserBench(browserbench.org/)等。

对比测试数据,可以得到以下性能消耗的对比结果

类型 影响范围 计算复杂度 典型耗时
简单重排 单个元素 O(1) 1-5ms
局部重排 子树 O(n) 5-15ms
全量重排 全局 O(n) 15-30ms
重绘 无布局变化 O(1) 0.1-1ms

具体测试结果可能存在误差,受以下因素影响:DOM 树复杂度、样式规则复杂度、GPU 加速是否开启,以及硬件设备和浏览器引擎的差异等。

事件循环与渲染阻塞

事件循环是浏览器处理 JS 任务和页面渲染的核心机制,而渲染阻塞则发生在 JS 执行时间过长时,导致页面无法及时更新。 下面是一个典型的性能问题示例:

// 典型性能问题代码
function badPractice() {
  for(let i=0; i<1000; i++) {
    const div = document.createElement('div');
    document.body.appendChild(div); // 每次循环都触发重排
    div.style.width = i + 'px';     // 再次触发重排
  }
}

性能影响过程:

  1. 每次循环触发 2 次重排
  2. 共 2000 次重排操作
  3. 主线程被完全阻塞
  4. 因此页面呈现卡死直至循环结束

性能消耗计算:

  • 每次循环消耗:2 次重排(≈5ms)×2 = 10ms;
  • 1000 次循环:1000 × 10ms = 10000ms;
  • 因此阻塞总时间约 10000ms,对应丢失约600帧(10000/16.67≈600);

结果是用户将体验到约 10 秒的卡顿。

手动优化方案

离线 DOM 操作(DocumentFragment)

将要添加的多个节点先批量添加到 DocumentFragment 中,最后一次性插入页面,有效降低重排频率。

// 优化前:直接操作 DOM
function appendItemsDirectly(items) {
  const container = document.getElementById('list');
  items.forEach(item => {
    const li = document.createElement('li');
    li.textContent = item;
    container.appendChild(li); // 每次添加都触发重排
  });
}

// 优化后:使用 DocumentFragment
function appendItemsOptimized(items) {
  const fragment = document.createDocumentFragment();
  items.forEach(item => {
    const li = document.createElement('li');
    li.textContent = item;
    fragment.appendChild(li);
  });
  
  document.getElementById('list').appendChild(fragment); // 单次重排
}

读写分离

利用浏览器批量更新机制、避免强制同步布局(Forced Synchronous Layout)进而减少布局计算次数

// 错误写法:交替读写布局属性
function badReadWrite() {
  const elements = document.getElementsByClassName('item');
  for(let i=0; i<elements.length; i++) {
    elements[i].style.width = '200px';        // 写操作
    const height = elements[i].offsetHeight;  // 读操作
    elements[i].style.height = height + 'px'; // 再次写操作
  }
}

// 优化写法:批量读写
function goodReadWrite() {
  const elements = document.getElementsByClassName('item');
  const heights = [];
  // 批量读
  for(let i=0; i<elements.length; i++) {
    heights.push(elements[i].offsetHeight);
  }
  // 批量写
  for(let i=0; i<elements.length; i++) {
    elements[i].style.width = '200px';
    elements[i].style.height = heights[i] + 'px';
  }
}

FastDom

FastDOM 是一个轻量级库,它提供公共接口,可将 DOM 的读/写操作捆绑在一起。它将每次测量(measure)和修改(mutate)操作排入不同队列,并利用 requestAnimationFrame 在下一帧统一批处理,从而降低布局压力。

// 使用 FastDOM 库(自动批处理)
function updateAllElements() {
  elements.forEach(el => {
    fastdom.measure(() => {
      const width = calculateWidth();
      const height = calculateHeight();
      
      fastdom.mutate(() => {
        el.style.width = width;
        el.style.height = height;
      });
    });
  });
}

可以参考此示例了解在修改 DOM 宽高时使用 FastDOM 前后的性能对比(wilsonpage.github.io/fastdom/exa…)

通过以上优化,可以大幅缓解渲染压力。但手动控制 DOM 更新不易维护,且在复杂应用中易出错。这时,虚拟 DOM 概念应运而生。

VDOM 时代

2013 年 Facebook 发布了 React 框架,提出了虚拟 DOM 概念,即用 JavaScript 对象模拟真实 DOM。

虚拟 DOM 树

将真实 DOM 抽象为轻量级的 JavaScript 对象(虚拟节点),形成一棵虚拟 DOM 树。

// 虚拟 DOM 节点结构示例
const vNode = {
  type: 'ul',
  props: { className: 'list' },
  children: [
    { type: 'li', props: { key: '1' }, children: 'Item 1' },
    { type: 'li', props: { key: '2' }, children: 'Item 2' }
  ]
};

差异化更新(Diffing)

简单来说,虚拟 DOM 利用 JavaScript 的计算能力来换取对真实 DOM 直接操作的开销。当数据变化时,框架通过比较新旧虚拟 DOM(即执行 Diff)来确定需要更新的部分,然后只更新相应的视图。

虚拟 DOM 的优点

  • 跨平台与抽象:虚拟 DOM 用 JavaScript 对象表示 DOM 树,脱离浏览器实现细节,可映射到浏览器 DOM、原生组件、小程序等,便于服务端渲染 (SSR) 和跨平台渲染。
  • 只更新变化部分:通过对比新旧虚拟 DOM 树并生成补丁 (patch),框架仅对真实 DOM 做必要的最小修改,避免重建整棵 DOM 树。
  • 性能下限有保障:虚拟 DOM 虽然不是最优方案,但比直接操作 DOM 更稳健,在无需手动优化的情况下能提供可预测的性能表现。
  • 简化 DOM 操作:更新逻辑从命令式变为声明式驱动,开发者只需关注数据变化,框架负责高效更新视图,从而大幅提升开发效率。
  • 增强组件化和编译优化能力:虚拟渲染让组件更易抽象和复用,并可结合 AOT 编译,将更多工作移到构建阶段,以减轻运行时开销。这在高频更新场景下效果尤为显著。

Diff算法

算法目标

找出新旧虚拟 DOM 的差异,并以最小代价更新真实 DOM。

基本策略

  • 只比较同级节点,不跨层级移动元素。

<!-- 之前 -->
<div>           <!-- 层级1 -->
  <p>            <!-- 层级2 -->
    <b> aoy </b>   <!-- 层级3 -->   
    <span>diff</span>
  </p> 
</div>

<!-- 之后 -->
<div>            <!-- 层级1 -->
  <p>             <!-- 层级2 -->
      <b> aoy </b>        <!-- 层级3 -->
  </p>
  <span>diff</span>
</div>

由于 Diff 算法只在同层级比较节点,上例中新增的 <span> 在层级 2,而原有 <span> 在层级 3,因此无法直接复用。框架只能删除旧节点并在层级 2 重新创建 <span>。这也导致了预期移动操作无法实现。

  • 使用 Key 标识可复用节点,提高节点匹配准确性。

例如,对于元素序列 a、b、c、d、e(互不相同),若未设置 key,更新时元素 b 会被视为新节点而被重新创建,旧的 b 节点会被删除。

若给每个元素指定唯一 key,则可正确识别并复用对应节点,如下图所示。

  • 当新旧节点类型不同(如标签名不同)时,框架会直接替换整个节点,而非尝试复用。

Diff 算法的演进

简单 Diff 算法

核心逻辑: 对新节点逐一在线性遍历的旧节点中查找可复用节点(sameVNode),找到则 patch,找不到则创建新节点。遍历完成后,旧节点中未被复用的节点将被删除。

缺点: 实现简单但不是最优,对于节点移动操作效率较低,最坏情况时间复杂度为 O(n²)

function simpleDiff(oldChildren, newChildren) {
  let lastIndex = 0
  for (let i = 0; i < newChildren.length; i++) {
    const newVNode = newChildren[i]
    let find = false
    for (let j = 0; j < oldChildren.length; j++) {
      const oldVNode = oldChildren[j]
      if (sameVNode(oldVNode, newVNode)) {
        find = true
        patch(oldVNode, newVNode) // 更新节点
        if (j < lastIndex) {
          // 需要移动节点
          const anchor = oldChildren[j+1]?.el
          insertBefore(parentEl, newVNode.el, anchor)
        } else {
          lastIndex = j
        }
        break
      }
    }
    if (!find) {
      // 新增节点
      const anchor = oldChildren[i]?.el
      createEl(newVNode, parentEl, anchor)
    }
  }
  // 删除旧节点...
}

举个例子:

双端 Diff 算法

在简单 Diff 基础上使用四个指针同时跟踪旧/新列表的头尾(oldStartVnodeoldEndVnodenewStartVnodenewEndVnode),从头尾进行四种快速比较:头-头、尾-尾、旧头-新尾、旧尾-新头。若匹配则执行更新,否则退回线性查找或插入操作。优点:对常见的“头部插入、尾部删除”场景非常高效;缺点:若中间区域节点顺序混乱,仍需遍历查找,可能导致较多 DOM 操作。平均时间复杂度 O(n)

function diff(oldChildren, newChildren) {
  let oldStartIdx = 0
  let oldEndIdx = oldChildren.length - 1
  let newStartIdx = 0
  let newEndIdx = newChildren.length - 1
  
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 四种情况比较
    if (sameVNode(oldChildren[oldStartIdx], newChildren[newStartIdx])) {
      // 情况1:头头相同
      patch(...)
      oldStartIdx++
      newStartIdx++
    } else if (sameVNode(oldChildren[oldEndIdx], newChildren[newEndIdx])) {
      // 情况2:尾尾相同
      patch(...)
      oldEndIdx--
      newEndIdx--
    } else if (sameVNode(oldChildren[oldStartIdx], newChildren[newEndIdx])) {
      // 情况3:旧头新尾
      insertBefore(parentEl, oldStartVNode.el, oldEndVNode.el.nextSibling)
      oldStartIdx++
      newEndIdx--
    } else if (sameVNode(oldChildren[oldEndIdx], newChildren[newStartIdx])) {
      // 情况4:旧尾新头
      insertBefore(parentEl, oldEndVNode.el, oldStartVNode.el)
      oldEndIdx--
      newStartIdx++
    } else {
      // 查找可复用节点...
    }
  }
  // 处理剩余节点...
}

举例:若发现 oldEndVnodenewStartVnode 是同一节点(sameVnode),则说明原列表的尾部节点在新列表中移到了开头。执行 patchVnode 时,会将对应的真实 DOM 节点移动到新列表的开始位置。

快速 Diff 算法

核心思路:

  1. 剥离公共前缀/后缀(prefix/suffix),把问题缩减到中间区。
  2. 为新中间区建立 key 映射,生成旧中间区到新索引的映射数组,同时对可复用节点执行 patch
  3. 对映射数组求 最长递增子序列(LIS),LIS 对应节点保持相对顺序,无需移动。
  4. 从右向左遍历新列表,若当前位置属于 LIS,跳过;否则将节点移动到正确位置或创建新节点。

通过 LIS 标识“一组相对顺序正确”的节点,只移动剩余节点,快速 Diff 在减少 DOM 移动次数方面显著优化了算法。但它需要额外的映射表和辅助数组开销。

快速 Diff 算法的优势在于在中间区大量移动/重排时能显著减少 DOM 移动次数与总时间,但是需要额外内存(映射、mapped、LIS 辅助数组)。整体时间复杂度为 O(nlogn)

function quickDiff(oldChildren, newChildren) {
  // 1. 处理前缀
  let i = 0
  while (i <= oldEnd && i <= newEnd && sameVNode(old[i], new[i])) {
    patch(...)
    i++
  }

  // 2. 处理后缀
  let oldEnd = oldChildren.length - 1
  let newEnd = newChildren.length - 1
  while (oldEnd >= i && newEnd >= i && sameVNode(old[oldEnd], new[newEnd])) {
    patch(...)
    oldEnd--
    newEnd--
  }

  // 3. 处理新增/删除
  if (i > oldEnd && i <= newEnd) {
    // 新增节点...
  } else if (i > newEnd) {
    // 删除节点...
  } else {
    // 4. 复杂情况处理
    const keyIndex = {} // 新节点key映射
    for (let j = i; j <= newEnd; j++) {
      keyIndex[newChildren[j].key] = j
    }

    // 找出最长递增子序列
    const lis = findLIS(...)
    
    // 移动/更新节点
    let lisPtr = lis.length - 1
    for (let j = newEnd; j >= i; j--) {
      if (lis[lisPtr] === j) {
        lisPtr--
      } else {
        // 需要移动节点
        insertBefore(...)
      }
    }
  }
}

VDOM 的挑战

  • 运行时开销: 每次状态更新都要重新构建 VDOM 树并进行 Diff,再更新真实 DOM。在高频小更新场景(如动画帧、复杂列表渲染)下,这些计算开销可能会超过直接操作 DOM 的成本。
  • 渲染冗余: 框架通常通过 shouldComponentUpdatememov-if 等手段减少不必要的更新,但这些本质上是人工干预。组件依赖复杂时,仍可能发生级联更新和不必要的 Diff。
  • 生态割裂: 不同框架的 VDOM 实现和优化策略差异较大,开发者需为不同生态编写特定优化代码,增加了学习和维护成本。
  • 设备压力: 在中低端设备或 WebView 场景,VDOM diff 的 CPU 开销显著,容易成为性能瓶颈。

基于以上原因,近年来出现了多种无虚拟 DOM 解决方案,将更多工作提前到编译时或采用细粒度响应式,以降低运行时成本。

无 VDOM 解决方案

无虚拟 DOM 的核心目标是:在编译期生成精确的 DOM 操作,或者将数据响应切分到最小单元,从而避免常规的 VDOM diff。主要技术路线有:

三条主流技术路线

Svelte(编译期生成精确 DOM 操作)

Svelte 在构建阶段将组件模板编译成直接操作 DOM 的 JavaScript 代码,运行时不再创建 VNode 或进行 Diff。编译器静态分析模板,决定哪些节点是静态,哪些依赖于变量,从而生成最小更新路径。

优点: 运行时开销极低、内存分配少、GC 压力低,首屏和交互延迟很低,适合移动端和首屏优化场景。

缺点: 编译器实现复杂,开发调试时依赖高质量 source map;对于运行时高度动态(如动态生成组件)的场景,需要额外方案支持。

Solid(细粒度响应式)

Solid 使用类似信号(signal)机制,将组件内部表达式拆分为最小依赖单元。数据变化只触发与之直接相关的更新回调,这些回调直接操作 DOM。

优点: 更新几乎零延迟,避免整组件或整树的重新渲染,非常适合高频小更新场景(如实时图表仪表盘)。

缺点: 编程模型与传统 VDOM 框架不同,需要理解信号粒度和副作用清理;在大型项目中需要特别注意内存管理和副作用回收。

Qwik(按需恢复的应用)

Qwik 将应用的状态尽量序列化(或在服务端预渲染时生成可恢复信息),客户端仅在需要时“唤醒”对应组件(按需 hydration)。它推迟或避免了不必要的运行时代价。

优点: 首次加载脚本体积小、交互延迟低,非常适合大页面或低算力设备。

缺点: 需要复杂的序列化/恢复机制,对路由和事件绑定有严格要求,迁移成本较高。

此外,以 Vue 为例的 Vapor/opt-in 编译模式 实际上把 Vue 的模板编译成“直达 DOM 的更新指令”,属于编译期优化思路的一种变体:保留 Vue 的语法与生态,同时在性能关键路径上逼近无 VDOM 性能。

性能比较

  • 内存与 GC:无 VDOM 的运行时分配显著下降(少量短期对象),GC 停顿减少;但编译产物体积可能会略增(生成更多特定更新函数)。
  • CPU 时间:高频更新场景中,无 VDOM 通常显著胜出,因为省去了每帧的树构造与 diff 运算。对于低更新频率的普通页面,差异不明显。
  • 开发与调试体验:调试“直接操作 DOM”生成代码有时不如调试抽象语义直观,因此优秀的 source map 与开发工具对这些框架尤为重要。

总结

VDOM 是一个强大的工程抽象,它把浏览器渲染复杂性封装为可预测的模型,推动了跨平台与组件化生态的发展。 但 VDOM 有真实的成本:对象分配、Diff 计算与可能的 GC 停顿,在高频更新或受限环境会成为瓶颈。 无 VDOM 方案并非魔法,而是通过编译期与细粒度响应式把运行时成本下降到“更接近命令式最优”的路径,适用于性能关键场景。

Bipes项目二次开发/设置功能-1(五)

2025年11月28日 17:02

Bipes项目二次开发/设置功能-1(五)

设置功能,这一期改动有点多,可能后期也会继续出设置功能-n文章,这一期是编程模式,那做目的有两个: 1,代码设计 现在确定做的模式有三种,硬件编程,离线编程,海龟编程三种。每种模式所涉及的代码不同,所以得划分出来。

2,可配置性 后期可能会出一些定制开发,界面就可以通过配置,进行界面调整。

编程模式

html

页面初始内容

<div class="settings-preview">
    <div id="settings-modal">
      <h3>设置</h3>
      <div class="settings-group">
        <label>模式选择</label>
        <div class="radio-group">
          <div class="radio-option">
            <input type="radio" id="mode-hardware" name="programMode" value="hardware" checked>
            <span>硬件编程</span>
          </div>
          <div class="radio-option">
            <input type="radio" id="mode-offline" name="programMode" value="offline">
            <span>离线编程</span>
          </div>
          <div class="radio-option">
            <input type="radio" id="mode-turtle" name="programMode" value="turtle">
            <span>海龟编程</span>
          </div>
        </div>
      </div>
      <div class="modal-actions">
        <button id="cancel-settings" class="btn btn-secondary">取消</button>
        <button id="save-settings" class="btn btn-primary">保存</button>
      </div>
    </div>
  </div>

js

import Common from "./common";

export default class SettingPreview extends Common {
    constructor() {
        super()
        this.state = false // 设置弹窗是否显示
    }
    initEvent() {
        $('#settingsButton').on('click', () => {
            this.changeSetting(!this.state)
        })
        
        // 添加取消按钮事件监听
        $('#cancel-settings').on('click', () => {
            this.changeSetting(false)
        })
        
        // 添加确认按钮事件监听
        $('#save-settings').on('click', this.saveSettings.bind(this))
    }
    changeSetting(state) {
        let status = state ? 'on' : 'off'
        $('.settings-preview').css('visibility', (state ? 'visible' : 'hidden'))
        $('#settingsButton').css('background', `url(../media/new-icon/setting-${status}.png) center / cover`)
        
        if (state) {
            // 显示时可以加载已保存的设置
            this.loadSettings();
        }

        this.state = state
    }
    
    // 保存设置到本地缓存
    saveSettings() {
        // 获取选中的模式
        let selectedMode = 'hardware'; // 默认值
        const selectedRadio = $('input[name="programMode"]:checked');
        if (selectedRadio.length > 0) {
            selectedMode = selectedRadio.val();
        }
        
        // 创建设置对象并保存到本地缓存
        const settings = {
            mode: selectedMode
        };
        
        try {
            localStorage.setItem('settings', JSON.stringify(settings));
            console.log('设置已保存:', settings);
        } catch (error) {
            console.error('保存设置失败:', error);
        }

        this.changeSetting(false)
    }
    
    // 从本地缓存加载设置
    loadSettings() {
        try {
            const savedSettings = localStorage.getItem('settings');
            if (savedSettings) {
                const settings = JSON.parse(savedSettings);
                if (settings.mode) {
                    // 设置选中的单选按钮
                    $(`input[name="programMode"][value="${settings.mode}"]`).prop('checked', true);
                }
            }
        } catch (error) {
            console.error('加载设置失败:', error);
        }
    }
}

界面效果

在这里插入图片描述

总结

出这一期主要针对编程模式,不同模式下做不同功能。 硬件编程:保留原有功能,通过连接板子,与板子通信,在板子上运行编写好的代码,做出不同效果 离线编程:学习,了解编程 海龟编程:学习,了解编程,让编程变得不枯燥。

前端高手进阶:从十万到千万,我的性能优化终极指南(实战篇)

作者 ssjlincgavw
2025年11月28日 16:55

性能优化,一个老生常谈却又常谈常新的话题。它不仅是技术的体现,更是一种用户体验至上的产品思维。很多初学者觉得性能优化就是“减少HTTP请求”、“压缩图片”,但这只是冰山一角。今天,我们就来掀开这座冰山,从宏观到微观,构建一套完整的性能优化体系。

一、 核心指标:我们到底在优化什么?

在开始之前,我们必须明确目标。现代前端性能的核心是 Web Vitals,这是谷歌提出的一套关键性能指标:

  1. LCP:最大内容绘制

    • 目标:  < 2.5秒
    • 意义:  衡量页面的主要内容加载速度。慢?用户会觉得“这个网站卡住了”。
  2. FID:首次输入延迟

    • 目标:  < 100毫秒
    • 意义:  衡量页面的可交互性。点击一个按钮没反应?这就是FID太差。
  3. CLS:累积布局偏移

    • 目标:  < 0.1
    • 意义:  衡量页面的视觉稳定性。突然弹出的图片或广告把阅读中的按钮挤走了?这就是CLS问题。

我们的所有优化,都应该围绕着提升这几项核心指标展开。

二、 网络层优化:速度的“第一公里”

80%的性能问题出在网络层面。

1. 拥抱现代构建工具:Vite > Webpack
为什么你的 npm run dev 要等半天?为什么热更新那么慢?是时候了解一下 Vite 了。它利用浏览器原生 ES 模块,实现了闪电般的冷启动和瞬间热更新。对于大型项目,开发体验的提升是颠覆性的。

javascript

// 你的 vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
  build: {
    // 构建产物更小,加载更快
    minify: 'esbuild',
    // 代码分割策略
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['react', 'react-dom'],
          utils: ['lodash-es', 'dayjs']
        }
      }
    }
  }
})

2. 极致的资源压缩与分发

  • 图片优化是重灾区:

    • 使用现代的 WebP/AVIF 格式,体积比 PNG/JPG 小 30%-70%。
    • 使用 sharp 库在构建时自动压缩图片。
    • 实现响应式图片:<picture> 元素和 srcset 属性,为不同设备提供最合适的图片尺寸。
  • 开启 Gzip/Brotli 压缩:  在服务器(如 Nginx)上开启 Brotli 压缩,压缩率比 Gzip 更高。

  • 利用 HTTP/2 和 CDN:  HTTP/2 的多路复用彻底解决了 HTTP/1.1 的队头阻塞问题。配合全球分布的 CDN,让用户从最近的节点获取资源。

三、 渲染层优化:让每一帧都丝滑

网络资源加载完了,页面为什么还是卡?

1. 代码分割与懒加载
不要把所有代码都打包到一个 bundle.js 里!使用 React.lazy 和 Suspense 实现路由级和组件级的懒加载。

javascript

// 传统方式:首屏就加载所有组件
// import HeavyComponent from './HeavyComponent';

// 优化后:按需加载
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

2. 关键 CSS 与防布局偏移

  • 关键 CSS:  将首屏渲染所必须的 CSS 样式内联到 <head> 中,避免因等待 CSS 文件加载而导致的页面白屏(FOUC)。

  • 防布局偏移:

    • 为图片、视频等元素明确设置 width 和 height 属性。
    • 为广告、嵌入内容预留好空间。
    • 使用 aspect-ratio CSS 属性来维持元素的宽高比。

3. 虚拟列表:海量数据渲染的救星
当你需要渲染成百上千条列表数据时(如表格、聊天记录),直接渲染会导致 DOM 节点过多,页面直接卡死。虚拟列表 技术只渲染可视区域内的元素,性能提升立竿见影。可以使用 react-window 或 vue-virtual-scroller 等库轻松实现。

四、 JavaScript 执行效率:告别卡顿

1. 防抖与节流
滚动、搜索、窗口缩放等高频触发的事件,必须使用防抖或节流。

javascript

// 搜索框防抖
function debounce(func, wait) {
  let timeout;
  return function executedFunction(...args) {
    const later = () => {
      clearTimeout(timeout);
      func(...args);
    };
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
  };
}

const searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce(function(e) {
  // 发起搜索请求
}, 300));

2. 使用 Web Workers 处理重计算
像图像处理、复杂数据排序、语法高亮等CPU密集型任务,会阻塞主线程,导致页面无法响应。把它们丢给 Web Worker 在后台线程执行,解放主线程。

3. 性能监控与持续优化
优化不是一劳永逸的。在生产环境部署性能监控:

  • 使用 PerformanceObserver API 来监听 Web Vitals 指标。
  • 使用 Chrome DevTools 的 Performance 面板 录制并分析运行时性能,找到长任务和性能瓶颈。

总结与展望

性能优化是一条没有尽头的路,它要求我们不仅懂 API,更要懂浏览器原理、网络协议和用户体验。总结一下本文的核心路径:

构建 → 压缩 → 缓存 → 分割 → 懒加载 → 高效渲染 → 持续监控

将这七个步骤融入到你的日常开发习惯中,你就能从一个“功能实现者”蜕变为“体验打造者”。

前端控制批量请求并发

作者 Nayana
2025年11月28日 16:35

最近准备做大文件上传的业务,就联想到前段时间很火的面试题🤦‍♂️ 假如有100条请求,前端如何控制并发。因此在网上看了一些优化相关性能的方案,简单记录一下

不做并发

async upload(Chunks) {
let queue = [...chunks];
 while (i<queue.length) {
       //此方法是自定义的上传异步方法不赘述
let uploadRes = await this.uploadFetch(queue[i])
        if(uploadRes){
        console.log('成功')
        }else{
        // 可写异常 再重试
        //重试优化先不写  queue.push(queue[i])
        }
        i++;
     }
  },

局限

  • 资源利用率差,对于文件片段多的时候全部接口成功等待长时间。

promnise.all分组并发

将请求数组均分为多个小数组,每次最多只开启concurrency个并发请求,以此来控制并发数量。每当一组请求完成后再发送新的一批请求,可以实现对异步任务的并发控制。

        //concurrency 参数为并发数量
      **浏览器并发限制是指浏览器对同一域名同时发起的HTTP请求数量的上限**‌,通常限制在6-8个之间 **
        //Chrome/Firefox:默认6个 假如预留一个空闲位的话concurrency设置为5 
async concurrencyUpload(chunks,concurrency) {
let queue = [...chunks];
while (queue.length > 0) {
const currentChunks = queue.splice(0, concurrency);
 await Promise.all(
      currentChunks.map(async chunk => {
      try {
                //此方法是自定义的上传异步方法
await this.uploadFetch(chunk);
} catch (err) {
// 重试:将分片重新加入队列
                //可优化重试逻辑 控制单任务重试次数
// queue.push(chunk);
}
})
).catch(err => console.error('出错:', err));
}
}

局限

  • 当前批次请求全部完成后才会继续下一批次,可能存在空闲位。
  • 可能返回无序的结果。
  • 有一个请求失败了,这个 Promise.all就失败了,没有返回值。

使用队列

另一种方式是使用一个队列来手动管理并发。你可以创建一个函数来管理并发请求的发送。

class RequestQueue {
  constructor(maxConcurrent) {
    this.maxConcurrent = maxConcurrent;
    this.queue = [];
    this.running = 0;
  }

  enqueue(promiseFn) {
      this.queue.push(promiseFn);
      this.processQueue();
    });
  }

  processQueue() {
    while (this.running < this.maxConcurrent && this.queue.length > 0) {
      const  promiseFn = this.queue.shift();
      this.running++;
      promiseFn()
        .then(()=>{
        //console.log('单任务返回成功')
        })
        .catch(()=>{
        // 重试:将上传任务重新加入队列
         //可优化重试逻辑 控制单任务重试次数
        this.queue.push(promiseFn)
        })
        .finally(() => {
          this.running--;
          this.processQueue(); // Process the next item in the queue
        });
    }
  }
}

// 使用示例
const queue = new RequestQueue(5); // 最大并发5个请求

// 请求函数 uploadFetch 已经是一个promise方法
chunks.forEach(chunk=>{queue.enqueue(this.uploadFetch(chunk))})

用第三方库 p-limit

安装依赖
npm install p-limit
js实现
import pLimit form 'p-limit';

const limit = pLimit(2); // 限制并发数为2
const results = await Promise.all(chunks.map(chunk => limit(() => this.uploadFetch(chunk))));

❌
❌