React 跨端同构逻辑:处理 React 组件在 Web、Node.js 与 React Native 间的环境兼容隔离

各位,大家好。

今天我们不聊那些虚头巴脑的架构图,也不聊什么“微前端”或者“Serverless”这种听起来很酷但落地全是坑的概念。今天,我们要聊的是 React 开发者的“三明治”夹层——同构

你们有没有过这种经历?你写了一个超级漂亮的 React 组件,用了 document.getElementById,用了 window.addEventListener,甚至还用了 localStorage。你以为你写的是纯逻辑,结果一打包,Web 端跑得好好的,一扔到 Node.js 服务端渲染(SSR)就报错:“window is not defined”;再扔到 React Native 手机上,又报错:“document is not defined”。

这时候,你的内心是不是有一万只草泥马奔腾而过?你觉得自己写的是代码,结果写的是“跨平台特供”的限定版。

别急,这就是我们今天要解决的核心问题:如何在 React 组件里,让 Web、Node.js 和 React Native 这三个性格迥异的“室友”和平共处。

这就像是在家里养了猫、狗和鱼,你不能指望猫去喂鱼,也不能指望鱼去遛狗。你得有一套“跨端同构逻辑”

准备好了吗?系好安全带,我们要开始解剖这个“同构怪兽”了。


第一部分:认清现实,它们根本不是一家人

首先,我们要明白,React 虽然长得一样,但它背后的“操作系统”完全不同。

  1. Web 端: 这是一个基于 DOM(文档对象模型)的野兽。它有 window,有 document,有 navigator,有 history。它喜欢 HTML 标签,喜欢 CSS。
  2. Node.js 端: 这是一个服务器端的苦力。它没有浏览器,它没有界面。它有 process,有 fs(文件系统),有 stream(流),但它绝对没有 window。如果你在 Node 里写 alert(),它可能会报错,或者甚至根本不存在这个函数。
  3. React Native 端: 这是一个原生应用。它没有 DOM,没有 HTML。它使用的是原生组件(View, Text, Image)。它有自己的样式系统(StyleSheet),而不是 CSS 文件。

核心冲突点: React 组件试图渲染 UI,但在 Web 上是 HTML,在 RN 上是原生视图,在 Node 上是字符串。

所以,我们的目标不是让它们完全一样,而是让我们的代码能“识别”当前环境,并“变身”成对应环境的形态。


第二部分:入门级——typeof 检查法

这是最原始,也是最笨,但最有效的方法。就像你去安检,没有护照(window)就别想过。

假设我们有一个获取设备宽度的组件。

错误的写法(Web 专用):

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

const DeviceInfo = () => {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    // 这里直接用 window,Web 没问题,但 Node 和 RN 会崩溃
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return <div>屏幕宽度: {width}px</div>;
};

export default DeviceInfo;

同构改造版:

我们需要在代码执行前进行“环境隔离”。

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

const DeviceInfo = () => {
  // 1. 首先判断环境。如果 window 不存在,说明我们在 Node.js 里(比如 SSR),直接返回 null 或者空字符串
  if (typeof window === 'undefined') {
    return null; 
  }

  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    // 2. 在副作用里也要检查,防止某些奇怪的运行时环境
    const handleResize = () => setWidth(window.innerWidth);

    // 3. 只有 Web 环境才有事件监听器
    window.addEventListener('resize', handleResize);

    return () => window.removeEventListener('resize', handleResize);
  }, []);

  // 4. React Native 环境下,可能需要使用 Dimensions API,而不是 window.innerWidth
  // 这里我们做个简单的判断
  const displayWidth = width; 

  return <div>屏幕宽度: {displayWidth}px</div>;
};

export default DeviceInfo;

专家点评:
这种写法虽然能跑,但太丑了。你的组件里到处都是 if (typeof window === 'undefined'),代码逻辑被割裂得支离破碎。而且,这只能解决“有没有”的问题,解决不了“长什么样”的问题(Web 是 <div>,RN 是 View)。


