GraalVM凭借强大的多语言互操作能力,让Java应用可以直接执行JavaScript代码,这为前后端同构渲染提供了全新的思路:同一份渲染逻辑,既能跑在浏览器里,也能跑在服务端的GraalJS引擎中。然而真实项目里,很多页面的数据获取逻辑是用jQuery的$.ajax写的,一旦把这些代码搬进GraalVM,立刻就会报ReferenceError: XMLHttpRequest is not defined之类的错误。这篇文章就来剖析问题的根源,并给出几种经过验证的解决方案。

为什么jQuery Ajax在GraalVM里跑不起来
jQuery的Ajax模块从设计之初就是面向浏览器环境的。它在内部会依次探测XMLHttpRequest、ActiveXObject等对象,判断当前环境是否具备发起HTTP请求的能力。这些对象全部由浏览器宿主提供,并不属于ECMAScript语言规范本身。
GraalVM里的GraalJS是一个纯JavaScript引擎,它只实现了语言标准,外加一些可选的标准内置对象。默认配置下,执行上下文中既没有window,也没有document,更没有XMLHttpRequest。当jQuery初始化时检测不到这些全局对象,或者干脆在加载阶段就因为缺少document而失败,Ajax自然无从谈起。
另一个容易被忽略的细节是:即便你手动注入了一个空的XMLHttpRequest桩对象,jQuery内部还会访问location、navigator等对象来拼接URL和判断协议。这些隐式依赖构成了一个完整的浏览器环境假设链,缺一环整个Ajax模块就失效。理解了这一点,后续的方案思路就很清晰了:要么补齐环境,要么替换掉Ajax实现。
方案一:在Java侧实现HTTP转发桥接对象
第一种方案的思路是保留jQuery代码不动,在Java这一侧把XMLHttpRequest的能力模拟出来。也就是在GraalVM的Context构建之后,注入一个用Java实现的HTTP桥接对象,让JavaScript层的请求真正由Java的HttpClient发出,再把响应回填给JS回调。这样做的最大好处是业务代码零改动,迁移成本最低。
import org.graalvm.polyglot.*;
import java.net.http.*;
import java.net.URI;
public class SsrBootstrap {
public static void main(String[] args) throws Exception {
Context context = Context.newBuilder("js")
.allowHostAccess(HostAccess.ALL)
.build();
// 注入一个用Java实现的HTTP桥接对象,供JS侧调用
context.getBindings("js").putMember("httpBridge", new HttpBridge());
context.eval(Source.newBuilder("js", new java.io.File("app.js")).build());
}
}
HttpBridge内部需要暴露request方法,接收请求方法、URL、请求头和请求体,底层走Java 11自带的HttpClient完成真正的网络调用。核心难点在于异步语义的桥接:JS侧的回调是在GraalVM的事件循环中触发的,而Java的CompletableFuture完成时并不在同一个上下文里。实践中可以把结果序列化成字符串推回JS层处理,或者在单线程渲染模式下用CountDownLatch阻塞等待,前者性能更好,后者实现更简单。
public class HttpBridge {
public String request(String method, String url, String headersJson, String body) throws Exception {
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url));
// 解析请求头并逐个设置
java.util.Map<String, String> headers = new com.google.gson.Gson()
.fromJson(headersJson, new com.google.gson.reflect.TypeToken<java.util.Map<String, String>>(){}.getType());
headers.forEach(req::header);
HttpRequest request = "POST".equalsIgnoreCase(method)
? req.POST(HttpRequest.BodyPublishers.ofString(body == null ? "" : body)).build()
: req.GET().build();
HttpResponse<String> resp = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
return String.format("{\"status\":%d,\"text\":%s}",
resp.statusCode(), com.google.gson.Gson.class.getName().isEmpty()
? quote(resp.body()) : quote(resp.body()));
}
private String quote(String s) {
return new com.google.gson.Gson().toJson(s);
}
}
这个方案的性能相当不错,因为请求由JVM直接发出,没有额外的模拟开销。缺点是实现工作量偏大,如果jQuery还用到了FormData、超时控制、跨域检查等特性,桥接层都要逐一覆盖,容易出现边跑边补窟窿的情况。另外要注意跨域校验:浏览器环境的同源策略在服务端并不存在,由Java转发请求时要自己做好目标域名的白名单控制。
方案二:用jsdom补齐完整的浏览器环境
第二种方案是引入jsdom,它能在纯JS环境里模拟出一个相当完整的浏览器DOM,包括window、document、navigator以及相关宿主对象。在GraalVM中先加载jsdom的打包产物,再在其构造出的全局环境里执行jQuery和业务代码,兼容性最好。
const { JSDOM } = require("jsdom");
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
url: "https://www.ipipp.com/",
pretendToBeVisual: true
});
// 将jsdom的window成员提升到全局作用域
const { window } = dom;
global.XMLHttpRequest = window.XMLHttpRequest;
global.document = window.document;
global.navigator = window.navigator;
global.location = window.location;
需要注意的是,jsdom自带的XMLHttpRequest在纯JS引擎里默认没有真实网络能力,会抛出未实现的错误。此时可以把window.XMLHttpRequest替换为方案一中的Java桥接实现,两者结合往往是最稳妥的组合拳。此外,jsdom的初始化在GraalJS里要消耗几百毫秒,如果渲染是高频操作,务必用对象池复用jsdom实例,避免每次请求都重新构建。
这个方案适合存量代码多、jQuery插件深度依赖DOM结构的场景。它的代价是内存占用更高,并且jsdom本身与GraalJS存在少量兼容坑,建议用打包工具把jsdom预先编译成单文件再加载,绕开GraalJS对部分Node模块解析的局限。
方案三:改造数据层,用fetch替换Ajax调用
如果项目还处于可以改造的阶段,最一劳永逸的办法是淡化对jQuery Ajax的依赖,把数据请求收敛到标准化的HTTP接口上。常见做法是引入isomorphic-fetch或cross-fetch,这些库在浏览器中使用原生fetch,在非浏览器环境回落到可注入的实现。配合polyfill,GraalJS也能顺利执行。
// fetch-polyfill.js:在GraalVM中注入的补丁
// httpBridge是Java侧暴露的对象,提供request方法
if (typeof fetch === "undefined") {
globalThis.fetch = function (url, options) {
options = options || {};
const method = options.method || "GET";
const body = options.body || null;
const headers = options.headers || {};
const raw = httpBridge.request(method, url, JSON.stringify(headers), body);
const payload = JSON.parse(raw);
return Promise.resolve(new Response(payload.text, {
status: payload.status,
headers: payload.headers
}));
};
}
改造之后,原本的$.ajax调用可以逐步替换为fetch写法,也可以保留一个轻量的$.ajax兼容层,把请求委托给fetch,让老代码在新环境里继续工作。这种渐进式迁移的风险最小,团队可以在业务迭代中逐步完成替换,而不需要一次性冻结所有功能做大规模改造。
选型建议与踩坑提醒
三种方案各有侧重:存量代码多、不能动业务逻辑的,选方案一;依赖DOM结构和jQuery插件的复杂页面,选方案二;有条件重构数据层的项目,直接走方案三。实践中也可以组合使用,比如用jsdom提供DOM,用Java桥接层提供网络请求,这也是不少Java技术栈构建SSR方案的通行做法。
最后提醒几个高频踩坑点。第一,GraalVM的Context不是线程安全的,多个请求并发渲染时要使用Context池,而不是共享一个实例。第二,注意HostAccess的权限边界,渲染不可信的第三方JS时务必收紧暴露的Java成员,避免安全风险。第三,jQuery版本尽量选用3.x,它的模块化程度更高,对非浏览器环境的容忍度好于1.x。把这些细节处理好,jQuery时代的代码也能平稳地跑在GraalVM的现代服务端渲染架构里。
GraalVMjQuery Ajax服务端渲染修改时间:2026-09-15 02:25:49