查看原文
其他

Web 视频播放前前后后那些事

前端大全 全栈前端精选 2023-08-04

(给全栈前端精选加星标,提升前端技能

英文:Paul Berberian  译文:jackpu

https://www.jackpu.com/yi-web-shi-pin-bo-fang-qian-qian-hou-hou/

注意:本文是JavaScript视频流的介绍,主要针对Web开发人员。这里的大部分示例都使用HTML和现代JavaScript(ES6)。如果您对它们还不够熟悉,那么您可能会发现很难继续学习,尤其是代码示例。

原生视频 API

从2000年代初期到后期,网络上的视频播放主要依靠Flash插件。


警告用户安装Flash插件


这是因为当时没有其他方法可以在浏览器上流式传输视频。作为用户,您可以选择安装Flash或Silverlight之类的第三方插件,还是根本无法播放任何视频。

为了填补这个空白,WHATWG 开始研究 HTML 标准的新版本,其中包括本地的视频和音频播放。苹果公司在其产品上禁用 Flash 后,这一趋势更加明显。而该标准后面成为现在的 HTML5 的标准。

因此,HTML5除其他新的标签外,将 <video> 标签带到当前 Web。

这个新标签允许您直接从HTML链接到视频,就像标签对图像所做的一样。

但是从媒体网站的角度来看,使用简单的类似于 img 的标记似乎不足以替代我们的优质Flash:

  • 我们可能想即时切换多种视频质量(例如YouTube)以避免缓冲问题

  • 直播是另一个用例,看起来很难实现

  • 像Netflix一样,在内容流式传输时根据用户的喜好更新内容的音频语言又如何呢?

值得庆幸的是,由于HTML5规范带来的好处,所有这些问题都可以在大多数浏览器上以本地方式解决。本文将详细介绍当今的技术是如何做到的。

Video 标签

如前文所述,在HTML5中,链接到页面中的视频非常简单。您只需在页面中添加具有很少属性的视频标签即可。

  1. <html>

  2. <head>

  3. <meta charset="UTF-8">

  4. <title>My Video</title>

  5. </head>

  6. <body>

  7. <video src="some_video.mp4" width="1280px" height="720px" />

  8. </body>

  9. </html>

HTML 将允许您的页面直接在支持相应编解码器的任何浏览器上播放some_video.mp4。

类似像这样:

HTML5 视频标签还提供了各种API,例如播放,暂停,搜索或更改视频播放的速度。

这些API可通过JavaScript直接访问:

  1. //pause the video

  2. myVideo.pause()


  3. // seek to 10 seconds

  4. myVideo.currentTime = 10;

但是,我们今天在网络上看到的大多数视频显示的行为比上面说的的行为复杂得多。例如,在视频质量和直播流之间切换将会非常麻烦。

所有这些网站实际上仍然使用video标签。但是,它们不只是在src属性中设置视频文件,而是使用功能更强大的Web API(Media Source Extensions)。

Media Source Extensions

“Media Source Extensions”(通常简称为“ MSE”)是 W3C 的一种规范,当今大多数浏览器都在实现。它的创建是为了直接使用 HTML 和 JavaScript 允许那些复杂的媒体使用案例。

这些“扩展”将 MediaSource 对象添加到 JavaScript。顾名思义,这将是视频的源,或更简单地说,这是代表我们视频数据的对象。

如前文所述,我们仍然使用 HTML5 视频标签。也许更令人惊讶的是,我们仍然使用它的 src 属性。仅这次,我们不添加视频链接,我们正在添加一个链接到 MediaSource 对象。

您可能对最后一句话感到困惑。我们在这里不是在讨论URL,而是在讨论 JavaScript 语言的抽象概念,如何将其称为 HTML 中定义的视频标签上的 URL?

为了允许这种用例,W3C定义了 URL.createObjectURL 静态方法。该API允许创建一个URL,该URL实际上将不引用在线可用资源,而是直接引用在客户端上创建的JavaScript对象。

因此,这是将MediaSource附加到视频标签的方式:

  1. const videoTag = document.getElementById("my-video");


  2. // creating the MediaSource, just with the "new" keyword, and the URL for it

  3. const myMediaSource = newMediaSource();

  4. const url = URL.createObjectURL(myMediaSource);


  5. // attaching the MediaSource to the video tag

  6. videoTag.src = url;

就是这样!现在,您已经知道流媒体平台如何在 Web 上播放视频!

… just kidding。所以现在有了 MediaSource,但是我们应该怎么做呢?

MSE规范不止于此。它还定义了另一个概念,即SourceBuffers。

Source Buffers

视频实际上并没有直接“推送”到 MediaSource 中进行播放,而是使用 SourceBuffers。

MediaSource 包含一个或多个实例。每个都与一种内容类型相关联。

为了简单起见,我们只说三种可能的类型:

  • 音讯

  • 视频

  • 音频和视频

实际上,“类型”是由其MIME类型定义的,其中还可能包含有关所使用的媒体编解码器的信息

SourceBuffers 都链接到单个 MediaSource,并且每个都将用于直接将 JavaScript 中的视频数据添加到 HTML5 视频标签中。

例如,一个常见的用例是在 MediaSource 上有两个源缓冲区:一个用于视频数据,另一个用于音频:

将视频和音频分离,还可以在服务器端分别对其进行管理。这样做会带来一些优势,我们将在后面看到。它是这样工作的:

  1. const videoTag = document.getElementById("my-video");

  2. const myMediaSource = new MediaSource();

  3. const url = URL.createObjectURL(myMediaSource);

  4. videoTag.src = url;


  5. // 1. add source buffers


  6. const audioSourceBuffer = myMediaSource

  7. .addSourceBuffer('audio/mp4; codecs="mp4a.40.2"');

  8. const videoSourceBuffer = myMediaSource

  9. .addSourceBuffer('video/mp4; codecs="avc1.64001e"');


  10. // 2. download and add our audio/video to the SourceBuffers


  11. // for the audio SourceBuffer

  12. fetch("http://server.com/audio.mp4").then(function(response) {

  13. // The data has to be a JavaScript ArrayBuffer

  14. return response.arrayBuffer();

  15. }).then(function(audioData) {

  16. audioSourceBuffer.appendBuffer(audioData);

  17. });


  18. // the same for the video SourceBuffer

  19. fetch("http://server.com/video.mp4").then(function(response) {

  20. // The data has to be a JavaScript ArrayBuffer

  21. return response.arrayBuffer();

  22. }).then(function(videoData) {

  23. videoSourceBuffer.appendBuffer(videoData);

  24. });

瞧!

现在,我们可以将视频和音频数据手动手动添加到我们的视频标签中。

现在该写音频和视频数据本身了。在上一个示例中,您可能已经注意到音频和视频数据为mp4格式

“ mp4”是一种视频容器格式(container format),它包含相关的媒体数据,还包含多个元数据,例如描述其中包含的媒体的开始时间和持续时间。

MSE规范没有规定浏览器必须理解哪种格式。对于视频数据,两个最常见的是 mp4 和 webm 文件。到目前为止,前者是众所周知的,后者是由Google赞助的,并且基于可能更为知名的Matroska格式(“ .mkv”文件)。

两者在大多数浏览器中均受良好支持。

切片

尽管如此,这里仍然有许多问题没有答案:

  • 我们是否必须等待所有内容下载完毕,才能将其推送到SourceBuffer(因此可以播放)?

  • 我们如何在多种品质或语言之间切换?

  • 由于媒体尚未制作完,如何播放直播内容?

在上一章的示例中,我们有一个文件代表整个音频,一个文件代表整个视频。这对于真正简单的用例就足够了,但是如果您想了解大多数流媒体网站提供的复杂性(切换语言,质量,播放实时内容等),则还不够。

在更高级的视频播放器中实际发生的是将视频和音频数据分为多个“片段”。这些片段的大小可以不同,但通常代表2到10秒的内容。

然后,所有这些视频/音频片段将形成完整的视频/音频内容。这些数据的“切片”为我们之前的示例增加了全新的灵活性:我们不必一次推送全部内容,而是可以逐步推送多个分片。

这是一个简化示例:

  1. // ... (definition of the MediaSource and its SourceBuffers)


  2. /**

  3. * Fetch a video or an audio segment, and returns it as an ArrayBuffer, in a

  4. * Promise.

  5. * @param {string} url

  6. * @returns {Promise.<ArrayBuffer>}

  7. */

  8. function fetchSegment(url) {

  9. return fetch(url).then(function(response) {

  10. return response.arrayBuffer();

  11. });

  12. }


  13. // fetching audio segments one after another (notice the URLs)

  14. fetchSegment("http://server.com/audio/segment0.mp4")

  15. .then(function(audioSegment0) {

  16. audioSourceBuffer.appendBuffer(audioSegment0);

  17. })


  18. .then(function() {

  19. return fetchSegment("http://server.com/audio/segment1.mp4");

  20. })

  21. .then(function(audioSegment1) {

  22. audioSourceBuffer.appendBuffer(audioSegment1);

  23. })


  24. .then(function() {

  25. return fetchSegment("http://server.com/audio/segment2.mp4");

  26. })

  27. .then(function(audioSegment2) {

  28. audioSourceBuffer.appendBuffer(audioSegment2);

  29. })


  30. // ...


  31. // same thing for video segments

  32. fetchSegment("http://server.com/video/segment0.mp4")

  33. .then(function(videoSegment0) {

  34. videoSourceBuffer.appendBuffer(videoSegment0);

  35. });


  36. // ...

这意味着我们在服务器端也有那些多个段。在前面的示例中,我们的服务器至少包含以下文件:

  1. ./audio/

  2. ├── segment0.mp4

  3. ├── segment1.mp4

  4. └── segment2.mp4

  5. ./video/

  6. └── segment0.mp4

注意:音频或视频文件可能不会在服务器端真正进行切片,客户端可能会使用Range HTTP标头代替来获取切片的文件(或者,实际上,服务器可能会根据您的请求进行任何操作您返回具体内容)。

但是,这些情况是实现细节。在这里,我们将始终认为服务器端具有这些分片文件。

所有这些意味着, 我们不必等待整个音频或视频内容下载就可以开始播放。我们通常只需要第一部分。

当然,大多数播放器并不像我们在此处那样为每个视频和音频段手动执行此逻辑,但是他们遵循相同的想法:依次下载段并将其推入源缓冲区。

看到这种逻辑在现实生活中发生的一种有趣方式是,可以在Firefox / Chrome / Edge上打开网络监视器(在Linux或Windows上,键入“ Ctrl + Shift + i”,然后转到“网络”标签,在Mac上应依次为Cmd + Alt + i和“网络”),然后在您喜欢的流媒体网站中启动视频。

您应该可以看到各种视频和音频片段正在快速下载:

顺便说一句,您可能已经注意到,我们的段只是\被推送到源缓冲区中,而没有指示 WHERE, 参考时间正确的位置的地方进行添加。

实际上,片段的容器确实定义了应将它们放入整个媒体的时间。这样,我们不必在JavaScript中立即进行同步。

自适应码流 Adaptive Streaming

许多视频播放器具有“自动播放清晰度”功能,根据用户的网络和处理能力自动选择具体视频质量。

这是称为自适应流的网络播放器的核心问题。

借助媒体分片的概念,也可以启用此行为。

在服务器端,段实际上是用多种质量编码的。例如,我们的服务器可能存储了以下文件:

  1. ./audio/

  2. ├── ./128kbps/

  3. | ├── segment0.mp4

  4. | ├── segment1.mp4

  5. | └── segment2.mp4

  6. └── ./320kbps/

  7. ├── segment0.mp4

  8. ├── segment1.mp4

  9. └── segment2.mp4

  10. ./video/

  11. ├── ./240p/

  12. | ├── segment0.mp4

  13. | ├── segment1.mp4

  14. | └── segment2.mp4

  15. └── ./720p/

  16. ├── segment0.mp4

  17. ├── segment1.mp4

  18. └── segment2.mp4

然后,网络播放器将随着网络或CPU条件的变化自动选择正确的段进行下载。

这完全是用JavaScript完成的。例如,对于音频片段,它可能看起来像这样:

  1. /**

  2. * Push audio segment in the source buffer based on its number

  3. * and quality

  4. * @param {number} nb

  5. * @param {string} language

  6. * @param {string} wantedQuality

  7. * @returns {Promise}

  8. */

  9. function pushAudioSegment(nb, wantedQuality) {

  10. // The url begins to be a little more complex here:

  11. const url = "http://my-server/audio/" +

  12. wantedQuality + "/segment" + nb + ".mp4");

  13. return fetch(url)

  14. .then((response) => response.arrayBuffer());

  15. .then(function(arrayBuffer) {

  16. audioSourceBuffer.appendBuffer(arrayBuffer);

  17. });

  18. }


  19. /**

  20. * Translate an estimated bandwidth to the right audio

  21. * quality as defined on server-side.

  22. * @param {number} bandwidth

  23. * @returns {string}

  24. */

  25. function fromBandwidthToQuality(bandwidth) {

  26. return bandwidth > 320e3 ? "320kpbs" : "128kbps";

  27. }


  28. // first estimate the bandwidth. Most often, this is based on

  29. // the time it took to download the last segments

  30. const bandwidth = estimateBandwidth();


  31. const quality = fromBandwidthToQuality(bandwidth);


  32. pushAudioSegment(0, quality)

  33. .then(() => pushAudioSegment(1, quality))

  34. .then(() => pushAudioSegment(2, quality));

