☰
VB网页编辑器源码:WebBrowser与designMode富文本实现
2026/10/30 3:19:27 网站建设 项目流程

简介:面向Visual Basic开发者的一款网页编辑器控件,适合需要在桌面应用中嵌入富文本编辑能力的程序员,可用于内容管理系统、论坛、博客等场景的HTML录入与格式化。其核心依托DHTML与IE渲染引擎,工具栏涵盖字体、字号、颜色、对齐、插入图片和链接等常用操作,让不熟悉HTML语法的用户也能生成带格式的网页内容。压缩包共99个文件,约197KB,包含gif界面素材、cs与工程文件、dll组件、xml/js/css资源以及aspx示例页面,结构兼顾控件源码、资源与演示程序。现有1046人学习,附带的演示工程展示了控件初始化、编辑数据处理与内容保存等关键环节,可作为VB富文本编辑组件的入门参考与二次开发起点,也便于理解DHTML控件在传统Win应用中的集成方式。读者可据此对照控件属性与事件处理,快速搭建自己的富文本输入界面,源码与资源分层清晰,方便按需查阅。

1. 一个 VB 网页编辑器能干到哪一步:先把预期摆正

接手一个老后台的时候,最别扭的往往不是数据库而是内容录入:运营只想要一个能加粗、能插图、能实时看见样式的框,你却在几个前端富文本库的版本依赖里打转。这时候一份 VB 网页编辑器的源码就很有吸引力——它不拉任何 npm 包,一个 WebBrowser 控件加上designMode属性,编译出来的 exe 双击就能改网页,特别适合内网工具、教学演示、老系统临时补一个「所见即所得」录入页这类场景。这份资源给的是一个能跑起来的最小完整工程,不是讲原理的 PPT:窗体、工具栏、源码视图、文件读写都在,缺什么自己往里加。

它适合两种人:一种是想给 VB 老系统塞进「富文本编辑」能力、又不想引入浏览器内核依赖的维护者;另一种是想搞明白execCommand这套机制到底怎么运转、准备自己写个轻量编辑器的开发者。不适合指望它替代现代编辑器的人——内核是旧的,CSS 支持有限,这是选型时就该接受的代价。

2. 技术选型与骨架:WebBrowser 控件加 designMode 这套组合

2.1 为什么不自己写渲染,而是借 IE 的内核

VB6 里做富文本编辑,常见有三条路:RichTextBox 控件、自己解析 HTML 用 PictureBox 画、以及塞一个 WebBrowser 控件进去。前两条我都试过,RichTextBox 只能处理 RTF,导出 HTML 时那堆\rtf1\ansi转换能把人逼疯;自己画渲染等于重写一个排版引擎,不现实。

WebBrowser 控件背后是 MSHTML,也就是 IE 的排版内核,它天生就带两个东西:真实的 CSS 渲染,以及document.execCommand这一整套编辑命令。加粗、斜体、缩进、插入链接、插入表格、撤销重做,几十条命令全是内核白送的,你只要负责把按钮和命令名对上。这套方案的血泪经验是:省下来的工作量巨大,但你必须接受内核版本老、部分现代 CSS 不生效。内网工具、教学场景完全够用,对外发布的站点就别想了。

一个容易被忽略的细节是designMode和contentEditable的区别。前者是把整篇文档切成可编辑态,适合「整个编辑区就是一个页面」的场景;后者是让某个元素可编辑,适合嵌在复杂页面里。这个工程走的是designMode路线,因为它要做的是一个完整的网页编辑器,不是页面里的一个小输入框。

2.2 工程结构与模块职责

VB 工程的模块划分不能太随意,否则三个月后自己都找不到代码在哪。这个工程的目录大致是这样组织的:

文件职责改它的频率
WebEditor.vbp工程文件,记录窗体、模块、引用几乎不动
frmMain.frm主窗体:菜单、工具栏、状态栏、编辑区高,UI 都在这里
frmSource.frm源码视图子窗体,一个多行 TextBox中
mdlEditor.bas编辑器核心:命令下发、状态同步、视图切换最高,逻辑都在这
mdlFile.bas文件打开保存、编码处理、路径拼装中
mdlAssets.bas图片等资源拷贝与目录维护低
clsHistory.cls撤销栈的备用实现(内核 Undo 不够用时启用)低
res/工具栏位图与图标低

这么分的核心原则是:所有跟 DOM 打交道的方法只能出现在mdlEditor.bas里。窗体代码里全是Call Exec(WebEditor, "Bold")这样的调用,不直接碰WebEditor.Document。好处是以后要换内核(比如换成 WebView2 或者某个 ActiveX 壳),只需要改一个模块,窗体一行不动。我见过太多 VB 工程把WebEditor.Document.body.innerHTML散落在十几个按钮的 Click 事件里,最后想改都没法改。

2.3 启动骨架:从 Navigate 到 designMode 的正确顺序

新手最容易翻车的地方在这里:Form_Load里刚Navigate完就去取WebBrowser.Document,结果拿到Nothing,报「对象变量或 With 块变量未设置」。原因是导航是异步的,文档还没加载完。正确的做法是把初始化拆成两步,第二步挂在DocumentComplete事件上。

' frmMain.frm —— 第一步:只发起导航,不碰 Document Private Sub Form_Load() WebEditor.Silent = True ' 屏蔽脚本错误弹窗,否则内网页面一报错就卡住 WebEditor.Navigate "about:blank" ' 先给个空白文档,DocumentComplete 里再初始化 End Sub ' 第二步:文档就绪后才允许进入编辑态 Private Sub WebEditor_DocumentComplete(ByVal pDisp As Object, URL As Variant) ' 只有顶层框架的完成事件才算数,iframe 的完成事件会重复触发 If pDisp Is WebEditor.Object Then mdlEditor.EnterDesignMode WebEditor mdlEditor.BindSelection WebEditor End If End Sub

EnterDesignMode是编辑器真正诞生的一刻,它做三件事:把文档切成可编辑、清掉默认内容、定下基础字号字体。

' mdlEditor.bas —— 进入编辑态 Public Sub EnterDesignMode(ByRef wb As WebBrowser) Dim doc As Object Set doc = wb.Document doc.designMode = "On" ' 关键开关,字符串 "On",不是 True doc.body.innerHTML = "" ' 清掉 about:blank 的残留 doc.body.style.fontFamily = "宋体" doc.body.style.fontSize = "14px" doc.body.style.margin = "8px" ' 给编辑区一点呼吸空间 End Sub

参数说明:designMode只认"On"和"Off"这两个字符串,写成True在某些 IE 版本上会静默失败,这是老版本内核的一个玄学点。Silent = True必须设,否则页面里任何一个 JS 报错都会弹出 IE 的错误对话框,编辑体验直接崩掉。body.style.margin只是让光标不贴着边框,值可以按自己审美改。

BindSelection负责绑定光标移动事件,用来刷新工具栏状态,下一章展开。

3. 核心编辑器模块拆解:一次命令从按钮到 DOM 的完整链路

3.1 命令分发:一张表管住所有工具栏按钮

二三十个工具栏按钮如果每个都写一遍 Click 事件,代码量会失控。我一般用一个「控件数组 + 命令映射表」的做法:所有按钮共用一个 Click 事件,通过Index反查对应的命令名。

' mdlEditor.bas —— 按钮索引到 execCommand 命令名的映射 Public Function CommandOf(ByVal idx As Integer) As String Select Case idx Case 0: CommandOf = "Bold" Case 1: CommandOf = "Italic" Case 2: CommandOf = "Underline" Case 3: CommandOf = "JustifyLeft" Case 4: CommandOf = "JustifyCenter" Case 5: CommandOf = "JustifyRight" Case 6: CommandOf = "InsertOrderedList" Case 7: CommandOf = "InsertUnorderedList" Case 8: CommandOf = "Outdent" Case 9: CommandOf = "Indent" Case 10: CommandOf = "Undo" Case 11: CommandOf = "Redo" Case 12: CommandOf = "RemoveFormat" Case Else: CommandOf = "" End Select End Function
' frmMain.frm —— 所有工具栏按钮共用的入口 Private Sub tbMain_ButtonClick(ByVal Button As MSComctlLib.Button) Dim cmd As String cmd = mdlEditor.CommandOf(Button.Index) If Len(cmd) > 0 Then mdlEditor.Exec WebEditor, cmd End Sub

