這幾年來,一直在思考怎麼作出一款功能強大且配置簡易的原生JS表格組件。
爲此作了不少功能,也對這些功能作過多輪的優化以達到配置簡易的願景。javascript
而在開發過程當中,總有個繞不過去的坎: 框架模板沒法解析。html
這是一個什麼概念呢?vue
當在框架環境中渲染表格組件,這個表格內的模板只能使用原生JS,沒法使用任何框架特性。
這也就意味着,當在表格模板內使用框架組件時將沒法渲染。java
以下所示,經過模板配置一個Vue的Button組件是沒法渲染的。node
columnData: [ { key: '操做', template: <el-button @click="delRelation(row)">刪除</el-button> } ]
在框架滿天飛的如今,這是沒法忍受的。react
當時,看着滿屏的代碼,發呆了許久。
期間一直在思考使用哪一種方案來面向框架:git
從新開發基於框架的表格組件,須要同時維護多套代碼。
而在原生組件上進行改造,能夠實現一套代碼多框架運行。github
畢竟不管哪一種框架,都是源於JS。app
一旦選擇,那麼就堅持下去吧。雖然,誰都會知道中間的路很坎坷。
思路講起來很簡單,只是作出來須要不少調研工做,須要對各個框架有必定的熟識度,甚至在找不到解決方案時須要去閱讀框架源碼。框架
構思了一段時間後,一套只有兩個步驟的實踐方案就這麼設定了:
思路肯定以後,就該動手開工了。
三個框架雖有不一樣,但都提供瞭解析原生DOM或動態建立框架對像的方法:
在對框架進行支撐前,首先要對原生組件進行一些改造。
聲明一個容器,用於存儲待解析的模板。
// 存儲容器,基本格式: {'table-key': []} const compileMap = {}; // 獲取指定表格的存儲容器 const getCompileList = gridManagerName => { if (!compileMap[gridManagerName]) { compileMap[gridManagerName] = []; } return compileMap[gridManagerName]; };
收集原生表格中使用到的模板:
爲這些模板提供解析函數, 經過該函數生成不一樣框架的待解析模板,並存入compileMap等待解析。
// td模板解析函數 const compileTd = (settings, el, template, row, index, key) => { const { gridManagerName, compileAngularjs, compileVue, compileReact } = settings; const compileList = getCompileList(gridManagerName); // React and not template if (!template) { return row[key]; } // React element or React function // react 返回空字符串,將單元格內容交由react控制 if (compileReact) { compileList.push({el, template, row, index, key, type: 'template', fnArg: [row[key], row, index, key]}); return ''; } // 解析框架: Angular 1.x || Vue if (compileVue || compileAngularjs) { compileList.push({el, row, index, key}); } // not React // 非react時,返回函數執行結果 if (!compileReact) { return template(row[key], row, index, key); } }; // ... 其它模板的解析函數,大體上與td相似
在原生組件擁有模板解析函數後,還須要爲原生組件提供與各框架版本的通迅函數。
// 通迅函數: 與各框架模板解析勾子進行通迅,在特定時間調用 function sendCompile(settings, isRunElement) { const { gridManagerName, compileAngularjs, compileVue, compileReact } = settings; const compileList = getCompileList(gridManagerName); if (compileList.length === 0) { return; } if (isRunElement) { compileList.forEach((item, index) => { item.el = document.querySelector(`[${getKey(gridManagerName)}="${index}"]`); }); } // 解析框架: Vue if (compileVue) { await compileVue(compileList); } // 解析框架: Angular 1.x if (compileAngularjs) { await compileAngularjs(compileList); } // 解析框架: React if (compileReact) { await compileReact(compileList); } // ... 其它操做 }
到這裏,原生組件所須要的改造就大體完成了。接下來就該對原生組件進行框架包裝,用於支持各個框架的特性。
如下實現是基於Angular 1.x版本,2.x及以上版本不可用。
這個過程當中會用到Angular的兩個生命週期函數: $onInit()
和 $onDestroy
。
class GridManagerController { constructor($element, $compile, $gridManager) { this._$element = $element; this._$compile = $compile; this._$gridManager = $gridManager; } // 在Angular提供的`$onInit()`內對原生組件的初始化 $onInit() { // 獲取當前組件的DOM const table = this._$element[0].querySelector('table'); // 調用原生組件進行實例化 new this._$gridManager(table, this.option, query => { typeof(this.callback) === 'function' && this.callback({query: query}); }); } // 在`$onDestroy`內進行原生組件的銷燬。 $onDestroy() { // 銷燬實例 this._$gridManager.destroy(this.option.gridManagerName); } } GridManagerController.$inject = ['$element', '$compile', '$gridManager']; // 向angular聲明一個新的module const template = '<table></table>'; const GridManagerComponent = { controller, template, controllerAs: 'vm', bindings: { option: '<', callback: '&' } }; const gridManagerModuel = angular.module('gridManager', []); // 在這個module上註冊組件 gridManagerModuel .component('gridManager', GridManagerComponent) .value('$gridManager', $gridManager);
到這一步,就能夠在Angular環境中經過<grid-manager></grid-manager>
來建立一個angular表格組件了。
可是簡單的使用後,會發現有些事情還待解決:
一個不能用模板的表格組件,真是難以想像如何使用。因此接下來須要支持在模板函數內解析Angular模板,並讓解析後的模板支持Angular特性。
這是一個必需要解決的問題,否則開發過程當中表格中使用的模板只能使用原生js而不能使用框架特性。
嘗試了多種方法都不能徹底解決,因而閱讀了angular相關的文檔和源碼,並最終經過如下代碼實現。
// 在包裝組件的基礎上,對`$onInit()`函數進行改造 $onInit() { // 當前表格組件所在的域 const _parent = this._$scope.$parent; // 獲取當前組件的DOM const table = this._$element[0].querySelector('table'); // 模板解析勾子,這個勾子在原生組件內經過sendCompile進行觸發 this.option.compileAngularjs = compileList => { return new Promise(resolve => { compileList.forEach(item => { // 生成模板所須要的$scope, 併爲$scope賦予傳入的值 const elScope = _parent.$new(false); // false 不隔離父級 elScope.row = item.row; elScope.index = item.index; elScope.key = item.key; // 經過compile將dom解析爲angular對像 const content = this._$compile(item.el)(elScope); // 將生成的內容進行替換 item.el.replaceWith(content[0]); }); // 延時觸發angular 髒檢查 setTimeout(() => { _parent.$digest(); resolve(); }); }); }; // 調用原生組件進行實例化 new this._$gridManager(table, this.option, query => { typeof(this.callback) === 'function' && this.callback({query: query}); }); }
在compileAngularjs(compileList)
函數內接收原生組件傳遞的解析隊列。每一個解析對像中包含了如下基本信息:
columnData.key
值在解析勾子函數內,有兩個不經常使用到的方法。
因爲angular的雙向綁定特性,各模板內(th,td等)的angular代碼能夠感知數據的實時變動。
至此, angular版表格組件開發完畢。
如下實現是基於Vue 2.x版本,Vue3.x中未涉及。Vue與Angular同爲雙向綁定,也是須要實現組件包裝與模板解析兩塊功能。
const GridManagerVue = { name: 'GridManagerVue', props: { option: { type: Object, default: {}, }, callback: { type: Function, default: query => query, } }, template: '<table></table>', mounted: () => { // 調用原生組件進行實例化 new $gridManager(this.$el, this.option, query => { typeof(this.callback) === 'function' && this.callback(query); }); }, destroyed: () => { // 銷燬實例 $gridManager.destroy(this.option.gridManagerName); } } // Vue install, Vue.use 會調用該方法。 GridManagerVue.install = (Vue, opts = {}) => { // 將構造函數掛載至Vue原型上 // 這樣在Vue環境下,可在實例化對像this上使用 this.$gridManager 進行方法調用 Vue.prototype.$gridManager = $gridManager; Vue.component('grid-manager', GridManagerVue); }; // 經過script標籤引入Vue的環境 if (typeof window !== 'undefined' && window.Vue) { GridManagerVue.install(window.Vue); }
到這一步,就能夠在Vue環境中經過<grid-manager-vue></grid-manager-vue>
來建立一個Vue表格組件了。
與Angular相同,也是須要在包裝的基礎上解決Vue模板問題。
與angular不一樣,Vue的特性決定了這個過程更簡單。
// 在包裝組件的基礎上對`mounted()`生命週期函數進行改造 mounted() { const _parent = this.$parent; // 解析Vue 模版 this.option.compileVue = compileList => { return new Promise(resolve => { compileList.forEach(item => { const el = item.el; // 繼承父對像 methods: 用於經過this調用父對像的方法 const methodsMap = {}; for (let key in _parent.$options.methods) { methodsMap[key] = _parent.$options.methods[key].bind(_parent); } // 合併父對像 data const dataMap = { row: item.row, index: item.index }; Object.assign(dataMap, _parent.$data); // create new vue new Vue({ parent: _parent, el: el, data: () => dataMap, methods: methodsMap, template: el.outerHTML }); }); resolve(); }); }; // 調用原生組件進行實例化 new $gridManager(this.$el, this.option, query => { typeof(this.callback) === 'function' && this.callback(query); }); }
Vue提供的構建函數,讓一切變的如此簡單。
與Vue和Angular不周,React爲單向綁定。在進行組件包裝及模板解析的同時,還須要感知數據變動。
在這個過程當中須要使用到React的三個生命週期函數:componentDidUpdate()
,componentDidMount()
,componentWillUnmount()
// 在render中返回原生組件須要的DOM目標 class ReactGridManager extends React.Component{ constructor(props) { super(props); this.tableRef = React.createRef(); } render() { return ( <table ref={this.tableRef}/> ); } // 在componentDidMount中對原生組件進行實例化 componentDidMount() { const table = this.tableRef.current; new $gridManager(table, this.option, query => { typeof(this.callback) === 'function' && this.callback({query: query}); }); } // 在componentWillUnmount中對原生組件進行消毀 componentWillUnmount() { $gridManager.destroy(this.option.gridManagerName); } }
到這一步,就能夠在React環境中使用<GridManagerReact></GridManagerReact>
來建立一個React表格組件了。
接下來,在包裝的基礎上解決React模板問題。
與Angular和Vue相同,也是在實例化原生組件前提供勾子函數。
// 在包裝組件的基礎上對`componentDidMount()`生命週期函數進行改造 componentDidMount() { // 框架解析惟一值 const table = this.tableRef.current; this.option.compileReact = compileList => { return new Promise(resolve => { compileList.forEach(item => { const { row, el, template, fnArg = []} = item; let element = template(...fnArg); // reactElement if (React.isValidElement(element)) { // 若是當前使用的模塊(任何類型的)未使用組件或空標籤包裹時,會在生成的DOM節點上生成row=[object Object] element = React.cloneElement(element, {row, index: item.index, ...element.props}); } // string if (typeof element === 'string') { el.innerHTML = element; return; } if (!element) { return; } // dom if (element.nodeType === 1) { el.append(element); return; } ReactDOM.render( element, el ); }); resolve(); }); }; // 調用原生組件進行實例化 new $gridManager(table, this.option, query => { typeof(this.callback) === 'function' && this.callback({query: query}); }); }
雖然到了這一步後,組件已經支持了部分React特性,但因爲React從設計理念上與Angular、Vue不一樣,致使如下問題:
className丟失,也體現了React與Angular、Vue的不一樣。
Angular、Vue提供了組件在渲染時使留原始標籤的機制,這個機制能夠保留原標籤上的樣式及class屬性,以下所示:
// 渲染前的組件標籤 <grid-manager class="test-class"></grid-manager> // 渲染後的組件標籤 <grid-manager class="test-class"> <div class="table-wrap"> ... </div> </grid-manager>
而React在render中卻不會保留這個標籤,所以這個標籤上的屬性也都會丟失, 以下所示:
// 渲染前的組件標籤 render() { return <GridManagerReact className="test-class"></GridManagerReact> }
// 渲染後的組件標籤 <div class="table-wrap"> ... </div>
知道了緣由,問題就變的簡單了,只須要執行兩個操做:
componentDidMount
執行原生組件實例化時,將className回填至DOM節點。componentDidUpdate
執行更新時,感知到最後的className並回填至DOM節點。雖然經過組件包裝和模板解析,讓組件能夠在React環境運行且能夠正常解析jsx。
但因爲模板中使用的jsx因爲與外部隔着一層原生代碼,這就致使了被嵌套的jsx並沒有法感知外部state的變動。
爲了解決這個問題,須要從原生組件開始分析。
在實例化原生組件時,會爲每一個實例生成一個Settings
對像,這個對像存儲了當前實例的實時數據:
setting: { gridManagerName: 'test-table', rendered: true, width: "100%", height: "100%", columnData: [], columnMap: {} // columnMap存儲了當前th、td所使用的實時模板 }
觸發模板渲染時,所使用的數據都是從Settings
對像進行獲取,好比其中的columnMap就存儲了當前th、td所使用的實時模板。
因此,當感知到state變化後,去修改Settings
對像並觸發模板渲染便可實現與外部組件的數據交互。
首先,在原生組件內提供resetSettings
函數。
resetSettings(table, settings) { // ...調用內部的更新機制 }
而後,在React生命週期函數componentDidUpdate
內觸發更新
componentDidUpdate() { // 向原生組件獲取最新的實例數據 const settings = gridManager.get(this.option.gridManagerName); // ... 更新使用到React的模板 // 調用原生組件更新settings函數 $gridManager.resetSettings(this.tableRef.current, settings); // ...其它邏輯 }
至此,支持state的React版表格組件開發完畢。
寫到這裏,忽然腦中浮現了一張帶語音功能的圖片: "別和我說什麼Angular、Vue、React, 老夫就是jQuery一把唆"。
也說不出是曾幾什麼時候,jQuery忽然就從討論的話題中消失了。
在感慨技術更迭的同時,仍是把對jQuery的支持保留在了原生組件內。
// 大家喜歡的jQuery調用方式,能夠直接進行實例化 $('table').GridManager(arg); // 固然,也能夠經過jQuery獲取到DOM節點進行使用 new GridManager($('table').get(0));
實現起來也很簡單, 調用jQuery提供的fn.extend函數就能夠了。
(jQuery => { if (!jQuery) { return; } const runFN = function () { return this.get(0).GM(...arguments); }; jQuery.fn.extend({ GridManager: runFN, // 提供簡捷調用方式 GM: runFN }); // 恢復jTool佔用的$變量 window.$ = jQuery; })(window.jQuery);
從最開始對Vue版進行開發,到最後對React的支持,先後經歷了一年多的時間。
期間白天上班晚上修修改改,過程當中也作了不少反反覆覆的無用功。
糾其緣由仍是源於對這些框架的不瞭解,爲此多走了不少彎路,踩了不少坑。
還好,我家妹子對我一天在家抱着電腦並不惱火。
之後對這些版本的維護還在繼續,也有計劃嘗試下TypeScript。
也但願GridManager能夠方便到你的開發體驗,有什麼問題均可以在github發起。
爲了文章的易讀性,上述的代碼片斷不少都被簡化了。有想了解詳細源碼的,能夠移步github上查看。