JavaScript面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

前端阅读 315月28日 03:34

前端模块规范有哪些?模块如何异步加载?

JavaScript 模块化经历了从全局变量污染到标准化模块系统的漫长演进,不同规范解决了不同阶段的问题。IIFE:最早的模块化尝试在规范出现之前,开发者用立即执行函数表达式创建独立作用域:var MyModule = (function() { var privateVar = 'hidden'; function privateMethod() { return privateVar; } return { publicMethod: function() { return privateMethod(); } };})();IIFE 通过闭包隔离内部变量,只暴露全局接口。缺点是依赖关系靠全局变量传递,script 标签顺序一旦出错就全局崩溃。CommonJS:Node.js 的选择// math.jsmodule.exports = { add: (a, b) => a + b };// main.jsconst { add } = require('./math');console.log(add(1, 2));CommonJS 用 require 同步加载模块,module.exports 导出。核心特征:运行时加载,require 执行时才确定依赖;输出值的拷贝,模块内部变化不会影响已导入的值;this 指向当前模块。同步加载在服务端不是问题——文件在本地磁盘,读取极快。但在浏览器中,模块要从网络下载,同步阻塞会让页面卡死。AMD:为浏览器而生define(['jquery', './utils'], function($, utils) { return { init: function() { $('body').append(utils.format()); } };});AMD(Asynchronous Module Definition)用 define 声明模块和依赖,依赖在回调执行前全部加载完成。RequireJS 是最知名的实现。依赖必须前置声明,不管是否马上用到都会先加载。CMD:依赖就近define(function(require, exports, module) { var $ = require('jquery'); // 用到时才加载 exports.init = function() { $('body').append('hello'); };});CMD 由 SeaJS 推广,和 AMD 的核心区别是依赖就近声明——只有执行到 require 时才加载对应模块。两者在浏览器端都已退出主流,被 ESModule 取代。UMD:兼容方案(function(root, factory) { if (typeof exports === 'object') module.exports = factory(); else if (typeof define === 'function') define(factory); else root.MyModule = factory();})(this, function() { return { version: '1.0' };});UMD 判断运行环境,兼容 CommonJS、AMD 和全局变量三种方式。库开发者打包时常用,确保代码在任何环境都能正常加载。ESModule:统一标准// math.jsexport const add = (a, b) => a + b;// main.jsimport { add } from './math.js';ESModule 是 JavaScript 语言层面的模块标准。与 CommonJS 的关键区别:编译时静态分析,import/export 必须在顶层,引擎在执行前就确定依赖关系;输出值的引用,模块内部变化会同步反映到导入方;顶层 this 为 undefined;天然支持 Tree-Shaking。模块异步加载异步加载的核心场景是按需加载——首屏不需要的代码延迟到使用时再请求,减少初始包体积。ESModule 动态导入:import() 返回 Promise,可在任意位置调用,是实现代码分割和路由懒加载的标准方式:button.addEventListener('click', async () => { const { openDialog } = await import('./dialog.js'); openDialog();});AMD 异步加载:require([deps], callback) 本身就是异步的,依赖列表中的模块并行下载后再执行回调。CommonJS:require 本身是同步的,不支持浏览器原生的异步加载。但打包工具(Webpack 等)可以将 import() 语法编译为 CommonJS 环境下的异步加载 chunk。追问import() 和顶层 import 有什么区别?顶层 import 是静态声明,必须在模块顶层,编译时确定依赖关系,引擎可以做静态分析和 Tree-Shaking。import() 是动态函数调用,返回 Promise,可以在任何位置调用,运行时才加载模块。后者用于代码分割、路由懒加载等按需加载场景。静态 import 在严格模式下还会被提升到模块顶部执行。为什么浏览器不支持 CommonJS 的 require?require 是同步调用——读取文件、编译、执行,然后返回结果。在服务端文件在本地磁盘上,同步读取耗时可以忽略;但在浏览器里模块要从网络下载,网络延迟不可控,同步阻塞意味着页面卡死直到所有依赖下载完成。AMD 和 ESModule 都采用异步加载模型,不阻塞主线程。库作者应该发布什么格式?ESM + CJS 双格式——package.json 的 exports 字段同时声明两种格式的入口,CJS 兼容老工具和老版本 Node.js,ESM 支持 Tree-Shaking 和静态分析。典型配置:{ "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } }}单独只发 CJS 会丢失 Tree-Shaking 能力,单独只发 ESM 会排除不支持 ESM 的旧环境。双格式是当前最稳妥的方案。
前端阅读 395月28日 03:28

Webpack 有哪些优化手段?

