很多桌面工具的最后一步,都是"让用户写点逻辑":
不同的医院、不同的产线、不同的客户,业务规则不一样,但又不值得每次改代码重新发版。
这时候把 JavaScript 嵌进 C# 是一个很实用的选择。本文用一个真实的 Windows 自动化工具为例,
从
Engine的最简用法讲到线程、超时、内存、事件队列和一堆踩过的坑。技术栈:.NET Framework 4.8 + Jint 4.16.3(.NET 6/8 同样适用)。
一、先选型:为什么是 Jint
C# 里跑 JavaScript,常见方案有四类:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Jint | 纯托管 JS 解释器 | 无原生依赖、部署简单、可限制语句/内存/递归 | 不是 V8,性能一般;没有 DOM/Node |
| WebView2 | 嵌入 Chromium | 完整浏览器能力 | 重、需要运行时、和 C# 通信跨进程 |
| ClearScript | 嵌入 V8/JScript | 性能好、生态成熟 | 原生 DLL,部署和架构匹配麻烦 |
| Node.js 子进程 | 独立进程 | 生态最全 | 进程/协议/生命周期复杂 |
本项目的选择是 Jint,原因很实际:
- 目标环境是 .NET Framework 4.8,很多现场机器不能随便装运行时;
- 脚本只做业务编排,不需要 DOM,也不需要 npm;
- 分发只要几个托管 DLL,不会因为 VC++ 运行库/架构问题装不上;
- 可以对脚本做硬限制:超时、语句数、递归深度、内存。
Jint 不是"把 Chrome 塞进 C#",它就是一个 JS 解释器。你要清楚它能做什么、不能做什么。
二、五分钟入门
2.1 安装
bash
dotnet add package Jint --version 4.16.3
2.2 最小示例
csharp
using Jint;
var engine = new Engine();
engine.SetValue("log", new Action<object>(value =>
Console.WriteLine("[js] " + value)));
engine.Execute(@"
function add(a, b) {
return a + b;
}
log('hello from javascript');
");
var result = engine.Invoke("add", 1, 2);
Console.WriteLine(result); // 3
三个核心 API:
| API | 作用 |
|---|---|
Execute(string) |
执行一段脚本,不关心返回值 |
Evaluate(string) |
执行表达式并返回 JsValue |
Invoke(name, args) |
调用 JS 里已定义的函数 |
SetValue(name, value) |
把 C# 的值/委托暴露给 JS |
GetValue(name) |
从 JS 取变量,返回 JsValue |
取值:
csharp
JsValue value = engine.Evaluate("1 + 2");
object clr = value.ToObject(); // long / double / string / bool ...
int number = Convert.ToInt32(engine.Evaluate("21 * 2").ToObject());
2.3 把 C# 方法暴露给 JS
最简单的方式是委托:
csharp
engine.SetValue("now", new Func<string>(() =>
DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")));
engine.SetValue("add", new Func<double, double, double>((a, b) => a + b));
engine.Execute(@"
log('现在:' + now());
log('1 + 2 = ' + add(1, 2));
");
也可以暴露对象,但生产环境要谨慎:
csharp
engine.SetValue("patient", new PatientDto
{
Id = "P001",
Name = "张三"
});
js
log(patient.Name);
建议:只暴露少量、明确的函数,不要 SetValue("db", realDatabase)。
活的数据库连接、WinForms 控件、COM 对象一旦进入 JS,生命周期、线程、异常都会失控。
三、推荐的宿主 API 设计:一个 JSON 桥
3.1 为什么不直接暴露 CLR
直接把 C# 对象暴露给 JS,短期很爽,长期很痛:
- JS 拿到的是活对象,什么时候释放、在哪个线程调用都不明确;
- C# 方法抛出的异常跨语言边界后信息丢失;
- 脚本可以调用对象上的任何公开成员,等于把内部实现暴露成 API;
- 版本升级时,脚本和 C# 强耦合。
更稳的做法是:只暴露一个函数,所有调用走 JSON。
csharp
engine.SetValue("__call",
new Func<string, string, string>(Host.Call));
C# 侧:
csharp
public string Call(string method, string json)
{
try
{
object value = Dispatch(method, json);
return Json.Serialize(new Dictionary<string, object>
{
["value"] = value
});
}
catch (Exception ex)
{
return Json.Serialize(new Dictionary<string, object>
{
["error"] = ex.Message
});
}
}
JS 侧封装:
js
function call(method, args) {
var response = JSON.parse(__call(method, JSON.stringify(args || {})));
if (response.error) throw new Error(response.error);
return response.value;
}
var app = {
log: function (value) {
return call('log', { text: String(value) });
},
readPatient: function (id) {
return call('readPatient', { id: String(id) });
}
};
这样做的收益:
- 边界清晰:JS 只能调用你列出的 method;
- 可序列化:返回值是纯数据,不把 COM/控件对象漏出去;
- 错误可控 :C# 异常变成
{error},JS 侧统一throw new Error; - 可测试:宿主可以换成 mock,不需要真的启动目标程序;
- 可演进:以后换 Jint/ClearScript/远程进程,JS 侧 API 不变。
3.2 参数校验放在哪
放在 C# 侧,不要只放在 JS 侧。
JS 侧校验只是"用户体验",C# 侧校验才是"安全边界"。因为:
- 脚本可以被用户直接改;
- 控制台可以执行任意 JS;
- 另一个脚本可以通过
runScript传任意 JSON 进来。
csharp
case "readPatient":
string id = Text(args, "id");
if (!Regex.IsMatch(id, "^[A-Za-z0-9_-]{1,32}$"))
throw new ArgumentException("id 格式不正确");
return ReadPatient(id);
把"脚本能做什么"限制在明确的 method 列表里,而不是"脚本能访问整个 CLR"。
四、一个可运行的脚本宿主
下面是一个最小但完整的宿主:一个脚本、一个线程、一个引擎、一个串行队列。
4.1 C# 宿主
csharp
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Web.Script.Serialization;
using Jint;
public sealed class JsSession : IDisposable
{
private readonly BlockingCollection<Action> _queue =
new BlockingCollection<Action>(128);
private readonly CancellationTokenSource _stop = new();
private readonly Thread _thread;
private readonly string _source;
private Engine _engine;
private volatile bool _ready;
private static readonly JavaScriptSerializer Json =
new JavaScriptSerializer { MaxJsonLength = 16 * 1024 * 1024 };
public JsSession(string source)
{
_source = source;
_thread = new Thread(Run)
{
IsBackground = true,
Name = "Jint-Session"
};
_thread.SetApartmentState(ApartmentState.MTA);
_thread.Start();
}
private void Run()
{
try
{
_engine = new Engine(options => options
.TimeoutInterval(TimeSpan.FromSeconds(2))
.MaxStatements(200_000)
.LimitRecursion(64)
.CancellationToken(_stop.Token));
_engine.SetValue("__call",
new Func<string, string, string>(CallHost));
_engine.Execute(JsLibrary.Source);
_engine.Execute(_source);
_ready = true;
Console.WriteLine("[host] script started");
// 所有事件/控制台输入都从这条队列进入引擎
foreach (Action job in _queue.GetConsumingEnumerable(_stop.Token))
{
_engine.Constraints.Reset();
job();
}
}
catch (OperationCanceledException)
{
// 正常停止
}
catch (Exception ex)
{
Console.Error.WriteLine("[host] script stopped: " + ex);
}
finally
{
_stop.Cancel();
_queue.CompleteAdding();
}
}
private string CallHost(string method, string json)
{
try
{
object value = Dispatch(method, json);
return Json.Serialize(new Dictionary<string, object>
{
["value"] = value
});
}
catch (Exception ex)
{
return Json.Serialize(new Dictionary<string, object>
{
["error"] = ex.Message
});
}
}
private object Dispatch(string method, string json)
{
var args = Json.Deserialize<Dictionary<string, object>>(json ?? "{}");
switch (method)
{
case "log":
Console.WriteLine("[js] " + Text(args, "text"));
return true;
case "readPatient":
return new
{
Id = Text(args, "id"),
Name = "张三",
Age = 42
};
default:
throw new ArgumentException("unknown host method: " + method);
}
}
private static string Text(
Dictionary<string, object> args, string key)
{
object value;
return args.TryGetValue(key, out value) && value != null
? Convert.ToString(value)
: "";
}
public void Post(string kind, string json)
{
// 脚本还没启动成功时不要投递,避免 _engine 为空
if (!_ready)
{
Console.Error.WriteLine("[host] session not ready, dropped: " + kind);
return;
}
// 可以从任何线程调用,但 JS 只在脚本线程执行
if (!_queue.TryAdd(() => _engine.Invoke("__dispatch", kind, json)))
Console.Error.WriteLine("[host] event queue full, dropped: " + kind);
}
public void Dispose()
{
_stop.Cancel();
_queue.CompleteAdding();
// 不要 Join:工作线程可能正阻塞在宿主调用里
}
}
.NET Framework 4.8 使用
JavaScriptSerializer时需要引用System.Web.Extensions;.NET 6/8 用户把
JavaScriptSerializer换成System.Text.Json即可。
JsLibrary.Source就是下一节那段公共库;第 9 节会给出它从文件读取的写法。
4.2 JS 公共库
js
var app = (function () {
function call(method, args) {
var response = JSON.parse(__call(method, JSON.stringify(args || {})));
if (response.error) throw new Error(response.error);
return response.value;
}
return {
log: function (value) {
return call('log', { text: String(value) });
},
readPatient: function (id) {
return call('readPatient', { id: String(id) });
}
};
})();
var console = {
log: function () {
var text = Array.prototype.map.call(arguments, function (value) {
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch (error) {
return String(value);
}
}).join(' ');
app.log(text);
}
};
var handlers = Object.create(null);
function on(kind, callback) {
if (typeof callback !== 'function')
throw new TypeError('callback must be a function');
// 用 Object.create(null),避免 __proto__ 之类的特殊键污染原型
var list = handlers[kind];
if (!list) list = handlers[kind] = [];
list.push(callback);
}
function __dispatch(kind, json) {
var value = JSON.parse(json);
var list = handlers[kind];
if (!list) return;
list.slice().forEach(function (callback) {
try {
callback(value);
} catch (error) {
// 一个监听器出错,不能影响其它监听器
app.log(kind + ' listener error: ' +
(error && error.stack ? error.stack : error));
}
});
}
4.3 业务脚本
js
var patient = app.readPatient('P001');
console.log('患者:', patient.Name, patient.Age);
on('patientChanged', function (event) {
console.log('患者变化:', event.id);
});
4.4 从 C# 推事件
csharp
using (var session = new JsSession(File.ReadAllText("script.js")))
{
Thread.Sleep(500);
session.Post("patientChanged", "{\"id\":\"P001\"}");
Console.ReadLine();
}
五、并发模型:一个引擎,一个线程,一条队列
这是整个宿主设计里最重要的一条:
Jint 的
Engine不是线程安全的。一个 Engine 只能在同一个线程上串行执行。
错误做法:
csharp
// ❌ 多个线程同时调用同一个 Engine
Task.Run(() => engine.Execute("a()"));
Task.Run(() => engine.Execute("b()"));
可能的结果:变量错乱、作用域损坏、莫名其妙的异常,甚至进程崩溃。
正确模型:
UI 线程 ─┐
钩子线程 ─┼──> BlockingCollection<Action> ──> 脚本线程(唯一)
定时器 ─┘ │
├─ Engine.Execute
├─ Engine.Invoke
└─ Engine.Evaluate
5.1 为什么钩子线程绝对不能跑 JS
低级鼠标/键盘钩子要求尽快返回。在钩子里跑 JS:
- 脚本可能很慢;
- 脚本可能调用 UIA,目标卡死时整个输入链被拖住;
- 脚本抛异常会破坏钩子回调;
- 系统可能直接摘掉你的钩子。
所以钩子只做一件事:把事件入队。
5.2 为什么 UI 线程也不能跑 JS
- 脚本可能执行几秒,界面直接卡死;
- 脚本调用宿主 API,宿主又
Invoke回 UI 线程,容易死锁; - 脚本异常会把 UI 事件链打断。
脚本线程和 UI 线程之间只做两件事:
- 脚本 → UI:
BeginInvoke(异步,不等待); - UI → 脚本:入队(异步,不等待)。
不要在脚本线程里同步 Invoke 等待 UI,也不要在 UI 线程里 Join 脚本线程。
5.3 重启不能重叠
用户点"停止"后又点"运行",或者保存设置触发重载:
旧会话正在停止(可能卡在一个宿主调用里)
新会话已经启动
两个引擎同时操作同一批资源 → 状态错乱
正确做法:
- 同一名称只允许一个活跃会话;
- 停止时先标记
正在停止; - 新启动请求记为"待重启",等旧会话真正结束后再启动;
- 只保留最新一次重启请求,避免排队爆炸。
csharp
if (activeSessions.TryGetValue(name, out var old))
{
pendingRestart[name] = newDefinition;
old.Stop();
return; // 等 old 的清理回调里再启动
}
5.4 资源清理要带会话 ID
脚本可能注册了:
- 定时器;
- 事件监听;
- 全局鼠标/键盘钩子;
- 进程;
- 绘制的覆盖框。
清理时如果只按"脚本名"找资源,会出现:
旧会话的清理回调
↓
把新会话刚注册的定时器/钩子删了
所以每个会话生成唯一 SessionId,资源都记在 ID 下,清理只清自己的。
六、限制与安全:别让脚本把宿主拖死
6.1 超时
csharp
var engine = new Engine(options => options
.TimeoutInterval(TimeSpan.FromSeconds(2)));
TimeoutInterval 是单次执行的墙钟上限。脚本里写:
js
while (true) {}
会在约 2 秒后抛异常。
但要注意:
- 它不能中断正在执行的宿主调用;
- 宿主调用返回后,下一次 JS 语句检查时才会发现超时;
- 所以一个阻塞 30 秒的
ReadPatient不会被 2 秒超时救回来。
6.2 语句数
csharp
.MaxStatements(200_000)
防止"不是死循环,但执行了几亿条语句"的脚本。超时和语句数是互补的:
- 超时防"卡住";
- 语句数防"跑太久但一直在动"。
6.3 递归深度
csharp
.LimitRecursion(64)
JS 里写:
js
function f() { return f(); }
f();
没有限制会栈溢出,直接杀进程。LimitRecursion 会抛托管异常。
6.4 取消
csharp
var cts = new CancellationTokenSource();
var engine = new Engine(options => options
.CancellationToken(cts.Token));
// 另一个线程
cts.Cancel();
取消是协作式的:Jint 在语句边界检查。它同样不能中断正在执行的宿主调用。
6.5 内存
Jint 允许自定义 Constraint。本项目用一个按线程统计分配量的约束:
csharp
using Jint;
using Jint.Runtime;
internal sealed class MemoryConstraint : Constraint
{
private readonly long _limit;
private long _initialBytes;
private long _hostBytes;
private int _threadId;
public MemoryConstraint(long limit) { _limit = limit; }
public override bool IsAmortizable => false;
public override void Reset()
{
_threadId = Environment.CurrentManagedThreadId;
_initialBytes = GC.GetAllocatedBytesForCurrentThread();
_hostBytes = 0;
}
public override void Check()
{
if (Environment.CurrentManagedThreadId != _threadId)
return;
long allocated =
GC.GetAllocatedBytesForCurrentThread() - _initialBytes - _hostBytes;
if (allocated > _limit)
throw new MemoryLimitExceededException(
"Script allocated " + allocated + " bytes, limit " + _limit);
}
public string InvokeHost(
Func<string, string, string> host, string method, string json)
{
// 先检查 JS 自己的分配,再进宿主
Check();
long before = GC.GetAllocatedBytesForCurrentThread();
try
{
return host(method, json);
}
finally
{
// 宿主调用期间的分配不算脚本的账
_hostBytes += GC.GetAllocatedBytesForCurrentThread() - before;
}
}
}
几个关键点:
GC.GetAllocatedBytesForCurrentThread()只统计当前线程,正好对应"一个引擎一个线程";- 每次入口(启动、事件、控制台输入)前
Reset(); - 宿主 API 里的 48MB 读取不应该算到脚本头上,否则正常业务会误报;
- 脚本不能通过"多调几次宿主 API"把预算补回来;
- 大 JSON 在 JS 里
JSON.parse仍然计入脚本分配。
6.6 权限:脚本就是代码
不要以为"JS 沙箱"等于"安全":
- 脚本可以发起真实鼠标点击、键盘输入、协议调用;
- 脚本可以启动进程;
- 脚本可以读取工具暴露的所有数据。
所以:
- 只暴露白名单 API;
- 不暴露文件系统/网络/CLR 反射;
- 对外部动作要求显式授权(例如 UI 上勾选、二次确认);
- 协议参数、路径、进程名都要在 C# 侧校验;
- 日志里记录"哪个脚本做了什么"。
七、事件、回调和定时器
7.1 事件队列要有界
csharp
private readonly BlockingCollection<Entry> _queue =
new BlockingCollection<Entry>(128);
为什么必须有界?
- 目标窗口每 10ms 触发一次事件;
- 脚本处理一次要 50ms;
- 无界队列会一直涨,最后 OOM。
队列满时有两个选择:
- 丢弃新事件,并计数;
- 合并同类事件(例如定时器只保留一个待处理)。
本项目对定时器做了合并:同一个定时器 ID 在队列里最多一个 tick,避免"慢回调追着自己跑"。
7.2 回调注册失败要回滚
js
app.setInterval(callback, 1000);
如果宿主注册定时器失败,JS 侧不能还保留 callback:
js
// ❌ 先存 callback,再调宿主
callbacks[id] = callback;
call('timer', { id: id, ms: ms }); // 抛错
// ✅ 宿主成功后再存
call('timer', { id: id, ms: ms });
callbacks[id] = callback;
否则会出现"定时器根本没注册,但 JS 里有一个永远不会被清理的闭包",以及后续事件派发到不存在的定时器。
同理适用于:
on()订阅;menu()菜单;setInterval();- 任何"先登记本地状态,再调用宿主"的操作。
7.3 一个回调失败不能影响其它回调
js
list.slice().forEach(function (callback) {
try {
callback(value);
} catch (error) {
report(error); // 只上报,不中断循环
}
});
否则第一个监听器抛异常,后面的监听器全部收不到事件。
7.4 菜单回调是一次性的
js
app.menu([
{ text: '重试', action: function () { retry(); } }
]);
菜单点击后,这个 action 就应该被删除。否则:
- 同一个菜单 id 被重复派发;
- 用户点一次,业务执行两次;
- 旧菜单的闭包一直不释放。
7.5 定时器不是 setInterval 原语
Jint 没有浏览器/Node 的事件循环。setInterval 是你自己实现的:
JS 调 app.setInterval
↓
宿主注册一个 System.Threading.Timer
↓
到点后把 tick 入队
↓
脚本线程派发 __dispatch('timer', {id})
↓
JS 找到 callback 并执行
如果你不实现,JS 里就没有 setInterval;如果你实现了但忘了清理,脚本停止后定时器还在跑。
八、踩坑清单
线程与执行
-
多线程共用一个
Engine症状:变量错乱、随机异常、偶发崩溃。
修复:一个引擎一个线程,所有入口串行化。
-
在 UI/钩子线程直接执行 JS
症状:界面卡顿、输入延迟、钩子被系统摘掉。
修复:只入队,脚本线程执行。
-
脚本线程同步
Invoke回 UI,UI 又在等脚本症状:死锁。
修复:脚本→UI 用
BeginInvoke;UI→脚本用队列;不要互相等待。 -
重启时旧引擎还没停,新引擎已经启动
症状:同一资源被两个会话操作,日志交错。
修复:待重启机制,旧会话结束后再启动。
-
清理资源只按脚本名,不按会话 ID
症状:旧会话清理把新会话的定时器/钩子删了。
修复:每个会话唯一 ID,资源按 ID 归属。
-
Dispose里Join工作线程症状:宿主调用阻塞时,退出流程永远卡住。
修复:取消 + 关闭队列,不强制 Join;依赖协作式退出。
引擎状态
-
修改脚本源码后直接
Execute新源码症状:旧变量、旧函数、旧监听还在,行为叠加。
修复:新源码用新引擎;要保留状态就明确设计"热更新"协议。
-
控制台输入和脚本源码共用引擎但以为互不影响
实际上控制台输入可以改变量、注册监听、触发真实动作。
修复:这是特性,不是 bug;要在 UI 上明确提示。
-
Evaluate的返回值直接当 C# 类型用JsValue可能是undefined、null、double、string、object。修复:显式
ToObject()并做类型/空值判断。 -
JS 的
number精度不够表示 64 位 IDJS 只有 IEEE754 double,超过
2^53的整数会丢精度。修复:ID 用字符串传递。
异常与限制
-
把所有异常都当成"脚本业务错误"继续跑
超时、内存超限、递归超限、取消,这些不是普通 JS 异常。
修复:只把
JavaScriptException当可恢复错误;其它约束异常停止会话。 -
以为
TimeoutInterval能中断宿主调用不能。宿主阻塞多久,脚本线程就卡多久。
修复:宿主 API 自己要有超时;不要让脚本线程无限等。
-
以为
CancellationToken能立刻停它只在语句边界检查。
修复:UI 显示"正在停止",不要假装已经停止。
-
内存限制把宿主分配也算进去
症状:正常读取 48MB 数据就报"脚本内存超限"。
修复:宿主调用前后做差,排除宿主分配。
-
每次事件不重置内存预算
症状:脚本跑得越久,累计分配越大,最后误报。
修复:每个入口
Constraints.Reset()。 -
没有递归限制
症状:栈溢出直接杀进程。
修复:
LimitRecursion。
JSON 与数据
-
JSON.stringify(undefined)返回undefined,不是字符串症状:宿主收到空/异常。
修复:显式处理
undefined,或统一用JSON.stringify(value ?? null)。 -
false、0、空字符串被当成"没有值"症状:跨脚本触发时参数丢失。
修复:用"键是否存在"判断,不要用 truthy 判断;JSON 原样保留。
-
JSON.stringify遇到循环引用/BigInt/函数会抛错修复:日志格式化要
try/catch,失败后退回String(value)。 -
Date直接传给 C#JS Date 和 C# DateTime 没有自动映射。
修复:统一传 ISO 8601 字符串,C# 侧显式解析。
-
NaN/Infinity序列化成null修复:在 C# 侧校验数值范围,不要假设 JSON 里一定有数。
-
对象里有
__proto__、constructor之类的键症状:原型污染、监听表被污染、
handlers['__proto__']行为异常。修复:用
Object.create(null)存键值表;校验事件名;不要用for...in遍历用户输入。 -
日志里
JSON.stringify大对象症状:日志暴涨、控制台卡死。
修复:单条截断(例如 16K 字符),对象只打印摘要。
事件与生命周期
-
监听器注册失败但本地还留着 callback
症状:幽灵回调、重复执行、内存泄漏。
修复:宿主成功后再登记本地状态。
-
一个监听器抛异常,后面的监听器收不到事件
修复:逐个
try/catch,错误上报但不中断派发。 -
定时器 tick 堆积
症状:脚本越跑越慢,队列里全是同一个定时器的 tick。
修复:同一定时器在队列里最多一个待处理 tick。
-
菜单/一次性回调没有清理
症状:点一次执行多次。
修复:派发后立即删除 callback。
-
事件队列无界
症状:高频率事件下内存持续上涨。
修复:有界队列 + 丢弃计数 + 合并策略。
-
停止后仍向队列投递事件
修复:
_completed标记,停止后TryPost直接返回 false。
安全与部署
-
把 CLR 对象/COM 对象直接暴露给脚本
症状:线程错误、对象已释放、内部 API 被滥用。
修复:只暴露 JSON 白名单方法,返回纯 DTO。
-
脚本可以传任意进程名/路径/协议参数
修复:C# 侧白名单/正则/存在性校验;外部动作要显式授权。
-
脚本里
require、fetch、document不存在Jint 不是 Node,也不是浏览器。
修复:需要什么能力就显式实现宿主 API,不要照搬前端代码。
-
async/await写了但不会自动执行Jint 没有事件循环;Promise 不会自己推进。
修复:用宿主定时器/事件驱动,或者不要在脚本里用 Promise 编排。
-
脚本文件编码/BOM 问题
症状:中文乱码、第一行
// @name识别失败。修复:统一 UTF-8(带不带 BOM 都要测);嵌入资源读取时指定编码。
-
stdout 混入日志
如果你的程序同时支持命令行 JSON 输出,脚本日志写 stdout 会破坏协议。
修复:机器可读输出走 stdout,日志/诊断走 stderr。
-
脚本无限增长/设置文件过大
修复:限制单个脚本大小、脚本数量、设置文件读取上限。
-
以为脚本停止就等于资源释放
脚本可能启动了进程、按住了鼠标键、注册了钩子。
修复:停止时按会话 ID 做兜底清理(释放按键、结束自己启动的进程、注销钩子)。
九、一个完整的最小工程
目录:
Demo/
Demo.csproj
Program.cs
JsLibrary.cs
JsLibrary.js
JsSession.cs
script.js
JsSession.cs 就是第 4.1 节的代码;Program.cs:
csharp
using System;
using System.IO;
using System.Threading;
internal static class Program
{
private static void Main()
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
string source = File.ReadAllText("script.js");
using (var session = new JsSession(source))
{
// 模拟业务事件
for (int i = 1; i <= 3; i++)
{
Thread.Sleep(300);
session.Post(
"patientChanged",
"{\"id\":\"P00" + i + "\"}");
}
Console.WriteLine("按回车停止...");
Console.ReadLine();
}
}
}
JsLibrary.cs(把上一节的公共库放到 JsLibrary.js):
csharp
using System.IO;
public static class JsLibrary
{
public static string Source => File.ReadAllText("JsLibrary.js");
}
JsLibrary.js 的内容就是第 4.2 节的公共库:
js
var app = (function () {
function call(method, args) {
var response = JSON.parse(__call(method, JSON.stringify(args || {})));
if (response.error) throw new Error(response.error);
return response.value;
}
return {
log: function (value) {
return call('log', { text: String(value) });
},
readPatient: function (id) {
return call('readPatient', { id: String(id) });
}
};
})();
var console = {
log: function () {
var text = Array.prototype.map.call(arguments, function (value) {
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch (error) {
return String(value);
}
}).join(' ');
app.log(text);
}
};
var handlers = Object.create(null);
function on(kind, callback) {
if (typeof callback !== 'function')
throw new TypeError('callback must be a function');
var list = handlers[kind];
if (!list) list = handlers[kind] = [];
list.push(callback);
}
function __dispatch(kind, json) {
var value = JSON.parse(json);
var list = handlers[kind];
if (!list) return;
list.slice().forEach(function (callback) {
try {
callback(value);
} catch (error) {
app.log(kind + ' listener error: ' +
(error && error.stack ? error.stack : error));
}
});
}
script.js:
js
var patient = app.readPatient('P001');
console.log('患者:', patient.Name, patient.Age);
on('patientChanged', function (event) {
console.log('收到患者变化:', event.id);
});
运行输出:
[host] script started
[js] 患者: 张三 42
[js] 收到患者变化: P001
[js] 收到患者变化: P002
[js] 收到患者变化: P003
这个工程已经具备:
- 引擎隔离;
- 串行执行;
- 超时/语句数/递归限制;
- 宿主 API 白名单;
- 事件队列;
- 监听器错误隔离。
剩下的就是按你的业务增加 API。
十、最后:一份嵌入 JS 的检查表
- 一个脚本会话 = 一个 Engine = 一个线程;
- 所有入口(启动、事件、控制台)进同一条串行队列;
- UI/钩子线程只入队,不执行 JS;
- 脚本→UI 用
BeginInvoke,不反向等待; - 重启有"待重启"机制,旧会话不重叠;
- 资源按会话 ID 归属和清理;
- 超时、语句数、递归、内存四件套都有;
- 约束异常和普通 JS 异常分开处理;
- 宿主调用有自己的超时,不能指望 Jint 中断;
- 只暴露 JSON 白名单 API,不暴露活对象;
- 参数在 C# 侧校验;
- 事件队列有界,定时器合并,丢弃有计数;
- 回调注册失败会回滚;
- 单个监听器出错不影响其它监听器;
- JSON 保留
false/0/空字符串,ID 用字符串; - 日志截断,stdout/stderr 分离;
- 停止是协作式的,UI 如实显示"正在停止";
- 外部动作显式授权,并记录审计日志。
Jint 的价值不是"让 C# 跑 JS",而是把变化频繁的业务编排从编译期移到运行期 。
但只要你把 JS 放进了宿主,就必须同时接受:线程、超时、内存、异常、权限、
生命周期这些问题一个都不会少。
把边界设计好,脚本是生产力;边界没设计好,脚本就是最难排查的那类线上问题。