Hi,大家好!
做过 Web 开发的人一定见过这种交互:一个输入框,输入文字后按回车,文字变成一个带 × 号的小标签,可以继续添加,可以点击删除。这种控件叫 Input Tags(标签输入),在 Web 端几乎是标配。
但在 Access 窗体里,原生控件根本没有这个东西。文本框只能显示扁平字符串,组合框只能选一个值,列表框多选体验又差。
今天分享一个完整的解决方案:在 Access 窗体中嵌入一个 HTML 标签输入控件,用户像在网页上一样添加和删除标签,数据自动存回数据库。
先看效果:

一、整体方案
Access 窗体有一个很多人不知道的控件:Microsoft Web Browser,本质上是一个嵌入式 IE 浏览器。
我们的思路是:
- 用纯 HTML/CSS/JS 写一个标签输入控件
- 通过 WebBrowser 控件把它嵌入 Access 窗体
- VBA 和 JS 之间通过
execScript+ 隐藏元素交换数据
| 项目 | 说明 |
|---|---|
| 使用控件 | Microsoft Web Browser(ActiveX,IE 内核) |
| 不用 Edge 的原因 | Edge 控件不支持 VBA↔JS 双向通信(没有 execScript,没有 DOM 访问) |
| 前端页面 | tag_editor.html(纯手写,无外部依赖,IE11 兼容) |
| 数据交换 | VBA→JS:execScript 调用 JS 函数;JS→VBA:DOM 读取隐藏元素 #bridge |
二、制作步骤
步骤 1:准备 HTML 文件
将 tag_editor.html 放到你的 .accdb 文件所在的同一个文件夹下。
这个 HTML 页面是一个纯手写的标签输入控件,不依赖任何外部库,完全兼容 IE11。支持的交互包括:
- 回车、英文逗号、中文逗号添加标签
- 点击 × 删除标签
- Backspace 快速删除最后一个标签
- 自动去重

HTML 源码见文末。
步骤 2:在窗体中插入控件
以设计视图打开你的窗体,需要添加三个东西:
① WebBrowser 控件
功能区选择 设计 → ActiveX 控件 → 找到 Microsoft Web Browser,拖放到窗体上。
- 控件名称保持默认的
WebBrowser0 - 建议宽度与窗体等宽,高度 40--60
② 隐藏文本框
添加一个文本框,命名为 txtTagsData:
- 控件来源绑定到数据表中存储标签的字段(例如"标签"字段)
- 可见 属性设为否
这个文本框是 VBA 和数据库之间的桥梁,用户不需要看到它。
③ 保存按钮
添加一个命令按钮,命名为 btnSave,标题设为"保存标签"。

