构建 Ruby on Rails 应用中集成了 Shadcn UI 的服务端渲染组件系统


在维护一个成熟的 Ruby on Rails 单体应用时,团队遇到的一个持续性挑战是在不牺牲服务端渲染(SSR)带来的首屏加载速度和 SEO 优势的前提下,引入现代、高度交互的前端组件。我们尝试过 Stimulus,它在处理轻量级交互上表现出色,但当面临复杂的状态管理、动画以及需要原子化、可组合的 UI 库时,就显得力不从心。ViewComponent 改进了服务端 HTML 的组织方式,但它本质上仍然是生成静态标记,无法满足日益增长的客户端动态性需求。

我们的目标很明确:找到一种方式,让我们能在 ERB 模板中像调用一个 ViewComponent 一样,无缝地渲染一个来自 Shadcn UI 的 React 组件,并且这个组件的初始 HTML 是在服务端生成的。我们不希望将应用割裂成一个 Rails API 后端和一个独立的 React SPA 前端,那会彻底颠覆现有的开发模式和团队结构。我们需要一个桥梁,一个能让 Rails 和 React 和谐共存的 SSR 桥梁。

最初的构想是利用现有的 react-rails gem,但经过评估,我们发现它在与现代前端工具链(如 Vite)的集成上存在诸多不便,且其 SSR 实现对我们来说像一个黑盒,难以定制和调试。因此,我们决定自己动手,构建一个轻量、可控且透明的 SSR 渲染管道。这个方案的核心在于:Rails 在渲染请求时,通过一个子进程调用一个独立的 Node.js 脚本来执行 React 组件的服务端渲染,捕获其输出的 HTML,然后将其无缝嵌入到最终的 ERB 响应中,同时将组件所需的数据(props)注入页面,以供客户端的 hydration 过程使用。

技术选型与环境搭建

这个方案成功的关键在于建立一个稳固且高效的开发环境,连接 Rails 的后端世界和现代化的 JavaScript 前端。我们选择了 vite_ruby,因为它能将 Vite 的极速开发体验和优化的生产构建无缝集成到 Rails 的资产生命周期中。

第一步是配置 Gemfile

# Gemfile

# ... other gems
gem 'vite_ruby'

然后运行 bundle installbundle exec vite install。这会自动生成必要的配置文件,包括 config/vite.jsonapp/frontend/entrypoints/application.js

接下来是前端环境。我们需要 React、TailwindCSS 以及 Shadcn UI 的所有依赖。

package.json 的核心依赖如下:

// package.json
{
  "name": "rails-ssr-bridge",
  "private": true,
  "dependencies": {
    "@radix-ui/react-dialog": "^1.0.5",
    "@radix-ui/react-slot": "^1.0.2",
    "class-variance-authority": "^0.7.0",
    "clsx": "^2.0.0",
    "lucide-react": "^0.292.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "tailwind-merge": "^2.0.0",
    "tailwindcss-animate": "^1.0.7"
  },
  "devDependencies": {
    "@types/node": "^20.9.0",
    "@types/react": "^18.2.37",
    "@types/react-dom": "^18.2.15",
    "@vitejs/plugin-react": "^4.2.0",
    "autoprefixer": "^10.4.16",
    "postcss": "^8.4.31",
    "tailwindcss": "^3.3.5",
    "typescript": "^5.2.2",
    "vite": "^5.0.0",
    "vite-plugin-ruby": "^5.0.0"
  }
}

