为每次页面更改装饰CookedElement?

由于你在 Development 频道发帖,这篇回复可能会稍长一些。不过,内容非常通用,主要是在解释 Discourse 中应遵循的模式,而不论你打算集成哪个库,或者想要针对哪些帖子元素。恰好,这里以你选择的脚本作为主要示例。

MediaElement 提供了两种初始化新音频播放器的方式:

  1. 你可以给元素添加一个 CSS 类和一些属性,它会自动为你处理。
  2. 你可以手动初始化它。

你目前使用的是第一种方式,所以我们先来看看这种方式。当某个脚本提供“元素自动初始化”功能时,这通常是脚本作者为了提升用户体验而添加的便利功能。在脚本内部,作者通常会监听文档加载事件(document load event),并对那些具有指定类名或属性的 DOM 元素执行相应操作。

那么,为什么它不工作呢?正如你所见,它在初次页面加载时运行正常,但在随后的应用内导航中却失效了。这是怎么回事?

简短的回答是:Discourse 是一个单页应用(Single-Page Application)。像 <HTML> 和 <body> 这样的标签只会被发送一次。因此,从某种意义上说,文档只加载了一次。所以,当你在初始页面加载后进行导航时,不会再有原生的“加载”事件被触发。请记住,文档在初次页面查看时已经加载完成。此后的所有操作都由 Discourse 自行处理。

当然,这并不意味着在后续导航中没有任何事件被触发。然而,这些是 Discourse 特有的事件。因此,第三方脚本作者无法提前知晓这些事件。想象一下,如果你是一个脚本作者,需要适配成百上千个不同的平台,那会怎样?这显然不可行,对吧?

所以,我们无法使用脚本作者精心添加的那种“便利功能”。接下来该怎么办?别忘了,我们仍然可以手动在目标元素上初始化脚本。让我们试试这个方法。

我之前提到过,浏览器层面只有一个原生的“加载”事件,但像 Discourse 这样的平台如果没有自己的事件系统,就无法良好运行。例如,插件 API 提供了一个方法,可以在虚拟页面导航时触发脚本。

你应该使用这个方法吗?不。这个方法非常适合用于分析统计等场景。但对于只处理 <audio> 标签的脚本来说,在每次页面加载时都触发它并没有意义——尤其是当页面上根本没有这些标签时。

那么,下一步是什么?好消息是,你已经找到了答案。decorateCookedElement 就是这里应该使用的正确方法。

它提供了一种方式……等等……来装饰帖子::tada:

Discourse 保证你添加的任何装饰器都会应用于每一个帖子。

既然你是在帖子装饰器中加载脚本,那么它应该会被添加并正常工作。那为什么在后续导航中却不生效呢?

要理解这一点,你需要了解 loadScript() 的工作原理。你的代码在加载脚本之前已经检查了是否存在有效的目标元素,这点做得很好:+1

但是,想象一下这样一种情况:你有 20 到 30 个连续的帖子,每个帖子都包含有效的元素。在这种情况下,将脚本加载 20 到 30 次有意义吗?显然没有。

loadScript() 足够智能,可以检测脚本是否已经加载。它不会重复加载,如果脚本已经下载完成,也不会重新加载。你可以在这里看到相关代码:

上面的 fullUrl 就是你调用 loadScript() 时传入的 URL,就像你的示例中那样。

现在,既然我们知道了这一点,就能大致理解为什么它在后续导航中不工作了。

你访问主题 A → 它包含一个音频元素 → loadScript() 加载脚本 → 脚本执行“自动初始化”操作 → 脚本在你的元素上初始化 → 你获得自定义音频播放器

然后……

你访问主题 B → 它也包含音频元素 → loadScript() 发现脚本已加载 → 不再执行“自动初始化” → 你得到默认的音频元素 → 于是问题出现了

那么,如何解决这个问题呢?这正是前面提到的第二种方法的作用所在:

  1. 你可以给元素添加一个 CSS 类和一些属性,它会自动为你处理。
  2. 你可以手动初始化它。

让我们采用第二种方法。这在你分享的页面上已有文档说明。我们需要像这样在我们的目标元素上调用它:

// 你可以使用字符串形式的播放器 ID(例如 `player`),
// 或者使用 `document.querySelector()` 来选择任意元素
var player = new MediaElementPlayer("player", {
  // ... 选项
});

你的代码已经分别处理了每个音频元素:+1,所以我们只需要修改这部分:

audioplayers.forEach(function (el) {
  el.classList.add("mejs__player");
  const controls = settings.theme_uploads.mejs - controls;
  // console.log("controls: " + controls);
  el.setAttribute(
    "data-mejsoptions",
    `{
                    "pluginPath": "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
                    "iconSprite": "https://friends.jimkleiber.com/uploads/default/original/1X/a17d9708a19654d9155dd9b79a79a05dea580067.svg",
                    "alwaysShowControls": "true",
                    "features": ["playpause", "current", "progress", "duration", "volume"]
                }`
  );
  el.setAttribute("preload", "auto");
});

