在 C# 里嵌入 JavaScript:用 Jint 做可热更新的自动化脚本(从入门到踩坑)

很多桌面工具的最后一步,都是"让用户写点逻辑":

不同的医院、不同的产线、不同的客户,业务规则不一样,但又不值得每次改代码重新发版。

这时候把 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) });
  }
};

这样做的收益:

  1. 边界清晰:JS 只能调用你列出的 method;
  2. 可序列化:返回值是纯数据,不把 COM/控件对象漏出去;
  3. 错误可控 :C# 异常变成 {error},JS 侧统一 throw new Error;
  4. 可测试:宿主可以换成 mock,不需要真的启动目标程序;
  5. 可演进:以后换 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。

队列满时有两个选择:

  1. 丢弃新事件,并计数;
  2. 合并同类事件(例如定时器只保留一个待处理)。

本项目对定时器做了合并:同一个定时器 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;如果你实现了但忘了清理,脚本停止后定时器还在跑。


八、踩坑清单

线程与执行

  1. 多线程共用一个 Engine

    症状:变量错乱、随机异常、偶发崩溃。

    修复:一个引擎一个线程,所有入口串行化。

  2. 在 UI/钩子线程直接执行 JS

    症状:界面卡顿、输入延迟、钩子被系统摘掉。

    修复:只入队,脚本线程执行。

  3. 脚本线程同步 Invoke 回 UI,UI 又在等脚本

    症状:死锁。

    修复:脚本→UI 用 BeginInvoke;UI→脚本用队列;不要互相等待。

  4. 重启时旧引擎还没停,新引擎已经启动

    症状:同一资源被两个会话操作,日志交错。

    修复:待重启机制,旧会话结束后再启动。

  5. 清理资源只按脚本名,不按会话 ID

    症状:旧会话清理把新会话的定时器/钩子删了。

    修复:每个会话唯一 ID,资源按 ID 归属。

  6. Dispose 里 Join 工作线程

    症状:宿主调用阻塞时,退出流程永远卡住。

    修复:取消 + 关闭队列,不强制 Join;依赖协作式退出。

引擎状态

  1. 修改脚本源码后直接 Execute 新源码

    症状:旧变量、旧函数、旧监听还在,行为叠加。

    修复:新源码用新引擎;要保留状态就明确设计"热更新"协议。

  2. 控制台输入和脚本源码共用引擎但以为互不影响

    实际上控制台输入可以改变量、注册监听、触发真实动作。

    修复:这是特性,不是 bug;要在 UI 上明确提示。

  3. Evaluate 的返回值直接当 C# 类型用

    JsValue 可能是 undefined、null、double、string、object。

    修复:显式 ToObject() 并做类型/空值判断。

  4. JS 的 number 精度不够表示 64 位 ID

    JS 只有 IEEE754 double,超过 2^53 的整数会丢精度。

    修复:ID 用字符串传递。

异常与限制

  1. 把所有异常都当成"脚本业务错误"继续跑

    超时、内存超限、递归超限、取消,这些不是普通 JS 异常。

    修复:只把 JavaScriptException 当可恢复错误;其它约束异常停止会话。

  2. 以为 TimeoutInterval 能中断宿主调用

    不能。宿主阻塞多久,脚本线程就卡多久。

    修复:宿主 API 自己要有超时;不要让脚本线程无限等。

  3. 以为 CancellationToken 能立刻停

    它只在语句边界检查。

    修复:UI 显示"正在停止",不要假装已经停止。

  4. 内存限制把宿主分配也算进去

    症状:正常读取 48MB 数据就报"脚本内存超限"。

    修复:宿主调用前后做差,排除宿主分配。

  5. 每次事件不重置内存预算

    症状:脚本跑得越久,累计分配越大,最后误报。

    修复:每个入口 Constraints.Reset()。

  6. 没有递归限制

    症状:栈溢出直接杀进程。

    修复:LimitRecursion。

