拼多多前端春招准备

围绕拼多多春招前端笔试里出现的 CSS 基线、浏览器时间切片、requestAnimationFrame 与 Vue 响应式手写题做系统整理。

#tech / dev / frontend #type / howto #status / growing #resource / interview #source / company / pdd

[!info] related notes

拼多多前端春招准备

围绕拼多多春招前端笔试里出现的 CSS 基线、浏览器时间切片、requestAnimationFrame 与 Vue 响应式手写题做系统整理。

这篇怎么用

  1. 先看每道题的“直接答法”,确保笔试或口述时能快速落点。
  2. 再看“背后的知识链”,把零散题目挂到已有知识地图上。
  3. 对需要手写的题,优先掌握最小可用实现,再去理解工程级边界。

这组题在考什么

表面上是 4 个题,底层其实在考 4 条主线:

  • 浏览器动画调度:为什么高帧动画更适合 requestAnimationFrame
  • CSS 行内排版:为什么字体、按钮、图标会出现基线错位
  • 主线程调度:怎么把长任务拆成时间切片,避免阻塞交互和渲染
  • Vue 原理:reactivewatchEffect 背后的依赖收集与触发更新

1. 选择题:为什么高帧动画优先用 requestAnimationFrame

直接答法

因为 requestAnimationFrame 会在下一次浏览器绘制前执行回调,和屏幕刷新节奏对齐,更适合做平滑动画;相比 setTimeout,它更不容易掉帧和时间漂移。

更完整的理解

这题不是在问“哪个 API 更快”,而是在问你知不知道动画属于浏览器渲染链路的一部分。

回答时最好带上这几个点:

  1. requestAnimationFrame 回调时机在下一次 paint 之前
  2. 浏览器可以根据刷新节奏统一调度动画
  3. 后台标签页会自动降频或暂停,避免无意义消耗
  4. 动画代码和渲染时机更贴近,视觉更稳定

setTimeout 的区别

对比项requestAnimationFramesetTimeout
目标场景动画、视觉更新通用延迟执行
时机下一次绘制前定时器到时后排队
是否容易漂移相对更稳容易受主线程繁忙影响
后台标签页通常自动降频也会被限制,但不是为动画设计

容易补充的一句

如果是高帧动画,我会优先选 requestAnimationFrame;如果是一般任务分片或延迟执行,则要看是不是更适合 MessageChannelsetTimeout 或 Worker。

2. 问答题:特殊字体导致基线不同,怎么解决

直接答法

这类问题本质上是行内排版的基线规则和字体度量差异导致的。排查时我会先统一 font-sizeline-height,再检查按钮/图标/文字的显示类型和默认样式;如果是按钮或图标混排,通常会改成 inline-flex、统一行高,必要时显式设置 vertical-align

背后的知识点

这题真正考的是:

  • 你知不知道“基线”不是盒子中心
  • 你知不知道字体、按钮、图片、inline-block 的对齐规则不同
  • 你会不会从行内格式化上下文去解释问题

相关知识主笔记:

实战排查顺序

  1. 看字体是否一致
  2. line-height 是否一致
  3. 看按钮/输入框有没有默认 paddingborder
  4. 看元素是不是 inline-block 或替换元素
  5. 试着改成 inline-flex,观察问题是否立刻消失

更稳的修法

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
}

.text {
  line-height: 1;
}

如果本质是多元素整体布局问题,优先用 Flex/Grid,不要继续拿 vertical-align 当万能方案。

3. 手写题:实现时间切片

题目描述:

当有大量同步函数时,每次最多执行 15ms,然后让浏览器有机会去更新动画或渲染;如果某个函数报错,返回错误 Promise。

这题在考什么

本质是浏览器主线程调度。

如果你把几十个同步函数一次性全跑完,主线程在这段时间里不能处理:

  • 用户点击
  • 动画刷新
  • 样式布局绘制

所以要把任务拆成多个切片,每片执行到预算上限就主动让出执行权。

答题时一定要提的关键词

  • 长任务
  • 主线程阻塞
  • 帧预算
  • performance.now()
  • Promise
  • fail-fast 错误处理