Webpack 有哪些优化手段?从构建速度和产物体积两个方向回答,面试中最常考察的优化点如下:构建速度优化1. 持久化缓存// webpack.config.jsmodule.exports = { cache: { type: 'filesystem', // Webpack 5 支持 buildDependencies: { config: [__filename] } }}首次构建后缓存写入 node_modules/.cache/webpack,二次构建可减少 60%-80% 时间。修改配置文件会自动失效。2. 缩小 Loader 处理范围module.exports = { module: { rules: [{ test: /\.js$/, include: path.resolve(__dirname, 'src'), // 只处理 src 目录 exclude: /node_modules/, use: 'babel-loader' }] }}不配置 include 时,Babel 会遍历 node_modules 中所有 JS 文件,构建时间翻倍。3. 多线程编译thread-loader:将耗时的 loader 放到 worker 池中并行执行terser-webpack-plugin 开启 parallel: true:并行压缩 JSWebpack 5 原生支持 parallel 配置4. 优化模块解析module.exports = { resolve: { extensions: ['.ts', '.tsx', '.js'], // 按使用频率排列,减少尝试次数 alias: { '@': path.resolve(__dirname, 'src') }, // 别名减少路径查找 }, module: { noParse: /lodash|jquery/, // 跳过已打包库的解析 }}noParse 告诉 Webpack 这些库没有 import/require,不需要解析依赖关系。5. 使用更快的编译器swc-loader 替代 babel-loader(Rust 实现,速度快 20 倍以上)esbuild-loader 替代 terser-webpack-plugin(Go 实现)6. DLL 预编译(Webpack 4 适用)// webpack.dll.config.jsnew webpack.DllPlugin({ name: '[name]', path: path.join(__dirname, 'dll', '[name]-manifest.json')})将 React、Vue 等不变库提前编译成 DLL,开发构建跳过。Webpack 5 的持久化缓存已基本替代此方案。产物体积优化1. 代码分割(Code Splitting)splitChunks — 提取公共依赖:module.exports = { optimization: { splitChunks: { chunks: 'all', cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all' } } } }}将 node_modules 中的库打包到独立 chunk,利用浏览器缓存。动态 import() — 按需加载:const Home = React.lazy(() => import('./Home'));const About = React.lazy(() => import('./About'));路由级懒加载,首屏只加载当前路由代码。 splitChunks 是配置层面的自动提取,动态 import() 是代码层面的手动分割,两者配合使用。2. Tree Shakingmodule.exports = { mode: 'production', // 自动开启 optimization: { usedExports: true, minimize: true, }}前提条件:必须使用 ESM(import/export),不能用 CommonJS(require)babel-loader 配置 "modules": false 防止转译为 CJSpackage.json 标记 "sideEffects": false 告知安全移除未使用导出为什么需要 ESM? ESM 的 import/export 是静态声明,编译时可确定依赖关系;CommonJS 的 require 是运行时调用,可以在 if 中使用,打包工具无法静态分析。3. Scope Hoisting(作用域提升)module.exports = { optimization: { concatenateModules: true, // 生产模式默认开启 }}将模块合并到同一个闭包中,减少函数声明和闭包数量,体积可减小 5%-10%。4. 压缩JS:terser-webpack-plugin(Webpack 5 生产模式默认启用)CSS:css-minimizer-webpack-pluginconst CssMinimizerPlugin = require('css-minimizer-webpack-plugin');module.exports = { optimization: { minimizer: [ new CssMinimizerPlugin(), ], }}5. externals 排除大型库module.exports = { externals: { react: 'React', 'react-dom': 'ReactDOM' }}将 React、Lodash 等通过 CDN 引入,不打包进 bundle,显著减小产物体积。6. 图片与资源优化小于 8KB 的图片转 base64 内联(Webpack 5 的 asset/inline)大图使用 image-webpack-loader 压缩或转 WebP{ test: /\.(png|jpg)$/, type: 'asset', parser: { dataUrlCondition: { maxSize: 8 * 1024 } // 8KB 以下内联 }}7. IgnorePlugin 排除无用模块new webpack.IgnorePlugin({ resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/})忽略 moment.js 的 locale 文件,体积减少约 200KB。构建分析工具| 工具 | 用途 ||------|------|| webpack-bundle-analyzer | 可视化分析产物体积,找出大依赖 || speed-measure-webpack-plugin | 统计各 loader/plugin 耗时 || webpack --profile --json > stats.json | 导出构建统计数据 |Webpack 5 新特性补充Module Federation:跨应用共享模块,实现微前端,避免重复打包公共依赖持久化缓存:替代 DLL 方案,配置更简单Asset Modules:内置资源模块,替代 url-loader/file-loader更好的 Tree Shaking:支持嵌套的 Tree Shaking 和内部模块的 Tree Shaking追问splitChunks 和动态 import 有什么区别?splitChunks 是 Webpack 配置层面的自动优化,根据策略提取公共依赖(如 node_modules 中的库),开发者无需手动干预。动态 import() 是代码层面的手动分割,由开发者决定哪些模块按需加载。两者互补:splitChunks 处理共享依赖,动态 import 处理路由级懒加载。Tree-Shaking 为什么需要 ESM?ESModule 的 import/export 是静态声明,在编译阶段就能确定模块的依赖关系和哪些导出被使用。CommonJS 的 require 是运行时调用,可以写在 if 语句、循环或函数中,打包工具无法在编译时判断哪些代码会被执行,因此无法安全移除未使用的代码。此外,需确保 Babel 不将 ESM 转译为 CJS(配置 "modules": false),并在 package.json 中声明 "sideEffects": false。webpack 的 cache 缓存了什么?为什么能加快构建?缓存了每个模块的 loader 处理结果和编译产物。首次构建后写入磁盘(node_modules/.cache/webpack),二次构建时 Webpack 检查文件时间戳和内容 hash,只重新编译变化的模块及其依赖链上的模块,未变化的模块直接使用缓存。典型场景:修改一个组件文件,只重新编译该文件和引用它的文件,其余数千模块跳过,构建时间从 30s 降到 5s 以内。Webpack 5 的 filesystem 缓存相比 Webpack 4 的 memory 缓存,重启开发服务器后缓存不丢失。Module Federation 解决了什么问题?多个独立构建的应用之间共享模块,避免重复打包相同依赖。典型场景:微前端架构中,主应用和子应用都依赖 React,传统方案各自打包 React,用 Module Federation 可以在运行时共享一份 React,减少总体加载量。核心配置是 exposes(暴露模块)和 remotes(消费远程模块),实现应用间的模块级复用。
前端阅读 355月28日 03:23

