很多人写 JavaScript 写了小半年,fetch也用了,Vue也跑起来了,但一被问到“为什么这里用const而不是let”“箭头函数里的this到底指向谁”“报了个undefined is not a function该怎么下手查”就直接卡壳。
我说句实在话:这些恰恰是 JavaScript 基础里最值钱的部分。基础不扎实,项目能跑全靠运气,报错全靠百度,换了个框架就彻底抓瞎。这篇就专门把这些“基础但又不只是基础”的东西掰开揉碎讲清楚。不管你是刚入门的新手,还是写了几个项目但心里没底的同学,这篇都值得认真看一遍。
1. 变量声明这条路:let、const、var 到底怎么选
1.1 从 var 到 let/const:不是语法变化,是作用域思维的转变
很多教材喜欢从“var 有变量提升,let 没有”这种角度讲,但我觉得对初学者来说,最容易理解的方式是先看一段代码:
function test() { // 等价于 var a; 被提升到了函数顶部 console.log(a); // undefined var a = 1; } test();var声明的变量会被提升到当前函数作用域的顶部,但赋值不会。所以你在声明之前打印,拿到的是undefined而不是报错。这在早期 JavaScript 里是个特性,但它带来一个非常反直觉的问题:变量作用域不是按{}划分的,而是按函数划分的。
if (true) { var flag = 1; } console.log(flag); // 1,for 循环里也一样var在if块里声明,外面照样能访问。一旦项目复杂起来,这种“泄漏”会让你莫名其妙地覆盖掉别的变量。
let和const引入的是块级作用域,简单说就是:变量只在它所在的{}内生效。这更符合正常人理解代码的方式。我自己带项目的时候,见过太多因为var循环变量泄漏导致的诡异 bug,改成let之后立刻好了。
1.2 面试和项目里都在考的 const 误用
现在开发规范里普遍推荐“默认用const,需要重新赋值才用let”。但很多人对const有个误解,以为它声明的是“不可变的常量”。
const user = { name: '张三' }; user.name = '李四'; // 不报错 console.log(user.name); // 李四为什么没报错?因为const锁定的不是对象内容,而是变量对内存地址的引用。user这个名字永远指向同一个对象,但对象内部的属性是可以改的。
真正让const报错的是重新赋值:
const arr = [1, 2, 3]; arr.push(4); // 可以 arr = [4, 5, 6]; // TypeError: Assignment to constant variable所以在项目里,数组和对象用const声明完全没问题,你用push、map、对象属性赋值都不会触发报错。真正该用let的是计数器、临时交换变量这类场景。
1.3 类型判断与隐式转换:面试必问的基础题
JavaScript 是动态类型语言,变量可以随时从字符串变成数字。这个特性灵活,但也处处是坑。看一个经典问题:
console.log(1 + '2'); // '12',数字被转成了字符串 console.log('3' * 2); // 6,字符串被转成了数字 console.log([] + []); // '',两个空数组转成了空字符串 console.log({} + []); // '[object Object]'+运算符特别特殊:只要有一边是字符串,它就把另一边也转成字符串做拼接;但*、-、/会尽量把字符串转成数字做运算。这种隐式转换的规则在面试题里出现频率极高,实际项目里也经常因为后端返回了"200"这种字符串导致1 + "200"变成了"1200"。
要规避这类问题,核心思路就一个:做运算之前先手动转类型。
// 拿数字就用 Number() 或 + const price = Number('200'); const count = +'3'; // 字符串拼接直接用 String() 或模板字符串 const total = `价格是:${price * count} 元`;判断类型也别只靠typeof。typeof null返回的是"object",typeof []返回的也是"object"。要区分数组和普通对象,得用Array.isArray()。
console.log(typeof null); // object,这是历史遗留 bug console.log(Array.isArray([])); // true console.log(Array.isArray({})); // false实践建议:把“类型转换”当成一道必考题来准备。理解了
+的拼接规则、==的隐式转换陷阱、typeof的边界情况,你排查线上问题的速度能提升一大截。
2. 函数这关过了,才算真正开始懂 JavaScript
2.1 函数声明与函数表达式:提升机制完全不同
两种写法看起来很像:
// 函数声明 function sayHi() { console.log('hi'); } // 函数表达式 const sayHello = function() { console.log('hello'); };但它们的“出生时间”不一样。函数声明会被完整提升到作用域顶部,所以你可以在声明之前调用它:
callMe(); // 正常运行 function callMe() { console.log('ok'); }函数表达式不一样,const声明的变量提升但赋值不提升,你提前调用会拿到ReferenceError或者TypeError。我自己遇到过的真实案例是:项目里有个全局工具函数用了函数声明,另一个模块也声明了同名函数,后加载的覆盖了先加载的,结果线上功能时好时坏。换成函数表达式或模块化导出之后问题再没出现过。
2.2 this 的指向规则:谁调用了它
this是 JavaScript 初学者最头疼的话题,没有之一。其实搞清楚这一句话就够了:this指向调用函数的那个对象。
const obj = { name: 'obj', say() { console.log(this.name); } }; obj.say(); // obj,因为 obj 调用了 say const fn = obj.say; fn(); // 报错或 undefined,因为 fn 是在全局环境下调用的obj.say()里的this是obj,因为点号左边是谁,this就是谁。但一旦把方法赋给一个全局变量再调用,调用对象变成了全局对象,this自然就变了。
这种“隐式绑定丢失”的问题在事件处理函数里特别常见:
class Counter { count = 0; increase() { this.count++; } } const counter = new Counter(); button.addEventListener('click', counter.increase); // this 丢了点击按钮时,increase是被浏览器事件系统调用的,this指向按钮元素,this.count就变成了undefined。
2.3 箭头函数为什么是“保送生”
箭头函数不绑定自己的this,它会捕获定义时所在作用域的this。拿上面的例子改一改:
class Counter { count = 0; // 箭头函数在定义时,this 指向实例本身 increase = () => { this.count++; } } const counter = new Counter(); button.addEventListener('click', counter.increase); // 完美运行所以箭头函数很适合做回调函数、事件处理函数,因为它不会“自作主张”地改变this指向。但反过来说,不能用箭头函数定义对象的方法,因为方法里的this不会指向对象:
const obj = { name: 'obj', say: () => { console.log(this.name); // undefined,this 指向外层(可能是 window/global) } };还有一个点,arguments对象在箭头函数里也不存在。如果你需要在函数内部访问实参列表,用箭头函数拿到的是外层函数的arguments,很容易踩坑。
实操心得:我写代码时给自己定了一条规则——需要动态
this的场景(事件回调、定时器、防抖节流)优先用箭头函数;需要对象方法或需要arguments时用普通函数。这样写半年之后回头看,this相关的 bug 少了很多。
3. 运行时报错排查:从一脸茫然到有章法
3.1 最常见的三种运行时报错
JavaScript 的报错信息看着唬人,其实翻来覆去就那几类。我统计过项目里的报错,90% 跑不出这三个类型:
| 报错类型 | 典型场景 | 含义 |
|---|---|---|
ReferenceError | 访问了未声明的变量 | 名字根本不存在 |
TypeError | 调用了一个不是函数的东西 | 值存在,但类型不对 |
NaN相关 | 把非数字转成了数字 | 不是报错,但是个“有毒”的值 |
ReferenceError最常见的原因是拼写错误、变量名不一致、或者忘了先声明。比如:
let userName = '张三'; console.log(userName); // 写成 userNmae 就报 ReferenceErrorTypeError最常见的两种场景:undefined is not a function和Cannot read property 'xxx' of undefined。说白了就是你拿到的值不是你预期的类型。比如从接口返回的数据里取data.list,接口异常时data是undefined,一取属性就炸了。
3.2 排查链路:从报错堆栈到最小复现
很多新手拿到报错就慌,我的建议是走这样一条固定排查链路:
第一步,看完整堆栈。浏览器控制台里报错信息下面有调用栈,它会告诉你错误发生在哪个文件哪一行。不要只盯着最上面的Error描述,往下翻才是真正的线索。
第二步,复现并打断点。在报错位置的上一两行打断点,刷新页面,看变量面板。我排查过一个问题:接口返回的data有时是数组,有时是对象,代码里却统一按data.list取数据,自然有时报错。断点一打,变量状态一目了然。
第三步,写最小复现。如果还是找不到原因,把出问题的代码提取到一个独立文件里,固定输入值,逐步注释其他逻辑。十次里有八次在提取过程中就能发现问题。
第四步,用console.log验证假设。别“猜”错误原因,用日志验证。打印你想取的那个变量到底是个啥:
console.log('data 的值:', data); console.log('data 的类型:', typeof data); console.log('data 是不是数组:', Array.isArray(data));3.3 一个典型的 undefined 排查实战
我印象很深的一个问题是热词里提到的elmessage 还是提示未定义。很多人用的是 Element Plus 的自动导入配置,组件标签都正常,但ElMessage这类命令式调用却报错。
当时排查思路是这样的:先确认ElMessage是否真的被导入了。自动导入只是对组件模板做了按需编译,并不会把所有工具函数挂到全局变量上。也就是说——自动导入组件标签不意味着你可以直接用ElMessage。
解法也很简单,显式导入一下:
import { ElMessage } from 'element-plus'; function handleClick() { ElMessage.success('操作成功'); }这个小问题的本质,就是ReferenceError:你以为有个全局变量,其实它没被声明。搞清楚报错机制后,这类问题基本一眼就能定位。
3.4 那些不会报错但代码不正确的情况
比运行时报错更隐蔽的是“不报错但结果不对”。最常见的三种是:
变量名拼错但不报错:let abc = 1; console.log(abC);直接报错这还好,但如果拼错成了另一个已存在的变量,程序不报错,结果却错了,这种最磨人。
错误的隐式转换:if ('0')是成立的,因为'0'不是空字符串,转成布尔是true。想判断数字 0 别依赖隐式转换。
闭包循环变量问题:
for (var i = 0; i < 3; i++) { setTimeout(function() { console.log(i); // 3, 3, 3 }, 100); }因为var没有块级作用域,三个定时器共享同一个i,等回调执行时i已经是 3 了。改成let i = 0瞬间正确。这种问题不说破之前你可能调试一晚上,说破之后就觉得基础真的太重要了。
4. 回调、Promise 与 async/await:把异步彻底搞明白
4.1 为什么 JavaScript 会“异步”
先想一个场景:浏览器里发起一个接口请求,网络可能要几秒才返回。如果 JavaScript 是同步的,代码就要卡在那里等几秒,页面连滚动都做不了。所以浏览器提供了一套事件循环机制:遇到耗时操作就挂起,先把后面的代码执行完,等耗时操作完成了再回来执行回调。
理解事件循环,记住三个关键词:调用栈、任务队列、事件循环。同步代码在调用栈里按顺序执行;异步操作完成后把回调扔进任务队列;调用栈清空后,事件循环从任务队列里取回调执行。
这个机制解释了为什么下面这段代码的输出顺序是1, 3, 2:
console.log(1); setTimeout(() => { console.log(2); }, 0); console.log(3);setTimeout(fn, 0)的意思不是“立刻执行”,而是“至少等当前调用栈清空后再执行”。
4.2 Promise 是怎么解决回调地狱的
早年写异步代码是真痛苦,一层套一层:
getUser(function(user) { getProfile(user.id, function(profile) { getFriends(profile.id, function(friends) { // 三层了 }); }); });Promise 的作用是把这种嵌套拍平,让异步结果有了“状态”:
getUser() .then(user => getProfile(user.id)) .then(profile => getFriends(profile.id)) .then(friends => { console.log(friends); }) .catch(err => { console.error('任一环节出错都会走这里', err); });.then链上任何一个 Promise 变成了rejected,都会跳到.catch,错误处理从“每个回调里都写if (err)”简化成了一个统一出口。
4.3 fetch API 的实际用法注意点
热词里提到了fetch API 各种语法,确实很多人用fetch的时候被几个小坑绊倒过。
第一个坑:fetch不会对 HTTP 错误状态抛异常。
fetch('/api/user') .then(res => { // 404、500 时 res.ok 是 false,但 fetch 不会 reject if (!res.ok) { throw new Error(`HTTP 错误:${res.status}`); } return res.json(); }) .then(data => console.log(data)) .catch(err => console.error(err));必须手动检查res.ok,否则你拿到的可能是一段错误页面的 HTML 而不是 JSON。
第二个坑:POST 请求不设置Content-Type,后端可能解析不了。
fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: '123456' }) });不传 JSON 格式的 body,只管写body: { username: 'admin' },fetch会把对象转成字符串[object Object]发给后端,接口直接 500。
第三个坑:把async/await和fetch结合时注意 try/catch。
async function loadUser() { try { const res = await fetch('/api/user'); if (!res.ok) throw new Error('请求失败'); return await res.json(); } catch (err) { console.error('加载用户失败', err); return null; } }用await之后代码像同步的,但它依然是异步的,任何一环出错都会抛异常,不 catch 就直接冒泡到控制台,项目多了这种未捕获的 Promise 错误很容易被忽略。
4.4 async/await 是 Promise 的语法糖,不是替代品
很多新手学完async/await就觉得 Promise 可以扔了,其实async函数内部本质还是 Promise,await只是在等 Promise 的状态变更。async函数返回值也会被包装成 Promise。
async function getData() { return 'data'; } getData().then(value => { console.log(value); // data,返回值被包成了 Promise });async/await适合把“多个步骤有先后依赖”的异步逻辑写得像同步代码,但遇到“三个请求互不依赖,要并发发出”的情况,用Promise.all更合适:
const [user, posts, comments] = await Promise.all([ fetch('/api/user').then(r => r.json()), fetch('/api/posts').then(r => r.json()), fetch('/api/comments').then(r => r.json()) ]);三个请求并发执行,全部完成才继续往下走,耗时取三者最大值而不是总和。这个细节在对接接口时特别实用。
经验补充:异步出错时,
console.error别只打一个错误对象,把出错的上下文也打出来。比如console.error('加载用户失败,userID=', userID, err),线上排查效率翻倍。
5. 工程化和项目落地:基础终归要服务于项目
5.1 环境配置与模块化起步
热词里有个javascript环境配置 macos,我就顺手说一下。在 macOS 上搭 JavaScript 开发环境,最核心的是装 Node.js 。装完之后打开终端验证:
node -v npm -vNode.js 自带 npm,这就是你后面安装各类依赖工具的起点。装完之后建个文件夹试一下:
mkdir js-demo cd js-demo npm init -y这会生成一个package.json,它管理项目和所有依赖包的版本。之后引入第三方库就用:
npm install element-plus所有的依赖装进node_modules目录,package.json里会自动记录版本号。团队协作时,别人拿到你的项目只需要执行npm install就能把所有依赖装回来。
模块化是工程化的第一步。ES Module 的import/export已经成了标准写法:
// math.js export function add(a, b) { return a + b; } // main.js import { add } from './math.js'; console.log(add(2, 3)); // 5一个文件就是一个模块,导出对外可见的接口,用import引入。这是现代前端项目的基石之一,Vue项目、React项目全是这个套路。
5.2 用 Vue 项目串一遍基础概念
把基础语法放进一个真实框架里,你会突然明白学的东西到底用在哪。Vue项目里的模板、事件、组件通信,本质上全是 JavaScript。
例如一个点击计数组件:
<script setup> import { ref } from 'vue'; const count = ref(0); function increase() { // 通过 value 读写 count.value++; } </script> <template> <button @click="increase"> 当前计数:{{ count }} </button> </template>这里用到了几个基础点:ref创建响应式数据,function声明事件函数,模板里{{ count }}是模板变量语法,它会自动追踪变化并更新视图。你没看错——就是内容里的那个javascript 模板变量热词的相关用法。
Vue的v-for渲染循环列表时也要求唯一的key:
<li v-for="item in list" :key="item.id"> {{ item.name }} </li>这个key的作用是让框架能在数据变化时精确复用或更新对应的 DOM 节点。我在项目里见过有人偷懒把key写成循环索引index,结果列表增删排序时出现渲染错乱。这个问题的原理,说到底就是对“数据驱动视图”这个概念的理解深度。
5.3 自动导入配置的一处常见坑
前面提到的elmessage 未定义,这个坑在 Element Plus 项目里几乎是必踩。当时我们团队从全量引入切换到按需自动导入后,页面标签完全正常,唯独ElMessage报错。原因刚才说过:自动导入插件负责处理模板中使用的组件,不代表你把el-message标签写好,ElMessage这个 JavaScript 对象就被注册成了全局变量。
处理办法通常有两种:
一种是在确认插件配置正确的前提下,手动导入:
import { ElMessage } from 'element-plus';另一种是用官方推荐的unplugin-element-plus插件,并在项目入口文件里统一引入样式。
这个报错从现象看只是“少了个 import”,但从机制看,暴露的是对“编译期”和“运行时”两个概念的理解差异。基础好一点的同事一下就能定位,基础薄弱的可能折腾一下午。
5.4 GIS 平台上的二三维切换,基础代码长什么样
热词里有个arcgis js api for javascript 4.x 二三维切换显示,这种场景正好能体现 JavaScript 基础在真实业务融合中的价值。ArcGIS JS API 4.x 提供了一套MapView(二维)和SceneView(三维)的组件体系,核心思路是改变视图类型来切换展示模式。
简化后的核心结构是这样的:
// 地面上的一个地图,包含一个底图图层 const map = new Map({ basemap: 'topo-vector' }); // 二维视图 const view2D = new MapView({ container: 'viewDiv', map: map, center: [116.39, 39.9], zoom: 10 }); // 三维视图 const view3D = new SceneView({ container: 'viewDiv', map: map, camera: { position: [116.39, 39.9, 10000], tilt: 45 } });切换时二选一,把另一个视图销毁或隐藏。这个案例里用到了对象创建、配置项传参、事件绑定等基础能力。如果一个新人连“对象属性怎么访问”“数组方法怎么用”都没掌握,即便把 API 文档丢给他,也很难写出顺畅的业务代码。
5.5 学习路径与面试准备的一些真实建议
热词里有一串关键词特别能反映大家的关注点:javascript百炼成仙、你不知道的javascript pdf、javascript高级程序设计、javascript面试题。我就以过来人的视角聊聊该怎么读这些和学习路径。
《你不知道的 JavaScript》这套书风格很理论,它讲的是 JavaScript 的“为什么”——闭包、原型链、this 指向、异步机制这些底层原理都讲得很透。但我不建议纯新手上来就读,容易劝退。最好先跟着网上的免费教程敲一遍基础,能独立做出一个小页面、小工具之后,再来读这套书,那时你会发现之前踩的无数坑一下子全通了。
《JavaScript 高级程序设计》也就是“红宝书”,目前第 4 版已经覆盖了 ES6+ 的内容。这本书更像工具书,适合随时翻。我的使用方式是:先读一遍建框架,之后写代码遇到某个知识点不清楚,再回来查对应的小节。
面试题部分,我的建议是不要死记硬背,而是用“讲给别人听”的方式来检验自己。比如你能否在不用草稿的情况下,把this的四种绑定规则讲清楚?能否解释事件循环里宏任务和微任务的执行顺序?这些都是面试高频题,同时也是实际开发的高频坑。
一点学习心得:每天保证写至少 30 分钟代码。不一定是做项目,可以是提炼一个知识点做个小 Demo。比如今天学了箭头函数,就写一个防抖函数来实践一下。基础能力的提升靠的是“写”和“查”,而不是“看”。
6. 从百炼成仙到实际项目:怎么查漏补缺最有效
很多人问,到底怎么检验自己的 JavaScript 基础到底好不好?我的答案特别简单:看你能不能把一个知识点讲成一个“为什么会这样”的故事。
比如闭包,能背出“函数内部可以访问外部变量”的是一层;能说出“因为函数在定义时记住了它所在的词法作用域,即使这个函数在外面被调用,它仍能访问当时的变量环境”的,是另一层;能写一个用闭包实现的计数器、防抖、或者模块化私有变量的人,才是真正通关的那一个。
我自己带人时经常用的方式,是让新人尝试给项目中一段常用函数写“讲解版注释”,不写它做了什么,而写它为什么要这么做,为什么用箭头函数而不用普通函数,为什么用const而不是let。这段注释写不出来,说明这里就是他的知识盲区;写出来了,这个知识点就长在他身上了。
还有一个很实用的检视思路:复盘自己最近一周写的代码,去找“哪些地方是在模仿,哪些地方是真的懂”。模仿本身没有错,是起步的必经之路。但要持续进步,需要不断把模仿转化为理解。每当你从文档里复制一段代码,多问一句“如果去掉这行,会发生什么”,你就比上一分钟又强了一点。