普通视图

发现新文章,点击刷新页面。
今天 — 2025年8月18日首页

Vue2实践(3)之用component做一个动态表单(二)

作者 wycode
2025年8月18日 16:56

前言

在上一篇中,我们已经通过<component>实现了组件的动态渲染,为这个动态表单功能定下框架。

在这篇中,我们将着重实现功能。

功能实现

在上一篇中,我们定下了由设置组件来制作具体组件的方案,我们先来完善这一功能——从设置组件中获取完整的具体组件信息。

在这里我选用SelectInputSetting来做例子

<template>
    <div>
        <TextInput :field="labelField" v-model="setting.name" />
        <div class="option" v-for="(item, index) in setting.options" :key="item.key"> 
            <TextInput class="option-content" :field="item" v-model="setting.options[index].value" />
            <button @click="deleteOption(index)">删除</button>
        </div>
        <button @click="addOption">添加选项</button>
    </div>
</template>
<script>
import TextInput from './TextInput.vue';
export default {
    components: {
        TextInput
    },
    data: () => ({
        labelField: {
            name: '选项名称',
            placeholder: '请输入选项名称',
            value: '', // 通过之前的源码文档,我们得知初始的object其中的属性是响应式的
        },
        setting: {
            id: '',
            editor: 'SelectInputSetting',
            type: 'SelectInput',
            name: '',
            value: '',
            options:[],
            optionCount: 0, // 内部自增标识
        },
    }),
    methods: {
        addOption() {
            this.setting.options.push({
                name: '选项内容',
                placeholder: '请输入选项名称',
                value: '',
                key: this.setting.optionCount++
            })
        },
        deleteOption(index) {            
            this.setting.options.splice(index, 1); // 通过之前的源码文档,我们得知vue通过劫持数组原型方法实现数组响应式,splice就是其中之一
        },
    }
}
</script>

设置组件与预览表单的数据交互

分析

目前在设置组件SelectInputSetting中,通过setting收集用户输入,已经能够得到一份“下拉框组件定义”数据;

接下来,只要把这份数据传递到“表单预览”中,即可。所以我们需要实现它们之间的数据交互,通常来说有许多方案,但是考虑到用户操作性,数据交互可以通过:点击、拖拽等交互实现。在这里我们选用“拖拽”交互。

实现拖拽交互

实现拖拽交互,需要使用浏览器提供的一些API。

SelectInputSetting.vue
<template>
    <div>
        <TextInput draggable="true" :field="labelField" v-model="setting.name" @dragstart="dragstart" />
        <div class="option" v-for="(item, index) in setting.options" :key="item.key"> 
            <TextInput class="option-content" :field="item" v-model="setting.options[index].value" />
            <button @click="deleteOption(index)">删除</button>
        </div>
        <button @click="addOption">添加选项</button>
    </div>
</template>
<script>
import TextInput from './TextInput.vue';
export default {
    components: {
        TextInput
    },
    data: () => ({
        labelField: {
            name: '选项名称',
            placeholder: '请输入选项名称',
            value: '',
        },
        setting: {
            id: '',
            editor: 'SelectInputSetting',
            type: 'SelectInput',
            name: '',
            value: '',
            options:[],
            optionCount: 0,
        },
    }),
    methods: {
        addOption() {
            this.setting.options.push({
                name: '选项内容',
                placeholder: '请输入选项名称',
                value: '',
                key: this.setting.optionCount++
            })
        },
        deleteOption(index) {            
            this.setting.options.splice(index, 1);
        },
        // 开始拖动事件
        dragstart(e) {
            const dataStr = JSON.stringify(this.setting);
            e.dataTransfer.setData('application/json', dataStr);
        }
    }
}
</script>

draggable标识: 应用于HTML元素,用于标识元素是否允许使用浏览器原生行为或HTML 拖放操作 API拖动。true时元素可以被拖动。

dragstart事件: dragstart 事件在用户开始拖动元素或被选择的文本时调用。

通过HTML的拖放API,我们将数据传递通过event进行传递。