JavaScript 如何实现自定义事件?

基本用法:CustomEvent 三步走自定义事件的核心流程就三步:创建 → 监听 → 触发。// 1. 创建事件const event = new CustomEvent('user-login', { detail: { userId: 42, role: 'admin' }, // 携带数据 bubbles: true, // 是否冒泡 cancelable: true // 能否被 preventDefault() 取消});// 2. 监听element.addEventListener('user-login', (e) => { console.log(e.detail); // { userId: 42, role: 'admin' }});// 3. 触发element.dispatchEvent(event);detail 是 CustomEvent 独有的字段,用于携带任意数据,和 Event 构造函数的唯一区别就在这里。Event 只能表示"某件事发生了",CustomEvent 还能告诉你"发生了什么"。事件的冒泡与委托bubbles: true 让事件沿 DOM 树向上冒泡,这意味着你可以在父元素上统一监听子元素发出的自定义事件:// 子元素派发child.dispatchEvent(new CustomEvent('item-delete', { detail: { id: 1 }, bubbles: true}));// 父元素委托监听parent.addEventListener('item-delete', (e) => { console.log('删除了:', e.detail.id); e.stopPropagation(); // 阻止继续冒泡});这是事件委托在自定义事件上的直接应用——不需要给每个子元素绑定监听器,一个父元素就够了。手写 EventEmitter:脱离 DOM 的事件系统自定义事件依赖 DOM,但面试常考的是纯 JS 的发布订阅实现。核心就是一个事件名到回调数组的映射:class EventEmitter { constructor() { this.events = new Map(); } on(event, handler) { if (!this.events.has(event)) { this.events.set(event, []); } this.events.get(event).push(handler); return this; // 支持链式调用 } off(event, handler) { const handlers = this.events.get(event); if (handlers) { this.events.set(event, handlers.filter(h => h !== handler)); } return this; } emit(event, ...args) { const handlers = this.events.get(event); if (handlers) { handlers.forEach(h => h(...args)); } return this; } once(event, handler) { const wrapper = (...args) => { handler(...args); this.off(event, wrapper); }; this.on(event, wrapper); return this; }}once 的实现要点:用包装函数代替原函数注册,触发后自动解绑。面试写到这里基本够用。自定义事件 vs 发布订阅:何时选谁| 维度 | CustomEvent | EventEmitter ||------|-------------|--------------|| 依赖 | DOM 元素 | 纯 JS,无依赖 || 事件传播 | 冒泡/捕获,支持委托 | 无传播机制 || 数据传递 | detail 字段 | 直接参数 || 内存管理 | 移除元素自动清理 | 需手动 off || Shadow DOM | composed: true 可穿透 | 不涉及 |简单判断:需要 DOM 层级传播用 CustomEvent,纯数据流用 EventEmitter。无框架组件通信没有 Vue/React 时,自定义事件就是组件通信的基础设施:// 子组件:触发事件class MyInput extends HTMLElement { connectedCallback() { this.addEventListener('input', () => { this.dispatchEvent(new CustomEvent('value-change', { detail: { value: this.value }, bubbles: true })); }); }}// 父组件:监听子组件事件parent.querySelector('my-input') .addEventListener('value-change', (e) => { console.log(e.detail.value); });这就是 Web Components 通信的基本模式,也是浏览器原生组件化方案的核心机制。Shadow DOM 边界与 composedShadow DOM 默认隔离事件。composed: true 让 CustomEvent 穿透 Shadow DOM 边界,否则事件被封闭在 Shadow DOM 内部:// Shadow DOM 内部派发shadow.dispatchEvent(new CustomEvent('inner-action', { detail: { data: 1 }, bubbles: true, composed: true // 关键:穿透 Shadow DOM}));composedPath() 方法可以查看事件经过的完整路径,调试 Shadow DOM 事件传播时很有用。内存泄漏防护自定义事件常见的内存陷阱:移除元素前没解绑监听器,或者闭包引用了大对象。// 错误示范:匿名函数无法解绑el.addEventListener('my-event', (e) => { /* ... */ });// 无法 off,因为匿名函数没有引用// 正确做法:保存引用const handler = (e) => { /* ... */ };el.addEventListener('my-event', handler);// 用完解绑el.removeEventListener('my-event', handler);更安全的方案是用 AbortController:const controller = new AbortController();el.addEventListener('my-event', handler, { signal: controller.signal});// 批量清理controller.abort();一个 abort() 就能清理同一 controller 下的所有监听器,比逐个 removeEventListener 方便得多。追问自定义事件和发布订阅有什么区别?自定义事件绑定在 DOM 元素上,有冒泡/捕获机制、事件委托、stopPropagation 等 DOM 事件系统的完整能力。发布订阅(EventEmitter)是纯 JS 模式,没有 DOM 树概念。需要跨层级通信、事件委托时用自定义事件;需要纯数据流、完全与 DOM 无关时用发布订阅。不用框架(Vue/React)怎么用自定义事件做组件通信?父组件给子组件传一个 DOM 元素引用,子组件在这个元素上 dispatchEvent(new CustomEvent('change', { detail: value })),父组件监听这个元素的 change 事件。这是 Web Components 的通信基础。也可以用自定义元素的属性变化回调 observedAttributes + attributeChangedCallback 配合事件派发做双向通信。自定义事件可以跨 Shadow DOM 吗?可以。composed: true 的 CustomEvent 会穿透 Shadow DOM 边界,不设置则封闭在 Shadow DOM 内部。用 event.composedPath() 可以查看事件传播的完整路径,这对调试 Shadow DOM 内的事件流向很有帮助。
前端阅读 525月28日 03:23

