Skip to content

华为OD前端面试准备

第一部分:JavaScript 核心

考点1:闭包(Closure)

  • 基础认知(是什么):函数嵌套函数,内部函数可以访问外部函数的变量。这些变量不会被垃圾回收,常驻内存。
  • 进阶原理(为什么存在):JS的作用域链机制。函数在定义时就会保存一个[[Scopes]]属性,指向它的父级作用域。即使父级函数执行完了,这个引用链还在,所以变量没被销毁。
  • 源码/实战回答(怎么答):闭包好用,但容易内存泄漏。在WeLink这种高频通信场景,如果不手动销毁定时器或断开DOM引用,累积的内存会导致页面越来越卡。

结合你的项目举例:

javascript
“在华为WeLink IM模块中,我使用闭包封装了消息发送的重试逻辑——内部维护重试次数和定时器,
对外只暴露发送接口,外部无法直接修改重试状态。
同时,在组件卸载时通过闭包捕获的清理函数清除定时器,避免内存泄漏。”

要点补充:

应用场景:函数柯里化、防抖节流、模块封装私有变量、事件监听中保存状态 内存管理:闭包持有外部变量引用,需注意及时释放(如组件卸载时清理)

代码理解(从基础写到优化)

javascript
// 1. 基础形态(人人都懂)
function outer() {
  let count = 0;
  return function inner() { // inner 就是闭包
    count++;
    return count;
  };
}
const add = outer();
console.log(add()); // 1 (count 没有被销毁)

// 2. 面试陷阱:循环中的闭包(var 导致的问题)
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100); // 输出 3,3,3 (因为 var i 是同一个引用)
}
// 解法:用 let(块级作用域)或者立即执行函数(IIFE)包裹

// 3. 你的 WeLink 项目实战(清理闭包引用)
function createWebSocketManager(url) {
  let ws = null;
  let heartTimer = null;

  function connect() {
    ws = new WebSocket(url);
    heartTimer = setInterval(() => { ws.send('ping'); }, 30000);
  }

  function destroy() {
    // 【必杀技】不仅要 clearInterval,还要把引用置 null
    if (heartTimer) clearInterval(heartTimer);
    heartTimer = null; 
    if (ws) ws.close();
    ws = null; // 断开闭包对大对象的引用,让 V8 垃圾回收器能回收内存
  }
  return { connect, destroy };
}
javascript
// 场景:WeLink IM 消息发送失败重试机制
function createMessageRetryer(messageId, sendCallback) {
  let retryCount = 0;
  const MAX_RETRY = 3;
  let timer = null;

  function retry() {
    // 闭包持有 retryCount, timer, messageId
    if (retryCount >= MAX_RETRY) {
      console.warn(`消息 ${messageId} 重试失败,放弃`);
      clearInterval(timer);
      timer = null; // 【关键】手动清除定时器引用,释放内存
      return;
    }
    retryCount++;
    sendCallback(messageId);
  }

  // 返回启动和清理函数,而不是直接暴露 timer
  return {
    start: () => { timer = setInterval(retry, 2000); },
    destroy: () => { 
      clearInterval(timer); 
      timer = null; 
      // 彻底断开闭包引用链,便于GC回收
    }
  };
}

// React 组件中使用(配合你的 useEffect)
useEffect(() => {
  const retryer = createMessageRetryer('msg_123', sendMsg);
  retryer.start();
  return () => retryer.destroy(); // 组件卸载时必清理!
}, []);

考点2:原型链(Prototype Chain)

  • 基础认知(是什么):JS对象可以通过__proto__(隐式原型)指向另一个对象,访问属性时如果自身没有,就顺着这条链往上找,直到null。这是JS实现继承的底层机制。
  • 进阶原理(为什么这么设计):为了节省内存。所有实例共享同一个prototype(显式原型)上的方法,而不是每个实例拷贝一份。
  • 源码/实战回答(怎么答):搞清楚 FunctionObject 的关系是面试常客。记住口诀:所有函数的 __proto__ 都指向 Function.prototype,所有对象的 __proto__ 最终都指向 Object.prototype

代码理解(画图辅助记忆)

javascript
function Person(name) { this.name = name; }
Person.prototype.sayHi = function() { console.log('Hi ' + this.name); };

const p = new Person('Tom');

// 关系链:
// p.__proto__ === Person.prototype  (true)
// Person.prototype.__proto__ === Object.prototype (true)
// Object.prototype.__proto__ === null (终点)

