普通视图

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

HTML代码规范

作者 hypnos_xy
2026年1月30日 11:39

HTML代码规范

缩进

建议缩进4个字符,在现代前端工程化,缩进4个字符比2字符更好的可读性。也不影响最终的结果

DOCTYPE 声明

统一使用HTML5声明<!DOCTYPE html>

meta 标签

  • 编码格式
<meta charset="utf-8"/>
  • SEO优化
<!-- 网页标题 -->
<title>网页标题</title>
<!-- 页面关键词 -->
<meta name ="keywords" content =""/>
<!-- 页面描述 -->
<meta name ="description" content =""/>
<!-- 网页作者 -->
<meta name ="author" content =""/>
  • 优先使用 IE 最新版本和 Chrome
<meta http-equiv="X-UA-Compatible" content="IE = edge,chrome = 1"/>
  • 为移动设备添加视口
<!-- device-width 是指这个设备最理想的 viewport 宽度 -->
<!-- initial-scale=1.0 是指初始化的时候缩放大小是1,也就是不缩放 -->
<!-- user-scalable=no 是指禁止用户进行缩放 yes 允许缩放-->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
  • 禁止自动识别页面中有可能是电话格式的数字
<meta name="format-detection" content="telephone=no"/>

pc端建议

<title>网页标题</title>
<meta charset="utf-8"/>
<meta name="keywords" content="your keywords"/>
<meta name="description" content="your description"/>
<meta name="author" content="author,email address"/>
<meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1"/>

移动端建议

<title>网页标题</title>
<meta charset="utf-8"/>
<meta name="keywords" content="your keywords"/>
<meta name="description" content="your description"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
<meta name="format-detection" content="telephone=no"/>

标签

  • 所有标签都统一使用小写字母
  • 自闭合标签:写法<br/>后面的/不要省略,常用的自闭合标签:
<br/>
<img/>
<input/>
<link/>
<meta/>
<base/>
<source/>
  • 非自闭合标签必须有结束标签,且不要省略:
<html></html>
<body></body>
<head></head>
<script></script>
<style></style>
<iframe></iframe>
<noscript></noscript>
<title></title>
<!-- 普通标签 -->
<h1></h1>
<p></p>
<ul></ul>
<ol></ol>
<li></li>
<option></option>
<div></div>
<span></span>
<a></a>
<form></form>
<label></label>
<button></button>
<textarea></textarea>
<select></select>
<canvas></canvas>
<audio></audio>
<video></video>
<!-- 语义化标签 -->
<header></header>
<footer></footer>
<nav></nav>
<menu></menu>
<main></main>
<article></article>
<section></section>
<aside></aside>
<figure></figure>
<figcatption></figcaption>
<time></time>
<address></address>
  • 尽量减少标签数量:如果设计到div装饰的小物件,尽量使用伪元素实现,而不是先建立一个元素实现
  • vue组件自定义标签: 如果设计到多个单词便签不要使用驼峰法,而是使用-分割。且需要闭合标签
  • 对于<span><a>、等行内元素不要在嵌套其他块级元素。
  • 块元素不要和行内元素并列

转移字符

  1. &nbsp;空格
  2. &lt;小于
  3. &gt;大于
  4. &amp;
  5. &quot;引号

元素属性

  • 元素属性值使用双引号语法
  • 自定义数据属性data-*,使用小写字符,避免使用特殊字符,多单词使用-分割

js 使用

// 获取属性data-user-id="123"
let userId = userDiv.dataset.userId
userId = userDiv.getAttribute('data-user-id');
// 修改
userDiv.dataset.userId = "456";
userDiv.setAttribute('data-user-id', '789');
// 删除属性
delete userDiv.dataset.userRole;
userDiv.removeAttribute('data-user-id');
//访问所有data
const allData = element.dataset; // 返回一个 DOMStringMap 对象

css 使用

[data-role="admin"] {
  background-color: #ffebee;
}
.tooltip::before {
  content: attr(data-tooltip);
  /* 其他样式 */
}

其他规范

  1. img必须(尽量)要使用alt属性
  2. 多个表单组合应该放入form表单内
  3. html 注释必须要单独一行,不能和任何标签同行
<!-- Comment Text -->
<div>...</div>
  1. 单行字数限制120字符比较合适
  2. 多属性建议分行写(属性超过3个建议分行)
<input type="text"
       class="form-control"
       id="exampleInputEmail1"
       placeholder="Enter email"
       data-attribute1="value1"
       data-attribute2="value2"/>

vscode提示找不到名称“Map”的解决方案

作者 南雨北斗
2026年1月30日 11:33

错误提示

“编辑器报错:找不到名称“Map”。是否需要更改目标库? 请尝试将 “lib” 编译器选项更改为“es2015”或更高版本。”

原因:

Map 是 ES2015(ES6)引入的新特性,而 TS 默认的 lib 配置可能只包含 ES5 相关的类型定义(ES5 中没有 Map),因此编译器不认识 Map,从而抛出 “找不到名称‘Map’” 的错误。

解决方案(两种方式,任选其一)

方式 1:修改 tsconfig.json 配置(推荐,全局生效)

这是最规范的做法,通过配置文件指定 TS 编译时使用的库版本:

  1. 找到项目根目录的 tsconfig.json 文件(如果没有,执行 tsc --init 生成);
  2. 找到 compilerOptions 中的 lib 字段,添加 ES2015 或更高版本(如 ES2020),同时保留必要的 DOM(如果涉及浏览器环境);
  3. 保存后重启编辑器(如 VS Code),报错会自动消失。

完整的 tsconfig.json 示例

json

{
  "compilerOptions": {
    "target": "ES2015", // 编译目标版本,建议和 lib 版本匹配
    "lib": ["ES2015", "DOM"], // 核心:添加 ES2015 及以上
    "module": "CommonJS",
    "strict": true, // 开启严格模式,增强类型检查
    "esModuleInterop": true
  }
}
  • lib 字段说明:

    • ES2015:包含 ES6 所有新特性(Map、Set、Promise 等)的类型定义;
    • DOM:包含浏览器环境的类型定义(如 documentwindow),如果是 Node.js 项目可省略。

方式 2:临时指定文件级别的 lib(仅当前文件生效)

如果不想修改全局配置,可在使用 Map 的 TS 文件顶部添加一行注释,强制指定该文件使用的库版本:

typescript

运行

/// <reference lib="es2015" />

// 此时 Map 就能被识别
const map = new Map<string, number>();

验证是否解决问题

修改配置后,编写以下代码测试:

typescript

运行

const map = new Map<string, number>();
map.set("test", 123);
console.log(map.get("test"));
  • 如果编辑器不再报 “找不到名称‘Map’” 的错误,且代码能正常编译(执行 tsc 无报错),说明配置生效。

补充说明

  • target vs lib

    • target:指定 TS 编译后生成的 JS 版本(如 ES5、ES2015);
    • lib:指定 TS 编译时参考的类型定义库版本(决定 TS 认识哪些内置对象 / 方法);
    • 建议 targetlib 版本保持一致(如都设为 ES2015),避免类型和编译结果不匹配。
  • 若项目是 Node.js 环境:除了 ES2015,还可根据 Node 版本选择 ES2020ESNext 等,无需添加 DOM

总结

  1. 报错核心原因:TS 编译器的 lib 配置未包含 ES2015,导致无法识别 Map 类型;
  2. 最优解决方案:在 tsconfig.jsoncompilerOptions.lib 中添加 ES2015(或更高版本);
  3. 关键配置:lib 字段控制 TS 能识别的内置 API 类型,target 控制编译后的 JS 版本,两者建议匹配。

科技爱好者周刊(第 383 期):你是第几级 AI 编程

作者 阮一峰
2026年1月30日 08:10

这里记录每周值得分享的科技内容,周五发布。

本杂志开源,欢迎投稿。另有《谁在招人》服务,发布程序员招聘信息。合作请邮件联系(yifeng.ruan@gmail.com)。

封面图