什么是柯里化函数?JavaScript 中有哪些使用场景?

什么是柯里化?柯里化(Currying)是将一个接受多个参数的函数,转换成一系列每次只接受一个参数的函数。转换后的函数会逐步收集参数,直到参数齐全才执行原函数。// 普通函数function add(a, b, c) { return a + b + c;}add(1, 2, 3); // 6// 柯里化后function curryAdd(a) { return function(b) { return function(c) { return a + b + c; }; };}curryAdd(1)(2)(3); // 6核心价值是参数复用和延迟执行——你可以先固定部分参数,得到一个更具体的函数,等剩余参数就位再调用。手写通用 curry 函数面试中最常考的是手写一个通用的 curry,它能将任意多参数函数转为柯里化形式:function curry(fn) { return function curried(...args) { // 收集的参数数量 >= 原函数参数数量,直接执行 if (args.length >= fn.length) { return fn.apply(this, args); } // 否则返回新函数继续收集 return (...nextArgs) => curried.apply(this, args.concat(nextArgs)); };}验证:function sum(a, b, c) { return a + b + c;}const curriedSum = curry(sum);curriedSum(1)(2)(3); // 6curriedSum(1, 2)(3); // 6 —— 支持一次传多个参数curriedSum(1)(2, 3); // 6关键点:利用 fn.length 判断原函数期望的参数个数,利用闭包在调用链中持续传递已收集的参数。柯里化的实际使用场景参数复用:创建预设函数function log(level, timestamp, message) { console.log(`[${level}] ${timestamp}: ${message}`);}const curriedLog = curry(log);const errorLog = curriedLog('ERROR'); // 预设 levelconst errorLogNow = errorLog(Date.now()); // 预设 timestamperrorLogNow('接口超时'); // [ERROR] 1748400000000: 接口超时errorLogNow('数据解析失败'); // 复用前两个参数bind 方法的本质Function.prototype.bind 就是偏函数应用——预设 this 和部分参数,返回新函数:const obj = { x: 42 };function getX(extra) { return this.x + extra;}const boundGetX = getX.bind(obj, 8); // 预设 this + 第一个参数boundGetX(); // 50Redux 中间件的函数链Redux 中间件签名 store => next => action => {} 就是三级柯里化:const logger = store => next => action => { console.log('dispatching', action); const result = next(action); console.log('next state', store.getState()); return result;};第一层拿到 store,第二层拿到 next(下一个中间件),第三层处理 action。分层柯里化使得每层的职责清晰,也方便测试——你可以只传入部分参数来测试中间件的某一步。React 事件处理中的参数绑定// 方式一:bind 预设参数<button onClick={handleClick.bind(null, item.id)}>删除</button>// 方式二:高阶函数(本质也是柯里化)const handleClick = (id) => (e) => { e.stopPropagation(); deleteItem(id);};<button onClick={handleClick(item.id)}>删除</button>柯里化与偏函数的区别两者容易混淆,核心区别在于参数接收方式:柯里化:将函数转为一系列一元函数,每次只接受一个参数(f(a)(b)(c))偏函数:预先填充部分参数,返回的函数仍可接受多个参数(f(a, b) → f'(c, d))// 柯里化:每次一个参数const curriedAdd = curry((a, b, c) => a + b + c);curriedAdd(1)(2)(3); // 6// 偏函数:一次传剩余参数function partial(fn, ...presetArgs) { return (...laterArgs) => fn(...presetArgs, ...laterArgs);}const addFive = partial((a, b) => a + b, 5);addFive(3); // 8bind 是偏函数,不是严格柯里化——因为 bind 返回的函数可以一次接收多个参数。柯里化的局限与注意事项性能开销:每次柯里化调用都会创建新的闭包和函数对象,在性能敏感场景(如高频事件处理、大循环内)应避免过度使用。只处理显式参数:fn.length 无法识别默认参数、rest 参数。带默认值的函数柯里化后行为可能不符合预期:function greet(name, greeting = 'Hello') { return `${greeting}, ${name}`;}greet.length; // 1,只有 name 是显式参数curry(greet)('Alice'); // 直接返回 "Hello, Alice",跳过了 greeting 的柯里化步骤调试困难:柯里化链越长,调用栈越深,错误堆栈的可读性越差。生产代码中应控制嵌套层级。追问柯里化和偏函数有什么区别?柯里化将函数拆成一元函数链(f(a)(b)(c)),偏函数预设部分参数但返回函数仍可多参(f(a, b) → f'(c, d))。bind 是偏函数不是严格柯里化。能手写一个通用的 curry 函数吗?利用闭包持续收集参数,用 fn.length 判断参数是否齐全,齐全则执行,否则返回新函数继续收集。见上方"手写通用 curry 函数"部分。柯里化在 React 中有什么应用?事件处理器的参数预绑定:onClick={handleClick(id)} 用高阶函数延迟执行;Hooks 中的自定义 Hook 参数分步传入也是类似思路。
前端阅读 335月28日 03:22

JavaScript 继承方式有哪几种?各自的优缺点是什么?

