API
执行 API 只有一个类:Phasis\Engine。其他所有内容(JsValue、JsFunction、JsObject、…)都属于引擎内部,只向连接 PHP↔JS 的宿主函数公开。另一个公开接口是独立的 解析器API(Phasis\Parser\Parser 和 Phasis\Ast 类),它只解析源代码,不执行代码。
构造
use Phasis\Engine;
$engine = new Engine(); // lazy mode (default)
$engine = new Engine(eager: true); // install every built-in up front
新建的 Engine 会公开完整的标准库——Object、Array、String、Number、Symbol、BigInt、Promise、RegExp、Error 以及核心语言内置功能都会立即安装,因此规范能够正常工作(没有 Function.prototype 就无法使用类,没有 Promise 就无法使用 async/await,没有 RegExp 就无法使用正则表达式字面量)。其他所有内容——Math、JSON、Map、Set、Date、TypedArray 和 array-buffer 系列、Atomics、Proxy、Reflect、console、Intl、weak-ref 系列、Temporal、ShadowRealm、Web Platform Pack 和 Fetch Pack——都会注册为占位访问器,并在首次读取时实例化。读取全局对象时无需特殊处理:Map、typeof Map、'Map' in globalThis、Object.keys(globalThis).includes('Map') 都能正常工作;首次读取后,占位符会被常规数据描述符替换。
如果确实需要预先惰性加载之前的行为,可传入 eager: true——构造时安装所有内置功能,并让完整的描述符接口可供内省。符合性测试运行框架(bin/test262、bin/wpt)会以预先加载模式构造引擎,以便 test262/WPT 能看到预期的描述符结构。
求值
public function eval(string $source): mixed
将 $source 作为经典脚本在引擎的全局作用域中解析并执行,然后运行事件循环直到队列清空。返回最后一个求值表达式的值,并将其转换为 PHP(如果程序末尾没有表达式,则返回 null)。转换规则见值转换。
$result = $engine->eval('1 + 2'); // 3
$result = $engine->eval('JSON.stringify({a: 1})'); // '{"a":1}'
$result = $engine->eval('[1,2,3].map(x => x * 2)'); // [2, 4, 6] (PHP array)
Promise 不会被解包:eval('Promise.resolve(5)') 会返回一个空数组。由于 eval() 返回时事件循环已经清空,请将已完成的值存入全局变量,再通过第二次 eval() 读取:
$engine->eval('let out; (async () => { out = await Promise.resolve(9); })();');
$engine->eval('out'); // 9
未被捕获的显式 JS throw 会作为 Phasis\Exceptions\JsThrowable 显现,其消息为 "Name: message",而其 $jsValue 属性保存抛出的 JsValue。引擎自身引发的错误,例如读取 null 的属性或使用未定义变量,会显示为 Phasis\Exceptions\TypeError、ReferenceError 或 RangeError。这些错误都继承自 Phasis\Exceptions\RuntimeError;解析错误则继承自 Phasis\Exceptions\SyntaxError。参见异常。
try {
$engine->eval('throw new Error("boom")');
} catch (\Phasis\Exceptions\JsThrowable $e) {
echo $e->getMessage();
// Error: boom
}
evalAsModule
public function evalAsModule(string $source, ?string $path = null): mixed
将 $source 作为 ES 模块求值:支持 import / export、顶层 await 和 import.meta,相对模块标识符以 $path(绝对文件路径)为基准解析。运行事件循环的方式与 eval() 相同,并且始终返回 null;请通过全局变量或控制台缓冲区读取结果。
$path = __DIR__ . '/entry.mjs';
$engine->evalAsModule(file_get_contents($path), $path);
加载器只解析相对路径和绝对路径;参见已知限制。
execFile
public function execFile(string $path): mixed
从磁盘读取文件并将其内容传给 eval(),因此无论文件扩展名是什么,都会作为经典脚本运行;扩展名为 import / export 的文件会导致 SyntaxError。文件的实际路径会成为动态 import() 标识符的基准路径。模块文件请使用 evalAsModule()。
$engine->execFile(__DIR__ . '/script.js');
如果无法读取文件,PHP 会发出警告,而 execFile() 会抛出普通的 \RuntimeException(“Cannot read file: …”)。
setGlobal
public function setGlobal(string $name, mixed $value): void
绑定一个全局变量,供之后所有 JS 求值使用。PHP 值会转换为 JS:
| PHP 类型 | JS 类型 |
|---|---|
null |
null |
bool |
boolean |
int / float |
number |
string |
string(即使它对应一个函数,例如 'strlen') |
| 数组(列表) | Array(副本) |
| 数组(关联数组) | Object(副本) |
Closure、可调用对象、[$object, 'method'] |
function |
| 其他任何对象、生成器或资源 | undefined |
数组会被复制:JS 对数组所做的更改不会反映到 PHP 数组中。暂时还不能传递普通的 PHP 对象(问题 #18);变通方法见共享对象。
$engine->setGlobal('config', ['debug' => true, 'version' => '1.0']);
$engine->setGlobal('log', fn(string $msg) => error_log($msg));
在 JS 内:
console.log(config.version); // "1.0"
log('hello'); // calls the PHP closure
调用
public function call(string $name, mixed ...$args): mixed
调用一个全局 JS 函数,并传入 PHP 参数(转换方式与 setGlobal 相同)。返回值会转换回 PHP。调用未定义的名称会抛出 Phasis\Exceptions\ReferenceError;调用不是函数的全局变量会抛出 Phasis\Exceptions\TypeError。
$engine->eval('function add(a, b) { return a + b; }');
echo $engine->call('add', 2, 3); // 5
控制台输出
public function getConsoleOutput(): string
public function getConsoleLines(): array
public function clearConsole(): void
console.log、console.info、console.warn、console.error 和 console.debug 不会直接输出内容。每次调用都会向引擎中的缓冲区追加一行格式化文本。getConsoleLines() 会以字符串列表的形式返回缓冲行,getConsoleOutput() 会用 \n 将它们连接起来,而 clearConsole() 会清空缓冲区。多次调用 eval() 之间不会清空缓冲区。
$engine->eval('console.log("a", 1); console.warn({ b: 2 })');
$engine->getConsoleLines(); // ['a 1', '{ b: 2 }']
echo $engine->getConsoleOutput();
// a 1
// { b: 2 }
$engine->clearConsole();
setLimit
public function setLimit(string $limit, int $value): void
设置一个可在运行时调整的资源限制。只有一项:
| 限制 | 默认值 | 说明 |
|---|---|---|
maxLoopIterations |
100000 |
树遍历解释器每次运行 for / while / do…while 循环时允许的最大迭代次数 |
$engine->setLimit('maxLoopIterations', 1_000_000);
其他名称都会被静默忽略;字符串长度、控制台输出和运行时间都不受限制。
循环限制目前尚未在所有地方生效。由字节码 VM 运行的循环(包括函数体和字节码编译器接受的顶层程序)不会计数,因此 while (true) {} 会一直运行,直到 PHP 将其停止(问题 #19)。不要依赖此限制来阻止不受信任的代码;请使用 PHP 的 max_execution_time 或单独的进程。
达到限制时,错误可由 JS try / catch 捕获(作为一条消息为“Maximum loop iterations exceeded”的 Error)。如果没有捕获,它会以 Phasis\Exceptions\InternalError 的形式传到 PHP,并继承自 Phasis\Exceptions\RuntimeError,而不是 JsThrowable。
引擎还内置了两个无法在运行时调整的上限:Phasis\Runtime\CallStack::maxDepth(4,096 个 JS 栈帧)和 JsToPhp 转译闭包的深度保护(8,192 个 PHP 栈帧)。超过任一上限都会抛出“Maximum call stack size exceeded”,并同样以 InternalError 的形式传到 PHP。这些上限远高于任何正常的 JS 程序所需;提高上限需要修改代码,不能通过配置实现。
重置
public function reset(): void
清除所有用户定义的全局变量和控制台缓冲区,并重建标准库和事件循环。
对于以默认惰性模式构造的引擎,reset() 目前会丢失所有惰性安装的内置功能:之后 Map、JSON、Math、console、fetch 等都会变成未定义(问题 #22)。此问题修复前,请改为构造一个新的 Engine,或使用 eager: true 构造;这种模式下 reset() 可以正常工作。
事件循环
引擎自带一个 JavaScript 事件循环,全局对象上提供 setTimeout、setInterval、clearTimeout、clearInterval 和 queueMicrotask。微任务(Promise 处理程序、queueMicrotask)会在每个宏任务之间执行完毕;计时器则通过每个 realm 的任务队列按截止时间顺序触发。
public function runEventLoop(): void
public function tickEventLoop(): bool
public function pendingTaskCount(): int
public function getEventLoop(): \Phasis\Runtime\EventLoop
eval() 和 evalAsModule() 会在同步代码执行完毕后自动调用 runEventLoop()。因此,在顶层安排 setTimeout(cb, 5000) 的脚本会在 eval() 返回前运行 cb——事件循环使用虚拟时间,将时钟直接跳到下一个待处理的截止时间,而不是实际等待 PHP。符合 node script.js 的语义。
如果要进行实时嵌入(长期运行的服务器、UI 集成、根据实时时钟进行调度),tickEventLoop() 会运行一次循环迭代(执行完微任务、触发到期的计时器)然后返回。pendingTaskCount() 会报告尚未完成的计时器;嵌入方可以自行驱动事件循环:
while ($engine->pendingTaskCount() > 0) {
$engine->tickEventLoop();
usleep(1000);
}
失控的 setInterval 会受到限制:runEventLoop() 最多运行 100,000 个周期,然后抛出普通的 \RuntimeException——JS 代码中的错误会以清晰的 PHP 错误显示,而不会导致卡死。该间隔计时器仍处于调度状态,因此此引擎上的后续每次 eval() 都会抛出相同错误;之后请丢弃该引擎。计时器回调中的未捕获错误会通过 error_log() 发出,而事件循环会继续执行;单个计时器出错不会终止程序。
Fetch 钩子
Phasis 默认启用 fetch(),并以 PHP 的 ext-curl 作为底层传输。三个钩子可让嵌入方替换传输方式、控制请求,或启用 Cookie 存储。三者都接受 PHP 可调用对象或对象上的方法;值的结构约定见Web API。
setFetchTransport
public function setFetchTransport(callable $transport): void
替换默认的 CurlTransport。可调用对象会收到请求描述符和可选的 AbortSignal JsObject,并返回响应描述符。
// Use Guzzle as the transport instead of curl
$client = new GuzzleHttp\Client();
$engine->setFetchTransport(function (array $req, $signal) use ($client) {
$r = $client->request($req['method'], $req['url'], [
'headers' => $req['headers'],
'body' => $req['body'],
'http_errors' => false,
]);
return [
'status' => $r->getStatusCode(),
'statusText' => $r->getReasonPhrase(),
'headers' => array_map(
fn ($name, $values) => array_map(fn ($v) => [$name, $v], $values),
array_keys($r->getHeaders()),
$r->getHeaders(),
),
'body' => (string) $r->getBody(),
];
});
常见的嵌入模式:在测试中安装模拟传输,在生产环境中安装真实的 curl 传输。
setFetchPolicy
public function setFetchPolicy(callable $hook): void
请求发送前的策略钩子。接收 Request JsObject;抛出异常可拒绝请求,返回修改后的 Request 可重写请求,返回 null 或不返回值则表示允许。
// Block non-HTTPS and inject auth on a specific host
$engine->setFetchPolicy(function ($req) {
$url = $req->get('url')->value;
if (str_starts_with($url, 'http://')) {
throw new \RuntimeException('HTTPS only');
}
if (str_contains($url, 'api.example.com')) {
$req->get('headers')->call('set', 'Authorization', 'Bearer ...');
}
return null;
});
setCookieJar
public function setCookieJar(mixed $jar): void
选择启用 Cookie 存储。接受任何公开 get(string $url): string 和 set(string $url, string $header): void 的对象——可在 PHP 端或 JS 端实现。
class SimpleJar
{
private array $cookies = [];
public function get(string $url): string { return implode('; ', $this->cookies); }
public function set(string $url, string $header): void { $this->cookies[] = $header; }
}
$engine->setCookieJar(new SimpleJar());
如果没有 setCookieJar(),fetch 会忽略传入的 Set-Cookie 标头和传出的 Cookie 标头——默认情况下不使用 Cookie。
异常
| 类 | 继承自 | 适用情况 |
|---|---|---|
Phasis\Exceptions\JsThrowable |
RuntimeError |
显式 JS throw 未被捕获 |
Phasis\Exceptions\TypeError、ReferenceError、RangeError |
RuntimeError |
引擎自身引发的错误(例如 null.x 或未定义变量)未被捕获 |
Phasis\Exceptions\InternalError |
RuntimeError |
超过循环限制或调用栈上限 |
Phasis\Exceptions\RuntimeError |
\RuntimeException |
上述类的基类 |
Phasis\Parser\ParseError |
Phasis\Exceptions\SyntaxError(\RuntimeException) |
源代码无法解析 |
\RuntimeException |
execFile() 无法读取文件;事件循环达到 100,000 周期上限 |
若要捕获脚本的所有错误,可捕获 Phasis\Exceptions\RuntimeError 和 Phasis\Exceptions\SyntaxError,或者直接捕获 \RuntimeException。
你自己的 PHP 宿主函数抛出的异常情况有所不同:目前这些异常会原样通过 JS try / catch 传到 eval() 的调用方,并保留其原始类(问题 #20)。参见宿主函数。
生命周期
构造 Engine 的开销很低(冷启动只需几毫秒),标准库也会惰性安装。对于大多数嵌入场景,每个进程使用一个长期运行的引擎即可。对于多租户用途,请为每个租户或请求创建单独的引擎,以免全局变量在租户间泄漏。目前,创建新引擎是可靠的清理方式(参见reset)。
foreach ($requests as $req) {
$engine = new Engine();
$engine->setGlobal('request', ['path' => $req->path, 'query' => $req->query]);
$engine->execFile($req->scriptPath);
}