刚刚建成四川宜宾高铁枢纽门户区,以高铁站为核心,包括8座塔楼、中央公园、数字艺术中心和商业文化街区。(via

你是第几级 AI 编程

史蒂夫·耶格(Steve Yegge)是一个著名的美国程序员。

他在亚马逊和谷歌都干过,但是他出名的不是写软件,而是写博客。

他喜欢在个人网站发布长篇大论,滔滔不绝地议论,直抒胸臆,毫不避讳。他的好多文章都在业内被广泛阅读,引起很大反响。

这些文章后来结集出版,甚至引进了国内,书名就叫《程序员的呐喊》(人民邮电出版社,2014)。

它的书名里面的"呐喊",英文单词是 ranting,直译就是"咆哮",确实就是他的文章风格。

这个月,他又发表了一篇最新文章,谈他对 AI 编程的看法。

他说 AI 编程有8级,他已经到了第8级,也就是最高级。

第1级,还没有接触到 AI 编程,你的 IDE 还是正常的样子(下图)。

第2级,你在 IDE 装了 AI 插件,开启了侧边栏,AI 时不时提出代码建议,问你是否接受(Yes or No)。

第3级,你开始信任 AI 编程,进入了 YOLO 模式("你只活一次"模式, You Only Live Once)。为了节省时间精力,你不再逐条确认 AI 的建议,只要是 AI 生成出来的东西,你就一路按 Yes,统统接受。

第4级,AI 占据的屏幕宽度越来越大,手工编辑的代码区仅用于比对代码差异。

第5级,你索性不要代码区了,改用命令行(比如 Claude Code),所有的屏幕宽度都留给了 AI。你现在不看 AI 的生成结果了,只看它的完成进度。

第6级,你觉得只用一个 AI 太慢,于是打开3到5个窗口,同时进行 AI 编程,加快速度。

第7级,同时打开的 AI 编程窗口到了10个以上,已经是你手工管理的极限了。

第8级,你开始使用 AI 任务编排器,让计算机管理并行的多个 AI 编程。

以上就是 AI 编程的8个级别,你是第几级?

到这里还没完,前面说了,史蒂夫·耶格本人已经到了第8级。他需要工具来管理并行的 AI 编程,但是找不到满意的工具。

于是,他就指挥 AI 写,并将这个工具起名为"煤气镇"(Gas Town)。这个名字来自电影《疯狂麦克斯》(Mad Max)第四部,是里面大反派老乔的老巢。那里到处都是二手零件组成的燃气机,能正常工作,但是看上去摇摇欲坠。

他说,"煤气镇"的开发就是东拼西凑,不考虑合理性,能用就加上去,没抛错就接受。"它有22.5万行 Go 语言代码,我从来没看过它的代码,也从来没想过要看。"

他建议用户不要使用这个工具,因为使用它需要全心全意信任 AI。并且,就算相信它,它也可能把事情搞得一团糟。另外,多个 AI 一起跑,很费钱。

但是,他还是把这个工具放到网上,因为它非常好玩。截至到上周,已经得到了6000颗星。

科技动态

1、牛的智力

一个奥地利农民惊奇地发现,自家的牛会从地上,叼起一根棍子来挠痒。

这个发现令人震惊,因为这表明牛会使用工具,以前从未有人提过。

目前,除了人类之外,只有黑猩猩被发现会使用工具。科学家表示,需要重新认识牛的智力。

2、轨道储能系统

一家美国公司设计出"轨道储能系统",利用山地轨道储藏能量。

电力充足时,索道通过电动机,把重物从山脚运到山顶。

电力不足时,就利用重力势能,让重物顺着轨道从山顶滑到山脚,通过索链带动发电机。

这个系统的优点是简单可靠,成本低,连续使用多年,也不会出现性能衰减。

3、喉部发声贴片

上一期周刊介绍了会说话的围脖,本期还有一个类似的发明。加州大学洛杉矶分校的研究团队发明的喉部贴片,可以让不能说话的病人重新发声。

某些病人由于喉部疾病,无法再发声了,成了哑巴,但是他们的喉部肌肉还能动。

这种贴片贴在病人的喉部,能够感知病人的喉部肌肉运动,并将这种运动转为电信号,发送出去。

计算机收到电信号以后,再转成对应的语音,从而实现发声。

为了将喉部肌肉运动与各种语音对应起来,研究团队使用了机器学习,通过算法将电信号与单词之间实现了关联。

文章

1、2026年的 Linux 音乐播放器(英文)

本文介绍 Linux 系统现在主要的几种音乐播放器。

2、选择性禁用 HTTP/1.0 和 HTTP/1.1(英文)

本文介绍如何设置 nginx,禁止 HTTP/1.0 和 HTTP/1.1 协议,只有白名单里面的客户端可以通过,这杜绝了绝大部分的攻击和爬虫。

3、我扫描了所有的 GitHub "孤儿提交"(英文)

如果你不小心把密码提交到 GitHub,怎么办?你可能会立刻修改代码,强制覆盖上次的提交。

本文告诉你,这样不行。因为 GitHub 不删除任何提交,你上次提交实际上还在。作者扫描了所有 GitHub 的强制提交事件,真发现了许多泄漏的密码,

4、CSS 动画计数器(英文)

本文介绍纯 CSS 动画计数器的各种写法。

5、我的 n8n 用例(英文)

n8n 是一个工作流编排器,可视化生成自动操作脚本。作者介绍了自己的用例:通过聊天软件,将每一笔费用发给 n8n 本地服务器,它会用 AI 进行分类,再将结果存入谷歌表格。

6、2025应该知道的 HTML 新知识(英文)

本文介绍 HTML 的一些新属性和新功能。

7、新的自托管应用推荐(英文)

作者推荐一些他个人喜欢的自托管应用,都相当不错。

工具

1、teemux

一个基于 JS 语言的命令行工具,将多个进程输出的日志放在一处查看,可以命令行查看,也可以浏览器查看。

2、daedalOS

浏览器里面的虚拟桌面环境,代码开源。

3、Dendron

VS Code 的笔记插件,将笔记的层级结构当作目录,并支持图表和内部链接,参见介绍文章

4、CWD(Cloudflare Workers Discuss)

基于 Cloudflare Workers 的网站评论系统。(@anghunk 投稿)

5、Mouse Gestures

开源的 Chrome 浏览器插件,使用鼠标滑动轨迹,完成各种浏览器操作。(@Chance-fyi 投稿)

6、relationship-ts

一个 JS/TS 库,用来计算中国亲戚关系(称谓),Demo 试用。(@ExploringTheCodeWorld 投稿)

7、Deck

macOS 剪贴板管理的开源桌面应用,特点是有 Touch ID 保护和端到端加密。(@yuzeguitarist 投稿)

8、EdgeTunnel (Refactored)

一个部署在 Cloudflare Workers 的隧道方案,代码进行了重构。(@tianrking 投稿)

9、Mail Studio

开源的可视化邮件编辑器,通过拖拽组件,生成响应式邮件模板,试用 Demo。(@wzc520pyfm 投稿)

10、TermClean

macOS 开源应用,在终端界面显示各种软件包占用的磁盘空间,并提供清除软件包功能。(@daijinhai 投稿)

AI 相关

1、ebook2audiobook

电子书转成有声书的工具。

2、WorkAny

开源的 AI Agent 桌面客户端,能够执行任务、操作文件,类似于 Claude Cowork。(@idoubi 投稿)

3、Voice Key

开源的桌面端 AI 语音转文字的工具。(@yexia553 投稿)

4、分镜大师(Storyboard Studio)

开源的 Windows 应用,使用 AI 对视频进行分镜。(@BroderQi 投稿)

资源

1、Claude Code 实战(Claude Code in Action)

Anthropic 官方的 Claude Code 免费入门教程,一共15节视频课,总长约1小时。

2、GitHub 证书

这个网站可以将某个用户2025年的 GitHub 活动,变成一张证书样式的图片。

3、Fontsniff

上传文本图片,自动识别使用了什么字体。(@cosmicqbit 投稿)

4、Future Style Periodic Table

开源的可视化元素周期表,会展示核外电子排布。(@SeanWong17 投稿)

5、nihongo

免费的日语学习平台,有词汇、听力、文章等。(@FrankZhai367 投稿)

图片

1、我不再写代码,而是雕刻代码

我的编码方式发生了变化,现在很少自己写了,都交给 Claude Code 自动完成。

我要做的,就是将 AI 的输出结果打磨成更持久耐用的东西。

AI 几乎从不删除无用代码。如果没有雕塑家,最终只会得到一座臃肿不堪、毫无特色、重得无法站立、也无法讲述故事的雕像。

2、蝴蝶壁画

一位法国艺术家,在世界各地的大楼外立面,绘制栩栩如生的蝴蝶标本壁画,唤起人们对生物多样性的关注。

以下都是真实照片,不是 AI 生成的。

迈阿密

休斯顿

西班牙

纽约

法国

文摘

1、为什么有些公司愿意"黑箱编程"

有些公司已经把编程完全交给了 AI,根本不看代码了,AI 写什么就运行什么。

我把这叫做"黑箱编程",开发过程变成了一个黑箱,根本不需要人类介入,也不欢迎人类介入。它所做的就是把规格参数转换成软件。

我知道,有些小公司就这么干,公司的人数一般不到五个人。虽然这种事情简直难以置信,但很可能就是我们的未来。

我问过一个这样的公司,他们为什么要这么做?

他解释说,作为小公司,他们团队的目标是证明产品的有效性。

人类的作用是设计出一个系统:找到新的模式,帮助 AI 有效工作,证明正在构建的软件产品是稳健有效的。剩下的事情就都交给 AI,这样效率最高。

我认为,这个解释令人信服。

这个公司很小,但在短短几个月内就开发出了可以运行的产品。团队当中有些人拥有超过20年的软件开发经验,曾参与过开发可靠性要求极高的系统,所以他们并非抱着天真无知的心态选择了"黑箱编程"。

我期待着,看到他们拿出最终产品,投入市场的那一刻。

言论

1、

大多数组织习惯于收到系统警报后,直接质问:"是谁刚刚发布了代码变更?" 人们认定合并代码的人肯定了解它的工作原理,并且能够迅速修复问题。

如果你部署的代码既不是某个人写的,也没有人真正理解它,会发生什么?

-- 《二十年的 DevOps 实践》

2、

JavaDoc 之类的工具,可以从代码直接生成文档。我觉得,这种自动生成的文档,价值并不大,未必比直接阅读源代码容易。

没有什么可以替代手写的、有组织的和人工编辑的文档。

-- 《什么是好的文档,以及如何编写》

3、

你学过的、使用过的每种语言和技术,即使会过时,也是有价值的,它们都会让下一种语言或技术更容易学习。

-- 《他们骗了你,开发软件真的很难》

4、

习惯了 AI 编程之后,有一天,我震惊地发现,自己竟然如此轻易地掉进了陷阱。

我已经变得对自己的代码库一无所知,也懒得自己去修复。只要用上了 AI,我就心情愉快,AI 让我感觉自己更聪明、更高效、掌控一切。一旦离开了 AI,我才发现这一切都只是幻觉。

-- 《有了 AI,我变得懒惰和愚蠢》

往年回顾

面对 AI,互联网正在衰落(#336)

蓝色指示灯的解决方案(#286)

中国的阳光地带(#236)

低纬度,高海拔,气候优势(#186)

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2026年1月30日

一看就懂的 Haskell 教程 - 作用域

作者 Anita_Sun
2026年1月30日 11:24

一、核心前提:词法作用域(静态作用域)

核心:绑定的有效范围由代码物理结构(缩进/块)决定,与运行时执行流程无关,看代码就能判断访问范围。

二、三大核心规则

规则1:块级隔离——绑定仅在定义块内有效,外部不可访问

核心:绑定归属明确代码块,块内可用、块外报错,不同块完全隔离。

关键块界定+示例

-- 1. let绑定:仅紧跟的in块有效
circleLet :: Double -> Double
circleLet r = let pi = 3.14  -- 定义块:let
              in 2*pi*r     -- 仅in块可访问pi ✅
-- 直接写pi → 报错(in块外无作用域)❌

-- 2. where绑定:仅紧邻的主表达式有效
factorial :: Int -> Int
factorial 0 = 1  -- 无法访问doubleN ❌(非所属主表达式)
factorial n = n * doubleN * factorial (n-1)
  where doubleN = n*2  -- 仅factorial n的主表达式可访问 ✅

-- 3. 条件分支:独立块,绑定互不访问
ifScope :: Int -> Int
ifScope x = if x>0
            then let y=x*2 in y+3  -- y仅then块有效 ✅
            else 0  -- 无法访问y ❌(else是独立块)

规则2:嵌套遮蔽——内层同名新绑定遮蔽外层,不修改原绑定

核心:内层创同名新绑定→内层优先使用,外层值/作用域不变(仅暂时隐藏),本质是「新绑定」非「修改」。

示例

-- 1. let嵌套遮蔽
scopeDemo :: Int
scopeDemo = let x=10        -- 外层x=10
            in x + let x=20 -- 内层新x=20,遮蔽外层(外层仍为10)
                   in x     -- 内层用x=20 → 结果10+20=30 ✅

-- 2. 函数参数+let遮蔽
shadowDemo :: Int -> Int
shadowDemo x = let x=x+1    -- 新x=原x+1,遮蔽参数x(原x不变)
               in x*2       -- 用内层x → 调用shadowDemo3 → (3+1)*2=8

规则3:全域不可变——绑定一旦定义,值永久固定

核心:无命令式「赋值修改」,全局/局部绑定值始终不变;编译期强制保障,直接修改报错;看似「修改」实则是新绑定遮蔽

示例

-- 1. 直接修改→编译报错(违反不可变性)
invalid :: Int -> Int
invalid x = x = x+1  -- 语法错误 ❌(无赋值操作)

-- 2. 看似修改,实际是多层新绑定遮蔽
validImmu :: Int -> Int
validImmu x = let x=x+1  -- 新绑定x1=原x+1
              in let x=x*2  -- 新绑定x2=x1*2,遮蔽x1
                 in x       -- 用x2 → 调用3 → (3+1)*2=8 ✅
-- 原x=3、x1=4均不变,仅被遮蔽

-- 3. where绑定不可变
circleWhere :: Double -> Double
circleWhere r = c+a
  where pi=3.14; c=2*pi*r; a=pi*r*r  -- pi/c/a值始终固定 ✅

三、let 表达式 vs where 子句

二者均遵循三大规则,仅作用域粒度、嵌套灵活性、使用场景不同,按需选择,示例对比更直观。

特性 let 表达式(表达式级绑定) where 子句(定义级绑定)
书写顺序 先定义绑定,后在in块使用 先写主表达式(用绑定),后定义绑定
作用域范围 仅紧跟的单个in块(细粒度) 仅紧邻的主表达式/函数分支
嵌套灵活性 可嵌套在任意表达式(if/let/where) 仅附着在函数/模式匹配/case
代码结构 需in包裹,多层嵌套有括号 扁平无嵌套,无需额外包裹
核心场景 某段具体表达式创局部绑定 整个函数/单个分支创辅助绑定

对比示例

-- let:嵌套在then分支,仅服务于该段表达式(细粒度)
letDemo :: Int -> Int
letDemo x = if x>0 then let y=x*2 in y+3 else 0  -- y仅then块有效

-- where:服务于整个函数主表达式,结构扁平
whereDemo :: Int -> Int
whereDemo x = res
  where y=x*2; res=if x>0 then y+3 else 0  -- y覆盖整个函数体

-- let嵌套在where内:仅in块有效,where其他绑定不可访问
mixDemo :: Int -> Int
mixDemo x = a + b
  where a=x*2; b=let c=x*3 in a+c  -- c仅b的in块有效 ✅

四、where 嵌套(核心+示例)

核心:内层where仅紧跟单个绑定(值/函数) ,为其提供专属辅助定义,层级隔离、互不干扰。

示例

-- 功能:环形面积(外圆-内圆)+外圆周长
ringCalc :: Double -> Double -> Double
ringCalc r1 r2 = ringArea + outerCir  -- 主表达式
  where
    ringArea = calcArea r1 - calcArea r2  -- 一级绑定
    outerCir = 2 * pi * r1                -- 一级绑定(用外层pi)
    pi = 3.14
    calcArea r = pi' * r * r              -- 二级函数绑定
      where pi' = 3.1415926               -- 内层where:仅calcArea可用 ✅
-- 内层pi'遮蔽外层pi,仅calcArea生效;外层outerCir仍用pi=3.14

五、终极总结(3句话概括所有)

  1. 作用域看结构:词法作用域,代码物理块决定绑定访问范围;
  2. 内外严隔离:块级作用域,绑定仅在定义块内有效,不污染外部;
  3. 值定永不改:全域不可变,嵌套遮蔽是新绑定,非修改原绑定。

let/where 选择一句话原则

  • 某段具体表达式创局部绑定 → 用let(细粒度、灵活);
  • 整个函数/单个模式分支创辅助绑定 → 用where(扁平、突出核心)。

vue2 甘特图 vxe-gantt 一行渲染多个子任务的配置

作者 自由的云h
2026年1月30日 11:21

vue2 甘特图 vxe-gantt 一行渲染多个子任务的配置,但需要在父级任务直接显示所有子任务时,可以通过 task-bar-subview-config 和父级任务设置type= VxeGanttTaskType.Subview 来设置,会自动将所有子任务渲染到父级任务中

gantt.vxeui.com

在这里插入图片描述 设置 task-bar-subview-config.showOverview 设置是否任务总览,当子任务被展开后自动显示任务总览

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script>
import { VxeGanttTaskType } from 'vxe-gantt'

export default {
    data() {
        const ganttOptions = {
              border: true,
  treeConfig: {
    transform: true,
    rowField: 'id',
    parentField: 'parentId'
  },
  taskConfig: {
    startField: 'start',
    endField: 'end',
    typeField: 'type',
    progressField: 'progress'
  },
  taskBarSubviewConfig: {
    showOverview: true 
  },
  taskBarConfig: {
    showContent: true,
    barStyle: {
      round: true
    }
  },
  taskViewConfig: {
    tableStyle: {
      width: 280
    }
  },
  columns: [
    { field: 'title', title: '任务名称', minWidth: 140, treeNode: true },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 },
    { field: 'progress', title: '进度(%)', width: 80 }
  ],
  data: [
    { id: 10001, parentId: null, title: '我的项目1', start: '', end: '', progress: 0, type: VxeGanttTaskType.Subview },
    { id: 10002, parentId: 10001, title: '我的项目2', start: '2024-03-02', end: '2024-03-05', progress: 70 },
    { id: 10003, parentId: null, title: '我的项目3', start: '', end: '', progress: 0, type: VxeGanttTaskType.Subview },
    { id: 10004, parentId: 10003, title: '我的项目4', start: '2024-03-03', end: '2024-03-04', progress: 50 },
    { id: 10005, parentId: 10003, title: '我的项目5', start: '2024-03-05', end: '2024-03-06', progress: 50 },
    { id: 10006, parentId: 10003, title: '我的项目6', start: '2024-03-08', end: '2024-03-11', progress: 60 },
    { id: 10008, parentId: null, title: '我的项目7', start: '', end: '', progress: 0, type: VxeGanttTaskType.Subview },
    { id: 10009, parentId: 10008, title: '我的项目8', start: '2024-03-07', end: '2024-03-09', progress: 50 },
    { id: 10010, parentId: 10008, title: '我的项目9', start: '2024-03-10', end: '2024-03-12', progress: 50 },
    { id: 10011, parentId: 10008, title: '我的项目10', start: '2024-03-13', end: '2024-03-15', progress: 50 }
  ]
        };
        return {
            ganttOptions
        };
    }
};
</script>

gitee.com/x-extends/v…

【配置化 CRUD 02】 useTable Hooks:列表查询、分页封装

作者 大杯咖啡
2026年1月30日 10:53

一:前言

在业务的CRUD开发体系中 ,几乎所有业务模块都需要实现列表查询与分页功能,你是不是经常会看到类似代码:

// 1.每个页面都要单独引入接口 
import { getListApi } from '@/api/user' 

// 2. 定义分页列表参数(每个列表页都要写一遍)
const current = ref(1) 
const pageSize = ref(10) 
const total = ref(0) 
const tableData = ref([]) 
const loading = ref(false)
const searchParams = ref({})

// 3. 重复拼接请求参数(分页+搜索)
const getListData = async () => { 
  loading.value = true 
  try {
  const params = { 
      current: current.value, 
      pageSize: pageSize.value, 
      ...searchParams.value 
     } 
  const res = await getListApi(params) 
  // 重复解析响应数据(每个页面可能还要适配不同后端格式)
  tableData.value = res.data.list 
  total.value = res.data.total 
  }catch (error) { 
     console.error('列表查询失败:', error) } 
  finally { 
    loading.value = false 
  } 
}

// 4. 重复编写分页联动监听
watch([current, pageSize], async () => { await getListData() }, 
{ immediate: true })
...

类似这样的代码,每新增一个列表页就需要几乎完整复制一遍,基于此,我们针对性封装 useTable Hooks,延续配置化 CRUD体系「通用逻辑内聚、业务逻辑外溢」的设计原则,将列表查询请求、分页参数管理、加载状态控制、查询与分页联动等通用逻辑全部抽离,支持自定义请求接口、灵活配置分页参数...,可联动专栏前一期的搜索重置组件,这样在实现列表页时,无需关注底层的查询与分页逻辑,只需传入简单配置,即可快速实现列表查询与分页功能,专注于业务本身的表单配置与数据展示。

文章指路(一起食用,味道更加):

1.动态表单深度实践: juejin.cn/post/757946…

2.搜索重置组件:juejin.cn/post/760056…

二:useTable hooks实现

首先明确useTable Hooks的实现目标,所有代码实现均围绕目标展开:

1.冗余逻辑抽离:将分页参数定义、列表查询请求、分页联动、搜索联动、表格操作等通用逻辑全部内聚;

2.配置化适配:支持通过入参灵活配置请求接口、响应数据格式、分页参数、默认参数等,适配不同业务模块、不同后端接口规范;

3.联动兼容:支持与前一期封装的搜索重置组件联动,同时适配表格组件,提供实例注册、选中行获取、表格列设置等方法,实现全流程联动;

...

接下来我们开始实现:

2.1.useTable Hooks Props类型约束

interface UseTableConfig<T = any> {
    // 请求Url 或函数
    getListApi: string | Function
    // 返回数据格式配置 -- 可指定对应列表和总数的对应字段
    response?: {
        list: string
        total?: string
    }
    // 接收默认参数, 如 pageType:"testPage",不参与响应式更新!
    initParams?: Object 
    // 搜索组件默认参数,注意跟搜索组件初始值保持一致,不参与响应式更新!
    searchInitParams?: Object 
    // 针对table的相关配置
    tableObject?: Object
}

2.2 数据状态:统一维护表格、搜索组件、初始参数等相关状态

// 表格组件实例引用,用于调用表格内部方法
const tableRef = ref(null) 
// 接收搜索组件传递的实时参数,参与响应式更新!
const searchParamObj = ref({}) 
// 表格相关
const tableObjects = ref({
    current: 1,    // 默认当前页为1
    pageSize: 10, // 默认页大小为10
    pageSizeOptions: ['5', '10', '15', '20'], // 默认分页大小可选值
    total: 0, // 数据总数,初始为0
    tableData: [], // 表格渲染数据,初始为空数组
    // 允许外部传入覆盖默认配置
    ...tableObject, 
    loading: false, // 表格加载状态,控制加载动画
})

// 利用`computed`计算属性自动组装请求参数
const parmsObj = computed(() => {
    return {
        current: tableObjects.value.current, // 分页-当前页
        pageSize: tableObjects.value.pageSize, // 分页-页大小
        ...initParams, // 固定参数(不响应式)
        ...searchInitParams, // 搜索默认参数(不响应式)
        ...searchParamObj.value, // 搜索实时参数(响应式)
    }
})

2.3 核心方法:封装查询、表格操作与搜索联动逻辑

2.3.1 register:注册表格ref

// 注册表格ref
const register = (ref: any) => {
   tableRef.value = ref
}
// 获取表格实例
const getTable = () => {
   return tableRef.value
}

2.3.2 getListData : 获取列表数据

const getListData = async () => {
     tableObjects.value.loading = true
     console.log('getListData::列表调用参数:', parmsObj.value)
     try {
           const res = await Http({
                 method,
                 url:getListApi,
                 params:parmsObj.value
            })
           tableObjects.value.total = res[response?.total || 'total']
           tableObjects.value.tableData = res[response?.list || 'list']
           tableObjects.value.loading = false
        } catch (error) {
           message.error(error)
        }
    }

2.3.3 setSearchParams : 搜索联动

const setSearchParams = (data: any, getList = true, isReset = false) => {
        Object.assign(searchParamObj.value, data)
        if (isReset) {
            // 重置
            searchParamObj.value = {}
        }
        // 是否获取数据
        getList && getListData()
    }

2.3.4 setRowChecked : 设置列选择

const setRowChecked = (row: any) => {
     const table = getTable() as any
     table && table.toggleRowSelection(row, true)
}

...

2.4 联动监听:分页参数变化自动触发查询

// 监听当前页变化,自动刷新列表
watch(() => tableObjects.value.current, async () => {
    getListData()
}, { immediate: true, deep: true })

// 监听页大小变化,自动刷新列表
watch(() => tableObjects.value.pageSize, async () => {
    getListData()
}, { immediate: true, deep: true })

2.5 API统一暴露:对外提供简洁易用的调用接口

return {
    // 核心方法集合,供外部调用
    methods: { 
        getListData, // 列表查询
        getTable, // 获取表格实例
        setRowChecked, // 列选择
        setSearchParams, // 设置查询参数
    },
    tableRef, // 表格实例引用(可选暴露,供外部手动操作)
    register, // 表格实例注册方法(表格组件必须调用)
    tableObject: tableObjects.value, // 分页与表格核心状态(响应式,供表格组件绑定)
}

2.6 完整代码

import { ref, watch, computed } from 'vue'
interface UseTableConfig<T = any> {
    getListApi: string | Function
    // 请求方式
    method?: 'get' | 'post'
    //返回数据格式配置 -- 可指定对应列表和总数的取值
    response?: {
        list: string
        total?: string
    }
    initParams?: Object //接收默认参数, 如 pageType:"testPage",不参与响应式更新!
    searchInitParams?: Object //搜索组件默认参数,注意跟搜索组件初始值保持一致,不参与响应式更新!
    tableObject?: Object // 针对table的相关配置
}

export const useTable = (
    config: UseTableConfig
) => {
    const { method = 'get',getListApi, response = {}, initParams = {}, tableObject = {}, searchInitParams = {} } = config
    const tableRef = ref(null)
    // 接收搜索组件传递的实时参数,参与响应式更新!
    const searchParamObj = ref({})
    //表格配置
    const tableObjects = ref({
        current: 1,    // 当前页
        pageSize: 10, // 页限制
        pageSizeOptions: ['5', '10', '15', '20'], // 分页配置
        total: 0, // 总数
        tableData: [], // 表格数据
        ...tableObject,
        loading: false, //表格加载状态
    })
    const parmsObj = computed(() => {
        return {
            current: tableObjects.value.current,
            pageSize: tableObjects.value.pageSize,
            ...initParams, // 固定限制参数
            ...searchInitParams, // 搜索默认参数(不响应式)
            ...searchParamObj.value, // 搜索实时参数(响应式)
        }
    })
    const getListData = async () => {
        tableObjects.value.loading = true
        console.log('getListData::列表调用参数:', parmsObj.value)
        try {
            const res = await Http({
              method,
              url:getListApi,
              params:parmsObj.value
            })
            tableObjects.value.total = res[response?.total || 'total']
            tableObjects.value.tableData = res[response?.list || 'list']
            tableObjects.value.loading = false
        } catch (error) {
            message.error(error)
        }
    }
    //统一拿到table的Ref
    const getTable = () => {
        return tableRef.value
    }
    //获取表格选中的列
    const getSelections = async () => {
        const table = getTable() as any
        return table.getSelectionRows()
    }
    //从外界直接传入的表格列参数
    const setcolumns = (columns: any) => {
        const table = getTable() as any
        table && table?.setcolumns(columns)
    }
    //设置列选择
    const setRowChecked = (row: any) => {
        const table = getTable() as any
        table && table.toggleRowSelection(row, true)
    }
    //注册表格ref
    const register = (ref: any) => {
        tableRef.value = ref
    }
    // 支持从外面设置搜索参数,并查询
    const setSearchParams = (data: any, getList = true, isReset = false) => {
        Object.assign(searchParamObj.value, data)
        if (isReset) {
            searchParamObj.value = {}
        }
        getList && getListData()
    }
    //检测到分页变化 重新获取数据
    watch(() => tableObjects.value.current, async () => {
        getListData()
    },
        { immediate: true, deep: true })
    //检测到分页变化 重新获取数据
    watch(() => tableObjects.value.pageSize, async () => {
        getListData()
    }, { immediate: true, deep: true })

    return {
        methods: {
            getListData,
            getTable,
            getSelections,
            setcolumns,
            setRowChecked,
            setSearchParams,
        },
        tableRef,
        register,
        tableObject: tableObjects.value,
    }
}

三: 实战使用(useTable结合搜索、表格组件)

页面引入Hooks使用:

const { methods, register, tableObject } = useTable({
  getListApi: 'test/list',
  initParams: {
    pageType:"testPage"
  },
  searchInitParams:{
    age:10,
    name:"初始化名字"
  }
})

const { getListData, setSearchParams } = methods

getListData()

搜索组件联动:search和reset方法改造

const searchColumn = [
  {
    label: '姓名',
    prop: 'name',
    initValue: '初始化名字',
    component: 'Input',
    componentProps: {
      onInput: (e: any) => {
         console.log('姓名输入框输入事件', e)
         searchRef.value?.setSchemas([
             {
                  prop: 'age',
                  path: 'componentProps.placeholder',
                  value: `请输入${e}的年龄`
            }
         ])
      }
    }
  },
  {
    label: '年龄',
    prop: 'age',
    component: 'Input',
    initValue: 10,
    formItemProps: {
      rules:[
      {
        required: true,
        message: '请输入年龄',
        trigger: 'blur'
      }
    ]
    }
  },
  {
    label: '上学阶段',
    prop: 'jieduan',
    component: 'Select',
    componentProps: {
        options: [
          {
            label: '幼儿园',
            value: 1
          },
          {
            label: '其他阶段',
            value: 2
          }
        ]
      }
  },
]

// 改动前:
<Search 
   ref="searchRef" 
   :schema="searchColumn" 
   @search="(params) => console.log('点击查询:',{params})" 
   :showReset="false" 
   :isVaildSearch="true"
   > 
</Search>

// 改动后:
<Search
   ref="searchRef"
   :schema="searchColumn"
   @search="setSearchParams"
   :showReset="false" 
   :isVaildSearch="true"
>
</Search>

表格组件支持:提供register方法、及相关props接收

const tablecolumns = [
  {
    label: '姓名',
    prop: 'name',
    minWidth: '140px',
  },
  {
    label: '年龄',
    prop: 'age',
      minWidth: '140px',
  },
  {
     label: '上学阶段',
     prop: 'jieduan',
    minWidth: '140px',
  }
]

 <Comtable
     v-model:current="tableObject.current"
     v-model:pageSize="tableObject.pageSize"
     :rowKey="'id'"
     :columns="tablecolumns"
     :dataList="tableObject.tableData"
     :pagination="tableObject"
     :loading="tableObject.loading"
     @register="register"
   >
      <template #operation="{ row }">
         <el-button
            type="primary"
            size="small"
            link
            @click="editClick(row)"
            >{{$t('Edit')}}</el-button
         >
      </template>
</Comtable>

运行截图:

初始状态:

image.png

输入搜索:

image.png

分页变化:

image.png

以上就是useTable Hooks 的封装过程以及实战案例~

2025-last-week-summary

作者 小文刀696
2026年1月30日 10:51

根据容器宽度超长省略

image.png

async fn 一定会返回一个promise, 它的返回是同步的,状态的改变时异步的

一定需要注意的性能优化和用户体验

  1. 产品,项目,测试和客户都比较注意的一个点就是对于列表的频繁切换,这里一定要用节流,就是在一定时间内的频繁点击,需要节流为一次点击。 不然就会出现快速点击十次,这时候如果再配合接口反应慢,会出现内容响应延迟, 内容跳动的情况。

2.如果遇到许多接口需要来自某一个变化的参数的话,比如列表点击会改变当前选中的行,当前行中存在详情接口,以及若干别的依赖的参数a, 那么就可以基于详情接口是否请求完毕,去做防抖,就是频繁点击详情接口的时候防抖,然后详情接口请求完毕之后在去请求别的接口,这种就不会出现多接口频繁请求混乱的问题

markdown 处理组件


import { Thinking } from "vue-element-plus-x";
import MarkdownIt from "markdown-it";
import MdMermaid from "mermaid-it-markdown";
import mermaid from "mermaid";

前端导出Pdf

import jsPDF from "jspdf";

// 函数:将HTML元素转换为PDF
const exportLoading = ref(false);
const headRef = ref();
const contentRef = ref();

const exportToPDF = () => {
  exportLoading.value = true;
  // 获取预览元素
  const previewElement = document.querySelector("#ai-report-content");
  // 使用html2canvas将预览内容转换为图片
  html2canvas(previewElement, {
    scale: 2, // 提高分辨率
    useCORS: true,
    logging: false
  })
    .then((canvas) => {
      // 创建jsPDF实例
      const pdf = new jsPDF("p", "mm", "a4");
      const ctx = canvas.getContext("2d");
      const a4w = 170;
      const a4h = 250; // A4大小,210mm x 297mm,四边各保留20mm的边距,显示区域170x257
      const imgHeight = Math.floor((a4h * canvas.width) / a4w); // 按A4显示比例换算一页图像的像素高度
      let renderedHeight = 0;

      while (renderedHeight < canvas.height) {
        const page = document.createElement("canvas");
        page.width = canvas.width;
        page.height = Math.min(imgHeight, canvas.height - renderedHeight); // 可能内容不足一页

        // 用getImageData剪裁指定区域,并画到前面创建的canvas对象中
        page
          .getContext("2d")
          .putImageData(
            ctx.getImageData(
              0,
              renderedHeight,
              canvas.width,
              Math.min(imgHeight, canvas.height - renderedHeight)
            ),
            0,
            0
          );
        pdf.addImage(
          page.toDataURL("image/jpeg", 1.0),
          "JPEG",
          20,
          20,
          a4w,
          Math.min(a4h, (a4w * page.height) / page.width)
        ); // 添加图像到页面,保留10mm边距

        renderedHeight += imgHeight;
        if (renderedHeight < canvas.height) {
          pdf.addPage(); // 如果后面还有内容,添加一个空页
        }
        // 预览pdf(这里我用的是事件总线把canvas传递过去展示,达到模拟pdf预览的效果,有用但效果不是很好,有需要的可以自行修改)
        //this.$EventBus.$emit('open-pdf', canvas);
      }

      // 保存PDF
      pdf.save(
        `${get(curCase, "value.case_name") || curCase.value?.police_number}-研判报告.pdf`
      );
    })
    .catch((error) => {
      console.error("生成PDF时出错:", error);
    })
    .finally(() => {
      exportLoading.value = false;
    });
};

Nest 和 Express 是什么关系?

作者 前端付豪
2026年1月30日 10:42

Express 是一个处理请求、响应的库,基础使用

const express = require('express')
const cookieParser = require('cookie-parser')
const cookieValidator = require('./cookieValidator')

const app = express()

async function validateCookies (req, res, next) {
  await cookieValidator(req.cookies)
  next()
}

app.use(cookieParser())

app.use(validateCookies)

app.use((err, req, res, next) => {
  res.status(400).send(err.message)
})

app.listen(3000)

通过 use 一个个中间件来处理请求、返回响应。

这种调用链叫做洋葱模型

image.png

基于中间件能完成各种功能。

但是 Express 只是一个处理请求的库,并没有提供组织代码的架构能力

企业级开发,我们会用对它封装了一层的库,比如 Nest

Nest 提供了 IOC、AOP 等架构特性,规定了代码组织的形式,而且对 websocket、graphql、orm 等各种方案都提供了开箱即用的支持

Node 写服务三个顺序

  • 直接使用 http、https 的模块
  • 使用 express、koa 这种库
  • 使用 Nest 这种企业级框架

Nest 相当于 java 中的 Spring

一、Nest 和 Express 的真实关系

1️⃣ Nest 默认就是用 Express 跑的

const app = await NestFactory.create(AppModule)

这行代码底层等价于:

  • 创建一个 Express 实例
  • Nest 把所有 Controller / Middleware / Guard / Pipe 等
  • 统一挂到 Express 上

Nest ≈ Express + 架构层 + DI + AOP

当然你也可以切换成 Fastify:

NestFactory.create(AppModule, new FastifyAdapter())

二、Nest 在 Express 之上到底封装了什么?

1️⃣ 路由层:从「函数」升级为「类 + 装饰器」

Express 写法

app.get('/users/:id', (req, res) => {
  res.send(req.params.id)
})

Nest 写法(本质还是 Express)

@Controller('users')
export class UserController {
  @Get(':id')
  getUser(@Param('id') id: string) {
    return id
  }
}

Nest 做了什么?

  • 装饰器收集元数据

  • 启动时:

    • 扫描所有 Controller
    • 生成路由映射
    • 最终调用的还是 expressRouter.get(...)

👉 你写的是“声明式路由”,Nest 帮你转成 Express 路由


2️⃣ 中间件体系:Express Middleware + 生命周期顺序

Nest 没有发明新的中间件模型,而是:

  • 完整兼容 Express middleware
  • 但多了一层 执行顺序管理

Nest 的完整请求链路(关键)

Express Middleware
   ↓
Guard(权限)
   ↓
Pipe(参数校验 / 转换)
   ↓
Interceptor(before)
   ↓
Controller
   ↓
Interceptor(after)
   ↓
Exception Filter

Express 里你只能靠「中间件顺序 + 约定」
Nest 里这是 框架级别保证的顺序


3️⃣ DI(依赖注入):Express 完全没有的能力

Express 的痛点

const userService = new UserService()
const orderService = new OrderService(userService)
  • 谁先创建?
  • 单例怎么管?
  • 测试怎么 mock?

Nest:DI 容器统一托管

@Injectable()
export class UserService {}

@Controller()
export class AppController {
  constructor(private readonly userService: UserService) {}
}

Nest 做了什么?

  • 扫描 @Injectable() 元数据
  • 分析 constructor 参数
  • 自动创建依赖图
  • 控制生命周期(singleton / request / transient)

👉 这是 Nest 和 Express 最大的本质差异


4️⃣ AOP 能力(Express 没有)

Nest 的这些东西,在 Express 里你只能“手搓”:

Nest 能力 作用
Guard 权限、登录态
Pipe DTO 校验、类型转换
Interceptor 日志、耗时、缓存
Filter 统一异常处理

而且可以 精确到方法级别

@UseGuards(LoginGuard)
@UseInterceptors(TimeInterceptor)
@Get()
getData() {}

Express:全局 or 路由级 middleware
Nest:方法级 AOP


5️⃣ 模块系统:解决 Express 项目后期失控的问题

Express 项目后期常见问题

  • routes / services 到处 import
  • 依赖关系混乱
  • 拆服务极痛苦

Nest 的 Module 设计

@Module({
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

Nest 在 Express 之上加了:

  • 模块边界
  • provider 可见性控制
  • 子模块隔离

👉 这是为“多人协作 + 大项目”准备的


6️⃣ 底层依然是 Express API

Nest 不封死 Express

import { Request, Response } from 'express'

@Get()
get(@Req() req: Request, @Res() res: Response) {
  res.send('ok')
}

你甚至可以直接用原生 middleware:

app.use((req, res, next) => {
  console.log(req.url)
  next()
})

三、总结

Express 是 HTTP 框架

Nest 是“后端应用架构框架”

Nest 用 Express 负责:

  • 路由
  • 请求 / 响应

Nest 自己负责:

  • 架构
  • 依赖注入
  • 模块化
  • AOP
  • 可测试性
  • 工程规范

四、什么时候该用谁?

用 Express 就够的场景

  • 小工具
  • BFF / 轻接口
  • 一个人维护
  • 生命周期短

用 Nest 更稳的场景(企业级)

  • 中大型后台
  • 多模块、多角色
  • 需要长期维护
  • 对结构、测试、扩展有要求

React-深度解析Diff 算法中Key 的作用

2026年1月30日 10:33

前言

在 React 开发中,我们经常会在控制台看到 Each child in a list should have a unique "key" prop 的警告。Key 到底是什么?它仅仅是一个为了消除警告的“随机字符串”吗?本文将带你从底层原理出发,看透 Key 在 Diff 算法中的核心价值。

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

Key 是 React 用于追踪列表元素身份的唯一辅助标识。它就像是每个 DOM 元素的“身份证号”,让 React 在复杂的更新过程中,能够精准地识别哪些元素被修改、添加或删除。

  • 唯一性:Key 必须在同级元素(Siblings)之间保持唯一。
  • 稳定性:一个元素的 Key 应该在其整个生命周期内保持不变,不建议使用 Math.random() 动态生成。

二、 Key 在 Diff 算法中的作用

React 的 Diff 算法通过 Key 来实现节点复用,这是性能优化的关键:

  1. 匹配新旧元素:当状态更新引发列表变化时,React 会对比新旧两棵虚拟 DOM 树,寻找具有相同 Key 的元素。
  2. 复用现有节点:如果 Key 相同,React 会认为这是同一个组件实例。它会选择复用现有的 DOM 节点和组件状态,仅仅更新发生变化的属性(如 textContent 或 className)。
  3. 减少重绘:由于复用了节点,浏览器不需要执行昂贵的“销毁旧节点 -> 创建新节点”操作,极大提高了更新效率。

三、 实战:Key 的正确用法

在 TSX 中,当我们使用 map 方法渲染列表时,务必在返回的最外层标签上绑定 Key。

import React, { useState } from 'react';

interface Todo {
  id: string; // 唯一标识符
  text: string;
}

const TodoList: React.FC = () => {
  const [todos, setTodos] = useState<Todo[]>([
    { id: '1', text: '学习 React' },
    { id: '2', text: '整理掘金笔记' }
  ]);

  return (
    <ul>
      {/* 这里的 Key 使用数据的唯一 ID */}
      {todos.map((todo) => (
        <li key={todo.id}>
          {todo.text}
          <input type="checkbox" />
        </li>
      ))}
    </ul>
  );
};

export default TodoList;

四、 注意事项:为什么不能盲目使用 Index 作为 Key?

很多新手喜欢直接用数组的 index 作为 Key,但在逆序添加、删除或排序列表时,这会导致严重的性能问题和 UI Bug。

1. 性能降低

假设你在列表头部插入一条数据,原来的 index 0 变成了 index 1。React 会发现 Key 对应的“数据”变了,从而导致原本可以复用的节点全部被迫重新渲染(Re-render)。

2. 状态错位 Bug

如果列表项中包含非受控组件(如 <input />),使用 Index 作为 Key 会导致输入框内容“串位”。因为 React 认为 Key 没变,就复用了旧的 Input 节点及其内部的本地状态。


五、 总结与最佳实践

  • 首选方案:使用来自数据库的唯一 ID(如 UUID 或主键 ID)。
  • 备选方案:如果数据确实是静态的(永远不会排序、过滤、增删),且没有唯一 ID,可以使用 Index。
  • 禁忌:绝对不要在渲染时使用 Math.random()Date.now() 生成 Key。这会导致每次渲染 Key 都不同,React 将无法复用任何节点,造成巨大的性能浪费。

uni.request 二次封装

2026年1月30日 10:30

基于uni.request api进行二次封装

核心功能

  • 响应内容格式和请求参数格式类型定义
  • 请求拦截器与响应拦截器配置
  • 设置请求头和params参数处理
  • 加载提示与自定义提示文本
  • 错误统一处理
  • 接口缓存
  • 取消请求功能
  • 失败自动重试机制
  • 并发请求控制

核心代码

// 响应内容格式
export interface InResult<T> {
  code: number | string
  message: string
  success: boolean
# data: T
}
// 请求参数格式
interface RequestOptions {
  baseURL?: string
  url: string
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
  data?: Record<string, any>
  params?: Record<string, any>
  header?: Record<string, string>
  loading?: boolean // 是否显示加载中提示
  loadingText?: string // 加载中提示文本
  removeToken?: boolean // 是否移除token
  cache?: boolean // 是否缓存响应结果
  returnResponse?: boolean // 直接返回响应
}

// 设置请求头
function setRequestHeaders(url: string) {
  const LOGIN_URL = '/auth/oauth2/token'
  if (url.includes(LOGIN_URL)) {
    return { Authorization: `Basic Z2VveHNwYWNlOmdlb3hzcGFjZQ==` }
  }
  const token = uni.getStorageSync('token')
  if (token) {
    return { Authorization: `Bearer ${token}` }
  }
  return {} as Record<string, string>
}
// 请求和响应拦截器
function requestAndResponseInterceptor() {
  uni.addInterceptor('request', {
    // 调用前置拦截器
    invoke(options: RequestOptions) {
      if (options.loading) {
        uni.showLoading({
          title: options.loadingText || '加载中...',
          mask: true,
        })
      }

      options.header = {
        ...options.header,
        ...setRequestHeaders(options.url),
      }

      // 移除token
      if (options.removeToken) {
        delete options.header.Authorization
      }

      // 处理params 参数
      if (options.params) {
        const urlPrams: string[] = []
        Object.keys(options.params).forEach((key) => {
          urlPrams.push(`${key}=${options.params![key]}`)
        })
        if (options.url.includes('?')) {
          options.url += urlPrams.join('&')
        }
        else {
          options.url += `?${urlPrams.join('&')}`
        }
      }
      return options
    },

    // 调用后置拦截器
    success(res) {
      return res
    },
    fail(err) {
      uni.showToast({
        title: '网络请求失败',
        icon: 'none',
      })
      return Promise.reject(err)
    },

    complete(option) {
      console.log('option.errMsg', option.errMsg)
      setTimeout(() => {
        uni.hideLoading()
      }, 15000)
    },
  })
}

// 调用请求拦截器和响应拦截器
requestAndResponseInterceptor()

const cacheMap = new Map<string, any>()
// 封装网络请求
export async function request(options: RequestOptions): Promise<any> {
  const { baseURL, url, header = {}, cache, returnResponse } = options
  // 合并配置
  const config = {
    ...options,
    url: url.startsWith('http') ? options.url : baseURL + options.url,
    header: {
      'Content-Type': 'application/json',
      ...header, // 允许自定义header
    },
    timeout: 10000, // 超时时间(ms)
  }

  if (cache) {
    if (cacheMap.has(url)) {
      return cacheMap.get(url)
    }
  }

  try {
    const response = await uni.request(config)

    if (options.loading) {
      uni.hideLoading()
    }
    // 响应拦截器
    if (response.statusCode === 200) {
      const data = returnResponse ? response : response.data
      if (cache) {
        cacheMap.set(url, data)
      }

      // @ts-expect-error 判断异常
      if (response.data.code !== '20000') {
        // @ts-expect-error 判断异常
        toast(response.data.msg, {
          icon: 'fail',
        })
      }

      return data
    }
    if (response.statusCode === 401) {
      toast('登录已过期,请重新登录!', {}, () => {
        uni.redirectTo({
          url: '/user/login/index',
        })
      })
    }
    else {
      toast('系统服务异常!')
    }
  }
  catch (error) {
    return Promise.reject(error)
  }
}

toast 封装代码

export function toast(title: string, options?: { duration?: number, icon?: 'success' | 'loading' | 'error' | 'none' | 'fail' | 'exception', mask?: boolean }, callback?: () => void) {
  const { mask = true, duration = 1000, icon = 'none' } = options || {}

  if (title && title.length > 14) {  // 当作字符长度>14时使用showModal展示
    uni.showModal({
      content: title,
      showCancel: false,
      success() {
        if (callback && typeof callback === 'function') {
          const timer = setTimeout(() => {
            callback()
            clearTimeout(timer)
          }, duration)
        }
      },
    })
  }
  else {
    uni.showToast({
      title,
      message: title,
      icon,
      mask,
      duration,
      success() {
        if (callback && typeof callback === 'function') {
          const timer = setTimeout(() => {
            callback()
            clearTimeout(timer)
          }, duration)
        }
      },
    })
  }
}

使用示例

import { type InResult, request } from '@/utils/request'

export interface IUser {
    id: string,
    name: string,
    age?: number
}

export function getUserList(data: any): Promise<InResult<{ records: Array<IUser>, total: number }>> {
  return request({
    baseURL,
    url: '/user/page',
    method: 'POST',
    params: data,
    loading: true,
  })
}

一篇文章搞懂响应式布局:从媒体查询到 Bootstrap 栅格系统

作者 YukiMori23
2026年1月30日 09:54

在移动端成为主流的今天,「一套页面适配多种设备」已经是前端的基本能力。
这篇文章是我系统学习**响应式布局(Responsive Web Design)**后的阶段性总结,涵盖:

  • 响应式布局的核心思想
  • 媒体查询的使用方式
  • Bootstrap 框架与栅格系统
  • 实战中的布局策略与技术选型

如果你正在从 PC 端迈向移动端,这篇文章会非常有帮助。


一、什么是响应式布局?

响应式布局的本质只有一句话:

根据不同屏幕尺寸,动态调整页面的布局和样式,让同一套 HTML 在不同设备上都有良好的显示效果。

常见适配设备包括:

  • 手机
  • 平板
  • 笔记本
  • 桌面显示器

响应式布局的核心技术是:CSS 媒体查询(Media Query)


二、响应式布局的核心原理

1. 设备尺寸的常见划分

在实际开发中,通常会按以下区间来划分设备宽度:

  • 超小屏幕(手机):小于 768px
  • 小屏设备(平板):768px ~ 991px
  • 中等屏幕(桌面):992px ~ 1199px
  • 大屏设备(大屏显示器):1200px 及以上

注意:
这些不是死规定,而是业界常用参考,实际项目可以按设计稿自行调整


2. 响应式布局容器的思想

响应式页面通常会有一个最外层布局容器,用于控制整体宽度。

典型思路是:

  • 小屏设备:容器宽度 100%
  • 中屏设备:固定宽度(如 970px)
  • 大屏设备:更大的固定宽度(如 1170px)

通过媒体查询,在不同屏幕下修改容器宽度,从而影响内部布局。

示例:

.container {
  width: 100%;
  margin: 0 auto;
}

@media (min-width: 768px) {
  .container {
    width: 750px;
  }
}

@media (min-width: 992px) {
  .container {
    width: 970px;
  }
}

@media (min-width: 1200px) {
  .container {
    width: 1170px;
  }
}

三、响应式布局实战:导航栏案例思路

假设有一个导航栏,包含 8 个菜单项,需求如下:

  • 屏幕 ≥ 800px:

    • 导航栏宽度 800px
    • 每个菜单项固定宽度,一行显示
  • 屏幕 < 800px:

    • 导航栏宽度 100%
    • 每行显示 3 个菜单项,自动换行

实现关键点:

  • 外层容器负责宽度变化
  • 子元素通过百分比宽度适配
  • 使用媒体查询切换布局规则

这种「一套结构,多套样式」的思想,是响应式布局的核心。


四、为什么要使用 Bootstrap?

1. Bootstrap 是什么?

Bootstrap 是由 Twitter 推出的前端开发框架,提供了一整套:

  • 规范化的 CSS 样式
  • 成熟的响应式布局方案
  • 丰富的 UI 组件

可以把它理解为:
已经帮你写好的响应式解决方案集合


2. 使用 Bootstrap 的好处

  • 开发速度快
  • 样式统一、规范
  • 天然支持响应式布局
  • 社区成熟,文档齐全

特别适合:

  • 初学响应式布局
  • 快速搭建页面
  • 中后台或展示型项目

五、Bootstrap 栅格系统详解(重点)

1. 栅格系统的核心概念

Bootstrap 将页面横向划分为 12 等份,通过列数控制布局。

常见类前缀:

  • .col-xs-*:超小屏(手机)
  • .col-sm-*:小屏(平板)
  • .col-md-*:中屏(桌面)
  • .col-lg-*:大屏(大屏显示器)

示例:

<div class="row">
  <div class="col-md-4 col-sm-6">内容</div>
  <div class="col-md-8 col-sm-6">内容</div>
</div>

含义是:

  • 中屏以上:4 + 8
  • 小屏:6 + 6

2. 行(row)与列(column)

几点非常重要的规则:

  • .row 用来包裹列
  • .row 会抵消容器左右 15px 的 padding
  • 每一列默认有左右 15px 内边距
  • 一行的列数加起来不能超过 12

3. 列嵌套

列中可以继续使用 .row.col-*,实现更复杂的布局:

<div class="col-sm-4">
  <div class="row">
    <div class="col-sm-6">小列</div>
    <div class="col-sm-6">小列</div>
  </div>
</div>

4. 列偏移与排序

偏移列:

<div class="col-md-4 col-md-offset-4"></div>

排序列:

<div class="col-md-4 col-md-push-8"></div>
<div class="col-md-8 col-md-pull-4"></div>

在不改 HTML 结构的情况下,就能调整显示顺序,非常适合响应式场景。


六、响应式工具类的使用

Bootstrap 提供了一些工具类,用于控制元素显示与隐藏:

  • .hidden-xs
  • .hidden-sm
  • .hidden-md
  • .hidden-lg

可以非常方便地为不同设备定制内容展示策略。


七、真实项目中的布局策略总结

在实际开发中,我总结出一套比较稳妥的流程:

  1. 先写 PC 端(md 及以上)布局
  2. 再针对小屏、超小屏做调整
  3. 栅格系统优先,其次再写自定义样式
  4. 不追求「完全一样」,而是「体验合理」

八、移动端布局方案对比

常见方案包括:

  • 流式布局(百分比)
  • Flex 弹性布局(推荐)
  • rem 适配布局(推荐)
  • 响应式布局(媒体查询)

实际项目中,往往是多种方案混合使用,而不是只选一种。


九、总结

响应式布局并不是某一个 CSS 属性,而是一整套设计思想:

  • 一套 HTML
  • 多套 CSS
  • 根据设备动态切换布局规则

Bootstrap 帮我们降低了学习和使用门槛,但真正重要的,是理解:

屏幕变化 → 容器变化 → 子元素重新排列

理解了这一点,响应式布局就不再神秘了。

React-深度拆解 React Render 机制

2026年1月30日 09:49

前言

在 React 中,我们常说“渲染(Render)”,但它不仅仅是将 HTML 丢给浏览器那么简单。Render 是一个包含 计算(Reconciliation)提交(Commit) 的复杂过程。理解这一过程,能帮助我们写出更高性能的代码。

一、 Render 的核心三部曲

当 React 决定更新界面时,会经历以下三个关键阶段:

1. 创建虚拟 DOM (Virtual DOM)

JSX 本质上是 React.createElement() 的语法糖。Babel 会将 JSX 编译为 JS 调用,生成一个描述 UI 的对象树(即虚拟 DOM)。

结构定义参考:

// 编译后的逻辑(简化版)
const vDom = {
  type: 'div',
  props: {
    className: 'active',
    children: 'Hello'
  }
};

2. Diff 算法比较 (Reconciliation)

React 并不会盲目替换整个 DOM,而是通过 Diff 算法 对比“新旧两棵虚拟 DOM 树”。

  • 同层比较:只比较同一层级的节点。
  • 类型检查:如果节点类型变了(如 divp),则直接销毁重建。
  • Key 值优化:通过 key 属性识别节点是否只是移动了位置。

3. 渲染真实 DOM (Commit)

在计算出最小差异(Patches)后,React 的渲染器(如 react-dom)会将这些变更同步到真实浏览器环境,触发重绘与回流,使用户看到更新。


二、 触发渲染的四大时机

在函数式组件中,Render 过程可能由以下四种情况触发:

触发场景 描述
首次渲染 应用启动,将组件树完整挂载到页面上。
State 改变 当调用 useStateset 函数或 useReducerdispatch 时。
Props 改变 父组件重新渲染导致传给子组件的属性发生变化。
Context 改变 组件通过 useContext 订阅了上下文,且 Providervalue 发生变更。

三、 实战演示:观测渲染行为

我们可以通过简单的日志输出,来观察不同场景下的渲染行为。

import React, { useState, useContext, createContext } from 'react';

// 创建 Context
const AppContext = createContext(0);

// 子组件
const Child: React.FC<{ count: number }> = ({ count }) => {
  console.log("子组件 Render...");
  return <div>父级传入的 Props: {count}</div>;
};

// 顶层组件
const Home: React.FC = () => {
  const [num, setNum] = useState<number>(0);
  const [other, setOther] = useState<boolean>(false);

  console.log("Home 组件 Render...");

  return (
    <AppContext.Provider value={num}>
      <div style={{ padding: '20px' }}>
        <h2>Render 触发测试</h2>
        
        {/* 1. 修改 State 触发 */}
        <button onClick={() => setNum(prev => prev + 1)}>
          修改 State (Count: {num})
        </button>

        {/* 2. 这里的修改虽然没传给 Child,但父组件重新渲染会导致 Child 也重新渲染 */}
        <button onClick={() => setOther(!other)}>
          无关渲染测试: {String(other)}
        </button>

        {/* 3. Props 改变触发子组件渲染 */}
        <Child count={num} />
      </div>
    </AppContext.Provider>
  );
};

export default Home;

从打包到优化|Vue3 可 Tree-shaking API 分类与避坑要点

作者 boooooooom
2026年1月30日 09:44

在 Vue3 的性能优化体系中,除了静态提升、PatchFlag、Block Tree 等渲染层面的优化,Tree-shaking(摇树优化)是构建层面的核心手段——它能自动移除项目中未被使用的代码,大幅缩减打包体积,尤其对中小型项目而言,体积优化效果可达 30% 以上。不同于 Vue2 全局引入导致大量冗余代码的问题,Vue3 从架构设计上原生支持 Tree-shaking,核心 API 均采用“按需引入”模式。本文将聚焦 Tree-shaking 在 Vue3 中的具体体现,重点梳理哪些 API 可被摇树、分类说明适用场景,同时拆解其底层实现逻辑与生效注意事项,完善 Vue3 优化知识体系。

一、先理清:Tree-shaking 是什么?Vue3 为何能原生支持?

Tree-shaking 本质是“消除死代码”的打包优化技术,依赖 ES6 模块的 import/export 语法(静态模块解析)——打包工具(Webpack、Vite、Rollup)能在编译阶段分析模块依赖,识别出未被引用的代码,将其从最终打包产物中移除。

Vue2 无法很好地支持 Tree-shaking,核心原因是其 API 多为全局挂载(如 Vue.componentVue.directive),即使未使用,也会被打包进产物;而 Vue3 彻底重构了 API 架构,采用“模块化导出”模式,所有 API 均通过 ES6 模块单独导出,未被引用的 API 可被打包工具精准识别并摇掉,实现“用多少、打包多少”。


// Vue2 全局挂载(无法 Tree-shaking)
import Vue from 'vue'
Vue.component('HelloWorld', HelloWorld) // 即使未使用该组件,也会打包

// Vue3 模块化导出(支持 Tree-shaking)
import { defineComponent, ref } from 'vue'
// 仅引用 defineComponent,ref 未被使用,打包时会被摇掉
export default defineComponent({
  setup() {
    // 未使用 ref
    return {}
  }
})

关键前提:Vue3 的 Tree-shaking 仅在 生产环境 生效,且打包工具需支持 ES6 模块解析(Webpack 4+、Vite、Rollup 均支持);开发环境为便于调试,不会移除未使用代码。

二、Vue3 中可被 Tree-shaking 的 API 全分类(附场景)

Vue3 的 API 按“功能模块”可分为核心 API、组件 API、指令 API、工具类 API 四大类,其中绝大多数 API 均可被 Tree-shaking,仅少数全局 API(需挂载到 App 实例)无法被摇掉。以下按分类梳理,明确每类可摇树 API 的用途与示例。

1. 核心响应式 API(最常用,均可摇树)

这类 API 是 Vue3 响应式系统的核心,均支持按需引入,未被使用时会被 Tree-shaking 移除,也是日常开发中体积优化的重点。

API 名称 功能说明 可摇树性 使用示例
ref 创建基本类型响应式数据 import { ref } from 'vue';
reactive 创建引用类型响应式数据 import { reactive } from 'vue';
computed 创建计算属性 import { computed } from 'vue';
watch 监听响应式数据变化 import { watch } from 'vue';
watchEffect 自动追踪响应式依赖的监听 import { watchEffect } from 'vue';
toRefs 将 reactive 对象转为 ref 集合 import { toRefs } from 'vue';

注意:这类 API 若仅引入未使用(如 import { ref } from 'vue' 但未创建 ref 数据),打包时会被完全摇掉,不会产生任何冗余代码。

2. 组件核心 API(组件开发必备,均可摇树)

这类 API 用于组件定义、生命周期管理,仅在组件开发中使用,未被引用时可被摇掉,核心包括组件定义、生命周期钩子、组件通信相关 API。

  • 组件定义 API:defineComponent(定义组件,支持 TypeScript 类型推导)、defineProps(定义组件 props)、defineEmits(定义组件事件)、defineExpose(暴露组件内部属性/方法)——均支持 Tree-shaking,仅在组件中使用时才会被打包。
  • 生命周期钩子:onMounted、onUpdated、onUnmounted、onBeforeMount 等所有组合式 API 生命周期钩子——按需引入,未使用的钩子会被摇掉(区别于 Vue2 全局生命周期)。
  • 组件通信 API:useAttrs、useSlots、provide、inject——仅在需要组件通信时引入,未使用时会被移除。

<script setup>
// 仅引入需要的组件 API,未使用的会被 Tree-shaking
import { defineProps, onMounted, provide } from 'vue'

const props = defineProps(['title'])

onMounted(() => {
  console.log('组件挂载完成')
})

// provide 仅引入未使用,打包时会被摇掉
</script>

3. 内置指令 API(按需使用,均可摇树)

Vue3 的内置指令中,除了 v-textv-html 等基础指令(默认打包,体积极小),其他指令均支持 Tree-shaking,仅在模板中使用时才会被打包。

核心可摇树指令 API 及场景:

  • v-model 相关:vModelText、vModelNumber、vModelCheckbox 等——仅在使用对应类型 v-model 时引入(如 v-model.number 需引入 vModelNumber)。
  • v-show:单独打包,未使用时会被摇掉(区别于 v-if,v-if 是模板语法,无需额外打包)。
  • 自定义指令相关:withDirectives(用于封装自定义指令)——仅在开发自定义指令时使用,未使用时会被移除。

4. 工具类 API(按需引入,均可摇树)

Vue3 提供了一系列工具类 API,用于辅助开发(如响应式数据转换、模板渲染等),这类 API 均为模块化导出,未被使用时会被 Tree-shaking 移除,核心包括:

  • 响应式工具:isRef、isReactive、isComputed、unref(判断/转换响应式数据);
  • 模板工具:h(创建 VNode,用于渲染函数)、render(渲染 VNode 到 DOM);
  • 其他工具:nextTick(等待 DOM 更新)、mergeProps(合并组件 props)。

// 按需引入工具类 API,未使用的会被摇掉
import { isRef, nextTick, h } from 'vue'

const count = ref(0)
if (isRef(count)) {
  console.log('count 是 ref 类型')
}

// nextTick 仅引入未使用,打包时被摇掉
// h 仅引入未使用,打包时被摇掉

5. 不可被 Tree-shaking 的 API(少数全局 API)

并非 Vue3 所有 API 都能被摇掉,以下全局 API 需挂载到 App 实例(如 app.use()app.component()),或为全局运行时依赖,即使未使用,也会被打包进产物(体积极小,无需担心):

  • 创建 App 相关:createApp(必用,全局依赖);
  • 全局挂载相关:app.use()、app.component()、app.directive()(挂载全局插件、组件、指令);
  • 基础全局 API:Vue.version(获取 Vue 版本,极少使用)。

三、Tree-shaking 在 Vue3 中的底层实现逻辑

Vue3 能实现高效 Tree-shaking,核心依赖“模块化架构设计”与“编译时标记”,具体分为两个层面:

1. 架构层面:ES6 模块化导出,避免全局挂载

Vue3 源码采用 ES6 模块编写,所有 API 均通过 export 单独导出(如 export const ref = ...),而非像 Vue2 那样挂载到全局 Vue 对象上。这种设计让打包工具能精准识别“哪些 API 被 import 且被使用”,未被引用的 API 会被判定为死代码,在打包时移除。

2. 编译层面:标记未使用代码,辅助打包工具摇树

Vue3 编译器在编译阶段(如 SFC 编译),会对引入但未使用的 API 进行标记,同时移除模板中未使用的指令、组件相关代码,为打包工具的 Tree-shaking 提供辅助,确保冗余代码被彻底移除。

例如,在 <script setup> 中引入但未使用的 API,编译器会在生成的代码中移除该 import 语句,进一步确保打包工具无需额外处理:


// 开发时代码(引入未使用)
import { ref, computed } from 'vue'
const count = 1

// 编译器处理后代码(移除未使用的 import)
import { } from 'vue'
const count = 1

// 打包工具最终处理(无任何冗余)
const count = 1

四、Tree-shaking 生效条件与避坑要点

Vue3 虽然原生支持 Tree-shaking,但在实际开发中,若配置不当或使用方式有误,会导致 Tree-shaking 失效,冗余代码无法被移除。以下是关键生效条件与常见坑点。

1. 生效条件(必满足)

  • 打包工具支持 ES6 模块解析:Webpack 4+、Vite、Rollup 均支持,需确保配置中未禁用 ES6 模块(如 Webpack 中 module.exports = { mode: 'production' } 即可)。
  • 使用 ES6 模块语法:必须使用 import/export 引入/导出 API,不可使用 CommonJS 语法(require/module.exports),否则 Tree-shaking 失效。
  • 生产环境打包:Vue3 仅在生产环境(process.env.NODE_ENV === 'production')下开启 Tree-shaking 相关优化,开发环境不会移除未使用代码。

2. 常见避坑点

  • 误区1:全局引入 Vue3 会触发 Tree-shaking——若使用 import Vue from 'vue' 全局引入,所有 API 都会被打包,无法摇掉;必须使用按需引入(import { ref } from 'vue')。
  • 误区2:引入但未使用的 API 一定会被摇掉——若 API 被间接引用(如通过第三方插件引用),或打包工具无法识别死代码(如配置错误),可能导致 Tree-shaking 失效,需检查打包配置。
  • 误区3:自定义指令/插件会影响 Tree-shaking——自定义指令/插件若采用按需引入模式(如 import { MyDirective } from './directive'),未使用时会被摇掉;若全局挂载(app.directive('my-dir', MyDirective)),则无法摇掉。
  • 误区4:Tree-shaking 会摇掉必要的运行时代码——Vue3 源码中已做好兼容,运行时核心依赖(如响应式系统基础逻辑)会被自动保留,不会被误摇掉。

五、实战验证:Tree-shaking 的体积优化效果

以一个简单的 Vue3 项目为例,对比“全局引入”与“按需引入”的打包体积差异(基于 Vite 打包,生产环境):

  1. 全局引入(未使用 Tree-shaking):import Vue from 'vue',打包后 Vue 相关体积约 50KB(gzip 后约 18KB);
  2. 按需引入(使用 Tree-shaking):仅引入 refdefineComponent,打包后 Vue 相关体积约 15KB(gzip 后约 6KB),体积缩减 70%。

实际项目中,随着 API 使用数量的增加,体积优化效果会略有下降,但总体仍能比 Vue2 减少 30%~50% 的冗余体积,尤其对轻量级应用(如移动端 H5、小程序),体积优化带来的加载速度提升更为明显。

六、总结:Tree-shaking 与 Vue3 优化体系的协同价值

Tree-shaking 在 Vue3 中的体现,本质是“模块化架构”与“按需引入”理念的落地,它与前文提到的静态提升、PatchFlag、Block Tree 等优化特性,形成了“构建层面+渲染层面”的全链路优化:

  • Tree-shaking:构建阶段移除未使用代码,缩减打包体积,提升加载速度;
  • 静态提升/PatchFlag/Block Tree:渲染阶段优化 VNode 创建与 Diff 效率,提升运行时性能。

梳理可被 Tree-shaking 的 API 后,我们能在实际开发中更有针对性地优化:优先采用按需引入模式,避免全局引入 API;减少“引入未使用”的冗余代码;合理使用第三方插件(选择支持 Tree-shaking 的插件)。

Vue3 对 Tree-shaking 的原生支持,彻底解决了 Vue2 体积冗余的痛点,让开发者无需额外配置,就能轻松实现项目体积优化。理解其底层原理与可摇树 API 清单,不仅能帮助我们写出更轻量的代码,更能深入掌握 Vue3 的架构设计思路,在性能优化场景中精准发力。

相关文章

避坑+实战|Vue3 hoistStatic静态提升,让渲染速度翻倍的秘密

吃透 Vue3 PatchFlag!8 大类标识含义+精准比对逻辑

Vue3 渲染优化双核心:Block Tree 原理与 Fragment 根节点妙用

volatile和synchronize有什么区别

作者 Asmewill
2026年1月30日 09:43

volatile和synchronize有什么区别?

volatile关键字干了什么?(什么叫指令重排)

volatile 能否保证线程安全?在DCL上的作用是什么?

一.工作内存,主内存,CPU缓存是什么?线程在读写时是怎么运作的?

工作内存
  • 工作内存包括二个部分,一个是线程私有栈,一个是对主内存部分变量进行拷贝的寄存器【程序计数器PC和CPU高速缓存区】
  • 程序计数器PC【Program Counter】是用来存储 程序执行到 【当前行代码指令】的地址,程序在执行的过程中,PC会自动更新下一条指令。
  • 工作内存是是每个线程的私有内存区域(栈内存),用于存储线程的是私有数据,包括局部变量,方法,参数等,工作内存中的数据是主内存中的数据副本。
主内存(堆内存+java8叫元空间【java6叫方法区】)
  • 主内存是所有线程共享的内存区域,用于存储对象实例,静态变量等共享数据。保证正了数据的一致性和可见性。
CPU缓存
  • CPU缓存用于CPU和主内存之间的高速缓存(变量被缓存到CPU缓存中的时机取决于CPU的缓存策略、程序的数据访问模式以及变量的访问频率)

二.线程的读写机制

读取数据
  • 如果该变量在CPU缓存中存在,直接从CPU缓存中读取数据,并将数据复制到工作内存中,无需访问主内存。如果该变量在CPU缓存中不存在,从主内存中读取数据,并将数据复制到工作内存中.
写入数据
  • 线程首先在自己的工作内存中更新变量的值,会在合适的时候(线程结束,线程释放锁,使用volatile关键字修饰的变量,使用synchronized同步的代码块或者方法)将修改的值同步更新到主内存。

三.禁止指令重排(保证有序性)

  • 指令重排序是JVM为了优化指令、提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度
  • volatile禁止指令重排,在读写操作指令前后会插入内存屏障
示例说明:
double r = 2.1; //(1)
double pi = 3.14;//(2)
double area = pi*r*r;//(3)

虽然代码语句的定义顺序为1->2->3,但是计算顺序1->2->3与2->1->3对结果并无影响,所以编译时和运行时可以根据需要对1、2语句进行重排序。

  • 指令重排序带来的问题 如果一个操作不是原子的,就会给JVM留下重排的机会。
线程A中
{
   context=loadContext();
   init=true;
}
线程B
{
   if(inited){
      fun(context)
   }
}

如果线程A中发生了指令重排序,那么B中很可能拿到一个尚未初始化完成的context,从而引发程序错误。

  • 指令重排在双重锁定单例模式中的影响基于双重检验的单例模式(懒汉型)
public class Singleton3 {
   private static Singleton3 instance = null;
   private Singleton3() {}
   public static Singleton3 getInstance() {
       if (instance == null) {
           synchronized(Singleton3.class) {
               if (instance == null)
                   instance = new Singleton3();// 非原子操作,可能会被指令重排
          }
      }
       return instance;
  }
}

instance= new Singleton()并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:

memory=allocate();//1.分配对象的内存空间
initInstance(memory);//2.初始化对象的引用
instance=memory;//3.将内存空间的地址赋值给对像的引用

上面的操作2依赖于操作1,但是操作3并不依赖于操作2。所以JVM是可以针对他们进行指令的优化,经过重排序后:

memory=allocate();//1.分配对象的内存空间
instance=memory;//3.将内存空间的地址赋值给对像的引用
initInstance(memory);//2.初始化对象

指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错。

volatile在DCL(double-check-lock)的作用是什么?解决指令重排序

用volatile关键字修饰instance变量,使得instance在读、写操作前后都会插入内存屏障,避免重排序。

public class Singleton3 {
   private static volatile Singleton3 instance = null;
   private Singleton3() {}
   public static Singleton3 getInstance() {
       if (instance == null) {
           synchronized(Singleton3.class) {
               if (instance == null)
                   instance = new Singleton3();
          }
      }
       return instance;
  }
}

四.volatile和synchronize的区别?

  • volatile只能作用于变量(基本数据类型和对象引用 ),但是synchronized可以作用于变量(仅对象引用),方法,类(代码块).
  • volatile之保证了可见性(禁止Cpu缓存),有序性(禁止编译器优化),无法保证原子性(其读写操作在单个线程内看似连续,但实际上在多线程环境中可能受到线程切换的影响而被中断比如:count++),synchronized可以保证线程间的有序性,原子性和可见性。
可见性:synchronized 关键字可以保证可见性。当一个线程释放了一个 synchronized 监听器(锁)后,它对共享变量的修改会立即对其他线程可见。其他线程在获取同一个锁时,会看到最新的变量值。

有序性:synchronized 关键字也保证了有序性。它禁止了指令重排序,确保了代码的执行顺序与程序中的顺序一致。这有助于防止由于编译器或处理器的优化而导致的多线程问题。

原子性:synchronized 关键字可以保证原子性。在一个 synchronized 块或方法中,所有操作都是原子的,即要么全部执行,要么全部不执行。这确保了在多线程环境下对共享资源的访问是安全的。

  • volatile线程不阻塞,synchronized线程是阻塞的.
  • volatile本质是告诉jvm当前变量在CPU cache中的值是不安全的,需要从主内存中读取;synchronized则是锁定当前变量,只有当前线程可以访问当当前变量,其他线程被阻塞。
  • volatile标记的变量不会被编译器优化(禁止指令重排),synchronized标记的变量可以被编译器优化(可以被指令重排)

五.volatile能否保证线程的安全?

  • volatile无法保证线程的安全和变量的原子性,只能保证线程的可见性和有序性。
  • 原子性指的是一个或者多个操作在 CPU 执行的过程中不被中断的特性。
//其读写操作在单个线程内看似连续,但实际上在多线程环境中可能受到线程切换的影响而被中断
public class Main {
    public volatile static int count = 0;
    public static void main(String[] args) {
        count=1;
        //开启5个线程
        for (int i = 0; i < 5; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //让count的值自增100次
                    for (int j = 0; j < 100; j++) {
                        count++;
                        System.out.println("count= " + count);
                    }
                }
            }).start();
        }
    }
}
输出数据:
...
count= 495
count= 496
count= 497

上面的代码,我们开启了5个线程,每隔线程让静态变量count自增100次,执行之后会发现,最终的count 的结果值未必是500,有可能小于500,出现上面的情况的原因是volatile没有保证变量读写过程中的原子性,例如:A线程获取到count的值为2,此时主内存与工作内存一致,然后我们执行自增操作,count值为3,但是主内存的值很有可能被其他线程更新为了8或者其他数值,如果A线程执行更新主内存,那么数目就相当于往下降低了

六.作用范围对比

1.volatile作用的对象 volatile仅能修饰变量(基本数据类+对象引用),其核心功能是保证变量的可见性和禁止指令重排序。

private volatile int count=0;  //基本数据类
private static  volatile  SingleTon instance=null; //对象引用

2.synchronized 作用的对象

  • 变量 (锁定对象引用)
public class Count{
    private int count=0;
    private final Object   lock=new Object(); //锁定当前对象实例
    public void increment(){
          synchronized(lock){  /* 代码块 */
                 count++;
          }
    }
    public void getCount(){
         synchronized(lock){  /* 代码块 */
            return count;        
         }
    }
}

  • 普通方法 【锁定当前对象实例】
public synchronized void method{ /*方法体*/  }
  • 静态方法 【锁定的是类对象 MyClass.class】
public static  synchronized void method{ /* 方法体*/ }
  • 类对象
synchronized(MyClass.class){  /* 代码块 */  }

七 .并发控制的影响

1.实例方法锁的局限性
  • 同一个实例的线程竞争:若多个线程操作同一个实例的synchronized 普通方法,会因为争夺this 锁而互斥。

  • 不同实例的线程竞争:不同实例的线程调用各自的synchronized 普通方法,不会互相阻塞,因为锁的对象不同。

2.静态方法锁的全局性
  • 跨实例的线程竞争:所有线程调用给类的静态方法时,均需要竞争同一个类锁(Class对象),无论是哪个实例。
  • 静态与非静态的独立性:静态方法锁和实例方法锁互不干扰,因为锁对象不同(Class 对象 vs this)
场景:
SynchronizedExample obj1 = new SynchronizedExample();
SynchronizedExample obj2 = new SynchronizedExample();

// 线程1:调用 obj1 的实例方法(锁 obj1)
new Thread(() -> obj1.method()).start();

// 线程2:调用 obj2 的实例方法(锁 obj2)
new Thread(() -> obj2.method()).start(); // 不会阻塞,因锁对象不同

// 线程3:调用静态方法(锁 SynchronizedExample.class)
new Thread(() -> SynchronizedExample.staticMethod()).start(); // 所有静态方法调用均互斥

八.适用范围

  • 实例方法锁:适用于保护实例级别的共享资源,锁粒度小,适合多实例场景.
  • 静态方法锁:适用于保护类级别的全局资源,锁粒度较大,注意性能影响.

微信小程序开发中碰到在组件中监听app.globalData 中值得变化 处理相对应的数据

作者 葉_
2026年1月30日 09:41

1.方法一 如果是简单数据 可以用Object.defineProperty 进行监听处理

Object.defineProperty 是 JavaScript 中的一个方法,用于直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。这个方法允许你精确地控制属性的特性,比如是否可写、是否可枚举、是否可配置等。

以下是 Object.defineProperty 方法的基本用法和参数说明
Object.defineProperty(obj, prop, descriptor)
  • obj:要在其上定义或修改属性的对象。
  • prop:要定义或修改的属性的名称,可以是一个字符串或 Symbol
  • descriptor:一个描述符对象,包含以下属性:
  • configurable:布尔值,表示属性是否可以被删除或重新配置。
  • enumerable:布尔值,表示属性是否可以在 for...in 循环或 Object.keys() 方法中被枚举。
  • writable:布尔值,表示属性值是否可以被改变。
  • value:属性的数据值,对于数据属性是必需的。
  • get:获取属性的访问器函数,对于存取器属性是必需的。
  • set:设置属性的访问器函数,对于存取器属性是必需的。
小程序中用法
//在app.js中配置全局函数
setWatcher(targetKey, callback) {
    const globalData = this.globalData;
    // 如果该属性尚未被监听,则为其定义访问器
    if (!globalData.hasOwnProperty(`_${targetKey}`)) {
      let val = globalData[targetKey];
      
      Object.defineProperty(globalData, targetKey, {
        configurable: true,
        enumerable: true,
        set(value) {
          val = value;
          // 调用所有注册的回调函数
          if (globalData._watchers && globalData._watchers[targetKey]) {
            globalData._watchers[targetKey].forEach((cb) => cb(value, val));
          }
        },
        get() {
          return val;
        }
      });
      
      // 初始化 watchers 存储对象
      if (!globalData._watchers) {
        globalData._watchers = {};
      }
      // 初始化该属性的回调函数数组
      if (!globalData._watchers[targetKey]) {
        globalData._watchers[targetKey] = [];
      }
    }
    
    // 将回调函数添加到该属性的监听列表中
    globalData._watchers[targetKey].push(callback);
  },
  
  
  //在相对应的组件中lifetimes中调用
   apps.setWatcher('role', (newValue:any, oldValue:any) => {
        console.log('role 已更新:', newValue);
        this.setData({
          userInfo: newValue
        });
      });
效果

971e286ff8719e8093d95b562db847b3.png

5408e68e0d633a90b79bbdd24fbc520b.png

2.方法二

例:

user:{ roles:{ role:0 } }需要修改role的值

可以用Proxy 对globalData进行代理

JavaScript的Proxy是一种对象代理机制,它可以在对象和函数之间添加一个中间层,从而实现对对象和函数的拦截和控制。Proxy可以用于拦截对象的读写、函数的调用、属性的枚举等操作,并在拦截时执行自定义的操作。使用Proxy可以实现各种高级功能,例如数据绑定、事件监听、缓存等。

以下是 Proxy 方法的基本用法和参数说明
let proxy = new Proxy(target, handler);

其中,target是要被代理的目标对象,handler是一个对象,用于定义拦截目标对象的各种操作的行为。handler对象可以包含以下方法:

  • get(target, prop, receiver):拦截对象属性的读取操作。
  • set(target, prop, value, receiver):拦截对象属性的写入操作。
  • apply(target, thisArg, args):拦截函数的调用操作。
  • construct(target, args, newTarget):拦截new操作符的调用操作。
  • has(target, prop):拦截in操作符的调用操作。
  • deleteProperty(target, prop):拦截delete操作符的调用操作。
  • defineProperty(target, prop, descriptor):拦截Object.defineProperty()方法的调用操作。
  • getOwnPropertyDescriptor(target, prop):拦截Object.getOwnPropertyDescriptor()方法的调用操作。
  • getPrototypeOf(target):拦截Object.getPrototypeOf()方法的调用操作。
  • setPrototypeOf(target, proto):拦截Object.setPrototypeOf()方法的调用操作。
  • isExtensible(target):拦截Object.isExtensible()方法的调用操作。
  • preventExtensions(target):拦截Object.preventExtensions()方法的调用操作。
  • ownKeys(target):拦截Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()、Object.keys()等方法的调用操作。
小程序中用法
//app.js中
// 代理全局数据
  initGlobalDataProxy() {
    const self = this;
    
    // 递归创建代理
    const createProxy = (data, path) => {
      if (typeof data !== 'object' || data === null) {
        return data;
      }
      
      return new Proxy(data, {
        get(target, prop, receiver) {
          const value = Reflect.get(target, prop, receiver);
          // 如果是对象则继续代理
          if (typeof value === 'object' && value !== null) {
            return createProxy(value, path ? `${path}.${String(prop)}` : String(prop));
          }
          return value;
        },
        set(target, prop, value, receiver) {
          const currentPath = path ? `${path}.${String(prop)}` : String(prop);
          const oldValue = Reflect.get(target, prop, receiver);
          const result = Reflect.set(target, prop, value, receiver);

          if (oldValue !== value) {
            console.log('数据变化:', currentPath, '新值:', value);
            
            // 1. 触发精确路径监听
            if (self?.globalDataListeners && self.globalDataListeners[currentPath]) {
              self.globalDataListeners[currentPath].forEach((callback) => {
                callback(value, currentPath);
              });
            }
            
            // 2. 触发父级路径监听(例如修改 user.role 时,如果监听了 user,也会触发)
            let parentPath = path;
            while (parentPath) {
              if (self?.globalDataListeners && self.globalDataListeners[parentPath]) {
                const parentValue = parentPath.split('.').reduce((obj, key) => obj[key], self.globalData);
                self.globalDataListeners[parentPath].forEach((callback) => {
                  callback(parentValue, parentPath);
                });
              }
              const lastDot = parentPath.lastIndexOf('.');
              parentPath = lastDot !== -1 ? parentPath.substring(0, lastDot) : '';
            }
          }
          return result;
        }
      });
    };

    this.globalData = createProxy(this.globalData);
  },
  // 添加监听器
  addGlobalDataListener(prop:any, callback:any) {
    if (!this.globalDataListeners) {
      this.globalDataListeners = {};
    }
    if (!this.globalDataListeners[prop]) {
      this.globalDataListeners[prop] = [];
    }
    this.globalDataListeners[prop].push(callback);
  }
  
  
  
  //在相对应的组件中lifetimes中调用
  apps.initGlobalDataProxy();
    
  // 监听 userInfo 变化
  apps.addGlobalDataListener('user', (newValue:any) => {
  console.log("更改值了",newValue.roles.role)
  });
 
效果

72185a009e9cb02f783c400ac40a8a30.png

就可以动态更改组件的需求了 当然这只是其中几种方法 可能有的小伙伴会有更好的方法 欢迎来讨论

为何 Node.js 环境中没有 DOM 和 BOM?

作者 Smilezyl
2026年1月30日 09:32

为何 Node.js 环境中没有 DOM 和 BOM?

核心答案

因为 DOM 和 BOM 不是 JavaScript 语言本身的一部分,而是浏览器提供的宿主环境 API。

  • DOM (Document Object Model):浏览器解析 HTML 后生成的文档对象模型
  • BOM (Browser Object Model):浏览器窗口相关的对象(window、navigator、location 等)

Node.js 是服务端运行时,没有浏览器窗口,没有 HTML 文档,自然不需要也无法提供这些 API。Node.js 提供的是服务端所需的 API(文件系统、网络、进程等)。

深入解析

底层机制

1. ECMAScript vs 宿主环境

类别 来源 示例
ECMAScript 标准 JS 语言规范 Array, Promise, class, async/await
浏览器宿主 API W3C/WHATWG 规范 document, window, fetch, localStorage
Node.js 宿主 API Node.js 项目 fs, http, process, Buffer

2. 为什么这样设计?

浏览器的职责:                    Node.js 的职责:
├── 渲染网页                      ├── 执行服务端逻辑
├── 处理用户交互                   ├── 文件读写
├── 管理页面导航                   ├── 网络服务
└── 多媒体播放                     └── 系统调用

不同的运行环境有不同的需求,提供不同的 API 是合理的设计。

3. V8 引擎的角色

V8 只负责执行 JavaScript 代码,它本身不包含 DOM/BOM:

V8 引擎提供:
├── JS 代码解析和编译
├── 执行字节码
├── 垃圾回收
└── ECMAScript 标准内置对象

V8 不提供:
├── DOM 操作
├── 网络请求
├── 文件系统
└── 任何 I/O 操作

常见误区

  1. "JavaScript 天生就有 document 和 window"

    • 错误!这些是浏览器注入的全局对象
  2. "Node.js 是阉割版的 JavaScript"

    • 错误!Node.js 完整实现了 ECMAScript,只是宿主 API 不同
  3. "console.log 是 JavaScript 的一部分"

    • 严格来说不是!console 是宿主环境提供的,只是浏览器和 Node.js 都实现了它

代码示例

环境检测

// 检测当前运行环境
function detectEnvironment() {
    // 浏览器环境
    if (typeof window !== 'undefined' && typeof document !== 'undefined') {
        console.log('浏览器环境');
        console.log('window:', typeof window);      // object
        console.log('document:', typeof document);  // object
        console.log('navigator:', typeof navigator); // object
    }

    // Node.js 环境
    if (typeof process !== 'undefined' && process.versions?.node) {
        console.log('Node.js 环境');
        console.log('process:', typeof process);    // object
        console.log('__dirname:', typeof __dirname); // string (CommonJS)
        console.log('window:', typeof window);      // undefined
        console.log('document:', typeof document);  // undefined
    }
}

detectEnvironment();

跨环境兼容代码

// 同构/通用 JavaScript 代码示例
const isNode = typeof process !== 'undefined'
    && process.versions != null
    && process.versions.node != null;

const isBrowser = typeof window !== 'undefined'
    && typeof window.document !== 'undefined';

// 根据环境使用不同的 API
async function fetchData(url) {
    if (isBrowser) {
        // 浏览器使用 fetch API
        return fetch(url).then(res => res.json());
    } else if (isNode) {
        // Node.js 18+ 也有 fetch,或使用 http 模块
        const { default: fetch } = await import('node-fetch');
        return fetch(url).then(res => res.json());
    }
}

Node.js 中模拟 DOM(jsdom)

// 在 Node.js 中使用 jsdom 模拟浏览器环境
const { JSDOM } = require('jsdom');

const dom = new JSDOM(`
    <!DOCTYPE html>
    <html>
        <body>
            <div id="app">Hello</div>
        </body>
    </html>
`);

// 现在可以使用 DOM API 了
const document = dom.window.document;
const app = document.getElementById('app');
console.log(app.textContent); // "Hello"

app.textContent = 'Hello from Node.js!';
console.log(dom.serialize()); // 输出修改后的 HTML

全局对象对比

// 浏览器中的全局对象
// window === globalThis === self (在主线程中)

// Node.js 中的全局对象
// global === globalThis

// 通用写法(ES2020+)
console.log(globalThis); // 在任何环境都能获取全局对象

面试技巧

面试官可能的追问方向

  1. "那 Node.js 怎么做服务端渲染 (SSR)?"

    • 回答:使用 jsdom、happy-dom 等库模拟 DOM 环境,或使用 React/Vue 的服务端渲染 API(renderToString)直接生成 HTML 字符串,不需要真正的 DOM
  2. "fetch 是 JavaScript 的一部分吗?"

    • 回答:不是,fetch 是 WHATWG 规范定义的 Web API。Node.js 18+ 才原生支持,之前需要 node-fetch 等 polyfill
  3. "为什么 setTimeout 在 Node.js 中也能用?"

    • 回答:因为 Node.js 选择实现了这个 API 以保持兼容性,但实现机制不同(Node.js 用 libuv,浏览器用事件循环)
  4. "globalThis 是什么?"

    • 回答:ES2020 引入的标准,统一获取全局对象的方式,解决了 window/global/self 在不同环境不一致的问题

如何展示深度理解

  • 区分 ECMAScript 规范宿主环境 API
  • 了解 V8 引擎 的职责边界
  • 知道 jsdomhappy-dom 等工具的存在和用途
  • 理解 同构 JavaScript 的概念和挑战
  • 提及 DenoBun 等新运行时对 Web API 的支持程度不同

一句话总结

DOM/BOM 是浏览器的"特产",不是 JavaScript 的"标配"——JS 只是一门语言,能做什么取决于宿主环境给它什么 API。

向完全不懂编程的产品经理解释 JavaScript 是什么。你会怎么说?

作者 Smilezyl
2026年1月30日 09:31

想象你正站在一个完全不懂编程的 产品经理 面前,试图向他解释 JavaScript 是什么。你会怎么说?请尝试用通俗易懂的语言(比如打比方)向他解释 ECMAScript、DOM 和 BOM 的关系。

核心答案

我会这样向产品经理解释:

JavaScript 就像是一个"万能工人",它能让网页从"死"的变成"活"的。

打个比方:

  • ECMAScript 是工人的"技能手册"——规定了他会哪些基本动作(比如说话、走路、数数)
  • DOM 是工人和"页面内容"打交道的方式——让他能修改页面上的文字、图片、按钮
  • BOM 是工人和"浏览器环境"打交道的方式——让他能控制浏览器窗口、历史记录、弹出提示框

简单关系图:ECMAScript(核心能力) + DOM(操作页面) + BOM(操作浏览器) = 完整的 JavaScript


深入解析

1. 用更形象的比喻

想象你在装修一个房子

组成部分 比喻 实际作用
ECMAScript 装修队的施工规范 定义语法、变量、循环、函数等基本规则
DOM 和家具、墙壁打交道 操作页面元素:修改文字、样式、添加删除元素
BOM 和房子本身打交道 操作浏览器:控制窗口大小、跳转页面、本地存储

2. 技术层面的解释

ECMAScript
  • 是 JavaScript 的语言标准,由 ECMA 国际组织制定
  • 只规定语言的语法和核心功能
  • 不涉及任何浏览器或环境相关的内容
  • 最新版本:ES6/ES2015、ES2024 等
DOM(Document Object Model)
  • 把 HTML 文档解析成树形结构
  • 提供了一组 API 让 JavaScript 能操作页面
  • 是 W3C 标准,不只是 JavaScript 专用
BOM(Browser Object Model)
  • 提供与浏览器交互的接口
  • 没有统一标准,不同浏览器实现有差异
  • 主要对象:windowlocationnavigatorhistorylocalStorage

3. 常见误区

误区1:ECMAScript 和 JavaScript 是同一个东西

纠正:ECMAScript 是标准规范,JavaScript 是这个规范的实现(类似接口和实现类的关系)

误区2:DOM 是 JavaScript 的一部分

纠正:DOM 是独立的标准,其他语言(如 Python)也能操作 DOM

误区3:BOM 有统一标准

纠正:BOM 长期缺乏标准,各浏览器实现不同,HTML5 规范后才逐步统一


代码示例

// ========== ECMAScript:核心语法 ==========
// 这些语法在任何符合 ES 标准的环境中都能运行

// 1. 基础语法
const name = 'JavaScript';
let count = 0;

// 2. 函数
function greet(user) {
    return `Hello, ${user}`;
}

// 3. 循环
for (let i = 0; i < 5; i++) {
    count++;
}

// 4. 对象和数组
const user = {
    name: 'Alice',
    age: 25,
    skills: ['js', 'html']
};


// ========== DOM:操作页面内容 ==========
// 只有在浏览器环境才可用

// 1. 获取元素
const button = document.querySelector('#myButton');
const title = document.getElementById('title');

// 2. 修改内容
title.textContent = '新的标题';

// 3. 修改样式
button.style.backgroundColor = 'blue';

// 4. 事件监听
button.addEventListener('click', () => {
    alert('按钮被点击了!');
});

// 5. 动态创建元素
const newDiv = document.createElement('div');
newDiv.className = 'box';
document.body.appendChild(newDiv);


// ========== BOM:操作浏览器 ==========
// 不同浏览器可能有差异

// 1. window 对象(BOM 的核心)
console.log(window.innerWidth);   // 浏览器窗口宽度
window.scrollTo(0, 500);          // 滚动页面

// 2. location(页面跳转)
console.log(location.href);       // 当前 URL
location.reload();                // 刷新页面

// 3. history(历史记录)
history.back();                   // 返回上一页
history.go(2);                    // 前进两页

// 4. navigator(浏览器信息)
console.log(navigator.userAgent);  // 浏览器标识

// 5. localStorage(本地存储)
localStorage.setItem('key', 'value');
const data = localStorage.getItem('key');

// 6. 定时器(window 的方法)
setTimeout(() => {
    console.log('1秒后执行');
}, 1000);

面试技巧

面试官可能的追问

  1. "ES6 新增了哪些特性?"

    • 回答:let/const、箭头函数、解构赋值、Promise、class、模块化等
  2. "DOM 操作为什么慢?"

    • 回答:会触发重排和重绘,建议用文档片段或虚拟 DOM
  3. "BOM 中有哪些常用的 API?"

    • 回答:localStorage、sessionStorage、history、location、navigator
  4. "JavaScript 只能在浏览器运行吗?"

    • 回答:不是,Node.js 让 JS 能在服务器运行,但没有 DOM/BOM

如何展示深度理解

  1. 提到标准的演进:说明 DOM 从混乱到标准化的历史
  2. 谈性能问题:DOM 操作的性能影响,如何优化
  3. 跨平台思考:Node.js、React Native 如何复用 ECMAScript
  4. 实际经验:举例说明处理过浏览器兼容性问题

一句话总结

ECMAScript 是语言规则,DOM 是操作页面的手,BOM 是操作浏览器的手——三者共同构成了我们在网页开发中使用的 JavaScript。

【连接篇】TCP/UDP 与前端性能的物理极限

2026年1月30日 09:24

作为《前端视角下的网络协议》系列的第一篇,我们不聊那些复杂的报文格式,而是聊聊物理规律

对于前端开发者来说,网络环境是一个“黑盒”。你写了一行 fetch('/api/data'),但在数据到达浏览器之前,它必须跨越数千公里的光纤、路由器和交换机。在这段旅程中,TCP 的连接机制决定了你的首屏加载速度(FP)存在一个无法逾越的“物理极限”。


一、 TCP 三次握手:避不开的 RTT 消耗

每一个 HTTP/1.x 或 HTTP/2 连接在传输数据前,都必须经过 TCP 三次握手。

  1. SYN (浏览器 -> 服务器)
  2. SYN-ACK (服务器 -> 浏览器)
  3. ACK (浏览器 -> 服务器)

对于前端性能来说,这意味着:在你的第一个字节(TTFB)发出之前,已经消耗了 1.5 个 RTT(往返时延)。

  • 物理极限: 如果用户在上海,服务器在纽约,单程延迟约 150ms,那么握手就要消耗 450ms。无论你的前端代码优化得多么极致,这近半秒的白屏是物理层面的“死刑”。

  • 优化对策: * CDN 边缘加速: 让握手发生在离用户最近的节点。

    • Connection Keep-Alive: 复用长连接,避免为每个请求重新握手。

二、 慢启动与“14KB 规则”:为什么 HTML 体积至关重要?

这是前端开发者最容易忽略的 TCP 特性:拥塞窗口(Congestion Window)

TCP 为了防止网络拥塞,不会一上来就全速发送数据。它会从一个很小的初始窗口(通常是 10 个段,约 14.6KB)开始尝试。如果接收方成功确认,窗口才会翻倍(20, 40, 80...)。

  • 前端含义: 你的首屏关键 HTML 和内联 CSS 最好控制在 14KB 以内。

    • 如果 HTML 是 10KB:只需 1 个往返即可完成下载并开始解析渲染。
    • 如果 HTML 是 20KB:需要 2 个往返。在网络环境差的情况下,这多出来的一个 RTT 可能意味着用户多看 200ms 的白屏。
  • 结论: 极致的性能优化,是从精简首屏 HTML 字节数开始的。


三、 TCP 队头阻塞:HTTP/2 的软肋

虽然 HTTP/2 实现了多路复用(Multiplexing),让我们可以并行下载资源,但它依然跑在 TCP 上。

  • 问题所在: TCP 是一个“流”协议,它必须保证包的顺序。如果其中一个 TCP 包在传输中丢失,即使后续的包已经到达浏览器,TCP 也必须等那个丢掉的包重传成功后,才能把数据交给浏览器。
  • 性能杀手: 即使你并行下载 10 个图片,只要丢了一个包,所有的图片下载都会被卡住。这就是 TCP 级别的队头阻塞(Head-of-Line Blocking)

四、 UDP 与 HTTP/3:打破物理枷锁

为了彻底解决 TCP 的顽疾,Google 推出了基于 UDP 的 QUIC 协议(即 HTTP/3)。

  1. 0-RTT 握手: UDP 无需握手。QUIC 在建立连接时可以实现 0-RTT 或 1-RTT,比 TCP 快得多。
  2. 解决队头阻塞: QUIC 在 UDP 之上实现了自己的流控制。如果一个流丢包,只会影响该流,其他流(其他资源请求)可以继续传输。
  3. 连接迁移: 用户从 WiFi 切换到 5G 时,IP 地址变了,TCP 连接会断开。但 QUIC 基于 Connection ID,可以实现无缝切换,不断连。

💡 前端开发者的硬核总结

  1. 首屏 HTML 尽量压缩在 14KB 以内: 避开 TCP 慢启动的第二次往返。
  2. 利用 Preconnect: 在 HTML 头部加入 <link rel="preconnect" href="https://api.example.com">,让浏览器提前完成那耗时的 TCP 握手。
  3. 关注 HTTP/3 普及率: 如果你的用户群网络环境复杂(如移动端、弱网),开启 HTTP/3 将带来质的飞跃。

结语

理解了 TCP 的物理限制,你就明白了为什么“减少请求数”和“减小包体积”永远是前端优化的金科玉律。网络协议不是为了增加难度,而是划定了性能的边界。

🔥 Vue3 + TS 实现长按指令 v-longPress:优雅解决移动端/PC端长按交互需求

作者 小马_xiaoen
2026年1月30日 09:21

🔥 Vue3 + TS 实现长按指令 v-longPress:优雅解决移动端/PC端长按交互需求

在前端开发中,长按(Long Press)是高频交互场景(如移动端删除操作、PC端右键菜单、批量操作触发等)。原生HTML没有直接的长按事件,通常需要通过 mousedown/touchstart 结合定时器实现。本文将教你基于 Vue3 + TypeScript 实现一个功能完善、体验友好、跨端兼容v-longPress 自定义指令,支持自定义长按时长、触发回调、取消触发等特性,开箱即用。

在这里插入图片描述

🎯 指令核心特性

  • ✅ 跨端兼容:同时支持PC端(鼠标长按)和移动端(触摸长按)
  • ✅ 自定义配置:支持自定义长按时长、触发回调、取消回调
  • ✅ 防误触优化:按下后移动超过阈值自动取消长按
  • ✅ 完整 TypeScript 类型定义,开发提示友好
  • ✅ 支持指令参数动态更新,适配动态业务场景
  • ✅ 自动清理定时器/事件监听,无内存泄漏
  • ✅ 支持阻止默认行为/冒泡,适配复杂交互场景

📁 完整代码实现(v-longPress.ts)

// directives/v-longPress.ts
import type { ObjectDirective, DirectiveBinding, App } from 'vue'

/**
 * 长按指令配置接口
 */
export interface LongPressOptions {
  /** 长按触发时长(ms),默认500ms */
  duration?: number
  /** 长按触发的回调函数 */
  handler: (e: MouseEvent | TouchEvent) => void
  /** 长按取消的回调函数(可选) */
  cancelHandler?: (e: MouseEvent | TouchEvent) => void
  /** 移动阈值(px),超过则取消长按,默认5px */
  moveThreshold?: number
  /** 是否阻止默认行为,默认true */
  preventDefault?: boolean
  /** 是否阻止事件冒泡,默认false */
  stopPropagation?: boolean
}

/**
 * 扩展元素属性,存储长按相关状态
 */
interface LongPressElement extends HTMLElement {
  _longPress?: {
    options: LongPressOptions
    timer: number | null          // 长按定时器
    startX: number                // 按下时X坐标
    startY: number                // 按下时Y坐标
    isPressing: boolean           // 是否正在长按中
    // 事件处理函数(绑定this,便于移除监听)
    mouseDownHandler: (e: MouseEvent) => void
    touchStartHandler: (e: TouchEvent) => void
    mouseUpHandler: (e: MouseEvent) => void
    touchEndHandler: (e: TouchEvent) => void
    mouseMoveHandler: (e: MouseEvent) => void
    touchMoveHandler: (e: TouchEvent) => void
    mouseLeaveHandler: (e: MouseEvent) => void
  }
}

/**
 * 默认配置
 */
const DEFAULT_OPTIONS: Omit<LongPressOptions, 'handler'> = {
  duration: 500,
  moveThreshold: 5,
  preventDefault: true,
  stopPropagation: false
}

/**
 * 计算两点之间的距离
 * @param x1 起始X坐标
 * @param y1 起始Y坐标
 * @param x2 结束X坐标
 * @param y2 结束Y坐标
 * @returns 距离(px)
 */
const calculateDistance = (x1: number, y1: number, x2: number, y2: number): number => {
  return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2))
}

/**
 * 清除长按相关状态和定时器
 * @param el 目标元素
 * @param e 触发事件(可选,用于取消回调)
 */
const clearLongPressState = (el: LongPressElement, e?: MouseEvent | TouchEvent) => {
  const longPressData = el._longPress
  if (!longPressData) return

  // 清除定时器
  if (longPressData.timer) {
    clearTimeout(longPressData.timer)
    longPressData.timer = null
  }

  // 触发取消回调
  if (longPressData.isPressing && longPressData.options.cancelHandler && e) {
    longPressData.options.cancelHandler(e)
  }

  // 重置状态
  longPressData.isPressing = false
}

/**
 * 绑定事件监听
 * @param el 目标元素
 */
const bindEvents = (el: LongPressElement) => {
  const longPressData = el._longPress
  if (!longPressData) return

  // PC端事件
  el.addEventListener('mousedown', longPressData.mouseDownHandler)
  el.addEventListener('mouseup', longPressData.mouseUpHandler)
  el.addEventListener('mousemove', longPressData.mouseMoveHandler)
  el.addEventListener('mouseleave', longPressData.mouseLeaveHandler)

  // 移动端事件
  el.addEventListener('touchstart', longPressData.touchStartHandler)
  el.addEventListener('touchend', longPressData.touchEndHandler)
  el.addEventListener('touchmove', longPressData.touchMoveHandler)
}

/**
 * 解绑事件监听
 * @param el 目标元素
 */
const unbindEvents = (el: LongPressElement) => {
  const longPressData = el._longPress
  if (!longPressData) return

  // PC端事件
  el.removeEventListener('mousedown', longPressData.mouseDownHandler)
  el.removeEventListener('mouseup', longPressData.mouseUpHandler)
  el.removeEventListener('mousemove', longPressData.mouseMoveHandler)
  el.removeEventListener('mouseleave', longPressData.mouseLeaveHandler)

  // 移动端事件
  el.removeEventListener('touchstart', longPressData.touchStartHandler)
  el.removeEventListener('touchend', longPressData.touchEndHandler)
  el.removeEventListener('touchmove', longPressData.touchMoveHandler)
}

/**
 * 初始化长按事件处理函数
 * @param el 目标元素
 */
const initHandlers = (el: LongPressElement) => {
  const longPressData = el._longPress
  if (!longPressData) return
  const { options } = longPressData

  // PC端鼠标按下
  longPressData.mouseDownHandler = (e: MouseEvent) => {
    // 阻止右键菜单(仅左键触发)
    if (e.button !== 0) return

    // 阻止默认行为/冒泡
    if (options.preventDefault) e.preventDefault()
    if (options.stopPropagation) e.stopPropagation()

    // 记录起始坐标
    longPressData.startX = e.clientX
    longPressData.startY = e.clientY
    longPressData.isPressing = true

    // 设置长按定时器
    longPressData.timer = window.setTimeout(() => {
      if (longPressData.isPressing) {
        options.handler(e)
        longPressData.isPressing = false
      }
    }, options.duration!) as unknown as number
  }

  // 移动端触摸开始
  longPressData.touchStartHandler = (e: TouchEvent) => {
    // 阻止默认行为/冒泡
    if (options.preventDefault) e.preventDefault()
    if (options.stopPropagation) e.stopPropagation()

    // 记录起始坐标(取第一个触摸点)
    const touch = e.touches[0]
    longPressData.startX = touch.clientX
    longPressData.startY = touch.clientY
    longPressData.isPressing = true

    // 设置长按定时器
    longPressData.timer = window.setTimeout(() => {
      if (longPressData.isPressing) {
        options.handler(e)
        longPressData.isPressing = false
      }
    }, options.duration!) as unknown as number
  }

  // PC端鼠标抬起
  longPressData.mouseUpHandler = (e: MouseEvent) => {
    clearLongPressState(el, e)
  }

  // 移动端触摸结束
  longPressData.touchEndHandler = (e: TouchEvent) => {
    clearLongPressState(el, e)
  }

  // PC端鼠标移动
  longPressData.mouseMoveHandler = (e: MouseEvent) => {
    if (!longPressData.isPressing) return

    // 计算移动距离,超过阈值取消长按
    const distance = calculateDistance(
      longPressData.startX,
      longPressData.startY,
      e.clientX,
      e.clientY
    )

    if (distance > options.moveThreshold!) {
      clearLongPressState(el, e)
    }
  }

  // 移动端触摸移动
  longPressData.touchMoveHandler = (e: TouchEvent) => {
    if (!longPressData.isPressing) return

    // 计算移动距离,超过阈值取消长按
    const touch = e.touches[0]
    const distance = calculateDistance(
      longPressData.startX,
      longPressData.startY,
      touch.clientX,
      touch.clientY
    )

    if (distance > options.moveThreshold!) {
      clearLongPressState(el, e)
    }
  }

  // PC端鼠标离开元素
  longPressData.mouseLeaveHandler = (e: MouseEvent) => {
    clearLongPressState(el, e)
  }
}

/**
 * 清理所有长按相关资源
 * @param el 目标元素
 */
const cleanup = (el: LongPressElement) => {
  // 清除状态和定时器
  clearLongPressState(el)
  
  // 解绑事件
  unbindEvents(el)
  
  // 删除扩展属性
  delete el._longPress
}

/**
 * v-longPress 自定义指令实现
 */
export const longPressDirective: ObjectDirective<LongPressElement, LongPressOptions | (() => void)> = {
  /**
   * 指令挂载时初始化
   */
  mounted(el: LongPressElement, binding: DirectiveBinding<LongPressOptions | (() => void)>) {
    // 1. 解析指令参数
    let options: LongPressOptions = {
      ...DEFAULT_OPTIONS,
      handler: () => {}
    }

    if (typeof binding.value === 'function') {
      // 直接传函数:作为长按触发回调,使用默认配置
      options.handler = binding.value
    } else if (typeof binding.value === 'object' && binding.value !== null) {
      // 传对象:合并配置
      options = {
        ...DEFAULT_OPTIONS,
        ...binding.value
      }
    }

    // 校验必填项
    if (typeof options.handler !== 'function') {
      console.warn('[v-longPress] 必须指定有效的长按回调函数')
      return
    }

    // 2. 初始化长按状态
    el._longPress = {
      options,
      timer: null,
      startX: 0,
      startY: 0,
      isPressing: false,
      mouseDownHandler: () => {},
      touchStartHandler: () => {},
      mouseUpHandler: () => {},
      touchEndHandler: () => {},
      mouseMoveHandler: () => {},
      touchMoveHandler: () => {},
      mouseLeaveHandler: () => {}
    }

    // 3. 初始化事件处理函数
    initHandlers(el)

    // 4. 绑定事件监听
    bindEvents(el)

    // 5. 添加长按样式提示
    el.style.cursor = 'pointer'
  },

  /**
   * 指令更新时处理参数变化
   */
  updated(el: LongPressElement, binding: DirectiveBinding<LongPressOptions | (() => void)>) {
    // 先清理旧配置
    cleanup(el)
    
    // 重新初始化
    this.mounted(el, binding)
  },

  /**
   * 指令卸载时清理资源
   */
  unmounted(el: LongPressElement) {
    cleanup(el)
  }
}

/**
 * 全局注册长按指令
 * @param app Vue应用实例
 * @param directiveName 指令名称,默认longPress
 */
export const setupLongPressDirective = (app: App, directiveName: string = 'longPress') => {
  app.directive(directiveName, longPressDirective)
}

// TypeScript 类型扩展
declare module 'vue' {
  export interface ComponentCustomDirectives {
    longPress: typeof longPressDirective
  }
}

🚀 快速上手

1. 全局注册指令(main.ts)

在 Vue3 入口文件中注册指令,全局可用:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { setupLongPressDirective } from './directives/v-longPress'

const app = createApp(App)

// 注册长按指令(默认名称v-longPress)
setupLongPressDirective(app)

app.mount('#app')

2. 基础使用(直接传回调)

最简单的用法:直接传递长按触发的回调函数,使用默认配置(500ms长按时长):

<template>
  <!-- PC端鼠标长按/移动端触摸长按触发 -->
  <button v-longPress="handleLongPress">
    长按500ms触发
  </button>
</template>

<script setup lang="ts">
// 长按触发回调
const handleLongPress = (e: MouseEvent | TouchEvent) => {
  console.log('长按触发', e)
  alert('长按成功!')
}
</script>

3. 高级使用(自定义配置)

通过对象参数配置完整的长按规则,支持自定义时长、移动阈值、取消回调等:

<template>
  <div class="card">
    <div 
      class="delete-btn"
      v-longPress="{
        duration: 800,          // 长按800ms触发
        moveThreshold: 10,      // 移动超过10px取消
        handler: handleDelete,  // 长按触发回调
        cancelHandler: handleCancel, // 长按取消回调
        preventDefault: true,   // 阻止默认行为
        stopPropagation: true   // 阻止事件冒泡
      }"
    >
      长按删除(800ms)
    </div>
  </div>
