Vue 3集成JSME与Ketcher:打造统一化学分子编辑器DEMO
2026/9/16 16:11:42 网站建设 项目流程

简介:基于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,调用初始化函数就能原地生成编辑器实例,方法名如setSmilesgetMolfile,全是Java时代延续下来的同步风格,写起来像操作一个稳定但形态老旧的控件。Ketcher是EPAM团队持续维护的开源编辑器,聚合了反应绘制、模板系统、自动布局等能力,SMILES解析更完善,但它的发布物与服务端交互方式决定了它更像一个“独立应用”而非普通控件。也就是说,JSME的接入心态是“在Vue组件里初始化一个原生控件”,Ketcher的接入心态是“在Vue组件里托管一个第三方渲染应用”。这两种心态决定了后面每一行代码的写法。

对比项JSMEKetcher
渲染内核GWT编译的JavaScript自研Canvas,反应式布局
官方组件形态全局对象 + div挂载React组件 / standalone构建
调用风格同步,Java风格Promise,await风格
分子编辑能力中小分子、原子级绘制小分子、反应、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; }

在编辑器组件里使用时,把watchContainerSizecallback指向“重新读取一次结构再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内部DOM

Ketcher的DOM结构由内部React树管理,任何时候都不要通过querySelector去改它的内部节点,比如给某个按钮加class、调整某个面板的样式。这种操作在浏览器控制台里可能立刻生效,但一旦Ketcher因为任何原因触发内部重渲染,改动就会被覆盖,并且还会干扰事件系统的正常触发。需要自定义样式时,正确的路径是包一层外层容器,用CSS作用域限定在编辑器外部,或者通过配置项关闭默认元素,再在Vue组件里补自己的按钮。这个边界能守住,后续排障会少一半奇怪的BUG。

4. 统一数据流:SMILES、Molfile的转换与vue路由参数同步

4.1 为什么编辑器之上必须有一层归一化

两个编辑器对同一分子的导出结果并不一致。典型例子:苯环在JSME里默认输出C1=CC=CC=C1,Ketcher则偏好输出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事件。如果不加区分,组件会把自己的回填行为误判为用户操作,导致死循环:回填触发watch,watch又触发setMolecule,setMolecule再次触发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。watchgetter必须显式返回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,此时画布还没来得及接受消息。正确的同步逻辑是必须在readytrue之后调用,DEMO的useChemEditor已经通过ready暴露了这个状态,业务方需要遵守这条顺序约束。另外JSME的setSmiles传入非法SMILES时也会静默失败,建议回填前先做基础校验:用正则检查括号配对,再用简易规则判断原子符号是否合法。

5.4 芳香性写法不一致导致的服务端校验失败

服务端结构校验通常用Indigo或RDKit做归一化,它们能解析绝大多数SMILES,但不同编辑器产出的芳香性写法不同:JSME习惯于凯库勒式单双键交替书写,Ketcher更常用小写字母表示芳香环。同样一个苯环,前端传C1=CC=CC=C1与传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: 'C1=CC2=CC=CC=C2C=C1' } ]; 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覆盖画布,再点模板编辑子结构,能够覆盖药物化学场景里“先看整体、再改局部”的常见操作路径。

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

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

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

立即咨询