// 【面试加分点】手动实现一个 new 操作符,彻底理解原型
function myNew(constructor, ...args) {
  // 1. 创建一个空对象,并将 __proto__ 指向构造函数的 prototype
  const obj = Object.create(constructor.prototype); 
  // 2. 执行构造函数,绑定 this
  const result = constructor.apply(obj, args);
  // 3. 如果构造函数返回了对象,则返回那个对象,否则返回新建的对象
  return typeof result === 'object' ? result : obj;
}
const p2 = myNew(Person, 'Jerry');
p2.sayHi(); // 输出正常,证明原型链完整
创建无原型对象
  • 基础认知(是什么)

    • 原型链:JS 中每个对象都有一个内部属性 [[Prototype]](浏览器中通过 __proto__ 访问),指向其构造函数的 prototype 对象。当访问对象属性时,如果自身没有,就沿着 __proto__ 链向上查找,直到 null。这是 JS 实现继承和属性共享的底层机制。
    • 创建无原型对象Object.create(null)。它创建一个完全空的对象,没有 __proto__,没有 toStringhasOwnProperty 等任何内置方法。
  • 进阶原理(为什么要无原型)

    • 纯粹做 “字典/映射表” 时,{ key: value } 会继承 Object.prototype 上的属性(如 toString),如果用 obj[key] 访问,可能和内置属性冲突(比如 key 恰好是 toString)。Object.create(null) 彻底消除了这个风险,且因为无原型链查找,属性访问速度更快
  • 源码/实战结合(你的项目)

    “在 IAMP 点云平台,我用 Object.create(null)‘坐标值 → 点索引’ 的快速反查表。因为点云数据量大(百万级),如果用普通对象,hasOwnProperty 的干扰和原型链查找会拖慢性能。用 Object.create(null) 后,查找速度提升约 15%。”


考点3:Event Loop(事件循环)与微任务/宏任务

  • 基础认知(是什么):JS是单线程,为了防止代码阻塞,把任务分为同步(立即执行)和异步(挂起等待)。异步又分宏任务setTimeoutsetInterval、I/O)和微任务Promise.thenMutationObserver)。
  • 进阶原理(执行规则):每轮循环先执行一个宏任务,然后清空所有微任务,最后可能尝试渲染UI,接着取下一个宏任务。
  • 源码/实战回答(怎么答):微任务的设计初衷是为了插队,保证高优先级的更新(如DOM渲染)尽早执行。

代码理解(死记硬背执行顺序)

javascript
// 面试必考:输出顺序是什么?
console.log('1'); // 同步

setTimeout(() => console.log('2'), 0); // 宏任务1

Promise.resolve().then(() => {
  console.log('3'); // 微任务1
  // 微任务中产生微任务,本轮循环必须清空
  queueMicrotask(() => console.log('4')); 
});

setTimeout(() => {
  console.log('5'); // 宏任务2
  Promise.resolve().then(() => console.log('6')); // 宏任务2的微任务
}, 0);

console.log('7'); // 同步

// 输出:1 -> 7 -> 3 -> 4 -> 2 -> 5 -> 6
// 【理解逻辑】:
// 1. 先清空所有同步(1,7)
// 2. 清空本轮微任务队列(3,4),注意4虽然是在微任务里加的,但本轮必须清完
// 3. 开始下一轮宏任务(2),接着清空2产生的微任务(没有)
// 4. 再下一轮宏任务(5),清空微任务(6)

结合你的项目:MMMP平台收到WebSocket告警时,用queueMicrotask合并高频数据更新,避免一秒内渲染几百次UI导致卡顿。

new Promise 本身是同步的,但它内部调用的 .then() / .catch() 是异步的。


第一层:基础认知
  • 同步部分new Promise((resolve, reject) => { ... }) 里的执行器(Executor)函数(也就是你传给构造函数的那个箭头函数)是立即执行、同步执行的。这和你写 console.log(1) 没有任何区别,JS引擎碰到它不会挂起,直接就跑。
  • 异步部分.then().catch().finally() 里面的回调函数,是异步(微任务) 执行的。只有当前同步代码全部执行完毕,并且 resolve/reject 被调用后,这些回调才会被放入微任务队列。

第二层:源码/代码验证

经典考题:说出下面代码的输出顺序。

javascript
console.log('1. 同步代码开始');

const promise = new Promise((resolve, reject) => {
  console.log('2. Promise 执行器(同步执行)');
  // 这里虽然调用了 resolve,但仅仅是改变状态,不会立即执行 .then()
  resolve('成功');
  console.log('3. 执行器内的同步代码继续');
});

// 这一行必须等上面的同步代码(1,2,3)全部执行完才会注册微任务
promise.then((res) => {
  console.log('6. 微任务 .then 回调', res);
});

console.log('4. 同步代码结束');

// 输出顺序:
// 1. 同步代码开始
// 2. Promise 执行器(同步执行)
// 3. 执行器内的同步代码继续
// 4. 同步代码结束
// 6. 微任务 .then 回调 成功

// 【特别注意】:没有 5,因为同步代码执行完后,才会去清空微任务队列!

第三层:手写极简源码(让你看透本质)