</template>

<script setup lang="ts">
// 长按删除回调
const handleDelete = () => {
  console.log('执行删除操作')
  if (confirm('确定要删除这条数据吗?')) {
    // 实际业务逻辑:调用删除接口
    alert('删除成功!')
  }
}

// 长按取消回调
const handleCancel = () => {
  console.log('长按取消')
  // 可添加取消提示,如:
  // ElMessage.info('已取消删除')
}
</script>

<style scoped>
.card {
  padding: 20px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  width: 300px;
  margin: 20px;
}
.delete-btn {
  color: #ff4d4f;
  cursor: pointer;
  padding: 8px 16px;
  border: 1px solid #ff4d4f;
  border-radius: 4px;
  display: inline-block;
}
.delete-btn:hover {
  background: #fff2f2;
}
</style>

4. 动态配置长按指令

适配动态变化的长按配置(如根据不同状态调整长按时长):

<template>
  <div>
    <div>
      <label>长按时长(ms):</label>
      <input 
        type="number" 
        v-model.number="pressDuration"
        min="100"
        max="2000"
        step="100"
      />
    </div>
    
    <button 
      v-longPress="{
        duration: pressDuration,
        handler: handleDynamicLongPress,
        cancelHandler: handleDynamicCancel
      }"
    >
      动态配置长按按钮
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

