ApliNi / blog

GNU General Public License v3.0
2 stars 0 forks source link

一半功能实现, 一半细节优化 #26

Open ApliNi opened 1 month ago

ApliNi commented 1 month ago

一篇可能没什么用的日记, 讲述了一个使用原生环境的前端开发者为了 "完美" 没苦硬吃的编码过程.

// start
if(you['想看看结果']){
    window.open(`https://github.com/ApliNi/cat.ipacel.cc`, '_blank');
}
if(you['真的对这种文章感兴趣'] === false){
    window.close();
}

不动用框架, 在原生前端环境能实现什么样的效果? 来尝试实现一些不算太常见的东西.

这个项目是一个聊天机器人的前端, 一个对话框, 再常见不过了吧, 输入消息, 发送, 得到响应, 就像这样, 没有复杂的需求, 也不用自己实现那些麻烦的格式解析 (不重复造轮子, 这些可以交给专业的 highlight.js, Katex, Marked 去处理). 那还能做什么? 好吧, 虽然我也不确定当时的目标, 但就这样写写写写写, 然后突然! 将自己对其他产品的一切不满都解决了一遍 :(.

我们要做什么? 一个聊天页面而已, 它只可能被分成两部分, 输入和输出, 是这样吧, ok.

输入框

首先实现的是一个输入框, 输入文本就足够了, 文本而已, 这有什么难度, 只需要一个 textarea 就能解决掉它. 大多数的产品也是这样做的, 不过我还想给它加一个自动适应内容高度的功能, 这样会好用一些.

首先想到到是判断有没有滚动条, 如果有, 就设定高度让滚动条消失, 然而它只能自动扩大, 删除文本的时候就不会收回去了.

然后是第二个想法, 添加一个隐藏的 div, 内容与输入框中的文本同步, 然后复制它的高度. 看上去没问题了, 直到我多敲了几个字符... div 的样式和 textarea 的不一样, 那就把 textarea 的样式和代理样式都复制过去, 似乎又正常了, 再多敲几个字符... 好吧, 依然看到它没正常重叠上, 但我已经将很多样式都试过了, 它影响功能吗? 并没有, 但就是对这几个字符的距离感到不满.

要不用元素的可编辑属性吧! (可编辑属性是个深坑 \(笑) 太完美了, 于是输入框变成了一个 div 和它的 contenteditable="true". 好像还少了什么, 好吧, 它能粘贴 HTML, 这可不是好事, 不过我有个简单的方法: contenteditable="plaintext-only". 看似问题又解决了 ... 直到有人用火狐浏览器打开了它 (

我印象里火狐浏览器是不支持 contenteditable="plaintext-only" 的, 后来我也简单试了一下, 似乎确实不支持, 至少我没法正常使用它

这下还得用回 contenteditable="true" 呀, 粘贴的问题就交给 JS 做个简单的过滤吧. 好, 问题再次解决. 然后我看着 div 上的 maxlength 的属性... 我是应该做个内容超出提示, 还是像 textarea 一样进行截断呢... 我不喜欢用 "提示", 于是又多了一个判断文本长度的输入事件监听器, 这下长度能限制住了, 不过感觉哪里怪怪的, 好吧.. 粘贴有点问题, 正常的文本框应该是粘贴内容直到达到限制, 再舍弃掉之后的部分, 但目前的限制做不到. 感觉这不是大问题, 再改改吧, ok, 能粘贴到底了, 但是粘贴之后... 光标又跑到文本开头去了. "这还不简单, 定位到末尾就好了", 但是用户不一定在文本末尾使用粘贴呀, 嗯... 我得先计算当前光标位置, 可以插入数据的长度, 然后将字符串拼接进来, 再把光标定位到插入之后的位置... 啊, 不对, 用户还可能选中一段内容, 用粘贴来替换它, 于是在粘贴之前又做了一个删除选中字符串的处理. 好吧, 这应该是最后一个问题了吧.

诶, 我撤销呢. 用 JS 直接修改文本内容之后撤销和重做就不能用了, 感觉我摊上了一个大麻烦, 先试试自己实现了一遍撤销和重做吧. 调了半天, 这下应该万无一失了, 模仿了正常文本编辑器的撤销重做, 撤销到一半再输入能自动删除之前的后半部分记录, 而且还实现了逐字撤销重做和自定义步数的功能, 并且还不和之前的粘贴什么的有冲突...

第一次写这种麻烦的文本和光标位置的处理, 如果当时不选用可编辑元素是不是会轻松一点呀. 不过竟然都做到这一步了, 那就再实现一个持久化保存并加上撤销记录吧, 保存当前内容 / 历史记录和光标位置, 这个能力只在一些开发工具上有见过, 似乎很少有人将它用在一个简单的文本框上吧.

顺便做了一个小细节, 如果文本里有一个换行, 就不能用回车键发送消息, 在其他产品里, 我会因为上档键没按好之类的原因导致正在输入的多行消息被发送出去, 这个体验真差.

那么现在这个输入框它完美了吗? 应该并不, 它的兼容性可能没原生的文本框好, 在某些我没见过的环境里可能会出问题的, 甚至我会没法去那样的环境里调试它, 还好这只是我自己的项目, 在没特殊要求的其他普通项目里才没心思考虑那么多.

image image 一部分的代码, 总感觉它很复杂, 但读和调试起来还算轻松, 可能只是变量名比较长吧.

消息列表

那么接下来该实现气泡框了吧, 气泡框而已, 这有什么难度...

看, 这不挺好的嘛, 还有消息渲染的过度动画...

不过我们一定要把输入框放在页面底部, 像大多数聊天软件那样吗? 虽然这可能更加符合用户的习惯, 但我总想做点差异出来... 于是我尝试将页面顺序倒过来, 好吧, 用户消息总是跑到下面去了, 这连我自己都难以接受, 那如果我再把用户消息倒过来, 嗯.. 感觉还不错, 不过如果机器人要同时发送两条消息, 在这种列表上定位就有些麻烦了, 给每个用户消息做一个框怎么样, 回复这条用户消息的内容都渲染在这个框的第一条用户消息下方, 一个奇怪的排版方式, 但意外的感觉有些符合直觉.

那么去做, 这下也不能用 CSS 来颠倒列表了, 在消息渲染的代码里做一些改动吧. 嗯... 渲染消息前生成一个消息框, 用户消息始终在顶部, 机器人消息始终在底部... 这部分不麻烦, 读取的时候稍微判断一下就好了.

image 用一个小游戏功能来演示它的效果, 输入输出消息顺序是: 用户: 钓鱼 -> 机器: 抛竿啦 -> 机器: 鱼上钩 -> 用户: 收杆 -> 机器: 钓到了.

放在 GitHub 上的项目, 总该支持 Markdown 语法吧, 这部分当然要用库, 那用库不就简单了, 最多就是自己画点 CSS 来统一一下样式什么的...

感谢 Marked 为本项目提供了我用过最好的 MD 解析库!

消息渲染好了, 但是因为一开始就没设计用虚拟列表 (而是使用延迟渲染和 CSS 的 content-visibility: auto 属性解决一部分性能问题), 那么它还有什么问题吗? 打开开发工具的网络页面, 我们会发现它把历史消息的所有图片都请求过来了, 它不影响使用, 但也仅仅只是不影响使用, 我肯定会给图片添加一个懒加载的, 这在 Marked 上实现起来也很轻松, 只需要像这样:

marked.use({
    renderer: {
        image: (token) => {
            let { href, raw, text, title } = token;
            return `<img src="${lib.htmlAttrEscape(href)}" alt="${lib.htmlAttrEscape(text || '')}" title="${lib.htmlAttrEscape(title)}" loading="lazy" />`;
        },
    },
});

lib.htmlAttrEscape() 是什么? 这只是一个用来将引号等内容转换成 HTML 字符实体的代码, 如果我们选择自己实现渲染器, 那么也能拿到最新鲜的内容, 然而用户并不能保证他们的 Markdown 消息里不出现 HTML 关键字.

那么接下来呢, 图片好像缺少一个好用的查看器, 虽然我经常使用鼠标或者 F12 将图片拖到新标签页里查看, 但这其实很麻烦, 不是吗. 那么图片查看器怎么实现, 只需要在页面中间摆一张图片, 然后能用鼠标放大就足够了吗? 这也太常见了, 我还是想整点不一样的.

https://github.com/ApliNi/cat.ipacel.cc/blob/main/img.html

于是我折腾出了这样一个东西, 它会弹出一个窗口来单独预览图片, 在这个窗口中, 图片可以缩放和拖动, 窗口也可以, 然后点击图片就能关闭这个窗口, 主程序里还写了自动设置窗口尺寸的代码, 整体感觉还挺不错 (这个图片查看器参考了电报的点击图片关闭和 QQ 的弹出窗口, 另外这个特殊的实现方式我也挺满意的. 这可能在网页版上略有些难看, 因为有地址栏, 但使用 PWA 版本, 一切都会好的.

那么接下来, 紧跟着的就是代码框了. 有 Markdown 解析, 怎么能少代码高亮, 那么它也用库就好了...

感谢 highlight.js 为本项目提供了我用过最好的代码高亮库!

要在 Marked 里集成 highlight.js 特别简单, 甚至不需要使用官方的扩展, 因为这两个库都提供了足够简单的 API, 或者叫扩展方式.:

marked.use({
    renderer: {
        code: (token) => {
            let { lang, raw, text } = token;
            const safetyLang = lib.htmlAttrEscape(lang || '');
            return `<pre><button class="btn" onclick="lib.copy(this.nextElementSibling.innerText); lib.btnFlash(this, 1500);" title="${`${safetyLang} 点击复制`.trim()}">#</button><code class="hljs" data-lang="${safetyLang}">${hljs.highlightAuto(text, lang ? [ lang ] : undefined).value}</code></pre>`;
        },
        codespan: (token) => {
            let { lang, raw, text } = token;
            let _text = raw.replace(/^`|`$/g, '');  // 绕过 html 转义
            return `<code class="hljs">${hljs.highlightAuto(_text).value}</code>`;
        },
    },
});
// 你可能觉得我拼接 HTML 就像在拼接 SQL 一样的不安全 (. 这会改的... 先别喷

这里添加了复制代码按钮的小功能, 虽然这个功能它马上就要被代替了... 然后! 有出现了新的问题. 我使用的代码高亮主题有些过时了, 一部分分类并没有正确的着色, 这下怎么办, 这可是唯一一个我觉得好看的配色 (我甚至真把它所有的主题都看了一遍), 我肯定不会换它, 那就去仓库里检查一下吧... 我把这个主题的两份代码 (亮色和暗色) 拿了出来, 和一个最近有更新主题进行比对, 总算是补全了它漏下的部分...

我应该, 或者说可以? 将这份修改过的主题交给 highlight.js 吗? 但我并不能保证我的分类符合这个主题的设计, 而且这还是别人写的主题, 虽然它已经有几年没维护了

好了, 现在我得到了一个完美的代码高亮, 完美的图片查看器, 和一个不完美的输入框 (

同时还实现了数学公式的渲染, 这个不麻烦, 能很好完成这项工作的库是存在的.

感谢 KaTeX 为本项目提供了我用过最好的公式渲染库! 如果这个项目对你有帮助, 也可以看看这个链接

但不同的是, 将 KaTeX 集成到 Marked 中用到了一个扩展.

感谢 marked-extended-latex 为本项目提供了具有扩展性的 KaTeX 对 Marked 的支持!

这个扩展与官方的有所不同, 它可以编辑 HTML! (. 为什么要编辑 HTML, 这在最后才会提到, 请坐和放宽 ~~

这部分进行的很顺利 (失望

那么然后呢?

感谢 marked-emoji 为本项目提供了 Markdown 的标记表情支持! :heart: 他们需要资金支持吗? 我没有找到, 但你可以帮我看看

现在, 这篇文章只剩最后一个"感谢"了

这个扩展我用的不够好, 因为我没很好的处理它需要加载的资源. 不过好的一点是, 这个扩展也提供了可编辑的 HTML, 我可以轻松的将普通 Emoji 和标记 Emoji 用特殊的样式区分开来, 这在其他产品里似乎不太经常出现.

输出

输出还能是什么? 除了我们在页面上看到的气泡框. 当然, 我们还需要能够把这些渲染得好看的内容给复制出来, 以一种通用的格式, 就像复制其中的 Markdown 一样, 我只需要在气泡框上放一个 "复制按钮" 就足够了吗? 为什么我要用网页提供的复制按钮, 为什么不是自己选择想要复制的部分. 那还需要实现什么? 直接选中复制不就完了. 但是这样就丢失了我们需要的一些 Markdown 文本, 比如公式 / 表格...

这复杂吗? 它可以设计的更简单一些就像吸附一样只要用户自己没选择就自动选中到最近一个完整的元素上, 但我还想做的更好一些. 我希望在此基础上, 随意选择的部分也能被很好的解析, 就像选中了同时存在文本与公式的内容, 只需要 Ctrl + C, 就能把这些 MD 格式的内容打包带走.

这下听起来有些难办了, 不过还是有办法的... 我们先做个简单的, 首先找出需要复制原始信息的元素, 比如公式, 然后将原始信息放进这个元素里, 比如使用一个 data-copy 属性, 当用户没有自己选中内容时, 右键点击将自动查找这个被点击元素的一个包含 data-copy 属性的父元素, 并选中它, 然后把这个元素存储起来.

image

接下来监听用户使用复制, 如果有被存储的待复制元素, 就直接取这个元素的 data-copy 内容即可. 接下来实现 "嵌套", 因为我并没有给所有元素都设置这样的属性, 比如可正常复制的 p 元素不需要这样做, 因此当复制一个拥有嵌套关系的元素时, 需要把可直接提取的和不可直接提取的分开处理.

然后实现任意选择的内容提取, 这部分稍有些复杂. 经过上面的判断, 接下来就一定是用户在自己选择内容了. 首先判断用户有没有选择文本, 这一步依然是必要的, 然后获得选择范围, 选择范围是一个简单的对象, 就像这样:

{
    collapsed,       // 不知道
    commonAncestorContainer,    // 选取内容共同的一个父元素
    endContainer,    // 选取结束在哪个元素
    endOffset,       //   - 结束元素上的偏移量
    startContainer,  // 选取起始在哪个元素
    startOffset,     //   - 起始元素上的偏移量
}

首先通过 document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT), 获取这里面的所有文本节点, 在遍历中排除未被选择的部分, 对首尾的两个节点通过选取偏移量进行截取并输出; 检查节点是否拥有一个包含 data-copy 属性的父元素, 如果是, 就记下这个父元素用来去重, 并将可复制的文本输出. 最后合并输出的内容再使用剪贴板接口复制即可.

image

章节结束

那么我们走到哪一步了? 现在的进度看着就像是一个装满彩蛋的简陋页面, 可能用户不会去试试右键或者选择一个公式, 然后因为找不到复制按钮而打开一个 issue. 这下怎么办呢, 可能还需要一个 "引导程序" 吧 (笑

怎么看都像是在做着没有意义的事, 那些更加有意义的功能都已经集成在各种库和框架里了, 那些用户会用的功能和使用方法都被考虑过了, 如果使用它们的方法, 最理想的情况可能就像一个只有赋值 / 判断和函数调用的简单程序以及它庞大的依赖吧, 并且每个人都能正确的使用所有功能.

一篇可能没什么用的日记, 只是讲述了一个使用原生环境的前端开发者为了 "完美" 没苦硬吃的编码过程.

最后感谢你看到这里. 第一次写这类文章, 没有经验, 如果有意见的话望轻喷 (