这几年来,一直在思考怎么作出一款功能强大且配置简易的原生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上查看。