// 动态长按时长
const pressDuration = ref(500)

// 动态长按回调
const handleDynamicLongPress = () => {
  alert(`长按${pressDuration.value}ms触发成功!`)
}

// 动态取消回调
const handleDynamicCancel = () => {
  console.log(`长按${pressDuration.value}ms取消`)
}
</script>

5. 结合UI库实现长按菜单

实际业务中常结合下拉菜单实现长按操作(以Element Plus为例):

<template>
  <div>
    <el-button 
      ref="btnRef"
      v-longPress="{
        handler: openContextMenu,
        duration: 600
      }"
    >
      长按打开操作菜单
    </el-button>
    
    <!-- 自定义上下文菜单 -->
    <el-dropdown 
      ref="dropdownRef"
      :visible="menuVisible"
      @visible-change="menuVisible = false"
    >
      <el-dropdown-menu>
        <el-dropdown-item @click="handleEdit">编辑</el-dropdown-item>
        <el-dropdown-item @click="handleCopy">复制</el-dropdown-item>
        <el-dropdown-item @click="handleDelete" divided>删除</el-dropdown-item>
      </el-dropdown-menu>
    </el-dropdown>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

const btnRef = ref<HTMLButtonElement>(null)
const dropdownRef = ref()
const menuVisible = ref(false)