如您所见,我们将不同质量的段组合在一起没有问题,这里的 JavaScript 方面一切都是透明的。在任何情况下,容器文件都包含足够的信息,以使此过程平稳运行。

切换语言

在更复杂的网络视频播放器上,例如 Netflix,Amazon Prime Video 或 MyCanal 上的视频播放器,还可以根据用户设置在多种音频语言之间进行切换。

既然您知道了什么,对您来说,完成此功能的方法应该看起来很简单。

像自适应流一样,我们在服务器端也有许多段:

  1. ./audio/

  2. ├──./esperanto/

  3. | ├──segment0.mp4

  4. | ├──segment1.mp4

  5. | └──segment2.mp4

  6. └── ./french/

  7. ├──segment0.mp4

  8. ├──segment1.mp4

  9. └──segment2.mp4

  10. ./video/

  11. ├──segment0.mp4

  12. ├──segment1.mp4

  13. └── segment2.mp4

这次,视频播放器必须不根据客户端的功能而是根据用户的喜好在语言之间进行切换。

对于音频段,这是客户端上的代码:

  1. // ...


  2. /**

  3. * Push audio segment in the source buffer based on its number and language.

  4. * @param {number} nb

  5. * @param {string} language

  6. * @returns {Promise}

  7. */

  8. function pushAudioSegment(nb, language) {

  9. // construct dynamically the URL of the segment

  10. // and push it to the SourceBuffer

  11. const url = "http://my-server/audio/" +

  12. language + "/segment" + nb + ".mp4"

  13. return fetch(url);

  14. .then((response) => response.arrayBuffer());

  15. .then(function(arrayBuffer) {

  16. audioSourceBuffer.appendBuffer(arrayBuffer);

  17. });

  18. }


  19. // recuperate in some way the user's language

  20. const language = getUsersLanguage();


  21. pushAudioSegment(0, language)

  22. .then(() => pushAudioSegment(1, language))

  23. .then(() => pushAudioSegment(2, language));