JavaScript 的继承方式有以下七种,按演进顺序理解更容易记住:原型链继承、构造函数继承、组合继承、原型式继承、寄生式继承、寄生组合继承、ES6 class extends。面试中重点掌握组合继承的问题和寄生组合继承的优化思路。原型链继承将子类的原型指向父类的实例:function Parent() { this.name = 'parent'; this.colors = ['red', 'blue'];}Parent.prototype.getName = function() { return this.name;};function Child() {}Child.prototype = new Parent();const child1 = new Child();const child2 = new Child();child1.colors.push('green');console.log(child2.colors); // ['red', 'blue', 'green'] — 共享了!核心问题:所有子实例共享父实例的引用类型属性,一个实例修改了 colors,其他实例也受影响。构造函数继承在子类构造函数中调用父类构造函数:function Parent() { this.colors = ['red', 'blue'];}function Child() { Parent.call(this); // 借用构造函数}const child1 = new Child();child1.colors.push('green');console.log(new Child().colors); // ['red', 'blue'] — 不共享了解决了引用属性共享的问题,但新的问题出现了:方法只能定义在构造函数里,每次创建实例都会重新创建方法,无法复用。而且根本访问不到父类原型上的方法。组合继承把上面两种方式组合起来——属性用构造函数继承,方法用原型链继承:function Parent(name) { this.name = name; this.colors = ['red', 'blue'];}Parent.prototype.getName = function() { return this.name;};function Child(name, age) { Parent.call(this, name); // 第二次调用 Parent this.age = age;}Child.prototype = new Parent(); // 第一次调用 ParentChild.prototype.constructor = Child;这是最常用的经典方案,但有一个效率问题:Parent 被调用了两次。第一次 new Parent() 在子类原型上创建了 name 和 colors,第二次 Parent.call(this) 又在实例上创建了同名的属性,原型上的那份其实是多余的。原型式继承不定义构造函数,直接基于已有对象创建新对象:function createObj(o) { function F() {} F.prototype = o; return new F();}// ES5 标准化了这个模式Object.create(proto, propertiesObject);和原型链继承有同样的共享问题——引用类型的属性会被所有派生对象共享。适用于不需要单独创建构造函数、只想让一个对象类似于另一个对象的场景。寄生式继承在原型式继承的基础上,增强对象后返回:function createChild(original) { const clone = Object.create(original); clone.sayHi = function() { console.log('hi'); }; return clone;}通过封装函数给对象添加能力,但和构造函数继承一样——每个实例都会重新创建方法,无法复用。寄生组合继承组合继承的优化版,也是目前公认的最优继承方案:function Parent(name) { this.name = name; this.colors = ['red', 'blue'];}Parent.prototype.getName = function() { return this.name;};function Child(name, age) { Parent.call(this, name); // 只调用一次 Parent this.age = age;}// 关键:用 Object.create 代替 new Parent()Child.prototype = Object.create(Parent.prototype);Child.prototype.constructor = Child;和组合继承相比,只改了一行:把 Child.prototype = new Parent() 换成了 Child.prototype = Object.create(Parent.prototype)。这一改做到了两件事:只调用一次父类构造函数,子类原型上不会出现多余的实例属性。ES6 的 class extends 经 Babel 编译后,底层就是寄生组合继承。ES6 class extendsclass Parent { constructor(name) { this.name = name; this.colors = ['red', 'blue']; } getName() { return this.name; }}class Child extends Parent { constructor(name, age) { super(name); // 必须在 this 之前调用 this.age = age; }}语法糖,底层仍然是原型链 + 寄生组合继承,但做了额外约束:不用 new 调用会报错(内置 new.target 检查)类方法不可枚举extends 同时继承静态属性和原型方法super 作为关键字比 Parent.call(this) 更安全,确保父类构造函数在访问 this 之前执行七种方式对比| 方式 | 引用属性共享 | 方法复用 | 父类调用次数 | 适用场景 ||------|:---:|:---:|:---:|------|| 原型链继承 | 共享 | 可以 | 1 | 不含引用属性的简单继承 || 构造函数继承 | 不共享 | 不可以 | 1 | 只需继承属性不需方法 || 组合继承 | 不共享 | 可以 | 2 | 传统项目,兼容性要求高 || 原型式继承 | 共享 | 可以 | 0 | 基于对象快速派生 || 寄生式继承 | 共享 | 不可以 | 0 | 给对象添加增强能力 || 寄生组合继承 | 不共享 | 可以 | 1 | ES5 环境下最优选择 || ES6 class extends | 不共享 | 可以 | 1 | 现代开发首选 |追问寄生组合继承为什么是最优方案?只调用一次父类构造函数,避免了组合继承中子类原型上出现冗余实例属性的问题。原型链保持干净——子类原型是通过 Object.create(Parent.prototype) 创建的空对象,只包含指向父类原型的方法,不包含父类实例属性。这是 ES5 环境下兼顾效率和正确性的最佳方案。ES6 class 和寄生组合继承有什么本质区别?class 不只是语法糖,它在语义层面做了额外约束:1) 不用 new 调用直接报错(new.target 检查);2) 类方法默认不可枚举;3) extends 同时继承静态属性,寄生组合继承做不到这点需要手动处理;4) super 是关键字而非函数调用,引擎保证父类构造函数在子类访问 this 之前执行,比 Parent.call(this) 更安全。Object.create 和 new 有什么区别?Object.create(proto) 创建一个新对象并将其 __proto__ 指向 proto,不执行任何构造函数逻辑。new Constructor() 会执行构造函数,将函数体内的 this 绑定到新对象,执行初始化逻辑后返回该对象。寄生组合继承用 Object.create 替代 new Parent(),目的就是避免多执行一次父类构造函数中的初始化逻辑。为什么组合继承中父类被调用了两次?第一次是 Child.prototype = new Parent(),这一步在子类原型上创建了 name、colors 等实例属性。第二次是 Parent.call(this, name),在子类实例自身上又创建了同名属性。实例访问属性时先找自身再找原型,所以原型上那些属性虽然存在但永远不会被访问到,属于冗余创建。寄生组合继承用 Object.create 绕过了第一次调用,只保留实例属性的正确初始化。
前端阅读 425月28日 03:21

