React 跨端同构组件环境隔离实践

React 跨端同构组件环境隔离实践:双面间谍的生存指南

各位码农朋友,大家好!

欢迎来到今天的“双面间谍”训练营。我是你们的老朋友,一个在 Web 和 Native 之间反复横跳的资深工程师。

今天我们要聊的话题有点硬核,但绝对能拯救你们的发际线。我们要讲的是——React 跨端同构组件环境隔离

什么?你问我为什么叫“双面间谍”?因为当你写代码的时候,你其实是在扮演两个完全不同的角色。有时候你是一个生活在浏览器里的“DOM 原住民”,有时候你是一个躲在手机屏幕后的“原生野孩子”。这两者虽然都长着 React 的脸,但它们的脾气、语言和生存法则天差地别。

如果你没有处理好这种“环境隔离”,恭喜你,你的项目很快就会变成一个巨大的屎山,控制台会像菜市场一样报错,Hydration 失败会让你怀疑人生。

所以,今天我们不整那些虚头巴脑的学术名词,咱们来聊聊怎么在这两个极端的世界里,优雅地共存,写出既能跑在 Chrome 上,又能跑在 iOS/Android 上,甚至还能跑在微信小程序里的“变形金刚”代码。


第一课:识别你的“环境”朋友

首先,我们要学会“认人”。在写代码之前,你得知道你到底在哪个环境里。

在浏览器里,我们有 window 对象,它是我们的老大。你有 alertdocumentlocalStorage,还有那个烦人的 fetch

但在 React Native 里,window?它根本不存在!就像在深海里找不到氧气一样。取而代之的是 Platform.OS,这是 RN 的祖宗,它能告诉你:“嘿,我是 iOS 还是 Android?”或者“我是 Web。”

陷阱一:盲目信任 window

很多新手最喜欢干的事,就是在组件里直接写 window.innerWidth。结果呢?在 Web 上它跑得欢快,一跑到 Native 端,直接给你报个 ReferenceError: window is not defined。就像你对着空气挥拳,打到了自己的脸。

解决方案:环境检测

别怕,我们要像侦探一样,在访问 window 之前先进行“安检”。

import React, { useEffect, useState } from 'react';

// 定义一个安全的 hook
const useWindowWidth = () => {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    // 只有当 window 存在时才去拿宽度
    if (typeof window !== 'undefined') {
      const handleResize = () => setWidth(window.innerWidth);

      // 初始化一下
      handleResize();

      // 挂载监听器
      window.addEventListener('resize', handleResize);

      // 清理垃圾,这是 React 的基本素养
      return () => window.removeEventListener('resize', handleResize);
    }
  }, []);

  return width;
};

const MyResponsiveComponent = () => {
  const width = useWindowWidth();

  return (
    <div style={{ padding: 20 }}>
      <h1>当前宽度: {width}</h1>
      <p>如果你的宽度变了,说明我们没死。</p>
    </div>
  );
};

看懂了吗?这个 typeof window !== 'undefined' 就是你的护身符。但在同构环境下,这个检查还不够完美,因为 window 在服务端渲染(SSR)时也是 undefined 的。所以,如果你在 SSR 环境下运行这个组件,width 会一直是 0。这也没事,只要你的 UI 能优雅降级就行。


第二课:DOM 与 View 的“翻译官”

React Native 和 React DOM 虽然长得像,但它们是两个物种。

在 Web 上,我们喜欢用 <div> 包裹一切,喜欢用 className 定义样式,喜欢用 onClick 处理点击。但在 Native 上,div 是不存在的,className 也没用,点击事件通常用 onPress

以前,我们写同构组件,得写两套逻辑:

// 这种写法简直是反人类
export const MyComponent = ({ title }) => {
  if (typeof window !== 'undefined') {
    // Web 逻辑
    return <div onClick={() => alert(title)} className="my-class">Web View</div>;
  } else {
    // Native 逻辑
    return <TouchableOpacity onPress={() => alert(title)}><Text>Native View</Text></TouchableOpacity>;
  }
};

写这种代码,你会想吐。而且,当你需要引入第三方库时,噩梦开始了。比如 react-chartjs-2,它在 Web 上能跑,在 Native 上直接给你报错,因为它依赖 document.createElement

这时候,我们需要一位“翻译官”,它就是大名鼎鼎的 react-native-web

核心魔法:将 Web 组件映射到 Native

react-native-web 的核心思想是:让 React Native 以为自己在运行 Web,然后自动把 DOM 标签翻译成 View。

首先,在你的入口文件(通常是 index.jsApp.js),你需要引入这个翻译官:

import { AppRegistry } from 'react-native';
import App from './App'; // 你的同构组件
import { name as appName } from './app.json';

