ES6+ 核心语法速通
为什么要先学 ES6+
ES6 (ES2015) 是 JavaScript 的现代化起点。在那之前的 JS 写起来像玩具语言,之后才真正具备了写大型应用的能力。Vue 3 的源码、TypeScript、以及几乎所有现代前端库,都大量使用 ES6+ 语法。
Java 类比:ES6 之于 JS,大概相当于 Java 8 的 Lambda + Stream + Optional 那一波更新——都是让语言从"能用"变成"好用"的分水岭。
1. 变量声明:let / const 取代 var
// 旧写法(少碰)
var name = 'foo'
// 新写法
let age = 18 // 可变
const MAX = 100 // 不可变(但对象里的属性还是能改)
Java 对照:
| Java | JS |
|---|---|
int age = 18; |
let age = 18 |
final int MAX = 100; |
const MAX = 100 |
坑点:
const锁的是"引用不能变",对象内部的属性仍然能改。这和 Java 的final完全一致。const user = { name: 'foo' } user.name = 'bar' // ✅ 可以 user = { name: 'x' } // ❌ 报错var有变量提升和函数作用域的怪异行为,新代码永远用let/const。
2. 箭头函数:轻量级 Lambda
// 普通函数
function add(a, b) {
return a + b
}
// 箭头函数
const add = (a, b) => a + b
Java 对照:
// Java 8 Lambda
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
关键差异:Java 的 Lambda 里 this 指向外部类;JS 的箭头函数里 this 也指向外层作用域。但普通 function 里的 this 是个坑——谁调用它就是谁,和 Java 完全不一样。
const obj = {
count: 0,
// ❌ 这里不要用箭头函数,this 会指向全局而非 obj
bad: () => { this.count++ },
// ✅ 方法简写,this 正确指向 obj
good() { this.count++ },
}
建议:回调函数用箭头函数,对象方法用简写。
3. 解构赋值:一次取多个值
const user = { name: 'foo', age: 18, email: 'f@x.com' }
// 对象解构
const { name, age } = user
// 等价于 const name = user.name; const age = user.age;
// 数组解构
const [first, second] = [10, 20]
// 重命名 + 默认值
const { name: userName, role = 'guest' } = user
Java 对照:Java 没有解构。在 Java 里你得写:
String name = user.getName();
int age = user.getAge();
常见用途:函数参数直接解构,等价于 Java 的命名参数。
function createUser({ name, age, role = 'user' }) {
// ...
}
createUser({ name: 'foo', age: 18 })
4. 模板字符串:告别字符串拼接
const name = 'foo'
const greeting = `Hello, ${name}! Today is ${new Date().toLocaleDateString()}.`
// 多行字符串也很自然
const html = `
<div>
<h1>${title}</h1>
</div>
`
Java 对照:类似 Java 15 的文本块 """...""",加上 String.format() 或 Java 21 的 STR."Hello \{name}" 模板。
5. 扩展运算符 (...)
// 数组
const arr1 = [1, 2, 3]
const arr2 = [0, ...arr1, 4] // [0, 1, 2, 3, 4]
// 对象合并
const base = { a: 1, b: 2 }
const extended = { ...base, c: 3 } // { a: 1, b: 2, c: 3 }
// 函数参数收集
function sum(...nums) {
return nums.reduce((a, b) => a + b, 0)
}
sum(1, 2, 3, 4) // 10
Java 对照:
- 收集参数
...nums≈ Java 的可变参数int... nums - 对象合并 ≈
new HashMap<>(base) { { put("c", 3); } },但 JS 优雅得多
注意:对象扩展是浅拷贝。嵌套对象还是引用。
6. Promise 与 async/await:告别回调地狱
这是 ES6+ 最重要的更新之一。前端几乎所有异步操作(网络请求、定时器、文件读写)都用它。
// 用 Promise
fetch('/api/user')
.then(res => res.json())
.then(user => console.log(user))
.catch(err => console.error(err))
// 用 async/await(推荐)
async function loadUser() {
try {
const res = await fetch('/api/user')
const user = await res.json()
console.log(user)
} catch (err) {
console.error(err)
}
}
Java 对照:
| Java | JS |
|---|---|
CompletableFuture<T> |
Promise<T> |
.thenApply(fn) |
.then(fn) |
.exceptionally(fn) |
.catch(fn) |
| Java 19+ 虚拟线程让异步代码看起来同步 | async/await |
关键理解:
async函数必然返回Promise,哪怕你 return 的是普通值await只能用在async函数里,它会"暂停"当前函数直到 Promise 完成await并不会阻塞线程——JS 是单线程的,它只是让出执行权给事件循环
常见坑:忘记 await 导致拿到的是 Promise 对象而不是结果值。
6.1 原理:Promise 是个状态机
一个 Promise 内部就三个字段:
state: 'pending' | 'fulfilled' | 'rejected' // 状态,一旦改变不可逆
value: resolve 时传入的值 / reject 时的错误
callbacks: 一组 then/catch 注册的回调
状态转移规则(单向、不可逆):
resolve(v)
pending ─────────────► fulfilled(v)
│
│ reject(e)
└─────────────► rejected(e)
const p = new Promise((resolve, reject) => {
resolve(1) // state: pending → fulfilled, value: 1
resolve(2) // 已经定型,这次调用被忽略
reject('boom') // 同样被忽略
})
Java 对照:和 CompletableFuture 几乎一致——complete() / completeExceptionally() 也只对未完成的 future 生效。
6.2 then 不是回调,是"返回新 Promise"
每个 .then() 都返回一个新的 Promise,链式调用本质是 Promise 的接力:
Promise.resolve(1)
.then(v => v + 1) // 返回新 Promise<2>
.then(v => v * 10) // 返回新 Promise<20>
.then(v => Promise.resolve(v + 5)) // 返回 Promise<25>,自动解套
.then(v => console.log(v)) // 25
关键规则:
then的回调返回普通值 → 新 Promise 直接用这个值 fulfill- 返回另一个 Promise → 新 Promise 等它完成(Promise 透传,避免嵌套)
- 回调里抛异常 → 新 Promise 变 rejected
- 没写
.catch→ 异常沿链往后传播,最后没人处理就触发unhandledrejection
这就是为什么 .then().then().catch() 能工作——异常像水一样往下流。
6.3 微任务(microtask):await 后面的代码什么时候跑
JS 的事件循环里有两个队列:
┌──────────────┐ ┌──────────────┐
│ 宏任务队列 │ │ 微任务队列 │
│ (macrotask) │ │ (microtask) │
├──────────────┤ ├──────────────┤
│ setTimeout │ │ Promise.then │
│ setInterval │ │ queueMicrotask│
│ I/O │ │ MutationObserver│
│ requestAnimationFrame│ │ async 函数 await 后续 │
└──────────────┘ └──────────────┘
执行规则(背下来):
- 跑同步代码到底
- 清空整个微任务队列(不止跑一个,跑到队列空为止)
- 渲染(如果需要)
- 跑一个宏任务
- 回到 2
经典面试题:
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
console.log('4')
// 输出顺序:1 → 4 → 3 → 2
为什么 3 在 2 前面?因为同步代码跑完后先清空整个微任务队列,再去碰宏任务。
await 的本质:await x 等价于"把后续代码包进 Promise.resolve(x).then(...)"——所以 await 后的代码总是在微任务里跑。
async function foo() {
console.log('A')
await 1
console.log('B') // 这行实际是微任务
}
console.log('start')
foo()
console.log('end')
// start → A → end → B
Java 对照:JVM 没有微任务/宏任务的双队列设计,但行为上类似 CompletableFuture 的 thenApplyAsync 在公共 ForkJoinPool 里跑——只是 JS 的微任务是固定优先于下一轮 I/O。
6.4 await 在循环里的陷阱
// ❌ 顺序执行,10 个请求串行,慢
for (const id of ids) {
await fetch(`/api/${id}`)
}
// ✅ 并行发起,等所有完成
await Promise.all(ids.map(id => fetch(`/api/${id}`)))
// ✅ 并行发起,部分失败也继续
const results = await Promise.allSettled(ids.map(id => fetch(`/api/${id}`)))
Promise.all 任一 reject 就整体 reject;Promise.allSettled 等所有完成(不管成败),返回每项结果数组。对应 Java 的 CompletableFuture.allOf()。
7. 模块化:import / export
// user.js
export function getUser() { /* ... */ }
export const USER_ROLE = 'admin'
export default class UserService { /* ... */ }
// main.js
import UserService, { getUser, USER_ROLE } from './user.js'
Java 对照:
export≈ Java 的public- 默认导出
export default≈ 一个文件的"主类",在 Java 里对应文件名同名的 public class import≈ Java 的import,但 JS 可以具名导入、重命名、全部导入
import * as UserModule from './user.js' // 全部导入成命名空间
import { getUser as fetchUser } from './user.js' // 重命名
8. 可选链 (?.) 与空值合并 (??)
这是 ES2020 加进来的,写 JS 和 TS 时非常常用。
// 可选链:避免 TypeError
const city = user?.address?.city // 任何一层是 null/undefined 就返回 undefined
// 空值合并:只在 null/undefined 时用默认值
const name = user.name ?? '匿名'
// 注意:|| 会把 '' 和 0 也当"假值"处理,?? 只看 null/undefined
Java 对照:
?.≈Optional.map(...).orElse(null),但语法优雅得多??≈Objects.requireNonNullElse(name, "匿名")
9. 数组高阶方法:函数式风格
const nums = [1, 2, 3, 4, 5]
nums.map(n => n * 2) // [2, 4, 6, 8, 10]
nums.filter(n => n % 2 === 0) // [2, 4]
nums.reduce((sum, n) => sum + n, 0) // 15
nums.find(n => n > 3) // 4
nums.some(n => n > 4) // true
nums.every(n => n > 0) // true
Java 对照:几乎一一对应 Java 8 Stream API。
nums.stream().map(n -> n * 2).toList();
nums.stream().filter(n -> n % 2 == 0).toList();
nums.stream().reduce(0, (a, b) -> a + b);
差异:
- JS 这些方法直接定义在数组上,不用先
.stream() - JS 的
map/filter返回新数组,不修改原数组,和 Stream 一致
10. 原理深挖:this 到底指谁
JS 里 this 是调用时绑定,不是定义时绑定,这和 Java 完全相反。Java 里 this 永远指当前实例,编译期就定了;JS 里同一个函数在不同地方调用,this 可能完全不同。
四条规则按优先级从低到高:
// 规则 1:默认绑定(独立调用 → this 是 undefined / 全局)
function show() { console.log(this) }
show() // 严格模式 undefined,非严格模式 window
// 规则 2:隐式绑定(作为方法调用 → this 是点前面的对象)
const obj = { name: 'A', show }
obj.show() // this 是 obj
// 规则 3:显式绑定(call/apply/bind 强行指定)
show.call({ name: 'B' }) // this 是 { name: 'B' }
// 规则 4:new 绑定(new 调用 → this 是新创建的实例,最高优先级)
new show() // this 是一个新对象
箭头函数是例外:它没有自己的 this,沿词法作用域链找最近的 this:
const obj = {
name: 'A',
show() {
setTimeout(function () { console.log(this.name) }, 0) // undefined(独立调用)
setTimeout(() => console.log(this.name), 0) // 'A'(继承外层 obj.show 的 this)
}
}
obj.show()
Vue 里的体现:Composition API 之所以舒服,就是把"this 指谁"这种心智完全抹掉了——<script setup> 里没有 this,所有变量都是词法作用域的普通变量。Options API 才需要纠结 this.count 是哪个 this。
11. 原理深挖:闭包 = 函数 + 它捕获的作用域
function makeCounter() {
let count = 0 // 这个变量本该在 makeCounter 返回后销毁
return {
inc: () => ++count,
get: () => count,
}
}
const c = makeCounter()
c.inc(); c.inc()
console.log(c.get()) // 2
count 没被销毁,是因为返回的两个函数还引用着它。函数对象内部隐藏一个 [[Environment]] 槽,记着定义时所处的词法环境(变量表)。这个"函数 + 被捕获的环境"组合就是闭包。
Java 对照:Java 的 Lambda 也是闭包,但它只能捕获 final 或事实 final 的局部变量——通过把变量值复制进 Lambda 实例的字段实现,被捕变量不可改。JS 的闭包可以读写外层变量,因为是真正的引用。
为什么这点对学 Vue 关键:
// Vue 3 响应式的 effect 系统就靠闭包工作
function effect(fn) {
const wrapped = () => {
activeEffect = wrapped // 闭包捕获了外层的 wrapped
fn()
activeEffect = null
}
wrapped()
}
后面学响应式时会反复看到这个套路:闭包让"哪个函数正在运行"这个上下文能在异步/回调里持续传递。
常见坑——循环里用 var:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0) // 输出 3 3 3(三个回调共享同一个 i)
}
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0) // 输出 0 1 2(每轮一个新的 i)
}
var 函数作用域,let 块作用域——let 在循环每一轮新建一个 i,每个回调闭包捕获的是不同的变量。这也是 ES6 改用 let 的核心动机之一。
12. 原理深挖:原型链 ≠ Java 类继承
JS 的"继承"不是类继承,是原型委托:每个对象都有一个隐藏指针 [[Prototype]](可通过 Object.getPrototypeOf(obj) 拿到),指向它的"原型对象"。访问属性找不到时,沿这个指针向上找。
class Animal {
constructor(name) { this.name = name }
speak() { console.log(this.name + ' speaks') }
}
class Dog extends Animal {
bark() { console.log('woof') }
}
const d = new Dog('旺财')
d.bark() // 自身找不到 → 沿原型链向上 → 找到 Dog.prototype.bark
d.speak() // Dog.prototype 没有 → 再上一层 → Animal.prototype.speak
d.toString() // 再往上 → Object.prototype.toString
d.foo // 找到 null 还没有 → undefined
实际查找路径:
d ──[[Prototype]]──► Dog.prototype ──[[Prototype]]──► Animal.prototype ──[[Prototype]]──► Object.prototype ──► null
class 是语法糖——等价于:
function Animal(name) { this.name = name }
Animal.prototype.speak = function () { console.log(this.name + ' speaks') }
ES6 的 class 没有引入新机制,只是把这套原型操作写得像 Java。
Java 对照:
| Java 类继承 | JS 原型链 |
|---|---|
| 编译期固定 | 运行时可改(Object.setPrototypeOf) |
| 字段在每个实例上 | 方法在原型上共享,字段在实例上 |
instanceof 看类层级 |
instanceof 看原型链上是否有对应的 prototype |
面试考点:Object.create(null) 创建一个没有原型的对象,连 toString / hasOwnProperty 都没有。常用作纯字典 Map,避免和原型上的方法名冲突。Vue 内部某些缓存就这么用。
13. 原理深挖:事件循环全景图
把第 6 节的微任务/宏任务放进完整图景:
┌─────────────────────────────────────────────────────┐
│ Call Stack │
│ (同步代码、当前正在跑的函数) │
└─────────────┬───────────────────────────────────────┘
│ 跑空了
▼
┌─────────────────────────────────────────────────────┐
│ Microtask Queue(清空到为空) │
│ Promise.then / queueMicrotask / await 后续 │
└─────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Render(如果到了渲染时机) │
│ 样式计算 → 布局 → 绘制 → 合成 │
└─────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Macrotask Queue(取一个跑) │
│ setTimeout / I/O / UI 事件 / postMessage │
└─────────────┬───────────────────────────────────────┘
│
└──── 回到顶部循环
几个推论:
setTimeout(fn, 0)不是立刻执行——至少要等当前同步代码跑完 + 当前微任务队列清空 + 一次渲染机会- 大量 Promise 链可以"饿死"宏任务——微任务队列必须清空,理论上一直 push 微任务能阻止下次渲染。所以别在
.then里写无限递归 - Vue 的
nextTick用的是微任务(Promise.resolve().then)——所以"改完数据,下一个微任务里 DOM 已更新" requestAnimationFrame在 render 之前触发——动画用它而不是 setTimeout,能保证刚好赶上下一帧
Java 对照:Node.js 的事件循环和浏览器类似但分阶段(timers / pending / poll / check / close),不过都是单线程 + 队列的设计。和 Java 的多线程并行模型完全不同。
14. 原理深挖:值类型 vs 引用类型 + 浅拷贝陷阱
JS 的内存模型只有两类:
- 值类型(primitive):
number / string / boolean / null / undefined / symbol / bigint—— 存在栈上,赋值复制值 - 引用类型(reference):
object / array / function—— 存在堆上,变量存"指向堆的指针",赋值复制指针
let a = { x: 1 }
let b = a // b 和 a 指向同一个对象
b.x = 2
console.log(a.x) // 2,被改了
// 扩展运算符是浅拷贝
let c = { ...a } // c 是新对象,但里面的嵌套对象还是共享引用
let nested = { inner: { v: 1 } }
let d = { ...nested }
d.inner.v = 99
console.log(nested.inner.v) // 99!inner 是共享的
深拷贝方案:
// 浏览器原生(推荐,处理循环引用、Date、Map、Set)
const deep = structuredClone(obj)
// 老办法(不处理函数、循环引用、Date 会变字符串)
const deep2 = JSON.parse(JSON.stringify(obj))
Java 对照:和 Java 的对象引用语义一致,区别是 Java 有原生 Cloneable 和深拷贝库(Apache Commons),JS 长期没有标准答案,2022 才有了 structuredClone。
Vue 场景:响应式系统对对象用 Proxy 包一层,对基本类型用 ref 包一层(因为基本类型没法挂 Proxy)——这个分裂正是源于 JS 的双内存模型。
小结:速查表
| 特性 | 一句话 |
|---|---|
let / const |
取代 var,块级作用域 |
箭头函数 => |
短函数 + 绑定外层 this(无独立 this) |
解构 { a, b } = obj |
批量取值 |
模板字符串 ` ` |
拼接字符串 + 多行 |
扩展运算符 ... |
展开数组/对象、收集参数(浅拷贝) |
async / await |
写起来像同步的异步,await 后续在微任务里跑 |
import / export |
模块化 |
?. / ?? |
空值安全 |
| 数组高阶方法 | 函数式风格,类似 Stream |
| Promise 状态机 | pending → fulfilled/rejected,不可逆 |
| 事件循环 | 同步 → 清空微任务 → 渲染 → 一个宏任务 |
| this 四规则 | 默认 < 隐式 < 显式 < new;箭头函数走词法 |
| 闭包 | 函数 + 它捕获的词法环境,Vue 响应式的基石 |
| 原型链 | 找属性时沿 [[Prototype]] 向上找,class 是语法糖 |
| 浅拷贝 vs 深拷贝 | ... / Object.assign 浅;structuredClone 深 |
延伸阅读
- MDN: JavaScript 指南
- 阮一峰《ECMAScript 6 入门》 — 最权威的中文 ES6 教程
- You Don't Know JS (2nd Edition) — 深入原理推荐这本
下一篇:TypeScript 基础(从 Java 视角看 TS 的类型系统)