JavaScript 如何使用 setTimeout 模拟实现 setInterval?

直接用 setInterval 有一个经典问题:如果回调执行时间超过了间隔时间,回调会在事件队列中堆积。用 setTimeout 递归模拟能解决这个痛点——每次回调执行完再设置下一次定时器,确保上一次执行完毕才开始下一轮计时。function mySetInterval(fn, delay) { let timer = null; function loop() { fn(); timer = setTimeout(loop, delay); } timer = setTimeout(loop, delay); return () => clearTimeout(timer);}const cancel = mySetInterval(() => console.log('tick'), 1000);// cancel(); 需要停止时调用核心思路:在回调函数末尾递归调用 setTimeout,使得下一次定时器只有在上一次回调执行完毕后才会被注册。返回一个取消函数,调用时 clearTimeout 清除当前等待中的定时器,递归链就此中断。setInterval 的回调堆积问题setInterval(fn, 100) 的行为是:每隔 100ms 向任务队列放入一个 fn,不管前一个 fn 是否执行完毕。如果 fn 执行需要 150ms:100ms:第一个回调进入队列,开始执行200ms:第二个回调进入队列(第一个还没执行完)300ms:第三个回调进入队列结果是回调不断排队,等当前任务执行完后,队列中的回调会连续执行,间隔远小于预期。这不是"跳过间隔",而是"堆积后连续执行"。setTimeout 模拟的优势与局限优势:不会回调堆积,每次执行完才开始计时下一轮间隔更可控,实际间隔 ≈ delay + 回调执行时间局限:仍然不精确——setTimeout 只保证"至少延迟 delay 毫秒",受事件循环和主线程阻塞影响如果回调本身执行时间很长,实际间隔会远大于设定值带错误处理的增强实现基础版本有个隐患:回调抛异常时,递归链中断,定时器静默停止。加上 try-catch 可以保证异常不会打断后续执行:function mySetIntervalSafe(fn, delay) { let timer = null; function loop() { try { fn(); } catch (e) { console.error('定时器回调异常:', e); } timer = setTimeout(loop, delay); } timer = setTimeout(loop, delay); return () => clearTimeout(timer);}支持 this 绑定和参数传递原生 setInterval 支持 setInterval(fn, delay, arg1, arg2) 的参数传递和 this 绑定。补全这两个能力:function mySetIntervalFull(fn, delay, ...args) { let timer = null; const context = this; function loop() { try { fn.apply(context, args); } catch (e) { console.error('定时器回调异常:', e); } timer = setTimeout(loop, delay); } timer = setTimeout(loop, delay); return () => clearTimeout(timer);}// 用法:传递参数mySetIntervalFull((name, count) => { console.log(`${name}: ${count}`);}, 1000, 'tick', 42);追问如果需要真正精确的定时循环怎么办?setTimeout 模拟解决的是回调堆积问题,不能解决定时精度问题。精确计时需要其他方案:requestAnimationFrame + performance.now():适合动画循环,每帧检查时间戳决定是否执行Web Worker 定时器:在独立线程运行,不受主线程阻塞影响时间戳补偿:记录预期执行时间和实际已执行次数,根据偏差动态调整下一轮 delay能不能用 setInterval 模拟 setTimeout?可以,执行一次后立即 clearInterval:function mySetTimeout(fn, delay) { const id = setInterval(() => { fn(); clearInterval(id); }, delay);}但实际中没人这么做——setTimeout 本身就是更合适的 API,这个追问考查的是对两个定时器语义的理解。递归 setTimeout 会不会导致栈溢出?不会。setTimeout 的回调是宏任务,每次执行时调用栈已经清空,递归是通过事件循环调度的,不是真正的函数递归调用,调用栈深度始终为 1。
前端阅读 365月28日 03:20

如何实现 JavaScript 的 bind 方法?