您可能还希望在切换语言时“清除”以前的SourceBuffer的内容,以避免混合多种语言的音频内容。

这可以通过SourceBuffer.prototype.remove方法完成,该方法以秒为单位的开始和结束时间:

  1. audioSourceBuffer.remove(0, 40);

当然,也可以将自适应流和多种语言结合在一起。我们可以这样组织服务器:

  1. ./audio/

  2. ├──./esperanto/

  3. | ├──./128kbps/

  4. | | ├──segment0.mp4

  5. | | ├──segment1.mp4

  6. | | └──segment2.mp4

  7. | └── ../320kbps/

  8. | ├──segment0.mp4

  9. | ├──segment1.mp4

  10. | └──segment2.mp4└──./

  11. french/

  12. ├──./128kbps/

  13. | ├──segment0.mp4

  14. | ├──segment1.mp4

  15. | └──segment2.mp4

  16. └── ./320kbps/

  17. ├──segment0.mp4

  18. ├──segment1.mp4

  19. └──segment2.mp4

  20. ./video/

  21. ├──./240p/

  22. | ├──segment0.mp4

  23. | ├──segment1.mp4

  24. | └──segment2.mp4

  25. └── ./720p/

  26. ├──segment0.mp4

  27. ├──segment1.mp4

  28. └──segment2.mp4

