带前端新人那会儿,我最常听到的一句话就是:“JS基础我都看完了,但一到写代码就卡住。”其实这不怪大家,基础语法和真实场景之间隔着一层东西,那层东西叫“案例”。它能把零散的知识点串成一条线,让你知道什么场景该用什么写法。这个系列就是干这件事的。这篇《JS基础案例02》聚焦前端日常开发里曝光率最高的几个场景:字符串处理、数组操作、表单联动、异步任务,还有面试绕不开的作用域与闭包。每个案例我都会给完整代码、解释背后的原理,再把我实际踩过的坑一并说出来,适合刚入门前端、正在刷面试题的朋友,也适合写了一阵子代码但感觉基础不牢的同行查漏补缺。
1. 案例一:字符串处理与URL校验
1.1 为什么字符串操作是前端的基本功
但凡做过表单校验、搜索过滤、路由参数解析这类功能,你就绕不开字符串。热搜词里“js判断字符串是否包含”和“js验证url有效性”这两个问题我几乎每次带新人都能碰到,很能说明问题。它们看起来简单,但真要处理边界条件时,能写对的人并不多。
我们先从最常用的几个API说起。
includes(searchString, position):判断一个字符串是否包含另一个字符串,返回布尔值。ES6引入,比indexOf可读性好太多。第二个参数是起始搜索位置,默认0。startsWith(searchString, position):判断字符串是否以某个子串开头。endsWith(searchString, endPosition):判断字符串是否以某个子串结尾。indexOf(searchString):返回子串第一次出现的索引,找不到返回-1。老牌API,现在仍有很多兼容性场景在用。match()和replace():配合正则做模式匹配和替换。
这些API本身没什么难理解的,难点在于“什么时候用哪个”。我的原则是:简单包含判断用includes;需要知道位置用indexOf;需要模式匹配,比如校验手机号、邮箱格式,直接用正则配合test()。
1.2 URL有效性的校验:正则外有更稳的方案
“js验证url有效性”这个问题挺有意思。很多人第一反应就是去写正则,但URL的正则相当难写完美,既要匹配协议、域名,又要管端口、路径、查询参数,边界情况多得吓人。而且网上流传的正则很多都有问题,要么漏掉IPv6地址,要么不支持带特殊字符的路径。
我的建议是,能不用正则就不用。浏览器环境里有一个现成的方案:URL构造函数。
javascript复制function isValidUrl(urlString) {
try {
new URL(urlString);
return true;
} catch (error) {
return false;
}
}
console.log(isValidUrl('https://example.com/path?name=js')); // true
console.log(isValidUrl('ftp://example.com')); // true,URL也支持其他协议
console.log(isValidUrl('not-a-url')); // false
这个方案的原理是:new URL()会按照WHATWG URL标准去解析字符串,如果格式不合法,直接抛TypeError。我们只需要捕获异常就行,完全不用自己造轮子。
但你注意,这个函数只是判断“格式上合法”,不代表这个URL一定能访问。比如https://不存在的域名23333.com,new URL()也能解析成功。所以如果你需要做“可访问性”校验,那就得用fetch去请求了(会有跨域问题,一般需要后端配合)。
另外有个细节:如果只允许http和https协议,可以加一层协议白名单判断。
javascript复制function isWebUrl(urlString) {
try {
const parsed = new URL(urlString);
return ['http:', 'https:'].includes(parsed.protocol);
} catch (error) {
return false;
}
}
提示:
URL.prototype.protocol返回的值是带冒号的,比如'https:',用includes判断时记得带冒号,别写成'http'。
1.3 实操:写一个用户输入的表单URL校验
我结合真实项目需求,把URL校验放进表单场景里。假设有一个用户资料编辑页,需要用户填写个人主页地址,前端提交前要校验格式并标准化。
javascript复制function validateAndNormalizeUrl(input) {
if (!input || typeof input !== 'string') {
return { valid: false, reason: 'URL不能为空' };
}
let trimmedUrl = input.trim();
// 用户经常不写协议头,这里自动补上
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmedUrl)) {
trimmedUrl = 'https://' + trimmedUrl;
}
try {
const parsed = new URL(trimmedUrl);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return { valid: false, reason: '仅支持http和https协议' };
}
if (!parsed.hostname.includes('.') && parsed.hostname !== 'localhost') {
return { valid: false, reason: '域名格式不正确' };
}
return { valid: true, url: parsed.toString() };
} catch (error) {
return { valid: false, reason: 'URL格式不正确' };
}
}
console.log(validateAndNormalizeUrl('example.com/profile'));
// { valid: true, url: 'https://example.com/profile' }
这段代码做了三件事:去掉首尾空格、自动补协议头、用URL对象做解析和标准化。第5行那个正则的作用是判断字符串开头是否已经是协议格式,[a-zA-Z][a-zA-Z0-9+.-]*匹配协议名,:\/\/匹配://。这里注意转义,正则里的/要先写成\/。
为什么还要检查域名里有没有点?因为URL解析器对单段域名(比如https://abc)是放行的。但用户表单里如果填abc,多半是漏写了域名后缀,拦截掉体验更好。当然localhost本身合法,需要单独放行。
1.4 字符串处理的高频坑
处理字符串踩坑最多的是中文字符和转义问题。String.prototype.length统计的是UTF-16码元,不是“字符个数”。一个中文字符在UTF-16里通常占1个码元,但某些生僻字或emoji会占2个码元,length就会比预期多。
javascript复制const emoji = '💻';
console.log(emoji.length); // 2,而不是1
如果需要按用户感知的字符个数来截断或统计,推荐用Array.from(str)或for...of,它们按码点遍历。
javascript复制function countCharacters(str) {
return Array.from(str).length;
}
console.log(countCharacters('💻前端')); // 3
另一个坑是replace不会修改原字符串,它返回新字符串。很多新手写str.replace('a', 'b')之后发现原字符串没变,就是这个原因。而且replace的第一个参数如果是字符串,只替换第一个匹配;想全局替换必须用正则加g标志,或者用replaceAll。
javascript复制const str = 'a-b-c';
const replaceFirst = str.replace('-', '+'); // 'a+b-c'
const replaceAll = str.replaceAll('-', '+'); // 'a+b+c'
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 案例二:数组操作与扩展运算符
2.1 面试题里那道“合并数组”,到底考什么
热搜词里有个非常具体的问题:“js怎么用扩展运算符把一个数组里面的值都添加到另外一个数组”。这个操作场景太常见了,我几乎每周都要用。比如分页接口返回数据,需要把新页的数据追加到列表末尾;或者前端收集多个来源的数组,想合并成一个再渲染。
最直接的写法就是扩展运算符展开数组:
javascript复制const oldArr = [1, 2, 3];
const newArr = [4, 5];
oldArr.push(...newArr);
console.log(oldArr); // [1, 2, 3, 4, 5]
展开运算符...在函数调用时会把数组拆成独立参数传给push,效果等同于push(4, 5)。这个操作没有生成中间数组,也不会改动newArr,只修改了oldArr。
但如果你想要“合并成一个新数组,且不修改原数组”,更推荐用concat或直接[...oldArr, ...newArr]:
javascript复制const merged = [...oldArr, ...newArr];
console.log(merged); // [1, 2, 3, 4, 5]
console.log(oldArr); // [1, 2, 3],原数组不变
两种写法都能达到目的,区别在于是否修改原数组。在React/Vue这类框架里,我们通常要维护“不可变数据”,所以用[...]合并的写法更常见。在纯函数任务里,我默认不修改传入的数组,这也是一个很重要的代码习惯。
2.2 map、filter、reduce:数组三件套的正确打开方式
刷题也好、工作也好,map、filter、reduce这三个方法几乎占据了数组处理的大半江山。很多人知道它们怎么用,但分不清什么场景该选哪个。
map:数组长度不变,逐个元素做变换。适合把一种数据结构转成另一种,比如把用户对象数组映射成name字符串数组。filter:按条件筛出子集,长度可能变化。适合做搜索过滤、状态筛选。reduce:将整个数组收敛成单个值,这个值可以是数字、对象、甚至嵌套数组。适合做求和、分组、统计。
选型的关键是问自己一个问题:“我要的结果,长度跟原数组一样吗?”一样用map;变短用filter;变成一个原子值或对象,用reduce。
看一个综合例子。假想一个任务列表,需要统计未完成任务的数量,并提取所有已完成任务的标题:
javascript复制const tasks = [
{ id: 1, title: '学习JS基础', done: true },
{ id: 2, title: '写一篇博客', done: false },
{ id: 3, title: '整理面试题', done: false },
];
const doneTitles = tasks
.filter(task => task.done)
.map(task => task.title);
console.log(doneTitles); // ['学习JS基础']
const todoCount = tasks.reduce((count, task) => {
return task.done ? count : count + 1;
}, 0);
console.log(todoCount); // 2
reduce的第二个参数0是初始值,千万别漏。漏了初始值,第一轮count就会是数组第一个元素(对象),然后你会发现task.done ? count : count + 1直接变成了字符串拼接,结果完全不对。这是我见过最常见的reduce错误。
2.3 扩展运算符的几个实用变体
扩展运算符不只能用在数组字面量和函数调用里,还能用在对象上。对象展开是ES2018的特性,用来做浅拷贝和合并非常顺手:
javascript复制const user = { name: '张三', age: 25 };
const updatedUser = { ...user, age: 26 };
console.log(updatedUser); // { name: '张三', age: 26 }
console.log(user.age); // 25,原对象没变
const role = { role: 'admin' };
const mergedUser = { ...user, ...role };
console.log(mergedUser); // { name: '张三', age: 25, role: 'admin' }
这里有个关键点:对象展开是浅拷贝。嵌套的对象或数组仍然共享引用。如果你改了嵌套对象里的属性,原对象也会跟着变。需要深拷贝时别用展开运算符,得用structuredClone或者序列化手段。
还有一个小技巧,用展开运算符去除对象里的某个属性:
javascript复制const { password, ...safeUser } = userWithPassword;
这个rest语法在解构赋值中会把剩下的属性收集成一个新对象,很适合把敏感字段剔除后打印日志。
2.4 数组操作里的性能与副作用
关于性能,我的建议很明确:数据量小(几百条以内)随便用数组方法,可读性优先。数据量达到几万级,再考虑for循环优化。实际上绝大多数前端项目卡顿,原因都不在数组方法上,而在重复渲染和网络请求。
副作用这一点更值得注意。前端项目里数据共享很普遍,两个模块可能引用同一个数组。你对这个数组执行sort、reverse、splice时,如果没意识到这些方法会修改原数组,很容易在项目里埋下隐蔽的bug。比如:
javascript复制const defaultOptions = ['a', 'b', 'c'];
function getOptions() {
return defaultOptions.sort().reverse();
}
const result = getOptions();
console.log(defaultOptions); // ['c', 'b', 'a'],原数组被改了!
排查这种问题很痛苦,因为错误往往出现在“另一个完全不相干的功能”里。我的习惯是:在代码里统一约定,凡是数组方法,优先用返回新数组的形式,比如[...arr].sort()或arr.filter(...);非改不可时,在函数名或注释里明确标注“会修改原数组”。
3. 案例三:省市区三级联动
3.1 联动组件的经典范式
三级联动是前端基础案例里非常经典的一个,热搜词“js三级联动”说明大家确实很需要它。它本身就是对事件处理、DOM操作、数据结构设计的综合练习。一个省份选择框决定城市列表,城市选择框决定区县列表,典型的联动逻辑。
实现思路很多,我这里分享一种朴素但完整的方式,不依赖框架,用原生JS你就能感受到数据驱动视图的本质。
第一步是准备数据。现实中省市区的数据量很大,通常由后端返回。为了演示,我用一种缩略的数据结构:
javascript复制const regionData = [
{
id: '11',
name: '北京市',
children: [
{ id: '1101', name: '北京市', children: [
{ id: '110101', name: '东城区' },
{ id: '110102', name: '西城区' },
]}
]
},
{
id: '44',
name: '广东省',
children: [
{ id: '4401', name: '广州市', children: [
{ id: '440103', name: '荔湾区' },
{ id: '440106', name: '天河区' },
]},
{ id: '4403', name: '深圳市', children: [
{ id: '440304', name: '福田区' },
{ id: '440305', name: '南山区' },
]}
]
}
];
这种嵌套结构在应用中很常见。每个节点的children属性要么是数组,要么不存在。我们的联动逻辑就是“根据上一级选中的项,找到对应节点的children,再填充下一个下拉框”。
3.2 实现一个纯原生的三级联动
HTML部分很简单,三个select元素:
html复制<select id="province"></select>
<select id="city"></select>
<select id="district"></select>
JS部分,我封装一个简单的类,方便复用:
javascript复制class Cascader {
constructor(container, data) {
this.provinceSelect = container.querySelector('#province');
this.citySelect = container.querySelector('#city');
this.districtSelect = container.querySelector('#district');
this.data = data;
this.init();
}
init() {
this.fillOptions(this.provinceSelect, this.data.map(item => ({
value: item.id,
label: item.name,
})));
this.provinceSelect.addEventListener('change', () => this.onProvinceChange());
this.citySelect.addEventListener('change', () => this.onCityChange());
this.provinceSelect.dispatchEvent(new Event('change'));
}
fillOptions(select, list) {
select.innerHTML = '';
list.forEach(item => {
const option = document.createElement('option');
option.value = item.value;
option.textContent = item.label;
select.appendChild(option);
});
}
onProvinceChange() {
const province = this.data.find(item => item.id === this.provinceSelect.value);
if (!province) return;
this.fillOptions(this.citySelect, province.children.map(item => ({
value: item.id,
label: item.name,
})));
this.citySelect.dispatchEvent(new Event('change'));
}
onCityChange() {
const province = this.data.find(item => item.id === this.provinceSelect.value);
if (!province) return;
const city = province.children.find(item => item.id === this.citySelect.value);
if (!city) return;
this.fillOptions(this.districtSelect, (city.children || []).map(item => ({
value: item.id,
label: item.name,
})));
}
}
new Cascader(document.getElementById('app'), regionData);
这里面有一个细节很多人会忽略:init()最后我手动dispatchEvent(new Event('change')),目的是让页面加载时就自动渲染出省级对应的城市和区县。如果不做这一步,初始化后城市下拉框是空的,用户必须手动切换一下省份才出来,体验就很糟。
另外一个细节是容错判断。find找不到对应节点时直接返回,避免因为数据缺失导致报错。
3.3 联动数据结构的选型
说到数据结构,其实业界还有一种更扁平化的设计:维护三个列表,省级列表、市级列表、区县列表,每项带父级ID。比如cityList里每一项有一个provinceId字段。这样选省时,用filter(city => city.provinceId === selectedProvinceId)就能拿到城市列表。
两种结构的对比:
| 维度 | 嵌套树形结构 | 扁平结构 |
|---|---|---|
| 取子级 | 直接访问node.children | 需要filter循环 |
| 数据冗余 | 较低 | 每个子项都要存父级ID |
| 后端接口友好度 | 常见于一次性全量返回 | 常见于按需查询 |
| 内存占用 | 低 | 略高 |
我平时接后端接口时,如果后端一次返回全量数据,多半是嵌套结构;如果按需请求(选省请求城市、选市请求区县),那扁平结构更便于按父级ID查询。前端的选择应该跟着后端接口风格走,别生硬套一种。
3.4 联动组件的细节体验
除了核心逻辑,联动组件还有一批体验细节值得打磨:
- 保持联动选择:选中“北京市”切到“广东省”,城市和区县必须跟着重置,不能残留上一个省的数据。
- 默认选中:建议默认选中第一项,并且让保存按钮的默认值可靠。
- 禁用态:当数据还没加载完时,应该禁用下级下拉框,避免用户选到空气。
- 键盘操作:原生
select天然支持键盘,这点比自研组件有优势,也说明不必为了“酷炫”而过度自定义组件。
再补充一个实际项目中容易踩的坑:如果你选中的值是通过异步接口拿到的(比如回显用户资料),你得等所有下拉框数据都就绪后再赋值,否则会出现“显示名称但值对不上”的诡异问题。我的做法是先把数据加载完,再统一执行赋值和联动。
4. 案例四:用Worker处理大文件上传
4.1 前端为什么需要Web Worker
热搜词里“前端使用worker上传大文件”是个很典型的进阶场景。当你在浏览器里处理大文件上传时,有两个问题非常棘手:一是上传过程中主线程卡顿,用户体验像“死机”;二是文件很大时,直接丢给后端不仅容易超时,失败后还得整个重传。
Web Worker的价值在于它开辟了独立于主线程的执行环境,可以跑密集计算而不冻结页面。文件上传里最常见的密集计算是计算文件指纹(hash),用来做秒传校验或断点续传。一个几百MB的文件,在主线程算MD5/SHA-1,页面可能卡好几秒甚至更久,用Worker去算就毫无压力。
这个案例我用一个文件分片上传的简化版来说明。真正的完整项目还要涉及后端接口约定、分片大小协商、并发控制等,这里只聚焦前端的实现思路。
4.2 分片读取与Worker通信
先看主线程部分:读取文件,分片,发给Worker计算hash。
javascript复制const fileInput = document.querySelector('#file');
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const worker = new Worker('hash-worker.js');
const chunkSize = 2 * 1024 * 1024; // 每个分片2MB
const chunks = [];
for (let i = 0; i < file.size; i += chunkSize) {
chunks.push(file.slice(i, i + chunkSize));
}
worker.postMessage({ chunks, totalSize: file.size });
worker.onmessage = (event) => {
const { hash } = event.data;
console.log('文件hash计算完毕:', hash);
startUpload(chunks, hash);
worker.terminate();
};
});
这里的File.slice是分片关键API,它不复制数据,只是创建一个引用原始文件某个范围的Blob对象,所以就算切了几百片,内存消耗也不大。
Worker内部代码:
javascript复制// hash-worker.js
self.onmessage = async (event) => {
const { chunks } = event.data;
const buffer = new Uint8Array(await chunks[0].arrayBuffer());
// 这里简化处理,实际项目需要用 crypto.subtle.digest 循环计算
// 或者引入 spark-md5 之类的库做增量hash
const hash = simpleHash(buffer);
self.postMessage({ hash });
};
function simpleHash(buffer) {
let hash = 0;
for (let i = 0; i < buffer.length; i++) {
hash = (hash << 5) - hash + buffer[i];
hash |= 0;
}
return hash.toString(16);
}
我故意把hash算法写得很简单,是出于两个考虑:一是告诉你Worker的通信和计算模型才是重点,算法可以替换成spark-md5或crypto.subtle.digest;二是在浏览器环境里,crypto.subtle.digest('SHA-256', data)是异步方法,配合Worker使用时要注意“增量hash”需要把每个分片结果拼起来再哈希,初学者很容易在这里搞混。
提示:Worker里无法直接使用DOM API,但
fetch是可以用的。所以你也可以在Worker里做网络请求,比如直接把分片上传的请求丢给Worker发,主线程只负责更新进度条。
4.3 主线程分片上传流程
算完hash之后,进入上传流程。这里我把“秒传校验”也一并做了,流程是:
- 先调用后端接口,把文件hash发给后端,询问是否已存在。
- 如果已存在,前端直接提示“秒传成功”,不需要上传。
- 如果不存在,后端返回哪些分片已经上传过,前端只传缺失的分片。
- 所有分片传完后,调用合并接口,后端把分片合成为完整文件。
前端上传部分的伪代码:
javascript复制async function startUpload(chunks, hash) {
const checkResponse = await fetch('/api/file/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: file.name, fileSize: file.size, hash }),
});
const { uploadedChunks = [] } = await checkResponse.json();
const uploadTasks = chunks.map((chunk, index) => {
if (uploadedChunks.includes(index)) {
return Promise.resolve({ index, status: 'skipped' });
}
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('hash', hash);
formData.append('index', index);
return fetch('/api/file/upload', {
method: 'POST',
body: formData,
}).then(() => ({ index, status: 'uploaded' }));
});
const results = await Promise.allSettled(uploadTasks);
const failed = results.filter(r => r.status === 'rejected');
if (failed.length === 0) {
await fetch('/api/file/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash }),
});
console.log('上传完成');
} else {
console.error('有分片失败,需要重传', failed);
}
}
Promise.allSettled在这里用得恰当,它能等所有任务都结束,再统一判断哪些失败、哪些成功,而不是在第一个失败时就整体退出。
4.4 大文件上传的常见坑
- 分片大小设置:2MB到5MB是一个常见区间。太少了请求次数爆炸,太多了单次失败的成本高、重试慢。我一般按文件大小动态调整:小于100MB用2MB分片,更大用5MB。
- 并发控制:
Promise.all一把梭会在分片几百个时瞬间发出几十个请求,可能会被浏览器或服务端限流。更稳的做法是用并发池,把并发数限制在3到5个。 - 进度条计算:进度分为“已上传字节 ÷ 总字节”,如果包含秒传跳过的分片,要注意跳过也算进度,不然用户会看到进度条卡住。
- Worker兼容性与生命周期:
new Worker在大多数现代浏览器都支持,但用完要记得terminate(),否则会一直占资源。 - 刷新中断:大文件上传过程中用户刷新页面,进度就丢了。要真正做到断点续传,需要用
localStorage或IndexedDB记录已上传分片序号,下次进来先读缓存。这部分代码会多一些,但架构思路是一样的。
5. 案例五:从零散知识到面试题——作用域与闭包
5.1 “前端八股文”为什么偏爱作用域和闭包
“前端面试题”和“前端八股文”这两个热搜词几乎是绑定出现的,而作用域与闭包永远是其中的高频考点。很多人觉得这是纸上谈兵,但在实际项目中,这俩货直接决定了你能不能看懂别人代码、能不能排查一些隐蔽bug。
作用域的核心规则是:函数内部可以访问外部变量,外部不能访问内部变量。这个规则的实现机制是作用域链,而每次函数创建时都会保存一个“定义时的作用域链快照”,这个快照就是闭包的来源。
闭包简单说就是:一个函数记住了它定义时所在作用域里的变量,即使这个作用域已经执行结束。最常见的例子是计数器:
javascript复制function createCounter() {
let count = 0;
return function() {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
createCounter()执行完之后,按理说局部变量count应该被垃圾回收了。但因为返回的函数还在引用count,JS引擎会把它保留下来。这就是闭包。
5.2 var与let的差异实验
面试里有个非常经典的考题:循环中使用var声明变量,点击事件中取i的值,结果全是最后一个。这个锅不在事件绑定,而在作用域。
javascript复制for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:3 3 3
原因是var声明的i是函数作用域,循环结束后i的值是3,三个定时器回调读的都是同一个i。
换成let就正常了:
javascript复制for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:0 1 2
不同之处在于let是块级作用域,每次循环都会创建一个新的i绑定,闭包捕获的也是各自独立的i。这个例子把“作用域、闭包、异步回调”三个知识点串起来了,面试答得清楚,说明是真的理解,不是背答案。
5.3 从闭包到防抖节流
闭包另一个实际应用是防抖和节流。这两个工具函数几乎每个前端项目都用得上,而它们的核心就是闭包保存状态。
防抖的思路:事件触发后延迟执行,延迟期内再次触发则重新计时。适合搜索框输入暂停后发起请求:
javascript复制function debounce(fn, delay = 300) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
const handleInput = debounce((value) => {
console.log('搜索请求:', value);
}, 500);
input.addEventListener('input', (e) => handleInput(e.target.value));
注意这里timer变量就是闭包的核心:每次调用返回的函数,都能访问到上一次调用留下的timer,所以才能实现“取消上一次的延迟”。
节流的思路:固定时间间隔内只执行一次。适合滚动监听、resize事件:
javascript复制function throttle(fn, interval = 200) {
let last = 0;
return function(...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}
5.4 垃圾回收与闭包的内存代价
闭包也不是没有代价。因为它会让外部函数的局部变量一直活在内存里,用得不好就会造成内存泄漏。我看过不少线上项目卡顿,就是因为事件监听器里绑了闭包,而监听器一直没被移除。
一个典型的错误:
javascript复制function attachEvents() {
const bigData = new Array(1000000).fill('x');
document.getElementById('btn').addEventListener('click', () => {
console.log(bigData.length);
});
}
这个回调闭包引用了bigData,只要按钮还在,bigData就永远无法被垃圾回收。如果每次点击都会重新调用attachEvents,就会创建越来越多的闭包和越来越大的内存占用。
在处理这类问题时,我现在的习惯是:结构清晰该用就用,但注意清理。比如在组件卸载或不需要监听时,调用removeEventListener,并确保被闭包引用的数据生命周期合理。
最后分享一个小习惯
我写这篇案例02的时候,回想了自己过去踩过的坑:URL正则想当然、reduce忘初始值、联动组件忘记触发初始渲染、大文件上传没控制并发……这些都是很小的事情,但每个都让我花过不少时间排查。所以这篇里我不光给代码,也把每个案例背后“为什么这么写”讲清楚,因为只有理解了原因,你才能在不同的场景里举一反三。
如果你读完觉得某个案例还不够深入,比如Worker的断点续传想看到完整实现,或者三级联动想和Vue/React结合,你可以顺着这个思路继续做扩展。我自己在项目里也一直是用这种方式:先搞懂一个最小闭环,再往里面加复杂度,这样每一步都是可控的。
