本文介绍如何使用React、CodeMirror及Web Worker构建一款支持游戏MOD开发的XML在线编辑器。文章详细阐述了从基础代码提示到利用FileSystemAccess API突破浏览器沙盒限制、实现本地文件直接读写的全过程,并分享了处理大规模文件树校验的性能优化方案。
前言
距离上一次更新博客已过去一年多。期间《骑马与砍杀2》发售,我转而投入游戏MOD开发。
使用C#耗时约7个月完成游戏MOD编写,每晚工作至深夜。同时,为介绍该MOD,我学习了PR软件并转型为B站UP主。
随后因个人想法变化及公司业务调整,博客更新停滞,不知不觉一年多便过去了。
这段经历仍有所收获:
- 在暂停MOD更新期间,该MOD在中文站及3DM等平台的下载量均位列第一,且远超第二名。熟悉《骑砍2》MOD的玩家或许能猜到作者身份。
- 在B站积累了五千余名粉丝。尽管演讲风格从结巴到依旧结巴,但凭借剪辑技巧,视频观看效果尚可。
- 深刻体会到制作UP主内容的繁琐。即便数据表现平平,也已超越B站大量零播放的UP主。浏览最新视频列表,数十页中大量视频播放量为零,景象独特。
- 这些有趣的经历丰富了人生体验。
言归正传。
目前MOD开发已暂停,UP主身份也不再深耕。
本文重点探讨技术部分,即一个纯前端实现的、用于编写MOD的XML在线编辑器。
该编辑器采用仿VSCode风格,能自动学习游戏MOD文件的生成约束规则,从而提供代码提示与校验功能。
其核心优势在于能够直接修改电脑本地的文件。
最终成品的代码仓库地址为:https://gitee.com/vvjiang/mod-xml-editor
此处附有一张成品展示图:

本篇博客涉及的技术栈包括:
- CodeMirror
- react-codemirror2
- xmldom
- FileReader
- IndexDB
- Web Worker
- File System Access
接下来从头开始讲解。
在线XML编辑器的需求
在制作《骑砍2》MOD时,需频繁编写XML文件。
《骑砍2》的数据配置以XML形式保存,MOD加载后,通过MOD的XML覆盖官方XML。
通常制作MOD数据时,需参考官方XML自行编写XML文件。
然而,XML缺乏代码提示和校验功能,细微字符错误难以察觉。
此外,游戏更新可能导致XML规则变更。
官方通常不会发布此类变更通知,若继续使用旧元素和属性,即视为错误。
错误结果通常是游戏加载MOD时直接崩溃,且无任何提示,只能逐步排查BUG。
《骑砍2》作为大型游戏,启动时间漫长,导致测试MOD数据配置正确性的流程极其耗时。
无数个夜晚,游戏崩溃的瞬间也让人心态崩溃。
因此,我决定开发一个在线XML编辑器以解决此问题。
技术预研
可视化编程
起初并未考虑开发XML编辑器,因其看似复杂,转而尝试通过可视化编程,利用拖拉拽元素和属性来实现。
确实完成了一套初步方案,但在配置大型XML时,反复拖拉拽导致心态爆炸,最终放弃该方案。
VSCODE插件
尝试寻找支持代码提示的VSCode插件,发现有一个使用XSD进行代码校验的插件,疑似由IBM提供。
遗憾的是该插件已废弃,无法使用,故放弃此方案。
在线编辑器
最终选择在线编辑器方案,源于三四月份公司计划开发一个在线编辑Java项目环境xml配置文件的功能。
我尝试开发并了解了CodeMirror。
CodeMirror通过配置tags支持XML代码提示,但不支持XML代码校验,需自行实现校验功能。
由于通常使用xsd校验XML,还需将xsd转换为CodeMirror的tags配置。
无论是百度、Google还是Github,均无相关方案,只能自行编写代码实现。
在此过程中,我对CodeMirror、xsd及htmllint有了深入了解,最终完成项目。
因涉及公司代码,此处不公开。
总之,通过此过程了解到CodeMirror,进而萌生使用CodeMirror制作MOD在线编辑器的想法。
最初形态:简单的在线XML编辑器
废话少说,直接动手。
最初形态无左侧文件树,仅包含一个编辑器和一个规则学习弹窗。
涉及的技术仅三项:
CodeMirror
FileReader
xmldom
使用CodeMirror构建编辑器
CodeMirror部分主要使用React封装版react-codemirror2,依据文档和Demo自行配置。
唯一难点在于网络上的CodeMirror配置介绍多为转载,且多数错误。