// 引入 react-native-web 的核心配置
import { registerRootComponent } from 'expo-router/entry'; // 假设你用了 Expo
// 或者如果是纯 RN:
// import { registerRootComponent } from 'react-native';

// 这一步是关键,它告诉 React Native:遇到 div 就给我转成 View
import { registerRootComponent } from 'react-native-web'; 
// 注意:在原生环境中,你不需要引入这个,它只在 Web 环境生效

// 注册组件
registerRootComponent(App);

然后,在你的 App.js 里,你可以大胆地写 Web 代码了:

import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; // 引入 RN 的基础组件
// 注意:虽然我们引入了 react-native-web,但我们依然使用 RN 的 import 语法
// 因为 Web 环境下,react-native 会被自动 polyfill 成 react-dom

// 甚至你可以引入一些纯 Web 的 UI 库,只要它们不直接操作 DOM
import { Button } from 'react-native-paper'; // 假设这个库支持 web

export default function App() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>同构组件实战</Text>
      <Button mode="contained" onPress={() => console.log('Pressed')}>
        我在 Web 和 Native 上都能点!
      </Button>

      {/* 下面这行代码在 Web 上渲染为 div,在 Native 上渲染为 View */}
      <div style={styles.webDiv}>
        我是一个被翻译的 div!
      </div>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  webDiv: {
    // 注意:直接写 CSS 属性在 RN 中是无效的
    // 必须通过 StyleSheet 或者 StyleSheet.create 的方式
    // 但 react-native-web 允许你在 style 属性里直接写对象,它会自动转换
    backgroundColor: 'blue',
    padding: 20,
    color: 'white',
  },
});

高阶技巧:动态导入

虽然 react-native-web 很强大,但它也不是万能的神。有些极其依赖浏览器 API 的库,你必须在运行时判断,然后动态加载它们。

import React, { useState, useEffect } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';

const DynamicChartComponent = () => {
  const [ChartComponent, setChartComponent] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // 这是一个异步操作,模拟从服务器获取 Web 版本的图表库
    const loadChart = async () => {
      try {
        // 动态导入,Web 环境下加载 echarts,Native 环境下加载 react-native-chart-kit
        const module = await import('./ChartLib'); 
        setChartComponent(module.default);
      } catch (error) {
        console.error('加载图表失败', error);
      } finally {
        setLoading(false);
      }
    };

    loadChart();
  }, []);

  if (loading) {
    return <ActivityIndicator />;
  }

  if (!ChartComponent) {
    return <Text>暂无图表组件</Text>;
  }

  // 渲染具体的图表
  return <ChartComponent data={[10, 20, 30]} />;
};

第三课:SSR 与 Hydration 的“时空穿越”悖论

这是同构开发中最痛苦的部分,没有之一。SSR(服务端渲染)让 React 看起来像是个时间旅行者,它能在服务器上生成 HTML,然后在客户端把那个 HTML “复活”。

问题来了:服务器上的 localStorage 是空的!

假设你写了一个计数器,逻辑是这样的:

const Counter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 坏习惯:直接在 useEffect 里操作 localStorage
    const saved = localStorage.getItem('count');
    if (saved) setCount(parseInt(saved, 10));
  }, []);

  const handleClick = () => {
    setCount(prev => {
      const next = prev + 1;
      localStorage.setItem('count', next.toString()); // 危险!
      return next;
    });
  };

  return <button onClick={handleClick}>{count}</button>;
};

场景重现:

  1. 用户请求页面: 服务器渲染 HTML,此时 localStorage 不存在,useEffect 还没跑,count 是 0。HTML 返回给用户,显示 0。
  2. 浏览器收到 HTML: React 开始 Hydration。它发现 HTML 是 <button>0</button>,这跟内存里的状态一致,没问题。
  3. React 开始跑代码: useEffect 触发,尝试 localStorage.getItem('count')啪! 报错了!因为在服务端渲染阶段,根本就没有 localStorage 这个对象。

修复方案:

不要在 useEffect 里初始化状态,要在组件体(或者 useLayoutEffect)里初始化。

const Counter = () => {
  // 1. 先在组件体里读取,不管环境
  const getCount = () => {
    if (typeof window !== 'undefined') {
      return parseInt(window.localStorage.getItem('count'), 10) || 0;
    }
    return 0; // SSR 环境
  };

  const [count, setCount] = useState(getCount);

  // 2. useEffect 只负责副作用,比如监听变化
  useEffect(() => {
    if (typeof window !== 'undefined') {
      window.localStorage.setItem('count', count.toString());
    }
  }, [count]);

  const handleClick = () => {
    setCount(prev => prev + 1);
  };

  return <button onClick={handleClick}>{count}</button>;
};

更优雅的方案:使用 Context

