ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Vue 3集成JSME与Ketcher:打造统一化学分子编辑器DEMO

2026/9/16 16:11:43 拓冰建站 浏览量
Vue 3集成JSME与Ketcher:打造统一化学分子编辑器DEMO 简介基于Vue框架的化学分子编辑器DEMO源码面向化学信息学开发者与科研教学人员解决化合物结构绘制、编辑与复用问题。作者将JSME与Ketcher两大化学编辑器集成于一套界面支持拖拽、立体化学标注等交互可直接作为课程设计或项目原型。压缩包共226个文件大小约21.56MB以153个JavaScript文件承载核心逻辑另有35个PNG、6个GIF、6个CSS、5个JSON、4个Vue组件及HTML、SVG、SDF等素材与配置结构清晰便于按需取用。已有343人学习/下载。源码不仅提供完整前端组件和样式还包含Vue配置、Babel转译、依赖管理及说明文档可帮助理解项目初始化、构建流程与二次开发方式。1. 化学分子编辑器DEMO为什么同时需要JSME与Ketcher做化学信息学应用的前端迟早要面对一个自带历史包袱的组件分子结构编辑器。从药物研发平台的活性筛选页到化合物登记系统里“画一下这个分子”的弹窗研究员都需要一块画布把苯环、氨基、羧基这些片段拼成结构。选型时JSME因为启动快、API简单被老系统大量使用但它的UI与交互风格偏保守功能面也更侧重中小分子Ketcher则覆盖反应绘制、R基团、聚合物片段等专业场景可官方组件面向React接进Vue工程要多做一层壳。这个DEMO源码的价值是在Vue框架内同时挂载两套编辑器并用统一状态层维护SMILES与Molfile让上游页面不关心底层是哪个编辑器。适合已经具备Vue工程化基础、准备评估编辑器集成方案的前端研发也适合平台负责人快速估算接入工作量。2. JSME与Ketcher的选型逻辑轻量嵌入与完整绘制怎么取舍2.1 选型时真正要比的是内在差异选编辑器不是挑一个完美的而是挑一个躲开自己团队短板最少的。JSME走的是GWT编译后的JavaScript路线脚本加载完毕后只需要放一个div调用初始化函数就能原地生成编辑器实例方法名如setSmiles、getMolfile全是Java时代延续下来的同步风格写起来像操作一个稳定但形态老旧的控件。Ketcher是EPAM团队持续维护的开源编辑器聚合了反应绘制、模板系统、自动布局等能力SMILES解析更完善但它的发布物与服务端交互方式决定了它更像一个“独立应用”而非普通控件。也就是说JSME的接入心态是“在Vue组件里初始化一个原生控件”Ketcher的接入心态是“在Vue组件里托管一个第三方渲染应用”。这两种心态决定了后面每一行代码的写法。对比项JSMEKetcher渲染内核GWT编译的JavaScript自研Canvas反应式布局官方组件形态全局对象 div挂载React组件 / standalone构建调用风格同步Java风格Promiseawait风格分子编辑能力中小分子、原子级绘制小分子、反应、R基团、聚合物数据导出getSmiles / getMolfilegetSmiles / getMolfile / getInchi加载体积几十KB级秒开较大首载较慢适用场景快速记忆、表单内联绘制研究平台、反应流程、专业模板这张表的核心结论JSME能覆盖80%的登记与检索场景但一旦需要画反应方程式或标记R基团就得用Ketcher。所以DEMO同时保留两者把“交互成本”和“能力边界”的选择权交给业务侧。2.2 JSME的最小初始化和数据读取JSME挂载只有两个关键动作确认全局对象存在再调用JSME.init。下面是最简封装// utils/jsmemount.js export function mountJSME(containerId) { // 脚本未加载完成时直接调用会抛错需要先检查全局对象 if (!window.JSME) { throw new Error(JSME script not loaded); } // 第二个参数是画布宽第三个参数是画布高 const applet window.JSME.init(containerId, 420px, 320px); return applet; } export function readSmiles(applet) { // 返回不带氢的简化SMILES return applet.getSmiles(); } export function writeSmiles(applet, smiles) { // 第二个参数 false 表示不触发额外的结构整理 applet.setSmiles(smiles, false); }JSME.init接收容器id、宽和高容器本身可以是一个空div初始化后JSME会在容器内部生成自己的DOM结构。setSmiles的第二个参数控制是否自动清理结构登记场景建议传false避免研究员手绘的临场修改被意外规范化。读数据时如果只是做展示getSmiles够用如果需要精确定位原子坐标则要改用getMolfile拿完整Mol块。2.3 Ketcher接入Vue的两条路线Ketcher接入Vue常见的有两条路线。第一条是用iframe包住官方standalone构建通信全部走postMessage隔离干净Ketcher升级时Vue侧代码几乎不用动但排查问题时多一层消息转发。第二条是动态挂载在组件挂载后加载standalone脚本然后在指定DOM节点上创建编辑器实例直接持有实例句柄读结构时性能更好。这个DEMO更推荐第二种代码可以这样写// utils/ketcher-mount.js export async function mountKetcher(containerId) { await loadScript(/standalone/ketcher-standalone.js); const root document.getElementById(containerId); const ketcher await window.ketcher.createKetcher({ element: root, config: { settings: { experimental: true, export.filename: structure } } }); return ketcher; } function loadScript(src) { return new Promise((resolve, reject) { const tag document.createElement(script); tag.src src; tag.onload resolve; tag.onerror () reject(new Error(load failed: ${src})); document.body.appendChild(tag); }); }createKetcher是standalone构建暴露的入口element要求是一个带明确尺寸的DOM节点config.settings里的experimental控制实验性功能开关export.filename决定导出文件的默认命名。loadScript用Promise封装脚本加载这样mountKetcher可以自然地用await串联加载失败时也能在调用链上游统一兜住。容器尺寸这一点务必注意Ketcher创建时拿到的高度为0编辑器会静默呈现空白画布后面第三、五章还会反复提及。3. Vue 3组合式封装把JSME与Ketcher注册为统一编辑器组件3.1 用Composable管理编辑器的生命周期Vue 3的组合式API非常适合处理这类“第三方控件挂载”场景初始化、销毁、实例获取都可以集中在一个useChemEditor里业务组件只关心ready状态和读写方法。// composables/useChemEditor.js import { onMounted, onBeforeUnmount, ref } from vue; import { mountJSME } from ../utils/jsmemount; import { mountKetcher } from ../utils/ketcher-mount; export function useChemEditor(mode jsme) { const ready ref(false); const containerId chem-editor-mount; let editorInstance null; onMounted(async () { // 根据 mode 选择不同的编辑器内核 if (mode jsme) { editorInstance mountJSME(containerId); ready.value true; } else { editorInstance await mountKetcher(containerId); ready.value true; } }); onBeforeUnmount(() { // 两个编辑器都没有官方 destroy这里至少清掉挂载点 const dom document.getElementById(containerId); if (dom) dom.innerHTML ; editorInstance null; }); return { ready, containerId, getInstance: () editorInstance }; }这里的关键是onBeforeUnmount里对DOM的清理。JSME和Ketcher在空白容器内创建了大量内部节点路由频繁切换时不清理会出现多个编辑器叠加、事件重复触发的现象。containerId固定为一个常量也是刻意为之确保同一时刻只有一个挂载点避免Vue组件复用时容器冲突。3.2 响应式处理画布尺寸Ketcher的Canvas画布在容器尺寸变化后不会自动重排典型现象是浏览器窗口拉大后画布出现留白或侧边面板遮住结构。ResizeObserver是处理这个问题最直接的手段// utils/resize-handler.js export function watchContainerSize(el, callback) { const observer new ResizeObserver((entries) { for (const entry of entries) { callback(entry.contentRect.width, entry.contentRect.height); } }); observer.observe(el); return observer; }在编辑器组件里使用时把watchContainerSize的callback指向“重新读取一次结构再setMolecule回画布”这一点对Ketcher尤其必要。JSME因为是SVG与Canvas混合渲染尺寸变化后多数情况下还能自动拉伸Ketcher则需要手动告知内部布局引擎。另外要注意ResizeObserver的回调在监听初期就会触发一次此时编辑器可能尚未初始化完成需要在调用前判断ready.value避免拿到空实例。3.3 双编辑器切换时的数据恢复DEMO里提供一个切换按钮让用户在JSME和Ketcher之间来回切换。切换的核心原则是“先导出、后重建、再回填”离开当前编辑器之前把SMILES和Molfile都缓存到临时变量切换到另一个编辑器后等它完全ready再一次性回填。如果只导出SMILES遇到包含立体化学信息的结构会丢细节所以两个格式都要存// components/ChemEditorSwitcher.vue (核心逻辑片段) async function switchMode(nextMode) { const current editorInstance; // 从当前编辑器导出两种格式 const payload current.getSmiles ? { smiles: current.getSmiles(), molfile: current.getMolfile() } : await current.getSmiles(); // 重建第二个编辑器时需要重新走 Composable 的挂载流程 editorMode.value nextMode; await nextTick(); const next editorInstance; if (next.setSmiles) { next.setSmiles(payload.smiles, false); } }这里之所以先取molfile是因为SMILES在表达原子坐标时天然有损切换一次编辑器就丢失一次坐标信息。把molfile作为首选传输格式SMILES只作为快速回填的备选。真实研发环境里这两个格式会在服务端同时保存前端展示用SMILES编辑场景用Molfile逻辑就在这里。3.4 不要手动操作Ketcher内部DOMKetcher的DOM结构由内部React树管理任何时候都不要通过querySelector去改它的内部节点比如给某个按钮加class、调整某个面板的样式。这种操作在浏览器控制台里可能立刻生效但一旦Ketcher因为任何原因触发内部重渲染改动就会被覆盖并且还会干扰事件系统的正常触发。需要自定义样式时正确的路径是包一层外层容器用CSS作用域限定在编辑器外部或者通过配置项关闭默认元素再在Vue组件里补自己的按钮。这个边界能守住后续排障会少一半奇怪的BUG。4. 统一数据流SMILES、Molfile的转换与vue路由参数同步4.1 为什么编辑器之上必须有一层归一化两个编辑器对同一分子的导出结果并不一致。典型例子苯环在JSME里默认输出C1CCCCC1Ketcher则偏好输出c1ccccc1带盐的分子在一个编辑器里可能自动拆成两个组件在另一个编辑器里却保留为一个整体。如果把编辑器的原始输出直接用于数据上报同一结构在不同页面会呈现出不同字符串检索时问题随之而来。所以DEMO里约定一个归一化层把“画布内容”统一转为Molfile语义再根据场景决定是否转回SMILES展示。数据格式典型使用场景主要风险SMILES列表展示、检索入参芳香性写法不统一、坐标丢失Molfile编辑回填、精确结构比对文本冗长、版本字段差异InChI数据库唯一性判断人不可读、无法直接回填画布这张表的实际含义是编辑器的输入输出尽量用Molfile应用内部传递用归一化后的SMILES数据库索引用InChI。三者各司其职避免“一种字符串打天下”带来的歧义。4.2 用单一Store维护当前分子Vue 3项目里常见的做法是用Pinia维护一个chemStore所有编辑器组件都只对store负责不直接相互通信// stores/chem.js import { defineStore } from pinia; import { ref } from vue; export const useChemStore defineStore(chem, () { const smiles ref(); const molfile ref(); const format ref(smiles); function setMolecule({ smiles: smi, molfile: mol, format: fmt }) { smiles.value smi; molfile.value mol; format.value fmt; } function getMolecule() { // 对外统一输出内部根据场景选择精确保存或轻量展示 return { smiles: smiles.value, molfile: molfile.value, format: format.value }; } return { smiles, molfile, format, setMolecule, getMolecule }; });format字段用来标记当前分子是手绘结构还是SMILES回填的简化结构。对于手绘结构molfile是权威数据对于SMILES回填molfile可能缺少原子坐标需要重新布局。这里的getMolecule虽然只是简单返回但它是后续接后端、接检索、接报表的统一出口所有结构相关页面都从这一个接口拿数据。4.3 watch策略区分用户手绘与程序回填编辑器初始化时会回填一段SMILES这个过程也会触发编辑器内部的change事件。如果不加区分组件会把自己的回填行为误判为用户操作导致死循环回填触发watchwatch又触发setMoleculesetMolecule再次触发change。解决办法是加一个isInternalUpdate开关// composables/useChemSync.js import { watch, ref } from vue; let isInternalUpdate false; export function watchEditorChange(editor, store) { return watch(() editor.value?.getSmiles?.(), (newSmiles) { // 程序回填造成的变更直接忽略 if (isInternalUpdate) return; store.setMolecule({ smiles: newSmiles, molfile: editor.value.getMolfile(), format: canvas }); }); } export function setEditorMolecule(editor, molecule, internal true) { isInternalUpdate internal; editor.value?.setSmiles?.(molecule.smiles, false); // 等事件循环结束后恢复标记避免阻塞后续用户操作 setTimeout(() { isInternalUpdate false; }, 0); }这里把isInternalUpdate声明在模块作用域而不是组件内部是为了避免多个编辑器实例切换时开关状态被覆盖。内部更新的标记在setTimeout后复位确保用户随后立刻绘制结构时能够正常触发watch。watch的getter必须显式返回editor.value?.getSmiles?.()这样Vue才知道该监听哪个值直接写watch(editor, ...)监听不到编辑器内部状态变化。4.4 用vue路由参数携带结构信息这个DEMO支持在列表页点击一条记录后跳转到详情页并把分子结构带给编辑器最常见的实现是把SMILES放进路由的query参数// router/chem-router.js router.push({ path: /editor/detail, query: { smiles: c1ccccc1, format: smiles } });详情页的编辑器组件在onMounted里读取route.query.smiles通过setEditorMolecule回填画布。要注意路由query天然会把号解析为空格SMILES里的溴原子[Br-]、带电荷片段在URL传递前需要encodeURIComponent否则编辑器会报结构解析失败。另一条更干净的路径是用Pinia在页面间传对象但刷新页面后状态会丢所以DEMO选择query传参并在进入页面时重新做一次SMILES合法性校验。5. 分子编辑器DEMO的高频坑加载时序、画布空白与结构校验失败接入这套DEMO时遇到的大多数报错并不来自Vue框架而是来自“脚本未就绪”“容器没有尺寸”“回填节奏不对”这三类问题。下面按出错频率从高到低逐个排查。5.1 JSME脚本加载时序导致初始化抛错window.JSME是undefined时调用JSME.init浏览器会提示找不到对象。常见原因是脚本放在head里同步加载但组件挂载更快。修复方式是把脚本改为动态注入并在调用前用setInterval轮询等待// utils/ensure-jsme.js export function ensureJSME(callback) { if (window.JSME) { callback(); return; } const timer setInterval(() { if (window.JSME) { clearInterval(timer); callback(); } }, 50); // 15秒超时兜底避免脚本加载失败时无限轮询 setTimeout(() clearInterval(timer), 15000); }轮询间隔50ms是经过考虑的值间隔太长会让页面出现明显“编辑器未出现”的空档太短又会在脚本执行半截时抢占主线程。超时后需要提示用户刷新页面而不是静默失败。5.2 Ketcher画布空白控制台却没有任何报错这种情况十有八九是挂载容器高度为0。Ketcher初始化完成后内部Canvas按容器尺寸绘制如果容器高度算出来是0画布就不可见。排查第一步不是看代码而是用DevTools检查容器元素的计算后样式。修复方式是在容器上强制设最小高度.chem-editor-mount { width: 100%; /* 避免父级flex布局压缩导致初始化高度为0 */ min-height: 320px; }min-height而非height是为了兼容编辑器自身的自适应逻辑。同时要注意Ketcher的standalone构建在初始化时也会读取容器位置的display属性如果是display: none同样会出现空白画布切换Tab时尤其常见。5.3 setMolecule回填不生效回填不生效通常表现为画布始终停留在默认结构或上一手留下的分子没有变化。最直接的原因是Ketcher在实例尚未创建完成时就被调用了setMolecule此时画布还没来得及接受消息。正确的同步逻辑是必须在ready为true之后调用DEMO的useChemEditor已经通过ready暴露了这个状态业务方需要遵守这条顺序约束。另外JSME的setSmiles传入非法SMILES时也会静默失败建议回填前先做基础校验用正则检查括号配对再用简易规则判断原子符号是否合法。5.4 芳香性写法不一致导致的服务端校验失败服务端结构校验通常用Indigo或RDKit做归一化它们能解析绝大多数SMILES但不同编辑器产出的芳香性写法不同JSME习惯于凯库勒式单双键交替书写Ketcher更常用小写字母表示芳香环。同样一个苯环前端传C1CCCCC1与传c1ccccc1在后端检索系统里命中结果不一样。统一做法是由前端先把SMILES交给后端归一化接口回填画布时也只使用归一化后的字符串。5.5 反复创建与销毁编辑器造成内存膨胀编辑器组件在Tab中反复激活时每次创建都会在容器里追加一层内部DOM长会话下内存占用持续增长。除了前文提到的onBeforeUnmount清理更稳妥的方案是复用同一个挂载点并把编辑器的创建过程与组件生命周期解耦// composables/usePersistentEditor.js let sharedInstance null; export function usePersistentEditor(mode) { onMounted(async () { if (sharedInstance) { // 复用已有实例避免重复创建 ready.value true; return; } sharedInstance await mountKetcher(persistent-container); ready.value true; }); }全局共享实例在单页应用里能显著降低重复创建的开销但代价是编辑器状态会在不同页面之间残留。如果业务要求每次进入页面都是全新画布那就必须在离开时显式调用setMolecule清空内容再配合onBeforeUnmount清理而不是依赖浏览器垃圾回收。6. 让DEMO接得住真实结构SDF批量导入与自定义骨架模板6.1 SDF文本块的解析与逐份导入真实研发环境的输入往往不是一条SMILES而是一个包含几十甚至上千个分子的SDF文件。SDF以$$$$分隔多个分子块每块内部是标准的Molfile文本。导入的核心逻辑是拆分文本块再逐个交给Ketcher回填// utils/sdf-import.js export function parseSdf(text) { // 按分隔符拆分并过滤空块 return text .split(/\n?\$\$\$\$\n?/) .map((block) block.trim()) .filter((block) block.length 0); } export async function importSdfToKetcher(ketcher, sdfText) { const blocks parseSdf(sdfText); for (const block of blocks) { // 每个块在独立微任务中加载避免一次setMolecule阻塞主线程 await ketcher.setMolecule(block, molfile); await new Promise((resolve) setTimeout(resolve, 50)); } return blocks.length; }逐个await是为了让浏览器有机会在两次导入之间完成渲染批量导入几十个分子时如果一次性全部推给Ketcher画布会长时间卡在“响应中”状态。这里的setMolecule接收的是Molfile文本而非文件对象所以解析时的换行符必须保留尤其不能在这种场景下使用JSON.stringify压缩文本。6.2 自定义骨架模板的注册JSME与Ketcher都支持向内注入模板最常见的做法是维护一组预置SMILES渲染为按钮列表// components/SkeletonTemplates.vue (模板注入片段) const templates [ { name: 吡啶, smiles: c1ccncc1 }, { name: 环己烷, smiles: C1CCCCC1 }, { name: 萘, smiles: C1CC2CCCCC2CC1 } ]; function applyTemplate(tpl) { // 先通过 store 拿到当前正在使用的编辑器实例 const editor chemStore.getEditorInstance(); if (editor.setSmiles) { editor.setSmiles(tpl.smiles, true); } else { editor.setMolecule(tpl.smiles, smiles); } }JSME的setSmiles第二个参数在这里要传true让编辑器自动计算合理的原子坐标Ketcher则要在内部完成一次结构规范化后再渲染。模板列表建议在页面加载时一次性生成不要频繁调用splice增删因为按钮点击事件和编辑器的原子坐标缓存有绑定关系动态增删模板导致事件失效的情况在真实项目里出现过多次。SDF导入与模板注入配合使用时先导入SDF覆盖画布再点模板编辑子结构能够覆盖药物化学场景里“先看整体、再改局部”的常见操作路径。本文还有配套的精品资源点击获取