React 环境适配器设计:源码分析 react-shared 包如何屏蔽 Node.js 与浏览器全局变量的物理差异

各位前端战士,各位热衷于在代码海洋里冲浪的极客们,大家好!

今天我们不聊怎么写一个 useEffect,也不聊怎么把 TypeScript 装饰得花里胡哨。今天,我们要来聊聊 React 内部最“隐秘”也最“硬核”的机制——环境适配器

如果有人问你:“React 是怎么在 Node.js 服务器上渲染出 HTML 的?” 你可能会说:“用 react-dom/server 呀!” 但如果再追问一句:“它怎么知道服务器上没有 window 这个东西?React 核心代码里不是到处都在用 window 吗?”

这时候,很多老铁就会卡壳了。其实,React 的核心源码里,并没有写死“我在浏览器里跑”。相反,它像是一个身怀绝技的特工,随身带着一套“环境伪装系统”。这套系统,就是我们今天要扒皮的 react-shared(或者更准确地说是 React 源码中负责环境适配的配置模块)。

准备好了吗?我们要开始“开箱验货”了。这可是深入 React 内核的硬核技术讲座,建议备好咖啡,因为我们要去的地方有点深。


第一章:分形宇宙的分裂——浏览器 vs Node.js

首先,我们得承认一个残酷的事实:JavaScript 这门语言,在诞生之初,其实是个“偏科生”。

在浏览器里,JavaScript 是个富家子弟,手握王炸:window 对象(包含 DOM、History、Storage 等等)、document 对象(HTML 的上帝视角)、navigator(用户设备信息)。

但是,一旦你把 Node.js 引入,这个富家子弟瞬间变成了流浪汉。在 Node.js 里,window 是不存在的,document 也是不存在的。Node.js 拥有的是 processglobalBuffer

这就造成了一个尴尬的局面:React 的核心逻辑,比如 React.createElementFiber 树的构建,本质上并不依赖 DOM API。但是,React 的渲染管线(Rendering Pipeline)在运行时需要知道“我在哪”。

如果 React 的核心代码里直接写了 const node = document.createElement('div'),那它在 Node.js 里一运行,当场就崩给你看。报错信息大概会像瀑布一样刷屏:TypeError: document is not defined

所以,React 必须要有一套机制,能够在运行时动态判断当前环境,然后“变魔术”一样,把一个假的 document 塞给 Node.js,把真的 document 留给浏览器。

这套机制,就是我们要讲的环境适配器


第二章:特工的伪装——react-shared 的核心架构

在 React 的源码世界里,并没有一个显眼的大名叫 react-shared 的文件。但实际上,React 把环境适配的逻辑抽离了出来。通常,我们会在 react-dom 的源码中找到 ReactDOMBrowserConfig.js 这个文件,它就是我们的“环境伪装总指挥”。

让我们来看看这个总指挥是如何工作的。它的核心思想非常简单:通过检查全局变量是否存在,来决定返回什么配置。

假设我们有一个 react-shared 模块,它定义了这样一个接口:

// 概念性代码:react-shared 模块
const reactShared = {
  get DOM() {
    // 核心逻辑:如果浏览器有 window,那就用真的;如果没有,那就造一个假的
    return typeof window !== 'undefined' ? {
      document: window.document,
      navigator: window.navigator,
      // ... 其他真实浏览器属性
    } : {
      document: require('./fake-document'),
      navigator: require('./fake-navigator'),
      // ... 这里就是 Node.js 的“替身”
    };
  }
};

module.exports = reactShared;

看到没?这就是适配器的精髓。它把“环境差异”这个巨大的物理障碍,封装成了一个透明的接口。


第三章:造物主的谎言——伪造 window 对象

在 Node.js 环境下,window 是不存在的。但是 React 的很多内部逻辑(比如事件系统、某些性能检测)会假设 window 是存在的。

于是,React 做了一件很“无赖”的事情:直接把 window 挂载到 global 上。

请看 React 源码中的经典操作(以较旧版本为例):

// react-dom/src/client/ReactDOMBrowserConfig.js
let isServerRendering = false;