建议参考官方文档(https://codemirror.net/)及Demo自行研究,盲目抄配置风险较大。
此处贴出我封装的编辑器组件配置代码,确保可用,涵盖大多数编辑器功能,但仅适用于XML编辑。
注释详尽,包括常用代码折叠、格式化等,不再逐一讲解,可参考官网。
部分引用代码未列出,有兴趣者可查看上述代码仓库。
import { useEffect } from 'react'
import { Controlled as ControlledCodeMirror } from 'react-codemirror2'
import CodeMirror from 'codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/theme/ayu-dark.css'
import 'codemirror/mode/xml/xml.js'
// 光标行代码高亮
import 'codemirror/addon/selection/active-line'
// 折叠代码
import 'codemirror/addon/fold/foldgutter.css'
import 'codemirror/addon/fold/foldcode.js'
import 'codemirror/addon/fold/xml-fold.js'
import 'codemirror/addon/fold/foldgutter.js'
import 'codemirror/addon/fold/comment-fold.js'
// 代码提示补全和
import 'codemirror/addon/hint/xml-hint.js'
import 'codemirror/addon/hint/show-hint.css'
import './hint.css'
import 'codemirror/addon/hint/show-hint.js'
// 代码校验
import 'codemirror/addon/lint/lint'
import 'codemirror/addon/lint/lint.css'
import CodeMirrorRegisterXmlLint from './xml-lint'
// 输入> 时自动键入结束标签
import 'codemirror/addon/edit/closetag.js'
// 注释
import 'codemirror/addon/comment/comment.js'
// 用于调整codeMirror的主题样式
import style from './index.less'
// 注册Xml代码校验
CodeMirrorRegisterXmlLint(CodeMirror)
// 格式化相关
CodeMirror.extendMode("xml", {
commentStart: "<!--",
commentEnd: "-->",
newlineAfterToken: function (type, content, textAfter, state) {
return (type === "tag" && />$/.test(content) && state.context) ||
/^</.test(textAfter);
}
});
// 格式化指定范围
CodeMirror.defineExtension("autoFormatRange", function (from, to) {
var cm = this;
var outer = cm.getMode(), text = cm.getRange(from, to).split("\n");
var state = CodeMirror.copyState(outer, cm.getTokenAt(from).state);
var tabSize = cm.getOption("tabSize");
var out = "", lines = 0, atSol = from.ch === 0;
function newline() {
out += "\n";
atSol = true;
++lines;
}
for (var i = 0; i < text.length; ++i) {
var stream = new CodeMirror.StringStream(text[i], tabSize);
while (!stream.eol()) {
var inner = CodeMirror.innerMode(outer, state);
var style = outer.token(stream, state), cur = stream.current();
stream.start = stream.pos;
if (!atSol || /\S/.test(cur)) {
out += cur;
atSol = false;
}
if (!atSol && inner.mode.newlineAfterToken &&
inner.mode.newlineAfterToken(style, cur, stream.string.slice(stream.pos) || text[i + 1] || "", inner.state))
newline();
}
if (!stream.pos && outer.blankLine) outer.blankLine(state);
if (!atSol && i < text.length - 1) newline();
}
cm.operation(function () {
cm.replaceRange(out, from, to);
for (var cur = from.line + 1, end = from.line + lines; cur <= end; ++cur)
cm.indentLine(cur, "smart");
cm.setSelection(from, cm.getCursor(false));
});
});
// Xml编辑器组件
function XmlEditor(props) {
const { tags, value, onChange, onErrors, onGetEditor, onSave } = props
useEffect(() => {
// tags 每次变动时,都会重新改变校验规则
CodeMirrorRegisterXmlLint(CodeMirror, tags, onErrors)
}, [onErrors, tags])
// 开始标签
function completeAfter(cm, pred) {
if (!pred || pred()) setTimeout(function () {
if (!cm.state.completionActive)
cm.showHint({
completeSingle: false
});
}, 100);
return CodeMirror.Pass;
}
// 结束标签
function completeIfAfterLt(cm) {
return completeAfter(cm, function () {
var cur = cm.getCursor();
return cm.getRange(CodeMirror.Pos(cur.line, cur.ch - 1), cur) === "<";
});
}
// 属性和属性值
function completeIfInTag(cm) {
return completeAfter(cm, function () {
var tok = cm.getTokenAt(cm.getCursor());
if (tok.type === "string" && (!/['"]/.test(tok.string.charAt(tok.string.length - 1)) || tok.string.length === 1)) return false;
var inner = CodeMirror.innerMode(cm.getMode(), tok.state).state;
return inner.tagName;
});
}
return (
<div className={style.editor} >
<ControlledCodeMirror
value={value}
options={{
mode: {
name: 'xml',
// xml 属性换行的时候是否加上标签的长度
multilineTagIndentPastTag: false
},
indentUnit: 2, // 换行的默认缩进多少个空格
theme: 'ayu-dark', // 编辑器主题
lineNumbers: true,// 是否显示行号
autofocus: true,// 自动获取焦点
styleActiveLine: true,// 光标行代码高亮
autoCloseTags: true, // 在输入>时自动键入结束元素
toggleComment: true, // 开启注释
// 折叠代码 begin
lineWrapping: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
// 折叠代码 end
extraKeys: {
// 代码提示
"'<'": completeAfter,
"'/'": completeIfAfterLt,
"' '": completeIfInTag,
"'='": completeIfInTag,
// 注释功能
"Ctrl-/": (cm) => {
cm.toggleComment()
},
// 保存功能
"Ctrl-S": (cm) => {
onSave()
},
// 格式化
"Shift-Alt-F": (cm) => {
const totalLines = cm.lineCount();
cm.autoFormatRange({ line: 0, ch: 0 }, { line: totalLines })
},
// Tab自动转换为空格
"Tab": (cm) => {
if (cm.somethingSelected()) {// 选中后整体缩进的情况
cm.indentSelection('add')
} else {
cm.replaceSelection(Array(cm.getOption("indentUnit") + 1).join(" "), "end", "+input")
}
}
},
// 代码提示
hintOptions: { schemaInfo: tags, matchInMiddle: true },
lint: true
}}
editorDidMount={onGetEditor}
onBeforeChange={onChange}
/>
</div>
)
}
export default XmlEditor
学习XML并提取tags规则
使用CodeMirror构建简单编辑器时,要实现XML代码提示,需使用tags。
不同游戏拥有不同的XML规则,且游戏更新后规则可能变更。
因此,必须建立机制不断学习XML规则,为此设计了一个学习XML文件规则的弹窗。
点击编辑器左上方的“约束规则”——>“新增约束规则”。

将弹出如下弹窗:

通过FileReader读取指定文件夹的XML文件,并使用xmldom依次解析XML文本,生成文档对象。
再分析文档对象以获取最终tags规则。
此步骤仅需了解XML,属基础操作,故略过不讲。
至此完成最初形态。每次使用时,需将编辑的XML文件内容复制至在线编辑器,编辑完成后,再将文本复制回原XML文件保存覆盖。
进化形态:加载树形文件结构和全文件校验功能的在线XML编辑器
上述编辑器使用场景狭窄,仅适用于新建XML。
一个MOD往往包含几十、上百甚至数千个文件,不可能逐个粘贴至编辑器校验。
因此,需在编辑器中加载MOD的所有XML文件,并进行代码校验。
涉及的技术两项:
- FileReader
- Web Worker
左侧文件树
左侧文件树使用Ant Design的Tree组件完成,此处略过配置讲解。
点击“打开文件夹”按钮时:

同样使用FileReader读取MOD文件夹中的文件。
但FileReader获取的是文件数组,需手动解析每个XML文件路径,并据此生成树形结构。
全文件校验功能
打开文件夹瞬间,需对所有XML文件进行代码校验。若校验有误,需在左侧文件夹中将相关文件及其父级、祖级文件夹全部标红。
此功能表面简单,实则坑点众多。校验计算量不小,特别是MOD包含几百几千个文件时,极易导致JS阻塞、页面无响应。
此处使用Web Worker新开线程处理校验过程,校验完成后将结果返回。
在此过程中,对Web Worker的使用有了更深了解。
印象中Web Worker需通过new Worker(某js文件)方式使用,感觉难以结合React模块化开发。
实际上,在webpack中配置worker-loader,可方便使用Web Worker。
首先,worker代码可编写如下:
import { lintFileTree } from '@/utils/files'
onmessage = ({ data }) => {
lintFileTree(data.fileTree, data.currentTags).then(content => {
postMessage(content)
})
}
使用时,可如下所示:
import { useWebWorkerFromWorker } from 'react-webworker-hook'
import lintFileTreeWorker from '@/utils/webWorker/lintFileTree.webworker'
const worker4LintFileTree = new lintFileTreeWorker()
const [lintedFileTree, startLintFileTree] = useWebWorkerFromWorker(worker4LintFileTree)
若再使用useEffect依赖lintedFileTree,变动时执行操作,编写起来如同使用useState般轻松。
非递归遍历树
上述所用技术与树相关,如遍历文件树校验代码。
切换约束规则后,也需遍历整个文件树重新校验。
遍历过程中,此前使用递归遍历整树,缺点是递归时内存无法释放,故改用非递归方式遍历整树。
IndexDB保存文件内容
因MOD文件内容多且大,内存占用可能极高,无法将所有文件内容置于内存中。
故读取文件内容后,依次存入IndexDB,仅展示当前编辑文件内容。
仅在需要时,如全文件校验或切换文件时,才从IndexDB再次获取文件内容。
究极进化形态:突破浏览器沙盒限制,实现对电脑本地文件的增删改
通过前述操作,终于完成一个基本可用的在线XML编辑器。
但其存在致命缺点:受浏览器沙盒环境限制,修改文件后无法直接保存至电脑,必须手动将修改代码复制到对应文件。
此操作繁琐复杂,导致编辑器功能可能仅能辅助编写代码和批量校验。
此前以为只能至此,但后来在知乎偶然看到帖子,发现Chrome86+版本新增功能API:FileSystemAccess。
此外,除非是本地localhost环境,否则该API仅在https环境下可调用。即在http网站上,即便使用Chrome86+或Edge86+,也无法调用。
该API允许直接操作本地电脑文件,不同于FileReader仅能读取,或FileSystem仅在浏览器沙盒内操作。
通过FileSystemAccess,不仅可实现对文件夹中文件的读取修改,还能新增和删除文件。
故使用此API全面替换此前使用FileReader的各点,实现在文件树上右键进行文件夹和文件的新增和删除(此处不支持文件重命名,但可通过删除后新增模拟,因嫌麻烦未做)。
同时,在按保存按钮或快捷键Ctrl+S后,即可直接对文件进行保存操作。
以下是一个使用FileSystemAccess打开文件夹的组件代码:
import React from 'react'
// 自定义的打开文件夹组件
const FileInput = (props) => {
const { children, onChange } = props
const handleClick = async () => {
const dirHandle = await window.showDirectoryPicker()
dirHandle.requestPermission({ mode : "readwrite" })
onChange(dirHandle)
}
return <span onClick={handleClick}>
{children}
</span>
}
export default FileInput
只要被此组件包裹的元素(如按钮)被点击,即立即调用showDirectoryPicker,请求打开文件夹。
打开文件夹后,通过获得的文件夹handle请求文件夹写入权限,再将文件夹handle传至外部,获取文件树结构。
此处操作有瑕疵,因请求打开文件夹时浏览器会弹窗向用户获取读取文件夹权限:

打开完毕后,又直接弹窗第二次获取写入权限,即打开文件夹时会弹出两次框。
但只能通过此手法一次性请求所有权限,否则等到保存时再请求权限体验不佳。
但瑕不掩瑜,通过此API不仅实现了文件增删改,还解除对IndexDB的使用。
因随时可通过文件Handle获取相应文件内容,故无需将文件内容保存至IndexDB中。
更多的功能与细节
以上仅概述技术核心功能,实际上该编辑器还有诸多细节。
如调整tags规则的面板、工具栏按钮、对dva的简单封装处理,以及分析XML时,若属性值为数字,则不进行提示而直接忽略,因数字往往意义不大且枚举值过大。
这一切细节繁多,但应用较为基础,故不想赘述,否则博客将过于冗长,难以突出核心思路。
不足与总结
此处不足更多源于懒惰,如前述文件夹和文件重命名功能,以及调整tags规则的自定义规则处不支持修改删除。
可实现,只是懒得做。
此项目前后耗时数月,并非每晚都在写,主要是有灵感时编写,或发现可改进之处再修改。
合计约两三周每晚都在做此事,当它愈趋完善和可用时,愈懒得做。
因剩余操作不重要,且脑补即可完成后,缺乏挑战性。
但总体而言,此项目当前可用性较强。
不仅可用于《骑马与砍杀2》、《了不起的修真模拟器》、《文明6》等游戏XML文件的辅助编写,还可用于无XSD规则又过于复杂的XML配置,甚至可学习自定义XML规则。
到此这篇关于js制作xml在线编辑器实例的文章就介绍到这了,更多相关js制作xml在线编辑器内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!