普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月2日掘金 前端

什么是模块联邦?(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(无样式) 组件库的广告,前端组件库覆盖了前端绝大多数技术场景,如果你想对生产级可用的组件库开发感兴趣,欢迎了解,也欢迎加入交流群:

全局重复接口取消&重复提示

2025年12月2日 10:46

重复接口取消

思路

我们创建了一个pendingMap对象,它用于存储每个请求的标识(请求路径、请求方式、请求参数3个维度唯一标识)和取消函数。当请求被发出时,我们将其添加到pendingMap中。再次调用pendingMap存在此请求,则取消请求,另外再此接口返回时,同样取消请求。

实现

1、创建AxiosCanceler类,定义了addPending、removeAllPending和removePending三个方法。addPending方法用于将请求添加到pendingMap中。removeAllPending方法可以用于取消所有请求,而removePending方法可以用于取消单个请求。

import type { AxiosRequestConfig } from 'axios'
import { generateRequestCode } from './index'

// 用于存储每个请求的标识和取消函数
const pendingMap = new Map<string, AbortController>()

export class AxiosCanceler {
  /**
   * 添加请求
   * @param config 请求配置
   */
  public addPending(config: AxiosRequestConfig): void {
    // 立刻移除重复请求
    this.removePending(config)
    // 请求唯一标识code
    const requestCode = generateRequestCode(config)
    // 取消请求对象
    const controller = new AbortController()
    config.signal = controller.signal
    if (!pendingMap.has(requestCode)) {
      // 如果当前请求不在等待中,将其添加到等待中
      pendingMap.set(requestCode, controller)
    }
  }

  /**
   * 清除所有等待中的请求
   */
  public removeAllPending(): void {
    pendingMap.forEach((abortController) => {
      if (abortController) {
        abortController.abort()
      }
    })
    this.reset()
  }

  /**
   * 移除请求
   * @param config 请求配置
   */
  public removePending(config: AxiosRequestConfig): void {
    const requestCode = generateRequestCode(config)
    if (pendingMap.has(requestCode)) {
      // 如果当前请求在等待中,取消它并将其从等待中移除
      const abortController = pendingMap.get(requestCode)
      if (abortController) {
        abortController.abort(requestCode)
      }
      pendingMap.delete(requestCode)
    }
  }

  /**
   * 重置
   */
  public reset(): void {
    pendingMap.clear()
  }
}

2、创建获取唯一标识请求code的方法generateRequestCode,通过url、method、data、params来唯一标识

import type { AxiosRequestConfig } from 'axios'

/**
 * 标准化参数对象
 * @param {Record<string, any> | null | undefined} params - 需要标准化的参数
 * @returns {Record<string, any>} - 标准化后的参数对象
 */
function normalizeParams(params?: Record<string, any> | null | undefined): Record<string, any> {
  // 处理undefined和未传参的情况
  if (arguments.length === 0 || params === undefined) {
    return {}
  }

  // 处理null和其他空值情况
  if (params === null) {
    return {}
  }

  // 如果是字符串,尝试解析为JSON对象
  if (typeof params === 'string') {
    try {
      const parsed = JSON.parse(params)
      if (typeof parsed === 'object' && parsed !== null) {
        return sortObjectDeep(parsed)
      }
    } catch (e) {
      // 解析失败,返回空对象
    }
    return {}
  }

  // 如果不是对象类型,返回空对象
  if (typeof params !== 'object') {
    return {}
  }

  // 如果是数组,返回空对象
  if (Array.isArray(params)) {
    return {}
  }

  // 检查是否为空对象
  if (Object.keys(params).length === 0) {
    return {}
  }

  // 对非空对象进行深度排序
  return sortObjectDeep(params)
}

/**
 * 深度排序对象
 * @template T - 输入对象类型
 * @param {T} obj - 需要排序的对象
 * @returns {T} - 排序后的对象
 */
function sortObjectDeep<T>(obj: T): T {
  if (typeof obj !== 'object' || obj === null) {
    return obj
  }

  if (Array.isArray(obj)) {
    return obj.map(sortObjectDeep).sort() as T
  }

  return Object.keys(obj as Record<string, any>)
    .sort()
    .reduce((result: Record<string, any>, key: string) => {
      result[key] = sortObjectDeep((obj as Record<string, any>)[key])
      return result
    }, {}) as T
}

/**
 * 生成请求唯一编码
 * @param {AxiosRequestConfig} config - Axios请求配置
 * @returns {string} - 唯一编码
 */
export function generateRequestCode(
  config: AxiosRequestConfig,
): string {
  // 确保config存在
  if (!config) {
    throw new Error('请求配置为必填参数')
  }

  // 确保url和method存在
  if (!config.url || !config.method) {
    throw new Error('URL和method为必填参数')
  }

  // 处理params的特殊情况
  const normalizedParams = normalizeParams(config.params)

  const normalizedData = normalizeParams(config.data)
  // 拼接字符串
  const stringToHash = `${config.url.toLowerCase()}|${config.method.toUpperCase()}|${JSON.stringify(normalizedParams)}|${JSON.stringify(normalizedData)}`
  
  return stringToHash
}

3、修改请求拦截器

instance.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    ... 
    axiosCanceler.addPending(config)
    return config
  },
  (error) => {
    return Promise.reject(error.data.error.message)
  },
)

// 响应拦截器
instance.interceptors.response.use(
  function(response) {
    ....
    // 移除请求
    axiosCanceler.removePending(response.config)
    return Promise.resolve(response)
  },
  function(error) {
    // 请求被取消时,返回错误提示
    if (isCancel(error)) {
      return Promise.reject('重复请求,已取消')
    }
    // 移除请求
    const { response } = error
    if (response && response.config) {
      axiosCanceler.removePending(response.config)
    } else if (error.config) {
      // 处理请求被取消的情况
      axiosCanceler.removePending(error.config)
    }
    if (response) {
      return Promise.reject(response)
    } else {
      /*
       * 处理断网的情况
       * eg:请求超时或断网时,更新state的network状态
       * network状态在app.vue中控制着一个全局的断网提示组件的显示隐藏
       * 后续增加断网情况下做的一些操作
       */
      // 断网情况下,清除所有请求
      axiosCanceler.removeAllPending()
    }
  },
)
// 只需要考虑单一职责,这块只封装axios
export default instance

重复提示

这里我是通过直接二次封装element的消息提示方法,我这里采用的是vue3,统一入口方式去做的,如果是vue2,可以在main.js将$message挂载到this之前做一下重写

代码

import { ElMessage, MessageHandler } from 'element-plus'

// 防止重复弹窗
let messageInstance: MessageHandler | null = null

interface MessageOptions {
  message: string
  type?: 'success' | 'warning' | 'info' | 'error'
  [key: string]: any
}

const mainMessage = (options: MessageOptions | string): MessageHandler => {
  // 如果弹窗已存在先关闭
  if (messageInstance) {
    messageInstance.close()
  }

  const messageOptions = typeof options === 'string'
    ? { message: options }
    : options

  messageInstance = ElMessage(messageOptions)
  return messageInstance
}


const extendedMainMessage: any = mainMessage

const arr: Array<'success' | 'warning' | 'info' | 'error'> = ['success', 'warning', 'info', 'error']
arr.forEach((type) => {
  extendedMainMessage[type] = (options: MessageOptions | string) => {
    const messageOptions = typeof options === 'string'
      ? { message: options }
      : { ...options }

    messageOptions.type = type
    return mainMessage(messageOptions)
  }
})

// message消息提示
export const $success = (msg: string) => {
  mainMessage({
    message: msg || '操作成功',
    type: 'success',
  })
}
export const $warning = (msg: string) => {
  mainMessage({
    message: msg || '操作失败',
    type: 'warning',
  })
}
export const $error = (msg: string) => {
  mainMessage({
    message: msg || '操作失败',
    type: 'error',
  })
}

export const $info = (msg: string) => {
  mainMessage({
    message: msg,
    type: 'info',
  })
}

使用

此时连续调用始终只会有一个消息体出现(successsuccess、error、warningwarning、info均共用同一个消息体)

import { $error, $success } from '@/hooks/index'

$error('错误提示1')
$error('错误提示2')

项目性能优化实践:深入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++异常捕获|得物技术

文 /阿列

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

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

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

前端导出页面内容为PDF

2025年12月2日 10:45

1.前言

接到一个将窗合同签署弹窗导出盖章之后的内容为PDF需求,首先想到后端导出,后端反馈导出样式把控困难,实现时间非常久。前端被迫接活导出PDF🤡。

2.实现方案

采用html2Canvas对页面内容进行截图,生成页面内容对应的图片,之后通过JsPDF将图片添加到pdf文件,并导出为PDF文件

html2Canvas + JsPDF

import html2Canvas from 'html2canvas'
import JsPDF from 'jspdf'

3.具体代码实现

具体代码位置:pv-admin\src\views\devopsManage\noticeManage\list\components\previewDialog.vue

这里大概阐述一下代码逻辑。

1.采用html2Canvas生成一张完整的图片

2.判断图片是否大于一页,如果只有一页则通过pdf.addImage方法塞入pdf

3.如果图片长度大于一页,则需要将图片分割生成多页,这里图片分割采用的方案是,通过canvas的drawImage方法渲染图片的某个高度到某个高度的内容,然后再转成图片,然后通过pdf.addImage一页一页的塞到pdf

4.通过pdf.output('blob')既可获取pdf文件流。

