阅读视图

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

从监听失败到实时更新:我在NFT铸造项目中搞定合约事件监听的全过程

背景

上个月,我接了一个NFT铸造平台的前端开发。项目有个核心需求:用户点击“铸造”按钮后,前端需要实时显示铸造成功的交易,并立刻更新用户的NFT持有数量。这听起来是个典型的“监听智能合约事件”场景。

我一开始觉得这很简单——用 ethers.jscontract.on 不就搞定了吗?但实际开发中,我遇到了各种幺蛾子:页面切换时监听没取消导致内存泄漏、用户切换钱包网络后监听器还在老链上工作、甚至有时候事件根本触发不了。用户反馈说“铸造成功了但页面没反应”,这体验实在太差。我不得不停下来,系统性地解决这个监听问题。

问题分析

我最开始的实现确实很 naive。在React组件里,我直接用了 ethers.jscontract.on('Transfer', callback)

useEffect(() => {
  const contract = new ethers.Contract(address, abi, provider);
  
  const handleTransfer = (from, to, tokenId, event) => {
    console.log('NFT转移了!', tokenId.toString());
    // 更新UI状态...
  };
  
  contract.on('Transfer', handleTransfer);
  
  return () => {
    contract.off('Transfer', handleTransfer);
  };
}, []);

这个方案有三个明显问题:

  1. 网络切换问题:当用户从以太坊主网切换到Polygon时,contract 实例还是基于旧网络的provider,监听自然失效
  2. 组件生命周期问题:虽然我写了清理函数,但有时候组件卸载和重新挂载的速度太快,off 可能没执行到位
  3. 状态同步问题:监听回调里更新React状态时,如果组件已经卸载,会报“内存泄漏”警告

更麻烦的是,我们的DApp支持多链(以太坊、Polygon、Arbitrum),用户随时可能切换网络。我需要一个能自动处理网络切换、能优雅清理、并且与React状态管理无缝集成的方案。

核心实现

放弃 ethers.js,拥抱 wagmi + viem

经过一番调研和试错,我决定用 wagmi + viem 这套现代Web3开发组合。wagmi 提供了完善的React Hooks,而 viem 是类型安全、模块化的以太坊库。最重要的是,wagmiuseWatchContractEvent Hook 看起来就是为这个场景设计的。

但这里有个坑:wagmi 的文档虽然不错,但关于事件监听的部分例子不多,特别是处理实时UI更新和错误处理的实战案例很少。我得自己摸索。

实现基础监听

首先,我配置了 wagmi 的客户端,支持多链:

// wagmi.config.ts
import { createConfig, http } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';

export const config = createConfig({
  chains: [mainnet, polygon, arbitrum],
  connectors: [injected()],
  transports: {
    [mainnet.id]: http(),
    [polygon.id]: http(),
    [arbitrum.id]: http(),
  },
});

然后在React组件中,我这样使用 useWatchContractEvent

import { useWatchContractEvent } from 'wagmi';

function NFTMintComponent() {
  const { address } = useAccount();
  
  useWatchContractEvent({
    address: '0x742d35Cc6634C0532925a3b844Bc9e...', // NFT合约地址
    abi: nftContractAbi,
    eventName: 'Transfer',
    args: { to: address }, // 只监听转给当前用户的事件
    onLogs: (logs) => {
      console.log('监听到新的Transfer事件:', logs);
      // 这里更新UI状态
    },
  });
  
  return (
    // UI组件
  );
}

这个基础版本已经比最初的 ethers.js 方案好多了:wagmi 会自动处理网络切换,当用户切换链时,监听会自动重新建立到新链上。

处理事件去重和状态更新

但很快我发现新问题:同一个交易的事件有时会被触发多次。这是因为区块链节点可能推送重复的事件,或者组件重新渲染导致监听重新建立。

我需要去重逻辑。每个事件都有唯一的 transactionHashlogIndex,可以用它们组合成唯一ID:

import { useCallback, useRef } from 'react';
import { useWatchContractEvent, Log } from 'wagmi';

function useUniqueContractEvents(options: {
  address: `0x${string}`;
  abi: any;
  eventName: string;
  onUniqueLogs: (logs: Log[]) => void;
}) {
  const processedIds = useRef<Set<string>>(new Set());
  
  const handleLogs = useCallback((logs: Log[]) => {
    const uniqueLogs = logs.filter(log => {
      const id = `${log.transactionHash}-${log.logIndex}`;
      if (processedIds.current.has(id)) {
        return false;
      }
      processedIds.current.add(id);
      return true;
    });
    
    if (uniqueLogs.length > 0) {
      options.onUniqueLogs(uniqueLogs);
    }
  }, [options.onUniqueLogs]);
  
  useWatchContractEvent({
    address: options.address,
    abi: options.abi,
    eventName: options.eventName,
    onLogs: handleLogs,
  });
}

然后在组件中使用这个自定义Hook:

function NFTMintComponent() {
  const [mintedTokens, setMintedTokens] = useState<number[]>([]);
  
  useUniqueContractEvents({
    address: nftContractAddress,
    abi: nftContractAbi,
    eventName: 'Transfer',
    onUniqueLogs: (logs) => {
      // 从事件数据中提取tokenId
      const newTokenIds = logs.map(log => 
        Number(log.args.tokenId)
      );
      
      // 批量更新状态,减少重新渲染
      setMintedTokens(prev => [...prev, ...newTokenIds]);
      
      // 显示成功提示
      showNotification(`成功铸造 ${newTokenIds.length} 个NFT!`);
    },
  });
  
  return (
    <div>
      已铸造的NFT: {mintedTokens.join(', ')}
    </div>
  );
}

处理组件卸载和错误边界

还有一个重要问题:如果监听过程中RPC节点连接失败怎么办?或者组件卸载时如何确保监听完全清理?

wagmiuseWatchContractEvent 在组件卸载时会自动清理,但错误处理需要我们自己加:

import { useWatchContractEvent, usePublicClient } from 'wagmi';
import { useEffect } from 'react';

function useRobustContractEvent(options: {
  address: `0x${string}`;
  abi: any;
  eventName: string;
  onLogs: (logs: Log[]) => void;
  onError?: (error: Error) => void;
}) {
  const publicClient = usePublicClient();
  
  // 先获取历史事件,避免遗漏
  useEffect(() => {
    const fetchPastEvents = async () => {
      try {
        const logs = await publicClient.getLogs({
          address: options.address,
          event: parseAbiItem(`event ${options.eventName}(address indexed from, address indexed to, uint256 indexed tokenId)`),
          fromBlock: 'latest', // 实际项目中可能需要更大的范围
        });
        
        if (logs.length > 0) {
          options.onLogs(logs);
        }
      } catch (error) {
        console.error('获取历史事件失败:', error);
        options.onError?.(error as Error);
      }
    };
    
    fetchPastEvents();
  }, [options.address, options.eventName, publicClient]);
  
  // 实时监听新事件
  useWatchContractEvent({
    address: options.address,
    abi: options.abi,
    eventName: options.eventName,
    onLogs: options.onLogs,
    onError: options.onError,
  });
}

这个方案结合了历史事件查询和实时监听,确保不会遗漏任何事件。即使实时监听暂时断开,也能通过轮询历史事件来弥补。

完整代码

下面是一个完整的、可直接运行的NFT铸造页面组件:

// NFTMintPage.tsx
import React, { useState, useCallback, useRef } from 'react';
import { useWatchContractEvent, useAccount, usePublicClient, useWriteContract } from 'wagmi';
import { parseAbiItem, Log } from 'viem';
import { showNotification } from './notification';

// NFT合约ABI片段
const nftContractAbi = [
  {
    name: 'Transfer',
    type: 'event',
    inputs: [
      { name: 'from', type: 'address', indexed: true },
      { name: 'to', type: 'address', indexed: true },
      { name: 'tokenId', type: 'uint256', indexed: true },
    ],
  },
  {
    name: 'mint',
    type: 'function',
    stateMutability: 'payable',
    inputs: [],
    outputs: [],
  },
] as const;

const NFT_CONTRACT_ADDRESS = '0x742d35Cc6634C0532925a3b844Bc9e...';

function useUniqueContractEvents(options: {
  address: `0x${string}`;
  abi: any;
  eventName: string;
  onUniqueLogs: (logs: Log[]) => void;
  onError?: (error: Error) => void;
}) {
  const processedIds = useRef<Set<string>>(new Set());
  const publicClient = usePublicClient();
  
  // 获取历史事件
  React.useEffect(() => {
    const fetchPastEvents = async () => {
      try {
        const logs = await publicClient.getLogs({
          address: options.address,
          event: parseAbiItem(`event ${options.eventName}(address indexed from, address indexed to, uint256 indexed tokenId)`),
          fromBlock: 'latest',
          toBlock: 'latest',
        });
        
        const uniqueLogs = logs.filter(log => {
          const id = `${log.transactionHash}-${log.logIndex}`;
          return !processedIds.current.has(id);
        });
        
        if (uniqueLogs.length > 0) {
          uniqueLogs.forEach(log => {
            processedIds.current.add(`${log.transactionHash}-${log.logIndex}`);
          });
          options.onUniqueLogs(uniqueLogs);
        }
      } catch (error) {
        console.error('获取历史事件失败:', error);
        options.onError?.(error as Error);
      }
    };
    
    fetchPastEvents();
  }, [options.address, options.eventName, publicClient]);
  
  // 实时监听
  const handleLogs = useCallback((logs: Log[]) => {
    const uniqueLogs = logs.filter(log => {
      const id = `${log.transactionHash}-${log.logIndex}`;
      if (processedIds.current.has(id)) {
        return false;
      }
      processedIds.current.add(id);
      return true;
    });
    
    if (uniqueLogs.length > 0) {
      options.onUniqueLogs(uniqueLogs);
    }
  }, [options.onUniqueLogs]);
  
  useWatchContractEvent({
    address: options.address,
    abi: options.abi,
    eventName: options.eventName,
    onLogs: handleLogs,
    onError: options.onError,
  });
}