// 打开上下文菜单
const openContextMenu = () => {
  if (btnRef.value && dropdownRef.value) {
    const rect = btnRef.value.getBoundingClientRect()
    // 设置菜单位置
    dropdownRef.value.$el.style.position = 'absolute'
    dropdownRef.value.$el.style.top = `${rect.bottom + 10}px`
    dropdownRef.value.$el.style.left = `${rect.left}px`
    menuVisible.value = true
  }
}

// 编辑操作
const handleEdit = () => {
  ElMessage.info('执行编辑操作')
  menuVisible.value = false
}

// 复制操作
const handleCopy = () => {
  ElMessage.success('复制成功')
  menuVisible.value = false
}

// 删除操作
const handleDelete = () => {
  ElMessage.warning('执行删除操作')
  menuVisible.value = false
}
</script>

🔧 核心知识点解析

1. 长按实现原理

长按的核心逻辑是**“按下计时 + 抬起/移动取消”**:

  1. 按下(mousedown/touchstart):记录起始坐标,启动定时器,达到指定时长触发回调。
  2. 抬起/离开(mouseup/touchend/mouseleave):清除定时器,取消长按。
  3. 移动(mousemove/touchmove):计算移动距离,超过阈值自动取消长按,防止误触。

2. 跨端兼容处理

  • PC端:监听 mousedown/mouseup/mousemove/mouseleave 事件。
  • 移动端:监听 touchstart/touchend/touchmove 事件。
  • 统一的坐标计算逻辑,兼容鼠标和触摸事件的坐标获取方式。