第三部分:进阶级——HOC(高阶组件)模式

为了解决逻辑复用和环境隔离的问题,我们引入高阶组件(Higher-Order Component)。这就像是给组件穿了一层“防弹衣”或“战衣”。

我们要写一个通用的 withPlatform HOC,用来包裹任何组件,让它在不同环境下自动切换渲染逻辑。

代码示例:

import React from 'react';

// 1. 定义一个环境检测工具函数
const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined';
const isNode = () => typeof process !== 'undefined' && process.versions && !!process.versions.node;
const isRN = () => typeof navigator !== 'undefined' && navigator.product === 'ReactNative';

// 2. 编写 HOC
const withPlatform = (Component) => {
  return class extends React.Component {
    render() {
      // 3. 根据环境分发不同的渲染逻辑
      if (isBrowser()) {
        return <Component type="web" {...this.props} />;
      } else if (isRN()) {
        return <Component type="native" {...this.props} />;
      } else if (isNode()) {
        // Node.js 环境通常用于 SSR,可能需要返回 null 或者字符串
        return null; 
      }
      return null;
    }
  };
};

export default withPlatform;

实战应用:

现在我们有一个按钮组件,Web 上用 <button>,RN 上用 <TouchableOpacity>

import React from 'react';
import { TouchableOpacity, Text, StyleSheet } from 'react-native'; // RN 导入

const MyButton = ({ children, onPress }) => {
  // 逻辑代码
  const handleClick = () => {
    console.log('Button clicked');
    if (onPress) onPress();
  };

  // 4. 根据传入的 type 属性来决定渲染什么
  if (this.props.type === 'native') {
    return (
      <TouchableOpacity style={styles.nativeBtn} onPress={handleClick}>
        <Text style={styles.nativeText}>{children}</Text>
      </TouchableOpacity>
    );
  }

  // Web 默认渲染
  return (
    <button style={styles.webBtn} onClick={handleClick}>
      {children}
    </button>
  );
};

// 使用 HOC 包裹
export default withPlatform(MyButton);

// 简单的样式
const styles = {
  webBtn: { padding: 10, backgroundColor: 'blue', color: 'white' },
  nativeBtn: { padding: 10, backgroundColor: 'blue' },
  nativeText: { color: 'white' }
};

专家点评:
HOC 的好处是逻辑复用。你不需要在每个组件里都写 if (isRN),你只需要在组件外面包一层 HOC 就行了。

但是,HOC 也有缺点。它会让组件的层级变深,调试起来比较麻烦。而且,如果你在一个组件里多次使用 HOC,那组件的 displayName 会变得很奇怪。


第四部分:现代级——Render Props 与 Hooks

随着 React Hooks 的普及,我们不再需要 HOC 那种“装饰”模式了。我们可以用 Render Props(渲染属性)或者自定义 Hooks 来实现更优雅的隔离。

1. Render Props:把选择权交给使用者

Render Props 的核心思想是:我不决定渲染什么,我给你一个函数,你决定渲染什么。

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

const PlatformRenderer = ({ renderWeb, renderNative }) => {
  // 依然需要判断环境
  const isBrowser = typeof window !== 'undefined';

  if (isBrowser) {
    return renderWeb ? renderWeb() : <div>Web Mode</div>;
  } else {
    return renderNative ? renderNative() : <View><Text>Native Mode</Text></View>;
  }
};

// 使用示例
<PlatformRenderer
  renderWeb={() => <button>Click Me on Web</button>}
  renderNative={() => <Text onPress={() => alert('Clicked on Phone')}>Tap Me on Phone</Text>}
/>

2. 自定义 Hooks:逻辑提取的圣杯

这是目前最推荐的方式。我们将环境检测和平台特定的逻辑封装进一个 Hook 里,组件内部只负责渲染 UI。

