1. 概述

本文将深入探讨什么是同构应用(Isomorphic Application),并介绍 Java 自带的 JavaScript 引擎 Nashorn。接着,我们会结合 React 这样的前端库,演示如何利用 Nashorn 实现一个完整的同构应用。

简单来说,目标是:用 Java 后端跑 React 前端代码,实现服务端渲染(SSR)。这在已有 Java 技术栈的团队中,是一种低成本接入现代前端生态的“骚操作”。

2. 前端发展简史

回顾一下前端的演变过程:

  • 早期 Web 应用以服务端为主,比如 PHP 直接生成 HTML 返回给浏览器。
  • 1995 年 Netscape 引入 JavaScript,开始将部分逻辑转移到浏览器端。
  • 随着用户对交互体验要求提升,前端框架开始爆发:
    • jQuery:早期的 DOM 操作王者,让 AJAX 变得简单粗暴。
    • AngularJS:Google 推出,引入 MVW 模式。
    • React:Facebook 提出组件化与虚拟 DOM,彻底改变前端开发方式。
    • Vue:渐进式框架,易上手,生态完善。

如今,前端已不再是“切页面”,而是构建复杂的单页应用(SPA)。但 SPA 也有痛点:首屏慢、SEO 不友好。

于是,同构应用应运而生——前后端共享同一套代码,实现服务端渲染 + 客户端激活(Hydration)。

3. 什么是同构应用?

同构应用(也叫通用应用,Universal App)指的是:

同一套前端代码,既能在浏览器运行,也能在服务端运行,并生成相同的 HTML 输出。

Isomorphic Apps

✅ 优势:

  • 首屏直出 HTML,提升加载速度
  • 更利于 SEO
  • 前后端逻辑统一,减少重复代码

⚠️ 关键点:服务端必须能执行 JavaScript。最常见的方案是 Node.js。

但如果你的后端是 Java 呢?总不能为了 SSR 把整个后端重构成 Node 吧?

这时候,Nashorn 就派上用场了。

4. Nashorn 是什么?

Nashorn 是 Java 8 内置的 JavaScript 引擎,无需额外依赖,开箱即用。

它的核心能力是:

✅ 将 JavaScript 编译为 JVM 字节码,在 JVM 上直接执行。

相比老一代的 Rhino 引擎,Nashorn 性能更好,兼容性更强。

使用场景:

  • 在 Java 项目中嵌入 JS 脚本
  • 实现 Java 与 JS 的双向调用
  • 服务端渲染前端框架(如 React)

对于已有 Java 技术栈的团队,Nashorn 是一个低成本实现 SSR 的选择。

5. 构建同构应用

我们来实现一个简单的同构应用:斐波那契数列生成器

功能:

  • 页面初始显示 [0,1,1]
  • 点击“Next”按钮,通过 REST 接口获取下一个数并追加显示

技术栈:

  • ✅ 前端:React.js
  • ✅ 后端:Spring Boot + Nashorn

6. 前端实现(React)

6.1 React 组件

var App = React.createClass({displayName: "App",
    handleSubmit: function() {
        var last = this.state.data[this.state.data.length-1];
        var secondLast = this.state.data[this.state.data.length-2];
        $.ajax({
            url: '/next/'+last+'/'+secondLast,
            dataType: 'text',
            success: function(msg) {
                var series = this.state.data;
                series.push(msg);
                this.setState({data: series});
            }.bind(this),
            error: function(xhr, status, err) {
                console.error('/next', status, err.toString());
            }.bind(this)
        });
    },
    componentDidMount: function() {
        this.setState({data: this.props.data});
    },    
    getInitialState: function() {
        return {data: []};
    },    
    render: function() {
        return (
            React.createElement("div", {className: "app"},
                React.createElement("h2", null, "Fibonacci Generator"),
                React.createElement("h2", null, this.state.data.toString()),
                React.createElement("input", {type: "submit", value: "Next", onClick: this.handleSubmit})
            )     
        );
    }
});

关键点解析:

  • getInitialState:初始化状态为空数组
  • componentDidMount:组件挂载时,从 props 接收初始数据
  • render:生成虚拟 DOM,显示数列和按钮
  • handleSubmit:点击按钮时,调用 /next 接口获取下一个数
  • 使用 React.createElement 而非 JSX(Nashorn 不支持 JSX)

6.2 使用 React 组件

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Hello React</title>
    <script type="text/javascript" src="js/react.js"></script>
    <script type="text/javascript" src="js/react-dom.js"></script>
    <script type="text/javascript" src="http://code.jquery.com/jquery-1.10.0.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/javascript" src="app.js"></script>
<script type="text/javascript">
    ReactDOM.render(
        React.createElement(App, {data: [0,1,1]}),
        document.getElementById("root")
    );
</script>
</body>
</html>

说明:

  • 引入 react.jsreact-dom.jsjquery
  • 创建 id="root" 的 div 作为挂载点
  • 加载 app.js(包含 App 组件)
  • 使用 ReactDOM.render 激活客户端

7. 后端实现(Spring Boot + Nashorn)

7.1 Maven 依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.tomcat.embed</groupId>
    <artifactId>tomcat-embed-jasper</artifactId>
    <scope>provided</scope>