JSON 与数据

  1. JSON.stringify(undefined) 返回 undefined,不是字符串

    症状:宿主收到空/异常。

    修复:显式处理 undefined,或统一用 JSON.stringify(value ?? null)。

  2. false、0、空字符串被当成"没有值"

    症状:跨脚本触发时参数丢失。

    修复:用"键是否存在"判断,不要用 truthy 判断;JSON 原样保留。

  3. JSON.stringify 遇到循环引用/BigInt/函数会抛错

    修复:日志格式化要 try/catch,失败后退回 String(value)。

  4. Date 直接传给 C#

    JS Date 和 C# DateTime 没有自动映射。

    修复:统一传 ISO 8601 字符串,C# 侧显式解析。

  5. NaN / Infinity 序列化成 null

    修复:在 C# 侧校验数值范围,不要假设 JSON 里一定有数。

  6. 对象里有 __proto__、constructor 之类的键

    症状:原型污染、监听表被污染、handlers['__proto__'] 行为异常。

    修复:用 Object.create(null) 存键值表;校验事件名;不要用 for...in 遍历用户输入。

  7. 日志里 JSON.stringify 大对象

    症状:日志暴涨、控制台卡死。

    修复:单条截断(例如 16K 字符),对象只打印摘要。

事件与生命周期

  1. 监听器注册失败但本地还留着 callback

    症状:幽灵回调、重复执行、内存泄漏。

    修复:宿主成功后再登记本地状态。

  2. 一个监听器抛异常,后面的监听器收不到事件

    修复:逐个 try/catch,错误上报但不中断派发。

  3. 定时器 tick 堆积

    症状:脚本越跑越慢,队列里全是同一个定时器的 tick。

    修复:同一定时器在队列里最多一个待处理 tick。

  4. 菜单/一次性回调没有清理

    症状:点一次执行多次。

    修复:派发后立即删除 callback。

  5. 事件队列无界

    症状:高频率事件下内存持续上涨。

    修复:有界队列 + 丢弃计数 + 合并策略。

  6. 停止后仍向队列投递事件

    修复:_completed 标记,停止后 TryPost 直接返回 false。

安全与部署

  1. 把 CLR 对象/COM 对象直接暴露给脚本

    症状:线程错误、对象已释放、内部 API 被滥用。

    修复:只暴露 JSON 白名单方法,返回纯 DTO。

  2. 脚本可以传任意进程名/路径/协议参数

    修复:C# 侧白名单/正则/存在性校验;外部动作要显式授权。

  3. 脚本里 require、fetch、document 不存在

    Jint 不是 Node,也不是浏览器。

    修复:需要什么能力就显式实现宿主 API,不要照搬前端代码。

  4. async/await 写了但不会自动执行

    Jint 没有事件循环;Promise 不会自己推进。

    修复:用宿主定时器/事件驱动,或者不要在脚本里用 Promise 编排。

  5. 脚本文件编码/BOM 问题

    症状:中文乱码、第一行 // @name 识别失败。

    修复:统一 UTF-8(带不带 BOM 都要测);嵌入资源读取时指定编码。

  6. stdout 混入日志

    如果你的程序同时支持命令行 JSON 输出,脚本日志写 stdout 会破坏协议。

    修复:机器可读输出走 stdout,日志/诊断走 stderr。

  7. 脚本无限增长/设置文件过大

    修复:限制单个脚本大小、脚本数量、设置文件读取上限。

  8. 以为脚本停止就等于资源释放

    脚本可能启动了进程、按住了鼠标键、注册了钩子。

    修复:停止时按会话 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 放进了宿主,就必须同时接受:线程、超时、内存、异常、权限、

生命周期这些问题一个都不会少。

把边界设计好,脚本是生产力;边界没设计好,脚本就是最难排查的那类线上问题。

相关推荐
天远API1 小时前
零信任架构实战:基于天远运营商二要素V即时版构建自动化分时租赁出行会员身份一致性审核网关
运维·人工智能·架构·自动化
FRing031 小时前
JavaScript入门到精通二十六(变量提升、函数剩余参数、展开运算符、ES6箭头函数)
开发语言·前端·javascript·es6·箭头函数
OpsEye2 小时前
怎么实现大模型的按用户、按应用配额限流?
javascript·ai编程
小羊没烦恼!3 小时前
异步编程:线程概述及使用
java·开发语言·mysql·算法·c#
云舜言传3 小时前
Bloom 手势演示操作 · 录屏指南
javascript·深度学习·机器学习
重生之高冷汉堡包4 小时前
ChatGPT 如何打造自动化账号:从零搭建到高效运营
运维·chatgpt·自动化
c#上位机5 小时前
C#上位机项目实战——exe项目调试更改dll中源代码
开发语言·c#
软件派5 小时前
Beam.Win 自动化办公与业务流程提效指南
运维·自动化
星禾元亨5 小时前
用结构化数据与干净实体信号,让技术内容被 AI 搜索引擎稳定引用
数据库·人工智能·自动化·创业创新