这样加一个新按钮,只在CommandOf里补一行、在工具栏设计器里加一个按钮就完事,剩下的逻辑不用碰。参数说明:Button.Index是控件数组的下标,必须和CommandOf的Case一一对应;一旦在工具栏前面插了新按钮,所有索引都会后移,这是这套写法唯一的脆弱点,所以我会在CommandOf里留Case Else兜底,返回空串让调用方直接跳过。

3.2 execCommand 的三个参数与焦点陷阱

真正下发命令的函数必须只有一个出口,所有异常、状态同步都在这里统一处理。

' mdlEditor.bas —— 唯一的命令下发出口 Public Sub Exec(ByRef wb As WebBrowser, ByVal cmd As String, _ Optional ByVal arg As Variant) Dim doc As Object On Error GoTo Fail Set doc = wb.Document doc.focus ' 焦点不在文档里,命令会静默失败,这是最常见的坑 If IsMissing(arg) Then doc.execCommand cmd, False, Empty Else doc.execCommand cmd, False, arg End If SyncToolbar wb ' 命令执行完立刻回显状态 Exit Sub Fail: MsgBox "命令执行失败:" & cmd & vbCrLf & Err.Description, vbExclamation End Sub

execCommand(command, showUI, value)三个参数的含义:第一个是命令名,大小写不敏感;第二个是是否弹出内核自带的 UI,比如CreateLink传True会弹 IE 的输入框,样式丑且不可控,所以一律传False,自己用 VB 窗体收集参数;第三个是命令的参数值,Bold这类不需要参数,InsertImage需要图片地址,FontSize需要字号。

doc.focus这行是保命的。用户点工具栏按钮时,焦点会从文档跑到按钮上,此时光标位置虽然还在,但execCommand找不到活动选区,表现就是「点了没反应也不报错」,属于最难查的那类问题。

3.3 设计视图与源码视图的往返同步

编辑器必须能切到源码视图手改 HTML,否则遇到内核渲染不出来的标签就没辙了。两个视图之间的同步,方向不同代价也不同。

' mdlEditor.bas —— 设计视图 -> 源码:只取 body,简单可靠 Public Function GetHtml(ByRef wb As WebBrowser) As String GetHtml = wb.Document.body.innerHTML End Function ' mdlEditor.bas —— 源码 -> 设计视图:还原时要防止脚本被执行 Public Sub SetHtml(ByRef wb As WebBrowser, ByVal html As String) Dim doc As Object Set doc = wb.Document doc.body.innerHTML = html doc.body.focus End Sub

参数说明:innerHTML拿到的是 body 内部的片段,不含<head>,所以切源码视图时要把文档头单独拼上,切回来时再剥掉,这部分逻辑放在mdlFile.bas的WrapDocument和UnwrapDocument里。SetHtml里没有做script标签过滤,是因为这个编辑器面向的是自己人,但如果要做成给别人用的工具,务必在回写前把<script>、<iframe>、on*属性清洗掉,否则源码视图就是一个能执行任意脚本的后门入口。

另一个坑是:body.innerHTML回写会丢掉head里的样式和 meta,如果你在源码里手写了一段<style>,切回来再切过去就没了。我的做法是切到源码视图时把整篇文档存进一个String变量(模块级,不落盘),切回来时判断源码里有没有<html标签决定用整篇还是只取 body。

3.4 状态回显:让按钮跟着光标亮起来

用户把光标挪到一段加粗的文字里,加粗按钮应该是按下状态。这个交互靠queryCommandState实现,触发时机是文档的onselectionchange。