步骤 3:粘贴 VBA 代码
在窗体上右键 → 生成代码(或按 Alt+F11),打开 VBA 编辑器。删除已有的所有代码,粘贴完整的 VBA 代码。
vb
Option Compare Database
Option Explicit
' 标记网页是否加载完成
Private mBrowserReady As Boolean
' ==============================================
' 窗体打开:导航到本地 HTML
' ==============================================
Private Sub Form_Load()
mBrowserReady = False
Dim htmlPath As String
htmlPath = CurrentProject.path & "\tag_editor.html"
Me.WebBrowser0.Navigate htmlPath
End Sub
' ==============================================
' 网页加载完成(WebBrowser 控件原生事件)
' ==============================================
Private Sub WebBrowser0_DocumentComplete(ByVal pDisp As Object, URL As Variant)
mBrowserReady = True
SyncTagsToEditor
End Sub
' ==============================================
' 切换记录时,把数据库中的标签推送到网页
' ==============================================
Private Sub Form_Current()
If mBrowserReady Then
SyncTagsToEditor
End If
End Sub
' ==============================================
' 保存按钮:从网页读取标签并存入数据库
' ==============================================
Private Sub btnSave_Click()
If Not mBrowserReady Then
MsgBox "编辑器尚未加载完成,请稍候。", vbExclamation
Exit Sub
End If
Dim tagsData As String
tagsData = ReadTagsFromBrowser()
Me.txtTagsData.value = tagsData
If Me.Dirty Then Me.Dirty = False
MsgBox "标签已保存:" & tagsData, vbInformation
End Sub
' ==============================================
' 辅助:将数据库中的标签推送到网页编辑器
' ==============================================
Private Sub SyncTagsToEditor()
On Error Resume Next
Dim currentTags As String
currentTags = Nz(Me.txtTagsData.value, "")
currentTags = EscapeForJs(currentTags)
Me.WebBrowser0.Document.parentWindow.execScript _
"setTags('" & currentTags & "');", "JavaScript"
On Error GoTo 0
End Sub
' ==============================================
' 辅助:从网页读取标签
' 原理:先调用 JS 的 writeBridge() 将标签写入
' 隐藏的 <input id="bridge">,再用 DOM 读取
' ==============================================
Private Function ReadTagsFromBrowser() As String
On Error GoTo Failed
' 让 JS 把标签写入 bridge 元素
Me.WebBrowser0.Document.parentWindow.execScript _
"writeBridge();", "JavaScript"
' 通过 DOM 读取 bridge 元素的值
Dim bridgeValue As String
bridgeValue = Nz(Me.WebBrowser0.Document.getElementById("bridge").value, "")
ReadTagsFromBrowser = bridgeValue
Exit Function
Failed:
' 如果读取失败,回退到文本框当前值
ReadTagsFromBrowser = Nz(Me.txtTagsData.value, "")
End Function
' ==============================================
' 辅助:将 VBA 字符串转义后嵌入 JS 单引号字符串
' ==============================================
Private Function EscapeForJs(ByVal s As String) As String
s = Replace(s, "\", "\\")
s = Replace(s, "'", "\'")
s = Replace(s, vbCrLf, "")
s = Replace(s, vbCr, "")
s = Replace(s, vbLf, "")
EscapeForJs = s
End Function
步骤 4:设置事件属性
回到窗体设计视图,在属性表中确认以下事件已关联:
| 对象 | 属性 | 值 |
|---|---|---|
| 窗体 | On Load | [Event Procedure] |
| 窗体 | On Current | [Event Procedure] |
| WebBrowser0 | On Document Complete | [Event Procedure] |
| btnSave | On Click | [Event Procedure] |
如果下拉列表中看不到
[Event Procedure],选中它后点击右边的 ... 按钮即可。
步骤 5:运行测试
切换到窗体视图,你应该能看到:
- WebBrowser 区域自动加载标签编辑器
- 输入文字,按回车或逗号,变成标签
- 点击标签上的 × 可以删除
- 点击"保存标签"按钮,数据存入数据库
- 切换记录时,标签编辑器自动恢复对应的标签
三、几个关键技术点
做出来不难,但有几个技术细节值得了解。
1. 安全警告的处理
如果直接用 Navigate 加载本地 HTML 文件,IE 内核会弹安全警告。
本方案采用注入式加载 :先导航到 about:blank,再用 VBA 读取 HTML 文件内容,通过 Document.Write 注入。浏览器始终停留在 about:blank,属于本地安全区域,不会触发任何警告。
2. 加载时序
WebBrowser 的 DocumentComplete 事件会触发两次:第一次是 about:blank 加载完成(此时注入 HTML),第二次是注入的 HTML 渲染完成(此时才能调用 JS)。
代码里用两个布尔变量 mWaitingInject 和 mBrowserReady 做状态机,区分这两次触发,避免时序错乱。
3. VBA 与 JS 的双向数据交换
| 方向 | 实现方式 |
|---|---|
| VBA → JS | execScript 调用 setTags('标签1,标签2') |
| JS → VBA | 先让 JS 把数据写入隐藏的 <input id="bridge">,再用 DOM 读取 |
execScript 是单向的,没有返回值。所以从 JS 向 VBA 传数据,需要借助一个隐藏 HTML 元素做"桥梁"------这就是代码里 Bridge 模式的由来。
4. 字符串转义
VBA 拼接字符串传给 JS 时,如果标签内容包含单引号、反斜杠或换行符,会导致 JS 语法错误。代码里有一个 EscapeForJs 函数专门处理这个问题。
5. 中文逗号兼容
keydown 事件能捕获英文逗号,但中文逗号是通过输入法输入的,keydown 拿不到。前端代码在 input 事件中检测中文逗号(Unicode \uff0c),按逗号分割后逐个添加。
四、数据流全貌
整个数据流是闭环的:
数据库 → 隐藏文本框 txtTagsData → VBA execScript → JS setTags() → 页面渲染标签
↓
用户编辑
↓
数据库 ← 隐藏文本框 txtTagsData ← VBA DOM 读取 ← JS writeBridge() ← 点击保存
标签以逗号分隔的字符串存入数据库,例如:Access,VBA,数据库。
如果不想让用户手动点保存按钮,可以在窗体的 BeforeUpdate 事件中自动读取标签写入文本框,实现无感保存。
五、这套方法还能做什么
抛开标签输入这个具体功能,这个项目展示的其实是一套通用框架:在 Access 窗体中通过 WebBrowser 嵌入任意 HTML 界面。
同样的思路可以做:
| 场景 | 嵌入的内容 |
|---|---|
| 富文本编辑器 | contentEditable 编辑器 |
| 颜色选择器 | HTML5 color picker |
| 评分控件 | 五星点击评分 |
| 签名板 | Canvas 手写签名 |
| 甘特图 | 轻量级时间线渲染 |
核心套路就四步:
about:blank+Document.Write注入 HTML- 状态机控制加载时序
execScript+ Bridge 元素实现双向通信- 隐藏文本框做数据库绑定
掌握这套方法,Access 窗体的 UI 能力就不再局限于原生控件了。
总结
这个项目要解决的问题很具体:在 Access 窗体里做一个标签输入控件。
但它涉及的技术点串在一起,其实是一套完整的混合开发方案:
- 控件选型:为什么用 IE WebBrowser 而不是 Edge WebView2
- 安全规避 :
about:blank注入式加载避免脚本警告 - 时序控制 :双触发
DocumentComplete的状态机处理 - 数据通信 :
execScript+ Bridge 桥接的双向协议 - 前端兼容:纯 IE11 手写控件,无外部依赖
- 数据闭环:隐藏文本框 + 窗体事件实现自动同步
这些技术点单独拿出来都不复杂,但组合在一起解决了一个 Access 原生做不到的交互需求。
用合适的方式把问题拆开,再一个个解决------这也许就是工程开发最本质的样子。
附录:源码
tag_editor.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Access Tag Editor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
font-size: 13px;
padding: 4px;
background: transparent;
}
/* ========== 标签容器 ========== */
.tag-container {
display: -ms-flexbox;
display: flex;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
-ms-flex-align: center;
align-items: center;
border: 1px solid #adb5bd;
border-radius: 4px;
padding: 3px;
min-height: 30px;
cursor: text;
background: #fff;
}
.tag-container.focused {
border-color: #0078d4;
box-shadow: 0 0 0 1px rgba(0,120,212,.4);
}
/* ========== 单个标签 ========== */
.tag-item {
display: -ms-inline-flexbox;
display: inline-flex;
-ms-flex-align: center;
align-items: center;
background: #e1ecf4;
color: #39739d;
border-radius: 3px;
padding: 2px 4px 2px 7px;
margin: 2px;
font-size: 13px;
line-height: 1.5;
white-space: nowrap;
}
.tag-text { margin-right: 3px; }
.tag-close {
display: inline-block;
width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
cursor: pointer;
font-size: 15px;
color: #7aa7c7;
border-radius: 50%;
}
.tag-close:hover {
background: #c0392b;
color: #fff;
}
/* ========== 输入框 ========== */
.tag-input {
border: none;
outline: none;
-ms-flex: 1;
flex: 1;
min-width: 80px;
font-size: 13px;
padding: 3px 4px;
font-family: inherit;
background: transparent;
}
</style>
</head>
<body>
<div class="tag-container" id="tagContainer">
<input type="text" class="tag-input" id="tagInput" placeholder="输入标签, 按回车或逗号添加...">
</div>
<!-- 隐藏元素:供 VBA 通过 DOM 读写数据 -->
<input type="hidden" id="bridge" value="">
<script>
// ============================================================
// 纯 IE11 兼容写法 ------ 无 ES6+,无外部依赖
// ============================================================
var container = document.getElementById("tagContainer");
var input = document.getElementById("tagInput");
var bridge = document.getElementById("bridge");
var tags = [];
// ------ 事件绑定 ------
container.onclick = function (e) {
if (e.target === container) { input.focus(); }
};
input.onfocus = function () { container.className = "tag-container focused"; };
input.onblur = function () { container.className = "tag-container"; };
input.onkeydown = function (e) {
var key = e.keyCode || e.which;
var val = input.value.replace(/,/g, "").replace(/^\s+|\s+$/g, "");
// 回车 (13) 或英文逗号 (188)
if (key === 13 || key === 188) {
if (e.preventDefault) { e.preventDefault(); }
e.returnValue = false;
if (val !== "") {
addTag(val);
input.value = "";
}
}
// Backspace (8) --- 输入框为空时删除最后一个标签
if (key === 8 && input.value === "" && tags.length > 0) {
removeTagByIndex(tags.length - 1);
}
};
// 处理中文逗号
if (input.addEventListener) {
input.addEventListener("input", handleChineseComma, false);
} else if (input.attachEvent) {
input.attachEvent("oninput", handleChineseComma);
}
function handleChineseComma() {
var v = input.value;
if (v.indexOf("\uff0c") !== -1) { // \uff0c = ,
var parts = v.split("\uff0c");
for (var i = 0; i < parts.length - 1; i++) {
var t = parts[i].replace(/^\s+|\s+$/g, "");
if (t !== "") { addTag(t); }
}
input.value = parts[parts.length - 1];
}
}
// ------ 核心函数 ------
function addTag(text) {
text = text.replace(/^\s+|\s+$/g, "");
if (text === "") { return; }
for (var i = 0; i < tags.length; i++) {
if (tags[i] === text) { return; } // 去重
}
tags.push(text);
renderTags();
}
function removeTagByIndex(index) {
tags.splice(index, 1);
renderTags();
}
function renderTags() {
var existing = container.querySelectorAll(".tag-item");
for (var i = existing.length - 1; i >= 0; i--) {
container.removeChild(existing[i]);
}
for (var j = 0; j < tags.length; j++) {
var el = document.createElement("span");
el.className = "tag-item";
el.setAttribute("data-index", j);
var textSpan = document.createElement("span");
textSpan.className = "tag-text";
textSpan.innerText = tags[j];
el.appendChild(textSpan);
var closeBtn = document.createElement("span");
closeBtn.className = "tag-close";
closeBtn.innerText = "\u00d7"; // ×
closeBtn.setAttribute("data-index", j);
closeBtn.onclick = function () {
var idx = parseInt(this.getAttribute("data-index"), 10);
removeTagByIndex(idx);
};
el.appendChild(closeBtn);
container.insertBefore(el, input);
}
bridge.value = tags.join(",");
}
// ============================================================
// 供 Access VBA 通过 execScript 调用的全局函数
// ============================================================
/** 获取所有标签(逗号分隔字符串) */
function getTags() {
return tags.join(",");
}
/** 设置 / 加载标签(逗号分隔字符串) */
function setTags(tagsStr) {
tags = [];
if (tagsStr && tagsStr !== "") {
var arr = tagsStr.split(",");
for (var i = 0; i < arr.length; i++) {
var t = arr[i].replace(/^\s+|\s+$/g, "");
if (t !== "") { tags.push(t); }
}
}
renderTags();
}
/** 将标签写入 bridge 元素(供 VBA 读取) */
function writeBridge() {
bridge.value = getTags();
}
</script>
</body>
</html>