如果面试官追问:“为什么 new Promise 是同步的?”,你可以直接说:“因为 Promise 构造函数在源码里,就是直接调用了你传进去的那个函数。”

手写模拟 myPromise 构造器(极简版,展示原理)

javascript
function myPromise(executor) {
  let state = 'pending';
  let value = null;
  let callbacks = [];

  // 定义 resolve 函数(为了验证同步性,我们暂不实现异步调度)
  const resolve = (newValue) => {
    if (state !== 'pending') return;
    state = 'fulfilled';
    value = newValue;
    // 注意:这里的回调执行在源码中是通过微任务触发的,此处仅为展示结构
  };

  // 【核心证据】:在这里,executor 被【立即执行】了!
  // 这就是为什么 console.log 会立刻打印
  executor(resolve, () => {}); 
  
  this.then = (onFulfilled) => {
    // 实际源码会推入微任务,这里略
    callbacks.push(onFulfilled);
    return this;
  };
}

// 测试
console.log('开始');
new myPromise((resolve) => {
  console.log('执行器内部:我被立刻打印了!');
  resolve(1);
});
console.log('结束');

// 输出:开始 -> 执行器内部:我被立刻打印了! -> 结束

第四层:面试官的“变态连环追问”与你的话术
追问 1:“既然 new Promise 是同步的,那它和 setTimeout 有什么区别?”
  • 你的回答:“执行时机完全不同new Promise 的执行器是当前宏任务(Script)的同步代码,只要 JS 引擎解析到这行就会立刻执行。而 setTimeout 即使是 setTimeout(fn, 0),它的回调也属于下一个宏任务,必须等当前宏任务的所有同步代码和所有微任务执行完才会执行。所以 new Promise 永远比 setTimeout 先执行。”
追问 2:“如果我在 new Promise 里写 setTimeout,那还算同步吗?”
  • 你的回答:“不算! new Promise 的执行器本身是同步执行的,但执行器里面的 setTimeout 属于异步宏任务。这会导致 resolve 被延迟执行,进而导致 .then() 的回调被推迟。这种写法常用于将异步操作(如定时轮询、WebSocket 连接)包装成 Promise。”

代码验证(秒懂)

javascript
new Promise((resolve) => {
  console.log('A: 同步执行'); // 立刻打印
  setTimeout(() => {
    console.log('C: 宏任务执行');
    resolve('数据'); 
  }, 0);
}).then((res) => {
  console.log('D: 微任务执行', res);
});
console.log('B: 同步结束');
// 输出顺序:A -> B -> C -> D
// 解释:执行器依然同步,但执行器内部把 resolve 藏到了 setTimeout 里,所以整体流程被拉长。
追问 3:“Promise.resolve().then(...) 里面的代码是同步还是异步?”
  • 你的回答:“全是异步微任务! Promise.resolve() 虽然生成了一个成功状态的 Promise,但它后面跟的 .then() 回调绝不会在当前同步代码中执行。这也是为什么 Promise.resolve().then(() => console.log(1)); console.log(2) 会输出 2 再输出 1。”

第五层:结合你的简历(业务场景怎么答)

面试官问:“你在实际项目中怎么利用这个特性的?”

你的回答(结合 WeLink / MMMP): “在 MMMP 监控平台中,我需要初始化 WebSocket 连接。我会把 WebSocket 的建立放在 new Promise 的执行器里,因为执行器是同步的,能保证页面刚加载就立刻开始建立连接(尽早开始握手),而不是等到后面的代码跑完。但处理收到的消息(onmessage)我会放在 .then()async/await 里,因为这些回调涉及到 UI 更新,必须等 DOM 渲染完毕后再通过微任务执行,避免阻塞首屏。

核心原则new Promise 用来启动任务(同步触发),.then() 用来消费结果(异步回调)。”


终极总结(面试前一分钟默念)
  • 一句话结论new Promise 本身是同步的(执行器立刻跑),.then异步微任务(等同步代码跑完再跑)。
  • 背口诀“Promise 本体跑得快,then 回调后面排;同步先跑微任务,宏任务最后来。”

考点4:异步编程(Promise/Async/Await)

  • 基础认知(是什么):Promise是“承诺”,有三种状态(pending/fulfilled/rejected),一旦改变不可逆。async/await是语法糖,让异步代码像同步一样写。
  • 进阶原理(微任务触发时机)await后面的代码相当于被Promise.resolve().then()包裹,属于微任务。
  • 源码/实战回答(怎么答):手写Promise.all要特别注意空数组非Promise值的处理。

手写 Promise.all(基础版到完整版)

