从零开始写一个 Web 视频播放器

12,014 阅读23分钟
原文链接: webfe.kujiale.com

前言

最近几周接连做完了酷家乐大学的直播页面和视频播放页改版,过程中有一些探讨与思考值得记录一下,本文会介绍实现一个 Web 视频播放器的过程。

前方提示1:本文不会着重在 Web 视频播放器的实现细节上。
前方提示2:本文要讲的 Web 视频播放器,不涉及底层实现,或者是关于视频格式、码率等其他知识,仅仅是一个纯 HTML5 的 Web 端视频组件。
前方提示3:初次撰文,可能有些字句表达不当,欢迎指出。

酷家乐旧版视频播放页所使用的视频播放器插件是 video.js,一款有着众多用户的、扩展性非常好的播放器插件,video.js 还提供了 Flash 播放器的支持,它会根据浏览器的版本切换 Flash 与 HTML5 的视频播放器,这使得在低版本浏览器下也可以获得与高级浏览器中一致的播放体验,而且它的插件非常之多,可以说你想要的功能一般都可以通过使用插件来解决,但是由于它的功能非常强大,这反而成了我不去使用它的原因,一方面 video.js 虽然强大,也就需要一定的学习成本,另外为了能让你的设计师朋友满意的话,你不得不得不去修改它的样式, 而改 video.js 的样式并不是那么灵活, 另一方面作为一个有追求的前端,我为何不自己开发一个视频播放器呢?

假如在两年前,我断然不会说出我要从零开始写一个 Web 视频播放器这种话,有两个原因,一是因为两年前的生产环境下还需要去兼容老版本的 IE 浏览器,使得你不得不去使用 Flash 来照顾那些比较怀旧的用户,另外一个原因是两年前我根本不会前端,这个笑话可能有点冷,但是真正想表达的是在现代浏览器下开发一个纯 HTML5 的 Web 视频播放器没有那么难。而我司的业务恰恰只需要兼容 IE11 以上的现代浏览器,所以可以不用考虑 Flash,一下省了不少事!

另外最近一段时间,如果访问国内的几个著名的视频网站可能都会发现除了 Flash 版本的浏览器外,还会提供纯 HTML5 版本的视频播放器,比如腾讯视频、优酷和哔哩哔哩等,特别的是哔哩哔哩还开源了 Flv.js ,一个利用 Media Source Extensions 实现了在 HTML5 视频中播放 FLV 格式视频的 JavaScript 库 ,使得众多 FLV 格式的视频可以在 HTML5 原生视频播放器上播放,我在做酷家乐直播页面时候就利用了这个库。查看上述几个网站的 HTML5 视频播放器的 DOM 结构,都会发现除了视频 video 元素本身是浏览器原生提供的,其他的播放器必不可少的控制栏上的功能模块都是由 DOM 模拟实现的,比如进度条、音量选择等。这些功能模块原生视频播放器上其实都已经提供,只不过它们在不同浏览器上表现各不一样。
分别长这样:(chrome)

这样: (edge)

以及这样: (Firefox)

一般来说,为了追求一致的用户体验和功能扩展不会在生产环境下使用原生的视频播放器,所以我们使用 DOM 模拟这些功能模块。

综上所述:实现一个视频播放器要做的就是,使用原生 video 元素做播放载体,然后为其写一套控制 UI 来控制 video 元素的状态。我们用一个完整的播放器例子来描述这一个过程。

开始实现

首先看下最终的效果:

我称它为 KuPlayer,正如前文所说,KuPlayer 和一些常见的 HTML5 播放器一样使用了原生的 video 元素,但是没有设置 controls, 这会使得浏览器仅仅提供一个不带控制栏的视频播放器,然后我们使用 DOM 模拟了播放器控制栏。
KuPlayer 在控制栏部分由以下几个功能组成:

  1. 播放/暂停
  2. 视频时长显示
  3. 音量控制
  4. 进度条部分 - 播放加载显示与进度显示
  5. 分辨率选择
  6. 全屏控制

首先看下大致的 DOM 结构:

