[Vue2]从零实现一个 el-popover 气泡框组件

前言

因为公司项目用 无界微前端框架 集成之后,Element-UI 中的 el-tooltip、el-popover 等气泡系列组件弹出位置会发生偏移,出于好奇本人看了 el-tooltip、el-popover 的部分源码,决定自己实现一个类似 el-popover 的组件

前置知识/准备

Node.appendChild 方法

(摘自 MDN)

所以我们可以把气泡元素从组件内部挂载到 document.body 中

js 复制代码
<template>
  <span>
    <div ref="popper" class="popover">这是popper弹出框</div>
    <span class="reference-wrapper">
      <span>这是触发popper的元素</span>
    </span>
  </span>
</template>
<script>
export default {
  mounted(){
    const popper = this.$refs.popper
    document.body.appendChild(popper) // $refs.popper 元素会从 span 元素内部移动到 
                                      // document.body.children 中
                                      // 这允许我们将气泡元素 $refs.popper 挂载到 document.body 上
  },
}

组件样式

组件所有样式如下,建议提前复制到 .vue 文件的 style 标签中,文章后面将会省略

js 复制代码
.popover {
  position: absolute;
  background: #fff;
  min-width: 150px;
  border-radius: 4px;
  border: 1px solid #ebeef5;
  padding: 12px;
  z-index: 2000;
  color: #606266;
  line-height: 1.4;
  text-align: justify;
  font-size: 14px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  word-break: break-all;
  z-index: 9999;
}

.popover__title {
  color: #303133;
  font-size: 16px;
  line-height: 1;
  margin-bottom: 12px;
}
.popper__arrow {
  position: absolute;
  width: 8px;
  height: 8px;
}

.popover .popper__arrow::after {
  content: '';
  position: absolute;
  width: 8px;
  height: 8px;
  background-color: #fff;
  border: 1px solid #e4e7ed;
  transform: rotate(45deg);
}
.popover[data-popper-placement^=bottom] .popper__arrow {
  top: -4px;
}

.popover[data-popper-placement^=bottom] .popper__arrow::after {
  border-right-color: transparent;
  border-bottom-color: transparent;
  border-top-left-radius: 2px;
}

.popover[data-popper-placement^=top] .popper__arrow {
  bottom: -4px;
}

.popover[data-popper-placement^=top] .popper__arrow::after {
  border-left-color: transparent;
  border-top-color: transparent;
  border-bottom-right-radius: 2px;
}

.popover[data-popper-placement^=left] .popper__arrow {
  right: -4px;
}

.popover[data-popper-placement^=left] .popper__arrow::after {
  border-left-color: transparent;
  border-bottom-color: transparent;
  border-top-right-radius: 2px;
}

.popover[data-popper-placement^=right] .popper__arrow {
  left: -4px;
}

.popover[data-popper-placement^=right] .popper__arrow::after {
  border-right-color: transparent;
  border-top-color: transparent;
  border-bottom-left-radius: 2px;
}

用 render 函数替代 template 模板编写组件结构

组件的所有 props、method、slot 等都将会与 el-popover 保持一致。 为了更灵活的为 reference 插槽元素在不同的 trigger(气泡触发方式) 下绑定气泡触发事件回调函数(比如 click、hover 等),需要用 render 函数代替模板来编写组件结构

(后面 reference 统一指代的是触发气泡框的媒介元素, popper 统一指代的是气泡框元素)。

