1. 为什么重新捡起JavaScript:先搞清楚它到底能干什么
前段时间整理旧资料,翻出一堆早年的代码草稿,发现最早接触JavaScript时犯的低级错误全在场:变量到处裸奔、回调函数嵌套成金字塔、数组操作硬写for循环。那时候觉得JS就是个“给网页加加弹窗”的小玩意儿,后来被现实教育过几次才明白,这门语言真正厉害的地方在于它能贯穿全栈——浏览器里的交互逻辑、服务器上的接口服务、桌面应用、物联网设备,底层逻辑全是同一套语言规则。
所以这篇笔记我打算按自己的理解重新梳理一遍基础,不按教科书顺序走,而是按“我会不会用”来分模块。这篇是第一篇,重点解决三件事:变量与作用域、函数的使用边界、数组对象的常用操作。这几样东西是JS的骨架,骨架没立正,后面学什么都会晃。
这篇笔记适合谁看?如果你是零基础刚开始摸JS,建议你把每个代码块都亲手敲一遍;如果你已经写过一阵子但觉得自己“会用但说不出原理”,那这篇里的对比和踩坑记录应该能帮你把模糊的地方钉死。所有代码都在浏览器控制台里能直接跑,不需要搭额外环境。
2. 变量声明:var、let、const的取舍,以及最容易翻车的作用域问题
2.1 三种声明方式到底差在哪
我刚学JS时只用var,因为教程里全是它。后来代码量上来,发现var的“自由”其实是坑。简单说:
- var声明在函数内部时,整个函数范围内都可见,哪怕你把它写在某个代码块里,它也会“跑”到函数顶部先声明好。这个机制叫提升(hoisting),新手第一次遇到会非常懵。
- let和const则是块级作用域,只在所在的
{}范围内有效,更符合“变量应该待在它该待的地方”这个直觉。
举一个我实际写过的例子。记得有一次我写一个倒计时逻辑,用var声明的循环变量在循环结束后还残留着最后一轮的值,导致后面的判断全部出错:
for (var i = 0; i < 3; i++) { setTimeout(function () { console.log(i); }, 100); } // 输出:3 3 3如果把var换成let,每次循环都会创建一个独立的绑定,输出就变成0、1、2。这个区别在事件回调里尤为致命,新手写第一个带循环的交互时基本都会踩一次。
2.2 const和let怎么搭配用
现在写代码,我默认用const,只有确认这个变量后面要重新赋值才用let。这不是矫情,而是为了避免自己手误。你想象一下,一个变量在文件开头定义,中间隔了两百行,再次看到它时你很难立刻想起来它有没有被改过。用const声明,等于告诉自己和后来看代码的人:这玩意儿从头到尾不变,你只需要关注它的值是什么,不用防着它变。
当然const也有个常见的坑,就是它只保证绑定不变,不保证值不可变。数组和对象用const声明后,你照样可以往数组里push,照样可以改对象的属性:
const list = [1, 2, 3]; list.push(4); // 合法,list依然指向同一个数组 list = [5, 6]; // 报错,不能重新赋值这说明const锁定的是变量名指向的那个引用,不是引用指向的内容。要真正让内容不可变,得搭配Object.freeze这类手段,不过日常业务里用不上这么强的约束,做到“能不用var就不用var”已经很合格了。
2.3 作用域提升:看似贴心的设计,实则是混乱的源头
JS里函数声明和var声明的提升机制,本意是让你“先调用后定义”也没关系,这在早期脚本式编程里很实用。但副作用是代码的可读性变差,尤其是变量名重名时,你根本分不清自己用的是哪个。
在某个项目里,我见过一段代码,函数中间某处给一个参数重新赋了值,导致后续调用全走错了分支,因为那个参数名恰好和外层变量重了。排查花了大半天,最后找出原因时只能说“JS太自由了”。现在我的习惯是:所有变量先声明再使用,顺序保持一致;绝不依赖“提升”这个特性来组织代码,因为它是隐式的,别人读代码时不会主动去猜哪里被提升了。
3. 函数基础:从声明到执行的完整认知
3.1 函数声明、函数表达式和箭头函数的区别
函数这部分,我建议你把它拆成“什么时候定义”和“怎么调用”两个问题来看。JS里有三种常见的写法:
| 写法 | 示例 | 特点 |
|---|---|---|
| 函数声明 | function greet() {} | 可以提升,可以在定义前调用 |
| 函数表达式 | const greet = function() {} | 不提升,必须先赋值再调用 |
| 箭头函数 | const greet = () => {} | 不提升,且this绑定规则不同 |
当初我理解不了为什么表达式不能在定义前调用,现在换个角度想就通了:函数声明像是你提前把工具放进工具箱里,什么时候用都行;函数表达式则是“你到这一步才开始把工具组装好”,在这之前你手里是空的。
箭头函数不只是写法更短,它在this处理上是个完全不同的物种。普通函数的this取决于“谁在调用它”,箭头函数的this取决于“它在哪儿定义的”。初学者很难适应这种切换,我的建议是:事件回调、定时器、数组遍历的回调里优先用箭头函数,省得处理this换绑的问题;需要动态绑定this的场景才用普通函数。
3.2 function中的arguments到底是不是数组
这是我学了很久才搞清楚的。普通函数内部有个类数组对象arguments,它能拿到函数调用时传入的所有参数,哪怕“超出参数列表定义”的那些也能拿到。它虽然有length,也能按下标访问,但它没有数组的push、forEach这些方法。
想把它转换成真正的数组,以前要写Array.prototype.slice.call(arguments),现在有更干净的办法:
function collectArgs() { const args = [...arguments]; arguments._temp = 1; // 因为是类数组对象,虽然能挂属性,但不要这么干 console.log(args); }注意,箭头函数内部是没有arguments的,你要拿到参数列表就得用剩余参数语法(...args)来收集。我以前写过一个工具函数,把普通函数改成箭头函数后,arguments直接报未定义,查了半天才反应过来。这种坑只有自己踩过才记得牢。
3.3 callback到底解决了什么问题
有人觉得回调函数是JS中最难懂的概念,我觉得把它拆开看就一句话:把一个函数作为参数传给另一个函数,让它在合适的时机被调用。最经典的就是事件监听:
button.addEventListener('click', function () { console.log('用户点了我'); });这里的匿名函数不会立刻执行,而是被“寄存”在按钮事件上,等用户真的点击了,它才会被触发。这种“先把行为登记,等事件到了再执行”的模型,就是JS异步思维的雏形。后面学的Promise、async/await,解决的都是“多个异步行为串在一起时如何让代码更可读”的问题,但地基仍是“把函数传来传去”。
4. 数组与对象操作:我每天都要用的那些方法
4.1 用map、filter、reduce代替手写for循环
数组是JS出镜率最高的数据类型,但处理数组的习惯才真正拉开经验差距。很多人写久了还是老一套:for循环一个一个处理。不是不行,是代码里混着“取出元素”和“业务处理”两件事,看着累。
我现在处理数据有以下基本套路:
- map:想根据原数组生成相等长度的新数组时用,一一映射,自动化批量转换。
- filter:想从原数组中筛出满足条件的元素时用。
- reduce:想从整体上累加、合并、汇总时用。
举例,模拟一个购物清单,要算出金额超过50的商品总价:
const cart = [ { name: 'A', price: 30 }, { name: 'B', price: 80 }, { name: 'C', price: 20 } ]; const total = cart .filter(item => item.price > 50) .reduce((sum, item) => sum + item.price, 0); console.log(total); // 80这段代码清晰记录了“先过滤、再求和”的流水线,阅读起来不费力。reduce那个初始值0不写就会拿第一个元素当初始值,求和场景还行,遇到复杂对象操作就会出问题,所以我习惯永远把初始值显式写出来。
4.2 展开运算符和结构赋值的日常用法
展开运算符...是最近几年我使用频率最高的语法糖。合并数组、浅拷贝数组、把函数参数展开,它都做得很顺手:
const base = [1, 2]; const extra = [3, 4]; const merged = [...base, ...extra]; // [1, 2, 3, 4]但一定要记住它是浅拷贝。数组里的元素如果是对象,展开后新数组和原数组里的对象仍然是同一个引用,改其中一个对象的内容,另一个也会变。这是项目里常见的内存/引用问题,不是展开运算符的责任,而是“只拷贝到一层”这个设计初衷。
解构赋值用得也不少,尤其是从对象里只取个别字段时:
const user = { id: 1, name: 'Alice', age: 20 }; const { name, age } = user; console.log(name, age); // Alice 20你要是没用过这个写法,请务必试试,它能淘汰掉大量user.name这种重复取值。
4.3 对象的“深浅拷贝”陷阱
对象操作最大的坑就是引用混用。你给一个对象变量赋值给另一个变量时,并没有复制出一份新对象,而是让两个变量指向了同一块内存。改其中一个,另一个跟着变。
在某个后台管理页面里,我遇到过列表编辑功能:点击编辑按钮后,把当前行数据直接赋给了弹窗里的表单数据,结果你在弹窗里改一个字,列表里那一行也同步变了。当时还觉得“奇怪,明明没保存”。后来才明白,两边是同一个对象,改的是同一块内存。正确的做法是:
// 浅拷贝一层 const formData = { ...currentRow };或者用JSON.parse(JSON.stringify(currentRow))做深拷贝。后者的代价是函数、undefined、特殊值会被忽略,所以只适合纯数据对象的复制。理解深浅拷贝,是对象操作里最重要的工程意识之一。
5. 常用调试手段:别只会console.log
5.1 控制台的几种日志级别
很多人都知道console.log,却不知道console还有兄弟。我平时排查代码时会轮流用:
- console.log:常规信息,日常主力。
- console.warn:警告,表示代码能跑但存在隐患。
- console.error:错误,比如接口返回异常时打个显眼的红字。
- console.table:数组或对象列表时,用表格方式展示,结构一目了然。
数据一多,log就像流水一样一刷而过,而table能把数组的键值对整齐排列出来,比反复展开对象省时间。
5.2 在代码里“埋点”的定位思路
如果一段逻辑没按预期走,先别急着猜。我的做法是在关键岔路口打上标记:这里进来了没有、这里拿到的值是什么、这里的条件是true还是false。控制台会清晰地告诉你走到哪一步断了。
类似场景:某个按钮点击没反应,第一步在事件绑定处log一下确认绑定成功,第二步在事件处理函数第一行log一下确认点击真的触发了,第三步在函数末尾log一下确认没有报错。通过这种逐步缩圈的方式定位问题,比乱猜要快得多。
5.3 断点调试,是比日志更强的“时光机”
浏览器开发者工具里的Sources面板支持断点调试,代码执行到断点处会暂停,你可以逐行看变量的变化,还能在控制台里直接调用当前作用域的命令。
我大概计算过,写日志和手动观察,平均一次问题定位要花10到20分钟;用断点调试,经常2到3分钟就能定位到具体行。这个时间差在你连续修bug的时候会非常明显。新手往往不爱用断点,觉得要开着开发者工具、要配置,其实最多花20分钟熟悉它,之后回报率极高。
5.4 悬停看值:最简单却被忽略的技巧
在大段的代码里,想知道此刻某个变量是什么,直接把鼠标悬停在变量名上,编辑器或浏览器会显示当前值。这个技巧配合断点使用非常爽:不用担心到处写临时log,代码干干净净的,变量的情况全在掌控之中。
6. 动手练习:跟着做一个小Demo,把知识点串起来
6.1 要做成什么样的东西
想检验这块基础有没有学透,最好的方式不是背概念,而是做一个立刻能看到反馈的小工具。我的建议是做一个待办清单,不用框架,单页HTML加JS就能跑,交互包含添加、删除、筛选。它能把本文前面所有的知识点都用上:用户输入触发事件、数组增删、条件渲染、函数拆分。
6.2 代码怎么写
我用一段手写的架子作为起步:
const input = document.getElementById('todo-input'); const addBtn = document.getElementById('add-btn'); const listEl = document.getElementById('todo-list'); let todos = []; function render() { listEl.innerHTML = ''; todos.forEach((todo, index) => { const li = document.createElement('li'); li.textContent = todo.text; const delBtn = document.createElement('button'); delBtn.textContent = '删除'; delBtn.addEventListener('click', function () { todos = todos.filter((item) => item.id !== todo.id); render(); }); li.appendChild(delBtn); listEl.appendChild(li); }); } addBtn.addEventListener('click', function () { const text = input.value.trim(); if (!text) return; todos.push({ id: Date.now(), text: text }); input.value = ''; render(); });这个Demo用了数组的push、filter,用了事件监听,用了箭头函数,用了对象结构。你能跑通并理解每一行的意思,说明第一章的核心知识已经过手了。别只把代码复制进网页里,要自己从空白开始写,遇到报错再去查。我也出过错,比如忘记给按钮写事件绑定,比如todos忘了初始化成数组,不写一遍根本想不到自己在这些地方会栽。
6.3 出错后的学习办法
遇到报错不要慌,先读错误信息。浏览器控制台会告诉你错误发生在第几行,涉及什么变量,把这两条看懂,大部分问题已经解决一半。剩下的一半靠对比预期值和实际值:这个函数应该返回什么,实际返回了什么;这个变量应该有几个元素,实际上是几个。带着问题去查文档,才会把知识内化,而不是看了一遍别人的答案就以为自己会了。
7. 读者常见的理解障碍与如何在浏览器里验证
7.1 为什么这一行代码不生效,报错也没有
我刚学JS时经常遇到“明明写了却没反应”,后来发现十有八九是代码压根没执行。比如事件监听器的回调函数写错了名字,比如函数声明写在后面又被同名变量覆盖了。这时候我习惯性检查三个地方:元素ID是否存在且唯一、JS文件有没有正确加载、事件名称是否拼写正确。
另一种情况是代码执行顺序问题。JS默认是同步执行的,从上往下跑,如果你在DOM元素生成之前就去获取它,拿回来就是null,事件绑定自然失败。连加载都等你把script放在body底部,或者用DOMContentLoaded事件包一层,我早期几乎每次都栽在顺序上。
7.2 报错常见表达到底是什么意思
控制台报错第一眼都挺唬人,其实是英语直译:
ReferenceError: xxx is not defined:用了未声明的变量。TypeError: xxx is not a function:试图调用一个不是函数的值。Cannot read property 'xxx' of undefined:对undefined值取属性了。
每一条都直指问题核心,别跳过,试着照着错误定位关键字。我现在看到报错,第一反应不是“完蛋”,而是“信息来了”。
7.3 用控制台做“验证实验”
你可以在任何页面的控制台里写JS,这相当于一个随时可用的实验场。遇到不确定的语法,直接敲进去看看输出;遇到API记不清,查一下文档然后在控制台试试。我许多“原来如此”的时刻都发生在控制台里,因为它反馈极快,几乎没有成本。
8. 时间换经验:笔记要记什么,怎么记才有用
很多初学者记笔记就是抄代码、抄概念,抄完就堆在那里再也不看。我自己的习惯是:只记三种东西——某个概念为什么存在、我自己踩过的坑、别人代码里用得特别妙的写法。第一种解决“怎么想”,第二种解决“怎么避免”,第三种解决“怎么更好”。
弄一个“易忘清单”也很有用,随手记下你不熟悉或容易混淆的知识点。比如我当时就记过“箭头函数没有arguments”“cookies在浏览器里存储太小”“展开运算符是浅拷贝”,过几天回头扫一眼,记忆就越来越牢固。
笔记的价值在于“重访”,不在于第一次写得多么完整。我偶尔会重读之前的笔记,发现当初觉得很难的内容已经变成下意识的事了,这种进度感本身就是学习的动力。