很多人学JavaScript都卡在一个尴尬的阶段:变量、循环、if else都看懂了,网上教程也能跟着敲出来,但一旦脱离教程、自己面对一个空白的HTML文件时,就不知道从哪下手。这个系列的第二篇,就是专门来解决“看懂了但写不出来”这个问题的。
上一篇我们把JS的基础语法过了一遍,包括变量声明、数据类型、运算符、流程控制和基本循环。这一篇我不会再去重复那些内容,而是聚焦在真正让JS变得有用的东西上:函数、对象、数组操作、作用域、DOM事件处理,以及怎么把这些知识点组合起来做成一个能交互的页面。
如果你已经掌握了JS语法基础,但还没系统梳理过进阶概念,或者你跟着视频敲了一堆代码但感觉都是碎片、连不起来,这篇内容就是给你准备的。我会尽量避免教科书式的讲解,多聊实际开发中会遇到的问题、容易踩的坑,以及为什么某个写法在项目里更常见、更好用。
1. 函数:从“调用别人写的”到“随心所欲自己造”
1.1 声明方式的选择与差异
函数是JavaScript的核心,这句话真不是夸张。很多人在第二阶段最容易感到混乱的,就是函数的三种声明方式:函数声明、函数表达式、箭头函数。
先说最传统的函数声明:
function greet(name) { return 'Hello, ' + name; }这种写法最简单直观,而且有一个特性叫“提升”,意思是你在代码里可以先调用、后声明。这个特性在代码组织上挺有用的,尤其当你习惯把主要逻辑写前面、工具函数写后面的时候。
接下来是函数表达式:
const greet = function(name) { return 'Hello, ' + name; };注意区别,这里函数没有名字,它被赋值给了一个变量。这种写法不会被提升,你必须在定义之后再调用。当你需要把函数当成一个值传来传去的时候,这种写法更符合“数据就是一切”的思维。
然后是箭头函数:
const greet = (name) => 'Hello, ' + name;箭头函数是ES6引入的,语法更简洁,但更重要的是它改变了this的绑定逻辑。很多第一次学箭头函数的人只记住了“语法更短”,却忽略了this行为的差异,结果写代码时遇到了奇怪的报错。
在具体项目里,我个人的习惯是:普通的功能函数用函数声明,回调函数和需要闭包捕获上下文的场景用箭头函数,偶尔需要动态this时才用函数表达式。
1.2 参数的默认值与灵活收集
ES6之前,给函数参数设定默认值需要在函数体里做判断:
function greet(name) { if (name === undefined) { name = 'Gentleman'; } return 'Hello, ' + name; }现在可以直接写在参数列表里:
function greet(name = 'Gentleman') { return 'Hello, ' + name; }这个看起来只是语法糖,实际用起来方便很多。特别是当函数有几个可选参数时,默认值可以让调用方少写很多判断逻辑。
另外还有arguments和剩余参数的概念。在箭头函数里没有arguments对象,想处理可变数量参数,就得用剩余参数语法:
const sum = (...nums) => nums.reduce((acc, n) => acc + n, 0);还记得上一篇基础如果提到过reduce,你还没掌握它的话,下一节我会专门讲数组的map和reduce,这两个方法用好了,能让你的代码量减半。
函数这一节的核心认知是:函数不只是一个“代码片段”,它其实是一种“值”,可以被传递、被返回、被组合。一旦你建立起这个认知,后面学闭包、高阶函数、函数式编程思想就会自然很多。
2. 对象与数组:带着数据玩出风格
2.1 对象的创建方式和属性操作
JS里的对象真的很灵活,字面量写法直接且好用:
const user = { name: 'Alice', age: 25, sayHi() { console.log(`Hi, I'm ${this.name}`); } };注意这里的sayHi()写法是简写,等价于sayHi: function() {...},但读起来更像“对象里有一个方法”。
对象属性可以是任何类型,包括数组和其他对象,这就让我们能表达嵌套数据结构。比如一个用户对象里可以有地址对象、订单数组、订单数组里每个订单又是对象。这种组合能力是实际开发的基础。
不过毛躁的新手经常在这里遇到一个麻烦:对象是引用类型。比如:
const obj1 = { name: 'Alice' }; const obj2 = obj1; obj2.name = 'Bob'; console.log(obj1.name); // 'Bob'赋值给新变量并没有复制对象,只是复制了引用。如果你需要一个真正的副本,得用展开运算符或者Object.assign:
const obj2 = { ...obj1 };这个坑在数组上也一样存在,处理不好会让调试变成噩梦。我的建议是:凡是涉及对象或数组的复制,先问自己“我想要的是拷贝还是引用”,然后对应的处理。
2.2 数组迭代:从for循环到map、filter、reduce
很多人写过这样的代码:
let doubled = []; for (let i = 0; i < nums.length; i++) { doubled.push(nums[i] * 2); }这段代码没错,但有了map之后可以写得更短:
const doubled = nums.map(n => n * 2);map的核心思维是:不改变原数组,而是返回一个新数组。这和无副作用的纯函数思想一致,也让代码更容易测试和推理。
filter的含义更直接——过滤出符合条件的元素:
const adults = users.filter(user => user.age >= 18);reduce是三者中最强的,也是让新手最头疼的。它可以把一个数组“折叠”成任意值,甚至可以用来实现map和filter:
const sum = nums.reduce((acc, n) => acc + n, 0);上面这个例子是把数组累加成一个数字。但reduce能做的远不止求和,比如把数组转成对象、统计次数、分组等等。我的建议是:先只记住“reduce接收函数和初始值,返回值会作为下一次迭代的acc”,然后在两三个小例子里亲手用一用,比看十篇文章都有效。
2.3 解构赋值与展开运算符:日常效率提升利器
对象和数组的解构,是我最早感觉“JS变顺手了”的特性之一。
const user = { name: 'Alice', age: 25 }; const { name, age } = user;数组同理:
const [first, second] = arr;这个特性配合函数的参数列表会非常优雅,比如接收配置对象时:
function init({ timeout = 3000, retry = false } = {}) { // ... }展开运算符则用于“展开”数组或对象:
const merged = [...arr1, ...arr2]; const newUser = { ...user, age: 26 };注意第二行,这是“不改原对象,生成新对象”的常见写法,也常用来解决之前提到的引用复制问题。
掌握了map、filter、reduce和解构,基本就已经能写出比较有风格的现代 JS 了。接下来我们需要理解代码的执行环境:作用域。
3. 作用域与闭包:藏在执行过程中的幕后规则
3.1 全局作用域、函数作用域与块级作用域
作用域这个概念,决定了某个变量在哪些地方能被访问到。用大白话说,就是“我写了一个变量,谁看得见它”。
全局变量当然谁都能访问,代价是污染命名空间,容易发生冲突。函数内部用var声明的变量属于函数作用域,外部访问不到。而let和const额外提供了块级作用域,比如在if语句的{}里用let声明的变量,块结束后就消失了。
新人最容易犯的错误之一,就是在块级作用域里用了var,结果出现了意料之外的变量泄漏:
if (true) { var leak = 'I am leaked'; } console.log(leak); // 可以访问,var没有块级作用域这种“到底谁能访问到变量”的困惑,经常会导致代码里出现莫名其妙的bug。为了省心,我几乎不用var。如果你在旧代码里看到var,能改成let或const就顺手改掉。
3.2 理解闭包的最好方式:看一个实际场景
闭包的定义满天飞,什么“函数返回函数内部可以访问外部变量”之类的,但看再多定义不如看一个真实场景。
比如你想做一个计数器,每次调用都从1开始递增。你用局部变量,结果每次重新归零;用全局变量,又怕被别人改掉。闭包可以这样解:
function createCounter() { let count = 0; return function() { count++; return count; }; } const counter = createCounter(); counter(); // 1 counter(); // 2这里的count对于外部世界不可见,但对于返回的内部函数来说是可见的、可持续的。这个内部函数就像带着一个“看不见的小背包”在外面游走,背包里的东西只有它自己能动。
闭包最重要的实际应用:事件监听函数里保存状态、防抖节流、模块化组织代码、以及在React或Vue里处理回调时保住某个特定值。
还有一个小贴士,如果你写循环时用var声明变量,并在回调里使用它,你大概率会遇到“循环变量被共享”的问题。用let即可解决,因为let会为每次迭代创建独立绑定。这不是玄学,而是作用域机制的直接结果。
3.3 立即执行函数与模块化的早期雏形
在没有模块系统的时代,前辈们用“立即执行函数表达式”制造了一个私有作用域,用来避免变量污染:
(function() { const secret = 'cannot be accessed'; window.myModule = { getSecret: () => secret }; })();现在有了ES模块,这种写法不常用了,但理解它的思想仍然有意义:隔离作用域、暴露最小接口。这个思路放在今天的任何框架里都不过时。
4. 从数据到页面:DOM操作与事件机制
4.1 选择元素:你可以像用CSS选择器一样操作DOM
JS和页面交互的起点是DOM操作。你要先“选中”一个元素,才能修改它、监听它、或者动态生成内容。
最常用的三个方法:
document.getElementById('app'); document.querySelector('.btn'); document.querySelectorAll('li');getElementById速度最快,但如果页面结构复杂,querySelector和querySelectorAll的可读性和灵活性更好,因为它们支持完整的选择器语法,包括嵌套和伪类。
选择到元素之后,可以修改它的文本内容、样式、属性,甚至直接改它的结构。比较常见的一种混淆是:innerHTMLvstextContent。要写入纯文本时一定用textContent,要塞入HTML片段时才能用innerHTML。前者更安全,不会因为字符串里含有无意输入的HTML而产生意外的DOM结构。
4.2 事件监听与事件对象
想让页面响应点击、键盘、表单提交等操作,核心方法是addEventListener:
const btn = document.querySelector('#submitBtn'); btn.addEventListener('click', (event) => { event.preventDefault(); console.log('clicked'); });event对象里包含了大量有用信息,比如点击坐标、目标元素、按键码等。新手经常默认不在事件处理函数里接收事件对象,后来想获取目标元素时才发现得从参数里拿。
这里要特别强调一下preventDefault。很多表单按钮默认提交行为会导致页面刷新,这在现代单页体验里经常不是我们想要的。如果是<button type="submit">,不阻止默认行为就会刷新页面。我见过不少新手在此困惑半天:代码明明执行了,但页面一闪又恢复原样了。
4.3 事件委托:高效且优雅的事件管理
假设你在一个列表里动态添加了大量<li>元素,如果给每个<li>都绑定一个独立事件监听,性能会随着列表变长而变差,而且新增的<li>还要重复绑定。事件委托的做法是:把监听器绑定到父容器上,利用事件冒泡机制,在父层判断事件源:
const list = document.querySelector('#list'); list.addEventListener('click', (event) => { if (event.target.tagName === 'LI') { console.log('你点了', event.target.textContent); } });这样做的好处很明显:不管列表后来怎么增删元素,都不用重新绑定事件;代码也变得更集中、更清晰。对动态渲染的页面来说,这几乎是标配操作。
你不需要刻意把所有点击事件都委托到最顶层,但当你发现页面中有大量相似交互、且内容为动态生成时,事件委托就是默认最优解。
4.4 防抖与节流:被频繁触发逼出来的方案
编写交互时会发现一些事件的触发频率远高于你的直觉,比如窗口resize、滚动、输入框的input事件。如果你在这些事件里做复杂计算或请求接口,页面会卡顿甚至出现大量重复请求。
防抖的思路是:事件被触发后不立即执行,而是等待一小段时间,如果期间又被触发就重新计时,只执行最后一次。生活的类比是:电梯门即将关闭时有人进来,就重新开门等待。
function debounce(fn, delay = 300) { let timer = null; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } input.addEventListener('input', debounce(handleSearch, 300));节流则是“至少要隔一段时间才能再执行一次”。类比为:水龙头拧开,水会以固定速率流出,不会因为快速开关而流出多倍水量。对应throttle函数的核心是记录上次执行时间,用时间戳或定时器实现。
防抖和节流是纯前端性能优化里的常客,两道题也常出现在面试里。不过对新手而言,更重要的是理解它们“为什么存在”:尊重浏览器的工作节奏,避免做一些无意义的重复计算和网络请求。
5. 一个把前面内容全部串起来的练习页面
5.1 小项目:待办清单的四个核心交互
很多东西学完当时觉得懂了,但过几天就忘,症结在于没有用完整的项目把知识点串起来。我建议你自己动手做一个小项目:一个待办清单页面,它能覆盖这一篇讲到的大部分内容。
这个待办清单需要具备这样几个核心交互:
- 输入框输入待办事项,点击按钮后添加为列表项
- 点击某个待办项时,切换完成状态并添加划线样式
- 统计当前未完成事项的数量
- 提供过滤功能,比如查看所有、只查看未完成、只查看已完成
这个项目不算难,但包含了事件监听、DOM操作、数组方法、对象运用、状态存储这几大要素,是一个非常好的综合训练。
5.2 实现中的关键点拆解
先在HTML里写下页面骨架:
<input id="todoInput" type="text" placeholder="输入事项" /> <button id="addBtn">添加</button> <ul id="todoList"></ul> <div id="todoStats">未完成:0</div>对应的JS部分,如果用数组存储待办项,每个待办项是一个对象:
let todoItems = [ { id: 1, text: '学习函数', done: false }, { id: 2, text: '练习map/filter', done: true } ]; function render() { const incompleteCount = todoItems.filter(item => !item.done).length; todoStats.textContent = `未完成:${incompleteCount}`; // 用 map 生成 HTML 字符串再渲染 }添加、删除、更新状态都基于todoItems这个数据源,然后重新调用render()。这种“数据驱动视图”的模式,其实就是当下前端框架的核心思想。你练会这一套之后,再去看组件化框架的响应式原理,会倍感亲切。
事件绑定上,列表部分的点击事件可以委托给todoList容器,这样新增的项不需要额外绑定事件。输入框的回调可以用上一节写的防抖函数,生成搜索或过滤功能时直接复用。
这个小项目完成后,自己尝试往里边加一点新功能。比如:增加“删除”按钮和过滤下拉框;刷新页面后用localStorage保存状态;甚至拆分出几个独立函数来组织逻辑。每加一个新功能,你都会重新思考之前的写法是否足够灵活,这种思考本身就是程序员的核心能力。
5.3 从“实现”到“思考:为什么是这样”
练习过程中遇到问题时,我建议不要急着搜“如何实现待办清单”,而是先问三个问题:这个交互涉及哪个事件?要修改哪个数据?怎么让视图根据数据重新渲染?
这个思维习惯如果养成了,前端开发的大门基本就为你敞开了。
6. 我踩过的真实坑和调试技巧
6.1 三个常见的报错,以及它们背后的真实原因
第一个是Cannot read property 'xxx' of undefined。它几乎总是意味着你在一个未定义的变量上访问了属性,比如接口返回的数据结构和你预期不一致。解决方式是在访问前加一个判断,或者用可选链操作符:
const userName = user?.profile?.name ?? '匿名用户';第二个是xxx is not a function。它有时候是因为变量名和函数名冲突,有时候是因为导入/引用方式的错误。比如你定义了一个函数,但某处意外用属性覆盖了它。排查方法是先console.log出变量的类型。
第三个是Unexpected token这类语法报错。大多是某处括号或引号没配对。遇到这种报错,不要慌着改,先查找离报错位置最近的括号。
6.2 把console.log用成一种调试策略
很多人调试全靠浏览器开发者工具打断点,但我就喜欢先用console.log做快速定位。需要检查的地方多打几个标签,这样能很快确认代码执行到哪一行、关键值是什么。
比如:
console.log('addBtn clicked', todoItems); console.log('before render', document.querySelector('#todoList').children.length);当你要找的“问题区间”缩小到某个局部之后,再改用断点逐步执行,效率会高很多。
不过记得调试完把console.log清理掉,尤其是提交代码之前。日志不仅影响控制台整洁度,还可能意外打印出敏感信息。
6.3 在浏览器工具里做的三件事
第一个,用“Sources”面板在疑似出错的那一行打断点,然后逐行执行并观察右侧的变量表。这会让你直观地看到每个变量的当前值,比凭空猜测好用太多。第二个,用“Console”面板直接输入变量名或执行表达式。如果你在函数里声明了一个变量,全局访问不到,但可以在作用域处于那个函数时打条件断点来查看。
第三个,使用“Network”面板查看网络请求的返回结果。尤其在涉及接口数据时,很多时候前端代码没错,数据格式压根就和预期不符。先确认后端到底返回了什么,再检查自己的代码。
这些习惯看着基础,但在实际排错中节省的时间远超过学习本身花费的时间。
7. 给继续学习者的几条经验与建议
回顾这一篇,我们实际上覆盖了函数、对象与数组操作、作用域与闭包、DOM与事件、一个综合练习、以及调试经验。说实话,每一条展开都能单独写一篇几千字的文章,但作为“学习二”这个阶段,核心目标是帮你把这些概念串成一个认知网络。
我特别想提醒的一点是:不要追求一次就彻底记住每一个API。map、filter、reduce的参数细节,各种事件类型,解构赋值的各种花式写法,这些如果你没刻意使用,忘掉太正常了。真正宝贵的是你已经知道它们的存在、知道它们能干什么,然后在使用时去查询具体语法细节。
如果让我给“下一篇该学什么”提个方向,我认为顺着这个阶段往下走,有三个方向值得探索:第一是异步编程,核心概念是“回调函数为什么不够用”,进而引出Promise和async/await,这套机制在现代JS里几乎无处不在地体现着,是接下来必须攻克的重点。第二是模块化,了解import和export如何组织代码,这会让你的项目结构从“几个文件堆在一起”变成“清晰的模块体系”。第三是面向浏览器或服务端的真实项目实践,把前端页面和真正的数据交互打通,而不再只是本地静态数据。
这周就试着把那个待办清单做出来吧,哪怕只有一个简陋的界面和几个基础交互也没关系。完成并用起来之后,你再看任何前端框架的教程,感觉会完全不一样。学到这里,你已经从“认识JS”前进到了“能用JS做东西”的层次,接下来就是让这次经验引导你进入更广阔的前端世界。