export function NFTMintPage() {
  const { address } = useAccount();
  const [mintedTokens, setMintedTokens] = useState<number[]>([]);
  const [isMinting, setIsMinting] = useState(false);
  
  const { writeContractAsync } = useWriteContract();
  
  // 监听Transfer事件
  useUniqueContractEvents({
    address: NFT_CONTRACT_ADDRESS,
    abi: nftContractAbi,
    eventName: 'Transfer',
    onUniqueLogs: (logs) => {
      // 只处理转给当前用户的事件
      const relevantLogs = logs.filter(log => 
        log.args.to?.toLowerCase() === address?.toLowerCase()
      );
      
      if (relevantLogs.length > 0) {
        const newTokenIds = relevantLogs.map(log => 
          Number(log.args.tokenId)
        );
        
        setMintedTokens(prev => {
          const combined = [...prev, ...newTokenIds];
          // 去重排序
          return [...new Set(combined)].sort((a, b) => a - b);
        });
        
        showNotification(`🎉 成功收到 ${newTokenIds.length} 个NFT!`);
      }
    },
    onError: (error) => {
      console.error('事件监听出错:', error);
      showNotification('事件监听连接不稳定,请刷新页面', 'warning');
    },
  });
  
  // 铸造函数
  const handleMint = async () => {
    if (!address) {
      showNotification('请先连接钱包', 'error');
      return;
    }
    
    try {
      setIsMinting(true);
      
      await writeContractAsync({
        address: NFT_CONTRACT_ADDRESS,
        abi: nftContractAbi,
        functionName: 'mint',
        value: 0.01n * 10n ** 18n, // 假设铸造价格是0.01 ETH
      });
      
      showNotification('交易已提交,请等待确认...', 'info');
    } catch (error: any) {
      console.error('铸造失败:', error);
      showNotification(`铸造失败: ${error.shortMessage || error.message}`, 'error');
    } finally {
      setIsMinting(false);
    }
  };
  
  return (
    <div className="nft-mint-page">
      <h1>NFT铸造平台</h1>
      
      <div className="mint-section">
        <button 
          onClick={handleMint}
          disabled={isMinting || !address}
        >
          {isMinting ? '铸造中...' : '铸造NFT (0.01 ETH)'}
        </button>
        
        {!address && (
          <p className="hint">请先连接钱包</p>
        )}
      </div>
      
      <div className="tokens-section">
        <h2>你的NFT ({mintedTokens.length}个)</h2>
        
        {mintedTokens.length === 0 ? (
          <p>还没有NFT,点击上方按钮铸造</p>
        ) : (
          <div className="token-list">
            {mintedTokens.map(tokenId => (
              <div key={tokenId} className="token-card">
                NFT #{tokenId}
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

踩坑记录

在实际开发中,我遇到了几个具体的坑,这里记录下来:

  1. 事件重复触发:最开始没有做去重,发现同一个铸造交易会触发2-3次事件更新。原因是节点推送可能重复,且组件重渲染会重新建立监听。解决方案就是用 transactionHash + logIndex 做唯一标识去重。

  2. 网络切换后监听不更新:虽然 wagmi 理论上应该自动处理,但我发现切换到某些测试网时,监听器还在旧链上。后来发现是因为我硬编码了RPC URL,没有用 wagmiusePublicClient。改用 usePublicClient() 后,网络切换就正常了。

  3. TypeScript类型错误viem 对类型要求很严格,事件参数的访问方式从 log.args[2] 变成了 log.args.tokenId。一开始我按老习惯写,类型检查报错。需要仔细看ABI定义,用正确的属性名访问。

  4. 内存泄漏警告:在监听回调中直接更新状态,如果组件卸载得快,会报“Can't perform a React state update on an unmounted component”。我加了 useRef 来跟踪组件挂载状态,但后来发现 wagmi 的 Hook 已经处理了这个问题,主要是我自己的 setState 调用时机不对。最终方案是把状态更新包装在条件判断里。

小结

经过这次折腾,我最大的收获是:现代Web3前端开发中,用 wagmi + viem 这套组合能省去很多底层细节的麻烦。事件监听这种看似简单的功能,实际上要考虑网络切换、错误处理、性能优化等多个方面。现在这套方案已经在生产环境稳定运行,用户反馈“铸造后立刻能看到NFT”的体验很好。

如果想进一步优化,可以考虑加上事件监听的状态指示器(比如显示“正在监听事件...”),或者实现离线事件队列,等网络恢复后一并处理。不过对于大多数DApp来说,现在的方案已经足够可靠了。

uni-app 编译小程序原生组件时疑似丢属性,可以给官方提 PR 了

前言

大家好,我是 uni-app 的核心开发 笨笨狗吞噬者。不少开发者都遇到过某些小程序原生组件在原生项目能跑,但是写到 uni-app 中就用不了,今天来分析下背后的原因以及原理,以后再遇到类似的问题,你也可以给 uni-app 仓库提 pr。

问题

我们以微信小程序的 official-account-publish 组件为例,看下错误的产物和正确的产物都是什么样的,

测试代码

<template>
  <official-account-publish1
    topic="和coco一起做好事"
  ></official-account-publish1>
</template>

错误产物

<official-account-publish
  wx:if="{{d}}"
  u-i="1d1772a4-1"
  bind:__l="__l"
  u-p="{{d}}"
></official-account-publish>

正确产物

<official-account-publish topic="和coco一起做好事"></official-account-publish>

true.jpg

可以看到错误的产物上面多了不少属性,比如 u-iu-p,而正常产物是没有这些的

原理

其实这个差异的核心,不在于 official-account-publish 组件本身,而在于编译阶段它有没有命中小程序的 isCustomElement

uni-app 在编译小程序模板时,会先给当前平台准备一份原生组件名单。这个名单通常就在对应平台包的 compiler/options 里维护,比如微信小程序这一份就放在平台编译配置里 github.com/dcloudio/un…

wx.jpg

除此之外,项目侧还可以通过 manifest.json 里的 nativeTags 再补一份,最后一起参与 isCustomElement 判断

也就是说,编译器遇到一个标签时,并不是简单看它长得像不像组件,而是先看它在不在这份名单里

如果命中了,说明它是小程序原生组件,那就按原生标签处理,模板产物会尽量保留原样,像 topic 这类属性也会直接落到最终产物里,不会额外补组件运行时需要的标记

如果没有命中,编译器就会把它当成普通 vue 组件继续处理。这时候就会进入组件那套编译分支,给节点补上 u-iu-pbind:__l 这一类运行时字段,用来做组件实例关联、props 同步这些事情,所以最终产物看起来就“不像原生组件了”

所以这个问题本质上就是一句话:official-account-publish 应该走小程序 isCustomElement 分支,而不是走 vue 组件分支。一旦判断错了,生成结果就会立刻不一样

这类问题的修复思路通常也很直接,如果某个小程序原生组件没有被正确识别,除了补平台内置名单,更常见的做法就是在项目配置里把它加到 nativeTags,让编译器把它当成原生标签处理

比如:

// manifest.json
{
  "mp-weixin": {
    "nativeTags": ["official-account-publish"]
  }
}

加完之后,这个标签就会进入 isCustomElement 的判断范围,编译时不再走 vue 组件分支,生成结果也就会回到小程序原生组件该有的样子

交流群

我建了一个微信群(非官方),大家可以在群里和我沟通交流 uniapp 开发遇到的问题、uniapp 的源码等问题。

mmqrcode1775031270633.png

在 Windows 上安装 uv(高性能 Python 包管理器)

1. 使用 PowerShell 安装(官方推荐)

这是最直接的方式。打开 PowerShell,运行以下命令:

PowerShell

powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
  • 注意:安装完成后,你可能需要重启终端(Powershell 或 VS Code 内置终端)才能让 uv 命令生效。

2. 使用包管理器安装

如果你电脑上已经安装了常见的 Windows 包管理器,可以用对应的命令:

  • pip (如果你已经有 Python 环境):

    Bash

    pip install uv
    
  • Winget (Windows 自带):

    Bash

    winget install astral-sh.uv
    
  • Scoop:

    Bash

    scoop install uv
    

3. 验证安装

安装完成后,输入以下命令检查是否成功:

PowerShell

uv --version

常用命令快速上手

安装好后,你可以尝试以下操作来感受它的速度:

  • 创建虚拟环境uv venv
  • 激活环境.venv\Scripts\activate
  • 安装包uv pip install flask(比原生 pip 快得多)
  • 运行脚本uv run python_file.py

小提示:如果你在 VS Code 中使用,建议安装 Python 扩展,它现在能很好地识别 uv 创建的虚拟环境。

🔍 React 面试官眼中的“秘密武器”:深度剖析 useRef

🔍 React 面试官眼中的“秘密武器”:深度剖析 useRef

在 React 的面试战场上,useRef 绝对是那个看似不起眼,实则能决定你能否进入下一轮的“关键先生”。很多候选人能熟练使用 useStateuseEffect,但当被问及“如何在不触发重渲染的情况下保存数据”或“为什么我的定时器关不掉”时,往往哑口无言。

今天,我们就通过两段典型的实战代码,揭开 useRef 的神秘面纱,让你在面试中不仅能写出代码,更能讲出原理。

📌 核心考点:useRef 是什么?

在深入代码前,你需要向面试官传达两个核心概念:

  1. 它是一个“盒子”useRef 返回一个普通的 JavaScript 对象,这个对象在组件的整个生命周期内保持引用稳定(始终是同一个对象)。
  2. 它是“非响应式”的:修改 .current 属性不会触发组件的重新渲染。这是它与 useState 最根本的区别。

💡 场景一:DOM 的直接掌控者

面试官提问: “如何在组件挂载后,让输入框自动获得焦点?”

这通常是考察 useRef 最经典的入门题。我们来看第一段代码:

import { useEffect, useRef, useState } from "react"

export default function App() {
  const [count, setCount] = useState(0);
  console.log('变了'); // 每次count变化,组件重渲染,这句都会执行
  
  const inputRef = useRef(null); // 创建一个ref容器
  
  useEffect(() => {
    // 在这里操作DOM
    inputRef.current.focus(); // 让input获得焦点
    console.log('222', inputRef.current); // 打印真实的DOM节点
  }, [])
  
  // 注意看这里:在渲染函数体中,inputRef.current 是 null
  console.log('111', inputRef.current); 
  
  return (
    <>
      <input ref={inputRef}/> {/* 将ref绑定到DOM上 */}
      <button type="button" onClick={() => setCount(count+1)}>count ++</button>
    </>
  )
}

面试解析要点:

  1. 同步与异步:请留意代码中的 console.log
    • 111:在函数体直接打印,此时 React 还在“画”界面,DOM 节点尚未生成,所以值为 null
    • 222:在 useEffect 中打印,此时 React 已经把界面“挂载”到页面上,inputRef.current 已经被赋值为真实的 DOM 元素。
  2. 执行时机useEffect 在浏览器完成渲染后执行,这保证了我们操作的 DOM 是真实存在的。
  3. 价值体现:这是 useRef 最直观的用途——打破 React 的虚拟 DOM 抽象层,直接操作真实 DOM

⏳ 场景二:跨越渲染的“持久化”存储

面试官提问: “React 函数组件每次渲染都会重新执行函数,如果我在一个定时器里需要保存状态,或者想在点击按钮时清除上一次的定时器,该怎么办?”

这时候,如果只用普通变量,每次重渲染变量都会被重置;如果用 useState,清除定时器的逻辑会变得复杂且容易出错。第二段代码展示了 useRef 的高级用法:

import { useRef, useState, useEffect } from 'react';

export default function App() {
    let intervalId = useRef(null); // 用ref来保存定时器ID
    const [count, setCount] = useState(0);
    
    function start() {
        // 即使组件多次渲染,intervalId 这个“盒子”始终是同一个
        intervalId.current = setInterval(() => {
            console.log('tick~~~');
        }, 1000);
        console.log(intervalId); // 打印 { current: 123 }
    }
    
    function stop() {
        // 从“盒子”里取出ID并清除
        clearInterval(intervalId.current);
    }

    // 仅用于演示:当count变化时,查看ref的值
    useEffect(() => {
        console.log(intervalId.current); // 只要定时器开着,这里会一直打印ID
    }, [count])
    
    return (
        <>
           <button onClick={start}>开始</button>
           <button onClick={stop}>停止</button>
           <button type="button" onClick={() => setCount(count+1)}>count ++</button>
        </>
    )
}

面试解析要点:

  1. 闭包陷阱的解药:在 start 函数中,我们将 setInterval 返回的 ID 存入了 intervalId.current。无论组件因为 count 变化重渲染多少次,intervalId 对象本身不会变,变的只是它里面的 current 值。
  2. 清理资源stop 函数能够准确获取到最新的定时器 ID 并清除它。如果不用 useRef,而用普通变量,stop 函数将无法访问到 start 函数内部的变量(作用域隔离)。
  3. 非响应式优势:我们将定时器 ID 存入 ref,并不希望它触发页面刷新。useRef 完美地充当了一个“默默奉献的存储柜”角色。

📝 总结:面试官想听到什么?

当被问及 useRef 时,请务必构建以下回答框架:

  1. 定义:它是用来创建一个在组件生命周期内持久化且引用稳定的对象。
  2. 两大用途
    • 访问 DOM:通过 ref 属性附加到元素上。
    • 存储可变值:存储定时器 ID、上一次的 props、第三方库实例等不需要触发视图更新的数据。
  3. useState 的区别ref 的变化是同步的且不触发重渲染;state 的变化是异步的且一定会触发重渲染。
  4. 避坑指南:不要试图用 useRef 替代状态管理,因为它的变化 React “看不见”,UI 不会自动更新。

掌握这些,你就能在面试中自信地告诉面试官:useRef** 不仅仅是一个获取 DOM 的工具,它是连接函数组件渲染周期与可变状态的桥梁。**

Vue3 的 v-model 双向绑定,90% 的人都用错了?(附 2026 最新避坑指南)

你的表单数据绑定了却不动?自定义组件 v-model 写了就是不生效?
而用 Vue3 正确的 v-model 写法一行代码搞定双向绑定,支持多字段同步、自定义事件、TS 完美兼容——再也不用手动写 $emit('input').sync 修饰符

如果你受够了:

  • 输入框改了值,页面没反应
  • 自定义组件传值像“猜谜游戏”
  • Vue2 转 Vue3 后 v-model 突然失效
  • 团队里有人写 :value + @input,有人写 v-model,代码风格混乱

那么,这篇 2026 年最新实操指南,就是为你写的——
不用翻文档,所有代码模板直接复制粘贴,今天就能写出零 bug 的双向绑定


一、先搞懂:Vue3 的 v-model,到底“新”在哪?

很多从 Vue2 过来的开发者,还在用老思维写 v-model,结果频频翻车。
Vue3 对 v-model 做了三大升级

特性 Vue2 Vue3
绑定属性 固定为 value 可自定义(如 titlecount
触发事件 input 统一为 update:xxx
多绑定支持 不支持 一个组件可绑多个 v-model
语法糖 需配合 .sync 原生支持,无需额外修饰符

一句话总结Vue3 的 v-model = 更灵活 + 更统一 + 更少代码


二、核心干货:v-model 3 大场景实战(附可运行模板)

场景1:基础表单绑定(覆盖 80% 日常开发)

适用于 <input><textarea><select>、复选框等。

【实操代码】(直接复制)

<template>
  <div class="form-demo">
    <!-- 文本输入 -->
    <input v-model="username" placeholder="账号" />
    
    <!-- 密码 -->
    <input v-model="password" type="password" placeholder="密码" />
    
    <!-- 多行文本 -->
    <textarea v-model="bio" placeholder="个人简介"></textarea>
    
    <!-- 复选框(布尔值) -->
    <label>
      <input type="checkbox" v-model="agree" />
      同意用户协议
    </label>

    <!-- 实时预览 -->
    <div class="preview">
      账号:{{ username }}<br/>
      密码:{{ password }}<br/>
      简介:{{ bio }}<br/>
      已同意:{{ agree ? '✅' : '❌' }}
    </div>
  </div>
</template>

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

const username = ref('')
const password = ref('')
const bio = ref('')
const agree = ref(false)
</script>

避坑提醒v-model自动忽略元素上的 valuechecked 属性,不要混用


场景2:自定义组件 v-model(组件通信必备)

让自定义组件像原生表单一样使用 v-model

1. 创建组件:MyInput.vue

<template>
  <div class="my-input">
    <span>自定义:</span>
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      placeholder="请输入..."
    />
  </div>
</template>

<script setup>
// 必须叫 modelValue!
const props = defineProps(['modelValue'])
// 必须 emit update:modelValue!
const emit = defineEmits(['update:modelValue'])
</script>

2. 父组件使用

<template>
  <MyInput v-model="customText" />
  <p>输入内容:{{ customText }}</p>
</template>

<script setup>
import { ref } from 'vue'
import MyInput from './MyInput.vue'

const customText = ref('')
</script>

效果:父组件 v-model="customText" → 子组件 modelValue 接收 → 输入时触发 update:modelValue → 父组件自动更新!


场景3:多 v-model 绑定(复杂表单神器)

一个组件同时绑定多个双向数据,比如姓名 + 年龄 + 邮箱。

父组件

<template>
  <UserForm 
    v-model:name="user.name"
    v-model:age="user.age"
    v-model:email="user.email"
  />
  <pre>{{ user }}</pre>
</template>

<script setup>
import { reactive } from 'vue'
import UserForm from './UserForm.vue'

const user = reactive({
  name: '',
  age: 0,
  email: ''
})
</script>

子组件:UserForm.vue

<template>
  <div>
    <input v-model="nameProxy" placeholder="姓名" />
    <input v-model.number="ageProxy" type="number" placeholder="年龄" />
    <input v-model="emailProxy" type="email" placeholder="邮箱" />
  </div>
</template>

<script setup>
const props = defineProps(['name', 'age', 'email'])
const emit = defineEmits(['update:name', 'update:age', 'update:email'])

// 使用计算属性代理,让 v-model 在子组件内也能用
import { computed } from 'vue'
const nameProxy = computed({
  get: () => props.name,
  set: (val) => emit('update:name', val)
})
const ageProxy = computed({
  get: () => props.age,
  set: (val) => emit('update:age', val)
})
const emailProxy = computed({
  get: () => props.email,
  set: (val) => emit('update:email', val)
})
</script>

优势:父组件只需写 v-model:xxx,逻辑清晰,维护成本极低!


三、实战避坑:90% 的人都会踩的 3 个致命错误

坑1:绑定非响应式数据

// 错误 
let text = '' // 普通变量
// v-model="text" → 修改无效!

// 正确 
const text = ref('') // 响应式

坑2:自定义组件命名不规范

// 错误(Vue2 写法)
defineProps(['value'])
defineEmits(['input'])

// 正确(Vue3 标准)
defineProps(['modelValue'])
defineEmits(['update:modelValue'])

坑3:v-model:value 混用

<!-- 错误  -->
<input v-model="msg" :value="defaultValue" />

<!-- 正确  -->
<input v-model="msg" />
<!-- 或初始化时:const msg = ref(defaultValue) -->

四、进阶技巧:用 TS 让 v-model 更安全

// MyInput.vue (TypeScript 版)
<script setup lang="ts">
interface Props {
  modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void
}>()
</script>

类型检查 + 智能提示,杜绝拼写错误!


五、谁在用 Vue3 的 v-model?

  • 字节跳动:所有内部表单系统强制使用多 v-model 模式
  • 腾讯文档:协作编辑组件通过 v-model:content 实时同步
  • Nuxt 3 官方模板:表单示例全部采用 Composition API + v-model
  • Vue 官方团队:在 RFC 中明确表示 “v-model 是未来组件通信的核心”

结语:双向绑定,本该如此优雅

Vue3 的 v-model 不是“小改动”,而是对组件通信范式的重新定义
当你能用 v-model:titlev-model:count 一行搞定复杂交互,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Pinia 比 Vuex 好用 10 倍?Vue3 状态管理终于不折磨人了!(新手复制即用)

还在为 Vuex 的 statemutationactionmodule 四件套头疼?
而用 Pinia一行代码定义状态,直接修改数据,无需 commit,TS 完美支持,刷新页面还能自动持久化——小项目 5 分钟搞定,大项目维护成本直降 60%!

如果你受够了:

  • 写个计数器要建 3 个文件
  • 改个状态要绕 commit('SET_COUNT', 1) 半天
  • 调试时找不到数据在哪被改了
  • 刷新页面状态全丢,还得手动存 localStorage

那么,这篇手把手实操指南,就是为你写的——
不用看文档,所有代码模板直接复制粘贴,今天就能替换掉 Vuex


一、先说清:为什么 Pinia 是 Vue3 的“官方亲儿子”?

Vuex 是 Vue2 时代的产物,设计时没考虑 Composition API 和 TypeScript。
Pinia 由 Vue 核心团队打造,专为 Vue3 而生,直接解决 Vuex 所有痛点:

痛点 Vuex Pinia
配置复杂度 需创建 store/index.js + modules 一个文件就是一个仓库
修改状态 必须通过 mutation(commit 直接 this.count++
TS 支持 弱,需额外类型声明 原生完美支持
代码体积 ~10KB ~5KB(更轻)
调试体验 多层嵌套难追踪 DevTools 一目了然

大厂现状:字节、腾讯、阿里内部 Vue3 项目 100% 使用 Pinia,Vuex 已成历史。


二、核心干货:Pinia 3 步上手(附可运行模板)

第一步:安装(1 行命令)

# 推荐 pnpm
pnpm add pinia

# 或 npm / yarn
npm install pinia
yarn add pinia

第二步:全局注册(2 行代码)

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia' // ← 只需引入这个
import App from './App.vue'

const app = createApp(App)
app.use(createPinia()) // ← 注册
app.mount('#app')

避坑提醒不需要像 Vuex 那样写 new Store({}) 或分模块配置!


第三步:创建并使用仓库(核心!直接复制)

1. 创建仓库:src/store/counterStore.js

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // state:直接返回对象(响应式)
  state: () => ({
    count: 0,
    name: 'Pinia测试'
  }),

  // getters:计算属性(自动缓存)
  getters: {
    doubleCount: (state) => state.count * 2
  },

  // actions:同步/异步方法(直接修改 state!)
  actions: {
    increment() {
      this.count++ // 不用 commit!
    },
    async incrementAsync() {
      await new Promise(r => setTimeout(r, 1000))
      this.count++
    }
  }
})

2. 在组件中使用

<template>
  <div>
    <h3>{{ counterStore.name }}</h3>
    <p>当前:{{ counterStore.count }}</p>
    <p>2倍:{{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment">+1</button>
    <button @click="counterStore.incrementAsync">异步+1</button>
  </div>
</template>

<script setup>
// 引入 + 实例化(关键!)
import { useCounterStore } from '@/store/counterStore'
const counterStore = useCounterStore() // ← 必须实例化!
</script>

效果:状态、计算属性、方法全部自动暴露,无需 mapStatemapActions


三、实战避坑:90% 新手都会踩的 3 个致命错误

坑1:只引入不实例化,导致 undefined

// 错误
import { useCounterStore } from '@/store/counterStore'
console.log(useCounterStore.count) // 报错!

// 正确
const counterStore = useCounterStore()
console.log(counterStore.count) // 正常

坑2:在组件里直接改状态,破坏可维护性

// 不推荐(小型 demo 可以,项目别这么干)
counterStore.count = 999

// 推荐(统一走 actions,便于调试和复用)
counterStore.increment()

坑3:多个仓库用相同 ID,数据互相污染

// 错误
defineStore('user', { ... })
defineStore('user', { ... }) // ID 重复!

// 正确
defineStore('user', { ... })
defineStore('cart', { ... }) // ID 唯一

四、进阶技巧:一行代码实现状态持久化(刷新不丢)

默认 Pinia 状态刷新就没了?用官方插件 pinia-plugin-persistedstate,轻松搞定!

1. 安装插件

pnpm add pinia-plugin-persistedstate

2. 配置插件

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // ← 启用插件
app.use(pinia)
app.mount('#app')

3. 仓库开启持久化

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: { ... },
  persist: true // ← 就这一行!
})

效果count 自动存入 localStorage,刷新页面依然保留!


五、谁在用 Pinia?

  • 字节跳动:抖音 Web 端、飞书文档全面采用 Pinia
  • 腾讯:微信开放平台、腾讯文档 Vue3 项目标配
  • Nuxt 3:官方默认状态管理方案
  • Vue 官方生态:Vue Router、VitePress 示例均使用 Pinia

结语:状态管理,本该如此简单

Pinia 的价值,不只是“替代 Vuex”,而是让状态管理回归本质:直观、可维护、可扩展
当你不再为写 mutation 而烦恼,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

救命!Vue3 的 Composition API,居然能让我少写 80% 冗余代码?(新手也能直接抄)

你的 Vue 组件里是不是还在 datamethodscomputedwatch 之间来回跳转?
而用 Composition API一个 setup 函数搞定所有逻辑,代码量直降 80%,逻辑清晰到实习生都能看懂

如果你受够了:

  • Options API 里找某个变量要翻半天
  • 相同逻辑(比如表单校验)在多个组件里复制粘贴
  • 面试被问 “Vue3 和 Vue2 区别” 只能答“Proxy 更快”
  • 想复用逻辑却只能靠 Mixin(然后陷入命名冲突地狱)

那么,这篇手把手实操指南,就是为你写的——
不用死记硬背,所有代码模板直接复制粘贴,今天就能用上


一、先澄清一个误区:Composition API 不是“花里胡哨”,是真能救急

很多新手觉得:“Options API 能用,为啥换?”
但真相是:Options API 在复杂组件中,逻辑天然割裂

举个真实例子:写一个带防抖搜索 + 加载状态 + 错误提示的搜索框

  • Options API 写法

    • data 里定义 keyword, loading, error
    • methods 里写 search(), debounce()
    • watch 里监听 keyword 触发搜索
    • mounted 里可能还要初始化默认值
      同一个功能,散落在 4 个地方!
  • Composition API 写法

    const { keyword, loading, error, search } = useSearch()
    

一行代码,逻辑内聚,复用无痛

大厂现状:字节、腾讯、阿里内部 Vue3 项目 100% 强制使用 Composition API,面试必考。


二、核心干货:Composition API 3 个必学用法(附可运行模板)

1. script setup:所有逻辑的“入口”,一次搞定所有

这是 Vue3 官方推荐的写法,无需 return,自动暴露所有变量和方法

实操代码模板(直接复制到项目)

<template>
  <div>
    <input v-model="username" placeholder="请输入账号" />
    <button @click="login" :disabled="isLoading">
      {{ isLoading ? '登录中...' : '登录' }}
    </button>
  </div>
</template>

<script setup>
// 1. 定义响应式数据(替代 data)
import { ref } from 'vue'
const username = ref('')       // 响应式字符串
const isLoading = ref(false)   // 响应式布尔值

// 2. 定义方法(替代 methods)
const login = () => {
  isLoading.value = true
  // 模拟登录请求
  setTimeout(() => {
    console.log('登录成功,账号:', username.value)
    isLoading.value = false
  }, 1000)
}
// 3. 无需 return!<script setup> 自动暴露
</script>

避坑提醒:只有普通 setup() 函数才需要手动 return<script setup> 不用!


2. ref vs reactive:响应式数据的“两大神器”,别再用混了

记住口诀:**简单数据用 **ref复杂对象用 reactive

场景 推荐 API 修改方式 模板中使用
字符串、数字、布尔值 ref count.value = 1 {{ count }}
对象、数组 reactive user.name = 'Tom' {{ user.name }}

ref 实操示例】

import { ref } from 'vue'
const count = ref(0)

const increment = () => {
  count.value++ // 必须加 .value!
}

【reactive实操示例】

import { reactive } from 'vue'
const user = reactive({
  name: '',
  age: 0,
  hobbies: []
})

const updateUser = () => {
  user.name = 'Alice' // 直接修改,不加 .value
  user.hobbies.push('coding')
}

关键技巧:用 toRefs 解构 reactive 对象,保持响应式

import { reactive, toRefs } from 'vue'
const user = reactive({ username: '', password: '' })

// 解构后仍响应式
const { username, password } = toRefs(user)
username.value = 'test' // 有效!

3. 生命周期钩子:按需引入,不用写空方法

Vue3 生命周期需显式导入,更灵活,且避免无用代码。

【常用生命周期对照表】

Vue2 Vue3
mounted onMounted
updated onUpdated
beforeUnmount onBeforeUnmount

【实操示例:页面加载后请求数据】

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

const list = ref([])

onMounted(async () => {
  const res = await axios.get('/api/user/list')
  list.value = res.data
})
</script>

避坑提醒:生命周期钩子必须在 <script setup>setup() 内部调用,不能在外部!


三、实战避坑:90% 的人都会踩的 3 个致命错误

坑1:忘记给 ref.value,导致响应式失效

// 错误
const count = ref(0)
count = 1 // 页面不会更新!

// 正确
count.value = 1

坑2:用 reactive 创建简单数据

// 错误
const count = reactive(0) // reactive 只接受对象/数组
count = 1 // 响应式丢失!

// 正确
const count = ref(0)

坑3:为了“规范”强行封装,把简单逻辑搞复杂

正确姿势:只有跨组件复用的逻辑才封装成 Hook,否则直接写!


四、进阶技巧:用自定义 Hook 复用逻辑,效率拉满!

把重复代码(如表单校验、请求封装、本地存储)抽成 Hook,多个组件直接引入,少写 80% 代码

【实战示例:封装通用表单校验 Hook】

第一步:创建 hooks/useForm.js

// hooks/useForm.js
import { ref } from 'vue'

export const useForm = (rules) => {
  const form = ref({})
  const errors = ref({})

  const validate = () => {
    let isValid = true
    for (const key in rules) {
      const rule = rules[key]
      if (!form.value[key] && rule.required) {
        errors.value[key] = rule.message
        isValid = false
      } else {
        errors.value[key] = ''
      }
    }
    return isValid
  }

  return { form, errors, validate }
}

第二步:在组件中使用

<script setup>
import { useForm } from '@/hooks/useForm'

const { form, errors, validate } = useForm({
  username: { required: true, message: '请输入账号' },
  password: { required: true, message: '请输入密码' }
})

const login = () => {
  if (validate()) {
    console.log('提交数据:', form.value)
  }
}
</script>

<template>
  <div>
    <input v-model="form.username" />
    <span v-if="errors.username" class="error">{{ errors.username }}</span>
    
    <input type="password" v-model="form.password" />
    <span v-if="errors.password" class="error">{{ errors.password }}</span>
    
    <button @click="login">登录</button>
  </div>
</template>

效果:以后任何表单,只需 3 行代码引入,校验逻辑自动生效!


五、谁在用 Composition API?

  • 字节跳动:抖音 Web 端全量 Vue3 + Composition API
  • 腾讯文档:协同编辑组件基于自定义 Hook 构建
  • 阿里云控制台:复杂表单系统 100% 使用 useXXX 模式
  • Vue 官方生态:Pinia、Vue Router 4 全面拥抱 Composition

结语:少写代码,才是高级程序员的终极追求

Composition API 的价值,不只是“新语法”,而是用函数式思维组织逻辑,让代码可读、可测、可复用
当你不再为找变量翻遍整个文件,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端必懂!一文搞懂 WebAssembly:Web/Electron/RN 全通用,你天天用的软件,底层都靠它

对于前端开发者而言,WebAssembly(简称 Wasm)或许是一个"熟悉又陌生"的名词。

image.png

偶尔能够在技术文章中看到,却很少在日常开发中用到。

但事实上,它在 ElectronReact Native 等主流跨平台框架中,Wasm 现在已经成为了突破前端性能瓶颈的主要手段。

Wasm 到底是啥?

很多时候大家会把 Wasm 当成一种编程语言,其实这是一个常见误区。

Wasm 不是编程语言,而是一种二进制字节码格式,是 W3C 推荐的第四种 Web 核心技术(与 HTML、CSS、JavaScript 并列)。

image.png

用最直白的话来说:

Wasm 是开发者用 C/C++、Rust、Go 等语言编写高性能代码,再通过编译工具将其编译成 .wasm 二进制文件。

前端开发者无需关心底层实现,只需像调用 npm 包一样,通过Js加载并调用其中的功能。

核心优势很直接,就是"接近原生的性能"。

由于是二进制格式,解析速度比Js快 5-10 倍,运行速度可达原生代码的 70%~90%

而且同时具备安全沙箱(运行在隔离环境,不直接访问系统资源)、跨平台(一次编译,多端通用)、体积小(二进制文件比Js体积小得多)的特点。

这里需要注意:Wasm 不是来替代Js的,而是和Js合作的

  • Js 负责 DOM 操作、UI 交互、网络请求等灵活场景。
  • Wasm 负责计算密集型、CPU 高负载任务(如 3D 渲染、图像处理、加密、大数据计算)。

Wasm 在跨平台框架中使用

Wasm 不是只能在浏览器中运行。

无论是桌面端的 Electron,还是移动端的 React Native,都能完美支持 Wasm,甚至比在浏览器中使用更自由、更灵活。

Wasm 的运行是不依赖具体的浏览器环境,只要有对应的运行时(如 V8 引擎、Wasm3 引擎),就能在任何平台运行。

而主流跨平台框架,早已内置或支持集成 Wasm 运行时。

Electron使用

Electron 的架构是"Chromium + Node.js",而 Chromium 内核本身就原生支持 WebAssembly

image.png

因此在 Electron 中使用 Wasm,和在浏览器中几乎没有区别。

// 加载并调用 Wasm 模块(以加法功能为例)
async function loadWasm() {
  // 1. 加载编译好的 .wasm 文件(和前端资源放在同一目录)
  const res = await fetch("/add.wasm");
  const bytes = await res.arrayBuffer();
  
  // 2. 编译 + 实例化 Wasm 模块
  const { instance } = await WebAssembly.instantiate(bytes);
  
  // 3. 直接调用 Wasm 暴露的方法,和调用 npm 包一致
  const result = instance.exports.add(10, 20);
  console.log("Wasm 计算结果:", result); // 输出 30
}

// 执行调用
loadWasm();

实际上 VS Code、Figma、剪映专业版等主流 Electron 桌面应用,都大量使用 Wasm 处理核心计算逻辑。

比如 Figma 的矢量图形引擎、剪映的视频解码,都是通过 C++ 编译成 Wasm 实现的,既保证了性能,又实现了跨平台兼容。

React Native使用

由于 React Native(RN)本身不依赖浏览器环境,无法直接使用浏览器的 Wasm 运行时。

但可以使用 react-native-webassembly(简洁易用)和 wasm3(轻量引擎)插件就能在 RN 中调用 Wasm 模块。

// 安装依赖
// yarn add react-native-webassembly 或 npx expo install react-native-webassembly

// 配置 metro.config.js
module.exports = {
  resolver: {
    extensions: ['.js', '.jsx', '.ts', '.tsx', '.wasm'], // 新增 .wasm 后缀
  },
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineSourceMap: false,
      },
    }),
  },
};

// 调用 Wasm
import React, { useEffect } from 'react';
import { View, Text } from 'react-native';
import WebAssembly from 'react-native-webassembly';
// 导入本地 .wasm 文件(需放在项目可访问目录)
import addWasm from './add.wasm';

const WasmDemo = () => {
  useEffect(() => {
    // 加载并调用 Wasm
    const runWasm = async () => {
      const { instance } = await WebAssembly.instantiate(addWasm);
      const result = instance.exports.add(20, 30);
      console.log("RN 中 Wasm 计算结果:", result); // 输出 50
    };
    runWasm();
  }, []);

  return (
    <View>
      <Text>React Native + WebAssembly 示例</Text>
    </View>
  );
};

export default WasmDemo;

注意:RN 中使用 Wasm 时,需确保项目支持新架构(部分旧版本 RN 可能存在兼容问题)。

总结

其实可以把 Wasm 理解为一个"不挑平台、不挑框架的超级高性能工具包"。

当你在 Web、Electron、RN 等平台开发时,遇到 JS 无法承载的计算密集型任务(如图像处理、3D 渲染、加密、AI 推理),就可以考虑引入Wasm。

🚨 还在用 rem) 做大屏适配?用 vfit.js 一键搞定,告别改稿8版的噩梦!

 导读:欢迎来到《vfit.js 大屏适配指南》系列第 1 篇。在这个系列中,我们将带你彻底告别大屏适配的折磨,打通"痛点-解法-实战-优化-落地"的完整闭环。

上周,群里一个做政务大屏的兄弟心态崩了:"地图和图表用 rem 调了一整周,好不容易对齐了。结果去交付现场一看,客户的指挥中心用的是3840×2160 的超宽带鱼屏,整个页面全乱套了……"

底下瞬间刷出几十条"太真实了"。

做大屏开发的你,是不是也经常遭遇这些"社死时刻"?

  •  政务指挥中心:设计稿 1920×1080,现场屏幕长宽比极其奇葩,ECharts 图表直接挤成一团。
  • 工业监控大屏:用 rem 算来算去,DataV 的飞线动效死活偏了3 个像素。
  • 数据驾驶舱:老板在 iPad 竖屏打开大屏链接,质问你"为什么右边全白了?"

如果你中招了,请立刻停下手里写了一半的媒体查询!这篇指南,就是你的救命稻草!


🛑 为什么传统的适配方案,在大屏上必死无疑?

说到底,很多人没搞懂一个核心:可视化大屏,根本不是普通网页!

做普通后台管理系统,内容像"水流",用 flex、grid 响应式排版就行。
但做智慧城市、数字孪生这种大屏,页面是一幅"静态油画"。

📌 标题必须死死钉在正中间;
📌 3D 地图必须霸占绝对 C 位;
📌 两侧的数据面板哪怕字小点,也绝不能换行或错位。

所有元素的相对位置,必须焊死!

我们来看看你以前用的方案,为什么会翻车。

1️⃣ rem 方案:万恶的"单位转换地狱"

算比例、转 px,每次窗口一动就要重算。最要命的是,像 ECharts、高德地图、Three.js 这些第三方可视化库,底层全认 px!你用 rem,等于给自己挖了一个永远填不满的兼容坑。

2️⃣ vw/vh 方案:控制不住的"高度变形"

车联网驾驶舱时,宽度用 vw 撑满了,一旦屏幕比例从 16:9 变成 16:10,高度用 vh 就会拉伸,你的圆形仪表盘直接变成"椭圆",客户看着直摇头。


💡 终极解法:Scale 等比缩放,为什么你没早点用?

既然大屏是一幅画,那最完美的适配逻辑就是:把这幅画当成一个整体,等比例放大缩小!

就像在 PPT 里拖拽图片的对角线一样,不改变内部任何尺寸,只改变整体视野

  • • 设计稿是 px,你就写 px:零转换成本,所见即所得。
  • • 可视化库完美兼容:ECharts、DataV 闭眼用,再也不用担心偏移。
  • • 极致性能:利用 GPU 硬件加速,大屏不卡顿。

但手动写 Scale 有个巨坑:你要自己算比例、监听窗口、处理绝对定位失真……


🚀 登场:vfit.js,把你从加班中拯救出来

为了解决 Scale 方案的最后一公里痛点,vfit.js 诞生了。
这是一个专为 Vue 3 可视化大屏打造的轻量级适配神器。

不管你是做公安大屏、还是工厂看板,只需 3 行代码:

import { createApp } from 'vue'
import { createFitScale } from 'vfit'
import 'vfit/style.css'

const app = createApp(App)

// 告诉它设计稿尺寸,剩下交给 vfit
app.use(createFitScale({
  designWidth1920,   
  designHeight1080,  
  scaleMode'auto'    
}))

app.mount('#app')

就这么简单!代码一交,按时下班。


🎁 互动福利:大屏避坑资料包

你以前做大屏遇到过最奇葩的屏幕尺寸是多少?
👇 在评论区吐槽你的经历 👇

🔥 福利时间
关注公众号,后台回复【大屏模板】,即可免费领取:

1. vfit.js 开箱即用 Vue3 工程模板(带 ECharts 示例)

2. 大屏常见奇葩分辨率适配速查表

官方资源直达:


🔗 推荐阅读与下期预告

📚 推荐深度阅读

在开始源码探索前,强烈推荐先阅读这两份权威指南:


🔜 下期预告:别急着复制,先懂底层!

今天我们明确了:Scale 是大屏适配的唯一真理
但作为高级前端,只会调包可不行。万一在嵌套 iframe 的工业后台里失效了怎么办?

下一篇: 《02 - 5分钟看懂 vfit.js 大屏适配源码:政务/工业看板防变形黑科技,就这50行!》
我们将扒开 vfit 的底裤,带你搞懂 ResizeObserver 和 GPU 缩放的核心原理。我们下期见!

紧急安全警报:Axios npm 包被投毒事件详解与防护指南

🚨 事件概述

2026 年 3 月 31 日,安全研究机构 StepSecurity 披露了一起震惊开源社区的重大安全事件:主流 JavaScript 库 Axios 的两个 npm 版本(1.14.1 和 0.30.4)被恶意植入远程控制代码

由于 Axios 是全球使用最广泛的 HTTP 客户端库,周下载量超过 3 亿次,此次供应链攻击的影响范围极其巨大,几乎所有使用 Node.js 的项目都可能受到影响。

⏰ 攻击时间线(北京时间)

timeline
    title Axios 供应链攻击事件时间线
    section 3 月 30 日
        23:59 : 攻击者发布<br/>plain-crypto-js@4.2.1
    section 3 月 31 日
        00:00 : 劫持维护者账号<br/>发布被投毒的 Axios 版本
        00:05 : Socket.dev 检测到<br/>异常依赖包
        04:00 : npm 官方下架<br/>所有恶意包
        08:00 : 安全机构公开披露<br/>事件详情

🔍 攻击手法深度解析

1️⃣ 账号劫持

攻击者成功劫持了 Axios 核心维护者 "jasonsaayman" 的 npm 账号,并将账号邮箱替换为匿名的 ProtonMail 地址。这一操作使得攻击者能够完全控制包的发布流程。

2️⃣ 绕过 CI/CD 审核

正常情况下,npm 包的发布需要通过 GitHub Actions 自动化流程进行构建和验证。但攻击者利用维护者权限,直接通过 npm CLI 手动上传被污染的版本,绕过了所有自动化安全检查。

3️⃣ 虚假依赖注入

这是整个攻击中最狡猾的部分。攻击者并没有直接修改 Axios 源码,而是采用了更隐蔽的手法:

{
  "dependencies": {
    "axios": "^1.14.1",
    "plain-crypto-js": "4.2.1"  // ← 恶意依赖包
  }
}

plain-crypto-js@4.2.1 是一个从未在 Axios 代码中被引用的虚假依赖包,它唯一的作用就是执行 postinstall 脚本。

4️⃣ 双重伪装策略

为了规避安全检测,攻击者提前 18 小时发布了两个版本的伪装包:

版本号 类型 作用
plain-crypto-js@4.2.0 干净版本 用于打掩护,降低安全工具警惕
plain-crypto-js@4.2.1 恶意版本 携带木马脚本,执行攻击

这种策略使得恶意包看起来像是"已有包的正常更新",而非"全新可疑包"。

5️⃣ 自动执行机制

当开发者执行 npm install axios 命令时,会发生以下连锁反应:

# 开发者执行的命令
npm install axios

# 实际发生的过程
├── 安装 axios@1.14.1 (被投毒版本)
├── 自动安装 plain-crypto-js@4.2.1 (恶意依赖)
└── 触发 postinstall 脚本
    └── 执行 setup.js (恶意脚本)
        └── 连接 C2 服务器 (sfrclak.com)
            └── 下载并运行跨平台木马

💀 恶意行为分析

感染流程

一旦触发恶意脚本,会根据操作系统类型执行不同的攻击载荷:

Windows 系统

# 创建隐藏的 PowerShell 窗口
VBScript → 隐藏 cmd.exe → 保存木马到 %TEMP%\6202033.ps1

# 持久化驻留
复制到:%PROGRAMDATA%\wt.exe
伪装成:Windows Terminal 可执行文件

macOS 系统

# 藏匿位置
/Library/Caches/com.apple.act.mond

# 伪装方式
伪装成:macOS 系统缓存进程

Linux 系统

# 直接执行
/tmp/ld.py

# 后台驻留
nohup python3 /tmp/ld.py &

恶意功能

木马成功后会执行以下操作:

  1. 连接远程指挥服务器 - 域名:sfrclak.com
  2. 窃取敏感信息 - 环境变量、API 密钥、配置文件
  3. 下载额外载荷 - 根据系统架构下载更多恶意程序
  4. 建立持久后门 - 保持后台运行,长期潜伏
  5. 自我清理 - 删除恶意脚本,伪造干净的配置文件

🎯 影响范围评估

高危项目

以下类型的项目风险最高:

  • 使用 axios@1.14.1 或 0.30.4 的所有项目
  • OpenClaw("龙虾")AI 智能体工具用户
  • React/Vue 前端项目
  • Node.js 后端服务
  • CI/CD 工具和自动化脚本
  • MCP Server 和各种 AI 编程工具

传播途径

graph LR
    A[开发者] --> B[npm install axios]
    B --> C[安装被投毒版本]
    C --> D[自动执行恶意脚本]
    D --> E[连接 C2 服务器]
    E --> F[下载木马程序]
    F --> G[系统被完全控制]
    
    H[AI 编程工具] --> I[自动安装依赖]
    I --> C

特别警示:AI 编程工具风险

2026 年流行的 AI 编程工具(如 Claude Code、Codex CLI、OpenClaw 等)大幅扩大了 npm 的攻击面:

  • 🔴 自动安装依赖 - AI 可能在你不知情的情况下安装被投毒的包
  • 🔴 高系统权限 - AI 工具通常有文件读写、命令执行权限
  • 🔴 难以审计 - 你可能连自己安装了什么都不清楚

正如社区所言:"你自己不写 npm 命令,AI 替你写了,你可能连自己装了什么都不知道。"

🛡️ 紧急处置方案

第一步:立即自查

# 检查项目中是否使用了 axios
npm list axios

# 或使用 pnpm
pnpm list axios

# 查看详细版本
npm list axios --depth=0

如果看到以下版本,立即采取行动

  • axios@1.14.1
  • axios@0.30.4

第二步:紧急卸载

# 立即卸载被投毒版本
npm uninstall axios

# 删除 node_modules 和锁文件(可选但推荐)
rm -rf node_modules package-lock.json
# Windows PowerShell:
# Remove-Item -Recurse -Force node_modules, package-lock.json

# 重新安装安全版本
npm install axios@latest

第三步:检查失陷迹象

Windows 系统

# 检查可疑文件
Test-Path "$env:PROGRAMDATA\wt.exe"
Test-Path "$env:TEMP\6202033.ps1"

# 检查网络连接
netstat -ano | findstr sfrclak.com

macOS 系统

# 检查可疑目录
ls -la /Library/Caches/com.apple.act.mond

# 检查异常进程
ps aux | grep -i "act.mond"

Linux 系统

# 检查恶意脚本
ls -la /tmp/ld.py

# 检查 Python 进程
ps aux | grep ld.py

# 检查网络连接
netstat -tulpn | grep sfrclak.com

第四步:重置凭证

如果你确认安装了被投毒的版本,必须立即重置所有敏感凭证:

  • 🔑 所有 API 密钥(云服务、数据库、第三方服务)
  • 🔑 SSH 密钥和访问令牌
  • 🔑 数据库密码
  • 🔑 管理员账户密码
  • 🔑 任何存储在环境变量中的敏感信息

因为木马具备窃取环境变量的能力,即使你已经卸载了恶意包,之前泄露的信息也需要全部更换

🔒 长期防护策略

1. 锁定依赖版本

package.json 中避免使用模糊版本范围:

{
  "dependencies": {
    "axios": "1.13.0"     // ✅ 确切版本
    // 而不是 "axios": "^1.13.0"  ❌
  }
}

2. 禁用自动脚本执行

# 全局配置
npm config set ignore-scripts true

# 或在 .npmrc 文件中添加
ignore-scripts=true

3. 启用 npm 审计

# 安装时自动审计
npm audit

# 自动修复可修复的问题
npm audit fix

# 强制修复(可能破坏兼容性)
npm audit fix --force

4. 使用安全工具

# 安装 socket-security 等安全工具
npm install -g socket-security

# 使用 Snyk 进行持续监控
npm install -g snyk
snyk test

5. 实施依赖审查流程

对于企业级项目,建议:

  • ✅ 使用私有 npm 镜像(如 Verdaccio)
  • ✅ 实施依赖包白名单制度
  • ✅ 定期生成 SBOM(软件物料清单)
  • ✅ 使用 Sigstore 等签名验证机制

6. AI 编程工具使用规范

如果你使用 AI 编程工具:

  • ⚠️ 审查所有自动安装的依赖
  • ⚠️ 不要给 AI 过高的系统权限
  • ⚠️ 定期检查 node_modules 内容
  • ⚠️ 在隔离环境中运行 AI 生成的代码

📊 技术细节补充

恶意域名信息

  • C2 服务器: sfrclak.com
  • 注册时间: 2026 年 3 月 30 日
  • 注册商: 匿名注册服务

恶意包哈希值

供安全工具检测使用:

plain-crypto-js@4.2.1:
SHA-256: [已移除,避免传播]

axios@1.14.1 (被投毒版本):
SHA-256: [已移除,避免传播]

axios@0.30.4 (被投毒版本):
SHA-256: [已移除,避免传播]

网络特征

安全设备可以监控以下网络请求:

POST https://sfrclak.com/api/gateway
User-Agent: node-fetch/1.0 (+https://github.com/bitinn/node-fetch)
Content-Type: application/json

🎓 事件启示

供应链安全的脆弱性

这次事件再次暴露了现代软件供应链的脆弱性:

  1. 单点故障 - 一个维护者账号被劫持,影响数亿用户
  2. 信任链断裂 - 我们信任的知名库也可能被投毒
  3. 自动化风险 - CI/CD 流程被绕过,缺乏多层验证
  4. 依赖传递 - 你的依赖的依赖也可能有问题

开源安全的新挑战

随着 AI 编程工具的普及,攻击面正在急剧扩大:

  • 🤖 AI 自动决策 - AI 可能选择安装不安全的依赖
  • 🤖 权限放大 - AI 的高权限使得攻击后果更严重
  • 🤖 审计困难 - 自动生成的代码更难追溯和审查

开发者的责任

作为开发者,我们需要:

  • ✅ 保持安全意识,不盲目信任任何依赖
  • ✅ 实施最小权限原则
  • ✅ 建立完善的依赖管理和审计流程
  • ✅ 关注安全动态,及时响应漏洞预警

📝 总结

关键要点

  1. 受影响版本: axios@1.14.1axios@0.30.4
  2. 攻击手法: 劫持维护者账号 + 虚假依赖 + 自动执行脚本
  3. 影响范围: 周下载量 3 亿+,全平台受影响
  4. 恶意行为: 远程控制木马 + 信息窃取 + 持久化驻留
  5. 处置方案: 立即自查 → 紧急卸载 → 检查失陷 → 重置凭证

行动清单

  • 检查所有项目的 axios 版本
  • 如果中招,立即卸载并重装安全版本
  • 检查系统是否有失陷迹象
  • 重置所有可能泄露的凭证
  • 更新 package.json 锁定版本号
  • 配置 npm 忽略自动脚本
  • 安装安全审计工具
  • 学习 AI 编程工具安全使用规范

🔗 参考资料

  1. StepSecurity 官方报告:链接
  2. npm 安全公告:链接
  3. Socket.dev 检测分析:链接
  4. GitHub Issue 讨论:链接

2026年,你敢信一些知名开源库都还不会正确使用防抖节流吗

摘要:防抖(debounce)和节流(throttle)是前端开发中高频使用的性能优化技巧,也是八股文的经典。但许多开发者甚至知名开源库的维护者都在误用它们。本文通过分析 vben 和 vue-office 两个热门项目的真实 PR,揭示常见的使用误区,并给出最佳实践。


Leader 大群 @ 我:"CSV 预览在 Mac 触控板上滑得太快了"

2026.3.16,那是一个普通的工作日,我正在专注地敲代码,突然 Leader 在公司大群里 @ 我:

image.png

"CSV 文件预览在 Mac 下触控板左右移动的速度好快,谁能调整一下?"

我心里一紧,我们的项目,用的是 vue-office 组件库预览office,我打开源码 debug了一下——居然是 throttle 的用法有问题。组件库里每次滚动事件都创建一个新的 throttle 函数,然后立即执行,根本没有节流效果。触控板的高频滚动事件直接穿透了,导致表格左右飞快移动。

项目中patch后,直接提了 PR。

这件事也让我想起了一年前给 vue-vben-admin 提的另一个类似 PR——那次是远程搜索的 debounce 用错了,也是每次输入都创建新实例

  • vue-vben-admin:一个拥有 32K+ Star 的现代化 Vue3 管理后台
  • vue-office:一个拥有 6K+ Star 的 Office 文件预览组件库

vben的项目维护者直接回复 "nice catch",

image.png

让我不禁思考:防抖和节流看似简单,但连这些知名库的资深开发者都会踩坑,说明这背后一定有什么容易忽视的细节。

本文就来复盘这两个案例,聊聊这些常见的使用误区。


vue-vben-admin 的远程搜索

问题案例

<template>
  <Input @search="useDebounceFn(onSearch, 300)" />
</template>

问题分析

核心错误@search 每次被调用时,都创建了一个新的 debounce 函数实例,然后立即执行它。

这意味着:

  1. 用户输入 "h",调用 debounceOptionsFn,创建 debounce A,立即执行 A,发起请求
  2. 用户继续输入 "he",再次调用 debounceOptionsFn,创建 debounce B,立即执行 B,再次发起请求
  3. 用户输入 "hel",再次调用,创建 debounce C,立即执行 C,又发起请求...

每个 debounce 实例都是全新的,内部的定时器逻辑完全没有机会发挥作用——防抖函数永远不会被触发(指延迟后的触发),而是每次都被立即执行。


vue-office 的 Excel 滚动优化

问题案例

vue-office 在处理 Excel 表格滚动时,想要使用 throttle 来优化性能,但代码存在类似的问题:

// ❌ 错误:在事件监听中直接使用 throttle
if (/Firefox/i.test(window.navigator.userAgent)) throttle(moveY(evt.detail), 50);
if (temp === tempX) throttle(moveX(deltaX), 50);
if (temp === tempY) throttle(moveY(deltaY), 50);

问题分析

这个错误的模式与上面的 debounce 案例如出一辙:

  1. 每次滚动事件触发,都创建一个新的 throttle 函数
  2. 新创建的 throttle 函数立即执行,没有任何节流效果

更严重的是,如果这是一个高频滚动场景,不断创建新的 throttle 函数还会带来内存泄漏的风险。


框架中的正确使用方式

Vue 组合式 API

<script setup>
import { debounce } from 'lodash-es'
import { onUnmounted } from 'vue'

// 在组件级别创建,保持引用稳定
const debouncedSearch = debounce(async (query) => {
  const results = await api.search(query)
  items.value = results
}, 300)

// 绑定到事件
function onInput(value) {
  debouncedSearch(value)
}

// 组件卸载时清理
onUnmounted(() => {
  debouncedSearch.cancel()
})
</script>

React Hooks

import { useMemo, useEffect } from 'react'
import { debounce } from 'lodash-es'

function SearchComponent() {
  // ✅ 使用 useMemo 保持 debounce 函数引用稳定
  const debouncedSearch = useMemo(
    () => debounce((query) => {
      api.search(query)
    }, 300),
    [] // 空依赖,只在组件挂载时创建
  )

  // 组件卸载时清理
  useEffect(() => {
    return () => {
      debouncedSearch.cancel()
    }
  }, [debouncedSearch])

  return (
    <input onChange={(e) => debouncedSearch(e.target.value)} />
  )
}

原生 JavaScript

import { throttle } from 'lodash-es'

class ScrollHandler {
  constructor() {
    // 在构造函数中创建,确保引用稳定
    this.throttledScroll = this.handleScroll.bind(this)
    this.throttledScroll = throttle(this.throttledScroll, 100)
    
    window.addEventListener('scroll', this.throttledScroll)
  }

  handleScroll() {
    // 处理滚动逻辑
  }

  destroy() {
    window.removeEventListener('scroll', this.throttledScroll)
    this.throttledScroll.cancel()
  }
}

结语

防抖和节流看起来简单,但实际使用中却暗藏陷阱。我在审查 vue-vben-admin 和 vue-office 代码时的经历告诉我:即使是经验丰富的开发者和知名开源项目,也可能在这些"基础"概念上栽跟头。

从那以后,我在代码审查时会多问一句写代码时多想一想函数引用,也养成了检查 debounce/throttle 使用模式的习惯。希望本文能帮助你写出更健壮、性能更优的代码。

如果你在项目中发现了类似的防抖节流误用,或者有其他最佳实践想分享,欢迎交流讨论!


参考资源


这篇文章记录了我发现并修复两个知名开源项目防抖节流问题的经历。如果你也遇到过类似的坑,欢迎在评论区聊聊你的故事。

kotlin安卓项目配置app横屏等方式

大家好,我的开源项目PakePlus可以将网页/Vue/React项目打包为桌面/手机应用并且小于5M只需几分钟,官网地址:pakeplus.com

在 Kotlin Android 项目中配置 App 固定横屏,在 AndroidManifest.xml 中为 Activity 添加 screenOrientation 属性:

<activity
    android:name=".MainActivity"
    android:screenOrientation="landscape"
    android:configChanges="orientation|screenSize">
</activity>

在 Android 中,screenOrientation 属性支持以下值:

常用方向

说明
unspecified 默认值,由系统选择方向
landscape 固定横屏(宽 > 高)
portrait 固定竖屏(高 > 宽)
reverseLandscape 反向横屏(屏幕倒转180度)
reversePortrait 反向竖屏(上下颠倒)
sensorLandscape 横屏,但允许根据传感器旋转180度
sensorPortrait 竖屏,但允许根据传感器旋转180度

LRU 缓存实现详解:双向链表 + 哈希表

LRU 缓存实现详解:双向链表 + 哈希表

摘要:本文深入剖析使用“双向链表 + 哈希表”实现 LRU(Least Recently Used)缓存的标准方法。从核心思想到代码实现,再到边界处理与复杂度分析,完整展示一个 O(1) 时间复杂度的 LRU 缓存如何工作。


1. 概述

LRU(最近最少使用)是一种常见的缓存淘汰策略:当缓存容量达到上限时,优先淘汰最长时间未被访问的数据。每次访问(读或写)都会将对应数据标记为“最近使用”。

为了支持 getput 操作均为 O(1) 时间复杂度,必须同时满足:

  • 快速查找:给定 key,能在 O(1) 时间内找到对应的数据。
  • 快速维护顺序:能够 O(1) 地将任意数据移动到“最近使用”的位置,并且 O(1) 删除“最久未使用”的数据。

数据结构组合哈希表 + 双向链表 完美达成上述要求。

为什么不能用数组或单向链表?

  • 数组移动元素 O(N)
  • 单向链表删除尾部需要遍历到前驱 O(N)
  • 双向链表 + 哈希表完美 O(1)

2. 核心数据结构

2.1 双向链表节点

每个节点存储键、值以及前驱和后继指针。其中存储 key 是为了在淘汰节点时能从哈希表中删除对应的键。

class ListNode {
  constructor(key, value) {
    this.key = key; // 存储 key 是为了淘汰时能从哈希表删除
    this.value = value;
    this.prev = null;   // 前驱指针
    this.next = null;   // 后继指针
  }
}

2.2 LRU 缓存类成员

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;   // 最大容量
    this.size = 0;              // 当前存储的节点数
    this.map = new Map();       // 哈希表:key -> 节点引用
    
    // 虚拟头尾节点(哨兵),简化边界操作
    this.head = new ListNode(0, 0);
    this.tail = new ListNode(0, 0);
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }
}

为什么使用虚拟头尾?

  • 避免对空指针的判断(如 if (node.prev) ...
  • 插入头部时,head.next 一定存在;删除尾部时,tail.prev 一定存在
  • 统一代码逻辑,减少边界错误

2.3 链表状态示意图

初始状态:

head <-> tail

插入一个有效节点后:

head <-> node1 <-> tail

多个节点按最近使用顺序排列:头部是最近使用的,尾部是最久未使用的。


3. 辅助方法(链表操作)

所有辅助方法的时间复杂度均为 O(1)

3.1 _addToHead(node):在头部插入节点

_addToHead(node) {
  node.prev = this.head;
  node.next = this.head.next;
  this.head.next.prev = node;
  this.head.next = node;
}

执行步骤(假设当前 head <-> A <-> ...):

  1. node.prev = this.head
  2. node.next = this.head.next (即 A)
  3. this.head.next.prev = node (A 的前驱指向 node)
  4. this.head.next = node (head 的后继指向 node)

结果:head <-> node <-> A <-> ...

3.2 _removeNode(node):删除任意节点

_removeNode(node) {
  node.prev.next = node.next;
  node.next.prev = node.prev;
}

原理:让 node 的前驱直接指向 node 的后继,node 的后继直接指向 node 的前驱,从而将 node 从链表中移除。node 自身的指针无需修改(因为节点即将被丢弃或移动)。

3.3 _moveToHead(node):将已有节点移到头部

_moveToHead(node) {
  this._removeNode(node);
  this._addToHead(node);
}

先删除,再插入头部,使该节点成为“最近使用”节点。

3.4 _removeTail():删除尾部真实节点(最久未使用)

_removeTail() {
  const tailNode = this.tail.prev;   // 虚拟 tail 的前一个才是真正的尾节点
  this._removeNode(tailNode);
  return tailNode;
}

返回被删除的节点,以便从哈希表中删除其键。


4. 主要操作实现

4.1 get(key)

get(key) {
  if (!this.map.has(key)) return -1;
  const node = this.map.get(key);
  this._moveToHead(node);   // 标记为最近使用
  return node.value;
}

流程

  • 哈希表查找 → O(1)
  • 不存在则返回 -1
  • 存在则移动节点到链表头部 → O(1)
  • 返回节点值

4.2 put(key, value)

put(key, value) {
  if (this.map.has(key)) {
    // 情况1:key 已存在 → 更新值并移到头部
    const node = this.map.get(key);
    node.value = value;
    this._moveToHead(node);
  } else {
    // 情况2:key 不存在 → 创建新节点
    const newNode = new ListNode(key, value);
    this.map.set(key, newNode);
    this._addToHead(newNode);
    this.size++;

    if (this.size > this.capacity) {
      // 淘汰最久未使用的节点
      const removed = this._removeTail();
      this.map.delete(removed.key);
      this.size--;
    }
  }
}

情况2详细步骤

  1. 创建新节点,存入哈希表
  2. 插入链表头部(成为最近使用)
  3. 缓存大小 +1
  4. 若超过容量:删除尾部真实节点,并从哈希表中删除其键,大小 -1

注意:先插入新节点,再淘汰旧节点,确保淘汰的一定是最久未使用的。


5. 完整代码

class ListNode {
  constructor(key, value) {
    this.key = key;
    this.value = value;
    this.prev = null;
    this.next = null;
  }
}

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.size = 0;
    this.map = new Map();
    this.head = new ListNode(0, 0);
    this.tail = new ListNode(0, 0);
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }

  _addToHead(node) {
    node.prev = this.head;
    node.next = this.head.next;
    this.head.next.prev = node;
    this.head.next = node;
  }

  _removeNode(node) {
    node.prev.next = node.next;
    node.next.prev = node.prev;
  }

  _moveToHead(node) {
    this._removeNode(node);
    this._addToHead(node);
  }

  _removeTail() {
    const tailNode = this.tail.prev;
    this._removeNode(tailNode);
    return tailNode;
  }

  get(key) {
    if (!this.map.has(key)) return -1;
    const node = this.map.get(key);
    this._moveToHead(node);
    return node.value;
  }

  put(key, value) {
    if (this.map.has(key)) {
      const node = this.map.get(key);
      node.value = value;
      this._moveToHead(node);
    } else {
      const newNode = new ListNode(key, value);
      this.map.set(key, newNode);
      this._addToHead(newNode);
      this.size++;
      if (this.size > this.capacity) {
        const removed = this._removeTail();
        this.map.delete(removed.key);
        this.size--;
      }
    }
  }
}

