阅读视图

发现新文章,点击刷新页面。

《大厂面试:从手写 Ajax 到封装 getJSON,再到理解 Promise 与 sleep》

大厂面试必考:从手写 Ajax 到封装 getJSON,再到理解 Promise 与 sleep

在前端工程师的求职过程中,尤其是冲击一线大厂(如阿里、腾讯、字节等)时,手写代码题几乎是绕不开的一环。这些题目看似基础,实则考察候选人对 JavaScript 核心机制的理解深度——包括异步编程、事件循环、内存模型以及浏览器原生 API 的掌握程度。

本文将焦三个经典手写题:

  1. 手写原生 Ajax
  2. 封装支持 Promise 的 getJSON 函数
  3. 手写 sleep 函数

我们将逐层深入,不仅写出代码,更要讲清楚“为什么这么写”,帮助你在面试中不仅能写出来,还能讲明白。


一、手写 Ajax:回调地狱的起点

虽然现代开发中我们早已习惯使用 fetchaxios,但 Ajax 是所有网络请求的基石。面试官让你手写 Ajax,不是为了让你重复造轮子,而是检验你是否真正理解 HTTP 请求在浏览器中的实现方式。 ajax 基于回调函数实现,代码复杂,这正是其痛点所在。

手写一个基础版 Ajax

function ajax(url, callback) {
  const xhr = new XMLHttpRequest();
  
  xhr.open('GET', url, true); // 异步请求
  xhr.onreadystatechange = function () {
    if (xhr.readyState === 4) { // 请求完成
      if (xhr.status >= 200 && xhr.status < 300) {
        // 成功:调用回调并传入响应数据
        callback(null, JSON.parse(xhr.responseText));
      } else {
        // 失败:传入错误
        callback(new Error(`HTTP ${xhr.status}`), null);
      }
    }
  };
  xhr.send();
}

问题在哪?

  • 强依赖回调函数:调用方必须传入 callback,无法链式操作;
  • 错误处理分散:成功和失败逻辑耦合在同一个函数里;
  • 无法组合多个异步操作:比如“先请求 A,再根据 A 的结果请求 B”,代码会迅速变得嵌套混乱——即所谓的“回调地狱”。

这正是为什么我们需要 Promise


二、封装 getJSON:用 Promise 改造 Ajax

“如何封装一个 getJSON 函数。使用 ajax,支持 Promise,get 请求方法,返回是 JSON”

这其实是一个典型的“将传统回调式 API 转为 Promise 化”的过程。

封装思路

  • 创建一个返回 Promise 的函数;
  • Promise 构造函数内部执行 Ajax;
  • 成功时调用 resolve(data),失败时调用 reject(error)
  • 自动解析 JSON 响应体。

实现代码

function getJSON(url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.setRequestHeader('Accept', 'application/json');

    xhr.onload = function () {
      if (xhr.status >= 200 && xhr.status < 300) {
        try {
          const data = JSON.parse(xhr.responseText);
          resolve(data);
        } catch (e) {
          reject(new Error('Invalid JSON response'));
        }
      } else {
        reject(new Error(`Request failed with status ${xhr.status}`));
      }
    };

    xhr.onerror = function () {
      reject(new Error('Network error'));
    };

    xhr.send();
  });
}

使用方式(对比 fetch)

// 使用我们封装的 getJSON
getJSON('/api/user')
  .then(user => console.log(user))
  .catch(err => console.error('Failed:', err));

// 等价于 fetch 写法(但 fetch 不自动抛出 HTTP 错误)
fetch('/api/user')
  .then(res => {
    if (!res.ok) throw new Error('HTTP error');
    return res.json();
  })
  .then(user => console.log(user))
  .catch(err => console.error(err));

为什么 Promise 更好?

“fetch 简单易用,基于 Promise 实现,(then)无需回调函数”

Promise 的核心优势在于:

  • 状态机模型:初始为 pending,只能变为 fulfilled(通过 resolve)或 rejected(通过 reject),且状态不可逆;
  • 链式调用.then().catch() 形成清晰的流程控制;
  • 统一错误处理:任意环节出错,都会被最近的 .catch 捕获。

这使得异步代码更接近同步逻辑的阅读体验。


三、深入 Promise:不只是语法糖

“promise 类 ,为异步变同步而(流程控制)实例化,事实标准。接收一个函数,函数有两个参数,resolve reject,他们也是函数。”

  • new Promise(executor) 中的 executor 是一个立即执行的函数;
  • 它接收两个参数:resolvereject,都是由 Promise 内部提供的函数;
  • 调用 resolve(value) 会将 Promise 状态转为 fulfilled,并将 value 传递给下一个 .then
  • 调用 reject(reason) 则转为 rejected,触发 .catch

例如:

const p = new Promise((resolve, reject) => {
  setTimeout(() => {
    Math.random() > 0.5 ? resolve('ok') : reject('fail');
  }, 1000);
});

p.then(console.log).catch(console.error);

这种设计让开发者能主动控制异步结果的“成功”或“失败”路径,是构建可靠异步系统的基础。


四、手写 sleep:Promise

实现

 function sleep(n){
            let p;
                 p = new Promise ((resolve,reject)=>{
                
                setTimeout(()=>{
                // pending 等待
                console.log(p);
                //resolve();
                reject();
                // fulfilled  成功
                console.log(p);
                }
                    ,n);
            })
            return p;
        }
        sleep(3000)
        .then(()=>{
            console.log('3s后执行');

        })
        .catch(()=>{
            console.log('3s后执行失败');
        })
        // promise 状态改变 就会执行
        .finally(()=>{
            console.log('finally');
        })

手写题的本质是理解机制

大厂面试之所以反复考察这些“老掉牙”的手写题,是因为它们像一面镜子,照出你对 JavaScript 运行机制的理解深度:

  • Ajax → 浏览器网络 API + 回调模型;
  • Promise 封装 → 异步流程控制范式升级;
  • sleep → Promise 与定时器的创造性结合;

当你不仅能写出这些代码,还能清晰解释其背后的原理时,你就已经超越了大多数候选人。

记住:面试不是考你会不会用库,而是考你知不知道库为什么存在。

前端高频面试题之Vuex篇

1、Vuex 是什么?什么情况下应该使用 Vuex?

Vuex 是专门为 Vue.js 应用提供状态管理模式的一个库,也是 Vue.js 官方推荐的状态管理方案,它将所有数据集中存储到一个全局 store 对象中,并制定了一定的规则,保证状态以预期的方式发生变化。

它的核心概念有:

  • state:存储状态,并提供响应式能力。
  • getter: 从 state 中派生出一些状态,相当于 Vue.js 中的计算属性 computed。
  • mutation: 通过提交 mutation,是 Vuex 中修改 state 的推荐方式。
  • action:可以包括异步操作,异步操作处理完后,通过提交 mutation 修改状态。
  • module: 模块化,可以将 store 分割成一个个小模块,每个模块拥有自己的 state、getter、mutation、action,甚至是嵌套子模块。

在构建中大型单页应用时,各组件和模块的状态流转逻辑会相当复杂,这时候就可以使用 Vuex 进行全局状态管理,并且里面用严格的 mutation 保证了状态的预期流转,使得项目的数据流变得清晰,提高了项目可维护性。

2、如何解决页面刷新后 Vuex 的数据丢失问题?

数据丢失原因:Vuex 中的状态 state 是存储在内存中的,刷新页面会导致内存清空,所以数据丢失。

解决方案:

2.1 第一步:使用持久化存储保存数据

将 Vuex 的数据在合适时机(比如监听 window 的beforeunload 事件)保存到浏览器的本地存储(localStoragesessionStorage),也可以直接采用 vuex-persistedstate 持久化插件(默认会存储到 localStorage 中,可通过配置修改)进行本地存储。

2.2 第二步:初始化应用,替换状态

应用初始化加载时,获取存储中的状态进行替换。Vuex 给我们提供了一个 replaceState(state: Object) API,可以很方便进行状态替换。

2.3 第三步:检查数据,发起请求

在状态替换后,还需要检查 Vuex 中的数据是否存在,如果不存在则可以在 action 中发送接口请求拿到数据,通过提交 mutation 修改状态把数据存储到 store 中。

2.4 第四步:状态同步

状态变化后将状态同步到浏览器存储中,保证本地存储中状态的实时性。

不过要注意的是,如果把数据持久化到 localStorage 或者 sessionStorage 中,会有一定的安全风险:

  1. 数据直接全部暴露在 storage 可通过控制台的 Application 选项卡进行查看,数据容易泄漏。持久化的数据毕竟没有内存中的数据安全。
  2. 用户可以直接在控制台 Application 中直接修改数据,从而可能绕过某些权限校验,看到一些预期外的界面和交互。

3、mutation 和 action 的区别有哪些?

  • 作用不同:action 是用来处理异步逻辑或者业务逻辑,而 mutation 是用来修改状态的。
  • 使用限制:action 中可以调用 mutation 或者其他 action,而 mutation 中则只能修改 state。
  • 返回值不同dispatch 时会将 action 包装成 promise,而 mutation 则没进行包装。
  • 严格模式下的差异:在 Vuex 开启严格模式 strict: true 后,任何非 mutation 函数修改的状态,将会抛出错误。

扩展:vuex 严格模式是如何监听非 mutation 函数修改状态的?

其核心思路如下:

  1. this._committing 表示程序是否处于 commit 执行过程。
  2. 用同步 watch(同步监听的意思是,一旦数据发生变化会立即调用回调,而不是在 下一次 Tick 中调用) 监听 store 中的 state 状态(深度监听)。
  3. 如果在 commit 执行过程中,state 发生了变化,在开发环境会报错。
class Store {
  commit(_type, _payload, _options) {
    this._withCommit(() => {
      // commit 中的处理
      entry.forEach(function commitIterator(handler) {
        handler(payload);
      });
    });
  }
  _withCommit(fn) {
    const committing = this._committing;
    this._committing = true;
    fn(); // 如果函数内部有异步修改状态逻辑,则下面的 watch 时会报错
    this._committing = committing;
  }
}
function enableStrictMode(store) {
  watch(
    () => store._state.data,
    () => {
      if (__DEV__) { // 开发环境报错
        assert(
          store._committing,
          `do not mutate vuex store state outside mutation handlers.`
        );
      }
    },
    { deep: true, flush: "sync" } // 定义同步的 watcher 进行同步监控
  );
}

4、Vuex 的 module 在什么情况下会使用?

用官方的话来说就是,“使用单一状态树,应用的所有状态会集中到一个比较大的对象。当应用变得非常复杂时,store 对象就有可能变得相当臃肿。”

所以我们在开发复杂应用时,可以按照业务逻辑将应用状态进行 modules 拆分,比如:

  1. 用户模块 user;
  2. 订单模块 order;
  3. 课程模块 course;
  4. ...等其它模块。

这样在开发应用和维护状态时更加精细和清晰,可维护性更强。

5、Vuex 和 Pinia 的区别?

Pinia 是以 Vuex 5 为原型,由 Vue.js 官方团队开发的新一代 Vue 官方推荐的状态管理方案。

它对比 Vuex 有以下区别:

5.1 API 设计和使用方式

  • Vuex:采用单一 store 结构,需要严格区分 mutation(同步修改状态)和 action(异步操作)。状态修改必须通过 commit mutations 进行,虽然让数据流向更清晰,但也会让代码更加冗长。
  • Pinia:更简单的 API 设计,所见即所得,也提供了符合组合式 API 风格的 API(比如用 defineStore 定义 store)。去掉了 mutation,直接在 actions 中修改 state(支持同步/异步)。

5.2 模块化和结构

  • Vuex:支持模块化(modules),但需要在单一 store 中组织,可能导致大型项目 store 膨胀。
  • Pinia:天生模块化,每个 store 独立定义和导入,支持动态注册和热重载。更适合大型应用,便于拆分成小 store。

5.3 TypeScript 支持

  • Vuex:TypeScript 支持一般,需要额外配置;
  • Pinia:本身源码就是用 TypeScript 编写,所以对TypeScript 支持十分友好,具备自动推断类型、类型安全和代码补全。

