1. 先弄懂javascript:void(0)这个经典写法,究竟是干嘛的
很多人在学JavaScript的过程中,第一次被绕晕不是被难懂的算法,而是被一行放在href里的代码:
html复制<a href="javascript:void(0);">点击</a>
这个写法在网上到处可见,但它到底是做什么的,为什么后来又在很多项目里引发“报错”?我们从这个最常见的问题切入,把JavaScript里几个容易被忽略的基础机制一起讲清楚。
1.1 “javascript:”伪协议和void的操作逻辑
浏览器地址栏、a标签的href属性里都可以写javascript:开头的内容。这不是网络协议,而是浏览器特别支持的“伪协议”,意思是:把后面的内容当作JavaScript代码来执行。
比如你在地址栏输入javascript:alert(1),按回车后会弹出一个提示框。放在a标签里也一样,点击链接时会执行对应的JS代码。
那void又是什么?它是一个操作符,作用很简单:执行后面的表达式,然后永远返回undefined。
javascript复制void 0; // undefined
void (1 + 1); // undefined
void alert('hello'); // 会弹窗,然后返回undefined
void(0)和void 0是同一个意思,只是括号写法更常见。它本身不会报错,也不会产生副作用。把它放到href里,就是为了让浏览器执行一段无害的JS代码,从而阻止页面跳转。在没有onclick事件拦截的年代,这是阻止链接默认行为最流行的办法。
1.2 那些“报错”到底是怎么来的
你搜javascript:void(0)报错,或者javascript:void(o)报错,会发现搜索结果里很多是“页面一点就报错”的求助帖。但仔细看,绝大多数报错并不是void本身导致的,而是下面几种情况:
第一,代码压缩工具会把变量名缩短。你写的void(obj),在压缩后的文件里可能变成void(o)。如果原本的obj没有定义,或者某处作用域引用出了问题,压缩后就会出现o is not defined这类错误。所以void(o)报错本质上是变量作用域错误,不是void操作符的问题。
第二,现代浏览器和CSP(内容安全策略)对javascript:协议的限制。很多站点配置了CSP,禁止执行内联脚本,javascript:伪协议也会被拦下来。于是页面上出现“已阻止执行内联脚本”之类的提示,给人感觉像是void(0)报错了。
第三,在React、Vue这类框架工程里,事件处理都绑在onClick上,很少有人再在href里写javascript:。如果收到老的模板代码,混用起来反而容易触发链接跳转,导致页面刷新,看起来就像“出错了”。
另外要提醒一下,javascript:void(document.title=document.cookie)这种写法,本质是利用伪协议读取并修改页面信息,属于非常典型的XSS攻击演示代码。任何时候看到类似javascript:后面跟着document.cookie的链接,都不要好奇点开,它可能是一段骗取Cookie的恶意代码。这也从侧面说明,浏览器暴露document.cookie是很危险的能力。
1.3 现在的项目里还这样写吗
现在写前端,几乎不需要再用javascript:void(0)。原因很简单:我们有了更好的方式。
html复制<a href="#" id="link">点击</a>
javascript复制document.getElementById('link').addEventListener('click', function (e) {
e.preventDefault();
// 你的逻辑
});
或者更直接一点,不需要跳转的“按钮”就不要用a标签,直接使用button。语义更准确,样式也更好控制。
对于React工程,写法更干净:
jsx复制<a
href="/detail"
onClick={(e) => {
// 需要拦截时
if (!needJump) {
e.preventDefault();
}
}}
>
详情
</a>
一句话总结:void操作符本身很重要,它是一个“执行表达式但不返回值”的干净手段,面试也爱问;但href="javascript:void(0)"是老时代的产物,遇到老代码能读懂就好,新代码不建议再写。考试和面试里喜欢考void的返回值,别答成“什么都不返回”,它的返回值是undefined。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 字符串和数组:高频API的易错点清单
字符串和数组是JavaScript里用到最频繁的两类数据类型。凡是写了几个月JS的人,基本都能说出一堆方法名。但真的上手写,踩坑最多的也恰恰在这两块。
2.1 字符串方法别只记名字,要记行为
先看一个老生常谈的对比:
| 方法 | 参数含义 | 是否支持负数 | 返回结果 |
|---|---|---|---|
slice(start, end) |
起始索引,结束索引(不含) | 支持负数 | 截取的新字符串 |
substring(start, end) |
起始索引,结束索引(不含) | 负数会被当作0 | 截取的新字符串 |
substr(start, length) |
起始索引,截取长度 | 支持负数(从尾部算) | 截取的新字符串 |
substr虽然还能用,但属于非标准方法,新代码里不建议写。slice和substring的区别集中在负数参数上。比如:
javascript复制const str = 'hello';
str.slice(-2); // "lo"
str.substring(-2); // "hello",因为-2被当成0
为什么slice更推荐?因为它和数组的slice行为完全一致,一套心智模型到处用。
还有两个非常容易忽略的点:
replace只替换第一个匹配。除非正则带g标志,否则后面的不会动。
javascript复制'2024-05-01'.replace('-', '/'); // "2024/05-01",只替换了第一个
'2024-05-01'.replace(/-/g, '/'); // "2024/05/01"
split接收的不是正则字符串时,注意转义。想按点号拆分,很多人写str.split('.')没问题,但按反斜杠拆分时写str.split('\\')很容易多一层或少一层转义。
2.2 数组sort的默认行为坑了多少人
这个坑我必须单独拿出来说。
javascript复制const arr = [10, 9, 80, 7];
arr.sort();
console.log(arr); // [10, 7, 80, 9]
你没看错,默认sort会把元素先转成字符串,再按Unicode码点排序。所以数字比较时必须传入比较函数:
javascript复制const arr = [10, 9, 80, 7];
arr.sort((a, b) => a - b); // [7, 9, 10, 80]
升序写a - b,降序写b - a,这个规则是从小到大排,a - b的结果如果为负数,表示a排在b前面,正好符合直觉。对象数组按某个字段排序也一样:
javascript复制const users = [{ name: 'u1', age: 24 }, { name: 'u2', age: 18 }];
users.sort((a, b) => a.age - b.age);
注意sort会改变原数组,它不是返回新数组。如果不想修改原数据,先用展开运算符拷贝一份:
javascript复制const sorted = [...users].sort((a, b) => a.age - b.age);
2.3 map、filter、reduce以及链式组合
map对每个元素做映射,返回等长新数组;filter按条件过滤,返回子集;reduce把整个数组合并成任意值。三者分工明确,但写错的地方也很典型。
map里面如果想做条件过滤,千万别直接return一个不存在的值,否则新数组里会出现undefined。更好的做法是先filter再map:
javascript复制const nums = [1, 2, 3, 4, 5];
// 错误示范:map里写if
const badResult = nums.map((n) => {
if (n % 2 === 0) {
return n * 10;
}
// 这里隐式return undefined
});
// 正确做法
const goodResult = nums
.filter((n) => n % 2 === 0)
.map((n) => n * 10);
reduce最常用的场景是求和:
javascript复制const nums = [1, 2, 3, 4];
const sum = nums.reduce((acc, cur) => acc + cur, 0); // 10
如果没给初始值0,acc第一次会是数组第一项,在某些场景下结果不对劲。养成给初始值的习惯,代码的可读性和稳定性都会好很多。
链式组合时,每一步都返回新数组,不会污染原数据。这是JavaScript数组设计里比较优雅的地方。
提示:
forEach天生不返回任何值,别用它来构建新数组。需要新数组时,优先想到map。
补充一个解构的实用技巧,交换两个变量不需要临时变量:
javascript复制let a = 1;
let b = 2;
[a, b] = [b, a];
字符串和数组是JS的“基本盘”,把它们的行为边界摸清楚,日常开发里能省下大量Debug时间。
3. 函数、闭包和this:真正影响你读懂JS的三件事
函数是JavaScript的一等公民。理解函数,比记一堆API更能提升你的代码水平。因为JS里很多“奇怪现象”,归根到底都是函数的作用域、闭包和this绑定导致的。
3.1 三种定义方式的差异比想象中大
JavaScript里函数有三种常见定义方式:
javascript复制// 函数声明
function foo() {
return 'foo';
}
// 函数表达式
const bar = function () {
return 'bar';
};
// 箭头函数
const baz = () => {
return 'baz';
};
函数声明会被“提升”,也就是说你在声明之前调用它也能找到:
javascript复制console.log(foo()); // 'foo'
function foo() {
return 'foo';
}
函数表达式不会提升。const bar在初始化之前处于临时死区,调用会报ReferenceError。
箭头函数和普通函数的区别更关键:
第一,箭头函数没有自己的this,它的this继承自外层作用域。第二,箭头函数不能用作构造函数,不能new。第三,箭头函数没有arguments对象。
javascript复制const obj = {
name: 'demo',
normalFn: function () {
console.log(this.name); // demo,this指向obj
},
arrowFn: () => {
console.log(this.name); // undefined,this指向外层的this(全局)
}
};
为什么会这样?因为箭头函数定义时就被绑定了外层的this,之后在任何地方调用都不会变。普通函数的this则在调用时才确定。
3.2 闭包不是概念,是每天在写的东西
闭包的本质:函数记住并访问它定义时的词法作用域。即使这个函数后来被拿到别的地方执行,它依然保留着对定义时外层变量的引用。
一个最经典的例子:
javascript复制function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
count是createCounter内部的局部变量,但返回的函数一直引用着它,所以count不会在createCounter执行完后被回收。这在实践里可以做很多东西:防抖函数、节流函数、缓存数据、私有变量、模块化封装。
我再给一个循环加闭包的经典坑,面试基本必问:
javascript复制for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}
// 输出 3 3 3
因为var声明的i是全局的,循环结束后i已经变成3,三个定时器回调拿到的都是同一个i。
解决办法有两种:
javascript复制// 方式一:用IIFE形成闭包
for (var i = 0; i < 3; i++) {
(function (index) {
setTimeout(function () {
console.log(index);
}, 100);
})(i);
}
// 方式二:用let声明,块级作用域直接解决问题
for (let i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}
let之所以能解决,是因为每次循环都会创建新的局部绑定,定时器回调保存的是本次循环的i,而不是共享一个变量。
闭包也有代价:被引用的变量不会释放。如果闭包引用了一个很大的对象,可能造成内存占用。用完大量闭包时,可以主动把引用置空null,帮助垃圾回收。
3.3 this到底听谁的
普通函数的this只有四种情况,记住就够用了:
- 直接调用
fn(),this是undefined(严格模式)或全局对象(非严格模式)。 - 方法调用
obj.fn(),this指向obj。 - 通过
call/apply/bind指定this。 - 用
new调用,this指向新创建的对象。
javascript复制const obj = {
name: 'demo',
say: function () {
console.log(this.name);
}
};
const fn = obj.say;
fn(); // 这时this不是obj,是undefined,因为fn是直接调用
obj.say和fn是同一个函数,但调用方式变了,this就变了。这也是为什么把方法作为回调传出去时,经常需要bind一下:
javascript复制const obj = {
name: 'demo',
say: function () {
console.log(this.name);
}
};
setTimeout(obj.say, 100); // undefined,this丢了
setTimeout(obj.say.bind(obj), 100); // demo
箭头函数则完全不用这套规则。它的this在定义时就定好了,永远跟着外层作用域走。
理解这个之后,再去看事件监听、React类组件里的回调、Vue组件方法,就不会因为this莫名其妙而头疼了。函数这一层是JS的底层逻辑,越早想明白,后面看源码越轻松。
4. 运行时错误与main.js:一次完整的排错思路
“JavaScript运行时报错”恐怕是每个前端每天都会遇到的事情。但报错不可怕,可怕的是对着报错信息无从下手。这里分享一套我自己常用的排错思路,同时专门说说一个很具体的热门报错:a javascript error occurred in the main process。
4.1 常见运行时错误类型速查
先把JS运行时错误分个类,看到报错信息能快速判断是哪一类问题:
| 错误类型 | 触发场景 | 英文提示示例 |
|---|---|---|
ReferenceError |
引用不存在的变量 | xxx is not defined |
TypeError |
对错误类型调用方法 | xxx is not a function |
SyntaxError |
代码语法错误,解析阶段就报 | Unexpected token |
RangeError |
数值超出合法范围 | Maximum call stack size exceeded |
URIError |
encodeURI或decodeURI使用不当 |
URI malformed |
TypeError是前端出现频率最高的运行时错误。最常见的两种:
undefined is not a function:你从一个接口返回的数据里取了一个方法,但数据可能没有这个字段。Cannot read properties of undefined:对象层级太深,res.data.list中间某一层是undefined。
遇到这类错误,第一时间在控制台打印完整数据,确认结构是否符合预期,别只盯报错那一行。
4.2 a javascript error occurred in the main process是从哪来的
这条报错很特殊,它不是浏览器里的报错,而是Electron框架的经典提示。Electron用来把Web技术打包成桌面应用,每个Electron应用都有两个进程:主进程(main process)和渲染进程(renderer process)。主进程跑在Node.js环境里,负责创建窗口、管理系统菜单、读取本地文件等;渲染进程跑在浏览器环境里,负责页面显示。
当你看到“a javascript error occurred in the main process”这个弹窗,说明主进程里的JavaScript代码执行时抛出了未被捕获的异常。而main.js通常是Electron应用的主进程入口文件,很多项目里这个文件就是项目的“启动中枢”。
主进程报错的常见原因:
- 入口文件里包含浏览器专用API,比如
window、document,但主进程并没有DOM。 - 模块路径写错,
require一个不存在的文件。 - 某个第三方模块在主进程环境下不兼容,启动时抛错。
排查步骤可以这样走:
- 在开发环境运行Electron应用,打开终端观察主进程的完整报错堆栈。
- 根据堆栈定位到
main.js的哪一行。 - 如果报错发生在加载某个业务模块时,用二分法注释代码,缩小范围。
- 检查是否在主进程误用了浏览器API,换成Electron提供的
BrowserWindow等模块。
我自己曾经遇到一次,原因是主进程里直接用了localStorage,这个API只在渲染进程存在。后来改成通过ipcMain和ipcRenderer通信,把数据操作放到渲染进程做。
4.3 排错链路:从报错框定位到root cause
整套排错思路,我总结成五步:
第一步,把报错信息完整复制下来,不要只看第一行。堆栈里面每一行都标了文件和行号,信息量很大。
第二步,判断报错发生在哪个阶段。是页面加载阶段、用户交互阶段,还是异步回调阶段?加载阶段的问题往往是初始化数据没准备好;交互阶段的问题多半是事件绑定或参数传递问题;异步回调的问题要重点检查Promise链。
第三步,缩小复现路径。改一下操作顺序,看看问题是不是稳定复现。稳定的问题好定位,偶发的问题要怀疑异步竞态或资源延迟加载。
第四步,在关键路径上打日志。别觉得打日志土,有时候最直接的办法就是在报错前的每一个函数入口打印入参。看到哪个打印没有输出,链条就断了。
第五步,修复后做回归验证。不要只验证报错路径,还要验证和它相关的分支,防止修改引入了新问题。
如果报错提示特别模糊,比如生产环境压缩后的代码,可以开启source map,让报错堆栈映射回源码位置。这个在前端项目调试里非常实用。压缩后报错根本看不出你原始代码长什么样,开了source map,浏览器控制台直接显示源码文件名和行号,思路一下子就通了。
5. JavaScript和Python怎么选,以及经常被提到的JSA
在搜索引擎的热词里,“javascript python 学哪个”常年排在前面。这个问题本身没有标准答案,但可以帮你理清楚自己的情况。
5.1 与其问学哪个,不如问代码跑在哪
JavaScript和Python都是非常适合入门的语言,但它们的“主战场”完全是两回事。
JavaScript是浏览器里唯一原生的编程语言。你想让网页有交互、有动效、能发请求,都必须通过JS。如果你以后想做前端工程师,或者想独立做一个网页/小程序/H5页面,那JavaScript绕不开。它也是Node.js的宿主语言,用它写后端服务、写脚手架工具、写自动化脚本都很成熟。
Python则是数据分析、人工智能、自动化脚本、爬虫领域的第一选择。你日常写Python,可能是为了处理Excel、训练模型、写接口,或者搞定一些系统管理脚本。
我的建议很直接:
- 想进Web前端,先学JavaScript。
- 想做数据分析、机器学习、自动化工具,先学Python。
- 如果只是想感受一下编程思维,两个都行,差别不大。
如果你时间充裕,两条路并不冲突。很多人先学Python入门,后来转前端再补JavaScript,反过来也常见。一旦你理解了一套编程语言里的变量、循环、分支、函数、对象这些概念,第二门语言学起来会快很多。重点永远不是“选哪个语言”,而是“你要用代码解决什么问题”。
5.2 JSA和JavaScript是什么关系
“javascript与jsa的关系”这个搜索词也经常出现。这里需要先明确一个概念:JSA在不同语境下可能指代不同的缩写,并没有一个全世界统一的“JSA语言”。
最常见的两种解释:
一种是“JavaScript API”的缩写,比如某些SDK文档里写JSA,意思就是“JavaScript接口文档”,它本质还是JavaScript,只是以接口文档的形式出现。
另一种是某些自动化环境里的“JavaScript Automation”缩写,这种场景下JSA的意思是“用JavaScript脚本完成自动化操作”,核心语法依然是JavaScript,只是运行环境从浏览器变成了系统的自动化框架。
所以遇到“JSA”这个词,不用慌。它不是一个全新的语言,而是JavaScript在某个特定场景下的封装或接口形式。你只需要做一件事:查对应的官方文档,看它在这个具体项目里指的是什么。把JavaScript的核心语法掌握好,不管它叫JSA还是JSBridge,底层都是同一套逻辑。
6. JavaScript的另一面:HTML5游戏、原生应用和原型工具
最后来聊JavaScript在浏览器之外的几个热门应用场景,因为热搜词里出现了“html css javascript网盘”、“oc和javascript互相调用”、“如何在axure嵌入javascript脚本”等等。
6.1 用Canvas写简易网页游戏,先掌握这三样
“没问题!我为你用 html5 + javascript(网页游戏最常用的技术)编写了一个简易的……”——这句话出现在很多AI问答的结果里。为什么HTML5 + JavaScript是网页游戏最常用的技术栈?因为浏览器天然跨平台,用户点开链接就能玩,不需要安装任何东西。Canvas画布、WebGL 3D渲染、DOM和CSS做界面,再加上JavaScript写游戏逻辑,一套技术就能搞定。
想写一个简单的网页游戏,先别碰复杂游戏引擎,从最基础的三个概念入手:
第一,游戏循环。游戏不是静止的,每帧都要重新绘制画面。浏览器提供了requestAnimationFrame来做这件事:
html复制<canvas id="game" width="400" height="300"></canvas>
javascript复制const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
function update() {
// 更新游戏状态,比如小球位置
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制当前帧
ctx.beginPath();
ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.fill();
}
function gameLoop() {
update();
requestAnimationFrame(gameLoop);
}
gameLoop();
第二,碰撞检测。两个对象什么时候“碰到”了,可以用矩形碰撞rectA.x < rectB.x + rectB.width这组公式,或者计算圆形距离distance < radiusA + radiusB。理解了这两个,很多2D游戏的判定逻辑都能看懂。
第三,用户输入。键盘方向键控制角色移动,最基础的是监听keydown/keyup,维护一个按键状态表。然后用状态表去更新角色的位置。
游戏开发的本质其实就是“循环 + 状态 + 绘制”。先用这几个概念做出一个小球碰到边缘反弹的Demo,理解之后再去学Phaser、PixiJS或者Three.js,会顺畅很多。
6.2 原生App与JS互相调用,本质是两条通道
“oc和javascript互相调用”是移动端开发里很常见的需求,尤其是在App内嵌了Web页面的时候。OC是Objective-C,iOS原生开发的语言之一。
原生和JS的通信可以拆成两个方向:
原生调用JS:方式很简单,通过WebView执行一段JS代码,相当于“远程调用”页面里的函数。
JS调用原生:JS需要通过WebView暴露给页面的桥接对象来发消息。在iOS的WKWebView里,核心模式是:
javascript复制// JS端向原生发消息
window.webkit.messageHandlers.demoHandler.postMessage({
type: 'action',
data: 'hello from web'
});
原生端提前注册demoHandler这个处理器,收到消息后执行原生代码,比如打开支付页面、调起相机、读取本地能力。
这里最关键的是:两个方向是一问一答的异步通信,不是同步方法调用。JS发了一个消息过去,原生处理完可能会通过“原生调用JS”把结果传回来。所以两边要先约定好消息格式,比如{type, data, callbackId},否则数据对不上,联调会很痛苦。
还有一个容易忽略的点:内存管理。原生持有WebView的强引用,JS再持有原生对象的引用,就可能导致循环引用。用完的桥接对象要在合适的时候清理,否则App会越来越卡。
当你在热搜词里看到“html css javascript网盘”时,很多网盘类App的预览页就是通过这类桥接方式实现的——HTML负责展示,原生负责下载和代理网络请求。
6.3 在Axure里嵌入JS,用对方式才不折腾
原型设计工具Axure里可以嵌入脚本,这也是不少人搜索“如何在axure嵌入javascript脚本”的原因。但我的经验是:先搞清楚你要达到什么效果,再选方式。
如果只是想让原型看起来更真实,比如做一个简单的时钟、列表筛选、表单校验,你可以在部件的“交互”里添加“自定义”动作,把JS作为javascript:协议去调用。
更有推荐价值的方案是:用Axure的“内联框架”(Inline Frame)部件,嵌入一个自己写的HTML文件。把页面交互逻辑都放在HTML里,Axure只负责承载这个框架。原型演示的效果更好,代码也好维护。这个方式和浏览器调试的思路完全一致:打开浏览器开发者工具,控制台、Sources、Network,该看的都能看。
Axure的脚本执行环境和浏览器不太一样,它有很多自己的封装,执行时机也受原型画布影响。遇到脚本没生效,别急着改代码,先在浏览器控制台里看看有没有报错。如果页面框架里的HTML能直接打开浏览器正常跑,那问题多半出在Axure的部件属性配置上,而不在脚本本身。
JavaScript从网页交互,到游戏开发,再到原生应用通信和设计原型,早已不只是一个“脚本语言”那么简单。但万变不离其宗,核心语法和调试思维是通用的。你如果能把前面几章的基础打牢,后面不管换什么框架、什么工具,都只是熟悉API的事情。
