移動端視口終極解決方案:使用 Visual Viewport封裝一個優雅的 React Hook

前言

在移動端開發中,視口高度一直是一個令人頭疼的問題。尤其是在 iOS Safari 瀏覽器中,還有三星手機的導航遮擋,當虛擬鍵盤彈出時,視口高度的變化會導致固定定位元素錯位、全屏布局異常等問題。本文將深入分析這個問題的本質,并提供一個完整的解決方案。

🎯 問題的本質

移動端視口的復雜性

在桌面端,100vh 通常能夠準確表示視口高度,但在移動端情況就復雜得多:

  1. 動態工具欄:移動瀏覽器的地址欄和工具欄會動態隱藏/顯示
  2. 虛擬鍵盤:輸入框聚焦時,虛擬鍵盤會改變可視區域
  3. 瀏覽器差異:不同瀏覽器對視口的處理策略不同

具體表現

/* 這樣的代碼在移動端可能出現問題 */
.fullscreen-modal {height: 100vh; /* 可能包含被鍵盤遮擋的部分 */position: fixed;bottom: 0;
}

當鍵盤彈出時:

  • iOS Safari100vh 不會改變,但實際可視區域變小
  • Android Chrome100vh 會動態調整 但三星有獨特的導航爛
  • 微信瀏覽器:行為介于兩者之間

🔍 Visual Viewport API 詳解

API 介紹

Visual Viewport API 是現代瀏覽器提供的解決方案,它能準確獲取當前可視區域的尺寸:

// 獲取可視視口信息
const viewport = window.visualViewport;
console.log(viewport.height); // 實際可視高度
console.log(viewport.width);  // 實際可視寬度
console.log(viewport.scale);  // 縮放比例

兼容性檢查

const supportsVisualViewport = () => {return typeof window !== 'undefined' && window.visualViewport !== undefined;
};

🛠? Hook 實現深度解析

完整源碼