改为如下:

audioplayers.forEach(function (el) {
-- el.classList.add("mejs__player");
-- const controls = settings.theme_uploads.mejs - controls;
-- // console.log("controls: " + controls);
-- el.setAttribute(
--   "data-mejsoptions",
--   `{
--                     "pluginPath": "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
--                     "iconSprite": "https://friends.jimkleiber.com/uploads/default/original/1X/a17d9708a19654d9155dd9b79a79a05dea580067.svg",
--                     "alwaysShowControls": "true",
--                     "features": ["playpause", "current", "progress", "duration", "volume"]
--                 }`
-- );
-- el.setAttribute("preload", "auto");
++ 
++ new MediaElementPlayer(el, {
++  // ... 选项
++ });
});

然后,我们将之前作为属性添加的选项作为对象传递,如下所示:

audioplayers.forEach(function (el) {
 new MediaElement(el, {
++ pluginPath: "//cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
++ iconSprite: settings.theme_uploads["mejs-controls"],
++ alwaysShowControls: "true",
++ features: ["playpause", "current", "progress", "duration", "volume"]
 });
});

让我们暂时放下这部分,先看看装饰器的其余部分。目前我们已有如下代码:

let loadScript = require("discourse/lib/load-script").default;

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");
    // console.log("player: " + audioplayers[0]);
    if (Object.entries(audioplayers).length > 0) {
      // console.log("audioplayers has length");
      loadScript(
        `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement.min.js`
      );
      loadScript(
        `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js`
      );
    }

    // forEach 曾经在这里
  },
  { id: "mediaelement-js", onlyStream: true }
);

如果你注意到,你调用了两个不同的 loadScript()。我不确定这是否是有意为之,但实际上你只需要其中一个。可以将其视为完整包和轻量级包。你需要的是自定义音频播放器,因此需要完整包。让我们移除另一个。

let loadScript = require("discourse/lib/load-script").default;

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");
    // console.log("player: " + audioplayers[0]);
    if (Object.entries(audioplayers).length > 0) {
      // console.log("audioplayers has length");
--    loadScript(
--      `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement.min.js`
--    );
      loadScript(
        `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js`
      );
    }

    // forEach 曾经在这里
  },
  { id: "mediaelement-js", onlyStream: true }
);

你正在检查帖子中是否存在音频播放器,并据此有条件地加载脚本。这可以简化如下。首先,直接检查长度:

let loadScript = require("discourse/lib/load-script").default;

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");
    // console.log("player: " + audioplayers[0]);
--    if (Object.entries(audioplayers).length > 0) {
++    if (audioplayers.length) {
      // console.log("audioplayers has length");

      loadScript(
        `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js`
      );
    }

    // forEach 曾经在这里
  },
  { id: "mediaelement-js", onlyStream: true }
);

然后将其移到顶部,如果长度为假(length < 0),直接 return。我还移除了代码中的注释:

let loadScript = require("discourse/lib/load-script").default;

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");

++  if (!audioplayers.length) {
++   return;
++  }

    loadScript(
      `https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js`
    );

    // forEach 曾经在这里
  },
  { id: "mediaelement-js", onlyStream: true }
);

由于脚本的 src 永远不会改变,让我们将其设为一个 const。loadScript() 调用也始终相同,我们也可以将其设为 const。

++ const loadScript = require("discourse/lib/load-script").default;

++ const MEDIA_ELEMENT_SRC =
++  "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js";

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");

    if (!audioplayers.length) {
      return;
    }

++  loadScript(MEDIA_ELEMENT_SRC);

    // forEach 曾经在这里
  },
  { id: "mediaelement-js", onlyStream: true }
);

让我们暂时先搁置这部分。在继续之前,我们需要再深入了解一下 loadScript() 的工作原理。

如果你想使用脚本的某部分,你肯定希望确保它在执行任何操作之前已经加载完成,对吧?loadScript() 正是为你处理这一点。它返回一个 Promise。Promise 听起来可能有点吓人,但其实很简单。Promise 字面上就是……一个承诺。

你想做一些工作……你向浏览器承诺,当工作完成后会通知它……浏览器会等待你。就是这么简单。剩下的只是理解语法。

我不会花太多时间在这上面,因为这稍微超出了本话题的范围。

让我们继续。loadScript() 是基于 Promise 的。Discourse 向浏览器承诺,当脚本完全加载后会通知它——无论脚本是否不存在需要加载,还是仅仅检查它是否已经加载。

