各位,大家好。
今天我们不聊那些虚头巴脑的架构图,也不聊什么“微前端”或者“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 虽然长得一样,但它背后的“操作系统”完全不同。
- Web 端: 这是一个基于 DOM(文档对象模型)的野兽。它有
window,有document,有navigator,有history。它喜欢 HTML 标签,喜欢 CSS。 - Node.js 端: 这是一个服务器端的苦力。它没有浏览器,它没有界面。它有
process,有fs(文件系统),有stream(流),但它绝对没有window。如果你在 Node 里写alert(),它可能会报错,或者甚至根本不存在这个函数。 - 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>
);
};
专家点评:
这种方式非常干净。组件代码里几乎看不到环境判断的痕迹,只有数据。但是,它有一个陷阱:副作用。
在 useWindowSize 的 useEffect 里,我们使用了 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…”。
常见原因:
- 环境变量不一致: 服务端是
undefined,客户端是对象。 - 时间相关数据:
new Date()在服务端和客户端可能不同。 - 随机数:
Math.random()。 - 浏览器 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。
组件逻辑:
- 接收数据
data。 - 判断环境。
- Web 端渲染
<canvas>。 - 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;
第九部分:陷阱与避坑指南
讲了这么多,我想再强调几个“深水区”的坑,你们一定要记住,不然上线了哭都来不及。
-
不要在
useLayoutEffect里做跨端逻辑:
useLayoutEffect会阻塞浏览器重绘。在 Web 上,这可能导致闪烁。在 RN 上,它的行为和useEffect略有不同。最稳妥的做法是统一使用useEffect。 -
CSS 样式隔离:
React Native 的StyleSheet是动态生成的,样式名是哈希值。Web 端的 CSS 是静态的。如果你在 Web 端使用了第三方组件库(比如 Ant Design),它们会生成大量的 CSS 类名。而在同构应用中,你可能会把这些 CSS 去掉,导致样式丢失。解决方案: 使用react-native-web,它会把 RN 的样式自动转成 CSS。 -
SVG 处理:
SVG 在 RN 中比较麻烦。你不能直接写<svg>标签。你必须使用react-native-svg库,并手动映射每一个<circle>,<rect>。而 Web 上可以直接写 SVG。这是同构开发中最大的痛点之一。 -
图片加载:
Web 图片可以用<img>标签。RN 图片需要用<Image>组件,而且需要指定source={{ uri: ... }}。加载策略也不同(Web 可以懒加载,RN 通常需要预加载)。在组件中处理图片时,一定要小心。
结语:拥抱混乱
各位,React 跨端同构开发就像是在走钢丝。你既要保证代码在服务器上跑得飞快,又要保证它在手机上丝般顺滑,还要保证它在电脑浏览器里不崩。
这确实很难,但这正是前端开发的魅力所在。通过 typeof 检查、HOC、Render Props、Hooks 以及 react-native-web 这些工具,我们正在逐渐统一这三个世界。
记住,同构的核心不是“让代码一样”,而是“让体验一样”。 无论你在哪里运行,给用户呈现的结果都应该是一致的、流畅的。
不要害怕环境差异,要把它们当成你代码里的一个个小怪兽,用你的逻辑去驯服它们。现在,拿起你的键盘,去写那个能跑遍全世界的 React 组件吧!
下课!