import { useState, useEffect } from 'react';interface ViewportHeight {height: number;isKeyboardOpen: boolean;
}export const useViewportHeight = (): ViewportHeight => {const [viewportHeight, setViewportHeight] = useState<ViewportHeight>(() => {if (typeof window === 'undefined') {return { height: 0, isKeyboardOpen: false };}const initialHeight = window.visualViewport?.height || window.innerHeight;return {height: initialHeight,isKeyboardOpen: false,};});useEffect(() => {if (typeof window === 'undefined') return;const updateHeight = () => {const currentHeight = window.visualViewport?.height || window.innerHeight;const screenHeight = window.screen.height;// 判斷鍵盤是否打開(高度減少超過 150px 認為是鍵盤)const heightDifference = screenHeight - currentHeight;const isKeyboardOpen = heightDifference > 150;setViewportHeight({height: currentHeight,isKeyboardOpen,});// 同步更新 CSS 自定義屬性document.documentElement.style.setProperty('--vh',`${currentHeight * 0.01}px`);};// 初始化updateHeight();// 監聽 Visual Viewport 變化if (window.visualViewport) {window.visualViewport.addEventListener('resize', updateHeight);return () => {window.visualViewport?.removeEventListener('resize', updateHeight);};}// 降級方案:監聽 window resizewindow.addEventListener('resize', updateHeight);window.addEventListener('orientationchange', updateHeight);return () => {window.removeEventListener('resize', updateHeight);window.removeEventListener('orientationchange', updateHeight);};}, []);return viewportHeight;
};

關鍵實現細節

1. 初始化策略
const [viewportHeight, setViewportHeight] = useState<ViewportHeight>(() => {// SSR 兼容性檢查if (typeof window === 'undefined') {return { height: 0, isKeyboardOpen: false };}// 優先使用 Visual Viewport APIconst initialHeight = window.visualViewport?.height || window.innerHeight;return {height: initialHeight,isKeyboardOpen: false,};
});

設計思路

  • 使用惰性初始化避免 SSR 問題
  • 優先級:visualViewport.height > window.innerHeight
  • 初始狀態假設鍵盤未打開
2. 鍵盤狀態檢測算法
const updateHeight = () => {const currentHeight = window.visualViewport?.height || window.innerHeight;const screenHeight = window.screen.height;// 核心算法:高度差值判斷const heightDifference = screenHeight - currentHeight;const isKeyboardOpen = heightDifference > 150;setViewportHeight({height: currentHeight,isKeyboardOpen,});
};

算法分析

  • screen.height:設備屏幕的物理高度
  • currentHeight:當前可視區域高度
  • 閾值 150px:經過大量測試得出的最佳值
    • 太小:可能誤判工具欄隱藏為鍵盤
    • 太大:可能漏掉小尺寸虛擬鍵盤
3. CSS 變量同步機制
// 將 JS 計算結果同步到 CSS
document.documentElement.style.setProperty('--vh',`${currentHeight * 0.01}px`
);

優勢

  • CSS 和 JS 保持一致
  • 支持傳統 CSS 布局
  • 性能優于頻繁的 JavaScript 樣式操作
4. 事件監聽策略
// 現代瀏覽器:精確監聽
if (window.visualViewport) {window.visualViewport.addEventListener('resize', updateHeight);
} else {// 降級方案:多事件覆蓋window.addEventListener('resize', updateHeight);window.addEventListener('orientationchange', updateHeight);
}

分層策略

  1. 優先:Visual Viewport API(精確度最高)
  2. 降級:傳統事件組合(覆蓋面廣)

📱 真實應用場景

場景 1:全屏模態框

import React from 'react';
import { useViewportHeight } from './hooks/useViewportHeight';const FullScreenModal = ({ isOpen, onClose, children }) => {const { height, isKeyboardOpen } = useViewportHeight();if (!isOpen) return null;return (<div className="modal-overlay"style={{height: `${height}px`,position: 'fixed',top: 0,left: 0,right: 0,background: 'rgba(0,0,0,0.5)',zIndex: 1000}}><div className="modal-content"style={{height: '100%',background: 'white',overflow: 'auto',// 鍵盤打開時調整內邊距paddingBottom: isKeyboardOpen ? '20px' : '40px'}}><button onClick={onClose}>關閉</button>{children}</div></div>);
};

場景 2:底部固定輸入框

const ChatInput = () => {const { height, isKeyboardOpen } = useViewportHeight();const [message, setMessage] = useState('');return (<div className="chat-container"style={{ height: `${height}px` }}><div className="messages"style={{height: isKeyboardOpen ? 'calc(100% - 80px)' : 'calc(100% - 60px)',overflow: 'auto',padding: '20px'}}>{/* 消息列表 */}</div><div className="input-area"style={{position: 'absolute',bottom: 0,left: 0,right: 0,height: isKeyboardOpen ? '80px' : '60px',background: 'white',borderTop: '1px solid #eee',display: 'flex',alignItems: 'center',padding: '0 16px'}}><inputtype="text"value={message}onChange={(e) => setMessage(e.target.value)}placeholder="輸入消息..."style={{flex: 1,border: '1px solid #ddd',borderRadius: '20px',padding: '8px 16px',fontSize: isKeyboardOpen ? '16px' : '14px' // 防止縮放}}/><button style={{marginLeft: '12px',background: '#007AFF',color: 'white',border: 'none',borderRadius: '16px',padding: '8px 16px'}}>發送</button></div></div>);
};

場景 3:表單頁面適配

const FormPage = () => {const { height, isKeyboardOpen } = useViewportHeight();return (<div className="form-page"style={{height: `${height}px`,overflow: 'hidden'}}><header style={{height: '60px',background: '#f8f9fa',borderBottom: '1px solid #dee2e6'}}><h1>用戶信息</h1></header><main style={{height: 'calc(100% - 120px)',overflow: 'auto',padding: '20px',// 鍵盤打開時自動滾動到聚焦元素scrollBehavior: isKeyboardOpen ? 'smooth' : 'auto'}}><form><div className="form-group"><label>姓名</label><input type="text" /></div><div className="form-group"><label>郵箱</label><input type="email" /></div><div className="form-group"><label>手機號</label><input type="tel" /></div>{/* 更多表單項 */}</form></main><footer style={{height: '60px',background: 'white',borderTop: '1px solid #dee2e6',display: 'flex',justifyContent: 'center',alignItems: 'center'}}><button type="submit"style={{background: '#007AFF',color: 'white',border: 'none',borderRadius: '8px',padding: '12px 32px',fontSize: isKeyboardOpen ? '16px' : '14px'}}>提交</button></footer></div>);
};

🚀 進階優化技巧

1. 防抖優化

import { useState, useEffect, useCallback } from 'react';
import { debounce } from 'lodash-es';export const useViewportHeightOptimized = () => {const [viewportHeight, setViewportHeight] = useState<ViewportHeight>(() => ({height: typeof window !== 'undefined' ? (window.visualViewport?.height || window.innerHeight) : 0,isKeyboardOpen: false,}));// 防抖更新函數const debouncedUpdate = useCallback(debounce(() => {const currentHeight = window.visualViewport?.height || window.innerHeight;const screenHeight = window.screen.height;const heightDifference = screenHeight - currentHeight;const isKeyboardOpen = heightDifference > 150;setViewportHeight({height: currentHeight,isKeyboardOpen,});document.documentElement.style.setProperty('--vh',`${currentHeight * 0.01}px`);}, 16), // 約 60fps[]);useEffect(() => {if (typeof window === 'undefined') return;debouncedUpdate();if (window.visualViewport) {window.visualViewport.addEventListener('resize', debouncedUpdate);return () => {window.visualViewport?.removeEventListener('resize', debouncedUpdate);debouncedUpdate.cancel();};}window.addEventListener('resize', debouncedUpdate);window.addEventListener('orientationchange', debouncedUpdate);return () => {window.removeEventListener('resize', debouncedUpdate);window.removeEventListener('orientationchange', debouncedUpdate);debouncedUpdate.cancel();};}, [debouncedUpdate]);return viewportHeight;
};

2. 自定義配置選項

interface UseViewportHeightOptions {keyboardThreshold?: number;debounceMs?: number;enableCSSVar?: boolean;cssVarName?: string;enableMetrics?: boolean;
}export const useViewportHeight = (options: UseViewportHeightOptions = {}) => {const {keyboardThreshold = 150,debounceMs = 0,enableCSSVar = true,cssVarName = '--vh',enableMetrics = false} = options;// ... 實現代碼,根據配置調整行為
};

🧪 測試策略

單元測試

import { renderHook, act } from '@testing-library/react';
import { useViewportHeight } from './useViewportHeight';// Mock Visual Viewport API
const mockVisualViewport = {height: 800,width: 375,addEventListener: jest.fn(),removeEventListener: jest.fn()
};describe('useViewportHeight', () => {beforeEach(() => {Object.defineProperty(window, 'visualViewport', {value: mockVisualViewport,writable: true});Object.defineProperty(window, 'innerHeight', {value: 800,writable: true});Object.defineProperty(window.screen, 'height', {value: 844,writable: true});});it('should return initial viewport height', () => {const { result } = renderHook(() => useViewportHeight());expect(result.current.height).toBe(800);expect(result.current.isKeyboardOpen).toBe(false);});it('should detect keyboard open', () => {const { result } = renderHook(() => useViewportHeight());// 模擬鍵盤打開act(() => {mockVisualViewport.height = 400; // 高度減少 400pxconst resizeEvent = new Event('resize');mockVisualViewport.addEventListener.mock.calls[0][1](resizeEvent);});expect(result.current.height).toBe(400);expect(result.current.isKeyboardOpen).toBe(true);});it('should handle orientation change', () => {const { result } = renderHook(() => useViewportHeight());act(() => {window.innerHeight = 375;window.screen.height = 667;const orientationEvent = new Event('orientationchange');window.dispatchEvent(orientationEvent);});expect(result.current.height).toBe(375);});
});

🎨 CSS 集成方案

方案 1:CSS 變量(推薦)

:root {--vh: 1vh; /* 由 JS 動態更新 */
}.fullscreen {height: calc(var(--vh, 1vh) * 100);
}.half-screen {height: calc(var(--vh, 1vh) * 50);
}

方案 2:CSS-in-JS

const useViewportStyles = () => {const { height } = useViewportHeight();return useMemo(() => ({fullscreen: {height: `${height}px`,width: '100%'},halfScreen: {height: `${height / 2}px`,width: '100%'}}), [height]);
};

方案 3:Styled Components

import styled from 'styled-components';const FullScreenContainer = styled.div`height: ${props => props.viewportHeight}px;width: 100%;position: relative;overflow: hidden;
`;// 使用
const MyComponent = () => {const { height } = useViewportHeight();return (<FullScreenContainer viewportHeight={height}>{/* 內容 */}</FullScreenContainer>);
};

🎯 最佳實踐總結

1. 使用原則

  • 優先使用 Visual Viewport API
  • 提供降級方案 確保兼容性
  • 合理設置閾值 避免誤判
  • 性能優化 使用防抖

2. 調試技巧

// 使用vconsole庫可以在真機打開控制臺
// 全局new一下就行
const vConsole = new VConsole();

結語

移動端視口高度問題是前端開發中的經典難題,通過深入理解問題本質、合理使用現代 API、提供完善的降級方案,我們可以構建出robust的解決方案。

這個 Hook 不僅解決了當前的問題,更重要的是提供了一套完整的思路和方法論。希望這篇文章能幫助大家在移動端開發中游刃有余,創造出更好的用戶體驗。

記住:好的代碼不僅要解決問題,還要考慮性能、兼容性、可維護性和可擴展性。


如果這篇文章對你有幫助,請點贊收藏!如果你有更好的優化建議或遇到問題,歡迎在評論區討論。

📚 相關資源

  • Visual Viewport API MDN 文檔

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/diannao/96361.shtml
繁體地址,請注明出處:http://hk.pswp.cn/diannao/96361.shtml
英文地址,請注明出處:http://en.pswp.cn/diannao/96361.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

react中key的作用

在 React 中&#xff0c;key 是一個特殊的屬性&#xff08;prop&#xff09;&#xff0c;它的主要作用是幫助 React 識別哪些元素發生了變化、被添加或被移除&#xff0c;從而高效地更新和重新渲染列表中的元素。以下是 key 的具體作用和注意事項&#xff1a;1. 高效更新虛擬 D…

Lua學習記錄 - 自定義模塊管理器

為人所知的是lua自帶的require函數加載腳本只會加載一次(就像unity里面的資源管理和AB包管理)&#xff0c;而主播調試習慣是用Odin插件的Button在unity編輯器模式里調試而非進入播放模式后調試&#xff0c;今天主播在做熱更新相關的時候企圖多次調用腳本打印以調試功能&#xf…

MongoDB 分片集群復制數據庫副本

文章目錄一、登錄MongoDB查詢數據庫及集合分片情況二、登錄MongoDB先創建副本數據庫并設置數據庫及集合分片功能三、登錄MongoDB查詢emop_slinkmain數據庫main_repetition集合和四、使用mongodump壓縮備份emop_slinkmain數據庫中的main_repetition集合和shard_repetition 集合五…

SQLite 加密與不加密性能對比與優化實踐

在項目中&#xff0c;為了保證數據安全&#xff0c;我們可能會對 SQLite 數據庫進行加密&#xff08;例如使用 SQLiteMC/SQLCipher&#xff09;。然而&#xff0c;加密數據庫在帶來安全性的同時&#xff0c;也會帶來顯著的性能損耗。本文結合實測與源碼分析&#xff0c;介紹 SQ…

Azure官網為何沒直接體現專業服務

微軟Azure官網沒有直接、醒目地展示其專業服務&#xff08;如遷移、定制化解決方案咨詢等&#xff09;&#xff0c;確實容易讓人疑惑。這背后其實是微軟Azure特定的市場策略和商業模式。下面我為你解釋原因&#xff0c;并告訴你怎么找到這些服務。&#x1f9e9; 核心原因&#…

人體生理參數信號采集項目——心電信號

1.硬件——焊接調試趣事&#xff1a;由于測量手法問題&#xff0c;以及對示波器不太熟悉&#xff0c;差點以為沒信號&#xff0c;都打算重焊一塊板子了&#xff0c;但&#xff0c;實際上&#xff0c;信號輸出是相對完美的&#xff1b;遇到的疑難雜癥&#xff1a;1&#xff09;5…

Go1.25的源碼分析-src/runtime/runtime1.go(GMP)g

1. 主要組成部分 Go語言的GMP調度器基于四個核心數據結構&#xff1a;g、m、p和schedt。 1.1 主要常量解讀 1.1.1G 狀態常量 const (_Gidle iota //剛分配尚未初始化的 G_Grunnable//已在運行隊列上&#xff0c;未執行用戶代碼&#xff1b;棧未被該 G 擁有_Grunning//正在…

使用jwt+redis實現單點登錄

首先理一下登錄流程 前端登錄—>賬號密碼驗證—>成功返回token—>后續請求攜帶token---->用戶異地登錄---->本地用戶token不能用&#xff0c;不能再訪問需要攜帶token的網頁 jwt工具類 package com.nageoffer.shortlink.admin.util;import cn.hutool.core.util.…

Trae配置rules與MCP

這個文章不錯&#xff0c;不過如果只是看&#xff0c;還感受不到作者的震撼&#xff0c;所以我自己實操了一下&#xff0c;深受震動&#xff0c;也希望看到這篇文章的人也自己實操一下。 與Cursor結對編程的四個月&#xff0c;我大徹大悟了&#xff01; 學到了什么 無論是熟悉…

對抗攻擊與防御:如何保護視覺模型安全?

對抗攻擊與防御:如何保護視覺模型安全? 前言 一、對抗攻擊的基本原理 二、對抗攻擊的主要類型 2.1 白盒攻擊 2.2 黑盒攻擊 三、對抗攻擊的常見形式 3.1 定向攻擊 3.2 非定向攻擊 四、對抗防御的核心思路 五、常見的對抗防御方法 5.1 對抗訓練 5.2 輸入預處理 5.3 防御蒸餾 六…

區塊鏈開發:Solidity 智能合約安全審計要點

本文聚焦區塊鏈開發中 Solidity 智能合約的安全審計要點。首先概述智能合約安全審計的重要性&#xff0c;接著詳細介紹常見的安全漏洞&#xff0c;如重入攻擊、整數溢出與下溢等&#xff0c;以及對應的審計方法。還闡述了審計的具體流程&#xff0c;包括自動化工具檢測、手動代…

C++ 新手第一個練手小游戲:井字棋

1. 引言 介于身邊有特別多沒有學習過編程&#xff0c;或者有一定C語言、python或是Java基礎的但是沒有接觸過C的新手朋友&#xff0c;我想可以通過一個很簡單的小項目作為挑戰&#xff0c;幫助大家入門C。 今天&#xff0c;我們將挑戰一個對新手來說稍微復雜一點&#xff0c;…

透射TEM 新手入門:快速掌握核心技能

目錄 簡介? 一、TEM 基本知識 1. 核心原理&#xff08;理解圖像本質&#xff09;? 2. 關鍵結構與成像模式&#xff08;對應圖像類型&#xff09;? 二、TEM 數據處理 1. 預處理&#xff08;通用步驟&#xff09;? 2. 衍射花樣&#xff08;SAED&#xff09;處理&#x…

day075-MySQL數據庫服務安裝部署與基礎服務管理命令

文章目錄0. 老男孩思想-老男孩名言警句1. 數據庫服務安裝部署1.1 下載安裝包1.2 系統環境準備1.2.1 關閉防火墻1.2.2 關閉selinux1.2.3 安裝依賴軟件1.2.4 卸載沖突軟件1.3 安裝程序1.3.1 上傳軟件包1.3.2 配置環境變量1.3.3 創建數據庫存儲數據目錄1.3.4 創建數據庫程序管理用…

Qt二維碼生成器項目開發教程 - 從零開始構建專業級QR碼生成工具

Qt二維碼生成器項目開發教程 - 從零開始構建專業級QR碼生成工具 項目概述 本項目是一個基于Qt框架開發的專業級二維碼生成器&#xff0c;集成了開源的qrencode庫&#xff0c;提供完整的QR碼生成、預覽、保存和分享功能。項目采用C語言開發&#xff0c;使用Qt的信號槽機制實現…

LLaVA-3D,Video-3D LLM,VG-LLM,SPAR論文解讀

目錄 一、LLaVA-3D 1、概述 2、方法 3、訓練過程 4、實驗 二、Video-3D LLM 1、概述 2、方法 3、訓練過程 4、實驗 三、SPAR 1、概述 2、方法 4、實驗 四、VG-LLM 1、概述 2、方法 3、方法 4、實驗 一、LLaVA-3D 1、概述 空間關系不足&#xff1a;傳…

Spring兩個核心IoCDI(二)

DI&#xff08;依賴注入&#xff09;就是從IoC容器中獲取對象并賦值給某個屬性&#xff0c;這就是依賴注入的過程。 關于依賴注入有3種方式&#xff1a; 1、屬性注入 2、構造方法注入 3、setter注入 目錄 1、屬性注入 2、 構造方法注入 3、Setter方法注入 4、3種注入方式優…

廣東省省考備考(第八十三天8.21)——言語、判斷推理(強化訓練)

言語理解與表達 錯題解析 文段開篇介紹足夠的執法權限對于基層治理高效運行的重要性&#xff0c;接著從兩方面進行論證&#xff0c;介紹權限不足和權限過度下放對基層治理的負面影響&#xff0c;最后通過“因此”進行總結&#xff0c;強調一方面要完善執法目錄動態調整機制和制…

字符串與算法題詳解:最長回文子串、IP 地址轉換、字符串排序、蛇形矩陣與字符串加密

字符串與算法題詳解&#xff1a;最長回文子串、IP 地址轉換、字符串排序、蛇形矩陣與字符串加密 前言 在編程題訓練中&#xff0c;字符串相關的題目非常常見。本文將結合幾個典型的例題&#xff0c;詳細解析它們的解題思路和實現方式&#xff0c;幫助初學者循序漸進地掌握常用技…

從協同設計到綠色制造:工業云渲染的價值閉環

在智能制造、建筑工程、能源電力、船舶海工等工業場景中&#xff0c;3D可視化已從傳統的桌面端逐步向Web端遷移&#xff0c;Web 3D憑借其跨平臺、輕量化、實時交互等特性&#xff0c;已成為企業構建數字孿生、實現遠程協作、推動云端交付的重要工具。這場技術變革不僅改變了工業…