' mdlEditor.bas —— 把光标所在位置的格式状态同步到工具栏 Public Sub SyncToolbar(ByRef wb As WebBrowser) Dim doc As Object On Error Resume Next ' 文档未就绪时这里会报错,忽略即可 Set doc = wb.Document ' 1 = 打开,0 = 关闭,其他值表示混合状态 frmMain.tbMain.Buttons("Bold").Value = IIf(doc.queryCommandState("Bold"), tbrPressed, tbrUnpressed) frmMain.tbMain.Buttons("Italic").Value = IIf(doc.queryCommandState("Italic"), tbrPressed, tbrUnpressed) frmMain.tbMain.Buttons("Underline").Value = IIf(doc.queryCommandState("Underline"), tbrPressed, tbrUnpressed) frmMain.tbMain.Buttons("Undo").Enabled = doc.queryCommandEnabled("Undo") End Sub ' frmMain.frm —— 挂接光标变化事件 Private Sub WebEditor_OnSelectionChange() mdlEditor.SyncToolbar WebEditor End Sub

queryCommandState返回布尔值,queryCommandEnabled返回命令当前是否可用(比如撤销栈空了,Undo 就该置灰)。这两个查询在光标没落在文档里时会抛异常,所以整个过程包在On Error Resume Next里,路由失败就当没这回事,别让用户看见报错框。

4. 文件与资源管理:保存、打开与相对路径这条线

4.1 目录约定:为什么必须用相对路径和 assets 目录

新手版编辑器最常见的做法是执行InsertImage时直接把C:\Users\xxx\Desktop\a.jpg塞进src。本机预览一切正常,把 html 拷给别人就全是裂图。这类「在我这儿是好的」问题,排查起来能让人后悔没早点定规范。

我一般强制一个目录约定:保存 html 时,同目录下自动建一个assets/子目录,所有插入的图片、附件都拷进去,src一律写成assets/文件名。好处是拷走整个文件夹就能用,不用打包。文件名冲突的处理方式是给重名文件加时间戳后缀,而不是覆盖——用户往往意识不到自己插了两张同名图。

' mdlAssets.bas —— 拷贝资源并返回相对路径 Public Function CopyIntoAssets(ByVal srcPath As String, ByVal htmlDir As String) As String Dim fileName As String, destDir As String, destPath As String destDir = htmlDir & "\assets\" If Dir(destDir, vbDirectory) = "" Then MkDir destDir ' 首次保存时自动建目录 fileName = GetBaseName(srcPath) destPath = destDir & fileName ' 重名不覆盖,加时间戳后缀,避免用户误插两张同名图丢文件 If Dir(destPath) <> "" Then fileName = StripExt(fileName) & "_" & Format(Now, "yyyymmddhhnnss") & "." & GetExt(fileName) destPath = destDir & fileName End If FileCopy srcPath, destPath CopyIntoAssets = "assets/" & fileName ' 正斜杠,浏览器不挑,Windows 路径也行但容易混 End Function

参数说明:htmlDir是当前 html 所在目录,未保存过的新文档要给一个默认值(比如App.Path & "\draft")。FileCopy在目标文件已存在时会抛错,所以重名判断必须放在前面,这一步漏了就是一个运行时错误。返回值用正斜杠是习惯问题,assets/a.jpg和assets\a.jpg浏览器都认,但正斜杠在后续做路径拼接时更省心。

4.2 保存:用 ADODB.Stream 写 UTF-8 才不乱码

VB6 原生的Open ... For Output默认按 ANSI 写文件,中文在别人的机器上打开就是一堆问号。这个坑几乎每个 VB 项目都会踩一次。稳的做法是用ADODB.Stream指定 charset。

' mdlFile.bas —— 保存 html,UTF-8 无 BOM 之外的编码话题都在这 Public Sub SaveHtml(ByVal path As String, ByVal bodyHtml As String, ByVal title As String) Dim st As Object Set st = CreateObject("ADODB.Stream") st.Type = 2 ' 1 = 二进制,2 = 文本,写 html 用 2 st.Charset = "utf-8" st.Open st.WriteText WrapDocument(bodyHtml, title) st.SaveToFile path, 2 ' 2 = adSaveCreateOverWrite,直接覆盖 st.Close Set st = Nothing End Sub ' 把 body 片段补成一份完整、带编码声明的文档 Private Function WrapDocument(ByVal bodyHtml As String, ByVal title As String) As String WrapDocument = "<!DOCTYPE html>" & vbCrLf & _ "<html><head><meta charset=""utf-8"">" & vbCrLf & _ "<title>" & title & "</title></head>" & vbCrLf & _ "<body>" & bodyHtml & "</body></html>" End Function

