想必不少「投身於教育行業」的前端工程師們都繞不過「課件」這個話題,對於前端來講,課件項目是教育公司相比互聯網公司特有的需求之一,對於公司來講也是及其重要的。目前教育行業我瞭解到的生產 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
。