VVC代码生成器是如何实现的-源码解析

567 阅读5分钟

VVC

介绍

作者: sahadev

源码: github.com/sahadev/vue…

演示地址: vcc3.surge.sh/

​​

使用它可以做什么

目前VCC集成了H5基本元素以及ElementUI绝大多数组件,所以可以通过VCC搭建常见的表格与表单页面。就像这样的:

w6

组件库

w4

第一章 源码解析之渲染

代码结构

{
  "template": {  //根节点
    "lc_id": "root",
    "__children": [
      {
        "div": { //组件名/标签名
          "class": "container", //class样式
          "style": "min-height: 100%; padding-bottom: 100px;", //内联样式
          "lc_id": "container", //容器唯一ID
          "__text__": "Hello,欢迎使用LCG,请往此区域拖拽组件", //文本内容
          "__children": [       //子节点
            {
              "div": {
                "__text__": "{{showText}}", //支持动态变量
                "lc_id": "text"
              }
            },
            {
              "el-button": {        //组件名/标签名
                "lc-mark": "",      //用于标记
                "type": "danger",   //组件的属性(prop)
                "lc_id": "COAAYXizyI",
                "__children": [],
                "__text__": "{{showValue}}",
                "@click": "hello",  //支持绑定方法
                "size": "small"
              }
            }
          ]
        }
      }
    ]
  }
}

初始化

渲染容器

<div class="preview-container">
    <div id="render-control-panel">
        <!--渲染的区域-->
    </div>
</div>

渲染处理类

MainPanelProvider:封装的一个类,将所有页面上的处理逻辑全部在这个类里面

export default{
    created() {
        this.mainPanelProvider = new MainPanelProvider();
    },
    mounted(){
        this.init()
    },
    methods:{
      init() {
      // 先订阅事件再渲染
      this.mainPanelProvider
        .onRootElementMounted(...)  //加载完毕后执行
        .onMerged(...)              //代码合并后执行
        .onCodeCreated(...)         //代码生成后执行
        .onCodeStructureUpdated(...)//代码结构更新后执行
        .onNodeDeleted(...)         //节点删除后执行
        .onSelectElement(...)       //选择某个节点后执行
        .saveJSCodeOnly(...)        //保存js代码合并后执行
        .render(                    //执行渲染函数
          this.initCodeEntity.codeStructure ?this.initCodeEntity.codeStructure : this.getFakeData());
    },
    }
}

render

步骤1:初始化了一个代码生成器,调用方法,将json转换为vue代码

this.codeGenerator = createNewCodeGenerator();
let code = this.codeGenerator.outputVueCodeWithJsonObj(rawDataStructure);

image-20240324140342596 image-20240324140623918

步骤2:执行正则替换格式化代码,去除多余的属性标记:如lc_id

let codeForShow = code.replace(/\s{1}lc_id=".+?"/g, '');
codeForShow = codeForShow.replace(/\s{1}lc-mark/g, "");

image-20240324141029739 image-20240324142127614

步骤3:调用解析函数,分别取出代码的 template style script内容,也就是上方的 里面的内容

const { template, script, styles, customBlocks } = parseComponent(code);
this.loadStyle(styles);
//通过 scope包, 模拟vue的样式隔离 为每个样式加上前缀来隔离样式  生成对应的文件,并且插入到页面头 用于后续的样式加载
function loadStyle(styles) {
    if (styles.length > 0) {
      const scopedStyle = styles[0];
​
      this.styleNodeName = `cssScoped${Date.now()}`;// 拼接上前缀
​
      const scopedCss = scope(scopedStyle.content.replace(/::v-deep/g, ''), this.styleNodeName);
      const styleNode = document.createElement('style');
      styleNode.innerText = scopedCss;
      document.head.appendChild(styleNode);
    }
  }

image-20240324141944960

步骤4:渲染

//通过Vue的compile方法编译 模板字符串 转换为Node节点
const res = Vue.compile(template.content);  
​
//将字符串转换为一个函数
let newScript = script.content.replace(/\s*export default\s*/, "")
const componentOptions = (new Function(`return ${newScript}`))();
componentOptions.template = template.content;
componentOptions.render = function () {
    const rootVNode = res.render.apply(this, arguments);
    return rootVNode;
};
​
​
const readyForMoutedElement = this.createMountedElement(); //获取到渲染容器节点
// 渲染当前代码
new Vue(componentOptions).$mount(readyForMoutedElement);
function createMountedElement() {
    const renderControlPanel = document.getElementById('render-control-panel');
    if(this.styleNodeName) {  //设置上面加载的隔离css的前缀
    	renderControlPanel.setAttribute('class', this.styleNodeName);
    }

    const child = document.createElement('div'); //创建一个节点用于挂载

    // 清空子节点
    while (renderControlPanel.firstChild) {
    	renderControlPanel.removeChild(renderControlPanel.firstChild)
    }

    renderControlPanel.appendChild(child);

    return child;
}

生命周期触发

使用eventemitter3包,功能类似与 $bus 通过订阅,派发,在对应代码执行的过程派发对应的函数

image-20240324162546558

image-20240324162522838

渲染结束

image-20240324163414010image-20240325104055631

第二章 源码解析之标注

当鼠标拖拽着元素时,悬浮在某个节点上 会计算放置的位置

标注1:辅助插入位置线

image-20240324164005108

<!-- 准备一个容器用于修改left/top值 实现绘制辅助线 -->
<div class="cross-flag">
	<div class="x"></div>
</div>
// onRootElementMounted  渲染完成后执行,参数为根节点元素
(rootElement)=>{
    // 只针对根div做事件监听
    initContainerForLine(rootElement.firstChild, this.currentPointer); //初始化根节点的事件
    
	document.getElementsByTagName('body')[0].addEventListener("click", () => {
		this.mainPanelProvider.clearElementSelect();  //用于清除辅助线
	})
}

function currentPointer(ele,index){  //接收 initContainerForLine方法提供的元素信息,传递给mainPanelProvider类去接着处理
    this.mainPanelProvider.setDropInfo({
        target: ele,
        index,
    });
}

1:鼠标拖拽到元素上

function initContainerForLine(targetElement,_currentPointer = () => {}){
    const currentPointer = (...args) => { //回调:传递信息给调用者
    	_currentPointer(...args);
  	};
    const crossX = document.querySelector(".x");
    targetElement.addEventListener("dragover", (event) => {
        event.preventDefault();
        drawLine(event);
      });
    ...
}
//参数:元素,鼠标x坐标,鼠标y坐标
function judgeTopOrBottom(e, x, y) {
  const position = e.getBoundingClientRect();  //浏览器API:获取元素的位置、大小信息
  const cutDistance = Math.round((position.bottom - position.top) / 3); //将元素高度分为3块
  return {
    top: y < position.top + cutDistance,  //顶部
    middle: y >= position.top + cutDistance && y <= position.top + cutDistance * 2,//中间
    bottom: y > position.top + cutDistance * 2,//底部
  };
}

function drawLine(event){
    const realTarget = event.target; //鼠标当前悬浮的元素
    //计算 鼠标在容器的位置
    const directionObj = judgeTopOrBottom(realTarget, event.clientX, event.clientY);
    if (directionObj.top && targetElement !== realTarget) {
      if (currentPostion === 'top' && currentTarget === realTarget) { //节流
        return;
      }
      currentPostion = 'top';
      currentTarget = realTarget;
        
      if (preSelectTarget) {  //清除悬浮中间的位置信息
        preSelectTarget.classList.remove("in-element");
      }
        
      //设置辅助线的位置
      crossX.style = ` 		  
     		top:${position.top}px;
      		width:${position.width}px;
      		left:${position.left}px;
      		display:block;
      	`;

      currentPointer(realTarget.parentElement, findElementIndex(realTarget)); //将节点的父元素、节点在父元素子元素的第几个元素 传递给调用者
    }else if(directionObj.bottom && targetElement !== realTarget){
        //底部的处理逻辑
        ...
    }else{
        //中间的处理逻辑
        currentPostion = 'middle';
        currentTarget = realTarget;
        realTarget.classList.add("in-element"); //添加一个样式,用于包住元素
        preSelectTarget = realTarget;
        crossX.style = `display:none;`;
        
        currentPointer(realTarget, -1); //-1代表是该元素的子元素,外面有用
    }
}