async generatePdf() {
  this.loading = true
  try {
    // 导出内容对应的dom的ref
    const element = this.$refs.rightContent
    const canvas = await html2Canvas(element, {
      allowTaint: true,
      useCORS: true,
      scale: 2,
      logging: false,
      letterRendering: true,
    })
    
    const imgData = canvas.toDataURL('image/jpeg', 1.0)
    // 初始化一个A4纸大小的PDF
    const pdf = new JsPDF('p', 'pt', 'a4')
    
    // 获取PDF的宽度和高度
    const pdfWidth = pdf.internal.pageSize.getWidth()
    const pdfHeight = pdf.internal.pageSize.getHeight()
    
    // 设置页面边距
    const marginTop = 40
    const marginBottom = 60
    
    // 计算图片的宽度和高度及比例
    const imgWidth = canvas.width
    const imgHeight = canvas.height
    const ratio = pdfWidth / imgWidth
    const scaledImgHeight = imgHeight * ratio
    
    // 使用新的分页方法:按照页面高度切割原始图像
    if (scaledImgHeight <= pdfHeight - marginTop - marginBottom) {
      // 如果内容高度不超过一页,直接添加图像
      pdf.addImage(imgData, 'JPEG', 0, marginTop, pdfWidth, scaledImgHeight)
    } else {
      // 如果内容超过一页,使用多页PDF
      let remainingHeight = imgHeight
      let yOffset = 0
      let pageCount = 0
      
      while (remainingHeight > 0) {
        // 计算当前页能显示的高度(以原始图像高度计算)
        const pageHeightInCanvas = ((pdfHeight - marginTop - marginBottom) / ratio)
        // 计算实际可用的高度
        const heightToPrint = Math.min(pageHeightInCanvas, remainingHeight)
        // 转换为PDF上实际高度
        const heightOnPdf = heightToPrint * ratio
        
        // 创建一个新的canvas,只包含当前页需要的部分
        const tmpCanvas = document.createElement('canvas')
        tmpCanvas.width = imgWidth
        tmpCanvas.height = heightToPrint
        
        const ctx = tmpCanvas.getContext('2d')
        // 将原始canvas的特定部分绘制到临时canvas
        ctx.drawImage(
          canvas,
          0, yOffset, // 源图像的起始位置
          imgWidth, heightToPrint, // 源图像的宽高
          0, 0, // 目标起始位置
          imgWidth, heightToPrint // 目标宽高
        )
        
        // 如果不是第一页,添加新页
        if (pageCount > 0) {
          pdf.addPage()
        }
        
        // 将当前页绘制到PDF
        const pageImgData = tmpCanvas.toDataURL('image/jpeg', 1.0)
        pdf.addImage(pageImgData, 'JPEG', 0, marginTop, pdfWidth, heightOnPdf)
        
        // 更新剩余高度和垂直偏移
        remainingHeight -= heightToPrint
        yOffset += heightToPrint
        pageCount++
      }
    }
    
    // 获取PDF的blob对象
    const pdfBlob = pdf.output('blob')
    // 创建File对象
    const fileName = `运维工作告知函_${this.detail.noticeNo}.pdf`
    const pdfFile = new File([pdfBlob], fileName, { type: 'application/pdf' })
    
    // 生成文件路径
    const filePath = '/notice/' + new Date().getTime() + '/' + fileName
    
    // 直接上传文件
    try {
      const res = await UploadFile(pdfFile, filePath, filePath.substring(1))
      if (res && res.url) {
        this.queryFn(res.url)
      } else {
        this.$message.error('生成PDF失败')
      }
    } catch (error) {
      console.error('上传PDF失败:', error)
      this.$message.error('上传PDF失败')
    }
  } catch (error) {
    console.error('生成PDF失败:', error)
    this.$message.error('生成PDF失败')
  } finally {
    this.loading = false
  }
},

4.效果展示

📎运维工作告知函_YW202507-02.pdf

5.存在问题

采用html2Canvas生成的图片,如果过长,分页时,分页截断的位置不好控制,会出现部分模块被分割到两页(哪怕一行字也看也能会被从中间截断展示)

word对比工具从入门到出门

作者 王大宇_
2025年12月2日 10:35

Word对比

本文,我们将实现一个Word对比工具

🌰demo

思路

Word文档解析

Word的本质是XML压缩包,在我们的diff过程中,我们只关心纯文本的变化,因此我们需要先将word进行解析转化。

本文借助mammoth.js完成

文本token化

根据不同的粒度做分词

Diff算法核心

LCS最长公共子序列算法

双栏对比

本文使用antd组件库

实现

文档解析

首先,我们通过mammoth将word转化为string

  // 上传word 并提取文本
  async function handleUpload(file: File, type: 'old' | 'new') {
    const arrayBuffer = await file.arrayBuffer();
    // mammith.extractRawText从docx中提取纯文本
    const { value } = await mammoth.extractRawText({ arrayBuffer });
    if (type === 'old') {
      setOldText(value);
    } else {
      setNewText(value);
    }
  }

分词

按照不同的粒度,将string划分

  // 分词
  function tokenize(text: string, mode: string): string[] {
    // 按行分,按换行符拆分为数组
    if (mode === 'line') return text.split(/\r?\n/);
    // 按字符分,把字符串拆分成单个字符数组
    if (mode === 'char') return text.split('');
    //用正则将文本切成中文单字、英文单词、其他单个符号
    return text.match(/[\u4e00-\u9fa5]|\w+|[^\w\u4e00-\u9fa5]/g) || [];
  }

Diff

最长公共子序列
  // LCS构建DP
  function buildLCS(a: string[], b: string[]) {
    // 构建二维数组,并将其初始化为0
    // dp[i][j]表示从a[i:]与b[j:]的最长公共子序列长度
    const dp = Array.from({ length: a.length + 1 }, () =>
      Array(b.length + 1).fill(0)
    );

    // 倒序填充dp
    for (let i = a.length - 1; i >= 0; i--) {
      for (let j = b.length - 1; j >= 0; j--) {
        if (a[i] === b[j]) {
          dp[i][j] = dp[i + 1][j + 1] + 1;
        } else {
          dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
        }
      }
    }

    return dp;
  }
差异计算

基于二维数组计算文本的差异,标记新增、删除、相同的部分

  • 设置两个指针分别指向新旧文本
  • 当元素相同,标记为equal,两个指针同时向前
  • 当新文本还有剩余,旧文本已经遍历完,插入新元素
  • 当不满足插入条件,为删除旧元素
  // 得到diff结构
  function diffTokens(a: string[], b: string[]): DiffPart[] {
    //构建新旧内容dp数组
    const dp = buildLCS(a, b);
    let i = 0,
      j = 0;
    const result: DiffPart[] = [];

    // 便利新旧内容
    while (i < a.length && j < b.length) {
      //若当前token相等,则记录下来并移动i j
      if (i < a.length && j < b.length && a[i] === b[j]) {
        result.push({ type: 'equal', text: a[i] });
        i++;
        j++;
      } else if (
        j < b.length &&
        (i === a.length || dp[i][j + 1] >= dp[i + 1][j])
      ) {
        result.push({ type: 'insert', text: b[j] });
        j++;
      } else if (i < a.length) {
        result.push({ type: 'delete', text: a[i] });
        i++;
      }
    }
    return result;
  }
数据流动🌰
// 旧文本
const oldText = "今天天气很好,我们去公园散步吧。";
// 新文本  
const newText = "今天天气不错,我们去山上徒步吧,顺便看看风景。";
// 对比粒度
const granularity = "word";

分词

// 旧文本分词结果
const oldTokens = tokenize("今天天气很好,我们去公园散步吧。", "word");
// 结果: ["今天", "天气", "很", "好", ",", "我们", "去", "公园", "散步", "吧", "。"]

// 新文本分词结果
const newTokens = tokenize("今天天气不错,我们去山上徒步吧,顺便看看风景。", "word"); 
// 结果: ["今天", "天气", "不", "错", ",", "我们", "去", "山上", "徒步", "吧", ",", "顺便", "看看", "风景", "。"]

构建LCS

oldTokens = ["今天", "天气", "很", "好", ",", "我们", "去", "公园", "散步", "吧", "。"]
newTokens = ["今天", "天气", "不", "错", ",", "我们", "去", "山上", "徒步", "吧", ",", "顺便", "看看", "风景", "。"]

Diff

[
  { type: 'equal',  text: '今天' },
  { type: 'equal',  text: '天气' },
  { type: 'delete', text: '很' },
  { type: 'delete', text: '好' },
  { type: 'equal',  text: ',' },
  { type: 'equal',  text: '我们' },
  { type: 'equal',  text: '去' },
  { type: 'insert', text: '山上' },
  { type: 'delete', text: '公园' },
  { type: 'insert', text: '徒步' },
  { type: 'equal',  text: '吧' },
  { type: 'insert', text: ',' },
  { type: 'insert', text: '顺便' },
  { type: 'insert', text: '看看' },
  { type: 'insert', text: '风景' },
  { type: 'equal',  text: '。' }
]

完整代码

import {
  Upload,
  Button,
  Row,
  Col,
  Select,
  Input,
  Typography,
  Space,
} from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import mammoth from 'mammoth';
const { TextArea } = Input;
const { Title } = Typography;

import './index.less';
import { useState } from 'react';

// diff
// insert 添加  delete 删除  equal 相等
type DiffPart = {
  type: 'insert' | 'delete' | 'equal';
  text: string;
};
const WordDiffTool = () => {
  // 旧文本内容
  const [oldText, setOldText] =
    useState<string>('这是一个示例文本,用于测试对比功能。');

  // 新文本内容
  const [newText, setNewText] = useState<string>(
    '这是用于测试的示例文本,用于对比功能。添加了一些内容。'
  );

  // 对比粒度
  const [granularity, setGranularity] = useState<'word' | 'char' | 'line'>(
    'word'
  );

  // 上传word 并提取文本
  async function handleUpload(file: File, type: 'old' | 'new') {
    const arrayBuffer = await file.arrayBuffer();
    // mammith.extractRawText从docx中提取纯文本
    const { value } = await mammoth.extractRawText({ arrayBuffer });
    if (type === 'old') {
      setOldText(value);
    } else {
      setNewText(value);
    }
  }

  // 分词
  function tokenize(text: string, mode: string): string[] {
    // 按行分,按换行符拆分为数组
    if (mode === 'line') return text.split(/\r?\n/);
    // 按字符分,把字符串拆分成单个字符数组
    if (mode === 'char') return text.split('');
    //用正则将文本切成中文单字、英文单词、其他单个符号
    return text.match(/[\u4e00-\u9fa5]|\w+|[^\w\u4e00-\u9fa5]/g) || [];
  }

  // LCS构建DP
  function buildLCS(a: string[], b: string[]) {
    // 构建二维数组,并将其初始化为0
    // dp[i][j]表示从a[i:]与b[j:]的最长公共子序列长度
    const dp = Array.from({ length: a.length + 1 }, () =>
      Array(b.length + 1).fill(0)
    );

    // 倒序填充dp
    for (let i = a.length - 1; i >= 0; i--) {
      for (let j = b.length - 1; j >= 0; j--) {
        if (a[i] === b[j]) {
          dp[i][j] = dp[i + 1][j + 1] + 1;
        } else {
          dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
        }
      }
    }

    return dp;
  }

  // 得到diff结构
  function diffTokens(a: string[], b: string[]): DiffPart[] {
    //构建新旧内容dp数组
    const dp = buildLCS(a, b);
    let i = 0,
      j = 0;
    const result: DiffPart[] = [];

    // 便利新旧内容
    while (i < a.length || j < b.length) {
      //若当前token相等,则记录下来并移动i j
      if (i < a.length && j < b.length && a[i] === b[j]) {
        result.push({ type: 'equal', text: a[i] });
        i++;
        j++;
      } else if (
        j < b.length &&
        (i === a.length || dp[i][j + 1] >= dp[i + 1][j])
      ) {
        result.push({ type: 'insert', text: b[j] });
        j++;
      } else if (i < a.length) {
        result.push({ type: 'delete', text: a[i] });
        i++;
      }
    }
    return result;
  }

  // 将diff 转化成左右两侧展示
  function buildSideBySide(parts: DiffPart[]) {
    const oldView: DiffPart[] = [];
    const newView: DiffPart[] = [];

    parts.forEach(p => {
      if (p.type === 'equal') {
        oldView.push({ type: 'equal', text: p.text });
        newView.push({ type: 'equal', text: p.text });
      } else if (p.type === 'delete') {
        oldView.push({ type: 'delete', text: p.text });
        newView.push({ type: 'equal', text: p.text });
      } else if (p.type === 'insert') {
        oldView.push({ type: 'equal', text: p.text });
        newView.push({ type: 'insert', text: p.text });
      }
    });

    return { oldView, newView };
  }

  const oldTokens = tokenize(oldText, granularity);
  const newTokens = tokenize(newText, granularity);

  const diffParts = diffTokens(oldTokens, newTokens);
  const { oldView, newView } = buildSideBySide(diffParts);

  function renderDiff(parts: DiffPart[]) {
    return parts.map((p, i) => {
      if (p.type === 'equal') return <span key={i}>{p.text}</span>;
      if (p.type === 'delete')
        return (
          <span key={i} className="deleted">
            {p.text}
          </span>
        );
      if (p.type === 'insert') {
        return (
          <span key={i} className="inserted">
            {p.text}
          </span>
        );
      }
      return null;
    });
  }

  return (
    <div className="word-diff-tool">
      <Title level={3}>Word 文本对比工具</Title>

      <Space style={{ marginBottom: 16 }}>
        <Upload
          beforeUpload={file => {
            handleUpload(file, 'old').catch(err => {
              console.log('文件处理失败', err);
            });
            return false;
          }}
          showUploadList={false}
        >
          <Button icon={<UploadOutlined />}>上传旧文本</Button>
        </Upload>

        <Upload
          beforeUpload={file => {
            handleUpload(file, 'new').catch(err => {
              console.log('文件处理失败', err);
            });
            return false;
          }}
          showUploadList={false}
        >
          <Button icon={<UploadOutlined />}>上传新文本</Button>
        </Upload>

        <Select
          value={granularity}
          onChange={v => setGranularity(v)}
          className="granularity-select"
        >
          <Select.Option value="word">按词 diff</Select.Option>
          <Select.Option value="char">按字符 diff</Select.Option>
          <Select.Option value="line">按行 diff</Select.Option>
        </Select>
      </Space>

      <Row gutter={24}>
        <Col span={12}>
          <div className="diff-col">
            <div className="col-title old">旧版本内容</div>
            <TextArea
              rows={10}
              value={oldText}
              onChange={e => setOldText(e.target.value)}
            />
            <div className="diff-result">{renderDiff(oldView)}</div>
          </div>
        </Col>

        <Col span={12}>
          <div className="diff-col">
            <div className="col-title new">新版本内容</div>
            <TextArea
              rows={10}
              value={newText}
              onChange={e => setNewText(e.target.value)}
            />
            <div className="diff-result">{renderDiff(newView)}</div>
          </div>
        </Col>
      </Row>
    </div>
  );
};

