XCEL 是由凹凸實驗室推出的一個 Excel 數據清洗工具,其經過可視化的方式讓用戶輕鬆地對 Excel 數據進行篩選。javascript
XCEL 基於 Electron 和 Vue 2.x,它不只跨平臺(windows 7+、Mac 和 Linux),並且充分利用 Electron 多進程任務處理等功能,使其性能優異。css
落地頁:https://xcel.aotu.io/ ✨✨✨
項目地址:https://github.com/o2team/xcel ✨✨✨html
用戶研究的定量研究和輕量級數據處理中,均需對數據進行清洗處理,以剔除異常數據,保證數據結果的信度和效度。目前因調研數據和輕量級數據的多變性,對輕量級數據清洗每每採起人工清洗,缺乏統1、標準的清洗流程,但對於調研和輕量級的數據每每是須要保證數據穩定性的,所以,在對數據進行清洗時最好有標準化的清洗方式。前端
基於用研組的需求,利用 Electron 和 Vue 的特性對該工具進行開發。vue
紙上得來終覺淺,絕知此事要躬行java
若是對某項技術比較熟悉,則可略讀/跳過。node
Electron 是一個能夠用 JavaScript、HTML 和 CSS 構建桌面應用程序的庫。這些應用程序能打包到 Mac、Windows 和 Linux 系統上運行,也能上架到 Mac 和 Windows 的 App Store。git
一般來講,每一個操做系統的桌面應用都由各自的原生語言進行編寫,這意味着須要 3 個團隊分別爲該應用編寫相應版本。而 Electron 則容許你用 Web 語言編寫一次便可。github
Electron 結合了 Chromium、Node.js 和用於調用操做系統本地功能的 API(如打開文件窗口、通知、圖標等)。web
基於 Electron 的開發就像在開發網頁,並且可以無縫地 使用 Node。或者說:在構建一個 Node 應用的同時,經過 HTML 和 CSS 構建界面。另外,你只需爲一個瀏覽器(最新的 Chrome)進行設計(即無需考慮兼容性等)。
Electron 有兩種進程:『主進程』和『渲染進程』。部分模塊只能在二者之一上運行,而有些則無限制。主進程更多地充當幕後角色,而渲染進程則是應用程序的各個窗口。
注:可經過任務管理器(PC)/活動監視器(Mac)查看進程的相關信息。
dialog
模塊擁有全部原生 dialog 的 API,如打開文件、保存文件和警告等彈窗。主進程,一般是一個命名爲 main.js
的文件,該文件是每一個 Electron 應用的入口。它控制了應用的生命週期(從打開到關閉)。它既能調用原生元素,也能建立新的(多個)渲染進程。另外,Node API 是內置其中的。
渲染進程是應用的一個瀏覽器窗口。與主進程不一樣,它能存在多個(注:一個 Electron 應用只能存在一個主進程)而且相互獨立(它也能是隱藏的)。主窗口一般被命名爲 index.html
。它們就像典型的 HTML 文件,但 Electron 賦予了它們完整的 Node API。所以,這也是它與瀏覽器的區別。
Chrome(或其餘瀏覽器)的每一個標籤頁(tab)及其頁面,就比如 Electron 中的一個單獨渲染進程。即便關閉全部標籤頁,Chrome 依然存在。這比如 Electron 的主進程,能打開新的窗口或關閉這個應用。
注:在 Chrome 瀏覽器中,一個標籤頁(tab)中的頁面(即除了瀏覽器自己部分,如搜索框、工具欄等)就是一個渲染進程。
因爲主進程和渲染進程各自負責不一樣的任務,而對於須要協同完成的任務,它們須要相互通信。IPC就爲此而生,它提供了進程間的通信。但它只能在主進程與渲染進程之間傳遞信息(即渲染進程之間不能進行直接通信)。
Electron 應用就像 Node 應用,它也依賴一個 package.json
文件。該文件定義了哪一個文件做爲主進程,並所以讓 Electron 知道從何啓動應用。而後主進程能建立渲染進程,並能使用 IPC 讓二者間進行消息傳遞。
至此,Electron 的基礎部分介紹完畢。該部分是基於筆者以前翻譯的一篇文章《Essential Electron》,譯文可點擊 這裏。
該工具使用了 Vue、Vuex、Vuex-router。在工具基本定型階段,由 1.x 升級到了 2.x。
對於筆者來講:
Vue 1.x -> Vue 2.0 的版本遷移用 vue-migration-helper 便可分析出大部分須要更改的地方。
網上已有不少關於 Vue 的教程,故在此再也不贅述。至此,Vue 部分介紹完畢。
該庫支持各類電子表格格式的解析與生成。它由 JavaScript 實現,適用於前端和 Node。詳情>>
目前支持讀入的格式有(不斷更新):
支持寫出的格式有:
目前該庫提供的 sheet_to_json
方法能將讀入的 Excel 數據轉爲 JSON 格式。而對於導出操做,咱們須要爲 js-xlsx 提供指定的 JSON 格式。
更多關於 Excel 在 JavaScript 中處理的知識可查看凹凸實驗室的《Node讀寫Excel文件探究實踐》。但該文章存在兩處問題(均在 js-xlsx 實戰的導出表格部分):
String.fromCharCode(65+j)
生成。當列大於 26 時會出現問題。這個問題會在後面章節中給出解決方案;原來的:
var result = 某數組.reduce((prev, next) => Object.assign({}, prev, {[next.position]: {v: next.v}}), {});
改成:
var result = 某數組.forEach((v, i) => data[v.position]= {v: v.v})
實踐是檢驗真理的惟一標準
在理解上述知識後,下面就談談在該項目實踐中總結出來的技巧、難點和重點。
Excel 單元格採用 table
標籤展現。在 Excel 中,被選中的單元格會高亮相應的『行』和『列』,以提醒用戶。在該應用中也有作相應的處理,橫向高亮採用 tr:hover
實現,而縱向呢?這裏所採用的一個技巧是:
假設 HTML 結構以下:
div.container table tr td
CSS 代碼以下:
.container { overflow:hidden; } td { position: relative; } td:hover::after { position: absolute; left: 0; right: 0; top: -1個億px; // 小目標達成,不過是負的😭 bottom: -1個億px; z-index: -1; // 避免遮住自身和同列 td 的內容、border 等 }
如圖:
分割線能夠經過 ::after/::before
僞類元素實現一條直線,而後經過 transform:rotate();
旋轉特定角度實現。但這種實現的一個問題是:因爲寬度是不定的,所以須要經過 JavaScript 運算才能獲得準確的對角分割線。
所以,這裏能夠經過 CSS 線性漸變 linear-gradient(to top right, transparent, transparent calc(50% - .5px), #d3d6db calc(50% - .5px), #d3d6db calc(50% + .5px), transparent calc(50% + .5px))
實現。不管寬高如何變,依然妥妥地自適應。
26 列
時就會產生問題(如:第 27
列,String.fromCharCode(65+26)
獲得的是 [
,而不是 AA
)。所以,這須要經過『十進制和 26 進制轉換』算法來實現。// 將傳入的天然數轉換爲26進製表示。映射關係:[0-25] -> [A-Z]。 function getCharCol(n) { let temCol = '', s = '', m = 0 while (n >= 0) { m = n % 26 + 1 s = String.fromCharCode(m + 64) + s n = (n - m) / 26 } return s }
// 將傳入的26進制轉換爲天然數。映射關係:[A-Z] ->[0-25]。 function getNumCol(s) { if (!s) return 0 let n = 0 for (let i = s.length - 1, j = 1; i >= 0; i--, j *= 26) { let c = s[i].toUpperCase() if (c < 'A' || c > 'Z') return 0 n += (c.charCodeAt() - 64) * j } return n - 1 }
Electron 爲 File 對象額外增了 path 屬性,該屬性可獲得文件在文件系統上的真實路徑。所以,你能夠利用 Node 隨心所欲😈。應用場景有:拖拽文件後,經過 Node 提供的 File API 讀取文件等。
Electron 應用在 MacOS 中默認不支持『複製』『粘貼』等常見編輯功能,所以須要爲 MacOS 顯式地設置複製粘貼等編輯功能的菜單欄,併爲此設置相應的快捷鍵。
// darwin 就是 MacOS if (process.platform === 'darwin') { var template = [{ label: 'FromScratch', submenu: [{ label: 'Quit', accelerator: 'CmdOrCtrl+Q', click: function() { app.quit(); } }] }, { label: 'Edit', submenu: [{ label: 'Undo', accelerator: 'CmdOrCtrl+Z', selector: 'undo:' }, { label: 'Redo', accelerator: 'Shift+CmdOrCtrl+Z', selector: 'redo:' }, { type: 'separator' }, { label: 'Cut', accelerator: 'CmdOrCtrl+X', selector: 'cut:' }, { label: 'Copy', accelerator: 'CmdOrCtrl+C', selector: 'copy:' }, { label: 'Paste', accelerator: 'CmdOrCtrl+V', selector: 'paste:' }, { label: 'Select All', accelerator: 'CmdOrCtrl+A', selector: 'selectAll:' }] }]; var osxMenu = menu.buildFromTemplate(template); menu.setApplicationMenu(osxMenu); }
Electron 的一個缺點是:即便你的應用是一個簡單的時鐘,但它也不得不包含完整的基礎設施(如 Chromium、Node 等)。所以,通常狀況下,打包後的程序至少會達到幾十兆(根據系統類型進行浮動)。當你的應用越複雜,就越能夠忽略文件體積問題。
衆所周知,頁面的渲染不免會致使『白屏』,並且這裏採用了 Vue 這類框架,狀況就更加糟糕了。另外,Electron 應用也避免不了『先打開瀏覽器,再渲染頁面』的步驟。下面提供幾種方法來減輕這種狀況,以讓程序更貼近原生應用。
對於第一點,若應用的背景不是純白(#fff
)的,那麼可指定窗口的背景顏色與其一致,以免渲染後的突變。
mainWindow = new BrowserWindow({ title: 'XCel', backgroundColor: '#f5f5f5', };
對於第二點,因爲 Electron 本質是一個瀏覽器,須要加載非網頁部分的資源。所以,咱們能夠先隱藏窗口。
var mainWindow = new BrowserWindow({ title: 'ElectronApp', show: false, };
等到渲染進程開始渲染頁面的那一刻,在 ready-to-show
的回調函數中顯示窗口。
mainWindow.on('ready-to-show', function() { mainWindow.show(); mainWindow.focus(); });
對於第三點,筆者並無實現,緣由以下:
其實現方式,可參考《4 must-know tips for building cross platform Electron apps》。
在渲染進程中調用本來專屬於主進程中的 API (如彈框)的方式有兩種:
ipcMain
進行監聽,而後在渲染進程經過 ipcRenderer
進行觸發;對於第二種方式,在渲染進程中,運行如下代碼便可:
const remote = require('electron').remote remote.dialog.showMessageBox({ type: 'question', buttons: ['不告訴你', '沒有夢想'], defaultId: 0, title: 'XCel', message: '你的夢想是什麼?' }
若是 Electron 應用沒有提供自動更新功能,那麼就意味着用戶想體驗新開發的功能或用上修復 Bug 後的新版本,只能靠用戶本身主動地去官網下載,這無疑是糟糕的體驗。Electron 提供的 autoUpdater 模塊可實現自動更新功能,該模塊提供了第三方框架 Squirrel 的接口,但 Electron 目前只內置了 Squirrel.Mac,且它與 Squirrel.Windows(須要額外引入)的處理方式也不一致(在客戶端與服務器端兩方面)。所以若是對該模塊不熟悉,處理起來會相對比較繁瑣。具體能夠參考筆者的另外一篇譯文《Electron 自動更新的完整教程(Windows 和 OSX)》。
目前 Electron 的 autoUpdater 模塊不支持 Linux 系統。
另外,XCel 目前並無採用 autoUpdater 模塊實現自動更新功能,而是利用 Electron 的 DownloadItem 模塊實現,而服務器端則採用了 Nuts。
經過 electron-builder 可直接生成常見的 MacOS 安裝包,但它生成的 Windows 的安裝包卻略顯簡潔(默認選項時)。
Mac 常見的安裝模式,將「左側的應用圖標」拖拽到「右側的 Applications」便可
經過 electron-builder 生成的 Windows 安裝包與咱們在 Windows 上常見的軟件安裝界面不太同樣,它沒有安裝嚮導和點擊「下一步」的按鈕,只有一個安裝時的 gif 動畫(默認的 gif 動畫以下圖,固然你也能夠指定特定的 gif 動畫),所以也就關閉了用戶選擇安裝路徑等權利。
Windows 安裝時 默認顯示的 gif 動畫
若是你想爲打包後的 Electron 應用(即經過 electron-packager/electron-builder 生成的,可直接運行的程序目錄)生成擁有點擊「下一步」按鈕和可以讓用戶指定安裝路徑的常見安裝包,能夠嘗試 NSIS 程序,具體可看這篇教程 《[教學]只要10分鐘學會使用 NSIS 包裝您的桌面軟體–安裝程式打包。徹底免費。》。
注:electron-builder 也提供了生成安裝包的配置項,具體查看>>。
NSIS(Nullsoft Scriptable Install System)是一個開源的 Windows 系統下安裝程序製做程序。它提供了安裝、卸載、系統設置、文件解壓縮等功能。正如其名字所描述的那樣,NSIS 是經過它的腳本語言來描述安裝程序的行爲和邏輯的。NSIS 的腳本語言和常見的編程語言有相似的結構和語法,但它是爲安裝程序這類應用所設計的。
至此,CSS、JavaScript 和 Electron 相關的知識和技巧部分闡述完畢。
下面談談『性能優化』,這部分涉及到運行效率和內存佔用量。
注:如下內容均基於 Excel 樣例文件(數據量爲:1913 行 x 180 列)得出的結論。
Vue 一直標榜着本身性能優異,但當數據量上升到必定量級時(如 1913 x 180 ≈ 34 萬個數據單元),會出現嚴重的性能問題(未作相應優化的前提下)。
如直接經過列表渲染 v-for
渲染數據時,會致使程序卡死。
答:經過查閱相關資料可得, v-for
在初次渲染時,須要對每一個子項進行初始化(如數據綁定等操做,以便擁有更快的更新速度),這對於數據量較大時,無疑會形成嚴重的性能問題。
當時,我想到了兩種解決思路:
最終,我選擇了第二條,理由是:
將本來繁重的 DOM 操做(Vue)轉換爲 JavaScript 的拼接字符串後,性能獲得了很大提高(不會致使程序卡死而渲染不出視圖)。這種優化方式難道不就是 Vue、React 等框架解決的問題之一嗎?只不過框架考慮的場景更廣,有些地方須要咱們本身根據實際狀況進行優化而已。
在瀏覽器當中,JavaScript 的運算在現代的引擎中很是快,但 DOM 自己是很是緩慢的東西。當你調用原生 DOM API 的時候,瀏覽器須要在 JavaScript 引擎的語境下去接觸原生的 DOM 的實現,這個過程有至關的性能損耗。因此,本質的考量是,要把耗費時間的操做盡可能放在純粹的計算中去作,保證最後計算出來的須要實際接觸真實 DOM 的操做是最少的。 —— 《Vue 2.0——漸進式前端解決方案》
固然,因爲 JavaScript 天生單線程,即便執行數速度再快,也不免會致使頁面有短暫的時間拒絕用戶的輸入。此時可經過 Web Worker 或其它方式解決,這也將是咱們後續講到的問題。
也有網友提供了優化大量列表的方法:https://clusterize.js.org/。但在此案例中筆者並無採用此方式。
將拼接的字符串插入 DOM 後,出現了另一個問題:滾動會很卡。猜測這是渲染問題,畢竟 34 萬個單元格同時存在於界面中。
添加 transform: translate3d(0, 0, 0) / translateZ(0)
屬性啓動 GPU 渲染,便可解決這個渲染性能問題。再次感嘆該屬性的強大。🐂
後來,考慮到用戶並不須要查看所有數據,只需展現部分數據讓用戶進行參考便可。咱們對此只渲染前 30/50 行數據。這樣便可提高用戶體驗,也能進一步優化性能。
另外,因爲本身學藝不精和粗枝大葉,忘記在生產環境關閉 Vuex 的『嚴格模式』。
Vuex 的嚴格模式要在生產環境中關閉,不然會對 state 樹進行一個深觀察 (deep watch),產生沒必要要的性能損耗。也許在數據量少時,不會注意到這個問題。
還原當時的場景:導入 Excel 數據後,再進行交互(涉及 Vuex 的讀寫操做),須要等幾秒纔會響應,而直接經過純 DOM 監聽的事件則無此問題。由此,判斷出是 Vuex 問題。
const store = new Vuex.Store({ // ... strict: process.env.NODE_ENV !== 'production' })
前面說道,JavaScript 天生單線程,即便再快,對於數據量較大時,也會出現拒絕響應的問題。所以須要 Web Worker 或相似的方案去解決。
在這裏我不選擇 Web worker 的緣由有以下幾點:
Electron 做者在 2014.11.7 在《state of web worker support?》 issue 中回覆瞭如下這一段:
Node integration doesn't work in web workers, and there is no plan to do. Workers in Chromium are implemented by starting a new thread, and Node is not thread safe. Back in past we had tried to add node integration to web workers in Atom, but it crashed too easily so we gave up on it.
所以,咱們最終採用了建立一個新的渲染進程 background process
進行處理數據。由 Electron 章節可知,每一個 Electron 渲染進程是獨立的,所以它們不會互相影響。但這也帶來了一個問題:它們不能相互通信?
錯!下面有 3 種方式進行通信:
background process
是 B,那麼 A 先將 Excel 數據傳遞到主進程,而後主進程再轉發到 B。B 處理完後再原路返回,具體以下圖。固然,也能夠將數據存儲在主進程中,而後在多個渲染進程中使用 remote 模塊來訪問它。該工具採用了第三種方式的第一種狀況:
一、主頁面渲染進程 A 的代碼以下:
//① ipcRenderer.send('filter-start', { filterTagList: this.filterTagList, filterWay: this.filterWay, curActiveSheetName: this.activeSheet.name }) // ⑥ 在某處接收 filter-response 事件 ipcRenderer.on("filter-response", (arg) => { // 獲得處理數據 })
二、做爲中轉站的主進程的代碼以下:
//② ipcMain.on("filter-start", (event, arg) => { // webContents 用於渲染和控制 web page backgroundWindow.webContents.send("filter-start", arg) }) // ⑤ 用於接收返回事件 ipcMain.on("filter-response", (event, arg) => { mainWindow.webContents.send("filter-response", arg) })
三、處理繁重數據的 background process
渲染進程 B 的代碼以下:
// ③ ipcRenderer.on('filter-start', (event, arg) => { // 進行運算 ... // ④ 運算完畢後,再經過 IPC 原路返回。主進程和渲染進程 A 也要創建相應的監聽事件 ipcRenderer.send('filter-response', { filRow: tempFilRow }) })
至此,咱們將『讀取文件』、『過濾數據』和『導出文件』三大耗時的數據操做均轉移到了 background process
中處理。
這裏,咱們只建立了一個 background process
,若是想要作得更極致,咱們能夠新建『CPU 線程數- 1 』 個的 background process
同時對數據進行處理,而後在主進程對處理後數據進行拼接,最後再將拼接後的數據返回到主頁面的渲染進程。這樣就能夠充分榨乾 CPU 了。固然,在此筆者不會進行這個優化。
不要爲了優化而優化,不然得不償失。 —— 某網友
解決了執行效率和渲染問題後,發現也存在內存佔用量過大的問題。當時猜想是如下幾個緣由:
background process
處理。在通信傳遞數據的過程當中,因爲不是共享內存(由於 IPC 是基於 Socket 的),致使出現多份數據副本(在寫這篇文章時纔有了這相對確切的答案)。null
,而後等待 GC 回收。因爲 Chromium 採用多進程架構,所以會涉及到進程間通訊問題。Browser 進程在啓動 Render 進程的過程當中會創建一個以 UNIX Socket 爲基礎的 IPC 通道。有了 IPC 通道以後,接下來 Browser 進程與 Render 進程就以消息的形式進行通訊。咱們將這種消息稱爲 IPC 消息,以區別於線程消息循環中的消息。
——《Chromium的IPC消息發送、接收和分發機制分析》
定義:爲了易於理解,如下『Excel 數據』均指 Excel 的所有有效單元格轉爲 JSON 格式後的數據。
最容易處理的無疑是第三點,手動將再也不須要的變量及時設置爲 null
,但效果並不明顯。
後來,經過操做系統的『活動監視器』(Windows 上是任務管理器)對該工具的每階段(打開時、導入文件時、篩選時和導出時)進行粗略的內存分析,獲得如下報告:
---------------- S:報告分割線 ----------------
經觀察,主要耗內存的是頁面渲染進程。下面經過截圖說明:
PID 15243
是主進程
PID 15246
是頁面渲染進程
PID 15248
是 background 渲染進程
a、首次啓動程序時(第 4 行是主進程;第 1 行是頁面渲染進程;第 3 行是 background 渲染進程 )
b、導入文件(第 5 行是主進程;第 2 行是頁面渲染進程;第 4 行是 background 渲染進程 )
c、篩選數據(第 4 行是主進程;第 1 行是頁面渲染進程;第 3 行是 background 渲染進程 )
因爲 JavaScript 目前不具備主動回收資源的功能,因此只能主動將對象設置爲 null
,而後等待 GC 回收。
所以,通過一段時間等待後,內存佔用以下:
d、一段時間後(第 4 行是主進程;第 1 行是頁面渲染進程;第 3 行是 background 渲染進程 )
由上述可得,頁面渲染進程因爲頁面元素和 Vue 等 UI 相關資源是固定的,佔用內存較大且不能回收。主進程佔用資源也不能獲得很好釋放,暫時不知道緣由,而 background 渲染進程則較好地釋放資源。
---------------- E:報告分割線 ----------------
根據報告,初步得出的結論是 Vue 和通信時佔用資源較大。
根據該工具的實際應用場景:Excel 數據只在『導入』和『過濾後』兩個階段須要展現,並且展現的是經過 JavaScript 拼接的 HTML 字符串所構成的 DOM 而已。所以將表格數據放置在 Vuex 中,有點濫用資源的嫌疑。
另外,在 background process
中也有存有一份 Excel 數據副本。所以,索性只在 background process
存儲一份 Excel 數據,而後每當數據變化時,經過 IPC 讓 background process
返回拼接好的 HTML 字符串便可。這樣一來,內存佔有量馬上降低許多。另外,這也是一個一舉多得的優化:
background process
,頁面渲染進程進一步減小耗時的操做;其實,這也有點像 Vuex 的『全局單例模式管理』,一份數據就好。
固然,對於 Excel 的基本信息,如行列數、SheetName、標題組等均依然保存在 Vuex。
優化後的內存佔有量以下圖。與上述報告的第三張圖相比(同一階段),內存佔有量降低了 44.419%:
另外,對於不須要響應的數據,可經過 Object.freeze()
凍結起來。這也是一種優化手段。但該工具目前並無應用到。
至此,優化部分也闡述完畢了!
該工具目前是開源的,歡迎你們使用或推薦給用研組等有須要的人。
大家的反饋(可提交 issues / pull request)能讓這個工具在使用和功能上不斷完善。
最後,感謝 LV 在產品規劃、界面設計和優化上的強力支持。全文完!