惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

W
WeLiveSecurity
博客园 - 【当耐特】
Microsoft Azure Blog
Microsoft Azure Blog
WordPress大学
WordPress大学
Stack Overflow Blog
Stack Overflow Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
IT之家
IT之家
Cloudbric
Cloudbric
The Register - Security
The Register - Security
小众软件
小众软件
PCI Perspectives
PCI Perspectives
G
Google Developers Blog
AI
AI
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Google DeepMind News
Google DeepMind News
Google DeepMind News
Google DeepMind News
宝玉的分享
宝玉的分享
Recent Commits to openclaw:main
Recent Commits to openclaw:main
量子位
TaoSecurity Blog
TaoSecurity Blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
F
Full Disclosure
N
Netflix TechBlog - Medium
博客园_首页
Last Week in AI
Last Week in AI
A
Arctic Wolf
B
Blog RSS Feed
J
Java Code Geeks
C
Cybersecurity and Infrastructure Security Agency CISA
I
InfoQ
aimingoo的专栏
aimingoo的专栏
云风的 BLOG
云风的 BLOG
NISL@THU
NISL@THU
MyScale Blog
MyScale Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Jina AI
Jina AI
有赞技术团队
有赞技术团队
S
Schneier on Security
L
Lohrmann on Cybersecurity
P
Privacy & Cybersecurity Law Blog
T
Threat Research - Cisco Blogs
P
Palo Alto Networks Blog
S
Security @ Cisco Blogs
Security Archives - TechRepublic
Security Archives - TechRepublic
Security Latest
Security Latest
Vercel News
Vercel News
博客园 - 司徒正美
Webroot Blog
Webroot Blog
Hacker News: Ask HN
Hacker News: Ask HN
A
About on SuperTechFans

Oragekk's Blog

OpenSpec 技术分享:用规格驱动 AI 编程 ChatGPT&Codex订阅教程 Jenkins 远程触发构建踩坑记 Git SSH 密钥配置 初识Rust vuepress-plugin-meting2 谷歌发布多平台应用开发神器Project IDX!PaLM 2加持 Vue常见优化手段 Vue2响应式原理解析 Dart 中的并发 Flutter 工作原理 如何利用GitHub Action提交URL到搜索引擎 提交URL到搜索引擎(百度、Bing、Google) GitHub Actions 使用介绍 素材设计 前端-Q&A 浏览器的事件循环 你不知道的 CSS 之包含块 CSS 属性计算过程 Vercel deploy忽略指定分支 评论插件 Waline 之邮件通知配置 公开API 终端究极美化iTerm2+Pure 使用Bing API提交网站URL Flutter 基础大集合 关于本站 关于本站 关于我 Markdown 展示 使用n命令管理node版本 幻灯片页 ReactNative State(状态) ReactNative开发环境配置,ES6语法介绍 ReactNative介绍 更优雅强大的终端ZSH 神经网络模型训练 YYCache优秀的缓存设计 WebViewJavascriptBridge NSError WCDB漫谈 优雅的实现TableViewCell单选 初探机器学习框架CoreML 深入理解swift中闭包的捕捉语义 ijkPlayer 集成 iOS 配置https iOS timelineLogistics iOS Cookie的配置及使用 WKWebView拦截URL WKWebView使用及自适应高度 textfield限制输入字符 评论系统从多说迁移到disqus指南 利用Runtime进行快速归档 iOS 10.3 keychain 重大更新 Cell的accessoryType属性标记单元格之后,出现的重用问题 通过UserAgent判断设备 AFNetworking A memory leak 一人一句宋词 OC 中的枚举类型 iOS - Image compression algorithm iOS程序启动原理(下) iOS程序启动原理(上) NSOperatioin TableView性能优化 Runloop Test Three ways to call Update Cocoapods 1.1.1 减小iOS-App或者静态库体积 Jekyll旧站回忆 JavaScript ES6 CommonJS,RequireJS,SeaJS 归纳笔记 Unix/Linux 扫盲笔记
一个 waline 评论系统bug引发的思考
Oragekk · 2024-05-23 · via Oragekk's Blog

提示

前言: Waline 评论系统这个 bug 有几个月了,现象就是回复完其他人的评论后,评论内容会保留在顶部输入框中,而且不会自动清空。具体见 👉GitHub issuse #2173

许久未见修复,正好我有空,就看看这个问题,接下来跟我一起分析一下这个问题

