普通视图

发现新文章,点击刷新页面。
昨天 — 2025年7月14日技术

小效果--多行文本溢出出现省略号

2025年7月14日 17:00

实现多行文本溢出溢出省略号

最近遇到一个小效果,多行文本溢出时候出现'...'省略号。 实现的方法有两种:

  • webkit内核的浏览器本身支持的css指令。
  • 兼容性更好的辅助元素实现。

法一:webkit内核css实现

我们只需要加上以下几行css即可:

display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;

这个方法简单高效,你要非说兼容性多差也不至于,顶多在不是webkit内核的浏览器不出现...,但依旧会隐藏超出的内容。放心用!

追求完美的请看下一种方法。

法二:伪元素辅助实现

我们先看思路:

  • 需要利用两个辅助元素: 1.一个专门放...的div 2.一个元素将文本顶下去。
  • 将...元素右浮动,此时元素会被文字环绕,这个时候就想办法将...给放置到最下方如果单独使用margin-top,那么文字会避开...的maigin,所以设置给'...'设置margin-top无效。
  • 这个时候就需要做出来一个伪元素,将我们的整个文本顶下去,然后再设置文本的margin-top为负值,调到正确的位置即可。

html结构

<!DOCTYPE html>
<html>
    <head>
      <meta charset="utf-8">
      <title></title>
      <style>
        .box{
                        width:200px;
                        height: 104px;
background-color: skyblue;
margin: 200px auto;
padding: 10px;
overflow: hidden;  /* 记得溢出隐藏*/
}
      </style>
    </head>
    
<body>
<div class="box">
    <div class="txt">来一点中文的多行文本溢出处理手机号介绍的就哈市换手机号加时间到货时间黄金时代回到家撒活动结合实际电话实践活动久啊圣诞节函数换手机号圣诞节啊</span>
</div>
</body>
        
<html>       

看下效果:

Snipaste_2025-07-14_16-52-26.png

接下来我们一点点实现:

  • 我们需要加一个元素专门放'...',然后给它浮动到右侧。
<!DOCTYPE html>
<html>
    <head>
      <meta charset="utf-8">
      <title></title>
      <style>
        .box{
                  width:200px;
                  height: 104px;
background-color: skyblue;
margin: 200px auto;
padding: 10px;
overflow: hidden;  /* 记得溢出隐藏*/
}
                
          /* 省略元素 */
.more{
                    float: right;/* 主要是使用浮动,让文字环绕 */
                    margin-right: 5px;
}
      </style>
    </head>
    
<body>
<div class="box">
                    <div class="more">...</div>
    <div class="txt">来一点中文的多行文本溢出处理手机号介绍的就哈市换手机号加时间到货时间黄金时代回到家撒活动结合实际电话实践活动久啊圣诞节函数换手机号圣诞节啊</span>
</div>
</body>
        
<html>       

现在效果是这样的:

Snipaste_2025-07-14_16-56-49.png

我们只需要想办法将...给去往下方即可,可以使用一个伪元素,将内容顶下去,然后再调整文本margin-top为负数

最终代码

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>

<style>
.box{
width:200px;
height: 104px;
background-color: skyblue;
margin: 200px auto;
padding: 10px;
overflow: hidden;  /* 记得溢出隐藏*/
}

.box .txt{
margin-top: -20px;
} 



/* 辅助伪元素 */
.box::before{
content: "";
display: block;
height: 90px;
}

/* 省略元素 */
.more{
float: right;/* 主要是使用浮动,让文字环绕 */
margin-right: 5px;
}
</style>
</head>
<body>
<div class="box">
<div class="more">...</div>
<div class="txt">来一点中文的多行文本溢出处理手机号介绍的就哈市换手机号加时间到货时间黄金时代回到家撒活动结合实际电话实践活动久啊圣诞节函数换手机号圣诞节啊</span>
</div>
</body>
</html>

再看下效果:

Snipaste_2025-07-14_16-59-03.png


朋友,我是喝西瓜汁的兔叽,感谢您的阅读,衷心祝福您和家人身体健康,事事顺心。

手摸手带你彻底搞懂Vue的响应式原理

作者 江上暮云
2025年7月14日 16:59

你是否好奇 Vue 为什么能在你修改变量时自动更新页面?

本文将手把手带你实现一个最简版的 Vue 响应式系统,从原理到代码,彻底讲透它的核心机制。

响应式系统的核心概念

什么是响应式

一句话概括就是:系统自动追踪数据的变化,并实时同步更新依赖该数据的视图或逻辑

大白话说就是:当你修改了变量,vue就会自动帮你更新页面,不用你手动改DOM

核心概念

在Vue中,它的本质是通过追踪数据和副作用函数之间的依赖关系,实现自动更新

那么它是怎么实现的呢?

依赖收集

当某个响应式变量被读取时,vue会记录当前是谁在用这个值,这个‘谁’通常是一个副作用函数,也就是effect

// 想象这个 effect 会订阅 count
effect(() => {
  console.log(count.value)
})

这段代码中effect传入的参数是一个函数,而这个函数中读取了count,那么,vue就会把这个函数,也就是副作用函数保存起来

当响应式变量的值发生变化时(如count.value++),Vue 会自动找到依赖它的所有副作用函数,并重新执行它们,从而更新视图或逻辑

这种机制就是发布订阅模式

  • 依赖收集 = 订阅
  • 触发更新 = 发布

触发更新

当响应式对象的某个属性被修改时,Vue会找到依赖它的所有effect,并依次调用他们。比如:

count.value++    // 会触发上面订阅了 count 的 effect

响应式对象

Vue3中的reactive使用了es6的proxy来实现响应式代理

每当你对对象进行访问(get)或者修改(set)时,Vue 都能感知到这一行为,并自动进行依赖收集或触发更新

接下来我们来实现一个简单的响应式系统

最简实现

案例:

const count = ref(0)

effect(() => {
  console.log(count.value)
})

setTimeout(() => {
  count.value++
}, 1000)

接下来我们依次实现refeffect,来成功让count变化时自动调用effect中的函数

effect实现

// 定义一个全局对象,用来存储正在执行的副作用函数
let activeSub = null

class ReactiveEffect {
  constructor(public fn: Function) {}

  run() {
    // 将当前实例赋值给activeSub
    activeSub = this
    try {
      // 执行副作用函数
      return this.fn()
    } finally {
      // 将全局对象存储的副作用函数清空
      activeSub = null
    }
  }
}

function effect(fn) {
  const e = new ReactiveEffect(fn)

  e.run()
}

ref实现

这块代码比较复杂,我先把整体放出来,然后在分步骤解析

class RefImpl {
  _value;   // 当前的值
  subs;     // 链表头,用来存副作用函数
  subsTail; // 链表尾

  constructor(value) {
    this._value = value
  }

  get value() {
    if (activeSub) {
      trackRef(this)
    }
    return this._value
  }  // 当访问 .value 时,Vue就会收集这个依赖,记录哪个副作用函数用到了
  set value(newValue) {
    this._value = newValue
    triggerRef(this)
  }  // 当 .value 改变时,系统就会调用 triggerRef 通知所有依赖它的副作用函数去重新执行
}

function ref(value) {
  return new RefImpl(value)
}

// 收集依赖
function trackRef(dep) {
  if (activeSub) {  // 如果当前有正在运行的 effect 就与这个 ref 建立关联
    link(dep, activeSub)
  }
}

// 触发ref关联的依赖
function triggerRef(dep) {
  if (dep.subs) {  // 如果有关联的副作用函数便执行
    propagate(dep.subs)
  }
}

function link(dep, sub) {
  // 定义一个依赖关系的双向链表节点的结构
  let newLink = {
    sub,                // 订阅者,即副作用函数(表示谁依赖了这个ref)
    nextSub: undefined, // 链表中的下一个订阅者(副作用函数)
    prevSub: undefined, // 链表中的上一个订阅者
  }

  // 判断当前的 dep 是不是第一次被订阅(即 subsTail 为 null)
  if (dep.subsTail) {
    dep.subsTail.nextSub = newLink  // 将尾节点的nextSub指向新的节点(插入到尾部)
    newLink.prevSub = dep.subsTail  // 将这个新节点的prevSub指向原来的尾节点
    dep.subsTail = newLink          // 将原来的尾节点指向新节点
  } else {
    // 第一次被订阅,将头和尾都指向这个新节点
    dep.subs = newLink
    dep.subsTail = newLink
  }
}

function propagate(subs) {
  let link = subs          // 拿到链表头
  const queuedEffect = []  // 定义一个数组用来存储需要重新执行的副作用函数
  while (link) {           // 如果有值就去将副作用函数取出来,存入到数组
    const sub = link.sub
    queuedEffect.push(link.sub) // 将副作用函数追加到数组中
    link = link.nextSub         // 赋值为下一个链表
  }
  // 依次调用
  queuedEffect.forEach(effect => effect.run())
}

首先说一下什么是链表?

链表是一种基础的数据结构,用于按顺序存储元素,通过节点之间的指针连接来组织数据

我们为什么用链表而不是数组?来看下这两者的简单对比:

特性 数组(Array) 链表(当前用的结构)
添加依赖(effect) O(1) or O(n) ✅ O(1)(直接接到尾部)
删除依赖(effect) ❌ O(n) ✅ O(1)(通过指针)
遍历执行所有依赖 O(n) O(n)
实现复杂度 简单 略高

链表的设计可以让我们更高效的添加和移除副作用函数依赖

接下来我们看看在我们的代码中链表结构是怎么设计的:

let newLink = {
  sub,                // 订阅者,即副作用函数 effect 实例(表示谁依赖了这个ref)
  nextSub: undefined, // 下一个
  prevSub: undefined, // 上一个
}

它用来存储与当前响应式数据关联的副作用函数effect实例,sub是我们调用link函数时传递过来的全局定义的一个变量,用来存储正在执行的副作用函数effect实例,这一块的代码在effect实现那里

接下来看这段代码,我将每行都写上了注释,想必应该很清楚了

// 判断当前的 dep 是否已有订阅者(即是否已经建立过依赖)
if (dep.subsTail) {
  // 已经有订阅者,追加到链表尾部

  // 1. 让当前尾节点的 nextSub 指向新节点(连接尾部)
  dep.subsTail.nextSub = newLink

  // 2. 让新节点的 prevSub 指向旧尾部(形成双向链表)
  newLink.prevSub = dep.subsTail

  // 3. 更新 dep.subsTail,标记新的尾节点
  dep.subsTail = newLink
} else {
  // 没有任何订阅者,这是第一次收集依赖

  // 1. 让 subs(链表头)指向新节点
  dep.subs = newLink

  // 2. 同时 subsTail(链表尾)也指向它(链表中只有一个节点)
  dep.subsTail = newLink
}

dep也就是调用link函数时传递过来的ref实例,在RefImpl构造函数中,我们定义了subssubsTail,分别用于存储链表的头部和尾部

这样的话我们在修改ref响应式数据时就可以从自己身上拿到subs然后一路nextSub下去,就可以拿到所有关联的副作用函数effect实例啦

然后我们把这些函数全部执行一遍就可以了

也就是这一段代码

function propagate(subs) {
  let link = subs                 // 1. 从链表头开始遍历
  const queuedEffect = []         // 2. 用一个数组缓存所有需要触发的副作用函数

  while (link) {                  // 3. 遍历链表,直到 null
    const sub = link.sub           // 当前节点中的副作用函数
    queuedEffect.push(sub)         // 加入执行队列
    link = link.nextSub            // 移动到下一个节点
  }

  // 4. 依次执行所有副作用函数(即 effect.run())
  queuedEffect.forEach(effect => effect.run())
}

这样一个简易的响应式系统就完成啦

const count = ref(0)

effect(() => {
  console.log(count.value)
})

setTimeout(() => {
  count.value++
}, 1000)
  1. () => {console.log(count.value)}执行
  2. 触发countget
  3. 收集依赖执行link函数
  4. 一秒后执行count.value++
  5. 触发countset
  6. count的值++
  7. 执行propagate
  8. 拿到count实例中的subs,遍历执行收集的副作用函数
  9. 控制台再次打印count.value

总结

虽然这个响应式系统已经能自动追踪数据变化并更新副作用函数,但它仍然是一个极简版本,存在不少限制,比如:

  • 不支持嵌套 effect
  • 不支持复用 effect
  • 没有调度器(scheduler)

这些问题将在后续文章中继续优化和完善,欢迎关注 🌟

Echart饼图自动轮播效果封装

作者 wangpq
2025年7月14日 16:54

Echart饼图效果:

1752481355951.png

未封装轮播效果

饼图组件chart-pie-stats-list.vue

<template>
  <div class="chart-wrap flex">
    <div class="item one">
      <div class="chart" ref="chartRef"></div>
    </div>
    <div class="item one flex chart-text">
      <div class="flex flex-wrap w-100 column">
        <div
          class="item one flex column pt15 pb15 chart-text-item"
          v-for="(item, index) in dataObj.data"
          :key="index"
        >
          <div class="title flex col-center pb10">
            <div
              class="icon-circle mr10"
              :style="'background:' + color[index]"
            ></div>
            <div class="text-cont">
              <el-tooltip
                :content="item.name.toString()"
                effect="dark"
                placement="top"
              >
                <span class="span-text-over">{{ item.name }}</span>
              </el-tooltip>
            </div>
          </div>
          <div class="value-percent flex-column">
            <div
              class="value flex flex-1 pb10"
              v-if="fields.count"
            >
              <div class="text-cont flex row-right">
                <el-tooltip
                  :content="item.value.toString()"
                  effect="dark"
                  placement="top"
                >
                  <span class="span-text-over"
                    >{{ parseFloat(item.value || 0).toLocaleString()
                    }}<span v-if="fields.unit && unit">{{ unit }}</span></span
                  >
                </el-tooltip>
              </div>
            </div>
            <div
              :style="[{ color: color[index % color.length] }]"
              class="percent flex flex-1 row-left"
              v-if="fields.percent"
            >
              <div class="text-cont flex row-right">
                <el-tooltip
                  :content="item.percent.toString() + '%'"
                  effect="dark"
                  placement="top"
                >
                  <span
                    class="span-text-over"
                    v-if="
                      Number(item.percent) === 100 || Number(item.percent) === 0
                    "
                    :style="'color:#686868'"
                  >
                    {{ item.percent }}%
                  </span>
                  <span class="span-text-over" :style="'color:#686868'" v-else
                    >{{ Number(item.percent) }}%</span
                  >
                </el-tooltip>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import * as echarts from "echarts";
export default {
  props: {
    dataObj: {
      type: Object,
      default() {
        return {};
      },
    },
    title: {
      type: String,
      default: "",
    },
    color: {
      type: Array,
      default: () => ["#409EFF", "#67c23a", "#ebb563"],
    },
    // 字段显示
    fields: {
      type: Object,
      default: () => ({
        count: true,
        unit: true,
        percent: true,
      }),
    },
    // 数据单位
    unit: {
      type: String,
      default: "人次",
    },
    // 自动播放时间间隔,默认2s
    autoTime: {
      type: Number,
      default: 2,
    },
  },
  data() {
    return {
      option: {
        tooltip: {
          trigger: "item",
          formatter: (item) => {
            if (item.data.name) {
              return (
                item.marker +
                item.name +
                " " +
                (this.fields.count && item.value >= 0
                  ? `${item.value}${
                      this.fields.unit && this.unit ? this.unit : ""
                    }`
                  : "") +
                " " +
                (this.fields.percent && item.percent ? item.percent + "%" : "")
              );
            }
          },
        },
        legend: {
          orient: "vertical",
          left: "left",
          show: false,
        },
        series: [
          {
            name: "",
            type: "pie",
            radius: ["78%", "70%"],
            label: {
              show: false,
              position: "center",
            },
            emphasis: {
              label: {
                show: true,
                fontSize: 28,
                fontWeight: "bold",
              },
            },
            labelLine: {
              show: false,
            },
            itemStyle: {
              borderRadius: 10,
            },
            data: [
              // { value: 1048, name: 'Search Engine' },
              // { value: 735, name: 'Direct' },
            ],
          },
        ],
        color: this.color,
      },
      currentIndex: -1,
      myChart: null,
      timer: null,
    };
  },
  watch: {
    dataObj: {
      handler() {
        this.initData();
      },
      deep: true,
    },
  },
  mounted() {
    this.initData();
  },
  methods: {
    initData() {
      this.$set(this.option.series[0], "data", this.dataObj.data);
      if (this.title) {
        this.option.title.text = this.title;
      }
      let chartDom = this.$refs.chartRef;
      this.myChart = echarts.init(chartDom);
      this.option && this.myChart.setOption(this.option);
      let chartTarget = this.$refs.chartRef;
      this.autoPlay();
      chartTarget.addEventListener("mouseenter", () => {
        this.stopLightChart();
      });
      chartTarget.addEventListener("mouseleave", () => {
        this.autoPlay();
      });
    },
    autoPlay() {
      this.autoLightChart();
      this.timer = setInterval(() => {
        this.autoLightChart();
      }, this.autoTime * 1000);
    },
    autoLightChart() {
      let dataLen = this.option.series[0].data.length;
      const textItems = document.querySelectorAll(".chart-text-item");
      // 取消之前高亮的图形
      this.myChart.dispatchAction({
        type: "downplay",
        seriesIndex: 0,
        dataIndex: this.currentIndex,
      });
      this.currentIndex = (this.currentIndex + 1) % dataLen;
      // 高亮当前图形
      this.myChart.dispatchAction({
        type: "highlight",
        seriesIndex: 0,
        dataIndex: this.currentIndex,
      });
      // 显示 tooltip
      this.myChart.dispatchAction({
        type: "showTip",
        seriesIndex: 0,
        dataIndex: this.currentIndex,
      });
      textItems.forEach((item, index) => {
        if (index === this.currentIndex) {
          item.classList.add("active");
        } else {
          item.classList.remove("active");
        }
      });
    },
    stopLightChart() {
      clearInterval(this.timer);
      let dataLen = this.option.series[0].data.length;
      const textItems = document.querySelectorAll(".chart-text-item");
      dataLen.forEach((item, index) => {
        // 取消之前高亮的图形
        this.myChart.dispatchAction({
          type: "downplay",
          seriesIndex: 0,
          dataIndex: index,
        });
      });
      textItems.forEach((item, index) => {
        item.classList.contains("active") && item.classList.remove("active");
      });
    },
  },
  destroyed() {
    this.stopLightChart();
  },
};
</script>
<style lang="scss" scoped>
.chart-wrap {
  width: 100%;

  .chart {
    height: 200px;
    width: 100%;
  }

  .chart-text {
    overflow: hidden;

    .title {
      .icon-circle {
        width: 8px;
        height: 8px;
      }
    }
  }
}

.chart-text-item {
  padding: 20px;
  opacity: 0.4;
  transition: all 0.4s;
  border-radius: 10px;
  transform: scale(0.99);

  &.active {
    opacity: 1;
    transform: scale(1.01);
  }
}
</style>