代码示例:获取窗口尺寸

import { useState, useEffect } from 'react';

// 1. 封装环境判断
const isBrowser = () => typeof window !== 'undefined';

export const useWindowSize = () => {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    if (!isBrowser()) return; // 如果不是浏览器环境,直接停止

    // 监听窗口大小变化
    const handler = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    window.addEventListener('resize', handler);

    // 初始化
    handler();

    return () => {
      window.removeEventListener('resize', handler);
    };
  }, []);

  return size;
};

组件中使用:

import React from 'react';
import { View, Text, Dimensions } from 'react-native'; // RN 里的 Dimensions
import { useWindowSize } from './hooks/useWindowSize';

const ResponsiveComponent = () => {
  const { width, height } = useWindowSize();

  // 2. 在组件内部做最终判断
  // 注意:在 RN 中,useWindowSize 可能会返回 0,我们需要用 Dimensions
  const displayWidth = width || Dimensions.get('window').width;

  return (
    <div style={{ padding: 20 }}>
      <h1>当前屏幕尺寸: {displayWidth} x {height}</h1>
      <p>如果你看到这个,说明你在浏览器里。</p>
    </div>
  );
};

专家点评:
这种方式非常干净。组件代码里几乎看不到环境判断的痕迹,只有数据。但是,它有一个陷阱:副作用

useWindowSizeuseEffect 里,我们使用了 window.addEventListener。这在 Web 端没问题。但在 React Native 端,虽然 Dimensions 也有监听,但 API 也不完全一样。而且,如果你在 useEffect 里直接访问 window,在 SSR 时可能会报错(除非你在 useEffect 外面做了保护)。

所以,永远不要在 useEffect 的依赖数组里放 window,除非你非常确定。


第五部分:神器登场——react-native-web

既然 Web 和 RN 的渲染逻辑差别这么大,有没有一种办法,让我们写一套代码,让 Web 看起来像 RN,让 RN 看起来像 Web?

有!这就是 react-native-web

这个库的原理是:它拦截了 React Native 的组件(如 View, Text, Image),然后将它们转换成 Web 的 DOM 元素(div, span, img)。

配置步骤(Webpack/Babel):

你需要安装 react-native-web@babel/plugin-transform-react-jsx-source

// babel.config.js
module.exports = {
  presets: ['module:metro-react-native-babel-preset'],
  plugins: [
    ['@babel/plugin-transform-react-jsx-source', { sourceType: 'module' }],
    // ...其他插件
  ],
};

代码示例:

现在,你可以在 Web 和 RN 中使用完全相同的组件代码!

import React from 'react';
import { View, Text, StyleSheet } from 'react-native'; // 无论是 Web 还是 RN,都导入这个

// 假设我们定义了一个通用的卡片组件
const Card = ({ title, content }) => {
  return (
    <View style={styles.card}>
      <Text style={styles.title}>{title}</Text>
      <Text style={styles.content}>{content}</Text>
    </View>
  );
};

const styles = StyleSheet.create({
  card: {
    padding: 16,
    backgroundColor: '#fff',
    borderRadius: 8,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3, // RN 专有属性
  },
  title: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 8,
  },
  content: {
    fontSize: 14,
    color: '#666',
  },
});

export default Card;

效果:

  • 在 RN 中: react-native-web 会忽略 elevation,渲染出原生的阴影效果。
  • 在 Web 中: react-native-web 会把 View 转换成 div,把 StyleSheet 转换成 CSS,把 elevation 转换成 box-shadow

专家点评:
这是同构开发的神器!但别高兴得太早。react-native-web 也有它的局限性。比如,它不支持所有的原生动画库,某些复杂的交互手势在 Web 上可能表现不一致。而且,Web 端的样式需要通过 babel-plugin-react-native-web 自动注入到 HTML 的 <head> 中,如果你使用的是 CSS Modules 或者 Tailwind,配置起来会比较麻烦。