6. 执行示例

假设 capacity = 2

操作 链表状态(头部最近) 哈希表内容 说明
put(1, 1) head <-> 1 <-> tail {1→node1} 插入节点1
put(2, 2) head <-> 2 <-> 1 <-> tail {1→node1, 2→node2} 插入节点2,成为最近使用
get(1) head <-> 1 <-> 2 <-> tail {1→node1, 2→node2} 访问1,1移到头部
put(3, 3) head <-> 3 <-> 1 <-> tail {1→node1, 3→node3} 容量满,淘汰尾部的2
get(2) 不变 不变 返回 -1

7. 边界情况处理

情况 处理方式
capacity ≤ 0 通常题目保证 capacity ≥ 1;若需处理,可在构造时抛出错误或使所有 put 无效
get 不存在的 key 返回 -1
put 更新已存在的 key 更新 value,移到头部,不改变 size
put 时容量已满 先插入新节点,再淘汰尾部节点(确保淘汰的是最久未使用的)
链表中只有一个有效节点 虚拟头尾仍然存在,所有辅助方法正常工作
重复 put 同一 key 且值不变 依然执行 _moveToHead,更新使用顺序

8. 复杂度分析

  • 时间复杂度

    • get:O(1)(哈希查找 + 链表移动)
    • put:O(1)(哈希查找/插入 + 链表操作)
    • 所有辅助方法均为 O(1)
  • 空间复杂度

    • O(capacity),哈希表存储最多 capacity 个节点,链表同样存储 capacity 个节点。