一个可用实现

function timeSlice(functionArray, option = {}) {
  const {
    budget = 15,
    args = [],
    yieldStrategy = 'messageChannel',
  } = option;

  if (!Array.isArray(functionArray)) {
    return Promise.reject(new TypeError('functionArray must be an array'));
  }

  let index = 0;
  const results = new Array(functionArray.length);
  const channel = typeof MessageChannel !== 'undefined'
    ? new MessageChannel()
    : null;

  return new Promise((resolve, reject) => {
    const scheduleNext = () => {
      if (yieldStrategy === 'raf' && typeof requestAnimationFrame === 'function') {
        requestAnimationFrame(runSlice);
        return;
      }

      if (yieldStrategy === 'timeout' || !channel) {
        setTimeout(runSlice, 0);
        return;
      }

      channel.port2.postMessage(undefined);
    };

    const runSlice = () => {
      const start = performance.now();

      try {
        while (
          index < functionArray.length &&
          performance.now() - start < budget
        ) {
          const task = functionArray[index];
          if (typeof task !== 'function') {
            throw new TypeError(`task at index ${index} is not a function`);
          }

          results[index] = task(...args);
          index += 1;
        }

        if (index >= functionArray.length) {
          channel && (channel.port1.onmessage = null);
          resolve(results);
          return;
        }

        scheduleNext();
      } catch (error) {
        channel && (channel.port1.onmessage = null);
        reject(error);
      }
    };

    if (channel) {
      channel.port1.onmessage = runSlice;
    }

    scheduleNext();
  });
}

为什么这版实现是对的

  • 顺序执行,符合“函数数组”的语义
  • 每轮通过 performance.now() 控制预算
  • 超预算就调度下一轮,不继续霸占主线程
  • 一旦抛错立即 reject
  • 最终返回所有函数的结果数组

这题背后的系统知识

建议连着看:

4. 手写题:用纯 JS 实现 reactivewatchEffect

直接思路

这题最小实现只需要三步:

  1. Proxy 拦截对象 get / set
  2. get 时收集依赖
  3. set 时触发依赖重新执行

watchEffect 的关键点是:

  • 执行回调前,把当前副作用函数挂到全局上下文里
  • 回调里读到哪些响应式属性,就自动把自己订阅到那些属性上

手写题最小骨架

const bucket = new WeakMap();
let activeEffect = null;

function track(target, key) {
  if (!activeEffect) return;
  let depsMap = bucket.get(target);
  if (!depsMap) {
    depsMap = new Map();
    bucket.set(target, depsMap);
  }
  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }
  dep.add(activeEffect);
}

function trigger(target, key) {
  const depsMap = bucket.get(target);
  const dep = depsMap?.get(key);
  dep?.forEach((effectFn) => effectFn());
}

function watchEffect(fn) {
  const effectFn = () => {
    activeEffect = effectFn;
    fn();
    activeEffect = null;
  };
  effectFn();
}

function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      track(target, key);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver);
      trigger(target, key);
      return result;
    },
  });
}

面试时最好主动补一句

上面这版只是最小原理版。真实 Vue 还会处理依赖清理、嵌套 effect、调度器、批量更新、数组和 Map/Set 等边界。

继续往下补的主线

5. 这组题应该怎么继续扩知识库

如果后面继续补这一组题,优先按下面的结构走:

  1. 动画与调度:requestAnimationFrame、长任务、时间切片、INP
  2. CSS 排版:基线、line-heightvertical-align、Flex 内部对齐
  3. Vue 原理:依赖收集、触发更新、ref/reactive/computed/watch 的边界
  4. 笔试表达:每个题都保留“30 秒答法 + 2 分钟展开 + 可手写版本”

6. 速背版

  • 高帧动画优先 requestAnimationFrame,因为它和浏览器绘制节奏对齐
  • 基线问题本质是行内排版和字体度量差异,不是简单的“没居中”
  • 时间切片是协作式调度:跑一会儿就主动让出主线程
  • reactive 负责拦截访问,watchEffect 负责自动依赖收集
创建于 2026/3/29 更新于 2026/5/27