if (typeof window === 'undefined') {
  // 嘿,Node.js 兄弟,我给你造一个 window
  // 在 Node.js 中,全局对象通常是 global
  // 在浏览器中,全局对象是 window
  const g = (typeof global !== 'undefined' ? global : window);

  // 创建一个假的 window 对象
  const fakeWindow = {
    document: {
      documentElement: null,
      body: null,
      // ... 空荡荡的
    },
    navigator: {
      userAgent: '',
      platform: '',
    }
  };

  // 把假的挂上去
  g.window = fakeWindow;

  // 标记一下,我们正在搞事情
  isServerRendering = true;
}

这段代码极其重要。它让 React 的核心代码以为它依然运行在一个完整的浏览器环境中。这就像是一个演员在舞台上,无论台下有没有观众,他都要假装自己真的在演一场大戏。

代码示例:为什么这很重要?

假设 React 内部有一行代码:

// React 核心逻辑内部
const width = window.innerWidth; // 假设这是某个布局计算

在浏览器里,width 是屏幕宽度。
在 Node.js 里,如果直接执行这行代码,程序直接 Crash。

但有了上面的适配器:

  1. react-shared 检测到没有 window
  2. 它创建了一个 fakeWindow
  3. React 内部去调用 window.innerWidth 时,实际上调用的是 fakeWindow.innerWidth
  4. fakeWindow 返回一个默认值(比如 0 或者 1024),程序继续运行,没有报错。

但是! 这只是第一步。window 还有很多属性,比如 locationhistorylocalStorage。如果 React 依赖这些属性,我们还得一个个去伪造。React 的策略通常是:只伪造它真正用到的属性,剩下的抛出 undefined 或者报错。


第四章:空壳的奇迹——伪造 document 对象

如果说伪造 window 还比较容易,那么伪造 document 就是技术活儿了。因为 document 是一个极其复杂的对象,它不仅是一个对象,它是一个

在浏览器中,document 代表整个 HTML 页面,里面有 headbodytitle 等等。

但在 SSR(服务端渲染)时,我们根本不需要一个真实的树。我们只需要一个“空壳”,一个让 React 的渲染管线能够跑通的“空壳”。

让我们看看这个空壳长什么样:

// 概念性代码:Fake Document
const createFakeDocument = () => {
  return {
    // documentElement 指向 <html> 标签
    documentElement: {
      nodeType: 9, // Document 对象的节点类型
      nodeName: 'HTML',
      childNodes: [],
      // 必须有,否则 React 会报错
      ownerDocument: null, 
    },
    // body 指向 <body> 标签
    body: {
      nodeType: 1, // Element 对象的节点类型
      nodeName: 'BODY',
      childNodes: [],
      ownerDocument: null,
    },
    // head 指向 <head> 标签
    head: {
      nodeType: 1,
      nodeName: 'HEAD',
      childNodes: [],
      ownerDocument: null,
    },
    // 必须有 createTextNode 方法,否则 React 无法创建文本节点
    createTextNode(text) {
      return {
        nodeType: 3, // Text 节点类型
        nodeName: '#text',
        textContent: text,
        nodeValue: text,
        parentElement: null, // 初始没有父元素
        childNodes: [],
      };
    },
    // ... 还有很多属性,比如 title, cookie 等
  };
};

深度解析:

注意 createTextNode 这个方法。React 在构建虚拟 DOM 树时,会生成很多文本节点。在浏览器里,这些节点只是数据;但在 SSR 时,React 会调用这个方法来创建节点。

更重要的是,这个伪造的 document 对象必须与 React 的渲染逻辑配合。React 在渲染时,会尝试向 document.body.appendChild 插入节点。在 SSR 模式下,react-dom/server 会拦截这个操作,不把真实的 DOM 插入到服务器硬盘里,而是把生成的 HTML 字符串拼接到一个缓冲区里。

代码示例:SSR 中的交互

// 客户端代码
import React from 'react';
import { renderToString } from 'react-dom/server';

function App() {
  return <div>Hello World</div>;
}

// 在 Node.js 环境下运行
const html = renderToString(<App />);
console.log(html); // 输出: <div data-reactroot="">Hello World</div>

// 此时,虽然代码里没有显式操作 document,
// 但 React 内部在构建 Fiber 树时,其实是把 "Hello World" 放到了
// reactShared.DOM.document.createTextNode("Hello World") 里面。
// react-dom/server 捕获了这些操作,生成了 HTML 字符串。

所以,react-shared 做的事情,就是确保这个 createTextNode 调用不会因为找不到方法而报错。