9. 与“纯 Map”实现的对比

维度 双向链表 + 哈希表 纯 Map 版本(依赖插入顺序)
实现原理 手动维护链表顺序,完全可控 利用 Map 的键顺序特性
代码复杂度 较高(约60行) 极低(约20行)
时间复杂度 严格 O(1) 也是 O(1),但淘汰时需获取迭代器
空间开销 每个节点额外存储两个指针 无额外指针
可移植性 任何支持哈希表和指针的语言均可实现 依赖特定语言特性(如 JavaScript 的 Map 顺序)

结论:虽然纯 Map 版本更简洁,但双向链表 + 哈希表是标准、通用的实现,更能体现 LRU 的核心思想。


10. 常见问题

Q1:为什么必须用双向链表?单向链表不行吗?
A:单向链表删除一个节点时,如果只有该节点的引用,无法 O(1) 获得它的前驱。双向链表可以通过 node.prev 直接修改前驱的 next 指针,实现 O(1) 删除。

Q2:节点中为什么要存储 key?
A:淘汰尾部节点时,需要通过 removed.key 从哈希表中删除对应的键。如果节点不存 key,则无法知道删除哈希表中的哪个条目。

Q3:虚拟头尾节点占用额外空间,会影响容量计算吗?
A:不影响。size 只统计实际存储的数据节点,虚拟节点不计入容量。

Q4:如果 capacity = 0 怎么办?
A:可以规定构造时抛出异常,或者在 put 方法中直接返回(不存储任何数据)。实际工程中通常不会允许容量为0的缓存。

Q5:能否用其他数据结构代替双向链表?
A:可以使用有序字典(如 Python 的 OrderedDict 或 Java 的 LinkedHashMap),但这些本质上也是哈希表+双向链表的封装。手写双向链表更能理解底层机制。


11. 总结

  • LRU 缓存的核心是 哈希表 提供 O(1) 查找,双向链表 提供 O(1) 顺序维护。
  • 虚拟头尾节点极大简化了链表边界操作。
  • 所有操作(get / put)时间复杂度 O(1),空间复杂度 O(capacity)。
  • 该实现不依赖特定语言特性,具有很好的可移植性和教学意义。

最新版vue3+TypeScript开发入门到实战教程之插槽slot详解

插槽概述

Slot,可翻译中文为插槽、空槽、钥匙槽。以下为官方定义Solt(插槽)是 Vue 提供的一种内容分发机制,允许父组件向子组件指定位置注入内容。简单理解为大门样式已经设计好,钥匙空槽预留,使用大门的人可以按装指纹锁、物理锁等锁。 Slot插槽分三种类型

  • 默认插槽
  • 具名插槽
  • 作用于插槽

默认插槽

概述

默认插槽是具名插槽的一个特例,实际类型应分成两类:

  • 具名插槽
  • 作用于插槽

