发布于 2024 年 9 月 11 日,星期三
博主在处理前端开发需求时遇到的挑战和困难。"踩坑"一词形象地表达了在开发过程中遇到的意外问题或技术障碍,通常是由于对需求理解不透彻、技术选型不当或代码实现中的疏忽所致。这类经历不仅反映了前端开发的复杂性和不确定性,也体现了开发者需要具备的快速学习和问题解决能力。通过分享这些"踩坑"经历,博主不仅帮助其他开发者避免类似错误,也展示了前端开发中不断学习和适应的重要性。
最近接到了一个需求,那是啥呢?
简单来说,就是要搞一个袖珍版的微信公众号文章编辑器。不过别被"袖珍"这个词骗了,麻雀虽小五脏俱全,这玩意儿要能在手机上跑,还得让用户边编辑边预览,最重要的是,还得能点击文章里的按钮做点啥,比如复制内容。
听起来是不是很简单? 我当时也是这么想的...
先别急, 让我慢慢道来。如果不行了解解决过程的,可以直接跳转到总结部分。
首先,咱们得搞清楚,微信公众号的文章其实可以是富文本、Markdown 或者 HTML。但是,如果你用 Markdown 或 HTML 写,还得找个第三方工具转换一下才能丢进微信公众号编辑器里。
好了,现在我们来捋一捋这个需求到底要我们干啥:
首先,我们需要一个"舞台
",也就是一个能容纳整个 HTML 的框架。为啥用 HTML 不用 Markdown?(等会儿我再跟你们解释,卖个关子)
其次,我们得让用户能在这个"舞台"上耍点花样, 比如点击按钮复制内容。这可是我们这次表演的**重头戏**
!
如果你像我一样,经常在深夜里对着电脑屏幕敲敲打打,努力创作出一篇又一篇"**10 万+"(梦里)**
,你可能已经注意到了一个有趣的现象。
你可能注意到有些文章用了不同的主题。等等, 我们写的不是 Markdown 吗? 为啥复制到微信公众号后会有一整套主题,连标题样式和代码块都齐全了? 这不科学啊!
这里面到底有什么猫腻?
其实啊,在我们用第三方工具复制的时候, 我们复制的不是普通的 Markdown, 而是一种**特殊格式的 HTML**
。
这就像是你以为自己在吃普通的饺子, 结果里面包的是黑松露。
**这种特殊的 HTML 格式就是我们这次任务的关键**
。它不仅包含了内容,还带着一身行头 - 样式、布局,全都打包在里面了。这就是为什么我刚才买的关子,说要用 HTML 而不是 Markdown 的原因。
比如,向下面这段, 我直接通过复制,粘贴到微信后台编辑器里面你会看到直接显示的是代码,而不是最终效果图
<section id="content-section-1" data-role="outer">
<section>
<section style="text-align: center;margin: 10px auto;">
<section style="background-color: rgb(255, 255, 255);padding: 9px;">
<section style="border-width: 1px;border-style: solid;border-color: rgb(217, 150, 148);">
<section
style="width: 35px;height: 35px;background-color: rgb(217, 150, 148);transform: translate(-2px, -2px);"
>
<section style="font-size: 16px;letter-spacing: 1.5px;color: #fff;line-height: 35px;">
<strong data-original-title="" title="">01</strong>
</section>
</section>
<section
data-autoskip="1"
style="padding: 10px 25px 20px;text-align: justify;line-height: 1.75em;letter-spacing: 1.5px;font-size: 14px;color: rgb(53, 50, 59);background: transparent;"
>
<p>
问:你体重多少?<span lang="EN-US"><o:p></o:p></span>
</p>
<p> 答:我只接受党和组织的调查。 </p>
</section>
</section>
</section>
</section>
</section>
</section>
拷贝到微信编辑器里面,你就会看到如下图:
好了,到这里,需求我们已经搞明白了,接下来就是要调研和 coding 了。
开始这个项目时,我首先需要找到一个合适的富文本编辑器框架。
经过一番筛选,最终锁定了两个主要候选者: **Quill 和 TinyMCE**
。这两个框架都有一个很大的优点:**它们可以直接接受现成的 HTML 内容**
,这正是我们需要的。
在比较这两个框架时,我特别关注了它们在移动端的表现。**Quill 在这方面表现出色, 它的移动端兼容性非常好**
。相比之下, **TinyMCE 虽然功能强大, 但在移动端的初始加载速度上稍逊一筹**
。
考虑到我们的**主要目标是移动端使用**
,我最初选择了 Quill 作为首选框架。它在移动设备上**加载速度快**
,这对用户体验来说是个很大的优势。
带着这个决定,我花了半天时间快速搭建了一个基于 Quill 的 demo。然而,当我尝试将我们特定格式的 HTML 内容复制到 Quill 时,问题出现了。
Quill 无法完全呈现我们 HTML 中的某些样式效果。比如, 下面这个例子中的**图片边框和标题背景颜色都没有正确显示**
:
然后我又在官方提供的demo中将 HTML 丢给它,然后发现它还是不行渲染出完整的效果,然后通过搜索引擎也没找到什么解决方案。
怎么办,换一个!那就是 TingMCE
虽然 TinyMCE 在移动端加载速度上稍慢, 但它对 **HTML 内容的渲染能力更强**
。
接下来 转向 TinyMCE,写 Demo
npm install tinymce @tinymce/tinymce-vue -S
# or
pnpm install tinymce @tinymce/tinymce-vue -S
<template>
<div class="container">
<editor v-model="editorContent" :init="editorConfig" api-key="xxxxxxxxxx" />
<!-- // 这个 api-key 可以在官方免费申请 -->
</div>
</template>
<script>
import Editor from '@tinymce/tinymce-vue';
export default {
components: {
editor: Editor,
},
props: {
content: {
type: String,
default: '',
},
},
data() {
return {
editorContent: ``,
editorConfig: {
menubar: false,
statusbar: false,
branding: false,
visual: false,
elementpath: false,
mobile: {
toolbar: false,
},
toolbar: false,
content_style: `
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 14px;
padding: 10px;
box-sizing: border-box;
max-width: 100vw;
word-wrap: break-word;
}
img {
max-width: 100%;
height: auto !important;
object-fit: contain;
display: block;
margin: 10px auto;
}
p {
margin: 0;
padding: 0;
}
.mceNonEditable {
background-color: #4CAF50;
color: white;
padding: 10px 20px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
margin: 4px 2px;
cursor: pointer;
user-select: none;
border: none;
}
`,
},
};
},
watch: {
content: {
immediate: true,
handler(newContent) {
this.editorContent = newContent;
},
},
editorContent(newContent) {
this.$emit('update:content', newContent);
},
},
methods: {
onEditorInit(editor) {
editor.setContent(this.editorContent);
},
},
};
</script>
<style scoped>
.container {
width: 100%;
height: calc(100vh - 90px);
overflow: hidden;
}
:deep(.tox-tinymce) {
border: none !important;
height: 100% !important;
max-height: 100vh !important;
}
:deep(.tox-editor-container) {
height: 100% !important;
}
:deep(.tox-edit-area) {
height: 100% !important;
}
:deep(.tox-edit-area::before) {
height: 100% !important;
border: none !important;
}
:deep(.tox-edit-area__iframe) {
height: 100% !important;
}
</style>
<template>
<div class="editor-container">
<TaskViewEditor :content="content" />
</div>
</template>
<script>
import TaskViewEditor from '@/components/TaskViewEditor.vue';
export default {
components: {
TaskViewEditor
},
data() {
return {
content: `
<button>复制内容1</button>
<section id="content-section-1" data-role="outer" label="edit by 135editor">
<section data-tools="135编辑器" data-id="107107">
<section style="text-align: center;margin: 10px auto;">
<section style="background-color: rgb(255, 255, 255);padding: 9px;">
<section style="border-width: 1px;border-style: solid;border-color: rgb(217, 150, 148);">
<section style="width: 35px;height: 35px;background-color: rgb(217, 150, 148);transform: translate(-2px, -2px);">
<section style="font-size: 16px;letter-spacing: 1.5px;color: #fff;line-height: 35px;">
<strong data-original-title="" title="">01</strong>
</section>
</section>
<section data-autoskip="1" style="padding: 10px 25px 20px;text-align: justify;line-height: 1.75em;letter-spacing: 1.5px;font-size: 14px;color: rgb(53, 50, 59);background: transparent;">
<p>
问:你体重多少?<span lang="EN-US"><o:p></o:p></span>
</p>
<p>
答:我只接受党和组织的调查。
</p>
</section>
</section>
</section>
</section>
</section>
</section>
<button>复制内容2</button>
<section id="content-section-2" data-role="outer" label="edit by 135editor">
<section data-tools="135编辑器" data-id="107107">
<section style="text-align: center;margin: 10px auto;">
<section style="background-color: rgb(255, 255, 255);padding: 9px;">
<section style="border-width: 1px;border-style: solid;border-color: rgb(217, 150, 148);">
<section style="width: 35px;height: 35px;background-color: rgb(217, 150, 148);transform: translate(-2px, -2px);">
<section style="font-size: 16px;letter-spacing: 1.5px;color: #fff;line-height: 35px;">
<strong data-original-title="" title="">02</strong>
</section>
</section>
<section data-autoskip="1" style="padding: 10px 25px 20px;text-align: justify;line-height: 1.75em;letter-spacing: 1.5px;font-size: 14px;color: rgb(53, 50, 59);background: transparent;">
<p>
问:你最满意身上哪个部位?<span lang="EN-US"><o:p></o:p></span>
</p>
<p>
答:最满意那双看不上你的眼睛。
</p>
</section>
</section>
</section>
</section>
</section>
</section>`,
},
}
};
</script>
效果图如下:(复制按钮暂时忽略,我们后面再看)
很好, 我们已经实现了基本功能。 现在可以实时编辑内容, 而且效果更新及时。
下一步我们需要处理的是用户交互部分。
在起初,想的是,**在 HTML 内容中自带 script,且逻辑由 HTML 自身提供**
。
这个方法可能是当下最好的方式,**既可以做到解耦,又可以做到代码分工明确**
。
按预期的在 HTML 中携带 script 代码,但一运行就出现了报错。
首先是各种报错信息,虽然经过调试最终解决了这些错误, 但新的问题出现了,**点击按钮后没有任何反应,甚至连预期的 log 信息都没有输出**
。
考虑到项目的时间限制,我不得不采取一个折中的方案。
**在 TinyMCE 的 setup 配置中实现交互功能**
,让 HTML 内容只负责提供方法名,而**具体的逻辑则由 TaskViewEditor 组件来实现**
。
我知道到这种方法在代码结构上不是最优的选择。它可能会导致组件和内容之间的耦合性增加, 使得后期维护和扩展变得更加困难。
但.....没办法,这是目前一个可行的临时解决方案。
修改 TaskViewEditor 组件代码如下:
<template>
<div class="container">
<editor v-model="editorContent" :init="editorConfig" api-key="xxxxxxx" />
</div>
</template>
<script>
import Editor from '@tinymce/tinymce-vue';
export default {
components: {
editor: Editor,
},
props: {
content: {
type: String,
default: '',
},
},
data() {
return {
editorContent: ``,
editorConfig: {
menubar: false,
statusbar: false,
branding: false,
visual: false,
elementpath: false,
mobile: {
toolbar: false,
},
toolbar: false,
+ content_style: `
+ body {
+ font-family: Helvetica, Arial, sans-serif;
+ font-size: 14px;
+ padding: 10px;
+ box-sizing: border-box;
+ max-width: 100vw;
+ word-wrap: break-word;
+ }
+ img {
+ max-width: 100%;
+ height: auto !important;
+ object-fit: contain;
+ display: block;
+ margin: 10px auto;
+ }
+ p {
+ margin: 0;
+ padding: 0;
+ }
+ .mceNonEditable {
+ background-color: #4CAF50;
+ color: white;
+ padding: 10px 20px;
+ text-align: center;
+ text-decoration: none;
+ display: inline-block;
+ font-size: 16px;
+ margin: 4px 2px;
+ cursor: pointer;
+ user-select: none;
+ border: none;
+ }
+ `,
valid_elements: '*[*]',
extended_valid_elements: 'script[*]',
setup: editor => {
+ editor.on('init', () => {
+ const copyScript = `
+ function copyContent(selector) {
+ var content = document.querySelector(selector).outerHTML;
+ var textarea = document.createElement("textarea");
+ textarea.value = content;
+ textarea.style.position = "fixed";
+ document.body.appendChild(textarea);
+ textarea.focus();
+ textarea.select();
+ try {
+ var successful = document.execCommand("copy");
+ if (successful) {
+ alert("内容已复制到剪贴板");
+ } else {
+ throw new Error("Copy command was unsuccessful");
+ }
+ } catch (err) {
+ console.error("复制失败:", err);
+ alert("复制失败,请手动复制");
+ } finally {
+ document.body.removeChild(textarea);
+ }
+ }
+ `;
+ const script = editor.dom.create('script', { type: 'text/javascript' }, copyScript);
+ editor.dom.add(editor.getBody(), script);
+ editor.on('click', e => {
+ if (e.target.classList.contains('mceNonEditable')) {
+ const idToCopy = e.target.getAttribute('data-mce-copyid');
+ if (idToCopy) {
+ editor.getWin().copyContent(idToCopy);
+ }
+ }
});
});
},
},
};
},
watch: {
content: {
immediate: true,
handler(newContent) {
this.editorContent = newContent;
},
},
editorContent(newContent) {
this.$emit('update:content', newContent);
},
},
methods: {
onEditorInit(editor) {
editor.setContent(this.editorContent);
},
},
};
</script>
+ <button class="mceNonEditable" onclick="copyContext('#content-section-1')">复制内容1</button>
<section id="content-section-1" data-role="outer" label="edit by 135editor">
<section data-tools="135编辑器" data-id="107107">
<section style="text-align: center;margin: 10px auto;">
<section style="background-color: rgb(255, 255, 255);padding: 9px;">
<section style="border-width: 1px;border-style: solid;border-color: rgb(217, 150, 148);">
<section
style="width: 35px;height: 35px;background-color: rgb(217, 150, 148);transform: translate(-2px, -2px);"
>
<section style="font-size: 16px;letter-spacing: 1.5px;color: #fff;line-height: 35px;">
<strong data-original-title="" title="">01</strong>
</section>
</section>
<section
data-autoskip="1"
style="padding: 10px 25px 20px;text-align: justify;line-height: 1.75em;letter-spacing: 1.5px;font-size: 14px;color: rgb(53, 50, 59);background: transparent;"
>
<p>
问:你体重多少?<span lang="EN-US"><o:p></o:p></span>
</p>
<p> 答:我只接受党和组织的调查。 </p>
</section>
</section>
</section>
</section>
</section>
</section>
+ <button class="mceNonEditable" onclick="copyContext('#content-section-2')">复制内容2</button>
<section id="content-section-2" data-role="outer" label="edit by 135editor">
<section data-tools="135编辑器" data-id="107107">
<section style="text-align: center;margin: 10px auto;">
<section style="background-color: rgb(255, 255, 255);padding: 9px;">
<section style="border-width: 1px;border-style: solid;border-color: rgb(217, 150, 148);">
<section
style="width: 35px;height: 35px;background-color: rgb(217, 150, 148);transform: translate(-2px, -2px);"
>
<section style="font-size: 16px;letter-spacing: 1.5px;color: #fff;line-height: 35px;">
<strong data-original-title="" title="">02</strong>
</section>
</section>
<section
data-autoskip="1"
style="padding: 10px 25px 20px;text-align: justify;line-height: 1.75em;letter-spacing: 1.5px;font-size: 14px;color: rgb(53, 50, 59);background: transparent;"
>
<p>
问:你最满意身上哪个部位?<span lang="EN-US"><o:p></o:p></span>
</p>
<p> 答:最满意那双看不上你的眼睛。 </p>
</section>
</section>
</section>
</section>
</section>
</section>
我复制的 HTML 内容粘贴到微信公众号编辑器后,要么显示原始代码, 要么效果全无。
很离奇,但目前只能想到这三种原因:
**验证 HTML 编码:utf-8 等等**
**确认 HTML 结构完整性:是否包括 html, body 等**
**检查剪贴板格式:或许是HTML有这某种格式**
经过一一排查,还是没解决问题(在这个问题上整整耗了一天)。
灵感突现时, 我想起了 mdnice
这个工具。
查看其开源项目 **markdown-nice**
的源码后,我发现了关键的 **copySafari**
方法,它处理了复制时的格式设置。
**使用这个方法后, 复制功能正常了,但出现了新问题:只有点击按钮的空白位置才能触发复制,这就导致用户很难触发点击事件。**
看文档,通过设置统一的按钮类名和使用 TinyMCE 的 noneditable_noneditable_class
属性来解决这个问题。
好了,目前面临的问题基本上已经解决了。
<template>
<div class="container">
<editor
v-model="editorContent"
:init="editorConfig"
api-key="xxxxx"
/>
</div>
</template>
<script>
import Editor from '@tinymce/tinymce-vue';
export default {
components: {
editor: Editor,
},
props: {
content: {
type: String,
default: '',
},
},
data() {
return {
editorContent: ``,
editorConfig: {
menubar: false,
statusbar: false,
branding: false,
visual: false,
elementpath: false,
mobile: {
toolbar: false,
},
toolbar: false,
noneditable_noneditable_class: 'mceNonEditable',
content_style: `
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 14px;
padding: 10px;
box-sizing: border-box;
max-width: 100vw;
word-wrap: break-word;
}
img {
max-width: 100%;
height: auto !important;
object-fit: contain;
display: block;
margin: 10px auto;
}
p {
margin: 0;
padding: 0;
}
.mceNonEditable {
background-color: #4CAF50;
color: white;
padding: 10px 20px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
margin: 4px 2px;
cursor: pointer;
user-select: none;
border: none;
}
`,
setup: editor => {
editor.on('init', () => {
const copyScript = `
function copyContext(selector) {
var element = document.querySelector(selector);
if (element) {
var content = element.outerHTML;
copySafari(content);
} else {
console.error('Element not found:', selector);
alert('未找到指定的元素');
}
}
function copySafari(text) {
let input = document.getElementById('copy-input');
if (!input) {
input = document.createElement('input');
input.id = 'copy-input';
input.style.position = 'absolute';
input.style.left = '-1000px';
input.style.zIndex = '-1000';
document.body.appendChild(input);
}
input.value = 'NOTHING';
input.setSelectionRange(0, 1);
input.focus();
document.addEventListener('copy', function copyCall(e) {
e.preventDefault();
e.clipboardData.setData('text/html', text);
e.clipboardData.setData('text/plain', text);
document.removeEventListener('copy', copyCall);
});
try {
var successful = document.execCommand("copy");
if (successful) {
alert("内容已复制到剪贴板");
} else {
throw new Error("Copy command was unsuccessful");
}
} catch (err) {
console.error("execCommand 复制失败:", err);
alert("复制失败,请长按选择内容并手动复制");
}
}
`;
const script = editor.dom.create('script', { type: 'text/javascript' }, copyScript);
editor.dom.add(editor.getBody(), script);
});
},
},
};
},
watch: {
content: {
immediate: true,
handler(newContent) {
this.editorContent = newContent;
},
},
editorContent(newContent) {
this.$emit('update:content', newContent);
},
},
methods: {
onEditorInit(editor) {
editor.setContent(this.editorContent);
},
},
};
</script>
<style scoped>
.container {
width: 100%;
height: calc(100vh - 90px);
overflow: hidden;
}
:deep(.tox-tinymce) {
border: none !important;
height: 100% !important;
max-height: 100vh !important;
}
:deep(.tox-editor-container) {
height: 100% !important;
}
:deep(.tox-edit-area) {
height: 100% !important;
}
:deep(.tox-edit-area__iframe) {
height: 100% !important;
}
</style>
<template>
<div class="editor-container">
<TaskViewEditor :content="content" />
</div>
</template>
<script>
import TaskViewEditor from '@/components/TaskViewEditor.vue';
export default {
components: {
TaskViewEditor
},
data() {
return {
content: `
<button class="mceNonEditable" onclick="copyContext('#content-section-1')">复制内容1</button>
<section id="content-section-1" data-role="outer" label="edit by 135editor">
<section data-tools="135编辑器" data-id="107107">
<section style="text-align: center;margin: 10px auto;">
<section style="background-color: rgb(255, 255, 255);padding: 9px;">
<section style="border-width: 1px;border-style: solid;border-color: rgb(217, 150, 148);">
<section
style="width: 35px;height: 35px;background-color: rgb(217, 150, 148);transform: translate(-2px, -2px);"
>
<section style="font-size: 16px;letter-spacing: 1.5px;color: #fff;line-height: 35px;">
<strong data-original-title="" title="">01</strong>
</section>
</section>
<section
data-autoskip="1"
style="padding: 10px 25px 20px;text-align: justify;line-height: 1.75em;letter-spacing: 1.5px;font-size: 14px;color: rgb(53, 50, 59);background: transparent;"
>
<p>
问:你体重多少?<span lang="EN-US"><o:p></o:p></span>
</p>
<p> 答:我只接受党和组织的调查。 </p>
</section>
</section>
</section>
</section>
</section>
</section>
<button class="mceNonEditable" onclick="copyContext('#content-section-2')">复制内容2</button>
<section id="content-section-2" data-role="outer" label="edit by 135editor">
<section data-tools="135编辑器" data-id="107107">
<section style="text-align: center;margin: 10px auto;">
<section style="background-color: rgb(255, 255, 255);padding: 9px;">
<section style="border-width: 1px;border-style: solid;border-color: rgb(217, 150, 148);">
<section
style="width: 35px;height: 35px;background-color: rgb(217, 150, 148);transform: translate(-2px, -2px);"
>
<section style="font-size: 16px;letter-spacing: 1.5px;color: #fff;line-height: 35px;">
<strong data-original-title="" title="">02</strong>
</section>
</section>
<section
data-autoskip="1"
style="padding: 10px 25px 20px;text-align: justify;line-height: 1.75em;letter-spacing: 1.5px;font-size: 14px;color: rgb(53, 50, 59);background: transparent;"
>
<p>
问:你最满意身上哪个部位?<span lang="EN-US"><o:p></o:p></span>
</p>
<p> 答:最满意那双看不上你的眼睛。 </p>
</section>
</section>
</section>
</section>
</section>
</section>`,
},
}
};
</script>
在这个项目中, 我们遇到并解决了三个主要问题:
复制的代码无法正常被微信公众号编辑器识别?
function copySafari(text) {
let input = document.getElementById('copy-input');
if (!input) {
input = document.createElement('input');
input.id = 'copy-input';
input.style.position = 'absolute';
input.style.left = '-1000px';
input.style.zIndex = '-1000';
document.body.appendChild(input);
}
input.value = 'NOTHING';
input.setSelectionRange(0, 1);
input.focus();
document.addEventListener('copy', function copyCall(e) {
e.preventDefault();
e.clipboardData.setData('text/html', text);
e.clipboardData.setData('text/plain', text);
document.removeEventListener('copy', copyCall);
});
try {
var successful = document.execCommand('copy');
if (successful) {
alert('内容已复制到剪贴板');
} else {
throw new Error('Copy command was unsuccessful');
}
} catch (err) {
console.error('execCommand 复制失败:', err);
alert('复制失败,请长按选择内容并手动复制');
}
}
用户交互功能的实现?
互动区域的可编辑性问题?
noneditable_noneditable_class
属性禁用这些区域的编辑功能。如果这篇对你有一点帮助, 关注点赞,好运不断! 点个在看,你最好看!
感谢阅读,我们下次再见!