参数说明:Type = 2是文本模式,配Charset = "utf-8",SaveToFile的第二个参数2对应adSaveCreateOverWrite,1是遇到同名文件就报错。<meta charset="utf-8">这行不能省,否则浏览器打开时可能按本地编码猜,中文又乱。

注意一点:ADODB.Stream写出的 UTF-8 通常带 BOM 头。对现代浏览器没影响,但如果你的 html 要被别的程序按纯文本解析,BOM 会变成文件开头的三个不可见字符,引发莫名其妙的「第一行多了个怪符号」。介意的话就改用二进制模式手写字节数组,或者读回来时手动裁掉前三个字节。

4.3 打开:把 head 一起读回来

打开比保存麻烦一点,因为要处理「打开一个别人写的完整 html」和「打开自己存的草稿」两种情况。

' mdlFile.bas —— 读取 html 并拆成 head 与 body 两部分 Public Function LoadHtml(ByVal path As String, ByRef bodyOut As String, _ ByRef titleOut As String) As Boolean Dim st As Object, all As String Dim p1 As Long, p2 As Long On Error GoTo Fail Set st = CreateObject("ADODB.Stream") st.Type = 2 st.Charset = "utf-8" st.Open st.LoadFromFile path all = st.ReadText(-1) ' -1 = adReadAll,一次读完 st.Close Set st = Nothing p1 = InStr(1, all, "<body", vbTextCompare) If p1 = 0 Then ' 没有 body 标签,整篇当片段处理 bodyOut = all Else p1 = InStr(p1, all, ">") + 1 p2 = InStr(p1, all, "</body>", vbTextCompare) If p2 = 0 Then p2 = Len(all) + 1 bodyOut = Mid$(all, p1, p2 - p1) End If titleOut = ExtractTitle(all) ' 从 <title> 里抠,抠不到就用文件名 LoadHtml = True Exit Function Fail: LoadHtml = False End Function

参数说明:ReadText(-1)里的-1是adReadAll,读到流末尾;Mid$的起始位置要 +1 跳过>,长度用p2 - p1而不是p2 - p1 + 1,因为结束位置指向</body>的第一个字符。这套字符串切分当然不如正则严谨,但对自家产出的 html 够用;如果用户会打开外部页面,建议把<body的查找范围限制在</head>之后,否则注释里出现<body就会切错位置。

5. 常见问题排查:五个把我按在椅子上半天的问题

5.1 点按钮完全没反应,也不报错

现象:程序跑起来,编辑区能打字,光标也闪,但点「加粗」没有任何效果,断点打进去Exec也确实执行了,execCommand也没抛异常。

原因:焦点被按钮抢走了。工具栏按钮点击后成为活动控件,document里没有活动选区,execCommand找不到作用对象,直接静默返回。

解决:Exec函数第一行加doc.focus,把焦点抢回文档。如果还不行,检查是不是在Exec里先做了MsgBox之类会再次夺焦点的操作,MsgBox关掉之后焦点不会自动回到文档,必须再补一次focus。

5.2 一打开就弹「对象变量未设置」

现象:Form_Load里写WebEditor.Document.body.innerHTML = "",程序一启动就弹错误框。

原因:Navigate是异步的,Form_Load执行完时文档还没加载好,Document是Nothing。

解决:把所有跟Document相关的初始化搬到WebEditor_DocumentComplete事件里,并且用If pDisp Is WebEditor.Object Then判断是不是顶层框架完成。漏了这个判断的话,页面里每有一个 iframe,初始化就会重复跑一次,表现是编辑区内容被清空好几次。

5.3 保存的中文网页在别人电脑上全是乱码

现象:自己机器上打开正常,发到同事那边标题和正文全是问号或者方块。