参考上面的 DOM 结构,简单介绍一下这些 DOM 元素以及它们的功能。

  1. kp-media 就是这个 video 元素本身,
  2. kp-controls 是为其写的控制栏的父元素,其子元素对应控制栏的功能所在。
  • kp-play 和 kp-pause 分别是控制视频播放和暂停的按钮,
  • kp-progress 表示进度条
  • kp-time 表示时长显示
  • kp-volume 为视频音量控制
  • kp-resolution 为分辨率切换
  • kp-full 和 kp-cancelFull 用来切换视频全屏
    具体的 DOM 结构如何组织完全取决于业务所要求的功能,以上只是一个基本的示例。

接下来我会先从基础的样式开始,然后介绍其中的一些功能点是如何实现的,比如如何控制播放与暂停,如何改变视频音量大小以及如何实现全屏、如何显示正确的进度条(包括播放进度和缓冲进度)。其余的一些功能的实现方式大同小异,就不再一一介绍。

先来谈谈基础的样式

对于 video 元素,它的原始大小就是它正在播放的视频的大小。这里解释一下,意思就是它的可视画面永远都以它播放的视频为比例缩放,类似于给一个元素设置背景图片为 background-size: cover ,所以如果给 video 元素设置为 width:100%height:100% 的话,它不会发生想象中的被拉伸的样子,而是还是按照它原始的比例来表现,设置为 width:100%,height:100%width:100%,height:auto 表现如下所示:

可以看到,即便设置为 width:100%,height:100% ,还是表现为原始视频的比例大小显示,浏览器自动给了视频不可见区域一个纯黑色的背景,而不是像我曾经猜测的那样被过分的拉伸。
回顾刚才的 DOM 结构,可以看到,整个播放器 DOM 结构只有 Div 、Button、Input 三种元素。其中 Div 作为一些模块的包裹元素,Button 则为一些诸如播放与暂停按钮,那么 Input 元素是干什么的呢?

大家都知道作为点播播放器,应该有一个可以显示和选择进度的进度条,所以要有一个可以拖拽的元素,然后给它绑定事件,用来控制播放器的的进度和状态。一般来说设计师丢给我们的进度条大都长这样:

一个轨道状的横条,上面有个让人忍不住去拖拽的滑块,可以拖拽它然后改变当前播放进度,而我是一个懒人,所以我连写一个可以拖拽的小控件都懒得做,有没有原生的 DOM 元素可以利用呢?答案是有的,只需要将 Input 元素设置为 (type='range') 就可以做到。先来看下 Input Range 元素在浏览器中长的样子,我们以 chrome 浏览器为例(后续所有用来举例的浏览器皆为 chrome )。一个纯天然的 Input Range 元素在浏览器中长这样:

除了有点丑之外,它似乎满足了我的要求,那有什么办法可以改变它的样式呢? 经过一番搜索加尝试,它在我的手上被改造成了这样!下图中底下变蓝的滑块是 hover 状态。

方法很简单,其实就是分别针对它的 track 和 thumb 进行修改,所用伪类元素为

:-webkit-slider-runnable-track:-webkit-slider-thumb ,跟修改浏览器原生滚动条样式差不多,只不过令人惊喜的是, 各大浏览器对于这几个属性兼容的很好,不过你可能得针对每个浏览器都写一遍类似的伪类,如果使用 scss 或者 less 的话,用 mixin 来做,会很方便。比如下图所示:

值得注意的是 -webkit-appearance: none ,这条语句相当重要,它会使得浏览器抛弃它原生的样式, 这篇文章 详细介绍了它, 针对不同的浏览器,总有一些怪异性的差异,比如在 chrome 浏览器下必须将 slider-thumb 的 margin-top 值设置为 -4px, 否则滑块总是会往下偏移 4px, 这可能是浏览器给我们开了一个玩笑,但是却经常让前端工程师们很恼火,网上有很多类似的修改 Input Range元素样式的文章,这里就不再列举。这么一番折腾下来,样式是美观了,但是貌似没有比直接用 div 模拟一个滑动控件来的简单,其实不然,用伪类元素去修改适配各大浏览器确实比较繁琐,但是这是浏览器原生提供给开发者们的,使用原生天然的就比自己手动模拟要天经地义的许多,只是目前的浏览器差异性还很大,不过可以看作是一个憧憬,假如在某一天,我们不用针对各大浏览器写那么多伪类的话,我们就可以把精力花在更值得做的事情上了...