封装轮播效果后

将自动轮播的过程封装为一个js方法调用

tool-pie.js

/**
 *  echarts tooltip 自动轮播
 *  @param myChart  //初始化echarts的实例
 *  @param num      //类目数量(原因:循环时达到最大值后,使其从头开始循环)
 *  @param time     //轮播间隔时长
 */
export function autoHover(myChart, num = 12, time = 2000, extra = { enable: true, normal: ".chart-text-item", active: 'active' }) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // echarts实例
      let echartsInstance = myChart.node;
      // 开启自动轮播
      let gInterVal = startAuto(myChart.chart, num, time, extra)

      // /* 鼠标位于触发点关闭自动轮播,离开触发点开启自动轮播 */
      echartsInstance.addEventListener("mouseenter", function () {
        stopAuto(gInterVal, myChart.chart, num, extra)
      })
      echartsInstance.addEventListener("mouseleave", function () {
        gInterVal = startAuto(myChart.chart, num, time, extra)
      })

      let dispose = () => {
        return stopAuto(gInterVal, myChart.chart, num, extra)
      }
      resolve(dispose)
    })
  })
}

// 开启自动轮播
function startAuto(myChart, num, time, extra) {
  let currentIndex = 0
  activeChart(myChart, currentIndex, num, extra)
  let timeTicket = null
  timeTicket && clearInterval(timeTicket)
  timeTicket = setInterval(() => {
    currentIndex = currentIndex + 1
    if (currentIndex >= num) {
      currentIndex = 0
    }
    activeChart(myChart, currentIndex, num, extra)
  }, time)
  return timeTicket
}

function activeChart(myChart, currentIndex, num, extra) {
  // 取消之前高亮的图形
  for (let i = 0; i < num; i++) {
    myChart.dispatchAction({
      type: 'downplay',
      seriesIndex: 0,
      dataIndex: i
    })
  }
  // 高亮当前图形
  myChart.dispatchAction({
    type: 'highlight',
    seriesIndex: 0,
    dataIndex: currentIndex
  })

  // 显示 tooltip
  myChart.dispatchAction({
    type: "showTip",
    seriesIndex: 0,
    dataIndex: currentIndex
  });

  // 建议同一个项目统一风格,方便处理
  if(extra?.enable){
    const textItems = document.querySelectorAll(extra.normal);
    textItems.forEach((item, index) => {
      if (index === currentIndex) {
        item.classList.add(extra.active);
      } else {
        item.classList.remove(extra.active);
      }
    });
  }

}

// 销毁自动轮播
function stopAuto(timeTicket, myChart, num, extra) {
  return new Promise((resolve, reject) => {
    for (let i = 0; i < num; i++) {
      myChart.dispatchAction({
        type: 'downplay',
        seriesIndex: 0,
        dataIndex: i
      })
    }

    // 建议同一个项目统一风格,方便处理
    if(extra?.enable){
      const textItems = document.querySelectorAll(extra.normal);
      textItems.forEach((item, index) => {
        item.classList.contains(extra.active) && item.classList.remove(extra.active);
      });
    }

    timeTicket && clearInterval(timeTicket)
    resolve()
  })

}

export default {
  autoHover
}

使用 tool-pie.js 的 chart-pie-stats-list.vue

<!--
 * @Descripttion: 会员饼图
 * @Author: wang pingqi
 * @Date: 2023-11-15 14:46:52
 * @LastEditors: wang ping qi
 * @LastEditTime: 2025-07-14 16:06:02
-->
<template>
  <div class="chart-wrap flex">
    <div class="item one">
      <div class="chart" ref="chartRef"></div>
    </div>
    <div class="item one flex chart-text">
      <div class="flex flex-wrap w-100 column">
        <div
          class="item one flex column pt15 pb15 chart-text-item"
          v-for="(item, index) in dataObj.data"
          :key="index"
        >
          <div class="title flex col-center pb10">
            <div
              class="icon-circle mr10"
              :style="'background:' + color[index]"
            ></div>
            <div class="text-cont">
              <el-tooltip
                :content="item.name.toString()"
                effect="dark"
                placement="top"
              >
                <span class="span-text-over">{{ item.name }}</span>
              </el-tooltip>
            </div>
          </div>
          <div class="value-percent flex-column">
            <div
              class="value flex flex-1 pb10"
              v-if="fields.count"
            >
              <div class="text-cont flex row-right">
                <el-tooltip
                  :content="item.value.toString()"
                  effect="dark"
                  placement="top"
                >
                  <span class="span-text-over"
                    >{{ parseFloat(item.value || 0).toLocaleString()
                    }}<span v-if="fields.unit && unit">{{ unit }}</span></span
                  >
                </el-tooltip>
              </div>
            </div>
            <div
              :style="[{ color: color[index % color.length] }]"
              class="percent flex flex-1 row-left"
              v-if="fields.percent"
            >
              <div class="text-cont flex row-right">
                <el-tooltip
                  :content="item.percent.toString() + '%'"
                  effect="dark"
                  placement="top"
                >
                  <span
                    class="span-text-over"
                    v-if="
                      Number(item.percent) === 100 || Number(item.percent) === 0
                    "
                    :style="'color:#686868'"
                  >
                    {{ item.percent }}%
                  </span>
                  <span class="span-text-over" :style="'color:#686868'" v-else
                    >{{ Number(item.percent) }}%</span
                  >
                </el-tooltip>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import tools from "@/utils/tool-pie";
import * as echarts from "echarts";
export default {
  props: {
    dataObj: {
      type: Object,
      default() {
        return {};
      },
    },
    title: {
      type: String,
      default: "",
    },
    color: {
      type: Array,
      default: () => ["#409EFF", "#67c23a", "#ebb563"],
    },
    // 字段显示
    fields: {
      type: Object,
      default: () => ({
        count: true,
        unit: true,
        percent: true,
      }),
    },
    // 数据单位
    unit: {
      type: String,
      default: "人次",
    },
    // 自动播放时间间隔,默认2s
    autoTime: {
      type: Number,
      default: 2,
    },
  },
  data() {
    return {
      option: {
        tooltip: {
          trigger: "item",
          formatter: (item) => {
            if (item.data.name) {
              return (
                item.marker +
                item.name +
                " " +
                (this.fields.count && item.value >= 0
                  ? `${item.value}${
                      this.fields.unit && this.unit ? this.unit : ""
                    }`
                  : "") +
                " " +
                (this.fields.percent && item.percent ? item.percent + "%" : "")
              );
            }
          },
        },
        legend: {
          orient: "vertical",
          left: "left",
          show: false,
        },
        series: [
          {
            name: "",
            type: "pie",
            radius: ["78%", "70%"],
            label: {
              show: false,
              position: "center",
            },
            emphasis: {
              label: {
                show: true,
                fontSize: 28,
                fontWeight: "bold",
              },
            },
            labelLine: {
              show: false,
            },
            itemStyle: {
              borderRadius: 10,
            },
            data: [
              // { value: 1048, name: 'Search Engine' },
              // { value: 735, name: 'Direct' },
            ],
          },
        ],
        color: this.color,
      },
      myChart: null,
      tools: null,
    };
  },
  watch: {
    dataObj: {
      handler() {
        this.initData();
      },
      deep: true,
    },
  },
  mounted() {
    this.initData();
  },
  methods: {
    initData() {
      this.$set(this.option.series[0], "data", this.dataObj.data);
      if (this.title) {
        this.option.title.text = this.title;
      }
      let chartDom = this.$refs.chartRef;
      this.myChart = echarts.init(chartDom);
      this.option && this.myChart.setOption(this.option);

      if(this.$refs.chartRef && !this.tools ){
        let dataLength = this.option.series[0].data.length || 0
        tools.autoHover(
          {
            node : this.$refs.chartRef,
            chart : this.myChart,
          },
          dataLength,
          2000
        ).then((tools) => {
          this.tools = tools
        });
      }
    }
  },
  destroyed() {
    if (this.tools) this.tools()
  },
};
</script>

<style lang="scss" scoped>
.chart-wrap {
  width: 100%;

  .chart {
    height: 200px;
    width: 100%;
  }

  .chart-text {
    overflow: hidden;

    .title {
      .icon-circle {
        width: 8px;
        height: 8px;
      }
    }
  }
}

.chart-text-item {
  padding: 20px;
  opacity: 0.4;
  transition: all 0.4s;
  border-radius: 10px;
  transform: scale(0.99);

  &.active {
    opacity: 1;
    transform: scale(1.01);
  }
}
</style>

调用 chart-pie-stats-list.vue

<template>
    <chartPieStatsList :dataObj="chartData2" />
</template>

<script>
import chartPieStatsList from "./components/chart-pie-stats-list";
export default {
  data() {
    return {
      chartData2: {
        loading: true,
        title: "性别比例",
        type: "pie",
        radius: ["50%", "50%"],
        data: [
          {
            value: 0,
            percent: 0,
            name: "男",
          },
          {
            value: 0,
            percent: 0,
            name: "女",
          },
        ],
      },
    }
  }
}
</script>

【Vue源码学习】Vue新手友好!为什么vue2 this能够直接获取到data和methods中的属性?

2025年7月14日 16:44

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

这是源码共读的第23期,链接:【若川视野 x 源码共读】第23期 | 为什么 Vue2 this 能够直接获取到 data 和 methods

1. 主题:

为什么vue2 this能够直接获取到data和methods?

源码解读原文:为什么 Vue2 this 能够直接获取到 data 和 methods ? 源码揭秘!

2. 源码解读补充:

如有不足或错误之处,欢迎各位大佬指正!

2.1 Vue 构造函数

function Vue (options) {
    if (!(this instanceof Vue)
    ) {
        warn('Vue is a constructor and should be called with the `new` keyword');
    }
    this._init(options);
}
// 初始化
initMixin(Vue);
stateMixin(Vue);
eventsMixin(Vue);
lifecycleMixin(Vue);
renderMixin(Vue);

this._init(options); 这一行指的是,调用实例的 _init 方法,实例上没有定义,沿着原型链,找到实例的构造函数 Vue 上的 _init 方法。

2.2 _init 初始化函数

// 代码有删减
function initMixin (Vue) {
    Vue.prototype._init = function (options) {
      var vm = this;

      // ……

      //  初始化状态
      initState(vm);
      // ……
    };
}

原型链和原型: 每一个对象,都有一个 __proto__ 属性,它指向该对象的构造函数的 prototype 对象,这个 prototype 就是该对象的原型对象。而这个层层嵌套的链式关系,就是原型链。调用该对象的方法或属性时,在自身属性上找不到就会沿着原型链查找。

initMixin(Vue) 这个方法里,给构造函数 Vueprototype 属性添加了 _init 方法。

2.3 initState 初始化状态

function initState (vm) {
    vm._watchers = [];
    var opts = vm.$options;
    if (opts.props) { initProps(vm, opts.props); }
    // 有传入 methods,初始化方法
    if (opts.methods) { initMethods(vm, opts.methods); }
    // 有传入 data,初始化 data
    if (opts.data) {
      initData(vm);
    } else {
      observe(vm._data = {}, true /* asRootData */);
    }
    if (opts.computed) { initComputed(vm, opts.computed); }
    if (opts.watch && opts.watch !== nativeWatch) {
      initWatch(vm, opts.watch);
    }
}

初始化顺序:props → methods → data → computed → watch。

2.4 initMethods 初始化方法

function initMethods (vm, methods) {
    var props = vm.$options.props;
    for (var key in methods) {
      {
        // 这里是对methods中方法的一些判断,判断是否是函数、命名是否与props中冲突、命名是否是保留字段
      }
      vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm);
    }
}

这里对methods中定义的合法的方法遍历,并给 vm 实例添加同名方法,赋值为绑定 this 指向到 vm 实例本身的原方法。如比便可以直接通过 this.xxx() 调用methods中的方法了。

关键在于 bind(methods[key], vm)

2.5 initData 初始化 data

function initData (vm) {
    var data = vm.$options.data;
    data = vm._data = typeof data === 'function'
      ? getData(data, vm)
      : data || {};
    if (!isPlainObject(data)) { // 这里校验data是否返回一个对象
      // ……
    }
    // proxy data on instance
    var keys = Object.keys(data);
    var props = vm.$options.props;
    var methods = vm.$options.methods;
    var i = keys.length;
    while (i--) {
      var key = keys[i];
      {
        // 这里校验data和methods中的同名变量
      }
      if (props && hasOwn(props, key)) { // 这里校验data和props中的同名变量
        // ……
      } else if (!isReserved(key)) {
        proxy(vm, "_data", key); // 合法的、不是内部私有的保留属性,做一层代理,代理到 _data 上。
      }
    }
    // observe data
    observe(data, true /* asRootData */); // 这里监听data,data中的数据转换为响应式数据(稍微看了下感觉有点复杂,没深入看)
}

关键在 proxy(vm, "_data", key) 这行。

proxy 方法利用 Object.defineProperty 定义对象,实现访问属性的转发this.xxx 实际上是访问的 this._data.xxx

function noop (a, b, c) {}
var sharedPropertyDefinition = {
    enumerable: true,
    configurable: true,
    get: noop,
    set: noop
};
function proxy (target, sourceKey, key) {
    sharedPropertyDefinition.get = function proxyGetter () {
      return this[sourceKey][key]
    };
    sharedPropertyDefinition.set = function proxySetter (val) {
      this[sourceKey][key] = val;
    };
    Object.defineProperty(target, key, sharedPropertyDefinition);
}

proxy 中我对 sharedPropertyDefinition 只全局(相对全局)定义了一次,而不是在 proxy 中每次调用每次生成有疑惑,思考加询问豆包之后得出以下结论:

  1. 利用了闭包的特点:

    1. 当调用 proxy 方法时,key 作为参数传入。在 sharedPropertyDefinitiongetset 函数内部,key 被引用。由于 JavaScript 的闭包特性,getset 函数会捕获并记住当前的 key 值。

      proxy(vm, "_data", "name") 实际上会将 sharedPropertyDefinitiongetset 函数修改为:

      function proxyGetter () {
        return this["_data"]["name"];
      }
      function proxySetter (val) {
        this["_data"]["name"] = val;
      }
      
  2. Object.defineProperty 会将这些修改后的 getset 函数应用到 target 对象的 key 属性上。target 对象的 key 属性会被赋予 sharedPropertyDefinition 中的 getset 函数的副本。一旦属性被定义,这些 getset 函数就与 sharedPropertyDefinitiongetset 解绑了。

  3. 这么做的目的,应该是出于性能考虑:

    在 JavaScript 里,对象的创建和初始化操作会带来一定的开销,这包含内存分配和属性初始化。要是在 proxy 方法每次调用时都创建一个新的 sharedPropertyDefinition 对象,就会有多次对象创建和属性初始化操作,这会对性能产生影响。

    而全局定义一次 sharedPropertyDefinition 对象,每次调用 proxy 方法时仅仅修改这个对象的 getset 属性,这样就避免了多次对象创建和属性初始化操作,降低了性能开销。

3. 总结:

手写实现 关键:

  1. methods:
    1. 对methods中定义的方法,是否与props中有重名的校验;
    2. 通过校验的方法,使用自定义bind方法将methods中的方法逐一对vm实例赋值同名函数并绑定this指向为实例;
  2. data:
    1. 对data类型的校验;
    2. 对data中定义的变量,是否与methods、props中有重名变量的校验;
    3. 通过校验的变量,使用自定义的proxy方法将data中的变量逐一对vm实例赋值同名变量并实现访问和修改的代理方法;
      1. proxy方法,本质上是通过Object.defineProperty方法实现,也即响应式原理实现关键。

4. 手写实现

开始默写了!(不考虑检验,只实现核心功能)

// 构造函数 MyVue
class MyVue {
  constructor(options) {
    this._init(options)
  }
  _init(options) {
    // methods 实现
    const methods = options.methods || {}
    if (Object.prototype.toString.call(methods) === '[object Object]') {
      const methodsKeys = Object.keys(methods)
      if (methodsKeys.length > 0) {
        for(const key of methodsKeys) {
          this[key] = methods[key].bind(this)
        }
      }
    }
    // data 实现
    const data = options.data() || {}
    if (Object.prototype.toString.call(data) === '[object Object]') {
      const dataKeys = Object.keys(data)
      if (dataKeys.length > 0) {
        for(const key of dataKeys) {
          Object.defineProperty(this, key, {
            enumerable: true,
            configurable: true,
            get() {
              return data[key]
            },
            set(val) {
              data[key] = val
              return val
            }
          })
        }
      }
    }
  }
}
// 实例调用
const myVm = new MyVue({
  data() {
    return {
      msg: '这是MyVue的实例'
    }
  },
  methods: {
    getMsg() {
      return 'getMsg: ' + this.msg
    }
  }
})
console.log(myVm.msg) // 这是MyVue的实例
console.log(myVm.getMsg()) // getMsg: 这是MyVue的实例

#React Router Dom 入门:构建现代单页面应用的前端路由方案

作者 遂心_
2025年7月14日 16:31

前言:前端路由的革命性意义

在传统的 Web 开发中,每次页面跳转都需要向服务器发送请求,等待完整的 HTML 页面返回。这种模式不仅用户体验不佳,也增加了服务器负担。而前端路由的出现彻底改变了这一局面,它允许我们在不刷新整个页面的情况下切换视图,创造出流畅的单页面应用体验。

React Router Dom 作为 React 生态中最主流的路由解决方案,已经成为构建现代 Web 应用的标配。本文将带您全面了解 React Router Dom 的核心概念与实践方法。

一、前端路由 vs 后端路由

后端路由(传统模式)

  • 服务器根据 URL 路径返回对应 HTML 页面
  • 每次导航都需要完整页面刷新
  • 前后端耦合,后端负责视图渲染

deepseek_mermaid_20250714_50761e.png

前端路由(现代 SPA)

  • 浏览器只加载一次应用
  • 路由切换在客户端完成
  • 动态加载组件,局部更新视图
  • 前后端分离,后端专注 API 开发

deepseek_mermaid_20250714_9ccd7d.png

二、React Router Dom 核心概念解析

路由配置基础

在 App.jsx 中配置路由是应用的起点:

import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import About from './pages/About'

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Router>
  )
}

页面组件结构

每个路由对应一个独立的页面组件:

// Home.jsx
const Home = () => {
  return <>首页</>
}

export default Home
// About.jsx
const About = () => {
  return <>关于我们</>
}

export default About

三、动态路由实践技巧

1. 参数化路由

使用冒号语法定义动态参数:

// App.jsx
<Route path="/user/:id" element={<UserProfile />} />

2. 访问路由参数

在组件中通过 useParams 获取参数:

import { useParams } from 'react-router-dom'

const UserProfile = () => {
  const { id } = useParams()
  
  return (
    <>用户ID: {id}</>
  )
}

3. RESTful 风格路由设计

遵循 REST 规范设计资源路由:

<Routes>
  <Route path="/products" element={<Products />} />
  <Route path="/products/new" element={<NewProducts />} />
  <Route path="/products/:id" element={<ProductDetails />} />
</Routes>

四、常见问题与解决方案

1. 路由拼写错误修正

在 UserProfile.jsx 中,修正引入路径错误:

- import { useParams } from 'reapt-router-dom'
+ import { useParams } from 'react-router-dom'

2. 避免直接访问 window.location

使用 React Router 提供的 hook 替代原生方法:

import { useLocation } from 'react-router-dom'

const UserProfile = () => {
  const location = useLocation()
  
  useEffect(() => {
    console.log('当前路径:', location.pathname)
  }, [location])
}

3. 嵌套路由实践

创建布局组件实现嵌套路由:

// App.jsx
<Route path="/dashboard" element={<DashboardLayout />}>
  <Route index element={<DashboardHome />} />
  <Route path="settings" element={<Settings />} />
  <Route path="analytics" element={<Analytics />} />
</Route>

五、React Router Dom 最佳实践

  1. 路由版本选择:当前稳定版本为 v7.6.3(主版本.次版本.补丁版本)
  2. 命名规范:路由组件使用 PascalCase 命名法
  3. 代码分割:结合 React.lazy 实现路由级代码分割
  4. 导航守卫:使用 <Navigate> 组件实现路由拦截
  5. 404处理:添加通配符路由捕获未匹配路径
// 404 处理示例
<Route path="*" element={<NotFoundPage />} />

六、完整路由配置示例

// App.jsx
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import About from './pages/About'
import Products from './pages/Products'
import NewProducts from './pages/NewProducts'
import ProductDetails from './pages/ProductDetails'
import UserProfile from './pages/UserProfile'

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/products" element={<Products />} />
        <Route path="/products/new" element={<NewProducts />} />
        <Route path="/products/:id" element={<ProductDetails />} />
        <Route path="/user/:id" element={<UserProfile />} />
      </Routes>
    </Router>
  )
}

结语:拥抱现代化前端路由

React Router Dom 不仅是一个路由库,更是构建现代化 Web 应用的基石。

最佳学习路径:从基础路由配置开始 → 掌握动态路由 → 实践嵌套路由 → 探索高级特性(如数据路由)

深入理解 JavaScript Event Loop:从原理到实战全解析

作者 TimelessHaze
2025年7月14日 16:28

深入理解 JavaScript Event Loop:从原理到实战全解析


1. 什么是 Event Loop?

Event Loop(事件循环)是 JavaScript 实现异步非阻塞编程的核心机制。它通过“执行栈 + 任务队列”协调同步与异步任务,确保 JavaScript 虽然是单线程语言,但仍可高效响应用户交互与 I/O 请求。

📌 三个核心特性

  • 单线程:同一时刻 JavaScript 只能执行一段代码
  • 非阻塞:通过异步任务延后执行,避免阻塞主线程
  • 事件驱动:一切基于事件与回调函数

2. JavaScript 为什么是单线程?

JavaScript 是为浏览器环境设计的,单线程模型具有以下优势:

✅ 原因分析

  1. DOM 安全
    • DOM 是共享资源,防止多线程引发数据竞争。
  2. 简化模型
    • 避免锁机制和线程同步,简化开发。
  3. 性能考虑
    • 减少上下文切换的系统开销。

⚠️ 单线程的挑战

  • 同步任务若耗时,会阻塞页面
  • UI 渲染、事件响应都在主线程
  • 用户体验易受长任务影响

🧠 示例演示

// 阻塞主线程(不推荐)
while (true) {
  console.log("页面已卡死");
}

// 异步非阻塞(推荐)
setTimeout(() => {
  console.log("异步执行");
}, 0);

3. 同步与异步任务机制

🧱 同步任务

在主线程中立即执行,会阻塞后续代码。

console.log('1');
console.log('2');
console.log('3');
// 输出顺序:1, 2, 3

🚀 异步任务

异步任务会被推入任务队列,由 Event Loop 在合适时机调度执行。

常见异步 API:

  • setTimeout / setInterval
  • fetch
  • Promise.then
  • MutationObserver

4. 宏任务与微任务详解

⏰ 宏任务(Macro Task)

  • 每轮 Event Loop 执行一个宏任务
  • 来源于宿主环境(浏览器/Node)

常见宏任务:

  • 整体 script
  • setTimeout
  • I/O 回调
  • setImmediate(Node)

🔬 微任务(Micro Task)

  • 每轮宏任务结束后执行所有微任务
  • 优先级更高

常见微任务:

  • Promise.then
  • queueMicrotask
  • MutationObserver
  • process.nextTick(Node)

🧠 执行优先级总结

同步任务 > 所有微任务 > 一个宏任务 > 所有微任务 > 下一个宏任务 > ...


📊 流程图:任务分类结构图

27f9022f5d39dfe0a2c2e1946a71510.png

5. 执行顺序与流程图

🧭 执行步骤

  1. 执行所有同步任务
  2. 清空微任务队列
  3. 执行一个宏任务
  4. 清空微任务队列
  5. 继续下一轮事件循环

🖼️ Event Loop 执行流程图

27f9022f5d39dfe0a2c2e1946a71510.png

6. 常见示例代码解析

示例 1

console.log('script start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(() => {
  console.log('promise');
});

console.log('script end');

✅ 输出顺序:

script start
script end
promise
setTimeout

示例 2

console.log('开始');

Promise.resolve().then(() => {
  console.log('promise1');
});

queueMicrotask(() => {
  console.log('microtask');
});

console.log('结束');

✅ 输出:

开始
结束
microtask
promise1

7. 浏览器 vs Node.js 的执行差异

📦 Node.js 示例

console.log('Start');

process.nextTick(() => {
  console.log('nextTick');
});

Promise.resolve().then(() => {
  console.log('Promise');
});

setTimeout(() => {
  console.log('setTimeout');
}, 0);

✅ 输出顺序:

Start
nextTick
Promise
setTimeout

✅ 对比总结

环境 特有机制 微任务优先级
浏览器 MutationObserver Promise > queueMicrotask
Node.js process.nextTick process.nextTick > Promise

📈 Node.js 执行顺序图

Node.js 微任务优先级图


8. Event Loop 实战技巧

🎯 场景 1:DOM 优化

queueMicrotask(() => {
  element.textContent = '更新成功';
});

🎯 场景 2:批量数据处理

async function processData(data) {
  for (let i = 0; i < data.length; i += 100) {
    await processBatch(data.slice(i, i + 100));
    await new Promise(r => setTimeout(r, 0));
  }
}

🎯 场景 3:性能监控

function monitor(task) {
  const start = performance.now();
  queueMicrotask(() => {
    const cost = performance.now() - start;
    if (cost > 16) {
      console.warn('任务耗时过长:', cost);
    }
  });
}

9. 面试高频题与答题技巧

❓ 题目:输出顺序

console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => {
    console.log('3');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('4');
});

console.log('5');

✅ 正确输出:

1
5
4
2
3

🧠 答题技巧:

  1. 先执行同步任务(1、5)
  2. Promise 的 then 属于微任务(4)
  3. setTimeout 属于宏任务,执行后再调度微任务(3)

10. 性能优化建议

✅ 拆分任务,避免长时间阻塞

async function heavy() {
  for (let i = 0; i < 10000; i++) {
    doSomething(i);
    if (i % 100 === 0) {
      await new Promise(r => setTimeout(r, 0));
    }
  }
}

✅ 微任务更新 DOM

queueMicrotask(() => {
  element.style.display = 'none';
  element.textContent = '更新完毕';
});

11. 总结与记忆口诀

📌 关键点

  • 单线程语言,事件循环协助异步编程
  • 同步任务 > 微任务 > 宏任务
  • 微任务清空优先,宏任务分阶段调度
  • 实战中可用于性能优化与线程让渡

🧠 一句话记忆法

“同步先,微全清,一宏来,再清微”


感谢阅读,如果觉得有帮助,欢迎点赞、收藏、关注我,获取更多前端底层机制解析 🙌

uni-app 弹窗总被父元素“绑架”?3招破局,H5/小程序/APP一招通杀!

2025年7月14日 16:09

背景介绍

在 uni-app 开发中,弹窗、抽屉、下拉菜单等覆盖型组件是非常常见的交互元素。这些组件通常需要相对于视口定位,不受父元素影响。然而,在某些场景下 Position: Fixed 会有不符合预期的表现题。而且 uni-app 一次编码,多端报错的特性,导致这个问题十分棘手,因此今天我们就要探讨一个跨端的解决方案来处理这些定位问题。

CSS Position: Fixed 的失效问题

问题描述

根据 CSS 规范,position: fixed 元素的定位上下文默认是相对于视口(viewport)的。但在以下情况下,定位上下文会发生改变:

  1. Transform 属性:当祖先元素应用了 transform 属性时
  2. Filter 效果:当祖先元素设置了 filterbackdrop-filter 属性时
  3. 3D 渲染上下文:当祖先元素设置了 perspective 属性时
  4. will-change:当祖先元素的 will-change 属性设置为上述值时

这种行为在 MDN 文档中有明确说明:

"当元素祖先的 transform、perspective、filter 或 backdrop-filter 属性非 none 时,容器由视口改为该祖先。"

—— MDN - position: fixed

实际开发中的影响

这个问题在实际开发中经常会带来以下困扰:

  1. 模态框定位异常

    • 在带有变换效果的容器中,模态框无法相对于视口居中
    • 弹窗位置会随父容器滚动而改变
  2. 固定导航失效

    • 使用 CSS transform 实现动画效果的页面中,固定导航栏会失去固定效果
    • 在滚动时导航栏可能会跟随内容移动
  3. 交互组件错位

    • 下拉菜单、提示框等定位不准确
    • 遮罩层无法完全覆盖视口

问题示例

<div style="transform: scale(1);">
  <!-- fixed 定位将相对于这个 div,而不是视口 -->
  <div style="position: fixed; top: 0; left: 0;">
    这个元素不会固定在视口顶部
  </div>
</div>

uni-app Vue3 中的跨平台解决方案

我们期望针对 uni-app Vue3 提供了一个优雅的跨平台解决方案。通过条件编译和平台特定的实现,组件能够在不同端完美运行。核心思路是将内容传送到应用根节点,从而避免中间层级的 CSS 上下文影响。

条件编译实现

使用 uni-app 的条件编译特性,我们可以为不同平台提供最优的实现方案:

<!-- #ifdef H5 -->
<teleport to="body">
  <slot />
</teleport>
<!-- #endif -->

<!-- #ifdef MP-WEIXIN || MP-ALIPAY -->
<root-portal>
  <slot />
</root-portal>
<!-- #endif -->

各端实现原理与细节

1. H5 环境 - Teleport 实现原理

Vue3 的 teleport 组件实现了一个传送门的概念,其工作原理如下:

  • 在组件的虚拟 DOM 树中正常渲染内容
  • 但在实际 DOM 操作时,将内容移动到指定目标
// teleport 在 uni-app H5 端的工作方式
// 1. 组件逻辑
const show = ref(false)
// 2. 模板中使用
<teleport to="body">
  <view v-if="show" class="popup">
    <slot />
  </view>
</teleport>

优点:

  • 完全复用 Vue3 的能力
  • 支持动态目标节点
  • 保持组件状态和事件绑定

2. 小程序环境 - root-portal 实现原理

小程序的 root-portal 组件可以使整个子树从页面中脱离出来。

<root-portal>
  <view class="popup">
    <slot />
  </view>
</root-portal>

3. App 环境 - renderjs 实现原理

App 端使用 renderjs 实现节点操作,这是一个强大的跨平台解决方案:

  • 直接运行在视图层(Webview)中
  • 可以访问完整的浏览器 API
  • 支持直接 DOM 操作
// App 端的实现
<script module="render" lang="renderjs">
export default {
  mounted() {
    // 获取根节点
    const root = document.querySelector('uni-app') || document.body
    if (this.$ownerInstance.$el) {
      root.appendChild(this.$ownerInstance.$el)
    }
  }
}
</script>

优点:

  • 直接 DOM 操作,灵活控制
  • 可以精确控制节点的生命周期
  • 保持完整的事件系统

统一封装实现

为了统一管理这三种实现方式,我们会将其封装一个统一的组件,在 WotUI 组件库中提供。

这个统一封装:

  1. 使用条件编译区分平台
  2. 保持一致的 API 和使用方式
  3. 解决了跨平台兼容性问题
  4. 支持微信小程序、支付宝小程序、APP和h5

扩展阅读

CSS 规范与文档

框架与工具

相关技术文章

最后

关注公众号【阿鱼聊前端】,爱摸鱼,不迷路。

Vue3 图片放大镜组件优化实践:用 transform 替代 relative 定位 & watchThrottled 优化性能

作者 雲墨款哥
2025年7月14日 16:00

Vue3 图片放大镜组件优化实践:用 transform 替代 relative 定位 & watchThrottled 优化性能

在开发电商类项目时,商品图片的放大镜效果是常见的交互需求。最近在实现这个功能时,我对传统实现方式做了两点优化:

  1. 用 transform 替代 relative/absolute 定位实现小滑块运动
  1. 用 watchThrottled 替代 watch 优化性能

本文将详细介绍这两点优化的思路、实现方式和带来的好处。

1. 用 transform 替代 relative/absolute 定位

传统做法

通常我们会给滑块(layer)设置 position: absolute,然后通过动态修改 left 和 top 属性来让滑块跟随鼠标移动。例如:

<!-- 蒙层小滑块 -->
<div class="layer" v-show="!isOutside" :style="{ left: `${left}px`, top: `${top}px` }"></div>

这种方式虽然直观,但频繁修改 left/top 会导致浏览器回流(reflow),影响性能,尤其是在滑块频繁移动时。

优化思路:用 transform

CSS 的 transform: translate(x, y) 可以实现同样的移动效果,而且不会引起回流,只会触发重绘(repaint),

性能更优。实现方式也很简单:

<div
  class="layer"
  v-show="!isOutside"
  :style="{ transform: `translate(${left}px, ${top}px)` }"
></div>

只需将原本的 left/top 替换为 transform: translate,即可让滑块平滑高效地跟随鼠标移动

优势
  • 性能更优:transform 不会引起回流,动画更流畅。
  • 并且浏览器对 transform、opacity 等属性做了硬件加速(通常会把元素提升到 GPU 合成层);
  • 这样一来,变换操作(如 transform: translate)可以直接在 GPU 上完成,不需要主线程参与复杂的布局和绘制流程;
  • 所以,transform 动画通常非常流畅,几乎不会卡顿。

2. 用 watchThrottled 替代 watch

背景

在监听鼠标移动时,elementX 和 elementY 变化非常频繁。如果直接用 watch 监听并处理,会导致回调函数高频执行,带来性能压力。

优化思路:用 watchThrottled

VueUse 提供了 watchThrottled,可以限制回调的触发频率。例如:

watchThrottled([elementX, elementY, isOutside], () => {
  // 处理滑块和大图位置
}, { throttle: 100 })

这样即使鼠标移动再快,回调函数每 100ms 最多只会执行一次,大大减少了不必要的计算和 DOM 操作。

优势
  • 降低性能消耗:减少回调执行次数,提升页面流畅度。
  • 更易控制:只需调整 throttle 时间即可平衡流畅度和性能。

3. 完整代码片段

核心部分如下:


<div class="middle" ref="target">

  <img :src="imageList[activeIndex]" alt="" />

  <div

    class="layer"

    v-show="!isOutside"

    :style="{ transform: `translate(${left}px, ${top}px)` }"

  ></div>

</div>