默认插槽实例

  • 创建Fish,Fish组件提供标题、尾部,中间插槽内容由使用者提供
  • 创建App组件,引用Fish组件

App组件代码

<template>
  <div class="app">
    <Fish>
      <div>游泳的鲫鱼</div>
    </Fish>
     <Fish>
      <template>
        <div>会飞的鱼</div>
      </template>
    </Fish>
    <Fish>
      <template v-slot:default>
        <div>跃龙门的鲤鱼</div>
      </template>
    </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

Fish组件代码

<template>
  <div class="fish">
    <h2>头部</h2>
    <slot></slot>
    <h2>底部</h2>
  </div>

</template>

运行效果: 在这里插入图片描述 注意中间位置,会飞的鱼没有显示出来,默认插槽不需要使用template标签,若使用,必须给标签设置默认名称。

具名插槽

概述

在Fish组件中,可能会有很多个插槽, 如顶部、中部都可以设置一个插槽。使用名称来区分插槽:

  • 给slot设置名称
  • template标签设置slot名称

具名插槽实例

Fish组件代码

<template>
  <div class="fish">
    <h2>{{title}}</h2>
    <slot name="content">默认数据</slot>
    <slot name="footer">
      <h2>底部</h2>
    </slot>
  </div>
</template>
<script setup lang="ts">
defineProps(['title']);
</script>

App组件代码

<template>
  <div class="app">
     <Fish title="飞鱼">
      <template v-slot:content>
        <div>会飞的鱼</div>
      </template>
    </Fish>
      <Fish title="飞鱼">
      <template v-slot:footer>
        <h2>鲤鱼跃龙门</h2>
      </template>
      <template v-slot:content>
        <div>会飞的鱼</div>
      </template>
    </Fish>
     <Fish title="草鱼">
      <template #content>
        <div>吃草的鱼</div>
      </template>
      <template v-slot:footer>
        <h2>很爱水草</h2>
      </template>
      </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

运行效果 在这里插入图片描述

  • 引用第一个Fish组件时,当不使用名称为footer的插槽时,它显示默认值
  • 引用第二个组件说明,template显示位置取决于所选Slot
  • 引用第三个组件说明,v-slot:可用#缩写,如v-slot:content缩写成#content

作用域插槽

概述

插槽实际分两类,一是具名插槽,一是作用域插槽,两者区别:

  • 具名插槽数据与显示都在使用者
  • 作用域插槽的数据是在被引用的组件当中,使用者只负责显示数据
  • 通过slot标签可以将数据传递给template

作用域插槽实例

Fish组件代码

<template>
  <div class="fish">
    <h2>{{title}}</h2>
    <slot name="content" :data="fishs">默认数据</slot>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';

defineProps(['title']);
let fishs = reactive([
  { name: '鲫鱼', price: 10 },
  { name: '草鱼', price: 33 },
  { name: '娃娃鱼', price: 88 },
])
</script>

App组件代码

<template>
  <div class="app">
     <Fish title="飞鱼">
      <template v-slot:content="params">
        <ul>
          <li v-for="item in params.data">
            鱼:{{ item.name }}--价格:{{ item.price }}
          </li>
        </ul>
      </template>
    </Fish>
    <Fish title="草鱼">
      <template #content="{data}">
          <h4 v-for="item in data">
            鱼:{{ item.name }}--价格:{{ item.price }}
          </h4>
      </template>
    </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

运行代码查看效果 在这里插入图片描述 核心代码:

  • 传递数据:slot传递数据<slot name="content" :data="fishs">默认数据</slot>
  • 传递数据:template 接收数据<template v-slot:content="params">。 注意param结构赋值data

总结

类型语法特点使用场景
默认插槽<slot />一个组件只有一个主要内容区域
具名插槽<slot name="header" />

本周 GitHub 趋势观察:为什么前端热榜越来越像“AI 工具市场”?

你以为自己在看前端热榜,点进去却像走进了 AI 工具超市。 这不是错觉,而是开发范式正在从“写功能”转向“编排能力”。

过去一周,我重新刷了 GitHub Trending(JavaScript / TypeScript / Python)和 GitHub Changelog,一个信号越来越清晰:前端圈的流量入口,正在被 AI 工具链重写。 image.png

今天最稀缺的,不是会写页面的人,而是会设计工作流的人。

01 这周榜单发生了什么:前端标签下,AI 项目在“占位”

先看 JavaScript 周榜,最吸引眼球的不是 UI 框架,而是 AI Coding 相关仓库:

  • affaan-m/everything-claude-code:本周约 +23,500 stars
  • gsd-build/get-shit-done:本周约 +5,066 stars
  • jarrodwatts/claude-hud:本周约 +2,931 stars
  • Mintplex-Labs/anything-llm:本周约 +668 stars

TypeScript 周榜同样明显:

  • shareAI-lab/learn-claude-code:本周约 +7,776 stars
  • thedotmack/claude-mem:本周约 +3,938 stars
  • Yeachan-Heo/oh-my-claudecode:本周约 +8,991 stars

这说明一件事:前端这个分类,已经不只承载页面工程,它正在承载开发者生产力本身。

榜单还是那个榜单,主角已经换了剧本。

02 为什么会这样:不是前端变了,是“价值密度”变了

过去前端项目的爆点,常见于组件库、脚手架、可视化方案。现在爆点逐渐迁移到三件事:Agent、上下文、自动化流程。

背后有三个推力。

第一,开发瓶颈从“写不出来”变成“协同太慢”。 代码不再是唯一瓶颈,需求理解、上下文切换、代码评审、知识传递,才是吞噬效率的黑洞。能减少这些摩擦的工具,更容易被市场追捧。

第二,AI 工具天然具备“演示传播力”。 一个工具仓库只要能展示“5 分钟做完过去 1 小时的事”,传播链路就会爆发。相比之下,纯工程优化往往很难形成同等冲击。

第三,平台级信号已经给出方向。 GitHub 在 4 月 1 日的更新里,把 Copilot cloud agent 推向“先研究、先规划、再编码”:

  • 可以先在分支产出改动,不必立刻开 PR
  • 可以先生成 implementation plan,再动代码
  • 可以做 codebase deep research,再给答案

这不是一个功能点,而是工作流层面的迁移。

当平台开始重写流程,个人就很难继续只优化手速。

03 对前端工程师意味着什么:角色正在从“实现者”升级为“系统设计者”

很多人问:这是不是“前端被替代”的前奏? 我更愿意把它理解成一次角色重排。

Before:前端的主战场

  • 页面实现
  • 交互细节
  • 性能调优
  • 工程规范

After:前端的新增战场

  • 设计“人 + AI”协作链路
  • 管理上下文(文档、约束、规范、记忆)
  • 把零散脚本变成可复用流程
  • 用可观测指标评估 AI 产出质量

你会发现,真正拉开差距的不是谁调用了更多模型,而是谁能把团队经验沉淀成“可执行系统”。

把重复交给系统,把判断留给自己。

04 这波趋势里,前端人该怎么占位

别只追“哪个仓库今天又涨了多少星”,更要看结构性机会。

一个更有效的行动顺序是:

  • 先把你的高频任务拆成流程图(需求分析、搭架子、联调、提测)
  • 再用 AI 工具做“单点替换”(先替换最耗时的一环)
  • 然后补上约束层(代码规范、评审规则、回滚策略)
  • 最后把有效实践文档化,沉淀成团队资产

核心不是“你会不会用某个 AI 工具”,而是“你能不能把团队方法沉淀成系统能力”。

会写代码是门槛,会设计系统才是护城河。

05 写在最后

如果你最近也在刷 GitHub 热榜,应该已经感受到这种变化: 前端没有消失,前端只是从“页面工种”走向“生产力中枢”。

下一阶段,比拼的不是手速,而是抽象能力、流程设计能力和协作效率。

最后留个问题: 你觉得未来 12 个月,前端工程师最该补的一门能力,是 AI 编码技巧,还是 AI 工作流设计?欢迎在评论区聊聊你的真实观察。

MutationObserver:DOM界的“卧底”,暗中观察每个风吹草动

你想知道页面上的某个元素什么时候被偷偷改了吗?比如有个熊孩子脚本悄悄改了你的广告位,或者某个懒加载图片终于加载完了?今天我们就来请一位“卧底”——MutationObserver,让它24小时盯着DOM树,任何变化都逃不过它的眼睛。

前言

假设你开了一家便利店,店里装了监控。你想知道:什么时候有人进来?什么时候货架上的商品被拿走了?什么时候价格标签被换了?普通的监控只能录像,但你需要的是“智能警报”——一有变化就通知你。

这就是MutationObserver的活。它是浏览器提供的一个API,专门用来监听DOM树的变化:节点增删、属性修改、文本内容改变……统统能抓到。而且它不会像setInterval那样一直轮询,性能好得多。

一、MutationObserver是啥?

MutationObserver是一个构造函数,用来创建一个观察者对象。你可以给它指定一个回调函数,然后让它去“盯”某个DOM节点。一旦这个节点或它的子孙节点发生变化,回调函数就会被触发。

// 创建一个观察者实例,传入回调
const observer = new MutationObserver((mutationsList, observer) => {
  for (let mutation of mutationsList) {
    console.log(mutation.type, '发生了变化');
  }
});

// 指定要观察的节点
const targetNode = document.getElementById('watch-me');

// 开始观察
observer.observe(targetNode, {
  attributes: true,    // 观察属性变化
  childList: true,     // 观察子节点增删
  subtree: true,       // 观察所有后代节点
  characterData: true  // 观察文本内容变化
});

// 某天不想观察了
// observer.disconnect();

二、能观察到哪些变化?

配置选项决定了你关心哪些“风吹草动”:

  • attributes:属性变了(比如classstylesrc被改)
  • childList:子节点被增删(添加或删除元素、文本节点)
  • characterData:文本节点的内容变了
  • subtree:是否监听后代节点(默认false,只监听目标节点)
  • attributeFilter:只监听特定属性,比如['class', 'src']
  • attributeOldValue:是否记录旧属性值
  • characterDataOldValue:是否记录旧文本值

三、实战:监听广告位有没有被篡改

很多网站会在页面上放广告,但有些恶意脚本会偷偷把广告位换成自己的内容。用MutationObserver可以第一时间发现并报警。

<div id="ad-container">
  <img src="real-ad.jpg" alt="官方广告">
</div>
const adContainer = document.getElementById('ad-container');

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList') {
      // 子节点被改了
      console.warn('⚠️ 广告位内容被篡改!');
      // 可以上报服务器,或者恢复内容
    } else if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
      console.warn('⚠️ 广告图片被替换了!');
    }
  });
});

observer.observe(adContainer, {
  childList: true,
  subtree: true,
  attributes: true,
  attributeFilter: ['src', 'href']
});

四、实战:监听输入框内容变化(代替input事件?)

input事件已经能监听输入框变化,但MutationObserver可以监听更底层的文本节点变化,比如通过JS直接修改.valueinput事件可能不触发,但MutationObserver可以。

<input id="username" type="text">
const input = document.getElementById('username');

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
      console.log('输入框的值被改了,新值:', input.value);
    }
  });
});

observer.observe(input, {
  attributes: true,
  attributeFilter: ['value']
});

注意:这种方式监听value属性变化,只对通过JS设置.value有效,用户手动输入不会触发(因为用户输入不改变value属性,而是改变元素的defaultValue和内部状态)。所以实际中监听输入框还是input事件更合适。这里只是演示能力。

五、实战:监听动态加载的图片,做懒加载

很多懒加载库用IntersectionObserver,但如果你想知道图片什么时候被添加到DOM,可以用MutationObserver。

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    mutation.addedNodes.forEach((node) => {
      if (node.nodeType === 1 && node.tagName === 'IMG') {
        console.log('新图片出现了:', node.src);
        // 可以在这里做懒加载初始化
      }
    });
  });
});

observer.observe(document.body, {
  childList: true,
  subtree: true
});

六、性能注意事项

MutationObserver虽然比轮询好,但也不能滥用。以下几点要注意:

  1. 不要观察整个document:如果你observe(document.body, { subtree: true, childList: true, attributes: true }),那页面上的任何变化都会触发回调,频繁执行可能影响性能。尽量把观察范围缩小到具体容器。

  2. 回调里不要做太重的操作:MutationObserver的回调是在微任务中执行的,如果里面操作DOM或者计算太多,会阻塞后续渲染。

  3. 及时disconnect:如果不再需要观察,记得调用disconnect()释放资源。

  4. 使用takeRecords():在disconnect之前,可以调用observer.takeRecords()取出尚未处理的变化记录。

七、与旧API对比:Mutation Events的悲惨往事

很久以前,浏览器有一套Mutation Events(比如DOMNodeInsertedDOMAttrModified等)。它们的问题很多:

  • 性能差,每次变化都同步触发,容易导致重入和崩溃
  • 不支持批量观察
  • 被标记为废弃

MutationObserver是它们的完美替代,异步、批量、性能好。

八、总结:MutationObserver就是你的“鹰眼”

  • 它能监听DOM树的各种变化:属性、子节点、文本内容。
  • 配置灵活,可以精确到特定属性或是否包含后代。
  • 异步回调,批量返回变化记录,性能优秀。
  • 应用场景:监听动态内容加载、检测第三方脚本篡改、实现数据绑定(比如某些MVVM库的底层)、与React/Vue的虚拟DOM配合调试等。

有了MutationObserver,你就可以在DOM变化时第一时间响应,像一个隐形的守护者。明天我们将进入Web Storage的世界,看看localStorage、sessionStorage和IndexedDB怎么帮你把数据存到用户浏览器里。

如果你觉得今天的“卧底”够犀利,点个赞让更多人看到。我们明天见!

kotlin安卓项目配置webview开启定位功能

大家好,我的开源项目PakePlus可以将网页/Vue/React项目打包为桌面/手机应用并且小于5M只需几分钟,官网地址:pakeplus.com

在 Kotlin 开发的 Android 应用中,让 WebView 正常使用 H5 页面的定位(navigator.geolocation),核心是权限配置 + WebView 设置 + WebChromeClient 回调三部分。下面是完整可直接使用的实现方案。

添加权限(AndroidManifest.xml)

<!-- 网页 Geolocation API(navigator.geolocation) -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

WebView 基础配置(Kotlin)

package com.app.pakeplus

import android.Manifest
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.Gravity
import android.webkit.PermissionRequest
import android.webkit.JavascriptInterface
import android.webkit.URLUtil
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.GeolocationPermissions
import android.webkit.WebView
import android.webkit.WebViewClient
import android.webkit.MimeTypeMap
import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
// import android.view.Menu
// import android.view.WindowInsets
// import com.google.android.material.snackbar.Snackbar
// import com.google.android.material.navigation.NavigationView
// import androidx.navigation.findNavController
// import androidx.navigation.ui.AppBarConfiguration
// import androidx.navigation.ui.navigateUp
// import androidx.navigation.ui.setupActionBarWithNavController
// import androidx.navigation.ui.setupWithNavController
// import androidx.drawerlayout.widget.DrawerLayout
// import com.app.pakeplus.databinding.ActivityMainBinding
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import org.json.JSONObject
import java.net.URISyntaxException
import android.util.Base64
import java.io.File
import java.io.FileOutputStream
import kotlin.math.abs

class MainActivity : AppCompatActivity() {

//    private lateinit var appBarConfiguration: AppBarConfiguration
//    private lateinit var binding: ActivityMainBinding

    private lateinit var webView: WebView
    private lateinit var gestureDetector: GestureDetectorCompat
    private var fileUploadCallback: ValueCallback<Array<Uri>>? = null
    private lateinit var fileChooserLauncher: ActivityResultLauncher<Intent>
    private lateinit var permissionLauncher: ActivityResultLauncher<Array<String>>
    private var pendingPermissionRequest: PermissionRequest? = null

    private lateinit var locationPermissionLauncher: ActivityResultLauncher<Array<String>>
    private var pendingGeolocationOrigin: String? = null
    private var pendingGeolocationCallback: GeolocationPermissions.Callback? = null

    // 全屏视频相关
    private var customView: View? = null
    private var customViewCallback: WebChromeClient.CustomViewCallback? = null
    private var originalOrientation: Int = 0

    /** 是否从配置启用了全屏(隐藏状态栏+导航栏) */
    private var isFullScreenMode: Boolean = false

    /** 当前主文档是否已出现加载错误;仅成功时隐藏启动遮罩 */
    private var mainFrameLoadError: Boolean = false

    /** app.json 中 launch 非空时才显示启动图遮罩 */
    private var showLaunchSplash: Boolean = false

    @SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 初始化文件选择器
        fileChooserLauncher = registerForActivityResult(
            ActivityResultContracts.StartActivityForResult()
        ) { result ->
            val resultCode = result.resultCode
            val data = result.data

            if (fileUploadCallback == null) return@registerForActivityResult

            var results: Array<Uri>? = null

            if (resultCode == RESULT_OK && data != null) {
                val dataString = data.dataString
                val clipData = data.clipData

                if (clipData != null) {
                    // 多文件选择
                    results = Array(clipData.itemCount) { i ->
                        clipData.getItemAt(i).uri
                    }
                } else if (dataString != null) {
                    // 单文件选择
                    results = arrayOf(Uri.parse(dataString))
                }
            }

            fileUploadCallback?.onReceiveValue(results)
            fileUploadCallback = null
        }

        // 初始化运行时权限请求(摄像头 / 麦克风)
        permissionLauncher = registerForActivityResult(
            ActivityResultContracts.RequestMultiplePermissions()
        ) { permissions ->
            val request = pendingPermissionRequest
            if (request == null) {
                return@registerForActivityResult
            }

            // 所有相关权限都通过才允许 WebView 使用
            val allGranted = permissions.values.all { it }
            if (allGranted) {
                request.grant(request.resources)
            } else {
                request.deny()
            }
            pendingPermissionRequest = null
        }

        // 网页 HTML5 定位(navigator.geolocation)
        locationPermissionLauncher = registerForActivityResult(
            ActivityResultContracts.RequestMultiplePermissions()
        ) { results ->
            val origin = pendingGeolocationOrigin
            val geoCallback = pendingGeolocationCallback
            pendingGeolocationOrigin = null
            pendingGeolocationCallback = null
            if (origin != null && geoCallback != null) {
                val fine = results[Manifest.permission.ACCESS_FINE_LOCATION] == true
                val coarse = results[Manifest.permission.ACCESS_COARSE_LOCATION] == true
                geoCallback.invoke(origin, fine || coarse, false)
            }
        }