我们使用 TypeScript 来保证组件的类型安全。tsconfig.json 的配置需要与 Shadcn UI 的要求保持一致,特别是 paths 配置,用于解析组件别名。

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,

    /* Aliases for Shadcn UI */
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "./app/javascript/*"
      ]
    }
  },
  "include": ["app/javascript/**/*.ts", "app/javascript/**/*.tsx"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

最后,使用 Shadcn UI 的 CLI 初始化项目并添加我们需要的组件。例如,添加一个 Dialog 组件:

npx shadcn-ui@latest init
npx shadcn-ui@latest add dialog

这会在 app/javascript/components/ui/ 目录下生成相应的 React 组件文件。至此,我们的前端开发环境已准备就绪。

SSR 桥接器的核心实现

桥接器的核心分为两部分:一个 Node.js 脚本负责渲染,一个 Ruby 模块负责调用并处理结果。

1. Node.js 服务端渲染脚本

我们在项目根目录下创建一个 ssr/render.mjs 脚本。这个脚本是整个架构的关键枢纽。它的职责是:

  1. 从标准输入(stdin)读取 JSON 字符串,其中包含要渲染的组件名和 props。
  2. 动态导入对应的 React 组件文件。
  3. 使用 ReactDOMServer.renderToString 将组件渲染为 HTML 字符串。
  4. 将渲染后的 HTML 和原始 props 组合成一个新的 JSON 对象,并将其打印到标准输出(stdout)。
// ssr/render.mjs
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';

// 生产环境下, Vite 会将所有文件打包, 我们需要一个 manifest 文件来查找正确的组件路径。
// 在开发环境下,我们可以直接解析路径。
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const isProduction = process.env.NODE_ENV === 'production';
const root = path.resolve(__dirname, '..');

async function render() {
  const input = await readInput();
  if (!input) {
    throw new Error('No input received from stdin.');
  }

  const { componentName, props } = input;

  try {
    // 动态构建组件的路径
    // 注意: Shadcn UI 的别名 '@/' 在 Node 环境中默认无法解析,
    // 我们需要将其手动转换为相对路径。
    const componentPath = path.join(root, `app/javascript/components/ui/${componentName}.tsx`);
    
    // 使用动态 import 导入组件模块
    const componentModule = await import(componentPath);
    const Component = componentModule.default || componentModule[componentName]; // 兼容不同的导出方式

    if (!Component) {
      throw new Error(`Component ${componentName} not found or has no default export.`);
    }

    const html = ReactDOMServer.renderToString(React.createElement(Component, props));
    
    // 将结果以 JSON 格式输出到 stdout
    process.stdout.write(JSON.stringify({ html, props }));
  } catch (error) {
    // 关键的错误处理: 将错误信息以可解析的格式输出到 stderr
    process.stderr.write(JSON.stringify({
      message: error.message,
      stack: error.stack,
      componentName,
      props
    }));
    process.exit(1);
  }
}

// 从 stdin 读取所有数据
function readInput() {
  return new Promise((resolve, reject) => {
    let data = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('readable', () => {
      let chunk;
      while ((chunk = process.stdin.read()) !== null) {
        data += chunk;
      }
    });
    process.stdin.on('end', () => {
      try {
        resolve(JSON.parse(data));
      } catch (e) {
        reject(e);
      }
    });
    process.stdin.on('error', reject);
  });
}

render();

这个脚本设计的关键在于它的健壮性。错误处理至关重要,因为任何在 Node.js 进程中的异常都必须被 Rails 捕获并清晰地报告出来,否则调试将成为一场噩梦。

2. Ruby 端的渲染器与辅助方法

在 Rails 中,我们创建一个模块来封装与 Node.js 脚本的交互逻辑。我们将它放在 lib/react_ssr_renderer.rb

# lib/react_ssr_renderer.rb
require 'open3'
require 'json'

module ReactSsrRenderer
  class RenderError < StandardError; end

  class Renderer
    # 确保 Node 进程的路径和工作目录是可配置的
    NODE_COMMAND = "node"
    SSR_SCRIPT_PATH = Rails.root.join('ssr', 'render.mjs')
    
    def self.render(component_name, props = {})
      # 准备要发送给 Node 脚本的 JSON 数据
      payload = { componentName: component_name, props: props }.to_json

      # 使用 Open3.capture2 来执行外部命令,这能同时捕获 stdout 和 stderr
      # 我们还设置了环境变量,以便 Node 脚本能感知运行环境
      stdout_str, stderr_str, status = Open3.capture3(
        { "NODE_ENV" => Rails.env },
        NODE_COMMAND,
        SSR_SCRIPT_PATH.to_s,
        stdin_data: payload
      )

      # 检查进程是否成功退出
      unless status.success?
        # 如果失败,解析 stderr 中的 JSON 错误信息并抛出异常
        # 这使得 Rails 中的错误堆栈能清晰地指向问题源头
        error_details = JSON.parse(stderr_str) rescue { message: "Unknown SSR error: #{stderr_str}" }
        raise RenderError, "Failed to render React component '#{component_name}'. Error: #{error_details['message']}\nStack: #{error_details['stack']}"
      end

      # 成功则解析 stdout 中的 JSON 结果
      JSON.parse(stdout_str)
    rescue JSON::ParserError => e
      raise RenderError, "Failed to parse SSR response JSON. stdout: '#{stdout_str}', stderr: '#{stderr_str}'. Original error: #{e.message}"
    end
  end
end

这个 Ruby 模块非常务实。它使用 Open3.capture3,这是一个健壮的选择,因为它能干净地处理 stdin, stdout, 和 stderr。当 Node 脚本失败时,它会捕获 stderr 中的结构化错误信息,并将其包装成一个 Ruby 异常。这对于在开发环境中快速定位问题至关重要。

为了在视图中方便使用,我们创建一个 ReactComponentHelper

# app/helpers/react_component_helper.rb
module ReactComponentHelper
  def render_react_component(name, props = {})
    # 为每个组件生成一个唯一的 ID,用于客户端 hydration
    component_id = "react-component-#{SecureRandom.uuid}"
    
    # 调用我们的 SSR 渲染器
    begin
      result = ReactSsrRenderer::Renderer.render(name, props)
      ssr_html = result['html'].html_safe
      ssr_props = result['props']
    rescue ReactSsrRenderer::RenderError => e
      # 在开发和测试环境中,如果渲染失败,显示一个清晰的错误信息而不是让整个页面崩溃
      return render_ssr_error(e, name, props) if Rails.env.development? || Rails.env.test?
      # 在生产环境中,记录错误并优雅地降级(比如渲染一个空的 div)
      Rails.logger.error(e.message)
      ssr_html = ''
      ssr_props = props
    end
    
    # 渲染一个占位符 div,其中包含服务端生成的 HTML。
    # 我们将组件名和 ID 作为 data 属性,以便客户端脚本找到它。
    # props 被序列化并存储在另一个 data 属性中,供 hydration 使用。
    content_tag(:div, ssr_html,
      id: component_id,
      data: {
        'react-component-name': name,
        'react-component-props': ssr_props.to_json
      }
    )
  end

  private

  def render_ssr_error(error, name, props)
    content_tag(:div, class: 'ssr-error') do
      concat(content_tag(:h4, "SSR Error: Failed to render '#{name}'"))
      concat(content_tag(:p, "Error: #{error.message}"))
      concat(content_tag(:pre, "Props: #{JSON.pretty_generate(props)}"))
      concat(content_tag(:pre, error.backtrace.join("\n")))
    end.html_safe
  end
end

这个 helper 的设计考虑了生产环境的稳定性。在开发中,它会直接在页面上显示详细的错误信息,便于调试。在生产中,它会记录错误并渲染一个空 div,避免单个组件的渲染失败导致整个页面崩溃。

客户端 Hydration

服务端渲染只是故事的一半。当页面加载到浏览器后,我们需要让 React “接管”这些由服务端生成的静态 HTML,附加事件监听器,使其成为一个完全动态的组件。这个过程被称为 hydration。

我们在 app/javascript/entrypoints/application.tsx 中设置 hydration 逻辑。

// app/javascript/entrypoints/application.tsx
import React from 'react';
import { hydrateRoot } from 'react-dom/client';

// 定义一个映射,将组件名映射到其实际的动态导入函数
// 这利用了 Vite 的代码分割能力,只有当页面上实际存在某个组件时,才会加载它的代码
const componentRegistry: Record<string, () => Promise<any>> = {
  // 示例: Shadcn UI 的 Dialog 和 Trigger
  'Dialog': () => import('@/components/ui/dialog'),
  'DialogTrigger': () => import('@/components/ui/dialog').then(mod => ({ default: mod.DialogTrigger })),
  'DialogContent': () => import('@/components/ui/dialog').then(mod => ({ default: mod.DialogContent })),
  'DialogHeader': () => import('@/components/ui/dialog').then(mod => ({ default: mod.DialogHeader })),
  'DialogTitle': () => import('@/components/ui/dialog').then(mod => ({ default: mod.DialogTitle })),
  'DialogDescription': () => import('@/components/ui/dialog').then(mod => ({ default: mod.DialogDescription })),
  'Button': () => import('@/components/ui/button'),
};

document.addEventListener('DOMContentLoaded', () => {
  // 查找所有标记为 React 组件的 DOM 元素
  const elements = document.querySelectorAll<HTMLElement>('[data-react-component-name]');

  elements.forEach(async (el) => {
    const componentName = el.dataset.reactComponentName;
    const propsJson = el.dataset.reactComponentProps;

    if (!componentName || !propsJson) {
      console.error('Missing component name or props for hydration.', el);
      return;
    }

    const props = JSON.parse(propsJson);
    const loadComponent = componentRegistry[componentName];

    if (!loadComponent) {
      console.error(`Component "${componentName}" is not registered for hydration.`);
      return;
    }

    try {
      const componentModule = await loadComponent();
      const Component = componentModule.default || componentModule[componentName];
      
      // 使用 hydrateRoot 来激活组件
      hydrateRoot(el, <Component {...props} />);
    } catch (error) {
      console.error(`Failed to hydrate component "${componentName}".`, error);
    }
  });
});

componentRegistry 是这里的关键。它避免了将所有可能的组件打包进一个巨大的 application.js 文件。通过使用动态 import(), 我们实现了代码的按需加载,极大地优化了首屏的 JavaScript 负载。

实践:渲染一个复杂的对话框

现在,让我们把所有部分串联起来,渲染一个包含触发器和内容的 Shadcn Dialog 组件。在真实的 Rails 项目中,我们可能需要将这些组件组合起来。但为了演示,我们将创建一个简单的 ERB 视图。

一个常见的错误是试图将整个 Dialog 结构一次性在服务端渲染。这在 React 中行不通,因为像 Dialog 这样的组件依赖于客户端的 Context API 来协同工作。服务端渲染只能渲染其初始的静态结构。真正的交互逻辑(如点击按钮打开模态框)必须在客户端 hydration 后才能生效。

因此,我们的 ERB 模板看起来会像这样:

<%# app/views/dashboard/index.html.erb %>

<h1>Dashboard</h1>
<p>Welcome to your dashboard. Here is some server-rendered content.</p>

<%# 渲染一个组合了多个 Shadcn UI 组件的 Dialog %>
<%= render_react_component 'Dialog', {}, children: capture do %>
  <%= render_react_component 'DialogTrigger', {}, children: capture do %>
    <%= render_react_component 'Button', { variant: 'outline' }, children: 'Edit Profile' %>
  <% end %>

  <%= render_react_component 'DialogContent', { className: 'sm:max-w-[425px]' }, children: capture do %>
    <%= render_react_component 'DialogHeader', {}, children: capture do %>
      <%= render_react_component 'DialogTitle', {}, children: 'Edit profile' %>
      <%= render_react_component 'DialogDescription', {}, children: "Make changes to your profile here. Click save when you're done." %>
    <% end %>
    
    <%# 这里可以继续嵌入标准的 ERB 或其他 React 组件 %>
    <div class="grid gap-4 py-4">
       <!-- ... form fields ... -->
    </div>
  <% end %>
<% end %>

注意,为了支持嵌套,我们需要修改 render_react_component helper 和 Node 脚本以接受 children prop。在 helper 中,我们使用 Rails 的 capture 方法来捕获块的内容作为子节点。

# app/helpers/react_component_helper.rb (修改版)
def render_react_component(name, props = {}, &block)
  if block_given?
    # 将 ERB 块中渲染的内容作为 'children' prop 传递
    props[:children] = capture(&block)
  end
  # ... (其余逻辑不变)
end

同时,Node 脚本也需要能够处理 children prop,它现在是一个 HTML 字符串。我们需要使用 dangerouslySetInnerHTML

// ssr/render.mjs (修改版)
// ...
const { componentName, props } = input;
const { children, ...otherProps } = props;

const element = children
  ? React.createElement(Component, { ...otherProps, dangerouslySetInnerHTML: { __html: children } })
  : React.createElement(Component, otherProps);

const html = ReactDOMServer.renderToString(element);
// ...

这是在异构系统中工作的现实——我们需要谨慎处理数据边界。将 ERB 渲染的 HTML 作为 children 传递给 React 组件是一种务实的妥协。

整个流程的生命周期可以用下图表示:

sequenceDiagram
    participant Browser
    participant Rails
    participant Node SSR Script

    Browser->>Rails: GET /dashboard
    Rails->>Rails: Controller#action
    Rails->>Rails: ERB Template rendering
    Rails->>Node SSR Script: spawn process with JSON (component, props)
    Node SSR Script->>Node SSR Script: import Component, ReactDOMServer.renderToString()
    Node SSR Script-->>Rails: stdout with JSON (html, props)
    Rails->>Rails: Embed HTML & props into ERB
    Rails-->>Browser: Send final HTML response

    Browser->>Browser: Parse HTML, CSS, JS
    Browser->>Browser: Execute application.tsx
    Browser->>Browser: Find [data-react-component-name] divs
    Browser->>Browser: Dynamically import Component.js
    Browser->>Browser: ReactDOM.hydrateRoot(element, )
    Browser->>Browser: Component is now interactive

局限性与未来展望

这个方案并非没有成本。最显著的性能开销在于为每个需要 SSR 的组件(或组件树)启动一个 Node.js 进程。对于一个页面上只有少量组件的场景,这种开销是完全可以接受的。但在组件数量非常多的页面上,累积的进程启动延迟可能会成为瓶颈。对此,一个可行的优化路径是启动一个常驻的 Node.js HTTP 服务,Rails 通过本地网络请求而非进程调用来进行通信,从而摊销进程启动成本。

其次,数据序列化是一个硬性约束。所有从 Rails 传递到 React 的 props 都必须是可 JSON 序列化的。这意味着不能直接传递 ActiveRecord 对象或复杂的 Ruby 对象。需要在 Controller 或 Helper 中预先将数据整理成简单的哈希和数组。在真实项目中,这通常需要引入一层 ViewModel 或 Serializer 来专门处理。

最后,这个架构增加了系统的复杂性。团队成员需要同时理解 Rails 和 React 的生态系统,并且要对这个桥接层的运作原理有清晰的认识。依赖管理也变得更加复杂,需要同步 Ruby 的 Gem 和 Node.js 的 Package。

尽管存在这些局限,但这套方案成功地在我们现有的 Rails 单体应用中实现了目标:一种与 Rails 开发体验高度融合的方式,来引入和管理一个现代、功能强大的组件库,同时保留了服务端渲染的核心优势。它不是一个适用于所有场景的银弹,而是一个在特定约束条件下,平衡了开发体验、用户体验和架构演进的务实选择。


  目录