javascript
function myPromiseAll(promises) {
  // 1. 基础:确保传入的是数组
  if (!Array.isArray(promises)) return Promise.reject(new TypeError('必须是数组'));

  return new Promise((resolve, reject) => {
    const result = [];
    let count = 0;
    const len = promises.length;

    // 2. 边界:空数组立即 resolve
    if (len === 0) return resolve(result);

    promises.forEach((p, index) => {
      // 3. 关键:用 Promise.resolve 包裹,兼容传入普通值(数字、字符串)
      Promise.resolve(p).then(
        (value) => {
          result[index] = value; // 保持顺序
          count++;
          if (count === len) resolve(result);
        },
        (reason) => {
          reject(reason); // 任何一个失败,整体失败
        }
      );
    });
  });
}

第二部分:Vue/React 框架原理(从设计哲学到源码实现)

考点1:Vue2 defineProperty vs Vue3 Proxy

  • 基础认知(是什么):两者都是实现“数据变化驱动视图更新”的工具。
  • 进阶原理(为什么升级)
    • Vue2(defineProperty:必须递归遍历data,性能开销大;无法监听数组下标赋值和对象新增/删除属性;this.$set 只是补丁。
    • Vue3(Proxy:代理整个对象,可以拦截getsetdeleteProperty等13种操作;懒代理(只有访问到嵌套对象时才递归代理,初始化更快)。
  • 源码/实战回答(怎么答):手写极简响应式,让面试官知道你懂track(依赖收集)和trigger(派发更新)。

考点2:虚拟DOM与Diff算法(含Key的作用)

  • 基础认知(是什么):虚拟DOM是用JS对象模拟真实DOM,通过Diff算法找最小差异,一次性更新真实DOM,减少昂贵回流重绘。
  • 进阶原理(Key为什么不能是Index)Key是节点的唯一标识。用Index时,如果数组头部插入数据,所有节点的Index都变了,Diff会误判所有节点都变了,导致全部销毁重建(性能崩了)。正确的Key(如ID)能让Diff精准识别“哪个节点被移动了,哪个是新来的”。
  • 源码/实战回答(怎么答):Vue2用的是双端比较(四个指针移动),Vue3引入了最长递增子序列(LIS),只移动必要节点,减少DOM移动开销。

代码理解(为什么 key 不能用 index)

javascript
// 场景:展示列表 ['A', 'B'],在头部插入 'C'
// 用 index 做 key 时(错误):
// 旧节点:key=0(A), key=1(B)
// 新节点:key=0(C), key=1(A), key=2(B)
// Diff 发现 key=0 的文本从 A 变成 C,key=1 从 B 变成 A,key=2 是新增 B
// 结果:A 和 B 两个 DOM 都被更新了,并新建了 B 的 DOM,原 B 复用错误!

// 用 ID 做 key 时(正确):
// 旧节点:key=A_id(A), key=B_id(B)
// 新节点:key=C_id(C), key=A_id(A), key=B_id(B)
// Diff 精准知道 A 和 B 只是移动了位置,C 是新增
// 结果:只新建一个 C 的 DOM,A 和 B 仅仅移动位置(或调整顺序),性能最高!

考点3:React vs Vue 状态管理(Redux vs Pinia)

  • 基础认知:Redux是单向数据流(Action -> Reducer -> Store),强调不可变数据;Pinia/Vuex是集中式状态管理,利用Vue响应式自动更新。
  • 进阶原理:Redux的中间件(如Redux-Thunk)处理异步,原理是拦截Action;Pinia取消了Mutations,只有Actions,更简洁,且完美支持TypeScript类型推断。
  • 实战回答:WeLink用Redux是因为IM消息状态极其复杂(已读/未读/发送中/失败),Redux的DevTools时间旅行能帮我们快速定位状态异常。IRMP用Pinia是因为简单,直接store.reportData = xx就能触发更新,开发效率高。

第三部分:项目深挖(STAR法则 + 量化数据)

项目1:IAMP 隧道智能检测(Three.js 点云)

  • 基础解释:点云就是激光雷达扫出来的大量三维坐标点(X,Y,Z),要在浏览器渲染出来。
  • 难点:数据量巨大(几十万到几百万个点),直接一次性渲染会卡死。
  • 你的回答(带上数据)
    • 视锥体裁剪:只渲染相机视野内的点。
    • 分块加载(Chunk):每5万个点生成一个Points对象,滚动时动态销毁/创建。
    • Worker解析:数据解析在Worker线程做,避免主线程阻塞。
    • 结果:支持百万级点云,帧率稳定60fps。

项目2:MMMP 监控平台(WebRTC 内网穿透)

  • 基础解释:工地摄像头是内网RTSP流,公网网页没法直接访问,要用WebRTC做中转。
  • 难点:内网没有公网IP,打洞失败率很高。
  • 你的回答:采用 STUN(打洞) + TURN(中转保底) 策略。优先尝试STUN建立P2P连接,失败则降级使用TURN服务器转发流量。用frp做内网穿透辅助信令交换。解决了工地远程看不了监控的痛点,异常响应从小时级变秒级。
最近更新