        // parseJsonWithNative
        val config = parseJsonWithNative(this, "app.json")
        val fullScreen = config?.get("fullScreen") as? Boolean ?: false
        val gesture = config?.get("gesture") as? Boolean ?: false
        val debug = config?.get("debug") as? Boolean ?: false
        val userAgent = config?.get("userAgent") as? String ?: ""
        val webUrl = config?.get("webUrl") as? String ?: "https://pakeplus.com/"
        val launchCfg = config?.get("launch") as? String
        showLaunchSplash = !launchCfg.isNullOrBlank()
        // enable debug by chrome://inspect
        WebView.setWebContentsDebuggingEnabled(debug)
        // config fullscreen
        isFullScreenMode = fullScreen
        if (fullScreen) {
            window.setFlags(
                WindowManager.LayoutParams.FLAG_FULLSCREEN,
                WindowManager.LayoutParams.FLAG_FULLSCREEN,
            )
            window.setFlags(
                WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION,
                WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION
            )
            window.navigationBarColor = android.graphics.Color.TRANSPARENT
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                val lp = window.attributes
                lp.layoutInDisplayCutoutMode =
                    WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
                window.attributes = lp
            }
            // 低于 P 时在这里用旧 API 隐藏导航栏;P 及以上在 setContentView 后由 hideSystemUI() 统一处理
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
                @Suppress("DEPRECATION")
                window.decorView.systemUiVisibility = (
                        View.SYSTEM_UI_FLAG_FULLSCREEN or
                                View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
                                View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
                                View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
                                View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
                                View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                        )
            }
        }
        // 可以让内容视图的颜色延伸到屏幕边缘
        enableEdgeToEdge()
        setContentView(R.layout.single_main)
        if (!showLaunchSplash) {
            findViewById<View>(R.id.splash_overlay).visibility = View.GONE
        }
        // set system safe area
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.ConstraintLayout))
        { view, insets ->
            val systemBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            view.setPadding(systemBar.left, systemBar.top, systemBar.right, systemBar.bottom)
            insets
        }
        // 全屏模式下隐藏状态栏和底部导航栏(Android 9+ 必须在这里调用,window 已就绪)
        if (isFullScreenMode) {
            window.decorView.post { hideSystemUI() }
        }
        webView = findViewById<WebView>(R.id.webview)
        webView.settings.apply {
            javaScriptEnabled = true
            domStorageEnabled = true
            setGeolocationEnabled(true)
            allowFileAccess = true
            useWideViewPort = true
            allowFileAccessFromFileURLs = true
            allowContentAccess = true
            allowUniversalAccessFromFileURLs = true
            loadWithOverviewMode = true
            mediaPlaybackRequiresUserGesture = false
            // setSupportMultipleWindows(true)
        }
        webView
        // set user agent
        if (userAgent.isNotEmpty()) {
            webView.settings.userAgentString = userAgent
        }

        webView.settings.loadWithOverviewMode = true
        webView.settings.setSupportZoom(false)

        // clear cache
        webView.clearCache(true)

        // 为 blob: 链接下载注入 JS 接口
        webView.addJavascriptInterface(BlobDownloadInterface(this), "BlobDownloader")

        // inject js
        webView.webViewClient = MyWebViewClient(debug)

        // get web load progress
        webView.webChromeClient = MyChromeClient(this)

        // 网页内下载:点击下载链接时由 DownloadManager 保存到系统下载目录
        webView.setDownloadListener { url, userAgent, contentDisposition, mimetype, contentLength ->
            startDownload(url, userAgent, contentDisposition, mimetype)
        }

        // Setup gesture detector
        gestureDetector =
            GestureDetectorCompat(this, object : GestureDetector.SimpleOnGestureListener() {
                override fun onFling(
                    e1: MotionEvent?,
                    e2: MotionEvent,
                    velocityX: Float,
                    velocityY: Float
                ): Boolean {
                    if (e1 == null) return false

                    val diffX = e2.x - e1.x
                    val diffY = e2.y - e1.y

                    // Only handle horizontal swipes
                    if (abs(diffX) > abs(diffY)) {
                        if (abs(diffX) > 100 && abs(velocityX) > 100) {
                            if (diffX > 0) {
                                // Swipe right - go back
                                if (webView.canGoBack()) {
                                    webView.goBack()
                                    return true
                                }
                            } else {
                                // Swipe left - go forward
                                if (webView.canGoForward()) {
                                    webView.goForward()
                                    return true
                                }
                            }
                        }
                    }
                    return false
                }
            })

        // Set touch listener for WebView
        webView.setOnTouchListener { _, event ->
            if (gesture) {
                gestureDetector.onTouchEvent(event)
            }
            false
        }

        // load webUrl or file:///android_asset/index.html
        webView.loadUrl(webUrl)

//        binding = ActivityMainBinding.inflate(layoutInflater)
//        setContentView(R.layout.single_main)

//        setSupportActionBar(binding.appBarMain.toolbar)

//        binding.appBarMain.fab.setOnClickListener { view ->
//            Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
//                .setAction("Action", null)
//                .setAnchorView(R.id.fab).show()
//        }

//        val drawerLayout: DrawerLayout = binding.drawerLayout
//        val navView: NavigationView = binding.navView
//        val navController = findNavController(R.id.nav_host_fragment_content_main)

        // Passing each menu ID as a set of Ids because each
        // menu should be considered as top level destinations.
//        appBarConfiguration = AppBarConfiguration(
//            setOf(
//                R.id.nav_home, R.id.nav_gallery, R.id.nav_slideshow
//            ), drawerLayout
//        )
//        setupActionBarWithNavController(navController, appBarConfiguration)
//        navView.setupWithNavController(navController)
    }


    override fun onPause() {
        super.onPause()
        webView.onPause()
        // 如果正在全屏播放视频,暂停播放
        if (customView != null) {
            webView.pauseTimers()
        }
    }

    override fun onResume() {
        super.onResume()
        webView.onResume()
        // 恢复 WebView 的定时器
        webView.resumeTimers()
    }

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)
        // 全屏模式下窗口重新获得焦点时再次隐藏导航栏(用户从边缘滑出后会自动再隐藏)
        if (hasFocus && isFullScreenMode && customView == null) {
            hideSystemUI()
        }
    }

    override fun onDestroy() {
        // 清理全屏视图
        if (customView != null) {
            hideCustomView()
        }
        webView.destroy()
        super.onDestroy()
    }

    @Deprecated("Deprecated in Java")
    override fun onBackPressed() {
        // 如果正在全屏播放视频,先退出全屏
        if (customView != null) {
            hideCustomView()
            return
        }

        if (webView.canGoBack()) {
            webView.goBack()
        } else {
            super.onBackPressed()
        }
    }

    // 显示全屏视频
    private fun showCustomView(view: View, callback: WebChromeClient.CustomViewCallback) {
        // 如果已经有全屏视图,先隐藏它
        if (customView != null) {
            hideCustomView()
            return
        }

        customView = view
        customViewCallback = callback

        // 保存当前屏幕方向
        originalOrientation = requestedOrientation

        // 获取根布局
        val decorView = window.decorView as ViewGroup
        val rootView = decorView.findViewById<ViewGroup>(android.R.id.content)

        // 创建全屏容器
        val fullscreenContainer = FrameLayout(this).apply {
            layoutParams = ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
            setBackgroundColor(android.graphics.Color.BLACK)
        }

        // 将全屏视图添加到容器
        fullscreenContainer.addView(view)

        // 将容器添加到根布局
        rootView.addView(fullscreenContainer)

        // 隐藏系统UI
        hideSystemUI()

        // 隐藏WebView
        webView.visibility = View.GONE
    }

    // 隐藏全屏视频
    private fun hideCustomView() {
        if (customView == null) return

        // 恢复系统UI
        showSystemUI()

        // 显示WebView
        webView.visibility = View.VISIBLE

        // 获取根布局
        val decorView = window.decorView as ViewGroup
        val rootView = decorView.findViewById<ViewGroup>(android.R.id.content)

        // 移除全屏容器
        val fullscreenContainer = customView?.parent as? ViewGroup
        fullscreenContainer?.let {
            rootView.removeView(it)
        }

        // 调用回调
        customViewCallback?.onCustomViewHidden()

        // 清理
        customView = null
        customViewCallback = null

        // 恢复屏幕方向
        requestedOrientation = originalOrientation
    }

    // 隐藏系统UI(全屏模式)
    private fun hideSystemUI() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            window.insetsController?.let {
                it.hide(android.view.WindowInsets.Type.systemBars())
                // 设置系统栏行为:通过滑动显示临时栏
                try {
                    @Suppress("NewApi")
                    it.systemBarsBehavior =
                        android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
                } catch (e: Exception) {
                    // 如果常量不可用,忽略此设置
                    Log.w("MainActivity", "BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE not available", e)
                }
            }
        } else {
            @Suppress("DEPRECATION")
            window.decorView.systemUiVisibility = (
                    View.SYSTEM_UI_FLAG_FULLSCREEN
                            or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
                            or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
                            or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                            or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                            or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                    )
        }
    }

    // 显示系统UI
    private fun showSystemUI() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            window.insetsController?.show(android.view.WindowInsets.Type.systemBars())
        } else {
            @Suppress("DEPRECATION")
            window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
        }
    }

    fun parseJsonWithNative(context: Context, jsonFilePath: String): Map<String, Any>? {
        val jsonString = assets.open(jsonFilePath).bufferedReader().use { it.readText() }
        return try {
            val jsonObject = JSONObject(jsonString)
            // 提取字段
            val name = jsonObject.getString("name")
            val webUrl = jsonObject.getString("webUrl")
            val debug = jsonObject.getBoolean("debug")
            val userAgent = jsonObject.getString("userAgent")
            val fullScreen = jsonObject.getBoolean("fullScreen")
            val launch = jsonObject.getString("launch")
            // 返回键值对
            mapOf(
                "name" to name,
                "webUrl" to webUrl,
                "debug" to debug,
                "userAgent" to userAgent,
                "fullScreen" to fullScreen,
                "launch" to launch
            )
        } catch (e: Exception) {
            e.printStackTrace()
            null
        }
    }

    /**
     * JS 调用的接口:接收 base64 数据并保存为文件
     */
    inner class BlobDownloadInterface(private val context: Context) {

        @JavascriptInterface
        fun downloadBase64File(base64Data: String, mimeType: String?, fileName: String?) {
            try {
                val bytes = Base64.decode(base64Data, Base64.DEFAULT)

                // 统一保存到系统 Download 目录,和普通下载保持一致
                val downloadsDir =
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
                if (!downloadsDir.exists()) {
                    downloadsDir.mkdirs()
                }

                val safeName = when {
                    !fileName.isNullOrBlank() -> fileName
                    !mimeType.isNullOrBlank() -> {
                        val ext = MimeTypeMap.getSingleton()
                            .getExtensionFromMimeType(mimeType) ?: "bin"
                        "download_${System.currentTimeMillis()}.$ext"
                    }

                    else -> "download_${System.currentTimeMillis()}.bin"
                }

                val outFile = File(downloadsDir, safeName)
                FileOutputStream(outFile).use { it.write(bytes) }

                showTopToast(context, "已保存到下载目录: ${outFile.name}", Toast.LENGTH_LONG)
                Log.d("BlobDownload", "File saved: ${outFile.absolutePath}")
            } catch (e: Exception) {
                Log.e("BlobDownload", "save error", e)
                showTopToast(context, "保存失败: ${e.message}", Toast.LENGTH_LONG)
            }
        }
    }

    /**
     * 根据 URL / Content-Disposition / MIME 开始一个系统下载任务
     * - 对常见的 mp4 纠正被识别成 .bin 的问题
     * - 供 WebView DownloadListener 和 shouldOverrideUrlLoading 共用
     */
    private fun startDownload(
        url: String,
        userAgent: String?,
        contentDisposition: String?,
        mimetype: String?
    ) {
        // 1. 先根据 URL / Content-Disposition / MIME 推测文件名
        var fileName = URLUtil.guessFileName(url, contentDisposition, mimetype)

        // 2. 处理 mp4 被识别成 .bin 的场景
        val lowerMime = mimetype?.lowercase() ?: ""
        val lowerName = fileName.lowercase()

        val isVideoMp4 = lowerMime.contains("video/mp4") ||
                (lowerMime.contains("application/octet-stream") && url.contains(
                    ".mp4",
                    ignoreCase = true
                ))

        if (isVideoMp4) {
            fileName = when {
                lowerName.endsWith(".mp4") -> fileName
                lowerName.endsWith(".bin") -> fileName.replace(
                    Regex(
                        "\\.bin$",
                        RegexOption.IGNORE_CASE
                    ), ".mp4"
                )

                !fileName.contains('.') -> "$fileName.mp4"
                else -> fileName
            }
        }

        val request = DownloadManager.Request(Uri.parse(url)).apply {
            // 对于 mp4 强制使用正确的 MIME,避免部分 ROM 再次误判
            if (isVideoMp4) {
                setMimeType("video/mp4")
            } else if (!mimetype.isNullOrEmpty()) {
                setMimeType(mimetype)
            }

            if (!userAgent.isNullOrEmpty()) {
                addRequestHeader("User-Agent", userAgent)
            }
            setDescription(getString(R.string.downloading))
            setTitle(fileName)
            setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
            setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
        }

        val dm = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
        dm.enqueue(request)
        showTopToast(this, getString(R.string.download_started), Toast.LENGTH_SHORT)
    }

    /**
     * 将 Toast 显示在屏幕顶部
     */
    private fun showTopToast(context: Context, message: String, duration: Int) {
        val toast = Toast.makeText(context, message, duration)
        toast.setGravity(Gravity.TOP or Gravity.CENTER_HORIZONTAL, 0, 120)
        toast.show()
    }

    /**
     * 判断一个 URL 是否是“常见文件类型”,用于自动触发下载
     */
    private fun isDownloadableFileUrl(url: String): Boolean {
        val checkUrl = url.substringBefore("?").substringBefore("#").lowercase()
        // 可按需要继续扩展
        val exts = listOf(
            "mp4", "mov", "mkv", "avi",
            "mp3", "aac", "wav", "flac",
            "jpg", "jpeg", "png", "gif", "webp", "bmp",
            "txt", "pdf",
            "doc", "docx", "xls", "xlsx", "ppt", "pptx",
            "zip", "rar", "7z"
        )
        return exts.any { checkUrl.endsWith(".$it") }
    }

//    override fun onCreateOptionsMenu(menu: Menu): Boolean {
//        // Inflate the menu; this adds items to the action bar if it is present.
//        menuInflater.inflate(R.menu.main, menu)
//        return true
//    }

//    override fun onSupportNavigateUp(): Boolean {
//        val navController = findNavController(R.id.nav_host_fragment_content_main)
//        return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
//    }

    private fun hideSplashOverlay() {
        if (!showLaunchSplash) return
        val overlay = findViewById<View>(R.id.splash_overlay)
        if (overlay.visibility != View.VISIBLE) return
        overlay.animate()
            .alpha(0f)
            .setDuration(200L)
            .withEndAction {
                overlay.visibility = View.GONE
                overlay.alpha = 1f
            }
            .start()
    }

    inner class MyWebViewClient(val debug: Boolean) : WebViewClient() {

        @Deprecated("Deprecated in Java", ReplaceWith("false"))
        override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
            if (url == null) return false
            val fixedUrl = url.toString()

            // 对常见文件类型的 HTTP/HTTPS 链接,直接拦截为下载,不在 WebView 内打开
            if (fixedUrl.startsWith("http://") || fixedUrl.startsWith("https://")) {
                if (isDownloadableFileUrl(fixedUrl)) {
                    val ua = view?.settings?.userAgentString ?: ""
                    // 根据扩展名推断 MIME
                    val ext = MimeTypeMap.getFileExtensionFromUrl(fixedUrl)
                    val mime = ext?.let {
                        MimeTypeMap.getSingleton().getMimeTypeFromExtension(it.lowercase())
                    }
                        ?: "application/octet-stream"
                    this@MainActivity.startDownload(fixedUrl, ua, null, mime)
                    return true
                }
                // 普通网页,交给 WebView 处理
                return false
            }

            // file:// 链接仍交给 WebView 处理
            if (fixedUrl.startsWith("file://")) {
                return false
            }

            // --- 处理外部应用链接 ---
            // 1. 检查是否是 Intent URI (e.g., intent://...)
            if (fixedUrl.startsWith("intent://")) {
                try {
                    // 解析 Intent URI
                    val intent = Intent.parseUri(fixedUrl, Intent.URI_INTENT_SCHEME)

                    // 检查设备上是否有应用可以处理此 Intent
                    if (intent.resolveActivity(view?.context?.packageManager!!) != null) {
                        view.context?.startActivity(intent)
                        return true // 已经处理,阻止 WebView 加载
                    }

                    // 如果找不到能处理的应用,可以尝试打开备用 URL (如果 Intent 中有定义 fallback URL)
                    val fallbackUrl = intent.getStringExtra("browser_fallback_url")
                    if (!fallbackUrl.isNullOrEmpty()) {
                        view.loadUrl(fallbackUrl)
                        return true // 加载备用 URL
                    }

                } catch (e: URISyntaxException) {
                    // 解析 Intent URI 失败
                    Log.e("WebViewClient", "Bad Intent URI: $fixedUrl", e)
                } catch (e: ActivityNotFoundException) {
                    // 找不到匹配的 Activity (外部应用未安装),此情况通常在 `resolveActivity` 后捕获
                    Log.e("WebViewClient", "No activity found to handle Intent: $fixedUrl", e)
                    // 您也可以在这里加载一个 "未安装应用" 的提示页面
                }
                // 如果是 Intent 但无法处理(例如未安装应用),您可以选择返回 false 让 WebView 尝试加载(通常会失败)
                // 或者继续执行下面的 Scheme 检查
            }


            // 3. 检查是否是其他自定义 Scheme (e.g., weixin://, zhihu://)
            // 注意:Intent URI 是更通用和推荐的方式,但有些应用可能直接使用 Scheme。
            try {
                val intent = Intent(Intent.ACTION_VIEW, fixedUrl.toUri())
                // 必须检查是否有应用可以处理此 Intent,否则会导致崩溃
                if (intent.resolveActivity(view?.context?.packageManager!!) != null) {
                    view.context?.startActivity(intent)
                    return true // 已经处理,阻止 WebView 加载
                } else {
                    // 没有安装相应的应用
                    Log.w("WebViewClient", "External app not installed for: $fixedUrl")
                    // 可以添加逻辑提示用户下载应用或打开相应的应用商店链接
                }
            } catch (e: Exception) {
                Log.e("WebViewClient", "Error starting external app: $fixedUrl", e)
            }
            // 如果不是外部应用 Scheme,也不是 HTTP/HTTPS,则返回 false,让 WebView 处理
            return false
        }

        override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
            super.doUpdateVisitedHistory(view, url, isReload)
        }

        override fun onReceivedError(
            view: WebView?,
            request: WebResourceRequest?,
            error: WebResourceError?
        ) {
            super.onReceivedError(view, request, error)
            println("webView onReceivedError: ${error?.description}")
            if (showLaunchSplash && request?.isForMainFrame == true) {
                mainFrameLoadError = true
            }
        }

        override fun onReceivedHttpError(
            view: WebView?,
            request: WebResourceRequest?,
            errorResponse: WebResourceResponse?
        ) {
            super.onReceivedHttpError(view, request, errorResponse)
            if (showLaunchSplash && request?.isForMainFrame == true) {
                val code = errorResponse?.statusCode ?: 0
                if (code >= 400) mainFrameLoadError = true
            }
        }

        override fun onPageFinished(view: WebView?, url: String?) {
            super.onPageFinished(view, url)
            // post 一次,尽量避免与 onReceivedError / onReceivedHttpError 的时序竞态
            view?.post {
                if (!mainFrameLoadError) hideSplashOverlay()
            }
            // 注入脚本,拦截 blob: 链接并通过 BlobDownloader 保存到本地
            val blobInterceptor = """
                (function () {
                  if (window.__blobDownloadInjected) return;
                  window.__blobDownloadInjected = true;
                  
                  document.addEventListener('click', function (e) {
                    try {
                      var target = e.target;
                      // 寻找最近的 <a> 标签
                      while (target && target.tagName && target.tagName.toLowerCase() !== 'a') {
                        target = target.parentElement;
                      }
                      if (!target) return;
                      
                      var href = target.getAttribute('href');
                      if (!href || href.indexOf('blob:') !== 0) return;
                      
                      // 拦截浏览器默认行为
                      e.preventDefault();
                      e.stopPropagation();
                      
                      var fileName = target.getAttribute('download') || 'download-' + Date.now();
                      
                      // 通过 fetch 拿到 blob,再转 base64 交给原生
                      fetch(href)
                        .then(function (res) { return res.blob(); })
                        .then(function (blob) {
                          var reader = new FileReader();
                          reader.onloadend = function () {
                            try {
                              var dataUrl = reader.result || '';
                              var commaIndex = dataUrl.indexOf(',');
                              var base64 = commaIndex >= 0 ? dataUrl.substring(commaIndex + 1) : dataUrl;
                              var mime = blob.type || 'application/octet-stream';
                              if (window.BlobDownloader && window.BlobDownloader.downloadBase64File) {
                                window.BlobDownloader.downloadBase64File(base64, mime, fileName);
                              } else {
                                console.error('BlobDownloader not found on window');
                              }
                            } catch (err) {
                              console.error('Blob download convert error', err);
                            }
                          };
                          reader.readAsDataURL(blob);
                        })
                        .catch(function (err) {
                          console.error('Blob download fetch error', err);
                        });
                    } catch (e2) {
                      console.error('Blob download interceptor error', e2);
                    }
                  }, true);
                })();
            """.trimIndent()

            view?.evaluateJavascript(blobInterceptor, null)
        }

        override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
            super.onPageStarted(view, url, favicon)
            if (showLaunchSplash) mainFrameLoadError = false
            if (debug) {
                // vConsole
                val vConsole = assets.open("vConsole.js").bufferedReader().use { it.readText() }
                val openDebug = """var vConsole = new window.VConsole()"""
                view?.evaluateJavascript(vConsole + openDebug, null)
            }
            // inject js
            val injectJs = assets.open("custom.js").bufferedReader().use { it.readText() }
            view?.evaluateJavascript(injectJs, null)
        }
    }

    inner class MyChromeClient(private val activity: MainActivity) : WebChromeClient() {
        override fun onProgressChanged(view: WebView?, newProgress: Int) {
            super.onProgressChanged(view, newProgress)
            val url = view?.url
            println("wev view url:$url")
        }

        // 处理 getUserMedia 权限请求(摄像头 / 麦克风)
        override fun onPermissionRequest(request: PermissionRequest?) {
            if (request == null) return

            activity.runOnUiThread {
                val resources = request.resources

                // 需要对应的原生权限
                val needPermissions = mutableListOf<String>()
                if (resources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) {
                    needPermissions.add(Manifest.permission.CAMERA)
                }
                if (resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) {
                    needPermissions.add(Manifest.permission.RECORD_AUDIO)
                }

                if (needPermissions.isEmpty()) {
                    // 不涉及摄像头/麦克风,直接允许
                    request.grant(resources)
                    return@runOnUiThread
                }

                // 检查是否已经有原生权限
                val notGranted = needPermissions.filter {
                    ContextCompat.checkSelfPermission(
                        activity,
                        it
                    ) != PackageManager.PERMISSION_GRANTED
                }

                if (notGranted.isEmpty()) {
                    // 已经有权限,直接授予给 WebView
                    request.grant(resources)
                } else {
                    // 先请求原生权限,保存 WebView 的请求
                    activity.pendingPermissionRequest?.deny()
                    activity.pendingPermissionRequest = request
                    activity.permissionLauncher.launch(notGranted.toTypedArray())
                }
            }
        }

        override fun onPermissionRequestCanceled(request: PermissionRequest?) {
            super.onPermissionRequestCanceled(request)
            if (activity.pendingPermissionRequest == request) {
                activity.pendingPermissionRequest = null
            }
        }

        override fun onGeolocationPermissionsShowPrompt(
            origin: String?,
            callback: GeolocationPermissions.Callback?
        ) {
            if (origin == null || callback == null) {
                super.onGeolocationPermissionsShowPrompt(origin, callback)
                return
            }
            activity.runOnUiThread {
                val fineOk = ContextCompat.checkSelfPermission(
                    activity,
                    Manifest.permission.ACCESS_FINE_LOCATION
                ) == PackageManager.PERMISSION_GRANTED
                val coarseOk = ContextCompat.checkSelfPermission(
                    activity,
                    Manifest.permission.ACCESS_COARSE_LOCATION
                ) == PackageManager.PERMISSION_GRANTED
                if (fineOk || coarseOk) {
                    callback.invoke(origin, true, false)
                    return@runOnUiThread
                }
                val need = buildList {
                    if (!fineOk) add(Manifest.permission.ACCESS_FINE_LOCATION)
                    if (!coarseOk) add(Manifest.permission.ACCESS_COARSE_LOCATION)
                }.toTypedArray()
                activity.pendingGeolocationCallback?.let { prevCb ->
                    activity.pendingGeolocationOrigin?.let { prevOrigin ->
                        prevCb.invoke(prevOrigin, false, false)
                    }
                }
                activity.pendingGeolocationOrigin = origin
                activity.pendingGeolocationCallback = callback
                activity.locationPermissionLauncher.launch(need)
            }
        }

        override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
            if (view != null && callback != null) {
                activity.showCustomView(view, callback)
            } else {
                super.onShowCustomView(view, callback)
            }
        }

        override fun onHideCustomView() {
            activity.hideCustomView()
            super.onHideCustomView()
        }

        // 处理文件选择(Android 5.0+)
        override fun onShowFileChooser(
            webView: WebView?,
            filePathCallback: ValueCallback<Array<Uri>>?,
            fileChooserParams: FileChooserParams?
        ): Boolean {
            // 如果之前有未完成的回调,取消它
            if (activity.fileUploadCallback != null) {
                activity.fileUploadCallback?.onReceiveValue(null)
            }
            activity.fileUploadCallback = filePathCallback

            try {
                val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
                    addCategory(Intent.CATEGORY_OPENABLE)

                    // 根据参数设置文件类型
                    val acceptTypes = fileChooserParams?.acceptTypes
                    if (acceptTypes != null && acceptTypes.isNotEmpty()) {
                        // 支持多种 MIME 类型
                        if (acceptTypes.size == 1) {
                            type = acceptTypes[0]
                        } else {
                            // 多个类型时使用通配符,并设置额外类型
                            type = "*/*"
                            putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes)
                        }
                    } else {
                        // 默认支持所有文件类型
                        type = "*/*"
                    }

                    // 支持多选
                    if (fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE) {
                        putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
                    }
                }

                // 创建选择器,允许用户选择不同的应用来打开文件
                val chooserIntent = Intent.createChooser(intent, "选择文件")
                activity.fileChooserLauncher.launch(chooserIntent)
                return true
            } catch (e: ActivityNotFoundException) {
                Log.e("WebChromeClient", "无法打开文件选择器", e)
                activity.fileUploadCallback?.onReceiveValue(null)
                activity.fileUploadCallback = null
                return false
            }
        }
    }
}

