知序
返回
保存草稿
发布
H1
H2
H3
1
2
3
Excalidraw 图
AI 写作
AI 优化
目录
输入内容后显示目录
Markdown
# 03 - Context API 核心 > 这篇解决什么问题:上一篇你用老接口 `ScriptEngine` 跑通了第一段 JS,但它拿不到 `Value`、控不了沙箱、异常也糊成一片。官方的推荐入口其实是 `org.graalvm.polyglot.Context`。这篇把一个 `Context` 讲到底:它是怎样一个"隔离环境"、生命周期怎么管理、`eval` 的返回值为什么有时是结果有时是 `undefined`、以及怎么从"全局作用域"里取回声明过的函数和变量。 > 读完你会知道:`Context` 作为隔离运行时的完整心智模型、它与 `ScriptEngine` 在生命周期与返回值上的根本差异、表达式 vs 声明两种代码在 `eval` 后去向何处的判别、以及 `getBindings("js")` 这个"全局作用域窗口"怎么用。 --- ## 1. Context 到底是什么:一个"JS 隔离环境" 用一句话建立心智:**`Context` 相当于给 JS 代码准备的一个独立运行时环境**——类似一个迷你的浏览器窗口。JS 的全局变量、函数、模块状态,全都活在某个 `Context` 里,彼此之间用 `Context` 做物理边界隔开。  *图:Context 隔离环境与 eval 的两种返回值* ``` ┌───────────── 一个 Context ──────────────────┐ │ │ │ JS 全局作用域(Bindings) │ │ ├── var 声明的变量 │ │ ├── function 声明的函数 │ │ └── 由 putMember 注入的 Java 对象 │ │ │ │ 执行引擎(解析 → Truffle AST → JIT) │ └─────────────────────────────────────────────┘ ``` ### 1.1 拆开"隔离环境"这句话的三个含义 1. **全局状态是"有记忆"的**:你在一个 `Context` 里先 `eval` 注册了函数,后面的 `eval` 就能把它取出来调用。这跟"每次执行一条独立、无状态"的认知相反——**同一个 `Context` 内部是连续的、共享状态的**(后文的 fibonacci 就是靠这个特性)。 2. **不同 `Context` 之间是隔离的**:A `Context` 里声明的变量,B `Context` 看不见。隔离是它作为"环境"的第一职责。 3. **执行引擎是同一颗**:全局作用域之上那行"解析 → Truffle AST → JIT"提醒你——不管建几个 `Context`,底层都是同一颗 GraalJS 引擎(Truffle 解释 + Graal JIT),`Context` 隔离的是"状态空间",不是"引擎本体"。 ### 1.2 生命周期的两种创建法 - `Context.create("js")`:用**默认配置**一步建好,最省事。没有额外的沙箱权限,够跑纯计算。 - `Context.newBuilder("js").build()`:可**逐一配置**(沙箱权限、宿主访问等),是"要控制能力"时的入口(后文 JS 调 Java、沙箱隔离篇都在这里加选项)。 > 一句话:**`Context` = 一个自己带全局状态、与其它环境隔离的 JS 运行时;创建分"默认一步建"和"构建器细配"两种。** --- ## 2. 生命周期管理:为什么推荐 try-with-resources ```java try (Context context = Context.create("js")) { // ... 用这个 context 做所有事 } ``` `Context` 实现了 `AutoCloseable`,`try` 块结束自动关闭。关闭的本质是**释放 JS 引擎占用的原生资源**——这不是可有可无的收尾,而是资源管理的一部分。这是 Context API 与 ScriptEngine 的第一个显著差异:**生命周期由你显式可控**。ScriptEngine 那种"拿引擎用到天荒地老"的写法在这里不合适。 **要点**:`Context` 要复用(同一个环境里先注册函数再用,才有意义),但**用完了就关**,别无限挂着占用原生资源。至于"一个长生命周期脚本该不该长期驻留一个 `Context` 做预热",这是 JIT 预热话题,第 07 篇会专门谈——先把"用完即关"这个卫生习惯立起来。 > 一句话:**`Context` 实现 `AutoCloseable`,推荐 `try-with-resources` 用完即关,显式管好引擎原生资源的生命周期。** --- ## 3. eval 的返回值:表达式 vs 声明——这是全篇最关键的分界 `context.eval("js", code)` 永远返回一个 `Value`,但**返回的 `Value` 具体是什么,取决于你给的是"表达式"还是"声明语句"**。这是 Context 与 ScriptEngine 最需要花力气搞清的差异点。 ```text 表达式(有值) → eval 直接返回结果 "40 + 2" → Value(42) "(x, y) => x + y" → Value(函数本身) 声明语句(无值) → eval 返回 undefined,效果是注册到全局作用域 "function fibonacci(n){...}" → 全局作用域多了 fibonacci "var x = 42" → 全局作用域多了 x ``` 把这个分界记死,后文所有"要不要用 `getBindings`"的选择都由此而来: - **表达式**:有值,`eval` 直接把那个值包成 `Value` 给你,不需要走别的窗口。 - **声明语句**:本身无值——`eval` 返回 `undefined`,它的真实效果是**把 `fibonacci` / `x` 注册进了当前 `Context` 的全局作用域**。 那么问题来了:声明语句注册了东西,**之后怎么取?** 答案就是第 3.1 节的 `getBindings`。 > 一句话:**`eval` 对"表达式"直接返回结果;对"声明语句"返回 `undefined` 并把名字注册进全局作用域——取声明成果要去全局作用域,取表达式结果直接用返回值。** ### 3.1 getBindings:通向"全局作用域"的窗口 声明语句注册的东西不在 `eval` 的返回值里,你得去**全局作用域**取。方法就是: ```java context.getBindings("js").getMember("名字") ``` `getBindings("js")` 返回当前 `Context` 的 JS 全局作用域对象——它等价于浏览器里的 `window`,也等价于 JS 代码里的 `globalThis`。`getMember("名字")` 就是 `window["名字"]`。 ```text JS 侧 Java 侧 var x = 42 ←──────────→ getBindings("js").getMember("x") function foo(){} ←──────────→ getBindings("js").getMember("foo") (表达式结果) ←──────────→ eval(...) 的返回值直接用 ``` 上表最后一行点破了两者的分工:**声明的东西走 `getBindings`,表达式的结果走 `eval` 返回值**——两条路,不要混。 > 一句话:**`getBindings("js")` = JS 全局作用域 = `window` 等价物;声明注册的成员用 `getMember(name)` 取,等价于 JS 写 `window[name]`。** --- ## 4. 逐段跑起来:六步看懂完整链路 ### 4.1 建 Context(try-with-resources) ```java try (Context context = Context.create("js")) { ``` 这行已经讲过,它是往后所有示例的骨架。 ### 4.2 执行表达式:eval 的返回值就是结果 ```java Value result = context.eval("js", "40 + 2"); System.out.println("40 + 2 = " + result.asInt()); // 40 + 2 = 42 ``` `"40 + 2"` 是表达式,`eval` 返回 `Value(42)`;`asInt()` 把 JS 数字转成 Java 的 `int`。这是"表达式 → 直接拿结果"的最直观演示。 ``` 40 + 2 = 42 ← eval("40 + 2") 返回 Value(42),asInt() = 42 ``` ### 4.3 声明函数:只注册,函数体不执行 ```java context.eval("js", """ function fibonacci(n) { if (n <= 1) return n; return fibonacci(n - 1) + fibonacci(n - 2); } """); ``` 这段 `function` 声明,按第 3 节的分界,它的效果是**注册**——`fibonacci` 挂到当前 `Context` 的全局作用域,但函数体此刻一行都不跑。对应"声明语句 → 去全局取"。 ### 4.4 从全局作用域取引用再调用 ```java Value fib = context.getBindings("js").getMember("fibonacci"); System.out.println("fibonacci(20) = " + fib.execute(20).asInt()); // fibonacci(20) = 6765 ``` 这里把 3.1 用上了:`getBindings("js")` 拿全局作用域,`getMember("fibonacci")` 把那个函数**引用**(包成 `Value`)取出来,`execute(20)` 传入参数并调用,返回 `Value(6765)`,再 `asInt()`。 ``` fibonacci(20) = 6765 ← getBindings 取引用 → execute(20) → asInt() ``` ### 4.5 函数表达式:eval 直接返回函数 ```java Value add = context.eval("js", "(x, y) => x + y"); System.out.println("(x, y) => x + y 的调用结果 = " + add.execute(3, 4).asInt()); // ... = 7 ``` 箭头函数是**表达式**,所以 `eval` **直接返回函数本身**,`add` 就是一个可 `execute(3, 4)` 的 `Value`——这步**不需要** `getBindings`,因为函数作为"表达式的值"已经到手了。对比 4.3/4.4:同样是用一个 JS 函数,**声明的走 `getBindings` 取,表达式的直接收 `eval` 返回值**。这一对比是理解整个 API 的关键。 ``` (x, y) => x + y 的调用结果 = 7 ← 表达式直接返回函数 → execute(3,4) → asInt() ``` > 一句话:**同一份 JS 函数,用 `function` 声明 → `getBindings` 去全局取;用箭头函数表达式 → `eval` 直接返回。判别依据就是第一个表里"表达式 vs 声明"。** --- ## 5. 两个值得停下来想清楚的问题 ### 5.1 getBindings 和 eval 返回值,都是 Value,区别在哪 两者都返回 `Value`,但语义分属两个"东西": | 表达式 | 含义 | 典型用途 | |---|---|---| | `eval("js", code)` | 执行代码,返回**代码的值** | 执行表达式、跑脚本 | | `getBindings("js")` | 返回**JS 全局作用域对象**(`window` 等价物) | 取 `var`/`function` 注册的全局成员 | 记忆口诀:**表达式用 eval 的返回值;声明语句用 getBindings 去全局作用域取。** > 💬 **Q:同一个变量 `var x = 42`,为什么有时用 eval 返回、有时用 getBindings?** > > 因为它们本就不是一回事:`eval("js", "42")` 的返回值是那个"值本体";`eval("js", "var x = 42")` 是声明,返回值是 `undefined`,要对 `x` 做任何事都得从 `getBindings("js")` 里取 `x`。**分清"值"和"注册的名字"是关键**。 ### 5.2 为什么 fibonacci(20) 是 6765 这不是魔法,是斐波那契数列的第 20 项(从 0 起数):0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, **6765**。 示例用它,是为了演示一个"手感":**Java 通过 `execute` 调用的是按 JS 语义执行的递归函数**——`fibonacci` 是 JS 里的函数,它的递归、作用域、参数绑定全都遵循 JS 规则,Java 侧只是"敲门"把它叫起来。 > 💬 **Q:`fibonacci` 递归实现,参数一大(如 40+)会不会很慢?** > > 会。这是朴素的指数级递归(`T(n) = T(n-1) + T(n-2)`),参数从 20 涨到 40 计算量指数爆炸。它引出一个真实风险:**当 Java 在一个"热循环"里反复调用 JS 递归函数时,性能可能成为瓶颈**。缓解思路不外乎:换迭代/记忆化实现、或用引擎预热发挥 JIT 能力(第 07 篇谈)。这里先意识到"跨语言调用并不消除算法的复杂度"。 > 一句话:**`eval` 返回值 vs `getBindings` 全局窗是两条互补的路;`fibonacci(20)=6765` 说明 Java 调用的是纯 JS 语义的函数,算法复杂度照旧。** --- ## 6. Context 的边界问题:隔离、并发与资源 把一个 `Context` 当"隔离环境"用,就得想清楚它的边界到底划在哪儿。这关系到你建几个、怎么复用、怎么防串扰。 ### 6.1 隔离的是"状态",不隔离"内存" `Context` 之间的隔离,隔离的是**全局作用域状态**——A 里 `var x` 了,B 看不到。但它**不是内存沙箱**:A 和 B 底层是同一颗引擎、同一个 JVM,共享的都是同一片堆。所以"隔离"要理解成"作用域隔离",不是"进程隔离"。第 06 篇讲的沙箱(能禁 IO、禁文件、禁类访问)是更彻底的隔离,那是另一个维度的事,别把两者混为一谈。 ### 6.2 何时新建、何时复用:一条务实的取舍 前面说过 1.3 的"复用 vs 用完即关"要看预热成本。把这条落成可执行的规则: | 场景 | 建 Context 的策略 | 理由 | |---|---|---| | 一次性跑一段脚本,跑完就丢 | 每次新建,用完即关 | 无需共享状态,生命期短 | | 长驻进程里反复执行同一类脚本、想享受 JIT 预热 | **建一个长期复用的 Context** | 复用让引擎积累热点,性能接近 V8 | | 不同来源的脚本,相互不能看到状态 | **各自独立的 Context** | 用 Context 做天然的隔离边界 | 这里最值得记的是**"一句话往一个 Context"这条分流还要再叠加"预热收益"的权衡**——这正是内嵌引擎挑结构时最费心思的地方,第 07 篇会带着性能数据专门展开。 ### 6.3 Context 不是线程安全的 和 ScriptEngine 一样,`Context` 本身**不是为多线程并发设计**的。多个线程同时 `eval` 同一个 `Context`,结果不可预期。要么一个线程独占一个 Context,要么用锁串行化访问。设计时把"线程"和"Context"的归属关系提前定清,能省掉一大堆诡异的并发 bug。 > 一句话:**`Context` 隔离的是作用域状态而非内存;取舍是"长驻复用吃预热 vs 每次新建求干净",且 `Context` 默认不保证线程安全,并发要自己管。** --- ## 7. 本篇小结 ```text ┌──────────────────────────────────────────────────────┐ │ 03 Context API 核心 要带走的话 │ ├──────────────────────────────────────────────────────┤ │ 1. Context = 自带全局状态的隔离 JS 运行时 │ │ 2. try-with-resources 用完即关,显式管原生资源 │ │ 3. eval 分界:表达式返回值直接取,声明注册全局作用域 │ │ 4. getBindings("js") = window,getMember 取声明成员 │ │ 5. 同一函数:声明走 getBindings,箭头函数走 eval 返回 │ │ 6. 跨语言调用不消除算法复杂度,热循环要防递归爆炸 │ └──────────────────────────────────────────────────────┘ │ v 已经能"建隔离环境 + eval + 取全局",下一步吃透值怎么在跨语言边界转换 ──→ 下一篇:04 - Value 类型系统 ``` ## 思考题(自测) 1. 如果连续 `eval` 两段代码:`var x = 1;` 和 `x + 1`,第二次 `eval` 能拿到 2 吗?为什么?(提示:结合第 3 节的表达式/声明分界) 2. 你在 A 这个 `Context` 里注册了 `var x = 1`,在另一个新建的 `Context` 里能看到 x 吗?这说明 `Context` 的隔离边界到底划在哪? 3. `fibonacci` 用递归实现,参数变大(如 40+)会很慢。如果 Java 在一个热循环里反复调用 JS 递归函数,会有哪些性能隐患?有哪些可用的缓解手段?
预览
发布文章
添加摘要让读者更快了解文章内容
取消
确认发布
AI 写作助手
输入一个主题,AI 将为你生成一篇 Markdown 博客文章
AI 正在写作中...
取消
开始生成
AI 优化助手
描述你的修改需求,AI 将在原文基础上进行补充或微调
AI 正在优化中...
取消
开始优化
画布
从已有图里选
✕
图名
保存后生成 /images/excalidraw/<图名>.svg(只能用字母、数字、短横线、下划线、点)
取消
保存并插入