所以,如果我们这样做:

const loadScript = require("discourse/lib/load-script").default;

const MEDIA_ELEMENT_SRC =
  "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js";

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");

    if (!audioplayers.length) {
      return;
    }

++  loadScript(MEDIA_ELEMENT_SRC).then(() => {
++    // 这只有在脚本已加载或正在加载时才会执行
++    console.log("my script has loaded");
++  });

    // forEach 曾经在这里
  },
  { id: "mediaelement-js", onlyStream: true }
);

现在,我们可以回到之前的 forEach 循环,并将其直接放在这里,这样我们就能够确保脚本是可用的。

const loadScript = require("discourse/lib/load-script").default;

const MEDIA_ELEMENT_SRC =
  "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js";

api.decorateCookedElement(
  element => {
    const audioplayers = element.querySelectorAll("audio");

    if (!audioplayers.length) {
      return;
    }

    loadScript(MEDIA_ELEMENT_SRC).then(() => {
++    audioplayers.forEach(function (el) {
++      new MediaElementPlayer(el, {
++        pluginPath: "//cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
++        iconSprite: settings.theme_uploads["mejs-controls"],
++        alwaysShowControls: "true",
++        features: ["playpause", "current", "progress", "duration", "volume"]
++      });
++    });
    });
  },
  { id: "mediaelement-js", onlyStream: true }
);

现在,看看上面的代码片段,看看是否有让你觉得不妥的地方……

我们传递给脚本实例的选项始终相同,但我们没有将它们放在一个 const 中。让我们修复这个问题。

    const loadScript = require("discourse/lib/load-script").default;
    
    const MEDIA_ELEMENT_SRC =
      "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js";
    
++  const MEDIA_ELEMENT_CONFIG = {
++    pluginPath: "//cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
++    iconSprite: settings.theme_uploads["mejs-controls"],
++    alwaysShowControls: "true",
++    features: ["playpause", "current", "progress", "duration", "volume"]
++  };
    
    api.decorateCookedElement(
      element => {
        const audioplayers = element.querySelectorAll("audio");
    
        if (!audioplayers.length) {
          return;
        }
    
        loadScript(MEDIA_ELEMENT_SRC).then(() => {
          audioplayers.forEach(function (el) {
++          new MediaElementPlayer(el, MEDIA_ELEMENT_CONFIG);
          });
        });
      },
      { id: "mediaelement-js", onlyStream: true }
    );

差不多就是这样了。你的代码已经很接近了,只需要更深入地理解 loadScript() 的工作原理。

现在,我们将所有内容整合在一起。

common > header 标签页

<script type="text/discourse-plugin" version="0.8.42">
  const loadScript = require("discourse/lib/load-script").default;

  const MEDIA_ELEMENT_SRC =
    "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelement-and-player.min.js";

  const MEDIA_ELEMENT_CONFIG = {
    pluginPath: "//cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/",
    iconSprite: settings.theme_uploads["mejs-controls"],
    alwaysShowControls: "true",
    features: ["playpause", "current", "progress", "duration", "volume"]
  };

  api.decorateCookedElement(
    element => {
      const audioplayers = element.querySelectorAll("audio");

      if (!audioplayers.length) {
        return;
      }

      loadScript(MEDIA_ELEMENT_SRC).then(() => {
        audioplayers.forEach(function (el) {
          new MediaElementPlayer(el, MEDIA_ELEMENT_CONFIG);
        });
      });
    },
    { id: "mediaelement-js", onlyStream: true }
  );
</script>

然后添加一些 CSS 来加载脚本的样式,并防止脚本替换元素时出现闪烁:

common > css

@import "https://cdnjs.cloudflare.com/ajax/libs/mediaelement/5.0.4/mediaelementplayer.min.css";

.cooked {
  --audio-player-height: 40px;

  .mejs__container,
  audio {
    // 匹配 Media-Element.js 播放器高度以防止闪烁
    height: var(--audio-player-height);
    display: block;
  }
}

现在,你应该在每个包含有效元素的帖子中看到自定义播放器。

解决了这个问题后,你需要注意,你选择的这个库已经相当老旧了。它是为古老浏览器编译的,并且尝试为许多现在已经标准化的功能提供 polyfill。

如果你知道为什么要使用它,那当然没问题。但如果你只是用它来定制播放器的外观,我建议避免使用它。我还没有检查,但很可能存在更轻量的现代替代方案。

所有这一切最好的地方在于,实现方式与上述内容并无不同。无论你想要针对哪些元素,或者使用哪些脚本,相同的模式都适用。唯一需要改变的是初始化自定义脚本的部分。每个优秀的库都有很好的文档,可以指导你完成这一过程。然后,你只需将其放入上述模式中即可。