function clearTargetOutline() {
    if (preSelectTarget) {
      preSelectTarget.classList.remove("in-element");
    }
  }

image-20240325100349688image-20240325100406666image-20240325100416510

2. 鼠标离开元素

function initContainerForLine(targetElement,_currentPointer = () => {}){
	...
    targetElement.addEventListener("dragleave", (event) => {
        clearTargetOutline();
  });
}

标注2:点击

点击的处理逻辑在 _render之后,也就是上一章所说的渲染完成后执行的,通过遍历所有渲染容器内的子节点,为其绑定事件实现剩下的逻辑

image-20240324163951840

// 每个节点都有唯一的 lc_id,通过查找该元素,为其添加一个样式
function initComonentsEvent() {
    const renderControlPanel = document.getElementById('render-control-panel');
    const elements = renderControlPanel.querySelectorAll("[lc_id]");
    elements.forEach(element => {
      element.addEventListener("click", event => {
        event.stopPropagation();
        // 处理之前的状态
        if (this.currentEditElement) { //清除上一个标记
          this.currentEditElement.classList.remove("mark-element");
        }
		
        if (element) {
          this.currentEditElement = element; //储存当前点击的节点
          element.classList.add("mark-element"); //标记

          // 触发元素选择生命周期,传递节点信息给外面
          const rawVueInfo = findRawVueInfo(element); //作用:从json树中 找到该节点的信息
          const codeRawInfoCopy = cloneDeep(rawVueInfo);
          this.eventEmitter.emit("selected", codeRawInfoCopy);
        } else {
          this.eventEmitter.emit("selected", null);
        }
      })
      ...
    })

   	...
}

第三章 组件库

自定义内容,

image-20240325110941667

结构

通过直接将代码写入一个vue文件中,然后直接渲染,由于结构比较简单,就直接介绍拖拽原理。

优点:方便自定义

缺点:编写繁琐,需要带上lc_id标识

image-20240325112104964

初始化

通过递归调用,获取到所有通过 lc_mark标记的所有节点,对所有节点进行初始化

function deepLCEle(rootElement) {
    // 对所有含有lc-mark属性的Element实现可拖拽能力
    function deep(ele) {
        if (ele.attributes["lc-mark"]) {
            // 统计标记组件数量
            initElement(ele);
        }

        if (ele.children.length > 0) {
            const length = ele.children.length;
            for (let i = 0; i < length; i++) {
                deep(ele.children.item(i));
            }
        }
    }

    deep(rootElement);
}

绑定拖拽信息

image-20240325113244367

function initElement(element) {
    element.draggable = true; //启动拖拽
    // 给每个组件添加拖拽事件
    element.addEventListener("dragstart", function (event) {
        ... //拖拽开始
    });
​
    // 处理组件标记
    element.addEventListener("mouseenter", (event) => {
        //鼠标进入
        event.target.addList.add('mark-element-unit')
        ...
    });
​
    element.addEventListener("mouseleave", (event) => {
        //鼠标离开
        event.target.addList.remove('mark-element-unit')
        ...
    });
}

拖拽开始

image-20240325113212560

element.addEventListener("dragstart", function (event) {
    event.dataTransfer.effectAllowed = "copy"; //设置虚化显示
    const raw = generateRawInfo(element);//方法:将元素==>json信息 (方便后续处理)
    const str = JSON.stringify(raw);  //原本这里传递了一大堆信息,其实可以简化成只传节点的json数据
    event.dataTransfer.setData("text/plain", str); //拖拽的属性,将信息设置在 dataTransfer上,在拖拽结束的节点去 绑定 drag事件 就可以拿到传递的数据
    event.stopPropagation();
});