而我们的客户将不得不同时管理语言和网络条件:

  1. /**

  2. * Push audio segment in the source buffer based on its number, language and quality

  3. * @param {number} nb

  4. * @param {string} language

  5. * @param {string} wantedQuality

  6. * @returns {Promise}

  7. */

  8. function pushAudioSegment(nb, language, wantedQuality) {

  9. // The url begins to be a little more complex here:

  10. const url = "http://my-server/audio/" +

  11. language + "/" + wantedQuality + "/segment" + nb + ".mp4");


  12. return fetch(url)

  13. .then((response) => response.arrayBuffer());

  14. .then(function(arrayBuffer) {

  15. audioSourceBuffer.appendBuffer(arrayBuffer);

  16. });

  17. }


  18. const bandwidth = estimateBandwidth();

  19. const quality = fromBandwidthToQuality(bandwidth);

  20. const language = getUsersLanguage();

  21. pushAudioSegment(0, language, quality)

  22. .then(() => pushAudioSegment(1, language, quality))

  23. .then(() => pushAudioSegment(2, language, quality));

如您所见,现在有很多方法可以定义相同的内容。

这揭示了分开的视频和音频段相对于整个文件的另一个优点。对于后者,我们将不得不在服务器端结合各种可能性,这可能会占用更多空间:

  1. segment0_video_240p_audio_esperanto_128kbps.mp4

  2. segment0_video_240p_audio_esperanto_320kbps.mp4

  3. segment0_video_240p_audio_french_128kbps.mp4

  4. segment0_video_240p_audio_french_320kbps.mp4

  5. segment0_video_720p_audio_esperanto_128kbps.mp4

  6. segment0_video_720p_audio_esperanto_320kbps.mp4

  7. segment0_video_720p_audio_french_128kbps.mp4

  8. segment0_video_720p_audio_french_320kbps.mp4

  9. segment1_video_240p_audio_esperanto_128kbps.mp4

  10. segment1_video_240p_audio_esperanto_320kbps.mp4

  11. segment1_video_240p_audio_french_128kbps.mp4

  12. segment1_video_240p_audio_french_320kbps.mp4

  13. segment1_video_720p_audio_esperanto_128kbps.mp4

  14. segment1_video_720p_audio_esperanto_320kbps.mp4

  15. segment1_video_720p_audio_french_128kbps.mp4

  16. segment1_video_720p_audio_french_320kbps.mp4

  17. segment2_video_240p_audio_esperanto_128kbps.mp4

  18. segment2_video_240p_audio_esperanto_320kbps.mp4

  19. segment2_video_240p_audio_french_128kbps.mp4

  20. segment2_video_240p_audio_french_320kbps.mp4

  21. segment2_video_720p_audio_esperanto_128kbps.mp4

  22. segment2_video_720p_audio_esperanto_320kbps.mp4

  23. segment2_video_720p_audio_french_128kbps.mp4

  24. segment2_video_720p_audio_french_320kbps.mp4

  25. Here we have more files, with a lot of redundancy (the

这里我们有更多的文件,并且有很多冗余(多个文件中包含完全相同的视频数据)。

如您所见,在服务器端效率很低。但这在客户端也很不利,因为切换音频语言可能会导致您也重新下载视频(带宽成本很高)。

直播

我们还没有谈论直播。

网络上的直播流媒体(twitch.tv,YouTube实时流媒体...)变得非常普遍,并且由于我们的视频和音频文件已分段,因此再次大大简化了这一过程。

为了说明它基本上以最简单的方式工作,让我们考虑一个4秒钟前才开始直播传输的 YouTube 频道。

如果我们的片段长2秒,那么我们应该已经在YouTube的服务器上生成了两个音频片段和两个视频片段:

  • 两个代表从0秒到2秒的内容(1个音频+ 1个视频)

  • 两个代表2秒到4秒(同样是1个音频+ 1个视频)


  1. ./audio/

  2. ├──segment0s.mp4

  3. └── segment2s.mp4

  4. ./video/

  5. ├──segment0s.mp4

  6. └── segment2s.mp4

在5秒钟时,我们还没有时间生成下一个片段,因此,到目前为止,服务器具有完全相同的可用内容。

6秒钟后,可以生成一个新的段,我们现在有:

  1. ./audio/

  2. ├──segment0s.mp4

  3. ├──segment2s.mp4

  4. └── segment4s.mp4

  5. ./video/

  6. ├──segment0s.mp4

  7. ├──segment2s.mp4

  8. └── segment4s.mp4

在服务器端,这是很合乎逻辑的,实时内容实际上并不是真正连续的,它们像非实时内容一样进行分段,但是随着时间的流逝,分段会逐渐出现。

现在,我们如何从JS中知道服务器上某个时间点可用的段?

我们可能只在客户端上使用一个时钟,然后随着时间的流逝推断出新的段在服务器端变得可用。

我们将遵循“ segmentX.mp4 ”的命名方案,并且每次都将从上次下载的“ X”开始递增(“ segment0.mp4”,然后是2秒后的“ Segment1.mp4”等)。

但是,在许多情况下,这可能变得太不精确:媒体段的持续时间可能可变,服务器在生成媒体段时可能会有延迟,它可能希望删除太旧以至于无法节省空间的段...

作为客户端,您想请求最新的分片,只要它们可用,同时仍避免在尚未生成细分市场时过早请求它们(这将导致404 HTTP错误)。

通常通过使用传输协议(有时也称为流媒体协议)解决此问题。

传输协议

对于本文,深入解释不同的传输协议可能太冗长。我们只说其中大多数具有相同的核心概念:Manifest。

Manifest是描述哪些段可用的服务器上的文件。

借助它,您可以Manifest中了解到的大多数信息:

  • 内容在服务器上可用的语言以及在服务器上的可用位置(例如,“在哪个URL”)

  • 提供不同的音频和视频质量

  • 当然,在直播流媒体的情况下,哪些细分可用

Web中使用的最常见的传输协议是:

DASH

YouTube,Netflix或 Amazon Prime Video(及许多其他公司)使用的 DASH。DASH 的清单称为“Media Presentation Description”(或MPD),是其基本XML。

DASH规范具有极大的灵活性,它允许MPD支持大多数用例(音频描述,父控制)并且与编解码器无关。

HLS

由Apple开发,并由DailyMotion,Twitch.tv和许多其他公司使用。HLS清单称为播放列表,格式为m3u8(它们是m3u播放列表文件,以UTF-8编码)。

Smooth Streaming

由Microsoft开发,被多个Microsoft产品和MyCanal使用。在“平滑流传输”中,清单称为……Manifests,并且基于XML。

当前 Web 播放现状

如您所见,网络视频背后的核心概念在于在 JavaScript 中动态添加的媒体分片。

这种行为很快变得非常复杂,因为视频播放器必须支持许多功能:

  • 它必须下载并解析某种清单文件

  • 它必须猜测当前的网络状况

  • 它需要注册用户首选项(例如,首选语言)

  • 它必须至少根据前两个要点知道要下载哪个段

  • 它必须管理一个段管道以在正确的时间顺序下载正确的段(同时下载每个段的效率很低:您需要最早的一个比下一个要早)

  • 它也必须处理字幕,通常完全由 JS 管理

  • 一些视频播放器还管理缩略图轨道,将鼠标悬停在进度条上时通常可以看到

  • 许多服务也需要 DRM 管理

还有很多其他事情。复杂的,与Web兼容的视频播放器的核心仍然都是基于 MediaSource 和 SourceBuffers。

这就是为什么这些任务通常由第三方库执行的原因。

通常,这些库甚至都没有定义用户界面。它们主要提供丰富的API,以清单和各种首选项作为参数,并在正确的时间在正确的源缓冲区中添加正确的缓冲区。

当设计媒体网站和 Web 应用程序时,这将实现更大的模块化和灵活性,而本质上讲,它们将是复杂的前端。

开源的播放器

今天有许多网络视频播放器可以完成本文所解释的工作。以下是各种开源示例:

  • rx-player:可配置的DASH和 Smooth Streaming 播放器。用 TypeScript 编写—我是开发人员之一。

  • dash.js:播放DASH内容,支持多种DASH功能。由DASH行业论坛(DASH Industry Forum)撰写,旨在促DASH传输协议的互操作性指南。

  • hls.js:久负盛名的 HLS播放器。在生产中由多个知名品牌使用,例如Dailymotion,Canal +,Adult Swim,Twitter,VK等。

  • shaka-player:DASH和HLS播放器。由Google维护。


❤️ 看完三件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  • 点个【在看】,或者分享转发,让更多的人也能看到这篇内容

  • 关注公众号【全栈前端精选】,不定期分享原创&精品技术文章。

  • 公众号内回复:【 1 】。加入全栈前端精选公众号交流群。


欢迎评论区留下你的精彩评论~

觉得文章不错可以分享到朋友圈让更多的小伙伴看到哦~

客官!在看一下呗

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存