第六部分:跨端事件处理——onClick 还是 onPress?

在 Web 上,我们习惯用 onClick。在 RN 上,我们习惯用 onPress。在 Node.js 里,我们甚至不需要点击事件。

解决方案:事件委托。

不要在组件内部写 onClick,而是封装一个通用的 PressEvent

代码示例:

import React from 'react';
import { TouchableOpacity, Text } from 'react-native';

// 1. 定义通用的事件处理器接口
const handlePress = (e, props) => {
  // 在 RN 中,e 可能是 event 对象,也可能没有 e
  // 在 Web 中,e 是 MouseEvent

  console.log('Event triggered');

  if (props.onPress) {
    // 注意:RN 的 onPress 传递的参数比较少,Web 的 onClick 传递的参数多
    // 我们需要做一个适配
    props.onPress(e); 
  }
};

const MyButton = ({ children, onPress }) => {
  return (
    <TouchableOpacity 
      onPress={(e) => handlePress(e, { onPress })}
      activeOpacity={0.7}
    >
      <Text>{children}</Text>
    </TouchableOpacity>
  );
};

export default MyButton;

更高级的封装:

为了处理更复杂的情况(比如 Web 上的 onMouseMove, RN 上的 onScroll),我们可以写一个 PlatformEvent Hook。

import React, { useCallback } from 'react';

export const usePlatformEvents = () => {
  const isBrowser = typeof window !== 'undefined';

  const onClick = useCallback((e) => {
    if (isBrowser) {
      // Web 点击逻辑
      console.log('Web Click', e);
    } else {
      // RN 点击逻辑(或者 Node 逻辑)
      console.log('Native Click');
    }
  }, [isBrowser]);

  return { onClick };
};

// 使用
const MyComponent = () => {
  const { onClick } = usePlatformEvents();
  return <div onClick={onClick}>Click me</div>;
};

第七部分:SSR(服务端渲染)的噩梦——水合不匹配

这是所有同构开发者最头疼的问题。当你的 React 组件在服务端渲染成 HTML 字符串,然后在客户端通过 JavaScript 恢复成组件时,如果服务端渲染的 DOM 结构和客户端 React 计算出的 DOM 结构不一致,React 就会报错:“Hydration failed…”。

常见原因:

  1. 环境变量不一致: 服务端是 undefined,客户端是对象。
  2. 时间相关数据: new Date() 在服务端和客户端可能不同。
  3. 随机数: Math.random()
  4. 浏览器 API: window.innerWidth

解决方案:

在 SSR 中,我们通常需要在服务端渲染时隐藏动态内容,或者提供默认值。

代码示例:

import React from 'react';

const UserProfile = () => {
  const isBrowser = typeof window !== 'undefined';

  // 如果是服务端,或者还没加载完,显示一个加载状态
  if (!isBrowser) {
    return <div>Loading User Profile...</div>;
  }

  // 客户端才获取真实数据
  const userData = window.__INITIAL_DATA__; // 假设数据通过全局变量注入

  return (
    <div>
      <h1>{userData.name}</h1>
      <p>{userData.bio}</p>
    </div>
  );
};

export default UserProfile;

在 Node.js 端(服务端):

const React = require('react');
const ReactDOMServer = require('react-dom/server');
const UserProfile = require('./UserProfile').default;

// 1. 在服务端渲染前,将数据注入到 window 对象(虽然服务端没有 window,但我们可以模拟一下逻辑)
// 实际上,我们通常直接传递 props
const html = ReactDOMServer.renderToString(
  <UserProfile />
);

// 2. 发送 HTML 给浏览器
console.log(html);

专家点评:
SSR 是一把双刃剑。它虽然能提升首屏加载速度,但极大地增加了代码的复杂度。处理 SSR 的同构逻辑时,“保守”是关键。不要在组件里做任何依赖浏览器环境的事情,除非你做了严格的判断。


