跨框架的表格組件: 一套代碼多框架運行

這幾年來,一直在思考怎麼作出一款功能強大且配置簡易的原生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或動態建立框架對像的方法:

  • Angular: $compile()
  • Vue: new Vue()
  • React: render()

在對框架進行支撐前,首先要對原生組件進行一些改造。

改造原生組件

聲明一個容器,用於存儲待解析的模板。

// 存儲容器,基本格式: {'table-key': []}
const compileMap = {};

// 獲取指定表格的存儲容器
const getCompileList = gridManagerName => {
    if (!compileMap[gridManagerName]) {
        compileMap[gridManagerName] = [];
    }
    return compileMap[gridManagerName];
};
複製代碼

收集原生表格中使用到的模板:

  • td模板
  • th模板
  • 爲空模板
  • 通欄模板

爲這些模板提供解析函數, 經過該函數生成不一樣框架的待解析模板,並存入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

如下實現是基於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事件沒法解析

一個不能用模板的表格組件,真是難以想像如何使用。因此接下來須要支持在模板函數內解析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)函數內接收原生組件傳遞的解析隊列。每一個解析對像中包含了如下基本信息:

  • el: 須要替換的DOM Node
  • row: el所使用的數據
  • index: el的索引
  • key: el所對應的columnData.key

在解析勾子函數內,有兩個不經常使用到的方法。

  • $compile(): 將HTML字符串或DOM編譯爲模板,並生成模板函數。而後經過生成的模板函數建立與scope進行連接。
  • $new(): 建立一個新的子域,第一個參數用於指定是否隔離父級域

因爲angular的雙向綁定特性,各模板內(th,td等)的angular代碼能夠感知數據的實時變動。

至此, angular版表格組件開發完畢。

Vue

如下實現是基於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提供的構建函數,讓一切變的如此簡單。

React

與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在渲染過程當中將丟失
  • state變化時,已經渲染過的模板不會更新
傳遞className

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節點。
感知state變化

雖然經過組件包裝和模板解析,讓組件能夠在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版表格組件開發完畢。

jQuery

寫到這裏,忽然腦中浮現了一張帶語音功能的圖片: "別和我說什麼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上查看。

相關連接

相關文章
相關標籤/搜索