DynamicForm.vue
<template>
    <div class="container">
        <div class="main-area" @drop="addComponent" @dragover.prevent>
            <!-- 表单预览域 -->
            <div class="form-title">
                <TextInput :field="titleField" />
            </div>
            <div class="form-content" v-for="(item) in fields" :key="item.id">
                <component class="form-component" :is="item.type" :field="item" />
            </div>
        </div>
        <div class="sidebar">
            <!-- 表单组件域 -->
            <SelectInput v-model="componentValue" :field="createField" />
            <div>
                <component class="form-component" :is="componentValue" />
            </div>
        </div>
    </div>
</template>

<script>
import TextInput from './FieldTypes/TextInput.vue';
import TextInputSetting from './FieldTypes/TextInputSetting.vue';
import SelectInput from './FieldTypes/SelectInput.vue';
import SelectInputSetting from './FieldTypes/SelectInputSetting.vue';
export default {
    components: {
        TextInput,
        TextInputSetting,
        SelectInput,
        SelectInputSetting
    },
    data: () => ({
        titleField: {
            name: '表单名称',
            placeholder: '请输入表单名称',
            value: ''
        },
        componentValue: '',
        createField: {
            name: '选择要创建的组件',
            placeholder: '',
            value: '',
            options: [
                { 'value': 'TextInputSetting', 'name': '文本框' },
                { 'value': 'SelectInputSetting', 'name': '下拉单选框' },
            ]
        },
        fields: [],
    }),
    methods: {
        addComponent(e) {
            e.preventDefault(); // drop事件必须阻止默认行为

            const dataStr = e.dataTransfer.getData('application/json');
            const data = JSON.parse(dataStr);
            data.id = Date.now().toString(); // 添加一个唯一标识用于diff
            this.fields.push(data);
        }
    }
}
</script>

<style lang="scss" scoped>
.container {
    display: flex;
    border: 2px solid #000;
    padding: 10px;
}

.main-area {
    flex-grow: 4;
    margin-right: 10px;
    padding: 0 10px;
    border: 2px solid #000;
    border-radius: 10px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: flex-start;

    .form-title {
        width: auto;
        text-align: center;
        margin-bottom: 8px;
    }

    .form-content {
        border-radius: 10px;
        padding: 8px;
        width: 90%;
        border: 1px solid #ccc; // 默认边框

        .form-component {
            width: 300px;
        }

        &:hover {
            border: 1px solid #ccc;
            cursor: all-scroll;
        }
    }
}

.sidebar {
    display: flex;
    flex-direction: column;
    border: 2px solid #000;
    border-radius: 10px;
    padding: 10px;
    flex-grow: 1;

    .form-component {
        border: 1px solid #555;
        border-radius: 10px;
        padding: 8px;
        margin-bottom: 10px;
    }

    label {
        margin-bottom: 5px;
    }

    input {
        margin-top: 10px;
        padding: 5px;
        border: 1px solid #000;
        border-radius: 5px;
    }
}
</style>

drop事件: 事件在元素或文本选择被放置到有效的放置目标上时触发。为确保 drop 事件始终按预期触发,应当在处理 dragover 事件的代码部分始终包含 preventDefault() 调用。

dragover.prevent: 只有在 dragenterdragover 事件中调用了 event.preventDefault(),浏览器才会允许元素的拖放操作。

通过HTML的拖放API,我们将数据传递通过event进行接接收。

功能完善

  • SelectInputSetting中,选项删除按钮在当前选项hover时才出现
<style lang="scss" scoped>
    .option {
        button {
            visibility: hidden;
        }
        &:hover {
            button {
                visibility: visible;
            }
        }
    }
</style>

使用visibility属性的修改只会触发重绘,使用display实现的话会触发重排。这个跟使用v-show还是v-if的问题相似;

使用css元素实现而不是vue指令,在于css更好控制:hover

  • 组件设置完成后应该有保存选项来进行锁定,避免误操作