第八部分:实战演练——打造一个“万能”的图表组件

为了巩固今天的内容,我们来做一个通用的图表组件。Web 上用 Canvas 或者 SVG,RN 上用 react-native-chart-kit

组件逻辑:

  1. 接收数据 data
  2. 判断环境。
  3. Web 端渲染 <canvas>
  4. RN 端渲染 <LineChart />

代码实现:

import React from 'react';
import { View } from 'react-native';
import { LineChart } from 'react-native-chart-kit'; // 假设这是 RN 的库
// 在 Web 端,我们需要一个 Canvas 组件,这里为了演示,我们简单处理
import { Canvas } from 'react-native-web'; // 假设这是 web 的 canvas

const UniversalChart = ({ data }) => {
  const isBrowser = typeof window !== 'undefined';

  // 1. 如果是 RN 环境
  if (isBrowser && window.navigator.product === 'ReactNative') {
    return (
      <LineChart
        data={{
          labels: ['A', 'B', 'C'],
          datasets: [{ data }]
        }}
        width={300}
        height={200}
        bezier
        style={{ marginVertical: 8, borderRadius: 16 }}
      />
    );
  }

  // 2. 如果是 Web 环境
  if (isBrowser) {
    return (
      <div style={{ width: 300, height: 200, border: '1px solid black' }}>
        {/* 这里应该写 Canvas 绘制逻辑,为了简洁省略 */}
        <p>Web Chart Rendering...</p>
        <pre>{JSON.stringify(data, null, 2)}</pre>
      </div>
    );
  }

  // 3. 如果是 Node.js 环境
  return null;
};

export default UniversalChart;

第九部分:陷阱与避坑指南

讲了这么多,我想再强调几个“深水区”的坑,你们一定要记住,不然上线了哭都来不及。

  1. 不要在 useLayoutEffect 里做跨端逻辑:
    useLayoutEffect 会阻塞浏览器重绘。在 Web 上,这可能导致闪烁。在 RN 上,它的行为和 useEffect 略有不同。最稳妥的做法是统一使用 useEffect

  2. CSS 样式隔离:
    React Native 的 StyleSheet 是动态生成的,样式名是哈希值。Web 端的 CSS 是静态的。如果你在 Web 端使用了第三方组件库(比如 Ant Design),它们会生成大量的 CSS 类名。而在同构应用中,你可能会把这些 CSS 去掉,导致样式丢失。解决方案: 使用 react-native-web,它会把 RN 的样式自动转成 CSS。

  3. SVG 处理:
    SVG 在 RN 中比较麻烦。你不能直接写 <svg> 标签。你必须使用 react-native-svg 库,并手动映射每一个 <circle>, <rect>。而 Web 上可以直接写 SVG。这是同构开发中最大的痛点之一。

  4. 图片加载:
    Web 图片可以用 <img> 标签。RN 图片需要用 <Image> 组件,而且需要指定 source={{ uri: ... }}。加载策略也不同(Web 可以懒加载,RN 通常需要预加载)。在组件中处理图片时,一定要小心。


结语:拥抱混乱

各位,React 跨端同构开发就像是在走钢丝。你既要保证代码在服务器上跑得飞快,又要保证它在手机上丝般顺滑,还要保证它在电脑浏览器里不崩。

这确实很难,但这正是前端开发的魅力所在。通过 typeof 检查、HOC、Render Props、Hooks 以及 react-native-web 这些工具,我们正在逐渐统一这三个世界。

记住,同构的核心不是“让代码一样”,而是“让体验一样”。 无论你在哪里运行,给用户呈现的结果都应该是一致的、流畅的。

不要害怕环境差异,要把它们当成你代码里的一个个小怪兽,用你的逻辑去驯服它们。现在,拿起你的键盘,去写那个能跑遍全世界的 React 组件吧!

下课!

发表回复

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