1. 定位问题

问题现象上边已经描述过了,我们先来定位一下问题。

1.1. 关键

关键在于【不会清空】,即使刷新浏览器,也不会清空,由此可知必然保存在 localStorage 中,打开开发者工具,看看保存在 localStorage 中的数据结构。发现如下图所示

localStorage
localStorage

然后有了key就好办了

1.2. 源码

在 Waline 评论系统源码中,我们全局搜索 WALINE_COMMENT_BOX_EDITOR 结果如下

// walinejs/packages/client/src/composables/inputs.ts
import type { RemovableRef } from '@vueuse/core';
import { useStorage } from '@vueuse/core';

export interface UserMeta {
    nick: string;
    mail: string;
    link: string;
}

export const useUserMeta = (): RemovableRef<UserMeta> =>
useStorage<UserMeta>('WALINE_USER_META', {
    nick: '',
    mail: '',
    link: '',
});

export const useEditor = (): RemovableRef<string> =>
useStorage<string>('WALINE_COMMENT_BOX_EDITOR', '');

继续查找调用,找到CommentBox.vue关键组件,该组件将textarea评论框v-modeluseEditor函数绑定,该函数返回一个RemovableRef,该类型为<string>

const editor = useEditor();
<textarea
  id="wl-edit"
  ref="editorRef"
  v-model="editor"
  class="wl-editor"
  :placeholder="replyUser ? `@${replyUser}` : locale.placeholder"
  @keydown="onKeyDown"
  @drop="onDrop"
  @paste="onPaste"
/>

2. 调试

2.1. 准备工作

  1. 已锁定问题文件为CommentBox.vue,接下来开始 debug,因为准备提 PR,所以先 fork 一份

  2. 按照waline-贡献指南进行准备

  3. 先执行pnpm i & pnpm build,本地调试依赖@waline/api 需要前置 build

  4. 使用 pnpm client:dev 启动 @waline/client 本地开发,由于 waline 是 Client/Server 架构,在调试 client 时,你需要设置 SERVERURL 为调试服务器(可以直接使用 vercel 的服务器),或同时启动下面的 server 开发服务器并使用默认的 localhost:9090

  5. 使用 pnpm server:dev 启动 @waline/server 本地开发,配置必要的本地环境变量至 example/.env。(这里我配置了 leancloud 的环境变量,一直在报错)

 const submitComment = async (): Promise<void> => {

     // 此处...省略若干

     try {
         if (recaptchaV3Key)
         comment.recaptchaV3 =
             await useReCaptcha(recaptchaV3Key).execute('social');

         if (turnstileKey)
         comment.turnstile = await useTurnstile(turnstileKey).execute('social');

         const options = {
         serverURL,
         lang,
         token: userInfo.value?.token,
         comment,
         };

         const response = await (props.edit
         ? updateComment({
             objectId: props.edit.objectId,
             ...options,
         })
         : addComment(options));

         isSubmitting.value = false;

         if (response.errmsg) return alert(response.errmsg);

         emit('submit', response.data!);

         editor.value = '';

         previewText.value = '';

         if (props.replyId) emit('cancelReply');
         if (props.edit?.objectId) emit('cancelEdit');
     } catch (err: unknown) {
         isSubmitting.value = false;

         alert((err as TypeError).message);
     }
 };

接下来看 watch

watch(
  () => editor.value,
  (value) => {
    const { highlighter, texRenderer } = config.value;
    content.value = value;
    previewText.value = parseMarkdown(value, {
      emojiMap: emoji.value.map,
      highlighter,
      texRenderer,
    });
    wordNumber.value = getWordNumber(value);

    if (value) autosize(editorRef.value!);
    else autosize.destroy(editorRef.value!);
  },
  {
    immediate: true,
  }
);

2.3. 断点测试,整体流程如下

  1. 监听editor.value,并将editortextarea绑定,用户输入的值自动保存在localStorage

  2. 点击提交按钮,将textarea中的值发送给后端,收到回调后将editor清空,如果抛出异常则不清空

  3. 正常提交评论无异常,在submitComment函数中的editor.value = '';localStorage存储的内容清空

  4. 回复他人评论时,前置流程提交和步骤 3 一致,然后触发了watch,此时watch监听到的value为未被清空的值,既之前用户输入的内容

  5. submitComment中的editor.value = '';watch都打上了断点,先赋值为空,后触发watch,而此时watch的 value 为被清空之前的值,即用户输入的内容

