普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月2日技术

前端下午茶!看看炫酷的动画,轻松一下!

作者 孟祥_成都
2025年12月2日 14:28

前言

之前说了会更新 gsap 动画教程,我们先来个开胃菜,看看最近练习的 demo 是否有同学愿意一起学习和交流。

既然你都点进来看帖子了,来都来了,留下来喝个小茶,看个小动画再走呗!

视频滚动动画

视频被切为了动画帧,随着滚动鼠标滚动或者触控板滑动而不断播放视频。

video1.gif

以上效果在线地址 - 国内

github 源码地址(感谢给个 star)

图片滚动动画

图片随着滚动鼠标滚动或者触控板滑动而不断变化,第二屏会有一定的视差滚动的效果。

主要涉及 clip-path 属性的变化。

clip-path1.gif

以上效果在线地址 - 国内

github 源码地址(感谢给个 star)

clip-path2.gif

以上效果在线地址 - 国内

github 源码地址(感谢给个 star)

transform 动画

图片随着滚动鼠标滚动或者触控板滑动而不断变化,主要是 transform 属性上的变化。

transform1.gif

以上效果在线地址 - 国内

github 源码地址(感谢给个 star)

transform2.gif

以上效果在线地址 - 国内

github 源码地址(感谢给个 star)

欢迎加入交流群

欢迎加入交流群一起进步!

不仅免费,还开源?这个 AI Mock 神器我必须曝光它

2025年12月2日 13:55

前两周我做了一个零侵入的接口 Mock 插件。还写了篇掘金文章记录了一下:juejin.cn/post/757098…

此前用 Popup 弹窗做联调,窗小、易误关、操作繁琐;

于是我重构为 Sidebar 常驻侧栏:规则随时可见,刷新也不丢。

  • 换成 Sidebar 侧边栏:钉在页面右边,想开就开,数据都存着,再也不怕手抖关掉。即使刷新页面,规则也不会丢,重新打开就能继续用

  • 接入 AI 自动生成:描述你想要的数据,或者直接贴接口的 TypeScript 类型,AI 秒出结构化数据。手写?不存在的

  • 支持延时和状态码:可以模拟慢接口(延时 3 秒)、接口报错(返回 500)等真实场景

现在用起来爽多了,前端开发不用干等后端,自己就能把页面跑起来。

下文附安装与使用指南,欢迎试用与反馈

如果你现在还在用 Popup 弹窗做 Mock,或者还在一个字一个字敲 JSON, 真心建议试试 Sidebar + AI 这套,用完你就知道什么叫"真香"。

演示效果

mock插件演示.gif

插件功能预览

  • 拦截能力:覆盖 fetch 与 XMLHttpRequest ,规则实时生效,零侵入
  • 匹配规则:支持模糊匹配和完全匹配、正则表达式
  • 响应控制:支持按 HTTP 方法过滤(GET/POST/PUT/DELETE)
  • 异常模拟:支持模拟200/400/401/403/500等状态码
  • AI 生成:输入描述或贴 TS 类型,秒出结构化数据,支持模板与一键覆写
  • 持续优化中,欢迎反馈与建议
  • 建设中: 调试与管理:导入导出、规则分组、Network 面板标识等;性能与匹配体验持续优化,欢迎反馈

什么时候你会需要它?

  • 后端还在写接口:你不用干等,Mock 一下先把前端页面撸出来
  • 测试加载状态:设置延时 2 秒,看看你的 Loading 动画、骨架屏是不是真的在转
  • 测试异常处理:一键切换 200/400/401/403/500,看看你的错误提示是否友好
  • 测试防重复提交:延时 3 秒 + 返回失败,看看用户会不会疯狂点击
  • 给老板/客户演示:不用搭环境、不用连服务器,打开浏览器就能演示

注:支持拦截ajax和fetch请求


🚀 快速开始

获取代码

打开代码仓库,按以下方操作下载插件包。安装包为 zip,请先解压。

下载与解压

  • 在仓库的 Releases 或打包入口下载插件包(zip)。
  • 将 zip 解压到任意目录,得到 AI-Mock-v1.0.0 文件夹。

参考示意图:

下载安装包.gif

一键安装

  1. 打开Chrome浏览器 3.. 地址栏输入:chrome://extensions/
  2. 打开右上角"开发者模式"
  3. 点击"加载已解压的扩展程序"
  4. 选择刚刚解压的AI-Mock-v1.0.0文件夹
  5. 搞定!扩展安装完成!

参考示意图:

安装ai-mock.gif

gitee 仓库链接的同理,在仓库页点击“发行版(Release)”, 进入最新版本页面,下载插件包即可

AI 前置准备

  • 使用 AI 生成前需配置 DeepSeek API Key。为便于体验,提供一个限额试用 Key;额度用尽或失效后,请前往 DeepSeek 官网申请个人 Key 并在侧栏替换
  • 试用 Key(仅用于体验,随时可能失效): sk-9fa67c84581d4f67b61039ff8b199baa

示例:

配置key.gif

DeepSeek 官网申请key示例:

设计与实现

1. Sidebar 侧边栏 vs popup弹窗

特性 Popup Sidebar
显示空间 小(~400x600px) 大(可调整宽度)
操作便捷性 点击外部就关闭 固定显示,不怕误触
与页面交互 互斥 可同时显示
数据持久化 关闭即丢失 刷新页面也保留

2. AI 生成 Mock 数据

这块就两件事:

  • 你给一个 URL,我把它转成正则,用来拦截这个接口
  • 你给数据类型或者一句话描述,AI 直接把 Mock 数据“现做现给”

1. 根据描述输出 Mock 数据

  • 只要一句话就够:
    • 输入:用户列表,包含姓名、年龄、邮箱,来 10 条

效果图:
根据描述输出 Mock 数据.gif

2. 根据类型输出 Mock 数据

  • 或者直接贴 数据的类型:
interface User {
  id: number;
  name: string;
  age: number;
  email: string;
}

效果图:

根据类型输出 Mock 数据.gif

3. 根据输入的地址生成正则表达式

目的:把带占位符的路径,转成更稳的正则,方便在开发态拦截请求

常用示例:

  • 输入: /api/users/:id
  • 正则: ^/api/users/[^/]+/?$
  • 可拦截: /api/users/1 、 /api/users/abc

效果图:

ai生成正则表达式.gif

修改 Mock 数据

  • 这块很直观,给你三种编辑入口,改完就是生效:

    • 规则编辑里改:打开“编辑规则”,在 Mock 数据 (JSON) 文本框里直接改,支持“格式化/验证/复制/清空”,点“保存”后立即应用
  • 放大弹窗改:点击“放大”,全屏 JSON 编辑器更适合改大结构或深层嵌套,改完“验证”一下再“保存”

  • 卡片内联改:在规则卡片下方的“Mock 响应”树视图,鼠标双击某个值就能直接编辑,编辑完进行回车保存。这个适合改小字段(比如把 data.hits.total 从 1 改成 20 )

效果图:

json数据修改方式.gif

同步规则

  • 三个地方改的都是同一份数据,任意入口保存后,其他入口会显示最新值
  • 修改仅作用于“当前这条规则”,不会影响其他规则或接口

推荐使用场景

  • 小改动(单字段/少量数值):用“卡片内联”最快
  • 中等改动(几个字段/一层结构):在“编辑规则”里改,配合“格式化/验证”
  • 大改动(数组长度、层级结构、批量替换):用“放大弹窗”编辑 JSON,更清晰、更不容易漏

操作建议

  • 先“验证”再“保存”:能快速提示 JSON 语法、字段类型不匹配等问题
  • 用“格式化”提升可读性,避免缩进错乱
  • 需要回到初始或重置时,用“清空”或点击“生成”重新出一版数据
  • 要分享或备份当前数据,用“复制”一键拷贝

4. 延时请求 - 测试加载状态

设置延时 3 秒,模拟慢接口:

  • 测试 Loading 动画是否正常
  • 测试骨架屏效果
  • 测试用户会不会重复点击
  • 测试超时逻辑

演示效果图:

延迟效果.gif

4. 状态码 - 测试异常场景

一键切换不同状态码:

  • 200:测试成功流程
  • 400:测试参数校验
  • 401:测试登录跳转
  • 403:测试权限提示
  • 500:测试错误兜底

效果图:

状态码修改.gif


核心实现

1. Manifest V3:从 Popup 到 Sidebar

配置文件:

{
  "manifest_version": 3,
  "name": "AI Mock",
  "version": "2.0.0",
  "side_panel": {
    "default_path": "sidepanel.html"
  },
  "permissions": [
    "sidePanel",
    "storage",
    "scripting"
  ],
  "host_permissions": [
    "<all_urls>"
  ]
}

打开侧边栏:

// background.ts
chrome.action.onClicked.addListener(async (tab) => {
  await chrome.sidePanel.open({ windowId: tab.windowId });
});

关键区别:

  • Popup:窗口小、点击外部就关闭、关闭即销毁
  • Sidebar:空间大、可固定显示、数据持久化

2. 拦截 fetch / XHR(支持延时和状态码)

劫持实现原理参考我上篇文章:【前端效率工具】再也不用 APIfox 联调!零侵入 Mock,全程不改代码、不开代理juejin.cn/post/757098…

核心代码:

injected.ts

const originalFetch = window.fetch;

window.fetch = async function(input, init) {
  const url = typeof input === 'string' ? input : input.url;
  const method = init?.method || 'GET';
  
  // 查找匹配的规则
  const rule = await findMatchedRule(url, method);
  
  if (rule) { 
    //  延时处理
    if (rule.delay > 0) {
      await new Promise(resolve => setTimeout(resolve, rule.delay));
    }
    
    // 返回指定状态码
    return new Response(
      JSON.stringify(rule.data),
      {
        status: rule.statusCode || 200,
        statusText: getStatusText(rule.statusCode),
        headers: { 'Content-Type': 'application/json' }
      }
    );
  }
  
  // 不拦截,调用原始 fetch
  return originalFetch.apply(this, arguments as any);
};

// 状态码文本映射
function getStatusText(code: number): string {
  const statusTexts: Record<number, string> = {
    200: 'OK',
    400: 'Bad Request',
    401: 'Unauthorized',
    403: 'Forbidden',
    404: 'Not Found',
    500: 'Internal Server Error',
    502: 'Bad Gateway',
    503: 'Service Unavailable',
    504: 'Gateway Timeout'
  };
  return statusTexts[code] || 'Unknown';
}

匹配规则:

async function findMatchedRule(url: string, method: string) {
  // 从 storage 获取规则
  const { rules } = await chrome.storage.local.get('rules');
  
  return rules.find((rule: Rule) => {
    if (!rule.enabled) return false;
    
    // 方法匹配
    if (rule.method !== 'ALL' && rule.method !== method) {
      return false;
    }
    
    // URL 匹配
    if (rule.matchMode === 'contains') {
      return url.includes(rule.url);
    } else if (rule.matchMode === 'exact') {
      return url === rule.url;
    } else if (rule.matchMode === 'regex') {
      return new RegExp(rule.url).test(url);
    }
    
    return false;
  });
}

支持的匹配模式:

  • 包含匹配/api/user 可以匹配 /api/user/list/api/user/detail
  • 完整匹配:必须完全一致
  • 正则匹配/api/user/\d+ 可以匹配 /api/user/123

3. AI 生成 Mock 数据

Prompt 设计:

const buildPrompt = (userInput: string) => {
  return `
你是一个专业的 Mock 数据生成助手。

用户需求:${userInput}

请生成符合以下规范的 JSON 数据:
1. 必须是有效的 JSON 格式
2. 包含常见的响应结构(code, msg, data)
3. 数据要真实合理(中文姓名、真实邮箱格式等)
4. 如果是列表,生成 2-3 条示例数据

只返回 JSON,不要有其他说明文字。
  `.trim();
};

生成逻辑:

const generateMockData = async () => {
  if (!aiPrompt.value) {
    ElMessage.warning('请输入数据描述');
    return;
  }

  generating.value = true;

  try {
    const response = await fetch('YOUR_AI_API_ENDPOINT', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        prompt: buildPrompt(aiPrompt.value),
        model: 'gpt-3.5-turbo'
      })
    });

    const data = await response.json();
    
    // 解析 AI 返回的 JSON
    const mockData = JSON.parse(data.choices[0].message.content);
    
    // 填充到编辑器
    form.data = JSON.stringify(mockData, null, 2);
    
    ElMessage.success('✨ AI 生成成功');
  } catch (error) {
    ElMessage.error('生成失败:' + error.message);
  } finally {
    generating.value = false;
  }
};

4. 规则数据结构

interface Rule {
  id: string;
  remark: string;              // 规则名称
  url: string;                 // URL 匹配
  matchMode: 'contains' | 'exact' | 'regex';
  method: 'ALL' | 'GET' | 'POST' | 'PUT' | 'DELETE';
  statusCode: number;          // 🆕 状态码(默认 200)
  delay: number;               // 🆕 延时(毫秒,默认 0)
  data: Record<string, any>;   // Mock 数据
  enabled: boolean;            // 是否启用
  createdAt: number;           // 创建时间
}

存储和读取:

// 保存规则
const saveRules = async () => {
  await chrome.storage.local.set({ rules: rules.value });
};

// 加载规则
const loadRules = async () => {
  const result = await chrome.storage.local.get('rules');
  rules.value = result.rules || [];
};

// 监听变化
chrome.storage.onChanged.addListener((changes, areaName) => {
  if (areaName === 'local' && changes.rules) {
    rules.value = changes.rules.newValue;
  }
});

总结

这次重构主要做了四件事:

  1. Sidebar 替代 Popup:更大的空间、更好的体验
  2. AI 自动生成:一句话描述即可生成 Mock 数据
  3. 延时请求:测试 Loading、骨架屏、超时逻辑
  4. 自定义状态码:测试各种异常场景

这四个功能组合起来,基本覆盖了前端开发中 90% 的 Mock 需求。

如果你也在等后端接口,或者被 Popup 小窗口折磨过, 真的可以试试这个插件,保证你用了就回不去了。

unipush推送入门:10分钟搞定UniPush在线消息集成

作者 扑棱蛾子
2025年12月2日 13:46

引言

消息推送是提升 App 用户活跃度和留存率的关键能力。对于采用 UniApp 开发的跨端应用,DCloud 官方提供的 UniPush 服务无疑是集成推送功能最高效选择。

本文将以一名开发者的视角,手把手带你完成 从项目初始化到成功接收第一条推送消息 的完整流程。你将学会:

  1. UniPush 的正确接入姿势与核心配置
  2. 开发阶段必须使用的自定义调试基座
  3. 如何获取设备标识(CID)并进行在线推送测试
  4. 避开新手常见“坑点”的实用技巧

技术栈:UniApp + UniPush(华为、小米等厂商推送配置将在下篇介绍)


一、 开发环境与项目初始化

1.1 项目创建与模块配置

  1. 项目创建:在 HBuilderX 中新建一个 UniApp 项目(过程略)。
  2. 启用 UniPush:打开项目根目录的 manifest.json 文件,进入  “App模块配置”  选项卡,勾选  “Push(消息推送)”  模块,并确保选中  “UniPush”  作为具体服务。
    image.png

1.2 制作自定义调试基座

为什么必须使用自定义基座?
UniApp 的标准运行基座(标准真机运行包)所使用的 DCloud 公有证书,其推送标识(CID)无法用于正式推送。只有使用你自己 App 的正式证书(或调试证书)打包的自定义基座,才能获得真实、有效的 CID。

制作步骤:

  1. 在 HBuilderX 顶部菜单,选择 运行 -> 运行到手机或模拟器 -> 制作自定义调试基座

  2. 在弹出的界面中,你需要为 Android 平台提供应用签名证书。

    • 证书生成:可以使用在线工具如 香蕉云编 快速生成调试证书(.keystore 文件),妥善保存密码及别名信息。
    • 证书填写:将生成的证书文件路径、密码、别名等信息,准确填入 HBuilderX 的对应输入框。
  3. 点击  “打包” ,等待云端完成自定义基座的制作。

如何使用:
制作完成后,运行项目时,务必在运行菜单中选择“自定义调试基座” ,而非“运行标准基座”。只有运行在自定义基座上,后续的推送测试才有效。

image.png


二、 UniPush 服务开通与配置

推送能力依赖于 DCloud 的后台服务,因此需要在开发者中心完成配置。

  1. 进入开发者中心:访问 DCloud 开发者中心,在  “应用列表”  中找到你的项目。

  2. 配置应用信息

    • 如果未创建,请先为你的 App 创建一个应用。
    • 进入应用详情,在  “各平台信息”  中配置 Android 平台信息。
    • 这里需要填写的包名、应用签名,必须与上一步制作自定义基座时使用的证书信息完全一致。你可以在“香蕉云编”等工具生成证书的页面找到对应的 MD5 或 SHA1 签名。

image.png


三、 核心代码实现与在线推送测试

3.1 获取设备推送标识 (CID)

CID 是 UniPush 服务识别每台设备的唯一 ID。在客户端,我们使用 uni.getPushClientId API 获取。

关键注意事项(时序问题):
推送服务初始化需要时间,应用启动后立即调用此 API 可能返回空值。一个稳健的做法是延时获取或在监听事件中获取。

推荐实现方案:

// 在 App.vue 的 onLaunch 中,或首个页面的 onLoad 中调用
function initUniPush() {
  // 方案1:延时获取(简单直接)
  setTimeout(() => {
    uni.getPushClientId({
      success: (res) => {
        const cid = res.cid;
        console.log('设备推送标识CID:', cid);
        // 重要:将 CID 发送到你的业务服务器进行关联存储
        // uploadCidToServer(cid);
        
        // 可通过 Vuex 或全局事件更新 UI 状态
        uni.$emit('push-cid-ready', cid);
      },
      fail: (err) => {
        console.error('获取CID失败:', err);
        // 处理失败逻辑,如重试
      }
    });
  }, 3000); // 延迟 3 秒,可根据实际情况调整

  // 方案2:监听事件(更精准)
  // uni.onPushMessage((res) => {...}); // 接收推送消息
  // 也可以在服务初始化成功的事件中获取CID
}

3.2 发送你的第一条推送

  1. 登录 DCloud 后台:进入你的应用,找到  “UniPush”  功能模块。

  2. 创建推送

    • 点击  “在线推送”  或  “推送消息”
    • 推送方式选择  “单播(CID)” ,将上一步获取的 CID 填入。
    • 填写推送标题、内容。内容应尽量模拟真实场景(如“欢迎使用”、“订单已发货”),避免使用“test”、“aaaa”等测试文本,以防被系统或厂商通道误判为骚扰信息而拦截。
    • 点击发送。

image.png

  1. 验证结果

    • 发送后,在  “推送记录”  中可以查看状态。显示“已接收”即表示推送已成功抵达客户端。
    • 确保你的 App 正处于前台运行后台存活状态。此时,你应该能在设备上收到通知栏消息。

image.png


四、 总结与注意事项

至此,你已经完成了 UniPush 在线推送的核心流程。我们来总结一下关键点:

✅ 正确流程:创建项目 → 启用模块 → 制作自定义基座(关键!)  → 平台配置 → 获取CID → 后台推送。

⚠️ 核心要点

  1. 调试必须用自定义基座:这是获得有效 CID 的前提。
  2. CID 获取有时序:使用延时或监听事件确保获取成功。
  3. 信息一致:开发者中心配置的包名、签名需与打包证书一致。
  4. 推送内容需“真实” :避免纯测试内容被过滤。
  5. 在线推送的局限性:目前实现的是“在线推送”,即 App 进程存活(前台或后台)才能接收。要实现 App 完全关闭后仍能接收(离线推送),需要集成各大手机厂商的推送通道(华为、小米、OPPO、vivo 等)

理解 CSS backface-visibility:卡片翻转效果背后的属性

作者 parade岁月
2025年12月2日 13:16

前段时间在做一个产品展示页,需要实现卡片翻转效果。本以为很简单,结果翻转的时候总是"穿帮"——正面和背面同时显示,或者背面的文字是镜像的。折腾了半天才发现,原来是 backface-visibility 这个属性没搞明白。

今天就来聊聊这个 CSS 属性。

🎮 在线演示frontend-learning.pages.dev/animation/d…

建议边看文章边打开演示页面,所有效果都可以直接交互体验。

先说结论

backface-visibility 控制的是:当一个元素的背面朝向你时,这个背面是否可见

听起来有点绕?没关系,我们慢慢来。

理解"背面"这个概念

这是理解整个属性的关键。很多人(包括我)一开始都没搞清楚这个"背面"到底是什么。

关键理解:每个 HTML 元素就像一张纸,天生就有正面和背面。当你用 rotateY(180deg) 翻转它时,你看到的是它的背面(就像纸翻过来,文字是镜像的)。

来看个最简单的例子:

<div
  style="
  background: linear-gradient(135deg, #10b981, #059669);
  color: white;
  padding: 40px;
  transform: rotateY(180deg);
"
>
  HELLO
</div>

你会看到什么?镜像的 HELLO。这就是元素的背面。

backface-visibility: hidden 的作用就是:当背面朝向你时,让它变透明

四个场景,逐步理解

我做了四个对比演示,帮你理解这个属性是怎么工作的。

场景 1:正常叠加

<div style="position: relative;">
  <div class="front" style="position: absolute;">FRONT</div>
  <div class="back" style="position: absolute;">BACK</div>
</div>

结果:只看到 BACK 原因:两个都是 absolute,BACK 在上层覆盖了 FRONT

这个很好理解,就是普通的 DOM 层叠。

场景 2:BACK 翻转 180°

.back {
  transform: rotateY(180deg);
}

结果:看到镜像的 BACK 原因:BACK 翻转后,你看到的是它的背面(文字镜像)

这里就出现问题了!虽然 BACK 翻转了,但你看到的是它的背面,文字是反的。

场景 3:隐藏 BACK 的背面

.back {
  transform: rotateY(180deg);
  backface-visibility: hidden; /* 关键!*/
}

结果:✅ 只看到 FRONT 原因:BACK 的背面被隐藏,露出下面的 FRONT

现在好了!BACK 的背面不可见了,所以你能看到下面的 FRONT。

场景 4:完美翻转

.front,
.back {
  backface-visibility: hidden; /* 两面都隐藏背面 */
}
.back {
  transform: rotateY(180deg);
}

/* 悬停时翻转父容器 */
.card-container:hover .card {
  transform: rotateY(180deg);
}

结果:✅ 悬停完美翻转 原理

  • 初始状态:FRONT 正面朝向你(显示),BACK 背面朝向你(隐藏)
  • 翻转后:FRONT 背面朝向你(隐藏),BACK 正面朝向你(显示)

这就是完美的卡片翻转效果!

语法很简单

.element {
  backface-visibility: visible | hidden;
}
  • visible:默认值,背面可见
  • hidden:背面不可见

就这两个值,没别的了。

一个常见误区:为什么不能分别旋转子元素?

这是我当时最困惑的地方。既然翻转后正面和背面都旋转了 180°,为什么不能直接这样写?

/* ❌ 错误写法 */
.card-container:hover .front {
  transform: rotateY(180deg);
}
.card-container:hover .back {
  transform: rotateY(180deg);
}

看起来很合理对吧?但实际上完全不行

问题在哪?

关键在于:CSS 的 transform 属性会被完全覆盖,而不是累加

/* 初始状态 */
.front {
  transform: rotateY(0deg);
}
.back {
  transform: rotateY(180deg);
}

/* 悬停后 */
.card-container:hover .front {
  transform: rotateY(180deg); /* ✓ 从 0° 变成 180° */
}
.card-container:hover .back {
  transform: rotateY(180deg); /* ✗ 从 180° 变成 180°,没变化! */
}

背面的初始值 rotateY(180deg) 被新值 rotateY(180deg) 覆盖,但因为值相同,所以背面根本没动!

正确做法:旋转父容器

/* ✅ 正确写法 */
.card-container:hover .card {
  transform: rotateY(180deg); /* 旋转整个父容器 */
}

这样做的原理是:子元素的 transform 是相对于父元素的坐标系

初始状态:
.card (0°)
  ├── .front: rotateY(0°) 相对于 .card   → 绝对位置 0°
  └── .back:  rotateY(180°) 相对于 .card → 绝对位置 180°

悬停后:
.card (180°)  ← 整个坐标系旋转了
  ├── .front: rotateY(0°) 相对于 .card   → 绝对位置 0° + 180° = 180°
  └── .back:  rotateY(180°) 相对于 .card → 绝对位置 180° + 180° = 360° = 0°

父元素旋转时,子元素的相对角度不变,但绝对角度会改变。这才是正确的翻转逻辑。

为什么需要三层结构?

标准的卡片翻转需要三层 DOM 结构:

<div class="爷容器">
  <!-- perspective(观察点) -->
  <div class="父容器">
    <!-- transform-style + rotateY(翻转者) -->
    <div class="正面"></div>
    <!-- backface-visibility(被翻转的面) -->
    <div class="背面"></div>
    <!-- backface-visibility(被翻转的面) -->
  </div>
</div>

每一层的职责

爷容器:设置 perspective

.card-container {
  perspective: 1000px; /* 必须在父元素上 */
}
  • 定义观察者的位置,创建 3D 空间
  • 类比:你站在舞台前看表演,这个属性决定你站在哪里看
  • 为什么在外层perspective 必须设置在父元素上,才能对子元素的 3D 变换生效

父容器:设置 transform-style 和执行 rotateY

.card {
  transform-style: preserve-3d; /* 保持 3D 空间 */
  transition: transform 0.6s;
}

.card-container:hover .card {
  transform: rotateY(180deg); /* 翻转整个卡片 */
}
  • preserve-3d:让子元素保持在 3D 空间中(而不是被压平)
  • 类比:这是舞台上的转盘,带着上面的演员一起旋转

子元素:正面和背面

.card-front,
.card-back {
  position: absolute;
  backface-visibility: hidden; /* 关键!隐藏背面 */
}

.card-back {
  transform: rotateY(180deg); /* 背面预先翻转 */
}
  • position: absolute:让两个面重叠在同一位置
  • 背面预先翻转 180°:这样当父容器翻转 180° 时,背面刚好正面朝向你

为什么不能少一层?

如果只有两层:

/* ❌ 错误 */
.card {
  perspective: 1000px;
  transform: rotateY(180deg);
}

问题perspectivetransform 在同一个元素上,perspective 不会对自己的 transform 生效,只会对子元素生效。结果就是没有透视效果。

完整的卡片翻转模板

直接复制这个模板,改改样式就能用:

<!-- HTML 结构 -->
<div class="card-container">
  <!-- 爷容器:观察点 -->
  <div class="card">
    <!-- 父容器:翻转者 -->
    <div class="card-front">正面内容</div>
    <div class="card-back">背面内容</div>
  </div>
</div>
/* 第 1 层:爷容器 - 设置观察点 */
.card-container {
  perspective: 1000px; /* 必须在父元素上 */
}

/* 第 2 层:父容器 - 执行翻转 */
.card {
  transform-style: preserve-3d; /* 保持 3D 空间 */
  transition: transform 0.6s;
}

.card-container:hover .card {
  transform: rotateY(180deg); /* 翻转整个卡片 */
}

/* 第 3 层:子元素 - 正面和背面 */
.card-front,
.card-back {
  position: absolute;
  width: 100%;
  height: 100%;
  backface-visibility: hidden; /* 关键!隐藏背面 */
}

.card-back {
  transform: rotateY(180deg); /* 背面预先翻转 */
}

这个模板是标准写法,三层结构缺一不可。

几个注意事项

  1. 必须配合 3D 变换backface-visibility 只在 3D 变换(如 rotateY, rotateX)中有意义,2D 旋转(rotate)不会产生背面
  2. 性能优化:设置为 hidden 可以让浏览器跳过背面的渲染,提升性能
  3. 浏览器兼容:现代浏览器都支持,旧版本可能需要 -webkit- 前缀

总结

backface-visibility 这个属性本身很简单,就两个值。但要真正理解它,需要搞清楚几个概念:

  1. 每个元素天生就有正面和背面
  2. backface-visibility: hidden 让背面朝向你时变透明
  3. 卡片翻转必须在父元素上执行旋转
  4. 标准的三层结构不能省略

最后再推荐一次我做的演示页面,里面有所有场景的交互演示和详细说明:

👉 frontend-learning.pages.dev/animation/d…

【vue高频面试题】第 20 题:Vue3 生命周期 + watch 执行顺序

作者 前端一课
2025年12月2日 12:35

第 20 题:Vue3 生命周期 + watch 执行顺序


🎯 一、核心问题

问:Vue3 中生命周期函数的执行顺序是怎样的?watch / onMounted / onBeforeUnmount 如何配合使用?

  • 面试官重点考察:

    • 生命周期顺序
    • watch 执行时机
    • 响应式更新与生命周期的关系

🎯 二、Vue3 生命周期顺序(组合式 API)

  1. setup() 执行

    • 初始化 props、state、ref、computed、watch
    • 可以在 setup 中注册生命周期函数
  2. onBeforeMount

    • 组件挂载前触发
    • 还未渲染到 DOM
  3. onMounted

    • 组件挂载完成,DOM 已渲染
    • 可以操作真实 DOM
  4. 响应式数据变化触发 watch

    • 如果 watch 在 setup 中注册
    • flush 默认是 'pre',在 DOM 更新前执行
    • flush: 'post' → 在 DOM 更新后执行
  5. onBeforeUnmount

    • 组件卸载前触发
    • 可清理定时器、事件监听等
  6. onUnmounted

    • 组件已卸载
    • DOM 已销毁

🎯 三、执行顺序图示

setup()         ← 初始化 state / computed / watch
  │
onBeforeMount() ← 挂载前
  │
DOM 渲染
  │
onMounted()     ← 挂载完成,可操作真实 DOM
  │
响应式数据变化
  │
watch()         ← flush: pre/post/sync 执行
  │
用户操作 / 卸载组件
  │
onBeforeUnmount()
  │
onUnmounted()

🎯 四、代码示例

<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'

const count = ref(0)

watch(count, (newVal) => {
  console.log('watch count:', newVal)
}, { flush: 'post' })

onMounted(() => {
  console.log('mounted')
})

onBeforeUnmount(() => {
  console.log('beforeUnmount')
})

setTimeout(() => count.value++, 1000)
</script>

输出顺序

mounted
watch count: 1    flush: post,DOM 已更新
beforeUnmount      卸载组件时

🎯 五、面试官常见追问


追问 1:watch flush 参数含义?

  • 'pre'(默认) → 在 DOM 更新前执行
  • 'post' → 在 DOM 更新后执行
  • 'sync' → 同步执行,立即触发

追问 2:setup 内 watch 和生命周期执行顺序?

  • setup 先执行
  • 生命周期注册在 setup 后,但会在挂载阶段依次触发
  • watch flush 'pre' 在 onMounted 前可能执行一次(第一次访问依赖会立即触发)

追问 3:onMounted 中可以访问 DOM 吗?

  • ✅ 可以
  • 因为组件已经挂载完成
  • 在 setup 或 onBeforeMount 中访问 DOM 是 null

追问 4:onBeforeUnmount 与 watch 清理有关系吗?

  • watch 可返回 stop 函数
  • 在 onBeforeUnmount 中调用 stop() 可手动停止 watch
  • 避免组件卸载后副作用仍然触发

🎯 六、一句话总结

Vue3 生命周期顺序:setup → onBeforeMount → onMounted → 响应式 watch → onBeforeUnmount → onUnmounted;watch flush 决定响应式回调执行时机,可精确控制 DOM 更新前后。

【vue高频面试题】第 19 题:Vue3 性能优化技巧

作者 前端一课
2025年12月2日 12:34

第 19 题:Vue3 性能优化技巧


🎯 一、核心问题

问:在 Vue3 中,如何优化性能?尤其是响应式系统、虚拟 DOM、静态节点、列表渲染等方面。

  • 面试官考察你对 Vue3 原理和实战性能优化的理解

🎯 二、优化方法

1️⃣ 响应式优化

  1. 减少不必要的响应式对象

    • reactive 会递归代理对象,避免对大量静态数据使用 reactive
    • 对于静态对象,可直接使用普通对象或 readonly
  2. 使用 shallowReactive / shallowRef

    • 只对最外层做响应式,减少深层 Proxy 开销
  3. 避免过度监听

    • watch / computed 只监听必要字段
    • watchEffect 谨慎使用大对象
  4. computed 缓存

    • 对计算量大的逻辑使用 computed,避免重复计算

2️⃣ 虚拟 DOM 优化

  1. PatchFlag

    • 使用模板编译优化,Vue3 自动生成 PatchFlag
    • 减少无效 Diff
  2. 静态节点提升

    • 不依赖响应式数据的节点在编译阶段提升
    • 避免每次渲染创建 VNode
  3. 合理使用 key

    • 列表渲染用 key 区分节点,优化复用,减少 DOM 移动

3️⃣ 列表渲染优化

  1. v-for + key

    • 唯一标识元素,提高 Diff 准确性
    <li v-for="item in list" :key="item.id">{{ item.name }}</li>
    
  2. 虚拟滚动 / 懒渲染

    • 大量列表只渲染可视区域,提高渲染性能
    • 可使用 vue-virtual-scroller 等组件
  3. 避免嵌套 v-for

    • 嵌套循环会产生指数级渲染开销
    • 可拆分组件,局部渲染优化

4️⃣ 事件和组件优化

  1. v-on / 方法绑定

    • 避免在模板中直接写内联函数,导致每次渲染生成新函数
    <!-- 不推荐 -->
    <button @click="count++"></button>
    <!-- 推荐 -->
    <button @click="handleClick"></button>
    
  2. 组件拆分 / 懒加载

    • 使用 <Suspense> 或动态 import 懒加载大型组件
    const AsyncComponent = defineAsyncComponent(() => import('./MyComponent.vue'))
    

🎯 三、综合示例

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

<script setup>
import { ref, computed } from 'vue'

const list = ref([...Array(10000).keys()].map(i => ({ id: i, name: `Item ${i}` })))
// 虚拟滚动或分页,仅显示部分
const visibleList = computed(() => list.value.slice(0, 100))
</script>
  • 只渲染 100 条数据,而不是全部 10000 条
  • 减少 DOM 数量 → 提升性能

🎯 四、面试官常见追问


追问 1:Vue3 响应式性能比 Vue2 好在哪?

  • Proxy 代替 defineProperty
  • 深度递归惰性代理,避免初始化成本高
  • 精准依赖收集,减少无效 effect 触发

追问 2:静态节点提升与 PatchFlag 怎么协同优化?

  • 静态节点 → 减少 VNode 创建和 Diff
  • PatchFlag → 减少动态节点 Diff 范围
  • 两者结合 → 最大化性能优化

追问 3:大量列表渲染时,Diff 性能瓶颈如何解决?

  • 使用 key 优化复用
  • 分页 / 虚拟滚动减少渲染节点数量
  • 尽量避免嵌套循环

追问 4:computed 和 watch 在性能优化上有什么作用?

  • computed → 缓存计算结果,避免重复计算
  • watch → 精准监听变化,执行副作用,不触发不必要渲染

追问 5:响应式对象深层次修改会影响性能吗?

  • 深层 reactive 会对每层对象递归代理
  • 对大对象或数组,建议使用 shallowReactive / shallowRef 或只代理必要层级

🎯 五、一句话总结(面试必背)

Vue3 性能优化核心:精准响应式(Proxy + computed 缓存)、虚拟 DOM PatchFlag + 静态节点提升、列表渲染优化(v-for + key + 虚拟滚动),结合组件拆分与懒加载,实现高效渲染。

【vue高频面试题】第 18 题:Vue3 响应式原理中的 effect、依赖收集与依赖触发

作者 前端一课
2025年12月2日 12:34

第 18 题:Vue3 响应式原理中的 effect、依赖收集与依赖触发


🎯 一、核心问题

问:Vue3 响应式系统中 effect 是什么?依赖是如何收集和触发的?

  • 面试官希望你理解:

    • 响应式依赖收集机制
    • effect 栈的作用
    • 数据变化如何触发对应组件重新渲染

🎯 二、核心概念

  1. effect

    • 本质是一个函数,用于包裹 副作用(如模板渲染函数、computed getter)
    • 当依赖的数据发生变化时,effect 会被重新执行
  2. 依赖收集(track)

    • 当 effect 执行过程中访问响应式对象属性时
    • Proxy 的 get 会调用 track
    • 将当前激活的 effect 保存到该属性的依赖集合中
  3. 依赖触发(trigger)

    • 当属性变化时,通过 Proxy 的 set 调用 trigger
    • 触发依赖集合中的所有 effect 执行
    • 更新模板 / computed / watch 回调
  4. effect 栈

    • 用来处理嵌套 effect
    • 确保 track 时收集的始终是 当前激活的 effect
    • 避免不同 effect 混淆依赖

🎯 三、深度图解(文字版图)

effectStack = []

1. 创建 effect:
effect(() => { console.log(state.count) })

2. 执行 effect:
   └─ push effect 到 effectStack
       │
       ▼
   Proxy get 拦截 state.count
       │
       ▼
   track(effectStack.top) → 保存依赖

3. 数据变化:
state.count++
   └─ Proxy set 拦截
       │
       ▼
   trigger(state.count.deps) → 执行依赖的 effect
       │
       ▼
   effectStack.top 被重新执行 → DOM / computed 更新

关系总结:

响应式对象 property → Set(effect1, effect2)
effect1 / effect2 → function(callback)
依赖变化 → trigger → 执行 effect

🎯 四、示例代码(最简单的实现效果)

import { reactive, effect } from 'vue'

const state = reactive({ count: 0 })

effect(() => {
  console.log('count =', state.count)
})

state.count++  // 自动触发 effect
state.count++  // 再次触发 effect

分析:

  1. effect 执行 → push 到 effectStack
  2. state.countget 调用 track → 收集 effect
  3. state.count++set 调用 trigger → 执行 effect → 输出最新值

🎯 五、面试官常见追问


追问 1:effect 栈为什么必要?

  • 支持嵌套 effect
  • 确保 track 时收集的是当前激活 effect
  • 避免 effect A 调用 effect B 时混乱收集依赖

追问 2:依赖收集如何避免重复?

  • 每个属性依赖使用 Set 存储 effect
  • 同一个 effect 多次访问同一属性只会收集一次

追问 3:computed 内部如何利用 effect?

  • computed getter 包裹 effect
  • effect 被标记 lazy = true
  • 访问 .value 时触发执行并收集依赖
  • 依赖变化 → 标记 dirty → 下次访问重新计算

追问 4:watch 是如何利用依赖触发的?

  • watch 内部创建 getter effect
  • 监听响应式对象 / getter
  • 数据变化 → trigger → 执行回调
  • 可获取新旧值,执行异步副作用

追问 5:为什么 Vue3 响应式性能比 Vue2 高?

  • Proxy 不需要递归 defineProperty → 节省初始化时间
  • 依赖收集精准 → 最小化 effect 执行
  • effect + dirty + PatchFlag → 精准更新

🎯 六、一句话总结(面试必背)

Vue3 响应式通过 Proxy 拦截访问,effect 栈管理当前激活 effect,track 收集依赖,trigger 触发依赖执行,实现高性能、精准响应式更新。

【vue高频面试题】第 17 题:Vue3 虚拟 DOM 与 PatchFlag 原理 + 静态节点提升

作者 前端一课
2025年12月2日 12:34

第 17 题:Vue3 虚拟 DOM 与 PatchFlag 原理 + 静态节点提升


🎯 一、核心问题

问:Vue3 中虚拟 DOM 是如何优化性能的?PatchFlag 和静态节点提升的作用是什么?

这是面试官非常喜欢问的高级题,尤其考察对 Vue3 编译器优化的理解。


🎯 二、标准回答(面试官满意版)

  1. 虚拟 DOM 本质

    • Vue3 将模板编译成 VNode(虚拟节点)
    • 每次响应式数据变化时,生成新的 VNode
    • 对比旧 VNode → 通过 Diff 算法最小化真实 DOM 更新
  2. PatchFlag(编译标记)

    • 在编译阶段,为 VNode 添加 标记字段,标识节点变化类型

    • 作用:

      • 告诉渲染器哪些属性可能变化
      • 避免对不变的节点重复 diff
    • 典型标记:

      • TEXT → 文本变化
      • CLASS → class 变化
      • STYLE → style 变化
      • PROPS → 普通 props 变化
      • FULL_PROPS → 所有 props 都可能变化
      • HYDRATE_EVENTS → 事件变化
  3. 静态节点提升(Hoist Static)

    • 编译器把模板中 不依赖响应式数据的节点 提前生成 VNode
    • 渲染时直接复用,不需要每次重新创建
    • 节约内存和计算,减少 Diff

🎯 三、工作流程示意

模板 → 编译器 → VNode + PatchFlag + 静态提升
响应式数据变化 → 生成新 VNode
     │
     ▼
Diff + Patch
     │
     ▼
仅更新变化的节点/属性

🎯 四、代码示例(编译器优化效果)

<template>
  <div>
    <h1>{{ title }}</h1>       <!-- 动态内容 → TEXT PatchFlag -->
    <p class="desc">静态描述</p>  <!-- 静态节点 → Hoist Static -->
  </div>
</template>
  • <h1> 会每次响应式数据变化时 diff
  • <p> 被提升为 静态节点,不会重复创建或比较

如果用 Vue3 编译输出 VNode,会生成类似:

const _hoisted_1 = /*#__PURE__*/createVNode("p", { class: "desc" }, "静态描述")
...
createVNode("div", null, [
  createVNode("h1", null, toDisplayString(title), 1 /* TEXT */),
  _hoisted_1
])

🎯 五、面试官常见追问(高频)


追问 1:PatchFlag 为什么比 Vue2 性能高?

  • Vue2 diff 每次都会比较整个 VNode 树
  • Vue3 PatchFlag 提前告诉渲染器哪些属性可能变化
  • 避免了大量无用比较 → 大幅减少 diff 计算

追问 2:静态节点提升有什么好处?

  • 静态节点只创建一次
  • 不参与每次渲染的 Diff
  • 节省内存分配 + CPU 时间
  • 特别适合大模板或列表的静态内容

追问 3:PatchFlag 和 key 的关系?

  • PatchFlag 用于标记节点内部变化类型

  • key 用于 列表节点快速定位变化

  • 结合使用:

    • PatchFlag 优化单节点属性更新
    • key 优化列表的重新排序和复用

追问 4:如何在开发中查看 PatchFlag?

  • 编译时输出 __DEV__ 模式下 VNode
  • Vue3 编译器会在 VNode 对象中显示 PatchFlag
  • 也可以用 vue-next 的源码分析

追问 5:PatchFlag 有哪些限制?

  • 仅对模板编译生成 VNode 生效
  • 手写 render 函数需要手动优化
  • 对复杂动态结构,PatchFlag 无法覆盖全部变化,需要配合 key 和列表 diff

🎯 六、一句话总结(背面试官即可)

Vue3 通过 PatchFlag 标记节点变化类型和静态节点提升,减少不必要的 Diff 和 VNode 创建,从而显著提升渲染性能。

【vue高频面试题】第 16 题:Vue3 响应式原理深度解析(Proxy + effect 栈 + 依赖追踪)

作者 前端一课
2025年12月2日 12:34

第 16 题:Vue3 响应式原理深度解析(Proxy + effect 栈 + 依赖追踪)


🎯 一、核心问题

问:Vue3 响应式是如何实现的?Proxy、effect 栈和依赖追踪是怎么协作的?

这是高频面试深度题,面试官一般会追问 “为什么 Vue3 性能比 Vue2 好”。


🎯 二、标准回答(面试官满意版)

  1. 响应式核心是 Proxy

    • Vue3 不再使用 defineProperty,而是使用 Proxy 代理对象
    • Proxy 可以拦截 getsetdeleteProperty 等操作
    • 支持新增属性、数组下标变化、删除属性等
  2. 依赖收集(Track)

    • 当访问响应式对象的属性时,Proxy 的 get 会被触发
    • Vue 内部将当前执行的 effect(函数)记录到 依赖集合
    • 这样当属性变化时,可以精准触发依赖的 effect
  3. effect 栈

    • Vue3 用栈结构维护当前激活的 effect
    • 当一个 effect 执行时,push 到栈顶
    • get 触发时,依赖收集使用栈顶 effect
    • 结束后 pop 出栈
    • 解决嵌套 effect 的依赖追踪问题
  4. 触发依赖(Trigger)

    • 当属性通过 Proxy set 改变时
    • Vue 会查找依赖集合,并依次执行 effect
    • 实现组件重新渲染或 computed 更新

🎯 三、简化流程图

响应式对象被访问
       │
       ▼
Proxy get 拦截
       │
       ▼
track(effect) 收集依赖
       │
       ▼
数据变化
       │
       ▼
Proxy set 拦截
       │
       ▼
trigger(effect) 触发依赖执行

🎯 四、示例代码(原理演示)

import { reactive, effect } from 'vue'

const state = reactive({ count: 0 })

effect(() => {
  console.log('count =', state.count)
})

state.count++  // 自动触发 effect,打印最新值

解释:

  • reactive 创建 Proxy
  • effect 包裹回调函数并入栈
  • 访问 state.count → track 收集依赖
  • 修改 state.count → trigger 执行 effect

🎯 五、面试官常见追问(高频)


追问 1:为什么 Vue3 使用 Proxy,比 Vue2 defineProperty 性能更好?

  • 不需要递归遍历整个对象(惰性代理)
  • 支持新增 / 删除属性、数组下标
  • 更少内存开销
  • 依赖收集更精准,减少无效更新

追问 2:effect 栈有什么作用?

  • 支持嵌套 effect
  • 避免误收集 effect
  • 保证 track 时总是收集当前激活的 effect

追问 3:依赖追踪如何避免重复收集?

  • 每个属性依赖使用 Set 保存 effect
  • 同一个 effect 不会重复加入依赖集合

追问 4:computed 是怎么利用 effect 栈的?

  • computed 内部也用 effect 包裹 getter
  • effect 栈保证依赖收集在计算时有效
  • 依赖变化时,computed 被标记 dirty,下次访问重新计算

追问 5:响应式循环引用怎么办?

  • Vue3 Proxy 本身支持循环对象
  • 每次 reactive 调用会 缓存已代理对象
  • 避免重复代理和无限递归

🎯 六、一句话总结(面试官必背)

Vue3 响应式原理:通过 Proxy 拦截对象操作 + effect 栈管理当前执行上下文 + track/trigger 精准收集依赖,实现高性能、可缓存、可嵌套的响应式系统。

解释watch和computed的原理

作者 前端一课
2025年12月2日 12:33

1️⃣ 核心区别表

特性 computed watch
用途 计算衍生数据 监听变化执行副作用
返回值 会返回值并缓存 无返回值(主要触发回调)
执行时机 访问 .value 时才执行 数据变化时立即触发(flush 可控制)
缓存机制 ✅ 会缓存,依赖未变不重新计算 ❌ 不缓存,每次依赖变化都会触发回调
依赖收集 自动收集依赖 需手动指定监听对象(或 watchEffect 自动收集)
可获取 oldValue
异步支持 ❌ 仅同步计算 ✅ 可做异步副作用(API、DOM 操作等)
适合场景 纯逻辑计算、UI 展示数据 副作用逻辑、异步请求、操作 DOM 等

2️⃣ 理解方式

  1. computed = 计算属性(惰性求值 + 缓存)

    • 主要用于根据已有数据计算出新的数据
    • 访问 value 时才触发计算
    • 不适合做副作用
  2. watch = 数据侦听器(观察者模式)

    • 用于监听某个响应式变量变化
    • 触发回调执行副作用(API 请求、DOM 操作、日志等)
    • 可访问新旧值
    • 可以做异步操作

3️⃣ 原理上的差异

  • computed

    • 内部有 effect + dirty flag
    • 依赖变化时标记 dirty = true
    • 下一次访问 value 时重新计算
    • 可以缓存,减少重复计算
  • watch

    • 直接对响应式数据注册 effect
    • 数据变化时立即触发回调
    • 回调不返回值
    • 可选择 flush:pre/post/sync 控制执行时机

computed 的原理

computed 本质上是一个 带缓存的响应式副作用

  1. 依赖收集

    • 当访问 computed.value 时,Vue 会触发其内部 effect,追踪它依赖的响应式数据(ref 或 reactive 对象)。
  2. 惰性求值(lazy)

    • 初次访问时执行 getter,生成值并缓存
    • 标记 dirty = false
  3. 缓存机制

    • 当依赖变化时,响应式系统会将 dirty = true
    • 下次访问 .value 时重新计算并更新缓存
  4. 不可获取旧值

    • 因为 computed 只关注最新值,内部只存当前缓存

示意流程:

访问 computed.value → 检查 dirty
    ├─ dirty = true → 执行 getter → 缓存值 → dirty = false
    └─ dirty = false → 直接返回缓存值
依赖变化 → effect 标记 dirty = true

示例代码:

import { ref, computed } from 'vue'

const count = ref(1)
const double = computed(() => count.value * 2)

console.log(double.value) // 2
count.value++
console.log(double.value) // 4,重新计算

2️⃣ watch 的原理

watch 本质是一个 响应式数据的观察者(Watcher) ,用于执行副作用:

  1. 手动依赖选择

    • 你传入要监听的响应式对象(ref / reactive / getter)
    • Vue 内部会对其注册 effect
  2. 响应式触发

    • 当被监听的数据发生变化时,effect 会触发回调函数
  3. 可获取 oldValue / newValue

    • watch 在触发时会提供新值和旧值
  4. 异步可选

    • 默认 flush: 'pre'
    • 可以指定 'post'(DOM 更新后)或 'sync'(同步执行)
  5. 没有缓存

    • 每次数据变化都会调用回调函数
    • 适合副作用逻辑,不用于纯计算

示意流程:

watch(reactiveData, callback)
    └─ 内部创建 effect
依赖变化 → 触发 effect → callback(newValue, oldValue)

示例代码:

import { ref, watch } from 'vue'

const count = ref(1)

watch(count, (newVal, oldVal) => {
  console.log(`count changed from ${oldVal} to ${newVal}`)
})

count.value++  // 触发 watch 回调

3️⃣ 原理总结对比

特性 computed watch
内部机制 effect + dirty flag effect 直接触发回调
触发时机 访问 .value 时(懒执行) 数据变化时立即触发
缓存 ✅ 缓存上次计算值 ❌ 不缓存,每次触发回调
oldValue ❌ 无 ✅ 有
使用目的 衍生数据计算 执行副作用(异步、DOM 操作等)

【vue高频面试题】第 15 题:computed vs watch 区别 + 使用场景

作者 前端一课
2025年12月2日 12:33

第 15 题:computed vs watch 区别 + 使用场景


🎯 一、标准回答(面试官必答点)

特性 computed watch
依赖收集 自动依赖追踪 需要手动指定依赖
返回值 会缓存(lazy evaluation) 不缓存
用途 计算衍生数据 执行副作用(API、DOM操作等)
执行时机 访问时才执行 数据变化时触发
获取 oldValue ✔ 可以获取
适用场景 纯计算逻辑 监听变化做异步或副作用处理

🎯 二、代码示例

1️⃣ computed 示例(缓存计算)

<script setup>
import { ref, computed } from 'vue'

const count = ref(1)
const double = computed(() => count.value * 2)

console.log(double.value) // 2

count.value++
console.log(double.value) // 4,computed 会自动更新并缓存
</script>

特点:

  • 只有访问 double.value 时才执行
  • 如果依赖未变化,直接返回缓存值

2️⃣ watch 示例(副作用监听)

<script setup>
import { ref, watch } from 'vue'

const count = ref(1)

watch(count, (newVal, oldVal) => {
  console.log(`count changed from ${oldVal} to ${newVal}`)
})
count.value++  // 触发 watch 回调

特点:

  • 触发副作用(日志、请求 API、操作 DOM)
  • 可以获取 oldVal
  • 默认 flush: 'pre'(可指定 post 或 sync)

🎯 三、面试官追问(高频)


追问 1:computed 能做异步吗?

  • computed 不支持异步(只计算同步逻辑)
  • 异步逻辑应该用 watchwatchEffect

追问 2:computed 的懒执行原理?

  • 内部依赖收集 + dirty flag
  • 依赖变化时标记 dirty = true
  • 下次访问 value 时才重新计算并缓存结果

追问 3:computed vs watchEffect 区别?

特性 computed watchEffect
返回值 缓存返回值 无返回值
依赖 自动收集 自动收集
执行时机 访问 value 时才执行 默认立即执行一次
用途 衍生数据 副作用逻辑

追问 4:watch 可以替代 computed 吗?

  • 技术上可以,但不推荐
  • watch 不缓存,每次访问都可能重新执行
  • computed 更轻量且性能高

追问 5:computed 可以和 ref 联动吗?

const count = ref(1)
const double = computed(() => count.value * 2)
double.value++ // ❌ 报错,computed 默认只读
  • 解决方法:使用 computed({ get, set }) 创建可写 computed
const double = computed({
  get: () => count.value * 2,
  set: val => count.value = val / 2
})

🎯 四、一句话总结(背面试官即可)

computed 用于缓存衍生数据,懒执行、自动依赖收集;watch 用于监听数据变化触发副作用,可获取新旧值。computed 更轻量,watch 更灵活,适合异步或副作用逻辑。

【vue高频面试题】第 14 题:Vue3 中虚拟 DOM 是什么?为什么要用?如何提升性能?

作者 前端一课
2025年12月2日 12:33

第 14 题:Vue3 中虚拟 DOM 是什么?为什么要用?如何提升性能?


🎯 一、标准回答(面试官必问核心点)

1️⃣ 什么是虚拟 DOM(VNode)

虚拟 DOM 是 Vue 内部的一种 JavaScript 对象表示的 DOM 树,抽象出真实 DOM 的结构和属性。

示例:

{
  tag: 'div',
  props: { id: 'app' },
  children: [
    { tag: 'span', children: 'Hello' }
  ]
}

它是内存中的 DOM 描述,不是浏览器真实 DOM。


2️⃣ 为什么要用虚拟 DOM

  1. 避免直接操作真实 DOM,性能高
  2. 跨平台渲染(可渲染到浏览器、Native、Canvas 等)
  3. Diff 算法:对比新旧 VNode,只更新变化部分
  4. 组合式渲染优化:更方便管理响应式更新

🎯 二、虚拟 DOM 优势(高分必答点)

优势 解释
性能优化 批量 diff → 最小化真实 DOM 更新
平台无关 可渲染在浏览器、服务端、Native、Canvas
响应式结合 Vue 响应式系统修改数据 → 生成新 VNode → diff → patch
可调试 内存中对象易于分析、调试、测试
跨渲染器 Vue3 可以通过 renderer API 渲染不同平台

🎯 三、Vue3 中虚拟 DOM 流程(高频面试考点)

  1. 模板 / render 函数 → 生成 VNode
  2. 响应式数据变化 → 触发 effect → 重新生成新的 VNode
  3. Diff 算法 → 对比新旧 VNode
  4. Patch 更新 → 最小化真实 DOM 更新

🎯 四、代码示例(render 函数)

import { h, render } from 'vue'

const vnode = h('div', { id: 'app' }, [
  h('span', 'Hello Vue3')
])

render(vnode, document.getElementById('root'))

流程解释:

  • h() → 创建 VNode
  • render() → 挂载或更新 DOM
  • 数据变更 → diff → patch → 最小更新

🎯 五、面试官常见追问


追问 1:虚拟 DOM 对性能真的有提升吗?

回答:

  • 对小型应用差异不大,但对于频繁修改 DOM 或大规模渲染时,diff 算法减少大量 DOM 操作,性能提升明显。
  • Vue3 中 静态节点提升 + PatchFlag 进一步优化性能。

追问 2:Vue3 Diff 算法怎么优化的?

回答:

  1. PatchFlag 静态提升

    • 静态节点不需要重新渲染
  2. Keyed Diff

    • key 快速比较列表节点
  3. Fragment 支持

    • 减少额外 DOM 包裹元素
  4. Teleport / Suspense

    • 提升复杂场景渲染效率

追问 3:虚拟 DOM 会消耗大量内存吗?

回答:

  • 是的,但 Vue3 使用 Proxy + effect + PatchFlag 减少重复创建
  • 对于大规模列表,建议使用 v-for key + 虚拟滚动等优化

追问 4:Vue3 为什么虚拟 DOM 不像 Vue2 那么慢?

回答:

  • Vue3 采用 Proxy + Composition API + Compiler 优化
  • 静态节点提升 + PatchFlag + Fragment → 减少 diff 量
  • 更少中间对象生成 → 性能显著提升

🎯 六、killer 问题(面试官爱问的进阶题)

❓ “虚拟 DOM 和真实 DOM 操作相比的优劣?”

答案要点:

  • 优点:批量处理、跨平台、可控更新
  • 缺点:比直接操作真实 DOM 多一次中间层
  • 结论:在大多数复杂应用中优势大于劣势,尤其配合 Vue3 编译优化

❓ “PatchFlag 是什么?为什么 Vue3 需要它?”

  • PatchFlag:编译阶段标记 VNode 的变化类型
  • 作用:让渲染器只对变化的部分进行 patch
  • 优化性能 → 减少 diff 计算

🎯 七、一句话总结(背面试官即可)

虚拟 DOM 是 Vue 内存中 DOM 描述对象,通过 diff 算法实现最小化更新,避免直接操作真实 DOM,Vue3 通过 PatchFlag、静态提升和 Proxy 响应式极大优化了性能。

【vue高频面试题】第 13 题:Vue 的 `nextTick` 原理是什么?为什么需要它?

作者 前端一课
2025年12月2日 12:32

第 13 题:Vue 的 nextTick 原理是什么?为什么需要它?


🎯 一、标准回答(面试必须说的核心)

nextTick 用来在 下一轮 DOM 更新完成之后 执行回调。

因为 Vue 的 DOM 更新是 异步的、批处理的,当你修改响应式数据时,DOM 并不会立即更新,而是放到一个任务队列里,等同一轮事件循环结束后统一更新。因此,如果你想操作“更新后的 DOM”,必须使用 nextTick


🎯 二、底层原理(高频考点,务必背)

Vue 的 nextTick 本质上是:

使用 微任务(Promise.then)优先 实现的异步回调机制,将任务推入事件循环的下一 tick 执行。

Vue3 实现优先级:

  1. Promise.then(微任务)
  2. 如果 Promise 不可用 → 再降级到 MutationObserver
  3. 最后才使用 setTimeout(宏任务)

为什么用微任务?

  • 优先级比 setTimeout 高
  • 执行非常快
  • 能保证 DOM 更新后尽快执行回调

这句话面试官很爱听:

Vue3 中 DOM 更新是异步批处理的,而 nextTick 就是让代码在 DOM 更新完后再执行。


🎯 三、为什么 DOM 更新是异步的?

这是面试官 100% 会追问的。

✔ 同步更新 DOM 性能太差

频繁数据变化会导致频繁 DOM 重绘、回流,性能崩溃。

✔ 异步批处理可以合并更新

多次状态修改:

count.value++
count.value++
count.value++

Vue 不会更新 3 次,而是:

合并所有状态变更 → 执行一次 DOM 更新

✔ nextTick 用于等待这个更新完成

非常好记:

“数据改完不是马上改 DOM,需要 nextTick 等 Vue 批量改完 DOM 后再执行代码。”


🎯 四、简单理解 nextTick 的执行机制(半白话)

你连续提交很多 DOM 更新,但 Vue 不会立即执行,而是推入任务队列。

然后:

  1. 当前同步代码执行完
  2. 微任务队列执行(Promise.then)
  3. Vue 批量更新 DOM
  4. 执行 nextTick 回调

顺序大概是:

同步代码 → Vue 收集更新 → 微任务执行 → DOM 更新 → nextTick 执行

🎯 五、代码示例(面试官通常让你举例)

count.value++
await nextTick()
console.log(divRef.value.innerText) // 拿到的是更新后的 DOM

🎯 六、手写一个 nextTick(必加分)

面试官看到你能手写,直接对你印象爆炸加分。

const callbacks = []
let pending = false

function nextTick(cb) {
  callbacks.push(cb)

  if (!pending) {
    pending = true
    Promise.resolve().then(() => {
      pending = false
      callbacks.forEach(fn => fn())
      callbacks.length = 0
    })
  }
}

解释:

  • 存储回调
  • 批处理
  • 利用 Promise.then 在微任务执行

🎯 七、面试官常见追问(附最佳回答)


追问 1:Vue3 DOM 更新是同步还是异步?

回答:

异步。Vue3 会将多次数据修改合并在一起,通过微任务在下一个 tick 统一更新 DOM。


追问 2:为什么 nextTick(() => {}) 有时还是拿不到 DOM?

回答:

因为你监听的是数据变化,而不是 DOM 更新;你需要确保 DOM 渲染参与了更新。
某些场景必须使用 await nextTick() 连续两次,或用 flush: 'post'


追问 3:watch 的 flush: 'post' 和 nextTick 的区别?

回答:

  • nextTick:等待 DOM 更新之后 执行
  • flush: 'post':让回调在 DOM 更新之后执行(类似 nextTick)

例如:

watch(count, () => {
  console.log('DOM 已更新')
}, { flush: 'post' })

二者都可以“等待 DOM 更新”,但:

  • nextTick 用于数据修改后手动等待 DOM
  • post 用于 侦听器自动等待 DOM

追问 4:nextTick 是不是 Vue 特有的?

回答:

不是,React 也有类似机制,所有虚拟 DOM 框架都有异步批处理。


🎯 八、一句话总结(背下来)

Vue 的 nextTick 利用 Promise 微任务,将回调延迟到 DOM 异步批处理更新完成后执行,用于保证你获取到的是最新 DOM。

来详细解释 Vue3 中 watchcomputed 的区别,从原理、用途和执行方式多维度展开,让你面试答题时一说就懂。


【vue高频面试题】第 12 题:Vue(尤其 Vue3)中父子组件通信方式有哪些?区别是什么?

作者 前端一课
2025年12月2日 12:32

第 12 题:Vue(尤其 Vue3)中父子组件通信方式有哪些?区别是什么?

这是 Vue 面试必考题,至少 90% 面试官会问。


🎯 一、标准答案(你在面试里应该先说的)

Vue3 中父子组件通信有 大常用方式:

父 → 子

  1. Props(最常见)
  2. Expose(父通过 ref 调用子方法)

子 → 父

  1. Emit 事件

父 ↔ 子

  1. v-model(语法糖,本质是 props + emit)

跨层级

  1. provide / inject

更大范围

  1. Pinia / Vuex(状态管理)

更底层/特殊情况

  1. EventBus(Vue3 不推荐,但面试会问)

🎯 二、父 → 子通信方式


1️⃣ Props(最常用)

父组件:

<Child :msg="text" />

子组件:

defineProps({
  msg: String
})

Props 特性:

  • 单向数据流(父 → 子)
  • 子不允许修改 props(会直接报错)
  • 适合传递配置类、静态数据

2️⃣ defineExpose(父组件通过 ref 调用子组件方法)

子组件:

const submit = () => { ... }
defineExpose({ submit })

父组件:

<Child ref="childRef" />
childRef.value.submit()

使用场景:

  • 子组件希望暴露一些内部方法给父组件
  • 提交表单、重置、校验等

🎯 三、子 → 父通信方式


3️⃣ Emit 事件

子组件:

const emit = defineEmits(['update'])
emit('update', newValue)

父组件:

<Child @update="handleUpdate" />

特点:

  • 单向数据流(子 → 父)
  • 组件解耦,官方推荐方式

🎯 四、父 ↔ 子 双向通信


4️⃣ v-model(语法糖)

父组件:

<Child v-model="value" />

子组件:

defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

emit('update:modelValue', 123)

本质只是 props + emit 的语法糖。


🎯 五、跨层级通信

5️⃣ provide / inject

祖先组件:

provide('theme', 'dark')

任意层子组件:

const theme = inject('theme')

特点:

  • 多层级,不需要一层一层传 props
  • 常用于配置类、上下文类数据(如表单组件库)

🎯 六、全局状态通信

6️⃣ Pinia(或 Vuex)

适合全局数据:

  • 用户信息
  • 权限状态
  • 全局设置
  • 缓存数据

Pinia 是 Vue3 官方主推状态库。


🎯 七、特殊情况

7️⃣ EventBus(Vue3 不推荐,但要知道)

适合简单的事件广播场景:

import mitt from 'mitt'
export const bus = mitt()

组件 A:

bus.emit('hello', 123)

组件 B:

bus.on('hello', val => {})

🔥 六、面试官常见追问(附高能回答)


追问 1:父子通信和 provide/inject 的区别是什么?什么时候用哪个?

你应该这样答:

  • 父子 props/emit:适合直接父子通信,数据清晰可控
  • provide/inject:适合跨层级通信,比如 3、5 层以上
  • provide/inject 数据不是响应式(除非你传 ref 或 reactive)

✔ 高分补充:

provide/inject 更像“依赖注入”,在 UI 组件库中用得非常多(Element Plus、Ant Design Vue)。


追问 2:v-model 为什么能实现双向绑定?底层是什么?

标准回答:

v-model 本质是 props(modelValue) + emit(update:modelValue)

就是:

父给子:modelValue
子返父:update:modelValue


追问 3:defineExpose 是什么时候用?和 props/emit 的关系?

答:

  • expose 用来暴露方法给父组件调用

  • 和 props/emit 不一样:

    • props/emit 用于 数据
    • expose 用于 方法、行为

例如表单组件:

defineExpose({
  validate,
  reset
})

追问 4:provide 是不是响应式的?如何让它变成响应式?

默认 不是响应式(非常多人不知道!)

必须这样传:

const theme = ref('dark')

provide('theme', theme)

inject 处能响应变化。


追问 5:EventBus 为什么不推荐?

  • 容易造成事件混乱
  • 不易追踪
  • 大项目可维护性差
  • TypeScript 支持差

面试官会觉得你非常专业。


🎯 七、一句话总结(背下来就能面试用)

Vue3 父子通信分:props、emit、v-model、expose,跨层级用 provide/inject,全局用 Pinia,少量事件可用 EventBus。

项目性能优化实践:深入FMP算法原理探索|得物技术

作者 得物技术
2025年12月2日 10:45

一、前 言

最近在项目中遇到了页面加载速度优化的问题,为了提高秒开率等指标,我决定从eebi报表入手,分析一下当前项目的性能监控体系。

通过查看报表中的cost_time、is_first等字段,我开始了解项目的性能数据采集情况。为了更好地理解这些数据的含义,我深入研究了相关SDK的源码实现。

在分析过程中,我发现采集到的cost_time参数实际上就是FMP(First Meaningful Paint) 指标。于是我对FMP的算法实现进行了梳理,了解了它的计算逻辑。

本文将分享我在性能优化过程中的一些思考和发现,希望能对关注前端性能优化的同学有所帮助。

二、什么是FMP

FMP (First Meaningful Paint) 首次有意义绘制,是指页面首次绘制有意义内容的时间点。与 FCP (First Contentful Paint) 不同,FMP 更关注的是对用户有实际价值的内容,而不是任何内容的首次绘制。

三、FMP 计算原理

3.1核心思想

FMP 的核心思想是:通过分析视口内重要 DOM 元素的渲染时间,找到对用户最有意义的内容完成渲染的时间点

3.2FMP的三种计算方式

  • 新算法 FMP (specifiedValue) 基于用户指定的 DOM 元素计算通过fmpSelector配置指定元素计算指定元素的完整加载时间
  • 传统算法 FMP (value) 基于视口内重要元素计算选择权重最高的元素取所有参考元素中最晚完成的时间
  • P80 算法 FMP (p80Value) 基于 P80 百分位计算取排序后80%位置的时间更稳定的性能指标

3.3新算法vs传统算法

传统算法流程

  • 遍历整个DOM树
  • 计算每个元素的权重分数
  • 选择多个重要元素
  • 计算所有元素的加载时间
  • 取最晚完成的时间作为FMP

新算法(指定元素算法)流程

核心思想: 直接指定一个关键 DOM 元素,计算该元素的完整加载时间作为FMP。

传统算法详细步骤

第一步:DOM元素选择

// 递归遍历 DOM 树,选择重要元素
selectMostImportantDOMs(dom: HTMLElement = document.body): void {
  const score = this.getWeightScore(dom);


  if (score > BODY_WEIGHT) {
    // 权重大于 body 权重,作为参考元素
    this.referDoms.push(dom);
  } else if (score >= this.highestWeightScore) {
    // 权重大于等于最高分数,作为重要元素
    this.importantDOMs.push(dom);
  }


  // 递归处理子元素
  for (let i = 0, l = dom.children.length; i < l; i++) {
    this.selectMostImportantDOMs(dom.children[i] as HTMLElement);
  }
}

第二步:权重计算

// 计算元素权重分数
getWeightScore(dom: Element) {
  // 获取元素在视口中的位置和大小
  const viewPortPos = dom.getBoundingClientRect();
  const screenHeight = this.getScreenHeight();


  // 计算元素在首屏中的可见面积
  const fpWidth = Math.min(viewPortPos.rightSCREEN_WIDTH) - Math.max(0, viewPortPos.left);
  const fpHeight = Math.min(viewPortPos.bottom, screenHeight) - Math.max(0, viewPortPos.top);


  // 权重 = 可见面积 × 元素类型权重
  return fpWidth * fpHeight * getDomWeight(dom);
}

权重计算公式:

权重分数 = 可见面积 × 元素类型权重

元素类型权重:

  • OBJECT, EMBED, VIDEO: 最高权重
  • SVG, IMG, CANVAS: 高权重
  • 其他元素: 权重为 1

第三步:加载时间计算

getLoadingTime(dom: HTMLElement, resourceLoadingMap: Record<string, any>): number {
  // 获取 DOM 标记时间
  const baseTime = getMarkValueByDom(dom);


  // 获取资源加载时间
  let resourceTime0;
  if (RESOURCE_TAG_SET.indexOf(tagType) >= 0) {
    // 处理图片、视频等资源
    const resourceTiming = resourceLoadingMap[resourceName];
    resourceTime = resourceTiming ? resourceTiming.responseEnd : 0;
  }


  // 返回较大值(DOM 时间 vs 资源时间)
  return Math.max(resourceTime, baseTime);
}

第四步:FMP值计算

calcValue(resourceLoadingMap: Record<string, any>, isSubPage: boolean = false): void {
  // 构建参考元素列表(至少 3 个元素)
  const referDoms = this.referDoms.length >= 3 
    ? this.referDoms 
    : [...this.referDoms, ...this.importantDOMs.slice(this.referDoms.length - 3)];


  // 计算每个元素的加载时间
  const timings = referDoms.map(dom => this.getLoadingTime(dom, resourceLoadingMap));


  // 排序时间数组
  const sortedTimings = timings.sort((t1, t2) => t1 - t2);


  // 计算最终值
  const info = getMetricNumber(sortedTimings);
  this.value = info.value;        // 最后一个元素的时间(最晚完成)
  this.p80Value = info.p80Value;  // P80 百分位时间
}

新算法详细步骤

第一步:配置指定元素

// 通过全局配置指定 FMP 目标元素
const { fmpSelector"" } = SingleGlobal?.getOptions?.();

配置示例:

// 初始化时配置
init({
  fmpSelector: '.main-content',  // 指定主要内容区域
  // 或者
  fmpSelector: '#hero-section',  // 指定首屏区域
  // 或者
  fmpSelector: '.product-list'   // 指定产品列表
});

第二步:查找指定元素

if (fmpSelector) {
  // 使用 querySelector 查找指定的 DOM 元素
  const $specifiedEl = document.querySelector(fmpSelector);


  if ($specifiedEl && $specifiedEl instanceof HTMLElement) {
    // 找到指定元素,进行后续计算
    this.specifiedDom = $specifiedEl;
  }
}

查找逻辑:

  • 使用document.querySelector()查找元素
  • 验证元素存在且为 HTMLElement 类型
  • 保存元素引用到specifiedDom

第三步:计算指定元素的加载时间

// 计算指定元素的完整加载时间
this.specifiedValue = this.getLoadingTime(
  $specifiedEl,
  resourceLoadingMap
);

加载时间计算包含:

  • DOM 标记时间
// 获取 DOM 元素的基础标记时间
const baseTime = getMarkValueByDom(dom);
  • 资源加载时间
let resourceTime0;
// 处理直接资源(img, video, embed 等)
const tagType = dom.tagName.toUpperCase();
if (RESOURCE_TAG_SET.indexOf(tagType) >= 0) {
  const resourceName = normalizeResourceName((dom as any).src);
  const resourceTiming = resourceLoadingMap[resourceName];
  resourceTime = resourceTiming ? resourceTiming.responseEnd : 0;
}
// 处理背景图片
const bgImgUrl = getDomBgImg(dom);
if (isImageUrl(bgImgUrl)) {
  const resourceName = normalizeResourceName(bgImgUrl);
  const resourceTiming = resourceLoadingMap[resourceName];
  resourceTime = resourceTiming ? resourceTiming.responseEnd : 0;
}
  • 综合时间计算
// 返回 DOM 时间和资源时间的较大值
return Math.max(resourceTime, baseTime);

第四步:FMP值确定

// 根据是否有指定值来决定使用哪个 FMP 值
if (specifiedValue === 0) {
  // 如果没有指定值,回退到传统算法
  fmp = isSubPage ? value - diffTime : value;
} else {
  // 如果有指定值,使用指定值
  fmp = isSubPage ? specifiedValue - diffTime : specifiedValue;
}

决策逻辑:

  • 如果 specifiedValue > 0:使用指定元素的加载时间
  • 如果 specifiedValue === 0:回退到传统算法

第五步:子页面时间调整

// 子页面的 FMP 值需要减去时间偏移
if (isSubPage) {
  fmp = specifiedValue - diffTime;
  // diffTime = startSubTime - initTime
}

新算法的优势

精确性更高

  • 直接针对业务关键元素
  • 避免权重计算的误差
  • 更贴近业务需求

可控性强

  • 开发者可以指定关键元素
  • 可以根据业务场景调整
  • 避免算法自动选择的偏差

计算简单

  • 只需要计算一个元素
  • 不需要复杂的权重计算
  • 性能开销更小

业务导向

  • 直接反映业务关键内容的加载时间
  • 更符合用户体验评估需求
  • 便于性能优化指导

3.4关键算法

P80 百分位计算

export function getMetricNumber(sortedTimings: number[]) {
  const value = sortedTimings[sortedTimings.length - 1];  // 最后一个(最晚)
  const p80Value = sortedTimings[Math.floor((sortedTimings.length - 1) * 0.8)];  // P80
  return { value, p80Value };
}

元素类型权重

const IMPORTANT_ELEMENT_WEIGHT_MAP = {
  SVG: IElementWeight.High,      // 高权重
  IMG: IElementWeight.High,      // 高权重
  CANVAS: IElementWeight.High,   // 高权重
  OBJECT: IElementWeight.Highest, // 最高权重
  EMBED: IElementWeight.Highest, // 最高权重
  VIDEO: IElementWeight.Highest   // 最高权重
};

四、时间标记机制

4.1DOM变化监听

// MutationObserver 监听 DOM 变化
private observer = new MutationObserver((mutations = []) => {
  const now = Date.now();
  this.handleChange(mutations, now);
});

4.2时间标记

// 为每个 DOM 变化创建性能标记
mark(count);  // 创建 performance.mark(`mutation_pc_${count}`)
// 为 DOM 元素设置标记
setDataAttr(elem, TAG_KEY, `${mutationCount}`);

4.3标记值获取

// 根据 DOM 元素获取标记时间
getMarkValueByDom(dom: HTMLElement) {
  const markValue = getDataAttr(dom, TAG_KEY);
  return getMarkValue(parseInt(markValue));
}

五、资源加载考虑

5.1资源类型识别

图片资源 标签的 src属性

视频资源:  标签的 src属性

背景图片: CSS background-image属性

嵌入资源: , 标签

5.2资源时间获取

// 从 Performance API 获取资源加载时间
const resourceTiming = resourceLoadingMap[resourceName];
const resourceTime = resourceTiming ? resourceTiming.responseEnd : 0;

5.3综合时间计算

// DOM 时间和资源时间的较大值
return Math.max(resourceTime, baseTime);

六、子页面支持

6.1时间偏移处理

// 子页面从调用 send 方法开始计时
const diffTime = this.startSubTime - this.initTime;
// 子页面只统计开始时间之后的资源
if (!isSubPage || resource.startTime > diffTime) {
  resourceLoadingMap[resourceName] = resource;
}

6.2FMP值调整

// 子页面的 FMP 值需要减去时间偏移
fmp = isSubPage ? value - diffTime : value;

七、FMP的核心优势

7.1用户感知导向

FMP 最大的优势在于它真正关注用户的实际体验:

  • 内容价值优先:只计算对用户有意义的内容渲染时间
  • 智能权重评估:根据元素的重要性和可见性进行差异化计算
  • 真实体验映射:更贴近用户的实际感知,而非技术层面的指标

7.2多维度计算体系

FMP 采用了更加全面的计算方式:

  • 元素权重分析:综合考虑元素类型和渲染面积的影响
  • 资源加载关联:将静态资源加载时间纳入计算范围
  • 算法对比验证:支持多种算法并行计算,确保结果准确性

7.3高精度测量

FMP 在测量精度方面表现突出:

  • DOM 变化追踪:基于实际 DOM 结构变化的时间点
  • API 数据融合:结合 Performance API 提供的详细数据
  • 统计分析支持:支持 P80 百分位等多种统计指标,便于性能分析

八、FMP的实际应用场景

8.1性能监控实践

FMP 在性能监控中发挥着重要作用:

  • 关键指标追踪:实时监控页面首次有意义内容的渲染时间
  • 瓶颈识别:快速定位性能瓶颈和潜在的优化点
  • 趋势分析:通过历史数据了解性能变化趋势

8.2用户体验评估

FMP 为产品团队提供了用户视角的性能评估:

  • 真实感知测量:评估用户实际感受到的页面加载速度
  • 竞品对比分析:对比不同页面或产品的性能表现
  • 用户满意度关联:将技术指标与用户满意度建立关联

8.3优化指导价值

FMP 数据为性能优化提供了明确的方向:

  • 资源优化策略:指导静态资源加载顺序和方式的优化
  • 渲染路径优化:帮助优化关键渲染路径,提升首屏体验
  • 量化效果评估:为优化效果提供可量化的评估标准

九、总结

通过这次深入分析,我对 FMP 有了更全面的认识。FMP 通过科学的算法设计,能够准确反映用户感知的页面加载性能,是前端性能监控的重要指标。

它不仅帮助我们更好地理解页面加载过程,更重要的是为性能优化提供了科学的依据。在实际项目中,合理运用 FMP 指标,能够有效提升用户体验,实现真正的"秒开"效果。

希望这篇文章能对正在关注前端性能优化的同学有所帮助,也欢迎大家分享自己的实践经验。

往期回顾

1. Dragonboat统一存储LogDB实现分析|得物技术

2. 从数字到版面:得物数据产品里数字格式化的那些事

3. 一文解析得物自建 Redis 最新技术演进

4. Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

5. RN与hawk碰撞的火花之C++异常捕获|得物技术

文 /阿列

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

什么是模块联邦?(Module Federation)

作者 eason_fan
2025年12月2日 11:37

一、前端模块共享的传统困境

在大型前端项目或多应用协作场景中,“模块共享” 是绕不开的需求 —— 比如多个应用复用同一套按钮组件、工具函数,或不同团队协作开发时共享基础模块。但在模块联邦(Module Federation)出现前,传统方案始终存在难以解决的痛点:

1. npm 包共享:迭代效率低下

将公共组件打包成 npm 包是最常见的方式,但流程繁琐:修改组件后需重新发布版本,所有依赖该包的应用都要手动更新依赖、重新构建部署。对于频繁迭代的内部组件库,这种 “发布 - 安装 - 重构” 的循环严重拖慢开发效率,且容易出现 “版本不一致导致的兼容问题”。

2. CDN 直接引入:粗糙且易出问题

为了跳过 npm 发布流程,部分团队会将组件打包成 umd 格式丢到 CDN,消费方通过

  • 依赖需手动管理:如果共享组件依赖 React、Vue 等库,消费方必须手动引入对应版本,否则会出现 “模块未定义” 的运行时错误;
  • 全局变量污染:umd 包通常挂载到 window 上,多个模块可能出现命名冲突;
  • 无法按需加载:
  • 无版本控制:CDN 资源更新后,需手动处理缓存或版本号,容易出现 “旧版本缓存导致的功能异常”。

3. 早期微前端:耦合度高,共享粒度粗

早期微前端方案(如 qiankun)更侧重 “应用级嵌入”—— 将多个独立应用整合成一个整体,但跨应用共享组件 / 模块时需额外适配(如通过自定义事件传递状态),集成成本高,且共享粒度仅停留在 “整个应用”,无法实现细粒度的组件 / 工具函数共享。

这些痛点的核心矛盾的是:传统方案无法在 “高效迭代”“按需加载”“依赖自动管理” 之间找到平衡,而模块联邦的出现,正是为了解决这一核心矛盾。

二、模块联邦:前端模块共享的终极方案

模块联邦(Module Federation,简称 MF)是 Webpack 5 推出的核心特性,其核心目标是:打破应用边界,让多个独立构建的前端应用,像使用本地模块一样按需共享组件、工具函数,且无需手动处理依赖和部署流程

简单来说,它就像一个 “前端模块的共享枢纽”—— 每个应用都可以作为 “模块提供者”(暴露自身模块)或 “模块消费者”(加载其他应用的模块),甚至两者兼具,形成灵活的模块共享生态。

三、模块联邦的核心原理(结合通俗理解)

很多人第一次接触模块联邦时,会有这样的直观认知:“是不是把共享组件放到 CDN 上,消费方通过 Webpack 识别 import 语句,再请求 CDN 资源并注入使用?”—— 这个理解方向完全正确,我们可以基于这个认知,拆解其底层原理:

核心原理一句话总结

模块联邦本质是:将共享模块(带依赖元信息)部署到远程服务器(如 CDN),Webpack 通过 “编译时标记 + 运行时加载”,让消费方用原生 import 语法加载远程模块;当代码执行到该 import 时,自动请求远程资源,经格式适配和依赖处理后,注入宿主应用的模块系统,实现 “本地使用” 的体验

原理拆解(分 4 步)

1. 模块提供者:打包 “带元信息的共享模块”

模块提供者(称为 Remote 应用)打包时,Webpack 会做两件关键事:

  • 生成 remoteEntry.js:这不是模块本身,而是 “模块导航文件”—— 包含模块清单(暴露了哪些模块、模块的真实地址)、依赖元信息(模块依赖的库如 React/Vue)、加载器函数(用于后续解析模块);
  • 拆分模块 chunk:将暴露的组件 / 工具函数拆成独立的 JS chunk(如 Button 组件对应 123.js),并部署到 CDN 或远程服务器。

这里的关键是:共享的不是 “裸模块”,而是 “带依赖元信息的模块单元” ,避免了传统 CDN 引入时 “依赖手动管理” 的问题。

2. 模块消费者:编译时标记远程模块

模块消费者(称为 Host 应用)配置 Webpack 时,会声明要加载的远程应用(如 app2: 'app2@cdn.example.com/remoteEntry…')。Webpack 编译时会识别这类远程模块的 import 语句(如 import 'app2/Button'),并做 “标记”—— 告诉运行时:“这个模块不是本地的,需要从远程加载”。

这一步就像你理解的 “Webpack 的 switch 功能”:编译时给远程模块打标签,运行时遇到标签就切换到 “远程加载逻辑”,而非本地模块的 “文件读取逻辑”。

3. 运行时:异步请求远程资源(非简单 script 插入)

当宿主应用运行到远程模块的 import 语句时,会触发以下流程:

  • 第一步:加载 remoteEntry.js:通过 Webpack 内置的异步加载逻辑(而非直接插入
  • 第二步:解析模块地址:remoteEntry.js 执行后,通过其内置的模块清单,找到目标模块(如 app2/Button)对应的真实 CDN 地址(如 cdn.example.com/123.js);
  • 第三步:加载目标模块:通过 Webpack 封装的异步加载函数(如 webpack_require.e)请求目标模块的 JS chunk,这一步同样不是简单插入

4. 注入使用:依赖自动处理 + 格式适配

目标模块加载完成后,Webpack 会做最后两件事:

  • 依赖自动复用:如果远程模块依赖的库(如 React),宿主应用已加载,则直接复用,避免重复打包;若未加载,则自动加载共享依赖(通过 shared 配置控制);
  • 格式适配与注入:将远程模块的代码格式,转换成宿主应用能识别的模块格式(避免全局污染),并注入宿主的模块系统 —— 此时,远程模块就像本地模块一样,可直接使用。

与 “简单 CDN 引入” 的核心区别

对比维度 简单 CDN 引入(umd 包) 模块联邦
依赖管理 手动引入,易冲突 自动识别依赖,共享复用
加载方式 插入 异步请求 Webpack 格式 chunk,无全局污染
按需加载 不支持,同步加载 支持,用到才加载
版本控制 需手动管理版本号 / 缓存 通过 shared 配置控制版本兼容
使用体验 需从 window 取模块,体验割裂 原生 import 语法,和本地模块一致

四、模块联邦的实际使用方法(React 示例)

下面用两个应用演示核心用法:app1(宿主应用,加载远程模块)和 app2(远程应用,暴露共享组件)。

前置条件

  • 构建工具:Webpack 5+(仅 Webpack 5 原生支持模块联邦);
  • 技术栈:React(Vue 用法类似,核心配置一致)。

1. 远程应用(app2:模块提供者)

步骤 1:配置 Webpack(webpack.config.js)

const { ModuleFederationPlugin } = require('webpack').container;
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  mode: 'development',
  devServer: { port: 3002 }, // 远程应用运行端口
  output: {
    uniqueName: 'app2', // 远程应用唯一标识(避免冲突)
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'app2', // 远程应用名称(宿主需通过该名称引用)
      filename: 'remoteEntry.js', // 模块导航文件(必须叫这个名字)
      exposes: {
        // 暴露的模块:key 是宿主引用的路径,value 是本地模块路径
        './Button': './src/Button', // 暴露 Button 组件
        './utils': './src/utils', // 暴露工具函数
      },
      // 共享依赖:避免 React/ReactDOM 重复打包
      shared: {
        react: { singleton: true, eager: true }, // singleton:单例模式;eager:优先加载
        'react-dom': { singleton: true, eager: true },
      },
    }),
    new HtmlWebpackPlugin({ template: './public/index.html' }),
  ],
};