3. 防误触优化

通过 moveThreshold 移动阈值实现防误触:

  • 默认阈值为5px,按下后移动超过该距离自动取消长按。
  • 计算两点间距离使用勾股定理:distance=(x2x1)2+(y2y1)2distance = \sqrt{(x2-x1)^2 + (y2-y1)^2}

4. 内存泄漏防护

  • unmounted 钩子中清理定时器、解绑所有事件监听。
  • updated 钩子中先清理旧配置,再初始化新配置。
  • 使用元素扩展属性存储状态,卸载时删除属性释放内存。

5. TypeScript 类型优化

  • 定义 LongPressOptions 接口,明确配置项类型。
  • 扩展 HTMLElement 类型,添加长按状态属性。
  • 支持两种参数类型(函数/对象),类型推导自动适配。

📋 配置项说明

配置项 类型 默认值 说明
duration number 500 长按触发时长,单位ms
handler (e: MouseEvent | TouchEvent) => void - 长按触发的回调函数(必填)
cancelHandler (e: MouseEvent | TouchEvent) => void - 长按取消的回调函数(可选)
moveThreshold number 5 移动阈值,超过则取消长按,单位px
preventDefault boolean true 是否阻止默认行为(如移动端长按弹出菜单)
stopPropagation boolean false 是否阻止事件冒泡