原因:用Open path For Output As #1这种方式写文件,编码跟随系统区域设置,是 ANSI,对方区域不同就解不出来。

解决:统一用ADODB.Stream配Charset = "utf-8",同时在 html 的<head>里写死<meta charset="utf-8">。两边都对了才稳,只改一边有时候看着正常,换个浏览器又乱。

5.4 源码视图改完切回来,样式全丢

现象:在源码视图里加了一段<style>或者<link rel="stylesheet">,切回设计视图发现样式没生效,再切回源码视图,那段代码已经不见了。

原因:剪切同步只用body.innerHTML,只在 body 内部搬运,head 里的东西根本没参与往返。

解决:切到源码视图时保存整篇文档字符串(含 head),切回来时先判断源码里有没有<html标签:有就整篇写回(用document.write或者重建文档),没有就当片段塞进body.innerHTML。整篇写回会触发DocumentComplete重新进入编辑态,注意别在事件里递归调用自己。

5.5 插入的图片换台机器就裂了

现象:本机编辑的时候图片显示正常,把 html 拷到 U 盘或者发出去,图片全变成裂图占位。

原因:InsertImage的src是绝对路径file:///C:/...,指向的是本机磁盘。

解决:插入时先FileCopy到 html 同级的assets/目录,再把相对路径assets/xxx.jpg传给execCommand。顺带说一句,execCommand("InsertImage", ...)在某些内核版本下会自作主张加上file:///前缀,所以插完最好再从 DOM 里把src读出来校正一次,这个属于内核行为不一致,只能兼容不能讲理。

6. 二次开发:把它改成你能真正用起来的编辑器

拿到这份工程之后,我建议先别急着加功能,先做两件事:把内核版本顶上去,把命令表抽出来。

第一件事是绕过 IE7 兼容模式。WebBrowser 控件默认可能跑在兼容视图里,flex、grid、border-radius全废,编辑出来的页面到自己手里看着还行,一上线就散架。做法是往注册表的FEATURE_BROWSER_EMULATION里给自己 exe 名字登记一个模式值,11001对应 IE11 标准模式。

' mdlEditor.bas —— 只在程序启动时跑一次,写 HKCU 不需要管理员权限 Public Sub SetBrowserEmulation(ByVal exeName As String, Optional ByVal mode As Long = 11001) Dim sh As Object Set sh = CreateObject("WScript.Shell") sh.RegWrite "HKCU\Software\Microsoft\Internet Explorer\Main\FeatureControl\" & _ "FEATURE_BROWSER_EMULATION\" & exeName, mode, "REG_DWORD" End Sub

关键在于exeName必须是带.exe后缀的完整程序名,写在 IDE 里调试时用的是vb6.exe,写错了完全没效果,而且不报错——这个坑我踩过一次,对着注册表找了两个小时。

第二件事是把CommandOf那张Select Case换成配置文件或者常量数组。命令表一旦可配置,加按钮、改快捷键、做自定义工具栏就是纯数据操作,不用重新编译。我一般把命令名、显示文字、图标索引、快捷键、是否需要参数这五个字段放进一个Type数组里,初始化时遍历生成工具栏,比在设计器里一个个拖控件快得多。

改完之后怎么验证?不要只看「点得动」。我的检查清单是四步:新建一篇文档,输入中英混排加三段不同格式,保存成 utf-8;关掉程序,双击那个 html,看中文和样式是否都在;把 html 连同assets/目录拷到另一台机器打开,看图是否裂;最后在源码视图里手写一个<table>和一段<style>,来回切两次,看有没有被吃掉。四步都过了,才算这次改造没白做。

顺手能加的小改进还有两个:一是给SetHtml加 sanitize,把script、iframe、on*属性过滤掉,避免源码视图成为注入入口;二是把撤销从内核的Undo换成自己用clsHistory维护的栈,内核的撤销栈在切换视图时会被清空,用户改了一大段再切源码,回来想撤销就来不及了。

从那以后我每次改这类老工程的编辑器模块,都强制先跑一遍上面那四步验证,尤其是「拷到另一台机器」这一步,因为它总能替我把路径和编码的问题提前抖出来。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询