2.4. 分析

问题已经定位到代码级别,接下来只需要找到 watch 被触发的原因即可,初步猜测可能原因:

submitComment是异步函数,其内部赋空值后,editor.value=''没有及时更新,导致watch触发取到旧值

遂添加 watch 的 bebug 函数加以验证

  1. onTrack 将在响应属性或引用作为依赖项被跟踪时被调用。相当于 get
  2. onTrigger 将在侦听器回调被依赖项的变更触发时被调用。相当于 set
watch(
  () => editor.value,
  (value) => {
    const { highlighter, texRenderer } = config.value;
    content.value = value;
    previewText.value = parseMarkdown(value, {
      emojiMap: emoji.value.map,
      highlighter,
      texRenderer,
    });
    wordNumber.value = getWordNumber(value);

    if (value) autosize(editorRef.value!);
    else autosize.destroy(editorRef.value!);
  },
  {
    immediate: true,
    onTrack(e) {
      // 当 editor.value 被追踪为依赖时触发
      debugger;
    },
    onTrigger(e) {
      // 当 editor.value 被更改时触发
      debugger;
    },
  }
);

结果为:

  1. editor.value=''时,onTrigger触发,newValue 为''
  2. 紧接着触发onTrackvalue 为旧值

调整回调的触发时机试试看:

  • Post Watchers

    如果想在侦听器回调中能访问被 Vue 更新之后的所属组件的 DOM,你需要指明 flush: 'post' 选项:

  • 同步侦听器

    它会在 Vue 进行任何更新之前触发:

    注意

    同步侦听器不会进行批处理,每当检测到响应式数据发生变化时就会触发。可以使用它来监视简单的布尔值,但应避免在可能多次同步修改的数据源 (如数组) 上使用。

结果是没有作用,但在更改为 sync 后,发现了一件有趣的事情,因为 sync 不会进行批处理的特性,所以触发了两次onTrack,我们来看一下两次的堆栈信息

第一次
第一次
第二次
第二次

很明显第一次触发是editor.value=''时触发的,第二次触发异步的,在submitComment还未执行完成时就触发了,点击堆栈信息定位到了291行,很明显早于editor.value=''297行。所以取到的是旧的值,虽然断点是editor.value=''先执行,但submitComment是异步的,watch 取到的是旧值,那接下来就需要看 watch是被什么触发了

由于源码中的watch是写在onMounted中的,断点确定二次走到watch中时,是触发了onMounted,那触发onMounted是组件重新加载了,查找后得知,整体逻辑是,评论区域,代码结构如下:

  • 组件为WalineComment.vue,其中包含了评论列表组件 itemCommentCard.vue和顶部默认输入框CommentBox.vue

  • 针对文章发布评论使用的是顶部的CommentBox.vue组件

  • 针对评论回复时,使用的是CommentCard.vue-CommentBox.vue

  • 回复完成后CommentCard.vue-CommentBox.vue销毁,顶部的CommentBox.vue组件重新渲染

顶部的CommentBox.vue组件重新渲染会触发 watch,此时取值是旧值, 经查看,submitComment中的editor.value=''执行完后, localStorage 中的值并未立即修改,所以重新渲染的顶部CommentBox.vue组件在初始化时取到的值仍为localStorage中的旧值。

3. 验证

上边已经基本确定问题出在这句上

const editor = useEditor();

而在 1.2 中可以看到useEditor()@vueuse/core的导出函数,怀疑其内部实现有一些异步操作,导致的没有立即更新localStorage

// 更换`editor.value`为
localStorage.setItem('WALINE_COMMENT_BOX_EDITOR', '');
// 或
await nextTick()

3.1. 至此问题解决

相关信息

nextTick()

等待下一次 DOM 更新刷新的工具方法。

  • 类型
function nextTick(callback?: () => void): Promise<void>
  • 详细信息

当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个“tick”才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。

nextTick() 可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。

4. 思考

vueusejs的文档中,其实useStorage应该是一个同步操作,它还有一个useStorageAsync的API,支持异步的响应式Storage,按理说里边不应该包含太多异步的或者延时性的代码,我也找到源码浅浅看了一下,目前还没找到问题在哪里,按照替换localStorage.setItem('WALINE_COMMENT_BOX_EDITOR', '');可行来看,问题就在useStorage身上无疑,后续有时间,会继续探索一下,给官方的PR#2524也已经提了