🎯 常见使用场景

场景1:移动端列表项长按操作

<template>
  <div class="list">
    <div 
      class="list-item"
      v-for="item in list"
      :key="item.id"
      v-longPress="{
        duration: 600,
        handler: () => showActionSheet(item),
        cancelHandler: () => hideActionSheet()
      }"
    >
      {{ item.name }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const list = ref([
  { id: 1, name: '微信' },
  { id: 2, name: '支付宝' },
  { id: 3, name: '抖音' }
])

// 显示操作面板
const showActionSheet = (item: any) => {
  console.log('长按触发', item)
  // 实际开发中可调用移动端ActionSheet组件
  alert(`长按${item.name},显示操作菜单`)
}

// 隐藏操作面板
const hideActionSheet = () => {
  console.log('长按取消')
}
</script>

<style scoped>
.list {
  width: 300px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}
.list-item {
  padding: 12px 16px;
  border-bottom: 1px solid #f0f0f0;
  cursor: pointer;
}
.list-item:last-child {
  border-bottom: none;
}
.list-item:hover {
  background: #f5f5f5;
}
</style>

场景2:PC端长按批量选择

<template>
  <div class="table-container">
    <el-table 
      :data="tableData"
      @row-click="handleRowClick"
    >
      <el-table-column 
        label="名称"
        prop="name"
      >
        <template #default="scope">
          <div 
            v-longPress="{
              handler: () => toggleSelect(scope.row),
              duration: 400
            }"
          >
            {{ scope.row.name }}
          </div>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="100">
        <template #default="scope">
          <el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
        </template>
      </el-table-column>
    </el-table>
    
    <div class="selected-count" v-if="selectedRows.length">
      已选择 {{ selectedRows.length }} 条数据
      <el-button size="small" @click="selectedRows = []">清空选择</el-button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