<!-- TextInput -->
<template>
  <div class="text-input-container">
    <label :for="field.name" class="text-input-label">{{ field.name }}:</label>
    <input class="text-input" type="text" :placeholder="field.placeholder" :value="value"
      @input="$emit('input', $event.target.value)" :required="field.required" :readonly="disabled" />
  </div>
</template>

<script>
export default {
  props: ['field', 'disabled', 'value'],
}
</script>
<!-- SelectInputSetting -->
<template>
    <div :class="{'drag':isFinished}" :draggable="isFinished" @dragstart="dragstart">
        <TextInput :disabled="isFinished" :field="labelField" v-model="setting.name" />
        <!-- 这里如果不使用item.key而是使用index,会因为节点复用导致显示错误 -->
        <div class="option" v-for="(item, index) in setting.options" :key="item.key"> 
            <TextInput :disabled="isFinished" class="option-content" :field="item" v-model="setting.options[index].value" />
            <button v-show="!isFinished" @click="deleteOption(index)">删除</button>
        </div>
        <button v-show="!isFinished" @click="addOption">添加选项</button>
        <button v-show="!isFinished" @click="finish">完成</button>
    </div>
</template>
<script>
import TextInput from './TextInput.vue';
export default {
    components: {
        TextInput
    },
    data: () => ({
        labelField: {
            name: '选项名称',
            placeholder: '请输入选项名称',
            value: '',
        },
        
        setting: {
            id: '',
            editor: 'SelectInputSetting',
            type: 'SelectInput',
            name: '',
            value: '',
            options:[],
            optionCount: 0
        },
        isFinished: false,
    }),
    methods: {
        addOption() {
            this.setting.options.push({
                name: '选项内容',
                placeholder: '请输入选项名称',
                value: '',
                key: this.setting.optionCount++
            })
        },
        deleteOption(index) {            
            this.setting.options.splice(index, 1);
        },
        dragstart(e) {
            const dataStr = JSON.stringify(this.setting);
            e.dataTransfer.setData('application/json', dataStr);
        },
        finish() {
            this.isFinished = true;
        },
    }
}
</script>
<style lang="scss" scoped>
.drag {
    &:hover {
        cursor: all-scroll; // 修改鼠标样式,更符合移动组件的暗示
    }
}
</style>

小结

至此我们已经完成了一个相对简单的动态表单组件。能从中体会组件的设计思想、代码组织,并且了解到一些具体实现需要调用的API。

接下来我们还将继续实现类似的有趣实践——导航栏

昨天以前首页

Vue2源码笔记(1)编译时-模板代码如何生效之生成AST树

作者 wycode
2025年8月11日 18:34

前言

虽然现在已经是Vue3的版本,也已经用了相当一段时间的Vue3。但想起来Vue2的源码之前断断续续也看了蛮长的时间,就再回头整理一遍。有始有终。

我这里把接下来要写的内容分为编译时运行时

首先登场的是编译时。

在平常的开发中,我们经常写.vue文件:

<template>
    <div class="container" style="color:red; font-size:14px">
        <div></div>
        hello {{ name }}
    </div>
</template>

这样的代码看起来和HTML很像,但浏览器是绝对不会认识它的,它们这么近那么远,隔了一座山。而用来打通这座山的就是Vue2的编译时代码:

我把这个过程简化为:parse() -> generate()->生成渲染函数代码,这些生成的js代码,便在运行时将我们在IDE中写在.vue文件中的代码画在了页面上。

注:以下代码与Vue2源码并非完全一致,但力求能在实现主要功能的基础上更便于理解。

parse()与AST抽象语法树

在编译时,模板代码被parse()解析后会生成抽象语法树ast(如果想抢先知道模板被处理成什么样的话,拖到最后有一个以上对应的模拟AST树)。

/**
 * 模板代码解析函数
 * @param {String} html 
 * @returns ast抽象语法树
 */
function parse(html) {
    
}

// 一个ast node,其实就是一个带有特定属性的js对象
astEg = {
    tag: tagName,
    type: ELEMENT_TYPE,
    children: [],
    attrs,
    parent: null,
}