watchThrottled([elementX, elementY, isOutside], () => {

  if (isOutside.valuereturn

  // ... 计算 left, top, positionX, positionY ...

}, { throttle100 })

4. 总结

  • 用 transform: translate 替代 left/top,让滑块运动更高效、动画更流畅。
  • 用 watchThrottled 替代 watch,有效降低高频事件带来的性能压力。 这两点优化在实际项目中效果显著,推荐大家在实现类似交互时尝试采用!

补充:useMouseInElement 的作用与用法

在实现放大镜图片预览时,精准获取鼠标在元素内的位置是关键。这里推荐使用 VueUse 提供的 useMouseInElement 组合式函数。

1. 作用

useMouseInElement 可以实时追踪鼠标在指定元素内的坐标(相对于元素左上角),并能判断鼠标是否在元素外部。它极大简化了鼠标位置的监听和计算逻辑。

2. 基本用法

import { ref } from 'vue'
import { useMouseInElement } from '@vueuse/core'

const target = ref(null)
const { elementX, elementY, isOutside } = useMouseInElement(target)
  • target:绑定到你想追踪的 DOM 元素(如图片容器)。
  • elementX、elementY:鼠标在元素内的横纵坐标(相对于元素左上角,单位 px)。
  • isOutside:布尔值,表示鼠标是否在元素外部。

3. 实际应用场景

在放大镜组件中,我们只需把 ref="target" 绑定到图片容器,然后用 elementX/elementY 计算滑块和大图的位置,用 isOutside 控制滑块和大图的显示隐藏。

<div class="middle" ref="target">
  <img :src="imageList[activeIndex]" alt="" />
  <div class="layer" v-show="!isOutside" :style="{ transform: `translate(${left}px, ${top}px)`}"></div>
</div>

4. 优势

  • 手动监听 mousemove/mouseleave,逻辑更简洁。
  • 自动解绑事件,不用担心内存泄漏。
  • 响应式数据,可直接用于模板和计算属性。

5. 适用场景

  • 图片放大镜
  • 拖拽交互
  • 自定义鼠标悬浮提示
  • 需要获取鼠标在元素内精确位置的任何场景

总结:

useMouseInElement 是 VueUse 提供的高效鼠标位置追踪工具,极大简化了鼠标相关的交互开发。推荐在 Vue3 项目中广泛使用!如果你想了解更多细节,可以查阅 VueUse 官方文档 useMouseInElement。


如果你有更好的优化思路,欢迎留言交流! 另外我曾用vue2.0写过一个放大镜效果,大家有兴趣的,也可以浏览指点一下!

@import 样式工作原理详解

作者 _advance
2025年7月14日 15:39

1. @import 语法解析过程

@import 是一个特殊的 CSS 规则,用于导入其他样式表。它的语法支持两种形式:

@import "style.css"; /* 直接导入 */
@import url("style.css") screen and (min-width: 768px); /* 带媒体查询的导入 */

引擎在解析时会进行如下处理:

// third_party/blink/renderer/core/css/parser/css_parser.h
class CSSParser {
private:
    // @import 规则的语法解析
    CSSImportRule* ParseImportRule(CSSParserTokenRange& range) {
        // 1. 必须检查是否在样式表开头
        if (!IsImportRuleAllowed()) {
            // @import 只能在其他规则之前使用
            AddError("@import rules must precede all other rules");
            return nullptr;
        }

        // 2. 解析 URL
        String url;
        if (range.Peek().GetType() == kStringToken) {
            // 处理 "url.css" 形式
            url = range.ConsumeString();
        } else if (range.Peek().GetFunctionId() == CSSValueID::kUrl) {
            // 处理 url("url.css") 形式
            url = ConsumeFunctionPayload(range);
        }

        // 3. 解析 media query(可选)
        MediaQuerySet* media_queries = nullptr;
        if (!range.AtEnd()) {
            media_queries = ParseMediaQueryList(range);
        }

        return CSSImportRule::Create(url, media_queries);
    }
};

2. @import 资源加载与处理

当浏览器遇到 @import 规则时,会执行以下步骤:

  1. 资源加载
    • 暂停当前样式表的解析
    • 开始加载被导入的样式表
    • 等待加载完成才继续处理当前样式表
  2. 处理流程
class StyleSheetContents {
private:
    void ProcessStyleSheet() {
        // 1. 设置状态为处理导入
        processing_state_ = ProcessingState::kProcessingImports;

        // 2. 首先处理所有 @import
        for (auto* import_rule : import_rules_) {
            // 递归处理每个导入的样式表
            import_rule->ProcessImportedStylesheet();

            // 等待导入完成
            if (!import_rule->IsLoaded()) {
                // 记录未完成的导入
                pending_imports_.push_back(import_rule);
                return;
            }
        }

        // 3. 所有 @import 处理完成后,才处理其他规则
        processing_state_ = ProcessingState::kProcessingRules;
        ProcessRules();
    }
};

这种处理方式会导致:

  • 串行加载,影响性能
  • 阻塞后续规则的处理
  • 可能造成加载瀑布流

3. @import 的优先级规则

@import 的优先级规则比较特殊:

  1. 基础优先级

    • @import 导入的样式表优先级是最低的
    • 它会被同一样式表中的所有直接规则覆盖
    /* theme.css */
    .button {
      color: red;
    }
    
    /* main.css */
    @import "theme.css";
    .button {
      color: blue;
    } /* 这个会覆盖 theme.css 中的规则 */
    
  2. 多重导入的优先级

    /* main.css */
    @import "theme1.css"; /* 优先级最低 */
    @import "theme2.css"; /* 优先级比 theme1.css 高 */
    .button {
      color: blue;
    } /* 优先级最高 */
    
  3. 优先级处理代码

class StyleResolver {
    void ApplyMatchedRules() {
        // 1. 先应用所有导入的样式(按导入顺序)
        for (auto* import : sheet_->ImportRules()) {
            ApplyImportedRules(import);
        }

        // 2. 再应用当前样式表的规则(会覆盖导入的规则)
        ApplySheetRules(sheet_);
    }
};

4. 为什么要求 @import 必须在前面?

这个设计有两个主要原因:

  1. 技术原因

    • 确保导入的样式表能被当前样式表的规则覆盖
    • 简化优先级处理逻辑
    • 避免复杂的规则交织
  2. 性能原因

    • 让浏览器尽早发现并加载依赖的样式表
    • 避免加载顺序混乱
    • 减少样式重计算

5. 实际应用示例

/* theme.css */
.button {
  color: red;
  padding: 10px;
}

/* main.css */
@import "theme.css";
@import "layout.css" screen and (min-width: 768px);

.button {
  color: blue; /* 会覆盖 theme.css 中的 color */
  /* 但不会覆盖 padding */
}

处理顺序:

  1. 加载并应用 theme.css 中的所有样式
  2. 如果屏幕宽度 ≥ 768px,加载并应用 layout.css
  3. 应用 main.css 中的样式,覆盖之前导入的相同规则

这就是为什么:

  • @import 的样式可以被覆盖
  • 但它仍然是一个有用的模块化工具
  • 适合用于主题和基础样式的引入

5. @import 内部工作机制详解

当浏览器解析 CSS 遇到 @import 规则时,会执行以下步骤:

class StyleEngine {
    void ProcessStyleSheet() {
        // 1. 遇到 @import 时
        if (token.type == CSSTokenType::Import) {
            // 暂停当前样式表的解析
            PauseCurrentParsing();

            // 开始加载 @import 的样式表
            LoadImportedStylesheet();

            // 必须等待加载完成才能继续
            WaitForImportComplete();
        }
    }
};

class CSSImportRule {
    void LoadImportedStylesheet() {
        // 阻塞式加载
        auto* imported_content = loader_->LoadStylesheetSync(import_url_);

        // 将导入的样式插入到父样式表的开头
        parent_sheet_->PrependRules(imported_content->Rules());

        // 标记导入完成
        SetLoadComplete();

        // 通知父样式表继续解析
        parent_sheet_->ResumeParsing();
    }
};

具体执行流程:

  1. 解析阻塞

    /* main.css */
    @import "theme.css"; /* 第1步:停止解析,先加载 theme.css */
    .button {
      color: blue;
    } /* 第3步:等 theme.css 加载完才解析这行 */
    
  2. 样式插入

    /* 最终的处理结果相当于 */
    <style>
      /* theme.css 的内容被插入到最前面 */
      .button { color: red; }  /* 第2步:theme.css 的内容 */
    
      /* main.css 的原始内容 */
      .button { color: blue; } /* 第3步:这里会覆盖上面的规则 */
    </style>
    
  3. 加载时序

    main.css 开始加载
    |
    遇到 @import "theme.css" -----> 暂停 main.css 解析
                                   |
                                   加载 theme.css
                                   |
                                   theme.css 加载完成
                                   |
    继续解析 main.css <-------------
    |
    完成
    

这种机制确保了:

  1. 样式优先级的正确性

    • 导入的样式被放置在最前面
    • 后面的规则可以正确覆盖导入的样式
    • 维护了 CSS 的层叠性(cascade)
  2. 加载的完整性

    • 确保所有导入的样式都被加载
    • 避免样式加载顺序错乱
    • 保证样式应用的一致性
  3. 性能影响

    • 会阻塞后续 CSS 的解析
    • 导致串行的加载过程
    • 这就是为什么不推荐使用 @import 的主要原因

相比之下,使用多个 <link> 标签可以实现样式表的并行加载:

<link rel="stylesheet" href="theme.css" />
<link rel="stylesheet" href="main.css" />
<!-- 这两个样式表会并行加载 -->

这就是为什么在性能优化时,通常建议:

  • 避免使用 @import
  • 使用多个 <link> 标签
  • 如果必须使用 @import,尽量减少嵌套层级

基于axios的http请求封装,支持数据缓存

作者 hktk_wb
2025年7月14日 15:36

使用场景

在前端开发过程中,使用http/https请求到服务端获取数据基本是必备场景了。

在这里使用tsaxiosantdnotification做了接口请求封装,可根据实际需求再做修改。

封装中包含基于token的登录验证,对请求响应的错误处理,以及对重复请求的缓存策略。

封装过程

创建axios实例

const BASE_URL = '/api' // 根据实际需求更改

const http: AxiosInstance = axios.create({
  baseURL: BASE_URL,
  timeout: 10000,
  headers: { 'Content-Type': 'application/json' },
})

使用拦截器做请求及响应的数据处理

请求拦截器

请求拦截器中主要处理用户登录token,如果能够获取到token,则将token传到请求headers对应字段中。

http.interceptors.request.use(
  (config) => {
    // 这里可以根据是否需要清除空白参数做调整
    if (config.params) config.params = deleteNullParams(config.params)
    if (config.data) config.data = deleteNullParams(config.data)

    // 如需登录验证,可以在这里添加headers的token验证
    const token = useSystemStore.getState().token
    if (token) config.headers['Authorization'] = token

    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

function deleteNullParams(params: { [key: string]: unknown }) {
  for (const key in params) {
    if (params[key] === null || params[key] === undefined || params[key] === '') {
      delete params[key]
    }
  }
  return params
}

响应拦截器

响应拦截器主要处理请求响应错误数据。包含http请求本身的错误以及服务返回数据内的自定义数据错误。

// 响应数据结构定义
export interface ApiRes<T> {
  code: number
  msg: string
  data: T
}

http.interceptors.response.use(
  <T>(response: AxiosResponse<ApiRes<T>>) => {
    // 响应 status 401 表示登录token验证失败,需要清除token并跳转到登录页面
    if (response.status === 401) {
      useSystemStore.getState().clear()
      location.href = '/login'
      return Promise.reject('Unauthorized access, please login again.')
    }

    // 响应 status 不为 200 表示请求失败
    if (response.status !== 200) {
      showNotify()
      return Promise.reject(response.statusText)
    }

    // 接口数据返回自定义错误处理
    const { code, data, msg } = response.data

    if (code !== 200) {
      showNotify(code, msg)
      return Promise.reject(msg)
    }

    return data
  },
  (error) => {
    showNotify(error.code, error.message)
    return Promise.reject(error.message)
  }
)

请求缓存

请求缓存主要是解决重复请求问题,同样一个请求如果连续发起多次,第一次会正常请求,并将请求的返回Promise保存到缓存中,后面再发起请求时,如果第一次发起的请求还没有返回数据(缓存还在),则将缓存直接作为该次请求的返回数据,无需发起重复请求。

const resPromiseCache = new Map<string, Promise<unknown>>()

const cachedRequest = <T>(config: AxiosRequestConfig) => {
  const key = getUniKey(config)

  if (resPromiseCache.has(key)) return resPromiseCache.get(key) as Promise<T>

  const promise = http.request<T>(config)
  resPromiseCache.set(key, promise)
  promise.finally(() => resPromiseCache.delete(key))
  return promise as Promise<T>
}

function getUniKey(config: AxiosRequestConfig): string {
  const { method, url, params, data } = config
  return `${method}-${url}-${JSON.stringify(params)}-${JSON.stringify(data)}`
}

常用请求方法封装

这里封装了常用的GETPOSTPUTDELETE快捷请求函数

export const get = <T>(url: string, params?: object): Promise<T> => {
  return cachedRequest<T>({ method: 'get', url, params })
}

export const post = <T>(url: string, data?: object): Promise<T> => {
  return cachedRequest<T>({ method: 'post', url, data })
}

export const put = <T>(url: string, data?: object): Promise<T> => {
  return cachedRequest<T>({ method: 'put', url, data })
}

export const del = <T>(url: string, params?: object, data?: object): Promise<T> => {
  return cachedRequest<T>({ method: 'delete', url, params, data })
}

完整示例

import axios from 'axios'
import type { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios'
import { notification } from 'antd'
import useSystemStore from '@/stores/system'

export interface ApiRes<T> {
  code: number
  msg: string
  data: T
}

const BASE_URL = '/api'

const resPromiseCache = new Map<string, Promise<unknown>>()

const http: AxiosInstance = axios.create({
  baseURL: BASE_URL,
  timeout: 10000,
  headers: { 'Content-Type': 'application/json' },
})

http.interceptors.request.use(
  (config) => {
    if (config.params) config.params = deleteNullParams(config.params)
    if (config.data) config.data = deleteNullParams(config.data)

    const token = useSystemStore.getState().token
    if (token) config.headers['Authorization'] = token

    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

http.interceptors.response.use(
  <T>(response: AxiosResponse<ApiRes<T>>) => {
    if (response.status === 401) {
      useSystemStore.getState().clear()
      location.href = '/login'
      return Promise.reject('Unauthorized access, please login again.')
    }

    if (response.status !== 200) {
      showNotify()
      return Promise.reject(response.statusText)
    }

    const { code, data, msg } = response.data

    if (code !== 200) {
      showNotify(code, msg)
      return Promise.reject(msg)
    }

    return data
  },
  (error) => {
    showNotify(error.code, error.message)
    return Promise.reject(error.message)
  }
)

const cachedRequest = <T>(config: AxiosRequestConfig) => {
  const key = getUniKey(config)

  if (resPromiseCache.has(key)) return resPromiseCache.get(key) as Promise<T>

  const promise = http.request<T>(config)
  resPromiseCache.set(key, promise)
  promise.finally(() => resPromiseCache.delete(key))
  return promise as Promise<T>
}

const showNotify = (status?: number, description = 'Server Error !') => {
  notification.error({
    message: `Request Error ${status ? status : ''}`,
    description,
  })
}

export default http

export const get = <T>(url: string, params?: object): Promise<T> => {
  return cachedRequest<T>({ method: 'get', url, params })
}

export const post = <T>(url: string, data?: object): Promise<T> => {
  return cachedRequest<T>({ method: 'post', url, data })
}

export const put = <T>(url: string, data?: object): Promise<T> => {
  return cachedRequest<T>({ method: 'put', url, data })
}

export const del = <T>(url: string, params?: object, data?: object): Promise<T> => {
  return cachedRequest<T>({ method: 'delete', url, params, data })
}

function deleteNullParams(params: { [key: string]: unknown }) {
  for (const key in params) {
    if (params[key] === null || params[key] === undefined || params[key] === '') {
      delete params[key]
    }
  }
  return params
}

function getUniKey(config: AxiosRequestConfig): string {
  const { method, url, params, data } = config
  return `${method}-${url}-${JSON.stringify(params)}-${JSON.stringify(data)}`
}

Element Plus 自定义(动态)表单组件

作者 karl_hg
2025年7月14日 15:26

演示环境

Vue:3.3.4
TypeScript:5.0.2
Sass:1.79.4
ElementPlus:2.7.8

开始

1、新建自定义表单组件 -MyForm.vue

<template>表单组件</template>

<script setup lang="ts"></script>

<style scoped lang="scss"></style>

2、新建一个测试vue文件 -Test.vue

<template>
    <div class="myBody">
        <el-card header="自定义表单">
            <my-form></my-form>
        </el-card>
    </div>
</template>

<script lang="ts" setup>
import MyForm from '@/components/utils/MyForm.vue';
</script>

<style lang="scss" scoped>
.myBody {
    margin: 1rem;
}
</style>

3、页面效果

image.png

创建初始化数据结构

1、新建表单数据和表单项 -Test.vue

<template>
    <div class="myBody">
        <el-card header="自定义表单">
            <my-form v-model="formData" :items="items"></my-form>
            <el-divider />
            <div>
                <div>表单数据</div>
                <pre>{{ formData }}</pre>
            </div>
        </el-card>
    </div>
</template>

<script lang="ts" setup>
import MyForm from '@/components/utils/MyForm.vue';

const formData = ref({
    name: null,
    age: null,
    sex: null,
    hobbies: [],
    dateOfBirth: ''
});

const items = [
    {
        label: '姓名',
        key: 'name',
        type: 'input',
        placeholder: '请输入姓名'
    },
    {
        label: '年龄',
        key: 'age',
        type: 'number',
        placeholder: '请输入年龄'
    },
    {
        label: '性别',
        key: 'sex',
        type: 'radio',
        options: [
            { label: '男', value: 'male' },
            { label: '女', value: 'female' },
            { label: '保密', value: 'secrecy' }
        ]
    },
    {
        label: '爱好',
        key: 'hobbies',
        type: 'checkbox',
        options: [
            { label: '阅读', value: 'reading' },
            { label: '旅行', value: 'traveling' },
            { label: '运动', value: 'sports' }
        ]
    },
    {
        label: '出生日期',
        key: 'dateOfBirth',
        type: 'datePicker',
        placeholder: '请选择出生日期'
    }
];
</script>

<style lang="scss" scoped>
.myBody {
    margin: 1rem;
}
</style>

2、打印接收的数据 -MyForm.vue

<template>
    表单组件接收的表单数据:
    <pre> {{ formData }}</pre>
    <el-divider />
    表单组件接收的表单项:
    {{ props.items }}
</template>

<script setup lang="ts">
const formData = defineModel();

const props = defineProps(['items']);
</script>

<style scoped lang="scss"></style>

3、页面效果

image.png

渲染表单项和绑定表单数据

1、让我们编写表单组件的代码,将基础的表单项和表单数据绑定完成

<template>
    <el-form :model="formData">
        <el-form-item v-for="item in props.items" :key="item.key" :prop="item.key" :label="item.label">
            <component :is="getComponent(item)" v-bind="getProps(item)" v-model="formData[item.key]"></component>
        </el-form-item>
    </el-form>
</template>

<script setup lang="ts">
import { ElCheckboxGroup, ElDatePicker, ElInput, ElInputNumber, ElRadioGroup } from 'element-plus';
import { omit } from 'lodash';

const formData = defineModel<Record<string, any>>({ default: {} });

const props = defineProps(['items']);

const componentMap: Record<string, Component> = {
    input: ElInput,
    number: ElInputNumber,
    radio: ElRadioGroup,
    checkbox: ElCheckboxGroup,
    datePicker: ElDatePicker
};

const getComponent = (item: any) => {
    return componentMap[item.type];
};

const rootProps = ['label', 'key', 'type'];

const getProps = (item: any) => {
    return omit(item, rootProps);
};
</script>

<style scoped lang="scss"></style>

2、页面效果

image.png

3、测试一下表单功能,都没问题,但是包含子组件的组件我们还没有处理

image.png

处理包含子组件的组件渲染

1、写一个函数生成需要渲染的子组件,然后在页面渲染即可

<template>
    <el-form :model="formData">
        <el-form-item v-for="item in props.items" :key="item.key" :prop="item.key" :label="item.label">
            <component :is="getComponent(item)" v-bind="getProps(item)" v-model="formData[item.key]">
                <component
                    v-for="(option, index) in getOptions(item)"
                    :key="index"
                    :is="option.component"
                    v-bind="option.props"
                ></component>
            </component>
        </el-form-item>
    </el-form>
</template>

<script setup lang="ts">
import { ElCheckbox, ElCheckboxGroup, ElDatePicker, ElInput, ElInputNumber, ElRadio, ElRadioGroup } from 'element-plus';
import { omit } from 'lodash';

const formData = defineModel<Record<string, any>>({ default: {} });

const props = defineProps(['items']);

const componentMap: Record<string, Component> = {
    input: ElInput,
    number: ElInputNumber,
    radio: ElRadioGroup,
    checkbox: ElCheckboxGroup,
    datePicker: ElDatePicker
};

const getComponent = (item: any) => {
    return componentMap[item.type];
};

const rootProps = ['label', 'key', 'type'];

const getProps = (item: any) => {
    return omit(item, rootProps);
};

const getOptions = (item: any) => {
    if (!item.options?.length) return [];

    const componentMap: Record<string, Component> = {
        radio: ElRadio,
        checkbox: ElCheckbox
    };

    const component = componentMap[item.type];
    if (!component) return [];

    return item.options.map((option: any) => ({
        component,
        props: option
    }));
};
</script>

<style scoped lang="scss"></style>

2、页面效果,功能测试都没问题

image.png

3、至此组件的渲染和基础功能都完成了,下面我们只需要进行一些细节功能添加

表单函数抛出

1、这里把常用的两个函数(表单验证和重置)抛出

<template>
    <el-form ref="formRef" :model="formData">
        <el-form-item v-for="item in props.items" :key="item.key" :prop="item.key" :label="item.label">
            <component :is="getComponent(item)" v-bind="getProps(item)" v-model="formData[item.key]">
                <component
                    v-for="(option, index) in getOptions(item)"
                    :key="index"
                    :is="option.component"
                    v-bind="option.props"
                ></component>
            </component>
        </el-form-item>
    </el-form>
</template>

<script setup lang="ts">
import { ElCheckbox, ElCheckboxGroup, ElDatePicker, ElInput, ElInputNumber, ElRadio, ElRadioGroup } from 'element-plus';
import { omit } from 'lodash';

const formRef = ref();

const formData = defineModel<Record<string, any>>({ default: {} });

const props = defineProps(['items']);

const componentMap: Record<string, Component> = {
    input: ElInput,
    number: ElInputNumber,
    radio: ElRadioGroup,
    checkbox: ElCheckboxGroup,
    datePicker: ElDatePicker
};

const getComponent = (item: any) => {
    return componentMap[item.type];
};

const rootProps = ['label', 'key', 'type'];

const getProps = (item: any) => {
    return omit(item, rootProps);
};

const getOptions = (item: any) => {
    if (!item.options?.length) return [];

    const componentMap: Record<string, Component> = {
        radio: ElRadio,
        checkbox: ElCheckbox
    };

    const component = componentMap[item.type];
    if (!component) return [];

    return item.options.map((option: any) => ({
        component,
        props: option
    }));
};

defineExpose({
    validate: (...args: any[]) => {
        return formRef.value.validate(...args);
    },
    resetFields: (...args: any[]) => {
        return formRef.value.resetFields(...args);
    }
});
</script>

<style scoped lang="scss"></style>

2、在页面上使用,添加提交和重置两个按钮

<template>
    <div class="myBody">
        <el-card header="自定义表单">
            <my-form ref="formRef" v-model="formData" :items="items"></my-form>
            <el-divider />
            <div>
                <div>表单数据</div>
                <pre>{{ formData }}</pre>
            </div>
            <el-divider />
            <el-space>
                <el-button type="primary" @click="submit">提交</el-button>
                <el-button @click="reset">重置</el-button>
            </el-space>
        </el-card>
    </div>
</template>

<script lang="ts" setup>
import MyForm from '@/components/utils/MyForm.vue';

const formRef = ref();

const formData = ref({});

const items = [
    {
        label: '姓名',
        key: 'name',
        type: 'input',
        placeholder: '请输入姓名'
    },
    {
        label: '年龄',
        key: 'age',
        type: 'number',
        placeholder: '请输入年龄'
    },
    {
        label: '性别',
        key: 'sex',
        type: 'radio',
        options: [
            { label: '男', value: 'male' },
            { label: '女', value: 'female' },
            { label: '保密', value: 'secrecy' }
        ]
    },
    {
        label: '爱好',
        key: 'hobbies',
        type: 'checkbox',
        options: [
            { label: '阅读', value: 'reading' },
            { label: '旅行', value: 'traveling' },
            { label: '运动', value: 'sports' }
        ]
    },
    {
        label: '出生日期',
        key: 'dateOfBirth',
        type: 'datePicker',
        placeholder: '请选择出生日期'
    }
];

const submit = () => {
    formRef.value
        .validate()
        .then(() => {
            console.log('表单验证通过,提交的数据:{}', formData.value);
        })
        .catch((error: Error) => {
            console.error('表单验证失败:{}', error.message);
        });
};

const reset = () => {
    formRef.value.resetFields();
    console.log('表单已重置');
};
</script>

<style lang="scss" scoped>
.myBody {
    margin: 1rem;
}
</style>

3、我们来测试一下,功能都没有问题,但是我们还没有添加表单校验规则

image.png

image.png

4、添加表单校验规则

<template>
    <div class="myBody">
        <el-card header="自定义表单">
            <my-form ref="formRef" v-model="formData" :items="items" :rules="rules"></my-form>
            <el-divider />
            <div>
                <div>表单数据</div>
                <pre>{{ formData }}</pre>
            </div>
            <el-divider />
            <el-space>
                <el-button type="primary" @click="submit">提交</el-button>
                <el-button @click="reset">重置</el-button>
            </el-space>
        </el-card>
    </div>
</template>

<script lang="ts" setup>
import MyForm from '@/components/utils/MyForm.vue';

const formRef = ref();

const formData = ref({});

const items = [
    {
        label: '姓名',
        key: 'name',
        type: 'input',
        placeholder: '请输入姓名'
    },
    {
        label: '年龄',
        key: 'age',
        type: 'number',
        placeholder: '请输入年龄'
    },
    {
        label: '性别',
        key: 'sex',
        type: 'radio',
        options: [
            { label: '男', value: 'male' },
            { label: '女', value: 'female' },
            { label: '保密', value: 'secrecy' }
        ]
    },
    {
        label: '爱好',
        key: 'hobbies',
        type: 'checkbox',
        options: [
            { label: '阅读', value: 'reading' },
            { label: '旅行', value: 'traveling' },
            { label: '运动', value: 'sports' }
        ]
    },
    {
        label: '出生日期',
        key: 'dateOfBirth',
        type: 'datePicker',
        placeholder: '请选择出生日期'
    }
];

const rules = {
    name: [
        { required: true, message: '请输入姓名', trigger: 'blur' },
        { min: 2, max: 10, message: '姓名长度在2-10个字符之间', trigger: 'blur' }
    ],
    age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
    sex: [{ required: true, message: '请选择性别', trigger: 'change' }],
    hobbies: [{ required: true, message: '请选择至少一个爱好', trigger: 'change' }],
    dateOfBirth: [{ required: true, message: '请选择出生日期', trigger: 'change' }]
};

const submit = () => {
    formRef.value
        .validate()
        .then(() => {
            console.log('表单验证通过,提交的数据:{}', formData.value);
        })
        .catch(() => {
            console.error('表单验证失败');
        });
};

const reset = () => {
    formRef.value.resetFields();
    console.log('表单已重置');
};
</script>

<style lang="scss" scoped>
.myBody {
    margin: 1rem;
}
</style>

5、在组件中绑定校验规则的值

<template>
    <el-form ref="formRef" :model="formData" :rules="rules">
        <el-form-item v-for="item in props.items" :key="item.key" :prop="item.key" :label="item.label">
            <component :is="getComponent(item)" v-bind="getProps(item)" v-model="formData[item.key]">
                <component
                    v-for="(option, index) in getOptions(item)"
                    :key="index"
                    :is="option.component"
                    v-bind="option.props"
                ></component>
            </component>
        </el-form-item>
    </el-form>
</template>

<script setup lang="ts">
import { ElCheckbox, ElCheckboxGroup, ElDatePicker, ElInput, ElInputNumber, ElRadio, ElRadioGroup } from 'element-plus';
import { omit } from 'lodash';

const formRef = ref();

const formData = defineModel<Record<string, any>>({ default: {} });

const props = defineProps(['items', 'rules']);

const componentMap: Record<string, Component> = {
    input: ElInput,
    number: ElInputNumber,
    radio: ElRadioGroup,
    checkbox: ElCheckboxGroup,
    datePicker: ElDatePicker
};

const getComponent = (item: any) => {
    return componentMap[item.type];
};

const rootProps = ['label', 'key', 'type'];

const getProps = (item: any) => {
    return omit(item, rootProps);
};

const getOptions = (item: any) => {
    if (!item.options?.length) return [];

    const componentMap: Record<string, Component> = {
        radio: ElRadio,
        checkbox: ElCheckbox
    };

    const component = componentMap[item.type];
    if (!component) return [];

    return item.options.map((option: any) => ({
        component,
        props: option
    }));
};

defineExpose({
    validate: (...args: any[]) => {
        return formRef.value.validate(...args);
    },
    resetFields: (...args: any[]) => {
        return formRef.value.resetFields(...args);
    }
});
</script>

<style scoped lang="scss"></style>

6、测试表单校验,功能都没问题

image.png

image.png

image.png

7、现在基本功能都没问题了,后面我们再继续添加一些细节功能

表单项自定义组件、自定义插槽

1、如果我们想将表单项设置为自定义的组件,可以这样,先创建一个 HelloWorld.vue 组件

<template>hello world</template>

<script setup lang="ts"></script>

<style scoped lang="scss"></style>

2、页面上使用,顺便设置一个默认组件,例如我们项目中输入框用的比较多,我想在不传type时让它默认为输入框

<template>
    <div class="myBody">
        <el-card header="自定义表单">
            <my-form ref="formRef" v-model="formData" :items="items" :rules="rules"></my-form>
            <el-divider />
            <div>
                <div>表单数据</div>
                <pre>{{ formData }}</pre>
            </div>
            <el-divider />
            <el-space>
                <el-button type="primary" @click="submit">提交</el-button>
                <el-button @click="reset">重置</el-button>
            </el-space>
        </el-card>
    </div>
</template>

<script lang="ts" setup>
import MyForm from '@/components/utils/MyForm.vue';
import HelloWorld from '@/pages/HelloWorld.vue';

const formRef = ref();

const formData = ref({});

const items = [
    {
        label: '姓名',
        key: 'name',
        type: 'input',
        placeholder: '请输入姓名'
    },
    {
        label: '年龄',
        key: 'age',
        type: 'number',
        placeholder: '请输入年龄'
    },
    {
        label: '性别',
        key: 'sex',
        type: 'radio',
        options: [
            { label: '男', value: 'male' },
            { label: '女', value: 'female' },
            { label: '保密', value: 'secrecy' }
        ]
    },
    {
        label: '爱好',
        key: 'hobbies',
        type: 'checkbox',
        options: [
            { label: '阅读', value: 'reading' },
            { label: '旅行', value: 'traveling' },
            { label: '运动', value: 'sports' }
        ]
    },
    {
        label: '出生日期',
        key: 'dateOfBirth',
        type: 'datePicker',
        placeholder: '请选择出生日期'
    },
    {
        label: '自定义组件',
        key: 'customComponent',
        type: HelloWorld
    },
    {
        label: '默认组件',
        key: 'defaultComponent',
        placeholder: '默认组件...'
    }
];

const rules = {
    name: [
        { required: true, message: '请输入姓名', trigger: 'blur' },
        { min: 2, max: 10, message: '姓名长度在2-10个字符之间', trigger: 'blur' }
    ],
    age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
    sex: [{ required: true, message: '请选择性别', trigger: 'change' }],
    hobbies: [{ required: true, message: '请选择至少一个爱好', trigger: 'change' }],
    dateOfBirth: [{ required: true, message: '请选择出生日期', trigger: 'change' }]
};

const submit = () => {
    formRef.value
        .validate()
        .then(() => {
            console.log('表单验证通过,提交的数据:{}', formData.value);
        })
        .catch(() => {
            console.error('表单验证失败');
        });
};

const reset = () => {
    formRef.value.resetFields();
    console.log('表单已重置');
};
</script>

<style lang="scss" scoped>
.myBody {
    margin: 1rem;
}
</style>

3、我们只需要改一下获取表单项组件的函数内容即可

<template>
    <el-form ref="formRef" :model="formData" :rules="rules">
        <el-form-item v-for="item in props.items" :key="item.key" :prop="item.key" :label="item.label">
            <component :is="getComponent(item)" v-bind="getProps(item)" v-model="formData[item.key]">
                <component
                    v-for="(option, index) in getOptions(item)"
                    :key="index"
                    :is="option.component"
                    v-bind="option.props"
                ></component>
            </component>
        </el-form-item>
    </el-form>
</template>

<script setup lang="ts">
import { ElCheckbox, ElCheckboxGroup, ElDatePicker, ElInput, ElInputNumber, ElRadio, ElRadioGroup } from 'element-plus';
import { omit } from 'lodash';

const formRef = ref();

const formData = defineModel<Record<string, any>>({ default: {} });

const props = defineProps(['items', 'rules']);

const componentMap: Record<string, Component> = {
    input: ElInput,
    number: ElInputNumber,
    radio: ElRadioGroup,
    checkbox: ElCheckboxGroup,
    datePicker: ElDatePicker
};

const getComponent = (item: any) => {
    if (item.type && typeof item.type !== 'string') {
        return item.type;
    }
    return componentMap[item.type || 'input'];
};

const rootProps = ['label', 'key', 'type'];

const getProps = (item: any) => {
    return omit(item, rootProps);
};

const getOptions = (item: any) => {
    if (!item.options?.length) return [];

    const componentMap: Record<string, Component> = {
        radio: ElRadio,
        checkbox: ElCheckbox
    };

    const component = componentMap[item.type];
    if (!component) return [];

    return item.options.map((option: any) => ({
        component,
        props: option
    }));
};

defineExpose({
    validate: (...args: any[]) => {
        return formRef.value.validate(...args);
    },
    resetFields: (...args: any[]) => {
        return formRef.value.resetFields(...args);
    }
});
</script>

<style scoped lang="scss"></style>

4、页面效果,功能都没问题

image.png

5、我们在页面上添加一个自定义插槽

<template>
    <div class="myBody">
        <el-card header="自定义表单">
            <my-form ref="formRef" v-model="formData" :items="items" :rules="rules">
                <template #customSlot>
                    <div>这是自定义的插槽</div>
                </template>
            </my-form>
            <el-divider />
            <div>
                <div>表单数据</div>
                <pre>{{ formData }}</pre>
            </div>
            <el-divider />
            <el-space>
                <el-button type="primary" @click="submit">提交</el-button>
                <el-button @click="reset">重置</el-button>
            </el-space>
        </el-card>
    </div>
</template>

<script lang="ts" setup>
import MyForm from '@/components/utils/MyForm.vue';
import HelloWorld from '@/pages/HelloWorld.vue';

const formRef = ref();

const formData = ref({});

const items = [
    {
        label: '姓名',
        key: 'name',
        type: 'input',
        placeholder: '请输入姓名'
    },
    {
        label: '年龄',
        key: 'age',
        type: 'number',
        placeholder: '请输入年龄'
    },
    {
        label: '性别',
        key: 'sex',
        type: 'radio',
        options: [
            { label: '男', value: 'male' },
            { label: '女', value: 'female' },
            { label: '保密', value: 'secrecy' }
        ]
    },
    {
        label: '爱好',
        key: 'hobbies',
        type: 'checkbox',
        options: [
            { label: '阅读', value: 'reading' },
            { label: '旅行', value: 'traveling' },
            { label: '运动', value: 'sports' }
        ]
    },
    {
        label: '出生日期',
        key: 'dateOfBirth',
        type: 'datePicker',
        placeholder: '请选择出生日期'
    },
    {
        label: '自定义组件',
        key: 'customComponent',
        type: HelloWorld
    },
    {
        label: '默认组件',
        key: 'defaultComponent',
        placeholder: '默认组件...'
    },
    {
        label: '自定义插槽',
        key: 'customSlot'
    }
];

const rules = {
    name: [
        { required: true, message: '请输入姓名', trigger: 'blur' },
        { min: 2, max: 10, message: '姓名长度在2-10个字符之间', trigger: 'blur' }
    ],
    age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
    sex: [{ required: true, message: '请选择性别', trigger: 'change' }],
    hobbies: [{ required: true, message: '请选择至少一个爱好', trigger: 'change' }],
    dateOfBirth: [{ required: true, message: '请选择出生日期', trigger: 'change' }]
};

const submit = () => {
    formRef.value
        .validate()
        .then(() => {
            console.log('表单验证通过,提交的数据:{}', formData.value);
        })
        .catch(() => {
            console.error('表单验证失败');
        });
};

const reset = () => {
    formRef.value.resetFields();
    console.log('表单已重置');
};
</script>

<style lang="scss" scoped>
.myBody {
    margin: 1rem;
}
</style>

6、在组件中处理一下插槽渲染,如果有传入自定义插槽就渲染,否则渲染表单项组件

<template>
    <el-form ref="formRef" :model="formData" :rules="rules">
        <el-form-item v-for="item in props.items" :key="item.key" :prop="item.key" :label="item.label">
            <slot :name="item.key">
                <component :is="getComponent(item)" v-bind="getProps(item)" v-model="formData[item.key]">
                    <component
                        v-for="(option, index) in getOptions(item)"
                        :key="index"
                        :is="option.component"
                        v-bind="option.props"
                    ></component>
                </component>
            </slot>
        </el-form-item>
    </el-form>
</template>

<script setup lang="ts">
import { ElCheckbox, ElCheckboxGroup, ElDatePicker, ElInput, ElInputNumber, ElRadio, ElRadioGroup } from 'element-plus';
import { omit } from 'lodash';

const formRef = ref();

const formData = defineModel<Record<string, any>>({ default: {} });

const props = defineProps(['items', 'rules']);

const componentMap: Record<string, Component> = {
    input: ElInput,
    number: ElInputNumber,
    radio: ElRadioGroup,
    checkbox: ElCheckboxGroup,
    datePicker: ElDatePicker
};

const getComponent = (item: any) => {
    if (item.type && typeof item.type !== 'string') {
        return item.type;
    }
    return componentMap[item.type || 'input'];
};

const rootProps = ['label', 'key', 'type'];

const getProps = (item: any) => {
    return omit(item, rootProps);
};

const getOptions = (item: any) => {
    if (!item.options?.length) return [];

    const componentMap: Record<string, Component> = {
        radio: ElRadio,
        checkbox: ElCheckbox
    };

    const component = componentMap[item.type];
    if (!component) return [];

    return item.options.map((option: any) => ({
        component,
        props: option
    }));
};

defineExpose({
    validate: (...args: any[]) => {
        return formRef.value.validate(...args);
    },
    resetFields: (...args: any[]) => {
        return formRef.value.resetFields(...args);
    }
});
</script>

<style scoped lang="scss"></style>

7、页面效果,可以看到我们自定义的插槽已经渲染出来了

image.png

8、现在这个组件能满足很多场景了,但是还有可以添加的功能,例如:用户想自定义布局排版呢?

自定义表单布局

1、先在表单组件中添加布局组件,如果不传占位大小(span)则默认24(占满一行)

<template>
    <el-form ref="formRef" :model="formData" :rules="rules">
        <el-row>
            <el-col v-for="item in props.items" :key="item.key" :span="item.span || 24">
                <el-form-item :prop="item.key" :label="item.label">
                    <slot :name="item.key">
                        <component :is="getComponent(item)" v-bind="getProps(item)" v-model="formData[item.key]">
                            <component
                                v-for="(option, index) in getOptions(item)"
                                :key="index"
                                :is="option.component"
                                v-bind="option.props"
                            ></component>
                        </component>
                    </slot>
                </el-form-item>
            </el-col>
        </el-row>
    </el-form>
</template>

<script setup lang="ts">
import { ElCheckbox, ElCheckboxGroup, ElDatePicker, ElInput, ElInputNumber, ElRadio, ElRadioGroup } from 'element-plus';
import { omit } from 'lodash';

const formRef = ref();

const formData = defineModel<Record<string, any>>({ default: {} });

const props = defineProps(['items', 'rules']);

const componentMap: Record<string, Component> = {
    input: ElInput,
    number: ElInputNumber,
    radio: ElRadioGroup,
    checkbox: ElCheckboxGroup,
    datePicker: ElDatePicker
};

const getComponent = (item: any) => {
    if (item.type && typeof item.type !== 'string') {
        return item.type;
    }
    return componentMap[item.type || 'input'];
};

const rootProps = ['label', 'key', 'type'];

const getProps = (item: any) => {
    return omit(item, rootProps);
};

const getOptions = (item: any) => {
    if (!item.options?.length) return [];

    const componentMap: Record<string, Component> = {
        radio: ElRadio,
        checkbox: ElCheckbox
    };

    const component = componentMap[item.type];
    if (!component) return [];

    return item.options.map((option: any) => ({
        component,
        props: option
    }));
};

defineExpose({
    validate: (...args: any[]) => {
        return formRef.value.validate(...args);
    },
    resetFields: (...args: any[]) => {
        return formRef.value.resetFields(...args);
    }
});
</script>

<style scoped lang="scss"></style>

2、页面上使用

<template>
    <div class="myBody">
        <el-card header="自定义表单">
            <my-form ref="formRef" v-model="formData" :items="items" :rules="rules">
                <template #customSlot>
                    <div>这是自定义的插槽</div>
                </template>
            </my-form>
            <el-divider />
            <div>
                <div>表单数据</div>
                <pre>{{ formData }}</pre>
            </div>
            <el-divider />
            <el-space>
                <el-button type="primary" @click="submit">提交</el-button>
                <el-button @click="reset">重置</el-button>
            </el-space>
        </el-card>
    </div>
</template>

<script lang="ts" setup>
import MyForm from '@/components/utils/MyForm.vue';
import HelloWorld from '@/pages/HelloWorld.vue';

const formRef = ref();

const formData = ref({});

const items = [
    {
        label: '姓名',
        key: 'name',
        type: 'input',
        placeholder: '请输入姓名',
        span: 12
    },
    {
        label: '年龄',
        key: 'age',
        type: 'number',
        placeholder: '请输入年龄',
        span: 12
    },
    {
        label: '性别',
        key: 'sex',
        type: 'radio',
        options: [
            { label: '男', value: 'male' },
            { label: '女', value: 'female' },
            { label: '保密', value: 'secrecy' }
        ],
        span: 12
    },
    {
        label: '爱好',
        key: 'hobbies',
        type: 'checkbox',
        options: [
            { label: '阅读', value: 'reading' },
            { label: '旅行', value: 'traveling' },
            { label: '运动', value: 'sports' }
        ],
        span: 12
    },
    {
        label: '出生日期',
        key: 'dateOfBirth',
        type: 'datePicker',
        placeholder: '请选择出生日期',
        span: 12
    },
    {
        label: '自定义组件',
        key: 'customComponent',
        type: HelloWorld,
        span: 12
    },
    {
        label: '默认组件',
        key: 'defaultComponent',
        placeholder: '默认组件...',
        span: 12
    },
    {
        label: '自定义插槽',
        key: 'customSlot',
        span: 12
    }
];

const rules = {
    name: [
        { required: true, message: '请输入姓名', trigger: 'blur' },
        { min: 2, max: 10, message: '姓名长度在2-10个字符之间', trigger: 'blur' }
    ],
    age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
    sex: [{ required: true, message: '请选择性别', trigger: 'change' }],
    hobbies: [{ required: true, message: '请选择至少一个爱好', trigger: 'change' }],
    dateOfBirth: [{ required: true, message: '请选择出生日期', trigger: 'change' }]
};

const submit = () => {
    formRef.value
        .validate()
        .then(() => {
            console.log('表单验证通过,提交的数据:{}', formData.value);
        })
        .catch(() => {
            console.error('表单验证失败');
        });
};

const reset = () => {
    formRef.value.resetFields();
    console.log('表单已重置');
};
</script>

<style lang="scss" scoped>
.myBody {
    margin: 1rem;
}
</style>

3、页面效果

image.png

4、同一行表单项挨在一起了,我们在表单组件中添加一个间距(gutter)即可

<template>
    <el-form ref="formRef" :model="formData" :rules="rules">
        <el-row :gutter="16">
            <el-col v-for="item in props.items" :key="item.key" :span="item.span || 24">
                <el-form-item :prop="item.key" :label="item.label">
                    <slot :name="item.key">
                        <component :is="getComponent(item)" v-bind="getProps(item)" v-model="formData[item.key]">
                            <component
                                v-for="(option, index) in getOptions(item)"
                                :key="index"
                                :is="option.component"
                                v-bind="option.props"
                            ></component>
                        </component>
                    </slot>
                </el-form-item>
            </el-col>
        </el-row>
    </el-form>
</template>

<script setup lang="ts">
import { ElCheckbox, ElCheckboxGroup, ElDatePicker, ElInput, ElInputNumber, ElRadio, ElRadioGroup } from 'element-plus';
import { omit } from 'lodash';

const formRef = ref();

const formData = defineModel<Record<string, any>>({ default: {} });

const props = defineProps(['items', 'rules']);

const componentMap: Record<string, Component> = {
    input: ElInput,
    number: ElInputNumber,
    radio: ElRadioGroup,
    checkbox: ElCheckboxGroup,
    datePicker: ElDatePicker
};

const getComponent = (item: any) => {
    if (item.type && typeof item.type !== 'string') {
        return item.type;
    }
    return componentMap[item.type || 'input'];
};

const rootProps = ['label', 'key', 'type'];

const getProps = (item: any) => {
    return omit(item, rootProps);
};

const getOptions = (item: any) => {
    if (!item.options?.length) return [];

    const componentMap: Record<string, Component> = {
        radio: ElRadio,
        checkbox: ElCheckbox
    };

    const component = componentMap[item.type];
    if (!component) return [];

    return item.options.map((option: any) => ({
        component,
        props: option
    }));
};

defineExpose({
    validate: (...args: any[]) => {
        return formRef.value.validate(...args);
    },
    resetFields: (...args: any[]) => {
        return formRef.value.resetFields(...args);
    }
});
</script>

<style scoped lang="scss"></style>

5、页面效果,现在有了间距,看上去舒服多了

image.png

6、基础功能都有了,在实际应用场景中,往往表单项是需要动态渲染的,我们来将动态渲染表单项功能实现

表单项动态渲染

1、在页面上简单的添加两个输入框,分别是:男生简介、女生简介,然后添加触发条件

<template>
    <div class="myBody">
        <el-card header="自定义表单">
            <my-form ref="formRef" v-model="formData" :items="items" :rules="rules">
                <template #customSlot>
                    <div>这是自定义的插槽</div>
                </template>
            </my-form>
            <el-divider />
            <div>
                <div>表单数据</div>
                <pre>{{ formData }}</pre>
            </div>
            <el-divider />
            <el-space>
                <el-button type="primary" @click="submit">提交</el-button>
                <el-button @click="reset">重置</el-button>
            </el-space>
        </el-card>
    </div>
</template>

<script lang="ts" setup>
import MyForm from '@/components/utils/MyForm.vue';
import HelloWorld from '@/pages/HelloWorld.vue';

const formRef = ref();

const formData = ref({}) as any;

const items = [
    {
        label: '姓名',
        key: 'name',
        type: 'input',
        placeholder: '请输入姓名',
        span: 12
    },
    {
        label: '年龄',
        key: 'age',
        type: 'number',
        placeholder: '请输入年龄',
        span: 12
    },
    {
        label: '性别',
        key: 'sex',
        type: 'radio',
        options: [
            { label: '男', value: 'male' },
            { label: '女', value: 'female' },
            { label: '保密', value: 'secrecy' }
        ],
        span: 12
    },
    {
        label: '爱好',
        key: 'hobbies',
        type: 'checkbox',
        options: [
            { label: '阅读', value: 'reading' },
            { label: '旅行', value: 'traveling' },
            { label: '运动', value: 'sports' }
        ],
        span: 12
    },
    {
        label: '出生日期',
        key: 'dateOfBirth',
        type: 'datePicker',
        placeholder: '请选择出生日期',
        span: 12
    },
    {
        label: '自定义组件',
        key: 'customComponent',
        type: HelloWorld,
        span: 12
    },
    {
        label: '默认组件',
        key: 'defaultComponent',
        placeholder: '默认组件...',
        span: 12
    },
    {
        label: '自定义插槽',
        key: 'customSlot',
        span: 12
    },
    {
        label: '男生简介',
        key: 'maleIntroduction',
        type: 'input',
        placeholder: '请输入男生的简介',
        hidden: !formData.value.sex || formData.value.sex !== 'male'
    },
    {
        label: '女生简介',
        key: 'femaleIntroduction',
        type: 'input',
        placeholder: '请输入女生的简介',
        hidden: !formData.value.sex || formData.value.sex !== 'female'
    }
];

const rules = {
    name: [
        { required: true, message: '请输入姓名', trigger: 'blur' },
        { min: 2, max: 10, message: '姓名长度在2-10个字符之间', trigger: 'blur' }
    ],
    age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
    sex: [{ required: true, message: '请选择性别', trigger: 'change' }],
    hobbies: [{ required: true, message: '请选择至少一个爱好', trigger: 'change' }],
    dateOfBirth: [{ required: true, message: '请选择出生日期', trigger: 'change' }]
};

const submit = () => {
    formRef.value
        .validate()
        .then(() => {
            console.log('表单验证通过,提交的数据:{}', formData.value);
        })
        .catch(() => {
            console.error('表单验证失败');
        });
};

const reset = () => {
    formRef.value.resetFields();
    console.log('表单已重置');
};
</script>

<style lang="scss" scoped>
.myBody {
    margin: 1rem;
}
</style>

2、页面效果

image.png

3、我们在组件中处理一下隐藏渲染的功能,很简单,写一个获取标点项的函数(getItems),过滤一下隐藏项即可

<template>
    <el-form ref="formRef" :model="formData" :rules="rules">
        <el-row :gutter="16">
            <el-col v-for="item in getItems" :key="item.key" :span="item.span || 24">
                <el-form-item :prop="item.key" :label="item.label">
                    <slot :name="item.key">
                        <component :is="getComponent(item)" v-bind="getProps(item)" v-model="formData[item.key]">
                            <component
                                v-for="(option, index) in getOptions(item)"
                                :key="index"
                                :is="option.component"
                                v-bind="option.props"
                            ></component>
                        </component>
                    </slot>
                </el-form-item>
            </el-col>
        </el-row>
    </el-form>
</template>

<script setup lang="ts">
import { ElCheckbox, ElCheckboxGroup, ElDatePicker, ElInput, ElInputNumber, ElRadio, ElRadioGroup } from 'element-plus';
import { omit } from 'lodash';

const formRef = ref();

const formData = defineModel<Record<string, any>>({ default: {} });

const props = defineProps(['items', 'rules']);

const getItems = computed(() => {
    return props.items.filter((item: any) => !item.hidden);
});

const componentMap: Record<string, Component> = {
    input: ElInput,
    number: ElInputNumber,
    radio: ElRadioGroup,
    checkbox: ElCheckboxGroup,
    datePicker: ElDatePicker
};

const getComponent = (item: any) => {
    if (item.type && typeof item.type !== 'string') {
        return item.type;
    }
    return componentMap[item.type || 'input'];
};

const rootProps = ['label', 'key', 'type'];

const getProps = (item: any) => {
    return omit(item, rootProps);
};

const getOptions = (item: any) => {
    if (!item.options?.length) return [];

    const componentMap: Record<string, Component> = {
        radio: ElRadio,
        checkbox: ElCheckbox
    };

    const component = componentMap[item.type];
    if (!component) return [];

    return item.options.map((option: any) => ({
        component,
        props: option
    }));
};

defineExpose({
    validate: (...args: any[]) => {
        return formRef.value.validate(...args);
    },
    resetFields: (...args: any[]) => {
        return formRef.value.resetFields(...args);
    }
});
</script>

<style scoped lang="scss"></style>

4、页面效果,隐藏项默认会隐藏,当选择性别后为什么没有显示对应性别的简介输入框?因为我们页面上的items是写死的值,当组件数据变化后,页面上的items内的数据不会随着变化,套一层计算属性即可

image.png

<template>
    <div class="myBody">
        <el-card header="自定义表单">
            <my-form ref="formRef" v-model="formData" :items="items" :rules="rules">
                <template #customSlot>
                    <div>这是自定义的插槽</div>
                </template>
            </my-form>
            <el-divider />
            <div>
                <div>表单数据</div>
                <pre>{{ formData }}</pre>
            </div>
            <el-divider />
            <el-space>
                <el-button type="primary" @click="submit">提交</el-button>
                <el-button @click="reset">重置</el-button>
            </el-space>
        </el-card>
    </div>
</template>

<script lang="ts" setup>
import MyForm from '@/components/utils/MyForm.vue';
import HelloWorld from '@/pages/HelloWorld.vue';

const formRef = ref();

const formData = ref({}) as any;

const items = computed(() => [
    {
        label: '姓名',
        key: 'name',
        type: 'input',
        placeholder: '请输入姓名',
        span: 12
    },
    {
        label: '年龄',
        key: 'age',
        type: 'number',
        placeholder: '请输入年龄',
        span: 12
    },
    {
        label: '性别',
        key: 'sex',
        type: 'radio',
        options: [
            { label: '男', value: 'male' },
            { label: '女', value: 'female' },
            { label: '保密', value: 'secrecy' }
        ],
        span: 12
    },
    {
        label: '爱好',
        key: 'hobbies',
        type: 'checkbox',
        options: [
            { label: '阅读', value: 'reading' },
            { label: '旅行', value: 'traveling' },
            { label: '运动', value: 'sports' }
        ],
        span: 12
    },
    {
        label: '出生日期',
        key: 'dateOfBirth',
        type: 'datePicker',
        placeholder: '请选择出生日期',
        span: 12
    },
    {
        label: '自定义组件',
        key: 'customComponent',
        type: HelloWorld,
        span: 12
    },
    {
        label: '默认组件',
        key: 'defaultComponent',
        placeholder: '默认组件...',
        span: 12
    },
    {
        label: '自定义插槽',
        key: 'customSlot',
        span: 12
    },
    {
        label: '男生简介',
        key: 'maleIntroduction',
        type: 'input',
        placeholder: '请输入男生的简介',
        hidden: !formData.value.sex || formData.value.sex !== 'male'
    },
    {
        label: '女生简介',
        key: 'femaleIntroduction',
        type: 'input',
        placeholder: '请输入女生的简介',
        hidden: !formData.value.sex || formData.value.sex !== 'female'
    }
]);

const rules = {
    name: [
        { required: true, message: '请输入姓名', trigger: 'blur' },
        { min: 2, max: 10, message: '姓名长度在2-10个字符之间', trigger: 'blur' }
    ],
    age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
    sex: [{ required: true, message: '请选择性别', trigger: 'change' }],
    hobbies: [{ required: true, message: '请选择至少一个爱好', trigger: 'change' }],
    dateOfBirth: [{ required: true, message: '请选择出生日期', trigger: 'change' }]
};

const submit = () => {
    formRef.value
        .validate()
        .then(() => {
            console.log('表单验证通过,提交的数据:{}', formData.value);
        })
        .catch(() => {
            console.error('表单验证失败');
        });
};

const reset = () => {
    formRef.value.resetFields();
    console.log('表单已重置');
};
</script>

<style lang="scss" scoped>
.myBody {
    margin: 1rem;
}
</style>

5、页面效果,功能都正常

image.png

image.png

6、现在组件基本上能满足大多数场景了,至于其他一些小细节功能和细节优化工作就交给你们了

结语

本文旨在提供思路,代码和风格不用学我的,只要思路学会了所有组件库的动态表单封装都没问题了
如有不懂或者疑问,可在下方留言或私信我,看到必回
希望对你能有所帮助,如果觉得文章写的不错,欢迎点赞/收藏,三克油~

从焦虑到专注:副业半年后我才明白的3件事

2025年7月14日 15:02

我是匿星,95程序员,坐标郑州,副业自媒体,目前与2000+朋友一起探索副业!

最近不少小伙伴问:匿星,你是怎么开始做副业的。

今天就和大家聊聊我在副业过程中的困境,以及是如何走出来的。

01 副业的第一要义:突破认知

我是去年5月份接触自媒体副业,当时纯纯一个小白,对公众号的知识一点都不了解。

那时的我还以为公众号只能靠接广告赚钱,但学习了以后才知道,早已经突破了私域的限制,走向公域流量,靠文章插入的广告就能拿到收益。

在以前我的认知范围里,做副业就是下班后摆个地摊,卖个小吃。

但我没有技术,如果去学,如何找到一个靠谱的老师是一件很关键的事,有没有时间,精力去做也是要考虑的因素。

再个就是辞职创业,但创业我能做什么,和我一个关系比较好同事天天聊。

结论是:赚的起,但赔不起,所以,不干就是赚。

随便创个业,十几万投进去了,如果赔了,这些年就白干了,所以我俩每次都只是在嘴上过过瘾。

不同的是,我经过这一年,副业虽然赚的还不多,但找到了方向。

image.png 我相信这是我以后要坚定不移努力去做的事情。

而我那个同事,他一开口就说自己不适合做 ,但不试试,怎么知道自己合不合适呢。

02 副业困境是必然存在的

我去年5月份就开始做公众号,主要做爆文,通过流量主赚钱。

学习如何拆解文章,写标题,以及如何分析爆款文章结构,写金句,如何使用AI等等。

努力了半年,换了各种赛道,也注销了好几个号,但依旧做不起来。

阅读量最高的一个号,单篇最多也才5000,可能是公众号平台为了可怜我,才给我推的流。

那时我一度怀疑,自己根本不适合做公众号,不会写作的人,可能真的不适合做,就想着放弃。

但看着社群里面频繁地出成绩,晒收益,我又不甘心。

凭什么他们能拿到爆款,而我就是不行,究竟差在哪里?

直到今年过了年,才学到了新的方法,也算是信息差。

image.png

如何正确使用AI,如何完善提示词减少AI味儿,如何养号起号,如何接流放大收益。

这些方法让我在单月内起成功三个公众号。

但年后的整体流量情况大家也有所了解,很多号都是一波流,快的两天,慢点一到两周,很快就被限流了,我也逃脱不了这个限制。

但不同的是,我并不会像之前为如何做爆款焦虑,我也体会过一秒刷新后阅读量蹭蹭涨的感觉。一波流后冷静下来分析,我应该做一些长期的事。

比如打磨自己的产品,如何在次流量周期,稳定自己的收益。打造个人IP, 是我现在要做的事,也是我认为很有价值的事。同时也会把个人参与过,认为靠谱的副业,推荐出来,以我自己经验,给信任我的朋友避坑。

03 长期主义是坚持初心做正确的事

最近在看一本书,《1000个铁粉:打造个人品牌的底层逻辑》

书中讲到了长期主义,长期主义是一直做一件事吗,不全是,因为这样不够。

就比如有的人公众号写了一年,每天还是那么点阅读量,粉丝正增长的速度和年龄保持正比。

如果做的事情没有价值,那对他人的意义在哪,只能属于自嗨,最终也会因为没有变现而失去动力。

再者是保持初心,当有巨大流量过来,是否能继续打磨产品,做好交付。

当有一定的势能以后,还能否保持对新人有足够的耐心,用自己的经验,带对方少有一些弯路。

这些对一个长期主义的人,都是一个考验。

我是匿星,今天的分享就到这里!

ps: 我有一个副业社群,平时分享一些赚钱项目、案例,以及个人成长类思维认知,感兴趣的朋友可以进群一起交流学习。长按下方加我微信,备注 “掘金”

图片

04-自然壁纸实战教程-搭建基本工程

作者 万少
2025年7月14日 14:57

04-自然壁纸实战教程-搭建基本工程

前言

本章节内容主要来完成工程的基本搭建,比如

  1. 工具类的封装
  2. 静态资源的准备
  3. 三方库的引入和使用 axios、navigation等

搭建工程要做的事情

在准备开发业务代码之前,往往需要先搭建工程基本的一些铺垫

  1. 全屏沉浸式的封装
  2. 网络请求工具的封装
  3. 公共逻辑类的封装
  4. 广告类的封装
  5. 文件下载类的封装
  6. 懒加载数据的封装
  7. 导航工具类的封装
  8. 敏感字过滤的封装处理
  9. 常见类型的封装处理
  10. 静态图片资源

image-20250630111846829

    axiosClient.ets            网络请求
    CommonUtils.ets            公共方法
    downloadFile.ets           文件下载
    fullScreenHelper.ets       全屏
    InterstitialAdUtil.ets     广告
    lazyForEachDataSource.ets  懒加载
    localData.ets              本地数据
    NativeAdUtil.ets           本地广告
    NavigationUtils.ets        页面导航
    sensitiveFilter.ets        敏感字过滤
    types.ets                  通用类型
    videoTypes.ets             视频类型

由于代码繁多,这里部分直接参考代码仓库即可。

一些静态图片资源 src/main/resources/base/media

image-20250630115816128

证书的配置

因为项目开发、上线都需要用到调试证书和发布证书,这个章节的配置比较繁琐,小伙伴们可以先使用默认的自动签名的证书就行,跳过这个环节,后续有需要了再进行证书的相关配置。

image-20250630120313198

配置文件 build-profile.json5,这里主要关注devdefault配置,dev表示使用调试证书,default表示发布证书。

配置好后,通过点击工具的按钮进行模式切换

image-20250630120536298

{
  "app": {
    "signingConfigs": [
      {
        "name": "dev",
        "type": "HarmonyOS",
        "material": {
          "storeFile": "./config/xwfw.p12",
          "storePassword": "0000001AB29773682E10527A66FEE35AB6CD2820EAF93B828AF87DEB9C8853098538D3519810A0E6FE9D",
          "keyAlias": "xwfw123456",
          "keyPassword": "0000001A73D398276C790CCCD22F9BF15056A9324923F0405B0423EC43DDDD43CFA6D2732AEF5C76E1FC",
          "signAlg": "SHA256withECDSA",
          "profile": "./config/自然壁纸-调试Debug.p7b",
          "certpath": "./config/小万服务-调试.cer"
        }
      },
      {
        "name": "default",
        "type": "HarmonyOS",
        "material": {
          "storeFile": "./config/xwfw.p12",
          "storePassword": "0000001A25F37CE63093D76493F3BF7749D50A0EBD6F7A3D83E12462257A1DB6EAADC8D1EB4076419E59",
          "keyAlias": "xwfw123456",
          "keyPassword": "0000001A9625DC325022C1A9FE9B9A599B7928C481F0B94246C25B00197B9391F167ACF6B18BD3C87650",
          "signAlg": "SHA256withECDSA",
          "profile": "./config/自然壁纸-发布Release.p7b",
          "certpath": "./config/小万服务-发布.cer"
        }
      }
    ],
    "products": [
      {
        "name": "default",
        "signingConfig": "default",
        "compatibleSdkVersion": "5.0.2(14)",
        "runtimeOS": "HarmonyOS",
        "buildOption": {
          "strictMode": {
            "caseSensitiveCheck": true,
            "useNormalizedOHMUrl": true
          }
        }
      },
      {
        "name": "dev",
        "signingConfig": "dev",
        "compatibleSdkVersion": "5.0.2(14)",
        "runtimeOS": "HarmonyOS",
        "buildOption": {
          "strictMode": {
            "caseSensitiveCheck": true,
            "useNormalizedOHMUrl": true
          }
        }
      }
    ],
    "buildModeSet": [
      {
        "name": "debug",
      },
      {
        "name": "release"
      }
    ]
  },
  "modules": [
    {
      "name": "entry",
      "srcPath": "./entry",
      "targets": [
        {
          "name": "default",
          "applyToProducts": [
            "default",
            "dev"
          ]
        }
      ]
    }
  ]
}

如何获取资料

获取资料的途径,可以关注我们 官网的公众号 青蓝逐码 ,输入 项目名称 《自然壁纸》 即可获得以上资料。

关于我们

关于青蓝逐码组织

Axios 如何跨域携带 Cookie?

2025年7月14日 14:43

Axios 如何跨域携带 Cookie?


一、什么是跨域?核心是“同源策略”

1. 同源的定义

同源:协议、域名、端口三者完全一致才算同源,否则就是跨域。

场景 前端页面URL 后端接口URL 是否跨域
同源 http://localhost:3000 http://localhost:3000/api
不同端口 http://localhost:3000 http://localhost:8080/api
不同域名 www.a.com api.b.com
不同协议 http://localhost:3000 https://localhost:3000/api

注意:即使 IP 相同,只要域名不同,也算跨域。


2. 为什么有跨域限制?

  • 同源策略是浏览器的安全机制,防止恶意网站窃取用户数据(如 Cookie)。
  • 跨域请求默认不会携带 Cookie,也不能访问非同源的 DOM。

二、跨域时的典型问题:Cookie 无法携带

1. 典型场景

  • 前端:web.example.com
  • 后端:api.example.com
  • 登录后后端 Set-Cookie: token=xxx; Domain=api.example.com
  • 问题:前端属于 web.example.com,Cookie 作用域是 api.example.com,浏览器不会自动携带 Cookie。

2. 常见报错

The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

三、Axios 跨域携带 Cookie 的完整解决方案

1. 前端配置(withCredentials)

推荐做法:统一封装 Axios 实例,强制 withCredentials: true

import axios from 'axios';

const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000,
  withCredentials: true, // 关键
});

export default api;

或在单个请求中:

axios.get('https://api.example.com/user/profile', {
  withCredentials: true
});

2. 后端 CORS 配置(以 Express 为例)

关键点:

  • Access-Control-Allow-Origin 必须是具体域名,不能是 *
  • Access-Control-Allow-Credentials: true
  • Cookie 设置 SameSite=None; Secure; Domain=.example.com
const cors = require('cors');
app.use(cors({
  origin: 'https://web.example.com', // 不能是 *
  credentials: true
}));

app.post('/login', (req, res) => {
  res.cookie('token', 'abc123', {
    httpOnly: true,
    secure: true, // 生产环境必须 https
    sameSite: 'None', // 跨域必须 None
    domain: '.example.com' // 子域共享
  });
  res.json({ success: true });
});

3. Cookie 属性设置

属性 说明
domain .example.com,子域共享
path /,全站有效
httpOnly true,防止 XSS
secure true,必须 HTTPS
sameSite None,跨域必须设置

4. 开发环境代理(绕过跨域)

Vue CLI 代理示例:

// vue.config.js
module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        secure: false,
        pathRewrite: { '^/api': '' }
      }
    }
  }
};

