這幾年來,一直在思考怎麼作出一款功能強大且配置簡易的原生JS表格組件。 爲此作了不少功能,也對這些功能作過多輪的優化以達到配置簡易的願景。javascript
而在開發過程當中,總有個繞不過去的坎: 框架模板沒法解析。html
這是一個什麼概念呢?vue
當在框架環境中渲染表格組件,這個表格內的模板只能使用原生JS,沒法使用任何框架特性。 這也就意味着,當在表格模板內使用框架組件時將沒法渲染。java
以下所示,經過模板配置一個Vue的Button組件是沒法渲染的。node
columnData: [
{
key: '操做',
template: <el-button @click="delRelation(row)">刪除</el-button> } ] 複製代碼
在框架滿天飛的如今,這是沒法忍受的。react
當時,看着滿屏的代碼,發呆了許久。git
期間一直在思考使用哪一種方案來面向框架:github
從新開發基於框架的表格組件,須要同時維護多套代碼。 而在原生組件上進行改造,能夠實現一套代碼多框架運行。app
畢竟不管哪一種框架,都是源於JS。框架
一旦選擇,那麼就堅持下去吧。雖然,誰都會知道中間的路很坎坷。
思路講起來很簡單,只是作出來須要不少調研工做,須要對各個框架有必定的熟識度,甚至在找不到解決方案時須要去閱讀框架源碼。
構思了一段時間後,一套只有兩個步驟的實踐方案就這麼設定了:
思路肯定以後,就該動手開工了。
三個框架雖有不一樣,但都提供瞭解析原生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上查看。