首先,咱们须要实现页面布局,在根目录建立 index.html
布局中咱们须要有一个 video
多媒体标签引入咱们的本地视频,添加输入弹幕的输入框、确认发送的按钮、颜色选择器、字体大小滑动条,建立一个 style.css
来调整页面布局的样式,这里咱们顺便建立一个 index.js
文件用于后续实现咱们的核心逻辑,先引入到页面当中。css
HTML 布局代码以下:html
<!-- 文件:index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="style.css"> <title>视频弹幕</title> </head> <body> <div id="cantainer"> <h2>Canvas + WebSocket + Redis 实现视频弹幕</h2> <div id="content"> <canvas id="canvas"></canvas> <video id="video" src="./barrage.mp4" controls></video> </div> <!-- 输入弹幕内容 --> <input type="text" id="text"> <!-- 添加弹幕按钮 --> <button id="add">发送</button> <!-- 选择文字颜色 --> <input type="color" id="color"> <!-- 调整字体大小 --> <input type="range" max="40" min="20" id="range"> </div> <script src="./index.js"></script> </body> </html>
CSS 样式代码以下:前端
/* 文件:style.css */ #cantainer { text-align: center; } #content { width: 640px; margin: 0 auto; position: relative; } #canvas { position: absolute; } video { width: 640px; height: 360px; } input { vertical-align: middle; }
布局效果以下图:web
咱们弹幕中的弹幕数据正常状况下应该是经过与后台数据交互请求回来,因此咱们须要先定义数据接口,并构造假数据来实现前端逻辑。redis
数据字段定义:数据库
上面的 value
和 time
是必填参数,其余的选填参数能够在前端设置默认值。npm
前端定义的假数据以下:编程
// 文件:index.js let data = [ { value: "这是第一条弹幕", speed: 2, time: 0, color: "red", fontSize: 20 }, { value: "这是第二条弹幕", time: 1 } ];
咱们但愿是把弹幕封装成一个功能,只要有须要的地方就可使用,从而实现复用,那么不一样的地方使用这个功能一般的方式是 new
一个实例,传入当前使用该功能对应的参数,咱们也使用这种方式来实现,因此咱们须要封装一个统一的构造函数或者类,参数为当前的 canvas
元素、video
元素和一个 options
对象,options
里面的 data
属性为咱们的弹幕数据,之因此不直接传入 data
是为了后续参数的扩展,严格遵循开放封闭原则,这里咱们就统一使用 ES6 的 class
类来实现。canvas
布局时须要注意 Canvas 的默认宽为 300px
,高为 150px
,咱们要保证 Canvas 彻底覆盖整个视频,须要让 Canvas 与 video
宽高相等。
由于咱们不肯定每个使用该功能的视频的宽高都是同样的,因此 Canvas 画布的宽高并无经过 CSS 来设置,而是经过 JS 在类建立实例初始化属性的时候动态设置。后端
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是没有传入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 当前的 canvas 元素 this.video = video; // 当前的 video 元素 // 设置 canvas 与 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默认暂停播放,表示不渲染弹幕 this.isPaused = true; // 没传参数的默认值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 对象的合并,将默认参数对象的属性和传入对象的属性统一放到当前实例上 Object.assign(this, defaultOptions, options); } }
应该挂在实例上的属性除了有当前的 canvas
元素、video
元素、弹幕数据的默认选项以及弹幕数据以外,还应该有一个表明当前是否渲染弹幕的参数,由于视频暂停的时候,弹幕也是暂停的,因此没有从新渲染,由于是否暂停与弹幕是否渲染的状态是一致的,因此咱们这里就用 isPaused
参数来表明当前是否暂停或从新渲染弹幕,值类型为布尔值。
咱们知道,后台返回给咱们的弹幕数据是一个数组,这个数组里的每个弹幕都是一个对象,而对象上有着这条弹幕的信息,若是咱们须要在每个弹幕对象上再加一些新的信息或者在每个弹幕对象的处理时用到了当前弹幕功能类 CanvasBarrage
实例的一些属性值,取值显然是不太方便的,这样为了后续方便扩展,遵循开放封闭原则,咱们把每个弹幕的对象转变成同一个类的实例,因此咱们建立一个名为 Barrage
的类,让咱们每一条弹幕的对象进入这个类里面走一遭,挂上一些扩展的属性。
// 文件:index.js class Barrage { constructor(item, ctx) { this.value = item.value; // 弹幕的内容 this.time = item.time; // 弹幕出现的时间 this.item = item; // 每个弹幕的数据对象 this.ctx = ctx; // 弹幕功能类的执行上下文 } }
在咱们的 CanvasBarrage
类上有一个存储弹幕数据的数组 data
,此时咱们须要给 CanvasBarrage
增长一个属性用来存放 “加工” 后的每条弹幕对应的实例。
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是没有传入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 当前的 canvas 元素 this.video = video; // 当前的 video 元素 // 设置 canvas 与 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默认暂停播放,表示不渲染弹幕 this.isPaused = true; // 没传参数的默认值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 对象的合并,将默认参数对象的属性和传入对象的属性统一放到当前实例上 Object.assign(this, defaultOptions, options); // ********** 如下为新增代码 ********** // 存放全部弹幕实例,Barrage 是创造每一条弹幕的实例的类 this.barrages = this.data.map(item => new Barrage(item, this)); // ********** 以上为新增代码 ********** } }
其实经过上面操做之后,咱们至关于把 data
里面的每一条弹幕对象转换成了一个 Barrage
类的一个实例,把当前的上下文 this
传入后能够随时在每个弹幕实例上获取 CanvasBarrage
类实例的属性,也方便咱们后续扩展方法,遵循这种开放封闭原则的方式开发,意义是不言而喻的。
CanvasBarrage
的 render
方法是在建立弹幕功能实例的时候应该渲染 Canvas 因此应该在 CanvasBarrage
中调用,在 render
内部,每一次渲染以前都应该先将 Canvas 画布清空,因此须要给当前的 CanvasBarrage
类新增一个属性用于存储 Canvas 画布的内容。
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是没有传入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 当前的 canvas 元素 this.video = video; // 当前的 video 元素 // 设置 canvas 与 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默认暂停播放,表示不渲染弹幕 this.isPaused = true; // 没传参数的默认值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 对象的合并,将默认参数对象的属性和传入对象的属性统一放到当前实例上 Object.assign(this, defaultOptions, options); // 存放全部弹幕实例,Barrage 是创造每一条弹幕的实例的类 this.barrages = this.data.map(item => new Barrage(item, this)); // ********** 如下为新增代码 ********** // Canvas 画布的内容 this.context = canvas.getContext("2d"); // 渲染全部的弹幕 this.render(); // ********** 以上为新增代码 ********** } // ********** 如下为新增代码 ********** render() { // 渲染整个弹幕 // 第一次先进行清空操做,执行渲染弹幕,若是没有暂停,继续渲染 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); // 渲染弹幕 this.renderBarrage(); if (this.isPaused == false) { // 递归渲染 requestAnimationFrame(this.render.bind(this)); } } // ********** 以上为新增代码 ********** }
在上面的 CanvasBarrage
的 render
函数中,清空时因为 Canvas 性能比较好,因此将整个画布清空,因此从坐标 (0, 0)
点,清空的宽高为整个 Canvas 画布的宽高。
只要视频是在播放状态应该不断的调用 render
方法实现清空画布、渲染弹幕、判断是否暂停,若是非暂停状态继续渲染,因此咱们用到了递归调用 render
去不断的实现渲染,可是递归时若是直接调用 render
,性能特别差,程序甚至会挂掉,以往这种状况咱们会在递归外层加一个 setTimeout
来定义一个短暂的递归时间,可是这个过程相似于动画效果,若是使用 setTimeout
实际上是将同步代码转成了异步执行,会增长不肯定性致使画面出现卡顿的现象。
这里咱们使用 H5 的新 API requestAnimationFrame
,能够在平均 1/60 S
内帮我执行一次该方法传入的回调,咱们直接把 render
函数做为回调函数传入 requestAnimationFrame
,该方法是按照帧的方式执行,动画流畅,须要注意的是,render
函数内使用了 this
,因此应该处理一下 this
指向问题。
因为咱们使用面向对象的方式,因此渲染弹幕的具体细节,咱们抽离出一个单独的方法 renderBarrage
,接下来看一下 renderBarrage
的实现。
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是没有传入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 当前的 canvas 元素 this.video = video; // 当前的 video 元素 // 设置 canvas 与 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默认暂停播放,表示不渲染弹幕 this.isPaused = true; // 没传参数的默认值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 对象的合并,将默认参数对象的属性和传入对象的属性统一放到当前实例上 Object.assign(this, defaultOptions, options); // 存放全部弹幕实例,Barrage 是创造每一条弹幕的实例的类 this.barrages = this.data.map(item => new Barrage(item, this)); // Canvas 画布的内容 this.context = canvas.getContext("2d"); // 渲染全部的弹幕 this.render(); } render() { // 渲染整个弹幕 // 第一次先进行清空操做,执行渲染弹幕,若是没有暂停,继续渲染 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); // 渲染弹幕 this.renderBarrage(); if (this.isPaused == false) { // 递归渲染 requestAnimationFrame(this.render.bind(this)); } } // ********** 如下为新增代码 ********** renderBarrage() { // 将数组的弹幕一个一个取出,判断时间和视频的时间是否符合,符合就执行渲染此弹幕 let time = this.video.currentTime; this.barrages.forEach(barrage => { // 当视频时间大于等于了弹幕设置的时间,那么开始渲染(时间都是以秒为单位) if (time >= barrage.time) { // 初始化弹幕的各个参数,只有在弹幕将要出现的时候再去初始化,节省性能,初始化后再进行绘制 // 若是没有初始化,先去初始化一下 if (!barrage.isInited) { // 初始化后下次再渲染就不须要再初始化了,因此建立一个标识 isInited barrage.init(); barrage.isInited = true; } } }); } // ********** 以上为新增代码 ********** }
此处的 renderBarrage
方法内部主要对每一条弹幕实例所设置的出现时间和视频的播放时间作对比,若是视频的播放时间大于等于了弹幕出现的时间,说明弹幕须要绘制在 Canvas 画布内。
以前咱们的每一条弹幕实例的属性可能不全,弹幕的其余未传参数并无初始化,因此为了最大限度的节省性能,咱们在弹幕该第一次绘制的时候去初始化参数,等到视频播放的时间变化再去从新绘制时,再也不初始化参数,因此初始化参数的方法放在了判断弹幕出现时间的条件里面执行,又设置了表明弹幕实例是否是初始化了的参数 isInited
,初始化函数 init
执行过一次后,立刻修改 isInited
的值,保证只初始化参数一次。
在 renderBarrage
方法中咱们能够看出来,其实咱们是循环了专门存放每一条弹幕实例(Barrage
类的实例)的数组,咱们在内部用实例去调用的方法 init
应该是在 Barrage
类的原型上,下面咱们去 Barrage
类上实现 init
的逻辑。
// 文件:index.js class Barrage { constructor(item, ctx) { this.value = item.value; // 弹幕的内容 this.time = item.time; // 弹幕出现的时间 this.item = item; // 每个弹幕的数据对象 this.ctx = ctx; // 弹幕功能类的执行上下文 } // ********** 如下为新增代码 ********** init() { this.opacity = this.item.opacity || this.ctx.opacity; this.color = this.item.color || this.ctx.color; this.fontSize = this.item.fontSize || this.ctx.fontSize; this.speed = this.item.speed || this.ctx.speed; // 求本身的宽度,目的是用来校验当前是否还要继续绘制(边界判断) let span = document.createElement("span"); // 能决定宽度的只有弹幕的内容和文字的大小,和字体,字体默认为微软雅黑,咱们就不作设置了 span.innerText = this.value; span.style.font = this.fontSize + 'px "Microsoft YaHei'; // span 为行内元素,取不到宽度,因此咱们经过定位给转换成块级元素 span.style.position = "absolute"; document.body.appendChild(span); // 放入页面 this.width = span.clientWidth; // 记录弹幕的宽度 document.body.removeChild(span); // 从页面移除 // 存储弹幕出现的横纵坐标 this.x = this.ctx.canvas.width; this.y = this.ctx.canvas.height; // 处理弹幕纵向溢出的边界处理 if (this.y < this.fontSize) { this.y = this.fontSize; } if (this.y > this.ctx.canvas.height - this.fontSize) { this.y = this.ctx.canvas.height - this.fontSize; } } // ********** 以上为新增代码 ********** }
在上面代码的 init
方法中咱们其实能够看出,每条弹幕实例初始化的时候初始的信息除了以前说的弹幕的基本参数外,还获取了每条弹幕的宽度(用于后续作弹幕是否已经彻底移出屏幕的边界判断)和每一条弹幕的 x
和 y
轴方向的坐标并为了防止弹幕在 y
轴显示不全作了边界处理。
咱们当时在 CanvasBarrage
类的 render
方法中的渲染每一个弹幕的方法 renderBarrage
中(原谅这么啰嗦,由于到如今内容已经比较多,说的具体一点方便知道是哪一个步骤,哈哈)只作了对每一条弹幕实例的初始化操做,并无渲染在 Canvas 画布中,这时咱们主要作两部操做,实现每条弹幕渲染在画布中和左侧移出屏幕再也不渲染的边界处理。
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是没有传入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 当前的 canvas 元素 this.video = video; // 当前的 video 元素 // 设置 canvas 与 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默认暂停播放,表示不渲染弹幕 this.isPaused = true; // 没传参数的默认值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 对象的合并,将默认参数对象的属性和传入对象的属性统一放到当前实例上 Object.assign(this, defaultOptions, options); // 存放全部弹幕实例,Barrage 是创造每一条弹幕的实例的类 this.barrages = this.data.map(item => new Barrage(item, this)); // Canvas 画布的内容 this.context = canvas.getContext("2d"); // 渲染全部的弹幕 this.render(); } render() { // 渲染整个弹幕 // 第一次先进行清空操做,执行渲染弹幕,若是没有暂停,继续渲染 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); // 渲染弹幕 this.renderBarrage(); if (this.isPaused == false) { // 递归渲染 requestAnimationFrame(this.render.bind(this)); } } renderBarrage() { // 将数组的弹幕一个一个取出,判断时间和视频的时间是否符合,符合就执行渲染此弹幕 let time = this.video.currentTime; this.barrages.forEach(barrage => { // ********** 如下为改动的代码 ********** // 当视频时间大于等于了弹幕设置的时间,那么开始渲染(时间都是以秒为单位) if (!barrage.flag && time >= barrage.time) { // ********** 以上为改动的代码 ********** // 初始化弹幕的各个参数,只有在弹幕将要出现的时候再去初始化,节省性能,初始化后再进行绘制 // 若是没有初始化,先去初始化一下 if (!barrage.isInited) { // 初始化后下次再渲染就不须要再初始化了,因此建立一个标识 isInited barrage.init(); barrage.isInited = true; } // ********** 如下为新增代码 ********** barrage.x -= barrage.speed; barrage.render(); // 渲染该条弹幕 if (barrage.x < barrage.width * -1) { barrage.flag = true; // 是否出去了,出去了,作中止渲染的操做 } // ********** 以上为新增代码 ********** } }); } }
每一个弹幕实例都有一个 speed
属性,该属性表明着弹幕移动的速度,换个说法其实就是每次减小的 x
轴的差值,因此咱们实际上是经过改变 x
轴的值再从新渲染而实现弹幕的左移,咱们建立了一个标识 flag
挂在每一个弹幕实例下,表明是否已经离开屏幕,若是离开则更改 flag
的值,使外层的 CanvasBarrage
类的 render
函数再次递归时不进入渲染程序。
每一条弹幕具体是怎么渲染的,经过代码能够看出每一个弹幕实例在 x
坐标改变后都调用了实例方法 render
函数,注意此 render
非彼 render
,该 render
函数属于 Barrage
类,目的是为了渲染每一条弹幕,而 CanvasBarrage
类下的 render
,是为了在视频时间变化时清空并从新渲染整个 Canvas 画布。
// 文件:index.js class Barrage { constructor(item, ctx) { this.value = item.value; // 弹幕的内容 this.time = item.time; // 弹幕出现的时间 this.item = item; // 每个弹幕的数据对象 this.ctx = ctx; // 弹幕功能类的执行上下文 } init() { this.opacity = this.item.opacity || this.ctx.opacity; this.color = this.item.color || this.ctx.color; this.fontSize = this.item.fontSize || this.ctx.fontSize; this.speed = this.item.speed || this.ctx.speed; // 求本身的宽度,目的是用来校验当前是否还要继续绘制(边界判断) let span = document.createElement("span"); // 能决定宽度的只有弹幕的内容和文字的大小,和字体,字体默认为微软雅黑,咱们就不作设置了 span.innerText = this.value; span.style.font = this.fontSize + 'px "Microsoft YaHei'; // span 为行内元素,取不到宽度,因此咱们经过定位给转换成块级元素 span.style.position = "absolute"; document.body.appendChild(span); // 放入页面 this.width = span.clientWidth; // 记录弹幕的宽度 document.body.removeChild(span); // 从页面移除 // 存储弹幕出现的横纵坐标 this.x = this.ctx.canvas.width; this.y = this.ctx.canvas.height; // 处理弹幕纵向溢出的边界处理 if (this.y < this.fontSize) { this.y = this.fontSize; } if (this.y > this.ctx.canvas.height - this.fontSize) { this.y = this.ctx.canvas.height - this.fontSize; } } // ********** 如下为新增代码 ********** render() { this.ctx.context.font = this.fontSize + 'px "Microsoft YaHei"'; this.ctx.context.fillStyle = this.color; this.ctx.context.fillText(this.value, this.x, this.y); } // ********** 以上为新增代码 ********** }
从上面新增代码咱们能够看出,其实 Barrage
类的 render
方法只是将每一条弹幕的字号、颜色、内容、坐标等属性经过 Canvas 的 API 添加到了画布上。
还记得咱们的 CanvasBarrage
类里面有一个属性 isPaused
,属性值控制了咱们是否递归渲染,这个属性与视频暂停的状态是一致的,咱们在播放的时候,弹幕不断的清空并从新绘制,当暂停的时候弹幕也应该跟着暂停,说白了就是不在调用 CanvasBarrage
类的 render
方法,其实就是在暂停、播放的过程当中不断的改变 isPaused
的值便可。
还记得咱们以前构造的两条假数据 data
吧,接下来咱们添加播放、暂停事件,来尝试使用一下咱们的弹幕功能。
// 文件:index.js // 实现一个简易选择器,方便获取元素,后面获取元素直接调用 $ const $ = document.querySelector.bind(document); // 获取 Canvas 元素和 Video 元素 let canvas = $("#canvas"); let video = $("#video"); let canvasBarrage = new CanvasBarrage(canvas, video, { data }); // 添加播放事件 video.addEventListener("play", function() { canvasBarrage.isPaused = false; canvasBarrage.render(); }); // 添加暂停事件 video.addEventListener("pause", function() { canvasBarrage.isPaused = true; });
// 文件:index.js $("#add").addEventListener("click", function() { let time = video.currentTime; // 发送弹幕的时间 let value = $("#text").value; // 发送弹幕的文字 let color = $("#color").value; // 发送弹幕文字的颜色 let fontSize = $("#range").value; // 发送弹幕的字体大小 let sendObj = { time, value, color, fontSize }; //发送弹幕的参数集合 canvasBarrage.add(sendObj); // 发送弹幕的方法 });
其实咱们发送弹幕时,就是向 CanvasBarrage
类的 barrages
数组里添加了一条弹幕的实例,咱们单独封装了一个 add
的实例方法。
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是没有传入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 当前的 canvas 元素 this.video = video; // 当前的 video 元素 // 设置 canvas 与 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默认暂停播放,表示不渲染弹幕 this.isPaused = true; // 没传参数的默认值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 对象的合并,将默认参数对象的属性和传入对象的属性统一放到当前实例上 Object.assign(this, defaultOptions, options); // 存放全部弹幕实例,Barrage 是创造每一条弹幕的实例的类 this.barrages = this.data.map(item => new Barrage(item, this)); // Canvas 画布的内容 this.context = canvas.getContext("2d"); // 渲染全部的弹幕 this.render(); } render() { // 渲染整个弹幕 // 第一次先进行清空操做,执行渲染弹幕,若是没有暂停,继续渲染 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); // 渲染弹幕 this.renderBarrage(); if (this.isPaused == false) { // 递归渲染 requestAnimationFrame(this.render.bind(this)); } } renderBarrage() { // 将数组的弹幕一个一个取出,判断时间和视频的时间是否符合,符合就执行渲染此弹幕 let time = this.video.currentTime; this.barrages.forEach(barrage => { // 当视频时间大于等于了弹幕设置的时间,那么开始渲染(时间都是以秒为单位) if (!barrage.flag && time >= barrage.time) { // 初始化弹幕的各个参数,只有在弹幕将要出现的时候再去初始化,节省性能,初始化后再进行绘制 // 若是没有初始化,先去初始化一下 if (!barrage.isInited) { // 初始化后下次再渲染就不须要再初始化了,因此建立一个标识 isInited barrage.init(); barrage.isInited = true; } barrage.x -= barrage.speed; barrage.render(); // 渲染该条弹幕 if (barrage.x < barrage.width * -1) { barrage.flag = true; // 是否出去了,出去了,作中止渲染的操做 } } }); } // ********** 如下为新增代码 ********** add(item) { this.barrages.push(new Barrage(item, this)); } // ********** 以上为新增代码 ********** }
其实咱们发现,弹幕虽然实现了正常的播放、暂停以及发送,可是当咱们拖动进度条的时候弹幕应该是跟着视频时间同步播放的,如今的弹幕一旦播放过不管怎样拉动进度条弹幕都不会再出现,咱们如今就来解决这个问题。
// 文件:index.js // 拖动进度条事件 video.addEventListener("seeked", function() { canvasBarrage.reset(); });
咱们在事件内部其实只是调用了一下 CanvasBarrage
类的 reset
方法,这个方法就是在拖动进度条的时候来帮咱们初始化弹幕的状态。
// 文件:index.js class CanvasBarrage { constructor(canvas, video, options = {}) { // 若是没有传入 canvas 或者 video 直接跳出 if (!canvas || !video) return; this.canvas = canvas; // 当前的 canvas 元素 this.video = video; // 当前的 video 元素 // 设置 canvas 与 video 等高 this.canvas.width = video.clientWidth; this.canvas.height = video.clientHeight; // 默认暂停播放,表示不渲染弹幕 this.isPaused = true; // 没传参数的默认值 let defaultOptions = { fontSize: 20, color: "gold", speed: 2, opacity: 0.3, data: [] }; // 对象的合并,将默认参数对象的属性和传入对象的属性统一放到当前实例上 Object.assign(this, defaultOptions, options); // 存放全部弹幕实例,Barrage 是创造每一条弹幕的实例的类 this.barrages = this.data.map(item => new Barrage(item, this)); // Canvas 画布的内容 this.context = canvas.getContext("2d"); // 渲染全部的弹幕 this.render(); } render() { // 渲染整个弹幕 // 第一次先进行清空操做,执行渲染弹幕,若是没有暂停,继续渲染 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); // 渲染弹幕 this.renderBarrage(); if (this.isPaused == false) { // 递归渲染 requestAnimationFrame(this.render.bind(this)); } } renderBarrage() { // 将数组的弹幕一个一个取出,判断时间和视频的时间是否符合,符合就执行渲染此弹幕 let time = this.video.currentTime; this.barrages.forEach(barrage => { // 当视频时间大于等于了弹幕设置的时间,那么开始渲染(时间都是以秒为单位) if (!barrage.flag && time >= barrage.time) { // 初始化弹幕的各个参数,只有在弹幕将要出现的时候再去初始化,节省性能,初始化后再进行绘制 // 若是没有初始化,先去初始化一下 if (!barrage.isInited) { // 初始化后下次再渲染就不须要再初始化了,因此建立一个标识 isInited barrage.init(); barrage.isInited = true; } barrage.x -= barrage.speed; barrage.render(); // 渲染该条弹幕 if (barrage.x < barrage.width * -1) { barrage.flag = true; // 是否出去了,出去了,作中止渲染的操做 } } }); } add(item) { this.barrages.push(new Barrage(item, this)); } // ********** 如下为新增代码 ********** reset() { // 先清空 Canvas 画布 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); let time = this.video.currentTime; // 循环每一条弹幕实例 this.barrages.forEach(barrage => { // 更改已经移出屏幕的弹幕状态 barrage.flag = false; // 当拖动到的时间小于等于当前弹幕时间是,从新初始化弹幕的数据,实现渲染 if (time <= barrage.time) { barrage.isInited = false; } else { barrage.flag = true; // 不然将弹幕的状态设置为以移出屏幕 } }); } // ********** 以上为新增代码 ********** }
其实 reset
方法中值作了几件事:
从而实现了拖动进度条弹幕的 “前进” 和 “后退” 功能。
要使用 WebSocket 和 Redis 首先须要去安装 ws
、redis
依赖,在项目根目录执行下面命令:
npm install ws redis
咱们建立一个 server.js
文件,用来写服务端的代码:
// 文件:index.js const WebSocket = require("ws"); // 引入 WebSocket const redis = require("redis"); // 引入 redis // 初始化 WebSocket 服务器,端口号为 3000 let wss = new WebSocket.Server({ port: 3000 }); // 建立 redis 客户端 let client = redis.createClient(); // key value // 原生的 websocket 就两个经常使用的方法 on('message')、on('send') wss.on("connection", function(ws) { // 监听链接 // 链接上须要当即把 redis 数据库的数据取出返回给前端 client.lrange("barrages", 0, -1, function(err, applies) { // 因为 redis 的数据都是字符串,因此须要把数组中每一项转成对象 applies = applies.map(item => JSON.parse(item)); // 使用 websocket 服务器将 redis 数据库的数据发送给前端 // 构建一个对象,加入 type 属性告诉前端当前返回数据的行为,并将数据转换成字符串 ws.send( JSON.stringify({ type: "INIT", data: applies }) ); }); // 当服务器收到消息时,将数据存入 redis 数据库 ws.on("message", function(data) { // 向数据库存储时存的是字符串,存入并打印数据,用来判断是否成功存入数据库 client.rpush("barrages", data, redis.print); // 再将当前这条数据返回给前端,一样添加 type 字段告诉前端当前行为,并将数据转换成字符串 ws.send( JSON.stringify({ type: "ADD", data: JSON.parse(data) }) ); }); });
服务器的逻辑很清晰,在 WebSocket 链接上时,当即获取 Redis 数据库的全部弹幕数据返回给前端,当前端点击发送弹幕按钮发送数据时,接收数据存入 Redis 数据库中并打印验证数据是否成功存入,再经过 WebSocket 服务把当前这一条数返回给前端,须要注意一下几点:
JSON.parse
方法进行解析;JSON.stringify
从新转换成字符串发送;type
值告诉前端,当前的操做行为。在没有实现后端代码以前,前端使用的是 data
的假数据,是在添加弹幕事件中,将获取的新增弹幕信息经过 CanvasBarrage
类的 add
方法直接建立 Barrage
类的实例,并加入到存放弹幕实例的 barrages
数组中。
如今咱们须要更正一下交互逻辑,在发送弹幕事件触发时,咱们应该先将获取的单条弹幕数据经过 WebSocket 发送给后端服务器,在服务器从新将消息返还给咱们的时候,去将这条数据经过 CanvasBarrage
类的 add
方法加入到存放弹幕实例的 barrages
数组中。
还有在页面初始化时,咱们以前在建立 CanvasBarrage
类实例的时候直接传入了 data
假数据,如今须要经过 WebSocket 的链接事件,在监听到链接 WebSocket 服务时,去建立 CanvasBarrage
类的实例,并直接把服务端返回 Redis 数据库真实的数据做为参数传入,前端代码修改以下:
// 文件:index.js // ********** 下面代码被删掉了 ********** // let canvasBarrage = new CanvasBarrage(canvas, video, { // data // }); // ********** 上面代码被删掉了 ********** // ********** 如下为新增代码 ********** let canvasBarrage; // 建立 WebSocket 链接 let socket = new WebSocket("ws://localhost:3000"); // 监听链接事件 socket.onopen = function() { // 监听消息 socket.onmessage = function(e) { // 将收到的消息从字符串转换成对象 let message = JSON.parse(e.data); // 根据不一样状况判断是初始化仍是发送弹幕 if (message.type === "INIT") { // 建立 CanvasBarrage 的实例添加弹幕功能,传入真实的数据 canvasBarrage = new CanvasBarrage(canvas, video, { data: message.data }); } else if (message.type === "ADD") { // 若是是添加弹幕直接将 WebSocket 返回的单条弹幕存入 barrages 中 canvasBarrage.add(message.data); } }; }; // ********** 以上为新增代码 ********** $("#add").addEventListener("click", function() { let time = video.currentTime; // 发送弹幕的时间 let value = $("#text").value; // 发送弹幕的文字 let color = $("#color").value; // 发送弹幕文字的颜色 let fontSize = $("#range").value; // 发送弹幕的字体大小 let sendObj = { time, value, color, fontSize }; //发送弹幕的参数集合 // ********** 如下为新增代码 ********** socket.send(JSON.stringify(sendObj)); // ********** 以上为新增代码 ********** // ********** 下面代码被删掉了 ********** // canvasBarrage.add(sendObj); // 发送弹幕的方法 // ********** 上面代码被删掉了 ********** });
如今咱们能够打开 index.html
文件并启动 server.js
服务器,就能够实现真实的视频弹幕操做了,可是咱们仍是差了最后一步,当前的服务只能同时服务一我的,但真实的场景是同时看视频的有不少人,并且发送的弹幕是共享的。
咱们须要处理两件事情:
// 文件:server.js const WebSocket = require("ws"); // 引入 WebSocket const redis = require("redis"); // 引入 redis // 初始化 WebSocket 服务器,端口号为 3000 let wss = new WebSocket.Server({ port: 3000 }); // 建立 redis 客户端 let client = redis.createClient(); // key value // ********** 如下为新增代码 ********** // 存储全部 WebSocket 用户 let clientsArr = []; // ********** 以上为新增代码 ********** // 原生的 websocket 就两个经常使用的方法 on('message')、on('send') wss.on("connection", function(ws) { // ********** 如下为新增代码 ********** // 将全部经过 WebSocket 链接的用户存入数组中 clientsArr.push(ws); // ********** 以上为新增代码 ********** // 监听链接 // 链接上须要当即把 redis 数据库的数据取出返回给前端 client.lrange("barrages", 0, -1, function(err, applies) { // 因为 redis 的数据都是字符串,因此须要把数组中每一项转成对象 applies = applies.map(item => JSON.parse(item)); // 使用 websocket 服务器将 redis 数据库的数据发送给前端 // 构建一个对象,加入 type 属性告诉前端当前返回数据的行为,并将数据转换成字符串 ws.send( JSON.stringify({ type: "INIT", data: applies }) ); }); // 当服务器收到消息时,将数据存入 redis 数据库 ws.on("message", function(data) { // 向数据库存储时存的是字符串,存入并打印数据,用来判断是否成功存入数据库 client.rpush("barrages", data, redis.print); // ********** 如下为修改后的代码 ********** // 循环数组,将某一我的新发送的弹幕在存储到 Redis 以后返回给全部用户 clientsArr.forEach(w => { // 再将当前这条数据返回给前端,一样添加 type 字段告诉前端当前行为,并将数据转换成字符串 w.send( JSON.stringify({ type: "ADD", data: JSON.parse(data) }) ); }); // ********** 以上为修改后的代码 ********** }); // ********** 如下为新增代码 ********** // 监听关闭链接事件 ws.on("close", function() { // 当某一我的关闭链接离开时,将这我的从当前存储用户的数组中移除 clientsArr = clientsArr.filter(client => client != ws); }); // ********** 以上为新增代码 ********** });
上面就是 Canvas + WebSocket + Redis 视频弹幕的实现,实现过程可能有些复杂,但整个过程写的仍是比较详细,可能须要必定的耐心慢慢的读完,并最好一步一步跟着写一写,但愿这篇文章可让读到的人解决视频弹幕相似的需求,真正理解整个过程和开放封闭原则,认识到前端面向对象编程思想的美。