bind 是 JavaScript 中显式绑定 this 的三种方式之一,与 call、apply 不同,bind 不会立即执行函数,而是返回一个绑定了 this 和预设参数的新函数。面试中考察 bind 的实现,重点在于:this 绑定、参数柯里化、new 调用兼容、原型链维护。最简实现:绑定 this + 预设参数bind 的核心行为只有两件事:绑定 this 上下文,预设部分参数。Function.prototype.myBind = function(context, ...args) { const fn = this; return function(...newArgs) { return fn.apply(context, [...args, ...newArgs]); };};这段代码保存了原函数引用 fn,返回的新函数在调用时将预设参数 args 和新传入参数 newArgs 合并,通过 apply 将 this 绑定到 context 执行。验证:const obj = { name: 'Alice' };function greet(greeting, punctuation) { return `${greeting}, ${this.name}${punctuation}`;}const bound = greet.myBind(obj, 'Hello');console.log(bound('!')); // "Hello, Alice!"兼容 new 调用上面的实现在 new bound() 时会出错——new 创建的实例本应作为 this,但被 bind 的 context 覆盖了。bind 的规范要求:new 的优先级高于 bind 绑定的 this。Function.prototype.myBind = function(context, ...args) { const fn = this; return function bound(...newArgs) { // new 调用时 this instanceof bound 为 true,应使用 new 创建的实例 return fn.apply( this instanceof bound ? this : context, [...args, ...newArgs] ); };};this instanceof bound 是判断是否通过 new 调用的关键:当 new bound() 执行时,this 指向新创建的实例对象,该实例的原型链上有 bound.prototype,因此 instanceof 返回 true。验证:function Person(name) { this.name = name;}const BoundPerson = Person.myBind({ name: 'ignored' });const p = new BoundPerson('Bob');console.log(p.name); // "Bob",而非 "ignored"维护原型链上面的实现仍有一个问题:通过 new bound() 创建的实例无法访问原函数原型上的属性和方法。Person.prototype.sayHi = function() { return `Hi, I'm ${this.name}`;};const p2 = new BoundPerson('Carol');console.log(p2.sayHi()); // TypeError: p2.sayHi is not a function原因是 bound 函数的 prototype 并没有指向 fn.prototype。需要用一个空对象作为桥接:Function.prototype.myBind = function(context, ...args) { if (typeof this !== 'function') { throw new TypeError('Bind must be called on a function'); } const fn = this; const fNOP = function() {}; const bound = function(...newArgs) { return fn.apply( this instanceof bound ? this : context, [...args, ...newArgs] ); }; // 维护原型链:让 bound 的实例能访问原函数的原型 fNOP.prototype = fn.prototype; bound.prototype = new fNOP(); return bound;};为什么用 fNOP 做中介而不是直接 bound.prototype = fn.prototype?因为直接赋值后,修改 bound.prototype 会同步影响 fn.prototype,这不符合预期。中介对象隔断了这种引用关系。验证:const p3 = new BoundPerson('Dave');console.log(p3.sayHi()); // "Hi, I'm Dave"console.log(p3 instanceof Person); // true边界情况与细节this 不是函数时抛出错误原生 bind 在非函数值上调用会抛出 TypeError,实现中应当对齐这一行为,已在上面的完整实现中包含。箭头函数的 bind 无效箭头函数没有自己的 this,其 this 由外层词法作用域决定。对箭头函数调用 bind 虽然不会报错,但绑定的 this 不会生效。const arrow = () => this;const boundArrow = arrow.myBind({ x: 1 });console.log(boundArrow()); // 仍是外层 this,而非 { x: 1 }bind 返回的函数没有 prototype 属性原生 bind 返回的绑定函数 prototype 为 undefined,不能作为构造函数使用(虽然通过 new 仍可调用,但原型链由内部 [[Constructor]] 处理)。这一点在深度追问时需要注意。多次 bind 不会叠加 thisbind 返回的函数内部 this 已固定,再次 bind 无法改变。但预设参数会叠加。function fn(a, b, c) { return [a, b, c]; }const bound1 = fn.bind({ x: 1 }, 1);const bound2 = bound1.bind({ x: 2 }, 2);console.log(bound2(3)); // [1, 2, 3],this 仍是 { x: 1 }追问bind 返回的函数再用 new 调用会发生什么?new 优先级高于 bind。new 创建的新对象会作为 this,bind 指定的 this 被忽略,但预设参数仍然生效。这是 ES5 规范明确规定的优先级:new > bind > apply/call > 默认绑定。call、apply、bind 三者区别?call(thisArg, arg1, arg2, ...):逐个传参,立即执行apply(thisArg, [arg1, arg2]):数组传参,立即执行bind(thisArg, arg1, arg2):预设 this 和参数,返回新函数,不立即执行三者的核心差异在于执行时机和传参方式。实际开发中 bind 常用于事件回调保留上下文,call/apply 常用于借用方法或类数组转数组。为什么要用 fNOP 做中介而不是直接赋值 prototype?直接 bound.prototype = fn.prototype 会导致两者引用同一个对象,后续对 bound.prototype 的修改(如添加属性)会污染原函数的原型。fNOP 中介创建了一个以 fn.prototype 为原型的新对象,既保证 instanceof 正确,又隔离了修改影响。
前端阅读 1035月28日 03:16

如何判断 JS 文件是 Node.js 环境还是浏览器环境?

看三个层面:模块语法、全局对象、环境 API,基本够用。模块语法是最直观的线索——用了 require/module.exports 的基本是 Node.js(CommonJS),但这不是充分条件,因为浏览器端打包工具也能处理 CJS。反过来,纯 import/export(ESM)两边都能跑,不能用来判断。全局对象更可靠:访问 process、__dirname、__filename、global 的是 Node.js;访问 window、document、navigator、localStorage 的是浏览器。但要注意,SSR 框架(Next.js)里两者可能同时存在。环境 API 是最终判据:调了 fs、child_process、net、crypto(非 Web Crypto 子集)等 Node 核心模块的只能在 Node 跑;用了 DOM API(document.querySelector、addEventListener)、WebSocket、WebRTC 的只能在浏览器跑。一个实用的判断函数:function detectEnv() { if (typeof process !== 'undefined' && process.versions?.node) return 'node'; if (typeof window !== 'undefined' && typeof document !== 'undefined') return 'browser'; return 'unknown';}这个函数够用但不完美——Web Worker 里有 self 没有 window,Electron 里两个都有。追问Webpack 的 target 配置和这个问题有什么关系?target: 'node' 时 Webpack 不会 polyfill fs/path 等 Node 模块,target: 'web' 时会。如果源码用了 Node API 但打包目标是浏览器,构建会报错或打出空模块。所以看 webpack.config.js 的 target 也能反推这个文件的预期运行环境。TypeScript 怎么区分这两种环境的类型?tsconfig.json 里 "lib": ["DOM"] 会注入浏览器类型(document、window),不加就没有。"types": ["node"] 会注入 Node 类型(process、__dirname)。编译时 TS 就能帮你揪出混用的情况——比如在 lib 不含 DOM 的配置下写了 document.getElementById,会直接报类型错误。实际项目中踩过什么坑?Next.js 里最常见——组件里直接用 window 做判断,SSR 阶段 window 不存在就炸了。正确做法是把浏览器 API 调用放进 useEffect 或 typeof window !== 'undefined' 守卫里。另一个坑:库的 package.json 没配 exports 字段,Node 和浏览器拿到同一个入口文件,结果浏览器端 import 了 Node 模块直接白屏。
前端阅读 295月28日 03:13

