前言
在移动端开发中,视口高度一直是一个令人头疼的问题。尤其是在 iOS Safari 浏览器中,还有三星手机的导航遮挡,当虚拟键盘弹出时,视口高度的变化会导致固定定位元素错位、全屏布局异常等问题。本文将深入分析这个问题的本质,并提供一个完整的解决方案。
🎯 问题的本质
移动端视口的复杂性
在桌面端,100vh
通常能够准确表示视口高度,但在移动端情况就复杂得多:
- 动态工具栏:移动浏览器的地址栏和工具栏会动态隐藏/显示
- 虚拟键盘:输入框聚焦时,虚拟键盘会改变可视区域
- 浏览器差异:不同浏览器对视口的处理策略不同
具体表现
css
/* 这样的代码在移动端可能出现问题 */
.fullscreen-modal {
height: 100vh; /* 可能包含被键盘遮挡的部分 */
position: fixed;
bottom: 0;
}
当键盘弹出时:
- iOS Safari :
100vh
不会改变,但实际可视区域变小 - Android Chrome :
100vh
会动态调整 但三星有独特的导航烂 - 微信浏览器:行为介于两者之间
🔍 Visual Viewport API 详解
API 介绍
Visual Viewport API 是现代浏览器提供的解决方案,它能准确获取当前可视区域的尺寸:
javascript
// 获取可视视口信息
const viewport = window.visualViewport;
console.log(viewport.height); // 实际可视高度
console.log(viewport.width); // 实际可视宽度
console.log(viewport.scale); // 缩放比例
兼容性检查
javascript
const supportsVisualViewport = () => {
return typeof window !== 'undefined' &&
window.visualViewport !== undefined;
};
🛠️ Hook 实现深度解析
完整源码
typescript
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 resize
window.addEventListener('resize', updateHeight);
window.addEventListener('orientationchange', updateHeight);
return () => {
window.removeEventListener('resize', updateHeight);
window.removeEventListener('orientationchange', updateHeight);
};
}, []);
return viewportHeight;
};
关键实现细节
1. 初始化策略
typescript
const [viewportHeight, setViewportHeight] = useState<ViewportHeight>(() => {
// SSR 兼容性检查
if (typeof window === 'undefined') {
return { height: 0, isKeyboardOpen: false };
}
// 优先使用 Visual Viewport API
const initialHeight = window.visualViewport?.height || window.innerHeight;
return {
height: initialHeight,
isKeyboardOpen: false,
};
});
设计思路:
- 使用惰性初始化避免 SSR 问题
- 优先级:
visualViewport.height
>window.innerHeight
- 初始状态假设键盘未打开
2. 键盘状态检测算法
typescript
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 变量同步机制
typescript
// 将 JS 计算结果同步到 CSS
document.documentElement.style.setProperty(
'--vh',
`${currentHeight * 0.01}px`
);
优势:
- CSS 和 JS 保持一致
- 支持传统 CSS 布局
- 性能优于频繁的 JavaScript 样式操作
4. 事件监听策略
typescript
// 现代浏览器:精确监听
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateHeight);
} else {
// 降级方案:多事件覆盖
window.addEventListener('resize', updateHeight);
window.addEventListener('orientationchange', updateHeight);
}
分层策略:
- 优先:Visual Viewport API(精确度最高)
- 降级:传统事件组合(覆盖面广)
📱 真实应用场景
场景 1:全屏模态框
jsx
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:底部固定输入框
jsx
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'
}}
>
<input
type="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:表单页面适配
jsx
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. 防抖优化
typescript
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. 自定义配置选项
typescript
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;
// ... 实现代码,根据配置调整行为
};
🧪 测试策略
单元测试
javascript
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; // 高度减少 400px
const 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 变量(推荐)
css
:root {
--vh: 1vh; /* 由 JS 动态更新 */
}
.fullscreen {
height: calc(var(--vh, 1vh) * 100);
}
.half-screen {
height: calc(var(--vh, 1vh) * 50);
}
方案 2:CSS-in-JS
javascript
const useViewportStyles = () => {
const { height } = useViewportHeight();
return useMemo(() => ({
fullscreen: {
height: `${height}px`,
width: '100%'
},
halfScreen: {
height: `${height / 2}px`,
width: '100%'
}
}), [height]);
};
方案 3:Styled Components
javascript
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. 调试技巧
javascript
// 使用vconsole库可以在真机打开控制台
// 全局new一下就行
const vConsole = new VConsole();
结语
移动端视口高度问题是前端开发中的经典难题,通过深入理解问题本质、合理使用现代 API、提供完善的降级方案,我们可以构建出robust的解决方案。
这个 Hook 不仅解决了当前的问题,更重要的是提供了一套完整的思路和方法论。希望这篇文章能帮助大家在移动端开发中游刃有余,创造出更好的用户体验。
记住:好的代码不仅要解决问题,还要考虑性能、兼容性、可维护性和可扩展性。
如果这篇文章对你有帮助,请点赞收藏!如果你有更好的优化建议或遇到问题,欢迎在评论区讨论。
📚 相关资源