学JS第五天回头看,第三天绝对是第一个真正的分水岭。前面两天跟变量、数据类型、运算符这些"名词解释"较劲,顶多算是认字;到了函数,才算真正开始学"造句"。当时我在笔记里写了一句:如果你觉得某个功能要在好几个地方用到,别复制粘贴,写个函数。这句话现在看来平平无奇,但对零基础的人来说,能主动从"抄代码"切换到"封装代码",本质上是一次编程思维的升级。
这篇笔记记录了我在Day03从零啃函数的完整经过,包括我一开始完全看不懂的三种写法、参数传值时的各种翻车现场、以及第一次接触作用域和闭包时的懵圈状态。内容按我当天的学习顺序整理,每一步都有代码和踩坑记录。适合跟我一样从零开始、正在学JS基础,或者学了一段时间但函数这块总觉得没吃透的朋友参考。
1. 函数的本质:把一段代码装进"盒子",随用随取
1.1 为什么非要用函数
第三天刚开头,我看了一个购物的例子:计算一件商品打折后的价格。不讲代码,先说要做什么。
如果不写函数,我得在页面上每个地方都写一遍"原价乘以折扣再四舍五入"。如果折扣规则变了,要把每个地方都找到、都改一遍,漏了一个就出bug。而函数的思路非常朴素:
- 把"打折计算"这段逻辑放进一个盒子
- 盒子开一个口(参数),告诉它"原价多少、折扣多少"
- 盒子再开一个口(返回值),把它算好的结果递出来
- 以后想在哪儿算,就把盒子调过来用一次
代码角度,函数的字面意思就是:封装一段可复用的逻辑,接收输入,经过处理,返回输出。它是JS里最基础的"抽象工具",也是后续学数组方法、事件处理、异步回调、框架组件的基础。我可以很负责任地说一句:函数没搞懂,后面的DOM操作、Vue或React全都寸步难行。这也是我为什么把Day03定位成"分水岭"的原因。
1.2 我的第一个函数:从三次复制粘贴到一次封装
当天练习题目很简单:输入两个数,返回较大的那个。先看不用函数的做法:
let a = 3; let b = 5; if (a > b) { console.log('较大的是:' + a); } else { console.log('较大的是:' + b); }这段代码写一次、两次还没感觉,但当我需要比较五组数字时,就得把这段if else复制五遍,变量名还得各起各的。这时我就开始意识到:重复代码不仅丑,更危险。最后我把它封装成了函数:
function getMax(a, b) { if (a > b) { return a; } else { return b; } } console.log(getMax(3, 5)); // 5 console.log(getMax(99, 88)); // 99 console.log(getMax(-1, -2)); // -1这个过程给我的启发是:写函数不是为了显得高级,而是为了消除重复。判断"什么时候该封装"的标准也很简单——同一段逻辑出现了两次以上,就值得考虑提成函数。这也是我后来写代码的基本习惯:先能跑,再提炼。
1.3 函数声明与提升机制
那天下午我发现了第一个"看不懂"的现象:我把函数调用写在了函数定义的前面,代码居然也能正常运行。
sayHello('小明'); function sayHello(name) { console.log('你好,' + name); }这就涉及一个知识点:函数声明会提升。JS引擎在正式执行前,会先把函数声明整体"搬"到当前作用域的最顶部,所以你在哪一行调用都行。这个机制叫函数提升。说白了,就是JS帮我们做了预整理,但初学者很容易把"能跑"误解成"顺序无所谓"。实际上只有函数声明这种写法才有这个待遇,换成函数表达式就完全不同,这个我在下一章详细说。
注意:早期我为了图省事,习惯把所有函数调用都放在代码前面,甚至一个JS文件里随意穿插。后来项目文件变大,发现这种写法非常难维护。建议从初学阶段起,就保持"先定义、后调用"的书写习惯,哪怕JS允许你反过来。
2. 写函数的三种姿势:声明、表达式、箭头函数
2.1 函数表达式:函数也只是一个值
刚学完函数声明,教程紧接着抛出一个概念:函数在JS里本身就是一个值。我不太理解什么叫"值",后来明白了,JS里数字是值、字符串是值、数组是值,函数同样也是一种可以赋给变量的值。
const getMax = function(a, b) { if (a > b) { return a; } else { return b; } };这就是函数表达式。它和函数声明最大的区别在于:函数声明有名字,能被提示(提升);函数表达式是给一个匿名函数起变量名,它不会被提升。如果在定义之前调用,控制台会报错:TypeError: getMax is not a function。因为此时变量还没被赋值。
这里有个坑我当时踩得特别实在。我把函数表达式赋值给const变量后,想重新给变量赋另一个函数,结果报错了。原因是const声明的变量不能重新赋值。如果想替换函数,得用let:
let getMax = function(a, b) { return a > b ? a : b; };这样一个细节带给我的认知是:函数表达式归根结底是"用变量装着函数值",遵循的是变量的规则(作用域、可赋值性),而函数声明遵循的是"语句的规则"(提升、独占一行)。理解了这层区别,后面看很多源码都不容易迷糊。
2.2 箭头函数:简洁到像变了个语言
箭头函数是ES6里我最喜欢、也最容易写错的写法。它还叫"胖箭头",因为用的是 => 符号而不是单纯的 ->。先看对照:
// 函数表达式 const add = function(a, b) { return a + b; }; // 箭头函数 const add = (a, b) => { return a + b; }; // 还能更简短:有返回值且只有一行,可以把{}和return一起省略 const add = (a, b) => a + b;第一次看到这种写法时我的反应是:这写的什么?中间的function去哪了?但适应之后,我反而觉得箭头函数更接近"输入-输出"的本质:左边是参数列表,右边是返回结果。
但我同时记了一条重要提醒:箭头函数不是简单的"简写",它和普通函数还有一些本质差异。比如它没有自己的this,没有arguments,也不能当作构造函数用。我第一天只求会用,等学到this的变化规律,再回到这一章对比理解,效果才真正出来。
建议:初学阶段先老老实实把函数声明和函数表达式练熟,箭头函数可以看、可以抄,但不要第一时间全换成箭头函数。等理解了this之后,再主动切换,否则出bug时根本没法排查。
2.3 三种写法怎么选
我把当天的感受总结成了一张表,不是标准答案,但新手参考够用了。
| 写法 | 是否提升 | this的表现 | 适用场景 |
|---|---|---|---|
| 函数声明 | 是 | 由调用方式决定 | 全局工具函数、回调函数(直观) |
| 函数表达式 | 否 | 由调用方式决定 | 按条件动态选择函数时 |
| 箭头函数 | 否 | 继承外层this | 回调、数组方法、不需要自己的this时 |
在实际项目里,我现在的习惯是:独立功能用函数声明;值传递、回调函数用箭头函数;需要动态指派的用函数表达式。但Day03当天,我只做了一件事:不限写法,把每个功能都写成函数。先把"能把代码写成函数"练成本能,再谈风格。
3. 参数与返回值:函数与外界的数据通道
3.1 实参、形参与缺失参数
学函数绕不开两个词:形参和实参。我当时记的理解方式是:形参相当于函数内部定义的"占位符",是函数"声明时"用来接收数据的参数名;实参是"调用时"真正传入的实际数据。
// a、b是形参 function add(a, b) { return a + b; } // 3和5是实参 let result = add(3, 5);这个还算好理解,但紧接着我试了一个"少传参数"的情况:add(3)。结果返回值是NaN,因为b的值为undefined,undefined加数字自然就是NaN。JS不像有些语言强制校验参数个数,少传的情况不会直接报错,但运算结果会变得很诡异。
这让我养成一个习惯:调试代码时如果发现输出是NaN、undefined拼在字符串里之类的情况,第一个要怀疑的就是某个函数没接收到正确的参数。后来我还会用一个技巧:在函数开头打印一下所有参数,确认它们到底是什么再往下查。
3.2 默认参数与剩余参数
为了处理"可能少传参数"的问题,ES6给函数增加了默认参数能力:
function greet(name = '朋友') { console.log('你好,' + name); } greet(); // 你好,朋友 greet('小明'); // 你好,小明这个语法非常直观,也很好用。唯一的坑是:只要实参不是undefined,默认值就不会生效。比如greet(null)会输出"你好,null",哪怕你在语义上并不想接收null。所以如果需要把null当作"没有传参",还得自己在函数体内加判断。
剩余参数的场景我也记了一个例子:写一个函数,接收任意多个数字,返回它们的总和。如果不知道剩余参数,会想到用arguments,但arguments是个"长得像数组但不是数组"的类数组对象,不能直接用数组的reduce方法。剩余参数就把这个事变得很顺手:
function sum(...numbers) { return numbers.reduce((total, num) => total + num, 0); } console.log(sum(1, 2, 3)); // 6 console.log(sum(1, 2, 3, 4, 5)); // 15这里面的numbers是一个真正的数组,可以直接用数组方法。这个体验恰好帮我理解了"rest参数把散装的实参收集成一个数组"的含义。
3.3 返回值:忘写return的翻车现场
Day03我遇到的第一起"诡异bug"就出在返回值上。我写了一个函数,里面console.log能正常输出,但用变量接收返回值时,得到的是undefined。
function double(num) { let result = num * 2; } console.log(double(4)); // undefined原因简单到让人脸红:函数没有return,自然没有任何东西返回。但我觉得这个教训值得反复讲,因为它太容易犯了。尤其在写了箭头函数、还习惯省略{}和return的时候,稍微多写一行语句,整段返回逻辑就消失了。
后来我养成了一个检查习惯:写函数时,先明确"这个函数到底要不要向外输出值"。如果只需要执行操作(比如打印、修改页面),那可以不用return;如果需要拿返回值继续使用,return千万别漏。顺序上,return之后的所有代码都不会执行,这一点也很重要。
4. 作用域、闭包与执行上下文:第一次被绕晕
4.1 全局作用域与局部作用域:变量去哪找
Day03后半段开始上难度了,我从"会写函数"进入"理解函数背后的机制"。第一个概念是作用域,它决定了一个变量在哪些区域能被访问到。
我用一句话记住的:作用域就是变量能生效的范围。JS中每个函数会创建自己的局部作用域,函数外面是全局作用域。内部可以访问外部的变量,但外部不能访问内部的变量。
let globalName = '全局'; function show() { let localName = '局部'; console.log(globalName); // 能访问外部变量:全局 } show(); console.log(localName); // ReferenceError: localName is not defined这段代码第一次正常打印、第二次报错,让我一下就理解了"外部拿不到内部变量"。基于这个规则,我总结出两条经验:
- 定义变量尽量靠近使用它的地方,不要全堆到全局,否则变量容易被无意覆盖
- 函数内部如果给变量赋值但没有用let声明,JS会默认把它当成全局变量来用,这是很低级的坑
4.2 闭包:函数悄悄记住了自己出生的地方
第二天我试着理解这个让无数新手头皮发麻的定义:闭包就是函数和它外部作用域的组合。后来我用另一个方式想通了:函数在"出生"的时候,会记住自己所在的环境(包括外部变量),哪怕它后来被搬到别的地方调用,它还能访问那些变量。
看一个最经典的计数例子:
function createCounter() { let count = 0; return function() { count++; return count; }; } let counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2 console.log(counter()); // 3普通情况下,createCounter调用结束,count应该被清理掉。但因为它内部返回的函数还在引用count,count就没被回收,每次调用都能基于上一次的值继续增加。这就是闭包的核心效果:延长了变量的存储范围。
第一次理解到这里,我最大的困惑不是语法,而是不知道"这有什么用"。后来遇到防抖、节流、模块化、组件封装等场景,回头才明白闭包无处不在。Day03我建议的新手任务是:能复现上面这个计数器的运行结果,并且能解释count为什么能持续累加,先不追求应用。
4.3 闭包循环陷阱:经典到几乎所有教程都讲
当我试图用闭包写"依次输出0到4"时,不熟悉var和let区别的我翻了大跟头:
for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 100); }期望输出0、1、2、3、4,实际连续输出5个5。这个现象在我第一次看到时,半天想不通。后来明白过程是这样的:var声明的i是函数级作用域,整个for循环就只有一个i变量;setTimeout里的回调函数到100毫秒后才执行,那时循环早结束,i已经变成5;五个回调访问的是同一个i,所以全是5。
改成let能解决,因为let是块级作用域,每轮循环都会创建独立的i绑定:
for (let i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 100); }这个例子让我真正理解了"变量捕获"这件事。它属于函数中比较深入的机制,新手遇到时很容易怀疑自己是不是不适合学编程——我也有过那样的心情,但请相信,这只是规律没讲透时的正常现象。把上面两步反复跑几次,亲手把5个5改成0到4,比看十遍文章都有用。
5. 回调函数、IIFE与this:函数最有魅力的"套路"
5.1 回调函数:把函数当作参数传递
回调函数的概念一句话就能讲清楚:把一个函数作为参数传给另一个函数,在合适的时候被调用。我在Day03就已经接触到它了,因为数组的forEach就是典型例子。
let numbers = [1, 2, 3]; numbers.forEach(function(item) { console.log(item); });这里传给forEach的这个函数,就是一个回调函数。它不马上执行,而是等待forEach在遍历每个元素时逐个调用。这跟我之前写的"自己写、自己调"的函数完全不同:回调函数是"写好规则,交给别人来调"。
理解这个思维转变很重要。因为JS里大量异步操作(事件监听、网络请求、定时器)本质上都是"等某个时机到了再执行回调"。我Day03还没到异步,先靠forEach和setTimeout记住了这个范式:把一个函数传给另一个函数,执行时机由后者说了算。
5.2 IIFE:定义完立刻执行,并且不留变量污染
全称是Immediately Invoked Function Expression,立即执行函数表达式。写法如下:
(function() { let secret = '外部拿不到我'; console.log('立即执行'); })();它的特征是:函数定义完马上调用,并且函数内部变量不会泄漏到全局。在没有模块化的年代,这个写法大量用于封装插件。现在写法变多了,但它帮我理解了一个关键点:"函数作用域是天然隔离变量的容器"。后来做项目,我需要临时处理一段逻辑又不想污染全局变量时,第一时间就会想到用IIFE包一层。
5.3 this:我在Day03只记住规则,不试图精通
this绝对是我往后好几天都在挣扎的内容。Day03当天我的态度很务实:先记住最常见的几种规则,不要求一次全懂。
- 在普通函数中,this指向调用它的对象,例如obj.method()里的this指向obj
- 在严格模式下,普通函数独立调用时this是undefined,而不是全局对象
- 箭头函数没有自己的this,它继承外层作用域
let person = { name: '小明', sayName: function() { console.log(this.name); } }; person.sayName(); // 小明这里为什么能输出"小明"?因为sayName被person这个对象调用了,所以this指向person。如果把这个函数拿出来单独调用,this可能就不再是person了。关于this,我给自己的建议是:遇到不确定,就在代码里打印一下this,马上就能验证,不要靠猜。
6. 我给Day03做的练习清单与避坑清单
6.1 六个让我真正掌握函数的练习
我的经验是:看再多教程,都不如亲手敲一遍。当天我给自己布置了几个小任务,从易到难:
- 写一个函数,接收一个数字n,返回1到n的和
- 把求最大值的逻辑封装成函数,并分别用函数声明、函数表达式、箭头函数实现
- 写一个判断字符串是否包含某个字符的函数(大小写不敏感),练一下参数和返回值
- 用回调风格给数组做一遍筛选(写一个自己的myFilter)
- 用闭包写一个"薪水计数器",每次调用加100,外部无法直接修改底薪
- 修复5.3里for+setTimeout输出全是5的问题
前两个练语法,第三个练参数处理,第四个练"函数作为参数"的思维,第五个练作用域与闭包,第六个练排查问题。做完这一套,函数的基础就算站住了。
我自己做这些题的时候,习惯是每写完一个小函数,就在控制台先跑出结果,再故意改出一两个错误(比如漏return、拼错参数名),观察报错信息长什么样。这个方法很笨,但对培养"报错敏感度"特别有效。
6.2 踩坑大合集:记下来就不会再犯
下面这些坑是我Day03当天真实踩过的,每一个都让我浪费了至少十分钟:
- 漏写return导致函数返回undefined;排查半天发现根本没返回值
- 箭头函数加花括号但忘记写return,输出直接变undefined
- const声明的函数表达式试图二次赋值,报Assignment to constant variable
- 试图在函数外部访问函数内部用let声明的变量,报ReferenceError
- setTimeout配合var循环输出全是最后一个数(闭包陷阱)
- 函数参数名与全局变量重名,导致函数内部无法访问外部的同名变量
如果你也在学Day03这个阶段,我建议你也这样建一份自己的踩坑清单。因为报错信息虽然看起来吓人,但圈定问题、定位范围之后,无非就是那几类:变量作用域、返回值、this指向。
6.3 给Day04的我:接下来要补的东西
写这篇笔记时我已经往后多学了一些内容,所以我在这里给当时的自己列了几个衔接点:
- 函数作用域、块级作用域、变量提升再往深挖一层,配合var和let逐一验证
- 数组的map、filter、reduce都离不开回调函数,它们值得单独花一天练
- this的指向规则要从"调用方式"去理解,而不是从函数定义的位置去理解
- 学完闭包后建议做一个小模块:用一个闭包保存私有状态,模拟"封装"
我当时的体会是,Day03的内容量看起来不大,天花板却很高。函数能往深挖的东西,足够再学一个月。但只要把"封装逻辑、传递参数、返回值、作用域、闭包、回调、this"这一条主线给捋顺了,后面的路会越走越踏实。
最后分享一个小经验:学函数千万不要只看不写,更不要急着用特别花哨的写法。从最朴素的function关键字开始,一步步优化成箭头函数,整个过程本身就是对"函数式思维"的一次完整练习。我现在写项目时,很多核心逻辑都还是靠这些基础函数撑起来的。把这天啃下来,你就算正式迈进了JS的世界。