最近因为公司有需求,正好把老早就感兴趣的可视化表单生成工具给研究了一番,现在记录下心得体会。
当时第一次看到这种拖拽生成表单或者页面的工具,就觉得很神奇,不知原理为何,后来查阅了一些资料和参考了一些开源项目,通过拆轮子搞懂了原理,解决了 自己的疑惑,在这里不将很详细的实现步骤,就记录下概要,自己在这个过程中的收获。具体项目代码git地址https://github.com/Miaodashu/form_build.git
一、 核心拖拽功能怎么实现的
1. 使用原生的`HTML draggable` 属性,开启标签的`draggable="true"`,然后监听各种拖拽事件。
2. 使用vuedraggable(Vue.Draggable是一款基于Sortable.js实现的vue拖拽插件)
因为项目技术选型时使用vue+element实现的,使用原生的话需要写很多复杂的事件交互(偷懒而已),有现成的轮子就直接使用了。 具体可以看这个vuedraggable中文文档上面有具体介绍和很多小列子。
二、 整体框架怎么设计
整体呢可以分为三个大模块,
- 左边模块为各种组件列表
- 中间为拖拽或者点击左边组件生成的可拖动展示页面
- 右边为组件的一些个性化配置(也可以添加全局配置功能)
左面模块:可以说是组件列表模块。 一般都会根据一定规则进行一下简单归类,比如说,一部分是基础组件,一部分是选择组件,一部分是容器组件
中间模块:作为展示模块,与左右模块联动
右边模块:对选中的组件进行一些参数配置
效果图如下
整个工具的核心其实依托于自定义的JSON数据,针对这些JSON数据做一些解析渲染的操作罢了,
// 数据示例
[{
// 组件的自定义配置
__config__: {
label: "单行文本", // 标题
labelWidth: null, // 标签宽度
showLabel: true, // 展示标题
tag: "el-input", // 组件name
tagIcon: "input", // 左边面板的展示icon
defaultValue: undefined,
required: true,
layout: "colFormItem", // 组件的布局容器属性
span: 24, // 表单栅格
// 正则校验规则
rules: "",
rulesMsg: ""
},
// 其余的为可直接写在组件标签上的属性
placeholder: "请输入",
style: { width: "100%" },
clearable: true,
maxlength: 15,
"show-word-limit": false,
readonly: false,
disabled: false
},
{
// 组件的自定义配置
__config__: {
label: "多行文本", // 标题
labelWidth: null, // 标签宽度
showLabel: true, // 展示标题
tag: "el-input", // 组件name
tagIcon: "textarea", // 左边面板的展示icon
defaultValue: undefined,
required: true,
layout: "colFormItem", // 组件的布局容器属性
span: 24, // 表单栅格
// 正则校验规则
rules: "",
rulesMsg: ""
},
type: "textarea",
// 其余的为可直接写在组件标签上的属性
placeholder: "请输入",
autosize: { minRows: 2, maxRows: 4 },
style: { width: "100%" },
clearable: true,
maxlength: null,
"show-word-limit": false,
readonly: false,
disabled: false
},]
那么怎么将左边选中的组件 给渲染到中间组件呢,这里当时我找了两种方案,
一种是利用vue提供的动态组件 <component v-bind:is="currentTabComponent"></component>
,然后根据自定义json数据的标签标识来渲染出组件
另一个则是使用render函数来进行渲染。
为了更全面贴合element-ui,使之能够灵活使用ui库的各种api,以及自定义组件的props,还有部分性能考虑,最终我选择使用了render函数来进行组件渲染。
render: function (createElement) {
return createElement('标签名字', {参数配置}, 子节点)
}
createElement
到底会返回什么呢?其实不是一个实际的 DOM 元素。它更准确的名字可能是 createNodeDescription
,因为它所包含的信息会告诉 Vue 页面上需要渲染什么样的节点,包括及其子节点的描述信息。我们把这样的节点描述为“虚拟节点 (virtual node)”,也常简写它为“VNode”。“虚拟 DOM”是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。它的参数描述以及数据对象概念大家区官网了解下数据对象,
然后将自定义的一些api与数据对象相结合,有的就覆盖,没有就放到attr里面。
然后写渲染功能的注意将逻辑细分一下,不要写的太耦合了。
三、 导出vue模板文件
这个功能主要是根据自定义的一些规则将vue整体页面拼接出来,可以单独的拼js,html和style,然后进行整合,这里具体逻辑就不写了,具体各位去git上拉代码来看,这里主要安利一波本地格式化文本格式的插件js-beautify
,因为当我拼接完成后,文本格式化始终存在样式格式化错乱问题,所以找到这个插件可以在本地格式化好,非常省心,还有其他的用法具体看这个插件的git文档
import jsBeautify from "js-beautify";
//使用
let htmlCode = jsBeautify.html(formBuild(options));
let jsCode = jsBeautify.js(buildJs(options));
let cssCode = jsBeautify.css(vueStyle());
这此开发主要认识了和运用一下api
1. Vue.extend()
使用基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象,可以理解为这个函数会返回一个新得vue构造函数,你实例化后就可以使用$mount('要挂载的dom节点类名')
挂载到你要挂载的地dom上。下面聚个小栗子,使用场景是假如你有个消息弹框组件,项目中好多地方使用它(忽略注册为全局组件),你每次都需要重复的import...from ...
引入,然后components
注册,就会显得很麻烦。然后可以使用 Vue.minx + Vue.extend
import pop from './components/pop' // 这里为消息组件
Vue.mixin({
methods:{
show(mes, el){
const constructor = Vue.extend(pop);
const vm = new constructor();
vm.$data.mes = mes;
vm.$mount(el);
}
}
})
使用
<div class="mask"></div>
<button @click="show('hello, world','.mask')"></button>
2. webpack的require.context ()
它是webpack的一个依赖管理, 官方文档,不明白这个api会返回什么,可以根据下面的列子自己输出点东西
require.context函数接受三个参数
directory {String} -读取文件的路径
useSubdirectories {Boolean} -是否遍历文件的子目录
regExp {RegExp} -匹配文件的正则
require.context('./test', false, /\.test\.js$/);
//(创建出)一个 context,其中文件来自 test 目录,request 以 `.test.js` 结尾。
require.context('../', true, /\.stories\.js$/);
// (创建出)一个 context,其中所有文件都来自父文件夹及其所有子级文件夹,request 以 `.stories.js` 结尾。
它主要能做什么用呢? 在前端工程中,如果遇到从一个文件夹引入很多模块的情况,可以使用这个api,它会遍历文件夹中的指定文件,然后自动导入,使得不需要每次显式的调用import导入模块,实现前端模块自动化导入
比如 将路由/vuex/自定义组件, 当你把上述文件划分成很多模块,你每次都要import引入很多次。
比如 我的vuex的module按照业务划分了很多module, 如下
以前肯定是 一个个的impoet导入模块,然后再注册模块。使用了require.context()一切就变简单多了
const modulesFiles = require.context('./module', true, /\.js$/)
const modules = modulesFiles.keys().reduce((modules, modulePath) => {
const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, '$1')
const value = modulesFiles(modulePath)
modules[moduleName] = value.default
return modules
}, {})
export default new Vuex.Store({
actions: {},
modules
})
上面只是一种应用示例,如果你要导入多个模块,就可以使用。还有如果有很多高频使用的组件,你需要全局注册,就不要用老方案一个个导入了, 你可以将高频组件全部放入一个文件夹中,然后在添加一个叫index.js的文件,在这个文件里使用require.context 动态将需要的高频组件统统打包进来,然后在main.js文件中引入index.js的文件。然后使用Vue.use(),进行插件注册
// index.js文件
import Vue from 'vue'
function changeStr (str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
const requireComponent = require.context('./', false, /\.vue$/)
// 查找同级目录下以vue结尾的组件
const install = () => {
requireComponent.keys().forEach(fileName => {
let config = requireComponent(fileName)
console.log(config) // ./child1.vue 然后用正则拿到child1
let componentName = changeStr(
fileName.replace(/^\.\//, '').replace(/\.\w+$/, '')
)
Vue.component(componentName, config.default || config)
})
}
export default {
install // 对外暴露install方法
}
3. eval()函数
定义和用法 eval() :
函数可计算某个字符串,并执行其中的的 JavaScript 代码。
说明:
该方法只接受原始字符串作为参数,如果 string 参数不是原始字符串,那么该方法将不作任何改变地返回。因此请不要为 eval() 函数传递 String 对象来作为参数。
如果试图覆盖 eval 属性或把 eval() 方法赋予另一个属性,并通过该属性调用它,则 ECMAScript 实现允许抛出一个 EvalError 异常。
抛出:
如果参数中没有合法的表达式和语句,则抛出 SyntaxError 异常。
如果非法调用 eval(),则抛出 EvalError 异常。
如果传递给 eval() 的 Javascript 代码生成了一个异常,eval() 将把该异常传递给调用者
用到这个方法是因为当我想做预览功能时, 我根据自己定义的json数据 生成了字符串格式的vue文件,下面是js部分的示例
{
data(){
return {
formData: {
field101: undefined,
field102: undefined,
field103: undefined,
},
rules: {
field101: [{"type":"string","required":true,"message":"多行文本不能为空","trigger":"blur"}],
field102: [{"type":"string","required":true,"message":"单行文本不能为空","trigger":"blur"}],
field103: [{"type":"string","required":true,"message":"计数器不能为空","trigger":"blur"}],
},
}
},
created(){
},
methods:{
submitForm() {
this.$refs['elForm'].validate((valid) => {
if (valid) {
console.log('submit!');
}
});
},resetForm() {
this.$refs['elForm'].resetFields();
}
}
}
因为这部分是字符串,就需要这里使用eval()将这个转化为可执行的js代码
转化前:可以看到在控制台输出的就是字符串格式
转化后
转换为可执行的js代码后,那怎么将 html部分的代码+js的代码转为可预览的页面呢,下来上代码
/*
* @params html template内的html代码
* @params js javascript内的js代码
* @params el 要挂载到的dom节点
*/
function init(html, js, el) {
let jsCode = eval(`(${js})`);
jsCode.template = `<div>${html}</div>`
var Profile = Vue.extend({
template: `<div><child/></div>`,
components: {
child: jsCode
}
})
new Profile().$mount(el)
}
export default init;
4. 自定义指令directive
官方文档https://cn.vuejs.org/v2/guide/custom-directive.html
我们通常给一个元素添加 v-if / v-show 来做权限管理,但如果判断条件繁琐且多个地方需要判断,这种方式的代码不仅不优雅而且冗余。
针对这种情况,我们可以通过全局自定义指令来处理:我们先在新建个 directive.js 文件,用于存放与权限相关的全局函数;
// directive.js
export function checkArray (key) {
let arr = ['1', '2', '3', '4', 'demo']
let index = arr.indexOf(key)
if (index > -1) {
return true // 有权限
} else {
return false // 无权限
}
}
// main.js
import { checkArray } from "./common/directive";
Vue.directive("permission", {
inserted (el, binding) {
let permission = binding.value; // 获取到 v-permission的值
if (permission) {
let hasPermission = checkArray(permission);
if (!hasPermission) { // 没有权限 移除Dom元素
el.parentNode && el.parentNode.removeChild(el);
}
}
}
});
最后我们在页面中就可以通过自定义指令 v-permission 来判断:
<div class="btns">
<button v-permission="'1'">权限按钮1</button> // 会显示
<button v-permission="'10'">权限按钮2</button> // 无显示
<button v-permission="'demo'">权限按钮3</button> // 会显示
</div>