想必不少“投身于教育行业”的前端工程师们都绕不过“课件”这个话题,对于前端来讲,课件项目是教育公司相比互联网公司特有的需求之一,对于公司来讲也是及其重要的。目前教育行业我了解到的生产 h5 课件的方式大体分为如下三种,每种方式也是各有优劣,下面是个人理解:css
有时候对于团队来讲,三种方式不是互斥的,大部分状况三种方式是并行的,会根据内容的类型、复杂度等方面去折中选择。对于咱们团队来讲,其实也是三种共存的,不过大部份内容生产使用第三种方式,下面我来给你们介绍一下励步课件的技术体系。前端
介绍技术以前,首先我来简单介绍下咱们课件使用的业务场景:webpack
其次咱们再来结合业务看下咱们要面临的技术难点:web
那么结合以上的业务场景和遇到的困难,我来给你们一一介绍咱们的处理方式,在此以前为了让你们更好的理解,我先放几张咱们总体的功能模块图以及功能演示图。数据库
功能模块图:canvas
课件编辑器:后端
课件播放器:性能优化
目前咱们的编辑器大体能够分为如下几大模块的功能:微信
互动系统:这其中还分为事件、动画、题型三个模块。websocket
那么在实现以上功能的时候,有几个关键的技术点与你们分享。
相信若是你也作过相似的产品,在初期作技术调研的时候必定也会在 Canvas
和 Dom
之间纠结,实际上用两种方式均可以实现这类功能,市面上也都有成功的案例,但咱们在开始以前还要针对咱们的业务场景来综合评估,首先咱们来梳理一下这两种方式的优缺点:
首先对于 Canvas 来讲,
优势
缺点
其次对于 Dom 来讲,
优势
缺点
那么结合他们各自的优缺点,咱们并无单纯的选取某一种方案,而是把两者结合起来,也就是说两种咱们都用了!下面咱们来介绍是如何结合使用的。
回头再来看一下咱们的元件系统,咱们能够分为两类,一类是 Canvas 支持的,另外一类是不支持的,想必你们也猜到了,对于 Canvas 不支持的元件咱们使用了 Dom,总结一下:
Canvas
的元件(画布元素):文本、图形、图片(静态图片)Dom
的元件(外挂元素):音频、视频、Gif 图、iframe那么两者在同一个画布上是怎么结合起来的呢?借助下面这个截图来解释一下
原理其实不难,若是我要添加一个外挂元素(音视频、gif、iframe)在画布上,那么在编辑的时候我会把它当作图片来处理,也就是说,用一个图片来在 Canvas 上作占位,咱们能够在画布上随意缩放,旋转等,其次我还会同步渲染一个 video
元素盖在画布上层,而且把 canvas 元素的属性翻译成 css 属性,下面列出段伪代码:
/** * klass:充当外挂元素的画布元素 * * */ export function getHtmlElement(klass, i, option = {}, evn = 'editor') { const basicStyle = { display: klass.visible ? 'block' : 'none', position: 'absolute', transform: `rotate( ${klass.angle}deg )`, ...klass.getBoundingRect(), // ... 其余公共属性 }; switch (klass.type) { case 'gif': if (klass.angle) { const canvasZoom = this.canvas.getZoom(); const width = (klass.width + klass.strokeWidth) * canvasZoom * klass.scaleX + 1; const height = (klass.height + klass.strokeWidth) * canvasZoom * klass.scaleY + 1; Object.assign(basicStyle, { left: klass.left * canvasZoom - width / 2, top: klass.top * canvasZoom - height / 2, width: width, height: height }); } Object.assign(basicStyle, { pointerEvents: 'none' }); return ( } />) ); case 'video': return( <video style={Object.assign(basicStyle, { width: basicStyle.width + 1, height: basicStyle.height + 1 })} src={klass.videoUrl} // ...其余 video 属性 /> ) case 'iframe': return <iframe key={klass.id} className="el-iframe" src={klass.iframeUrl} style={basicStyle} />; case 'audio': // ... }
你们知道,对于这种富前端应用来讲,存储的数据会至关大。以咱们的课件系统举例,画布上每一个元素都会有 20-30 个属性,一页课件上可能会有数十甚至上百个元素,每一个课件大概会有 15-30 页不等,一讲课件产生的课件数据至少要在 1M 以上(课间数据是指对于课件的描述数据,好比元素的位置,课件的页码,题型等,不包括静态资源)。因此对于咱们来讲,如何组织这些数据变的尤其关键,组织很差会对后期维护以及性能形成很大的影响。
在设计数据存储结构以前,要考虑清楚目标,那么我在设计以前大体考虑了两点:
结合咱们的场景举个例子,咱们要执行下面一系列操做:
那么这个场景通常状况下咱们可能会把数据设计成这样:
const data = [ { id: "elementA", name: "圆形", left: 100, top: 100, event: { type: "click", target: { id: "elementB", // 其余属性 }, }, }, { id: "elementB", name: "方形", left: 100, top: 100, event: { type: "click", target: { id: "elementB", left: 150, top: 150, vfx: [ //动画数据 { name: "point1", left: 20, top: 20 }, { name: "point2", left: 50, top: 50 }, // ... ], }, }, // 其余属性 }, ];
这样若是数据量小的时候,是没有问题的,获取数据简单,方便咱们开发。但若是数据量多的时候,缺点就会突显:这种嵌套结构,会使数据量变大,层级过深不易维护。
真实状况咱们是这样处理的,相似数据库同样,咱们在前端设计了几张表:元件表,动画表,事件表,题型表等。 表与表之间用 id 作关联(主键),数据结构相似下面这样:
const data = { // 元件表 levelList: [{ id: "element1", name: "元件1",left: 10,top: 10}], // 动画表 vfxData: [{ id: "vfx1", target: "element1", path: [] }], // 事件表 actionData: [{ id: "action1", target: "element1", type: "click" }], // 题型表 activityData: [{ id: "activity1", target: "element1", source: 'element2' type: "fill" }], };
这样咱们能够更清晰的看到这页数据,都有哪些元件、动画、事件及题型,经过 id 关联也必定程度的减小了数据的大小(对于减小数据体积的问题,咱们在序列化的时候,还会过滤掉一些框架提供的无用属性)。固然这样作也是有缺点的,好比在删除某个元件的时候,咱们须要额外处理相关的表中的数据,这须要咱们在代码中封装出相应的方法。
在说播放器以前,仍是回顾一下上面那张图,咱们的播放器会在多个场景下使用,有线上课、线下课以及其余一些业务系统中。出于这些考虑,咱们把核心播放器抽离成了公共组件,每一个使用方在播放器组件的上层去作定制化的功能,那么下面咱们首先来讲这个核心播放器组件。
首先咱们来看下组件的调用方式很简单,相似这样:
<CourseWarePlayer defaultPage={pid} data={coursewareData} onPageChange={(page, currentData) => { this.setState({ currentPlayPage: page, notes: currentData.notes }); }} options={{ video: { controlsList: "nodownload", }, }} extraElements={ <Fragment> <ClassroomWrapper onClose={this.onToggleClassroom} scale={this.state.canvas.getZoom()} /> </Fragment> } onQuestionCommit={this.onQuestionCommit} // ... other props />
组件内部包含了数据处理,课件、题型、动画等课件内容的展现,以及答题结果展现、处理回调等功能。各个使用方在调用的时候只须要传入指定格式的课件数据,课件就能够渲染出来了。如下我来介绍几个与其相关的技术点。
实时互动的意思是老师和学生均可以操做课件,而且相互可以看到,通常实现这种需求有两种方式,一种是直接录屏直播,学生可以保证看到老师全部的交互,但若是想让学生和学生之间互动就比较难实现了;另一种是全部用户都打开课件,相似在线游戏,经过传递消息来实现同步,咱们目前使用的就是这种。
实现这功能,重点须要处理状态同步。 提及来容易作起来其实挺费劲的,细节比较多,列举几个问题,你们也能够思考如何实现:
除去后端相关的内容,咱们直接说课件端主要须要作哪些工做,说 3 点关键的功能点:
fabric.js
很容易捕获到,外挂元素就会稍微复杂一些了,主要针对音视频,须要咱们手动去绑定对应的事件了,好比 video.addEventListener('play', this.update);
id
,left
,top
数据fabric.js
中 API LoadFromJSON
能够实现,而外挂元素就须要咱们去手工封装音频、视频受控组件去处理了。此外还有答题步骤等,这里我就不详细描述了。迄今为止,咱们对于 iframe 以及 Gif 图的状态同步尚未实现,若是您有解决方案,期待您的不吝赐教。
前面有说过,性能对于播放器而言也是个比较大的挑战。对于性能优化,咱们的工做分为两部分:播放器组件和使用方。组件内部的优化点相对比较零散,网上前端性能优化的方案也不少,咱们基本上也是从那些方面作优化,我简单列举几点,这里我不详细介绍:
下面我重点来讲一下咱们的线下课离线方案。
离线,顾名思义就是不依赖网络能够正常播放课件,之因此要作离线主要出于两点考虑:
如何实现呢?咱们利用 Electron + 校区公盘实现了资源本地化的功能。 具体实现流程咱们来看下面这张图,包含了咱们资源整个的生命周期:用户上传-> 资源加密 -> 同步到校区公盘 -> 播放课件 -> 获取课件数据 -> 本地化资源 -> 资源解密 -> 渲染课件
。
基于这套方案咱们基本上能够作到课件本地化,以前测试过,课件中一个 150M 的视频文件,基本能够在 2 秒以内彻底缓冲完。
其实完整的课件系统还衍生出诸多周边辅助产品,咱们也不例外,会有不少辅助工具,好比:
以上就是我对于励步课件系统中比较重要的几个功能点的简介,但愿对你们有所帮助,其实还有不少细节问题一篇文章讲不清楚,若是有什么问题或者指导欢迎知音楼联系 郑庆鑫
,或者加微信 zqx362965772
。