5.4 性能和集成

  • Vuex:Vuex4 在 Vue3 中可用,但与 Composition API 集成不够顺畅,可能需要额外的适配;
  • Pinia:更轻量(体积小,约1kb),性能更好;完美支持 Vue 3 的 Composition API 和 reactivity 系统。

6、Pinia 和 Vuex 如何选择?

  • 新项目:强烈推荐用 Vue3 + Pinia
  • 老 Vue2 项目:如果不把项目升级到 Vue3 还是建议用 Vuex,如果需要升级到 vue3,就可以逐步把 Vuex 替换为 Pinia,Vuex 和 Pinia 是可以同时安装在同一个项目中,这也为项目升级提供了一定的便利。当然,由 Vuex -> Pinia,是一次,无疑和 Vue2 -> Vue3 一样,是一次大的破坏性升级,工作量还是相当大的。

结语

以上是整理的 Vuex 的高频面试题,如有错误或者可以优化的地方欢迎评论区指正,后续还会更新 Vue-router 相关面试题。

CSS 像素≠物理像素:0.5px 效果的核心密码是什么?

先明确两者的关系:CSS 像素是 “逻辑像素”(页面布局用),物理像素是屏幕实际发光的像素点,两者通过 设备像素比(DPR)  关联,公式为:1 个 CSS 像素 = DPR × DPR 个物理像素(仅高清屏缩放为 1 时)。

理解这个核心关系后,再看 0.5px 效果的实现逻辑就更清晰了,以下重新整理(重点补充像素关系,再对应方法):

一、先搞懂:CSS 像素、物理像素、DPR 的核心关系

  1. 定义

    • CSS 像素:写代码时用的单位(如 width: 100px),是浏览器渲染布局的 “逻辑单位”,和屏幕硬件无关。

    • 物理像素:屏幕面板上实际的发光点(如手机屏分辨率 1080×2340,就是横向 1080 个、纵向 2340 个物理像素),是屏幕的硬件属性。

    • DPR(设备像素比):DPR = 物理像素宽度 / CSS 像素宽度(默认页面缩放为 1 时),由设备硬件决定。

      • 例 1:老款普通屏(DPR=1):1 个 CSS 像素 = 1×1 个物理像素(写 1px 就对应屏幕 1 个发光点)。
      • 例 2:高清屏(DPR=2,如 iPhone 8):1 个 CSS 像素 = 2×2 个物理像素(写 1px 实际占用屏幕 4 个发光点,视觉上更粗)。
      • 例 3:超高清屏(DPR=3,如 iPhone 14 Pro):1 个 CSS 像素 = 3×3 个物理像素(写 1px 占用 9 个发光点,更粗)。
  2. 关键结论

    • 我们想要的 “0.5px 效果”,本质是 让线条只占用 1 个物理像素(视觉上最细)。
    • 但高清屏(DPR≥2)默认下,1 个 CSS 像素会占用多个物理像素,所以不能直接写 1px,需要通过方法 “压缩” CSS 像素对应的物理像素数量,最终落到 1 个物理像素上。

二、按 DPR 要求分类的 0.5px 实现方法(结合像素关系)

(一)仅 DPR≥2 生效:直接让 CSS 像素对应 1 个物理像素

核心逻辑:利用 DPR≥2 的像素映射关系,让 CSS 像素经过计算后,刚好对应 1 个物理像素。

1. 直接声明 0.5px
  • 像素关系:DPR=2 时,0.5px CSS 像素 = 0.5×2 = 1 个物理像素(刚好满足需求);DPR=3 时,0.5px CSS 像素 = 0.5×3 = 1.5 个物理像素(接近细线条,视觉可接受)。
  • 前提:DPR≥2 + 浏览器支持亚像素渲染(iOS 9+、Android 8.0+)。
  • 代码border: 0.5px solid #000;
  • 局限:DPR=1 时,0.5px CSS 像素 = 0.5×1 = 0.5 个物理像素(屏幕无法渲染,会四舍五入为 0px 或 1px)。
2. transform: scale(0.5) 缩放
  • 像素关系:先写 1px CSS 像素(DPR=2 时对应 2 个物理像素),再缩放 50%,最终 2×50% = 1 个物理像素。

  • 前提:DPR≥2(只有 DPR≥2 时,1px CSS 像素才会对应 ≥2 个物理像素,缩放后才能落到 1 个)。

  • 代码

    .line::after {
      content: '';
      width: 200%;
      height: 1px; /* 1px CSS = 2 物理像素(DPR=2) */
      background: #000;
      transform: scale(0.5); /* 2 物理像素 × 0.5 = 1 物理像素 */
    }
    
  • 局限:DPR=1 时,1px CSS 像素 = 1 物理像素,缩放后变成 0.5 物理像素(屏幕无法渲染,线条消失或模糊)。

3. viewport 缩放(全局方案)
  • 像素关系:通过 initial-scale=1/DPR 改变页面缩放比例,让 1px CSS 像素直接对应 1 个物理像素。

    • 例:DPR=2 时,缩放 50%(1/2),此时 1px CSS 像素 = 1 物理像素(原本 2 物理像素,缩放后压缩为 1);DPR=3 时,缩放 33.3%(1/3),1px CSS 像素 = 1 物理像素。
  • 前提:DPR≥2(高清屏),需配合布局单位(如 rem)调整。

  • 代码

    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <script>
      const dpr = window.devicePixelRatio || 1;
      document.querySelector('meta[name="viewport"]').setAttribute('content', 
        `width=device-width, initial-scale=${1/dpr}, user-scalable=no`
      );
    </script>
    
  • 优势:直接写 border: 1px 就是 1 物理像素,适配所有 DPR≥2 的设备。

  • 局限:全局缩放会影响布局,需重新计算 rem 基准值(如 html { font-size: 16px * dpr })。

(二)DPR≥2 最优,DPR=1 可模拟:视觉层面实现 “细于 1px”

核心逻辑:不依赖像素映射的精准计算,而是通过视觉欺骗或矢量渲染,让线条看起来比 1px 细(DPR=1 时无法实现 1 物理像素,只能模拟)。

1. SVG 绘制
  • 像素关系:SVG 是矢量图,不依赖 CSS 像素和物理像素的映射,直接按 “坐标 + 线条宽度” 渲染。

    • DPR≥2 时:stroke-width="1" + y1="0.5" 直接渲染为 1 个物理像素(矢量渲染支持亚像素精准控制)。
    • DPR=1 时:同样的代码会渲染为 “视觉上 0.5px 细的线条”(实际还是 1 物理像素,但矢量缩放让边缘更细腻,比直接写 1px 看起来细)。
  • 前提:无严格 DPR 要求,所有支持 SVG 的浏览器(几乎所有移动端)。

  • 代码

    <svg width="100%" height="1" xmlns="http://www.w3.org/2000/svg">
      <line x1="0" y1="0.5" x2="100%" y2="0.5" stroke="#000" stroke-width="1" />
    </svg>
    
2. 背景渐变(background-image
  • 像素关系:利用 1px 高的 CSS 容器,通过颜色分割模拟 “半像素”。

    • DPR=2 时:1px CSS 容器 = 2 物理像素高,渐变 “透明 50% + 有色 50%” 刚好对应 1 个物理像素的有色线条。
    • DPR=1 时:1px CSS 容器 = 1 物理像素高,渐变后视觉上是 “半透明细线”(比纯 1px 细,但本质是 1 物理像素的颜色叠加)。
  • 前提:支持 CSS3 渐变的浏览器(iOS 7+、Android 4.4+)。

  • 代码

    .line {
      height: 1px;
      background: linear-gradient(to bottom, transparent 50%, #000 50%);
    }
    
3. box-shadow 模拟
  • 像素关系:DPR=2 时,box-shadow: 0 0.5px 0 #000 中,0.5px CSS 偏移量 = 1 物理像素,形成 1 物理像素的细阴影(视觉上是细线条)。
  • 前提:DPR≥2(DPR=1 时,0.5px 偏移 = 0.5 物理像素,屏幕无法渲染,阴影不显示或模糊)。
  • 代码box-shadow: 0 0.5px 0 #000;

三、最终总结(结合像素关系)

实现方式 像素映射逻辑(核心) 依赖 DPR 视觉效果
直接 0.5px DPR≥2 时,0.5px CSS = 1 物理像素 DPR≥2 精准细线条
transform: scale DPR≥2 时,1px CSS(2 物理像素)缩放 50% = 1 物理像素 DPR≥2 兼容性好,精准细线条
viewport 缩放 DPR≥2 时,缩放 1/DPR 让 1px CSS = 1 物理像素 DPR≥2 全局适配,精准细线条
SVG 绘制 矢量渲染,直接控制 1 物理像素(DPR≥2)或模拟细线条(DPR=1) 无(DPR≥2 最优) 跨设备,细腻无模糊
背景渐变 DPR≥2 时 1px CSS(2 物理像素)颜色分割 = 1 物理像素;DPR=1 时视觉欺骗 无(DPR≥2 最优) 模拟细线条,无兼容性问题
box-shadow DPR≥2 时,0.5px CSS 偏移 = 1 物理像素阴影 DPR≥2 非边框线条适用

核心一句话:所有 “真实 0.5px 效果”(1 物理像素)都依赖 DPR≥2 的高清屏(利用 CSS 像素与物理像素的映射关系);DPR=1 时只能模拟,无法实现物理级半像素。

以下是包含 CSS 像素 / 物理像素 / DPR 关系说明 的 0.5px 兼容代码合集,每个方法都标注核心逻辑和适用场景,可直接复制使用:

一、说明(所有方法通用)

  • 核心目标:让线条最终占用 1 个物理像素(视觉最细)。
  • 像素关系:1 CSS 像素 = DPR × DPR 物理像素(默认缩放 1 时),高清屏(DPR≥2)需通过代码 “压缩” 映射关系。
  • 适配原则:优先选兼容性广、无布局影响的方法(如 SVG、transform 缩放)。

二、6 种实用兼容代码

1. 推荐首选:transform: scale (0.5) 缩放(DPR≥2 生效,兼容性最好)

  • 核心逻辑:1px CSS 像素(DPR=2 时对应 2 物理像素)→ 缩放 50% → 最终 1 物理像素。
  • 适用场景:边框、独立线条,不影响布局。
/* 通用细线条类(上下左右可按需调整) */
.thin-line {
  position: relative;
  /* 父容器需触发 BFC,避免线条溢出 */
  overflow: hidden;
}

.thin-line::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  height: 1px; /* 1px CSS = 2 物理像素(DPR=2) */
  background: #000; /* 线条颜色 */
  transform: scaleY(0.5); /* 垂直缩放 50% → 2 物理像素 → 1 物理像素 */
  transform-origin: 0 0; /* 缩放原点避免偏移 */
}

/* 横向线条(默认)、纵向线条(按需添加) */
.thin-line-vertical::after {
  width: 1px;
  height: 100%;
  transform: scaleX(0.5);
}
  • 使用方式:<div class="thin-line">内容</div>

2. 跨 DPR 优选:SVG 绘制(所有设备适配,精准无模糊)

  • 核心逻辑:SVG 矢量渲染不依赖像素映射,直接指定 1 物理像素线条(DPR≥2 精准,DPR=1 模拟细线条)。
  • 适用场景:UI 严格还原、跨设备兼容(推荐用于分割线、边框)。
<!-- 横向细线条(直接嵌入,可复用) -->
<svg class="svg-thin-line" width="100%" height="1" xmlns="http://www.w3.org/2000/svg">
  <!-- y1="0.5" + stroke-width="1" → 直接对应 1 物理像素(DPR≥2) -->
  <line x1="0" y1="0.5" x2="100%" y2="0.5" stroke="#000" stroke-width="1" />
</svg>

<!-- 纵向细线条(宽度 100%,高度自适应) -->
<svg class="svg-thin-line-vertical" width="1" height="100%" xmlns="http://www.w3.org/2000/svg">
  <line x1="0.5" y1="0" x2="0.5" y2="100%" stroke="#000" stroke-width="1" />
</svg>

<!-- 样式优化(可选) -->
<style>
  .svg-thin-line {
    display: block;
    margin: 8px 0; /* 上下间距 */
  }
</style>
  • 使用方式:直接嵌入 HTML,修改 stroke 颜色、width/height 适配场景。

3. 现代设备:直接 0.5px 声明(简洁高效,DPR≥2 + 现代浏览器)

  • 核心逻辑:DPR=2 时,0.5px CSS 像素 = 1 物理像素,浏览器直接渲染。
  • 适用场景:iOS 9+、Android 8.0+ 设备,无需兼容旧机型。
/* 直接声明,简洁高效 */
.simple-thin-line {
  border-bottom: 0.5px solid #000; /* 横向线条 */
  /* 纵向线条:border-left: 0.5px solid #000; */
}

/* 兼容写法(部分浏览器需前缀) */
.compact-thin-line {
  border-bottom: 0.5px solid #000;
  -webkit-border-bottom: 0.5px solid #000;
}
  • 使用方式:<div class="simple-thin-line">内容</div>

4. 全局适配:viewport 缩放(DPR≥2,全局细线条统一)

  • 核心逻辑:缩放页面为 1/DPR,让 1px CSS 像素 = 1 物理像素(需配合 rem 布局)。
  • 适用场景:整个页面需要大量细线条,愿意调整布局单位。
<!-- 第一步:设置 viewport(初始缩放 1.0) -->
<meta name="viewport" id="viewport" content="width=device-width, user-scalable=no">

<!-- 第二步:动态调整缩放比例 -->
<script>
  (function() {
    const dpr = window.devicePixelRatio || 1;
    const viewport = document.getElementById('viewport');
    // 缩放 1/DPR,让 1px CSS = 1 物理像素(DPR=2 → 缩放 50%)
    viewport.setAttribute('content', `width=device-width, initial-scale=${1/dpr}, user-scalable=no`);
    
    // 可选:调整 rem 基准值(避免布局错乱)
    const html = document.documentElement;
    html.style.fontSize = `${16 * dpr}px`; // 1rem = 16*dpr px(适配缩放后布局)
  })();
</script>

<!-- 第三步:直接写 1px 即可(此时 1px = 1 物理像素) -->
<style>
  .global-thin-line {
    border-bottom: 1px solid #000; /* 实际是 1 物理像素细线条 */
    margin: 0.5rem 0; /* rem 单位适配缩放后布局 */
  }
</style>
  • 使用方式:全局引入脚本,之后所有 1px 边框都会变成细线条。

5. 视觉模拟:背景渐变(无兼容性问题,DPR≥2 最优)

  • 核心逻辑:1px CSS 容器(DPR=2 时 2 物理像素)→ 颜色分割为 50% 透明 + 50% 有色 → 视觉上 1 物理像素。
  • 适用场景:背景线条、无法用边框 / 伪元素的场景。
/* 横向线条 */
.gradient-thin-line {
  height: 1px;
  width: 100%;
  /* 上半透明,下半有色 → 视觉上细线条 */
  background: linear-gradient(to bottom, transparent 50%, #000 50%);
  background-size: 100% 1px;
}

/* 纵向线条 */
.gradient-thin-line-vertical {
  width: 1px;
  height: 100%;
  background: linear-gradient(to right, transparent 50%, #000 50%);
  background-size: 1px 100%;
}
  • 使用方式:<div class="gradient-thin-line"></div>(独立线条容器)。

6. 非边框场景:box-shadow 模拟(DPR≥2,适合阴影类线条)

  • 核心逻辑:DPR=2 时,0.5px CSS 偏移 = 1 物理像素,阴影即细线条。
  • 适用场景:无需占用布局空间的线条(如文字下方细下划线)。
.shadow-thin-line {
  height: 0;
  /* y 轴偏移 0.5px → 1 物理像素,无模糊、无扩散 */
  box-shadow: 0 0.5px 0 #000;
  -webkit-box-shadow: 0 0.5px 0 #000; /* 兼容 Safari */
}

/* 文字下划线示例 */
.text-thin-underline {
  display: inline-block;
  box-shadow: 0 0.5px 0 #000;
  padding-bottom: 2px;
}
  • 使用方式:<span class="text-thin-underline">带细下划线的文字</span>

三、使用建议

  1. 优先选 transform 缩放 或 SVG 绘制:兼容性广、无布局影响,覆盖 99% 场景。
  2. 现代设备(iOS 9+/Android 8.0+)直接用 0.5px 声明:代码最简洁。
  3. 全局大量细线条用 viewport 缩放:需配合 rem 布局,一次性解决所有线条问题。

面试官:JWT、Cookie、Session、Token有什么区别?

JWT、Cookie、Session、Token 是 Web 开发中常用的身份认证和状态管理技术,它们之间既有区别,也有联系

一、JWT(JSON Web Token)

JWT 是一种开放标准(RFC 7519),用于在网络应用之间安全地传输信息(通常是身份认证信息)。它是一个自包含的、可验证的、不可篡改的字符串,格式如下:

Header.Payload.Signature

三部分组成:

  1. Header(头部):声明类型和签名算法(如 HS256)。
  2. Payload(载荷):包含用户信息(如用户 ID、角色等)和元数据(如过期时间)。
  3. Signature(签名):用密钥对 Header 和 Payload 签名,防止篡改。

特点:

  • 无需服务器存储(无状态)。
  • 可跨域使用(常用于分布式系统、微服务)。
  • 一旦签发,在过期前无法撤销(除非引入黑名单机制)。

二、Cookie

Cookie 是浏览器存储的一小段文本信息,由服务器通过 HTTP 响应头 Set-Cookie 设置,浏览器在后续请求中自动携带。

特点:

  • 自动携带(浏览器行为)。
  • 可设置过期时间、作用域、HttpOnly、Secure 等属性。
  • 容量小(约 4KB)。
  • 可用于存储 Session ID 或 JWT。

三、Session(会话)

Session 是服务器端维护的用户会话状态。通常流程如下:

  1. 用户登录后,服务器创建一个 Session,生成一个唯一的 Session ID
  2. Session ID 通过 Cookie 返回给浏览器。
  3. 浏览器后续请求自动携带该 Cookie,服务器通过 Session ID 查找对应的用户状态。

特点:

  • 状态存储在服务器端(通常是内存、Redis、数据库)。
  • 安全性较高(用户无法直接篡改)。
  • 不适合分布式系统(需要共享 Session 存储)。

四、Token(令牌)

Token 是一个广义概念,指用于身份验证的凭证。JWT 就是一种 Token。

常见 Token 类型:

  • Access Token(访问令牌):用于访问资源。
  • Refresh Token(刷新令牌):用于获取新的 Access Token。
  • JWT:一种结构化的 Token。

五、它们之间的关系与区别

名称 存储位置 状态管理 安全性 适用场景
JWT 客户端 无状态 分布式系统、移动端、API 认证
Cookie 客户端 无状态 存储小量数据、自动携带
Session 服务器端 有状态 传统 Web 应用
Token 客户端 无状态 通用身份凭证(JWT 是其一)

六、常见组合方式

方式一:Session + Cookie(传统 Web)

  • 登录后服务器创建 Session,Session ID 存 Cookie。
  • 每次请求带 Cookie,服务器查 Session 验证身份。

方式二:JWT + Header(前后端分离)

  • 登录后服务器返回 JWT,前端存 localStorage 或 Cookie。
  • 每次请求手动在 Header 中加 Authorization: Bearer <JWT>

方式三:JWT + Cookie(安全增强)

  • JWT 存 Cookie,设置 HttpOnly + Secure,防止 XSS。
  • 浏览器自动携带,服务器解析 JWT 验证身份。

七、总结

  • JWT 是一种自包含的 Token不依赖服务器存储
  • Cookie浏览器存储机制,可存 Session ID 或 JWT。
  • Session服务器存储的用户状态,依赖 Cookie 传递 ID。
  • Token身份凭证,JWT 是其中一种实现。

React组件命名为什么用小写开头会无法运行?

在React项目实际开发中,我们经常用到一些约定俗成的语法,今天我们来聊一聊为什么组件命名时以小写字母开头的组件无法运行的这个现象,这个现象是由什么原因导致的。这个背后有重要的设计原理。

这就不得不谈 JSX,JSX是一种语法扩展,它允许我们在JavaScript中编写类似HTML的代码。

React项目中遇到JSX中的元素时,函数组件首字母大小写决定了React编译这个元素 是原生DOM元素还是自定义组件。 具体来说:

  • 当JSX标签以小写字母开头时,React会将其视为原生DOM元素(如divspan等),并尝试在DOM中创建对应的标签。
  • 当JSX标签以大写字母开头时,React会将其视为自定义组件,并去查找当前作用域中对应的函数或类组件。

那么,这个问题产生的根本原因:JSX 的编译机制

// 当 Babel 编译 JSX 时,它会根据标签的首字母大小写来决定如何转换:
<MyComponent />
<div />

// 编译后的 JavaScript
React.createElement(MyComponent, null);  // 大写 - 作为变量/组件
React.createElement("div", null);        // 小写 - 作为字符串(HTML 标签)
// ❌ 错误:小写组件名
function avatar({ src, alt }) {
  return <img src={src} alt={alt} />;
}

function UserProfile() {
  return (
    <div>
      {/* 这会导致错误 */}
      <avatar src="user.jpg" alt="User" />
      {/* 编译为:React.createElement("avatar", { src: "user.jpg", alt: "User" }) */}
      {/* React 会寻找 <avatar> HTML 标签,但不存在 */}
    </div>
  );
}

Babel有一个插件(通常是@babel/plugin-syntax-jsx或@babel/preset-react)来处理JSX语法。这个插件会将JSX转换为React.createElement调用。

实现这一转换的Babel插件内部,会有一个Visitor来处理JSXElement节点。在Visitor中,它会检查JSXOpeningElement的name属性。如果name是一个JSXIdentifier,并且首字母是小写,则将其作为字符串;如果是大写,则保留为标识符。 那么我们来模拟插件内部是怎么解析的呢?看下方代码

<MyComponent prop="value" />
<div className="container" />

// Babel 解析为 AST(抽象语法树)
{
  type: 'JSXElement',
  openingElement: {
    type: 'JSXOpeningElement',
    name: {
      type: 'JSXIdentifier',
      name: 'MyComponent'  // 或 'div'
    }
    // ...
  }
}

转换阶段核心代码

export default function (babel) {
  const { types: t } = babel;
  
  return {
    name: "transform-jsx",
    visitor: {
      JSXElement(path) {
        const openingElement = path.node.openingElement;
        const tagName = openingElement.name.name;
        
        // 关键判断逻辑
        let elementType;
        if (/^[a-z]/.test(tagName)) {
          // 小写开头 -> HTML 标签 -> 字符串
          elementType = t.stringLiteral(tagName);
        } else {
          // 大写开头 -> 组件 -> 标识符
          elementType = t.identifier(tagName);
        }
        
        // 转换为 React.createElement 调用
        const createElementCall = t.callExpression(
          t.identifier('React.createElement'),
          [elementType, ...processAttributes(openingElement.attributes)]
        );
        
        path.replaceWith(createElementCall);
      }
    }
  };
}

实际 Babel 插件源码分析

在 @babel/plugin-transform-react-jsx 中:

function transformJSX() {
  return {
    visitor: {
      JSXElement(path) {
        const { node } = path;
        const tag = node.openingElement.name;
        
        let tagExpr;
        if (tag.type === 'JSXIdentifier') {
          const tagName = tag.name;
          
          // 关键判断:首字母是否小写
          if (
            /^[a-z][a-z0-9]*$/.test(tagName) || 
            // 或者是已知的 SVG 标签等
            knownHTMLTags.has(tagName) ||
            knownSVGTags.has(tagName)
          ) {
            // HTML/SVG 标签 -> 字符串字面量
            tagExpr = types.stringLiteral(tagName);
          } else {
            // 组件 -> 标识符
            tagExpr = types.identifier(tagName);
          }
        } else if (tag.type === 'JSXMemberExpression') {
          // 处理 <MyComponent.SubComponent /> 这种情况
          tagExpr = transformJSXMemberExpression(tag);
        }
        
        const createElementCall = types.callExpression(
          types.identifier('React.createElement'),
          [tagExpr, ...createAttributes(node.openingElement.attributes)]
        );
        
        path.replaceWith(createElementCall);
      }
    }
  };
}

完整的编译示例如下

JSX

  return (
    <div className="app">
      <Header title="Welcome" />
      <main className="content">
        <UserList users={users} />
        <footer className="site-footer">
          <Copyright year={2024} />
        </footer>
      </main>
    </div>
  );
}

Babel 编译后的 JavaScript

  return React.createElement(
    "div", 
    { className: "app" },
    React.createElement(Header, { title: "Welcome" }),
    React.createElement(
      "main", 
      { className: "content" },
      React.createElement(UserList, { users: users }),
      React.createElement(
        "footer", 
        { className: "site-footer" },
        React.createElement(Copyright, { year: 2024 })
      )
    )
  );
}

以上就是组件命名大小写在react插件中的运行示例演示,解释了为什么组件用小写开头无法运行。

看似简单的首字母大小写判断,实际上是整个 React 开发设计和生态的重要一环。

我是大布布将军,一个AICodeing时代下的前端开发思考者。

前端高频面试题之Vue(高级篇)

1、说一下 Vue.js 的响应式原理

1.1 Vue2 响应式原理

核心原理就是通过 Object.defineProperty 对对象属性进行劫持,重新定义对象的 gettersetter,在 getter 取值时收集依赖,在 setter 修改值时触发依赖更新,更新页面。

Vue2 对数组和对象做了两种不同方式的处理。

监听对象变化:

针对对象来说,Vue 会循环遍历对象的每一个属性,用 defineReactive 重新定义 gettersetter


function defineReactive(target,key,value){
    observer(value);
    Object.defineProperty(target,key,{ ¸v
        get(){
            // ... 收集依赖逻辑
            return value;
        },
        set(newValue){
            if (value !== newValue) {
                value = newValue;
                observer(newValue) // 把新设置的值包装成响应式
            }
            // ...触发依赖更新逻辑
        }
    })
}
function observer(data) {
    if(typeof data !== 'object'){
        return data
    }
    for(let key in data){
        defineReactive(data,key,data[key]);
    }
}

监听数组变化:

我们都知道,数组其实也是对象,同样可以用 Object.defineProperty 劫持数组的每一项,但如果数组有100万项,那就要调用 Object.defineProperty 一百万次,这样的话性能太低了。鉴于平时我们操作数组大都是采用数组提供的原生方法,于是 Vue 对数组重写原型链,在调用7个能改变自身的原生方法(pushpopshiftunshiftsplicesortreverse)时,通知页面进行刷新,具体实现过程如下:

// 先拿到数组的原型
const oldArrayProtoMethods = Array.prototype
// 用Object.create创建一个以oldArrayProtoMethods为原型的对象
const arrayMethods = Object.create(oldArrayProtoMethods)
const methods = [
    'push',
    'pop',
    'shift',
    'unshift',
    'sort',
    'reverse',
    'splice'
]
methods.forEach(method => {
    // 给arrayMethods定义7个方法
    arrayMethods[method] = function (...args){
        // 先找到数组对应的原生方法进行调用
        const result = oldArrayProtoMethods[method].apply(this, args)
        // 声明inserted,用来保存数组新增的数据
        let inserted
        // __ob__是Observer类实例的一个属性,data中的每个对象都是一个Observer类的实例
        const ob = this.__ob__
        switch(method) {
            case 'push':
            case 'unshift':
                inserted = args
                break
            case 'splice':
                inserted = args.slice(2)
            default:
                break
        }
        // 比如有新增的数据,新增数据也要被定义为响应式
        if(inserted) ob.observeArray(inserted)
        // 通知页面更新
        ob.dep.notify()
        return result
    }
})

Object.defineProperty的缺点:

  1. 无法监听新增属性和删除属性的变化,需要通过 $set$delete 实现。
  2. 监测数组的索引性能太低,故而直接通过数组索引改值无法触发响应式。
  3. 初始化时需要一次性递归调用,性能较差。

1.2 Vue3 的响应式改进

Vue3 采用 Proxy + Reflect 配合实现响应式。能解决上述 Object.defineProperty 的所有缺陷,唯一缺点就是兼容性没有 Object.defineProperty 好。

let handler = {
  get(target, key) {
    if (typeof target[key] === "object") {
      return new Proxy(target[key], handler);
    }
    return Reflect.get(target, key);
  },
  set(target, key, value) {
    let oldValue = target[key];
    if (oldValue !== value) {
      return Reflect.set(target, key, value);
    }
    return true;
  },
};
let proxy = new Proxy(obj, handler);

2、介绍一下 Vue 中的 diff 算法?

Vue 的 diff 算法是平级比较,不考虑跨级比较的情况。内部采用深度递归的方式 + 双指针的方式进行比较。

比较过程:

  1. 先比较是否是相同节点。
  2. 相同节点比较属性,并复用老节点。
  3. 比较儿子节点,考虑老节点和新节点儿子的情况。
  4. 优化比较:头头、尾尾、头尾、尾头。
  5. 比对查找进行复用。

Vue3 在这个比较过程的基础上增加了最长递增子序列实现diff算法。

  • 找出不需要移动的现有节点。
  • 只对需要移动的节点进行操作。
  • 最小化 DOM 操作次数。

3、Vue 的模板编译原理是什么?

Vue 中的模板编译就是把我们写的 template 转换为渲染函数(render function) 的过程,它主要经历3个步骤:

  1. 解析(Parse):将 template 模板转换成 ast 抽象语法树。
  2. 优化(Optimize):对静态节点做静态标记,减少 diff 过程中的比对。
  3. 生成(Generate):重新生成代码,将 ast 抽象语法数转化成可执行的渲染函数代码。

3.1 解析阶段

<div id="app">
  <p>{{ message }}</p>
</div>
  • 用 HTML 解析器将模板解析为 AST。
  • AST中用 js 对象描述模板,里面包含了元素类型、属性、子节点等信息。
  • 解析指令(v-for、v-if)和事件(@click)、插值表达式{{}}等 vue 语法。

3.2 优化阶段

  • 遍历上一步生成的 ast,标记静态节点,比如用 v-once 的节点,以及没有用到响应式数据的节点。
  • 标记静态根节点,避免不必要的渲染。

3.3 代码生成阶段

vue2 解析结果:

function render() {
  with(this) {
    return _c('div', {
      attrs: {
        "id": "app"
      }
    }, [_c('p', [_v(_s(message))])])
  }
}
  • _c: 是 createElement 的别名,用于创建 VNode。
  • _v: 创建文本 VNode。
  • _s: 是 toString 的别名,用于将值转换为字符串。

vue3 解析结果:

import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", { id: "app" }, [
    _createElementVNode("p", null, _toDisplayString(_ctx.message), 1 /* TEXT */)
  ]))
}
  • _openBlock: 开启一个"block"区域,用于收集动态子节点。
  • _createElementBlock: 创建一个块级虚拟 DOM 节点。
  • _createElementVNode: 创建一个普通虚拟 DOM 节点。
  • _toDisplayString: 将响应式数据 _ctx.message 转换为显示字符串,或者处理 null/undefined 等值,确保它们能正确渲染为空白字符串。

vue2在线编译:template-explorer.vuejs.org/

vue3在线编译:v2.template-explorer.vuejs.org/

运行时+编译(runtime-compiler) vs 仅运行时(runtime-only):

  1. 完整版(运行时+编译):
    • 包含编译模块,可以写 template 模版。
    • 体积较大(~30kb)。
  2. 仅运行时版本
    • 需要在打包时使用 vue-loader 进行编译。
    • 体积较小(~20kb)。

平时开发项目推荐使用仅运行时(runtime-only)版本。

编译后的特点:

  1. 虚拟DOM:渲染函数生成的是虚拟DOM节点(VNode)。
  2. 响应式绑定:渲染函数中的变量会自动建立依赖关系。
  3. 性能优化:通过静态节点标记减少不必要的更新。

4、v-show 和 v-if 的原理

简单来说,v-if 内部是通过一个三元表达式来实现的,而 v-show 则是通过控制 DOM 元素的 display 属性来实现的。

v-if 源码:

function genIfConditions (
    conditions: ASTIfConditions,
    state: CodegenState,
    altGen?: Function,
    altEmpty?: string
    ): string {
    if (!conditions.length) {
        return altEmpty || '_e()'
    }
    const condition = conditions.shift()
    if (condition.exp) {   // 如果有表达式
        return `(${condition.exp})?${ // 将表达式作为条件拼接成元素
        genTernaryExp(condition.block)
        }:${
        genIfConditions(conditions, state, altGen, altEmpty)
        }`
    } else {
        return `${genTernaryExp(condition.block)}` // 没有表达式直接生成元素 像v-else
    }

    // v-if with v-once should generate code like (a)?_m(0):_m(1)
    function genTernaryExp (el) {
        return altGen
        ? altGen(el, state)
        : el.once
            ? genOnce(el, state)
            : genElement(el, state)
    }
}

v-show 源码:

{
    bind (el: any, { value }: VNodeDirective, vnode: VNodeWithData) {
    const originalDisplay = el.__vOriginalDisplay =
        el.style.display === 'none' ? '' : el.style.display // 获取原始显示值
        el.style.display = value ? originalDisplay : 'none' // 根据属性控制显示或者隐藏
    }  
} 

5、v-if 和 v-for 哪个优先级更高?为什么?

  • vue2 中 v-for 的优先级比 v-if 高,它们作用于一个节点上会导致先循环后对每一项进行判断,浪费性能。
  • vue3 中 v-if 的优先级比 v-for 高,这就会导致 v-if 中的条件无法访问 v-for 作用域名中定义的变量别名。
<li v-for="item in arr" v-if="item.visible">
  {{ item}}
</li>

以上代码在 vue3 的编译结果如下:

import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createCommentVNode as _createCommentVNode } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_ctx.item.visible)
    ? (_openBlock(true), _createElementBlock(_Fragment, { key: 0 }, _renderList(_ctx.arr, (item) => {
        return (_openBlock(), _createElementBlock("li", null, _toDisplayString(item), 1 /* TEXT */))
      }), 256 /* UNKEYED_FRAGMENT */))
    : _createCommentVNode("v-if", true)
}

可以看出 vue3 在编译时会先判断 v-if,然后再走 v-for 的循环,所以在 v-if 中自然就无法访问 v-for 作用域名中定义的变量别名。

这样的写法在 vue3 中会抛出一个警告⚠️,[Vue warn]: Property "item" was accessed during render but is not defined on instance,导致渲染失败。

以上代码在 vue2 还不能直接编译,因为 vue2 的组件需要一个根节点,所以我们在外层加一个 div

<div>
  <li v-for="item in arr" v-if="item.visible">
    {{ item}}
  </li>
</div>

其编译结果如下:

function render() {
  with(this) {
    return _c('div', _l((arr), function (item) {
      return (item.visible) ? _c('li', [_v("\n    " + _s(item) + "\n  ")]) :
        _e()
    }), 0)
  }
}

很明显是先循环 arr,然后每一项再用 item.visible 去判断的,也印证了在 vue2 中, v-for 的优先级高于 v-if

所以不管是 vue2 还是 vue3,都不推荐同时使用 v-ifv-for,更好的方案是采用计算属性,或者在外层再包裹一个容器元素,将 v-if 作用在容器元素上。

6、nextTick 的原理是什么?

6.1 Vue2 的 nextTick:

  • 首选微任务:
    • Promise.resolve().then(flushCallbacks):最常见,使用 Promise 创建微任务。
    • MutationObserver:如果 Promise 不可用,创建一个文本节点,修改其内容触发 MutationObserver 的观察器回调。
  • 回退宏任务:
    • setImmediate:如果环境支持 setImmediate,比如 node 环境,则会优先使用 setImmediate 。
    • setTimeout(flushCallbacks, 0):最后使用定时器。

这里体现了优雅降级的思想。

6.2 Vue3 的 nextTick:

  • 由于 Vue3 不再考虑 promise 的兼容性,所以 nextTick 的实现原理就是 promise.then 方法。

7、Vue.set 方法是如何实现的?

Vue2的实现:在 Vue 2 中,Vue.set 的实现主要位于 src/core/observer/index.js 中:

export function set (target: Array | Object, key: any, val: any): any {
    // 1.如果是数组 Vue.set(array,1,100); 调用我们重写的splice方法 (这样可以更新视图)
    if (Array.isArray(target) && isValidArrayIndex(key)) {
        target.length = Math.max(target.length, key)
        target.splice(key, 1, val)
        return val
    }
    // 2.如果是对象本身的属性,则直接添加即可
    if (key in target && !(key in Object.prototype)) {
        target[key] = val
        return val
    }
    const ob = (target: any).__ob__
    // 3.如果是响应式的也不需要将其定义成响应式属性
    if (!ob) {
        target[key] = val
        return val
    }
    // 4.将属性定义成响应式的
    defineReactive(ob.value, key, val)
    // 5.通知视图更新
    ob.dep.notify()
    return val
}

Vue3 中 set 方法已经被移除,因为 proxy 天然弥补 vue2 响应式的缺陷。

8、Vue.use 是干什么的?原理是什么?

Vue.use 是用来使用插件的,我们可以在插件中扩展全局组件、指令、原型方法等。

Vue.use 源码:

Vue.use = function (plugin: Function | Object) {
    // 插件不能重复的加载
    const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
    if (installedPlugins.indexOf(plugin) > -1) {
        return this
    }
    // additional parameters
    const args = toArray(arguments, 1)
    args.unshift(this)  // install方法的第一个参数是Vue的构造函数,其他参数是Vue.use中除了第一个参数的其他参数
    if (typeof plugin.install === 'function') { // 调用插件的install方法
        plugin.install.apply(plugin, args)  Vue.install = function(Vue,args){}
    } else if (typeof plugin === 'function') { // 插件本身是一个函数,直接让函数执行
        plugin.apply(null, args) 
    }
    installedPlugins.push(plugin) // 缓存插件
    return this
}

9、介绍下 Vue 中的 mixin,Vue3 为何不再推荐使用它?

mixin 是 Vue 2 中一种复用组件逻辑的方式,允许将可复用的配置(data、methods、computed、lifecycle hooks 等)抽离成一个对象,然后通过 mixins: [] 合并到组件中。支持全局注入和局部注入。

  • 作用:抽离公共的业务逻辑
  • 原理:类似“对象的继承”,当组件初始化时会调用 mergeOptions 方法进行合并,采用策略模式针对不同的属性进行合并。如果混入的数据和本身组件中的数据冲突,会采用“就近原则”以组件的数据为准。

mixin 的优点:

  • 复用逻辑(如表单验证、权限判断)。
  • 全局注入(如日志、埋点)。
  • 减少重复代码。

mixin 中有很多缺陷:

  • 命名冲突问题:mixin 中的变量、函数名可能会与组件中的重名。
  • 依赖问题:
  • 数据来源问题:

vue3 不再推荐使用它的理由如下:

问题 说明
1. 隐式依赖 & 数据来源不明确 组件行为来自多个 mixin,难以追踪 data、methods 是从哪里来的。
2. 命名冲突 多个 mixin 可能定义同名 data、methods,合并规则复杂(同名 methods 后者覆盖前者,data 合并为对象,同名 Key 后者覆盖前者)。
3. 调试和维护困难 父组件无法知道子组件内部有哪些 mixin 注入的属性,排查 bug 和调试困难。
4. 不利于 Tree-shaking 打包时难以移除未使用的 mixin 代码。
5. 与 Composition API 理念冲突 Mixin 是“横切关注点”,而 Composition API 强调显式、可组合的逻辑。

Vue 3 推荐替代方案:Composition API + 可复用函数(Composables)。

特性 Mixin Composables
数据来源明确 隐式 显式(import)
是否有命名冲突问题
逻辑封装 全局污染 按需引入
Tree-shaking 支持
TypeScript 支持

对于全局混入(Global Mixin),Vue3 虽然提供了 app.mixin(),但不推荐,推荐使用:

  1. app.config.globalProperties
  2. app.provide 在顶层提供数据,组件通过 inject 方法消费数据。

10、介绍下 Vue.js 中的函数式组件、异步组件和递归组件

10.1 函数式组件(Functional Components)

函数式组件是一种轻量级、无状态的组件形式。它们很像纯函数:接收 props,返回 虚拟 DOM(vnode)。函数式组件在渲染过程中不会创建组件实例 (也就是说,没有 this),也不会触发常规的组件生命周期钩子。没有响应式系统、生命周期和实例的开销,函数式组件自然在渲染上更加高效和快速。

总而言之,函数式组件有无状态无this无生命周期性能更高等特点。

使用场景:适合简单、静态的 UI 元素,如列表项或包装组件。

在 Vue 2 中,通过 functional: true 声明;在 Vue 3 中,函数式组件更简单,直接返回渲染函数。

Vue2 示例:

<template functional>
  <div>{{ props.msg }}</div>
</template>

或者 js 形式:

export default {
  functional: true,
  props: ['msg'],
  render(h, { props }) {
    return h('div', props.msg);
  }
};

Vue3 示例:

<script setup>
import { h } from 'vue';

const FunctionalComp = (props) => h('div', props.msg);
</script>

<template>
  <FunctionalComp msg="Hello Functional" />
</template>

10.2 异步组件(Async Components)

异步组件是一种懒加载(Lazy Loading)机制,用于按需加载组件代码,优化初始加载时间和性能。Vue 会动态导入组件,只有在使用时才下载和渲染,常用于路由或大型组件。

特点:

  • 通过 import() 动态加载,返回 Promise。
  • 支持加载中(loading)、错误(error)和超时(timeout)处理。
  • 在 Vue 3 中,使用 defineAsyncComponent 更规范,支持与 <Suspense> 结合(Vue 3 独有,用于统一处理异步)。

Vue2 示例:

<script>
export default {
  components: {
    AsyncComp: () => import('./AsyncComp.vue')
  }
};
</script>

<template>
  <AsyncComp />
</template>

Vue3 示例:

<script setup>
import { defineAsyncComponent } from 'vue';

const AsyncComp = defineAsyncComponent(() => import('./AsyncComp.vue'));
</script>

<template>
  <Suspense>
    <template #default>
      <AsyncComp />
    </template>
    <template #fallback>加载中...</template>
  </Suspense>
</template>

10.3 递归组件(Recursive Components)

递归组件是指组件内部调用自身,用于处理树形或嵌套数据结构,如菜单、树视图或评论回复。Vue 支持组件自引用,但需注意避免无限循环(通过条件终止递归)。

特点:

  • 组件需有名称(name 选项),才能自引用。
  • 常结合 v-for 和 props 传递数据。

Vue 2 示例:

<template>
  <ul>
    <li v-for="item in tree" :key="item.id">
      {{ item.name }}
      <Tree v-if="item.children" :tree="item.children" />
    </li>
  </ul>
</template>

<script>
export default {
  name: 'Tree',  // 必须有 name
  props: ['tree']
};
</script>

Vue 3 示例:

<script setup>
import { defineAsyncComponent } from 'vue';  // 可选:异步加载避免循环

const Tree = defineAsyncComponent(() => import('./Tree.vue'));  // 自引用
defineProps(['tree']);
</script>

<template>
  <ul>
    <li v-for="item in tree" :key="item.id">
      {{ item.name }}
      <Tree v-if="item.children" :tree="item.children" />
    </li>
  </ul>
</template>

11、Vue.js 中的 vue-loader 是什么?

Vue-loader 是一个专为 Vue.js 设计的 Webpack loader(加载器),其主要作用是将 Vue 的单文件组件(Single-File Components,简称 SFC,即 .vue 文件)转换为可执行的 JavaScript 模块。 它允许开发者以一种结构化的方式编写组件,将模板(template)、脚本(script)和样式(style)封装在同一个文件中,便于管理和维护。

核心功能:

  • 解析 SFC 文件:Vue-loader 会自动处理 .vue 文件中的三个部分:
    • template 部分:编译为 Vue 的渲染函数(render function)。
    • script 部分:提取为组件的 JavaScript 逻辑,支持 ES 模块和 TypeScript。
    • style部分:处理 CSS,支持预处理器(如 Sass、Less)并可选地应用 scoped(作用域样式)或 CSS Modules。
  • 热重载(Hot Module Replacement,HMR):在开发模式下,支持组件的热更新,无需刷新页面即可看到变化,提高开发效率。
  • 自定义块(Custom Blocks):支持扩展,如添加 <docs> 或其他自定义标签,用于文档生成或其他工具集成。
  • 预处理器支持:无缝集成 Babel、PostCSS 等工具链。

12、Vue.extend 方法的作用?

Vue.extend方法可以作为基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象。

Vue2 示例:

var dialog = Vue.extend({
  template: "<div>{{hello}} {{world}}</div>",
  data: function () {
    return {
      hello: "hello",
      world: "world",
    };
  },
});
// 创建 dialog 实例,并手动挂载到一个元素上。
new dialog().$mount("#app");

注意:在 Vue.extend 中的 data 必须是一个函数,要不然会报错。

Vue3 示例:

Vue3 中不在使用 Vue.extend 方法,而是采用render方法进行手动渲染。

<!-- Modal.vue -->
<template>
  <div class="modal">这是一个弹窗</div>
</template>

<script>
export default {
  name: 'Modal',
}
</script>
<template>
  <div id="box"></div>
</template>

<script setup>
import { h, render, createApp, onMounted } from 'vue'
import Modal from './Modal.vue'

onMounted(() => {
  render(h(Modal), document.getElementById('box'));
})
</script>

13、keep-alive 的原理

<keep-alive> 是 Vue.js 的内置组件,它的功能是在多个组件间动态切换时缓存被移除的组件实例,避免重复渲染和状态丢失,提高性能。它是一个抽象组件(abstract: true),不会渲染到 DOM,也不会出现在组件树中,而是通过插槽(slots)和自定义 render 函数实现缓存逻辑。

核心实现机制:

  1. 抽象组件与 Render 函数:
  • <keep-alive> 通过 render 函数处理包裹的内容(通常是动态组件,如 <component> 或 v-if 切换的组件)。
  • 在 render 中,它从插槽获取子组件的 VNode(虚拟节点)。如果子组件有 key(推荐使用),则用 key 作为缓存标识;否则用组件的 tag 或 cid(组件 ID)。
  • 如果组件已缓存,直接返回缓存的 VNode(设置 vnode.componentInstance.keepAlive = true 以标记缓存状态);否则,渲染新实例并存入缓存。
  1. 缓存存储:
  • 内部使用一个对象(this.cache)作为缓存 Map,以 key 为键,值为 VNode 对象(包含组件实例)。
  • 当组件首次渲染时,存入缓存;切换回时,从缓存取出,避免重新创建实例和执行 mounted 钩子。
  1. LRU 缓存算法:
  • 支持 max 属性设置最大缓存数量(Vue 2.5+)。
  • 使用 Least Recently Used(最近最少使用)算法:当缓存超出 max 时,删除最久未访问的组件(通过 this.keys 数组跟踪访问顺序)。
  • 访问组件时,将其 key 移到数组末尾(最近使用);超出时,删除数组开头的 key,并销毁对应实例(调用 $destroy)。
  1. 过滤规则(include/exclude):
  • 通过 include(白名单)和 exclude(黑名单)属性决定哪些组件缓存,支持字符串、正则、数组或函数。
  • 在 created 钩子中,监听这些 prop 的变化,并调用 pruneCache 更新缓存(移除不匹配的组件)。
  1. 生命周期钩子:
  • 缓存组件不会触发 destroyed/unmounted,而是使用 activated(激活时)和 deactivated(失活时)钩子。
  • 这允许开发者在切换时管理状态(如暂停定时器),而非完全销毁。

注意事项:

  • 只缓存一个直接子组件(插槽内容),不支持多个。
  • Vue 3 中原理类似,但优化了 VNode 处理和 Composition API 支持。
  • 潜在问题:缓存过多导致内存占用;需手动清理资源(如在 deactivated 中停止监听)。

14、Vue.js 中使用了哪些设计模式?

1. 观察者模式 (Observer Pattern)

  • 描述:Vue 的响应式系统使用观察者模式,通过 Proxy (Vue 3) 或 Object.defineProperty (Vue 2) 拦截对象属性的 get/set 操作。当数据变化时,通知订阅者(Watcher)更新视图。
  • 应用:在 reactive() 函数中,返回 Proxy 对象,get 陷阱用于依赖收集 (track),set 陷阱用于触发更新 (trigger)。
  • 优势:实现了细粒度的变更检测,避免全局重渲染。

2. 发布-订阅模式 (Publish-Subscribe Pattern)

  • 描述:Vue 的事件系统和响应式通知机制采用 Pub-Sub 模式。数据变化时发布事件,订阅者(如组件渲染函数)接收并响应。
  • 应用:在响应式系统中,trigger() 函数检索订阅者效果并调用它们;事件 API 如 emitemit 和 on 也基于此。
  • 优势:解耦了数据生产者和消费者,支持异步更新。

3. 代理模式 (Proxy Pattern)

描述:Vue 3 的响应式系统直接使用 ES6 Proxy 作为代理层,拦截对象操作,实现透明的响应式。 应用:reactive() 返回 Proxy 对象,代理目标对象的访问和修改。 优势:比 Vue 2 的 defineProperty 更强大,支持数组和 Map/Set 等类型。

4. 策略模式 (Strategy Pattern)

  • 描述:Vue 的虚拟 DOM diff 算法使用不同策略(如 key-based diff 或简单 patch)来优化更新。
  • 应用:在渲染过程中,根据节点类型选择 diff 策略。
  • 优势:最小化 DOM 操作,提高渲染效率。

5. 单例模式(Singleton Pattern)

  • 描述:整个程序中有且仅有一个实例。
  • 应用:vuex 的 store 和插件。
  • 优势:全局唯一、节约资源、便于管理。

6. 工厂模式(Factory Pattern)

  • 描述:提供了一种创建对象的方式,使得创建对象的过程与使用对象的过程分离。
  • 应用:Vue2 中的组件创建,传入参数给 createComponentInstance 就可以创建实例。
  • 优势:解藕,易于维护。

15、Vue.js 应用中常见的内存泄漏来源有哪些?

  1. 未清理的事件监听器、定时器、动画
<script setup>
import { onMounted, onUnmounted } from 'vue';

let timer = null;
let controller = null;
let raf = null;

onMounted(() => {
  // 定时器
  timer = setInterval(() => {}, 1000);
  // 动画
  raf = requestAnimationFrame(() => {});
  // 事件监听
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  clearInterval(timer);
  cancelAnimationFrame(this.raf);
  window.removeEventListener('resize', handleResize);
});
</script>
  1. 未移除的第三方库实例
<script setup>
import { onMounted, onUnmounted } from 'vue';

let chart = null;

onMounted(() => {
  chart = echarts.init(this.$refs.chart);
});

onUnmounted(() => {
  chart?.dispose();
});
</script>
  1. 事件总线(Event Bus)未解绑

vue2 用可以用 new Vue 全局创建一个事件总线实例,或者在组件中直接使用 this.$onthis.$emitthis.$off

vue3 则需要借助第三库,比如 mitt 来实现事件总线。

<script setup>
import { onMounted, onUnmounted } from 'vue';
import mitt from 'mitt';

// 创建事件总线实例
const emitter = mitt();

onMounted(() => {
  emitter.on('update', this.handler);
});

onUnmounted(() => {
  emitter.off('update', this.handler);
});
</script>

顺便提一下, vue3 为啥去掉 $on、$emit、$off 这些 API,主要有以下原因:

  1. 设计理念的调整

Vue 3 更加注重组件间通信的明确性和可维护性。$on 这类事件 API 本质上是一种 "发布 - 订阅" 模式,容易导致组件间关系模糊(多个组件可能监听同一个事件,难以追踪事件来源和流向)。Vue 3 推荐使用更明确的通信方式,如:

  • 父子组件通过 props 和 emit 通信
  • 跨组件通信使用 provide/inject 或 Pinia/Vuex 等状态管理库
  • 复杂场景可使用专门的事件总线库(如 mitt
  1. 与 Composition API 的适配

Vue 3 主推的 Composition API 强调逻辑的封装和复用,而 $on 基于选项式 API 的实例方法,与 Composition API 的函数式思维不太契合。移除后,开发者可以更自然地使用响应式变量或第三方事件库来实现类似功能。

  1. 减少潜在问题
  • $on 容易导致内存泄漏(忘记解绑事件)
  • 事件名称可能冲突(全局事件总线尤其明显)
  • 不利于 TypeScript 类型推断,难以实现类型安全

4. 未清理的 Watcher

Vue 本身不会泄漏内存,泄漏几乎都来自开发者未清理的副作用。养成“创建即清理”的习惯,使用 beforeDestroy 或者 onUnmounted 集中清理,在使用 keep-alive 的组件中,视情况在 deactivated 钩子中清理资源。

16、Vue.js 中的性能优化手段有哪些?

16.1 数据相关

  • Vue2 中数据层级不易过深,合理设置响应式数据;
  • Vue2 非响应式数据可以通过 Object.freeze()方法冻结属性;
  • 合理使用 computed,利用其缓存能力提高性能。

16.2 组件相关

  • 控制组件粒度(Vue 采用组件级更新);
  • 合适场景可使用函数式组件(函数式组件开销低);
  • 采用异步组件(借助构建工具的分包的能力,减少主包体积);
  • 在组件卸载或者非激活状态及时清除定时器、DOM事件、事件总线、三方库的实例等。
  • v-on 按需监听、使用动态 watch 和及时销毁 watch。

16.3 渲染相关

  • 合理设置 key 属性;
  • v-show 和 v-if 的选取;
  • 使用防抖、节流、分页、虚拟滚动、时间分片等策略;
  • 合理使用 keep-alive 、v-once、v-memo 进行逻辑优化。

结语

以上是整理的 Vue 高级的高频面试题,如有错误或者可以优化的地方欢迎评论区指正,后续还会更新 Vuex 和 Vue-router 相关面试题。

Ajax 数据请求详解与实战

在现代前端开发中,网页与服务器的数据交互已经成为核心功能之一。
而支撑这一功能的技术之一,正是 Ajax(Asynchronous JavaScript and XML)
今天我们就来系统了解一下 Ajax 的工作原理、请求流程以及一个完整的示例。


一、什么是 Ajax?

Ajax 全称是 异步 JavaScript 和 XML
中文意思是“异步的 JavaScript 与 XML”。

虽然名字里有 XML,但如今开发中我们更多使用 JSON 格式来传输数据。
它最大的特点是:在不刷新页面的情况下与服务器通信,动态更新网页内容。


二、Ajax 的基本工作流程

Ajax 的实现依赖浏览器内置的一个对象:XMLHttpRequest(简称 XHR)。
通过这个对象,我们可以主动发起 HTTP 请求并接收响应。

流程如下:

  1. 创建请求对象

    const xhr = new XMLHttpRequest();
    
  2. 配置请求信息

    xhr.open(method, url, async);
    
    • method:请求方式(如 GETPOST
    • url:目标接口地址
    • async:是否异步(true 为异步,false 为同步)
  3. 发送请求

    xhr.send();
    
  4. 监听请求状态变化

    xhr.onreadystatechange = function() {
        if (xhr.readyState === 4 && xhr.status === 200) {
            const data = JSON.parse(xhr.responseText);
            console.log(data);
        }
    };
    

三、readyState 状态说明

在 Ajax 请求过程中,readyState 表示请求的不同阶段:

状态码 含义 说明
0 初始化 请求未初始化
1 打开 已调用 open(),还未发送
2 发送 已发送请求,接收到响应头
3 接收 正在接收服务器数据
4 完成 请求完成,已接收到全部响应数据

同时要注意:

  • xhr.status 表示 HTTP 响应状态(例如 200 表示成功)。
  • xhr.responseText 是服务器返回的字符串数据。

四、实战示例:请求 GitHub 数据

下面是一个完整的 Ajax 请求示例,用来获取 GitHub 上某组织的成员数据:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Ajax 数据请求</title>
</head>
<body>
    <ul id="members"></ul>

    <script>
        // 1. 创建 XMLHttpRequest 对象
        const xhr = new XMLHttpRequest();

        // 2. 打开请求 (异步)
        xhr.open('GET', 'https://api.github.com/orgs/lemoncode/members', true);

        // 3. 监听状态变化
        xhr.onreadystatechange = function() {
            if (xhr.readyState === 4 && xhr.status === 200) {
                // 4. 解析 JSON 数据
                const data = JSON.parse(xhr.responseText);
                console.log(data);

                // 5. 渲染到页面
                document.getElementById('members').innerHTML =
                    data.map(item => `<li>${item.login}</li>`).join('');
            }
        };

        // 6. 发送请求
        xhr.send();
    </script>
</body>
</html>

执行后,浏览器会在控制台打印出返回的数据,同时在网页中显示成员列表。


五、同步与异步的区别

  • 同步请求(async = false)
    浏览器会等待服务器响应后再执行后续代码,页面会“卡住”。
  • 异步请求(async = true)
    浏览器不会等待,能继续执行后续代码,响应回来后再触发回调函数。
    —— 这正是 Ajax 的核心优势所在。

六、现代替代方案:Fetch 与 Axios

如今,在实际开发中,我们更常用以下方式:

  • Fetch API:更简洁现代的异步请求方式。
fetch('https://api.github.com/orgs/lemoncode/members')
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));

🧩 七、小结

要点 内容
Ajax 全称 异步 JavaScript 和 XML
核心对象 XMLHttpRequest
关键方法 open()send()onreadystatechange
常用属性 readyStatestatusresponseText
主要用途 实现网页的动态数据加载,不刷新页面即可更新内容

✅ 结语

Ajax 是前端与服务器通信的基础技术之一。
理解其底层原理不仅能帮助你更好地使用 fetch
更能让你彻底理解浏览器异步通信机制的本质。

iOS 社招 - Runtime 相关知识点

核心概念 本质:runtime是 oc 的一个运行时库(libobjc.A,dylib),它为 oc 添加了 面向对象的能力 以及 运行时的动态特性。 面向对象的能力:rutime用 C 语言实现了类

前端代码规范体系建设与团队落地实践

一、为什么需要前端代码规范? 在现代前端开发中,代码规范是团队协作的基石。随着项目规模扩大和团队成员增多,统一的代码规范能够带来显著的收益: 核心价值 提升代码可读性:一致的代码风格让团队成员能够快速

如何解析 zip 文件

41b1cd13e28d537170538a42a63d1018.jpg

前言

最近在做 zip 包解析的相关工作,总结 zip 包相关协议以及 jszip 的解析流程是怎么样的。

什么是 zip 文件

zip 文件是一种压缩文件格式,它可以将多个文件压缩成一个文件,从而节省空间。

如何解析

在解析 zip 文件之前,我们在项目中创建了一个 zip 文件,并添加了 1 个 txt 文件。

15.png

本文的 zip 案例文件

读取 zip 文件内容