其他的一些元素的 css 怎么写,完全就是因视觉稿而异了,当然如果作为一个独立的播放器组件,开发过程中的一些好的行为可以使得后续维护起来更加方便,比如合理的类名、简洁的 DOM 层级、合理的 DOM 元素再加上必要的语义化等等 ,当然在这方面 KuPlayer 还有一些待优化的点,欢迎提出你的建议。

再来说说功能实现

首先打开 MDN 搜索一下 video 元素 ,你会看到独属于 video 元素的竟然有那么多的属性和事件,光是事件就多达 23 个!比如:abort 、 canplay 、ended 、error、 loadedmetadata 等。这些事件为 video 元素的内部事件,它的作用对象全部都是指它正在播放的视频本身。而前端工程师想要控制它的播放与暂停或者其他的一些状态,不能直接调用它内部的这些事件,但是可以监听这些事件然后实现一些特殊的效果,比如说当 error 和 stalled 事件发生时,应该监听到它们,然后告诉用户: ”对不起,您的网络情况不好,请刷新后重试!"。

作为一个只打算省事的前端,我能想到的最完美的实现就是利用好这些事件,事实上能做的也仅限如此。

首先创建一个KuPlayer的类

class KuPlayer {}

如何实现视频的播放与暂停

vide1o 元素对外提供了诸多方法,可以让开发者控制它的状态,比如 play 和 pause 方法可以控制视频的播放与暂停,如果调用了这两个方法,那么在 video 元素内部的 play 和 pause 事件也会随即触发,video 的播放状态也会随即改变,比如:当 pause() 方法被调用时候,这个时候获取 video 元素的 paused 属性会变成 true。
所以我们可以这么写(代码仅供参考)

class KuPlayer {
 constructor(){
 // 首先获取到媒体对象,指代 video 元素本身,下文所有video元素都以此代替。
 this.media = document.querySelector('.J_kpMedia');
 }
 // 然后是 play 和 pause 方法。 
 play(){
 // 执行原生play()方法
 this.media.play();
 // 这里显示播放器播放状态UI
 }
 pause(){
 // 执行原生pause()方法
 this.media.pause();
 // 这里显示播放器暂停状态UI
 }
}

就在我测试这两个看似简单的功能的时候,chrome 给了我一个惊喜:Console 中报了这个错误。

这是我在快速测试播放器播放与暂停的时候发现的,顺着它提示的网址进去,发现 chrome 已经告诉了我们为何会出现这一提示。

原来 this.media.play() 会返回一个 Promise ! 如果这个 Promise 函数被顺利执行,那么视频就会播放,同时 video 元素也会执行 playing 事件,出现这个错误提示的原因是,是在 this.media.play() 返回的异步操作( Promise ) 还未被 resolved 或 rejected 的时候,强行调用 this.media.pause() 方法中断了这一过程,使得视频本身没有被播放成功,所以控制台里提示了这一错误,而实际上,调用 pause() 方法并不是终断这一过程的唯一方法,像改变当前播放进度亦或者是替换视频的 src 等时候都会触发,所以说在网络不佳的情况下或者进行一些操作,比如用户频繁切换播放与暂停的时候,提示会不断的触发。

虽然这个错误更像是一个警告,即使报错也不会被用户所感知,不会影响整个播放体验,但是在控制台里一直在报错也是作为前端所不能忍受的,所以我们必须给 this.media.play() 方法返回的 Promise 添加一个错误处理。简单处理的话这样做就可以了,至少控制台不会再报错。

play() {
 // 返回一个promise
 const playPromise = this.media.play();
 if (playPromise !== undefined) {
 playPromise.catch(() => {
				//
 });
 }
 }

不过,在 playPromise.catch() 里,最适合的操作还是直接调用一下 this.pause() 方法,这使得在play() 事件出错的时候继续显示暂停状态。
由于我们的业务要求,当用户打开播放页的时候需要自动播放视频,但是当我在safari浏览器测试的时候,发现了另外一个惊喜:视频无法在 safari 播放器里自动播放,这会不会是被苹果禁止掉了?一番搜索后,果不其然,如这里所说 : Auto-Play Policy Changes for macOS ,safari 11 上已经禁止了视频自动播放,需要用户手动点击选择是否允许自动播放。而且据不可靠消息,苹果为了不一刀切,已经把诸如腾讯视频、优酷等11 家视频网站加入了白名单,可以允许他们的网页直接播放视频,但对于其他的网站,可就没那么好运了,不过苹果也贴心的告诉了开发人员,如何处理这一情况:

开发者可以主动调用 this.media.play() 事件,但由于会被 safari 禁止掉,所以 this.media.play() 返回的 Promise 会 rejected , 我的处理方法是在 catch 函数体内直接调用 this.pause() 方法,直接暂停掉播放,并且显示暂停状态的UI,问题解决了,只是无法给与 safari 用户打开页面即播放的体验。

如何改变视频音量大小

原生的 video 元素 volume 属性是存取器属性:既是 getter 也是 setter。 可以设置或返回视频的当前音量,而它的取值范围是0.0 - 1.0 (默认是1.0),所以直接调用

this.media.volume = 0.5;

就可以将当前视频音量设置为 0.5,这是控制视频本身的音量大小,但其实音量的过度范围以及间隔都是可以由开发者自行决定的,比如腾讯视频的播放器设置音量大小的范围是 0 - 100 ,实际上只不过做了比值替换,更改的还是 video 元素音量本身的 volume 大小。

可以设置音量大小,自然也可以直接设置静音,将当前 volume 的值设置为 0.0, 或者将 video 元素 的 muted 属性设置为 true( muted 属性是一个布尔属性,可以设置和返回视频是否被静音 )就可以将视频静音。
一旦更改了视频音量大小就会触发 [volumechange](https://developer.mozilla.org/en-US/docs/Web/Events/volumechange) 方法。

volumechange 方法在音频音量改变时触发(既可以是 volume 属性改变,也可以是 muted 属性改变)。

如何改变视频当前进度。

如同上面提到的 volume 属性 ,currentTime 也是存取器属性,它既是 [getter](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Functions/get) 也是 [setter ](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Functions/set) ,currentTime 可以设置或返回音频/视频播放的当前位置, 所以直接赋值给 this.media.currentTime 某个数值就可以改变视频进度了,只不过要搭配一些 UI 和交互的修改,下文会提到如何改变进度条。 一旦主动设置了 curentTime,就触发了 video 元素本身的 seeking 方法和 seeked 方法。

seeking 方法和 seeked 方法分别表示正在进行跳跃操作(更改播放时间点)和跳跃操作结束。

如何实现视频全屏

视频播放的全屏可以分为伪全屏和原生全屏两种来实现,其中伪全屏的实现方式是直接将当前要全屏的元素放大到窗口大小就可以了,爱奇艺就提供了视频播放时网页全屏的功能,不过它只会填充当前的浏览器窗口大小,无法填充整个屏幕的窗口大小,就我个人体验而言,填充整个屏幕所带来的沉浸式观看体验要比填充浏览器窗口更好一些,所以 KuPlayer 只提供了原生全屏,最终的效果如下图所示:视频会填满整个屏幕,并且会有原生提示 " 按下 ESC 键可以退出全屏模式 " ,实际效果等同于在
window 下按下 F11 键。

标准的调用全屏的方法是 Element.requestFullscreen , 该方法用于发出异步请求使元素进入全屏模式,不过考虑到浏览器兼容性,需要针对 webkit、moz、ms 等前缀的浏览器分别做兼容。
所以请求全屏的代码可以这么写(依然是仅供参考):

requestFullscreen(elem) {
 if (elem.requestFullscreen) {
 elem.requestFullscreen();
 } else if (elem.msRequestFullscreen) {
 elem.msRequestFullscreen();
 } else if (elem.mozRequestFullScreen) {
 elem.mozRequestFullScreen();
 } else if (elem.webkitRequestFullscreen) {
 elem.webkitRequestFullscreen();
 }
}

取消全屏的方法是 document.exitFullscreen ,不过有趣的是,exitFullscreen 虽然是 w3c 标准里的退出全屏的方法,但是在 chrome 和火狐里,却分别变成了 webkitCancelFullScreen 和 mozCancelFullScreen,可能是 chrome 觉得用【取消】比用【退出】更合适一点。
所以退出全屏的代码可以这么写:

cancelFullscreen(){
 if (document.exitFullscreen) {
 document.exitFullscreen();
 } else if (document.msExitFullscreen) {
 document.msExitFullscreen();
 } else if (document.mozCancelFullScreen) {
 document.mozCancelFullScreen();
 } else if(document.oRequestFullscreen){
 document.oCancelFullScreen();
 }else if (document.webkitExitFullscreen){
 document.webkitExitFullscreen();
 }
}

从上面两块代码可以看出,实现某一元素的全屏是调用它的 requestFullscreen 方法,比如 document.queryselector('.kp-video).requestFullscreen() ,而退出全屏却是直接对 document 触发,这是因为如果调用某个元素的 requestFullscreen 方法,那么这个元素会被扩展成全屏大小,其中包括它的子元素。而同一时刻内,全屏的元素只会有一个,所以取消的话直接调用 document.CancelFullScreen 就可以了。

那么我们还要根据是否全屏来定制一些效果,这个时候就在 document 对象上监听它的 fullscreenchange 事件了,无论是请求全屏还是取消全屏,这两种操作成功后,fullscreenchange 这个事件都会触发,然后我们在这个方法内利用 document.fullscreenElement 是否等于 null 来判断当前当前是否已经是全屏状态,然后可以自定义全屏状态下的样式。实际上,我们只能在再调用 fullscreenchange 事件触发后才能判断当前全屏状态,因为 requestFullscreen() 本身是一个异步请求,请求发出后,并不会立即触发全屏状态,而且既然是请求,会成功也就会失败,失败的 requestFullscreen 方法会触发 fullscreenerror 事件。

此外,实现全屏只能通过用户手动触发,也就是通过事件触发,而不能直接在代码里调用,所以不能实现打开页面即全屏的效果。

如何显示进度条

一个视频播放器应该让用户可以知道当前的视频播放进度与缓冲进度。
对于播放进度来说,无非就是获取当前的播放时间(currentTime)和视频总时长(duration),然后将这二者的比例使用元素和样式展示出来。

使用

const {currentTime, duration } = this.media

可以获取到视频的 currentTime 与 duration,然后进行简单的百分比计算:

const percent = `${currentTime/ duration * 100}%`

接着使用元素和样式来模拟进度条。可以简单的使用 div 然后设置它的宽度,也可以使用原生的 progress 元素,这里就又涉及到修改 progress 元素的样式修改了,如同上文针对 Input Range 元素的修改一样,需要针对各种浏览器写不同的样式, 这篇文章 中有详细的介绍,这里就不再赘述。

如何改变播放进度现在已经知道了,那么如何让进度条表现的顺畅自然呢?这个时候又得利用好原生
video 元素提供的一些事件了: timeupdate 触发的条件是当 currentTime 发生更新时。这正好契合我们展示进度条的需要,所以直接监听 timeupdate 事件,然后进行上述获取时长与计算比值的操作就可以了。不过进度的表现不仅是在视频正常播放的时候,如果用户进行 seek 操作时候也得相应的改变当前的播放进度,所以监听视频的 seeking 事件,然后继续调用改变播放进度的函数,也可以手动的在改变视频 currentTime 的时候调用。

缓冲进度的实现要稍微麻烦一点, 原因是描述当前视频加载状况的属性是 Buffered , 这个属性会告诉我们视频的哪一部分已经下载好了。它返回一个 TimeRanges 对象,用来表明视频中的哪些块已经下载完成 , 一个 TimeRanges 对象包含以下内容:

length 属性可以获得视频中已缓冲范围的数量。
调用 start(index) 方法可以获得某个已缓冲范围的开始位置。
调用 end(index) 方法可以获得某个已缓冲范围的结束位置。

如果播放过程从头到尾都没有被打断改变过,也就说没有额外操作,那么通常只有一个缓冲范围,所以计算缓冲区间可以直接使用以下几行代码:

const { buffered } = this.media;
const start = buffered.start(0);
const end = buffered.end(0);

所取得的 start 和 end 的差值就是缓冲区间,不过因为这是假设中的没有额外操作的情况,所以 start 约等于 0,所以我们要使用的区间长度就是 end 的实际的值。然后就如同播放进度条一样,使用元素模拟出来进度的变化就行。同理,可以监听原生 video 元素 的 progress 和 playing 事件来替我们自行更改缓冲状态,

playing 是在视频开始播放时触发(不论是初次播放、在暂停后恢复、或是在结束后重新开始)。
progress 告知媒体相关部分的下载进度时周期性地触发。有关媒体当前已下载总计的信息可以在元素的 buffered 属性中获取到。

但是,视频播放中避免不了的要进行 seek 操作,这个时候 TimeRanges 的 length 就不再是 1 了,每一次跳跃到一个未被缓冲过的视频区域都会产生一个新的缓冲区间,而这个缓冲区是由小到大排列的。
借用一下 MDN 中的一个原理图描述一下这个过程:

------------------------------------------------------------------------------
|=============|                   |===========|  |
------------------------------------------------------------------------------
0             5                   15         19 21

这表示两个缓冲时间范围——第一个跨越 0 到 5 秒,第二个跨越 15 到 19 秒。如果这时候将 currentTime 设置为 10 ,那么又会产生一个从 10 秒开始的缓冲区间,具体缓冲到 13 秒还是 14 秒这不得而知(实际测试中,缓冲到 15 秒的时候,会将 10 - 15 秒这个缓冲区间与 15 到 19 秒这个缓冲区间合二为一) 。这个时候就不能再取 start(0) - end(0) 为缓冲区间了,应该取的是以 10 为起点的缓冲区间,再计算所在的缓冲区间的索引值:index,利用 buffered.start(index) 来做缓冲区的起点。buffered.end(index) 来作为缓冲区的截止点,再取两者的差值作为当前的缓冲区间。

一些小技巧

通过上述几个具体功能的实现,可以发现原生 video 提供的很多属性都是存取器属性,为了在实例化后的对象中直接可以访问到暴露出这些属性,我们可以在类中写下以下代码。

class KuPlayer {
 constructor(){}
 
 ...
 
 get volume() {
 return this.media.volume;
 }

 set volume(value) {
 this.media.volume = value;
 }

 get muted() {
 return this.media.muted;
 }

 set muted(muted) {
 this.media.muted = muted;
 }

 get currentTime() {
 return this.media.currentTime;
 }
 ...
}

另外,不管是播放与暂停,或者更改音量和进度,又或者原生视频本身的一些诸如 loadedmetadata ( 媒体的元数据已经加载完毕,现在所有的属性包含了它们应有的有效信息 ) 、 error( 在发生错误时触发 )、ended( 在视频播放结束时触发 )等事件,种种事件发出后,有必要在实例化对象的方法中监听这些事件,为此我们引入 events 模块 ,调用 on 和 emit 方法监听 video 元素的内部事件。

import EventEmitter from 'events';
...
class KuPlayer {
 constructor(){
 // 首先获取到媒体对象,指代 video 元素本身。
 this.media = document.querySelector('.J_kpMedia');
 // 生成一个EventEmitter 的实例。
 this.emiter = new EventEmitter();
 }
 
 // 方法: 监听一个事件
 on(event, listener) {
 this.emiter.on(event, listener);
 }

 // 方法: 移除一个事件
 off(event, listener) {
 this.emiter.removeListener(event, listener);
 }

 // 方法: 触发一个事件
 emit(event, data) {
 this.emiter.emit(event, data);
 }
 
 // 方法: 播放视频
 play(){
 ...
 // 触发emiter 的 play 事件
 this.emit('play');
 }

 // 类方法:暂停视频
 pause(){
 ...
 // 触发emiter 的 pause 事件
 this.emit('pause');
 }
 
 ... 
}

实例化后可以直接使用 on 方法监听播放器内部事件 :

const kuPlayer = new KuPlayer();

// 监听 KuPlayer play 事件
kuPlayer.on('play', ()=>{
 // 埋点或者其他的一些操作
})

// 直接获取当前 currentTime
console.log(kuPlayer.currentTime)
...

小结

本文也许只介绍了 video 元素的冰山一角,还有许许多多的 video 元素原生事件与属性由于业务开发中没有涉及也就未多做研究,如果发现文章有错误的地方请联系 titian@qunhemail.com

希望这篇文章可以给想要开发 Web 视频播放器的同学一点启发,做出更惊艳与实用的视频播放器。
另外播放器的开发过程以及本文写作的过程中,参考了许多优秀的开源项目以及文章,其中包括: video.jsplyr.js 以及 MDN 等。

最后打个广告,有没有愿意来酷家乐工作的小伙伴呢?作为前端只需要兼容 IE 11 以上哦!我们欢迎优秀的你!

知识共享许可协议
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。