类似 Facebook 的话题弹窗 - 这样更好吗?

你好 :waving_hand:

虽然采用了与 @Damian_Boon 不同的方法:不使用 iframe,所有内容都作为原生 Glimmer 组件运行,并复用 Discourse 自身的 Post/post-stream 机制。由于在讨论串中出现了一些具体问题,以下是我最终解决每个问题的方法。


为什么不用 iframe

iframe 无疑是实现这一目标最快的方法,完整的话题路由、编辑器、 moderation 功能,全部 免费 提供。代价(正如 @nicolsdennis 关于 MessageBus 的问题所指出的)是页面上最终会出现两个活跃的 Discourse 实例:两个 post-stream、两个 MessageBus 连接、两个编辑器。

我选择了另一条路。模态框是一个 DModal,它针对一个由 store.createRecord("topic", …) 构建的 postStream 渲染真实的 Post/PostSmallAction 组件,使用同一个应用、同一个 MessageBus 连接、同一个编辑器。这意味着只要模态框处于打开状态,就需要将少数核心单例服务(modalbookmarkApiDiscourseURL.routeTo、环境 controller:topic)指向模态框的话题:

// 核心组件(例如 PostBookmarkManager)从 controller:topic 读取/写入 topic.bookmarks
// 在模态框打开期间,将其指向我们的 topicModel。
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
  this.originalTopicControllerModel = this.topicController.model;
}

每个补丁在关闭、销毁或导航离开时,都会通过幂等的 restoreServicePatches() 进行还原,因此一旦模态框消失,就不会有任何内容泄漏到应用的其余部分。


直接链接与内部路由

分为两部分。首先,模态框应该在读者实际停留的位置打开,而不是始终在第 1 楼:

const lastRead = this.topic.last_read_post_number ?? 0;
const highestPostNumber = this.topic.highest_post_number ?? 1;
const initialPostNumber = Math.max(
  1,
  Math.min(lastRead + 1, highestPostNumber)
);

其次,帖子 内部 指向同一话题的链接不应跳出模态框。DiscourseURL.routeTo 在其打开期间会被修补:

this.originalRouteTo = DiscourseURL.routeTo.bind(DiscourseURL);
DiscourseURL.routeTo = (path, opts) => {
  if (typeof path === "string") {
    const match = path.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/(\d+))?/);
    if (match && parseInt(match[1], 10) === this.topicId) {
      this.jumpToPost(match[2] ? parseInt(match[2], 10) : 1);
      return Promise.resolve();
    }
  }
  this.restoreServicePatches();
  return this.originalRouteTo(path, opts);
};

任何指向其他地方的链接都会先还原补丁,然后回退到正常导航,因此离开模态框时不会留下过期的路由状态。


回复情况(20+ 条回复)

通过哨兵元素 + IntersectionObserver 实现下方的无限滚动,上方对称的“加载更早”功能(预览可以在中途打开),两者都驱动真实话题路由所使用的确切 postStream.appendMore() / prependMore()

sentinel = modifier((element) => {
  const obs = new IntersectionObserver(
    (entries) => {
      if (entries.some((e) => e.isIntersecting)) {
        this.loadBelow();
      }
    },
    {
      root: document.querySelector(".topic-preview-modal .d-modal__body"),
      rootMargin: "200px",
    }
  );
  obs.observe(element);
  return () => obs.disconnect();
});

首次渲染

骨架屏(头像 + 线条占位符,闪烁效果)在首次渲染时填充,而不是使用加载旋转器,帖子列表本身分阶段渲染而不是一次性全部渲染,首先渲染足够多的帖子以到达目标帖子,然后通过 requestIdleCallback 每次再渲染五个,直到其余部分跟上:

const renderRemainingPosts = () => {
  const totalPosts = this.postStream?.posts?.length || 0;
  if (this.renderLimit < totalPosts) {
    this.renderLimit = Math.min(this.renderLimit + 5, totalPosts);
    if (this.renderLimit < totalPosts) {
      if (window.requestIdleCallback) {
        window.requestIdleCallback(renderRemainingPosts, { timeout: 300 });
      } else {
        setTimeout(renderRemainingPosts, 30);
      }
      return;
    }
  }
  this.renderLimit = Number.MAX_SAFE_INTEGER;
};

每个帖子包装器还获得了 content-visibility: auto 和一个大致的 contain-intrinsic-size,因此浏览器完全跳过对屏幕外帖子的布局计算:

.topic-post {
  contain: layout;
  content-visibility: auto;
  contain-intrinsic-size: 1px 180px;
}

这是我会最强烈推动的部分。与其在点击后重用实例,我在点击 之前 进行预取。每个话题列表行监控自身的可见性,防抖 200ms 以防止快速滚动触发十几个请求,并交由一个小型共享队列处理,该队列限制为最多 2 个并发获取:

function createTopicPrefetchQueue(maxConcurrent) {
  const pending = [];
  const activeIds = new Set();
  function drain() {
    while (activeIds.size < maxConcurrent && pending.length) {
      const { id, task } = pending.shift();
      activeIds.add(id);
      Promise.resolve()
        .then(task)
        .finally(() => {
          activeIds.delete(id);
          drain();
        });
    }
  }
  return {
    schedule(id, task) {
      if (activeIds.has(id) || pending.some((item) => item.id === id)) {
        return;
      }
      pending.push({ id, task });
      drain();
    },
    cancel(id) {
      const index = pending.findIndex((item) => item.id === id);
      if (index !== -1) {
        pending.splice(index, 1);
      }
    },
  };
}

预取结果存储在私有 PreloadStore 键下,故意 使用核心 topic_${id} 键,因为在那里写入会将作用域为 last_read+1 的部分 JSON 泄漏到真实的话题完整导航中。在实际点击时,它会被提升为模态框加载器期望的键:

promoteTopicPrefetch(topic) {
  if (!topic?.id) {
    return;
  }
  try {
    const key = this.prefetchStoreKey(topic.id);
    const prefetched = PreloadStore.get?.(key);
    if (prefetched != null) {
      PreloadStore.remove?.(key);
      PreloadStore.store(`topic_${topic.id}`, prefetched);
    }
  } catch {
    // 尽力而为 - 模态框回退到全新加载
  }
}

如果某行在点击前滚出视图,待处理的获取会被取消,任何缓存结果都会被丢弃,因此它只保留当前屏幕上实际存在的话题数据。


还有一个,讨论串中尚未提及的:嵌套模态框

一些模态框流程(核心自身打开的,而不是通过连接到 showSubModal() 的显式回调属性打开的)直接调用 modal 服务。为了防止这些操作从读者下方关闭 预览 模态框,modal.show() 被修补为选择性地将目标模态框重定向到一个小型本地子模态框插槽,只要预览处于打开状态:

this.originalModalShow = this.modal.show.bind(this.modal);
this.modal.show = (component, opts = {}) => {
  return this.showSubModal(component, opts.model);
};

因此,这些目标流程会叠加在预览之上,而不是替换它,同时阅读跟踪(/topics/timings)通过定期刷新由 IntersectionObserver 跟踪的可见帖子编号集合在后台继续运行,因此关闭后的未读计数仍然正确,与正常访问话题相同。


这是跨越模态框、话题列表触发器和样式的大量代码(总共约 1700 行),这仅覆盖了其中的三分之一。迄今为止最棘手的部分是服务修补以及让 content-visibility 在图片加载完成后帖子高度发生变化时表现正常。如果感兴趣,我很乐意就其中任何部分提供更多细节,或将其开源。