第五章:间谍的接头暗号——__REACT_DEVTOOLS_GLOBAL_HOOK__

这是 React 生态中最有趣的一个部分。React 并不是孤立运行的,它需要配合 React DevTools(浏览器插件)来工作。

DevTools 需要注入一段代码到页面中,通过一个全局变量 window.__REACT_DEVTOOLS_GLOBAL_HOOK__ 来与 React 核心通信。React 核心会检查这个变量是否存在,如果存在,就会把调试信息发过去;如果不存在,就假装没这回事,不消耗性能。

场景 A:浏览器环境
DevTools 插件已经安装了,window.__REACT_DEVTOOLS_GLOBAL_HOOK__ 是一个功能完备的 Hook 对象。

场景 B:Node.js 环境
服务器上根本没有浏览器插件。如果 React 还傻乎乎地去读这个变量,Node.js 会直接抛出 ReferenceError: __REACT_DEVTOOLS_GLOBAL_HOOK__ is not defined

所以,react-shared 的适配器必须处理这个变量。

// 概念性代码
const reactShared = {
  get DevTools() {
    if (typeof window !== 'undefined' && window.__REACT_DEVTOOLS_GLOBAL_HOOK__) {
      return window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
    }
    // 在 Node.js 环境下,我们需要创建一个空的、无害的 Hook
    // 它不能报错,也不能真的连接到 DevTools
    return {
      supportsFiber: true,
      // 模拟 connect 方法
      renderers: new Set(),
      onCommitFiberRoot: () => {},
      onCommitFiberUnmount: () => {},
      // ... 其他必要的空方法
    };
  }
};

这段代码非常狡猾。它创建了一个假的 Hook 对象。React 核心代码里可能写着类似这样的逻辑:

// React 核心内部
const hook = reactShared.DevTools;
if (hook && typeof hook.onCommitFiberRoot === 'function') {
  // 只有当 Hook 存在且有这个方法时,才发送调试数据
  hook.onCommitFiberRoot(renderer, fiberRoot, priorityLevel);
}

这样,在 Node.js 下,即使 reactShared.DevTools 返回了一个假对象,React 也能安全地运行,因为它检查了方法是否存在。


第六章:构建时的魔法——process.env

虽然运行时适配器解决了大部分问题,但 React 还有一个杀手锏:构建时优化

你有没有想过,为什么 React 的源码里经常能看到 process.env.NODE_ENV === 'development' 这样的判断?

这是因为,React 在构建(Webpack/Rollup/Vite)的时候,会根据环境变量进行代码折叠。

代码示例:条件编译

// React 源码片段
function invariant(condition, format, a, b, c, d, e, f) {
  if (process.env.NODE_ENV !== 'production') {
    // 开发模式:详细报错,包含堆栈信息
    if (condition) return;
    // ... 抛出包含堆栈的详细错误
  } else {
    // 生产模式:极简报错,只告诉你哪里错了,不暴露堆栈
    if (condition) return;
    // ... throw new Error('Invariant failed');
  }
}

在 Node.js SSR 环境下,process.env.NODE_ENV 通常是 'production'(或者被构建工具强制设为 'production')。

这意味着,当你在服务器上运行 React 时,React 会自动剔除所有开发模式的调试代码,只保留生产模式的轻量级逻辑。

这进一步减少了 Node.js 环境下 react-shared 适配器的负担。因为生产模式下,很多复杂的 DOM 检测逻辑被移除了。


第七章:react-shared 的具体实现细节

既然你提到了 react-shared,我们来看看在 React 官方源码仓库中,这些适配逻辑是如何组织的。

在 React 的 packages/react-dom 目录下,你会发现 src/client/ReactDOMBrowserConfig.js 这个文件。它不仅仅是一个简单的对象,它还暴露了一些辅助方法。

// react-dom/src/client/ReactDOMBrowserConfig.js
let canUseDOM = !!(
  typeof window !== 'undefined' &&
  typeof window.document !== 'undefined' &&
  typeof window.document.createElement !== 'undefined'
);

let canUseEventListeners = canUseDOM && !!window.addEventListener;
let canUseViewport = canUseDOM && !!window.innerHeight;

// 暴露给外部使用的工具
module.exports = {
  canUseDOM,
  canUseEventListeners,
  canUseViewport,
  canUseCookies: canUseDOM && navigator.cookieEnabled,
};