export default WordDiffTool;
.word-diff-tool {
  padding: 24px;

  .diff-col {
    background: #fff;
    padding: 16px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);

    .col-title {
      font-size: 15px;
      margin-bottom: 8px;
      font-weight: 600;

      &.old {
        color: #c0392b;
      }
      &.new {
        color: #27ae60;
      }
    }

    .diff-result {
      margin-top: 12px;
      padding: 12px;
      min-height: 200px;
      border: 1px solid #f0f0f0;
      border-radius: 6px;
      line-height: 1.8;
      white-space: pre-wrap;
      overflow: auto;
      background: #fafafa;
      color: #bbb;
    }
  }

  .deleted {
    background: #ffe5e5;
    color: #e11;
    padding: 1px 3px;
    border-radius: 3px;
  }

  .inserted {
    background: #e6ffed;
    color: #0a0;
    padding: 1px 3px;
    border-radius: 3px;
  }
}

第5章:容器类组件 —— 5.1 填充(Padding)

作者 旧时光_
2025年12月2日 10:27

5.1 填充(Padding)

📚 章节概览

欢迎来到第5章:容器类组件!本章第一节将学习 Padding 组件,它是最基础也是最常用的容器类组件之一:

  • Padding - 填充组件
  • EdgeInsets - 边距设置类
  • all - 所有方向相同填充
  • only - 指定某个方向填充
  • symmetric - 对称方向填充
  • fromLTRB - 分别指定四个方向
  • 实际应用 - 常见使用场景

🎯 核心知识点

Padding vs Margin

在Flutter中,Padding 用于设置内边距(类似CSS的padding),而外边距通常通过Container的margin属性或者外层Padding来实现。

┌──────────────────┐
│   Margin (外)    │  ← Container的margin或外层Padding
│  ┌────────────┐  │
│  │ Padding(内)│  │  ← Padding组件
│  │  ┌──────┐  │  │
│  │  │Child │  │  │  ← 子组件
│  │  └──────┘  │  │
│  └────────────┘  │
└──────────────────┘

1️⃣ Padding(填充组件)

1.1 构造函数

Padding({
  Key? key,
  required EdgeInsetsGeometry padding,
  Widget? child,
})

1.2 主要属性

属性 类型 说明
padding EdgeInsetsGeometry 填充值(必需)
child Widget? 子组件

1.3 基础用法

Padding(
  padding: EdgeInsets.all(16),
  child: Text('Hello World'),
)

2️⃣ EdgeInsets(边距设置)

2.1 什么是EdgeInsets

EdgeInsetsEdgeInsetsGeometry 的子类,提供了多种便捷方法来设置填充值。

2.2 四个便捷方法

1. all - 所有方向相同
EdgeInsets.all(double value)

用法:

Padding(
  padding: EdgeInsets.all(16),  // 上下左右都是16
  child: Text('All方向填充'),
)

等价于:

EdgeInsets.fromLTRB(16, 16, 16, 16)
2. only - 指定某个方向
EdgeInsets.only({
  double left = 0.0,
  double top = 0.0,
  double right = 0.0,
  double bottom = 0.0,
})

用法:

// 只设置左边
Padding(
  padding: EdgeInsets.only(left: 8),
  child: Text('左侧填充'),
)

// 同时设置多个方向
Padding(
  padding: EdgeInsets.only(left: 20, right: 20),
  child: Text('左右填充'),
)
3. symmetric - 对称方向
EdgeInsets.symmetric({
  double vertical = 0.0,    // 上下
  double horizontal = 0.0,  // 左右
})

用法:

// 只设置上下
Padding(
  padding: EdgeInsets.symmetric(vertical: 8),
  child: Text('上下填充'),
)

// 同时设置上下和左右
Padding(
  padding: EdgeInsets.symmetric(
    vertical: 12,
    horizontal: 20,
  ),
  child: Text('对称填充'),
)
4. fromLTRB - 四个方向分别指定
EdgeInsets.fromLTRB(
  double left,
  double top,
  double right,
  double bottom,
)

用法:

Padding(
  padding: EdgeInsets.fromLTRB(20, 10, 20, 30),
  child: Text('四个方向不同值'),
)

2.3 方法对比

方法 参数 适用场景
all 1个 所有方向相同
only 1-4个 指定某些方向
symmetric 1-2个 对称设置
fromLTRB 4个 四个方向都不同

2.4 选择建议

// ✅ 推荐:简洁明了
EdgeInsets.all(16)                    // 四个方向相同
EdgeInsets.symmetric(horizontal: 16)  // 左右相同
EdgeInsets.only(left: 16)            // 只设置一个

// ❌ 不推荐:过于繁琐
EdgeInsets.fromLTRB(16, 16, 16, 16)  // 应该用all(16)
EdgeInsets.fromLTRB(16, 0, 16, 0)    // 应该用symmetric(horizontal: 16)

3️⃣ 详细示例

3.1 all示例

Container(
  color: Colors.blue[100],
  child: Padding(
    padding: EdgeInsets.all(16),
    child: Container(
      color: Colors.white,
      child: Text('16像素填充'),
    ),
  ),
)

效果:

┌────────────────────┐
│ ████████████████ │  ← 蓝色背景(外层Container)
│ ██┌──────────┐██ │
│ ██│ 16px填充 │██ │  ← 白色背景(内层Container)
│ ██└──────────┘██ │
│ ████████████████ │
└────────────────────┘

3.2 only示例

Column(
  children: [
    // 只设置左边
    Padding(
      padding: EdgeInsets.only(left: 8),
      child: Text("Hello world"),
    ),
    
    // 只设置上下
    Padding(
      padding: EdgeInsets.only(top: 8, bottom: 8),
      child: Text("I am Jack"),
    ),
    
    // 设置左右
    Padding(
      padding: EdgeInsets.only(left: 20, right: 20),
      child: Text("Your friend"),
    ),
  ],
)

3.3 symmetric示例

// 上下填充
Padding(
  padding: EdgeInsets.symmetric(vertical: 16),
  child: Text('上下各16像素'),
)

// 左右填充
Padding(
  padding: EdgeInsets.symmetric(horizontal: 24),
  child: Text('左右各24像素'),
)

// 同时设置
Padding(
  padding: EdgeInsets.symmetric(
    vertical: 12,
    horizontal: 20,
  ),
  child: Text('上下12,左右20'),
)

3.4 fromLTRB示例

Padding(
  padding: EdgeInsets.fromLTRB(20, 10, 20, 30),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text('左:20像素'),
      Text('上:10像素'),
      Text('右:20像素'),
      Text('下:30像素'),
    ],
  ),
)

4️⃣ 实际应用场景

场景1:卡片内容填充

Container(
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(8),
    boxShadow: [
      BoxShadow(
        color: Colors.grey.withOpacity(0.3),
        blurRadius: 4,
        offset: Offset(0, 2),
      ),
    ],
  ),
  child: Padding(
    padding: EdgeInsets.all(16),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          '文章标题',
          style: TextStyle(
            fontSize: 16,
            fontWeight: FontWeight.bold,
          ),
        ),
        SizedBox(height: 8),
        Text(
          '文章内容描述...',
          style: TextStyle(color: Colors.grey),
        ),
      ],
    ),
  ),
)

场景2:按钮内边距

Container(
  decoration: BoxDecoration(
    color: Colors.blue,
    borderRadius: BorderRadius.circular(4),
  ),
  child: Padding(
    padding: EdgeInsets.symmetric(
      horizontal: 24,
      vertical: 12,
    ),
    child: Text(
      '确定',
      style: TextStyle(color: Colors.white),
    ),
  ),
)

场景3:列表项间距

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return Padding(
      padding: EdgeInsets.only(bottom: 8),
      child: ListTile(
        title: Text('列表项 $index'),
      ),
    );
  },
)

场景4:页面整体内边距

Scaffold(
  body: Padding(
    padding: EdgeInsets.all(16),
    child: Column(
      children: [
        // 页面内容
      ],
    ),
  ),
)

🤔 常见问题(FAQ)

Q1: Padding和Margin的区别?

A: 在Flutter中:

概念 实现方式 说明
Padding(内边距) Padding组件 子组件内部的留白
Margin(外边距) Container.margin 组件外部的留白
// Padding(内边距)
Padding(
  padding: EdgeInsets.all(16),
  child: Container(
    color: Colors.blue,
    child: Text('Text'),
  ),
)

// Margin(外边距)
Container(
  margin: EdgeInsets.all(16),
  color: Colors.blue,
  child: Text('Text'),
)

Q2: EdgeInsets.zero是什么?

A: 所有方向填充为0的快捷方式

EdgeInsets.zero  // 等价于 EdgeInsets.all(0)

Q3: 如何设置负数填充?

A: Padding不支持负数,但可以用Transform.translate

// ❌ 错误:Padding不支持负数
Padding(
  padding: EdgeInsets.all(-10),  // 会报错
  child: Text('Text'),
)

// ✅ 正确:使用Transform
Transform.translate(
  offset: Offset(-10, -10),
  child: Text('Text'),
)