前端 H5 测试代码

<!DOCTYPE html>
<html>
<body>
    <h1>WebView 定位测试</h1>
    <p id="location"></p>

    <script>
        function getLocation() {
            if (navigator.geolocation) {
                navigator.geolocation.getCurrentPosition(
                    (position) => {
                        const lat = position.coords.latitude;
                        const lng = position.coords.longitude;
                        document.getElementById("location").innerText =
                            `纬度: ${lat}\n经度: ${lng}`;
                    },
                    (error) => {
                        document.getElementById("location").innerText =
                            `错误: ${error.message}`;
                    }
                );
            } else {
                document.getElementById("location").innerText = "浏览器不支持定位";
            }
        }
        // 页面加载后获取定位
        window.onload = getLocation;
    </script>
</body>
</html>

常见问题与注意事项

  1. Android 7.0+ 安全限制

    <ul>
    <li>定位只在 <strong>HTTPS</strong> 页面有效(<code>onGeolocationPermissionsShowPrompt</code> 仅对 HTTPS 调用)</li>
    <li>测试可用 <code>android:usesCleartextTraffic=&quot;true&quot;</code>(仅开发环境)</li>
    </ul>
    </li>
    <li>
    <p><strong>权限回调不触发</strong></p>
    
    <ul>
    <li>必须同时配置:<code>setGeolocationEnabled(true)</code> + <code>WebChromeClient</code></li>
    <li>必须先拿到<strong>系统定位权限</strong>,Web 层权限才会生效</li>
    </ul>
    </li>
    <li>
    <p><strong>定位不准 / 失败</strong></p>
    
    <ul>
    <li>开启 <code>ACCESS_FINE_LOCATION</code>(GPS)</li>
    <li>确保设备已开启「定位服务」</li>
    <li>室外 / 开阔环境测试</li>
    </ul>
    </li>
    <li>
    <p><strong>Android 12+ 后台定位</strong></p>
    
    <ul>
    <li>如需后台定位,额外申请 <code>ACCESS_BACKGROUND_LOCATION</code></li>
    <li>需在系统设置中手动授权「始终允许」</li>
    </ul>
    </li>
    

Qt 信号与槽对象通信的核心机制(十)

适合人群: 已掌握 QML 基础,想理解 Qt 对象系统通信原理的开发者

前言

按钮点击触发动作、输入框内容变化更新界面、后台数据完成加载通知 UI 刷新——这些"某件事发生时,另一件事跟着响应"的场景,在 Qt 中统一由信号与槽机制处理。

信号与槽不只是 QML 的概念,它是整个 Qt 框架的核心通信机制,C++ 和 QML 都建立在它之上。理解它,才能真正读懂 Qt 的运作方式。


一、为什么需要信号与槽?

传统的 UI 编程用回调函数(callback)处理事件:

// 传统回调方式(伪代码)
button->setOnClickCallback([](void* data) {
    doSomething(data);
});

回调函数的问题:

  • 发送方必须知道接收方的具体函数指针
  • 类型不安全,容易传错参数
  • 一对多通知非常繁琐
  • 对象销毁后回调仍可能被调用,导致崩溃

Qt 的信号与槽解决了这些问题:

// Qt 信号槽方式
connect(button, &QPushButton::clicked, this, &MyClass::onButtonClicked);
  • 发送方只需发出信号,不关心谁在监听
  • 编译时类型检查,参数类型不匹配直接报错
  • 一个信号可以连接多个槽
  • 对象销毁时连接自动断开,不会产生悬空指针

二、Qt 对象系统:MOC 的作用

信号与槽是 C++ 语言本身不支持的特性,Qt 通过 元对象编译器(MOC,Meta-Object Compiler) 来实现它。

2.1 MOC 的工作流程

你写的 .h 文件(含 Q_OBJECT 宏)
        ↓  MOC 处理
moc_xxx.cpp(自动生成的元对象代码)
        ↓  普通 C++ 编译器
最终可执行文件

MOC 扫描带有 Q_OBJECT 宏的类,自动生成支持信号槽、属性系统、运行时类型信息所需的代码。

2.2 Q_OBJECT 宏

每个需要使用信号槽的 C++ 类都必须:

  1. 继承自 QObject(直接或间接)
  2. 在类声明的第一行加上 Q_OBJECT
#include <QObject>

class Counter : public QObject
{
    Q_OBJECT    // 必须放在 private 区域第一行

public:
    explicit Counter(QObject *parent = nullptr);
    int value() const { return m_value; }

public slots:
    void setValue(int value);
    void increment() { setValue(m_value + 1); }

signals:
    void valueChanged(int newValue);    // 只声明,不实现

private:
    int m_value = 0;
};

signals 块中只写声明,不需要实现——MOC 自动生成信号的发射代码。


三、C++ 中的信号与槽

3.1 定义信号和槽

// counter.h
#pragma once
#include <QObject>

class Counter : public QObject
{
    Q_OBJECT

public:
    explicit Counter(QObject *parent = nullptr);
    int value() const { return m_value; }

public slots:
    // 槽函数:普通成员函数,加上 slots 关键字
    void setValue(int value) {
        if (value == m_value) return;    // 防止循环触发
        m_value = value;
        emit valueChanged(m_value);     // 发射信号
    }

signals:
    // 信号:只声明,参数就是传递的数据
    void valueChanged(int newValue);

private:
    int m_value = 0;
};

3.2 连接信号与槽

QObject::connect() 是建立连接的核心函数:

// main.cpp
#include "counter.h"
#include <QDebug>

int main()
{
    Counter a, b;

    // 函数指针语法(Qt 5+ 推荐,编译时类型检查)
    QObject::connect(&a, &Counter::valueChanged,
                     &b, &Counter::setValue);

    // 当 a 的值改变时,b 自动同步
    a.setValue(10);
    qDebug() << b.value();    // 输出:10

    return 0;
}

3.3 连接到 Lambda 函数

槽不一定是成员函数,可以直接连接到 Lambda:

Counter counter;

QObject::connect(&counter, &Counter::valueChanged,
                 [](int value) {
                     qDebug() << "值变为:" << value;
                 });

counter.setValue(42);    // 输出:值变为:42

3.4 一信号多槽

一个信号可以连接到多个槽,发射时所有槽按连接顺序依次调用:

Counter counter;
QLabel *label1 = new QLabel;
QLabel *label2 = new QLabel;

// 同一信号连接两个槽
QObject::connect(&counter, &Counter::valueChanged,
                 label1, [label1](int v) { label1->setText(QString::number(v)); });

QObject::connect(&counter, &Counter::valueChanged,
                 label2, [label2](int v) { label2->setText("值:" + QString::number(v)); });

counter.setValue(99);    // label1 和 label2 都会更新

3.5 断开连接

// 断开特定连接
QObject::disconnect(&a, &Counter::valueChanged,
                    &b, &Counter::setValue);

// 断开某对象的所有连接
QObject::disconnect(&a, nullptr, nullptr, nullptr);

四、Qt 内存管理:对象树

Qt 通过父子对象树管理内存,这与信号槽系统密切相关。

4.1 父子关系

QWidget *window = new QWidget();            // 根对象,没有父级
QPushButton *btn = new QPushButton(window); // 父级是 window
QLabel *label = new QLabel(window);         // 父级是 window

规则:父对象销毁时,所有子对象自动销毁。

{
    QWidget *window = new QWidget();
    QPushButton *btn = new QPushButton(window);  // btn 的父是 window
    // ...
    delete window;    // btn 也被自动删除,不会内存泄漏
}

4.2 对象树与信号槽的协作

当一个 QObject 被销毁时,Qt 自动:

  1. 发射 destroyed() 信号
  2. 断开所有与该对象相关的信号槽连接

这保证了不会出现"槽函数引用了已销毁对象"的崩溃问题:

Counter *counter = new Counter();
QLabel  *label   = new QLabel();

QObject::connect(counter, &Counter::valueChanged,
                 label, [label](int v) {
                     label->setText(QString::number(v));
                 });

delete label;       // label 销毁,连接自动断开
counter->setValue(5); // 安全,不会崩溃

4.3 在 QML 中的对象生命周期

QML 对象的父子关系由可视层级决定:

Rectangle {                // 父对象
    id: container

    Rectangle {            // 子对象,container 销毁时一并销毁
        id: child
    }

    Component.onCompleted: {
        console.log("container 加载完成")
    }

    Component.onDestruction: {
        console.log("container 即将销毁")
    }
}

五、QML 中的信号与槽

5.1 QML 内置信号

Qt Quick 的每个属性变化都自动生成对应的信号,命名规则是 属性名 + Changed

Rectangle {
    id: box
    width: 200

    // 监听 width 变化
    onWidthChanged: console.log("宽度变为:" + width)

    // 监听 visible 变化
    onVisibleChanged: console.log("可见性:" + visible)
}

5.2 自定义信号

Rectangle {
    id: card
    width: 200; height: 100

    // 声明自定义信号(可以带参数)
    signal clicked()
    signal dataChanged(string key, var value)

    MouseArea {
        anchors.fill: parent
        onClicked: {
            card.clicked()                          // 发射无参信号
            card.dataChanged("status", "active")    // 发射带参信号
        }
    }
}

在父对象中响应:

Card {
    onClicked: console.log("卡片被点击")
    onDataChanged: function(key, value) {
        console.log(key + " = " + value)
    }
}

5.3 Connections 元素

当需要在对象外部监听信号,或需要动态管理连接时,使用 Connections

import QtQuick

Rectangle {
    id: sender
    signal messageSent(string text)
}

// 在另一个地方监听 sender 的信号
Connections {
    target: sender    // 监听的目标对象

    function onMessageSent(text) {
        console.log("收到消息:" + text)
    }
}

Connections 的实际应用——监听全局单例的信号:

// AppState.qml(单例)
pragma Singleton
import QtQuick

QtObject {
    signal userLoggedIn(string userName)
    signal userLoggedOut()
}
// 在任意组件中监听
Connections {
    target: AppState

    function onUserLoggedIn(userName) {
        welcomeText.text = "欢迎," + userName
    }

    function onUserLoggedOut() {
        welcomeText.text = "请登录"
    }
}

5.4 connect() 方法

QML 中也可以用 JavaScript 风格的 connect() 动态建立连接:

Rectangle {
    id: buttonA
    signal tapped()
}

Rectangle {
    id: buttonB
    signal tapped()

    function onAnyTapped() {
        console.log("有按钮被点击了")
    }

    Component.onCompleted: {
        // 动态连接两个信号到同一个函数
        buttonA.tapped.connect(onAnyTapped)
        buttonB.tapped.connect(onAnyTapped)
    }
}

断开连接:

buttonA.tapped.disconnect(onAnyTapped)

六、C++ 信号与 QML 槽的跨语言连接

Qt 最强大的能力之一是 C++ 后端与 QML 前端之间的信号槽连接。

6.1 C++ 信号 → QML 响应

定义 C++ 类(后端):

// backend.h
#pragma once
#include <QObject>
#include <QString>

class Backend : public QObject
{
    Q_OBJECT

public:
    explicit Backend(QObject *parent = nullptr);

public slots:
    void fetchData();    // QML 可以调用这个函数

signals:
    void dataReady(const QString &data);      // 数据准备好时发射
    void errorOccurred(const QString &msg);   // 出错时发射
};
// backend.cpp
#include "backend.h"
#include <QTimer>

void Backend::fetchData()
{
    // 模拟异步操作:500ms 后返回数据
    QTimer::singleShot(500, this, [this]() {
        emit dataReady("从服务器获取的数据内容");
    });
}

main.cpp 中暴露给 QML:

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include "backend.h"

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);
    QQmlApplicationEngine engine;

    Backend backend;
    // 将 C++ 对象注册为 QML 上下文属性
    engine.rootContext()->setContextProperty("backend", &backend);

    engine.load(QUrl("qrc:/Main.qml"));
    return app.exec();
}

在 QML 中响应 C++ 信号:

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 360; height: 300
    visible: true

    // 监听 C++ backend 的信号
    Connections {
        target: backend

        function onDataReady(data) {
            resultText.text = data
            loadingIndicator.visible = false
        }

        function onErrorOccurred(msg) {
            resultText.text = "错误:" + msg
            resultText.color = "red"
        }
    }

    Column {
        anchors.centerIn: parent
        spacing: 16
        width: 280

        BusyIndicator {
            id: loadingIndicator
            anchors.horizontalCenter: parent.horizontalCenter
            visible: false
        }

        Text {
            id: resultText
            width: parent.width
            text: "点击按钮获取数据"
            wrapMode: Text.Wrap
            horizontalAlignment: Text.AlignHCenter
            font.pixelSize: 15
        }

        Button {
            anchors.horizontalCenter: parent.horizontalCenter
            text: "获取数据"
            onClicked: {
                loadingIndicator.visible = true
                resultText.text = "加载中..."
                backend.fetchData()    // 调用 C++ 函数
            }
        }
    }
}

七、综合示例:计时器应用