我们把模板文件<template>中书写的内容看作普通的文本内容,放到js中进行处理。HTML堪称是一门非常友好的“语言”,特有的尖括号、标签、属性等很容易被人理解,那么同样的,这些特征,也使得我们能很快想到如何建立一个识别它的模型,分门别类地捕获它们,把它们填入一个js对象中,即得到了ast-node

内容识别

Vue2的parse()函数使用了相当多的正则来识别这些内容:

function parse(html) {
    const ncname = `[a-zA-Z_][\-\.0-9_a-zA-Z]*`; //匹配标签名 形如 abc-123
    const qnameCapture = `((?:${ncname}\:)?${ncname})`; //匹配特殊标签 形如 abc:234 前面的abc:可有可无
    const startTagOpen = new RegExp(`^<${qnameCapture}`); // 匹配标签开始 形如 <abc-123 捕获里面的标签名
    const startTagClose = /^\s*(/?)>/; // 匹配标签结束  >
    const endTag = new RegExp(`^<\/${qnameCapture}[^>]*>`); // 匹配标签结尾 如 </abc-123> 捕获里面的标签名

    const attribute = /^\s*([^\s"'<>/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性
}

而对于ast node也进行了约定:

function parse(html) {
    // ...
    const ELEMENT_TYPE = 1; // 元素节点
    const TEXT_TYPE = 3; // 文本节点
    function createASTElement(tagName, attrs) {
        return {
            tag: tagName,
            type: ELEMENT_TYPE,
            children: [],
            attrs,
            parent: null
        }
    }
    function createASTText(text) {
        return {
            type: TEXT_TYPE,
            text,
        }
    }
}

代码逻辑

而对于文本的解析则通过循环逐步读取、解析。

开始标签

对于开始标签的解析:

function parse(html) {
    // ...
    while (html) {
        let textEnd = html.indexOf("<"); // 找到最近的 < 字符位置
        if (textEnd === 0) {
            const startTagMatch = parseStartTag(); // 捕获开始标签及attr,返回值形如{tagName: 'xxx', attr: [{name: '', value: ''}] }, 没结果的话返回undefined
        }
    }
    
    function parseStartTag() {
        const start = html.match(startTagOpen);
        if (start) {
            const match = {
                tagName: start[1], // 使用正则捕获到最近的标签的内容,即<tagName中的tagName
                attrs: [],
            }
        }
        // advance是一个用来将解析html内容的位置往前推的函数,类似于将内容标记为已读。我们稍后实现它
        advance(start[0].length); // start[0]则是<tagName
        
        let end, attr;
        while (
            !(end = html.match(startTagClose)) // 这里再确定当前标签未闭合,>
            &&
            (attr = html.match(attribute)) // 在标签未闭合的情况下,捕获最近的属性值
        ) {
            advance(attr[0].length); // 标记当前内容已读
            attr = {
                name: attr[1], // 属性名
                value: attr[3] || attr[4] || attr[5] // 这里是因为正则捕获支持双引号、单引号、无引号 标记的属性值
            }
            match.attrs.push(attr);
        }
        // 上述过程就持续至到达>时, 并赋值给end, end = html.match(startTagClose)
        if (end) {
            advance(1);
            return match;
        }
    }
}

对于开始标签的处理:

function parse(html) {
    let root, currentParent; // 存储root节点、当前父节点
    let stack = []; // 解析过程中内容存放于栈中,当匹配到结束标签时出栈,以完成对嵌套节点的处理,保证结构正确
    // ...
    while (html) {
        let textEnd = html.indexOf('<');
        if (textEnd === 0) {
            const startTagMatch = parseStartTag();
            if (startTagMatch) {
                handleStartTag(startTagMatch);
                continue; // 此时对于开始标签的处理已经完成,重回循环,因为处理的情况可能是<tag1>content</tag1>, 也可能是<tag1><tag2></tag2></tag1>
            }
        }
    }
    
    function handleStartTag({tagName, attrs}) {
        let element = craeteASTElement(tagName, attrs); // 创建元素节点
        if (!root) {
            root = element;
        }
        currentParent = element;
        stack.push(element); // 将节点先入栈,等到结束标签在做处理
    }
}

结束标签

对于结束标签的解析和处理(因为结束标签上并没有需要解析的内容,所以这部分会比开始标签要简略):

function parse(html) {
    while (html) {
        let textEnd = html.indexOf('<');
        if (textEnd === 0) {
            const startTagMatch = parseStartTag();
            if (startTagMatch) {
                handleStartTag(startTagMatch);
                continue;
            }
            
            // 结束标签
            const endTagMatch = html.match(endTag); // 结束标签</tag>上并没有需要解析的内容,所以只需要确认是否是结束标签即可
            if (endTagMatch) {
                advance(endTagMatch[0].length);
                handleEndTag(endTagMatch[1]);
                continue;
            }
        }
    }
    
    function handleEndTag(tagName) {
        let element = stack.pop() // 匹配到结束标签,自然认为与栈顶为一对
        currentParent = stack[stack.length - 1]; // 重新设置栈顶节点为当前父节点
        if (currentParent) {
            // 从这里可以看出在慢慢形成树一样的结构,所以称为AST树
            element.parent = currentParent; // 此时栈顶元素必然是其父节点, 兄弟节点和子节点早就出栈了
            currentParent.children.push(element); // 存入父节点的children属性中
        }
    }
}

文本

除了标签相关的内容外,我们的template里还可能存在的就是文本,继续来处理它们:

function parse(html) {
    while (html) {
        let textEnd = html.indexOf('<');
        if (textEnd === 0) {
            const startTagMatch = parseStartTag();
            if (startTagMatch) {
                handleStartTag(startTagMatch);
                continue;
            }
            
            const endTagMatch = html.match(endTag);
            if (endTagMatch) {
                advance(endTagMatch[0].length);
                handleEndTag(endTagMatch[1]);
                continue;
            }
        }
        
        // 文本
        let text;
        if (textEnd > 0) { // 最近的 < 不在当前读到的位置,而无论是开始标签还是结束标签都已经被以上代码分支处理,这里必是文本
            text = html.substring(0, textEnd)
        }
        if (text) {
            advance(text.length);
            handleChars(text);
        }
    }
    
    function handleChars(text) {
        text = text.replace(/\s/g, ""); // 处理文本中的 所有 空格
        if (text) {
            let textNode = createASTText(text);
            currentParent.children.push(textNode);
        }
    }
    
    return root; // 当这个循环完成后,从root节点已经长成一棵AST树
}

到这里parse()的主要流程就结束了,我们最后揭晓一下advance()函数:

function parse(html) {
    // ...
    function advance(n) {
        html = html.substring(n)
    }
}

结语

最后我们看一个实例,由我们一开始的那个模板代码经过parse()处理的产物的模拟:

{
    tag: 'div',
    type: 1,
    attrs: [{name: 'class', value: 'container'}, {name: 'style', value: 'color:red; font-size:14px'}],
    parent: null,
    children: [
        {
            tag: 'div',
            type: 1,
            attrs: [],
            parent,
            children: []
        },
        {
            type: 3,
            text: 'hello{{name}}'
        }
    ]
}

这会跟在Vue2版本中断点查看的有些许出入,但不影响我们理解这一过程。

至此我们已经完成了第一篇,在这里我们主要整理了Vue2编译时,从模板生成ast树的过程,来做一个总结:

  1. 模板内容被视作文本,交由js代码处理
  2. 使用正则进行匹配捕获:开始标签、标签中的属性、结束标签、文本
  3. 使用栈结构,开始标签->元素节点,入栈;结束标签作为出栈的标志,出栈;文本->文本节点,添加到当前currentParent.children中
  4. 循环遍历模板内容逐步由栈结构得到AST树

下一篇,我们将继续整理generate()函数,记录和分析从AST树 到 code文本的过程。

【参考】:

Vue2.0源码(二)模板编译原理

❌
❌