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

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


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

📚 相关资源

相关推荐
前端W1 分钟前
腾讯地图组件使用说明文档
前端
页面魔术3 分钟前
无虚拟dom怎么又流行起来了?
前端·javascript·vue.js
胡gh4 分钟前
如何聊懒加载,只说个懒可不行
前端·react.js·面试
Double__King7 分钟前
巧用 CSS 伪元素,让背景图自适应保持比例
前端
Mapmost8 分钟前
【BIM+GIS】BIM数据格式解析&与数字孪生适配的关键挑战
前端·vue.js·three.js
一涯9 分钟前
写一个Chrome插件
前端·chrome
鹧鸪yy16 分钟前
认识Node.js及其与 Nginx 前端项目区别
前端·nginx·node.js
跟橙姐学代码17 分钟前
学Python必须迈过的一道坎:类和对象到底是什么鬼?
前端·python
汪子熙19 分钟前
浏览器里出现 .angular/cache/19.2.6/abap_test/vite/deps 路径究竟说明了什么
前端·javascript·面试