// 模拟表格数据
const tableData = ref([
  { id: 1, name: '数据1' },
  { id: 2, name: '数据2' },
  { id: 3, name: '数据3' }
])

// 选中的行
const selectedRows = ref<any[]>([])

// 长按切换选择状态
const toggleSelect = (row: any) => {
  const index = selectedRows.value.findIndex(item => item.id === row.id)
  if (index > -1) {
    selectedRows.value.splice(index, 1)
  } else {
    selectedRows.value.push(row)
  }
}

// 点击行(取消批量选择)
const handleRowClick = (row: any) => {
  selectedRows.value = []
}

// 编辑操作
const handleEdit = (row: any) => {
  console.log('编辑', row)
}
</script>

<style scoped>
.table-container {
  width: 500px;
  margin: 20px;
}
.selected-count {
  margin-top: 10px;
  padding: 8px;
  background: #f5f5f5;
  border-radius: 4px;
}
</style>

🚨 注意事项

  1. 移动端默认行为:长按会触发浏览器默认菜单(如复制、保存图片),需设置 preventDefault: true 阻止。
  2. 触摸事件穿透:移动端需注意事件穿透问题,可结合 pointer-events: none 处理。
  3. 性能优化:避免在长按回调中执行复杂计算,建议使用防抖/节流。
  4. 兼容性
    • PC端:所有现代浏览器均支持。
    • 移动端:iOS Safari/Android Chrome 均支持,低版本安卓需测试。
  5. 右键菜单冲突:PC端长按左键触发,避免与右键菜单冲突,可通过 e.button !== 0 过滤右键。

📌 总结

本文实现的 v-longPress 指令具备以下核心优势:

  1. 跨端兼容:同时支持PC端和移动端,一套代码适配多端。
  2. 体验优秀:内置防误触机制,支持自定义移动阈值,避免误触发。
  3. 配置灵活:支持自定义长按时长、回调函数、事件行为,适配各种业务场景。
  4. 类型安全:基于TypeScript开发,类型提示完善,减少开发错误。
  5. 性能优异:自动清理定时器和事件监听,无内存泄漏问题。

这个指令可以直接集成到你的Vue3项目中,解决各种长按交互需求。如果需要进一步扩展,可以在此基础上增加:

  • 支持长按进度条显示
  • 支持多段长按(短按/中按/长按触发不同操作)
  • 支持长按拖拽
  • 支持自定义长按样式(如按压反馈)

希望这篇文章对你有帮助,欢迎点赞、收藏、评论交流!

❌
❌