JavaScript

必会基础

变量声明:var / let / const

一句话定义

letconst 解决 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 种基本类型(undefinednullbooleannumberstringsymbolbigint)+ 1 种引用类型(object)。区分它们用 typeofObject.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()

原理/细节

基本类型存在栈内存,引用类型存在堆内存,变量存的是指针。nulltypeof 误判为 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)。nullundefined 转数字结果不同(0 vs NaN),判断对象是否存在优先用 value == null

原理/细节

隐式转换遵循 ToPrimitive 规则:对象先调用 [Symbol.toPrimitive],没有则按 valueOftoString 顺序尝试,再将结果转数字或字符串。+ 运算符只要任一端是字符串,就执行拼接而非加法。

相等比较:== 与 ===

一句话定义

=== 不转换类型直接比较,== 会做类型转换再比较。永远默认用 === ,只有明确需要类型转换时用 ==

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 时进入
}

使用判断

  • 典型场景 :比较值用 ===;判断 nullundefined 可以安全用 == null;判断对象属性是否存在用 obj.prop === undefined
  • 边界提醒NaN === NaNfalse,判断 NaNNumber.isNaN()。对象比较的是引用,不是内容。

原理/细节

== 转换规则:双方类型不同时,优先转数字比较。nullundefined 互相相等且不等于其他任何值。


函数声明与箭头函数

一句话定义

箭头函数是函数的简写,但没有自己的 thisargumentssuperthis 继承自外层作用域。普通函数的 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 改变。setTimeoutaddEventListener 中的回调注意 this 丢失。

原理/细节

this 的本质是函数调用时传入的隐藏参数。call/apply 会立即执行并改变 thisbind 返回新函数且 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...ofPromise.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()
  }
}

使用判断

  • 典型场景:用户登录态保持、接口鉴权、页面刷新后还原登录状态。
  • 边界提醒
    1. 存储选型localStorage 持久化(关闭浏览器还在),sessionStorage 仅当前标签页有效。Cookie 更安全(httpOnly 防 XSS)但无法用 JS 读取,若后端设置 httpOnly Cookie,前端无需手动塞 Authorization 头(浏览器自动带),但需处理 CSRF 防护。
    2. XSS 风险localStorage 中的 Token 可被恶意脚本读取,生产环境对用户输入做严格过滤(textContent 替代 innerHTML),或考虑 httpOnly Cookie。
    3. 跨域携带 Cookie :若用 Cookie 存储,需在 axios 中配置 withCredentials: true,且后端允许 Access-Control-Allow-Credentials
    4. Token 刷新access_token 过期时,401 拦截里可用 refresh_token 静默续期(避免直接踢回登录),续期成功则重发原请求。
    5. 多标签页同步 :在一个标签页退出登录(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+)、安装包发布、备份文件同步。配合秒传 (相同文件跳过)和断点续传(网络中断后继续)大幅提升体验。
    • 下载:报表导出(带进度)、客户端更新包下载、素材库批量打包下载。
  • 边界提醒(必读!)

    1. 内存泄漏(头号杀手)
      • URL.createObjectURL() 创建的 URL 不主动 revoke 会一直占用内存直到页面关闭。每创建一个,用完必须 revoke
      • FileReader.readAsDataURL() 会将文件转为 Base64(体积膨胀 1/3),只适合预览小图(< 5MB),大图请用 createObjectURL
      • 下载方案A(Blob 一次性)只适合 500MB 以下,超大文件必须用方案B(流式写盘),否则页面会因内存不足直接崩溃(OOM)。
    2. 分片上传的坑
      • 分片大小不是越小越好(请求数太多),也不是越大越好(重试成本高),生产环境常用 5MB~10MB
      • 计算哈希时全量读取大文件会卡死主线程,需抽样哈希(只读首尾和中间片段)或使用 Web Worker 异步计算。
      • 服务端合并分片时可能超时(Nginx 默认 60s),后端需用异步合并 + 轮询状态。
    3. 跨域与安全
      • 分片上传和下载都需服务端正确配置 CORS(Access-Control-Allow-OriginAccess-Control-Expose-Headers 暴露 content-length)。
      • 文件预览的 blob: URL 只能当前页面访问,无法分享给他人(属于同源隔离)。
    4. 移动端适配
      • showSaveFilePicker 在 iOS Safari 和部分安卓浏览器上不支持,必须准备降级方案(方案A的 Blob 下载)。

原理/细节

  • 二进制基础File 继承自 Blob,两者都是"二进制数据的容器"。File 多了 namelastModified 属性。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 表示成功,否则严格模式下抛出 TypeErrorProxy 无法拦截 ===Object.is() 比较。代理对象与原对象不相等(proxy === userfalse)。

原理/细节

Proxy 创建对象的"门面",所有对代理的操作都路由到 handler 上的陷阱方法(trap)。Reflect 的方法与 Proxy 的陷阱一一对应,设计目的就是让开发者在陷阱中方便地调用默认行为,避免手动编写 try/catch 或硬编码原始操作。Proxy 支持撤销代理(Proxy.revocable()),用于临时权限控制场景。

相关推荐
彭于晏爱编程1 小时前
纯 JS + Node,一个下午手搓了能读懂公司代码的 AI 助手,老板以为我转行了
前端·javascript
Delicate2 小时前
前端路由扫盲篇:Hash 模式和 History 模式到底怎么选?
前端
妙码生花2 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(十四):眨眼小人登录页制作
前端·javascript·ai编程
妙码生花2 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(十三):前端路由初始化
前端·javascript·ai编程
PBitW2 小时前
GPT训练我的第四天,被打惨了!!!😭😭😭
前端·javascript·面试
梨子同志2 小时前
CSS
前端
一tiao咸鱼3 小时前
Ai 相关 7月1日学习
前端·agent
梨子同志3 小时前
HTML
前端
ZhengEnCi3 小时前
Q06-导航按钮高级拟态玻璃效果构建完全指南
前端·css