步骤 2:编写共享模块

创建 src/Button.jsx(共享组件):

export default function App2Button() {
  return <button style={{ color: 'red', fontSize: '20px' }}>我是 app2 的共享按钮</button>;
}

步骤 3:启动远程应用

运行 npm run dev,远程应用会启动在 http://localhost:3002,此时可通过 http://localhost:3002/remoteEntry.js 访问导航文件。

2. 宿主应用(app1:模块消费者)

步骤 1:配置 Webpack(webpack.config.js)

const { ModuleFederationPlugin } = require('webpack').container;
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  mode: 'development',
  devServer: { port: 3001 }, // 宿主应用运行端口
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1', // 宿主应用名称(仅用于标识)
      // 配置要加载的远程应用:key 是远程应用名称,value 是 "远程名称@导航文件地址"
      remotes: {
        app2: 'app2@http://localhost:3002/remoteEntry.js',
      },
      // 共享依赖:和远程应用保持一致,避免重复打包
      shared: {
        react: { singleton: true, eager: true },
        'react-dom': { singleton: true, eager: true },
      },
    }),
    new HtmlWebpackPlugin({ template: './public/index.html' }),
  ],
};

步骤 2:使用远程模块

创建 src/App.jsx,用原生 import 语法加载远程模块:

import React, { lazy, Suspense } from 'react';
// 按需加载远程模块(推荐):用 lazy + Suspense 处理加载状态
const App2Button = lazy(() => import('app2/Button')); // 格式:远程应用名称/暴露的模块 key
const App2Utils = lazy(() => import('app2/utils'));
function App() {
  return (
    <div style={{ padding: '20px' }}>
      <h1>我是宿主应用(app1)</h1>
      {/* 远程模块加载时显示 fallback */}
      <Suspense fallback="加载中...">
        <App2Button />
      </Suspense>
    </div>
  );
}
export default App;