用信号槽实现一个完整的秒表,涵盖 QML 自定义信号、Connections、状态管理:

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    id: window
    width: 360; height: 500
    visible: true
    title: "秒表"

    // 自定义计时器组件(内联)
    component Stopwatch: QtObject {
        id: sw

        property int elapsed: 0          // 已过毫秒数
        property bool running: false

        signal started()
        signal stopped(int totalMs)
        signal reset()

        function start() {
            running = true
            timer.start()
            started()
        }

        function stop() {
            running = false
            timer.stop()
            stopped(elapsed)
        }

        function doReset() {
            running = false
            timer.stop()
            elapsed = 0
            reset()
        }

        Timer {
            id: timer
            interval: 10        // 每 10ms 触发一次
            repeat: true
            onTriggered: sw.elapsed += 10
        }
    }

    Stopwatch {
        id: stopwatch

        onStarted: statusText.text = "计时中..."
        onStopped: function(totalMs) {
            statusText.text = "已停止,共 " + (totalMs / 1000).toFixed(2) + " 秒"
        }
        onReset: statusText.text = "已重置"
    }

    // 格式化显示
    function formatTime(ms) {
        var minutes = Math.floor(ms / 60000)
        var seconds = Math.floor((ms % 60000) / 1000)
        var millis  = Math.floor((ms % 1000) / 10)
        return pad(minutes) + ":" + pad(seconds) + "." + pad(millis)
    }

    function pad(n) {
        return n < 10 ? "0" + n : "" + n
    }

    Column {
        anchors.centerIn: parent
        spacing: 24
        width: 280

        // 时间显示
        Rectangle {
            width: parent.width
            height: 120
            radius: 16
            color: stopwatch.running ? "#1A2332" : "#f5f5f5"

            Behavior on color {
                ColorAnimation { duration: 300 }
            }

            Text {
                anchors.centerIn: parent
                // 绑定到 elapsed,自动实时更新
                text: formatTime(stopwatch.elapsed)
                font.pixelSize: 42
                font.family: "monospace"
                color: stopwatch.running ? "white" : "#333"
                font.bold: true

                Behavior on color {
                    ColorAnimation { duration: 300 }
                }
            }
        }

        // 状态文字
        Text {
            id: statusText
            anchors.horizontalCenter: parent.horizontalCenter
            text: "准备就绪"
            font.pixelSize: 14
            color: "#888"
        }

        // 控制按钮
        RowLayout {
            width: parent.width
            spacing: 12

            Button {
                Layout.fillWidth: true
                text: stopwatch.running ? "暂停" : "开始"
                highlighted: !stopwatch.running
                onClicked: stopwatch.running ? stopwatch.stop() : stopwatch.start()
            }

            Button {
                Layout.fillWidth: true
                text: "重置"
                enabled: stopwatch.elapsed > 0
                onClicked: stopwatch.doReset()
            }
        }
    }
}

八、常见问题

Q:信号与 JavaScript 函数调用有什么区别?

直接调用函数是同步的、紧耦合的——调用方必须知道被调用方的存在。信号是松耦合的——发射方不知道也不关心谁在监听,可以没有监听者,也可以有多个监听者。

Q:emit 关键字是必须的吗?

在 C++ 中,emit 只是一个空宏(展开为空),语义上是可选的,直接调用信号函数也能发射。但强烈建议保留 emit,它让代码读者一眼看出这里在发射信号,而不是调用普通函数。

Q:槽函数可以有返回值吗?

槽函数本身可以有返回值,但通过 connect() 触发的槽,返回值会被忽略。如果需要获取返回值,应该直接调用函数而不是通过信号槽。

Q:信号可以连接到另一个信号吗?

可以,信号可以直接连接到另一个信号,形成信号链:

connect(sender, &Sender::signal1, receiver, &Receiver::signal2);
// sender 发射 signal1 时,receiver 自动发射 signal2

总结

概念 要点
Q_OBJECT 启用元对象系统,必须继承 QObject 并添加此宏
signals 声明信号,只写声明不写实现,MOC 自动生成
slots 声明槽函数,本质是普通成员函数
emit 发射信号,触发所有连接的槽
connect() 建立信号槽连接,支持函数指针和 Lambda
disconnect() 断开连接
对象树 父对象销毁时子对象自动销毁,连接自动断开
QML signal 声明自定义信号,on + 信号名 处理
Connections 在对象外部监听信号,支持动态目标
跨语言连接 C++ 信号可在 QML 中用 Connections 响应

参考资料:Qt Academy — Making Connections · Qt 信号槽文档

Qt Quick 布局Positioners、Anchors 与 Layouts(九)

适合人群: 已掌握 Qt Quick Controls 基础,想让 UI 自适应不同屏幕尺寸的开发者


前言

写出来的界面能跑,但一调整窗口大小就乱掉——这是 Qt Quick 新手最常遇到的问题。根本原因是没有掌握布局系统。

Qt Quick 提供了三套定位与布局机制,各有适用场景:

机制 模块 特点
anchors QtQuick 内置 基于边对齐,灵活但不自动分配尺寸
Positioners(RowColumnGridFlow QtQuick 内置 自动排列,固定间距,不管理尺寸
Layouts(RowLayoutColumnLayoutGridLayout QtQuick.Layouts 自动排列 + 管理尺寸,响应式首选

本文按从简到难的顺序,把三套机制都讲透。


一、anchors 深入

上一篇简单介绍过 anchors,这里补全所有细节。

1.1 锚点属性完整列表

Item {
    anchors.left:              // 左边
    anchors.right:             // 右边
    anchors.top:               // 上边
    anchors.bottom:            // 下边
    anchors.horizontalCenter:  // 水平中线
    anchors.verticalCenter:    // 垂直中线
    anchors.baseline:          // 文字基线(Text 元素专用)

    anchors.fill:              // 填满某个元素(等同于同时设置四边)
    anchors.centerIn:          // 居中于某个元素

    // 边距
    anchors.margins:           // 四边统一边距
    anchors.leftMargin:        // 单独左边距
    anchors.rightMargin:
    anchors.topMargin:
    anchors.bottomMargin:
}

1.2 常用布局模式

顶部导航栏 + 剩余内容区:

Rectangle {
    id: navbar
    height: 56
    anchors {
        top: parent.top
        left: parent.left
        right: parent.right
    }
    color: "#4A90E2"
}

Rectangle {
    anchors {
        top: navbar.bottom
        left: parent.left
        right: parent.right
        bottom: parent.bottom
    }
    color: "#f5f5f5"
}

左侧边栏 + 右侧内容区:

Rectangle {
    id: sidebar
    width: 200
    anchors {
        top: parent.top
        left: parent.left
        bottom: parent.bottom
    }
    color: "#2C3E50"
}

Rectangle {
    anchors {
        top: parent.top
        left: sidebar.right
        right: parent.right
        bottom: parent.bottom
    }
    color: "#ECF0F1"
}

1.3 anchors 的限制

  • 不能自动分配剩余空间:无法让两个元素平分父容器宽度
  • 不能跨层级锚定:只能锚定父元素或同级兄弟元素
  • 与 Layouts 冲突:放在 Layout 内的子元素不要使用 anchors

遇到这些情况,改用 Layouts。


二、Positioners:快速排列元素

Positioners 适合子元素尺寸固定、只需要自动排列间距的场景。

2.1 Row — 水平排列

import QtQuick

Row {
    spacing: 12

    Rectangle { width: 80; height: 80; color: "#4A90E2"; radius: 8 }
    Rectangle { width: 80; height: 80; color: "#E2934A"; radius: 8 }
    Rectangle { width: 80; height: 80; color: "#4AE29A"; radius: 8 }
}

RTL(从右到左)语言支持:

Row {
    spacing: 8
    layoutDirection: Qt.RightToLeft
}

2.2 Column — 垂直排列

Column {
    spacing: 8
    width: 280

    TextField { width: parent.width; placeholderText: "用户名" }
    TextField { width: parent.width; placeholderText: "密码"; echoMode: TextInput.Password }
    Button    { width: parent.width; text: "登录" }
}

2.3 Grid — 网格排列

Grid {
    columns: 3
    spacing: 10

    Repeater {
        model: 9
        delegate: Rectangle {
            width: 80; height: 80
            color: Qt.rgba(0.2 + index * 0.08, 0.4, 0.8, 1)
            radius: 8

            Text {
                anchors.centerIn: parent
                text: index + 1
                color: "white"
                font.bold: true
                font.pixelSize: 18
            }
        }
    }
}

2.4 Flow — 流式排列(自动换行)

Flow 是最灵活的 Positioner,子元素从左到右排列,空间不足时自动换行:

Flow {
    width: 360
    spacing: 8

    Repeater {
        model: ["Qt Quick", "QML", "跨平台", "嵌入式", "移动端",
                "桌面开发", "C++", "JavaScript", "动画", "UI设计"]
        delegate: Rectangle {
            width: tagText.width + 20
            height: 28
            radius: 14
            color: "#E6F1FB"
            border.width: 1
            border.color: "#B5D4F4"

            Text {
                id: tagText
                anchors.centerIn: parent
                text: modelData
                color: "#185FA5"
                font.pixelSize: 13
            }
        }
    }
}

2.5 Positioners 的局限

Positioners 不管理子元素的尺寸——子元素需要自己设置 widthheight,Positioners 只负责摆放位置。想要自动分配尺寸,用 Layouts。


三、Layouts:响应式布局的核心

import QtQuick.Layouts

3.1 RowLayout

RowLayout {
    width: 400
    height: 48
    spacing: 8

    Button {
        text: "取消"
        Layout.preferredWidth: 80
    }

    Item {
        Layout.fillWidth: true    // 弹性空间,把后面的按钮推到右边
    }

    Button {
        text: "保存草稿"
        Layout.preferredWidth: 100
    }

    Button {
        text: "发布"
        highlighted: true
        Layout.preferredWidth: 80
    }
}

3.2 ColumnLayout

ColumnLayout {
    anchors.fill: parent
    anchors.margins: 20
    spacing: 12

    Label { text: "文章标题"; font.bold: true }

    TextField {
        Layout.fillWidth: true
        placeholderText: "请输入标题"
    }

    Label { text: "正文内容" }

    ScrollView {
        Layout.fillWidth: true
        Layout.fillHeight: true    // 填满剩余高度
        TextArea {
            placeholderText: "在此输入正文..."
            wrapMode: TextArea.Wrap
        }
    }

    RowLayout {
        Layout.fillWidth: true
        spacing: 8
        Button { text: "取消"; Layout.fillWidth: true }
        Button { text: "发布"; highlighted: true; Layout.fillWidth: true }
    }
}

3.3 GridLayout

GridLayout {
    width: 360
    columns: 2
    columnSpacing: 16
    rowSpacing: 12

    Label { text: "姓名" }
    TextField { Layout.fillWidth: true; placeholderText: "请输入" }

    Label { text: "邮箱" }
    TextField { Layout.fillWidth: true; placeholderText: "请输入" }

    Label { text: "手机" }
    TextField { Layout.fillWidth: true; placeholderText: "请输入" }

    Button {
        Layout.columnSpan: 2
        Layout.fillWidth: true
        text: "提交"
        highlighted: true
    }
}

3.4 Layout 附加属性完整参考

属性 说明
Layout.fillWidth: true 填满布局剩余宽度
Layout.fillHeight: true 填满布局剩余高度
Layout.preferredWidth: 120 期望宽度
Layout.preferredHeight: 44 期望高度
Layout.minimumWidth: 80 最小宽度,不会被压缩到此以下
Layout.maximumWidth: 200 最大宽度,不会被拉伸到此以上
Layout.columnSpan: 2 跨列数(GridLayout 专用)
Layout.rowSpan: 2 跨行数(GridLayout 专用)
Layout.alignment: Qt.AlignRight 格子内的对齐方式
Layout.margins: 8 外边距

四、响应式布局:适配不同屏幕

4.1 用属性绑定实现断点

ApplicationWindow {
    id: window
    width: 800; height: 500
    visible: true

    // 定义断点属性
    readonly property bool isNarrow: width < 480
    readonly property bool isMedium: width >= 480 && width < 800
    readonly property bool isWide:   width >= 800

    GridLayout {
        anchors.fill: parent
        anchors.margins: 16
        columns: isNarrow ? 1 : isMedium ? 2 : 3    // 自动切换列数
        columnSpacing: 12
        rowSpacing: 12

        Repeater {
            model: 6
            delegate: Rectangle {
                Layout.fillWidth: true
                height: 100
                radius: 8
                color: "#4A90E2"
                opacity: 0.6 + index * 0.07

                Text {
                    anchors.centerIn: parent
                    text: "模块 " + (index + 1)
                    color: "white"
                    font.pixelSize: 16
                    font.bold: true
                }
            }
        }
    }
}

拖动窗口调整宽度,网格列数在 1、2、3 之间自动切换。

4.2 侧边栏折叠效果

ApplicationWindow {
    id: window
    width: 900; height: 600
    visible: true

    RowLayout {
        anchors.fill: parent
        spacing: 0

        // 侧边栏:窗口窄时自动折叠
        Rectangle {
            Layout.preferredWidth: window.width < 600 ? 56 : 200
            Layout.fillHeight: true
            color: "#1A2332"

            Behavior on Layout.preferredWidth {
                NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
            }

            Text {
                anchors.centerIn: parent
                text: window.width < 600 ? "≡" : "侧边栏"
                color: "white"
                font.pixelSize: window.width < 600 ? 22 : 16
            }
        }

        // 主内容区
        Rectangle {
            Layout.fillWidth: true
            Layout.fillHeight: true
            color: "#F5F7FA"

            Text {
                anchors.centerIn: parent
                text: "主内容区\n当前宽度:" + Math.round(parent.width)
                color: "#666"
                font.pixelSize: 14
                horizontalAlignment: Text.AlignHCenter
            }
        }
    }
}

4.3 Flow 卡片自动换行

ScrollView {
    anchors.fill: parent

    Flow {
        width: parent.width
        padding: 16
        spacing: 12

        Repeater {
            model: 12
            delegate: Rectangle {
                width: 160; height: 120
                radius: 10
                color: "#ffffff"
                border.width: 1
                border.color: "#e8e8e8"

                Column {
                    anchors.centerIn: parent
                    spacing: 8

                    Rectangle {
                        width: 36; height: 36; radius: 18
                        color: "#4A90E2"
                        anchors.horizontalCenter: parent.horizontalCenter
                    }

                    Text {
                        text: "功能 " + (index + 1)
                        font.pixelSize: 13
                        font.bold: true
                        color: "#333"
                        anchors.horizontalCenter: parent.horizontalCenter
                    }
                }
            }
        }
    }
}

五、综合示例:响应式仪表盘框架

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

ApplicationWindow {
    id: window
    width: 900; height: 600
    minimumWidth: 360
    visible: true
    title: "响应式仪表盘"

    readonly property bool compact: width < 600

    RowLayout {
        anchors.fill: parent
        spacing: 0

        // 侧边栏
        Rectangle {
            Layout.preferredWidth: compact ? 56 : 200
            Layout.fillHeight: true
            color: "#1A2332"

            Behavior on Layout.preferredWidth {
                NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
            }

            Column {
                anchors.top: parent.top
                anchors.topMargin: 16
                width: parent.width
                spacing: 2

                // Logo
                Rectangle {
                    width: parent.width; height: 48
                    color: "transparent"
                    Text {
                        anchors.centerIn: parent
                        text: compact ? "D" : "Dashboard"
                        color: "white"
                        font.pixelSize: compact ? 18 : 16
                        font.bold: true
                    }
                }

                Rectangle {
                    width: parent.width; height: 1
                    color: "#ffffff20"
                }

                // 菜单
                Repeater {
                    model: ["概览", "数据", "报告", "设置"]
                    delegate: Rectangle {
                        width: parent.width; height: 44
                        color: index === 0 ? "#ffffff18" : "transparent"

                        Text {
                            anchors.centerIn: parent
                            text: compact ? modelData[0] : modelData
                            color: index === 0 ? "white" : "#ffffff70"
                            font.pixelSize: 14
                        }
                    }
                }
            }
        }

        // 主内容区
        ColumnLayout {
            Layout.fillWidth: true
            Layout.fillHeight: true
            spacing: 0

            // 顶栏
            Rectangle {
                Layout.fillWidth: true
                height: 56
                color: "white"

                RowLayout {
                    anchors.fill: parent
                    anchors.leftMargin: 20
                    anchors.rightMargin: 20

                    Label {
                        text: "概览"
                        font.pixelSize: 18
                        font.bold: true
                    }

                    Item { Layout.fillWidth: true }

                    Label {
                        text: Qt.formatDate(new Date(), "yyyy-MM-dd")
                        color: "#999"
                        font.pixelSize: 13
                    }
                }
            }

            // 内容滚动区
            ScrollView {
                Layout.fillWidth: true
                Layout.fillHeight: true
                contentWidth: availableWidth

                ColumnLayout {
                    width: parent.width
                    spacing: 16

                    Item { height: 4 }

                    // 统计卡片
                    GridLayout {
                        Layout.fillWidth: true
                        Layout.leftMargin: 16
                        Layout.rightMargin: 16
                        columns: compact ? 2 : 4
                        columnSpacing: 12
                        rowSpacing: 12

                        Repeater {
                            model: [
                                { label: "用户总数", value: "12,480", accent: "#4A90E2" },
                                { label: "今日活跃", value: "3,291",  accent: "#1D9E75" },
                                { label: "新增订单", value: "847",    accent: "#E2934A" },
                                { label: "总收入",   value: "¥52K",   accent: "#9B59B6" }
                            ]
                            delegate: Rectangle {
                                Layout.fillWidth: true
                                height: 88
                                radius: 10
                                color: "white"
                                border.width: 1
                                border.color: "#f0f0f0"

                                Rectangle {
                                    width: 4; height: 36; radius: 2
                                    color: modelData.accent
                                    anchors {
                                        left: parent.left; leftMargin: 14
                                        verticalCenter: parent.verticalCenter
                                    }
                                }

                                Column {
                                    anchors {
                                        left: parent.left; leftMargin: 28
                                        verticalCenter: parent.verticalCenter
                                    }
                                    spacing: 4
                                    Text {
                                        text: modelData.label
                                        font.pixelSize: 12; color: "#999"
                                    }
                                    Text {
                                        text: modelData.value
                                        font.pixelSize: 20; font.bold: true; color: "#222"
                                    }
                                }
                            }
                        }
                    }

                    // 图表占位(窄屏隐藏右侧饼图)
                    RowLayout {
                        Layout.fillWidth: true
                        Layout.leftMargin: 16
                        Layout.rightMargin: 16
                        spacing: 12

                        Rectangle {
                            Layout.fillWidth: true
                            height: 180; radius: 10
                            color: "white"
                            border.width: 1; border.color: "#f0f0f0"
                            Text {
                                anchors.centerIn: parent
                                text: "折线图区域"
                                color: "#ccc"; font.pixelSize: 14
                            }
                        }

                        Rectangle {
                            visible: !compact
                            Layout.preferredWidth: 200
                            height: 180; radius: 10
                            color: "white"
                            border.width: 1; border.color: "#f0f0f0"
                            Text {
                                anchors.centerIn: parent
                                text: "饼图区域"
                                color: "#ccc"; font.pixelSize: 14
                            }
                        }
                    }

                    Item { height: 16 }
                }
            }
        }
    }
}

六、三套机制选型指南

image.png

实际项目常见组合:

  • 页面整体框架用 ColumnLayout(顶栏 + 内容区)
  • 卡片网格用 GridLayout + 断点属性(自动切换列数)
  • 标签、按钮组用 Flow(自动换行)
  • 卡片内部元素用 anchors 精确定位
  • 工具栏按钮用 RowLayout + Item { Layout.fillWidth: true } 推到右边

总结

机制 管理尺寸 自动换行 典型场景
anchors 精确定位、顶底栏、填满父容器
Row / Column 固定尺寸元素的快速排列
Grid 固定尺寸元素的网格排列
Flow 标签云、卡片流式布局
RowLayout 工具栏、按钮组、水平自适应
ColumnLayout 表单、页面主框架、垂直自适应
GridLayout 表单标签对、响应式卡片网格

参考资料:Qt Academy — Positioners and Layouts · Qt Quick Layouts 文档

❌