必会基础
变量声明:var / let / const
一句话定义
let 和 const 解决 var 的变量提升和全局污染问题。const 是常量绑定(不是值不可变),let 是块级作用域变量。
javascript
// var 的问题:变量提升 + 全局挂载
console.log(a) // undefined(不是报错)
var a = 1
window.a // 1
// let/const 推荐写法
let count = 0
const PI = 3.14
// PI = 3.15 // ❌ 报错:Assignment to constant variable
// const 的"坑":对象属性可以改
const user = { name: 'Tom' }
user.name = 'Jerry' // ✅ 允许
// user = {} // ❌ 报错
使用判断
- 典型场景 :所有新代码都用
const作为默认,只有需要重新赋值时用let。永远不用var。 - 边界提醒 :
const只保证变量指向不变,不保证对象内容不变。想冻结对象用Object.freeze()。
原理/细节
let/const 存在"暂时性死区"(TDZ),变量声明前不可访问,避免 var 的意外提升行为。
数据类型与类型判断
一句话定义
JS 有 8 种数据类型:7 种基本类型(undefined、null、boolean、number、string、symbol、bigint)+ 1 种引用类型(object)。区分它们用 typeof 和 Object.prototype.toString。
javascript
// typeof 的坑
typeof null // 'object' ❌ 经典 bug
typeof [] // 'object' ✅ 数组确实是 object
typeof function(){} // 'function' ✅
// 靠谱判断
function getType(v) {
return Object.prototype.toString.call(v).slice(8, -1)
}
getType(null) // 'Null'
getType([]) // 'Array'
getType(new Date()) // 'Date'
// 判断数组专用
Array.isArray([]) // true
使用判断
- 典型场景 :判断
undefined/null用严格相等===;判断数组用Array.isArray();判断复杂类型用Object.prototype.toString。 - 边界提醒 :
typeof NaN === 'number'(NaN 是数字类型),判断是否为有效数字用isNaN()或Number.isNaN()。
原理/细节
基本类型存在栈内存,引用类型存在堆内存,变量存的是指针。null 被 typeof 误判为 object 是语言初版遗留问题。
你说得对,数据类型转换是日常踩坑高频区,确实该有独立章节。请把下面内容插入到「数据类型与类型判断」之后、「相等比较:== 与 ===」之前。
数据类型转换(显式与隐式)
一句话定义
JS 是弱类型语言,运算时类型不一致会自动转换(隐式转换)。也可以用 String()、Number()、Boolean() 手动转换(显式转换)。写代码时优先显式转换,避免依赖隐式规则。
javascript
// ---- 显式转换(推荐) ----
String(123) // '123'
Number('456') // 456
Boolean(0) // false
Boolean('hello') // true
// ---- 隐式转换(极易踩坑) ----
'5' - 3 // 2 (字符串转数字)
'5' + 3 // '53'(数字转字符串,+ 遇字符串优先拼接)
'5' - '3' // 2
'5' * '2' // 10
1 + null // 1 (null 转 0)
1 + undefined // NaN(undefined 转 NaN)
// 数组/对象的隐式转换(ToPrimitive)
[] + [] // '' (空数组转空字符串)
[] + {} // '[object Object]'
({} + []) // 输出 '[object Object]'
{} + [] // 0 (此处 {} 被解析为空代码块,实际执行 +[] => 0,经典面试陷阱)
// 引擎看到开头的 {,认为这是一个独立代码块(类似 if 后面的 {}), 于是 {} 被直接忽略,代码变成了:+ [];
// ---- 字符串转数字的两种方式 ----
parseInt('42px', 10) // 42(解析到非数字停止,务必加基数)
parseFloat('3.14em') // 3.14
Number('42px') // NaN(严格转换,只要含非数字就失败)
// ---- 假值大全(Falsy) ----
// false, 0, '', null, undefined, NaN
// 其余都是真值(Truthy),特别注意 '0'、' '、[]、{} 都是 true
if ('0') console.log('真值') // 输出 '真值'
使用判断
- 典型场景 :从 URL 参数或输入框取值转数字用
parseInt(value, 10);转字符串统一用String();做空值校验利用假值特性写if (!value)来同时判断null/undefined/''/0。 - 边界提醒 :
+运算符用于加法时两端必须都是数字,否则会触发字符串拼接,建议统一用Number()显式转换。parseInt不加10会导致parseInt('09')在旧环境被解析为八进制(得到 0)。null和undefined转数字结果不同(0vsNaN),判断对象是否存在优先用value == null。
原理/细节
隐式转换遵循 ToPrimitive 规则:对象先调用 [Symbol.toPrimitive],没有则按 valueOf → toString 顺序尝试,再将结果转数字或字符串。+ 运算符只要任一端是字符串,就执行拼接而非加法。
相等比较:== 与 ===
一句话定义
=== 不转换类型直接比较,== 会做类型转换再比较。永远默认用 === ,只有明确需要类型转换时用 ==。
javascript
// == 的诡异转换
0 == false // true
'' == false // true
null == undefined // true
[] == false // true (空数组转字符串 '' 再转数字 0)
[] == ![] // true (经典面试题)
// 安全写法
if (value === null || value === undefined) {
// 同时判断 null/undefined
}
// 更简洁
if (value == null) { // 这里 == 是安全的,因为 null == undefined
// 仅当 value 是 null 或 undefined 时进入
}
使用判断
- 典型场景 :比较值用
===;判断null或undefined可以安全用== null;判断对象属性是否存在用obj.prop === undefined。 - 边界提醒 :
NaN === NaN是false,判断NaN用Number.isNaN()。对象比较的是引用,不是内容。
原理/细节
== 转换规则:双方类型不同时,优先转数字比较。null 和 undefined 互相相等且不等于其他任何值。
函数声明与箭头函数
一句话定义
箭头函数是函数的简写,但没有自己的 this、arguments、super ,this 继承自外层作用域。普通函数的 this 取决于调用方式。
javascript
// 普通函数:this 动态绑定
const obj = {
name: 'Alice',
greet: function() {
console.log(this.name)
}
}
obj.greet() // 'Alice',this 指向 obj
const fn = obj.greet
fn() // undefined(严格模式)或 window(非严格),this 丢失
// 箭头函数:this 固定
const obj2 = {
name: 'Bob',
greet: () => {
console.log(this.name) // this 是外层(全局或模块)
}
}
obj2.greet() // undefined(非严格下 window.name)
// 实际正确用法
function Timer() {
this.seconds = 0
setInterval(() => {
this.seconds++ // this 指向 Timer 实例
}, 1000)
}
使用判断
- 典型场景 :箭头函数用于回调、定时器、事件监听中需要保留外层
this的场景。普通函数用于对象方法、构造函数。 - 边界提醒 :箭头函数不能用作构造函数(
new会报错),没有prototype属性。addEventListener中慎用箭头函数,会丢失this(指向外层)。
原理/细节
箭头函数的 this 在定义时确定,是词法作用域。普通函数的 this 在运行时确定,遵循"谁调用指向谁"规则。bind/call/apply 无法改变箭头函数的 this。
核心原理
作用域与闭包
一句话定义
作用域决定变量在哪里可用。闭包是函数能记住并访问其词法作用域(定义时的作用域),即使函数在作用域外执行。
javascript
// 闭包经典:计数器
function createCounter() {
let count = 0
return function() {
count++
return count
}
}
const counter = createCounter()
counter() // 1
counter() // 2
// count 变量被"封存"在闭包中,外部无法直接访问
// 闭包陷阱:循环中 var 的问题
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i) // 打印 3,3,3
}, 100)
}
// 修复:用 let(块级作用域)或 IIFE
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100) // 0,1,2
}
使用判断
- 典型场景:封装私有变量(模块模式)、函数工厂、防抖/节流、缓存(记忆函数)。
- 边界提醒:闭包持有外部变量引用,容易造成内存泄漏(尤其 DOM 引用)。注意循环内闭包捕获变量的问题。
原理/细节
每个函数在定义时保存了其上层作用域的引用链(\[Scope])。闭包让内部函数可以访问外部函数的变量,即使外部函数已返回。
this 的指向规则
一句话定义
this 是函数执行时的上下文对象,其值由调用方式决定,而非定义位置。掌握 4 条规则即可覆盖 99% 场景。
javascript
// 规则1:默认绑定(独立调用)→ 全局/undefined(严格模式)
function foo() { console.log(this) }
foo() // 非严格: window,严格: undefined
// 规则2:隐式绑定(对象方法)→ 调用对象
const obj = { name: 'obj', foo }
obj.foo() // obj
// 规则3:显式绑定(call/apply/bind)→ 指定对象
foo.call({ name: 'call' }) // { name: 'call' }
// 规则4:new 绑定 → 新实例
new foo() // foo 实例
// 优先级:new > 显式 > 隐式 > 默认
// 常见坑:回调中 this 丢失
const handler = { name: 'handler', handle: function() { console.log(this.name) } }
setTimeout(handler.handle, 100) // undefined(默认绑定)
// 修复:
setTimeout(handler.handle.bind(handler), 100) // 'handler'
使用判断
- 典型场景 :需要动态指定上下文时用
call/apply(立即调用),需要固定上下文时用bind(返回新函数)。 - 边界提醒 :箭头函数没有
this,无法用call/apply/bind改变。setTimeout、addEventListener中的回调注意this丢失。
原理/细节
this 的本质是函数调用时传入的隐藏参数。call/apply 会立即执行并改变 this,bind 返回新函数且 this 不可再变。
原型与继承
一句话定义
每个对象都有一个内部 [[Prototype]] 指向其原型对象。访问属性时若自身没有,会沿原型链向上查找。class 语法是原型继承的语法糖。
javascript
// 原型链基础
function Animal(name) {
this.name = name
}
Animal.prototype.say = function() { return this.name }
const dog = new Animal('Dog')
dog.say() // 'Dog'
// dog.__proto__ === Animal.prototype
// Animal.prototype.__proto__ === Object.prototype
// ES6 class 等价写法
class AnimalClass {
constructor(name) { this.name = name }
say() { return this.name }
}
// 继承
class Dog extends AnimalClass {
bark() { return 'Woof' }
}
const d = new Dog('Buddy')
d.say() // 'Buddy' (继承)
d.bark() // 'Woof'
// 组合式继承(避免 class 的坑)
function mixin(target, ...sources) {
Object.assign(target, ...sources)
}
使用判断
- 典型场景 :多个对象共享方法用原型(减少内存)。需要继承层次用
class。需要多重继承用mixin组合。 - 边界提醒 :不要用
__proto__(非标准),用Object.getPrototypeOf()/Object.setPrototypeOf()。修改内置原型(Array.prototype.myMethod = ...)是大忌,会造成全局污染和命名冲突。
原理/细节
new 操作做了三件事:创建空对象 → 将空对象的 [[Prototype]] 指向构造函数的 prototype → 执行构造函数绑定 this。原型链末端是 Object.prototype,再往上 null。
异步:回调 → Promise → async/await
一句话定义
JS 是单线程,异步通过事件循环实现。回调是基础,Promise 解决回调地狱,async/await 让异步代码像同步一样书写。
javascript
// 回调地狱(不推荐)
getUser((user) => {
getOrders(user.id, (orders) => {
getOrderDetail(orders[0].id, (detail) => {
console.log(detail)
})
})
})
// Promise 链式调用
function fetchUser() {
return fetch('/api/user').then(res => res.json())
}
fetchUser()
.then(user => fetch(`/api/orders?uid=${user.id}`).then(res => res.json()))
.then(orders => fetch(`/api/order/${orders[0].id}`).then(res => res.json()))
.then(detail => console.log(detail))
.catch(err => console.error(err))
// async/await 终极方案
async function getDetail() {
try {
const user = await fetch('/api/user').then(r => r.json())
const orders = await fetch(`/api/orders?uid=${user.id}`).then(r => r.json())
const detail = await fetch(`/api/order/${orders[0].id}`).then(r => r.json())
console.log(detail)
} catch (err) {
console.error(err)
}
}
// 注意:await 必须在 async 函数内
使用判断
- 典型场景 :所有异步操作(网络请求、定时器、文件读写)优先用
async/await。需要并行请求用Promise.all。需要竞速用Promise.race。 - 边界提醒 :
async/await不自动捕获错误,务必用try/catch或.catch()。forEach中不能用await(它不等待),用for...of。Promise.all有一个失败就会全部失败,需要容错用Promise.allSettled。
原理/细节
Promise 有三种状态:pending → resolved/rejected,状态一旦改变不可逆。async 函数返回 Promise,await 会暂停函数执行直到 Promise 决议。
事件循环(Event Loop)
一句话定义
JS 单线程通过事件循环处理异步:同步任务在主线程(调用栈)执行,异步任务进入任务队列,主线程空闲时从队列取出任务执行。
javascript
console.log('1') // 同步
setTimeout(() => console.log('2'), 0) // 宏任务
Promise.resolve().then(() => console.log('3')) // 微任务
console.log('4') // 同步
// 输出顺序:1, 4, 3, 2
// 原因:同步执行完 → 清空微任务(Promise/MutationObserver)→ 取一个宏任务(setTimeout)
// 宏任务 vs 微任务
function test() {
setTimeout(() => console.log('macro'), 0)
Promise.resolve().then(() => console.log('micro'))
console.log('sync')
}
test() // sync, micro, macro
使用判断
- 典型场景 :理解这个顺序才能正确预测异步代码执行结果。常用于解决"为什么
setTimeout延迟不准"和 DOM 更新时机问题。 - 边界提醒:微任务(Promise.then、queueMicrotask)会阻塞渲染,避免大量微任务导致卡顿。宏任务(setTimeout、setInterval、I/O)每轮至少执行一个。
原理/细节
每轮事件循环:执行调用栈中的同步代码 → 清空微任务队列 → 执行一个宏任务 → 重复。requestAnimationFrame 在渲染前执行,介于微任务和宏任务之间。
实战模式
数组常用操作(map / filter / reduce)
一句话定义
map 转换数组每个元素,filter 筛选符合条件的元素,reduce 累积计算。这三个方法替代 for 循环处理数据,代码更清晰。
javascript
const users = [
{ name: 'Alice', age: 25, active: true },
{ name: 'Bob', age: 30, active: false },
{ name: 'Carol', age: 22, active: true }
]
// map:提取名字列表
const names = users.map(u => u.name) // ['Alice','Bob','Carol']
// filter:只保留活跃用户
const activeUsers = users.filter(u => u.active) // Alice, Carol
// reduce:计算平均年龄
const avgAge = users.reduce((sum, u) => sum + u.age, 0) / users.length // 25.67
// 链式组合:活跃用户的平均年龄
const activeAvg = users
.filter(u => u.active)
.reduce((sum, u, _, arr) => sum + u.age / arr.length, 0)
// 数组去重(经典)
const arr = [1,2,2,3,3,4]
const unique = [...new Set(arr)] // [1,2,3,4]
使用判断
- 典型场景 :数据格式转换用
map;筛选数据用filter;求和/平均值/分组用reduce。链式调用时注意性能(遍历次数叠加)。 - 边界提醒 :
reduce第二个参数(初始值)最好总是提供,避免空数组报错。map不会修改原数组,但回调中修改对象属性会影响原对象(浅拷贝)。
深拷贝与浅拷贝
一句话定义
浅拷贝复制对象第一层属性(引用类型只复制指针),深拷贝递归复制所有层级。日常需要深拷贝时用 JSON 方法或 structuredClone。
javascript
// 浅拷贝方法
const obj = { a: 1, b: { c: 2 } }
const shallow1 = { ...obj } // 扩展运算符
const shallow2 = Object.assign({}, obj)
shallow1.b.c = 3
obj.b.c // 3 互相影响
// 深拷贝:JSON 方法(有局限)
const deep1 = JSON.parse(JSON.stringify(obj))
// 缺点:丢弃 undefined、函数、Symbol,Date 变成字符串,循环引用报错
// 现代方案:structuredClone(Node 17+ / 浏览器)
const deep2 = structuredClone(obj)
// 自己实现简单深拷贝(面试常考)
function deepClone(val) {
if (val === null || typeof val !== 'object') return val
if (Array.isArray(val)) return val.map(deepClone)
const result = {}
for (const key in val) {
if (val.hasOwnProperty(key)) {
result[key] = deepClone(val[key])
}
}
return result
}
使用判断
- 典型场景:拷贝配置对象、状态管理(Redux/Zustand)中避免直接修改 state 用浅拷贝。需要完全独立副本时用深拷贝(但考虑性能)。
- 边界提醒 :深拷贝对大型对象性能差,优先考虑不可变数据(Immer)或只拷贝必要层级。
JSON方法无法拷贝Date/RegExp/Function,会静默丢弃或类型转换。
防抖(Debounce)与节流(Throttle)
一句话定义
防抖:连续触发只执行最后一次(如搜索输入)。节流:固定时间间隔最多执行一次(如滚动事件)。两者都用于控制高频事件的处理频率。
javascript
// 防抖:延迟执行,重新触发则重置计时器
function debounce(fn, delay = 300) {
let timer = null
return function(...args) {
clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), delay)
}
}
// 使用:搜索输入
searchInput.addEventListener('input', debounce(function(e) {
console.log('搜索:', e.target.value)
}, 500))
// 节流:每 delay 毫秒最多执行一次
function throttle(fn, delay = 300) {
let last = 0
return function(...args) {
const now = Date.now()
if (now - last >= delay) {
last = now
fn.apply(this, args)
}
}
}
// 使用:滚动监听
window.addEventListener('scroll', throttle(function() {
console.log('滚动位置', window.scrollY)
}, 200))
使用判断
- 典型场景:防抖用于输入框实时搜索、按钮防连点、窗口 resize。节流用于滚动加载、拖拽、游戏帧更新。
- 边界提醒 :防抖/节流都要注意
this指向,用apply保留。防抖可能导致操作无响应(如验证码倒计时),需配合立即执行版本。
模块导入导出(ES Module)
一句话定义
ES Module 是官方模块方案,用 export 导出,import 导入。编译时静态分析,支持 Tree Shaking。
javascript
// 导出:user.js
export const name = 'Alice'
export function greet() { return 'Hello' }
export default class User { constructor(name) { this.name = name } }
// 导入:main.js
import User, { name, greet as sayHello } from './user.js'
console.log(name) // 'Alice'
console.log(sayHello()) // 'Hello'
const u = new User('Bob')
// 动态导入(按需加载)
button.onclick = async () => {
const module = await import('./heavy-module.js')
module.doSomething()
}
// 导出所有
export * from './utils.js'
export { pi, e } from './math.js'
使用判断
- 典型场景 :所有现代项目都用 ES Module。需要按需加载用动态
import()。需要兼容旧环境用打包工具转换。 - 边界提醒 :
import是静态的,不能放在条件语句中(除非用动态import)。default导出和命名导出混用时注意导入语法。export default后面不能跟变量声明(export default const a = 1是错的)。
收到,这是登录态管理实战知识点,属于日常开发最高频场景之一。请把下面内容插入到「实战模式」章节中「模块导入导出」之后、「进阶与避坑」之前。
登录态管理与 Token 自动携带(客户端存储)
一句话定义
localStorage / sessionStorage / Cookie 用于在浏览器端持久化存储登录凭证(Token)。通过 Axios 请求拦截器统一将 Token 写入请求头,实现"一次登录,所有接口自动鉴权";通过判断存储中是否有有效 Token 来控制路由跳转(登录/未登录)。
javascript
// ========== 1. 存储工具(auth.js) ==========
const TOKEN_KEY = 'access_token'
export const getToken = () => localStorage.getItem(TOKEN_KEY)
export const setToken = (token) => localStorage.setItem(TOKEN_KEY, token)
export const removeToken = () => localStorage.removeItem(TOKEN_KEY)
// 判断是否登录:只要有 token 就算已登录(真实有效性由后端接口返回 401 判定)
export const isLoggedIn = () => !!getToken()
// ========== 2. Axios 请求拦截(自动携带) ==========
import axios from 'axios'
import { getToken, removeToken } from './auth'
const api = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器:每次请求自动塞入 token
api.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
// 若后端要求自定义头,用 config.headers['X-Token'] = token
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器:检测 401(token 过期/无效)统一踢回登录页
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
removeToken() // 清除失效凭证
window.location.href = '/login' // 跳转登录页(SPA 中用路由 push)
}
return Promise.reject(error)
}
)
// ========== 3. 登录接口调用示例 ==========
async function login(username, password) {
const res = await api.post('/login', { username, password })
const { token } = res.data
setToken(token) // 存起来
// 后续所有 api 请求都会自动带 Authorization 头
return res
}
// ========== 4. 路由前置守卫(React/Vue 通用伪代码) ==========
// 在路由切换前判断
function routeGuard(to, from, next) {
if (to.meta.requiresAuth && !isLoggedIn()) {
next('/login') // 未登录,拦截到登录页
} else {
next()
}
}
使用判断
- 典型场景:用户登录态保持、接口鉴权、页面刷新后还原登录状态。
- 边界提醒 :
- 存储选型 :
localStorage持久化(关闭浏览器还在),sessionStorage仅当前标签页有效。Cookie 更安全(httpOnly 防 XSS)但无法用 JS 读取,若后端设置 httpOnly Cookie,前端无需手动塞 Authorization 头(浏览器自动带),但需处理 CSRF 防护。 - XSS 风险 :
localStorage中的 Token 可被恶意脚本读取,生产环境对用户输入做严格过滤(textContent替代innerHTML),或考虑 httpOnly Cookie。 - 跨域携带 Cookie :若用 Cookie 存储,需在 axios 中配置
withCredentials: true,且后端允许Access-Control-Allow-Credentials。 - Token 刷新 :
access_token过期时,401 拦截里可用refresh_token静默续期(避免直接踢回登录),续期成功则重发原请求。 - 多标签页同步 :在一个标签页退出登录(
removeToken),其他标签页无法自动感知。可监听window.addEventListener('storage', (e) => { if (e.key === TOKEN_KEY) { /* 同步状态 */ } })实现跨标签页登出。
- 存储选型 :
原理/细节
localStorage 同源共享,不同标签页数据互通但不会触发更新事件(需手动监听 storage 事件)。Token 放在 Authorization 头是 JWT 标准做法,后端无需依赖 Cookie 即可无状态校验。路由守卫本质是"读存储 + 判断",不依赖后端接口,响应速度更快。
收到。你说得对,企业级文件处理是「日常高频 + 容易踩坑」的典型场景,不应该只讲下载。我把「大文件下载」扩展为完整的企业级文件上传下载与预览专题,替换掉之前简短的下载版本。请放在「实战模式」章节中「登录态管理」之后。
企业级文件上传 / 下载 / 预览
一句话定义
文件操作的本质是二进制数据(Blob/File)在浏览器与服务器之间的流转 。企业级方案需覆盖三种场景:预览 (不下载到本地即可查看)、上传 (含进度、暂停、断点续传)、下载 (含进度、流式落盘)。核心难点在于内存控制 (大文件不撑爆页面)和网络可靠性(分片重试)。
javascript
// ======================== 一、文件预览 ========================
// 场景:选择图片/PDF/文本后立即在页面展示
function previewFile(file) {
// 1. 图片/PDF预览:使用 URL.createObjectURL(内存占用小,释放需手动)
if (file.type.startsWith('image/')) {
const url = URL.createObjectURL(file)
document.getElementById('preview-img').src = url
// 注意:组件卸载或切换文件时调用 URL.revokeObjectURL(url) 释放
}
// 2. 文本预览(小文件):使用 FileReader
if (file.type === 'text/plain' && file.size < 1024 * 1024) {
const reader = new FileReader()
reader.onload = (e) => {
document.getElementById('preview-text').textContent = e.target.result
}
reader.readAsText(file, 'UTF-8')
}
// 3. 视频/音频:直接赋值给 video/audio 标签的 src
if (file.type.startsWith('video/')) {
const url = URL.createObjectURL(file)
document.getElementById('preview-video').src = url
}
}
// ======================== 二、文件上传(标准版) ========================
// 场景:单文件/多文件上传,带进度条
async function uploadFile(file, onProgress) {
const formData = new FormData()
formData.append('file', file)
// 额外参数:formData.append('folder', '/home')
const response = await axios.post('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
const percent = Math.round((e.loaded / e.total) * 100)
onProgress?.(percent)
}
})
return response.data
}
// ======================== 三、大文件分片上传(企业核心) ========================
// 策略:将文件切成 2MB~10MB 分片,并发上传,支持断点续传
const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB/片
// 1. 计算文件哈希(用于秒传和断点续传)
async function computeHash(file) {
// 使用 spark-md5 库,抽样计算(大文件全量读太慢)
return new Promise((resolve) => {
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
const chunks = Math.ceil(file.size / CHUNK_SIZE)
let current = 0
const loadNext = () => {
const start = current * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
reader.readAsArrayBuffer(file.slice(start, end))
}
reader.onload = (e) => {
spark.append(e.target.result)
current++
if (current < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
loadNext()
})
}
// 2. 主上传流程(秒传 + 断点续传)
async function uploadLargeFile(file, onProgress) {
const hash = await computeHash(file) // 计算指纹
// 第一步:检查服务端是否已有该文件(秒传)
const { exist, uploadedChunks } = await axios.post('/api/check', { hash })
if (exist) {
alert('秒传成功!')
return
}
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
const uploadedSet = new Set(uploadedChunks || []) // 已上传的分片索引
// 并发控制(同时最多 3 个分片)
const concurrency = 3
const queue = []
for (let i = 0; i < totalChunks; i++) {
if (!uploadedSet.has(i)) {
queue.push(i)
}
}
let completed = uploadedSet.size
const updateProgress = () => {
onProgress?.(Math.round((completed / totalChunks) * 100))
}
// 上传单个分片,失败自动重试 2 次
async function uploadChunk(index, retry = 2) {
const start = index * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
const chunk = file.slice(start, end)
const formData = new FormData()
formData.append('chunk', chunk)
formData.append('hash', hash)
formData.append('index', index)
formData.append('total', totalChunks)
try {
await axios.post('/api/upload-chunk', formData)
completed++
updateProgress()
} catch (err) {
if (retry > 0) {
await uploadChunk(index, retry - 1) // 重试
} else {
throw new Error(`分片 ${index} 上传失败`)
}
}
}
// 消费队列(并发控制)
const workers = Array(Math.min(concurrency, queue.length)).fill(null)
await Promise.all(
workers.map(async () => {
while (queue.length) {
const index = queue.shift()
await uploadChunk(index)
}
})
)
// 所有分片上传完毕,通知服务端合并
await axios.post('/api/merge', { hash, totalChunks })
onProgress?.(100)
}
// ======================== 四、文件下载(含进度 + 流式落盘) ========================
// 场景:大文件下载不占内存,实时显示进度
async function downloadFile(url, filename, onProgress) {
const response = await fetch(url)
const total = parseInt(response.headers.get('content-length'), 10)
let loaded = 0
const reader = response.body.getReader()
// 方案A:如果文件小(< 500MB),用 Blob 一次性下载
if (total < 500 * 1024 * 1024) {
const chunks = []
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
loaded += value.length
onProgress?.(Math.round((loaded / total) * 100))
}
const blob = new Blob(chunks)
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
link.click()
URL.revokeObjectURL(link.href)
return
}
// 方案B:大文件(> 500MB)使用 File System Access API 流式写入磁盘
if ('showSaveFilePicker' in window) {
const fileHandle = await window.showSaveFilePicker({ suggestedName: filename })
const writable = await fileHandle.createWritable()
while (true) {
const { done, value } = await reader.read()
if (done) break
loaded += value.length
onProgress?.(Math.round((loaded / total) * 100))
await writable.write(value)
}
await writable.close()
} else {
// 降级:用 a 标签触发浏览器默认下载(无进度条)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
}
}
使用判断
-
典型场景:
- 预览:图片/PDF/视频选择后立即展示;聊天附件缩略图;网盘文件列表缩略图。
- 标准上传:日常表单附件(≤100MB)、头像更换、文档归档。
- 分片上传 :视频投稿(1GB+)、安装包发布、备份文件同步。配合秒传 (相同文件跳过)和断点续传(网络中断后继续)大幅提升体验。
- 下载:报表导出(带进度)、客户端更新包下载、素材库批量打包下载。
-
边界提醒(必读!):
- 内存泄漏(头号杀手) :
URL.createObjectURL()创建的 URL 不主动revoke会一直占用内存直到页面关闭。每创建一个,用完必须revoke。FileReader.readAsDataURL()会将文件转为 Base64(体积膨胀 1/3),只适合预览小图(< 5MB),大图请用createObjectURL。- 下载方案A(
Blob一次性)只适合 500MB 以下,超大文件必须用方案B(流式写盘),否则页面会因内存不足直接崩溃(OOM)。
- 分片上传的坑 :
- 分片大小不是越小越好(请求数太多),也不是越大越好(重试成本高),生产环境常用 5MB~10MB。
- 计算哈希时全量读取大文件会卡死主线程,需抽样哈希(只读首尾和中间片段)或使用 Web Worker 异步计算。
- 服务端合并分片时可能超时(Nginx 默认 60s),后端需用异步合并 + 轮询状态。
- 跨域与安全 :
- 分片上传和下载都需服务端正确配置 CORS(
Access-Control-Allow-Origin、Access-Control-Expose-Headers暴露content-length)。 - 文件预览的
blob:URL 只能当前页面访问,无法分享给他人(属于同源隔离)。
- 分片上传和下载都需服务端正确配置 CORS(
- 移动端适配 :
showSaveFilePicker在 iOS Safari 和部分安卓浏览器上不支持,必须准备降级方案(方案A的 Blob 下载)。
- 内存泄漏(头号杀手) :
原理/细节
- 二进制基础 :
File继承自Blob,两者都是"二进制数据的容器"。File多了name和lastModified属性。Blob.slice()是实现分片的核心,它将一个大文件切分成若干小Blob。 - 内存流转路径 :用户选择文件 →
File对象存在于内存 →FormData包装 → XMLHttpRequest 将二进制流逐步发送(不复制全部)。FileReader将二进制转为文本/DataURL/ArrayBuffer 时会产生新的内存副本,这是它"耗内存"的根本原因。 - 秒传原理:服务端存储每个文件的哈希值(如 MD5)和存储路径。上传前发送哈希,服务端查库,若存在则直接返回"上传成功",前端跳过实际上传。分片断点续传则是服务端记录已收到的分片索引,前端只传缺失部分。
- 并发控制实现:上述代码用"工作池"(固定数量的 Worker 从队列取任务)控制并发,避免一次性发几十个请求打爆浏览器连接池(Chrome 同域名限制 6 个并发)或服务端压力过大。
进阶与避坑
内存泄漏的常见场景
一句话定义
内存泄漏是程序不再使用的内存未被回收,导致性能下降甚至崩溃。JS 有垃圾回收(GC),但开发者仍会无意创建无法回收的引用。
javascript
// 1. 意外的全局变量
function leak() {
leaked = 'this is global' // 未声明变量,挂到 window
}
// 修复:'use strict' 会报错
// 2. 闭包中引用大对象
function outer() {
const bigData = new Array(1000000).fill('*')
return function inner() {
console.log('hello') // 虽然没用到 bigData,但闭包持有它
}
}
const fn = outer() // bigData 一直被引用
// 3. DOM 引用未清理
const el = document.getElementById('btn')
document.body.removeChild(el)
// el 仍然引用 DOM 节点,导致无法回收
// 修复:el = null
// 4. 定时器未清除
const timer = setInterval(() => { /* 做事情 */ }, 1000)
// 不再需要时:clearInterval(timer)
使用判断
- 典型场景:单页应用(SPA)中切换路由时清理定时器、取消未完成的请求、解绑事件监听。
- 边界提醒:Chrome DevTools 的 Memory 面板可以拍快照对比。WeakMap/WeakSet 可以避免键值对导致的泄漏(键是弱引用)。
原理/细节
GC 主要靠"可达性"判断,从根(window)出发能访问到的对象都不会被回收。打破引用链条即可释放内存。
事件委托
一句话定义
利用事件冒泡,将子元素的事件监听委托给父元素。减少监听器数量,且动态添加的子元素也能响应事件。
javascript
// 反模式:给每个列表项绑定事件
document.querySelectorAll('li').forEach(li => {
li.addEventListener('click', () => console.log(li.textContent))
})
// 如果后续新增 li,新元素没有监听
// 事件委托:监听父元素
const list = document.getElementById('list')
list.addEventListener('click', function(e) {
// e.target 是实际点击的元素
if (e.target.tagName === 'LI') {
console.log(e.target.textContent)
}
// 若 li 内还有子元素,需要判断 closest
const li = e.target.closest('li')
if (li) console.log(li.textContent)
})
// 动态添加 li 自动生效
const newLi = document.createElement('li')
newLi.textContent = 'New Item'
list.appendChild(newLi)
使用判断
- 典型场景:列表/表格的行点击、菜单项点击、任何需要监听大量同类元素的场景。
- 边界提醒 :某些事件不冒泡(如
focus/blur,可用focusin/focusout代替)。stopPropagation()会阻断委托,慎用。
原理/细节
事件流:捕获(从 window 到目标)→ 目标 → 冒泡(从目标到 window)。事件委托利用冒泡阶段。
浮点数精度问题
一句话定义
JS 的 Number 是双精度浮点数(IEEE 754),二进制无法精确表示十进制小数,导致 0.1 + 0.2 !== 0.3。
javascript
console.log(0.1 + 0.2) // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3) // false
// 解决方案1:使用整数计算(金额按分存储)
const price = 0.1 * 100 // 10分
const total = (0.1 * 100 + 0.2 * 100) / 100 // 0.3
// 解决方案2:指定精度
const result = (0.1 + 0.2).toFixed(2) // '0.30'
parseFloat(result) // 0.3
// 解决方案3:使用第三方库(decimal.js / big.js)
// 不展开,生产环境推荐
// 安全比较函数
function nearlyEqual(a, b, epsilon = Number.EPSILON) {
return Math.abs(a - b) < epsilon
}
nearlyEqual(0.1 + 0.2, 0.3) // true
使用判断
- 典型场景:金额计算、价格显示、任何需要精确小数的业务。
- 边界提醒 :
toFixed返回字符串,且存在四舍五入不准确问题(银行家舍入)。Number.EPSILON是 2^-52,适合做误差容限。
原理/细节
浮点数在内存中以二进制的科学计数法存储,大部分十进制小数(如 0.1)是无限循环二进制,只能近似表示。
迭代器与生成器(实用级)
一句话定义
迭代器(Iterator)让对象可被 for...of 遍历。生成器(Generator)是能暂停执行的函数,用于实现自定义迭代或异步流程控制。
javascript
// 可迭代对象:实现 [Symbol.iterator]
const range = {
start: 1,
end: 5,
[Symbol.iterator]() {
let current = this.start
const end = this.end
return {
next() {
if (current <= end) {
return { value: current++, done: false }
}
return { done: true }
}
}
}
}
for (const num of range) {
console.log(num) // 1,2,3,4,5
}
// 生成器简化迭代
function* genRange(start, end) {
for (let i = start; i <= end; i++) {
yield i
}
}
for (const num of genRange(1, 5)) {
console.log(num) // 1,2,3,4,5
}
// 生成器用于异步(了解即可)
function* asyncFlow() {
const data = yield fetch('/api/data')
console.log(data)
}
使用判断
- 典型场景:需要自定义遍历逻辑(如分页、树遍历)。需要"按需计算"的无限序列(斐波那契)。
- 边界提醒:生成器在日常业务中很少直接使用,但理解它有助于读懂库源码(如 Redux-Saga)。不要为了用而用。
原理/细节
for...of 会调用对象的 [Symbol.iterator] 方法获取迭代器,然后反复调用 next()。生成器函数执行时返回生成器对象,yield 暂停并返回值。
收到,已补充「代理与反射」。请把它插入到「进阶与避坑」章节中「迭代器与生成器」之后、「附:高频面试题速查」之前。
代理(Proxy)与反射(Reflect)
一句话定义
Proxy 让你在对象上安插"门卫",拦截读、写、删除等底层操作。Reflect 是配套的"操作手册",提供对应方法让你正常执行对象的原始行为。两者结合用于实现响应式数据、访问校验、私有属性防护等高级场景。
javascript
const user = { name: 'Alice', age: 25, _password: '123' }
const handler = {
// 拦截读取操作
get(target, key) {
// 私有属性(_开头)禁止访问
if (key.startsWith('_')) {
console.warn(`禁止访问私有属性 ${key}`)
return undefined
}
console.log(`[LOG] 读取 ${key}`)
return Reflect.get(target, key) // 调用原始行为
},
// 拦截设置操作
set(target, key, value) {
if (key === 'age' && typeof value !== 'number') {
throw new Error('年龄必须是数字') // 校验拦截
}
console.log(`[LOG] 设置 ${key} = ${value}`)
return Reflect.set(target, key, value) // 必须返回布尔值
},
// 拦截删除操作
deleteProperty(target, key) {
if (key.startsWith('_')) {
console.warn('禁止删除私有属性')
return false // 返回 false 表示删除失败
}
return Reflect.deleteProperty(target, key)
}
}
const proxy = new Proxy(user, handler)
proxy.name // 输出日志,返回 'Alice'
proxy._password // 警告,返回 undefined
proxy.age = 30 // 正常设置
// proxy.age = '30' // 报错:年龄必须是数字
delete proxy.name // 正常删除
delete proxy._password // 警告,删除失败
使用判断
- 典型场景:Vue 3 / MobX 等框架的响应式系统底层;表单数据校验;私有属性防护;访问日志埋点;实现"只读"对象(set 时抛错)。
- 边界提醒 :
Proxy只能拦截通过代理对象的操作,直接修改target会绕过拦截(务必保持全量使用代理)。set拦截器必须返回true表示成功,否则严格模式下抛出TypeError。Proxy无法拦截===或Object.is()比较。代理对象与原对象不相等(proxy === user为false)。
原理/细节
Proxy 创建对象的"门面",所有对代理的操作都路由到 handler 上的陷阱方法(trap)。Reflect 的方法与 Proxy 的陷阱一一对应,设计目的就是让开发者在陷阱中方便地调用默认行为,避免手动编写 try/catch 或硬编码原始操作。Proxy 支持撤销代理(Proxy.revocable()),用于临时权限控制场景。