步骤 3:启动宿主应用

运行 npm run dev,访问 http://localhost:3001,即可看到来自 app2 的红色按钮 —— 此时,宿主应用已成功加载并使用远程模块,且完全感知不到 “这是远程资源”。

五、模块联邦的核心特性与适用场景

核心特性

  1. 独立部署:宿主和远程应用可各自独立开发、构建、部署,修改远程模块后无需重构宿主;
  1. 双向共享:一个应用既可以是宿主(加载模块),也可以是远程(暴露模块);
  1. 依赖去重:共享依赖自动复用,避免重复打包,减小包体积;
  1. 按需加载:远程模块仅在使用时才加载,优化首屏性能。

适用场景

  1. 微前端架构:实现细粒度的组件 / 模块共享,替代传统粗粒度的应用嵌入;
  1. 大型应用拆分:将巨型应用拆分为多个独立构建的子应用(如首页、订单页),各自迭代;
  1. 内部组件库共享:无需发布 npm 包,多个应用实时使用最新版公共组件;
  1. 跨团队协作:不同团队维护不同子应用,通过模块联邦无缝集成,降低协作成本。

六、使用注意事项

  1. 依赖版本兼容:共享依赖(如 React)需保证版本兼容,可通过 requiredVersion 配置限制版本;
  1. 构建工具限制:仅 Webpack 5+ 原生支持,Vite 需用 vite-plugin-federation 插件,Rollup 支持有限;
  1. 缓存与降级:给 remoteEntry.js 和模块 chunk 加哈希值(如 remoteEntry.[hash].js),避免缓存问题;同时需做好降级方案(如远程应用挂掉时,显示本地备用组件);
  1. 样式隔离:共享组件的样式需避免污染宿主,可使用 CSS Modules、Shadow DOM 等方案。

