跨框架的表格组件: 一套代码多框架运行

2,815 阅读6分钟

这几年来,一直在思考怎么做出一款功能强大且配置简易的原生JS表格组件。 为此做了很多功能,也对这些功能做过多轮的优化以达到配置简易的愿景。

而在开发过程中,总有个绕不过去的坎: 框架模板无法解析。

这是一个什么概念呢?

当在框架环境中渲染表格组件,这个表格内的模板只能使用原生JS,无法使用任何框架特性。 这也就意味着,当在表格模板内使用框架组件时将无法渲染。

如下所示,通过模板配置一个Vue的Button组件是无法渲染的。

columnData: [
	{
		key: '操作',
		template: <el-button @click="delRelation(row)">删除</el-button>
	}
]

在框架满天飞的现在,这是无法忍受的。

选择方案

当时,看着满屏的代码,发呆了许久。

期间一直在思考使用哪种方案来面向框架:

  • 原生版本停止开发,重新开发多套基于框架的表格组件。
  • 在现有组件上进行改造,支持框架特性。

重新开发基于框架的表格组件,需要同时维护多套代码。 而在原生组件上进行改造,可以实现一套代码多框架运行。

毕竟无论哪种框架,都是源于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上查看。

相关链接