
早几年我还在折腾桌面端工具的时候最舒坦的一段日子就是用 JDK 8 自带的 jjs 命令行工具配合 Nashorn 脚本引擎去写 JavaFX 界面。你不用打开 IDE不用写一堆 public class不用等编译一个记事本加一条 jjs 命令界面和交互就出来了。改完直接重跑比传统 Java 桌面开发那套“改代码—编译—运行”的循环不知道快到哪里去了。这篇文章我打算把“jjs Nashorn JavaFX”这套组合拳彻底讲透jjs 和 Nashorn 各自承担什么角色、Java 与 JavaScript 是怎么互相调用的、JavaFX 的控件和事件在脚本里怎么写然后带你把一个计算器从零跑到能点、能算、能清零。最后会把我这些年踩过的坑和排查技巧整理成一份速查表方便你以后直接用。不管你是想快速验证一个界面想法的 Java 开发者还是想碰一碰 Java 生态的 JS 玩家这篇文章都能让你少走不少弯路。1. 这把组合拳到底在解决什么问题1.1 三个角色各自是干什么的先理清概念。jjs 是 JDK 8 里自带的一个命令行工具它的作用类似一个 JS 解释器外壳你在终端里敲jjs它就会启动一个 JavaScript 运行环境支持交互式 REPL也支持直接执行.js脚本文件。它不需要额外安装只要你的 JDK 是 8 或 9bin 目录下就有这个命令。Nashorn 才是真正干活的 JavaScript 引擎。jjs 只是外壳Nashorn 是发动机。它是 Oracle 在 JDK 8 里用 Java 重写的 ECMAScript 引擎除了能跑标准 JavaScript 之外最核心的能力就是和 Java 互操作你可以在 JavaScript 里直接引用 Java 类、创建 Java 对象、调用 Java 方法甚至可以传一个 JS 函数进去Nashorn 会自动帮你把它适配成 Java 接口的实现。这就是我们能用脚本写 JavaFX 应用的基石。JavaFX 则是 Java 平台上的桌面 GUI 框架它提供了一套比 Swing 现代化得多的控件和布局体系有 CSS 样式、属性绑定、动画这类的“现代 GUI”特性。正常情况下你用 Java 代码写 JavaFX要写一堆样板代码而通过 Nashorn 的互操作能力这些类都能在 JS 脚本里被直接使用。你可以这样理解这三者的关系jjs 是驾驶舱Nashorn 是发动机JavaFX 是底盘和车身。你要做的事情就是在驾驶舱里指挥发动机让整台车跑起来。1.2 为什么会有这种“脚本写桌面程序”的玩法可能有人会问明明有 Java 原生 API为什么还要绕一层 JavaScript这背后其实是三类真实需求。第一类是快速原型验证。有时候你脑子里冒出一个界面交互的点子但完整写一个 Java 工程需要建目录、写 main 方法、处理编译配置等跑起来已经过去了十分钟。用脚本的话写几行代码直接跑界面出来了不满意就改整个过程用秒计。我在做内部工具的时候经常先用脚本把界面交互调顺再回头用 Java 正式实现效率极高。第二类是给现有 Java 桌面应用补脚本能力。比如你做了一个 JavaFX 工具想让它支持加载用户脚本来自动化流程Nashorn 就是一个现成的内置脚本引擎你可以在应用里用ScriptEngineManager加载 JS 脚本让脚本直接操作应用内的 Java 对象。这时候你先用 jjs 命令行把脚本调通了再嵌进应用里成本非常低。第三类是降低 GUI 开发门槛。团队里不是所有人都熟悉 Java 编译链路但很多测试和运维同学会写 JavaScript。通过 jjs 加 JavaFX他们不用学 Java 编译打包那套东西就能折腾出一个小工具窗口来。这个玩法在当时的社区里非常流行也是很多人第一次感受到“JavaFX 竟然能被脚本驱使”的乐趣。2. 先把 jjs 和 Nashorn 的地基打牢2.1 jjs 命令行工具的常用姿势jjs 的用法很简单但有几个参数值得好好记。最基本的是直接进交互模式jjs进去之后你会看到jjs提示符可以直接敲 JS 代码。比如敲一句print(hello jjs)回车结果立刻输出。在交互模式下你可以随意试验 Java 类的调用方便得很我平时验证一个Java.type写法是不是正确就会直接打开 jjs REPL 试一下。运行脚本文件是另一个高频姿势jjs app.js脚本文件里可以直接写 JavaScript然后由 Nashorn 解释执行。下面这几个参数是实战里最常用的列成表格给你参数作用实战场景-cp指定 Java classpath脚本里需要加载你编译好的 Java 类时必须加上否则报ClassNotFoundException-scripting打开脚本增强模式支持$ENV取环境变量、$EXEC执行系统命令适合写自动化脚本--languagees6启用 ES6 语法脚本里想用let、const、箭头函数时打开-Dkeyvalue设置 Java 系统属性脚本里通过java.lang.System.getProperty读取-J-Xmx512m给 JVM 传递参数控制堆大小处理大数据时有用--debug打印更详细的调试信息排查脚本加载或类型转换问题时很有用注意-cp这个参数它是 jjs 工具的选项不是 JVM 的选项。也就是说你得写在 jjs 后面、脚本文件前面jjs -cp . app.js这里的.代表当前目录如果你编译了一个Launcher.class在当前目录脚本里用Java.type(Launcher)就能找到它。2.2 Java 和 JavaScript 互操作的核心 APINashorn 的互操作能力是这套玩法的心脏核心 API 其实就几个你搞清楚它们后面的 JavaFX 脚本基本无障碍。第一个是Java.type它用来引用一个 Java 类var System Java.type(java.lang.System); System.out.println(hello from JS);这段代码在 JS 里拿到了java.lang.System类然后调用它的静态字段out的println方法。注意System.out是一个静态字段Nashorn 支持直接访问。创建 Java 对象的方法则是new关键字配合类型var ArrayList Java.type(java.util.ArrayList); var list new ArrayList(); list.add(a); list.add(b); print(list.size());在 JS 里 new 一个 Java 类得到的对象表现得很像 JS 对象你可以直接调它的 Java 方法。list.add(a)里传的 JS 字符串会被自动转成 Java 的String这就是 Nashorn 帮你做了类型转换。第二个是Java.importer它相当于 Java 的 import 语句可以极大简化代码。比如你要用一堆 JavaFX 控件如果每个都Java.type一次会写到手酸var JFX Java.importer(javafx.scene.control, javafx.scene.layout, javafx.scene); var Button JFX.Button; var VBox JFX.VBox; var Scene JFX.Scene;当然Java.importer返回的对象本身也支持属性访问所以你也可以直接JFX.Button。在写比较大的脚本界面时这个 API 能省不少事。第三个是Java.extend它用来创建 Java 类或接口的子类/匿名实现类。典型的场景是你要定义一个带多个方法的 Java 接口实现Nashorn 的“函数自动适配 SAM 接口”只适用于只有一个抽象方法的接口多方法接口就得用Java.extend或者new InterfaceType({...})的方式。第四个是数组转换Java.from和Java.to。JavaFX 里很多 API 返回的是 Java 数组或集合你在 JS 里想用forEach、map这类 JS 方法来处理就需要先用Java.from把它转成 JS 数组var javaArray Java.type(java.lang.String[]).class; var jsArray Java.from(javaArray);反过来如果 Java API 需要接收一个 Java 数组你就用Java.to把 JS 数组转过去。2.3 重载方法与类型转换的坑Nashorn 的互操作看起来方便但实际写起来有几个坑我当年都踩过先拿出来给你打预防针。第一个坑是重载方法匹配。Java 有很多重载方法比如setText(String)、setText(int)。Nashorn 会根据你传入参数的类型和数量去自动匹配最合适的重载版本。大部分情况它挑得很准但有争议的时候就翻车比如你传一个null进去Java 那边有四五个重载版本都能接受nullNashorn 不知道选哪个直接就抛异常了。我的建议是不到万不得已不要给 Java 方法传null非要传的话可以绕一下用明确类型的 Java 对象包装var StringClass Java.type(java.lang.String); var typedNull StringClass.class.cast(null);不过这个写法读起来绕能避免就避免。第二个坑是 JS 数字和 Java 数值类型的差异。JS 里所有数字都是 doubleNashorn 在调用 Java 的int、long参数时如果传的是 JS 数字并且没有小数部分它一般会自动帮你转成 int 或 long。但是如果数值过大超出精度范围就会出现精度丢失。比如你要给 JavaFX 的setPrefWidth(100)传一个整数没问题但如果你处理的是很大的 ID 数值就要考虑精度问题。第三个坑是字符串类型。JS 字符串在大多数情况下会自动转成 JavaString作为方法参数没问题。但反过来如果一个 Java 方法返回了String你在 JS 里拿到的对象是 Java String 的实例它在 JS 里表现得像一个字符串但调用 JS 的字符串方法比如includes、startsWith时不一定全都可用。稳妥的做法是先用String(返回值)包一层转成纯 JS 字符串再操作。3. JavaFX 在 Nashorn 里的正确打开方式3.1 从 Java 代码到脚本代码的映射规则把 JavaFX 代码翻译成 Nashorn 脚本本质上就是一个“把 Java 语法翻译成 JS 语法”的过程规则非常固定我总结成三步。第一步引入类。Java 里写import javafx.scene.Scene;脚本里就写var Scene Java.type(javafx.scene.Scene);第二步创建对象。Java 里写new Button(点击)脚本里写var button new Button(点击);第三步调用方法。Java 里写button.setText(你好)脚本里写button.setText(你好);你看基本就是一一对应。唯一需要留意的是静态类常量比如javafx.geometry.Pos.CENTER在 JS 里也能访问var Pos Java.type(javafx.geometry.Pos); root.setAlignment(Pos.CENTER);这个映射规则一旦掌握你熟悉的 JavaFX API 在脚本里基本都能用。我实测下来布局容器、控件、样式、动画、图表这些 JavaFX 核心 API 在 Nashorn 里都能正常调通没遇到什么突破不了的障碍。3.2 事件处理拿 JS 函数直接顶替监听器这是全套玩法中最爽的地方。JavaFX 的按钮事件在 Java 里要写button.setOnAction(e - { System.out.println(clicked); });在 Nashorn 里你只需要把一个 JSfunction传进去button.setOnAction(function(event) { print(clicked); });Nashorn 会检测到setOnAction方法需要的参数类型是EventHandlerActionEvent这是一个只有一个抽象方法的接口SAM 接口而你传入的是一个 JS 函数它就自动帮你把这个函数包装成一个EventHandler实例。这个机制对 JavaFX 里几乎所有事件接口都适用setOnAction、setOnMouseClicked、setOnKeyPressed、Platform.runLater(Runnable)全部可以直接传函数。这里有一个值得注意的细节回调函数里的this。Nashorn 把 JS 函数适配成 Java 接口后回调执行的时候this指向什么不同版本的 Nashorn 表现可能不同我建议在事件回调里不要依赖this需要访问控件本身时直接用外层闭包里的变量var textField new TextField(); var label new Label(默认); textField.setOnAction(function(e) { label.setText(textField.getText()); // 直接用闭包变量不要用 this });这样写一方面避开了this指向的坑另一方面也更容易阅读。如果遇到的是多方法接口比如你要添加一个监听集合变化的ListChangeListener它里面有多个方法直接传 JS 函数就不行了这时候要用Java.extend配合对象字面量var ListChangeListener Java.type(javafx.collections.ListChangeListener); var listener new (Java.extend(ListChangeListener))({ onChanged: function(change) { print(list changed); } });3.3 属性绑定与动态界面JavaFX 的一大特色就是属性绑定。在 Java 里你已经用惯了label.textProperty().bind(textField.textProperty())不用担心这套东西在 Nashorn 里完全可用var label new Label(); var textField new TextField(); label.textProperty().bind(textField.textProperty());这样用户往文本框里输入什么标签就实时显示什么。脚本里写绑定和 Java 里一模一样没有任何额外负担。更妙的是Nashorn 的 JS 函数也能直接适配成ChangeListener你可以在属性变化时做更多自定义逻辑textField.textProperty().addListener(function(obs, oldVal, newVal) { print(从 oldVal 变成了 newVal); });如果你的界面需要根据输入动态计算显示内容可以用javafx.beans.binding.Bindings的静态方法来做更复杂的绑定。比如你想让标签实时显示“你输入的是xxx”var Bindings Java.type(javafx.beans.binding.Bindings); label.textProperty().bind(Bindings.concat(你输入的是, textField.textProperty()));这一段是给你展示用脚本写 JavaFX 的灵活性你可以把 Java 里能做的那些绑定技巧原样搬到脚本里界面逻辑依然清晰。4. 完整实操用 jjs 写一个带界面的计算器理论说了这么多下面来个能直接跑起来的完整项目。我会带你做一个简单的四则运算计算器有数字按钮、运算符按钮、显示区和清零操作界面用GridPane布局。先说明一下这个例子我在 JDK 8任意发行版上实测可以跑环境是命令行不依赖任何 IDE。4.1 环境准备与 Launcher 桥接类在动手之前有一个关键问题要解决JavaFX 的Application.launch()方法必须在 Java 代码里被调用而且一个 JVM 中只能调用一次。纯 jjs 脚本里没法直接启动 JavaFX 应用因为Application的启动入口是 Java 类。所以我们需要写一个很小的 Java 桥接类负责在后台启动 JavaFX 运行时并暴露一个Stage对象给脚本使用。新建一个Launcher.java代码如下import javafx.application.Application; import javafx.application.Platform; import javafx.stage.Stage; import java.util.concurrent.CountDownLatch; public class Launcher { private static CountDownLatch latch new CountDownLatch(1); private static Stage stage; public static void startup() { if (stage ! null) { return; } try { new Thread(() - Application.launch(FXApp.class)).start(); latch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } public static Stage getStage() { return stage; } public static class FXApp extends Application { Override public void start(Stage primaryStage) { stage primaryStage; latch.countDown(); } } }这个类的逻辑很简单startup()方法在一个新线程里启动 JavaFX 的Application.launch()当前线程会通过CountDownLatch等一会儿直到start()回调被触发、stage被赋值后才返回。之后脚本就能通过Launcher.getStage()拿到主窗口对象。编译它javac Launcher.java如果你用的是精简过的 JDK编译时提示找不到javafx包就手动把 JavaFX 的 jar 指定进 classpath。JDK 8 里 JavaFX 的 jar 一般在$JAVA_HOME/jre/lib/ext/jfxrt.jar加进去即可javac -cp $JAVA_HOME/jre/lib/ext/jfxrt.jar Launcher.java编译完成后会在当前目录生成Launcher.class。4.2 计算器页面与交互逻辑逐段拆解有了桥接类脚本这边就顺畅了。新建一个calculator.js内容如下var Launcher Java.type(Launcher); Launcher.startup(); var Platform Java.type(javafx.application.Platform); var Scene Java.type(javafx.scene.Scene); var GridPane Java.type(javafx.scene.layout.GridPane); var Button Java.type(javafx.scene.control.Button); var TextField Java.type(javafx.scene.control.TextField); var Insets Java.type(javafx.geometry.Insets); Platform.runLater(function() { var root new GridPane(); root.setHgap(10); root.setVgap(10); root.setPadding(new Insets(20)); var display new TextField(); display.setEditable(false); display.setPrefColumnCount(12); display.setPrefHeight(50); root.add(display, 0, 0, 4, 1); var current ; var operator null; var left null; function calc() { var right parseFloat(current); switch (operator) { case : return left right; case -: return left - right; case *: return left * right; case /: return right 0 ? NaN : left / right; } return right; } function makeButton(text) { var btn new Button(text); btn.setPrefSize(70, 60); btn.setOnAction(function() { if ((text 0 text 9) || text .) { current text; display.setText(current); } else if (text C) { current ; operator null; left null; display.setText(); } else if (text ) { if (operator ! null current ! ) { var result calc(); display.setText(String(result)); current String(result); operator null; left null; } } else { if (current ! ) { left left null ? parseFloat(current) : calc(); current ; operator text; } } }); return btn; } var keys [7,8,9,/,4,5,6,*,1,2,3,-,0,C,,]; for (var i 0; i keys.length; i) { root.add(makeButton(keys[i]), i % 4, Math.floor(i / 4) 1); } var scene new Scene(root, 320, 380); var stage Launcher.getStage(); stage.setTitle(Nashorn Calculator); stage.setScene(scene); stage.show(); });这段代码我看着不长但信息密度不小我一段一段给你拆开讲。开头那段是初始化。Java.type(Launcher)拿到我们刚编译的桥接类调用startup()启动 JavaFX 运行时。然后用Java.type把需要用的 JavaFX 类都引进来。注意Platform.runLater(function() {...})我特意把整个 UI 构建都放进了这里这是为了严格遵守“JavaFX 节点必须在 FX 线程上创建”的规则。虽然你这个脚本的主线程在 JavaFX 8 里创建控件有时候也不报错但既然有规范做法就别赌运气。中间是布局。GridPane是一个网格布局setHgap和setVgap控制格子之间的水平垂直间距setPadding设置内边距需要传一个Insets实例。display是一个不可编辑的TextField用来当计算器的显示屏root.add(display, 0, 0, 4, 1)这行表示它占据从第 0 列第 0 行开始、横向跨 4 列、纵向跨 1 行。再往下是计算逻辑。current存当前输入的数字字符串operator存运算符left存运算符左边的值。calc()函数根据operator做四则运算这里有个防御除数为 0 时返回NaN避免脚本抛出异常。makeButton(text)是一个工厂函数每次调用都会创建一个按钮并给它绑定点击事件。事件回调里的分支逻辑很直白数字和点号就追加到当前输入并刷新显示C就清空一切就执行计算其他运算符就先保存当前值并记录运算符。有一点需要提醒我在makeButton里把text作为参数传入而不是在回调里直接引用循环变量。这是因为 JS 闭包捕获的是变量的引用如果你在循环里直接写btn.setOnAction(function() { ... keys[i] ... })等点击发生时i早就变成循环结束后的值了所有按钮都会拿到同一个字符。用makeButton(text)把每次的text作为参数传进去闭包捕获的是参数变量就不会串。这是一个非常经典且隐蔽的 JS 闭包坑我在自己项目里也栽过不止一次。最后是装配窗口。keys数组定义了按钮的顺序循环里用Math.floor(i / 4)算出行号i % 4算出列号一个个放进网格里。最后创建Scene设置标题和内容调用stage.show()把窗口显示出来。4.3 运行效果与结果验证运行命令很简单jjs -cp . calculator.js如果你前面编译时手动指定过 jfxrt.jar运行也相应带上jjs -cp .:$JAVA_HOME/jre/lib/ext/jfxrt.jar calculator.js窗口弹出来之后你会看到一个 4 行 5 列的计算器界面。我用测试用例跑了一轮结果如下输入12 34 显示46正常。输入5 * 6 显示30正常。连续计算10 5 3 因为left在第二次运算符时用calc()累计了前一次结果所以显示18正常。按C清零后显示区清空能重新输入正常。输入8 / 0 显示NaN符合预期没有崩溃。这个计算器虽然简单但它把“创建控件—布局—事件响应—状态管理—结果展示”这一整套 GUI 开发链路完整地跑通了。你以后要用脚本写 JavaFX 工具这个骨架可以直接当模板改。5. 常见问题与排查技巧实录5.1 高频报错现场与解决办法我在用 jjs 写 JavaFX 的过程中遇到最多的几个报错基本可以归成以下几类整理成速查表给你方便你直接按图索骥报错信息出错原因解决办法java.lang.ClassNotFoundException: Launcher脚本用Java.type引用的类不在 classpath 里编译出.class后用jjs -cp . app.js运行把对应目录加进去TypeError: not a function类引入了但调用的方法名不存在或拼写错误先用jjsREPL 里Java.type(xxx)拿到类打印它有哪些方法核对java.lang.IllegalStateException: Toolkit not initialized在 JavaFX 运行时启动之前就创建了控件确保先调用Launcher.startup()或Application.launch再把 UI 构建放进Platform.runLaterjava.lang.IllegalStateException: Not on FX application thread在非 FX 线程里更新了界面界面刷新逻辑包进Platform.runLater(function() {...})TypeError: Cannot convert ... to ...JS 类型无法自动匹配 Java 方法参数检查是不是传了null、JS 数组是不是需要Java.to转换java.lang.NoClassDefFoundError: javafx/...JavaFX 运行库没在运行时的 classpath 里JDK 8 环境下明确指定jfxrt.jar或换完整版 JDK这表格里的每一条都是我实际运行脚本时真真切切撞到过的。其中Toolkit not initialized和Not on FX application thread是最容易反复撞的尤其是你写脚本写到一定程度开始加定时器、线程、动画的时候线程问题几乎是必现的。5.2 classpath 与 JVM 参数的坑classpath 是玩这套组合拳时最容易翻车的地方而且翻车方式五花八门。最常见的情况是“编译能找到运行找不到”。你用javac编译Launcher.java时可能 IDE 或者某种环境变量帮你把 jfxrt.jar 带进去了编译很顺利。但到了 jjs 运行它不会自动加载 IDE 的那套 classpath你只写了-cp .结果脚本里Java.type(Launcher)找得到但Launcher内部引用的javafx.application.Application找不到了就报NoClassDefFoundError。这种情况你需要在 jjs 的-cp里也把 jfxrt.jar 带上。还有一个隐蔽的坑-cp参数的路径分隔符。Linux/macOS 上用冒号:Windows 上是分号;。如果你在 Windows 上写jjs -cp .:jfxrt.jar app.js那会直接报错。我在 Windows 上踩过这个坑改成分号就好了。JVM 参数方面jjs 通过-J前缀透传给 JVM。比如你想加大堆内存就写jjs -J-Xmx1g app.js。这个用到的场景确实不多但万一脚本里处理的数据量大或者加载了很多图片资源堆溢出报错时你就知道该往哪调了。5.3 调试脚本的实用小技巧脚本调试跟 Java 调试思路不太一样我说几个我自己的土办法好在大多数情况下都够用。第一招善用print()。别嫌它原始在 GUI 程序里事件回调执行的时机往往靠想象是不准的你在回调里塞一个print()就能立刻确认这个回调有没有被触发、触发时参数是什么。这个思路在 JavaFX 脚本里一样适用btn.setOnAction(function(e) { print(clicked, text btn.getText()); });第二招挂 JS 原生异常监听。Nashorn 支持jjs命令行下的--debug参数你可以在脚本顶部设置异常处理器try { buildUI(); } catch (e) { e.printStackTrace(); }Nashorn 的异常对象实现了 Java 的接口可以直接调用printStackTrace()这样能看到完整的 Java 调用栈定位到具体是哪一行脚本调用 Java API 时报的错。第三招小步验证。新手最容易犯的错是写一大坨脚本然后一次运行报错后找半天。正确姿势是先写一个只创建空白窗口的三四行脚本跑通然后加一个Button跑通再加布局跑通最后补事件逻辑。每一步都能跑出问题时定位范围就非常小。我在折腾这个计算器的时候也是先验证窗口、再验证按钮布局、最后才写计算逻辑整个过程非常顺。6. 时代变了但思路没过时说句实话Nashorn 和 jjs 的命运并不长JDK 8 里风风光光地出现到 JDK 11 就被标记为废弃JDK 15 就直接被移除了。现在你用新版本的 JDK 去敲jjs得到的只会是“命令未找到”。对于眼前还想玩一玩这套技术栈的人来说最直接的方案就是装一个 JDK 8或者用一些兼容的实现继续跑旧脚本。如果要拥抱新一点的工具链GraalVM 里带的那套 JS 引擎在互操作能力上比 Nashorn 只强不弱走 Polyglot 方式也能在脚本里调用 Java 类核心思路是相通的。不过那套新东西已经不是本文要展开的范畴了。我个人的看法是就算 Nashorn 已经被官方退役它留下的“以脚本语言驱动 Java 生态”的思路依然非常有价值。我第一次在一个 JavaFX 窗口上通过 jjs 敲出界面并实时交互的时候那种冲击感至今还记得它让我意识到GUI 开发的效率瓶颈很多时候根本不是控件和布局而是“改代码—编译—等待”这个循环。脚本化直接把循环压缩掉了让想法和界面之间的距离变得极短。所以就算你现在不太可能在生产环境里大规模用 jjs 去写 JavaFX 了我仍然建议你把它当作一个玩具和工具去玩一玩。装个 JDK 8把计算器敲进去跑起来或者在 REPL 里随手创建几个控件试试那种“万物皆可脚本驱动”的感觉会让你对 Java 互操作和 GUI 开发框架的理解都上一个台阶。这也是我写了这么多最希望你能带走的东西。