总结

模块联邦的核心价值,是让前端模块共享从 “繁琐的手动管理” 走向 “自动化、按需化、低耦合”。它没有改变开发者的使用习惯(仍用 import 语法),却解决了传统方案的核心痛点,让大型前端项目的拆分与协作变得更高效。

如果你正在面临 “多应用共享组件”“大型项目拆分” 等问题,模块联邦无疑是当前最理想的解决方案 —— 它不仅是一种技术,更是一种 “前端架构的设计思想”:打破边界,让模块自由流动

写埋点、扒 SDK、改框架:JS 函数复写 10 连招实战手册

2025年12月2日 11:12
# JS 中“复写函数”的 10 种姿势(附完整可运行 Demo)

在前端工程里,“复写一个函数”(Hook / Patch / Override)是非常常见的需求,尤其是在:

- 调试黑盒 SDK
- 做监控埋点
- 改第三方库行为但又不能直接改源码

本文把常见的 10 种手法整理成一个“武器清单”,**每种都给出可直接跑的 Demo**,方便你复制到控制台 / Node 里试。

---

## 🟦 一、直接覆写:最简单粗暴

**思路**:如果函数本来就是某个对象上的方法,直接重新赋值即可。

### 示例场景

已有一个业务对象:

```js
const userService = {
  getUserName(id) {
    console.log('[orig] getUserName', id);
    return 'Alice';
  }
};

// 原始调用
console.log('--- 原始调用 ---');
console.log(userService.getUserName(1));

现在想完全替换getUserName 的实现:

// 直接覆写
userService.getUserName = function (id) {
  console.log('[new] getUserName', id);
  return 'Bob';
};

console.log('--- 覆写后 ---');
console.log(userService.getUserName(1));

特点

  • 简单直接
  • 不保留原逻辑(除非你自己先存一份引用)

🟩 二、Monkey Patch:保留原逻辑再“加一层壳”(最常用)

思路:保存原函数引用 → 用同名函数包装一层。

示例:给 SDK 方法加埋点

// 模拟一个 SDK
const sdk = {
  track(eventName, payload) {
    console.log('[orig track]', eventName, payload);
    return { ok: true };
  }
};

// 原始使用
console.log('--- 原始调用 ---');
sdk.track('page_view', { path: '/home' });

// 开始 Monkey Patch
const origTrack = sdk.track;

sdk.track = function (...args) {
  console.log('[before track]', ...args);

  const result = origTrack.apply(this, args);

  console.log('[after track]', result);
  return result;
};

console.log('--- Patch 之后 ---');
sdk.track('page_view', { path: '/detail' });

适用

  • 想在原逻辑前后“插入”自己的逻辑
  • Hook 黑盒 SDK、监控、埋点等

🟧 三、Proxy:拦截整个对象(高级黑盒分析)

思路:用 Proxy 代理整个对象,在 get 里统一拦截函数调用。

示例:拦截所有方法调用做日志

// 一个“黑盒”对象,方法名可能会变
const api = {
  login(user, pwd) {
    console.log('login...', user);
    return { token: 'xxx' };
  },
  logout() {
    console.log('logout...');
  }
};

// 用 Proxy 包一层
const apiWithLog = new Proxy(api, {
  get(target, prop, receiver) {
    const value = Reflect.get(target, prop, receiver);

    if (typeof value === 'function') {
      return function (...args) {
        console.log(`[Proxy] call ${String(prop)} with`, args);
        const result = value.apply(this, args);
        console.log(`[Proxy] ${String(prop)} result`, result);
        return result;
      };
    }

    return value;
  }
});

// 使用方式跟原来一样
console.log('--- 使用 Proxy 后 ---');
apiWithLog.login('alice', '123456');
apiWithLog.logout();

适用

  • 不知道具体方法名(SDK 里一堆方法)
  • 想统一拦截所有方法调用(sniff)

🟨 四、Object.defineProperty:强行改 getter / setter / 不可枚举属性

思路:当属性不可写、或是 getter / setter,需要用 defineProperty 操作描述符。

示例 1:把只读属性改成可写函数

const obj = {};

// 初始定义:只读属性
Object.defineProperty(obj, 'foo', {
  value: 'readonly',
  writable: false,
  configurable: true,
  enumerable: true
});

console.log('原始 foo =', obj.foo);

// 直接赋值会失败(严格模式下报错,非严格模式下无效)
obj.foo = 'new';
console.log('直接赋值后 foo =', obj.foo); // 还是 readonly

// 用 defineProperty 强行改成函数
Object.defineProperty(obj, 'foo', {
  configurable: true,
  writable: true,
  value: function () {
    console.log('I am new foo()');
  }
});

console.log('--- 改成函数之后 ---');
obj.foo();

示例 2:用 getter 做懒加载 Hook

const service = {};

Object.defineProperty(service, 'expensiveApi', {
  configurable: true,
  get() {
    console.log('[getter] 初始化 expensiveApi');
    // 真正创建对象
    const realApi = {
      call() {
        console.log('real api called');
      }
    };
    // 用普通属性替换掉 getter(只初始化一次)
    Object.defineProperty(service, 'expensiveApi', {
      value: realApi,
      writable: false,
      configurable: false
    });
    return realApi;
  }
});

// 第一次访问会触发 getter
console.log('--- 第一次访问 ---');
service.expensiveApi.call();

// 第二次访问直接用缓存
console.log('--- 第二次访问 ---');
service.expensiveApi.call();

🟥 五、Hook 全局内置方法:网络、事件、路由等(非常强)

示例 1:拦截 fetch(浏览器)

// 保存原始 fetch
const origFetch = window.fetch;

// 覆写
window.fetch = async (...args) => {
  console.log('[Hook fetch] 请求参数:', args);

  const res = await origFetch(...args);

  // 复制一份 Response 查看内容(注意:这里只是示意,实际需考虑流只能读一次)
  const clone = res.clone();
  clone.text().then(text => {
    console.log('[Hook fetch] 响应文本截断:', text.slice(0, 100));
  });

  return res;
};

// 使用:之后所有 fetch 都会经过 Hook
// fetch('https://jsonplaceholder.typicode.com/todos/1');

示例 2:拦截所有 DOM 事件监听

const origAdd = EventTarget.prototype.addEventListener;

EventTarget.prototype.addEventListener = function (type, listener, options) {
  console.log('[Hook addEventListener]', type, 'on', this);

  // 也可以在这里包一层 listener,实现事件埋点
  return origAdd.call(this, type, listener, options);
};

// Demo:添加一个 click 事件
document.body.addEventListener('click', () => {
  console.log('body clicked');
});

还能 Hook

  • XMLHttpRequest.prototype.send
  • WebSocket.prototype.send
  • history.pushState / replaceState(前端路由监控)
  • console.log(调试场景)

🟪 六、代理模块加载:require Hook / 构建期 alias

6.1 Node:Hook require(CommonJS)

// hook-require.js
const Module = require('module');
const originalRequire = Module.prototype.require;

Module.prototype.require = function (path) {
  console.log('[require]', path);
  const exports = originalRequire.apply(this, arguments);

  // 针对指定模块做 Monkey Patch
  if (path === './sdk') {
    const origDo = exports.doSomething;
    exports.doSomething = function (...args) {
      console.log('[patched sdk.doSomething]', args);
      return origDo.apply(this, args);
    };
  }

  return exports;
};

// 主入口
require('./hook-require');
const sdk = require('./sdk');

sdk.doSomething('hello');
// sdk.js
exports.doSomething = function (msg) {
  console.log('[orig sdk.doSomething]', msg);
};

运行 node main.js 即可看到 require 日志 + patch 效果。


6.2 前端:通过 alias 替换整个模块

以 Vite 为例(Webpack 思路类似)。

步骤 1:配置 alias

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  resolve: {
    alias: {
      'some-sdk': '/src/sdk-wrapped.js' // 用你自己的 wrapper 替换原 SDK
    }
  }
});

步骤 2:在 wrapper 里再 Monkey Patch

// src/sdk-wrapped.js
import * as realSdk from 'some-sdk-original'; // 假设这是原 SDK 包名

const sdk = { ...realSdk };

const origInit = sdk.init;
sdk.init = function (...args) {
  console.log('[wrapped sdk.init]', args);
  return origInit.apply(this, args);
};

export default sdk;
export * from 'some-sdk-original'; // 其余原样导出

业务代码:

// import sdk from 'some-sdk'; // 实际拿到的是 sdk-wrapped.js
// sdk.init(...);

🟫 七、ES6 class 继承:在可控框架里“正统扩展”

思路:当你能控制实例创建时,直接用 extends 继承原类,覆写方法并按需 super.foo()

示例:扩展一个 UI 组件类

class Button {
  click() {
    console.log('[Button] clicked');
  }
}

// 原始使用
const btn = new Button();
btn.click();

// 新类:在原 click 前后加逻辑
class LogButton extends Button {
  click() {
    console.log('[LogButton] before click');
    const result = super.click();
    console.log('[LogButton] after click');
    return result;
  }
}

const logBtn = new LogButton();
logBtn.click();

适合

  • 你能自己 new 子类,而不是框架内部帮你 new
  • 比如内部业务类、自己封装的组件体系

🟩 八、Proxy + construct:拦截 new 的构造行为

思路:用 Proxy 代理“类本身”,在 construct 里拦截所有实例创建。

示例:给每个实例自动打 Patch

class Service {
  foo() {
    console.log('[Service.foo]');
  }
}

const PatchedService = new Proxy(Service, {
  construct(target, args, newTarget) {
    console.log('[Proxy construct] 创建 Service 实例,参数:', args);

    // 创建原始实例
    const instance = Reflect.construct(target, args, newTarget);

    // 给实例打补丁
    const origFoo = instance.foo;
    instance.foo = function (...fooArgs) {
      console.log('[patched foo] before', fooArgs);
      const result = origFoo.apply(this, fooArgs);
      console.log('[patched foo] after', result);
      return result;
    };

    return instance;
  }
});

// 之后统一用 PatchedService,而不是 Service
const s = new PatchedService();
s.foo();

适合

  • 第三方库暴露的是“类”,你可以控制 new 的地方
  • 想让所有实例都应用同一套 Patch

🟦 九、Hook 原型:对所有实例生效

思路:直接改 Class.prototype.xxx 或内置类型原型,例如 Array.prototype.push

示例 1:改自定义类的原型方法

function Person(name) {
  this.name = name;
}

Person.prototype.sayHi = function () {
  console.log('Hi, I am', this.name);
};

const p1 = new Person('Alice');
p1.sayHi();

// Patch 原型
const origSayHi = Person.prototype.sayHi;

Person.prototype.sayHi = function () {
  console.log('[patched] before sayHi');
  origSayHi.call(this);
  console.log('[patched] after sayHi');
};

const p2 = new Person('Bob');

console.log('--- Patch 之后 ---');
p1.sayHi(); // 旧实例也会受影响
p2.sayHi();

示例 2:给所有数组的 push 打日志(谨慎使用)

const origPush = Array.prototype.push;

Array.prototype.push = function (...items) {
  console.log('[Array.push patched] this =', this, 'items =', items);
  return origPush.apply(this, items);
};

const arr = [1, 2];
arr.push(3);  // 会打印日志

注意:改内置原型影响范围非常大,要在可控环境下使用。


🟣 十、动态注入 <script>:在不能改源码时复写全局函数

思路:在浏览器通过脚本注入的方式覆盖某个全局函数(油猴脚本、Chrome 插件常用)。

示例:复写页面里已有的 window.someFn

假设目标页面有:

window.someFn = function () {
  console.log('[page] someFn');
};

你在自己的脚本里注入一段:

(function inject() {
  const script = document.createElement('script');

  script.innerHTML = `
    (function () {
      const orig = window.someFn;
      window.someFn = function (...args) {
        console.log('[injected] before someFn', args);
        const result = orig && orig.apply(this, args);
        console.log('[injected] after someFn', result);
        return result;
      };
    })();
  `;

  document.documentElement.appendChild(script);
  script.remove();
})();

执行后,页面中所有对 someFn 的调用都会走你注入的版本。

适用

  • 你不能改页面源码,但能注入 JS(油猴脚本 / 浏览器扩展 / WebView 注入等)

🔥 总结:复写函数的“武器清单”

方法 能力/复杂度 典型场景
直接覆写 最简单 你能直接访问对象和方法名
Monkey Patch ⭐最常用 Hook SDK、埋点、调试
Proxy(拦截对象) ⭐高级黑盒分析 不知道具体方法名,想全量 sniff
defineProperty 强力 只读属性、getter/setter 代理
Hook 全局 API ⭐非常强 网络、事件、路由、日志监控
require Hook / alias 构建期 替换整个 SDK 模块
class 继承 正统扩展 自己可控的类/组件
Proxy + construct 高级 拦截所有实例的创建过程
原型 Hook 全局影响 对所有实例生效(谨慎)
script 注入 浏览器插件/油猴场景 不能改源码、只能运行时注入

--

Uni-App 鸿蒙应用微信相关功能上架踩坑:自制微信安装检测插件

作者 MrTan
2025年12月2日 11:07

背景:上架失败的痛点

做 Uni-App 跨端开发时,鸿蒙应用上架遇到了一个棘手问题:应用集成了微信登录、支付、分享等核心功能,但上架平台的测试机未安装微信,导致这些功能触发时直接崩溃,最终上架失败。

翻遍了 Uni-App 官方文档、鸿蒙开发者社区,甚至各类技术论坛,都没找到现成的「鸿蒙平台检测微信是否安装」的解决方案 —— 毕竟 Uni-App 对鸿蒙的适配还在持续完善中,很多原生能力的跨端封装还未覆盖到。

无奈之下,只能自己摸索鸿蒙原生能力与 Uni-App 的桥接方式,最终实现了一个轻量的检测插件,完美解决了上架问题。今天把这个过程分享出来,希望能帮到有同样需求的开发者~

核心思路

Uni-App 调用鸿蒙原生能力,本质是通过「Uni-App 插件 + 鸿蒙原生代码」的方式实现桥接:

  1. 鸿蒙原生层:通过鸿蒙的包管理能力,查询设备是否安装了微信(微信包名:com.tencent.mm
  2. 插件层:将原生查询结果封装成 Uni-App 可调用的 API
  3. 应用层:在调用微信登录 / 支付 / 分享前,先通过插件检测是否安装微信,避免崩溃

插件实现步骤(附完整代码)

一、创建 Uni-App 插件结构

首先通过 HBuilder X 在 Uni-App 项目中右键 uni_modules,新建 UTS插件-API插件,标准结构如下:

image.png

二、编写鸿蒙原生检测逻辑

1. 路径 uni_modules=>hl-appcheckplugin=>utssdk=>interface.uts

/**
 * 内部结果类型(避免对象字面量作为类型)
 */
export interface CheckResult {
  isInstalled: boolean;
  errMsg: string;
}

/**
 * 检查微信安装状态的入参配置
 */
export interface CheckWeChatInstalledOptions {
  /** 检查成功回调(仅当已安装时触发) */
  success?: (result: CheckWeChatInstalledSuccess) => void;
  /** 检查失败回调(未安装或检查出错时触发) */
  fail?: (result: CheckWeChatInstalledFail) => void;
  /** 检查完成回调(无论成败) */
  complete?: (result: CheckWeChatInstalledComplete) => void;
}

/**
 * 成功回调结果类型
 */
export interface CheckWeChatInstalledSuccess {
  isInstalled: true;
  errMsg: "ok"; // 严格字面量类型,避免 string 不匹配
}

/**
 * 失败回调结果类型
 */
export interface CheckWeChatInstalledFail {
  isInstalled: false;
  errMsg: string; // 错误信息描述
}

/**
 * 完成回调结果类型
 */
export interface CheckWeChatInstalledComplete {
  isInstalled: boolean;
  errMsg: string;
}

2. 路径 uni_modules=>hl-appcheckplugin=>utssdk=>app-harmony=>index.uts

import {
CheckWeChatInstalledOptions,
CheckWeChatInstalledSuccess,
CheckWeChatInstalledFail,
CheckWeChatInstalledComplete,
CheckResult
} from '../interface.uts'

// 重新导出类型,供外部使用
export type {
CheckWeChatInstalledOptions,
CheckWeChatInstalledSuccess,
CheckWeChatInstalledFail,
CheckWeChatInstalledComplete,
CheckResult
}

import { bundleManager } from '@kit.AbilityKit';
import hilog from '@ohos.hilog';
import { BusinessError } from '@kit.BasicServicesKit';

/**
 * 常量定义(使用大写+下划线命名规范)
 */
export const WECHAT_BUNDLE_NAME = 'com.tencent.mm'; // 微信官方包名(Android/鸿蒙通用)
const LOG_TAG = 'HL_APPCHECK'; // 日志标签常量
const LOG_DOMAIN = 0x0000; // 日志域常量

/**
 * 检查设备是否安装微信(优化版)
 * @param options 配置参数(包含成功、失败、完成回调)
 * @returns Promise<CheckResult> 异步返回检查结果(支持 Promise 链式调用)
 */
export async function checkWeChatInstalled(
options : CheckWeChatInstalledOptions = {} // 给参数设置默认值,避免 undefined 报错
) : Promise<CheckResult> {
// 初始化默认结果
let result : CheckResult = {
isInstalled: false,
errMsg: '检查中...'
};

try {
// 检查微信是否安装
const isInstalled = await isAppInstalled(WECHAT_BUNDLE_NAME);

if (isInstalled) {
// 微信已安装 - 构造成功结果
result = {
isInstalled: true,
errMsg: 'ok'
};
// 调用成功回调(类型安全转换)
options.success?.(result as CheckWeChatInstalledSuccess);
hilog.info(LOG_DOMAIN, LOG_TAG, '微信已安装');
} else {
// 微信未安装 - 构造失败结果
result = {
isInstalled: false,
errMsg: '微信未安装'
};
// 调用失败回调(类型安全转换)
options.fail?.(result as CheckWeChatInstalledFail);
hilog.info(LOG_DOMAIN, LOG_TAG, '微信未安装');
}

} catch (err) {
// 异常处理
const error = err as BusinessError | Error;
const errorCode = 'code' in error ? error.code : 'unknown';
const errorMsg = error.message || `检查失败(错误码:${errorCode})`;

// 构造错误结果
result = {
isInstalled: false,
errMsg: errorMsg
};

// 调用失败回调
options.fail?.(result as CheckWeChatInstalledFail);
// 错误日志增强
hilog.error(LOG_DOMAIN, LOG_TAG, `检查微信安装状态失败:${errorMsg},错误码:${errorCode}`);

} finally {
// 调用完成回调
options.complete?.(result as CheckWeChatInstalledComplete);
hilog.info(LOG_DOMAIN, LOG_TAG, `检查微信安装状态完成:${JSON.stringify(result)}`);
}

// 返回 Promise 结果,支持 async/await 调用
return result;
}

/**
 * 检查指定包名的应用是否已安装)
 * @param bundleName 应用包名
 * @returns Promise<boolean> 是否已安装
 */
async function isAppInstalled(bundleName : string) : Promise<boolean> {
try {
// 获取应用信息
await bundleManager.getBundleInfo(
bundleName,
bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT
);
return true;
} catch (err) {
return false;
}
}

三、Uni-App 中使用插件

1. 调用检测方法

在需要使用微信功能的页面(如登录页、支付页),先调用检测方法:

    import { checkWeChatInstalled } from "@/uni_modules/hl-appcheckplugin"

    checkApp() {
      // 统一返回 Promise,确保所有端行为一致
      return new Promise((resolve) => {
        // #ifdef APP-PLUS
        const isExist = plus.runtime.isApplicationExist({
          pname: 'com.tencent.mm',
          action: 'weixin://'
        });
        resolve(isExist);
        // #endif

        // #ifdef APP-HARMONY
        checkWeChatInstalled({
          success: () => {
            console.log('微信已安装');
            resolve(true);
          },
          fail: (err) => {
            console.log('微信未安装或检测失败', err);
            resolve(false);
          }
        });
        // #endif

        // #ifdef MP-WEIXIN
        resolve(true);
        // #endif
      });
    },

公司 React 应用感觉很慢,我把没必要的重复渲染砍掉了 40%!

作者 孟祥_成都
2025年12月2日 10:47

前言

公司 React 应用表现很差。重复渲染感觉像迷一样,不知道大家有不没有过这样的经历:

  • 明明传入的数据没变,组件却还是重渲染
  • 父组件一更新,很多子组件跟着一起重渲染(部分子组件数据并没有变化)
  • 在组件内部定义函数,导致每次渲染都出现“新的”函数引用
  • Context API 的更新把大块区域都刷新了,而不是只刷新需要的那一小块

这些问题也侧面反映,要求 React 使用者需要更多的心智以及对框架原理更多了解,才能做好 React 开发。

但这也是一个很好的话题,当面试官问你性能优化的时候,关于 React 框架层面的优化,可以参考这篇文章,如何解决公司的 React 应用重复渲染问题!

首先我们需要找到为什么性能出现问题的原因

用 Profiler 把问题揪出来

第一步开始用数据查看到底哪里出了问题。React DevTools 里的 Profiler 很好用,它可以记录你在应用中的交互,告诉你哪些组件重渲染了、为什么重渲染、用了多长时间。

装好 React DevTools 后,打开 “Profiler” 标签。开始录制,做一些感觉卡顿的操作,然后停止录制。

生成的“火焰图”能很直观的告诉我们发生了什么。我能看到一些组件在 props 看起来没变的情况下也在重渲染。这就成了我优化的切入点。

1_eZCNwCucXBbH7il1bUI-3g.webp

一些简单却有效的修复措施

定位到问题区域后,我开始逐个尝试修复。有些方法很简单,但效果很明显。

用 React.memo 阻止无效渲染

一个很常见的问题是:父组件重渲染时,子组件也跟着重渲染,哪怕它自己的 props 并没有变化。

  • 问题:默认情况下,只要父组件渲染,React 也会把它的子组件一起渲染。
  • 解决:用 React.memo 包裹组件,告诉 React:“只有当这个组件的 props 真的变了才重渲染。注意,它只会对 props 做浅比较!举例如下:

之前:

function UserProfile({ name }) {
  console.log('Rendering UserProfile');
  return <div>{name}</div>;
}

之后:

import { memo } from 'react';

const UserProfile = memo(function 
UserProfile({ name }) {
  console.log('Rendering UserProfile');
  return <div>{name}</div>;
});

把组件用 React.memo 包起来后,它只会在 name 这个 prop 变化时重渲染,而不是每次父组件渲染都跟着来一遍。

用 useMemoizedFn 固定函数引用

在复杂场景最好放弃用 useCallback,写依赖是一个很麻烦的事情,强烈建议使用 ahooks 库导出的 useMemoizedFn 函数。为什么呢?

  • useCallback 通过依赖项保证“函数引用稳定”,但只在依赖不变时稳定;一旦依赖变更,函数引用也会变,容易让子组件误以为 props 变了而重渲染。同时会有闭包中拿到旧值的问题。
  • ahooks 的 useMemoizedFn 始终返回“稳定的函数引用”,同时内部能拿到最新的闭包值,避免“陈旧闭包”问题与不必要的重渲染。

举例:

useCallback

// useCallback:依赖变化会导致函数引用变化
const [state, setState] = useState('');

// 当 state 变化,  func 函数的引用才变化
const func = useCallback(() => {
  console.log(state);
}, [state]);

useMemoizedFn

// useMemoizedFn:始终稳定的函数引用,内部总能拿到最新状态,不用写第二个依赖参数
const func = useMemoizedFn(() => {
  console.log(state);
});

用 useMemo 让对象和数组保持稳定

和函数类似,如果你在渲染过程中创建对象或数组,也会带来问题。

  • 问题:每次渲染都会新建一个对象或数组,即便里面的数据没变。把它作为 prop 传给经过记忆化的子组件,仍然会触发重渲染。
  • 解决: useMemo 会对值进行记忆化,只在依赖发生变化时才重新计算。

之前:

function StyleComponent({ isHighlighted }) {
  const style = {
    backgroundColor: isHighlighted ? 
    'yellow' : 'white',
    padding10
  };

  return <div style={style}>Some content</
  div>;
}

之后:

import { useMemo } from 'react';

function StyleComponent({ isHighlighted }) {
  const style = useMemo(() => ({
    backgroundColor: isHighlighted ? 
    'yellow' : 'white',
    padding10
  }), [isHighlighted]);

  return <div style={style}>Some content</
  div>;
}

用 useMemo 后, style 这个对象只会在 isHighlighted 变化时才被重新创建,避免了不必要的重渲染。

拆分 Context

  • Context API 能避免层层传递 props,但也可能成为性能陷阱。
  • 问题:只要某个 Context 的值变化,所有消费它的组件都会重渲染,即便它只关心其中未变的那一小部分数据。
  • 解决:不要用一个“大而全”的 Context,把不同的状态拆分成多个更小的 Context。
  • 例如:不再用一个同时包含用户数据、主题设置、通知的 AppContext ,而是拆成 UserContext 、 ThemeContext 、 NotificationContext 。这样主题更新只会重渲染使用 ThemeContext 的组件。

最终效果

  • 应用这些修复后再次用 Profiler 测试,差异非常明显。持续的重复渲染消失了,交互更顺滑。
  • 数据显示整体渲染时间减少了约 40%,应用终于顺畅运行。

欢迎加入交流群

  • 插入一个我开发的 headless(无样式) 组件库的广告,前端组件库覆盖了前端绝大多数技术场景,如果你想对生产级可用的组件库开发感兴趣,欢迎了解,也欢迎加入交流群:
❌
❌