前端请求 /api/user/profile,实际代理到 https://api.example.com/user/profile

注意:仅限开发环境,生产环境必须用 CORS。


5. 第三方 API 跨域(不可控后端)

方案:服务端中转

// Node.js 代理
app.get('/proxy', async (req, res) => {
  const result = await axios.get('https://third-party.com/data');
  res.send(result.data);
});

前端请求自己的 /proxy,由服务端转发。


四、常见错误与安全注意事项

  1. 不能用 Access-Control-Allow-Origin: * 搭配 Cookie
  2. Cookie 必须 SameSite=NoneSecure=true 才能跨域
  3. 生产环境强制 HTTPS
  4. 限制允许的方法和请求头,避免安全隐患

五、总结表

步骤 说明
前端 withCredentials 必须设置 withCredentials: true
后端 CORS Access-Control-Allow-Origin 指定前端域名,credentials: true
Cookie 属性 SameSite=None; Secure; Domain=.example.com
生产环境 必须 HTTPS
开发环境 可用代理,生产必须 CORS

六、最佳实践建议

  • 生产环境后端精确配置 CORS
  • Cookie 设置合适的 domainsameSitesecure
  • 前端统一封装 Axios,强制 withCredentials
  • 建议用 JWT 替代 Cookie(减少跨域复杂度)

七、常见问题答疑

Q1:为什么 withCredentials 必须配合后端 CORS?
A:否则浏览器会拦截响应,Cookie 不会被设置。

Q2:为什么 SameSite=None 必须 Secure?
A:浏览器安全要求,防止 Cookie 被劫持。

*Q3:能不能用通配符
A:不能,带 Cookie 的跨域请求必须指定具体域名。


八、流程图

前端(web.example.com--withCredentials--> 后端(api.example.com)
        |                                        |
        |<--Set-Cookie: SameSite=None; Secure----|
        |                                        |
        |<--Access-Control-Allow-Origin: web.example.com
        |<--Access-Control-Allow-Credentials: true

九、参考代码片段

前端:

axios.post('https://api.example.com/login', data, {
  withCredentials: true
});

后端:

res.cookie('token', 'abc123', {
  sameSite: 'None',
  secure: true,
  domain: '.example.com'
});
res.header('Access-Control-Allow-Origin', 'https://web.example.com');
res.header('Access-Control-Allow-Credentials', 'true');

十、结论

  • 跨域携带 Cookie,前端 withCredentials: true,后端 CORS 配置+Cookie 属性必须正确。
  • 生产环境务必用 HTTPS,开发环境可用代理。
  • 推荐 JWT 方案,减少跨域 Cookie 的复杂度。

做付费社群,强烈建议大家做这件事!

作者 Immerse
2025年7月14日 14:36

大家好,我是 Immerse,一名独立开发者、内容创作者。

  • 关注公众号:#沉浸式趣谈,获取最新文章(更多内容只在公众号更新)
  • 个人网站:https://yaolifeng.com 也同步更新。
  • 转载请在文章开头注明出处和版权信息。

我会在这里分享关于编程独立开发AI干货开源个人思考等内容。

如果本文对您有所帮助,欢迎动动小手指一键三连(点赞评论转发),给我一些支持和鼓励,谢谢!

做付费社群,强烈建议大家做这件事!

最近加入了好多付费社群,发现每次去“爬楼”去看信息,特别累,个人觉得这钱花的半值半不值 🤣

在这个信息量爆炸的时代,大家都在找有价值的信息或知识。好多小伙伴都做起了付费社群,为大家第一时间提供最新信息或知识。

最近发现了付费社群大部分都没有最好这件事,作为付费社群成员,觉得这是一个非常值得尝试的方式。

用户为什么会加入到你的社群?

一句话就是:能从你这获得价值!

大家能加入到付费社群,说明,当前的这个付费社群已经给你提供了价值,比如:便于你获取第一手信息、给你带来更多 idea、收获一些经验,让你少踩坑,等等。

初期大家加入到社群,可能每天会抽出较多的时间来留意社群内的信息,长时间下来,估计 90% 的人不会去每天留意社群的信息,而是抽空了去“爬楼”看。也有小伙伴从一开始加入到社群,就一直处于潜水状态,这样长时间下来,大家估计会对这个付费社群丧失好感度。

因为用户付费了,但没有获得对应的信息,久而久之,更多的小伙伴可能不会续费,也可能会降低对社群负责人的好感度。(这不能怪社群负责人,因为足够的信息已经在社群内,而是用户没有对应的时间去一一“爬楼”去获取。也有可能是这个社群纯属是割韭菜的,那这就是社群负责人的问题了)

如何利用 AI 提供社群服务水准?

那作为付费社群的负责人,初衷肯定是为每位小伙伴提供价值,但时间长了,没有人会一一“爬楼”。

所以,建议大家可以尝试:“为你的社群引入 AI 群聊总结机器人!”

让它每天定时抓取,分析社群内容,然后每天某个时间点把当天的总结内容发出来,这样既便于大家,也提高了社群服务水准。

为什么这是一个“很不错的选择”?

  1. 告别“爬楼”问题,高效获取更多有价值的信息:

    对于付费社群的成员来说,时间是非常宝贵的。AI 群聊总结机器人可以每天或定期将群内的重点讨论、精华观点、重要通知、甚至是分享的文件链接等,提炼成一份简洁明了的摘要。社群内的小伙伴就无需再花费大量时间去“爬楼”,只需几分钟总结,就能快速掌握当日的社群动态和核心价值信息。

  2. 提升社群服务水准与专业度: 引入 AI 群聊总结机器人,本身就是一种增值服务。这不仅这体现了社群运营者对成员体验的重视。对于付费社群而言,让大家觉得这笔“入群费”花得值。

  3. 激活“潜水”成员: 其实很多成员“潜水” 并非对社群内容不感兴趣,而是没有精力跟上节奏。而 AI 群聊总结正好为这部分成员提供了一个低门槛、高效率了解社群动态的窗口。当他们通过总结发现感兴趣的话题或有价值的信息时,很有可能会参与到讨论中。

  4. 社群价值沉淀与回顾: 优质的社群讨论和分享是付费社群的核心资产。AI 群聊总结机器人不仅能做每日总结,很多还能支持关键词搜索、历史信息回顾等功能。方便新成员快速融入,也方便老成员随时回顾。

  5. 解放社群运营者精力,聚焦核心运营: 没有 AI 前,负责任的运营者可能会手动整理,但这特别耗时耗力。而随着 AI 群聊总结机器人的出现,可以将运营者从一些重复性、事务性工作中解放出来,让他们有更多精力去策划活动、引导大家讨论等等。

如何上手?

市面上有不少 AI 群聊总结工具,大家可以自己找,例如一些基于微信、企业微信、钉钉的等等。

结语

在现在的社群领域,尤其是强调价值交付的付费社群,任何能够提升成员体验、放大社群价值的工具都值得关注和尝试。

其他好文推荐

2025 最新!独立开发者穷鬼套餐

就这样用 Vibe Coding 又完成了一个项目

最近 Vibe Coding 的实践经验分享

分享一款 AI 自动生成流程图的工具

一个 Cursor mdc 自动生成器,基于 Gemini 2.5,很实用!

这个 361k Star 的项目,一定要收藏!

搞定 XLSX 预览?别瞎找了,这几个库(尤其最后一个)真香!

实战分享】10 大支付平台全方面分析,独立开发必备!

关于 MCP,这几个网站你一定要知道!

做 Docx 预览,一定要做这个神库!!

【完整汇总】近 5 年 JavaScript 新特性完整总览

关于 Node,一定要学这个 10+万 Star 项目!

从按钮 "跳帧" 到 3D 翻书:CSS 动画进阶的 "三级跳"

作者 归于尽
2025年7月14日 14:32

很多刚接触 CSS 动画的同学都会觉得它很难,其实就像学开车一样,先掌握油门刹车(基础属性),再练习复杂路况(组合动画),最后就能玩出漂移(惊艳效果)。今天咱们就按这个节奏,从最基础的动画属性讲起,一步步做出能让人眼前一亮的动画效果。

第一步:认识动画的 “油门和方向盘”

CSS 动画的核心就两个基础工具:transition(过渡)和@keyframes(关键帧)。咱们先从transition开始,它就像给元素的样式变化装了个 “缓冲器”。

比如一个普通的 div,默认状态是这样的:

.box {
  width: 100px;
  height: 100px;
  background: #42b983;
}

当我们用 hover 改变它的样式时,没加 transition 会很生硬:

/* 生硬的变化 */
.box:hover {
  width: 200px;
  background: #ff4400;
}

加上transition后,变化就会变得平滑:

.box {
  width: 100px;
  height: 100px;
  background: #42b983;
  /* 关键代码:所有样式变化在0.5秒内完成 */
  transition: all 0.5s;
}

这里的all表示所有样式变化都应用过渡,我们也可以指定具体属性,比如只让宽度变化有过渡:

transition: width 0.5s; /* 只有width变化会平滑过渡 */

transition还有两个重要参数:timing-function(时间函数)和delay(延迟)。时间函数就像控制动画的 “节奏”,比如:

  • ease:默认值,先慢再快最后慢(适合按钮 hover)

  • linear:匀速(适合加载动画)

  • ease-in:开始慢(适合下拉菜单)

  • ease-out:结束慢(适合弹出提示)

试试这个带节奏的按钮效果:

.btn {
  padding: 8px 16px;
  background: #2c3e50;
  color: white;
  border: none;
  border-radius: 4px;
  /* 0.3秒过渡,节奏是ease-out */
  transition: all 0.3s ease-out;
}
.btn:hover {
  background: #3498db;
  transform: translateY(-2px); /* 向上移动2px */
}

过渡.gif

第二步:用关键帧制作 “自动播放” 的动画

transition需要触发(比如 hover),但很多时候我们需要动画自动播放,这时候就需要@keyframes了。它就像拍电影时的分镜脚本,定义了动画的关键画面。

比如制作一个不停旋转的加载图标:

/* 定义关键帧:从0度转到360度 */
@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
.loader {
  width: 40px;
  height: 40px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #3498db;
  border-radius: 50%;
  /* 应用动画:使用spin关键帧,1秒完成,匀速,无限循环 */
  animation: spin 1s linear infinite;
}

@keyframes里可以定义多个关键帧,让动画更丰富。比如做一个 “呼吸灯” 效果:

@keyframes breathe {
  0% { 
    transform: scale(1);
    opacity: 1;
  }
  50% { 
    transform: scale(1.1); /* 放大到1.1倍 */
    opacity: 0.7; /* 半透明 */
  }
  100% { 
    transform: scale(1);
    opacity: 1;
  }
}
.light {
  width: 20px;
  height: 20px;
  background: #e74c3c;
  border-radius: 50%;
  animation: breathe 2s ease-in-out infinite;
}

呼吸灯.gif animation属性还有很多实用参数,比如:

  • animation-iteration-count: 3:只播放 3 次

  • animation-direction: alternate:播放完反向再播

  • animation-fill-mode: forwards:动画结束后保持最后状态

第三步:打造惊艳的 3D 翻书动画

掌握了基础属性,我们来做一个能撑场面的 3D 翻书动画。这个效果的关键是利用transform-style: preserve-3d开启 3D 空间,配合perspective设置透视感。

HTML 结构

<div class="book-container">
  <div class="book">
    <div class="book-page front">封面</div>
    <div class="book-page back">内页</div>
  </div>
</div>

CSS 样式

/* 容器:设置透视,让3D效果更真实 */
.book-container {
  perspective: 1200px; /* 数值越小,透视感越强 */
  width: 300px;
  height: 400px;
  margin: 50px auto;
}
/* 书本容器:开启3D空间 */
.book {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d; /* 关键:让子元素保持3D关系 */
  transition: transform 1s cubic-bezier(0.645, 0.045, 0.355, 1);
}
/* 书页样式 */
.book-page {
  position: absolute;
  width: 100%;
  height: 100%;
  backface-visibility: hidden; /* 隐藏背面,避免翻转时穿帮 */
  border-radius: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24px;
  font-weight: bold;
  color: white;
}
/* 封面 */
.front {
  background: #3498db;
}
/* 内页:初始翻转180度,让背面朝上 */
.back {
  background: #2ecc71;
  transform: rotateY(180deg);
}
/* 鼠标悬停时翻转书本 */
.book-container:hover .book {
  transform: rotateY(-180deg);
}

让动画更惊艳的细节处理

1. 3D 透视增强

.book-container {
  perspective: 1500px;
}
  • 效果:设置更大的透视值(1500px),使 3D 效果更自然,避免视角过近导致的变形感
  • 原理:perspective 值越大,观察者与物体的距离越远,3D 效果越平缓

2. 旋转轴心调整

.book {
  transform-origin: left center;
}
  • 效果:将旋转中心设为左侧边缘,模拟真实书本的翻页效果
  • 对比:默认旋转中心为元素中心,会产生 "悬浮" 翻页的不真实感

3. 翻页角度优化

.book:hover {
  transform: rotateY(-160deg);
}
  • 效果:设置略小于 180° 的旋转角度(-160°),模拟真实翻书时的最大角度
  • 细节:角度过大会导致背面书页看起来 "贴脸",破坏立体感

4. 书页厚度模拟

.book::before {
  content: '';
  position: absolute;
  width: 10px;
  background: linear-gradient(to right, #1a5276, #1e8449);
  transform: rotateY(90deg) translateX(-5px);
}
  • 效果:通过伪元素创建书脊,增加书本的真实厚度感
  • 技巧:使用渐变让书脊颜色与封面封底自然过渡

5. 动态阴影效果

.book:hover .front {
  box-shadow: -15px 5px 30px rgba(0,0,0,0.3);
}
  • 效果:翻页时封面产生向左偏移的阴影,增强空间层次感
  • 原理:阴影方向与翻页方向相反,模拟光线照射下的真实投影

6. 不对称圆角设计

.book-page {
  border-radius: 5px 15px 15px 5px;
}
  • 效果:右侧圆角更大,模拟真实书本右侧边缘的自然弯曲
  • 细节:左侧圆角小,避免与书脊重叠时产生视觉冲突

7. 渐变背景增强

.front {
  background: linear-gradient(135deg, #3498db, #2980b9);
}
.back {
  background: linear-gradient(135deg, #2ecc71, #27ae60);
}
  • 效果:使用渐变代替纯色背景,增加书页的光影质感
  • 技巧:135° 渐变方向与翻页方向一致,增强立体感

8. 过渡时间优化

.book {
  transition: transform 1.2s ease-in-out;
}
  • 效果:设置较长的过渡时间(1.2 秒),使翻页动作更显优雅从容
  • 对比:过短的时间(如 0.5 秒)会让动画显得急促生硬

这个 3D 翻书动画结合了我们学过的所有知识:transition控制整体过渡,transform实现 3D 旋转,@keyframes(如果加自动翻页效果)控制播放,再加上细节处理,就能做出让人眼前一亮的效果。

翻页.gif

完整css代码如下:

 body {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background: #f5f5f5;
}

.book-container {
  perspective: 1500px;
  width: 300px;
  height: 450px;
}

.book {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  transition: transform 1.2s ease-in-out;
  transform-origin: left center;
}

.book:hover {
  transform: rotateY(-160deg);
}

.book-page {
  position: absolute;
  width: 100%;
  height: 100%;
  backface-visibility: hidden;
  border-radius: 5px 15px 15px 5px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 28px;
  font-weight: bold;
  color: white;
  box-shadow: 0 5px 25px rgba(0,0,0,0.3);
  transition: all 0.5s ease;
}

.front {
  background: linear-gradient(135deg, #3498db, #2980b9);
  transform: rotateY(0deg);
  z-index: 2;
}

.back {
  background: linear-gradient(135deg, #2ecc71, #27ae60);
  transform: rotateY(180deg);
}

/* 书脊效果 */
.book::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 10px;
  height: 100%;
  background: linear-gradient(to right, #1a5276, #1e8449);
  transform: rotateY(90deg) translateX(-5px);
  z-index: 3;
}

/* 翻页时的阴影效果 */
.book:hover .front {
  box-shadow: -15px 5px 30px rgba(0,0,0,0.3);
}

其实所有复杂动画都是基础属性的组合,关键在于理解每个属性的作用,然后像搭积木一样把它们组合起来。试着修改上面的参数,看看会有什么变化 —— 动手尝试是学好动画的最好方法。如果有更棒的创意,欢迎在评论区分享!

Select对于onChange的prop进行警告

作者 TheRedAce
2025年7月14日 14:30

一、环境准备

vue + ant-design-vue

二、问题警告

[Vue warn]: Invalid prop: type check failed for prop "onChange". Expected Function, got Array
at <ASelect value="" onUpdate:value=fn onChange=[fn,fn]

// parent.vue
<template>
    <CustomUserSelect />
</template>

// CustomUserSelect.vue
<template>
  <Select @change="handleChange" placeholder="请选择" allowClear>
    <SelectOption value="1">1</SelectOption>
    <SelectOption value="2">2</SelectOption>
    <SelectOption value="3">3</SelectOption>
  </Select>
</template>

三、原因分析

使用CustomUserSelect并没有传入,但是控制台会出现 <CustomUserSelect id="customSelectAnthorInfo" onBlur=fn<onBlur8> onChange=fn<onChange9> 会出现一个默认的onChangeonBlur

是UI库(Ant Design Vue)的Select组件内部绑定的事件处理函数,自动绑定到是正常现象,它们用于处理组件的内部逻辑(如当内部发生值变化时触发change事件,当失焦时触发blur事件)

简单来说Select组件会在内部处理一些原生事件(如blur、focus等),并触发自定义事件(如blur、change等)。 在Select组件的模板中,它的根元素可能监听了原生的blur事件,并调用内部方法,然后触发blur事件(自定义事件)

四、解决办法

其实就因为 CustomUserSelect 组件的根元素是 Select ,所以自动绑定了一个onChange9, 而我们又传入了一个 handleChange 方法,vue又会对onChange的方法进行汇总,导致实际传入 onChange=[handleChange, onChange9], 所以报警告,期待的是 Function, 但是传入的是 Array

// CustomUserSelect 将根元素设置为div 即可消除警告
<template>
  <div>
    <Select @change="handleChange" placeholder="请选择" allowClear>
      <SelectOption value="1">1</SelectOption>
      <SelectOption value="2">2</SelectOption>
      <SelectOption value="3">3</SelectOption>
    </Select>
  </div>
</template>

// 完美解决警告

Cursor前端初体验-不懂MCP?那就做一个MCP

作者 Mishi
2025年7月14日 14:29

xlg.jpg

前言

AI越来越火了,从年初公司提出AI优先后,这股AI的风也是慢慢吹到了团队中,笔者所在的团队是一个有着十几个人的前端小团队。其他团队的小伙伴慢慢卷起来了,隔天就要开个AI工具分享会,当其他人卷起来时,你也不得不跟紧脚步,于是便开始了解cursor,慢慢发现他似乎对我们日常的工作帮助还是蛮大的,从被迫AI到接受AI再到主动AI。
了解Cursor后,会发现mcp其实是区分他与之前的AI工具非常大的一个变动,甚至于了解完Mcp后,我甚至有点想通过他搞钱的想法,加上钱的动力,让我下定决心搞懂MCP,赚钱嘛不寒碜

什么是MCP

在cursor的文档中,解释的很透彻了,虽然他有点儿很浓的技术味。MCP其实就是供LLM智能体直接使用的工具。你可以让大模型通过MCP直接调用某些能力。
拿现实类比,AI就好像电脑,MCP就好像U盘,可以让电脑用你U盘里的所有数据,或者软件。
而他最吸引我的功能,我认为就是通过MCP可以让大模型知道实时的数据,这也是我今天想分享的内容
众所周知GPT出世的那会,AI最为诟病的其实是数据的延后性与复杂性。数据的延后让你没办法实时通过AI获取到你想要的内容,比如,我今天该不该买股票啊!!我相信用到AI的时候,各位韭菜肯定动过让AI帮你买股票的心思。数据复杂性更不用说了,一股脑检索全网的数据,让AI一本正经的胡说八道。

开发MCP

关于MCP的开发,官方文档其实说的挺清晰了,但按着文档走,终归是会遇到点问题的,所以还是将开发的全过程分享出来 因为我是前端,所以自然而然的选择TS语言,其他的语言也有,奢俭由人吧
前端的话,能看到这的大家都是懂一点的,初始化工程的事长话短说
node>22 初始化package.json

{
  "name": "mcp-cursor",
  "version": "1.0.0",
  "description": "",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "start": "node dist/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.15.1",
    "zod": "^3.25.76"
  },
  "devDependencies": {
    "@types/node": "^24.0.13",
    "typescript": "^5.7.2"
  }
}

在入口文件src/index.ts我们进行代码的编写

编写之前,先分析下我们的需求:我想写一个MCP工具让cursor能够通过当前的工具,实时获取到某个地方的天气预报,例如获取到美国加州的当前天气。那么我们需要的就有以下几方面的内容
1、有个接口能让我实时获得天气预报,这个MCP文档中提供了
2、有现成工具包,能让我调用他以后,cursor就能自动调用上面的接口,这个便是MCP官方提供的工具包@modelcontextprotocol/sdk

一、初始化服务器,可以直接在本地运行

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";// 基础MCP服务,官方提供
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";// 服务器与大模型通信桥梁
import { z } from "zod";// 校验工具,校验大模型调用接口时传的入参格式

const NWS_API_BASE = "https://api.weather.gov"; 
const USER_AGENT = "weather-app/1.0"; 

// 创建 server instance 
const server = new McpServer({ 
    name: "weather",
    version: "1.0.0",
    capabilities: { resources: {}, tools: {}, },
});

二、提供接口调用方法与数据处理

在该MCP工具中需要暴露出能够给AI调用的接口能力,大模型执行该方法后,能够获得到对应的数据给大模型

// 定义请求方法
async function makeNWSRequest<T>(url: string): Promise<T | null> {
  const headers = {
    "User-Agent": USER_AGENT,
    Accept: "application/geo+json",
  };

  try {
    const response = await fetch(url, { headers });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return (await response.json()) as T;
  } catch (error) {
    console.error("Error making NWS request:", error);
    return null;
  }
}
// 定义数据结构格式化方法
function formatAlert(feature: AlertFeature): string {
  const props = feature.properties;
  return [
    `Event: ${props.event || "Unknown"}`,
    `Area: ${props.areaDesc || "Unknown"}`,
    `Severity: ${props.severity || "Unknown"}`,
    `Status: ${props.status || "Unknown"}`,
    `Headline: ${props.headline || "No headline"}`,
    "---",
  ].join("\n");
}

三、注册Tool类,提供给大模型调用

这里只注册一个方法,就是根据缩写获取到对应州的地址 tool方法有四个参数

第一个是方法名
第二个是方法描述,
第三个是对于大模型调用该方法时入参的数据格式校验,这里用zod规定了大模型传的state只能是2个字符
第四个就是具体的方法了,return 的数据会提供给大模型

server.tool(
  "get-alerts",
  "Get weather alerts for a state",
  {
    state: z.string().min(2).max(2).describe("Two-letter state code (e.g. CA, NY)"),
  },
  async ({ state }) => {
    const stateCode = state.toUpperCase();
    const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`;
    //
    const alertsData = await makeNWSRequest<AlertsResponse>(alertsUrl);

    if (!alertsData) {
      return {
        content: [
          {
            type: "text",
            text: "Failed to retrieve alerts data",
          },
        ],
      };
    }

    const features = alertsData.features || [];
    if (features.length === 0) {
      return {
        content: [
          {
            type: "text",
            text: `No active alerts for ${stateCode}`,
          },
        ],
      };
    }

    const formattedAlerts = features.map(formatAlert);
    const alertsText = `Active alerts for ${stateCode}:\n\n${formattedAlerts.join("\n")}`;

    return {
      content: [
        {
          type: "text",
          text: alertsText,
        },
      ],
    };
  },
);

运行 server

完成业务逻辑开发后,启动服务

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Weather MCP Server running on stdio");
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});

以上便完成了全部mcp的业务开发,接下来要做的就是执行build生成产物,这里生成的产物就在本地环境,dist/index.js,为了验证你的产物是否正常,可以让cursor为你生成一个测试脚本,这里就不再赘述,测试脚本内容也就是手动构造入参,然后调用对应的tool类

调试MCP

打开cursor配置,在tool目录下,将我们刚刚写的mcp工具添加进去,

333.png 这其实就是告诉cursor,执行 node dist/index.js命令就能拿到我提供的工具类了

{
  "mcpServers": {
    "weather": {
      "command": "node",// 命令
      "args": ["/Users/mishi/Cursor/dist/index.js"],// 地址指向
      "env": {}
    }
  }
}

效果展示

222.png 可以看到大模型给工具类传入了{state:"CA"} 并且能够根据result给我们提供相应的建议

结语

能看到这的,你以为我们只是让大模型帮我们做天气预报吗?如果这里的数据不是天气预报,而是某个公司爬取下来的财报,亦或者就是某个公司的近半年股价。那么大模型,会帮助我们分析出什么呢?
我承认,一开始脑海中浮现的就是这个,才推动着我,手写一个MCP。不仅学到了点东西,甚至能让大模型真正为个人服务

介绍一下Shopify App 的 Theme App Extensions

作者 浅墨momo
2025年7月14日 14:28

在 Shopify 生态中,App 是商家用来扩展功能的重要工具,比如通知栏、倒计时、推荐商品等等,而如何将 App 的功能在商店前(Theme)中展示出来呢?比如下图中顶部的通知栏是如何展示在商店里面的。

以前,开发者常常需要让商家手动修改 Liquid 代码,将 App 的代码片段嵌入到模板文件里,这不仅容易出错,而且体验很不友好。为了更好地解决这个痛点,Shopify 推出了 Theme App Extensions

Theme App Extensions

Theme App Extensions 是 Shopify 推出的一种机制,允许 App 以「扩展」的方式直接与商店的主题集成,商家只需在主题编辑器里进行简单配置,就可以将 App 功能块添加到店铺前台页面,无需再修改代码。它是 App 的一部分,但它和 App 主体是独立部署、独立更新的,使用的是 Shopify 提供的扩展框架和文件夹结构。

└── extensions
  └── my-theme-app-extension
      ├── assets
      ├── blocks
      ├── snippets
      ├── locales
      ├── package.json
      └── shopify.extension.toml

这些目录结构的作用如下:

  • blocks/: 定义在主题编辑器中可以插入的区块,比如公告栏、倒计时条、推荐商品区块等。

  • snippets/: Liquid 文件,用来渲染前端 HTML 内容,通常和 block 配合使用。

  • assets/: 存放 CSS、JS 等静态资源文件。

  • locales/: 国际化语言文件,用来支持多语言。

Theme App Extensions 的特点

  1. 无需手动修改代码:商家不需要自己去复制/粘贴 Liquid 片段,减少了错误和麻烦。

  2. 可视化配置:App 提供的区块(Block)可以在 Shopify Theme Editor(主题编辑器)中像组件一样拖放、配置,体验更好。

  3. 自动更新与安全性:扩展更新由 Shopify 管理,安全、可靠,并且可以快速同步到商店中。

  4. 更好的主题兼容性:通过 Shopify 官方的机制插入内容,不容易和其他自定义 Theme 或其他 App 冲突。

比如我们开发了一个「限时折扣倒计时」App,以前商家需要复制代码到 product.liquid 文件中,现在可以用 Theme App Extension 提供一个「通知栏」扩展,商家只要在 Theme Editor 的App embeds中直接打开这个扩展的开关,就能立即生效,如下图所示,这是Essential Announcer这个App的效果:

Theme App Extensions的开发流程

  1. 初始化

    shopify app generate extension --type=theme

这条命令会自动帮我们生成一个 extensions/your-extension-name 文件夹。

  1. 开发与配置 blockblocks/ 文件夹里定义我们要插入的区块,修改 schema JSON 配置文件来控制在主题编辑器里的展示方式和可配置字段。

  2. 编写 Liquid 渲染逻辑snippets/ 文件夹里写好渲染内容,比如 HTML 结构、JS 初始化等。

  3. 打包并部署

    shopify app deploy

发布后,商家就可以在主题编辑器中找到并使用我们的扩展。

App Blocks 和 App Embed Blocks

接下来我们看看与之相关的两个概念,当我们用 Theme App Extensions 开发 Shopify App 时,有两个非常重要的概念,就是 App Blocks 和 **App Embed Blocks,**这两者看起来类似,但其实用途、添加方式和适用场景都不一样。

App Blocks

App Blocks 是指可以被商家在 主题编辑器的具体模板(比如首页、产品页、集合页等)里拖入并放置到特定位置的区块,这些区块就像主题自带的 Section 或 Block 一样灵活,商家可以在编辑器中「点击 + 添加」,自由决定出现在哪个模板、哪个区域,以及配置其内容、样式、显示条件等。比如下图中的评分组件

App Embed Blocks

App Embed Blocks 是指可以全局启用或关闭的功能块,主要用于一些需要在所有页面上统一插入的脚本、浮动按钮、全局样式或隐藏逻辑等,Embed Block 通常不会出现在具体的页面模板中,而是在「主题编辑器 → App Embeds」区域里,通过开关控制是否启用。比如下图右下角的悬浮组件

总结一下就是:App Blocks 更适合需要精准控制位置、可视化编辑、可多次使用的场景。App Embed Blocks 则适合需要全站生效、只需一个开关就能控制启用或停用的场景。

Theme扩展就介绍到这里,本文只是做一个简单的介绍,便于大家理解,关于Theme扩展更详细的的信息可以参考官方文档

关于我:

曾在字节跳动等大厂工作超过8年,有资深的移动端、前端开发经验,目前在做Shopify相关业务的创业,关注我,我们一起探索Shopify的精彩世界。

卫星公众/小🍠:浅墨 momo

❌
❌