js 复制代码
<script>
export default {
  name: "MyPopover",
  props: {
    content: String,
    title: String
  },
  data() {
    return {
      showPopper: true  // 控制气泡元素显隐
    }
  },
  render(h) {
    const referenceSlot = this.$slots.reference // reference 插槽内容
    // 如果没有传入 reference 插槽内容,则不渲染组件 
    if (!Array.isArray(referenceSlot) || referenceSlot.length === 0) return null
     /**
      将传入 reference 插槽的第一个元素节点(非文本节点)作为 reference
      错误示范(直接传入文本节点,组件将不渲染任何元素):
      <MyPopover>
          <template v-slot:reference>
              123      // 注: 文本节点 '123' 不能作为 reference
          </template>  // 必须得是 <el-button>click me</el-button> 或 <span>reference</span> 这种元素节点才行
      </MyPopover>
    */     
    let referenceVnode, vnode
    for (let i = 0, l = referenceSlot.length; i < l; i++) {
      vnode = referenceSlot[i]
      // 虚拟 DOM 有 tag 值,是元素节点
      if (vnode.tag) {
        referenceVnode = vnode
        break
      }
    }
    if (!referenceVnode) return null
    /** 组件结构概览,不过我们这里用 render 函数实现
      <span>
        <div class="popover" ref="popper" v-show="showPopper">
          <div v-if="title" class="title">{{ title }}</div>
          <slot>{{ content }}</slot>
        </div>
        <span class="reference-wrapper" ref="wrapper">
          <slot name="reference"></slot>
        </span>
      </span>
    */
    return h("span", [
      h(
        "div",
        {
          ref: "popper",
          class: ["popover"],
          // v-show 控制气泡显隐
          directives: [
            {
              name: "show",
              value: this.showPopper
            }
          ]
        },
        [
          // popover 标题
          this.title ? h("div", {class: ["popover__title"]}, this.title) : null,
          // popper 内容
          this.$slots.default || this.content
        ]
      ),
      h("span", {class: ["reference-wrapper"], ref: "wrapper"}, [referenceVnode])
    ])
  },
}
</script>

页面效果:

父组件 <template> 结构:

js 复制代码
<my-popover title="这是一个标题">
    <p>这天气好热,快吃点甜甜的西瓜吧~</p>
    <template v-slot:reference>
        <el-button type="primary">click me</el-button>
    </template>
 </my-popover>

为 reference 在不同 trigger 下添加事件回调函数

我们这里暂时只考虑 triggerclick 这种情况:

js 复制代码
// 先将 showPopper 默认值设为 false,更方便我们观察气泡从隐藏到显示的效果
data() {
    return {
      showPopper: false // 控制气泡元素显隐
    }
},
methods: {
    /** 点击 reference 切换气泡显隐*/
    doToggle() {
      this.showPopper = !this.showPopper
    },
    /** 点击页面其他地方隐藏气泡*/
    handleDocumentClick(e) {
      let reference
      (reference = this.$refs.wrapper) && (reference = reference.children) && (reference = reference[0])
      const popper = this.$refs.popper

      if(!reference || !popper) {
        return this.showPopper = false
      }
      /**如果点击元素是 reference 或 popper 的子元素,则不隐藏气泡 */
      if(reference.contains(e.target) || popper.contains(e.target)) return
      /**关闭气泡 */
      this.showPopper = false
    }
},

完善 render 方法,同时在组件销毁时清理点击事件:

js 复制代码
render(h) {
    ... // 前面这部分代码保持不变
    if (!referenceVnode) return null
    /**
       referenceVnode 是我们传入 reference 插槽的第一个元素节点的虚拟 DOM(vnode)
       Vue 会将绑定在普通 html元素或组件上的 .native 事件回调函数添加在 vnode.data.on 中
       比如模板: <button @click="handleClick">click me </button> 转化成虚拟DOM:
       Vnode {
          tag: 'button',
          data: {
            on: {
              click: handleClick,
              ...
            }
          },
          ...
        }
      }
      
      这里我们自己往 vnode.data.on.click 里添加点击 reference 的事件回调函数来控制气泡显隐
      后续 Vue 用 diff 算法将虚拟DOM转化为真实元素时会自动绑定上我们添加的事件
     */
    const data = referenceVnode.data || (referenceVnode.data = {})
    const on = data.on || (data.on = {})
    const events = on.click  
    if(Array.isArray(events)) {
      events.push(this.doToggle)
    } else if(events) {
      on.click = [events, this.doToggle]
    } else {
      on.click = this.doToggle
    }
    /**点击页面其他地方关闭气泡*/
    document.addEventListener('click', this.handleDocumentClick)
    
    return h("span", [ 
    // ... 后面代码保持不变
},
...,
// 组件销毁时需要清理document事件监听函数
beforeDestroy() {
    document.removeEventListener('click', this.handleDocumentClick)
}

现在能够点击 reference 元素控制气泡显隐了,页面效果:

父组件 <template> 结构:

js 复制代码
 <my-popover title="这是一个标题">
     <p>这天气好热,快吃点甜甜的西瓜吧~</p>
     <template v-slot:reference>
        <el-button type="primary">click me</el-button>
     </template>
 </my-popover>

针对 trigger 的不同值绑定相应的事件触发函数

这里我们还是暂时只考虑 triggerclickhover 的情况

js 复制代码
props: {
    ...,
    trigger: {
      type: String,
      default: 'click',
      validator: value => ['click', 'hover'].indexOf(value) > -1
    },
    // trigger 为 hover 时移出指针,popover 气泡隐藏的延迟时间(ms)
    closeDelay: {
      type: Number,
      default: 200
    },
},
methods: {
    ...,
    // 指针移入 reference、popper 时的回调
    handleMouseEnter() {
      // 注: this.timer 更多充当一个变量,不需要绑定响应式
      // 所以 this._timer 并没有写在 data 选项里,
      clearTimeout(this._timer)
      this.showPopper = true
    },
    // 指针移出 reference、popper 时的回调
    handleMouseLeave() {
      this._timer = setTimeout(() => this.showPopper = false, this.closeDelay)
    },
    // 将为虚拟DOM添加事件的逻辑封装为一个函数
    addVnodeEvents(vnode, event, handler) {
      const data = vnode.data || (vnode.data = {})
      const on = data.on || (data.on = {})
      const events = on[event]
      if (Array.isArray(events)) {
        events.push(handler)
      } else if (events) {
        on[event] = [events, handler]
      } else {
        on[event] = handler
      }
    }
}

完善 render 方法,当 triggerhover 时,还需要为 popper 气泡元素添加 mouseentermouseout 事件回调函数

js 复制代码
render(h) {
    ... // 上面代码保持不变
    if (!referenceVnode) return null
    // 之前逻辑注释掉
    // const data = referenceVnode.data || (referenceVnode.data = {})
    // const on = data.on || (data.on = {})
    // const events = on.click
    // if (Array.isArray(events)) {
    //   events.push(this.doToggle)
    // } else if (events) {
    //   on.click = [events, this.doToggle]
    // } else {
    //   on.click = this.doToggle
    // }
    /**点击页面其他地方关闭气泡*/
    // document.addEventListener("click", this.handleDocumentClick)
    
    if (this.trigger === "click") {
      this.addVnodeEvents(referenceVnode, "click", this.doToggle)
      document.addEventListener("click", this.handleDocumentClick)
    } else {
      document.removeEventListener("click", this.handleDocumentClick)
      // 为 reference 元素绑定指针移入、移出事件
      if (this.trigger === "hover") {
        this.addVnodeEvents(referenceVnode, "mouseenter", this.handleMouseEnter)
        this.addVnodeEvents(referenceVnode, "mouseleave", this.handleMouseLeave)
      }
    }
    
    return h("span", [
      h(
        "div",
        {
          ref: "popper",
          class: ["popover"],
          // v-show 控制 popper 显隐
          directives: [
            {
              name: "show",
              value: this.showPopper
            }
          ],
          on: {
            // 为气泡弹框绑定指针移入、移出事件
            mouseenter: this.trigger === "hover" ? this.handleMouseEnter : () => {},
            mouseleave: this.trigger === "hover" ? this.handleMouseLeave : () => {}
          }
        },
        [
          // popover 标题
          this.title ? h("div", {class: ["popover__title"]}, this.title) : null,
          // popper 内容
          this.$slots.default || this.content
        ]
      ),
      h("span", {class: ["reference-wrapper"], ref: "wrapper"}, [referenceVnode])
    ])
}

引入 @popperjs/core 库,修正气泡位置

先下载 @popperjs/core 依赖,popperjs 使用文档

js 复制代码
npm i @popperjs/core

在项目中引入 @popperjs/core

js 复制代码
import { createPopper } from '@popperjs/core'

添加侦听器,监听 showPopper 的改变,将 popper 气泡位置定位到 reference 下方

js 复制代码
import { createPopper } from '@popperjs/core'
...,
watch: {
    showPopper(val) {
        val ? this.createPopper() : this.destroyPopper()
    },
}
...,
methods: {
    ...,
    async createPopper() {
      // 需要等 DOM 更新完成后再定位 popper,此时 popper 的 style.display !== 'none'
      await this.$nextTick()
      let referenceElm
      (referenceElm = this.$refs.wrapper) && (referenceElm = referenceElm.children) && (referenceElm = referenceElm[0])
      const popperElm = this.$refs.popper
      if(!referenceElm || !popperElm) return
      // 将popper气泡元素插入到 document.body 中
      document.body.appendChild(popperElm)
      // 将 popper 定位到 reference 下方
      this.popperJS = createPopper(referenceElm, popperElm, {
          // 使 popper 与 reference 保持一定距离
          modifiers: [
            {
              name: 'offset',
              options: {
                offset: [0, 10],
              },
            }
          ]
        })
    },
    // 销毁 
    destroyPopper() {
      if(!this.popperJS) return
      this.popperJS.destroy()
      this.popperJS = null
    }
}

页面效果:

为 popper 添加指向箭头

需要将 div.popper__arrow 元素(气泡框箭头元素,相关样式在文章前面) 插入到 popper 元素中,此外需要为 div.popper__arrow 添加 data-popper-arrow 属性(popperjs 会自动将 popper 的子元素中具有 data-popper-arrow 属性的元素作为箭头元素,定位 popper 的同时会使箭头元素指向 reference 中心)

js 复制代码
// 在 popper 中添加 div.popper_arrow
render(h) {
    ... // 上面部分代码保持不变
    return h("span", [
      h(
        "div",
        {
          ref: "popper",
          class: ["popover"],
          // v-show 控制 popper 显隐
          directives: [
            {
              name: "show",
              value: this.showPopper
            }
          ],
          on: {
            mouseenter: this.trigger === "hover" ? this.handleMouseEnter : () => {},
            mouseleave: this.trigger === "hover" ? this.handleMouseLeave : () => {}
          }
        },
        [
          this.title ? h("div", {class: ["popover__title"]}, this.title) : null,
          this.$slots.default || this.content,
          // 插入了 div.popper__arrow 箭头元素
          h('div', { class: 'popper__arrow', attrs: { 'data-popper-arrow': '' } })
        ]
      ),
      h("span", {class: ["reference-wrapper"], ref: "wrapper"}, [referenceVnode]),
    ])
}

页面效果:

支持传入 props:placement、offset 改变气泡位置、气泡距 reference 偏移

js 复制代码
props: {
    ...,
    placement: {
      type: String,
      default: 'bottom',
      validator: value => /^(top|bottom|left|right)(-start|-end)?$/g.test(value)
    },
    offset: {
      type: Number,
      default: 10
    }
}
...,
methods: {
    ...,
    async createPopper() {
      if (!/^(top|bottom|left|right)(-start|-end)?$/g.test(this.placement)) {
        return;
      }
      await this.$nextTick()
      let referenceElm
        (referenceElm = this.$refs.wrapper) && (referenceElm = referenceElm.children) && (referenceElm = referenceElm[0])
        const popperElm = this.$refs.popper
        if(!referenceElm || !popperElm) return
        document.body.appendChild(popperElm)
        this.popperJS = createPopper(referenceElm, popperElm, {
          placement: this.placement, // 根据 props.placement 
          modifiers: [
            {
              name: 'offset',
              options: {
                offset: [0, this.offset],
              },
            }
          ]
        })
    },
}

支持 v-model 和 trigger === 'manual' 控制气泡显隐

js 复制代码
props:{
    ...,
    trigger: {
      type: String,
      default: "click",
      // validator: (value) => ["click", "hover"].indexOf(value) > -1
      validator: (value) => ["click", "hover", "manual"].indexOf(value) > -1
    },
    ...,
    value: Boolean,
},
watch: {
    showPopper(val) {
        val ? this.createPopper() : this.destroyPopper()
        this.$emit('input', val) // +
    }
},
...,
created() {
    // 在 created 中我们自己定义一个侦听器监听 props.value 的值
    // 因为在 created 钩子中:监听 showPopper 的侦听器已经定义完成
    // 所以当为 valWatcher 设置 immediate 为 true 时
    // 一定能够触发 watch.showPopper 监听器的回调函数,然后气泡出现并定位到正确位置
    if (!this.valWatcher) {
      this.valWatcher = this.$watch(
        "value",
        function (val) {
          this.showPopper = val
        },
        {immediate: true}
      )
    }
},
...

完整代码

js 复制代码
<script>
import {createPopper} from "@popperjs/core"
export default {
  name: "MyPopover",
  props: {
    content: String,
    title: String,
    trigger: {
      type: String,
      default: "click",
      validator: (value) => ["click", "hover", "manual"].indexOf(value) > -1
    },
    // trigger 为 hover 时移出指针,popover 气泡隐藏的延迟时间(ms)
    closeDelay: {
      type: Number,
      default: 200
    },
    placement: {
      type: String,
      default: "bottom",
      validator: (value) => /^(top|bottom|left|right)(-start|-end)?$/g.test(value)
    },
    offset: {
      type: Number,
      default: 10
    },
    value: Boolean
  },
  watch: {
    showPopper(val) {
      val ? this.createPopper() : this.destroyPopper()
      this.$emit("input", val)
    }
  },
  data() {
    return {
      showPopper: false // 控制气泡元素显隐
    }
  },
  render(h) {
    const referenceSlot = this.$slots.reference // reference 插槽内容
    // 如果没有传入 reference 插槽内容,则不渲染组件
    if (!Array.isArray(referenceSlot) || referenceSlot.length === 0) return null
    let referenceVnode, vnode
    for (let i = 0, l = referenceSlot.length; i < l; i++) {
      vnode = referenceSlot[i]
      // 虚拟 DOM 有 tag 值,是元素节点
      if (vnode.tag) {
        referenceVnode = vnode
        break
      }
    }
    if (!referenceVnode) return null
    if (this.trigger === "click") {
      this.addVnodeEvents(referenceVnode, "click", this.doToggle)
      document.addEventListener("click", this.handleDocumentClick)
    } else {
      document.removeEventListener("click", this.handleDocumentClick)
      // 为 reference 元素绑定指针移入、移出事件
      if (this.trigger === "hover") {
        this.addVnodeEvents(referenceVnode, "mouseenter", this.handleMouseEnter)
        this.addVnodeEvents(referenceVnode, "mouseleave", this.handleMouseLeave)
      }
    }
    return h("span", [
      h(
        "div",
        {
          ref: "popper",
          class: ["popover"],
          // v-show 控制 popper 显隐
          directives: [
            {
              name: "show",
              value: this.showPopper
            }
          ],
          on: {
            mouseenter: this.trigger === "hover" ? this.handleMouseEnter : () => {},
            mouseleave: this.trigger === "hover" ? this.handleMouseLeave : () => {}
          }
        },
        [
          this.title ? h("div", {class: ["popover__title"]}, this.title) : null,
          this.$slots.default || this.content,
          // 插入了 div.popper__arrow 箭头元素
          h("div", {class: "popper__arrow", attrs: {"data-popper-arrow": ""}})
        ]
      ),
      h("span", {class: ["reference-wrapper"], ref: "wrapper"}, [referenceVnode])
    ])
  },
  methods: {
    /** 点击 reference 切换气泡显隐*/
    doToggle() {
      this.showPopper = !this.showPopper
    },
    /** 点击页面其他地方隐藏气泡*/
    handleDocumentClick(e) {
      let reference
      ;(reference = this.$refs.wrapper) && (reference = reference.children) && (reference = reference[0])
      const popper = this.$refs.popper

      if (!reference || !popper) {
        return (this.showPopper = false)
      }
      /**如果点击元素是 reference 或 popper 的子元素,则不隐藏气泡 */
      if (reference.contains(e.target) || popper.contains(e.target)) return
      /**关闭气泡 */
      this.showPopper = false
    },
    // 指针移入 reference、popper 时的回调
    handleMouseEnter() {
      // 注: this.timer 更多充当一个变量,不需要绑定响应式
      // 所以 this._timer 并没有写在 data 选项里,
      clearTimeout(this._timer)
      this.showPopper = true
    },
    // 指针移出 reference、popper 时的回调
    handleMouseLeave() {
      this._timer = setTimeout(() => (this.showPopper = false), this.closeDelay)
    },
    // 将为虚拟DOM添加事件的逻辑封装为一个函数
    addVnodeEvents(vnode, event, handler) {
      const data = vnode.data || (vnode.data = {})
      const on = data.on || (data.on = {})
      const events = on[event]
      if (Array.isArray(events)) {
        events.push(handler)
      } else if (events) {
        on[event] = [events, handler]
      } else {
        on[event] = handler
      }
    },
    async createPopper() {
      if (!/^(top|bottom|left|right)(-start|-end)?$/g.test(this.placement)) {
        return
      }
      await this.$nextTick()
      let referenceElm
      ;(referenceElm = this.$refs.wrapper) && (referenceElm = referenceElm.children) && (referenceElm = referenceElm[0])
      const popperElm = this.$refs.popper
      if (!referenceElm || !popperElm) return
      document.body.appendChild(popperElm)
      this.popperJS = createPopper(referenceElm, popperElm, {
        placement: this.placement, // 根据 props.placement
        modifiers: [
          {
            name: "offset",
            options: {
              offset: [0, this.offset]
            }
          }
        ]
      })
    },
    // 销毁
    destroyPopper() {
      if (!this.popperJS) return
      this.popperJS.destroy()
      this.popperJS = null
    }
  },
  created() {
    if (!this.valWatcher) {
      this.valWatcher = this.$watch(
        "value",
        function (val) {
          this.showPopper = val
        },
        {immediate: true}
      )
    }
  },
  beforeDestroy() {
    document.removeEventListener("click", this.handleDocumentClick)
  }
}
</script>
<style lang="scss" scoped>
.popover {
  position: absolute;
  background: #fff;
  min-width: 150px;
  border-radius: 4px;
  border: 1px solid #ebeef5;
  padding: 12px;
  z-index: 2000;
  color: #606266;
  line-height: 1.4;
  text-align: justify;
  font-size: 14px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  word-break: break-all;
  z-index: 9999;
}

.popover__title {
  color: #303133;
  font-size: 16px;
  line-height: 1;
  margin-bottom: 12px;
}
.popper__arrow {
  position: absolute;
  width: 8px;
  height: 8px;
}

.popover .popper__arrow::after {
  content: "";
  position: absolute;
  width: 8px;
  height: 8px;
  background-color: #fff;
  border: 1px solid #e4e7ed;
  transform: rotate(45deg);
}
.popover[data-popper-placement^="bottom"] .popper__arrow {
  top: -4px;
}

.popover[data-popper-placement^="bottom"] .popper__arrow::after {
  border-right-color: transparent;
  border-bottom-color: transparent;
  border-top-left-radius: 2px;
}

.popover[data-popper-placement^="top"] .popper__arrow {
  bottom: -4px;
}

.popover[data-popper-placement^="top"] .popper__arrow::after {
  border-left-color: transparent;
  border-top-color: transparent;
  border-bottom-right-radius: 2px;
}

.popover[data-popper-placement^="left"] .popper__arrow {
  right: -4px;
}

.popover[data-popper-placement^="left"] .popper__arrow::after {
  border-left-color: transparent;
  border-bottom-color: transparent;
  border-top-right-radius: 2px;
}

.popover[data-popper-placement^="right"] .popper__arrow {
  left: -4px;
}

.popover[data-popper-placement^="right"] .popper__arrow::after {
  border-right-color: transparent;
  border-top-color: transparent;
  border-bottom-left-radius: 2px;
}
</style>

最后想说的话

其实与其说这是一篇教学文章,不如更像是我自己记录的一个学习笔记,谢谢大家观看!

相关推荐
qq_424409194 小时前
uniapp的app项目,某个页面长时间无操作,返回首页
前端·vue.js·uni-app
我在北京coding4 小时前
element el-table渲染二维对象数组
前端·javascript·vue.js
布兰妮甜4 小时前
Vue+ElementUI聊天室开发指南
前端·javascript·vue.js·elementui
SevgiliD4 小时前
el-button传入icon用法可能会出现的问题
前端·javascript·vue.js
我在北京coding4 小时前
Element-Plus-全局自动引入图标组件,无需每次import
前端·javascript·vue.js
鱼 空4 小时前
解决el-table右下角被挡住部分
javascript·vue.js·elementui
01传说5 小时前
vue3 配置安装 pnpm 报错 已解决
java·前端·vue.js·前端框架·npm·node.js
sunbyte7 小时前
50天50个小项目 (Vue3 + Tailwindcss V4) ✨ | DoubleVerticalSlider(双垂直滑块)
前端·javascript·css·vue.js·vue
拾光拾趣录8 小时前
虚拟DOM
前端·vue.js·dom
合作小小程序员小小店9 小时前
web网页,在线%食谱推荐系统%分析系统demo,基于vscode,uniapp,vue,java,jdk,springboot,mysql数据库
vue.js·spring boot·vscode·spring·uni-app