<body>
    <div>
        <div>上传一个zip文件,并在控制台查看内存存储方式</div>
        <input id="uploadInput" type="file" multiple="">
        <br>
    </div>
    <script>
        const uploadInput = document.getElementById("uploadInput");
        uploadInput.addEventListener(
            "change",
            () => {
                const file = uploadInput.files[0];
                console.log('File 对象,只读不能写:', file)
                const reader = new FileReader();
                reader.onload = function (e) {
                    console.log('Array Buffer对象,不能读写:', e.target.result)
                    const uint8arr = new Uint8Array(e.target.result);
                    console.log('Uint8Array对象,可读写:', uint8arr);
                };
                reader.onerror = function (e) {
                    console.log("error", e.target.error);
                };
                reader.readAsArrayBuffer(file);
            },
            false
        );

    </script>
</body>

image.png

zip 文件协议格式

zip文件官方规范可以看这里。从 官方文档 可以看出,

组成标准zip文件:本地文件头 + 中央目录头 + 中央目录记录结尾

[local file header 1] // 本地文件头
[file data 1] 

[local file header 2] // 本地文件头
[file data 2] 

[local file header 3] // 本地文件头
[file data 3] 

[central directory header 1] // 中央目录头
[central directory header 2] // 中央目录头
[central directory header 3] // 中央目录头

[end of central directory record] // 中央目录记录结尾

本地文件头

本地文件头是 zip 文件的第一个部分,它包含了文件的名称、大小、压缩方式等信息。其格式如下:

local file header signature     4 bytes  (0x04034b50) // 本地文件头签名
version needed to extract       2 bytes // 版本需要提取
general purpose bit flag        2 bytes // 通用目的位标志
compression method              2 bytes // 压缩方法
last mod file time              2 bytes // 最后修改文件时间
last mod file date              2 bytes // 最后修改文件日期
crc-32                          4 bytes // CRC-32
compressed size                 4 bytes // 压缩大小
uncompressed size               4 bytes // 未压缩大小
file name length                2 bytes // 文件名称长度
extra field length              2 bytes // 额外字段长度
file name (variable size) // 文件名称 (可变大小)
extra field (variable size) // 额外字段 (可变大小)

以当前的 zip 文件为例,我们可以看到本地文件头的内容如下:

local file header signature: 50 4B 03 04 // 本地文件头签名,因为是小端模式,也就是 16 进制的 0x04034b50
Version needed to extract: 0A 00 // 10
general purpose bit flag:  00 00 // 通用目的位标志
compression method: 00 00 // 压缩方法
last mod file time: 1B 7C // 最后修改文件时间
last mod file date: 4F 5B // 最后修改文件日期
crc-32: 52 9E D6 8B // CRC-32
compressed size: 0B 00 00 00 // 压缩大小
uncompressed size: 0B 00 00 00 // 未压缩大小
file name length: 09 00 // 文件名称长度
extra field length: 1C 00 // 额外字段长度
file name: 68 65 6C 6C 6F 2E 74 78 74 // 文件名称
extra field: 00 00 00 00 // 额外字段 这里可以根据 extra field length 的长度获取

// file Data.. 

中央目录头

central file header signature   4 bytes  (0x02014b50)
version made by                 2 bytes
version needed to extract       2 bytes
general purpose bit flag        2 bytes
compression method              2 bytes
last mod file time              2 bytes
last mod file date              2 bytes
crc-32                          4 bytes
compressed size                 4 bytes
uncompressed size               4 bytes
file name length                2 bytes
extra field length              2 bytes
file comment length             2 bytes
disk number start               2 bytes
internal file attributes        2 bytes
external file attributes        4 bytes
relative offset of local header 4 bytes

file name (variable size)
extra field (variable size)
file comment (variable size)

大部分字段本地文件头协议差不多,需要留意的是 relative offset of local header 字段,他标识了本地文件头的所在位置。

以当前的 zip 文件为例,我们可以看到 中央目录头 的内容如下:

central file header signature: 50 4B 01 02 // 中央目录头标识
version made by: 1E 03
version needed to extract: 0A 00
general purpose bit flag:  00 00 
compression method: 00
last mod file time: 1B 7C
last mod file date: 4F 5B
crc-32            : 52 9E D6 8B 
compressed size   : 0B 00 00 00 
uncompressed size : 0B 00 00 00
file name length  : 09 00 
extra field length: 18 00
file comment length : 00 00
disk number start   : 00 00
internal file attributes        01 00
external file attributes        00 00 A4 81
relative offset of local header 00 00 00 00
file name (variable size): 68 65 6C 6C 6F 2E 74 78 74
extra field: 55 54 05 00 03 26 4E EF 68 75 78 0B 00 01 04 F5 01 00 00 04 14 00 00 // 这里是根据 extra field length 的长度来获取的。

中央目录记录结尾

end of central dir signature    4 bytes  (0x06054b50)
number of this disk             2 bytes
number of the disk with the start of the central directory  2 bytes
total number of entries in the central directory on this disk  2 bytes
total number of entries in the central directory           2 bytes
size of the central directory   4 bytes
offset of start of central directory with respect to the starting disk number        4 bytes
.ZIP file comment length        2 bytes
.ZIP file comment       (variable size)

以当前的 zip 文件为例,我们可以看到 中央目录记录结尾 的内容如下:

end of central dir signature: 50 4B 05 06 
number of this disk: 00 00
number of the disk with the start of the central directory: 00 00
total number of entries in the central directory on this disk: 01 00 // 存储的是文件总数
total number of entries in the central directory: 01 00 // 存储的是文件总数
size of the central directory : 4F 00 00 00 // 中央目录区占据的字节大小。
offset of start of central directory with respect to the starting disk number: 4E 00 00 00 // 中央目录区开始的位置。
.ZIP file comment length: 00 00

综上,我们大概了解了 zip 包协议的内容,并根据协议的内容读取了 案例文件 对应数据。

接下来,我们看看 JSzip 是怎么 “读懂” 这段数据的。

JSZip 是如何解析的?

依旧以 当前的案例文件 为准。

JSZip 内部流程大致是这样👇

1. 定位 “50 4B 05 06”,找到 中央目录记录结尾 的位置

JSZip 先从文件尾部向前扫描,寻找 0x06054B50(50 4B 05 06),这是 End of Central Directory Record(中央目录的结束标志)。

17.png

找到之后,JSZip 会根据 offset of start of central directory with respect to the starting disk number 字段,找到中央目录区的开始位置。

const offset = centralDirectoryHeader.offsetOfStartOfCentralDirectoryWithRespectToTheStartingDiskNumber;

2. 找到 中央目录区的开始位置 + 找到 中央目录区的结束位置, 锁定中央目录区

找到中央目录区的开始位置后,JSZip 会根据 total number of entries in the central directory 字段,找到中央目录区的结束位置。

const end = centralDirectoryHeader.offsetOfStartOfCentralDirectoryWithRespectToTheStartingDiskNumber + centralDirectoryHeader.sizeOfTheCentralDirectory;

找到中央目录区的结束位置后,JSZip 会根据 central directory header 字段,找到中央目录区的内容。

3. 遍历中央目录区,找到每个文件的本地文件头

找到每个文件的本地文件头后,JSZip 会根据 local file header 字段,找到每个文件的本地文件头的内容。

18.png

这就是 JSZip 自底向上的解析流程。

面试必考:从setTimeout到Promise和fetch

前言

JavaScript是一种单线程语言,这意味着它一次只能执行一个任务。这种设计简化了编程模型,但也带来了挑战:如何处理耗时操作而不阻塞主线程?

一、 异步编程基础

1.同步 vs 异步执行

// 同步代码示例
console.log(1);
console.log(2);
console.log(3);
// 输出顺序:1 2 3

2.异步代码示例

console.log(1);
setTimeout(function() {
  console.log(2);
}, 1000);
console.log(3);
// 输出顺序:1 3 2(1秒后)

其中setTimeout是一个延迟函数,设定在一秒后执行,所以改变了原有的代码执行顺序

3.事件循环机制

JavaScript通过事件循环机制处理异步操作:

  1. 同步代码立即执行
  2. 异步代码被放入事件队列
  3. 当调用栈为空时,事件队列中的任务按顺序执行

二、Promise:异步编程的解决方案

1.Promise基本用法

const promise = new Promise((resolve, reject) => {
  // 异步操作代码
  // 成功时调用 resolve(value)
  // 失败时调用 reject(error)
});

2. 示例

console.log(1);
const p = new Promise((resolve) => {
  setTimeout(function() {
    console.log(2);
    resolve();
  }, 3000);
});
p.then(() => {
  console.log(3);
});
console.log(4);
// 输出顺序:1 4 2 3

3.Promise处理文件读取

import fs from 'fs';

console.log(1);

const p = new Promise((resolve, reject) => {
  console.log(3); // 同步执行
  
  fs.readFile('./a.txt', function(err, data) {
    if (err) {
      reject(err);
      return;
    }
    resolve(data.toString());
  });
});

p.then(data => {
  console.log(data);
}).catch(err => {
  console.log(err);
});

console.log(4);
// 输出顺序:1 3 4 [文件内容]

注意:被Promise实例包裹的同步代码并不会延时触发,而是正常按照顺序执行

三、fetch API:网络请求

// 基本用法
fetch("https://api.github.com/orgs/lemoncode/members")
  .then((response) => response.json())
  .then((data) => {
    // 处理数据
    console.log(data);
  })
  .catch((error) => {
    console.error('Error:', error);
  });

实际应用示例

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>GitHub Members</title>
</head>
<body>
  <ul id="members"></ul>
  <script>
    fetch("https://api.github.com/orgs/lemoncode/members")
      .then((response) => response.json())
      .then((members) => {
        const membersList = document.getElementById("members");
        membersList.innerHTML = members
          .map((member) => {
            return `<li>${member.login}</li>`;
          })
          .join("");
      })
      .catch((error) => {
        console.error('Error fetching members:', error);
      });
  </script>
</body>
</html>

异步编程最佳实践

1. 错误处理

// 使用catch处理Promise错误
someAsyncFunction()
  .then(result => {
    // 处理成功结果
  })
  .catch(error => {
    // 处理错误
    console.error('An error occurred:', error);
  });

2. 并行执行多个异步操作

// 使用Promise.all同时执行多个异步操作
Promise.all([
  fetch('/api/users'),
  fetch('/api/posts'),
  fetch('/api/comments')
])
.then(([users, posts, comments]) => {
  // 所有请求都完成后执行
})
.catch(error => {
  // 任何一个请求失败都会进入这里
});

3. async/await语法糖

// 使用async/await使异步代码更易读
async function fetchData() {
  try {
    const response = await fetch('/api/data');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Error fetching data:', error);
  }
}

总结

JavaScript的异步编程模型虽然初看起来复杂,但理解其核心概念后,可以编写出高效、非阻塞的代码。关键要点包括:

  • JavaScript是单线程的,使用事件循环处理异步操作
  • setTimeout是基础的异步机制
  • Promise提供了更强大的异步控制能力
  • fetch是处理网络请求的现代API
  • async/await语法让异步代码更易读

掌握这些概念和工具,将帮助你编写更高效、更可靠的JavaScript应用程序。

Vue 项目上线前必查!8 个易忽略知识点,90% 开发者都踩过坑

Vue 项目上线前必查!8 个易忽略知识点,90% 开发者都踩过坑

最近最近接手了一个朋友的 Vue3 项目,改 bug 改到怀疑人生 —— 明明语法看着没毛病,页面就是不更新;父子组件传值偶尔失效;打包后样式突然错乱… 排查后发现全是些 “不起眼” 的知识点在作祟。

这些知识点不像响应式、生命周期那样被反复强调,却偏偏是面试高频考点和项目线上问题的重灾区。今天就带大家逐个拆解,每个都附代码示例和避坑方案,新手能避坑,老手能查漏,建议收藏备用!🚀

1. Scoped 样式的 “隐形泄露”,父子组件样式串味了

写组件时大家都习惯加scoped让样式局部化,但你可能遇到过:父组件的样式莫名其妙影响了子组件?这可不是 Vue 的 bug。

隐藏陷阱

Vue 为scoped样式的元素添加独特属性(如data-v-xxx)来隔离样式,但子组件的根节点会同时继承父组件和自身的 scoped 样式。比如这样的代码:

vue

<!-- 父组件 App.vue -->
<template>
  <h4>父组件标题</h4>
  <HelloWorld />
</template>
<style scoped>
h4 { color: red; }
</style>

<!-- 子组件 HelloWorld.vue -->
<template>
  <h4>子组件标题</h4> <!-- 会被父组件的red样式影响 -->
</template>
<style scoped></style>

最终子组件的 h4 也会变成红色,很多人第一次遇到都会懵圈。

避坑方案

  1. 给子组件根元素加唯一 class,避免标签选择器冲突

    vue

    <!-- 优化后 HelloWorld.vue -->
    <template>
      <div class="hello-world">
        <h4>子组件标题</h4>
      </div>
    </template>
    
  2. Vue3 支持多根节点,直接用多个根元素打破继承链

  3. 尽量用 class 选择器替代标签选择器,减少冲突概率

2. 数组 / 对象响应式失效?别再直接改索引了

这是 Vue 响应式系统的经典 “坑”,Vue3 用 Proxy 优化了不少,但某些场景依然会踩雷。

隐藏陷阱

Vue 的响应式依赖数据劫持实现,但以下两种操作无法被监听:

  1. 给对象新增未声明的属性
  2. 直接修改数组索引或长度

vue

<template>
  <div>{{ user.age }}</div>
  <div>{{ list[0] }}</div>
  <button @click="modifyData">修改数据</button>
</template>
<script setup>
import { reactive } from 'vue'
const user = reactive({ name: '张三' })
const list = reactive(['苹果'])

const modifyData = () => {
  user.age = 25 // 新增属性,页面不更新
  list[0] = '香蕉' // 直接改索引,页面不更新
}
</script>

点击按钮后,数据确实变了,但页面纹丝不动。

避坑方案

针对不同数据类型用正确姿势修改:

vue

<script setup>
import { reactive } from 'vue'
const user = reactive({ name: '张三' })
const list = reactive(['苹果'])

const modifyData = () => {
  // 对象新增属性:直接赋值即可(Vue3 Proxy支持)
  user.age = 25 
  // 数组修改:用splice或替换数组
  list.splice(0, 1, '香蕉') 
  // 也可直接替换整个数组
  // list = ['香蕉', '橙子']
}
</script>

小贴士:Vue2 中需用this.$set(user, 'age', 25),Vue3 的 Proxy 无需额外 API,但修改数组索引仍需用数组方法。

3. setup 里的异步请求,别漏了 Suspense 配合

Vue3 的 Composition API 是趋势,但很多人在 setup 里写异步请求时,遇到过数据渲染延迟或报错的问题。

隐藏陷阱

setup 函数执行时组件还未挂载,若直接在 setup 中写 async/await,返回的 Promise 会导致组件渲染异常,因为 setup 本身不支持直接返回 Promise。

vue

<!-- 错误示例 -->
<script setup>
import axios from 'axios'
const data = ref(null)

// 直接用await会导致组件初始化异常
const res = await axios.get('/api/list') 
data.value = res.data
</script>

避坑方案

用 Vue3 内置的<Suspense>组件包裹异步组件,搭配异步 setup 使用:

vue

<!-- 父组件 -->
<template>
  <Suspense>
    <template #default>
      <DataList />
    </template>
    <template #fallback>
      <div>加载中...</div> <!-- 加载占位 -->
    </template>
  </Suspense>
</template>

<!-- DataList.vue 异步组件 -->
<script setup>
import { ref } from 'vue'
import axios from 'axios'
const data = ref(null)

// setup可以写成async函数
const fetchData = async () => {
  const res = await axios.get('/api/list')
  data.value = res.data
}
fetchData()
</script>

这样既能正常发起异步请求,又能优雅处理加载状态,提升用户体验。

4. 非 props 属性 “悄悄继承”,DOM 多了莫名属性

给组件传了没在 props 中声明的属性(如 id、class),结果发现子组件根元素自动多了这些属性,有时会导致样式或功能冲突。

隐藏陷阱

这是 Vue 的非 props 属性继承特性,像 id、class、name 这类未被 props 接收的属性,会默认挂载到子组件的根元素上。比如:

vue

<!-- 父组件 -->
<template>
  <UserCard id="user-card" class="card-style" />
</template>

<!-- 子组件 UserCard.vue 未声明对应props -->
<template>
  <div>用户信息卡片</div> <!-- 最终会被渲染为<div id="user-card" class="card-style"> -->
</template>

若子组件根元素已有 class,会和继承的 class 合并,有时会覆盖预期样式。

避坑方案

  1. 禁止继承:用inheritAttrs: false关闭自动继承

    vue

    <script setup>
    // 关闭非props属性继承
    defineOptions({ inheritAttrs: false }) 
    </script>
    
  2. 手动控制属性位置:用$attrs将属性挂载到指定元素

    vue

    <template>
      <div>
        <div v-bind="$attrs">只给这个元素加继承属性</div>
      </div>
    </template>
    

5. 生命周期的 “顺序陷阱”,父子组件执行顺序搞反了

Vue2 升级 Vue3 后,生命周期不仅改了命名,父子组件的执行顺序也有差异,这是面试高频题,也是项目中异步逻辑出错的常见原因。

隐藏陷阱

很多人仍沿用 Vue2 的思维写 Vue3 代码,比如认为父组件的onMounted会比子组件先执行,结果 DOM 操作时报错。

阶段 Vue2 执行顺序 Vue3 执行顺序
初始化 父 beforeCreate→父 created→父 beforeMount→子 beforeCreate→子 created→子 beforeMount→子 mounted→父 mounted 父 setup→父 onBeforeMount→子 setup→子 onBeforeMount→子 onMounted→父 onMounted

避坑方案

  1. 数据初始化:Vue3 可在 setup 中直接用 async/await 发起请求,配合 Suspense

  2. DOM 操作:务必在onMounted中执行,且要清楚子组件的 mounted 会比父组件先触发

  3. 清理工作:定时器、事件监听一定要在onBeforeUnmount中清除,避免内存泄漏

    vue

    <script setup>
    import { onMounted, onBeforeUnmount } from 'vue'
    let timer = null
    onMounted(() => {
      timer = setInterval(() => {
        console.log('定时器运行中')
      }, 1000)
    })
    // 组件卸载前清除定时器
    onBeforeUnmount(() => {
      clearInterval(timer)
    })
    </script>
    

6. CSS 中用 v-bind,动态样式的正确打开方式

Vue3.2 + 支持在 CSS 中直接用 v-bind 绑定数据,这个特性很实用,但很多人不知道它的底层逻辑和使用限制。

隐藏陷阱

直接在 CSS 中绑定计算属性时,误以为修改数据后样式不会实时更新,或者担心影响性能。

vue

<template>
  <div class="text">动态颜色文本</div>
  <button @click="changeColor">切换颜色</button>
</template>
<script setup>
import { ref, computed } from 'vue'
const primaryColor = ref('red')
const textColor = computed(() => primaryColor.value)
const changeColor = () => {
  primaryColor.value = primaryColor.value === 'red' ? 'blue' : 'red'
}
</script>
<style>
.text {
  color: v-bind(textColor);
}
</style>

避坑方案

  1. 无需担心性能:v-bind 会被编译成 CSS 自定义属性,通过内联样式应用到组件,数据变更时仅更新自定义属性
  2. 支持多种数据类型:可绑定 ref、reactive、computed,甚至是 props 传递的值
  3. 与 scoped 兼容:动态样式同样支持局部作用域,不会污染全局

7. ref 获取元素,别在 onMounted 前急着用

用 ref 获取 DOM 元素是基础操作,但新手常犯的错是在 DOM 未挂载完成时就调用元素方法。

隐藏陷阱

setuponBeforeMount中获取 ref,结果拿到undefined

vue

<template>
  <input ref="inputRef" type="text" />
</template>
<script setup>
import { ref, onBeforeMount } from 'vue'
const inputRef = ref(null)

onBeforeMount(() => {
  inputRef.value.focus() // 报错:Cannot read property 'focus' of null
})
</script>

避坑方案

  1. 基础用法:在onMounted中操作 ref 元素,此时 DOM 已完全挂载

    vue

    <script setup>
    import { ref, onMounted } from 'vue'
    const inputRef = ref(null)
    
    onMounted(() => {
      inputRef.value.focus() // 正常生效
    })
    </script>
    
  2. 动态元素:若 ref 绑定在 v-for 渲染的元素上,inputRef 会变成数组,需通过索引访问

  3. 组件 ref:获取子组件实例时,子组件需用defineExpose暴露属性和方法

8. watch 监听数组 / 对象,深度监听别写错了

watch 是 Vue 中处理响应式数据变化的核心 API,但监听复杂数据类型时,很容易出现 “监听不到变化” 的问题。

隐藏陷阱

直接监听数组或对象时,默认只监听引用变化,对内部属性的修改无法触发监听。

vue

<script setup>
import { ref, watch } from 'vue'
const user = ref({ name: '张三', age: 20 })

// 错误:监听不到age的变化
watch(user, (newVal) => {
  console.log('用户信息变了', newVal)
})

const changeAge = () => {
  user.value.age = 25 // 仅修改内部属性,不触发监听
}
</script>

避坑方案

根据 Vue 版本选择正确的监听方式:

  1. Vue3 监听 ref 包裹的对象:开启深度监听

    vue

    watch(user, (newVal) => {
      console.log('用户信息变了', newVal)
    }, { deep: true }) // 开启深度监听
    
  2. 精准监听单个属性:用函数返回值的方式,性能更优

    vue

    // 只监听age变化,无需深度监听
    watch(() => user.value.age, (newAge) => {
      console.log('年龄变了', newAge)
    })
    

最后总结

Vue 这些易忽略的知识点,本质上都是对底层原理理解不透彻导致的。很多时候我们只顾着实现功能,却忽略了这些细节,等到项目上线出现 bug 才追悔莫及。

以上 8 个知识点,建议结合代码逐个实操验证。如果本文帮你避开了坑,欢迎点赞收藏,也可以在评论区分享你踩过的 Vue 神坑,一起避雷成长!💪

删一个却少俩:Antd Tag 多节点同时消失的原因

删一个却少俩:Antd Tag 多节点同时消失的原因

需求

image.png

一个表单的小需求,能填写多个福利,最多十个,福利名称允许重复,和官方的动态添加和删除示例交互一模一样,只是官方示例不支持 tag 内容重复,使用的 tag 内容作为 key

我复制丢给 AI,下掉去重,限制个数,好!满足需求了,key 值怎么办不能用重复的,拼个索引吧,最后主要代码如下,

image.png

反问一下:你觉得这会有什么问题,能达到删一个少俩的效果吗🤔???

问题

image.png

大家应该都知道用 index 作为 key,会有一些问题,对于我这个需求的影响是,点击第一个福利删除操作,实际移除的是最后一个福利的节点,我觉得在这个需求是可以接受的,在没有动画前提下也体感不到,所以我并没有改这个代码。

image.png

但是实际测试的效果的是,最后一个福利的节点没了,第一个福利节点也没了,我!!!

这啥情况,这不对呀,根据 React Diff 算法的原理吧啦吧啦...他不应该是这样然后...他咋能...

原因

image.png

开始 debug,打开控制台 command + shif + p,搜索并进入 react-dom.development.js 文件,搜索 reconcileChildrenArray 方法,打上断点

image.png

React Diff 的处理也没问题呀,被标记删除的是最后一个节点,第一个节点也被正确复用了,什么情况?难道后面的代码出了问题,我就讲断点就放到了 commitRoot 方法,先看了 root.finishedWork 发现也是正确的,后面继续执行了几下,想找出问题出在哪...就突然觉的这个福利节点是不是真没被删除,但是被隐藏掉了,我就切换到了元素控制台,还真是我去!!!这为啥多了个 ant-tag-hidden 的类

image.png

看了下 Antd Tag 代码,还真有这个设计,给被移除项加 ant-tag-hidden,加上索引 key 复用导致出现“多节点同时消失”

image.png

❌