JavaScript 深入浅出第 2 课:函数是一等公民是什么意思?
Category(分类): JavaScript Status: 已整理(2026)
本文保留原文关于“函数是一等公民”、回调、函数作为值、返回函数和闭包的讲解,也保留第二部分对无意义包装函数的讨论。原文中的
var、普通函数和页面转载格式属于历史背景;代码已恢复为可运行形式,并补充高阶函数、this、异步回调和现代数组 API 的边界。
《JavaScript 深入浅出》系列
一、什么是“一等公民”
“一等公民”(first-class citizen)不是 ECMAScript 规范中的正式术语,而是程序设计语言中的能力描述:如果某种值可以像其他普通值一样被赋给变量、作为参数传递、作为返回值,通常就说它具有一等地位。许多资料还会把“可以放进数据结构”列为一等值的表现。
因此,JavaScript 中的函数可以:
- 赋值给变量;
- 作为对象属性或数组元素保存;
- 作为参数传给另一个函数;
- 由函数返回;
- 在满足调用条件时被执行。
function greet(name) {
return `Hello, ${name}`
}
const anotherGreet = greet
const functions = [greet, anotherGreet]
console.log(anotherGreet('Ada')) // Hello, Ada
console.log(functions[0] === greet) // true
这里的“函数是一等公民”并不意味着函数是 JavaScript 中唯一重要的值,也不意味着 JavaScript 是纯函数式语言。它只是说明函数可以作为数据流经程序。
历史背景
原文提到 JavaScript 借鉴 Scheme,并将函数提升到一等公民的地位。这个说法适合作为语言历史背景:Scheme 等 Lisp 方言对词法作用域、闭包和函数式编程影响很大。但“一等函数”不是 JavaScript 专属特性,也不是由某一条 ECMAScript 条款命名的概念。
二、函数作为参数:回调与高阶函数
把函数传给另一个函数时,接收函数的函数就是一个高阶函数(higher-order function)。更完整的定义是:高阶函数接收一个或多个函数作为参数,或者返回一个函数,或者同时具备两者。
function runTwice(action) {
action()
action()
}
runTwice(() => {
console.log('run')
})
作为参数传入的函数通常叫回调函数(callback)。回调不必然意味着异步:Array.prototype.map()、sort() 的比较函数也是同步回调。
2.1 setTimeout 中的回调
setTimeout(() => {
console.log('稍后执行')
}, 1000)
setTimeout 是浏览器、Node.js 等宿主环境提供的计时器 API,不是 ECMAScript 核心语言本身。它的第一个参数是函数,宿主在计时器到期后安排一次调用;1000 毫秒是最小等待请求,不是精确的执行时间。
回调可能同步执行,也可能异步执行,必须以具体 API 的契约为准:
function mapSynchronously(values, callback) {
return values.map(callback)
}
function later(callback) {
setTimeout(callback, 0)
}
console.log(mapSynchronously([1, 2], value => value * 2)) // 立即得到 [2, 4]
later(() => console.log('下一次任务机会'))
2.2 sort 的比较函数
sort 可以接收自定义比较函数,因此同一个排序 API 可以处理不同的业务规则:
const employees = [
{ name: 'Liu', age: 21 },
{ name: 'Zhang', age: 37 },
{ name: 'Wang', age: 45 },
{ name: 'Li', age: 30 },
{ name: 'Zan', age: 55 },
{ name: 'Xi', age: 37 }
]
employees.sort((a, b) => a.age - b.age)
console.log(employees.map(employee => employee.age)) // [21, 30, 37, 37, 45, 55]
现代 JavaScript 的排序有几个容易忽略的点:
sort()会原地修改数组,并返回同一个数组;- 从 ES2019 开始,规范要求排序稳定,即比较结果相等的元素保持原有相对顺序;
- 比较函数应尽量满足反对称性、传递性和一致性,否则结果取决于引擎实现和输入;
- 不想修改原数组时,可以使用 ES2023 的
toSorted(),或先复制再排序。
const byName = (a, b) => a.name.localeCompare(b.name, 'zh-CN')
const sortedEmployees = employees.toSorted(byName)
console.log(sortedEmployees === employees) // false
console.log(employees !== sortedEmployees) // true
如果需要兼容没有 toSorted() 的旧环境,可以使用:
const sorted = [...employees].sort(byName)
三、把函数赋值给变量
函数声明和函数表达式都能得到函数值:
function helloByDeclaration() {
return 'Hello'
}
const helloByExpression = function () {
return 'Hello'
}
const helloByArrow = () => 'Hello'
console.log(typeof helloByDeclaration) // function
console.log(typeof helloByExpression) // function
console.log(typeof helloByArrow) // function
typeof 对函数返回 "function",这是 typeof 为可调用对象保留的特殊结果;函数在更广义上仍然是对象,可以有属性、原型和方法:
function add(a, b) {
return a + b
}
add.description = '加法函数'
console.log(add.description) // 加法函数
console.log(add.length) // 2:形参列表中第一个默认参数之前的参数数量
Function.length 只适合描述简单的形参数量,不等于函数真正必须接收的参数数量:
function example(a, b = 1, ...rest) {}
console.log(example.length) // 1
四、函数保存到对象和数组
函数可以作为对象属性保存:
const person = {
name: 'Wang Lei',
age: 40,
greeting() {
return `Hello! My name is ${this.name}.`
}
}
console.log(person.age) // 40
console.log(person.greeting()) // Hello! My name is Wang Lei.
“函数作为对象的方法调用时,this 是该对象”需要加上调用形式这个前提。person.greeting() 有一个明确的接收者 person,但取出函数后再调用就不再自动保留它:
const greeting = person.greeting
console.log(greeting()) // 非严格函数/模块环境下的结果不同,不要依赖它是 person
const boundGreeting = person.greeting.bind(person)
console.log(boundGreeting()) // Hello! My name is Wang Lei.
箭头函数没有自己的 this,不能用作需要动态接收者的方法:
const counter = {
value: 1,
read: () => this.value
}
console.log(counter.read()) // 通常不是 1:箭头函数捕获定义处的 this
如果方法需要动态使用调用者,应使用普通函数方法;如果需要固定外层上下文,可以明确使用箭头函数或 bind。不要把“函数是一等值”和“函数自动携带调用对象”混为一谈。
五、函数作为返回值:闭包与函数工厂
函数也可以返回另一个函数:
function sayHello(message) {
return function () {
return `Hello, ${message}`
}
}
const sayHelloToFundebug = sayHello('Fundebug!')
const sayHelloToGoogle = sayHello('Google!')
console.log(sayHelloToFundebug()) // Hello, Fundebug!
console.log(sayHelloToGoogle()) // Hello, Google!
返回的函数保留了对外层 message 绑定的访问,这就是闭包的常见表现。每次调用 sayHello 都会形成独立的环境:
function createCounter(initial = 0) {
let value = initial
return {
increment(step = 1) {
value += step
},
getValue() {
return value
}
}
}
const first = createCounter(100)
const second = createCounter(200)
first.increment(5)
console.log(first.getValue()) // 105
console.log(second.getValue()) // 200
闭包常用于:
- 创建私有状态;
- 配置函数或函数工厂;
- 生成事件处理器;
- 实现记忆化、节流和防抖;
- 封装模块内部实现。
但闭包不是自动的内存泄漏。只有当定时器、事件监听器、缓存或其他可达对象仍然引用闭包,且闭包又引用了较大的对象时,才可能造成不必要的长期保留。
六、函数式编程与高阶函数
一等函数是函数式风格的重要基础,但 JavaScript 仍然是一门多范式语言。函数式编程通常还会强调纯函数、不可变数据、组合和避免隐式副作用;一等函数本身并不能保证这些性质。
6.1 map、filter、reduce
const numbers = [1, 2, 3, 4]
const doubled = numbers.map(value => value * 2)
const even = numbers.filter(value => value % 2 === 0)
const total = numbers.reduce((sum, value) => sum + value, 0)
console.log(doubled) // [2, 4, 6, 8]
console.log(even) // [2, 4]
console.log(total) // 10
这些 API 把遍历流程交给数组,把每个元素如何转换或筛选交给回调。回调仍可能有副作用,因此不能仅因为使用了 map 就称为纯函数式代码。
6.2 组合函数
const trim = value => value.trim()
const toLowerCase = value => value.toLowerCase()
const addPrefix = value => `user:${value}`
const pipe = (...functions) => value =>
functions.reduce((current, currentFunction) => currentFunction(current), value)
const normalizeUserId = pipe(trim, toLowerCase, addPrefix)
console.log(normalizeUserId(' Ada ')) // user:ada
组合函数把多个小函数连接成新的函数。实际项目中应注意错误处理、异步 Promise、类型约束和调试可读性,不要为了“函数式”而制造过深的调用链。
七、第二部分:不要无故增加包装函数
原文第二部分用下面的例子说明:如果包装函数只是把相同的参数原样传给另一个函数,可以直接传递后者:
const hi = name => `Hi ${name}`
const unnecessaryGreeting = name => hi(name)
const greeting = hi
console.log(unnecessaryGreeting('Jonas')) // Hi Jonas
console.log(greeting('Jonas')) // Hi Jonas
同理,下面两种写法在 ajaxCall 的参数签名、返回值和 this 语义完全一致时等价。为使示例可独立阅读,这里提供一个最小的 ajaxCall 占位实现:
function ajaxCall(callback) {
return callback({ ok: true })
}
const getServerStuff = callback => ajaxCall(json => callback(json))
const getServerStuffDirectly = ajaxCall
console.log(getServerStuffDirectly(json => json.ok)) // true
但不能把“包装函数总是多余”当成绝对规则。包装函数在这些场景中是有意义的:
- 调整参数顺序或参数结构;
- 绑定或转换
this; - 增加日志、重试、鉴权和错误处理;
- 将回调结果转换为 Promise;
- 适配不同 API 的签名;
- 限制暴露给调用者的数据和能力。
function requestWithLogging(request, logger) {
return function wrappedRequest(...args) {
logger.log('request started')
try {
const result = Reflect.apply(request, this, args)
logger.log('request finished')
return result
} catch (error) {
logger.log('request failed')
throw error
}
}
}
因此更准确的建议是:删除没有改变语义的机械包装,但保留具有适配、约束或副作用边界意义的包装。
7.1 控制器的直接委托
原文用控制器举例:
const Views = {
index(posts) {
return `index: ${posts.length}`
},
show(post) {
return `show: ${post.title}`
}
}
const BlogController = {
index: Views.index,
show: Views.show
}
console.log(BlogController.index([{}, {}])) // index: 2
这个直接赋值只有在 Views.index 不依赖 this 且签名兼容时才安全。如果原方法需要 Views 作为 this,应使用绑定或有意编写适配器:
const viewController = {
index: Views.index.bind(Views)
}
八、this、回调与方法借用
原文提醒把对象方法作为一等函数传递时要小心 this,这个提醒仍然有效:
const database = {
prefix: 'db',
save(value) {
return `${this.prefix}:${value}`
}
}
function invoke(callback, value) {
return callback(value)
}
// invoke(database.save, 'item'):调用形式已经不再是 database.save()
console.log(invoke(database.save.bind(database), 'item')) // db:item
在现代代码中,可以通过以下方式明确接收者:
database.save.bind(database);value => database.save(value);- 将依赖作为参数传入的纯函数;
- 使用类实例方法时由调用者保持方法引用。
“尽量少用 this”是某些函数式编程风格的偏好,不是 JavaScript 的规范要求。面向对象 API、DOM 事件、Web API 和类方法仍然经常使用 this;关键是让绑定规则显式、稳定。
九、异步函数也可以是一等值
Promise 和 async 函数把函数值继续用于异步控制流:
function fetchJson(url, fetchImpl = fetch) {
return fetchImpl(url).then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
})
}
async function loadUser(id) {
return fetchJson(`/api/users/${id}`)
}
then、catch 和 finally 都接收回调;async 函数本身也是可传递的函数值,但它返回 Promise,而不是直接返回最终数据。把函数作为回调传递时,应明确异常是同步抛出、Promise rejection,还是由宿主事件机制报告。
十、总结
- 一等函数是语言能力描述,不是 ECMAScript 的正式类型分类;
- JavaScript 函数可以赋值、传参、返回、存储和调用;
- 高阶函数是接收函数和/或返回函数的函数;
- 回调可以同步也可以异步,不能仅凭名称判断执行时机;
- 函数作为方法调用时的
this取决于调用形式,函数脱离对象后可能丢失接收者; - 返回函数常常形成闭包,闭包可以保存私有状态,但不等于内存泄漏;
- 直接传递函数可以减少无意义的胶水代码,但适配、日志、错误处理和绑定
this都是合理的包装场景; - 一等函数是函数式编程的重要基础,但不自动使 JavaScript 成为纯函数式语言。