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

angular,vue,react,原生表格

拭目以待 发布于

这几年来,一直在思考怎么做出一款功能强大且配置简易的原生JS表格组件。 为此做了很多功能,也对这些功能做过多轮的优化以达到配置简易的愿景。 而在开发过程中,总有个绕不过去的坎: 框架模板无法解析。 这是一个什么概念呢? 当在框架环境中渲染表格组件,这个表格内的模板只能使用原生JS,无法使用任何框架特性。 这也就意味着,当在表格模板内使用框架组件时将无法渲染。 如下所示,通过模板配置一个Vue的Button组件是无法渲染的。 ``` javascript columnData: [ { key: '操作', template: 删除 } ] ``` 在框架满天飞的现在,这是无法忍受的。 ## 选择方案 > 当时,看着满屏的代码,发呆了许久。 期间一直在思考使用哪种方案来面向框架: - 原生版本停止开发,重新开发多套基于框架的表格组件。 - 在现有组件上进行改造,支持框架特性。 重新开发基于框架的表格组件,需要同时维护多套代码。 而在原生组件上进行改造,可以实现一套代码多框架运行。 毕竟无论哪种框架,都是源于JS。 ## 设计思路 > 一旦选择,那么就坚持下去吧。虽然,谁都会知道中间的路很坎坷。 思路讲起来很简单,只是做出来需要很多调研工作,需要对各个框架有一定的熟识度,甚至在找不到解决方案时需要去阅读框架源码。 构思了一段时间后,一套只有两个步骤的实践方案就这么设定了: - 为每个框架提供壳项目,在壳项目中实现框架解析模板的勾子。 - 原生组件负责在渲染过程中对各类模板进行整合,并在特定的时机发送至壳项目中进行解析。 ![](/upload/blog/pic/9009_framework.png) 思路确定之后,就该动手开工了。 ## 实施 三个框架虽有不同,但都提供了解析原生DOM或动态创建框架对像的方法: - Angular: $compile() - Vue: new Vue() - React: render() 在对框架进行支撑前,首先要对原生组件进行一些改造。 ### 改造原生组件 声明一个容器,用于存储待解析的模板。 ``` javascript // 存储容器,基本格式: {'table-key': []} const compileMap = {}; // 获取指定表格的存储容器 const getCompileList = gridManagerName => { if (!compileMap[gridManagerName]) { compileMap[gridManagerName] = []; } return compileMap[gridManagerName]; }; ``` 收集原生表格中使用到的模板: - td模板 - th模板 - 为空模板 - 通栏模板 为这些模板提供解析函数, 通过该函数生成不同框架的待解析模板,并存入compileMap等待解析。 ``` javascript // 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类似 ``` 在原生组件拥有模板解析函数后,还需要为原生组件提供与各框架版本的通迅函数。 ``` javascript // 通迅函数: 与各框架模板解析勾子进行通迅,在特定时间调用 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`。 ``` javascript 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 = '

'; const GridManagerComponent = { controller, template, controllerAs: 'vm', bindings: { option: '<', callback: '&' } }; const gridManagerModuel = angular.module('gridManager', []); // 在这个module上注册组件 gridManagerModuel .component('gridManager', GridManagerComponent) .value('$gridManager', $gridManager); ``` 到这一步,就可以在Angular环境中通过``来创建一个angular表格组件了。 但是简单的使用后,会发现有些事情还待解决: - 模板内angular组件无法解析 - 模板内无法获取当前所在域的属性 - 模板内的angular事件无法解析 一个不能用模板的表格组件,真是难以想像如何使用。所以接下来需要支持在模板函数内解析Angular模板,并让解析后的模板支持Angular特性。 #### 解析模板 > 这是一个必须要解决的问题,不然开发过程中表格中使用的模板只能使用原生js而不能使用框架特性。 尝试了多种方法都不能完全解决,于是阅读了angular相关的文档和源码,并最终通过以下代码实现。 ``` javascript // 在包装组件的基础上,对`$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同为双向绑定,也是需要实现组件包装与模板解析两块功能。 #### 包装组件 ``` javascript const GridManagerVue = { name: 'GridManagerVue', props: { option: { type: Object, default: {}, }, callback: { type: Function, default: query => query, } }, template: '
', 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环境中通过``来创建一个Vue表格组件了。 与Angular相同,也是需要在包装的基础上解决Vue模板问题。 #### 解析模板 >与angular不同,Vue的特性决定了这个过程更简单。 ``` javascript // 在包装组件的基础上对`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()` ``` javascript // 在render中返回原生组件需要的DOM目标 class ReactGridManager extends React.Component{ constructor(props) { super(props); this.tableRef = React.createRef(); } render() { return ( ); } // 在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环境中使用``来创建一个React表格组件了。 接下来,在包装的基础上解决React模板问题。 #### 解析模板 > 与Angular和Vue相同,也是在实例化原生组件前提供勾子函数。 ``` javascript // 在包装组件的基础上对`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属性,如下所示: ``` html // 渲染前的组件标签 // 渲染后的组件标签
...
``` 而React在render中却不会保留这个标签,因此这个标签上的属性也都会丢失, 如下所示: ``` javascript // 渲染前的组件标签 render() { return } ``` ``` html // 渲染后的组件标签
...
``` 知道了原因,问题就变的简单了,只需要执行两个操作: - 在生命周期函数`componentDidMount`执行原生组件实例化时,将className回填至DOM节点。 - 在生命周期函数`componentDidUpdate`执行更新时,感知到最后的className并回填至DOM节点。 ##### 感知state变化 虽然通过组件包装和模板解析,让组件可以在React环境运行且可以正常解析jsx。 但由于模板中使用的jsx由于与外部隔着一层原生代码,这就导致了被嵌套的jsx并无法感知外部state的变更。 为了解决这个问题,需要从原生组件开始分析。 在实例化原生组件时,会为每个实例生成一个`Settings`对像,这个对像存储了当前实例的实时数据: ``` javascript setting: { gridManagerName: 'test-table', rendered: true, width: "100%", height: "100%", columnData: [], columnMap: {} // columnMap存储了当前th、td所使用的实时模板 } ``` 触发模板渲染时,所使用的数据都是从`Settings`对像进行获取,比如其中的columnMap就存储了当前th、td所使用的实时模板。 所以,当感知到state变化后,去修改`Settings`对像并触发模板渲染即可实现与外部组件的数据交互。 首先,在原生组件内提供`resetSettings`函数。 ``` javascript resetSettings(table, settings) { // ...调用内部的更新机制 } ``` 然后,在React生命周期函数`componentDidUpdate`内触发更新 ``` javascript 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的支持保留在了原生组件内。 ``` javascript // 你们喜欢的jQuery调用方式,可以直接进行实例化 $('table').GridManager(arg); // 当然,也可以通过jQuery获取到DOM节点进行使用 new GridManager($('table').get(0)); ``` #### 包装组件 > 实现起来也很简单, 调用jQuery提供的fn.extend函数就可以了。 ``` javascript (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](https://github.com/baukh789/GridManager)发起。 为了文章的易读性,上述的代码片段很多都被简化了。有想了解详细源码的,可以移步github上查看。 ## 相关链接 - [原生表格组件](https://github.com/baukh789/GridManager) - [Angular版表格组件](https://github.com/baukh789/GridManager-Angular-1.x) - [Vue版表格组件](https://github.com/baukh789/GridManager-Vue) - [React版表格组件](https://github.com/baukh789/GridManager-React) - [API](http://gridmanager.lovejavascript.com/)