js--14是我给自己整理的第十四份JavaScript阶段笔记。跟前面的编号一样,这不是某个具体的开源项目,而是把我最近半年在前端项目、面试准备、日常脚本里反复踩到和用到的JS知识点做了一次大扫除。内容跨度确实有点大,从html+css+js基础语法一路到WPS里的js宏、音源脚本、Vite构建报错、Promise并发控制、原型链、Vue响应式、TypeScript对比,基本把我手头接触到的JS相关场景都装进去了。如果你自学前端有一段时间,想系统查漏补缺,或者正在准备面试,想快速过一遍高频考点,这份笔记应该能帮你省不少时间。我按七个大块来写,每个部分独立成章,感兴趣可以直接跳到对应位置。
1. 基础语法与高频API:先把手上的活干利索
很多人一上来就追框架、追工程化,反而把最基础的原生JS语法用得磕磕绊绊。我在面试候选人和帮同事review代码时,发现大量问题都出在字符串、数字、数组这几个最常用的API上。这一节不聊虚的,全是日常开发里真正高频的写法。
1.1 字符串包含判断:includes与indexOf的取舍
判断一个字符串里有没有子串,最常见的就是includes和indexOf。两者核心区别只有一个:includes返回布尔值,indexOf返回找到的下标,找不到就返回-1。
const text = 'Hello, JavaScript!'; console.log(text.includes('JavaScript')); // true console.log(text.indexOf('JavaScript')); // 7 console.log(text.includes('Vue')); // false console.log(text.indexOf('Vue')); // -1那到底用哪个?我个人的习惯是:只需要知道“有没有”的时候用includes,代码意图更清晰;需要知道“在哪个位置”或者要根据位置做截取、替换的时候用indexOf。还有个容易忽略的细节:includes是ES2016引入的,理论上比indexOf在非常老的浏览器里兼容性差一点,但现在还在维护的项目基本不用担心这个。另外这两个方法都是区分大小写的,要做不区分大小写的匹配,一般是先toLowerCase()再判断。
如果你要匹配的是复杂模式,比如邮箱格式、手机号格式,那就别用字符串方法了,直接上正则。text.includes('@')只能判断有没有@符号,但/^[^\s@]+@[^\s@]+\.[^\s@]+$/才能判断是不是一个像样的邮箱地址。记住一个原则:精确匹配用字符串方法,模式匹配用正则,别混着用。
1.2 数字处理:保留两位小数、判断整数,细节比想象中多
先说保留两位小数。大多数人的第一反应是num.toFixed(2),这个方法确实是常用方案,但它有个坑:四舍五入的精度在某些边界值上不符合直觉。
console.log((1.005).toFixed(2)); // "1.00" 而不是 "1.01" console.log((2.55).toFixed(1)); // "2.5" 而不是 "2.6"原因很简单,浮点数在二进制里不是精确表示的,1.005实际存储的值比1.005略小一点,所以toFixed四舍五入时掉下去了。如果对精度要求高,比如金额计算,建议先转成整数再处理,或者用Math.round(num * 100) / 100这种偏移方式,再配合toFixed(2)补位:
function formatMoney(num) { return (Math.round((num + Number.EPSILON) * 100) / 100).toFixed(2); }Number.EPSILON是ES6提供的“最小精度差”,补偿浮点误差很实用。
再说判断整数。Number.isInteger(3)是true,Number.isInteger(3.0)也是true,但Number.isInteger('3')是false,Number.isInteger(3.1)是false。这里要注意它和parseInt的区别:parseInt('3.9')返回3,但它不是“判断整数”,而是“截断取整”。如果你要判断一个变量是不是整数,优先用Number.isInteger。如果输入可能来自表单字符串,那要先转成number再判断:
const input = '42'; console.log(Number.isInteger(input)); // false console.log(Number.isInteger(Number(input))); // true1.3 数组三件套:map、filter、forEach的思维差异
数组方法里最容易混的就是map、filter、forEach,我在代码review时见过不少人把三者写混。记住它们的本质区别:
map:把数组每个元素映射成新值,返回一个等长度的新数组。filter:把不满足条件的元素过滤掉,返回一个长度小于等于原数组的新数组。forEach:只遍历,不关心返回值,没有新数组。
const nums = [1, 2, 3, 4, 5]; const doubled = nums.map(n => n * 2); // [2, 4, 6, 8, 10] const evens = nums.filter(n => n % 2 === 0); // [2, 4] nums.forEach(n => console.log(n)); // 遍历打印,没有返回值常见错误是在forEach里收集结果时忘了push,或者在map里只做赋值不return,结果得到一堆undefined。另一个常见误区是想“边遍历边过滤”,用forEach加上if判断再push到新数组,虽然能实现,但完全可以用filter一步到位,代码更简洁。React里渲染列表时,data.filter(...).map(...)这种链式写法非常实用,先过滤再映射,一气呵成。
forEach还有一个特点:不能用break或return跳出整个循环,return只是结束当前这次回调。想提前终止循环,要么用for...of,要么用some/every配合短路逻辑。
1.4 forEach后怎么知道“循环完了”:从回调到Promise的改造
搜索“js foearch怎么判断循环完了”的人大概率是把forEach拼错了,真正的名字是forEach。但这个问题本身很经典:forEach遍历完同步代码后,函数就结束了,它没有返回值,也没有类似“遍历完成”的回调。
你可能会试这样写:
let done = false; arr.forEach(item => { // 处理item }); done = true;如果里面的处理都是同步的,那没问题,forEach执行完,下一行done = true立刻执行。但如果在forEach里做了异步操作,比如发请求、读文件,这个done就会在请求还没返回时被置为true,因为forEach不会等异步回调完成。
要解决这个问题,最简单的办法是改用for...of循环,配合await;或者把每个异步任务收集成Promise数组,再用Promise.all统一等待:
const tasks = arr.map(async item => { await doSomething(item); }); await Promise.all(tasks); console.log('全部完成');这里其实引出了一个更本质的问题:JavaScript的异步流程控制。下面专门说。
2. 异步编程:Promise、async/await与那条隐藏的“传染链”
异步是JavaScript区别于其他语言最明显的一块。新手写回调,老手写Promise,高手在理解事件循环的前提下合理选择。这一节讲的都是实际项目里高频出现的点和那些容易绕进去的弯。
2.1 Promise基础与Promise.all的真实应用场景
Promise三个状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。状态一旦从pending变成fulfilled或rejected,就不能再变了。这是Promise设计的核心,也是它比回调可靠的原因——回调可以被调用多次,Promise只能落定一次。
日常最常见的是fetch发请求:
fetch('/api/user') .then(res => res.json()) .then(data => { console.log(data); }) .catch(err => { console.error(err); });真正体现Promise威力的是Promise.all。它的场景是这样的:一个页面需要同时拿到用户信息、订单列表、商品列表三个接口的数据,三个接口互不依赖,就可以并行发起,全部返回后再一起处理。
const [user, orders, products] = await Promise.all([ fetch('/api/user').then(r => r.json()), fetch('/api/orders').then(r => r.json()), fetch('/api/products').then(r => r.json()), ]);Promise.all是“全成功才成功,一个失败就失败”。如果你需要等所有请求结束,但不想被某一个失败拖垮,那就用Promise.allSettled,它会等所有Promise都落定,然后返回每个结果的完整状态。在批量上传文件、批量导入数据这种场景里,allSettled比all更实用,因为你能知道哪些成功、哪些失败,而不是一个失败就全军覆没。
2.2 async“传染性”到底是什么
“async传染性”这个说法在社区里越来越常见。意思是:一个函数只要被标成async,它的返回值就变成一个Promise,调用方就得用await或.then来接;一旦调用方用了await,调用函数本身又可能变成async……就像病毒一样一层层往上传染。
async function getData() { return 'data'; } function outer() { // 这里拿不到 'data',只能拿到 Promise const result = getData(); // Promise { 'data' } }为什么会有传染性?因为async函数内部如果有await,代码执行到那里会暂停,外部根本无法同步拿到最终值。JavaScript是单线程的,异步结果只能通过回调、Promise这些“将来”的通道传递。所以“传染”不是设计缺陷,而是单线程模型下必然的选择。
实际开发里怎么应对?在项目入口把异步边界限制住,不要让async无限向上传染。比如一个React组件里,事件处理函数可以写成async,但渲染函数不能;初始化数据的调用放在useEffect里,而不是组件体顶层同步执行。理解了传染性的来源,就知道该在哪里主动切回同步边界,而不是到处await一团乱。
2.3 手写sleep函数:从setTimeout到同步等待思维转变
js sleep搜索频率一直很高,因为很多人想用Java里Thread.sleep()的思路在JS里“睡一会儿”。但浏览器和Node都是单线程的,不可能真正同步阻塞主线程,不然整个页面就卡死了。我们说的sleep,实际是“延迟继续执行”,用Promise包装setTimeout最直接:
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // 使用 (async () => { console.log('开始'); await sleep(2000); console.log('2秒后'); })();如果你想要一个带“超时中断”的sleep,可以结合Promise.race:
function sleepWithTimeout(ms, timeout) { return Promise.race([ sleep(ms).then(() => 'done'), sleep(timeout).then(() => 'timeout'), ]); }在轮询场景里,sleep非常常用:比如每5秒检查一次某个任务状态,直到完成或超时。记住,sleep返回的是Promise,只能在async函数里用await等,不能“同步睡”。想明白这一层,就不容易被它的“传染性”带偏。
2.4 异步任务并发控制:从一个请求排队的小需求说起
有一回我做个批量上传工具,一次要传几十张图片,如果直接Promise.all把所有图片塞给后端,后端扛不住,浏览器也会同时开太多连接。这种时候就需要并发控制:同时只跑N个任务,跑完一个,从队列里补一个。
一个简单的并发池实现思路是:维护一个结果数组和一个当前活跃计数,每次启动任务时判断活跃数是否达到上限,没达到就继续启动,达到就等某个任务结束再启动下一个。
async function runWithConcurrency(tasks, limit) { const results = []; const executing = new Set(); for (const task of tasks) { const promise = Promise.resolve().then(task); results.push(promise); executing.add(promise); const clean = () => executing.delete(promise); promise.then(clean, clean); if (executing.size >= limit) { await Promise.race(executing); } } return Promise.all(results); }这个代码里最关键的是Promise.race(executing):它会在任意一个任务完成时立刻返回,让循环有机会继续往里塞新任务。实际项目中,也可以直接用现成的库,但我建议自己写一遍这个逻辑,因为并发控制的思路在好多场景都能复用,比如爬虫限速、数据分片上传、队列消费。
3. 核心机制与横向对比:原型链、Vue响应式、TypeScript
基础API是“术”,这一节是“道”。搞不清楚原型链,很多JS行为在你眼里就是玄学;不理解响应式,Vue的更新机制总是隔层纱;不弄明白TypeScript和JavaScript的分工,很容易在项目里把类型系统用成一堆any。
3.1 原型链:构造函数、原型对象与实例之间的关系
原型链是JS面试的常客,也是很多人最懵的一块。其实拆开看就三样东西:构造函数、原型对象、实例。
function Person(name) { this.name = name; } Person.prototype.sayHi = function () { console.log(`Hi, I am ${this.name}`); }; const p = new Person('张三'); p.sayHi(); // Hi, I am 张三这里的关系是:Person是构造函数,它有一个prototype属性,指向原型对象Person.prototype;p是实例,它有一个__proto__属性,也指向Person.prototype;原型对象Person.prototype上有一个constructor属性,指回构造函数Person。所以p.sayHi找不到时,JS引擎会沿着p.__proto__(也就是Person.prototype)去找,找到了就调用。
数组方法同理。为什么[1,2,3].map(...)能用?因为Array.prototype上定义了map,实例数组通过__proto__链找到了它。这就是“继承”的本质——不是复制,而是顺着原型链查找。理解这一点后,那些“为什么这个对象上有这个方法”的问题基本都能自己推出来了。instanceof运算符的原理也跟原型链有关:它检查右边构造函数的prototype是否出现在左边的原型链上。
3.2 用“依赖收集”理解Vue响应式
js深入浅出vue这类搜索表明很多人想啃Vue原理。Vue的响应式核心其实就四个字:依赖收集。原理是:当读取一个响应式数据时,把当前正在执行的“副作用函数”(比如组件的渲染函数)收集到这个数据的依赖列表里;当数据改变时,再把这个列表里的副作用函数全部重新执行一遍。
Vue2用Object.defineProperty逐属性拦截get和set,Vue3用Proxy代理整个对象。两者目标一样,但Proxy能拦截新增属性、删除属性、数组索引变更这些Vue2处理起来很别扭的操作。
const raw = { count: 0 }; const deps = new Set(); const proxy = new Proxy(raw, { get(target, key) { if (currentEffect) deps.add(currentEffect); return target[key]; }, set(target, key, value) { target[key] = value; deps.forEach(effect => effect()); return true; } });上面这个迷你版,currentEffect就是当前注册的副作用函数。读取proxy.count时它被收集进deps,修改proxy.count时deps里的函数全部重新执行。Vue的computed、watch、组件更新,底层都是这套逻辑的延伸。理解了依赖收集,Vue的性能优化手段(比如合理划分组件、用computed缓存中间结果)也就能讲出道理了。
3.3 TypeScript到底比JS多了什么
经常有人问typescript和js的区别。一句话:TypeScript是JavaScript的超集,它在JS的基础上加了类型系统,写完之后编译成纯JS运行。区别不在运行时,而在编译期——TS能帮你在代码跑起来之前发现很多低级错误。
interface User { id: number; name: string; age?: number; } function greet(user: User): string { return `Hello, ${user.name}`; }JS里的user可能来自接口、从对象解构、被同事改来改去,没有类型约束时,稍不注意就把user.name拼成了user.nmae,运行时才报错。TS让这种错误在编辑器和编译阶段就暴露出来。但这不意味着TS比JS“高级”——运行时执行的都是JS,TS只是给你加了一道安全网。
什么时候值得上TS?我的经验是:项目超过五千行、多人协作、接口数据结构复杂,就非常值得。如果只是写个几十行的脚本、写个临时demo,用TS反而增加成本。选择依据是“要不要长期维护”和“几个人一起写”,而不是“别人都在用”。
4. 浏览器端实战:打印、剪贴板、图片压缩与页面生命周期
前几节偏理论,这里全部是能直接抄走的页面功能代码。每一项都是我实际在业务系统里做过的,包括打印、复制文字、图片压缩、防止页面误关、iframe父子页面通信。
4.1 实现局部打印:window.print与iframe方案
浏览器打印最常见的需求是:用户点一个按钮,只打印某个区域的单据/表格,而不是整个页面。window.print()会打印当前整个页面,配合CSS可以控制显示范围:
@media print { body * { visibility: hidden; } .print-area, .print-area * { visibility: visible; } .print-area { position: absolute; left: 0; top: 0; } }这个方案的好处是不用改DOM结构,坏处是在某些浏览器里布局容易错位。我更喜欢另一种方案:动态创建隐藏iframe,把要打印的内容写进去,再调用iframe里的print()。
function printHTML(html) { const iframe = document.createElement('iframe'); iframe.style.position = 'fixed'; iframe.style.right = '0'; iframe.style.bottom = '0'; iframe.style.width = '0'; iframe.style.height = '0'; iframe.style.border = '0'; document.body.appendChild(iframe); const doc = iframe.contentWindow.document; doc.open(); doc.write(html); doc.close(); iframe.onload = () => { iframe.contentWindow.focus(); iframe.contentWindow.print(); setTimeout(() => document.body.removeChild(iframe), 1000); }; }注意:向iframe写内容时,要带上完整的<html>和<head>,包括样式和字符集,否则中文容易乱码、样式全丢。打印完成后再移除iframe,免得页面残留隐藏节点。
4.2 保存网页文字到剪贴板:Clipboard API与兼容方案
“手机 保存网页文字 js代码”这类搜索,本质是想实现一个“点击复制”按钮。现代浏览器推荐用Clipboard API:
async function copyText(text) { try { await navigator.clipboard.writeText(text); console.log('复制成功'); } catch (err) { console.error('复制失败', err); } }但navigator.clipboard只在https或localhost环境下可用,而且需要用户授权。老项目或者非https环境,就得用兼容方案:创建一个隐藏的textarea,选中它,再执行document.execCommand('copy')。
function copyTextFallback(text) { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); }execCommand虽然被标记废弃,但兼容性极好,很多扫码、H5页面还在用。我通常是先尝试navigator.clipboard,失败再走fallback,两条路都包一层错误处理,这样用户在iOS微信内置浏览器里也不会点一下没反应。
4.3 canvas压缩图片转WebP:参数与坑
前端压缩图片是上传场景的刚需,比如用户拿手机拍了一张5MB的照片直接上传,体验和带宽都扛不住。常见做法是:用FileReader读文件,创建Image对象加载,再画到canvas上,最后用canvas.toDataURL('image/webp', quality)导出压缩数据。
function compressImage(file, quality = 0.7) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = e => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); canvas.toBlob(blob => { if (blob) resolve(blob); else reject(new Error('压缩失败')); }, 'image/webp', quality); }; img.onerror = reject; img.src = e.target.result; }; reader.onerror = reject; reader.readAsDataURL(file); }); }这里有三个坑我反复踩过。第一,如果图片来自其他域名且没有CORS头,canvas会被污染,调用toBlob或toDataURL会直接报错,这种场景要先确保服务器返回Access-Control-Allow-Origin,图片也要加crossOrigin="anonymous"。第二,超大图片在部分安卓机上会超过canvas尺寸上限,导致压缩出来是黑图,稳妥做法是先按比例把长边限制到比如2560以内。第三,WebP格式不是所有浏览器都支持,导出前最好判断一下:canvas.toDataURL('image/webp').startsWith('data:image/webp'),不支持就回退到jpeg。
4.4 防止页面自动关闭:beforeunload与在线状态统计
“js如何防止页面自动关闭”这个问题有两种理解:一种是用户不小心点了关闭,希望弹窗确认;另一种是页面在后台被浏览器自动回收。前者用beforeunload事件:
window.addEventListener('beforeunload', e => { e.preventDefault(); e.returnValue = ''; });注意现代浏览器出于用户体验考虑,已经不允许自定义弹窗文案,只会显示默认的“可能离开此网站吗?”。这个方案适合有未保存表单的场景,比如在线编辑页面。不要滥用,不然用户会觉得页面很烦。
至于页面因为长时间处于后台被浏览器“冻结”,这其实是浏览器的省电策略,JS在后台被挂起是正常行为。可以用visibilitychange监听页面是不是重新可见了,然后做数据刷新:
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { // 页面重新可见,刷新数据或重新登录校验 } });顺带说一个高频搜索:“js打开手机根目录怎么打开”。浏览器出于安全沙箱限制,JS不能直接访问手机文件系统根目录。你能做的是通过input[type="file"]让用户主动选择文件,或者用File System Access API在用户授权后访问特定目录。如果是你们自己的App里的WebView,可以通过原生桥接暴露一个获取真实路径的接口,那不在纯web技术范围内。
4.5 iframe + jQuery父子页面通信的旧项目维护实录
现在新项目很少用jQuery和iframe了,但存量系统的维护里,这两个组合还是经常出现。我前段时间就接手了一个老后台:父页面有一个列表,子页面在iframe里,子页面操作完要关掉自身并刷新父页面的列表。
子页面里这样调父页面方法:
window.parent.refreshList(); // 或者带参数 window.parent.handleAfterEdit(data);父页面里要定义好这些全局函数,并且注意调用时机。如果两个页面域名不同,那就涉及跨域,必须走postMessage:
// 父页面监听 window.addEventListener('message', e => { if (e.data.type === 'refresh') { loadList(); } }); // 子页面发送 window.parent.postMessage({ type: 'refresh' }, 'https://parent-domain.com');老项目另一个常见问题是分不清jquery的js下载——准确的表述应该是“jQuery库文件的本地引入”,因为jQuery就是一个js文件,以前很多系统习惯下载到本地静态目录,然后<script src="jquery.min.js"></script>引入。维护老代码时还要注意IE兼容:js 打开 ie这种需求,本质上是因为部分老旧网站还依赖IE的ActiveX控件,但IE早已停止支持,现在主流的做法是让用户使用Edge浏览器的“IE模式”,或者逐步改造系统,别再基于IE做新功能了。
5. 框架、构建与常见报错:Vite、Element、CSV导出、URL校验
工程化工具的报错是最让人头疼的,因为信息往往不直观。这一节记录了最近遇到的构建报错、组件库用法、数据导出和校验的实战处理,都是可以直接拿来对照的。
5.1 Vite构建报错[vite:esbuild-transpile]排查实录
搜到[vite:esbuild-transpile] transform failed with 2 errors: static/js/general-9这种报错,说明Vite在构建时用esbuild对某个js文件做语法转译失败了。我遇到的那次,具体表现是本地开发正常,一执行npm run build就挂,报错文件指向static/js/general-9。
排查步骤我建议按这个顺序来:
- 先看报错信息里是否带有行号和具体语法片段,如果有,顺着文件打开那一行检查语法。esbuild对语法错误比较敏感,常见的原因包括:多括号少括号、模板字符串里嵌套了没转义的字符、某个依赖包里残留了TS语法却没被插件处理。
- 如果报错文件是压缩后的静态文件,先找到生成它的源码。
static/js/general-9这种命名一般是某个应用自身打包产物,可能是旧版本缓存。清空dist目录、node_modules里的.vite缓存,再重新安装依赖试试。 - 锁定esbuild版本。esbuild是Vite的重要依赖,版本升级偶尔会带来语法兼容变化,如果项目里直接依赖了esbuild,把它固定到和Vite匹配的版本。
- 用
npx vite build --debug跑一次,拿到更详细的日志。
那次我最终定位到是node_modules里某个库的新版本用了一个较新的JS语法特性,而项目配置的构建目标太低,esbuild拒绝转译。解决方案是在vite.config.js里把build.target调到'es2020',同时把那个库升级到兼容版本。遇到这类问题别直接删node_modules重装,先确认报错文件是源码还是第三方库,方向对了很快就能解开。
5.2 Vue + Element日历组件禁用未来月份的两种实现
看到了js vue element 日历判断月份大于当前月的月份不能选择,这个需求在业务系统里很常见,比如报销日期不能填未来、预约只能选当天之前。Element UI的DatePicker用picker-options里的disabledDate就能实现:
disabledDate(time) { const today = new Date(); today.setHours(0, 0, 0, 0); return time.getTime() > today.getTime(); }disabledDate接收一个time参数,即当前正在遍历的日期,返回true表示禁用。上面的写法会把今天之后的所有日期禁用,但今天本身可选。如果你还想禁用今天,只允许选昨天及以前,把判断改成time.getTime() >= today.getTime()就行。
如果是Element Plus,属性名变成了disabled-date,写法类似。注意比较时要先把时间归零,否则时分秒会影响结果。还有一个坑:disabledDate接收的Date对象是当地时区的,不要在比较时随意调用toISOString(),会出现时区偏移导致日期判断差一天。把new Date()和time都用setHours(0,0,0,0)归一化,是最稳妥的。
5.3 前端导出CSV:三种方案对比与中文乱码处理
“js中导出csv的方法”在后台管理系统里特别常见,查出来一条条渲染成表格太麻烦,直接导成CSV用Excel打开最省事。纯前端导出CSV最直接的方法是拼字符串,然后用Blob下载:
function exportCSV(rows) { const header = Object.keys(rows[0]).join(','); const body = rows.map(row => Object.values(row).map(v => `"${String(v).replace(/"/g, '""')}"`).join(',') ).join('\n'); const csv = `${header}\n${body}`; const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'export.csv'; a.click(); URL.revokeObjectURL(url); }这里最容易被坑的是乱码。CSV本质是纯文本,Excel默认用ANSI解析,你用UTF-8生成的CSV直接双击打开经常是乱码。解决办法就是在文本前面加一个UTF-8的BOM:\uFEFF,上面代码里的\uFEFF${csv}就是干这个的。
三个方案对比一下:方案一是纯前端拼字符串,适合小数据量,零依赖;方案二是后端生成文件返回下载链接,适合大数据量,不卡浏览器;方案三是用SheetJS这类库直接生成xlsx,功能更强但体积较大。我的建议是:数据量千行以内,用纯前端CSV,因为轻、简单、可控;数据量大或者要带样式、多sheet,还是让后端出文件更靠谱。
5.4 URL有效性校验与省市区三级联动
“js验证url有效性”也是一个常被搜索的基础需求。最现代的正规方式是直接用new URL()构造函数:
function isValidUrl(url) { try { new URL(url); return true; } catch (err) { return false; } }new URL()能解析比较完整的URL格式,但注意它比较宽容,比如new URL('baidu.com')在浏览器里会报错,因为缺少协议。如果你希望用户必须填完整的https://开头,再补一个协议校验:
function isValidHttpUrl(url) { try { const u = new URL(url); return u.protocol === 'http:' || u.protocol === 'https:'; } catch (err) { return false; } }还有一类的“验证URL有效性”是指能不能访问通,那就得用fetch去请求,但会受跨域限制,通常需要后端代劳。正则校验虽然也能写,但维护成本高,而且容易漏掉边界情况,能用URL构造函数就少写正则。
省市区三级联动则是一个经典的前端交互题。核心思路是准备好省市区数据,用map和filter做级联过滤:
const cityList = provinces.find(p => p.code === selectedProvince)?.cities || []; const areaList = cityList.find(c => c.code === selectedCity)?.areas || [];联动组件要注意数据量,全国省市区数据一般几百KB,做成一次性加载没问题,但搜索、切换要有缓存,别每次选省都去请求一遍。用computed(Vue)或useMemo(React)缓存中间结果,交互会顺滑很多。
5.5 ArcGIS API for JS二三维切换的踩坑记录
arcgis js api for javascript 4.x 二三维切换显示这类需求通常是在一张地图上让用户自由切换2D和3D视角。ArcGIS API for JS 4.x本身就支持二三维一体化,关键是用同一个Map对象,但view不同:2D用MapView,3D用SceneView。
const map = new Map({ basemap: 'topo-vector' }); const view2D = new MapView({ container: 'viewDiv', map: map, center: [116.3, 39.9], zoom: 10 }); const view3D = new SceneView({ container: 'viewDiv3D', map: map, center: [116.3, 39.9], zoom: 10 });切换的时候,可以在同一个container上交替设置view.container和view.viewpoint来同步相机位置。我踩过的主要坑有三个:一是切换后图层渲染异常,因为3D场景下部分图层(比如某些栅格服务)默认不在SceneView显示,需要手动把图层加到map;二是相机同步,从2D切到3D后视角抖动,需要把原来的中心点、缩放级别同步过去,必要时用viewpoint对象整体传递;三是性能,3D场景对显卡要求高,不要在移动端同时加载太多图层。这类GIS调试没法纯靠浏览器控制台,建议多打印view.viewpoint来定位同步问题。
6. 特殊场景与扩展能力:WPS JS宏、脚本注入、前端安全
JavaScript的生态早就超出浏览器范畴了。WPS支持用JS写宏,不少播放器和工具类应用把JS当作规则引擎,浏览器插件本身也离不开JS。这一节聊几个渗透在生活里的JS场景,顺便说说前端安全那些事。
6.1 WPS JS宏:用JS写办公自动化,顺带去重
“js宏”和“wps js宏”搜的人越来越多,因为WPS默认支持两种宏语言:VBA和JS。对于不熟悉VBA但有前端基础的办公人员,用JS写宏确实更友好。在WPS表格里,按Alt+F11打开宏编辑器,选择JS环境,就能用类似ActiveSheet的对象模型操作工作表。
举个例子,WPS表格里有一列数据,要把重复记录去掉。用JS宏可以这样写:
function removeDuplicates() { const sheet = ActiveSheet; const range = sheet.Range('A1:A100'); const values = range.Value2; const seen = new Set(); const unique = []; for (let i = 0; i < values.length; i++) { const val = values[i][0]; if (!seen.has(val)) { seen.add(val); unique.push([val]); } } range.ClearContents(); const target = sheet.Range('A1:A' + unique.length); target.Value2 = unique; }这里就是先把整列值读出来,用Set去重,再一次性写回。操作Excel/WPS对象时,尽量用Range级别的批量读写,不要一个单元格一个单元格地set,否则数据量大时会非常慢。JS宏和VBA宏的对象模型基本一致,只是语法不同,会JS的人学这个几乎零成本。顺带说一句:宏功能虽然强大,但运行未知来源的宏有安全风险,只运行自己写的或可信来源的代码。
6.2 音源脚本与数据源适配:JS不止跑在网页里
很多人搜到“lxmusic音源js在线”“野草音源js文件”,其实是指某些音乐播放器通过外置JavaScript脚本,把不同平台的音乐接口适配成统一的数据源格式。这类脚本本质是一个“数据源适配层”:播放器定义好了一个统一的接口约定(比如搜索返回什么字段、获取播放链接返回什么字段),脚本只需要把这个约定实现出来,就能把任意数据源接进来。
这个思路在很多领域都能看到:视频网站播放器解析脚本、小说阅读器书源脚本、插件系统的生命周期脚本。核心能力是:理解宿主环境提供的API,按照约定的数据结构输出结果。这类脚本通常运行在受限的JS运行时里,没有DOM、没有window,但提供了http、crypto这类扩展函数。写这类脚本最需要关注的是接口字段的映射和请求参数的处理,比如搜索关键词要URL编码、响应数据要做容错处理。
需要提醒的是,音源脚本涉及音乐版权问题,作为技术爱好者了解一下适配思路没问题,但如果用来抓取和传播无授权内容,就有合规风险。我这里聊的是“数据源适配”这个通用技术模型,不是鼓励去搞盗版源。
6.3 Alook等浏览器的JS插件与轻量自动化
“alook浏览器js插件大全”这类搜索背后,是很多人想给浏览器加装一些轻量脚本,实现页面去广告、自动展开全文、保存网页文字这类操作。Alook这类支持脚本注入的浏览器,本质上提供了一个迷你扩展环境,每种脚本用**和CSS选择器匹配页面元素,再执行自定义JS。
比如屏蔽页面广告弹窗,可以写一个简单脚本,定时查找带有ad、banner这类class的节点并隐藏:
function removeAds() { const selectors = ['.ad', '.banner', '.popup', '[id*="ad-"]']; selectors.forEach(sel => { document.querySelectorAll(sel).forEach(el => { el.style.display = 'none'; }); }); } setInterval(removeAds, 2000);这类脚本写起来不难,难点在于不同站点的广告节点命名五花八门,维护选择器列表比写脚本本身更费时间。另外一个更通用的做法是学习一下MutationObserver,它能在DOM变动时触发回调,比固定间隔轮询更高效、更优雅:
const observer = new MutationObserver(() => { removeAds(); }); observer.observe(document.body, { childList: true, subtree: true });用浏览器脚本做自动化时,注意别破坏页面的正常功能,比如误隐藏掉表单、按钮。还有一种必坑场景是:页面是单页应用,路由切换后DOM重新渲染,脚本可能失效,所以要么保持轮询,要么用MutationObserver持续监听。
6.4 前端代码保护与数据校验:关于JS逆向与反爬的认知
这两年“js逆向”“js反爬实战”特别火,我也经常被问到。这是个需要摆正认知的话题:前端代码是公开下发给浏览器的,任何人按F12都能看到源码,所以前端永远做不到绝对保密。所谓“逆向”和“反爬”,本质上是加固与绕过的猫鼠游戏。
我自己的态度是:了解逆向技术有助于排查线上问题、分析第三方SDK行为、保护自有代码,但我反对把精力花在非法抓取别人数据上。从实操上看,前端工程师真正需要掌握的安全加固手段有这么几类:
- 代码压缩混淆,让阅读难度提高,常用工具如JavaScript obfuscator。
- 接口参数签名,前端用国密算法(比如SM2、SM3、SM4)对关键参数做加密或加签,服务端验签。国密算法在js侧可以直接用
sm-crypto之类的库实现,Java后端也有对应用法,两端配合才能保证参数不被篡改。 - 请求频率限制、验证码、设备指纹等风控手段,放在服务端而非前端。
这里要明确一点:前端利用国密算法对数据做加密,能防止明文传输、防止参数被随意篡改,但密钥如果暴露在前端代码里,仍然可以被提取。所以前端加密的正确目标是“增加攻击成本”,而不是“绝对阻止攻击”。真正的安全边界必须在服务端。做这行别迷信“前端加密就安全了”,也别钻牛角尖非要把自己代码搞得谁都看不懂——那样自己维护也是灾难。
7. 面试与算法:从吉利JS面试题说起
搜“吉利js面试算法题”的人应该是在准备车企前端岗位的面试。JS方向的面试题虽然花样多,但翻来覆去其实就几类:手写实现、输出题、异步题、算法题。我结合亲身经历和网上公开信息,把高频的考点拉了一遍,顺便给一份自查清单。
7.1 手写实现、输出题与异步题:JS面试三类常客
手写实现类主要考察基本功。比如手写数组去重,至少能写出三种方法:Set、filter + indexOf、Map。Set最简单:
const unique = arr => [...new Set(arr)];再比如手写防抖和节流,这两个概念经常被混淆。防抖是“事件停止触发后一段时间才执行”,节流是“固定时间间隔内最多执行一次”。区分方法很简单:防抖适合输入框搜索,你连续打字的时候不会一直发请求,停下来了才搜;节流适合滚动加载、按钮连点,保证固定频率执行。
function debounce(fn, delay = 300) { let timer = null; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } function throttle(fn, interval = 300) { let last = 0; return function (...args) { const now = Date.now(); if (now - last >= interval) { last = now; fn.apply(this, args); } }; }输出题考察的是对闭包、作用域、事件循环的理解。经典题目是:
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); } // 输出 3 3 3原因在于var没有块级作用域,循环结束后i已经是3,三个定时器回调共用同一个i。把var改成let,输出就是0 1 2,因为let每次循环创建了新绑定。这题能延伸出很多变体,比如改成用闭包立即执行函数保存值。理解了原理,变体怎么变都不怕。
异步题考的是Promise执行顺序。比如:
console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D'); // 输出 A D C B原因是微任务(Promise.then)在当前宏任务结束后、下一个宏任务之前执行,宏任务(setTimeout)永远排在微任务后面。掌握了事件循环的优先级,这类题几乎就是送分题。
7.2 自查清单:从闭包到函数式组合,以及关于“js散度”的冷知识
如果你准备JS面试,我建议对着这份清单自查一遍,缺哪块补哪块:
- 变量提升、暂时性死区、闭包的定义与使用场景。
- 深拷贝与浅拷贝的区别,手写一个考虑函数、Date、RegExp的深拷贝。
call、apply、bind的区别与手写实现。- 原型链、
new关键字底层做了什么。 - 事件循环:宏任务、微任务、
async/await的执行顺序。 - Promise常用静态方法:
all、allSettled、race、any。 - ES6+常用新特性:
let/const、解构、扩展运算符、可选链、空值合并。 - 手写数组的
map、filter、reduce,理解它们的行为。 - 函数柯里化与组合函数,能说出实际应用场景。
这份清单是我自己在带人时常用的。面试不一定要全部答得出,但至少不能出现“会用框架但不知道JS基础”的情况。框架更新快,语言基础相对稳定,把基础夯实了,框架换个新的也能快速上手。
最后分享一个搜索冷知识:搜“js”时还经常出现“js散度”。这里的JS是Jensen-Shannon divergence,信息论里用来衡量两个概率分布相似度的一种指标,常用于文本相似度计算、模型蒸馏,跟JavaScript没有半点关系。同一个缩写在不同领域指代完全不同的东西,这也是技术人检索资料时特别容易踩的坑,看到不明词汇先确认领域,再深挖内容。
整理js--14这份笔记的过程中,我最大的感受是:JavaScript这个语言看起来松松散散,真正深入下去每一个知识点都能挖出一串关联内容,比如从forEach出发会碰到异步,从异步会碰到事件循环,从事件循环又会碰到Promise和async。如果只靠背API列表,很容易学了后面忘了前面;换成“问题驱动”的学习方式,遇到问题就顺着链条往下追,知识反而记得牢。我个人还有一个习惯,就是每隔一段时间把踩过的坑、搜过的高频词、看过的源码心得汇总成编号笔记,js--14就是这样诞生的。下次再聊,我争取把Vue响应式源码和Node侧的性能调优继续写透。