这段代码的巧妙之处在于:

  1. canUseDOM:这是一个非常著名的布尔值。它决定了 React 是否会尝试去操作 DOM。在 SSR 时,这个值是 false。React 的渲染核心会根据这个值来决定是“去真的 DOM 里插节点”还是“去字符串缓冲区里拼 HTML”。
  2. canUseEventListeners:服务器上根本没有事件监听器。这个标志位告诉 React 内部的合成事件系统:“嘿,别费劲了,这里没有事件,直接返回 false 吧,别去注册什么监听器了。” 这极大地节省了 Node.js 的内存和 CPU。

代码示例:如何使用这个标志位

// React 内部逻辑(伪代码)
function mountComponent() {
  if (reactShared.canUseDOM) {
    // 如果是浏览器,我们要挂载到真实的 DOM 树上
    const domNode = document.createElement('div');
    container.appendChild(domNode);
  } else {
    // 如果是 Node.js,我们不需要挂载 DOM,只需要生成 HTML 字符串
    // 或者把数据存储到内存中
    const html = renderToString(this);
  }
}

第八章:进阶挑战——Web Worker 与 iframe

环境适配器的设计并没有止步于“浏览器 vs 终端”。React 还面临着更复杂的场景。

场景 1:Web Worker
Web Worker 运行在一个独立的线程中,它没有 window 对象,也没有 document 对象。它只有 self

如果你的 React 应用跑在 Web Worker 里,react-shared 的适配器必须能识别 self 并将其作为 window 的代理。

// 概念性代码:Worker 适配
if (typeof window === 'undefined') {
  const g = (typeof global !== 'undefined' ? global : (typeof self !== 'undefined' ? self : {}));
  // ... 伪造 window
}

场景 2:跨域 iframe
如果你在一个跨域的 iframe 里运行 React,window.parent 是不可访问的。React 需要检测这种隔离情况,防止跨域脚本错误。


第九章:性能与内存——适配器的代价

虽然适配器让 React 变得灵活,但它也是有代价的。

1. 内存占用
每次在 Node.js 里初始化 React,我们都需要创建一个假的 windowdocument 对象。虽然这些对象很简单,但如果你每秒渲染 1000 个页面,那就是 1000 个假的 document 对象在内存里飘荡。React 通过复用这些对象来缓解这个问题,但始终存在开销。

2. 逻辑复杂度
React 核心代码不能直接写 document.body,必须写 reactShared.DOM.body。这意味着,如果 React 想要修改 DOM 操作的逻辑(比如增加一个新的特性),它必须修改适配器层和核心层两处代码。这是一种“分层隔离”带来的维护成本。

3. 运行时检查
typeof window !== 'undefined' 这种检查是在每次模块加载时都会执行的。虽然现代 JS 引擎对这种检查做了优化,但在极端性能敏感的场景下,这是不可避免的。


第十章:总结——这就是 React 的“变形金刚”

好了,各位,我们今天把 react-shared 的皮都扒光了。

React 之所以能成为前端界的霸主,不仅仅是因为它的虚拟 DOM 算法,更因为它的通用性

通过环境适配器模式,React 实现了“一套代码,两种运行”

  • 浏览器端:适配器返回真实的 windowdocument,React 变成“超级赛亚人”,利用浏览器的强大能力进行交互。
  • 服务端:适配器返回假的 windowdocument,React 变成“隐身侠”,利用 Node.js 的计算能力生成静态 HTML。

这种设计模式,就是典型的策略模式

react-shared 就像是那个在幕后默默工作的魔术师,它把复杂的物理环境差异封装起来,让 React 核心代码可以专注于“渲染组件”这个伟大的使命,而不用担心脚下踩的是浏览器还是服务器。

所以,当你下次在浏览器里看到一个炫酷的 React 应用,或者在服务器上看到一个首屏秒开的 SSR 页面时,请记住这个幕后英雄——环境适配器。没有它,React 根本无法在 Node.js 上生存。

现在,去你的代码里找找看,有没有手动处理 windowdocument 的代码?如果有,试着用 react-shared 的思想重构一下它,你会发现世界都清净了。

这就是今天的讲座。我是你们的编程专家,我们下次再见,记得多写点注释,别让维护你代码的人(或者未来的 AI)骂你!

发表回复

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