简介:这是一份基于C#实现的代码脚本编辑器完整示例工程,适合需要在视觉软件或上位机系统中嵌入脚本扩展功能的.NET开发人员。工程模拟了简化版Visual Studio,支持脚本编辑、编译运行、输出结果及编译错误提醒,并可引用第三方库,便于灵活扩展业务逻辑。压缩包共156个文件,约30.37MB,包含71个DLL、23个CS源码、6个Script脚本及相关配置、缓存、资源文件,其中CS与Script文件是核心代码,DLL提供编译与依赖库支持,整体目录结构清晰,方便导入自有项目二次开发。目前已有937人学习下载,可作为实现脚本引擎、编译器集成和IDE交互界面的参考范例。通过研读源码,可以快速掌握C#动态编译原理、编辑器控件封装、错误列表集成等实用技巧,缩短功能落地周期。 最近在做一个C#上位机项目,客户提了个挺实际的需求:操作员希望能在界面上临时改一段控制逻辑,而不是每次改动都找我重新编译整个程序。说白了,就是要在我的WinForms程序里内置一个能写代码、能高亮、能运行的脚本编辑器。折腾完这个需求之后,我想把整个实现过程记录下来,也就是你看到的这篇“C#实现代码脚本编辑器的功能”的完整复盘。
先别被“代码脚本编辑器”这个词吓到。它不是让你从零去写一个Visual Studio,也不是让你去啃编译原理。真实项目里的诉求一般都很朴素:给用户一个多行的文本输入区,把代码关键字标成彩色,最好有点智能提示,点一下运行就能执行这段脚本。如果你也是做C#工具类软件、上位机、或者内部管理系统的,这篇内容可以帮你少走不少弯路。
1. 先想清楚:什么场景才需要自己写脚本编辑器
1.1 C#上位机/工具软件里的“脚本需求”
我这边的实际场景是这样的:一台工控机上跑着一个WinForms上位机,负责采集设备数据、控制电机启停。客户现场的工艺人员不是专业程序员,但他们对动作时序很熟。设备厂家给他们的解决方案就是让现场人员改一个参数文件,但参数文件表达能力有限,遇到复杂的联动逻辑就写不了。
于是“脚本编辑器”这事就顺理成章了:现场人员打开一个脚本面板,修改一段类似C#的规则脚本(比如“当温度高于80度且持续5秒,就打开冷却阀”),点保存、点运行,上位机把这段脚本当成动态逻辑执行。这个模式在很多工控软件里都能看到,本质上是把可变逻辑从主程序里抽出来,交还给业务人员维护。
这个设计有个很隐蔽的好处:主程序的稳定性和脚本的灵活性解耦了。主程序我改一次大概要一两天,改完还要做回归测试;脚本改一下几秒钟,哪怕写错了也顶多报个错,不会把整个上位机搞崩。所以如果你在一个需要长期维护、又经常有逻辑变更的项目里,给程序加一个代码脚本编辑器的功能,性价比是非常高的。
1.2 控件选型:先别急着造轮子
在做编辑器之前,第一步遭遇战就是控件选型。我在WinForms里试过几套方案,列个表供你参考:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 官方TextBox/RichTextBox | 零依赖,简单稳定 | 无高亮、无行号、性能一般 | 简单配置编辑 |
| ScintillaNET(NuGet) | 功能完整,高亮/补全/折叠都有 | 原生DLL体积大,部署稍重 | 通用代码编辑器场景 |
| ICSharpCode.TextEditor | 纯托管,老牌 | 部分功能依赖GPL协议,需注意授权 | 个人/内部项目 |
| 自己封装RichTextBox | 可控性强,无额外依赖 | 工作量集中在高亮和性能优化 | 本文主推的轻量方案 |
我做这个项目的选择是:基于RichTextBox自己封装一个轻量编辑器。原因有三点。第一,目标程序本身很小,客户不希望装完软件多出来一个几十MB的原生DLL;第二,我们需要高亮的只有C#脚本关键字和少量自定义函数名,这个用正则完全能搞定;第三,自己封装在用Visual Studio开发时不会遇到任何依赖判断的问题,后续维护起来也省心。
当然,如果你做的是通用型编辑器产品,或者项目允许引入第三方组件,那ScintillaNET依然是首选。它里面很多细节处理得很专业,自绘高亮、自动缩进、括号匹配都是现成的。本文后面讲的所有思路,在ScintillaNET里都能映射到对应的API上,所以就算你选那条路,这篇文章的内容依然有参考价值。
2. 编辑器骨架搭建(核心中的核心)
2.1 用正则分词做语法高亮
脚本编辑器的第一印象就是“代码有没有颜色”。WinForms自带的RichTextBox支持选中一段文本后设置颜色,所以语法高亮的核心可以简化为:找出需要标色的文本范围,逐个设置颜色。
我采用的做法是正则表达式分词。先定义一个关键字列表,比如:
private static readonly string[] CSharpKeywords = { "abstract", "as", "base", "bool", "break", "case", "catch", "class", "const", "continue", "default", "delegate", "do", "else", "enum", "event", "explicit", "extern", "false", "finally", "for", "foreach", "goto", "if", "implicit", "in", "int", "interface", "internal", "is", "lock", "long", "namespace", "new", "null", "object", "operator", "out", "override", "params", "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", "void", "volatile", "while" };然后把这组关键字拼接成正则表达式:
private static readonly Regex KeywordRegex = new Regex(@"\b(" + string.Join("|", CSharpKeywords) + @")\b", RegexOptions.Compiled);这里用\b保证只匹配完整单词,避免interface被inter这种子串命中。高亮的触发点放在TextChanged事件里,因为用户每次输入或删除都会触发这个事件。但一个必须注意的坑是:不要在TextChanged里对全文本做高亮,否则输入一个字符就会造成整篇文本的Selection操作,编辑会明显卡顿,甚至会因为反复设置Selection而把用户的输入焦点打乱。
我的处理方案是“延迟高亮”加“可视区域限制”。延迟高亮就是用System.Windows.Forms.Timer,用户停止输入300毫秒后再做高亮;可视区域限制则是估算当前RichTextBox可视范围内能看到多少行,只高亮这些行的文本。对于几千行以内的脚本,这个方案已经很流畅了。
下面这段代码是我用的核心高亮函数:
private void HighlightSyntax(int start, int end) { // 提取要处理的文本块 string text = richTextBox.Text.Substring(start, end - start); // 先记录用户当前光标位置 int selectionStart = richTextBox.SelectionStart; int selectionLength = richTextBox.SelectionLength; // 暂时禁用重绘,避免闪烁 richTextBox.SuspendLayout(); // 清空原有颜色,回到默认色 richTextBox.Select(start, end - start); richTextBox.SelectionColor = Color.Black; // 匹配关键词 foreach (Match match in KeywordRegex.Matches(text)) { richTextBox.Select(start + match.Index, match.Length); richTextBox.SelectionColor = Color.Blue; } // 匹配字符串(粗略版) MatchCollection strMatches = StringRegex.Matches(text); foreach (Match match in strMatches) { richTextBox.Select(start + match.Index, match.Length); richTextBox.SelectionColor = Color.Maroon; } // 恢复光标位置 richTextBox.Select(selectionStart, selectionLength); richTextBox.ResumeLayout(); }我实际测试下来,几百行的脚本高亮时间可以控制在几十毫秒内,用户基本无感。需要留意的是,高亮逻辑里正则匹配的次数越多越耗时,所以我只做了“关键字”和“字符串”两类高亮。注释高亮我也做了,但只处理单行注释//,块注释/* */要跨行处理,复杂度高不少,如果业务上不常用可以暂缓。
2.2 行号栏:两个控件的滚动同步
代码编辑器没有行号,就像写字没有横格纸。行号栏的实现方案有好几种,最直观的就是在RichTextBox左侧放一个Panel,手动绘制行号文本。
具体做法是:窗体上用Dock = Left放一个Panel作为行号栏,右侧放RichTextBox。关键在于滚动同步。RichTextBox的VScroll事件会告诉你垂直滚动位置,这时需要重新绘制行号栏。行号栏在Paint事件里根据RichTextBox的GetPositionFromCharIndex来获取第一行和最后一行的位置:
private void LineNumberPanel_Paint(object sender, PaintEventArgs e) { int firstIndex = richTextBox.GetCharIndexFromPosition(new Point(0, 0)); int firstLine = richTextBox.GetLineFromCharIndex(firstIndex); int height = richTextBox.Height; int currentLineTop = richTextBox.GetPositionFromCharIndex( richTextBox.GetFirstCharIndexFromLine(firstLine)).Y; using (Font font = new Font("Consolas", 9F)) using (SolidBrush brush = new SolidBrush(Color.Gray)) { int line = firstLine; while (currentLineTop < height) { // 从行首取字符位置,保证和文本内容对齐 int charIndex = richTextBox.GetFirstCharIndexFromLine(line); Point pos = richTextBox.GetPositionFromCharIndex(charIndex); e.Graphics.DrawString((line + 1).ToString(), font, brush, 5, pos.Y + 2); currentLineTop = pos.Y; line++; } } }这里有个细节:行号栏字体最好和编辑区字体一致或接近,否则行高对不上,行号会越画越歪。如果你用的字体是Consolas,行号栏也最好用Consolas,大小可以略小一点。行号文字距离左边的缩进我用5像素,这个值可以根据实际情况微调。实测下来这个方案唯一的坑是RichTextBox的垂直滚动条宽度,如果滚动条显示后文本可视宽度变了,VScroll事件还会自动触发一次,行号栏会自己纠正,所以问题不大。
2.3 代码编辑的“手感”:Tab、缩进、快捷键
没有格式化功能的编辑器只能用“手感”打动用户,而手感主要来自几个细节。第一,Tab键不能真的插入一个\t字符,而是要插入固定数量的空格(比如4个),这样在任意编辑器里打开都不会错位。处理方式是在KeyDown事件里拦截Keys.Tab,手动插入空格并标记Handled = true。
第二,回车换行时最好能继承上一行的缩进。现在的缩进层级虽然还没有智能到像IDE那样自动匹配花括号,但至少要让下一行和上一行保持同样的空格数,用户手动对齐的成本就低很多。这个功能在KeyPress或KeyDown事件里拿到当前行的文本,复制前面的空白字符串插入即可。
第三,提供几个高频快捷键。Ctrl+A全选、Ctrl+C/V/X复制粘贴这些RichTextBox已经原生支持了,真正需要自己加的是Ctrl+Z撤销和多级撤销。WinForms的TextBoxBase原生只支持一级撤销,这很尴尬,用户一不小心删错了整段代码还没法恢复。要突破这个问题,方案是给RichTextBox挂一个栈,在每次TextChanged前保存历史快照。
我用的简化实现是保存RichTextBox.Rtf文本作为撤销栈的数据:
private Stack<string> undoStack = new Stack<string>(); private bool isUndoing = false; private void richTextBox_TextChanged(object sender, EventArgs e) { if (isUndoing) return; // 限制栈深度,防止内存暴涨 undoStack.Push(richTextBox.Rtf); if (undoStack.Count > 50) { // 移除最底部的快照 var list = undoStack.ToArray(); Array.Reverse(list); undoStack = new Stack<string>(list.Skip(1).Reverse()); } } private void Undo() { if (undoStack.Count == 0) return; isUndoing = true; richTextBox.Rtf = undoStack.Pop(); isUndoing = false; }这里只用了Rtf格式做快照,好处是整个文本的所有格式(颜色、字体)都一起被恢复了,不用自己重建高亮状态。坏处是大文本时Rtf字符串会很大。所以我对撤销栈做了深度限制,50步对脚本编辑来说基本够用。如果你要更省内存,可以只保存纯文本,但恢复后要重新跑一遍语法高亮,各有利弊。
3. 让脚本真正跑起来
3.1 用Roslyn执行C#脚本
前面所有工作都是为了编辑,但脚本编辑器的灵魂是“能执行”。如果脚本只是个文本编辑器,那和记事本没区别。在C#世界里,让用户输入的脚本代码跑起来,最直接的方案是Roslyn的脚本API。
如果你的项目是.NET Framework,可以用NuGet安装Microsoft.CodeAnalysis.CSharp.Scripting包。需要注意这个包在4.x版本前后的API略有差异,我用的是比较稳定的版本,核心代码如下:
using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; public async Task<object> RunScriptAsync(string code) { var options = ScriptOptions.Default .WithReferences( typeof(object).Assembly, typeof(Console).Assembly, Assembly.GetExecutingAssembly()) .WithImports("System", "System.Collections.Generic", "System.Linq"); // 返回最后一个表达式的值 return await CSharpScript.EvaluateAsync(code, options); }这里WithImports相当于在脚本文件顶部自动加了一行using System;,这样用户写脚本的时候不用每次开头都敲一大堆using,体验和写一个方法体差不多。WithReferences则是把当前程序集引用进去,这样脚本里可以直接调用你上位机里的公共类,比如我们自己定义的DeviceManager、AlarmLogger等。
有一点要给第一次接触Roslyn脚本的朋友提个醒:脚本API不是把代码丢给C#编译器就完事的,它每次调用EvaluateAsync都会重新编译一次。如果你在循环里频繁执行脚本,性能会很差。我的处理方式是先执行一次“预热”,把编译结果缓存起来,后续只传入不同的参数。这个优化后面小节展开说。
3.2 超时、异常捕获与安全边界
脚本能跑之后,程序员最担心的就是“用户写了死循环把我程序卡死了”。这个担忧很正常,Roslyn脚本默认是在当前线程同步执行的,如果脚本里有个while(true){},你的界面就会无响应。
解决办法是把脚本执行放到独立线程或Task.Run里,同时用CancellationTokenSource加超时控制。脚本里可以用WithCancellationToken把取消信号传入,这样超时的时候Roslyn能尝试中断脚本执行:
var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(10)); try { var result = await Task.Run(() => CSharpScript.EvaluateAsync(code, options, cancellationToken: cts.Token)); // 拿到返回值后显示到界面上 } catch (CompilationErrorException ex) { // 编译阶段就出错了,比如语法错误、类型不对 Log($"编译错误: {string.Join(Environment.NewLine, ex.Diagnostics)}"); } catch (OperationCanceledException) { Log("脚本执行超时,已终止。"); } catch (Exception ex) { Log($"运行时异常: {ex}"); }这里要多说两句关于“中断执行”的真相:Roslyn的取消机制其实没法保证一定停掉一个正在死循环的脚本,它只能在脚本进入下一次await或编译中断点时响应取消。说白了,用户的while(true){}里如果没有await,超时也没办法从外部强制杀掉线程。更稳妥的做法是给脚本执行单独开一个AppDomain,跑完之后直接卸载这个AppDomain,让里面的线程强制销毁。但这个方案涉及跨域通信,对新手来说复杂度直接翻倍。我的折中方案是:明确告诉使用者“脚本里不要写死循环,否则界面会卡住”,同时在脚本执行入口加一个“危险代码”检查,比如禁止while(true)这种字面循环,算是一种简单防御。
安全边界的另一个重要维度是权限。如果这个脚本编辑器是给现场人员维护工艺逻辑的,那脚本里能访问什么、不能访问什么,必须在文档里写清楚。比如不让脚本访问文件系统、不让调用Process.Start,这些可以用ScriptOptions的WithAllowedGlobals或者自定义ScriptObject来限制。我这边采取的是“白名单式”的开放方案:不把整个程序集丢给脚本,而是专门定义一个ScriptContext类,把允许脚本操作的方法都暴露在这个类里,脚本只能看到一个新创建的上下文实例,其他什么都碰不到。
3.3 带上下文的脚本:给脚本喂对象
一个脚本编辑器如果只能执行孤立的一段代码,那价值很小。真正的价值在于让脚本能和你的主程序对话。比如我的上位机能给脚本提供一个Api对象,脚本可以这样写:
var value = Api.ReadSensor(1); if (value > 80) { Api.OpenValve(2); } Api.Log("温度超限,已打开冷却阀");要做到这一点,只需要在调用EvaluateAsync时把自定义对象传进去:
var scriptContext = new ScriptContext { DeviceManager = this.deviceManager, AlarmService = this.alarmService }; await CSharpScript.EvaluateAsync(code, options, globals: scriptContext);注意这里有一个类型限制:globals参数的类型必须是固定的,而且要在ScriptOptions.WithImports或程序集引用中可见。如果脚本里需要频繁使用上下文提供的状态,比如“上一次的值”,可以把这个状态放在ScriptContext的属性里,脚本里直接读写就行。
还有一点是在线程模型上的考虑。脚本执行线程和UI线程不是同一个线程,如果脚本里直接操作UI控件(比如弹个Dialog),那肯定要出问题。所以在ScriptContext里我封装了线程切换方法,比如Api.UI(() => MessageBox.Show("...")),内部用BeginInvoke切回UI线程。这样脚本作者不用懂线程调度也能安全地调用界面操作。
4. 常见问题与排查技巧实录
4.1 高亮导致编辑器卡顿与闪烁
我遇到的第一个大坑就来自高亮。当时图省事,直接在TextChanged里对全文跑正则高亮,结果输入一个字符,整篇几百行的文本就要重新设置一遍颜色,那个卡顿感非常明显,连删除都一卡一卡的。后面改成“停止输入300ms后再高亮”和“只处理可视区域”两个优化,问题基本消失。
闪烁的问题则出在SelectionColor的频繁设置上。RichTextBox每设置一次颜色都会触发重绘,如果一次高亮要设置几百个范围,界面会明显闪。解决办法一是SuspendLayout()和ResumeLayout()包裹操作,二是设置RichTextBox的双缓冲。
4.2 编码问题:中文乱码与文件加载
脚本编辑器肯定要支持打开和保存文件。这里我踩过一个很实际的问题:上位机现场的文件经常是从别的机器拷过来的,可能是GB2312编码,也可能是UTF-8带BOM、不带BOM。直接用File.ReadAllText(path)读,遇到GB2312就会乱码。我的处理方案是先检测BOM,如果没BOM就尝试用UTF-8严格解码,失败再退回GB2312。
保存的时候我默认写UTF-8 with BOM,因为这是全平台兼容性最好的选择,C#脚本用Roslyn编译也完全不挑。但如果你要对接旧的C#编译器,建议用系统默认编码,具体结合项目实际情况取舍。
4.3 部署:依赖文件与DLL精简
最后说下部署。如果按我的方案用ScintillaNET,那发布时要记得带上SciLexer.dll原生库,x86和x64要分别处理。如果只依赖RichTextBox自研方案,部署就简单很多。但如果用Roslyn,那程序集就会引入了不少系统库文件,发布时体积会增大。对WinForms项目,如果客户没有.NET运行时环境,你需要用安装工具打一个全环境包。这个在百度上搜“c#的winform如何制作安装包”能搜到不少教程,核心就一句话:用Visual Studio Installer Projects扩展,或者用Inno Setup,前者适合初学者,后者适合想要更多定制的情况。
我用的是Inno Setup,因为它可以把.NET运行时、脚本编辑器依赖的第三方DLL一起打包进一个exe,客户双击安装就完事。对于工控机这种不能保证每台机器都有完整开发环境的场景,这一步必须提前做好,别等部署了再手忙脚乱。
5. 踩过的坑与经验总结
这个编辑器从立项到可用,前后大概花了两周,大部分时间不是花在代码上,而是花在“想清楚到底要给用户什么”上。最深的体会就是:不要为了技术炫耀而堆功能。代码高亮、自动补全、语法检查这些功能,每加一个都是要付出性能代价的,而且每个功能背后都隐藏着很多边界情况。
如果你也打算做类似功能,我建议按这个顺序推进:第一步先做可执行,哪怕界面很丑,只要用户能把脚本跑起来,需求就成立了一半;第二步再做编辑体验,行号、高亮、快捷键;第三步才是锦上添花,比如自动补全、括号匹配、错误定位行号。反着来的话,很容易陷入编辑器本身的功能陷阱,做了一大堆炫酷功能,却发现用户真正需要的核心执行逻辑还没跑通。
最后再分享一个小技巧,脚本编辑器这个功能很多时候不需要做得和IDE一样完整,只要把“编辑-保存-执行-看结果”这条链路打通,用户就会觉得很专业。你可以把脚本的返回值直接输出到界面上,再加上一个带时间戳的运行日志框,这个体验已经能超过很多商业软件的脚本模块了。
本文还有配套的精品资源,点击获取