由于你在 Development 频道发帖,这篇回复可能会稍长一些。不过,内容非常通用,主要是在解释 Discourse 中应遵循的模式,而不论你打算集成哪个库,或者想要针对哪些帖子元素。恰好,这里以你选择的脚本作为主要示例。
MediaElement 提供了两种初始化新音频播放器的方式:
- 你可以给元素添加一个 CSS 类和一些属性,它会自动为你处理。
- 你可以手动初始化它。
你目前使用的是第一种方式,所以我们先来看看这种方式。当某个脚本提供“元素自动初始化”功能时,这通常是脚本作者为了提升用户体验而添加的便利功能。在脚本内部,作者通常会监听文档加载事件(document load event),并对那些具有指定类名或属性的 DOM 元素执行相应操作。
那么,为什么它不工作呢?正如你所见,它在初次页面加载时运行正常,但在随后的应用内导航中却失效了。这是怎么回事?
简短的回答是:Discourse 是一个单页应用(Single-Page Application)。像 <HTML> 和 <body> 这样的标签只会被发送一次。因此,从某种意义上说,文档只加载了一次。所以,当你在初始页面加载后进行导航时,不会再有原生的“加载”事件被触发。请记住,文档在初次页面查看时已经加载完成。此后的所有操作都由 Discourse 自行处理。
当然,这并不意味着在后续导航中没有任何事件被触发。然而,这些是 Discourse 特有的事件。因此,第三方脚本作者无法提前知晓这些事件。想象一下,如果你是一个脚本作者,需要适配成百上千个不同的平台,那会怎样?这显然不可行,对吧?
所以,我们无法使用脚本作者精心添加的那种“便利功能”。接下来该怎么办?别忘了,我们仍然可以手动在目标元素上初始化脚本。让我们试试这个方法。
我之前提到过,浏览器层面只有一个原生的“加载”事件,但像 Discourse 这样的平台如果没有自己的事件系统,就无法良好运行。例如,插件 API 提供了一个方法,可以在虚拟页面导航时触发脚本。
你应该使用这个方法吗?不。这个方法非常适合用于分析统计等场景。但对于只处理 <audio> 标签的脚本来说,在每次页面加载时都触发它并没有意义——尤其是当页面上根本没有这些标签时。
那么,下一步是什么?好消息是,你已经找到了答案。decorateCookedElement 就是这里应该使用的正确方法。
它提供了一种方式……等等……来装饰帖子:
Discourse 保证你添加的任何装饰器都会应用于每一个帖子。
既然你是在帖子装饰器中加载脚本,那么它应该会被添加并正常工作。那为什么在后续导航中却不生效呢?
要理解这一点,你需要了解 loadScript() 的工作原理。你的代码在加载脚本之前已经检查了是否存在有效的目标元素,这点做得很好:+1
但是,想象一下这样一种情况:你有 20 到 30 个连续的帖子,每个帖子都包含有效的元素。在这种情况下,将脚本加载 20 到 30 次有意义吗?显然没有。
loadScript() 足够智能,可以检测脚本是否已经加载。它不会重复加载,如果脚本已经下载完成,也不会重新加载。你可以在这里看到相关代码:
上面的 fullUrl 就是你调用 loadScript() 时传入的 URL,就像你的示例中那样。
现在,既然我们知道了这一点,就能大致理解为什么它在后续导航中不工作了。
你访问主题 A → 它包含一个音频元素 → loadScript() 加载脚本 → 脚本执行“自动初始化”操作 → 脚本在你的元素上初始化 → 你获得自定义音频播放器
然后……
你访问主题 B → 它也包含音频元素 → loadScript() 发现脚本已加载 → 不再执行“自动初始化” → 你得到默认的音频元素 → 于是问题出现了
那么,如何解决这个问题呢?这正是前面提到的第二种方法的作用所在:
- 你可以给元素添加一个 CSS 类和一些属性,它会自动为你处理。
- 你可以手动初始化它。
让我们采用第二种方法。这在你分享的页面上已有文档说明。我们需要像这样在我们的目标元素上调用它:
// 你可以使用字符串形式的播放器 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。
如果你知道为什么要使用它,那当然没问题。但如果你只是用它来定制播放器的外观,我建议避免使用它。我还没有检查,但很可能存在更轻量的现代替代方案。
所有这一切最好的地方在于,实现方式与上述内容并无不同。无论你想要针对哪些元素,或者使用哪些脚本,相同的模式都适用。唯一需要改变的是初始化自定义脚本的部分。每个优秀的库都有很好的文档,可以指导你完成这一过程。然后,你只需将其放入上述模式中即可。