如果你有多个组件都依赖 localStorage,上面的写法会让代码到处都是 typeof window !== 'undefined'。这时候,创建一个 Context 来封装这些逻辑是最好的办法。

// StorageContext.js
import React, { createContext, useContext, useEffect, useState } from 'react';

const StorageContext = createContext();

export const StorageProvider = ({ children }) => {
  const [state, setState] = useState(() => {
    // 使用函数式初始化,确保只在客户端执行一次
    if (typeof window === 'undefined') return {};
    try {
      return JSON.parse(localStorage.getItem('appState')) || {};
    } catch (e) {
      return {};
    }
  });

  useEffect(() => {
    if (typeof window !== 'undefined') {
      localStorage.setItem('appState', JSON.stringify(state));
    }
  }, [state]);

  return (
    <StorageContext.Provider value={state}>
      {children}
    </StorageContext.Provider>
  );
};

export const useStorage = () => useContext(StorageContext);

使用:

const MyComponent = () => {
  const storage = useStorage();
  return <div>存储的值是: {JSON.stringify(storage)}</div>;
};

第四课:样式隔离的“颜色冲突”

Web 开发和 Native 开发,对样式的理解是两个维度的。

Web 上,我们习惯用 CSS 文件,全局变量满天飞,!important 满地爬。
Native 上,我们习惯用 StyleSheet.create,一切都是对象。

痛点: 你在 Web 上定义了一个 primaryColor: '#007AFF',在 Native 上也定义了 primaryColor: '#007AFF'。结果呢?iOS 的蓝色是 #007AFF,Android 的蓝色通常是 #2196F3。如果两个平台混用同一个配置,你的应用看起来就像是个色盲患者。

最佳实践: 使用 Platform.OS 来区分样式。

import { StyleSheet, Platform } from 'react-native';

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'white',
    // 如果是 Android,用浅灰背景,如果是 iOS,用白色
    backgroundColor: Platform.OS === 'android' ? '#F5F5F5' : '#FFFFFF',
  },
  text: {
    color: Platform.OS === 'ios' ? '#333333' : '#000000',
    // 字体大小也不一样
    fontSize: Platform.OS === 'ios' ? 16 : 14,
  },
  // 特定平台的样式
  ...Platform.select({
    ios: {
      shadowColor: 'black',
      shadowOffset: { width: 0, height: 2 },
      shadowOpacity: 0.2,
      shadowRadius: 4,
    },
    android: {
      elevation: 4, // Android 需要用 elevation 来模拟阴影
    },
  })
});

第五课:实战演练——一个完整的同构组件

让我们来组装一个稍微复杂一点的组件:“智能天气卡片”

这个组件需要:

  1. 获取天气数据(模拟 API)。
  2. 在 Web 上显示一个漂亮的 SVG 图标。
  3. 在 Native 上显示一个图片(或者纯色块)。
  4. 响应式布局。
import React, { useState, useEffect, useMemo } from 'react';
import { 
  View, 
  Text, 
  StyleSheet, 
  ActivityIndicator, 
  Platform, 
  ScrollView 
} from 'react-native';
// 引入 react-native-web 的 polyfills
import { SafeAreaView } from 'react-native-safe-area-context';
// 假设我们有一个用于 Web 的天气图标组件
import WeatherIconWeb from './components/WeatherIconWeb'; 

// 模拟 API 请求
const fetchWeather = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({ temp: 26, condition: 'Sunny' });
    }, 500);
  });
};

const WeatherCard = () => {
  const [weather, setWeather] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchWeather()
      .then(data => setWeather(data))
      .catch(err => setError(err))
      .finally(() => setLoading(false));
  }, []);

  // 使用 useMemo 优化渲染,避免在 Web 环境下重复创建 SVG
  const weatherIcon = useMemo(() => {
    if (!weather) return null;
    if (Platform.OS === 'web') {
      return <WeatherIconWeb type={weather.condition} />;
    }
    // Native 环境下,我们可以使用 react-native-svg
    return <Text>☀️</Text>; 
  }, [weather]);

  if (loading) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" color="#007AFF" />
      </View>
    );
  }

  if (error) {
    return (
      <View style={styles.center}>
        <Text style={styles.error}>加载失败: {error.message}</Text>
      </View>
    );
  }

  return (
    <SafeAreaView style={styles.safeArea}>
      <ScrollView contentContainerStyle={styles.scrollContainer}>
        <View style={styles.card}>
          <Text style={styles.city}>我的城市</Text>
          <View style={styles.iconContainer}>
            {weatherIcon}
          </View>
          <Text style={styles.temp}>{weather.temp}°C</Text>
          <Text style={styles.condition}>{weather.condition}</Text>
        </View>
      </ScrollView>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  safeArea: {
    flex: 1,
    backgroundColor: '#F2F2F7', // iOS 风格背景
  },
  scrollContainer: {
    padding: 20,
    justifyContent: 'center',
    alignItems: 'center',
  },
  center: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  card: {
    width: '100%',
    maxWidth: 300, // Web 限制最大宽度
    padding: 30,
    borderRadius: 20,
    backgroundColor: 'white',
    alignItems: 'center',
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.1,
    shadowRadius: 8,
    elevation: 5, // Android elevation
  },
  city: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 10,
    color: '#333',
  },
  iconContainer: {
    width: 100,
    height: 100,
    justifyContent: 'center',
    alignItems: 'center',
    marginBottom: 20,
  },
  temp: {
    fontSize: 48,
    fontWeight: '300',
    color: '#007AFF',
    marginBottom: 10,
  },
  condition: {
    fontSize: 16,
    color: '#666',
    textTransform: 'capitalize',
  },
  error: {
    color: 'red',
  }
});

export default WeatherCard;

这段代码的亮点:

  1. SafeAreaView:在 iOS 上自动处理刘海屏,在 Web 上通常被 polyfill 成 <main> 或者 <div>,兼容性极佳。
  2. Platform.OS 判断:在 weatherIcon 中,我们明确区分了 Web 和 Native 的渲染逻辑。
  3. useMemo:在 Web 上,我们复用了 SVG 组件,避免了不必要的重绘。
  4. StyleSheet:所有的样式都通过 StyleSheet.create 管理,这保证了在 Web 环境下,这些样式最终会被转换成 CSS,而在 Native 环境下保持原样。

第六课:调试的“炼狱”与救赎

当你把代码部署到 Web 和 Native 两端时,你会发现,Native 端的报错信息通常非常模糊,而 Web 端的报错信息又太长太啰嗦。

常见报错:

  1. “Hydration failed”

    • 症状: 页面闪一下,然后变成空白,或者控制台疯狂报错。
    • 原因: 服务端渲染的 HTML 和客户端 JS 产生的 HTML 不一致。
    • 排查: 检查 localStorageDate 对象、随机数生成器。这些在服务器和客户端是不一样的!
    • 代码示例:
      // 错误示范
      const MyComponent = () => {
         const [date, setDate] = useState(new Date()); // 每次渲染都变!
         return <div>{date.toLocaleTimeString()}</div>
      };
      // 修复:使用 useMemo
      const [date] = useState(() => new Date());
  2. “Element type is invalid”

    • 症状: 运行时白屏。
    • 原因: 动态导入失败,或者引入了一个在当前环境不存在的模块。
    • 排查: 确保你的动态导入路径正确,并且在 try-catch 里做了处理。
  3. CSS 样式在 Web 上生效了,在 Native 上没生效

    • 原因: 你在 style 属性里写了 CSS 属性(比如 color: red),而不是使用 StyleSheet 对象。
    • 修复: 强制自己使用 StyleSheet.create

第七课:构建工具的“隐形墙壁”

最后,我们得谈谈构建工具。Webpack、Vite、Expo、Metro。

在 Web 端,你用 Webpack 配置 resolve.alias,把 react-native 指向 react-native-web
在 Native 端,你用 Metro 配置 resolver.sourceExts,把 .js 当作 .jsx 处理。

如果你在同一个仓库里管理 Web 和 Native,这简直是地狱。

推荐方案: 使用 Monorepo(单仓库)结构,配合 TurborepoNx

你的目录结构大概是这样的:

/my-app
  /apps
    /web (React + Webpack)
    /native (React Native + Metro)
  /packages
    /shared-components (这里存放你的同构组件!)

这样,/shared-components 里的代码,既可以在 Web 的 Webpack 环境下被编译,又可以在 Native 的 Metro 环境下被编译。它们共享同一份逻辑,但拥有不同的打包输出。


结尾:拥抱双面人生

好了,朋友们,今天的讲座就到这里。

跨端同构听起来很复杂,其实剥开来看,就是一场关于“隔离”“融合”的游戏。

  • 隔离:你需要把 windowdocumentnavigator 等环境变量隔离在各自的沙箱里。
  • 融合:你需要用 react-native-web 这样的桥梁,把 Web 的组件和 Native 的组件融合在一起。

记住,写同构代码,心态要稳。不要试图在一个组件里解决所有问题,如果太复杂,就把它拆分成 Web 专用组件和 Native 专用组件,然后在入口处做路由分发。

当你第一次在手机上看到你在电脑上写的代码,并且运行得完美无缺时,那种成就感,比吃了一顿火锅还爽。那就是代码的魅力,那就是跨端开发的魅力。

现在,拿起你的键盘,去征服那两个世界吧!别忘了,别忘了在 useEffect 里写上 return () => cleanup,那是我们程序员的底线。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注