Q4: Padding会影响子组件的约束吗?

A: 会!Padding会减小传递给子组件的约束

// 假设父组件约束为 100×100
Container(
  width: 100,
  height: 100,
  child: Padding(
    padding: EdgeInsets.all(10),
    // 子组件实际可用空间:(100-20)×(100-20) = 80×80
    child: Container(color: Colors.blue),
  ),
)

Q5: 如何选择使用哪个EdgeInsets方法?

A: 遵循简洁原则:

// 四个方向相同 → all
EdgeInsets.all(16)

// 上下或左右相同 → symmetric
EdgeInsets.symmetric(horizontal: 16)
EdgeInsets.symmetric(vertical: 8)

// 只设置1-3个方向 → only
EdgeInsets.only(left: 16)
EdgeInsets.only(left: 16, right: 16)

// 四个方向都不同 → fromLTRB
EdgeInsets.fromLTRB(20, 10, 15, 30)

🎯 跟着做练习

练习1:实现一个带内边距的卡片组件

目标: 创建一个自定义Card组件,内容有16像素填充

步骤:

  1. 使用Container作为卡片背景
  2. 添加圆角和阴影
  3. 使用Padding设置内容填充
💡 查看答案
class CustomCard extends StatelessWidget {
  final Widget child;
  
  const CustomCard({super.key, required this.child});
  
  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: Colors.grey.withOpacity(0.2),
            spreadRadius: 1,
            blurRadius: 4,
            offset: Offset(0, 2),
          ),
        ],
      ),
      child: Padding(
        padding: EdgeInsets.all(16),
        child: child,
      ),
    );
  }
}

// 使用
CustomCard(
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text(
        '标题',
        style: TextStyle(
          fontSize: 18,
          fontWeight: FontWeight.bold,
        ),
      ),
      SizedBox(height: 8),
      Text('这是卡片的内容描述...'),
    ],
  ),
)

练习2:实现响应式Padding

目标: 根据屏幕宽度动态调整Padding值

步骤:

  1. 使用MediaQuery获取屏幕宽度
  2. 计算合适的padding值
  3. 应用动态padding
💡 查看答案
class ResponsivePadding extends StatelessWidget {
  final Widget child;
  
  const ResponsivePadding({super.key, required this.child});
  
  @override
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;
    
    // 根据屏幕宽度计算padding
    double padding;
    if (screenWidth < 600) {
      padding = 16;  // 手机
    } else if (screenWidth < 1200) {
      padding = 24;  // 平板
    } else {
      padding = 32;  // 桌面
    }
    
    return Padding(
      padding: EdgeInsets.all(padding),
      child: child,
    );
  }
}

// 使用
ResponsivePadding(
  child: Text('响应式内容'),
)

📋 小结

核心概念

组件/类 作用 常用方法
Padding 添加填充 padding属性
EdgeInsets 设置填充值 all、only、symmetric、fromLTRB

EdgeInsets方法速查

EdgeInsets.all(16)                        // 所有方向16
EdgeInsets.only(left: 8)                 // 只设置左边
EdgeInsets.only(left: 8, right: 8)       // 设置左右
EdgeInsets.symmetric(vertical: 16)       // 上下16
EdgeInsets.symmetric(horizontal: 20)     // 左右20
EdgeInsets.symmetric(                    // 上下和左右
  vertical: 12,
  horizontal: 20,
)
EdgeInsets.fromLTRB(20, 10, 20, 30)     // 四个方向分别指定
EdgeInsets.zero                          // 所有方向为0

记忆技巧

  1. all:all方向相同
  2. only:only某些方向
  3. symmetric:对称的两个方向
  4. fromLTRB:Left、Top、Right、Bottom顺序

🔗 相关资源


一次请求 Request failed with status code 400的解决之旅

2025年12月2日 10:27

最近在项目开发中遇到一个奇怪的问题,就是某些请求在首页时发送和接收正常,在详情页中请求会报400错误。排查参数信息发现,两者传递的参数一致。然后又对请求进行的对比,发现详情页中的Referer中包含了页面传递的参数中,有""。正是请求中的Referer携带的 "",会导致请求报 400。

解决思路

Referer 中包含 * 导致请求报 400(Bad Request),核心原因是 * 属于 HTTP 协议中 Referer 头的非法字符,或服务器端对 Referer 格式做了严格校验(比如正则匹配拒绝含通配符的 Referer)。

发现问题后,第一时间想起,手动修改header中的Referer,

  // 强制覆盖浏览器的 referer 参数
  // 注意:某些浏览器可能会忽略或限制对 Referer 头的修改
  const customReferer = location.origin + location.pathname
  // eslint-disable-next-line dot-notation
  config.headers['Referer'] = customReferer
  // eslint-disable-next-line dot-notation
  config.headers['referer'] = customReferer

修改后发现,请求是不报错了,但是控制台会有新的提示:

error

Refused to set unsafe header "Referer"浏览器的安全限制导致的:根据 HTTP 规范和浏览器的同源策略 / 安全机制,Referer 属于「受保护的请求头」—— 浏览器禁止前端 JavaScript 代码手动修改 / 设置这个头,仅允许浏览器根据当前页面 URL 自动生成,目的是防止恶意脚本伪造来源地址。

于是再次更换方案,既然报错的原因是由于Referer中携带""导致,那就对""进行处理,Referer是取值的当前的链接地址,那就在跳转前对参数进行encode。

`&params=${encodeURIComponent(JSON.stringify(params))}`

问题解决。

其他解决方案

1. 控制 Referer 不发送(最简单,兜底方案)

<!-- 完全不发送 Referer 头 -->
<meta name="referrer" content="no-referrer">

<!-- 可选:仅同源请求发送 Referer,跨域不发送(更灵活) -->
<meta name="referrer" content="same-origin">

2. 单个请求生效(精准控制)

针对特定请求配置 referrerPolicy(优先级高于 meta 标签):

axios({
    url: 'https://目标接口地址',
    method: 'GET/POST',
    // axios 需通过 referrerPolicy 配置(部分版本需配合 headers 清空)
    referrerPolicy: 'no-referrer',
    headers: {
        // 不要手动设置 Referer,留空或删除
        Referer: undefined 
  }})
fetch('https://目标接口地址', {
    method: 'GET/POST',
    // 关键:设置 Referer 策略,禁止发送 
    RefererreferrerPolicy: 'no-referrer', 
      // 注意:删除手动设置的 Referer 头!// 
      headers: { Referer: 'xxx' }  // ❌ 要删掉这行
})

3. 服务端处理

3.1 Nginx配置改造

Nginx 默认会拒绝含非法字符的 Referer 头,或 valid_referers 规则匹配失败返回 400/403。解决方法:

  • 跳过 Referer 非法字符校验
        # 关闭非法 header 检查(允许含*的 Referer 进入业务逻辑)
        ignore_invalid_headers off;
  • 放宽 valid_referers 规则

