前言
在移動端開發中,視口高度一直是一個令人頭疼的問題。尤其是在 iOS Safari 瀏覽器中,還有三星手機的導航遮擋,當虛擬鍵盤彈出時,視口高度的變化會導致固定定位元素錯位、全屏布局異常等問題。本文將深入分析這個問題的本質,并提供一個完整的解決方案。
🎯 問題的本質
移動端視口的復雜性
在桌面端,100vh
通常能夠準確表示視口高度,但在移動端情況就復雜得多:
- 動態工具欄:移動瀏覽器的地址欄和工具欄會動態隱藏/顯示
- 虛擬鍵盤:輸入框聚焦時,虛擬鍵盤會改變可視區域
- 瀏覽器差異:不同瀏覽器對視口的處理策略不同
具體表現
/* 這樣的代碼在移動端可能出現問題 */
.fullscreen-modal {height: 100vh; /* 可能包含被鍵盤遮擋的部分 */position: fixed;bottom: 0;
}
當鍵盤彈出時:
- iOS Safari:
100vh
不會改變,但實際可視區域變小 - Android Chrome:
100vh
會動態調整 但三星有獨特的導航爛 - 微信瀏覽器:行為介于兩者之間
🔍 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);
}
分層策略:
- 優先:Visual Viewport API(精確度最高)
- 降級:傳統事件組合(覆蓋面廣)
📱 真實應用場景
場景 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 文檔