</dependency>

说明:

  • spring-boot-starter-web:Web 核心依赖
  • tomcat-embed-jasper:支持 JSP 编译(用于服务端渲染输出)

7.2 Web 控制器

@Controller
public class MyWebController {
    @RequestMapping("/")
    public String index(Map<String, Object> model) throws Exception {
        ScriptEngine nashorn = new ScriptEngineManager().getEngineByName("nashorn");
        nashorn.eval(new FileReader("static/js/react.js"));
        nashorn.eval(new FileReader("static/js/react-dom-server.js"));
        nashorn.eval(new FileReader("static/app.js"));
        Object html = nashorn.eval(
          "ReactDOMServer.renderToString(" + 
            "React.createElement(App, {data: [0,1,1]})" + 
          ");");
        model.put("content", String.valueOf(html));
        return "index";
    }
}

核心逻辑:

  1. 获取 Nashorn 引擎实例
  2. 加载 react.jsreact-dom-server.js(服务端渲染专用)
  3. 加载前端组件 app.js
  4. 调用 ReactDOMServer.renderToString() 生成 HTML 字符串
  5. 将 HTML 注入模型,传递给 JSP 视图

⚠️ 踩坑提醒:必须使用 react-dom-server.js,否则 renderToString 不存在。

7.3 JSP 视图

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Hello React!</title>
    <script type="text/javascript" src="js/react.js"></script>
    <script type="text/javascript" src="js/react-dom.js"></script>
    <script type="text/javascript" src="http://code.jquery.com/jquery-1.10.0.min.js"></script>
</head>
<body>
<div id="root">${content}</div>
<script type="text/javascript" src="app.js"></script>
<script type="text/javascript">
    ReactDOM.render(
        React.createElement(App, {data: [0,1,1]}),
        document.getElementById("root")
    );
</script>
</body>
</html>

关键变化:

  • ${content}:插入服务端渲染的 HTML
  • 客户端 React 会检测到已有内容,进行“激活”而非重新渲染

7.4 REST 接口

@RestController
public class MyRestController {
    @RequestMapping("/next/{last}/{secondLast}")
    public int index(
      @PathVariable("last") int last, 
      @PathVariable("secondLast") int secondLast) throws Exception {
        return last + secondLast;
    }
}

一个简单的 REST 接口,计算斐波那契下一项。

8. 运行应用

启动类:

@SpringBootApplication
public class Application extends SpringBootServletInitializer {
    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(Application.class);
    }
    public static void main(String[] args) throws Exception {
        SpringApplication.run(Application.class, args);
    }
}

启动后访问首页:

Index Page 1

渲染流程:

  1. 浏览器请求 /
  2. Spring Controller 使用 Nashorn 执行 React 代码,生成 HTML
  3. JSP 将 HTML 插入 #root
  4. 浏览器收到完整 HTML,立即渲染
  5. 客户端 JS 下载完成后,React 激活页面,接管交互

✅ 核心机制:React 的 同构激活(Hydration) —— 客户端 React 会复用服务端生成的 DOM,避免重复渲染。

9. 更复杂的场景

本文例子较为简单,实际项目中还需考虑:

  • 组件拆分:多个组件构成层级结构,通过 props 传递数据
  • 模块化:使用 import/export 管理依赖
  • 状态管理:引入 Redux 管理全局状态
  • 副作用处理:使用 redux-thunk 或 Redux-Saga 处理异步
  • JSX 支持:使用 Babel 将 JSX 编译为 React.createElement

⚠️ 限制:Nashorn 仅支持标准 JavaScript,不支持 JSX、ES6+ 新语法。

解决方案:

  1. 使用 Webpack 或 Rollup 打包前端代码
  2. 通过 Babel 将 JSX 和 ES6+ 编译为 ES5
  3. 生成一个兼容 Nashorn 的 JS 文件

最终 Nashorn 执行的是编译后的“纯 JS”,完美兼容。

10. 同构应用的优势

10.1 首屏渲染更快

  • ❌ SPA:白屏 → 下载 JS → 执行 → 渲染
  • ✅ 同构:直接返回 HTML,内容秒出

对用户体验提升显著,尤其在网络较差的移动端。

10.2 更利于 SEO

  • ❌ SPA:搜索引擎爬虫可能无法执行 JS,抓取不到内容
  • ✅ 同构:服务端返回完整 HTML,爬虫可直接解析

⚠️ 注意:现代爬虫(如 Googlebot)已支持 JS 渲染,但仍有不确定性。服务端渲染更稳妥。

11. 总结

本文通过一个简单示例,演示了如何使用 Spring Boot + Nashorn + React 实现同构应用。

核心价值:

  • ✅ 利用 Java 生态实现服务端渲染
  • ✅ 无需引入 Node.js,降低架构复杂度
  • ✅ 提升首屏性能与 SEO

虽然 Nashorn 在复杂项目中有局限(如不支持 JSX),但结合 Webpack/Babel 构建流程后,依然能胜任中等复杂度的同构需求。

代码已托管至 GitHub:https://github.com/eugenp/tutorials/tree/master/spring-boot-modules/spring-boot-nashorn


原始标题:Isomorphic Application with React and Nashorn