若用 valid_referers 做防盗链,调整规则兼容含 * 的 Referer:

        location / {
            # 允许空 Referer、指定域名、含*的 Referer(用~* 正则匹配)
            valid_referers none blocked server_names ~*.example.com ~*/*;
            if ($invalid_referer) {
                # 注释掉 return 403/400,或改为日志记录而非拒绝
                # return 400;
                access_log /var/log/nginx/invalid_referer.log warn;
                }
            }
3.2 Apache服务器
  • 关闭 RequestHeader严格校验
            # 在 httpd.conf 或 .htaccess 中
            AcceptPathInfo On
            # 或禁用 Referer 校验模块(若启用了 mod_rewrite 校验 Referer)
            RewriteEngine Off  # 临时关闭,或调整 RewriteRule 忽略*
  • 若用 mod_rewrite 校验 Referer,修改规则兼容 *
            RewriteCond %{HTTP_REFERER} !^https://example.com/.* [NC]
            # 改为(允许*)
            RewriteCond %{HTTP_REFERER} !^https://example.com/[^\n]* [NC]

3.3 后端应用配置

拿Java举例:

            <Connector port="8080" protocol="HTTP/1.1"
                       connectionTimeout="20000"
                       redirectPort="8443"
                       relaxedPathChars="[]|{}^&#x5c;&#x60;&quot;&lt;&gt;*"  # 加入*
                       relaxedQueryChars="[]|{}^&#x5c;&#x60;&quot;&lt;&gt;*" />

Spring Boot 可通过配置文件:

        # application.properties
        server.tomcat.relaxed-path-chars=*
        server.tomcat.relaxed-query-chars=*

总结

  1. 发起方:移除 / 编码 *,或不发送 Referer;
  2. 接收方:放宽服务器对 Referer 的字符校验(Nginx/Tomcat 配置),或忽略含 * 的 Referer 校验;
  3. 核心原则:遵循 HTTP 标准,避免在 Referer 中使用非法字符,改用合法格式或自定义头传递通配语义。

Arco Design Layout 中使用 ResizeBox 实现可拖拽侧边栏

作者 好_快
2025年12月2日 10:01

Arco Design Layout 中使用 ResizeBox 实现可拖拽侧边栏

问题

a-layout-sider 虽然有 width 属性可以设置宽度,但没有拖拽事件来动态调整宽度。直接使用 a-resize-box 替换会导致布局混乱。

解决方案

核心要点:必须使用 <a-layout :has-sider="true"> 包裹 a-resize-box

<a-layout :has-sider="true">
  <a-resize-box
    :directions="['right']"
    :width="sidebarWidthImmediate"
    @moving="handleSidebarResize"
    @moving-end="handleSidebarResizeEnd"
  >
    <div>侧边栏内容</div>
  </a-resize-box>
  <a-layout-content>
    主内容区
  </a-layout-content>
</a-layout>

性能优化:双变量设计

问题

如果只用一个变量,拖拽过程中会频繁触发子组件更新和计算属性重新计算,导致卡顿。

方案

使用两个变量分离视觉反馈和数据传递:

// 视觉反馈变量:高频更新,只影响 ResizeBox
const sidebarWidthImmediate = ref(200)

// 数据传递变量:低频更新,用于子组件和计算属性
const sidebarWidth = ref(200)

function handleSidebarResize(size: { width: number; height: number }) {
  // 拖拽过程中:只更新视觉反馈变量
  sidebarWidthImmediate.value = Math.max(minSidebarWidth, size.width)
}

function handleSidebarResizeEnd() {
  // 拖拽结束时:同步数据传递变量,触发一次子组件更新
  sidebarWidth.value = sidebarWidthImmediate.value
}

性能对比

方案 拖拽时子组件渲染 性能表现
单变量 高频(每秒数十次) ❌ 卡顿
双变量 低频(结束时1次) ✅ 流畅

完整示例

<template>
  <a-layout>
    <a-layout-header>Header</a-layout-header>
    
    <a-layout :has-sider="true">
      <a-resize-box
        :directions="['right']"
        :width="sidebarWidthImmediate"
        @moving="handleSidebarResize"
        @moving-end="handleSidebarResizeEnd"
      >
        <div class="sidebar">侧边栏内容</div>
      </a-resize-box>
      
      <a-layout-content>
        <MainContent
          :sidebar-width="sidebarWidth"
          :width="contentWidth"
        />
      </a-layout-content>
    </a-layout>
  </a-layout>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

const minSidebarWidth = 200
const sidebarWidthImmediate = ref(minSidebarWidth) // 视觉反馈变量
const sidebarWidth = ref(minSidebarWidth) // 数据传递变量
const resizerWidth = 6
const containerWidth = ref(1200)

const contentWidth = computed(() => {
  return containerWidth.value - sidebarWidth.value - resizerWidth
})

function handleSidebarResize(size: { width: number; height: number }) {
  sidebarWidthImmediate.value = Math.max(minSidebarWidth, size.width)
}

function handleSidebarResizeEnd() {
  sidebarWidth.value = sidebarWidthImmediate.value
}
</script>

注意事项

  1. 必须设置 has-sider="true":否则布局会混乱
  2. 宽度计算需减去拖拽条宽度:通常为 6px
  3. 设置最小宽度限制:防止侧边栏过小
  4. 使用双变量模式:避免拖拽时频繁触发子组件更新

【前端】每天一个知识点-NPM

2025年12月2日 09:58

npm install

命令执行时,会从注册的源下载依赖包,并根据依赖树构建本地的node_modules目录。

// 根据当前的目录的package.json,安装所有声明的依赖,
// 生成或更新package-lock.json,默认安装到当前目录的node_modules
npm install 

//安装pnpm,并添加到dependencies
npm install pnpm

//安装依赖到生成环境的依赖中dependencies
npm install --save
npm install -S

// 安装开发环境依赖 devDependencies
npm install --save-dev
npm install -D

//安装全局依赖
npm install -g
npm install --global
//查看全局安装目录
npm root -g

//只安装生产依赖
npm install --production

//安装 但不写入到package.json
npm install xxxx --no-save

// --force 或 -f
// 强制重新下载依赖,即使本地缓存存在
npm install --force

// 忽略peerDependencies同伴依赖冲突
npm install --legacy-peer-deps

// --omit 忽略某类依赖
npm install --omit=dev  等价于 --production

// --dry-run 仅模拟安装过程,不实际下载或修改文件
npm install --dry-run

// --prefer-offline 优先使用本地缓存  
// --prefer-online 每次都从远程检查更新
npm install --prefer-offline

// --no-audit 禁用npm的安全审计功能
npm install --no-audit

npm update

// 安装或升级特定版本的包
npm update react

npm ci

根据package-lock.json精确安装,保证一致性,与npm install 区别,npm install 根据package.json安装,可能更新package-lock.json文件

最佳实践

深入理解 JavaScript 中的 Symbol:独一无二的“魔法钥匙”

作者 AAA阿giao
2025年12月2日 09:57

引言

在 JavaScript 的世界里,数据类型就像各种各样的工具,每一种都有其独特的用途。而 Symbol 就像一把独一无二、不可复制的魔法钥匙,它不声不响地藏在 ES6(ECMAScript 2015)的新特性中,却在多人协作、避免命名冲突等场景下大放异彩。

今天,我们就结合代码,深入浅出地揭开 Symbol 的神秘面纱!


什么是 Symbol?

Symbol 符号
简单数据类型
传统数据类型包括:数字 number、字符串 string、布尔值 boolean、空值 null、未定义 undefined、ES6 bigint 大整数、Symbol 符号
JS 总共有 8 种数据类型(七上八下)

没错!Symbol 是 JavaScript 中第 7 种原始(简单)数据类型(加上 object 共 8 种)。它的最大特点就是:每一个 Symbol 值都是独一无二的,即使描述相同,也不会相等


如何创建一个 Symbol?

来看这段代码代码(我们将逐行注解):

// 构造函数,却是简单数据类型
const id1 = Symbol();
console.log(typeof id1);
  • Symbol() 是一个工厂函数(不是构造函数,不能用 new),用于创建一个新的 Symbol 值。
  • 虽然看起来像函数调用,但返回的是原始类型,所以 typeof id1 的结果是 "symbol"
  • 这说明 Symbol 被归类为简单数据类型,和 number、string 同级。
const id2 = Symbol();
// 每个 Symbol() 函数返回的值都是唯一的,独一无二
console.log(id1 === id2);
  • 即使 id1id2 都是通过 Symbol() 创建的,且没有任何参数,
  • 它们的值也绝对不相等!输出结果是 false
  • 这正是 Symbol 的核心特性:唯一性

💡 想象一下:Symbol 就像给每个对象属性配了一把世界上仅此一把的钥匙,别人就算照着图纸做,也打不开你的锁!


给 Symbol 加个“标签”(可选描述)

虽然 Symbol 本身是唯一的,但我们可以通过传入一个字符串参数作为“描述”(description),方便调试:

const s1 = Symbol('张三');
const s2 = Symbol('李四');
console.log(s1 === s2);
  • s1s2 的描述分别是 '张三''李四'
  • 即便描述不同,它们本来就不等;但即使描述相同,比如都写 Symbol('name'),两个 Symbol 依然不相等!
  • 这里的 '张三' 只是给人看的标签,不影响 Symbol 的唯一性。
  • 输出结果依然是 false
const secretKey = Symbol('secret');
console.log(secretKey,'//////');
  • 这里创建了一个名为 secretKey 的 Symbol,描述为 'secret'
  • 打印时你会看到类似:Symbol(secret) //////
  • 这个描述在调试时非常有用,但不会改变 Symbol 的本质

Symbol 的核心用途:作为对象的唯一 key

这是 Symbol 最强大的应用场景!

继续看 :

// 多人协作中 Why Symbol ?
// 动态 不太安全
// key string 类型 | symbol 类型
const a = 'ecut';
const user = {
  [secretKey]: '123456',
  name: '张三',
  email: '123@qq.com',
  'a': 456,
  [a]: 123 // 用中括号时,key 是 symbol 类型,作为对象的唯一key,不会被覆盖
}

让我们逐行解析这个对象:

  • [secretKey]: '123456'

    • 使用 计算属性名[ ])将 secretKey(一个 Symbol)作为对象的 key。
    • 这个属性无法通过常规方式访问(比如 user.secretKeyundefined)。
    • 不会与其他字符串 key 冲突,哪怕别人也定义了 'secret' 字符串属性。
  • 'a': 456

    • 这是一个普通的字符串 key,值为 456。
  • [a]: 123

    • 注意:a 是变量,值为 'ecut',所以 [a] 等价于 'ecut'
    • 因此,这里其实是定义了 user.ecut = 123
    • 但注意:这里的 key 是字符串 'ecut',不是 Symbol!
    • 注释说“key 是 symbol 类型”其实有误——只有当 a 本身是 Symbol 时才是。此处 a 是字符串,所以 key 仍是字符串。
    • 不过重点在于:Symbol key 和字符串 key 完全隔离,互不干扰
console.log(user.ecut, user[a]);
  • user.ecutuser[a] 都会输出 123,因为 a === 'ecut'
  • 但如果你尝试 user[secretKey],才能拿到 '123456'
  • user.secretuser['secret'] 都拿不到,因为 secretKey 是 Symbol,不是字符串!

关键优势:在大型项目或多人协作中,不同开发者可能无意中使用相同的属性名(如 'id''config'),导致覆盖。而用 Symbol 作为 key,就能确保绝对不冲突


Symbol key 会被遍历吗?

对象动态的 Symbol key 不会被覆盖的
for key in 不可以枚举
Object.getOwnPropertySymbols() 可以获取对象的所有 Symbol key

这意味着:

  • for...in 循环不会遍历到 Symbol 属性。
  • Object.keys()JSON.stringify()忽略 Symbol key。
  • 但你可以用 Object.getOwnPropertySymbols(obj) 专门获取所有 Symbol key

例如:

const sym = Symbol('test');
const obj = { [sym]: 'hidden', name: 'visible' };

console.log(Object.keys(obj)); // ['name']
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(test)]

这使得 Symbol 成为实现“私有属性”的一种巧妙方式(虽非真正私有,但对外隐藏)。


总结:Symbol 的三大特性

  1. 唯一性
    每次 Symbol() 调用都产生一个全新、不可重复的值。
  2. 作为对象 key 的安全性
    Symbol key 不会被字符串 key 覆盖,避免命名冲突,特别适合多人协作。
  3. 不可枚举性
    默认不会出现在 for...inObject.keys() 等遍历中,具有“半私有”特性。

小贴士:全局 Symbol 注册表(补充知识)

值得一提:
如果你希望在不同文件中共享同一个 Symbol,可以用:

const sym1 = Symbol.for('shared'); // 注册到全局
const sym2 = Symbol.for('shared'); // 返回同一个 Symbol
console.log(sym1 === sym2); // true

但这属于进阶用法,基础场景中,每次 Symbol() 都是全新的就足够了。


结语

Symbol 虽小,却威力无穷。它不像数字或字符串那样日常高频使用,但在需要唯一标识符避免属性冲突模拟私有成员的场景下,它是 JavaScript 工具箱中不可或缺的“秘密武器”。

Symbol 可以作为对象的唯一 key,用于多人协作,避免命名冲突

下次当你担心属性名被覆盖时,不妨掏出这把“魔法钥匙”——Symbol,为你的代码加上一层安全锁!

useEffect 戒断指南与“鬼畜”的双重请求之谜

2025年12月2日 09:44

author: 大布布将军

前言:午夜惊魂 Network 面板

假设前端同学==A

继上周改完 Context 的 Bug 后,A这周又遇到了新的灵异事件。

场景很经典:A写了一个简单的详情页,页面加载时请求一次 API 获取数据。代码看起来人畜无害,清纯可爱。

但是,当A打开 Chrome 的 Network 面板时,A揉了揉眼睛。同一个 API 接口,在几毫秒内被调用了两次。

A第一反应是鼠标坏了,双击了? 第二反应是后端 Socket 没握手好? 第三反应:React,是不是你小子又在搞我?

今天我们就来聊聊这个让无数前端在深夜破防的 useEffect,以及它是如何一步步变成代码里的“逻辑黑洞”的。


第一案:双重请求的“灵异”事件

先看代码,这应该是全天下 React 开发者写得最顺手的一段:

useEffect(() => {
  console.log('组件挂载了,开始请求数据...');
  fetchUserProfile(id).then(data => setUser(data));
}, [id]);

现象: 控制台打印了两行“组件挂载了...”,Network 里飞出去两个请求。

真相(React 18 Strict Mode): 如果你的项目是用 create-react-app 或者 vite 建的,并且套了一层 <React.StrictMode>,在 开发环境(Development) 下,React 会故意搞你心态。

它会执行:Mount(挂载) -> Unmount(卸载) -> Mount(再次挂载)

React 官方的解释是:

“我们要帮你测试,你的 Effect 里的清理函数(Cleanup Function)是不是写对了。如果你的组件卸载时不清理垃圾,我就把它重新挂载一遍,让你看看会不会出 Bug。”

怎么解决?

  1. 鸵鸟战术(不推荐) :去 index.js<StrictMode> 删了。世界清静了,但你就失去了一个帮你检查内存泄漏的保镖。
  2. AbortController(正道的光) : 在 useEffect 里写上清理逻辑,取消上一次请求。
  const controller = new AbortController();

  fetchUserProfile(id, { signal: controller.signal })
    .then(data => setUser(data))
    .catch(err => {
      if (err.name !== 'AbortError') {
         // 处理真正的错误
      }
    });

  return () => {
    // 组件卸载(或者由于 Strict Mode 导致的假卸载)时,取消请求
    controller.abort();
  };
}, [id]);

老司机心里话: 其实绝大多数时候,在开发环境请求两次根本无所谓。只要你的后端接口是幂等的(查询通常都是),除了看着心烦,上线后(生产环境)它是不会跑两次的。

第二案:useEffect 是怎么变成“逻辑黑洞”的?

比双重请求更可怕的,是把 useEffect 当作逻辑胶水来用。

也就是传说中的“连锁反应(Chain Reaction)”

假设有一个表单:用户选了“国家”,自动重置“省份”;选了“省份”,自动重置“城市”。

❌ 菜鸟写法(Effect 连锁):

useEffect(() => {
  setProvince('');
}, [country]);

// 监听 province,变了就改 city
useEffect(() => {
  setCity('');
}, [province]);

// 监听 city,变了就去请求区号...
useEffect(() => {
  if(city) fetchAreaCode(city);
}, [city]);

这有什么问题? 这简直就是玩弹珠台!

  1. 用户改了 country -> Render -> 触发 Effect 1。
  2. Effect 1 修改 province -> Render -> 触发 Effect 2。
  3. Effect 2 修改 city -> Render -> ...

React 必须跑好几轮渲染才能把状态稳住。代码一旦复杂起来,这种依赖链条就像纠缠在一起的耳机线,根本理不清数据流向。

✅ 高手写法(在事件中处理):

永远记住一句话:如果是用户操作引起的改变,就在事件处理函数里写,别在 Effect 里写。

  setCountry(newCountry);
  // 直接在这里重置,不要等 Effect
  setProvince('');
  setCity(''); 
};

这就叫Batch Update(批处理) 。一次点击,改好所有状态,React 只需一次 Render 就能把正确的 UI 画出来。清晰、高效、无副作用。

第三案:由数据推导数据的“脱裤子放屁”

还有一个重灾区:冗余状态

❌ 错误示范:

const [lastName, setLastName] = useState('Doe');
const [fullName, setFullName] = useState('');

// 兄弟,你这是在用 React 算 1+1=2 吗?
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

这里 fullName 根本不需要是 State!因为它可以直接由 firstNamelastName 计算出来。 你这么写,又是多一次 Render。

✅ 正确示范:

const [lastName, setLastName] = useState('Doe');

// 在渲染过程中直接计算
// 只要组件重渲染,fullName 自动就是最新的
const fullName = `${firstName} ${lastName}`;

如果计算很昂贵(比如要遍历几千个数组),那就用 useMemo 包一下,但千万别用 useEffectsetState

总结:该把 useEffect 关进笼子了

React 官方文档现在都在极力劝退 useEffect

为什么? 因为 useEffect 的本意是: “将组件与外部系统(Network, DOM, Subscription)同步” 。 它不是用来监听数据变化的,也不是用来做逻辑流转的。

当你准备写下 useEffect 之前,请先问自己三个问题:

  1. 这是为了和后端/外部同步吗? 是 -> 写吧。
  2. 这是用户交互(点击/输入)导致的吗? 是 -> 写在 Event Handler 里。
  3. 这是可以用现有的 Props/State 算出来的吗? 是 -> 直接算,或者用 useMemo

useEffect 用在刀刃上,你的代码会少一半 Bug,你的发际线会后退得慢一点。

好了,A要去把那个在 useEffect 里写 setInterval 还没有清除定时器的实习生代码改了,不然浏览器内存要炸了。


下期预告:你以为 useRef 只是用来获取 DOM 节点的吗?错了,它是 React 函数式组件里唯一的“逃生舱”和“时光胶囊”。下一篇,带你解锁 useRef 的骚操作。

vue对接海康摄像头-H5player

作者 海边的云
2025年12月2日 09:31

1.在public中放入js

📎H5播放器功能性能说明.xlsx

视口大小

init() {
  // 设置播放容器的宽高并监听窗口大小变化
  window.addEventListener("resize", () => {
    this.player.JS_Resize();
  });
},
// 媒体查询设置窗口的大小
@media screen and (max-width: 1990px) {
  #player {
    width: calc(100vw - 16px);
    height: calc((90vw - 16px) * 5 / 8);
  }
  #control {
    width: calc(100vw - 10px);
    height: 50px;
    margin-top: -3px;
    position: relative;
    z-index: 1;
    background-color: rgb(62, 61, 66, 0.8);
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 20px;
  }
}

@media screen and (min-width: 750px) {
  #player {
    width: calc(70vw - 8px);
    height: calc((70vw - 8px) * 5 / 8);
  }
  #control {
    width: calc(70vw - 10px);
    height: 50px;
    background-color: rgb(62, 61, 66, 0.8);
    margin-top: -3px;
    position: relative;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 20px;
  }
}

监控点树形列表

 // 查询区域列表v2
    getNodesByParams() {
      this.forwardParams.requestMethod = "post";
      this.forwardParams.url =
        process.env.VUE_APP_BASE_URL + "/api/irds/v2/region/nodesByParams";
      this.forwardParams.paramType = "body";
      this.forwardParams.params = {
        resourceType: "camera",
        pageNo: "1",
        pageSize: "100",
      };
      getHik(this.forwardParams).then((res) => {
        const jsonStr = res.data;
        const data = JSON.parse(jsonStr);
        console.log("区域列表", data.data.list);
        const nodeList = data.data.list;
        this.getCameraOnline(nodeList);
      });
    },

    // 获取监控点列表
    getCameraOnline(nodeList) {
      this.forwardParams.requestMethod = "post";
      this.forwardParams.url =
        process.env.VUE_APP_BASE_URL + "/api/resource/v2/camera/search";
      this.forwardParams.paramType = "body";
      this.forwardParams.params = {
        pageNo: "1",
        pageSize: "100",
      };
      getHik(this.forwardParams).then((res) => {
        const jsonStr = res.data;
        const data = JSON.parse(jsonStr);
        if (data.data) {
          // console.log("视频列表", data.data.list);
          const cameraList = data.data.list;

          const newCodeArray = cameraList.map((camera) => ({
            name: camera.name,
            parentIndexCode: camera.regionIndexCode,
            indexCode: camera.indexCode,
            cameraType: camera.cameraType,
          }));

          const tree = [...newCodeArray, ...nodeList];
          this.treeData = this.handleTree(tree, "indexCode", "parentIndexCode");
          // console.log("treeData", this.treeData);
        }
      });
    },

获取视频流

 handleNodeClick(data) {
       this.count++;
      
      if (this.count % 2 === 0) {
        // 偶数次点击的逻辑
        console.log('偶数次点击');
         console.log("data", data);
        // 球机弹出云台控制卡片
      if (data.cameraType === 2) {
        this.allowClick = true;
        this.customTreeStyle.minHeight = "55%";
      } else {
        this.allowClick = false;
        this.customTreeStyle.minHeight = "95%";
      }
      //码流类型,0:主码流 1:子码流 2:第三码流 参数不填,默认为主码流
      if (this.splitNum === 3) {
        this.streamType = 1;
      } else {
        this.streamType = 0;
      }

      this.cameraIndexCode = data.indexCode;
      this.forwardParams.requestMethod = "post";
      this.forwardParams.url =
        process.env.VUE_APP_BASE_URL + "/api/video/v2/cameras/previewURLs";
      this.forwardParams.paramType = "body";
      this.forwardParams.params = {
        cameraIndexCode: data.indexCode,
        streamType: this.streamType,
        protocol: "ws",
        transmode: 1,
        expand: "transcode=0",
        streamform: "ps",
      };
      getHik(this.forwardParams).then((res) => {
        const jsonStr = res.data;
        const data = JSON.parse(jsonStr);
        console.log("视频流", data.data.url);
        const url = data.data.url;
        this.urls.realplay = url;
        this.realplay(); // 预览视频方法
      });
      }
     
    },

云台控制

 // 云台操作
    controlling(command) {

      this.forwardParams.requestMethod = "post";
      this.forwardParams.url =
        process.env.VUE_APP_BASE_URL + "/api/video/v1/ptzs/controlling";
      this.forwardParams.paramType = "body";
      this.forwardParams.params = {
        cameraIndexCode: this.cameraIndexCode,
        action: 0,
        command: command,
        presetIndex: 20,
      };
      getHik(this.forwardParams).then((res) => {
        const jsonStr = res.data;
        const data = JSON.parse(jsonStr);
        console.log("云台操作", data.msg);
      });
    },

分屏暂停切窗口 文档中写

react学习12:状态管理redux

2025年12月2日 09:26

useReducer

在学习 redux 之前,我们先学习下useReducer的使用。

前面用的 setState 都是直接修改值,那如果在修改值之前需要执行一些固定的逻辑呢?

这时候就要用 useReducer 了:

import { Reducer, useReducer } from "react";

interface Data {
    result: number;
}

interface Action {
    type: 'add' | 'minus',
    num: number
}
function reducer(state: Data, action: Action) {

    switch(action.type) {
        case 'add':
            return {
                result: state.result + action.num
            }
        case 'minus': 
            return {
                result: state.result - action.num
            }
    }
    return state;
}

function App() {
  const [res, dispatch] = useReducer<Reducer<Data, Action>>(reducer, { result: 0});

  return (
    <div>
        <div onClick={() => dispatch({ type: 'add', num: 2 })}>加</div>
        <div onClick={() => dispatch({ type: 'minus', num: 1 })}>减</div>
        <div>{res.result}</div>
    </div>
  );
}

export default App;

useReducer 的类型参数传入 Reducer<数据的类型,action 的类型>

然后第一个参数是 reducer,第二个参数是初始数据。

点击加的时候,触发 add 的 action,点击减的时候,触发 minus 的 action。

当然,你直接 setState 也可以:

import { useState } from "react";

function App() {
  const [res, setRes] = useState({ result: 0});

  return (
    <div>
        <div onClick={() => setRes({ result: res.result + 2 })}>加</div>
        <div onClick={() => setRes({ result: res.result - 1 })}>减</div>
        <div>{res.result}</div>
    </div>
  );
}

export default App;

有同学可能会说,用 useState 比 useReducer 简洁多了。

确实,这个例子不复杂,没必要用 useReducer。

但如果要执行比较复杂的逻辑呢?

用 useState 需要在每个地方都写一遍这个逻辑,而用 useReducer 则是把它封装到 reducer 里,通过 action 触发就好了。

当修改 state 的逻辑比较复杂,用 useReducer。

这就是useReducer的用法。

它有如下概念:

  • state 或 store
  • action
  • reducer
  • dispatch

了解了这些,学习redux就会轻松一些,因为useReducer就是抄了redux的逻辑写成的。

context + useReducer

我们使用contextuseReducer 来完成一个全局共享的状态库。

创建store.ts,定义全局对象

import { nanoid } from 'nanoid'

export type TodoType = {
  id: string
  title: string
}

const initialState: TodoType[] = [
  {
    id: nanoid(5),
    title: '学习react',
  },
  {
    id: nanoid(5),
    title: '学习vue',
  },
]

export default initialState

创建reducer.ts,定义如何修改全局数据

import { type TodoType } from './store'

export type ActionType = {
  type: string
  payload?: any // 附加的内容,要新增的todo等
}

function reducer(state: TodoType[], action: ActionType) {
  switch (action.type) {
    case 'add':
      return [...state, action.payload]
    case 'delete':
      return state.filter(todo => todo.id !== action.payload)
    default:
      throw new Error()
  }
}

export default reducer

创建index.tsx,通过执行useReducer函数,并传入reducer, initialState,返回数据state,及修改state的函数dispatch。然后把 state, dispatch 赋值给 context,这样所有的组件都能获取到state, dispatch。

import { type FC, createContext, useReducer } from 'react'
import List from './List'
import InputForm from './InputForm'
import initialState from './store'
import reducer, { type ActionType } from './reducer'

export const TodoContext = createContext({
  state: initialState,
  dispatch: (action: ActionType) => {},
})

const Demo: FC = () => {
  const [state, dispatch] = useReducer(reducer, initialState)
  return (
    <TodoContext.Provider value={{ state, dispatch }}>
      <List />
      <InputForm />
    </TodoContext.Provider>
  )
}

export default Demo

创建List.tsx

import { type FC, useContext } from 'react'
import { TodoContext } from '.'

const List: FC = () => {
  const { state, dispatch } = useContext(TodoContext)
  return (
    <ul>
      {state.map(item => (
        <li key={item.id}>
          <span>{item.title}</span>
          <button onClick={() => dispatch({ type: 'delete', payload: item.id })}>删除</button>
        </li>
      ))}
    </ul>
  )
}

export default List

创建InputForm.tsx

import { type FC, type ChangeEvent, useState, useContext, memo } from 'react'
import { nanoid } from 'nanoid'
import { TodoContext } from '.'

const InputForm: FC = () => {
  const [text, setText] = useState('')
  const { dispatch } = useContext(TodoContext)
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value)
  }
  const handleSubmit = (e: ChangeEvent<HTMLFormElement>) => {
    e.preventDefault()
    dispatch({ type: 'add', payload: { id: nanoid(5), title: text } })
  }
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="new-todo">what do you want to do?</label>
      <br />
      <input
        type="text"
        id="new-todo"
        style={{ border: '1px solid #ccc' }}
        onChange={handleChange}
        value={text}
      />
      <button type="submit">添加</button>
    </form>
  )
}

export default InputForm

这样就完成了一个简化版本的redux

redux

下面我们看一个真实项目如何使用 redux

首先安装 @reduxjs/toolkitreact-redux:

npm i  @reduxjs/toolkit react-redux -S

新建 index.ts

import { configureStore } from '@reduxjs/toolkit'
import userReducer, { type UserStateType } from './userReducer'
import pageInfoReducer, { type PageInfoStateType } from './pageInfoReducer'


export type StateType = {
  user: UserStateType
  pageInfo: PageInfoStateType
}

export default configureStore({
  // reducer的值就是全局的state数据
  reducer: {
    user: userReducer,
    pageInfo: pageInfoReducer,
  },
})

新建userReducer.ts

import { createSlice, type PayloadAction } from '@reduxjs/toolkit'

export interface UserStateType {
  username: string
  nickname: string
}

const initialState: UserStateType = {
  username: '',
  nickname: '',
}

export const userSlice = createSlice({
  name: 'user',
  initialState: initialState,
  reducers: {
    // Redux Toolkit 允许我们在 reducers 写 "可变" 逻辑。它
    // 并不是真正的改变状态值,因为它使用了 Immer 库
    // 可以检测到“草稿状态“ 的变化并且基于这些变化生产全新的
    // 不可变的状态
    logoutReducer: state => {
      state.username = ''
      state.nickname = ''
    },
    setUserInfoReducer: (state: UserStateType, action: PayloadAction<UserStateType>) => {
      state.username = action.payload.username
      state.nickname = action.payload.nickname
    },
  },
})

export const { logoutReducer, setUserInfoReducer } = userSlice.actions

export default userSlice.reducer

新建pageInfoReducer.ts

import { createSlice, type PayloadAction } from '@reduxjs/toolkit'

export interface PageInfoStateType {
  title: string
  desc?: string
  js?: string
  css?: string
  isPublish?: boolean
}

const initialState: PageInfoStateType = {
  title: '',
  desc: '',
  js: '',
  css: '',
  isPublish: false,
}

export const pageInfoSlice = createSlice({
  name: 'pageInfo',
  initialState: initialState,
  reducers: {
    resetPageInfo: (state: PageInfoStateType, action: PayloadAction<PageInfoStateType>) => {
      Object.assign(state, action.payload)
    },

    changePageTitle: (state: PageInfoStateType, action: PayloadAction<string>) => {
      state.title = action.payload
    },
  },
})

export const { resetPageInfo, changePageTitle } = pageInfoSlice.actions

export default pageInfoSlice.reducer

上面的步骤定义了数据源,以及定义了修改数据源的方法。

那怎么在页面获取数据呢?

新建一个文件useGetUserInfo.ts:

import { useSelector } from 'react-redux'
import { type StateType } from '../store'
import { type UserStateType } from '../store/userReducer'

function useGetUserInfo() {
  const { username, nickname } = useSelector<StateType, UserStateType>(state => state.user)
  return { username, nickname }
}

export default useGetUserInfo

StateType类型为整个数据源的类型

UserStateType类型为整个数据源里面属性user的类型

那如何更新数据呢?

import { resetPageInfo } from '@/store/pageInfoReducer'
import { useDispatch } from 'react-redux'

const dispatch = useDispatch()
const handleValuesChange = () => {
    const values = form.getFieldsValue()
    dispatch(resetPageInfo(values))
}

redux 包括以下核心概念:

  • state 或 store:数据源
  • reducers:定义了如何修改数据源
  • dispatch:触发reducer,并传入新的值

JavaScript 面向对象编程:从字面量到原型继承的深度探索

2025年12月2日 09:26

在前端开发的世界里,JavaScript 是一门“看似简单、实则深邃”的语言。尤其在面向对象编程(OOP)方面,它既不像 Java 那样有清晰的类结构,也不像 Python 那样支持多重继承。然而,正是这种灵活性和独特性,使得 JavaScript 的 OOP 模型成为开发者必须深入理解的核心内容之一。

本文将带你从最原始的对象字面量出发,逐步深入构造函数、原型链、继承机制,最终揭开 ES6 class 背后的本质——基于原型的面向对象编程


一、万物皆对象?JavaScript 的“伪 OOP”之谜

JavaScript 常被称作“基于对象的语言”。你几乎遇到的所有东西——字符串、数字、数组、函数——背后都有对应的对象包装(如 StringNumberArrayFunction)。但严格来说,JavaScript 并不是传统意义上的面向对象语言

  • 它没有原生的“类”(直到 ES6 才引入 class 语法糖)
  • 没有真正的构造器(constructor)概念
  • 继承不是通过类层级,而是通过原型链(Prototype Chain)

这导致很多初学者困惑:“我到底是在写面向对象,还是在操作一堆对象?”


二、从对象字面量开始:最原始的“实例化”

让我们看一段来自 1.js 的代码:

var Cat = { name: "", color: "" };

var cat1 = {};
cat1.name = "加菲猫";
cat1.color = "橘色";

var cat2 = {};
cat2.name = "黑猫警长";
cat2.color = "黑色";

这段代码创建了两个“猫”对象。虽然能用,但问题显而易见:

  • 重复代码多:每次都要手动赋值
  • 无模板约束:无法保证每个实例都有 namecolor
  • 实例之间毫无关系cat1cat2 是完全独立的,无法共享方法

这就像手工作坊造车——每辆车都单独打造,效率低、难维护。


三、构造函数:封装“实例生成”的过程

为了解决上述问题,我们引入构造函数

function Cat(name, color) {
  this.name = name;
  this.color = color;
}

var cat1 = new Cat("加菲猫", "橘色");
var cat2 = new Cat("黑猫警长", "黑色");

当你使用 new 调用函数时,JavaScript 引擎会:

  1. 创建一个空对象 {}
  2. this 指向这个新对象
  3. 执行函数体,给 this 添加属性
  4. 默认返回这个新对象

这样,我们就有了“类模板”的雏形。但仍有缺陷:

  • 方法无法复用:如果给每个实例添加 sayHello 方法,会重复创建函数,浪费内存
function Cat(name, color) {
  this.name = name;
  this.color = color;
  this.sayHello = function() { console.log("Meow!"); }; // ❌ 每个实例都新建函数
}

四、原型(Prototype):共享方法的终极方案

JavaScript 的精妙之处在于 原型机制

每个函数都有一个 prototype 属性,指向一个对象;每个实例都有一个内部属性 [[Prototype]](可通过 __proto__ 访问),指向其构造函数的 prototype

于是我们可以这样优化:

function Cat(name, color) {
  this.name = name;
  this.color = color;
}

Cat.prototype.sayHello = function() {
  console.log(`${this.name} says Meow!`);
};

现在,所有 Cat 实例共享同一个 sayHello 方法,内存高效,逻辑清晰。

关键理解

  • 实例属性放在构造函数中(如 name, color
  • 公共方法放在 prototype 上(如 sayHello

这就是经典的 “构造函数 + 原型” 模式,也是早期 JS OOP 的标准实践。


五、继承:如何让“猫”继承“动物”?

假设我们有一个父类 Animal

function Animal(species) {
  this.species = species;
}

Animal.prototype.breathe = function() {
  console.log("I am breathing...");
};

如何让 Cat 继承 Animal

方案1:借用构造函数(不完整继承)

function Cat(name, color) {
  Animal.call(this, "猫科"); // 绑定 this,继承实例属性
  this.name = name;
  this.color = color;
}

✅ 优点:能继承父类的实例属性
❌ 缺点:无法继承父类原型上的方法(如 breathe

方案2:原型链继承(经典方案)

Cat.prototype = new Animal("猫科");
Cat.prototype.constructor = Cat; // 修复 constructor 指向

现在 cat1 instanceof Animaltrue,且能调用 breathe()

但问题又来了:所有子类实例共享父类实例属性,若父类有引用类型属性(如数组),会互相污染。

方案3:组合继承(推荐)

结合前两种方式:

function Cat(name, color) {
  Animal.call(this, "猫科"); // 继承属性
  this.name = name;
  this.color = color;
}

Cat.prototype = Object.create(Animal.prototype); // 继承方法
Cat.prototype.constructor = Cat;

Cat.prototype.sayHello = function() {
  console.log(`${this.name} says Meow!`);
};

这才是真正意义上的“继承”:属性私有,方法共享,结构清晰


六、ES6 的 class:语法糖还是革命?

ES6 引入了 class 语法:

class Animal {
  constructor(species) {
    this.species = species;
  }
  breathe() {
    console.log("I am breathing...");
  }
}

class Cat extends Animal {
  constructor(name, color) {
    super("猫科");
    this.name = name;
    this.color = color;
  }
  sayHello() {
    console.log(`${this.name} says Meow!`);
  }
}

看起来像 Java/Python?但请记住:

class 只是原型继承的语法糖!底层仍是基于原型链。

你可以验证:

console.log(Cat.prototype.__proto__ === Animal.prototype); // true

所以,理解原型,才是掌握 JS OOP 的钥匙


七、总结:JS OOP 的演进脉络

阶段 特点 问题
对象字面量 简单直接 无法复用,无模板
构造函数 封装实例化 方法不能共享
原型模式 方法共享 实例属性需在构造函数中定义
组合继承 属性+方法完美分离 稍显冗长
ES6 class 语法简洁 仍是原型,需理解底层

写在最后

JavaScript 的面向对象不是“残缺”,而是“另辟蹊径”。它的原型系统赋予了语言极大的动态性和灵活性——你可以随时修改原型、扩展内置对象、实现 mixin 等高级模式。

不要被 class 迷惑,也不要畏惧 prototype
真正理解原型链,你才能写出高性能、可维护、可扩展的 JavaScript 代码。

UniApp Vue3 词云组件开发实战:从原理到应用

作者 Amy_yang
2025年12月2日 09:13

UniApp Vue3 词云组件开发实战:从原理到应用

在数据可视化领域,词云是一种直观展示文本数据分布的方式。本文将详细介绍如何在 UniApp Vue3 环境下开发一个功能完善的词云组件,包括核心算法实现、组件封装及性能优化技巧。通过本文,你将掌握碰撞检测、螺旋布局等关键技术,最终实现一个可自定义、高性能的词云组件。

引言:词云组件的应用价值

词云作为一种将文本数据可视化的手段,通过词汇大小和颜色变化直观反映关键词的重要程度和出现频率,在数据分析、用户画像、舆情监控等场景中有着广泛应用。在移动应用开发中,一个高性能、可定制的词云组件能为用户提供更丰富的数据洞察方式。

UniApp 作为跨平台开发框架,其 Vue3 版本带来了更好的性能和更简洁的语法。然而,在移动端实现词云面临诸多挑战:如何在有限的屏幕空间内合理布局词汇?如何避免词汇重叠?如何保证在不同设备上的显示效果一致?本文将逐一解决这些问题,带你从零构建一个适配安卓环境的 UniApp Vue3 词云组件。

核心功能实现

词汇尺寸计算

词云的核心在于通过词汇大小反映其权重。我们首先需要根据词汇的权重值计算其显示尺寸。在提供的代码中,通过 calculateWordDimensions 函数实现了这一功能:

const calculateWordDimensions = (word) => {
  // Approximate text width (characters * font size * constant)
  const charWidth = word.size * 0.6;
  const charHeight = word.size * 1.2;
  return {
    width: word.text.length * charWidth,
    height: charHeight,
  };
};

该函数根据词汇的基础大小(word.size)和文本长度计算出词汇的宽高。这里使用了经验系数 0.6 和 1.2 来近似字符的宽高比,你可以根据实际字体进行调整。

碰撞检测机制

为了避免词汇重叠,我们需要实现碰撞检测。isOverlapping 函数通过比较两个词汇的边界框来判断它们是否重叠:

const isOverlapping = (word1, word2, padding = 5) => {
  return !(
    word1.x + word1.width + padding < word2.x - padding ||
    word2.x + word2.width + padding < word1.x - padding ||
    word1.y + word1.height + padding < word2.y - padding ||
    word2.y + word2.height + padding < word1.y - padding
  );
};

这个函数通过判断两个矩形是否完全分离来确定是否发生碰撞。如果四个方向上任意一个方向满足"一个矩形完全在另一个矩形之外"的条件,则认为没有碰撞,返回 false;否则返回 true,表示发生了碰撞。

image.png

边界检查

除了避免词汇间重叠,还需要确保所有词汇都在词云容器内显示。isWithinBounds 函数负责这一检查:

const isWithinBounds = (word, width, height, padding = 10) => {
  return (
    word.x >= padding &&
    word.y >= padding &&
    word.x + word.width <= width - padding &&
    word.y + word.height <= height - padding
  );
};

该函数确保词汇在容器内留有一定边距(padding),避免词汇紧贴容器边缘,提升视觉效果。

螺旋布局算法

词云布局是整个组件的核心。本组件采用了螺旋布局算法,从中心向外逐步放置词汇,当遇到碰撞时调整位置继续尝试。核心代码如下:

const angle = index + attempts * 0.2;
const radius = Math.min(effectiveWidth, effectiveHeight) * 0.3 * (attempts / maxAttempts);
const x = centerX + radius * Math.cos(angle) - dimensions.width / 2;
const y = centerY + radius * Math.sin(angle) - dimensions.height / 2;

这段代码通过极坐标计算词汇位置:随着尝试次数(attempts)增加,半径(radius)逐渐增大,同时角度(angle)也在变化,形成螺旋轨迹。这种布局方式能让词汇从中心向外均匀分布,形成美观的圆形词云。

image.png

网格布局 fallback

当螺旋布局尝试多次仍无法放置词汇时,代码会 fallback 到网格布局:

const gridCellWidth = dimensions.width + padding * 2;
const gridCellHeight = dimensions.height + padding * 2;
for (let x = padding; x <= props.width - dimensions.width - padding; x += gridCellWidth) {
  for (let y = padding; y <= props.height - dimensions.height - padding; y += gridCellHeight) {
    // 尝试放置词汇
  }
}

网格布局将容器划分为等大小的单元格,在每个单元格中尝试放置词汇,确保即使在极端情况下所有词汇都能被放置。

样式设计与交互效果

为了提升用户体验,组件还实现了丰富的样式和交互效果:

  • 随机颜色生成:通过 getRandomColor 函数为每个词汇分配随机颜色
  • 悬停效果:通过 CSS 过渡实现词汇缩放效果
  • 点击事件:通过 onWordClick 函数触发点击事件回调

关键代码解析

词汇布局主流程

calculatePositionsWithCollision 函数是词汇布局的核心,其流程如下:

  1. 对词汇按大小排序,确保大词汇优先布局

  2. 初始化中心位置和有效宽高

  3. 对每个词汇执行螺旋布局算法:

    • 计算螺旋轨迹上的候选位置
    • 检查位置是否在边界内
    • 检查是否与已放置词汇碰撞
    • 如果找到合适位置则放置词汇
  4. 如果螺旋布局失败,尝试网格布局

  5. 将最终位置信息保存到 positionedWords

// 按大小排序词汇(大词汇优先)
const sortedWords = [...props.words].sort((a, b) => b.size - a.size);

// 放置每个词汇
sortedWords.forEach((word, index) => {
  const dimensions = calculateWordDimensions(word);
  let placed = false;
  let attempts = 0;
  const maxAttempts = 200;

  while (!placed && attempts < maxAttempts) {
    // 螺旋算法计算位置
    const angle = index + attempts * 0.2;
    const radius = Math.min(effectiveWidth, effectiveHeight) * 0.3 * (attempts / maxAttempts);
    const x = centerX + radius * Math.cos(angle) - dimensions.width / 2;
    const y = centerY + radius * Math.sin(angle) - dimensions.height / 2;

    const candidateWord = { ...word, x: Math.round(x), y: Math.round(y), ...dimensions };

    // 检查边界和碰撞
    if (!isWithinBounds(candidateWord, props.width, props.height, padding)) {
      attempts++;
      continue;
    }

    let hasCollision = false;
    for (const placedWord of positions) {
      if (isOverlapping(candidateWord, placedWord)) {
        hasCollision = true;
        break;
      }
    }

    if (!hasCollision) {
      positions.push(candidateWord);
      placed = true;
    } else {
      attempts++;
    }
  }

  // 如果螺旋布局失败,尝试网格布局
  if (!placed) {
    placeInGrid(positions, word, dimensions, padding);
  }
});

这段代码体现了算法的核心思想:通过螺旋轨迹探索可能的位置,结合碰撞检测确保词汇不重叠,大词汇优先放置以保证视觉效果。

响应式更新

为了在词汇数据变化时自动更新布局,组件使用了 Vue3 的 computed:

computed(() => {
  if (props.words && props.words.length > 0) {
    calculatePositionsWithCollision();
  }
});

当 props.words 变化时,会自动触发重新布局,确保视图与数据同步。

使用示例

基本用法

在页面中引入词云组件并传入词汇数据:

<template>
  <view class="content">
    <word-cloud :words="wordData" :width="300" :height="300" @word-click="handleWordClick"></word-cloud>
  </view>
</template>

<script setup>
import WordCloud from '@/components/WordCloud.vue';
import { ref } from 'vue';

const wordData = ref([
  { text: 'JavaScript', size: 24, weight: 10 },
  { text: 'Vue3', size: 20, weight: 8 },
  { text: 'UniApp', size: 18, weight: 7 },
  { text: '词云', size: 16, weight: 6 },
  // 更多词汇...
]);

const handleWordClick = (word) => {
  uni.showToast({ title: `点击了:${word.text}` });
};
</script>

自定义样式

通过 CSS 变量自定义词云样式:

.word-cloud-container {
  --word-cloud-bg: #f5f5f5;
  --word-cloud-border-radius: 16px;
}

动态更新数据

通过修改 wordData 实现词云动态更新:

// 添加新词汇
const addWord = () => {
  wordData.value.push({
    text: '新词汇',
    size: 14 + Math.random() * 10,
    weight: 5
  });
};

// 清空词云
const clearWords = () => {
  wordData.value = [];
};

优化建议

性能优化

  1. 减少重绘:词汇位置计算是 CPU 密集型操作,建议使用 requestAnimationFrame 分批处理词汇布局。
  2. 缓存计算结果:对于相同的词汇数据,缓存布局结果,避免重复计算。
  3. 虚拟滚动:对于大量词汇,考虑实现虚拟滚动,只渲染可见区域的词汇。
  4. 使用 Web Workers:将布局计算放入 Web Worker 中执行,避免阻塞主线程。

用户体验提升

  1. 响应式设计:根据容器大小自动调整词汇布局,适应不同屏幕尺寸。
  2. 动画过渡:添加词汇出现和消失的过渡动画,提升视觉体验。
  3. 交互反馈:为词汇添加点击、长按等交互效果,支持跳转或显示详情。
  4. 可访问性:确保颜色对比度符合标准,支持屏幕阅读器。

功能扩展

  1. 自定义形状:支持自定义词云形状,如圆形、矩形、图片轮廓等。
  2. 颜色主题:提供多种预设颜色主题,支持自定义颜色映射。
  3. 词汇分组:支持按类别对词汇进行分组,使用不同颜色区分。
  4. 动态权重:支持动态更新词汇权重并实时更新词云。

总结

本文详细介绍了 UniApp Vue3 词云组件的实现原理和使用方法。通过碰撞检测、螺旋布局等核心算法,我们解决了移动端词云布局的关键问题。组件支持自定义尺寸、颜色和交互,可灵活应用于各种数据可视化场景。

UniApp 提供的跨平台能力结合 Vue3 的响应式系统,使得开发高性能移动词云组件成为可能。通过本文介绍的优化建议,你可以进一步提升组件性能和用户体验,满足更复杂的业务需求。

词云作为数据可视化的重要手段,其应用场景正在不断扩展。希望本文能为你的移动应用开发提供新的思路和工具,让数据展示更加生动直观。

image.png

❌
❌