为每次页面更改装饰CookedElement?

我正在尝试使用 Mediaelement.js 库来覆盖自定义音频 onebox,并且在页面加载/刷新时它能正常工作。但是,当我在不同主题之间导航时,它似乎没有重新加载,而是显示了原生的 Discourse 音频 onebox。

加载/刷新时:

如果我然后导航到另一个主题:

我很确定我在 mejs 加载 方面做错了什么,但又觉得可能不是,并且我必须对 onPageChange 做一些花哨的事情。

<html>

<script type="text/discourse-plugin" version="0.8.42">

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`
            );
        }

        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");            
        });            
      },
      { id: "mediaelement-js", onlyStream: true}
    );
</script>

css

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

有人有什么建议,如何调整才能让它在每次页面加载时自动重新加载吗?

由于你在 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 永远不会改变,让我们将其设为一个 constloadScript() 调用也始终相同,我们也可以将其设为 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。

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

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

正是这些时刻,我才希望 Meta 正在使用 https://meta.discourse.org/t/discourse-reactions-beyond-likes/183261,因为一个“喜欢”不足以表达我现在感受到的爱和深深的感激之情。

我曾希望有人至少给我一点提示,但你却花时间写了一份非常详尽的解释,说明我是如何走到这一步的,并指导我如何让它奏效,在此过程中教会了我很多东西(甚至还有关于抖动的细节!)

我正在学习 Discourse 的结构如何能鼓励这种行为,这意味着如果我一次性回答好了,其他人也能看到,我就不必再回答一遍——这鼓励我继续用它来建立社区;然而,我认为这并不能完全解释你为什么为我写下这些,而你的意愿可能会鼓励我更多地使用这个平台。

谢谢你。


关于 Mediaelement,是的,它很老了,但它与我现有的 WordPress 网站搭配得很好,并且我在那里进行了大量定制,试图为用户提供熟悉的外观(而且目前也不想学习另一个库 :smiley:

一个后续问题,也许是个愚蠢的问题:我正在尝试加载多个脚本,因为 Mediaelement 允许插件脚本。我想确保所有脚本都加载完毕后再返回 Promise。

我尝试通过循环遍历脚本源的常量,然后创建一个 Promise 数组,再使用 Promise.all() 来初始化播放器,但当我这样做时,我收到了一个错误,说找不到 mejs,我认为这是 Mediaelement-and-player 中的命名空间或用于调用不同函数的某个东西。

      let scripts_loaded = [];

      MEDIA_ELEMENT_SCRIPTS.forEach(function (src, index){
          scripts_loaded[index] = loadScript(src);
      });

      Promise.all(scripts_loaded).then(() => {
            audioplayers.forEach(function (el) {
              new MediaElementPlayer(el, MEDIA_ELEMENT_CONFIG);
            });
      });

但是,如果我手动链接它们,它似乎可以工作,如下所示:

      loadScript(MEDIA_ELEMENT_SRC).then(() => {
          loadScript(MEDIA_ELEMENT_SPEED_SRC).then(() => {
            loadScript(MEDIA_ELEMENT_SKIP_BACK_SRC).then(() => {
                loadScript(MEDIA_ELEMENT_JUMP_FORWARD_SRC).then(() => {
                    audioplayers.forEach(function (el) {
                        new MediaElementPlayer(el, MEDIA_ELEMENT_CONFIG);
                    });
                });
            });
        });
      });

在这种情况下,我只使用少量脚本,所以我只是手动输入了所有脚本,我只是好奇我是否遗漏了关于 Promise.all() 函数的明显内容,或者是否有 Discourse 函数允许我从数组加载多个脚本。

您的代码应该可以正常工作。您只是遇到了 Discourse 中的一个 bug。

loadScript() 不会为其加载的脚本设置 async 属性。因此,它默认为 async="true",这会扰乱您的加载顺序。这是一个浏览器怪癖。您必须强制使用 JS 加载的脚本的 async="false"

插件较小,因此加载速度比主包快,但由于它们是 async 的,因此不再尊重加载顺序——等待主包加载并执行后再执行。

它可能未被注意到,因为据我所知,loadScript 在核心中没有嵌套。您通常会将需要协同工作的文件打包在一起。所以,回答您的另一个问题。不,没有 Discourse 函数可以处理那种事情。

您的其他代码片段也应该可以工作。为了让它更容易阅读,也许可以尝试链接它们而不要嵌套它们。

// This goes outside the decorator
const PLUGINS = {
  speed: "https://example.com/foo.js",
  skipBack: "https://example.com/bar.js",
  jumpForward: "https://example.com/baz.js"
};

// Then do your work inside the decorator
loadScript(MEDIA_ELEMENT_SRC)
  .then(() => loadScript(PLUGINS.speed))
  .then(() => loadScript(PLUGINS.skipBack))
  .then(() => loadScript(PLUGINS.jumpForward))
  .then(() => {
    audioplayers.forEach(function (el) {
      new MediaElementPlayer(el, MEDIA_ELEMENT_CONFIG);
    });
  });

哦天哪,谢谢您告诉我!

好的,听起来不错。

哇,我非常感激。干净多了。再次感谢 :pray: