很多初学JavaScript的朋友都有一种相似的体验:语法书看了不少,变量、循环、条件判断都能默写,可一旦落到真实项目里,面对报错、按钮点击、接口请求和一堆函数调用,脑子就一片空白。这其实是“JavaScript基础”系列里一个很关键的阶段。我们前几篇把语言的地基铺完,这次进入真正决定代码质量的进阶部分:函数、异步、请求、调试,以及工程化项目里最常遇到的那几个问题。
这次“JavaScript基础 3”的内容,主要解决三个痛点。第一,把函数讲透,尤其是箭头函数和普通函数为什么经常“行为不一致”;第二,把fetch这类异步请求的常见语法坑整理出来;第三,从纯语法过渡到真实开发环境,聊一聊Vue项目里Element Plus自动导入、运行时错误排查,以及JavaScript在Web之外还能跑在哪些地方。适合刚学完基础语法、正准备写实战项目或者复习面试基础的朋友作为中间垫脚石。
1. 为什么第三部分偏偏要讲这些内容
1.1 “基础”不只是语法,更是看待代码的视角
你可能会觉得奇怪,函数和fetch不是“后面才学的东西”吗?为什么要把它们称作基础?我在帮人看代码的时候发现,很多人遇到的不是一两个语法不会,而是对函数式写法、回调关系、错误处理没有形成直觉。比如下面这段非常日常的代码:
javascript复制const list = document.querySelectorAll('.item');
list.forEach(item => {
item.addEventListener('click', () => {
console.log(item.textContent);
});
});
如果你只看懂querySelectorAll和addEventListener,到箭头函数这一层就容易卡住,更别说理解为什么后面嵌套的箭头函数能访问外面的item。这种“在函数里再写函数”的结构,是现代JavaScript项目里绕不过去的日常。把函数当作普通值来传递、当作返回值来生成,是理解事件循环、异步回调、组件生命周期管理的第一步。所以第三部分优先讲函数,不是拔高,而是补上最容易被忽略的地基。
1.2 JavaScript和Python先学哪个,答案没那么复杂
很多热点讨论里总有人纠结“JavaScript和Python到底先学哪个”,其实这个题目要看你后台是什么目的。如果是想做网页、前端交互、全栈开发,那JavaScript就是绕不开的起点;如果目标是数据分析、人工智能,那Python更合适。这两个语言连哲学都不一样:Python强调可读性,用缩进表达代码块;JavaScript最初为浏览器动态交互而生,如今扩展到服务端和桌面端。与其纠结谁先谁后,不如想想“我下一周想做什么东西”。如果你手里有一门课、一个作品集目标,或者一个想马上做的网页,那就直接学JavaScript,边做边补。单纯比较语言的优劣没有意义,我在后来的项目里既写JavaScript也写Python,真正让你记住语言的,永远是你用它解决过的那个问题。
1.3 三本热门资料给我的启发
有人提到《你不知道的JavaScript》和《JavaScript高级程序设计》,也有朋友说看“JavaScript百炼成仙”这类趣味书更容易上手。我的看法是:趣味书能降低初学门槛,但别指望它代替系统性原理。像“JavaScript高级程序设计”前几章讲作用域和闭包的篇幅很重,这才是后期排查奇怪问题的依据。基础阶段不需要把每一条规范背下来,但要建立自己的索引系统:知道有“闭包”这个概念、知道函数声明和函数表达式的提升规则不同、知道严格模式会改变哪些行为,遇到问题时知道去哪里查。这比零散记报错信息有效得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 写函数先分清两件事:普通函数和箭头函数
2.1 写法差异只是表面
先看最常见的对比:
javascript复制// 普通函数
function add(a, b) {
return a + b;
}
// 函数表达式
const add = function (a, b) {
return a + b;
};
// 箭头函数
const add = (a, b) => a + b;
很多新手会把箭头函数理解成“function关键字的简写”,这种印象不够准确。箭头函数和普通函数至少有四个明显区别:没有自己的this、没有arguments对象、不能被用作构造函数、不能通过new调用。如果你现在还不理解什么叫“没有自己的this”,别着急,等一下用例子说清楚。但你需要先在心里放一个钩子:箭头函数不是普通函数的语法糖,它们很多场景可以互换,但在涉及this、对象方法、原型链时不能乱换。
2.2 this绑定才是最大的坑
我见过最典型的问题场景是这样的:
javascript复制const user = {
name: '张三',
showName: function () {
setTimeout(function () {
console.log(this.name);
}, 1000);
}
};
user.showName(); // 输出 undefined
这里setTimeout里的普通函数在调用时,this指向的是全局对象,而不是user。在浏览器里非严格模式下,this可能是window;严格模式下,这回是undefined。于是我们看到undefined,而不是“张三”。
如果换成箭头函数,结果就变了:
javascript复制const user = {
name: '张三',
showName: function () {
setTimeout(() => {
console.log(this.name);
}, 1000);
}
};
user.showName(); // 输出 张三
箭头函数不绑定自己的this,它会从定义它的外层作用域里继承this。正是因为这一条,在事件回调、定时器、数组遍历里大量使用箭头函数会非常省心。但反过来,如果你在对象方法里也一刀切用箭头函数,反而会出现新的问题:
javascript复制const user = {
name: '李四',
showName: () => {
console.log(this.name);
}
};
user.showName(); // undefined
对象方法没有产生新的函数作用域,箭头函数依然沿用外层全局作用域的this,结果自然不对。这类问题我建议每个学习者都亲手敲一遍,分别用普通函数和箭头函数跑一次,亲眼看到this的变化比背十遍规则有用。
2.3 默认参数与展开运算符:写函数时容易被忽略的功能
还有一个基础但常用的点:默认参数和展开运算符。早期JavaScript只能通过判断arguments或typeof来模拟默认值,写法很啰嗦。现在可以直接写:
javascript复制function request(url, method = 'GET', body = null) {
console.log(`${method} ${url}`);
}
展开运算符在数组和对象拷贝里同样高频:
javascript复制const arr1 = [1, 2, 3];
const arr2 = [...arr1]; // 重新生成数组,修改arr2不会影响arr1
const obj1 = { a: 1, b: 2 };
const obj2 = { ...obj1, c: 3 }; // 拷贝对象并新增属性
这些细节在面试中经常被拆开问,也是实际项目里的基础操作。函数这一节如果能清楚理解箭头函数与普通函数在this绑定上的差异,后续看Vue组件的methods、React的函数组件都会顺畅很多。
3. 异步编程与fetch API的语法细节
3.1 从回调地狱走到async/await
JavaScript是一门单线程语言,但它不能在一个耗时操作完成前锁死整个页面。所以异步编程非常关键。早期处理异步主要靠回调函数,比如:
javascript复制getUser(function (user) {
getOrders(user.id, function (orders) {
getDetail(orders[0].id, function (detail) {
console.log(detail);
});
});
});
这段代码能工作,但一旦嵌套层级变多,阅读和维护都会很痛苦。社区里管这种代码叫“回调地狱”。后来Promise出现了,把嵌套扁平化:
javascript复制getUser()
.then(user => getOrders(user.id))
.then(orders => getDetail(orders[0].id))
.then(detail => console.log(detail))
.catch(err => console.error(err));
再后来async/await语法进一步简化,让它看起来更像同步代码:
javascript复制async function run() {
try {
const user = await getUser();
const orders = await getOrders(user.id);
const detail = await getDetail(orders[0].id);
console.log(detail);
} catch (err) {
console.error(err);
}
}
很多初学者会疑惑:既然有async/await,还需要学Promise吗?答案是必须学,因为fetch和很多第三方库返回的都是Promise。async/await只是Promise的语法包装,不理解底层的resolve、reject和状态流转,一旦遇到并行请求或者需要取消请求的复杂场景,还是会不知道从哪下手。
3.2 fetch到底怎么用才对
fetch是目前前端发起网络请求的标配API,浏览器原生支持。基础语法是:
javascript复制fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(err => console.error(err));
这里的常见错误,是把response.json()理解成返回数据本身。其实fetch返回的response对象需要先通过.json()方法解析,而且这个方法的返回值本身也是一个Promise,所以要继续then。另一个容易被忽略的点是,fetch默认在HTTP状态码为404或500时并不会自动进入catch,它只会把resolve的response返回给你。如果你不手动检查response.ok,就可能把错误响应当成功数据用:
javascript复制async function getData() {
const response = await fetch('https://api.example.com/data');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
}
除了GET请求,提交JSON数据也很常见:
javascript复制const response = await fetch('https://api.example.com/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: '张三',
age: 18
})
});
新手经常会忘记JSON.stringify,直接把对象塞进body,或者漏掉Content-Type头。小项目里接口联调报错,大部分时间都花在这种小地方上,所以建议把常用写法整理成一份自己的函数模板。
3.3 处理超时和取消:容易被忽略的高级细节
我刚开始用fetch时,默认它会有超时功能,后来才发现没有。如果请求一直不返回,页面就一直处于等待状态,用户只能刷新。好在可以用AbortController手动取消请求:
javascript复制const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch('https://api.example.com/data', {
signal: controller.signal
});
clearTimeout(timeoutId);
// 继续处理response
} catch (err) {
if (err.name === 'AbortError') {
console.error('请求超时,已取消');
} else {
console.error(err);
}
}
这种写法在真实后台管理系统里特别实用。还有并行请求的场景,如果多个接口之间没有依赖关系,用Promise.all比逐个await快很多:
javascript复制const [userInfo, orderList, messageList] = await Promise.all([
fetchUserInfo(),
fetchOrderList(),
fetchMessageList()
]);
理解这些细节后,你再看框架里封装的axios或请求工具,心里就有底了,因为它们大多数也是对这些API做了一层封装,加上拦截器、错误重试等功能。
4. JavaScript运行时报错的定位与预防
4.1 三大高频报错:ReferenceError、TypeError和SyntaxError
在实际项目里最常遇到的报错类型,其实集中在这三个。ReferenceError通常是引用了一个不存在的变量,或者变量名拼错了;TypeError则意味着你试图对一个值做它不具备的操作,比如调用一个不是函数的东西、读取null或undefined上的属性;SyntaxError是语法解析阶段就崩了,通常有拼写错误、括号不匹配、模板字符串忘加反引号等问题。
举例来说,你写了一件这样的代码:
javascript复制console.log(userName);
如果变量名是user_name,就会报ReferenceError,提示userName is not defined。再比如:
javascript复制const ele = document.querySelector('.title');
ele.style.color = 'red';
当页面里找不到.title时,ele其实是null,这句代码会报“Cannot read properties of null (reading 'style')”。这是非常经典的运行时错误。解决方法不只靠if判断,还可以用可选链:
javascript复制const ele = document.querySelector('.title');
ele?.style?.color = 'red';
这里用可选链,表示只有ele不是null或undefined时才继续访问style,但需要注意赋值操作符在这里并不会真正给null赋值。如果还想写得更稳,先用条件判断会更清晰:
javascript复制const ele = document.querySelector('.title');
if (ele) {
ele.style.color = 'red';
}
4.2 用console和断点定位,而不是瞎猜
入门阶段你可能习惯在出问题的地方加几个console.log,打印变量值,看到不对就继续改。这种方式不是不行,但你最好提前规划打印哪些内容,而不是满屏都是log。我自己的排查顺序是:先看控制台最顶层的错误信息,点开右侧的堆栈;堆栈会列出调用链,一般能很快定位到崩在哪一层。
如果错误信息和预期偏差很大,我会上断点。在Chrome开发者工具的Sources面板里点一下行号,代码执行到这一行就会暂停,然后在Scope面板里查看当前作用域所有变量,在Call Stack里看到调用关系。这种方式比console.log更直接,因为你能看到代码暂停那一瞬间的运行时状态,包括this指向、参数值、外部变量。程序员之间调侃“最讨厌别人不动脑子就console.log”,其实主要针对的是无计划地乱打印。结构化地看报错,分步确认输入值和输出值,效率会高非常多。
4.3 javascript:void(0)到底有什么说法
很多老教程里会看到类似这样的链接写法:
html复制<a href="javascript:void(0)" onclick="handleClick()">点击</a>
这句话里的void是一个操作符,作用是计算后面的表达式并返回undefined。void(0)的结果就是undefined。把它放在href里,是防止浏览器跳转或刷新页面,让点击只触发onclick事件。这个写法在网络早期非常流行,因为那时候很多人用a标签做按钮。
现在的前端开发其实不推荐用这种方案。更现代、更语义化的做法是干脆用button元素,或者用a标签同时通过事件对象阻止默认行为:
html复制<a href="#" id="actionLink">点击</a>
javascript复制document.getElementById('actionLink').addEventListener('click', function (event) {
event.preventDefault();
// 执行业务逻辑
});
在维护老代码时看到javascript:void(0)不奇怪,你只需要知道它返回undefined、因此不会让浏览器跳走,就足够理解了。我建议新手不要在新建项目时依赖这个写法,按钮就该是button,链接就该是链接,语义清晰能省掉很多无谓的可访问性问题。
5. 从基础走向工程:macOS环境与Vue项目中的Element Plus
5.1 环境配置是走向实战的第一道坎
话题从纯语法切到工程实践时,第一步往往是环境。不少用macOS的初学者卡在Node.js环境配置上,主要问题不在于安装,而是版本混乱。今天这个项目要Node 16,明天另一个项目要求Node 20,如果只装一个全局版本,切换起来会很痛苦。
我建议使用nvm来管理Node版本。安装完成后,常见命令无非是这些:
bash复制nvm install 20
nvm use 20
nvm alias default 20
这样装完后再配合npm或pnpm安装依赖,基本就能避免大部分node版本带来的兼容坑。真正进入项目后还有一个经常遇到的错误:在项目根目录执行npm install时出现permission相关的报错。如果在macOS上出现EACCES,就要排查是不是当前用户对node_modules没有写入权限。给目录随意执行chmod -R 777是最后的办法,更好的做法是检查你的项目目录权限归属。
5.2 Vue项目里自动导入Element Plus的正确姿势
Vue 3搭配Element Plus是目前很多中后台项目常见的选择。Element Plus组件库引入了按需自动导入的功能,听上去很方便,但如果没理解它的工作方式,很容易被一个诡异报错卡住:按钮和表单都能正常显示,却在使用ElMessage时收到“ElMessage is not defined”或“elmessage还是提示未定义”。
先看一种常见配置。假设你在Vite项目里用unplugin-auto-import和unplugin-vue-components来按需导入组件:
javascript复制// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
]
});
这个配置会让模板里用到的<el-button>等组件被自动按需引入,写按钮或表单时不需要手动import。但ElMessage、ElMessageBox这类函数式调用,并不是模板组件。它需要在代码里被直接调用:
javascript复制import { ElMessage } from 'element-plus';
const handleSave = () => {
ElMessage.success('保存成功');
};
很多网上的速成教程把自动导入说得太万能,导致读者以为配置完插件后所有东西都能“自动出现”。实际上,unplugin-auto-import可以配合配置把ElMessage作为API自动导入,但前提是你告诉它确切的依赖来源。比如:
javascript复制AutoImport({
imports: ['vue', 'vue-router'],
resolvers: [ElementPlusResolver()]
})
这里resolver能处理import { ElMessage } from 'element-plus'的自动导入,但前提是你的代码里已经写了import。如果你连import都不写,却直接用ElMessage,还是会报错。理解这个边界后,再遇到类似报错就知道从哪排查:先看配置文件,再看代码里有没有真正的import语句,最后看是不是函数式调用被误当成模板组件了。
5.3 自动导入背后的原理帮你想清楚问题
用一句话概括自动导入的原理:插件在编译阶段扫描你的代码,发现组件标签或API引用后,帮你往文件顶部插入对应的import语句。它并没有改变JavaScript“先声明后使用”的规则。换句话说,自动导入只是优化代码编写体验,并没有绕开语言本身的作用域约束。
如果你在开发者工具里打开编译后的代码,看到import语句已经自动插入,却还是运行时报错,那就得去检查eslint配置或编辑器的自动修复是不是把import当成未使用变量删掉了。我之前就踩过这个坑,某些eslint规则会把“看起来没用到”的script setup内部变量误判,自动import组件后又在保存时被移除,造成运行环境里找不到组件定义。排查时不要只盯控制台,还要看看编译输出和编辑器保存后改了什么。这种问题看似玄学,一旦顺着“声明与使用”的关系去查,就能快速定位。
6. JavaScript不只在浏览器里:跨端场景和技术选型参考
6.1 为什么你在iOS技术文章里也能看到JavaScript
如果你搜过“oc和javascript互相调用”,可能会奇怪:搞iOS原生开发的人也需要学JavaScript?其实很多App里的H5页面、混合开发方案,都免不了让原生代码和Web页面互相传数据。iOS开发里,WKWebView提供了通过WKScriptMessageHandler让JavaScript调用原生方法的通道,同时原生代码也能执行页面里的JavaScript。另一个场景是在iOS的JavaScriptCore里,通过JSContext导入一个JS文件,然后在原生端调用JS函数。
JavaScript的这套思想已经不只是网页前端专属,它还被嵌入在大量宿主环境里。你会看到“JavaScript环境配置 macOS”这类搜索词,正是因为很多人要在macOS上运行Node.js、做自动化脚本或者配合编辑器插件。学习JavaScript时,不妨用一种“语言能力包装在宿主环境里”的角度去看:同样的函数、变量、作用域规则,在不同环境下通过不同对象与系统功能打交道。明白这一点后,你的知识迁移能力会强很多。
6.2 一个最该动手做的小项目:用HTML5和JavaScript写个小游戏
很多人问看完“JavaScript网页设计案例”后怎么巩固基础,我建议直接做一个轻量项目,比如用HTML5 Canvas实现一个小游戏。这个小项目会自然覆盖很多基础点:定义一个游戏主角对象、监听键盘事件、根据requestAnimationFrame更新画面、检测碰撞、管理游戏状态。在这个过程里,你得写函数、用对象、处理点击事件,可能还要用一点Math.random。代码量不需要多,两三屏就很够练手。
比如一个通关跳跃类游戏,核心部分可以拆成这样:
javascript复制const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const player = {
x: 50,
y: 300,
width: 40,
height: 60,
vy: 0,
isJumping: false
};
const gravity = 0.6;
let frames = 0;
function update() {
if (player.isJumping) {
player.vy += gravity;
player.y += player.vy;
if (player.y >= 300) {
player.y = 300;
player.isJumping = false;
}
}
frames += 1;
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#4caf50';
ctx.fillRect(player.x, player.y, player.width, player.height);
}
function gameLoop() {
update();
draw();
requestAnimationFrame(gameLoop);
}
document.addEventListener('keydown', function (event) {
if (event.code === 'Space' && !player.isJumping) {
player.vy = -12;
player.isJumping = true;
}
});
gameLoop();
这段代码一眼看过去,已经在使用函数封装逻辑、对象存储状态、事件监听和循环驱动动画这些知识。当你能把一个几十行的小游戏跑起来,再回头看在“JavaScript基础 3”里讨论的箭头函数、this指向和运行时错误,就会有完全不同的感受。因为这时每个知识都和你真实遇到的问题发生了联系。
6.3 如何选择适合自己的下一阶段学习路线
学会了基础的变量、函数、异步和基本调试之后,下一个问题往往是我该继续深入原生JavaScript,还是直接学框架。如果学习目标是前端就业或独立做项目,我的建议不是先啃透所有高级原理再学框架,而是两条腿走路:每天花一点时间阅读权威书籍里的核心概念,同时尽快用Vue或React做一个小的待办事项或内容管理页面。
框架会告诉你流行实践长什么样,但真正遇到“这个状态为什么没更新”“这个事件为什么触发两次”时,还是要回到JavaScript本身去理解。在项目里善用控制台和断点,在写代码时保持“我到底想让这个变量做什么”的清晰意识,遇到报错不要急着复制粘贴到搜索框,而是先读一遍错误信息和堆栈,再动手。这是长期学习中最值得养成的好习惯。
对我个人而言,最近练手时最常用到的一个技巧是把“可能出错”的地方尽早用错误处理包住,并且在每个函数入口想清楚参数到底该是什么类型、返回值给谁用。很多Bug不是突然出现的,而是因为某个变量一开始就不符合预期,后续所有操作都被带偏了。你在写代码时如果能在心里多问一句“这一步可能失败吗?失败了用户会看到什么?”,未来排查问题的时间一定会大幅缩短。