如何用 JavaScript 广度优先遍历 DOM 树?

用队列。从根节点入队,每次出队一个节点处理,再把它的子节点依次入队,循环到队列为空。function bfsTraverse(root) { if (!root) return; const queue = [root]; while (queue.length) { const node = queue.shift(); console.log(node.tagName); for (const child of node.children) { queue.push(child); } }}面试官问这道题,考的是你对树形结构和队列的理解——DOM 是棵树,BFS 用队列逐层扩展,DFS 用栈先钻到底。别搞混数据结构就行。追问BFS 和 DFS 在 DOM 上各自适合什么场景?BFS 适合找离根近的节点——比如页面第一个 <article> 标签。DFS 适合找深层嵌套的元素,比如 <head> 里的 <meta>。日常用的 querySelector 浏览器内部走的就是 DFS 前序遍历。shift() 的性能问题怎么解决?Array.prototype.shift() 是 O(n),每次都要移动剩余元素。节点多的时候拖慢整体。两个解法:索引队列(推荐面试写法):function bfsTraverse(root) { if (!root) return; const queue = [root]; let head = 0; while (head < queue.length) { const node = queue[head++]; for (const child of node.children) queue.push(child); }}链表队列:生产环境更稳,但面试写起来费时间,提到就行。实际面试直接写 shift() 版本没问题,能顺嘴提到这个优化点就够了。node.children 和 node.childNodes 有什么区别?children 只返回元素节点(Element),childNodes 返回所有节点包括文本节点、注释节点。BFS 遍历 DOM 树通常用 children,除非你明确要处理文本节点。时间和空间复杂度?时间 O(n),每个节点入队出队各一次。空间 O(w),w 是树的最大宽度——最宽那一层有多少个节点。完全二叉树最宽层约 n/2 个节点,所以最坏空间也是 O(n)。真实项目里什么时候会手写 DOM 遍历?很少。浏览器提供了 querySelectorAll、TreeWalker、NodeIterator 等原生 API,绝大多数场景不需要手写遍历。但面试考这个是在验证你对数据结构的基本功,就像问快排不是为了让你手写排序,而是看你懂不懂分治思想。
前端阅读 835月28日 03:13

什么是 JS Bridge?WebView 和原生通信有哪几种方式?

JS Bridge 是 WebView 里 JS 和原生 App 之间互相调用的通信桥梁,Hybrid App 开发中几乎离不开它。实现方式主要有三种:URL Scheme 拦截——JS 通过 iframe.src 发自定义 scheme URL(如 myapp://camera/open),原生在 shouldOverrideUrlLoading 中拦截解析并执行,只能 JS→原生单向通信,且连续调用会丢消息需要队列化;注入 API 对象——原生通过 addJavascriptInterface(Android)或 WKScriptMessageHandler(iOS)把对象注入 WebView 的 JS 上下文,JS 直接调用方法,支持双向通信和回调,是当前最主流的方式;prompt/console 拦截——JS 调 window.prompt(),原生重写 onJsPrompt() 拦截消息并解析执行,性能比 URL Scheme 好且能拿到返回值。实际项目普遍以注入 API 为主、prompt 拦截为辅的混合方案。追问JS Bridge 的回调机制怎么实现的?调用时生成唯一 callbackId,和参数一起发给原生。原生处理完通过 evaluateJavaScript() 调用 JS 侧全局回调函数,把 callbackId 和结果传回来。如果原生要主动推消息给 JS,也是通过 evaluateJavaScript() 调用 JS 挂载的全局监听函数。本质是异步 request-response + publish-subscribe 混合模式。URL Scheme 连续调用丢消息的原因和解决方案?iframe.src 连续赋值时前一条 URL 还没被 shouldOverrideUrlLoading 拦截就被覆盖了。解法:JS 侧维护消息队列,每次只发一条,原生处理完通过回调通知 JS 发下一条。也可以改用 prompt 拦截,它是同步的不会丢。addJavascriptInterface 有什么安全漏洞?Android 4.2 之前,注入对象的任何方法都能被 JS 通过反射拿到 Java 层的 Runtime,执行任意系统命令。4.2 后要求方法必须加 @JavascriptInterface 注解才暴露。iOS 的 WKWebView 用 messageHandler 机制只传消息不直接暴露方法,天生更安全。实际开发中还要对传入参数做白名单校验,防止 XSS 注入调用敏感接口。小程序和 WebView JS Bridge 有什么区别?小程序逻辑层(JS)和渲染层(WebView)跑在不同线程,所有通信都要经过 Native 中转序列化,setData 走的就是这条通道。普通 WebView JSBridge 是同进程内通信,性能好但渲染和逻辑互相阻塞。小程序的代价是频繁 setData 序列化开销大,所以官方建议合并数据、减少调用次数。怎么设计一个通用的 JS Bridge SDK?定义统一协议格式:{ module, method, params, callbackId },JS 侧封装 call(method, params, callback) 和 on(event, handler),原生侧按 module 注册 handler。兼容层先尝试注入 API,失败降级到 prompt 拦截,再失败降级 URL Scheme。加上消息队列防丢、超时重试、日志上报,就是一套生产级方案。