移动端视口终极解决方案:使用 Visual Viewport封装一个优雅的 React Hook

前言

在移动端开发中,视口高度一直是一个令人头疼的问题。尤其是在 iOS Safari 浏览器中,还有三星手机的导航遮挡,当虚拟键盘弹出时,视口高度的变化会导致固定定位元素错位、全屏布局异常等问题。本文将深入分析这个问题的本质,并提供一个完整的解决方案。

🎯 问题的本质

移动端视口的复杂性

在桌面端,100vh 通常能够准确表示视口高度,但在移动端情况就复杂得多:

  1. 动态工具栏:移动浏览器的地址栏和工具栏会动态隐藏/显示
  2. 虚拟键盘:输入框聚焦时,虚拟键盘会改变可视区域
  3. 浏览器差异:不同浏览器对视口的处理策略不同

具体表现

css 复制代码
/* 这样的代码在移动端可能出现问题 */
.fullscreen-modal {
  height: 100vh; /* 可能包含被键盘遮挡的部分 */
  position: fixed;
  bottom: 0;
}

当键盘弹出时:

  • iOS Safari100vh 不会改变,但实际可视区域变小
  • Android Chrome100vh 会动态调整 但三星有独特的导航烂
  • 微信浏览器:行为介于两者之间

🔍 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);
}

分层策略

  1. 优先:Visual Viewport API(精确度最高)
  2. 降级:传统事件组合(覆盖面广)

📱 真实应用场景

场景 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 不仅解决了当前的问题,更重要的是提供了一套完整的思路和方法论。希望这篇文章能帮助大家在移动端开发中游刃有余,创造出更好的用户体验。

记住:好的代码不仅要解决问题,还要考虑性能、兼容性、可维护性和可扩展性。


如果这篇文章对你有帮助,请点赞收藏!如果你有更好的优化建议或遇到问题,欢迎在评论区讨论。

📚 相关资源

相关推荐
Ticnix23 分钟前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人26 分钟前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl30 分钟前
OpenClaw 深度技术解析
前端
崔庆才丨静觅33 分钟前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人42 分钟前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼1 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端
布列瑟农的星空1 小时前
前端都能看懂的Rust入门教程(三)——控制流语句
前端·后端·rust
Mr Xu_1 小时前
Vue 3 中计算属性的最佳实践:提升可读性、可维护性与性能
前端·javascript
jerrywus1 小时前
我写了个 Claude Code Skill,再也不用手动切图传 COS 了
前端·agent·claude
玖月晴空1 小时前
探索关于Spec 和Skills 的一些实战运用-Kiro篇
前端·aigc·代码规范