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