普通视图
深入理解 JavaScript 词法作用域链:从代码到底层实现机制
ehcarts 实现 饼图扇区间隙+透明外描边
Next.js SEO 优化完整方案
适用于 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. 推荐实践总结
- 优先 SSR 或 SSG 渲染关键内容
- metadata + canonical + sitemap + robots.txt 配置完整
- URL 简短语义化,避免重复
- 使用 next/image、语义化标签和动态加载优化性能
- 配置 OpenGraph 和结构化数据提升社交分享与搜索展示效果
- 国际化站点务必设置语言 alternates
- 定期使用 Lighthouse 或 PageSpeed 检测性能
JavaScript 词法作用域与闭包:从底层原理到实战理解
JS运行机制
词法作用域
“词法”这个词听起来有点抽象,其实它的意思很简单: “词法” = “和你写代码的位置有关” 。
换句话说,JavaScript 中很多行为在你写代码的时候就已经确定了,而不是等到程序运行时才决定。这种特性也叫静态作用域(static scoping)。
你可以这样理解:
代码怎么写的,它就怎么执行——这非常符合我们的直觉。
比如,let 和 const 声明的变量之所以不能在声明前使用(会报“暂时性死区”错误),就是因为它们属于词法环境的一部分。而词法环境正是由你在源代码中的书写位置决定的。你把变量写在哪里,它就在哪里生效,不能“穿越”到还没写到的地方去用——这很合理,也很直观。
所以,“词法”本质上就是:看代码结构,而不是看运行过程。
看一段关于词法作用域的代码
function bar() {
console.log(myName);
}
function foo() {
var myName = '极客邦'
bar()// 运行时
}
var myName = '极客时间'
foo();
这里输出的是
极客时间
为什么输出的不是 "极客邦"?
因为 bar 函数是在全局作用域中声明的,所以它的词法作用域链在定义时就已经固定为:自身作用域 → 全局作用域。
JavaScript 查找变量时,遵循的是词法作用域规则——也就是说,它只关心函数在哪里被定义,而不关心函数在哪里被调用。
当 bar 内部访问变量(比如 test 或 myName)时,引擎会先在 bar 自己的执行上下文中查找;如果找不到,就沿着词法作用域链向外层查找,也就是直接跳到全局作用域,而不会进入 foo 的作用域——尽管 bar 是在 foo 里面被调用的。
因此,bar 根本“看不见” foo 中的 myName = "极客邦",自然也就无法输出它。
![]()
总结:
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。
![]()
换句话说:变量查找看的是函数“在哪里定义”,而不是“在哪里调用” 。这条由内向外的查找路径,就是我们所说的 作用域链。
在 JavaScript 的设计中,每个函数的执行上下文都包含一个内部指针(通常称为 [[Outer]] 或 “outer 引用”),它指向该函数定义时所在的作用域——也就是它的词法外层环境。
当你在代码中嵌套定义多个函数时,每个函数都会通过这个 outer 指针,链接到它上一层的词法环境。这样一层套一层,就形成了一条静态的、由代码结构决定的链式结构,我们称之为 词法作用域链(Lexical Scope Chain) 。
正是这条链,决定了变量查找的路径:从当前作用域开始,沿着 outer 指针逐级向外搜索,直到全局作用域为止。
![]()
这种机制是 JavaScript 闭包、变量访问和作用域行为的核心基础。理解了 outer 指针如何连接各个词法环境,你就真正掌握了词法作用域链的本质。
闭包 ——前面内容的优雅升华
闭包(Closure)是 JavaScript 中一个基于词法作用域的核心机制。掌握它,不仅能写出更灵活、模块化的代码,还能轻松应对面试中的高频问题。下面用通俗易懂的方式,带你彻底搞懂闭包。
一、什么是闭包?
闭包 = 一个函数 + 它定义时所处的词法环境。
换句话说:
当一个函数即使在自己原始作用域之外被调用,仍然能够访问并操作其定义时所在作用域中的变量,这个函数就形成了闭包。
这并不是魔法,而是 JavaScript 词法作用域机制的自然结果。
二、闭包形成的两个必要条件(缺一不可)
- 函数嵌套:内部函数引用了外部函数的变量;
-
内部函数被暴露到外部:比如通过
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没有被任何函数使用 → 被正常回收。
-
📌 重点:闭包不会阻止整个函数上下文销毁,只保留“被引用”的变量。
这既保证了功能,又避免了内存浪费。
![]()
五、闭包的本质与词法作用域的关系
1. 闭包的本质
闭包不是某种特殊语法,而是一种运行时行为:
函数 + 它出生时的词法环境 = 闭包
你可以把它想象成:函数随身带了一个“背包”,里面装着它定义时能访问的所有外部变量。无论它走到哪里(哪怕在全局调用),都能从背包里取用或修改这些数据。
2. 与词法作用域的关联
- 词法作用域:变量的作用域由代码书写位置决定(静态的、编译期确定)。
- 闭包:正是词法作用域在函数被传递到外部后依然生效的体现。
✅ 所以说:闭包不是额外特性,而是词法作用域 + 函数作为一等公民 的必然产物。
💡 记住一句话:
闭包不是“不让变量销毁”,而是“还有人用,所以不能销毁”。
它让 JavaScript 实现了私有状态、模块封装、回调记忆等强大能力。
理解闭包,你就真正迈入了 JavaScript 高阶编程的大门。
日本股票市场渲染 KlineCharts K 线图
下面是针对日本股票市场的完整对接方案,包含从获取股票列表到渲染 KlineCharts K 线图的详细步骤和代码。
核心流程
-
获取日本股票列表:使用
countryId=35查询日本市场的股票,获取目标股票的id(即 PID)。 -
获取 K 线数据:使用该
pid请求历史 K 线数据。 - 绘制图表:将数据转换为 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>
关键点说明
-
CountryId=35 的使用:
-
countryId=35主要用于查询列表 (/stock/stocks) 阶段,用于筛选出日本市场的股票及其对应的 PID。 - 一旦拿到 PID,在请求 K 线数据 (
/stock/kline) 时,只需要 PID,不需要再传 countryId。
-
-
数据映射 (Mapping):
- StockTV 返回的字段是
time,open,high,low,close,volume。 - KlineCharts 要求的字段是
timestamp,open,high,low,close,volume。 - 代码中
timestamp: item.time这一行完成了关键的转换。
- StockTV 返回的字段是
-
周期格式:
- 请确保传给 API 的
interval参数是P1D(日),PT1H(时) 等 ISO8601 格式,否则 API 可能会报错或返回空数据。
- 请确保传给 API 的
Vue2 通用文件在线预览下载组件:一站式解决多类型文件处理需求(支持视频、文档、图片、Office)
在前端开发中,文件预览与下载是高频需求,但不同文件类型(视频、图片、文档、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的寻亲记:作用域与作用域链全解析
黑马喽大闹天宫与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 = 1 → var、a、=、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委员会)看不下去了,派出了let和const两位大神:
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() → 全局
如图
![]()
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 = 全局作用域
如图
⚔️ 第八章:作用域链的实战兵法
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();
}
🏆 大结局:黑马喽的毕业总结
经过这番学习,黑马喽终于明白了作用域的真谛:
🎯 作用域的进化史
-
ES5的混乱:
var无视块级作用域,到处捣乱 -
ES6的秩序:
let/const引入块级作用域和暂时性死区 - outer指针机制:词法作用域在编译时确定,一辈子不变
🧠 作用域链的精髓
- outer指针:函数在编译时就确定了自己的"娘家"
- 词法作用域:看出生地,不是看调用地
- 就近原则:先找自己,再按outer指针找上级
- 闭包的力量:函数永远记得自己出生时的环境
💡 最佳实践心法
// 好的作用域设计就像好的家风
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 组件
项目背景 基础需求: 我这边的需求是要把表格的表头设置为动态的情况、并且允许默写列进行显示隐藏、冻结、排序、默认某几项进行禁止取消勾选。 在这个基础需求上需要兼容按钮权限、以及每一个单元格内的点击事件和toolbar的按钮动态禁止功能
动态表头配置
不同角色、不同场景下,用户对字段的关注点不同,需支持运行时动态调整表头结构,包括列的显示/隐藏、顺序调整、宽度记忆等。 精细化列控制 允许部分业务列(如“备注”“标签”)被用户自由隐藏; 关键列(如首列、操作列、复选框、序号)必须始终可见,禁止在列设置中取消勾选; 支持列冻结(固定左/右)、排序、宽度拖拽等交互能力。 深度交互与权限集成 每个单元格需支持独立点击事件(如跳转详情、编辑内联); 表格工具栏(Toolbar)按钮需按用户权限动态渲染(兼容 v-permission 等指令); 操作列中的按钮同样需支持权限控制与自定义插槽。 配置持久化
用户对表头的个性化设置(如隐藏了哪些列、调整了哪些顺序)应自动保存至服务端,并在下次访问时还原,提升使用体验。
效果图
图片
![]()
![]()
视频效果
版本号
在这里插入代码片
"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)" /> {{ 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:如何排查非线性历史中被隐秘覆盖的修改(完整实战笔记)
在多人协作开发中,尤其是 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:ce3ba83ff8e8dc155ae6
→ 只要找到哪个 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 确实就是删除的来源吗?
✔️ 是的。
判断依据:
-
git diff parent..child直接显示删除 → 100% 明确 - 另一个 child 没删除
-
git log -S没找到显式删除的记录 → merge 覆盖导致 - Git DAG 可证明唯一路径包含这个 child
结论:
删除源头 commit 明确为:
ce3ba83f5dce2bcda26d1d2081d9259c904aa8e7
8️⃣ 总结:如何在非线性历史中定位“被覆盖的改动”
流程简化版:
-
查找 key 最后出现的 commit
git log -S "xxx-key" -
找它的 children
git rev-list --children develop | grep <commit> -
对比 parent 与 children
git diff <parent>..<child> -
哪个 child 删除了 key → 问题提交
-
GitLab Compare 使用:
... (三个点)
AI生成CAD图纸(云原生CAD+AI让设计像聊天一样简单)
项目概述
本章节探讨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,内径100,6个直径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秒
技术架构深度解析
代码执行流程
![]()
核心模块说明
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服务会弹出一个胶囊输入框。我们点击设置按钮,如下图:
我们需要线配置AI的api接口。这里我们选择iflow AI服务 这是目前国内免费的最佳供应商,如下图:
![]()
具有配置如下:
首先我们打开iflow.cn 登录账号,然后我们鼠标移入头像,找到api管理,如下图:
![]()
我们把api key填写到MxCAD AI服务中,如下图:
![]()
选择模型商: iFlow
填写API Key: 刚刚复制的key粘贴在这里, 模型选择: 支持很多模型,都可以,甚至稍微差一些的模型都可以,iFlow目前所有的模型都是免费使用。
然后我们点击“保存”按钮。就可以开始在胶囊输入框内输入你的需求了,比如:一个比较抽象的需求, "画一朵花" 然后按下回车键,如下图:
等待一会儿, 就把代码生成出来给你看,并且还有预览效果,如果满意的话点击确认就可以把这朵花插入到图元中了。如果不满意,我们可以继续与AI对话进行修改,如下图:
比如现在我们觉得这个花不够精致。我们和AI说, “花不够精致”。然后按下回车键,如下图:
![]()
我们可以不断的让AI修改代码,从而达到一个满意的效果。但要真正投入使用,还需要结合具体的需求调整提示词和整个智能体的流程,以上演示的是建模智能体的能力。而通用智能体的能力,目前主要是用于操作一些实体。
比如:"选中实体按照原本比例放大10倍,间距还是原本的间距"
我们点击生成的代码点击运行,效果就出来了,如下图:
还有很多操作,只要是代码可以完成的操作,都可以通过AI配合网页CAD完成。
JavaScript 中的 简单数据类型:Symbol——是JavaScript成熟的标志
深入理解 JavaScript 中的 Symbol:不只是“唯一值”的哲学
在 JavaScript 的八种数据类型中,Symbol 是 ES6 引入的新成员。它不像 number 或 string 那样直观,也不像 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...in、Object.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实现虚拟表格
这是一个专栏 从零实现多维表格,此专栏将带你一步步实现一个多维表格,缓慢更新中
虚拟表格
虚拟表格(Virtual Table) 是一种优化技术,用于处理大量数据时的性能问题。它只渲染当前可见区域(视口)内的表格单元格,而不是渲染整个表格的所有数据。
实现原理
一个简单的虚拟表格实现主要包括以下两点(注:一个完善的虚拟表格需要关注的方面更多,这里只讨论核心实现,后续的优化项会在本专栏的后续文章中实现)
- 按需渲染:只创建和渲染用户当前能看到的数据行和列
- 滚动监听:监听容器滚动事件,动态计算新的可见范围
代码大纲
基于上述原理,我们可以写出如下代码:
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 编年史
前言
作为前端开发者,你应该对以下技术演进过程并不陌生: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...)
根据影响范围重排又可以分为
- 只影响一个元素及其子元素的简单重排
- 影响一个子树下所有元素的局部重排
- 整个页面都需要重新计算布局的全量重排
性能消耗对比
布局计算时间的简化模型可以表示为: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'; // 再次触发重排
}
}
性能影响过程:
- 每次循环触发 2 次重排
- 共 2000 次重排操作
- 主线程被完全阻塞
- 因此页面呈现卡死直至循环结束
性能消耗计算:
- 每次循环消耗: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 基础上使用四个指针同时跟踪旧/新列表的头尾(oldStartVnode、oldEndVnode、newStartVnode、newEndVnode),从头尾进行四种快速比较:头-头、尾-尾、旧头-新尾、旧尾-新头。若匹配则执行更新,否则退回线性查找或插入操作。优点:对常见的“头部插入、尾部删除”场景非常高效;缺点:若中间区域节点顺序混乱,仍需遍历查找,可能导致较多 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 {
// 查找可复用节点...
}
}
// 处理剩余节点...
}
举例:若发现 oldEndVnode 与 newStartVnode 是同一节点(sameVnode),则说明原列表的尾部节点在新列表中移到了开头。执行 patchVnode 时,会将对应的真实 DOM 节点移动到新列表的开始位置。
![]()
![]()
快速 Diff 算法
核心思路:
- 剥离公共前缀/后缀(
prefix/suffix),把问题缩减到中间区。 - 为新中间区建立 key 映射,生成旧中间区到新索引的映射数组,同时对可复用节点执行
patch。 - 对映射数组求 最长递增子序列(LIS),LIS 对应节点保持相对顺序,无需移动。
- 从右向左遍历新列表,若当前位置属于 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 的成本。
- 渲染冗余: 框架通常通过
shouldComponentUpdate、memo、v-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(五)
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);
}
}
}
界面效果
![]()
总结
出这一期主要针对编程模式,不同模式下做不同功能。 硬件编程:保留原有功能,通过连接板子,与板子通信,在板子上运行编写好的代码,做出不同效果 离线编程:学习,了解编程 海龟编程:学习,了解编程,让编程变得不枯燥。
前端高手进阶:从十万到千万,我的性能优化终极指南(实战篇)
性能优化,一个老生常谈却又常谈常新的话题。它不仅是技术的体现,更是一种用户体验至上的产品思维。很多初学者觉得性能优化就是“减少HTTP请求”、“压缩图片”,但这只是冰山一角。今天,我们就来掀开这座冰山,从宏观到微观,构建一套完整的性能优化体系。
一、 核心指标:我们到底在优化什么?
在开始之前,我们必须明确目标。现代前端性能的核心是 Web Vitals,这是谷歌提出的一套关键性能指标:
-
LCP:最大内容绘制
- 目标: < 2.5秒
- 意义: 衡量页面的主要内容加载速度。慢?用户会觉得“这个网站卡住了”。
-
FID:首次输入延迟
- 目标: < 100毫秒
- 意义: 衡量页面的可交互性。点击一个按钮没反应?这就是FID太差。
-
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-ratioCSS 属性来维持元素的宽高比。
- 为图片、视频等元素明确设置
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. 性能监控与持续优化
优化不是一劳永逸的。在生产环境部署性能监控:
-
使用
PerformanceObserverAPI 来监听 Web Vitals 指标。 - 使用 Chrome DevTools 的 Performance 面板 录制并分析运行时性能,找到长任务和性能瓶颈。
总结与展望
性能优化是一条没有尽头的路,它要求我们不仅懂 API,更要懂浏览器原理、网络协议和用户体验。总结一下本文的核心路径:
构建 → 压缩 → 缓存 → 分割 → 懒加载 → 高效渲染 → 持续监控
将这七个步骤融入到你的日常开发习惯中,你就能从一个“功能实现者”蜕变为“体验打造者”。
前端控制批量请求并发
最近准备做大文件上传的业务,就联想到前段时间很火的面试题🤦♂️ 假如有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))));