阅读视图

发现新文章,点击刷新页面。

🔹🔹🔹 vue 通信方式 eventBus

没啥关系的两个组件,

找破天,都找不到啥关系的两个组件。

数据占比比较多,事件的话还要监听:
用vuex、用localstorage。


事件占比比较多

eventBus

一、使用方法:

1、在main.js中创建一个空的vue实例作为全局事件总线,同时,将其绑定在vue原型上。

//main.js
Vue.prototype.$eventBus = new Vue()

2、子组件或兄弟组件,通过$emit来触发事件。

sendFunc(){
    this.$eventBus.$emit( 'changeFunc' ,  123, 'abc' )
}

3、父组件或兄弟组件,通过$on来监听事件。

created(){
    //绑定前需先解绑,避免反复触发、内存泄漏的问题
    this.$eventBus.$off( 'changeFunc' );  
    this.$eventBus.$on( 'changeFunc' , (val1, val2)=>{
        this.msg = val1;
        this.msg2 = val2;
    } )
}

组件监听eventBus中的事件前,一定要记得先解绑。

created(){
    //this.$eventBus.$off( 'changeFunc' );  
    this.$eventBus.$on( 'changeFunc' , (val)=>{
        this.msg = val;
    } )
}

如上,绑定前不解绑的话,主要有两方面的问题:1、事件反复触发;2、内存泄漏;


很显然,上面是vue2的写法。

什么年代了还在vue2,

下面是vue3的写法:


Vue2 里常用:

// eventBus.js
import Vue from 'vue'
export const eventBus = new Vue()

// 组件A
eventBus.$emit('sayHello', '你好')

// 组件B
eventBus.$on('sayHello', (msg) => {
  console.log(msg) // 你好
})

那么vue3呢,vue3没有new Vue()了啊。

vue3

  1. vue3 用 mitt
npm i mitt
  1. 新建 eventBus.js
// eventBus.js
import mitt from 'mitt'

// 创建一个全局事件总线
const eventBus = mitt()

export default eventBus
  1. 组件A(发送事件)
<script setup>
import eventBus from '@/eventBus.js'

function sendMessage() {
  eventBus.emit('sayHello', '你好,我是组件A')
}
</script>

<template>
  <button @click="sendMessage">发送消息</button>
</template>
  1. 组件B(接收事件)
<script setup>
import { onMounted, onUnmounted } from 'vue'
import eventBus from '@/eventBus.js'

function handleMessage(msg) {
  console.log('组件B收到消息:', msg)
}

onMounted(() => {
  eventBus.on('sayHello', handleMessage)
})

onUnmounted(() => {
  eventBus.off('sayHello', handleMessage) // 记得销毁,避免内存泄漏
})
</script>

<template>
  <div>我是组件B</div>
</template>

  • 父子组件通信:props / emits
  • 跨层级组件通信:provide / inject
  • 全局状态管理:Pinia(推荐)
  • 非父子组件通信(解耦):eventBus(基于 mitt)

完事了。

Python采集tiktok视频详情数据,tiktok API系列

在Python中采集TikTok视频详情数据,可通过官方API、第三方API服务或非官方库实现,以下是一些主流方法及其技术细节与适用场景分析:

一、官方API:TikTok for Business API

适用场景:企业级应用,需合规获取广告、账号或创作者市场数据。
功能覆盖

  • Marketing API:管理广告活动(如创建、投放、优化广告),支持批量操作与实时数据查询。
  • Accounts API:监控企业账号活动(如视频发布、互动数据),提供深度分析报表。
  • Data Portability API:合规转移用户数据(如GDPR场景下的用户请求处理)。

技术实现

  1. 认证授权:通过OAuth 2.0获取access_token,需注册TikTok for Business开发者账号并申请权限。

  2. 接口调用:使用requests库发送HTTPS请求,示例代码:

    python
    import requests
    auth_url = "https://business-api.tiktok.com/api/v1/oauth/token/"
    params = {
        "client_key": "YOUR_APP_ID",
        "client_secret": "YOUR_SECRET",
        "grant_type": "client_credentials"
    }
    response = requests.post(auth_url, data=params)
    access_token = response.json()["access_token"]
    
  3. 数据获取:调用视频详情接口(需替换为实际API端点):

    python
    video_url = "https://api.tiktok.com/v2/video/detail/"
    headers = {"Authorization": f"Bearer {access_token}"}
    params = {"video_id": "YOUR_VIDEO_ID"}
    response = requests.get(video_url, headers=headers, params=params)
    video_data = response.json()
    

优势:数据权威、合规性强,支持高并发请求。
限制:需企业资质审核,个人开发者难以获取权限;功能聚焦于商业场景,普通视频数据获取受限。

二、第三方API服务:TikHub.io

适用场景:快速获取用户主页视频、热门视频等公开数据,无需逆向工程。
功能覆盖

  • 支持通过sec_user_idunique_id查询用户作品列表。
  • 返回字段包括视频标题、播放量、点赞数、封面URL等。

技术实现

  1. 注册获取API Key:访问TikHub用户后台完成注册。

  2. 调用接口:示例代码:

    python
    import requests
    url = "https://api.tikhub.io/api/v1/tiktok/app/v3/fetch_user_post_videos"
    headers = {
        "Authorization": "Bearer YOUR_API_KEY",
        "accept": "application/json"
    }
    params = {
        "sec_user_id": "MS4wLjABAAAA...",
        "count": 20,
        "sort_type": 0
    }
    response = requests.get(url, headers=headers, params=params)
    videos = response.json()["data"]["videos"]
    for video in videos:
        print(f"标题: {video['desc']}, 播放量: {video['play_count']}")
    

优势

  • 免爬虫、免逆向,接口稳定且支持大规模请求。
  • 提供中文文档与SDK,开发者友好。
  • 免费注册,每日签到可领取调用额度。

限制:部分高级功能需付费;数据延迟可能高于官方API。

三、非官方库:TikTok-API-Python

适用场景:开源社区支持,适合技术探索与个性化需求。
功能覆盖

  • 获取热门视频、用户帖子、话题帖子及音乐帖子数据。
  • 支持加密传输,确保数据安全。

技术实现

  1. 安装依赖

    bash
    git clone https://github.com/demon071/Tiktok-API-Python.git
    cd Tiktok-API-Python
    pip install -r requirements.txt
    
  2. 调用API:示例代码:

    python
    from tiktok import TikTok
    api = TikTok()
    videos = api.trending()  # 获取热门视频
    for video in videos[:5]:
        print(f"标题: {video['title']}, 视频URL: {video['video_url']}")
    

优势

  • 功能丰富,支持多种数据类型获取。
  • 社区活跃,持续更新修复bug。

限制

  • 非官方库,可能违反TikTok服务条款。
  • 依赖逆向工程,接口稳定性较差,易受平台风控策略影响。

四、对比与推荐

方案 适用场景 数据权威性 稳定性 开发成本
TikTok for Business API 企业级商业应用 高(需审核)
TikHub.io 快速获取公开数据
TikTok-API-Python 技术探索与个性化需求

推荐选择

  • 企业开发者:优先使用TikTok for Business API,确保合规性与数据权威性。
  • 个人开发者/研究者:选择TikHub.io,平衡效率与成本。
  • 技术爱好者:可尝试TikTok-API-Python,但需注意法律风险与接口稳定性。

【手写组件】 Vue3 + Uniapp 手写一个高颜值日历组件(含跨月补全+今日高亮+选中状态)

文章目录

日历组件实现

一个基于 Vue3 + Composition API 的日历组件,功能包括:

功能概览

功能点 实现情况
月份切换 ✅ 通过 handleLastMonth / handleNestMonth 实现
今日高亮 ✅ 使用 isToday 判断并加样式
选中日期 ✅ 使用 SelectedDate 存储并高亮
跨月补全 ✅ 上月和下月的日期用 isOtherMonth 标记并灰显
响应式布局 ✅ 使用 grid + aspect-ratio 实现正方形格子
样式变量 ✅ 使用 CSS 变量(如 --primary, --gary_light

代码亮点

  1. 逻辑清晰:用 computed 生成 days 数组,结构干净。
  2. 日期处理封装得好getDateformatDate 方法复用性强。
  3. 样式语义化:类名如 .today, .selected, .other-month 非常直观。
  4. 组件化思维:使用 TagCop 作为子组件,符合 uni-app 风格。

接下来开始我们的代码之旅😄:在这里插入图片描述

基础功能搭建

首先创建模板结构:

<template>
  <div class="calendarCop">
    <!-- 日历顶部栏 -->
    <div class="calendarCop-header"></div>
    <!-- 日历星期栏 -->
    <div class="calendarCop-weekdays">
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
    </div>
    <!-- 日历 -->
    <div class="calendarCop-days"></div>
  </div>
</template>

<script setup></script>

<style scoped lang="scss">
.calendarCop {
  background-color: var(--gary_light);
  padding: 16rpx;
  border-radius: var(--radius);
  .calendarCop-header {
  }
  .calendarCop-weekdays {
  }
  .calendarCop-days {
  }
}
</style>

搭建日历顶部栏结构:

创建出顶部栏需要展示的空间,分别有分布于左侧的切换至上个月图标按钮和右侧的切换至下个月图标按钮,以及中间年月份展示区

<!-- 日历顶部栏 -->
<div class="calendarCop-header">
    <!-- 顶部栏月份切换区 -->
    <div class="changeMouth">
        <!-- 切换至上个月图标按钮 -->
        <span class="left">
            <uni-icons type="left" size="24" color="#23ad1e"> </uni-icons>
        </span>
        <!-- 年月份展示区 -->
        <p class="data">2025 年 9 月</p>
        <!-- 切换至下个月图标按钮 -->
        <span class="right">
            <uni-icons type="right" size="24" color="#23ad1e"></uni-icons>
        </span>
    </div>
</div>

接下来编写样式:

.calendarCop-header {
    .changeMouth {
        display: inline-flex;
        align-items: center;
        gap: 16rpx;
        height: 50rpx;
        .left,
        .right {
            font-weight: 900;
        }
        .data {
            font-size: 36rpx;
            line-height: 50rpx;
        }
    }
}

效果:

image-20250916100540390

现在结构已经搭建好了,逻辑交互等日历日期渲染出来了再做。

搭建日历星期栏样式:

直接使用网格布局将七个星期都渲染出来,然后再添加一些修饰就完成啦。

  .calendarCop-weekdays {
    color: var(--primary_dark);
    font-weight: 900;
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    text-align: center;
    padding-bottom: 8rpx;
    margin-bottom: 8rpx;
    border-bottom: 4rpx solid var(--gary_dark);
  }

效果:

image-20250916101310240

渲染日历日期

接下来就是重头戏了,要想渲染出时间日期,我们就要请出Date时间对象来。

先来获取到当前年|月|日数据:

  • 这里使用ref响应式创建了一个时间对象
  • 然后用计算属性分别计算出CurrentDate时间对象的年|月|日信息
/* 当前日期时间 */
// 获取当前时间对象
const CurrentDate = ref(new Date());
// 获取当前年份
const Year = computed(() => CurrentDate.value.getFullYear());
// 获取当前月份
const Month = computed(() => CurrentDate.value.getMonth());
// 获取当前日期
const Today = computed(() => CurrentDate.value.getDate());

拿到了日期时间后,就可以在日历顶部栏中替换掉之前写死的年月份

<!-- 年月份展示区 -->
<p class="data">{{ Year }} 年 {{ Month + 1 }} 月</p>

⚠注意:时间对象中拿到的月份是从0开始的,所以相比较于现实的月份会少1,所以要在渲染月份的时候加上1

写一个获取日期对象方法

const getDate = ({ year, month, day } = {}) =>
  new Date(year ?? Year.value, month ?? Month.value, day ?? Today.value);

生成日期数据:

/* 生成日期数据 */
const days = computed(() => {
  const result = [];
  // 获取每个月的第一天和最后一天
  const firstDay = getDate({ day: 0 });
  const lastDay = getDate({ month: Month.value + 1, day: 0 });
  // 通过遍历来渲染所有日期
  for (let i = 1; i <= lastDay.getDate(); i++) {
     const date = getDate({ year: Year.value, month: Month.value, day: i });
    result.push({
      date,
      text: i,
    });
  }
  return result;
});

整体逻辑就是先拿 lastDay 定出本月共有多少天,然后从 1 号循环到该天数,每天调用 getDate 生成一个 Date 对象塞进数组,最终得到“本月所有日期”列表。

我们可以打印一下days来观察数据长啥样:console.log("🚀:", days.value);

image-20250916110935784

接下来将日期数据渲染到模板上:

    <!-- 日历 -->
    <div class="calendarCop-days">
      <div class="item" v-for="day in days" :key="day.date">
        <div class="day">
          {{ day.text }}
        </div>
      </div>
    </div>

// 样式
  .calendarCop-days {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 8rpx;
    .item {
      font-size: 32rpx;
      aspect-ratio: 1; // 宽=高,正方形
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      .day {
      }
    }
  }

如今,日历已经有初步形态:

image-20250916113420544

接下来完成今日日期显示:

给日期格子添加上样式类名和并且准备好样式:

<div class="calendarCop-days">
    <div class="item" v-for="day in days" :key="day.date">
        <div
             :class="{
                     day: true,
                     base: true,
                     today: isToday(day.date),
                     }"
             >
            {{ day.text }}
        </div>
    </div>
</div>

// 样式:
.base {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--radius);
    font-weight: 900;
}
.today {
    color: var(--primary_dark);
    background: var(--primary_light);
}

判断是否为今天isToday方法:

// 格式化日期方法
const formatDate = (date) =>
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
    2,
    "0"
  )}-${String(date.getDate()).padStart(2, "0")}`;

/* 今日日期 */
const today_date = new Date();
const isToday = (date) => formatDate(today_date) === formatDate(date);

这里补充一下padStart方法:

padStart 只做一件事:把字符串“补长”到指定长度,不够就在前面补规定的字符。

语法:str.padStart(目标长度, 补什么)

例如:

'5'.padStart(2, '0')   // 长度=1,差1位 →补0 → '05'
'12'.padStart(2, '0')  // 长度已够 → 原样返回 '12'

效果:

image-20250916135515932

处理选中日期效果:

同样的,先添加上选中的类名和样式效果:

<div
     :class="{
             day: true,
             base: true,
             today: isToday(day.date),
             selected: isSelected(day.date),
             }"
     @click="selectDay(day.date)"
     >
    {{ day.text }}
</div>

// 样式:⚠注意selected类名要在today下方,这样选中效果才能覆盖掉today样式
.today {
    color: var(--primary_dark);
    background: var(--primary_light);
}
.selected {
color: #fff;
background: var(--primary);
}

编写逻辑:

/* 选择日期相关 */
// 选中日期
const SelectedDate = ref(null);
// 选中日期方法
const selectDay = (date) => {
  SelectedDate.value = formatDate(date);
};
const isSelected = (date) => SelectedDate.value === formatDate(date);
// 初始化选中今天
onMounted(() => {
  SelectedDate.value = formatDate(today_date);
});

现在选中效果也做好啦:

image-20250916140133877

回到今日:

<div class="calendarCop-header">
    <!-- 顶部栏月份切换区 -->
    <div class="changeMouth">
        <!-- ... -->
    </div>
    <TagCop
            class="selectToday"
            text="今日"
            backgroundColor="var(--primary_light)"
            @click="selectToday"
            />
</div>

⚠这个<TagCop>就是一个标签组件而已啦!

添加回到今日方法:

/* 今日日期 */
const today_date = new Date();
const isToday = (date) => formatDate(today_date) === formatDate(date);
const selectToday = () => {
  CurrentDate.value = today_date;
  selectDay(today_date);
};

效果:

image-20250916143412219

月份切换

现在来制作月份切换效果:

给图标绑定好切换方法:

<!-- 切换至上个月图标按钮 -->
<uni-icons
           class="left"
           type="left"
           size="24"
           color="#23ad1e"
           @click="handleLastMonth"添加
           >
</uni-icons>
<!-- 年月份展示区 -->
<p class="data">{{ Year }} 年 {{ Month + 1 }} 月</p>
<!-- 切换至下个月图标按钮 -->
<uni-icons
           class="right"
           type="right"
           size="24"
           color="#23ad1e"
           @click="handleNestMonth"添加
           >
</uni-icons>

编写方法:

/* 月份切换相关 */
const handleLastMonth = () => {
  CurrentDate.value = getDate({
    year: Year.value,
    month: Month.value - 1,
    day: 1,
  });
};
const handleNestMonth = () => {
  CurrentDate.value = getDate({
    year: Year.value,
    month: Month.value + 1,
    day: 1,
  });
};

现在月份可以切换了,但是每个日期对应的星期没有正确分布出来,接下来就需要引入上个月的日期,才能保证后面星期数是对的上的。

月份补充

为了方便理解,先记住 3 个前提:

  1. getDate({ year, month, day }) 内部就是 new Date(year, month, day) – 月份从 0 开始(0=1 月 … 11=12 月) – 如果 day=0 会得到“上个月的最后一天”,day=-n 会得到“上个月倒数第 n 天”——这是 JS Date 的天生能力。
  2. 组件要求日历从周一开头(模板里周一在第一个格子)。
  3. 一行 7 格,总共 5 行 = 35 格。 如果“上月补头 + 当月天数 + 下月补尾”不足 35,就再补 7 格,凑够 42 格(6 行)。

可视化说明:

gap = 5(周一到周五共 5 天)
头补:12728293031 日

当月:1 日 … 28 日
已用:5 + 28 = 33

remains = 35 - 33 = 2
尾补:312 日
最终数组长度:35

上月补充(补“头部”)

// 1. 当月 1 号
const firstDay = getDate({ day: 1 });

// 2. 当月 1 号是星期几? 0=周日 1=周一 ... 6=周六
const startDayOfWeek = firstDay.getDay(); // 例如 3 → 周三

// 3. 要补几个空位?
//    我们想让它从“周一”开始,所以:
//    周一 → 补 0 个
//    周二 → 补 1 个
//    ...
//    周日 → 补 6 个
const gap = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1;

举例:

  • 2025-09-01 是周一 → startDayOfWeek=1gap=0不补
  • 2025-10-01 是周三 → startDayOfWeek=3gap=2补 2 天
本月1号 周日 周一 周二 周三 周四 周五 周六
getDay() 0 1 2 3 4 5 6
需补几天 6 0 1 2 3 4 5

所以就可以通过这一特性,当作遍历次数:

/* 上月补充 */
// 获取第一天的星期数
const startDayOfWeek = firstDay.getDay(); // 0=周日
// 获取上个月最后一天(从周一开始算,所以要调整偏移)
const gap = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1;
for (let i = gap; i > 0; i--) {
    // 倒序生成日期对象
    const date = getDate({ year: Year.value, month: Month.value, day: -i });
    result.push({
        date,
        text: date.getDate(),
        isOtherMonth: true,
    });
}

下月补充(补“尾部”)

实现原理:

// 1. 已经装了几天?
const already = result.length; // 头补 + 当月天数

// 2. 一共想要 35 格(5 行),不够就再补 7 格,凑够 42 格
const remains = 5 * 7 - already; // 可能为 0 甚至负数

如果 remains ≤ 0 说明 35 格已够,就不会再进循环; 如果 remains > 0 就继续往后数数:

/* 下月补充 */
  const remains = 5 * 7 - result.length;
  for (let i = 1; i <= remains; i++) {
    const date = getDate({ year: Year.value, month: Month.value + 1, day: i });
    result.push({
      date,
      text: i,
      isOtherMonth: true,
    });
  }

技巧点

  • month: Month.value + 1 如果原来是 11(12 月),+1 变成 12,JS 会自动变成下一年 0 月(1 月),无需手写跨年逻辑。
  • 只补到 35 格,保证永远是完整 5 行;如果想固定 6 行,可把 5*7 改成 6*7

完成效果:

image-20250916155023574

完整代码:

<template>
  <div class="calendarCop">
    <!-- 日历顶部栏 -->
    <div class="calendarCop-header">
      <!-- 顶部栏月份切换区 -->
      <!-- 年月展示区 -->
      <div class="changeMouth">
        <!-- 切换至上个月图标按钮 -->
        <uni-icons
          class="left"
          type="left"
          size="24"
          color="#23ad1e"
          @click="handleLastMonth"
        >
        </uni-icons>
        <!-- 年月份展示区 -->
        <p class="data">{{ Year }} 年 {{ Month + 1 }} 月</p>
        <!-- 切换至下个月图标按钮 -->
        <uni-icons
          class="right"
          type="right"
          size="24"
          color="#23ad1e"
          @click="handleNestMonth"
        ></uni-icons>
      </div>
      <!-- 回到今日 -->
      <TagCop
        class="selectToday"
        text="今日"
        backgroundColor="var(--primary_light)"
        @click="selectToday"
      />
      <!-- 更多操作 -->
      <uni-icons
        v-show="false"
        type="more-filled"
        class="more"
        size="24"
        color="#23ad1e"
      ></uni-icons>
    </div>
    <!-- 日历星期栏 -->
    <div class="calendarCop-weekdays">
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
    </div>
    <!-- 日历 -->
    <div class="calendarCop-days">
      <div class="item" v-for="day in days" :key="day.date">
        <div
          :class="{
            day: true,
            base: true,
            today: isToday(day.date),
            selected: isSelected(day.date),
            'other-month': day.isOtherMonth,
          }"
          @click="selectDay(day.date)"
        >
          {{ day.text }}
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from "vue";
import TagCop from "@/components/base/tag-cop";

/* 当前日期时间 */
// 获取当前时间对象
const CurrentDate = ref(new Date());
// 获取当前年份
const Year = computed(() => CurrentDate.value.getFullYear());
// 获取当前月份
const Month = computed(() => CurrentDate.value.getMonth());
// 获取当前日期
const Today = computed(() => CurrentDate.value.getDate());

// 获取日期对象方法
const getDate = ({ year, month, day } = {}) =>
  new Date(year ?? Year.value, month ?? Month.value, day ?? Today.value);
// 格式化日期方法
const formatDate = (date) =>
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
    2,
    "0"
  )}-${String(date.getDate()).padStart(2, "0")}`;

/* 生成日期数据 */
const days = computed(() => {
  const result = [];
  // 获取每个月的第一天和最后一天
  const firstDay = getDate({ day: 1 });
  const lastDay = getDate({ month: Month.value + 1, day: 0 });
  /* 上月补充 */
  // 获取第一天的星期数
  const startDayOfWeek = firstDay.getDay(); // 0=周日
  // 获取上个月最后一天(从周一开始算,所以要调整偏移)
  const gap = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1;
  for (let i = gap; i > 0; i--) {
    // 倒序生成日期对象
    const date = getDate({ year: Year.value, month: Month.value, day: -i });
    result.push({
      date,
      text: date.getDate(),
      isOtherMonth: true,
    });
  }

  /* 本月日期 */
  // 通过遍历来渲染所有日期
  for (let i = 1; i <= lastDay.getDate(); i++) {
    const date = getDate({ year: Year.value, month: Month.value, day: i });
    result.push({
      date,
      text: i,
    });
  }

  /* 下月补充 */
  const remains = 5 * 7 - result.length;
  for (let i = 1; i <= remains; i++) {
    const date = getDate({ year: Year.value, month: Month.value + 1, day: i });
    result.push({
      date,
      text: i,
      isOtherMonth: true,
    });
  }

  return result;
});

/* 今日日期 */
const today_date = new Date();
const isToday = (date) => formatDate(today_date) === formatDate(date);
const selectToday = () => {
  CurrentDate.value = today_date;
  selectDay(today_date);
};

/* 选择日期相关 */
// 选中日期
const SelectedDate = ref(null);
// 选中日期方法
const selectDay = (date) => {
  SelectedDate.value = formatDate(date);
};
const isSelected = (date) => SelectedDate.value === formatDate(date);
// 初始化选中今天
onMounted(() => {
  SelectedDate.value = formatDate(today_date);
});

/* 月份切换相关 */
const handleLastMonth = () => {
  CurrentDate.value = getDate({
    year: Year.value,
    month: Month.value - 1,
    day: 1,
  });
};
const handleNestMonth = () => {
  CurrentDate.value = getDate({
    year: Year.value,
    month: Month.value + 1,
    day: 1,
  });
};
</script>

<style scoped lang="scss">
.calendarCop {
  background-color: var(--gary_light);
  padding: 16rpx;
  border-radius: var(--radius_big);
  .calendarCop-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    .more {
      transform: rotate(90deg);
    }
    .changeMouth {
      display: inline-flex;
      align-items: center;
      gap: 16rpx;
      height: 50rpx;
      .left,
      .right {
        font-weight: 900;
      }
      .data {
        font-size: 36rpx;
        line-height: 50rpx;
      }
    }
  }
  .calendarCop-weekdays {
    color: var(--primary_dark);
    font-weight: 900;
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    text-align: center;
    padding-bottom: 8rpx;
    margin: 8rpx 0;
    border-bottom: 4rpx solid var(--gary_dark);
  }
  .calendarCop-days {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 8rpx;
    .item {
      font-size: 32rpx;
      aspect-ratio: 1; // 宽=高,正方形
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      .day {
      }
      .other-month {
        color: var(--gary_dark);
      }
      .base {
        width: 100%;
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: var(--radius);
        font-weight: 900;
      }
      .today {
        color: var(--primary_dark);
        background: var(--primary_light);
        position: relative;
        &::after {
          content: "今";
          font-size: 18rpx;
          position: absolute;
          top: 4rpx;
          right: 8rpx;
        }
      }
      .selected {
        color: #fff;
        background: var(--primary);
      }
    }
  }
}
</style>

最终效果: image-20250916164921068

🧩 未来迭代方向

  1. 把“选中”改成 v-model 双向绑定 父组件 <CalendarCop v-model="date" /> 就能直接拿到日期。
  2. 加“事件点” 父组件传 events: Record<'yyyy-mm-dd', {dot?: boolean, text?: string, color?: string}>, 日历在对应格子画小圆点/小标签。
  3. 支持“范围选择”SelectedDate: Ref<string> 升级成 SelectedRange: Ref<{start?: string; end?: string}>, 点击逻辑改为:
    • 第一次点 → start
    • 第二次点 → end(若再点则清空重设) 样式层加 .in-range 做背景条。
  4. 支持“多选” SelectedDates: Set<string>,点击 toggle,样式加 .selected 即可。
  5. 加“ swipe 手势” 因为你在 uni-app,可以直接用 @touchstart/@touchend 算滑动距离, 或者引 uni-swiper-action 做整月滑动切换。
  6. 加“农历/节假日”text: number 拓展成 text: number | {solar: number; lunar: string; festival?: string}, 下面再画一行小字。

image.png

迭代器和生成器

1.迭代器

1.1 什么是迭代器?

迭代器:本质上是一个对象,符合迭代器协议。

迭代器协议:

1.其对象要返回一个next函数

2.调用next函数返回一个对象,这个对象包含两个属性

  2.1 done(完成),值为Boolean,返回true/false

    2.1.1 如果这个迭代器没有迭代完成,返回{done:false}

    2.1.2 如果这个迭代器迭代完成,返回{done:true}

  2.2 val(当前值),可以返回js中的任意值

1.2 迭代器的基本实现

根据迭代器的定义,我们需要实现next函数并根据需要返回{done:xxx,val:xxx}

// 定义索引
let index = 0
// 定义原始迭代对象
const arr = ['Alice', 'Bob', 'John']
// 定义一个迭代器对象
let iterator = {
    // next函数
    next() {
        // - 没有迭代完成,返回done:false,继续迭代
        if (index < arr.length) {
            return { done: false, value: arr[index++] }
        } else {
            // -- 迭代完成,done:true 
            return { done: true, value: undefined }
        }
    }
}

console.log(iterator.next())//{ done: false, value: 'Alice' }
console.log(iterator.next())//{ done: false, value: 'Bob' }
console.log(iterator.next())//{ done: false, value: 'John' }
console.log(iterator.next())//{ done: true, value: undefined }

说明1:迭代器是一个对象,实现next方法,next方法返回一个新的对象,对象中done用于观察迭代是否完成,对象中的值用于表示迭代当前值。既有next又有done和val,符合迭代器协议。

说明2:此时定义的一些变量如index、arr都暴露到了全局,违背了高内聚的开发思想,导致当前迭代器效率特别低,综上对目标迭代器进行封装。

1.3 迭代器的封装实现

function createIterator(iterator) {
    let index = 0
    let _iterator = {
        next() {
            if (index < iterator.length) {
                return { done: false, value: iterator[index++] }
            } else {
                return { done: true, value: undefined }
            }
        }
    }
    return _iterator
}
let iter = createIterator(arr)
console.log(iter.next())//{ done: false, value: 'Alice' }
console.log(iter.next())//{ done: false, value: 'Bob' }
console.log(iter.next())//{ done: false, value: 'John' }
console.log(iter.next())//{ done: true, value: undefined }

2.可迭代对象

2.1 什么是可迭代对象

首先需要明确,迭代器对象和可迭代对象是完全不同的东西,尽管他们之间会存在联系,但是还是不要将这二者进行混淆。

可迭代对象:

1.是一个对象,符合可迭代协议

2.可迭代协议是什么?

  2.1 实现了[Symbol.iterator]作为key的方法,且这个方法返回一个迭代器对象

3.for...of...本质上就是调用了这个[Symbol.iterator]为key的方法

2.2 原生可迭代对象(JS内置)

  • String
  • Array
  • Set
  • Map
  • NodeList类数组对象
  • Arguments类数组对象

2.2.1 部分可迭代对象for...of...展示

let str = 'Alice'
let arr = [1, 2, 3, 4, 5]
let map = new Map([['name', 'Alice'], ['age', 18]])

for (const element of str) {
    console.log(element);//会依次打印
}
for (const element of arr) {
    console.log(element);
}
for (const element of map) {
    console.log(element);
}

2.2.2 查看内置的[Symbol.iterator]方法

由2.2.1我们可以迭代这些对象,既然可以迭代,那必然是符合可迭代对象协议

const arr = ['Alice', 'Bob', 'John']
// 数组的[Symbol.iterator]方法
let arrIterator = arr[Symbol.iterator]()
console.log(arrIterator.next())//{ value: 'Alice', done: false }
console.log(arrIterator.next())//{ value: 'Bob', done: false }
console.log(arrIterator.next())//{ value: 'John', done: false }
console.log(arrIterator.next())//{ value: undefined, done: true }

const str = 'Alice'
// 字符串的[Symbol.iterator]方法
let strIterator = str[Symbol.iterator]()
console.log(strIterator.next())//{ value: 'A', done: false }
console.log(strIterator.next())//{ value: 'l', done: false }
console.log(strIterator.next())//{ value: 'i', done: false }
console.log(strIterator.next())//{ value: 'c', done: false }
console.log(strIterator.next())//{ value: 'e', done: false }
console.log(strIterator.next())//{ value: undefined, done: true }

2.3 可迭代对象的实现

既然我们需要对对象可迭代,那么必须实现[Symbol.iterator]方法,并且这个方法返回一个迭代器对象。

// 定义一个可迭代对象
let iterableObject = {
    arr: ['Alice', 'Bob', 'John'],
    [Symbol.iterator]: function () {
        let index = 0
        console.log(this)
        /**
            {
            arr: [ 'Alice', 'Bob', 'John' ],
            [Symbol(Symbol.iterator)]: [Function: [Symbol.iterator]]
            }
         */
        let _iterator = {
            next: () => {
                if (this.arr.length > index) {
                    return { done: false, value: this.arr[index++] }
                } else {
                    return { done: true, value: undefined }
                }
            }
        }
        return _iterator
    }
}

let arrIter = iterableObject[Symbol.iterator]()
console.log(arrIter.next())//{ done: false, value: 'Alice' }
console.log(arrIter.next())//{ done: false, value: 'Bob' }
console.log(arrIter.next())//{ done: false, value: 'John' }
console.log(arrIter.next())//{ done: true, value: undefined }

当我们使用for...of...对目标对象进行迭代的时候,它会自动执行[Symbol.iterator]方法进行迭代。

2.4 可迭代对象的使用场景

  • for...of...
  • 展开语法
  • 结构语法
  • promise.all(iterable)
  • promise.race(iterable)
  • Array.from(iterable)
  • ...

2.5 自定义可迭代类实现

由上面几节,我们实现了字面量可迭代对象的实现,接下来我们来设计一个可迭代类的实现。

class MyInfo {
    constructor(name, age, friends) {
        this.name = name
        this.age = age
        this.friends = friends
    }
    [Symbol.iterator]() {
        let index = 0
        let _iterator = {
            next: () => {
                if (this.friends.length > index) {
                    return { done: false, value: this.friends[index++] }
                } else {
                    return { done: true, value: undefined }
                }
            }
        }
        return _iterator
    }
}

const myInfo = new MyInfo('Alice', 18, ['Amy', 'Lihua'])
for (const element of myInfo) {
    console.log(element)//Amy Lihua
}

这个MyInfo类只是简单的实现了对friends的迭代,当然你也可以迭代所有你想要迭代的对象

3.生成器

生成器是ES6新增的一种可以对函数进行控制的方案,它可以控制函数的暂停执行和继续执行。

生成器函数和普通函数的不同:

  • 普通函数是function定义,生成器是function*定义
  • 生成器函数可以通过yield来控制函数的执行
  • 生成器函数返回一个生成器(Generator),生成器是一种特殊的迭代器

3.1 生成器函数的基本实现

function* test() {
    console.log('xxx');
}
// 为什么调用的函数的执行,却没有出现打印结果?
test()
console.log(test())//Object [Generator] {}

首先我们会发现,函数突然暂停了,没有项普通函数那样正常的执行打印。
我们前面也说过,他是生成器函数,执行的结果会返回一个生成器,同时也是一个特殊的迭代器。

所以和普通函数相比,他的执行好像被强行暂停了,那怎么让它继续执行呢?我们慢慢来探讨。

3.2 生成器函数的单次执行

function* test() {
    console.log('xxx');
}
// 为什么调用的函数的执行,却没有出现打印结果?
test()
console.log(test())//Object [Generator] {}
console.log(test().next())//xxx  { value: undefined, done: true }

我们根据上面函数的执行结果,发现调用test()函数我们得到了一个生成器,生成器有个next方法,又能让函数继续执行从而打印了xxx,并且还有一段很熟悉的东西: { value: undefined, done: true }

这个对象我们是不是在哪见过?

没错,就是迭代器执行next函数的结果,现在就能体会为什么说生成器是个特殊的迭代器了。

这个对象表示这次迭代是最后一次,后面没值可继续迭代了

那话说回来,yield关键字去哪了?我们马上就来探讨。

3.3 生成器的多次执行

既然单次调用就是执行一次迭代器,那我们用yield看看是不是真的控制生成器的执行。

function* generator() {
    console.log('开始了');
    yield 1
    console.log('进行中');
    console.log('多执行一次');
    yield 2
    console.log('结束了');
    yield 3
}

generator() //还是依然无事发生
let genera = generator()
// 执行了第一个yield,并且将yield后的值当做第一个迭代器的值,并且告知后面还能继续迭代
console.log(genera.next());//开始了  { value: 1, done: false }
// 有点意思,第二次执行了yield 1 和yield 2之间的所有语句 不是仅仅执行一行
console.log(genera.next());//进行中 多执行一次 { value: 2, done: false }
// 那不出意外 这里就是获得 结束了 以及 value为3的迭代器值
console.log(genera.next());// 结束了 { value: 3, done: false } 
// 果然 和我们预料的一致,但是这个done 还是false 后面还有?呢我们在继续执行一次
console.log(genera.next());//{ value: undefined, done: true }
// 这次再没有了

到这,我们发现了,这那叫生成器,这不就是迭代器么?

没错,这就是迭代器,只不过迭代的进行 通过函数内的yield关键字来当做开关

yield x 这个x会被当做此次迭代器的值,有没有yield会被当做done的结果 告诉我们后面 能不能继续迭代

既然叫做生成器函数,呢么这个函数的参数是如何传递的?

3.4 生成器函数的传参

我们先按照函数的传参去尝试一下

function* name(name1, name2) {
    let firstName = yield name1
    let lastName = yield name2
    console.log(firstName, lastName);
    return firstName + lastName
}

console.log(name('Kaselin', 'Alice'));//Object [Generator] {}
// 还是一样的,是个生成器 函数的执行被暂停了
let getName = name('Keselin', 'Alice')
console.log(getName.next());//{ value: 'Kaselin', done: false }
// 第一次执行,获取到了name1的值
console.log(getName.next());//{ value: 'Alice', done: false }
// 第二层执行,获取到了name2的值
console.log(getName.next());//{ value: NaN, done: true }
// 喂 怎么返回了NaN?按理说不应该是字符串的拼接?
// 我们加上log函数,去尝试打印firstName和lastName,得到的都是undefined,
// 这NaN原来是undefined+undefined得来的

// 由此我们得出生成器函数的参数是分段传递的

function* name(name1, name2) {
    let firstName = yield name1
    let lastName = yield name2
    return firstName + lastName + 1
}

console.log(name('Kaselin', 'Alice'));//Object [Generator] {}
// 还是一样的,是个生成器 函数的执行被暂停了
let getName = name('Keselin', 'Alice')
console.log(getName.next());//{ value: 'Keselin', done: false }
console.log(getName.next('Amy'));//{ value: 'Alice', done: false }
console.log(getName.next('Bob'));//{ value: 'AmyBob1', done: true }
console.log(getName.next());//{ value: undefined, done: true }
console.log(getName.next());//{ value: undefined, done: true }

// 第一次next传入的Amy被舍弃,第三次未传参变为undefined
console.log(getName.next('Amy'));//{ value: 'Keselin', done: false }
console.log(getName.next('Bob'));//{ value: 'Alice', done: false}
console.log(getName.next());//{ value: 'Bobundefined1', done: true }
console.log(getName.next());//{ value: undefined, done: true }
console.log(getName.next());//{ value: undefined, done: true }

// 这里的第三次NaN实际上是因为消耗最后一个yield以及yield被消耗完的呢一次没有传参都是undefined导致的
console.log(getName.next());//{ value: 'Keselin', done: false }
console.log(getName.next());//{ value: 'Alice', done: false }
console.log(getName.next());//{ value: NaN, done: true }
console.log(getName.next('Amy'));//{ value: undefined, done: true }
console.log(getName.next('Bob'));//{ value: undefined, done: true }



// 由此我们可以得出这个传值的一般规律:
// 1.有yield接受的情况下,传入的任何参数都不会被接收
// 2.本次执行next函数消耗最后一个yield的时候,当前next传入的参数会赋值给第一个变量
// 3.yield被消耗完,还会再接受一次next传入的参数
// 4.错过这个传参窗口,后续所有参数都会舍弃

这里的生成器函数传参需要多尝试理解一下,是一个很让人费解的机制。

  1. 第一次next()调用的参数被忽略(历史原因)
  2. 后续next()调用的参数成为上一个yield表达式的返回值
  3. 每个next()调用推进到下一个yield或return语句
  4. 生成器完成后,所有next()调用返回{value: undefined, done: true}

3.5 生成器代替迭代器

我们一直在说,生成器是一种特殊的迭代器,那生成器必定替代迭代器对象的。

let friends = ['Alice', 'Bob', 'Amy']

function* createFriendsIterator(friendsArr) {
    for (const friend of friendsArr) {
        yield friend
    }
}

// let create = createFriendsIterator(friends)
// 还是个生成器
// console.log(create) // Object [Generator] {}
// console.log(create.next()) // { value: 'Alice', done: false }
// console.log(create.next()) // { value: 'Bob', done: false }
// console.log(create.next()) // { value: 'Amy', done: false }
// console.log(create.next()) // { value: undefined, done: true }

// 熟悉的结果,熟悉的配方,都是符合我们的预期的
// 能不能写的更优雅一点?还真有 yield*
// yield* 这个写法会自动为可迭代对象的结果追加yield
function* createFriendsIterator(friendsArr) {
    yield* friendsArr
}
let create = createFriendsIterator(friends)
console.log(create) // Object [Generator] {}
console.log(create.next()) // { value: 'Alice', done: false }
console.log(create.next()) // { value: 'Bob', done: false }
console.log(create.next()) // { value: 'Amy', done: false }
console.log(create.next()) // { value: undefined, done: true }
// 有没有发现结果和上面一样?

既然我们学会了这么优雅的写法来获取迭代器,那我们可以不可以对最开始的那个MyInfo类进行改造?

4.生成器改造可迭代对象

class MyInfo {
    constructor(name, age, friends) {
        this.name = name
        this.age = age
        this.friends = friends
    }

    *[Symbol.iterator]() {
        yield* this.friends
    }
}

let myInfo = new MyInfo('Alice', 18, ['Bob', 'John', 'Amy'])

for (const info of myInfo) {
    console.log(info);
    // Bob
    // John
    // Amy
}


-------------------------------------------------------------
class MyInfo {
    constructor(name, age, friends) {
        this.name = name
        this.age = age
        this.friends = friends
    }
    [Symbol.iterator]() {
        let index = 0
        let _iterator = {
            next: () => {
                if (this.friends.length > index) {
                    return { done: false, value: this.friends[index++] }
                } else {
                    return { done: true, value: undefined }
                }
            }
        }
        return _iterator
    }
}

const myInfo = new MyInfo('Alice', 18, ['Amy', 'Lihua'])
for (const element of myInfo) {
    console.log(element)//Amy Lihua
}

上下一对比,简直了。

5.结论

  1. 迭代器需要满足迭代器协议,拥有自己独特的next方法,返回{done:Boolean,value:any}
  2. 可迭代对象需要符合可迭代协议,即拥有[Symbol.iterator]方法,执行这个方法后返回一个迭代器
  3. 生成器通过function*(){ }声明,yield关键字控制执行,并且返回迭代器
  4. 生成器是一种特殊的迭代器

6.结语

坚持是一件特别有趣的事,当你日复一日年复一年的积累,时间的复利就会在你的身上发生。

如有问题欢迎大家一起讨论学习,共勉。(^▽^)

鸿蒙5.0项目开发——V2装饰器@Event的使用

【高心星出品】

V2装饰器@Event的使用

概念

由于@Param装饰的变量在本地无法更改,使用@Event装饰器装饰回调方法并调用,可以实现更新数据源的变量,再通过@Local的同步机制,将修改同步回@Param,以此达到主动更新@Param装饰变量的效果。

@Event用于装饰组件对外输出的方法:

  • @Event装饰的回调方法中参数以及返回值由开发者决定。
  • @Event装饰非回调类型的变量不会生效。当@Event没有初始化时,会自动生成一个空的函数作为默认回调。
  • 当@Event未被外部初始化,但本地有默认值时,会使用本地默认的函数进行处理。

@Param标志着组件的输入,表明该变量受父组件影响,而@Event标志着组件的输出,可以通过该方法影响父组件。使用@Event装饰回调方法是一种规范,表明该回调作为自定义组件的输出。父组件需要判断是否提供对应方法用于子组件更改@Param变量的数据源。

装饰器说明

@Event属性装饰器 说明
装饰器参数 无。
允许装饰的变量类型 回调方法,例如()=>void、(x:number)=>boolean等。回调方法是否含有参数以及返回值由开发者决定。
允许传入的函数类型 箭头函数。
  1. 通信桥梁 @Event装饰的回调方法作为子组件的“输出口”,允许子组件主动触发父组件逻辑。当子组件需要修改父组件传递的@Param变量时(由于@Param本地不可直接修改),通过调用@Event装饰的方法通知父组件更新数据源。

  2. 与@Param的协作

    • 父组件通过@Param传递数据给子组件
    • 子组件通过@Event回调请求父组件修改数据源
    • 父组件更新后的数据通过@Local同步机制回传给子组件

应用场景

  1. 表单输入组件 子组件(如自定义输入框)通过@Event将用户输入实时同步到父组件数据源。
  2. 状态联动更新 当多个子组件需要基于同一父级状态协同变化时,通过事件回调统一管理状态。
  3. 动态配置传递 父组件传递初始配置后,子组件通过事件回调请求调整配置参数。

案例

双向同步案例。

父组件结构(Eventpage):

  • 使用@Local管理状态变量count
  • 通过eventchild({count: this.count, cb: ...})向子组件传递数据与回调
  • 在回调函数内通过this.count += val响应子组件事件

子组件结构(eventchild):

  • 通过@Param count: number接收父组件的状态
  • 通过@Event cb: (val: number) => void定义事件回调接口
  • 点击按钮时通过this.cb(2)触发父组件逻辑

GIF 2025-9-16 15-30-40.gif

@ComponentV2
struct eventchild{
  @Require @Param count:number
  @Event cb:(val:number)=>void
  build() {
    Column(){
      Button('child count: '+this.count)
        .width('60%')
        .onClick(()=>{
          // @param不允许直接更新count
          // this.count++
          // 可以通过@event对外暴漏一个出口,间接让父组件更新父组件的变量
          this.cb(2)
        })
    }
    .width('100%')
    .padding(20)
  }
}

@Entry
@ComponentV2
struct Eventpage {
  @Local count: number = 10;

  build() {
    Column(){
     Button('page count: '+this.count)
       .width('60%')
       .onClick(()=>{
         this.count++
       })
      // 重写@event函数
      eventchild({count:this.count,cb:(val:number)=>{
        // 更新父组件的count  会同步到子组件
        this.count+=val
      }})
    }
    .height('100%')
    .width('100%')
  }
}

单向同步案例,并且是子控制父,父无法控制子。

父组件结构:

  • 使用@Local装饰器定义响应式数组arr,并通过按钮点击事件修改数组第一个元素
  • 通过eventchild1子组件传递处理后的数组(使用[...this.arr]进行解构)和事件回调函数

子组件结构:

  • 通过@Param接收父组件传递的数组
  • 通过@Event定义事件回调接口cb
  • 点击按钮触发回调函数向父组件传递参数

GIF 2025-9-16 15-50-56.gif

@ComponentV2
struct eventchild1{
  @Require @Param arr:number[]
  @Event cb:(val:number)=>void
  build() {
    Column(){
      Button('child count: '+this.arr[0])
        .width('60%')
        .onClick(()=>{
          // 双向同步关系
          // this.arr[0]++
          // 单向同步关系,子组件能控制父组件的显示,父组件不会同步过来
          // 使用的深度拷贝 没有引用
          this.cb(1)
        })
    }
    .width('100%')
    .padding(20)
  }
}

@Entry
@ComponentV2
struct Eventpage1 {
  @Local arr: number[] = [1,2,3];

  build() {
    Column(){
      Button('page count: '+this.arr[0])
        .width('60%')
        .onClick(()=>{
          this.arr[0]++
        })
      // 重写@event函数
      // 现在子组件和父组件都是使用数组的引用,建立双向同步
      // 同时改变
      // eventchild1({arr:this.arr,cb:(val:number)=>{
      //   this.arr[0]+=val
      // }})
      // 单向同步关系,子组件能控制父组件的显示,父组件不会同步过来
      // 使用的深度拷贝 没有引用
      eventchild1({arr:[...this.arr],cb:(val:number)=>{
        this.arr[0]+=val
      }})
    }
    .height('100%')
    .width('100%')
  }
}

通过合理运用@Event,开发者能构建出高内聚、低耦合的组件化架构,实现ArkTS应用中灵活的数据流控制。

web服务代理用它,还不够吗?

前言:现大部分项目都是前后端分离,与后端调试时需要使用其提供的服务接口,但是同时开发几个功能对应不同的接口人,这时候就需要频繁的切换接口IP,大部分项目切换IP时需要关闭项目重新启动,非常麻烦。使用node写一个代理就尤为重要,还能自定义返回内容以方便调试。

依赖安装

库介绍:

代理:http-proxy-middleware,使用比较广泛,配置也比较简单

请求拦截:express、body-parser

npm install express@4.18.2 http-proxy-middleware@2.0.6 body-parser@1.20.1 --save -dev

主要代码

    const { createProxyMiddleware, responseInterceptor } = require('http-proxy-middleware');
    const express = require('express');
    const bodyParser = require('body-parser');
    const app = express();
    // 处理application/json内容格式的请求体
    app.use(bodyParser.json());
    // 处理application/x-www-form-urlencoded内容格式的请求体
    app.use(bodyParser.urlencoded({ extended: true }));
    // 选项
    const options = {
        xfwd: false,
        selfHandleResponse: true, //是否修改响应内容
        target: serverInfo.ip, // 目标服务器:代理ip+端口
        changeOrigin: true, // 默认false,是否需要改变原始主机头为目标URL
        ws: true, //代理socket
        secure: false, //是否需要证书认证
        router: function(req) { //动态代理
            //如:http://localhost:3000?proxyIp=http://1.1.1.1将被代理到http://1.1.1.1
            if(req.headers.referer) {
const hostUrl = new URL(req.headers.referer);
const proxyIp = hostUrl.searchParams.get('proxyIp');
if(proxyIp) return proxyIp;
}
        },
        onProxyReqWs(proxyReq, req, socket) {
            //代理socket
            proxyReq.on('upgrade', function(proxyRes, proxySocket, proxyHead) {
                proxySocket.on('data', (data) => {
                    socket.write(data);
                });
            });
        },
        onError(err, req, res, target) {
            //错误监听
            res?.writeHead?.(500, {
                'Content-Type': 'text/plain',
            });
            res?.end(err.message);
        },
        onProxyReq: async (proxyReq, req, res) => {
           //监听、重写请求头
           proxyReq.write(Buffer.from(JSON.stringify(req.body)));
            proxyReq.end();
        },
        onProxyRes: (proxyRes, req, res) => {
            //监听、重写返回结果
             return responseInterceptor(async (buffer, proxyRes, req, res) => {
                //响应内容
                let responseTxt = buffer.toString('utf8');
                return buffer;
             })(proxyRes, req, res)
        },
    }
    // 创建反向代理服务
    const proxy = createProxyMiddleware(options);
    // 定义 context 函数
    const server = app.use(proxy);
    // 启动服务
    app.listen(8080, function () {
        console.log([
            '代理服务已启动',
            `设备地址${serverInfo.ip}`,
            `请将proxy、cgi等所有与设备相关的地址修改为:http://localhost:${PORT}`
        ].join(","));
    });

各位还有其他方法吗,评论区留言。

OpenTiny NEXT 内核新生:生成式UI × MCP,重塑前端交互新范式!

近期,我们推出 OpenTiny NEXT —— OpenTiny的下一代企业级前端智能开发解决方案。这不仅是一次技术升级,更是一场用户交互范式的变革:从传统的人机交互升级成为人机交互范式和智能体交互范式的融合。我们坚信,每一个企业应用都值得被 AI 理解,每一次用户交互都可以更自然、更智能。

项目背景

当前,大语言模型(LLM)正在深刻地改变人机交互的方式。用户期望通过自然语言完成更复杂、更智能化的操作。然而现有的企业应用(包括Web应用、桌面应用、移动应用等)大多仍依赖于传统的图形用户界面(GUI)点击操作,无法直接响应 LLM 的指令,使得企业应用与智能体(Agent)之间形成了一道鸿沟。

随着 LLM 和 Agent 技术的发展,企业应用正逐步迈入“智能化”阶段。OpenTiny 作为一套成熟的企业前端开发解决方案,拥有 UI 组件库(TinyVue)和低代码引擎(TinyEngine)等产品,在服务传统前端开发场景的基础上,我们顺应 AI 时代需求,对 OpenTiny 进行一次代际升级,构建一套面向未来的“企业智能前端开发解决方案”。

OpenTiny NEXT 新的解决方案整合了 AI 技术与 OpenTiny 原有能力,支持企业应用允许 Agent 理解用户意图并自主完成任务,打造一个 Agent 主导的企业智能应用生态系统。

愿景与架构

OpenTiny NEXT 旨在成为业界领先的企业智能前端开发解决方案,我们致力于为企业应用无缝注入“智驾”能力,打破人、AI 与应用之间的壁垒。

我们的愿景是:让每一个企业应用都能支持 AI 理解用户意图并自主完成任务,让自然语言成为企业应用的下一代交互范式。

以下是 OpenTiny NEXT 的整体架构图:

1.png

项目介绍

OpenTiny NEXT 智能前端开发解决方案以生成式 UI + WebMCP 两大核心技术为依托,构建一个从后端服务、开发工具到前端 UI 完整的智能产品族。

  • 基础设施层 (IaaS):

    • WebAgent: 连接 Agent 智能体与企业应用内置的 MCP 服务的手臂。
  • 开发工具层 (PaaS/SDKs):

    • NEXT-SDKs: 提供跨前端框架、高可扩展的企业应用智能化开发工具库。
    • TinyEngine NEXT: 可生成“智能”应用的智能低代码引擎。
  • 应用与组件层 (SaaS/UI):

    • TinyRobot NEXT: 面向最终企业用户的智能体对话入口。
    • TinyVue NEXT: 承载生成式 UI 引擎的企业级智能组件库。
  • 门户与生态:

    • OpenTiny NEXT 官网: 产品的统一入口、文档和社区。

NEXT-SDKs:智能应用开发工具包

NEXT-SDKs 是一套开发工具包,旨在简化 WebAgent 的集成与使用,支持多种编程语言和前端框架,帮助开发者快速实现智能化功能。

它的核心 SDK (包括 TypeScript, Python, Java 等版本),提供简化的 API 封装与 WebAgent 服务的连接、认证等逻辑,同时提供易用的 API 让开发者将企业应用的前端功能声明为 MCP Server。针对不同前端框架(Vue、React、Angular、Vanilla)特性,它提供 API 以降低用户在特定前端框架中的使用 MCP Server 和连接 WebAgent 的难度。

此外,它还提供一个适配器层,可以将任意前端 AI 对话框组件(包括 TinyRobot 组件)快速接入 WebAgent 服务。并且它支持抹平不同 LLM 差异,支持文字、语音等多模态输入,使得 AI 对话框连接的 LLM 支持受控端的 MCP 工具调用。另外,它还提供动态生成二维码功能,让企业应用里的 MCP 服务成为 AI 对话框里可以让 Agent 调用的工具。

当前市面上的MCP服务都是后端服务,但是如果用户的后端服务Api想要改造成大模型可以理解的MCP服务,成本是非常高的,我们用这种逆向思维把MCP Server放在前端,这样用户是不需要对已有的后端Api进行改动,已有的业务逻辑如果已经封装成前端的Api,则可以直接注册成MCP Tool,前端的工具方法或者业务方法放在MCP Tool的回调里就完成了向AI提供工具的实现。

2.png

WebAgent:智能体MCP服务代理

WebAgent 是连接 Agent 智能体与企业应用内置的 MCP 服务的手臂。提供 MCP 市场和动态添加 MCP 插件能力,支持 Agent 调用多个授权企业应用里的 MCP 服务。基于 OAuth 2.1 协议的授权机制,受控的企业应用拥有者可以精细化授权给指定的遥控端 AI 应用。支持 MCP 插件化架构,可连接企业内部的云服务(如对象存储、数据库)或本地工具(如代码执行器),支持企业私有化部署,支持数据和模型调用均在企业内网,并提供多种维度的计费模型,支持用户注册、登录、角色权限分配及管理等。支持多语言版本,与 MCP 官方 SDKs 相对应,分为 TypeScript、Python、Java 等版本。

在浏览器运行的 Web 应用都可以接入 Web Agent Server:

3.png

TinyRobot:企业智能体个人助手

TinyRobot 是一个企业 AI 应用,支持 Agent 智能体识别用户意图,代替用户自主完成跨多个企业应用的任务。TinyRobot 可调用的 MCP 服务来自 WebAgent 的 MCP 市场和动态添加的 MCP 插件。TinyRobot 会调用 NEXT-SDKs 的能力,实现扫码动态添加 MCP 插件,以及抹平不同 LLM 差异实现 Agent 自主规划和完成任务。

同时它也可以作为对话框组件库使用,也可以当作浏览器扩展安装,助力开发者快速构建各种对话框场景页面。

4.jpeg

TinyVue NEXT:生成式UI智能组件库

TinyVue 智能组件库在传统组件库基础上,支持在生成式 UI 场景中使用,AI 智能体可以根据用户意图,按需灵活选择 TinyVue 的组件,呈现给用户可视化的效果,并支持实时互动和交互。

5.png

TinyEngine NEXT:智能应用低代码引擎

TinyEngine智能低代码引擎集成MCP能力,支持自然语言或图片生成页面,并提供可视化手动编辑与AI智能优化双模式,帮助开发者快速构建应用。同时生成应用接入OpenTiny NEXT,支持 LLM 直接操控,可助力企业应用实现智能化升级。

6.png

场景实践

出差申请是企业高频的办公场景之一,却常因「填表多、流程长」被吐槽。这里我们以“出差申请”场景为例,接入OpenTiny NEXT技术后,只需四个步骤,就能实现通过AI让企业应用直接被操控,从而实现智能化,让用户直接输入指令,就能完成整个出差流程闭环。

【实操视频】www.bilibili.com/video/BV1v7…

立即体验,共创智能前端未来

OpenTiny NEXT 即将正式发布,官网、文档、示例、Demo 一站配齐:

🌐 官网:opentiny.design

📦 GitHub:github.com/opentiny (欢迎star)

💬 交流群:添加微信小助手 opentiny-official 回复【OpenTiny NEXT】

后续我们也会对 OpenTiny NEXT 技术做出详细解读,将陆续发布《一场 MCP 生态的变革 —— 详解 OpenTiny NEXT 逆向思维的技术创新》 技术文章,请大家敬请期待~

OpenTiny NEXT,让每一个企业应用都能支持 AI 理解用户意图并自主完成任务,让自然语言成为企业应用的下一代交互范式。

未来已来,欢迎上车!

同时欢迎大家进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

HTML <output> 标签:原生表单结果展示容器,自动关联输入值

在前端表单开发中,“实时展示结果”是高频需求——无论是计算购物车商品总价、检测密码强度,还是显示文件上传进度,都需要将动态结果直观地呈现给用户。但传统实现方式往往依赖繁琐的JavaScript逻辑:手动监听输入框变化、反复获取并计算值、再更新DOM元素内容,不仅代码冗余,还容易出现“结果不实时”“重置残留”等问题。而HTML5原生的<output>标签,正是为解决这些痛点而生的“表单结果专属容器”。它能自动关联表单元素、支持表单重置同步、提升语义化与无障碍体验,让表单结果展示变得简洁高效。今天,我们就来彻底解锁这个被低估的原生标签。

一、为什么需要 标签?传统方案的3大痛点

<output>标签出现前,开发者通常用<div><span>展示表单结果,这种方式存在明显缺陷,让表单开发效率大打折扣。

1.1 痛点1:代码冗余,监听与更新逻辑繁琐

以“两数求和”这个简单场景为例,传统方案需要编写完整的监听、计算、更新代码:

<!-- 传统方案:用<span>展示结果 -->
<div>
  <input type="number" id="num1" placeholder="数字1">
  +
  <input type="number" id="num2" placeholder="数字2">
  =
  <span id="sumResult">0</span>
</div>

<script>
  // 1. 获取所有元素
  const num1 = document.getElementById('num1');
  const num2 = document.getElementById('num2');
  const sumResult = document.getElementById('sumResult');

  // 2. 定义计算函数
  function calculateSum() {
    const val1 = Number(num1.value) || 0;
    const val2 = Number(num2.value) || 0;
    sumResult.textContent = val1 + val2; // 手动更新结果
  }

  // 3. 为每个输入框绑定监听事件
  num1.addEventListener('input', calculateSum);
  num2.addEventListener('input', calculateSum);
</script>
  • 问题:仅两个输入框的求和,就需要10+行JavaScript代码;若输入元素增多(如3个数字相乘),监听逻辑会更复杂,维护成本陡增。

1.2 痛点2:语义化缺失,无障碍体验差

<div><span>是通用容器,没有任何语义标识——屏幕阅读器无法识别“这是表单的计算结果”,视障用户无法感知结果与输入框的关联关系,不符合无障碍开发规范。

1.3 痛点3:表单重置时,结果无法自动清空

当点击<button type="reset">重置表单时,输入框会恢复默认值,但用普通元素展示的结果不会同步清空,需额外编写监听逻辑:

<form id="sumForm">
  <input type="number" name="num1" value="0">
  +
  <input type="number" name="num2" value="0">
  =
  <span id="sumResult">0</span>
  <button type="reset">重置</button>
</form>

<script>
  // 额外监听reset事件,手动清空结果
  document.getElementById('sumForm').addEventListener('reset', () => {
    document.getElementById('sumResult').textContent = '0';
  });
</script>
  • 问题:增加冗余代码,且容易遗漏(如忘记绑定reset事件,导致重置后结果残留,误导用户)。

1.4 标签的破局:原生适配表单场景

<output>标签的核心价值在于“原生贴合表单结果展示需求”,它能一次性解决上述痛点:

  1. 简化代码:通过for属性关联输入元素,语义化明确,减少逻辑冗余;
  2. 自动同步:关联表单后,重置时结果会自动恢复初始值,无需额外JS;
  3. 无障碍友好:屏幕阅读器会识别为“表单结果区域”,提升视障用户体验;
  4. 表单提交:支持name属性,提交时可将结果随表单数据一起传递到服务器。

二、 标签基础:3个核心属性与用法

<output>标签的用法简洁直观,核心依赖forformname三个属性,掌握这些属性,就能实现80%的表单结果展示场景。

2.1 基础语法:快速实现“两数求和”

<output>标签重构“两数求和”功能,对比传统方案的简化效果:

<!-- <output> 方案:语义化+少代码 -->
<form oninput="x.value=parseInt(a.value)+parseInt(b.value)">0
  <input type="range" id="num1" value="50">100
  +<input type="number" id="num2" value="50">
  =<output name="x" for="num1 num2"></output>
</form>

Screen-2025-09-16-142815.gif

  • 核心优化:
    • 语义化:<output>明确标识“这是表单结果”,代码可读性更高;
    • 语法简化:支持value属性,更新结果时无需区分textContentinnerHTML
    • 关联清晰:for="num1 num2"直观体现“结果依赖这两个输入框”。

2.2 核心属性解析:解锁完整功能

1. for 属性:关联触发结果的输入元素

  • 作用:指定哪些表单元素(如<input><select><textarea>)的变化会影响<output>的结果,多个元素ID用空格分隔;
  • 本质:建立“结果与输入”的语义关联,帮助浏览器和辅助工具理解逻辑关系(不会自动触发计算,仍需JS监听输入变化);
  • 示例:关联下拉选择框与输入框,计算商品总价:
<form oninput="total.value=Number(product.value)*Number(count.value)">
  <select id="product" name="product">
    <option value="99">T恤(99元)</option>
    <option value="199">裤子(199元)</option>
  </select>
  ×
  <input type="number" id="count" name="count" value="1" min="1">
  =
  <output for="product count" id="total">99</output></form>

Screen-2025-09-16-144156.gif

2. form 属性:关联所属表单,支持自动重置

  • 作用:指定<output>所属的表单(即使<output><form>标签外部),实现“表单重置时结果自动恢复初始值”;
  • 语法form="目标表单ID"
  • 示例:外部<output>关联表单,重置时自动清空:
<!-- 表单主体 -->
<form id="shoppingForm" oninput="totalPrice.value = Number(price.value) * Number(quantity.value)">
  <input type="number" id="price" name="price" value="99" min="0">
  <input type="number" id="quantity" name="quantity" value="1" min="1">
  <button type="reset">重置</button>
</form>

<!-- 外部output:通过form属性关联表单 -->
<p>总价:<output for="price quantity" form="shoppingForm" id="totalPrice">99</output></p>

Screen-2025-09-16-150258.gif

  • 关键效果:点击“重置”按钮,输入框恢复默认值,<output>自动从“计算结果”恢复为初始值“99”,无需额外编写reset监听逻辑。

三、实战场景: 标签的4个高频应用

<output>标签在表单开发中适用性极强,以下是4个典型实战案例,覆盖计算、反馈、上传等核心场景,帮助你快速落地。

3.1 场景1:电商购物车——实时计算商品总价

电商购物车需要根据“单价×数量×折扣”实时计算总价,<output>标签能简化这一流程:

<form id="cartForm">
  <div class="cart-item">
    <label>单价:</label>
    <input type="number" name="price" id="price" value="129" min="0" step="0.01"></div>
  <div class="cart-item">
    <label>数量:</label>
    <input type="number" name="quantity" id="quantity" value="1" min="1"></div>
  <div class="cart-item">
    <label>折扣:</label>
    <input type="range" name="discount" id="discount" min="50" max="100" value="90" step="5">
    <span id="discountText">90%</span>
  </div>
  <div class="cart-item">
    <label>应付总价:</label>
    <output for="price quantity discount" form="cartForm" id="totalPrice" name="totalPrice">116.1</output></div>
  <button type="submit">结算</button>
  <button type="reset">清空</button>
</form>

<style>
  .cart-item { margin: 15px 0; }
  label { display: inline-block; width: 80px; }
  input { padding: 4px 8px; }
</style>

<script>
  // 获取元素
  const price = document.getElementById('price');
  const quantity = document.getElementById('quantity');
  const discount = document.getElementById('discount');
  const discountText = document.getElementById('discountText');
  const totalPrice = document.getElementById('totalPrice');

  // 计算总价函数
  function calculateTotal() {
    const priceVal = Number(price.value);
    const quantityVal = Number(quantity.value);
    const discountVal = Number(discount.value) / 100; // 转为折扣系数
    // 计算并保留2位小数
    // 更新结果
    totalPrice.value = (priceVal * quantityVal * discountVal).toFixed(2);
    discountText.textContent = `${discountVal * 100}%`;
  }

  // 监听所有输入变化(用事件委托优化,减少监听次数)
  document.getElementById('cartForm').addEventListener('input', calculateTotal);

  // 初始计算一次(确保页面加载时显示正确)
  calculateTotal();
</script>
  • 效果:用户修改单价、数量或拖动折扣滑块时,总价实时更新;点击“清空”按钮,所有输入和结果自动恢复初始值,体验流畅。

3.2 场景2:注册表单——密码强度实时反馈

注册表单中,“密码强度检测”需要实时向用户反馈结果,<output>标签能语义化展示反馈信息:

<form id="registerForm">
  <div class="form-group">
    <label for="password">设置密码:</label>
    <input type="password" id="password" name="password" required>
  </div>
  <div class="form-group">
    <label>密码强度:</label>
    <output for="password" form="registerForm" id="strengthOutput" class="strength weak">请输入密码</output>
  </div>
  <button type="submit">注册</button>
</form>

<style>
  .form-group { margin: 15px 0; }
  label { display: inline-block; width: 100px; }
  input { padding: 4px 8px; width: 200px; }
  .strength {
    padding: 2px 8px;
    border-radius: 4px;
    color: white;
    font-size: 14px;
  }
  .weak { background-color: #dc3545; } /* 弱 */
  .medium { background-color: #ffc107; } /* 中 */
  .strong { background-color: #28a745; } /* 强 */
</style>

<script>
  const password = document.getElementById('password');
  const strengthOutput = document.getElementById('strengthOutput');

  // 密码强度检测逻辑
  function checkPasswordStrength() {
    const value = password.value;
    if (value.length === 0) {
      strengthOutput.textContent = '请输入密码';
      strengthOutput.className = 'strength weak';
    } else if (value.length < 6) {
      strengthOutput.textContent = '弱(至少6个字符)';
      strengthOutput.className = 'strength weak';
    } else if (/^[a-zA-Z0-9]+$/.test(value)) {
      strengthOutput.textContent = '中(建议包含特殊字符)';
      strengthOutput.className = 'strength medium';
    } else {
      strengthOutput.textContent = '强(符合安全要求)';
      strengthOutput.className = 'strength strong';
    }
  }

  // 监听密码输入变化
  password.addEventListener('input', checkPasswordStrength);

  // 监听表单重置(若后续添加重置按钮)
  document.getElementById('registerForm').addEventListener('reset', () => {
    checkPasswordStrength();
  });
</script>

Screen-2025-09-16-151310.gif

  • 优势:<output>标签明确标识“这是密码强度反馈结果”,屏幕阅读器会朗读“密码强度:强(符合安全要求)”,无障碍体验更优。

3.3 场景3:文件上传——展示上传进度

文件上传时,需要向用户展示实时进度,<output>标签可作为进度信息的容器:

<form id="uploadForm">
  <div class="form-group">
    <label for="file">选择文件:</label>
    <input type="file" id="file" name="file" required>
  </div>
  <div class="form-group">
    <button type="button" id="uploadBtn">上传</button>
    <output for="file" form="uploadForm" id="uploadProgress">未上传</output>
  </div>
</form>

<style>
  .form-group { margin: 15px 0; }
  button { padding: 4px 16px; cursor: pointer; }
</style>

<script>
  const fileInput = document.getElementById('file');
  const uploadBtn = document.getElementById('uploadBtn');
  const uploadProgress = document.getElementById('uploadProgress');

  uploadBtn.addEventListener('click', () => {
    const file = fileInput.files[0];
    if (!file) {
      uploadProgress.value = '请先选择文件';
      return;
    }

    // 模拟文件上传进度(实际项目中替换为真实上传逻辑)
    const formData = new FormData();
    formData.append('file', file);

    // 模拟进度更新
    let progress = 0;
    uploadProgress.value = `上传中:0%`;

    const interval = setInterval(() => {
      progress += 10;
      uploadProgress.value = `上传中:${progress}%`;
      if (progress >= 100) {
        clearInterval(interval);
        uploadProgress.value = `上传完成:${file.name}${(file.size / 1024 / 1024).toFixed(2)}MB)`;
      }
    }, 300);

    // 实际项目中使用Fetch API上传文件(带进度监听)
    // fetch('/upload', {
    //   method: 'POST',
    //   body: formData,
    //   headers: {
    //     'X-Requested-With': 'XMLHttpRequest'
    //   },
    //   signal: controller.signal
    // })
    // .then(response => response.json())
    // .then(data => {
    //   uploadProgress.value = `上传成功:${file.name}`;
    // })
    // .catch(error => {
    //   uploadProgress.value = `上传失败:${error.message}`;
    // });
  });
</script>
  • 效果:点击“上传”按钮后,<output>会实时显示上传进度(如“上传中:30%”),上传完成后展示文件信息,用户能清晰感知上传状态,无需额外添加进度条元素。

3.4 场景4:表单验证——展示动态验证结果

在表单实时验证场景中(如检测用户名是否已存在、邮箱格式是否正确),<output>标签可用于展示验证反馈,替代传统的<span>提示:

<form id="loginForm">
  <div class="form-group">
    <label for="username">用户名:</label>
    <input type="text" id="username" name="username" required>
    <output for="username" form="loginForm" id="usernameTip" class="tip"></output>
  </div>
  <div class="form-group">
    <label for="email">邮箱:</label>
    <input type="email" id="email" name="email" required>
    <output for="email" form="loginForm" id="emailTip" class="tip"></output>
  </div>
  <button type="submit">登录</button>
</form>

<style>
  .form-group { margin: 15px 0; display: flex; align-items: center; }
  label { display: inline-block; width: 80px; }
  input { padding: 4px 8px; flex: 1; max-width: 200px; }
  .tip { margin-left: 10px; font-size: 14px; }
  .success { color: #28a745; }
  .error { color: #dc3545; }
</style>

<script>
  const username = document.getElementById('username');
  const usernameTip = document.getElementById('usernameTip');
  const email = document.getElementById('email');
  const emailTip = document.getElementById('emailTip');

  // 用户名验证(模拟检测用户名是否已存在)
  username.addEventListener('input', async () => {
    const value = username.value.trim();
    if (value.length < 3) {
      usernameTip.textContent = '用户名至少3个字符';
      usernameTip.className = 'tip error';
      return;
    }

    // 模拟异步请求(检测用户名是否已存在)
    // const response = await fetch(`/check-username?username=${value}`);
    // const data = await response.json();
    // if (data.exists) {
    //   usernameTip.textContent = '用户名已被占用';
    //   usernameTip.className = 'tip error';
    // } else {
    //   usernameTip.textContent = '用户名可用';
    //   usernameTip.className = 'tip success';
    // }

    // 模拟成功结果
    usernameTip.textContent = '用户名可用';
    usernameTip.className = 'tip success';
  });

  // 邮箱格式验证
  email.addEventListener('input', () => {
    const value = email.value.trim();
    const emailReg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!value) {
      emailTip.textContent = '';
    } else if (!emailReg.test(value)) {
      emailTip.textContent = '邮箱格式不正确(如xxx@xxx.com)';
      emailTip.className = 'tip error';
    } else {
      emailTip.textContent = '邮箱格式正确';
      emailTip.className = 'tip success';
    }
  });

  // 表单提交前验证
  document.getElementById('loginForm').addEventListener('submit', (e) => {
    const isUsernameValid = usernameTip.classList.contains('success');
    const isEmailValid = emailTip.classList.contains('success');
    if (!isUsernameValid || !isEmailValid) {
      e.preventDefault();
      alert('请完善表单信息后再提交');
    }
  });
</script>

Screen-2025-09-16-151551.gif

  • 优势:每个输入框的验证结果用专属<output>标签展示,语义化清晰,且能通过for属性与输入框强关联,代码结构更规整。

四、兼容性处理与最佳实践

4.1 浏览器兼容性现状

<output>标签是HTML5新增元素,兼容性良好,覆盖了绝大多数现代浏览器,仅需注意旧版IE的兼容问题:

浏览器 支持情况
Chrome 4+(完全支持)
Firefox 4+(完全支持)
Safari 5.1+(完全支持)
Edge 12+(完全支持)
IE 11及以下(完全不支持,需降级方案)

降级方案:针对IE浏览器的兼容处理

若项目需兼容IE 11及以下,可通过“条件注释”或“JS检测”将<output>替换为<span>,确保基础功能正常:

<!-- 方法1:使用条件注释(仅IE识别) -->
<!--[if IE]>
  <span id="sumResult">0</span>
<![endif]-->
<!--[if !IE]><!-->
  <output for="num1 num2" id="sumResult">0</output>
<!--<![endif]-->

<!-- 方法2:JS检测并替换(更灵活) -->
<div id="resultContainer">
  <output for="num1 num2" id="sumResult">0</output>
</div>

<script>
  // 检测浏览器是否支持output标签
  function isOutputSupported() {
    return 'output' in document.createElement('output');
  }

  // 不支持则替换为span
  if (!isOutputSupported()) {
    const output = document.getElementById('sumResult');
    const span = document.createElement('span');
    span.id = 'sumResult';
    span.textContent = output.textContent;
    // 复制output的for属性(用于语义关联)
    if (output.hasAttribute('for')) {
      span.setAttribute('data-for', output.getAttribute('for'));
    }
    output.parentNode.replaceChild(span, output);
  }

  // 后续JS逻辑不变(获取元素时仍用id="sumResult")
  const sumResult = document.getElementById('sumResult');
  // ... 计算逻辑 ...
</script>
  • 效果:IE浏览器会显示<span>,现代浏览器显示<output>,基础的结果展示功能不受影响,实现“优雅降级”。

4.2 最佳实践:提升 标签使用效率的5个技巧

1. 优先用 value 属性更新结果,而非 textContent

<output>标签同时支持valuetextContent,但value更贴合表单场景(与<input>value属性一致),且在表单提交时会优先使用value的值:

// 推荐
output.value = '计算结果:' + total;
// 不推荐(提交时无法传递该值)
output.textContent = '计算结果:' + total;

2. 用事件委托减少监听次数

当多个输入元素关联同一个<output>时,优先使用事件委托(监听父容器的input事件),而非为每个输入元素单独绑定监听,减少代码冗余:

// 事件委托:监听表单的input事件,覆盖所有输入元素
document.getElementById('cartForm').addEventListener('input', (e) => {
  const target = e.target;
  // 仅处理需要的输入元素
  if (['price', 'quantity', 'discount'].includes(target.name)) {
    calculateTotal();
  }
});

3. 初始值设置要明确,避免空结果

<output>的初始值建议设置为合理的默认值(如“0”“未输入”),避免页面加载时显示空内容,提升用户体验:

<!-- 推荐:设置初始值 -->
<output for="num1 num2" id="sumResult">0</output>
<!-- 不推荐:初始为空 -->
<output for="num1 num2" id="sumResult"></output>

4. 结合 CSS 美化结果展示

<output>添加专属样式(如颜色、字体大小),让结果与普通文本区分开,提升视觉辨识度:

output {
  color: #28a745; /* 成功色 */
  font-weight: bold;
  margin-left: 8px;
}
/* 错误结果样式 */
output.error {
  color: #dc3545; /* 错误色 */
}

5. 避免过度使用:仅用于表单结果展示

<output>标签的核心定位是“表单结果容器”,不建议用于非表单场景(如页面普通文本展示),避免语义化混淆。非表单场景仍推荐使用<div><span>

五、总结:表单结果展示的“原生最优解”

HTML <output>标签虽简单,却解决了表单开发中“结果展示”的核心痛点,其核心价值可概括为三点:

  1. 简化开发:减少手动监听、更新、重置的JS代码,提升开发效率;
  2. 语义化明确:明确标识“表单结果”,提升代码可读性和无障碍体验;
  3. 表单原生适配:支持自动关联表单、同步重置、提交传递结果,贴合表单场景需求。

在实际开发中,只要涉及“表单动态结果展示”(如计算、验证、反馈),<output>标签就是比<div>/<span>更优的选择。它不仅能让代码更简洁,还能提升表单的专业性和用户体验,是前端开发者值得掌握的原生工具。

你在表单开发中遇到过哪些结果展示的难题?欢迎在评论区分享你的解决方案~

vue3项目不支持低版本的android,如何做兼容

vue3 项目做 Android 低版本(尤其是 Android 5/6 及以下的 WebView 和旧浏览器)兼容处理是一个系统性的工程。你需要从语法转译、APIpolyfill、构建配置等多个方面入手。

以下是详细的步骤和解决方案:

核心问题分析

Android 低版本浏览器(如 Android 5.1 自带的 WebView 和 Chrome 40~50)的主要问题:

  1. 不支持 ES6+ 语法(如 const/let, 箭头函数, Promiseasync/awaitClass)。
  2. 缺少现代 Web API(如 fetchObject.assignArray.prototype.includes)。
  3. 不支持 Vue 3 运行时所依赖的现代 JavaScript 特性

系统性兼容方案

第一步:语法转译与 API Polyfill (最关键的一步)

这是兼容的基础,主要依靠 Babel 和 core-js

  1. 安装核心依赖

    bash

    npm install --save-dev @babel/core @babel/preset-env @vue/babel-preset-app
    npm install --save core-js regenerator-runtime # 注意 core-js 是 --save
    
    • @babel/preset-env: 智能预设,根据目标浏览器决定需要转译的语法和引入的 polyfill。
    • core-js: JavaScript 标准库的 polyfill,提供了几乎所有新 API 的实现。
    • regenerator-runtime: 用于转译 async/await 和 generator 函数。
  2. 创建或修改 Babel 配置文件 (babel.config.js) :

    javascript

    module.exports = {
      presets: [
        [
          '@babel/preset-env',
          {
            // 1. 指定需要兼容的安卓低版本
            // 这里的参数意味着“兼容所有浏览器的最新两个版本,但必须兼容安卓4.4以上”
            // targets: { android: '4.4' } 或直接指定版本
            targets: {
              android: '4.4', // 非常重要!指定最低安卓版本
              // 也可以更精确地指定浏览器版本
              // browsers: ['last 2 versions', 'android >= 4.4', 'ie >= 11'] 
            },
            // 2. 核心配置:按需加载 polyfill 和语法转译
            useBuiltIns: 'usage', // 'usage' 表示只引入代码中用到的 polyfill
            corejs: 3, // 指定 core-js 的版本,必须与安装的版本一致(推荐3)
            // 3. 确保转换所有 ES6+ 模块语法
            modules: false, // 让 webpack 处理模块化,babel 只做语法转换
          },
        ],
      ],
    };
    
  3. 在项目入口文件 (main.js 或 src/main.ts) 顶部引入 polyfill:

    javascript

    // 必须放在最前面,最先执行
    import 'core-js/stable'; // 提供 API polyfill
    import 'regenerator-runtime/runtime'; // 提供 async/await 支持
    
    import { createApp } from 'vue';
    import App from './App.vue';
    
    createApp(App).mount('#app');
    

第二步:配置 Vue CLI 或 Vite (构建工具)

如果你是使用 Vue CLI 创建的项目:
Vue CLI 内部已经集成了 Babel 和 polyfill 的配置,但你仍需检查和覆盖。

  1. 检查根目录下的 package.json 或 vue.config.js 文件中的 browserslist 字段。这是 Vue CLI 和许多工具共享目标浏览器配置的地方。

    json

    // package.json
    {
      "browserslist": [
        "> 1%",
        "last 2 versions",
        "Android >= 4.4", // 添加这一行
        "not dead"
      ]
    }
    

    这个配置会被 @babel/preset-env 自动读取。

  2. (可选) 创建 vue.config.js 进行高级配置:

    javascript

    const { defineConfig } = require('@vue/cli-service')
    
    module.exports = defineConfig({
      transpileDependencies: true, // 默认为 true,会转译 node_modules 中的依赖
      // 配置 webpack 的 loader,确保 node_modules 里的依赖也被正确转译
      chainWebpack: config => {
        config.module
          .rule('js')
          .include.add(/node_modules/(.*my-es6-module.*)/) // 如果需要转译特定node_modules包
      }
    })
    

如果你是使用 Vite 创建的项目:
Vite 默认使用 ESBuild 进行构建,速度极快,但ESBuild 不做语法降级。因此需要额外的插件。

  1. 安装 Vite 的降级插件

    bash

    npm install --save-dev @vitejs/plugin-legacy
    
  2. 配置 vite.config.js:

    javascript

    import { defineConfig } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import legacy from '@vitejs/plugin-legacy' // 引入插件
    
    // https://vitejs.dev/config/
    export default defineConfig({
      plugins: [
        vue(),
        // 配置 legacy 插件
        legacy({
          targets: ['android >= 4.4', 'iOS >= 9'], // 指定目标版本
          modernPolyfills: true // 为现代浏览器也提供必要的 polyfill
        })
      ]
    })
    

    @vitejs/plugin-legacy 插件会自动为你生成两套包:一套给现代浏览器,一套给旧浏览器,并自动在旧浏览器中注入所需的 polyfill。

第三步:处理第三方库

有些第三方库可能使用了未转译的 ES6+ 语法,即使你自己的代码转译了,它们也会在低版本浏览器中报错。

  1. 将第三方库加入 Babel 转译范围
    在 vue.config.js 中,transpileDependencies 选项默认是 true,这会转译所有 node_modules 中以 vue@vue@vuepressvuexvue-router 等开头的依赖。如果你的问题库不在此列,需要显式添加:

    javascript

    // vue.config.js
    module.exports = {
      transpileDependencies: true, // 转译所有依赖(不推荐,慢)
      // 或者更精确地指定
      transpileDependencies: ['my-es6-library', 'another-es6-package'],
    }
    

第四步:验证与调试

  1. 构建并测试

    bash

    npm run build
    

    将 dist 目录部署到服务器,然后使用真实的低版本 Android 设备浏览器开发者工具的模拟功能进行测试。

  2. 使用 Can I Use
    访问 caniuse.com 查询特定 API 或语法在目标 Android 版本中的支持情况。

  3. 查看打包结果
    运行 npm run build -- --report(Vue CLI)或使用 vite-bundle-analyzer(Vite)分析最终生成的包,检查 polyfill 是否被正确引入。

总结 checklist

  1. 安装 polyfill 依赖core-js 和 regenerator-runtime

  2. 配置 Babel:在 babel.config.js 中设置 targets 和 useBuiltIns: 'usage'

  3. 入口文件引入:在 main.js 最顶部引入 core-js 和 regenerator-runtime

  4. 配置构建工具

    • Vue CLI:检查 package.json 中的 browserslist 字段。
    • Vite:安装并配置 @vitejs/plugin-legacy
  5. 处理第三方库:必要时在 vue.config.js 中配置 transpileDependencies

  6. 真实环境测试:务必在真机或可靠的模拟环境下进行最终测试。

通过以上步骤,你的 Vue 3 应用应该可以顺利在 Android 低版本系统上运行。

2.1.7 network-浏览器-前端浏览器数据存储

前端浏览器数据存储

在前端开发中,浏览器存储是保存数据的重要方式之一。常见的浏览器存储方案有cookie、localStorage、sessionStorage、indexDB等。选择合适的存储方案对应用性能和用户体验有重要影响。

1. cookie

cookie内容请参考Cookie专题

2. sessionStorage

sessionStorage为每一个给定的源(given origin)维持一个独立的存储区域,该存储区域在页面会话期间可用(即只要浏览器处于打开状态,包括页面重新加载和恢复)。

// 保存数据到 sessionStorage
sessionStorage.setItem('key', 'value');

// 从 sessionStorage 获取数据
var data = sessionStorage.getItem('key');

// 从 sessionStorage 删除保存的数据
sessionStorage.removeItem('key');

// 从 sessionStorage 删除所有保存的数据
sessionStorage.clear();

sessionStorage在创建时便已确定了生命周期,即在当前标签页关闭时数据会被清除。

2.1 应用场景

  • 临时表单数据保存(防止页面刷新丢失)
  • 页面间临时数据传递
  • 多步骤表单的状态管理
  • 临时购物车信息

3. localStorage

localStorage 同样为每一个给定的源(given origin)维持一个独立的存储区域,但其生命周期不会因为标签页的关闭而清除,除非主动清除。

// 保存数据到 localStorage
localStorage.setItem('key', 'value');

// 从 localStorage 获取数据
var data = localStorage.getItem('key');

// 从 localStorage 删除保存的数据
localStorage.removeItem('key');

// 从 localStorage 删除所有保存的数据
localStorage.clear();

3.1 数据存储优化

// 对于复杂对象,使用JSON序列化
const userInfo = {
  name: '张三',
  age: 25,
  preferences: ['reading', 'coding']
};

// 存储
localStorage.setItem('userInfo', JSON.stringify(userInfo));

// 读取
const storedUserInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');

3.2 存储空间检测

// 检查localStorage是否可用
function isLocalStorageAvailable() {
  try {
    const test = '__test__';
    localStorage.setItem(test, test);
    localStorage.removeItem(test);
    return true;
  } catch(e) {
    return false;
  }
}

// 检查存储空间
function getLocalStorageSize() {
  let size = 0;
  for(let key in localStorage) {
    if(localStorage.hasOwnProperty(key)) {
      size += localStorage[key].length;
    }
  }
  return size;
}

3.3 数据过期处理

// 带过期时间的localStorage封装
class StorageWithExpiration {
  static set(key, value, ttl) {
    const data = {
      value: value,
      expiry: new Date().getTime() + ttl,
    };
    localStorage.setItem(key, JSON.stringify(data));
  }
  
  static get(key) {
    const item = localStorage.getItem(key);
    if (!item) {
      return null;
    }
    const data = JSON.parse(item);
    if (new Date().getTime() > data.expiry) {
      localStorage.removeItem(key);
      return null;
    }
    return data.value;
  }
}

// 使用示例
StorageWithExpiration.set('tempData', 'temporary value', 5000); // 5秒过期
const value = StorageWithExpiration.get('tempData');

3.4 应用场景

  • 用户偏好设置(主题、语言等)
  • 长期缓存的数据(如城市列表、用户信息)
  • 离线应用的数据存储
  • 购物车持久化

4. 浏览器存储方案对比

特性 cookie localStorage sessionStorage indexDB
生命周期 一般由服务器生成,可以设置过期时间 除非被清理,否则一直存在 页面会话期间可用(标签页关闭即清除) 除非被清理,否则一直存在
存储大小 4KB 5MB 5MB 无限
与服务端通信 每次都会携带在header中,对于请求性能有影响 仅在客户端存储,不与服务端通信 仅在客户端存储,不与服务端通信 仅在客户端存储,不与服务端通信
存储方式 只能存储字符串类型数据 只能存储字符串类型数据 只能存储字符串类型数据 可以存储任何类型数据
同步/异步 同步 同步 同步 异步

5. sessionStorage数据共享机制

<!-- 使用一个新标签页打开自身,并设置一个 sessionStorage -->
<a href="index.html" target="_blank" onclick="sessionStorage.setItem('name', 'liam')">open myself</a>
  1. 在浏览器中打开这个 index.html,我们称之为标签页 A。注意:需要用 http 协议打开!例如 http://localhost/index.html
  2. 点击页面上的链接,此时会弹出来标签页 B。
  3. 在标签页 B 中打开控制台并执行 sessionStorage.getItem('j')
  4. 控制台输出's',这说明标签页 A 和 B 共享了 sessionStorage 中的数据
  5. 接下来,先关闭这两个标签页,然后再打开一个标签页 C,再读取一下 j 的值,得到的是 null

但是如果进行如下操作发现表现与预期不符合:

  1. 在浏览器中打开这个 index.html,我们称之为标签页 A。注意:需要用 http 协议打开!例如 http://localhost/index.html
  2. 点击页面上的链接,此时会弹出来标签页 B。
  3. 在标签页 B 中打开控制台并执行 sessionStorage.getItem('j'),得到 's'
  4. 新建一个新标签页 D,然后在地址栏内输入 http://localhost/index.html 打开同样的页面, 然后执行 sessionStorage.getItem('j')
  5. 按照我的预期,标签页 D 得到的应该还是 's',毕竟我认为 sessionStorage 的数据是在同一网站的多个标签页之间共享的。但是我错了,得到的结果是 null

标签页 B 和标签页 D 之间唯一的不同就是它们被打开的方式:标签页 B 是通过在标签页 A 中点击链接打开的,但标签页 D 是在浏览器地址栏输入地址打开的

通过点击链接(或者用了 window.open)打开的新标签页之间是属于同一个 session 的(因为她们共用了同一个浏览器的进程),但新开一个标签页总是会初始化一个新的 session,即使网站是一样的,它们也不属于同一个 session

localStorage - 只要是同源,不同 Tab 之间均可读写,相互影响。 sessionStorage - 前提还是同源, 同一 Tab 在新标签或窗口打开一个页面时会复制顶级浏览会话的上下文作为新会话的上下文,复制之后读写操作独立,“互不影响”。 不同 Tab 之间,读写操作独立,“互不影响”。 上面打引号原因是,<a target="_blank"></a> 和 window.open() 两种方式创建新 Tab 的初始缓存不一样。前者是全新的一个 sessionStorage 对象,且初始值为空。后者则基于原页面的 sessionStorage 拷贝一份,并作为新 Tab 的初始缓存值。 Cookie - 只要 Domain 和 Path 一致情况下,不同 Tab 之间即可相互读取。Cookie 最宽松的情况是同站即可,就是说 Domain 设为二级域名、Path 设为 /。

sessionStorage 的数据会在同一网站的多个标签页之间共享吗?这取决于标签页如何打开

6. 安全注意事项

  • 敏感信息不应存储在客户端存储中
  • 对于用户身份凭证,应使用HttpOnly cookie
  • 注意存储数据的隐私合规性
  • 定期清理不需要的存储数据

React 组件渲染

React 渲染机制与重新渲染原理

本文档简单解析 React 的渲染机制、重新渲染原理以及性能优化策略,帮助开发者更好地理解 React 的工作原理并编写高性能的应用。

React 渲染机制概述

在 React 中,渲染(Render) 是指将组件转换为 DOM 节点的过程。这个过程包括:

  1. 组件函数执行:调用组件函数,生成虚拟 DOM ;
  2. 虚拟 DOM 比较Diff算法 比较新旧 虚拟DOM 树 ;
  3. DOM 更新:将差异应用到真实 DOM ;

渲染的两个阶段

渲染过程主要分为两个阶段:

1. Render 阶段(渲染阶段)
  • 目的:计算状态和 props 的变化,生成新的 虚拟 DOM ;
  • 特点:可以被打断,React 可以 暂停、中止或重启 这个阶段 ;
  • 操作:执行组件函数,进行 Diff 比较 ;
2. Commit 阶段(提交阶段)
  • 目的:将变化应用到真实 DOM
  • 特点不能被中断,必须同步执行
  • 操作:更新 DOM 节点,执行副作用(useEffect)
// 渲染过程示例
const Component = ({ name }) => {
  // Render 阶段:执行组件函数
  const [count, setCount] = useState(0);
  
  // Render 阶段:生成虚拟 DOM
  return (
    <div>
      <h1>Hello, {name}!</h1>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

// Commit 阶段:更新真实 DOM,执行副作用
useEffect(() => {
  console.log('Component mounted or updated');
}, [count]);

React 渲染原理

React 使用虚拟 DOM 来提高性能, 虚拟 DOM 实际上就是一个 JSON 对象:

// 虚拟 DOM 结构示例
const virtualDOM = {
  type: 'div',
  props: {
    className: 'container',
    children: [
      {
        type: 'h1',
        props: {
          children: 'Hello World'
        }
      },
      {
        type: 'button',
        props: {
          onClick: handleClick,
          children: 'Click me'
        }
      }
    ]
  }
};

Diff 算法

React 使用 Diff 算法来比较新旧虚拟 DOM 树:

1. 同层比较

React 只比较同一层级的节点,不会跨层级比较:

// 旧树
<div>
  <ComponentA />
  <ComponentB />
</div>

// 新树
<div>
  <ComponentA />
  <ComponentC />  {/* 只比较这一层 */}
</div>
2. Key 的作用

Key 帮助 React 识别哪些元素发生了变化:

// 没有 key:React 会重新创建所有元素
{items.map(item => <Item data={item} />)}

// 有 key:React 可以复用相同 key 的元素
{items.map(item => <Item key={item.id} data={item} />)}
3. 组件类型比较

React 会比较组件的类型:

// 组件类型相同:复用组件实例
<Button onClick={handleClick} />

// 组件类型不同:卸载旧组件,挂载新组件
<Button onClick={handleClick} />  // 旧
<Link href="/path" />             // 新

渲染优先级

React 18 引入了并发特性,支持不同优先级的渲染:

// 高优先级更新(用户交互)
const handleClick = () => {
  setCount(count + 1); // 立即渲染
};

// 低优先级更新(数据获取)
const fetchData = async () => {
  const data = await api.getData();
  setData(data); // 可能被延迟渲染
};

组件重新渲染触发条件

理解 React 组件重新渲染的触发条件是进行性能优化的基础。以下是导致组件重新渲染的主要情况:

1. 组件内部状态改变

当组件内部使用 useStateuseReducer 等 Hook 管理的状态发生变化时,组件会重新渲染。

const Component = () => {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    setCount(count + 1); // 状态改变,触发重新渲染
  };

  console.log('render');

  return <button onClick={handleClick}>Count: {count}</button>;
};

2. 父组件重新渲染

当父组件重新渲染时,默认情况下所有子组件都会重新渲染,无论子组件是否有 props 变化。

const Parent = () => {
  const [parentState, setParentState] = useState(0);

  console.log('Parent render');
  return (
    <div>
      <button onClick={() => setParentState(parentState + 1)}>
        Parent: {parentState}
      </button>
      <Child /> {/* 父组件重新渲染时,Child 也会重新渲染 */}
    </div>
  );
};

const Child = () => {
  console.log('Child render'); // 每次父组件渲染都会执行
  return <div>Child Component</div>;
};

3. Props 改变

当父组件传递给子组件的 props 发生变化时,子组件会重新渲染。

  • 示例1
// 示例1:通过外部数据源改变 props
const Parent = () => {
  const [externalData, setExternalData] = useState({ name: 'Alice' });
  
  // 模拟外部数据变化(比如从 API 获取)
  const handleDataChange = () => {
    setExternalData({ name: externalData.name === 'Alice' ? 'Bob' : 'Alice' });
  };

  console.log('Parent render');
  return (
    <div>
      <button onClick={handleDataChange}>Change External Data</button>
      <Child user={externalData} /> {/* externalData 改变时,Child 重新渲染 */}
    </div>
  );
};

const Child = ({ user }) => {
  console.log('Child render,user:', user.name);
  return <div>Hello, {user.name}!</div>;
};
  • 示例2
// 示例2:通过计算属性改变 props
const Parent = () => {
  const [count, setCount] = useState(0);
  
  // 计算属性作为 props 传递给子组件
  const computedValue = count * 2;

  console.log('Parent render');
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <Child value={computedValue} /> {/* computedValue 改变时,Child 重新渲染 */}
    </div>
  );
};

const Child = ({ value }) => {
  console.log('Child render,value:', value);
  return <div>Value: {value}</div>;
};

这里需要区分两个概念:

  1. Props 改变导致重新渲染:当传递给子组件的 props 发生变化时,子组件会重新渲染
  2. 父组件重新渲染导致子组件重新渲染:当父组件重新渲染时,默认情况下所有子组件都会重新渲染,无论 props 是否变化

关键点:在 React 中,子组件的重新渲染主要有两个原因:

  • 父组件重新渲染(这是默认行为)
  • Props 发生变化(这也会触发重新渲染)

要阻止不必要的重新渲染,需要使用 React.memo 来包装子组件,这样只有当 props 真正发生变化时,子组件才会重新渲染。

// 示例:展示父组件重新渲染对子组件的影响
const Parent = () => {
  const [parentState, setParentState] = useState(0);
  const [childProp, setChildProp] = useState('initial');
  
  console.log('Parent render');
  return (
    <div>
      <button onClick={() => setParentState(parentState + 1)}>
        Change Parent State: {parentState}
      </button>
      <button onClick={() => setChildProp(childProp === 'initial' ? 'changed' : 'initial')}>
        Change Child Prop: {childProp}
      </button>
      
      {/* 即使 childProp 没有变化,Child 也会因为父组件重新渲染而重新渲染 */}
      <Child prop={childProp} />
      
      {/* 使用 React.memo 包装的组件,只有 props 变化时才重新渲染 */}
      <MemoizedChild prop={childProp} />
    </div>
  );
};

const Child = ({ prop }) => {
  console.log('Child render,prop:', prop);
  return <div>Child: {prop}</div>;
};

const MemoizedChild = React.memo(({ prop }) => {
  console.log('MemoizedChild render,prop:', prop);
  return <div>MemoizedChild: {prop}</div>;
});

在这个示例中:

  • 点击"Change Parent State"按钮时,Child 会重新渲染(因为父组件重新渲染),但 MemoizedChild 不会重新渲染(因为 props 没有变化)
  • 点击"Change Child Prop"按钮时,两个子组件都会重新渲染(因为 props 发生了变化)

4. Context 值改变

当组件消费的 Context 值发生变化时,所有消费该 Context 的组件都会重新渲染。

const ThemeContext = createContext();

const App = () => {
  const [theme, setTheme] = useState('light');
  
  return (
    <ThemeContext.Provider value={theme}>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        Toggle Theme
      </button>
      <ThemedComponent /> {/* theme 改变时重新渲染 */}
    </ThemeContext.Provider>
  );
};

const ThemedComponent = () => {
  const theme = useContext(ThemeContext);
  return <div className={theme}>Themed Content</div>;
};

5. 强制重新渲染

通过 forceUpdate(类组件)或自定义 Hook 强制触发重新渲染。

// 类组件中的强制更新
class Component extends React.Component {
  handleForceUpdate = () => {
    this.forceUpdate(); // 强制重新渲染
  };
}

// 函数组件中的强制更新(不推荐)
const Component = () => {
  const [, forceUpdate] = useReducer(x => x + 1, 0);
  
  const handleForceUpdate = () => {
    forceUpdate(); // 强制重新渲染
  };
};

6. 组件卸载和重新挂载

当组件的 key 发生变化时,React 会卸载旧组件并挂载新组件,这也会触发重新渲染。

const Parent = () => {
  const [key, setKey] = useState(0);
  
  console.log('render');
  return (
    <div>
      <button onClick={() => setKey(key + 1)}>Remount</button>
      <Child key={key} /> {/* key 改变时,Child 会卸载并重新挂载 */}
    </div>
  );
};

重新渲染的传播机制

React 的重新渲染遵循向下传播的原则:当 App 中的 state 改变时,整个组件树都会重新渲染。

const App = () => {
  const [state, setState] = useState(0);
  
  console.log('App render');
  return (
    <div>
      <button onClick={() => setState(state + 1)}>Update</button>
      <Level1 /> {/* 会重新渲染 */}
    </div>
  );
};

const Level1 = () => {
  console.log('Level-1 渲染');
  return <Level2 />; // 会重新渲染
};

const Level2 = () => {
  console.log('Level-2 渲染');
  return <Level3 />; // 会重新渲染
};

const Level3 = () => {
  console.log('Level-3 渲染');
  return <div>Final Level</div>;
};

中断重新渲染链

可以使用 React.memo 来中断重新渲染链:只有 App 组件会重新渲染,子组件不受影响。

const App = () => {
  const [state, setState] = useState(0);
  
  console.log('App render');
  return (
    <div>
      <button onClick={() => setState(state + 1)}>Update</button>
      <MemoizedLevel1 /> {/* 使用 React.memo 包装 */}
    </div>
  );
};

const MemoizedLevel1 = React.memo(() => {
  console.log('Level-1 渲染');
  return <Level2 />;
});

const Level2 = React.memo(() => {
  console.log('Level-2 渲染');
  return <Level3 />;
});

const Level3 = React.memo(() => {
  console.log('Level-3 渲染');
  return <div>Final Level</div>;
});

性能优化建议

  1. 合理使用 React.memo:对于纯展示组件使用 React.memo
  2. 状态下沉:将状态移动到合适的组件层级
  3. 避免不必要的状态提升:不要将状态提升得太高
  4. 使用 useMemo 和 useCallback:在必要时记忆化计算结果和函数
  5. 拆分 Context:避免 Context 值频繁变化导致大量组件重新渲染

理解这些重新渲染的触发条件对于编写高性能的 React 应用非常重要。


渲染性能优化策略

1. 组件拆分策略

将大组件拆分为小组件,减少重新渲染的范围:

// ❌ 大组件:任何状态改变都会重新渲染整个组件
const LargeComponent = () => {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [comments, setComments] = useState([]);
  
  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
      <CommentList comments={comments} />
    </div>
  );
};

// ✅ 拆分组件:只有相关部分会重新渲染
const OptimizedComponent = () => {
  return (
    <div>
      <UserSection />
      <PostSection />
      <CommentSection />
    </div>
  );
};

const UserSection = () => {
  const [user, setUser] = useState(null);
  return <UserProfile user={user} />;
};

const PostSection = () => {
  const [posts, setPosts] = useState([]);
  return <PostList posts={posts} />;
};

const CommentSection = () => {
  const [comments, setComments] = useState([]);
  return <CommentList comments={comments} />;
};

2. 状态提升与下沉

合理管理状态的位置,避免不必要的重新渲染:

// ❌ 状态提升过高
const App = () => {
  const [modalOpen, setModalOpen] = useState(false);
  
  return (
    <div>
      <Header />
      <MainContent />
      <Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} />
    </div>
  );
};

// ✅ 状态下沉到合适位置
const App = () => {
  return (
    <div>
      <Header />
      <MainContent />
    </div>
  );
};

const MainContent = () => {
  const [modalOpen, setModalOpen] = useState(false);
  
  return (
    <div>
      <Content />
      <Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} />
    </div>
  );
};

3. 记忆化优化

使用 React.memouseMemouseCallback 进行记忆化:

// 使用 React.memo 包装纯组件
const ExpensiveComponent = React.memo(({ data, onUpdate }) => {
  const processedData = useMemo(() => {
    return data.map(item => ({
      ...item,
      processed: true
    }));
  }, [data]);
  
  const handleUpdate = useCallback((id) => {
    onUpdate(id);
  }, [onUpdate]);
  
  return (
    <div>
      {processedData.map(item => (
        <Item key={item.id} data={item} onUpdate={handleUpdate} />
      ))}
    </div>
  );
});

4. 懒加载组件

使用 React.lazySuspense 进行代码分割:

import { lazy, Suspense } from 'react';

const LazyComponent = lazy(() => import('./LazyComponent'));

const App = () => {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <LazyComponent />
      </Suspense>
    </div>
  );
};

5. Context 优化

合理设计 Context 结构,避免不必要的重新渲染:

// ❌ 混合状态和 API
const AppContext = createContext();

const AppProvider = ({ children }) => {
  const [state, setState] = useState(initialState);
  
  const value = {
    state,
    updateState: setState,
    // 其他 API
  };
  
  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
};

// ✅ 分离状态和 API
const StateContext = createContext();
const APIContext = createContext();

const AppProvider = ({ children }) => {
  const [state, setState] = useState(initialState);
  
  const api = useMemo(() => ({
    updateState: setState,
    // 其他 API
  }), []);
  
  return (
    <APIContext.Provider value={api}>
      <StateContext.Provider value={state}>
        {children}
      </StateContext.Provider>
    </APIContext.Provider>
  );
};

6. 渲染性能监控

使用性能监控工具来识别性能瓶颈:

import { Profiler } from 'react';

const onRenderCallback = (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
  console.log('Component:', id);
  console.log('Phase:', phase);
  console.log('Actual Duration:', actualDuration);
  console.log('Base Duration:', baseDuration);
};

const App = () => {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <YourComponent />
    </Profiler>
  );
};

7. 避免在渲染中创建对象

避免在渲染过程中创建新的对象或函数:

// ❌ 每次渲染都创建新对象
const Component = ({ items }) => {
  const style = { color: 'red', fontSize: '16px' };
  const handleClick = () => console.log('clicked');
  
  return (
    <div style={style} onClick={handleClick}>
      {items.map(item => <Item key={item.id} data={item} />)}
    </div>
  );
};

// ✅ 使用 useMemo 和 useCallback
const Component = ({ items }) => {
  const style = useMemo(() => ({ color: 'red', fontSize: '16px' }), []);
  const handleClick = useCallback(() => console.log('clicked'), []);
  
  return (
    <div style={style} onClick={handleClick}>
      {items.map(item => <Item key={item.id} data={item} />)}
    </div>
  );
};

CSS实现文字横向无限滚动效果

CSS实现文字横向无限滚动效果

一、效果

image.png

image.png

二、核心:文字滚动效果样式

这部分是实现文字循环滚动的关键,分为容器和内容两部分:

1. 滚动容器样式

.text-scroll-container {
  overflow: hidden;
  position: relative;
  white-space: nowrap;
  margin-bottom: 32px;
}
  • overflow: hidden:隐藏容器外的内容(核心!确保文字滚动到边缘后被截断,只显示容器内部分)
  • position: relative:为内部元素提供定位参考(虽然这里没直接用,但属于常规布局准备)
  • white-space: nowrap:强制文本在一行显示(关键!如果文字换行,就不会产生滚动效果)
  • margin-bottom: 32px:与下方内容保持距离

2. 滚动内容样式

.text-scroll-content {
  display: inline-block;
  animation: scroll 15s linear infinite;
}
/* 无缝滚动的关键:复制一份文本 */
.text-scroll-content::after {
  content: attr(data-text);
  padding-left: 2rem;
}
  • display: inline-block:让内容作为行内块元素,能被动画平移(块级元素默认占满宽度,无法滚动)
  • animation: scroll 15s linear infinite:应用名为scroll的动画,持续 15 秒,匀速运动,无限循环
  • ::after 伪元素:通过content: attr(data-text)复制了data-text属性中的文本(与原文本相同),实现 "无缝滚动" 的视觉效果(当原文本滚动到一半时,复制的文本刚好衔接上)
  • padding-left: 2rem:复制的文本与原文本之间留一定间距,避免重叠

3. 滚动动画定义

@keyframes scroll {
  0% {
    transform: translateX(0);
  }
  100% {
    transform: translateX(-50%);
  }
}
  • @keyframes scroll 定义动画关键帧:
    • 0%(开始):transform: translateX(0) → 文本在初始位置(不偏移)
    • 100%(结束):transform: translateX(-50%) → 文本向左偏移自身宽度的 50%
  • 为什么是 - 50%?因为::after复制了一份文本,整个内容总长度是原文本的 2 倍,偏移 50% 刚好让复制的文本完全替代原文本位置,形成 "无限循环" 的错觉

三、示例卡片样式(展示不同滚动速度)

.examples-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 24px;
}
@media (min-width: 768px) {
  .examples-grid {
    grid-template-columns: 1fr 1fr;
  }
}
.example-card {
  background-color: #f9fafb;
  padding: 16px;
  border-radius: 8px;
  border: 1px solid #e5e7eb;
}
.example-title {
  font-weight: 600;
  color: #4b5563;
  margin-bottom: 8px;
}
/* 不同速度的滚动示例 */
.example-2 .text-scroll-content {
  animation-duration: 20s; /* 中等速度(20秒滚完一次) */
}
.example-3 .text-scroll-content {
  animation-duration: 10s; /* 快速(10秒滚完一次) */
}
  • .examples-grid 使用网格布局展示多个示例,默认 1 列,屏幕宽度≥768px 时变为 2 列(响应式)
  • .example-card 定义示例卡片样式:浅灰背景、边框、圆角,内部留白
  • .example-title 示例标题样式:半粗体、灰色
  • 通过animation-duration调整动画时长,实现不同滚动速度(数值越小,滚动越快)

总结核心原理

  1. 容器限制:overflow: hidden + white-space: nowrap 确保文本在固定宽度内不换行,超出部分隐藏
  1. 动画平移:通过@keyframes让文本向左平移,形成滚动效果
  1. 无缝循环:用::after复制一份文本,配合 50% 的偏移量,让复制文本在原文本滚出视野时刚好衔接,实现 "无限滚动" 的视觉效果

你可以通过修改animation-duration(速度)、容器宽度、文本样式等属性,轻松适配不同的设计需求。

四、附完整代码

`

文字循环滚动效果
<style>
    /* 基础样式 */
    * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
    }
    
    body {
        font-family: 'Inter', system-ui, sans-serif;
        background-color: #f9fafb;
        min-height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 16px;
        color: #1f2937;
    }
    
    .container {
        width: 100%;
        max-width: 640px;
        background-color: white;
        border-radius: 8px;
        box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
        padding: 24px;
    }
    
    .title {
        font-size: 20px;
        font-weight: bold;
        color: #1f2937;
        margin-bottom: 24px;
        display: flex;
        align-items: center;
    }
    
    .title i {
        color: #3B82F6;
        margin-right: 8px;
    }
    
    /* 滚动文字核心样式 */
    .text-scroll-container {
        overflow: hidden;
        position: relative;
        white-space: nowrap;
        margin-bottom: 32px;
    }
    
    .example-1 {
        background-color: rgba(59, 130, 246, 0.1);
        border-radius: 9999px;
        padding: 12px 24px;
        border: 1px solid rgba(59, 130, 246, 0.2);
    }
    
    .text-scroll-content {
        display: inline-block;
        animation: scroll 15s linear infinite;
    }
    
    /* 为了实现无缝滚动,复制一份内容 */
    .text-scroll-content::after {
        content: attr(data-text);
        padding-left: 2rem;
    }
    
    @keyframes scroll {
        0% {
            transform: translateX(0);
        }
        100% {
            transform: translateX(-50%);
        }
    }
    
    /* 示例网格布局 */
    .examples-grid {
        display: grid;
        grid-template-columns: 1fr;
        gap: 24px;
    }
    
    @media (min-width: 768px) {
        .examples-grid {
            grid-template-columns: 1fr 1fr;
        }
    }
    
    .example-card {
        background-color: #f9fafb;
        padding: 16px;
        border-radius: 8px;
        border: 1px solid #e5e7eb;
    }
    
    .example-title {
        font-weight: 600;
        color: #4b5563;
        margin-bottom: 8px;
    }
    
    .example-2 .text-scroll-container,
    .example-3 .text-scroll-container {
        border-radius: 6px;
        border: 1px solid #d1d5db;
        padding: 8px;
        height: 40px;
        display: flex;
        align-items: center;
    }
    
    .example-2 .text-scroll-content {
        color: #4b5563;
        animation-duration: 20s;
    }
    
    .example-3 .text-scroll-content {
        color: #4b5563;
        animation-duration: 10s;
    }
    
    .primary-text {
        color: #3B82F6;
        font-weight: 500;
    }
</style>

文字滚动展示示例

    <!-- 滚动文字容器 -->
    <div class="text-scroll-container example-1">
        <div class="text-scroll-content primary-text" 
             data-text="这是一段需要在有限宽度内循环滚动显示的文本内容,当文本长度超过容器宽度时会自动滚动展示全部内容。">
            这是一段需要在有限宽度内循环滚动显示的文本内容,当文本长度超过容器宽度时会自动滚动展示全部内容。
        </div>
    </div>
    
    <div class="examples-grid">
        <div class="example-card example-2">
            <h3 class="example-title">中等速度滚动</h3>
            <div class="text-scroll-container">
                <div class="text-scroll-content" 
                     data-text="这是中等速度的滚动文本示例 - 适合大多数场景使用">
                    这是中等速度的滚动文本示例 - 适合大多数场景使用
                </div>
            </div>
        </div>
        
        <div class="example-card example-3">
            <h3 class="example-title">快速滚动</h3>
            <div class="text-scroll-container">
                <div class="text-scroll-content" 
                     data-text="这是快速滚动的文本示例 - 适合简短信息展示">
                    这是快速滚动的文本示例 - 适合简短信息展示
                </div>
            </div>
        </div>
    </div>
</div>
`

Vue3 + Element Plus 输入框省略号插件:零侵入式全局解决方案

🚀 Vue3 + Element Plus 输入框省略号插件:零侵入式全局解决方案

📖 前言

在日常开发中,我们经常会遇到输入框内容过长需要显示省略号的需求。传统的做法是在每个组件中手动添加样式和逻辑,但这种方式存在以下问题:

  • 重复代码:每个输入框都要写一遍相同的逻辑
  • 维护困难:样式分散在各个组件中,难以统一管理
  • 容易遗漏:新增输入框时容易忘记添加省略号功能
  • 性能问题:每个组件都要单独处理,没有统一的优化

今天我将分享一个零侵入式的全局解决方案,通过 Vue3 插件的方式,自动为所有 `el-input` 输入框添加省略号显示和悬浮提示功能。

🎯 功能特性

  • 完全自动化:无需在任何组件中手动添加代码
  • 智能监听:自动处理动态添加的输入框
  • 性能优化:使用 WeakSet 避免重复处理
  • 类型安全:完整的 TypeScript 支持
  • 内存友好:完善的事件监听器清理机制
  • 响应式:支持窗口大小变化时重新计算

🛠️ 技术实现

核心思路

我们的解决方案基于以下几个核心技术:

  1. MutationObserver:监听 DOM 变化,自动处理动态添加的输入框
  2. WeakSet:记录已处理的元素,避免重复处理
  3. Vue3 插件系统:通过插件方式全局注册功能
  4. 事件委托:统一管理事件监听器

完整代码实现

/**
 * el-input 省略号全局插件
 * 自动为所有 el-input 输入框添加省略号显示和悬浮提示功能
 * 不包含 textarea 类型
 */

class InputEllipsisManager {
  private observer: MutationObserver | null = null
  private processedElements = new WeakSet<HTMLElement>()

  constructor() {
    this.init()
  }

  init() {
    // 等待 DOM 加载完成后开始处理
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', () => this.startObserving())
    } else {
      this.startObserving()
    }
  }

  private startObserving() {
    // 处理已存在的元素
    this.processExistingElements()

    // 创建 MutationObserver 监听 DOM 变化
    this.observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.type === 'childList') {
          mutation.addedNodes.forEach((node) => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              this.processElement(node as HTMLElement)
            }
          })
        }
      })
    })

    // 开始观察
    this.observer.observe(document.body, {
      childList: true,
      subtree: true
    })
  }

  private processExistingElements() {
    // 处理页面中已存在的所有 el-input
    const inputs = document.querySelectorAll('.el-input:not(.el-textarea)')
    inputs.forEach(input => this.processElement(input as HTMLElement))
  }

  private processElement(element: HTMLElement) {
    // 如果已经处理过,跳过
    if (this.processedElements.has(element)) {
      return
    }

    // 查找 el-input 元素
    const inputs = element.classList?.contains('el-input') && !element.classList?.contains('el-textarea')
      ? [element]
      : Array.from(element.querySelectorAll?.('.el-input:not(.el-textarea)') || [])

    inputs.forEach(inputEl => {
      if (this.processedElements.has(inputEl)) {
        return
      }

      this.processedElements.add(inputEl)
      this.addEllipsisToInput(inputEl)
    })
  }

  private addEllipsisToInput(inputEl: HTMLElement) {
    const inputInner = inputEl.querySelector('.el-input__inner') as HTMLInputElement
    
    if (!inputInner || inputInner.tagName.toLowerCase() === 'textarea') {
      return
    }

    // 添加省略号样式
    inputInner.style.textOverflow = 'ellipsis'
    inputInner.style.whiteSpace = 'nowrap'
    inputInner.style.overflow = 'hidden'

    // 创建更新提示的函数
    const updateTooltip = () => {
      const text = inputInner.value || inputInner.placeholder || ''
      if (text && inputInner.scrollWidth > inputInner.clientWidth) {
        inputInner.title = text
      } else {
        inputInner.removeAttribute('title')
      }
    }

    // 添加事件监听器
    const events = ['input', 'focus', 'blur', 'change']
    events.forEach(eventType => {
      inputInner.addEventListener(eventType, updateTooltip)
    })

    // 初始检查
    updateTooltip()

    // 监听窗口大小变化
    const resizeHandler = () => {
      setTimeout(updateTooltip, 100)
    }
    window.addEventListener('resize', resizeHandler)

    // 保存清理函数
    ;(inputEl as any)._ellipsisCleanup = () => {
      events.forEach(eventType => {
        inputInner.removeEventListener(eventType, updateTooltip)
      })
      window.removeEventListener('resize', resizeHandler)
    }
  }

  // 公共方法:手动刷新所有输入框的省略号状态
  public refreshInputEllipsis() {
    this.processExistingElements()
  }

  // 销毁方法
  public destroy() {
    if (this.observer) {
      this.observer.disconnect()
    }
    
    // 清理所有已处理元素的事件监听器
    document.querySelectorAll('.el-input').forEach(inputEl => {
      if ((inputEl as any)._ellipsisCleanup) {
        ;(inputEl as any)._ellipsisCleanup()
        delete (inputEl as any)._ellipsisCleanup
      }
    })
  }
}

// 创建全局实例
let ellipsisManager: InputEllipsisManager | null = null

// Vue 插件定义
export default {
  install(app: any) {
    // 在应用挂载后启动
    app.mixin({
      mounted() {
        if (!ellipsisManager) {
          ellipsisManager = new InputEllipsisManager()
        }
      }
    })

    // 提供全局方法
    app.config.globalProperties.\$refreshInputEllipsis = () => {
      if (ellipsisManager) {
        ellipsisManager.refreshInputEllipsis()
      }
    }
  }
}

// 导出管理器类(可选,用于高级用法)
export { InputEllipsisManager }

关键代码解析

1. MutationObserver 监听 DOM 变化

this.observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList') {
      mutation.addedNodes.forEach((node) => {
        if (node.nodeType === Node.ELEMENT_NODE) {
          this.processElement(node as HTMLElement)
        }
      })
    }
  })
})

作用:自动监听页面中新增的 DOM 元素,确保动态添加的输入框也能被处理。

2. WeakSet 避免重复处理

private processedElements = new WeakSet<HTMLElement>()

if (this.processedElements.has(element)) {
  return
}
this.processedElements.add(element)

作用:使用 WeakSet 记录已处理的元素,避免重复处理同一个输入框,提高性能。

3. 智能省略号检测

const updateTooltip = () => {
  const text = inputInner.value || inputInner.placeholder || ''
  if (text && inputInner.scrollWidth > inputInner.clientWidth) {
    inputInner.title = text
  } else {
    inputInner.removeAttribute('title')
  }
}

作用:通过比较 `scrollWidth` 和 `clientWidth` 来判断内容是否超出,只有超出时才显示悬浮提示。

📦 安装使用

1. 创建插件文件

将上述代码保存为 `src/plugins/inputEllipsis.ts`

2. 在 main.js 中注册插件

import { createApp } from 'vue'
import App from '@/App.vue'
import inputEllipsisPlugin from '@/plugins/inputEllipsis'

const app = createApp(App)

app
  .use(inputEllipsisPlugin) // 注册输入框省略号插件
  .mount('#app')

3. 添加全局样式(可选)

// src/styles/element-plus.scss

// el-input 省略号全局样式
.el-input:not(.el-textarea) {
  .el-input__inner {
    // 确保省略号正确显示
    &[style*=\"text-overflow: ellipsis\"] {
      display: block;
      width: 100%;
      box-sizing: border-box;
    }
  }

  // 为只读状态的输入框也支持省略号
  &.is-disabled .el-input__inner {
    &[style*=\"text-overflow: ellipsis\"] {
      cursor: default;
    }
  }
}

// 确保输入框容器支持省略号
.el-input__wrapper {
  overflow: hidden;
}

🎨 使用效果

安装插件后,所有的 `el-input` 都会自动添加省略号功能:

<template>
  <!-- 这些输入框会自动添加省略号功能 -->
  <el-input v-model=\"value1\" placeholder=\"自动添加省略号\" />
  <el-input v-model=\"value2\" placeholder=\"这个也会自动处理\" />
  
  <!-- textarea 不会被影响 -->
  <el-input type=\"textarea\" v-model=\"value3\" placeholder=\"这是文本域,不会被处理\" />
  
  <!-- 动态添加的输入框也会被自动处理 -->
  <el-input v-if=\"showInput\" v-model=\"value4\" placeholder=\"动态输入框也会被处理\" />
</template>

功能演示

  • 内容超出时显示省略号
  • 鼠标悬浮时显示完整内容
  • 支持输入内容变化时动态更新
  • 支持窗口大小变化时重新计算
  • 自动排除 textarea 类型

🔧 高级用法

手动刷新省略号状态

// 在任何组件中
this.\$refreshInputEllipsis()

获取管理器实例

import { InputEllipsisManager } from '@/plugins/inputEllipsis'

// 创建自定义实例
const customManager = new InputEllipsisManager()

🚀 性能优化

1. 防抖处理

const resizeHandler = () => {
  setTimeout(updateTooltip, 100)
}

窗口大小变化时使用防抖,避免频繁计算。

2. 事件监听器清理

;(inputEl as any)._ellipsisCleanup = () => {
  events.forEach(eventType => {
    inputInner.removeEventListener(eventType, updateTooltip)
  })
  window.removeEventListener('resize', resizeHandler)
}

每个输入框都保存清理函数,避免内存泄漏。

3. WeakSet 优化

使用 WeakSet 而不是 Set,让垃圾回收器自动清理不再使用的元素引用。

🎯 适用场景

  • 管理系统:大量表单输入框
  • 数据展示:表格中的输入框
  • 动态表单:根据条件动态生成的输入框
  • 组件库:需要统一处理输入框样式的项目

🔍 技术亮点

  1. 零侵入式:无需修改任何现有组件代码
  2. 自动化:完全自动处理,无需手动干预
  3. 高性能:使用现代浏览器 API 优化性能
  4. 类型安全:完整的 TypeScript 支持
  5. 内存友好:完善的内存管理机制

📝 总结

这个输入框省略号插件通过 Vue3 插件系统、MutationObserver 和 WeakSet 等技术,实现了一个完全自动化的解决方案。它不仅解决了传统方案的痛点,还提供了更好的性能和用户体验。

核心优势

  • 🚀 零侵入:安装即用,无需修改现有代码
  • 🎯 自动化:智能处理所有输入框
  • 高性能:优化的算法和内存管理
  • 🛡️ 类型安全:完整的 TypeScript 支持

如果您觉得这个方案有用,欢迎点赞收藏!也欢迎在评论区分享您的使用心得和改进建议。


作者简介:专注于前端技术分享,Vue3 + TypeScript 实践者

技术栈:Vue3, TypeScript, Element Plus, 前端工程化

别让 “断字” 毁了 Canvas 界面!splitByGrapheme 轻松搞定非拉丁文本换行

做前端 Canvas 文本开发时,你是否遇到过中文长文本无法自动换行,甚至字符被截断的尴尬?比如想在画布上展示一段产品介绍,结果 “你好” 被拆成 “你” 在第一行末尾、“好” 在第二行开头 ——Fabric.js 的 TextBox 搭配 splitByGrapheme 属性,正是解决这类问题的关键方案。

一、认识 splitByGrapheme:文本换行的 “幕后推手”

提到 Fabric.js 的文本处理,很多人先想到 IText 类,但要实现自动换行,必须升级到 TextBox 类。而 splitByGrapheme,就是 TextBox 控制换行 “姿势” 的核心开关。

这里的 “Grapheme” 指字符的图形单位—— 简单说就是你肉眼看到的 “一个完整字符”。当 splitByGrapheme 设为true时,Fabric.js 会按单个图形单位拆分文本,确保换行只在完整字符间发生;若设为false,可能按字节拆分,直接导致字符截断。

比如显示 “Fabric.js 文本处理”,splitByGrapheme=true时会在 “理” 后换行,false时可能把 “处” 拆成两半,一半在第一行、一半在第二行。

二、为何非 splitByGrapheme 不可?

做中文项目的开发者肯定懂:英文靠空格分隔单词,换行在空格处就很自然;但中文、日文是连续书写,没有 “天然空格边界”。

想象一下,你在画布上展示 “用户协议条款”,没开 splitByGrapheme 的话,可能出现 “本协议最终解” 在第一行,“释权归公司所有” 在第二行 ——“解释权” 被拆成两半,用户读起来费劲,还显得界面不专业。

splitByGrapheme 的核心价值,就是让非拉丁字符的换行 “踩准节奏”,不管是纯中文还是中英文混合,都能保持文本完整性。

三、实操指南:三步实现文本优雅换行

不用复杂配置,只需三步,就能让中文文本在 Canvas 上自然换行,新手也能快速上手。

步骤 1:引入 Fabric.js 并初始化画布

注意!初始化画布时建议设置背景色,方便后续调试文本框位置,避免文本 “隐身” 在透明画布中。

<!-- 引入Fabric.js(CDN方式,无需本地下载) -->
<script src="https://cdn.jsdelivr.net/npm/fabric@5.3.0/dist/fabric.min.js"></script>
<!-- 创建canvas容器,必须指定id供后续调用 -->
<canvas id="textCanvas"></canvas>
<script>
// 初始化画布,宽度高度根据需求调整
const canvas = new fabric.Canvas('textCanvas', {
  width: 800,  
  height: 500, 
  backgroundColor: '#f5f5f5' // 浅灰色背景,便于区分文本区域
});
</script>

步骤 2:创建 TextBox 实例并配置 splitByGrapheme

这里有个 “必选项”——width属性必须设!否则即使开了 splitByGrapheme,文本也不会换行,只会单行超出画布。

// 待显示的长文本,可替换为项目中的动态内容
const textContent = 'Fabric.js 是一款功能强大的前端图形库,通过TextBox的splitByGrapheme属性,能轻松解决中文等非拉丁字符的换行问题,让长文本显示更自然、更美观。';
const textbox = new fabric.Textbox(textContent, {
  width: 400,          // 核心:文本框宽度,超过此宽度自动换行
  left: 100,           // 文本框左上角x坐标,控制水平位置
  top: 100,            // 文本框左上角y坐标,控制垂直位置
  fontSize: 18,        // 字体大小,根据画布尺寸调整
  fill: '#333',        // 文本颜色,避免与背景色冲突
  textAlign: 'left',   // 文本对齐方式(left/center/right可选)
  splitByGrapheme: true// 重点:启用按图形单位拆分,解决中文换行
});

步骤 3:将 TextBox 添加到画布并渲染

别漏了renderAll()!如果只add不渲染,文本框可能不会立即显示,尤其是在复杂画布场景中。

// 将文本框添加到画布
canvas.add(textbox);
// 强制渲染画布,确保文本框生效
canvas.renderAll();

四、避坑要点:这些细节不能忽略

很多开发者配置完后发现换行不生效,大概率是踩了这三个坑:

  1. 必须设置文本框宽度:再次强调!width是换行的 “触发条件”,没设width或设为 0,splitByGrapheme 再开也没用,文本会一直单行显示。
  1. 混合字符集需测试:比如 “Fabric.js 中文文本处理”,英文部分可能比中文长,建议在 Chrome、Firefox 等浏览器都测一遍,避免出现 “Fabric.j” 在第一行、“s 中文” 在第二行的情况。
  1. 交互时的实时更新:用户拖动文本框边缘调整大小时,文本会自动重新换行 —— 但如果你的项目有自定义拖拽逻辑,要注意别覆盖 Fabric.js 的默认换行机制,否则可能出现 “拖大文本框,换行却没变化” 的问题。

五、总结

如果你正在用 Fabric.js 开发带文本的 Canvas 项目(比如海报生成、在线绘图工具),splitByGrapheme 绝对是提升用户体验的 “小而美” 功能 —— 它不用复杂逻辑,只需一行配置,就能让中文文本从 “截断混乱” 变 “优雅换行”。

🤔 试试 OpenAI 的最强编程模型 "GPT-5-Codex"?

1. 引言

🐶 Cursor 近半年 "作妖不断" (各种变相涨价),20🔪的 Pro 越来越 "不耐用" 前阵子 "白嫖" 的三个月Pro,在我很 "节俭" 非重度使用的情况下 (尽量用临时免费的 grok-fast ,解决不了再Claude 4,还不行才 Claude 4🧠),结果两星期不到就蹬完了🤡,一个月两个 Pro 都不够用啊🤦‍♂️,还是得找下平替。

😶 CC (Claude Code) 刷屏好一阵子了,🤔 可能真的很强,但 200🔪/月的价格让大多数人望而却步 (5人车基本得350-400),毕竟杰哥的编码诉求并不是特别强烈 (😏花百来块买了一个月三方中转,后面也评测下)

💁‍♂️ Anthropic 最近也骚操作不断:模型降智、缩减用量、更严格的限制地区、极端反华言论,CC的信息流少了,反而频繁刷到 "Codex" (群里、小红书、公众号等),各种什么吊打 CC,性价比拉满啥的。🙉 杰哥一般是不主动尝鲜的,毕竟现在的AI工具太多了,根本试不完,之前火了一阵子的 Gemini CLI 到现在我都还没试过🤣。

💥 今早 OpenAI 发布新模型 GPT-5-Codex,这是其在GPT-5基础上专门为软件工程优化的模型版本,进一步提升了 Codex 中的智能体编程 (Agentic Coding) 能力。OpenAI在博客中提到,GPT-5-Codex的训练侧重于实际的软件工程工作,其可以根据任务动态调整思考时间,在大型复杂任务上能够 独立工作超过7个小时。同时在基准测试中,相比于GPT-5,GPT-5-Codex在多项基准测试中的 准确率、代码审查的高影响力评论概率 都实现提升。

😐 刚好看到L站有人 15出 GPT Plus 三个月兑换码 (虾皮活动、越南IP+绑卡、无质保),😀 原价20🔪/月,妥妥上车,直接 30块 淘了一个成品,早上没啥事,简单体验下是不是真如 "自媒体们" 吹的那么神~

2. 简介

Codex 早在 2021年 就存在了,不过当前AI编程还不火,OpenAI2025.5.16 正式推出 终端编程助手 Codex CLI,支持自然语言指令直接 生成/修改代码,并能 运行任务创建文件调用脚本 等。包含三个产品:Codex Cloud Agent (云端智能代理)、Codex CLI (终端命令行工具) 和 Codex VS Code 插件

【官方仓库】openai/codex

😄 开源,但 不免费

  • Plus30-150条/5h,有周额度限制 (🐶具体多少没公布),反正用超了就会提示等几天才能用。
  • Pro、Business300-1500条/5h,前者有周额度限制。L站很多这种 "Team一刀拼车",约10-15块/月,用量大的童鞋可自己检索,建议找有售后的 (失效了按天数退款) 😶。

Codex Cloud Agent 目前 "不限额度",账号需启用 MFA 认证,项目得同步到 Github 上,而且对 Codex 进行访问授权,然后就可以发布任务,等AI完成后,验收合并PR,把代码改动应用到本地了。

更多订阅信息相关可参见:《Using Codex with your ChatGPT plan》

3. Codex CLI

3.1. 安装配置

执行下述命令安装:

# 直接使用Nodejs的npm安装
npm install -g @openai/codex

# 如果你有在使用 Homebrew
brew install codex

# 验证
codex --version

安装完只需运行 codex 即可开始,但接着需要进行 "登录配置",根据自己的实际情况二选一:

3.1.1. ChatGPT 账号登录

选择 1. Sign in with ChatGPT 回车,会自动跳转网页进行登录认证

认证信息会保存在 ~/codex/auth.json 文件中:

接着可以通过 /model 命令切换不同版本的 GPT-5 模型,😄 按次数算,不是Token,妥妥切换到 high 啊:

3.1.2. 使用 API Key

没订阅 ChatGPT 但有 API KEY,只需创建一个 OPENAI_API_KEY 的环境变量:

export OPENAI_API_KEY="你的OpenAI Key"

第三方中转,则需要修改配置文件 ~/.codex/config.toml,以 packycode 中转为例:

model = "gpt-5"
model_provider = "packycode"
model_reasoning_effort = "high"
disable_response_storage = true


[model_providers.packycode]
name = "packycode"
base_url = "https://codex-api.packycode.com/v1"
wire_api = "responses"

然后 auth.json 文件的 OPENAI_API_KEY 需填入中转商提供的api密钥:

{
    "OPENAI_API_KEY": "sk-xxx"
}

重启终端,输入 codex 就可以玩耍了。

3.2. 基础使用

【官方仓库】openai/codex

3.2.1. CLI 常见命令

  • codex:启动交互式 TUI(文本用户界面),如:codex。
  • codex "..." :为交互式 TUI 提供初始提示,如:codex "fix lint errors"。
  • codex exec "..." :非交互式 "自动化模式",如:codex exec "explain utils.ts"。

💡 Tips--model/-m:指定模型类型、--ask-for-approval/-a:请求批准更改。

其它:

/mode    切换审批模式(Suggest/Auto Edit/Full Auto)
/model    切换AI模型和推理级别
/approvals    切换批准模式(Read Only/Auto/Full Access)
/init    创建AGENTS.md 文件,为 Codex 提供项目指令
/status    显示当前会话配置和令牌使用情况
/diff    显示 Git 差异(包括未跟踪文件)
/clear    清除会话历史
/prompts    显示示例提示
/help    显示帮助信息

3.2.2. 将 Prompt 作为输入

Codex 会根据 Prompt 生成相应的代码或执行任务,Codex 会在 沙盒环境中 生成文件,安装缺失的依赖项,并展示实时结果。你可以查看并批准这些更改,然后它们会被提交到你的工作目录。示例:

# Codex 会尝试解释整个代码库
codex "explain this codebase to me"

# Codex 会自动创建一个复杂的待办事项应用
codex --full-auto "create the fanciest todo-list app"

# Codex 会将类组件重构为 React Hooks,运行 npm test 并显示代码差异。
codex "Refactor the Dashboard component to React Hooks"

# Codex 会推测你的 ORM,创建迁移文件,并在沙盒数据库中运行这些迁移。
codex "Generate SQL migrations for adding a users table"

# Codex 会生成单元测试,执行这些测试,直到它们通过。
codex "Write unit tests for utils/date.ts"

# Codex 会安全地批量重命名文件,并更新所有相关的导入和使用。
codex "Bulk-rename *.jpeg -> *.jpg with git mv"

# Codex 会逐步解释正则表达式的含义。
codex "Explain what this regex does: ^(?=.*[A-Z]).{8,}$"

# Codex 会建议对当前代码库提出 3 个有影响力且范围明确的 PR。
codex "Carefully review this repo, and propose 3 high impact well-scoped PRs"

# Codex 会查找并解释安全漏洞。
codex "Look for vulnerabilities and create a security review report"

3.2.3. 编写 AGENTS.md

🤔 一种用于指导 AI 编程助手理解和操作代码库的标准化 Markdown 文件,类似于人类开发者使用的 README.md 文件,但专门为 AI 设计,提供机器可读的项目上下文和操作指令。其核心作用:

  • 提供项目上下文:描述项目结构、模块功能、依赖关系等,帮助 AI 理解代码库的组织方式。
  • 定义编码规范:列出命名约定、代码风格、注释规范等,确保 AI 生成的代码符合团队标准。
  • 指定构建与测试流程:提供构建、测试、CI/CD 等命令和流程,指导 AI 执行相关操作。
  • 设定安全与合规要求:强调安全实践、敏感数据处理、API 使用规范等,确保 AI 生成的代码符合安全标准。
  • 统一 AI 行为标准:为不同的 AI 工具(如 Cursor、Gemini CLI 等) 提供统一的指导,减少配置差异。

常见组成部分

  • 项目概述:简要介绍项目的目的、架构和主要模块。
  • 构建与测试:列出构建、测试和 CI/CD 的命令和流程。
  • 编码规范:定义命名约定、代码风格、注释规范等。
  • 安全与合规:强调安全实践、敏感数据处理、API 使用规范等。
  • 提交与 PR 指南:提供提交信息格式、 PR 审查流程等。
  • 环境与依赖:描述开发环境、依赖管理和配置要求。

简单示例:

# 项目概述

本项目是一个基于 React 和 Node.js 的 Web 应用,主要功能包括用户认证、数据展示和实时通知。

# 构建与测试

- 安装依赖:`npm install`
- 启动开发服务器:`npm run dev`
- 运行测试:`npm test`
- 构建生产版本:`npm run build`

# 编码规范

- 使用 TypeScript 严格模式
- 变量命名使用 camelCase
- 函数命名使用动词开头
- 组件命名使用 PascalCase
- 使用 ESLint 和 Prettier 进行代码格式化

# 安全与合规

- 所有数据库查询使用参数化语句,防止 SQL 注入
- 敏感信息使用环境变量管理
- API 密钥和令牌不应硬编码在代码中

# 提交与 PR 指南

- 提交信息格式:`[类型] 描述`
- 提交前运行 `npm run lint` 和 `npm test`
- PR 描述应包括变更的目的、影响和测试情况

# 环境与依赖

- 使用 Node.js 16.x 版本
- 使用 pnpm 作为包管理工具
- 使用 Docker 容器化开发环境

在大型项目或单体仓库 (monorepo) 中,可在 子项目或模块 的目录中放置局部的 AGENTS.md 文件,以覆盖或补充根目录中的指令。AI 工具会优先读取离当前操作目录最近的 AGENTS.md 文件,确保指令的针对性和准确性。三种不同层级的配置:

  • ~/.codex/AGENTS.md —— 全局个人配置
  • 项目根目录的 AGENTS.md —— 团队级共享说明
  • 当前工作目录的 AGENTS.md —— 子目录或功能模块专属配置

使用它的优势:

  • 提高代码质量:AI 生成的代码遵循项目的编码规范和最佳实践,减少了代码审查和重构的工作量。
  • 加速 AI 上手:AI 可以快速理解项目的结构和要求,提升开发效率。
  • 确保一致性:不同的 AI 工具遵循统一的指令,减少了配置差异带来的问题。
  • 降低开发成本:减少了因 AI 生成代码不符合要求而导致的返工和修正。

更详细的用法可参考:AGENTS.md

3.2.4. 小贴士 & 快捷键

使用 @ 进行文件搜索

在提示中输入 @ 即可触发模糊文件名搜索,使用上下方向键选择,Tab 或 Enter 确认,Esc 取消。

图片输入

可直接粘贴 (CV) 或通过 CLI 使用 -i/--image 参数附加图片:

codex -i screenshot.png "Explain this error"
codex --image img1.png,img2.jpg "Summarize these diagrams"

③ "回溯模式"

  • 当你在 Codex 的交互式界面中输入消息时,有时你可能想重新编辑之前的输入,或者修改某些已经提交的消息。按下 EscCodex 会进入回溯模式,显示上次输入的信息,你可以直接修改这条信息。如果想查看更早的消息,你可以 继续按Esc 键。每按一次,Codex 会将编辑器中的消息回退到你 之前 输入的消息。
  • 当你找到想要修改的消息后,你可以 按 Enter 键,Codex 会确认这条消息,并允许你在该点 分叉 对话。即从这条消息开始重新构建对话流程。另外,Codex 会 预填充 该消息内容在编辑框中,方便你进行编辑和修改,然后重新提交。
  • 好处:轻松回到之前的对话,并进行修改或扩展 Codex 给出的回复,而不是从头开始

3.2.5. 恢复交互式会话

如果你之前进行过一次交互式会话,并希望恢复,可以使用以下命令:

  • codex resume:显示会话选择器,允许你选择恢复的会话。
  • codex resume --last:恢复最近的一次会话。
  • codex resume <SESSION_ID> :通过会话 ID 恢复某个特定会话。你可以通过 /status 或 ~/.codex/sessions/ 获取会话 ID。

示例:

# 打开会话选择器
codex resume

# 恢复最近的会话
codex resume --last

# 通过会话 ID 恢复特定会话
codex resume 7f9f9a2e-1b3c-4c7a-9b0e-123456789abc

3.2.6. 为 Shell 环境配置自动补全

在终端中运行以下命令之一,根据你使用的 shell 环境生成补全脚本:

codex completion bash
codex completion zsh
codex completion fish

启用补全脚本后,后续使用 Codex 时,你可以 按 Tab 键 来自动补全你输入的命令、文件路径或参数。

3.2.7. --cd/-C 选项

在执行某些命令时,你可能需要进入到特定的目录进行操作,比如运行测试、生成代码等。使用 --cd 选项后,你可以 跳过进入目录的步骤,直接指定一个目录作为 当前的工作目录

3.2.8. 审批模式

Codex 提供了三种主要审批模式,用于控制其在本地文件系统和网络上的操作权限:

  • Auto-自动模式:默认模式。Codex 可以在工作目录内读取、编辑文件并执行命令,无需额外确认。如果尝试操作工作区之外的文件或访问网络,会向你请求审批。
  • Read Only-只读模式:适合纯对话或先制订计划时使用,Codex 只能读取文件,任何编辑、执行命令或网络请求都需审批。
  • Full Access-完全访问模式:无需审批即可读取、编辑文件并执行命令,且具有网络访问能力。风险较高,谨慎使用。

默认策略与推荐设置

  • 默认沙箱保护:Codex 启动时会在沙箱中运行,阻止对工作区外文件的修改,默认关闭网络访问。可通过命令 /status 查看当前沙箱中包含哪些路径。
  • 启动时推荐:如果当前文件夹在版本控制下(如 Git),推荐使用 Auto 模式。如果未检测到版本控制,推荐使用 Read Only 模式。
  • 工作区范围:默认为当前目录及其子目录,以及系统临时目录 (如 /tmp)。

你也可以在命令行中显式指定模式和审批策略:

codex --sandbox workspace-write --ask-for-approval on-request
codex --sandbox read-only       --ask-for-approval on-request

# 如果你希望 Codex 在任何操作时都不弹出审批提示
# 此参数可与任何 --sandbox 模式配合使用,Codex 会在所选沙箱策略下最大化自主运行。
--ask-for-approval never

常见组合示例

#【安全的只读浏览】仅可读取文件;对编辑、执行命令、网络请求均需审批。
--sandbox read-only --ask-for-approval on-request

#【CLI 管道只读】仅读取,无任何升权限审批请求,适合无交互自动化流程。
--sandbox read-only --ask-for-approval never

#【修改仓库,必要审核】可读写工作区并执行命令;对工作区外操作或网络访问请求审批。
-sandbox workspace-write --ask-for-approval on-request

#【自动化预设 (Auto模式)】在工作区内自动读写与执行命令;仅在沙箱操作失败或需要升权限时审批
--full-auto
# 等同于
--sandbox workspace-write + --ask-for-approval on-failure

#【YOLO模式-不推荐】取消沙箱限制、取消所有提示;最高风险。
--dangerously-bypass-approvals-and-sandbox
# 别名
--yolo

💡 注:在 workspace-write 模式下,网络访问默认仍被禁用。如需允许,请在配置文件中设置 [sandbox_workspace_write].network_access = true

~/.codex/config.toml 中进行全局细化设置:

# 默认模式设置
approval_policy = "untrusted"       # 操作失败时请求审批
sandbox_mode    = "read-only"       # 只读模式

# 全自动模式
approval_policy = "on-request"      # 每次操作前询问
sandbox_mode    = "workspace-write" # 工作区读写模式

# 允许网络访问
[sandbox_workspace_write]
network_access = true

# 你还可以保存不同的配置档 (Profile)
[profiles.full_auto]
approval_policy = "on-request"
sandbox_mode    = "workspace-write"

[profiles.readonly_quiet]
approval_policy = "never"
sandbox_mode    = "read-only"

# 加载配置档
codex --profile full_auto <command>

3.2.9. MCP

Codex CLI 支持 MCP,如果是配置"MCP客户端" ,需要打开用户主目录下的 ~/.codex/config.toml,添加 mcp_servers 区段:

[mcp_servers.example-server]
command = "npx"
args    = ["-y", "mcp-server"]
env     = { "API_KEY" = "your_api_key" }

也可作为 "MCP服务器" 运行,启用示例:

npx @modelcontextprotocol/inspector codex mcp

启动后,工具列表会包含两个工具:

codex: 新建或运行 Codex 会话,主要参数:

  • prompt (string, 必需):会话起始提示
  • approval-policy (string):命令审批策略,可选值 untrusted、on-failure、never
  • base-instructions (string):自定义初始指令,覆盖默认指令
  • config (object):覆盖 config.toml 中的单项设置
  • cwd (string):工作目录
  • include-plan-tool (boolean):是否在对话中包含“计划”工具
  • model (string):指定模型名称(如 o3、o4-mini)
  • profile (string):使用 config.toml 中的配置档
  • sandbox (string):沙箱模式,可选 read-only、workspace-write、danger-full-access

codex-reply:在已有会话中继续对话。

  • prompt (string, 必需):下一个用户提示
  • conversationId (string, 必需):要继续的会话 ID

4. Codex VS Code 插件

VS Code 插件市场直接搜 codex,没搜到搜 openai,进行安装:

安装完会出现在左侧边栏,😄 我是直接在Cursor上装的,补全还是用它,然后Chat用 Codex

审核选项:

  • approve once,只批准当前一次操作,适用于需要对每步改动严格把控时。
  • approve this session:批准本次会话所有操作,无需每次都人工确认。
  • reject:拒绝此次操作,Codex将不会执行当前提议的代码修改或命令。

🤔 如果用的三方中转,无法聊天,可以点击 vscode 的个人中心里面的设置,搜索 @ext:openai.chatgpt

点击在 settings.json 中编辑,在 json 最下面加上以下内容 (以具体中转平台文档为准):

"chatgpt.apiBase": "https://codex-api.packycode.com/v1",
"chatgpt.config": {
    "preferred_auth_method":"apikey",
    "model":"gpt-5",
    "model_reasoning_effort":"high",
    "wire_api":"responses"
}

保存之后重启 vscode,即可开始重新使用。打开设置还能开启类似于 CC TODO List 的 TODO comment

5. 小结

😶 习惯了图形化界面的 IDE,用起 CLI 来还是有点别扭的,插件倒是可以作为 Cursor Chat 的平替。感觉跟模型有关系,CodexClaude 4.0 那样说一堆废话来给你输出情绪价值,或者说着说着就开始原地拉💩 (写各种文档),Codex 就老老实实埋头干活,小步推进, 主打一个 "" 工出细活 (🐶是真的慢)。体验过程中遇到的三个主要问题:

① approve(审批)" 弹个不停

🤡 明明点了 approve this session,但还是要我每次确认,不知道是BUG还是啥...

② 中文乱码问题

有两种类型:"终端显示乱码" 和 "文件写入乱码",我轻度使用,目前只遇到前者:

搜了下,貌似是 Windows 平台的问题,把 PowerShellcmd 的编码全都改成 utf-8 就好了,用命令 "chcp 65001",否则默认是GBK编码。还有,在 CodexAGENTS.md 中写 Prompt 让它写代码使用 UTF-8

Codex 配置 MCP 的问题

😶 表现为:启动MCP服务超时/无法连接MPC服务),大部分反馈的用户都是 Windows,官方issues也有人在喷:

网上给出的一个解法是 "检查 nodejs 版本" 和 "pip 安装uv/uvx 包"。

① nodejs 的版本必须 20.x 以上的版本,低于这个版本很多 mcp 服务不兼容,可以键入 node -v 查看版本:

要进行版本升级的话,可以直接到官网下载安装新版安装包 (最简单),或者使用版本管理器 (nvm、fnm、volta 等) 进行版本切换。接着检查有没有安装 uv/uvx 的Python包,直接 pip install uv 进行安装 (我装过了):

uv 自带 uvx 了,不需要另外安装,你再安装是会报错的:

解释:PyPI 上的 uvx 现在只是一个 "占位" 包,里面没有任何代码,安装必然失败,真正提供 uvx 命令的是 uv 本身。如果你需要的是 "老第三方 uvx" (作者改名了),可以改成 pip install uvenv。🤡 但我安装完还是这样:

🤷‍♀️ 我用 Cursor 添加了一下其中一个MCP服务,明显起来了...

🙂 所以就是 codex 的锅,已经更到最新的0.36.0,还是这样:

😳 不会是需要自己传一个 env 参数吧?官方文档也没写啊:

搜了一圈 issues,在《MCPs do not work natively on Windows (Powershell)》发现了解决方法,添加 env 参数 (CoderPig 改为自己的电脑用户名):

env = { APPDATA = "C:\Users\CoderPig\AppData\Roaming", LOCALAPPDATA = "C:\Users\CoderPig\AppData\Local", HOME = "C:\Users\CoderPig", SystemRoot = "C:\Windows", ComSpec = "C:\Windows\System32\cmd.exe" }

修改后:

再次输入 /mcp,就好了,其它没配置的依旧失败:

🙂 坑,搞了一早上,然后可能因为今天刚宣发,体验的人多了,模型响应变得更慢的,而且有时卡在那里转半天不继续干活,真的垃 🤷‍♀️ ...

样式工程化:如何实现Design System

对于样式的处理,我经历了好几个阶段,也对其中的琐碎、麻烦、相互干扰、不够工程化深恶痛绝,可以说这玩意就是我从后端开发转向全栈的一个拦路虎。好在我现在踩了很多坑,也算积累了一点经验了。

从原生CSS到TailwindCSS

 原生CSS

大家应该都知道最初的“三剑客”,也就是Html、JavaScript、CSS。如果只要做一个简单的demo,也就是单网页,我们可以这么做:

  1. html标签上加上CSS类名,比如.my-container
  2. 在CSS文件中,写CSS样式,例如:
.my-container {
    width: 200px;
    height: 200px
}
  1. 在HTML中使用stylesheet标签引入CSS文件。

这也就是最初始的写法,也是我们在网页上F12,去浏览别人的网页看到的样子。这么写有一些缺点:

  1. 对于命名能力要求高,心智负担重,往往需要再学习一套命名的规范。 写函数容易,给函数命名难,所以有了匿名函数,而样式也是一样:写一个标题,CSS类名叫heading, 再写一个标题呢?写一个描述,叫description, 再写一个呢?如果有各种不同样式布局的标题、容器、文本,逐渐就变得越来越搞不清楚哪个对哪个。如果是多人合作的项目,还需要沟通好规范。

  2. 样式、模板位置分离,难改。我们现在需要改某个页面元素的样式,需要:

 ①找到HTML元素的类名(可能有多个应用优先级不同的样式,所以需要判断是哪个决定了它最终呈现出的样式)  ②跳到CSS文件中,修改样式  ③查看网页上样式有没有修改成功  ④检查其他页面使用相同类名的地方是否正常

在多个文件和窗口中跳来跳去,很容易打断思路,也会越来越难以维护。 3. CSS原生写法不支持嵌套,所有伪类(::before, ::after) 样式、状态样式(:hover, :focus)等,都需要独立闭合的大括号括起来,写起来相当麻烦。

  1. 样式是否真的生效相当难以判断,很多时候都是看页面上样式是不是正常了。这种非强制要求可能导致”技术债“越积越深,直到有一天要求调整样式的时候,发现已经极为混乱和难以维护。

 工程化、框架和PostCSS

在原生Html和Javascript有了框架:Vue、React等等后,CSS也有了更好的处理方式。

  1. SCSS/ LESS 等CSS后处理器出现,支持嵌套等各种语法糖,让CSS写起来更为简便。

  2. 支持组件内局部应用的样式。例如:Vue的  标签支持加上 scoped 关键字,让这个组件内的样式只在本组件内有效,避免样式污染,也有效降低了心智负担。

原理:通过打包时的插件配置,将开发时所写的样式还原成浏览器可以识别的CSS文件,将嵌套的扁平化,局部或者不同适用场景的统一添加前缀。

TailwindCSS: 快速入门+设计思想

一旦接触了TailwindCSS 很难不被它的直接简洁迷上。

它相当于提供了一整套速写词,再也不用去记臃肿的写法了!再也不用三四行来写一个属性了!也不用再到处找样式文件,只需要在html标签里写上一串原子类的className,样式就直接显示到了屏幕上。

原先设置字号需要设置13px14px 等,现在只需要写text-xs 或者text-sm。原先需要设置圆角尺寸,都是需要传入特定的值,现在只需要传入 rounded-mdrounded-lg。 阴影原先需要自己写,现在只需要写 shadow-mdshadow-sm。 换言之,只需要给出语义化的类名,就可以实现想要的效果,再也不需要关心琐碎的细节。

快速的可见即可得: 从html元素的className中读到bg-red,直接就能看到页面元素的背景是红色的。而原来则需要多一次跳转:先看到类名,再找到样式实现。当整个系统都如此,对于降低心智负担是极为重要的。

最棒的就是它内置了一套设计系统,包括尺寸、状态和色彩系统。

1. 尺寸

直接使用符合响应式标准的单位rem,和一套语义化的封装: smmdlg……换言之,我们不需要再考虑这个按钮到底应该占多大,而是应该从它实际的功能和使用场景出发,考虑它应该是大、中等还是小。

2. 色彩

内置了多种标准颜色的色彩系统,也支持自己定义色调梯度,使用颜色+渐进数值来提供原始色值和类名的对应关系,更符合直觉:需要颜色更深一些,数字就更大,需要颜色更小一些,数字就更浅。

举例:主要按钮使用 bg-blue-600, 背景色使用 bg-blue-100 ,而不需要再去调色盘查看哪个是主要的蓝色,哪个是浅蓝色。透明度也不需要自己写了,而是直接使用形如bg-blue-600/80的格式,就是透明度80%的意思。

3. 状态

在写了一套样式后,我们需要考虑它处于不同上下文的种种状态,例如:在不同屏幕尺寸下/明暗或者不同主题下/用户不同行为下/数据不同状态下等,我将其统一称之为“状态”。

举例: ① 在电脑上可以排列成每行四个的卡片,在手机屏幕上只能放得下每行一个,不然内容放不下。间距也应该随之缩小。

② 按钮在悬停时应该有特殊的样式,给用户以“我正在交互热区”的正反馈,当按钮不能点击时,应该有disabled 的状态。当正在从接口请求数据时,页面和按钮应该是loading状态。

③ 在黑暗模式下,所有的色彩都需要调整成适配的样子。例如原本是白底黑字,暗黑模式就应该是黑底白字。

TailwindCSS支持使用冒号来响应状态查询、容器查询和媒体查询。例如:bg-red-500 hover:bg-red-600 ,意思就是在hover状态下背景颜色加深。

可读性非常强。对于响应式的尺寸设计,也可以通过md: sm: 的格式来实现。

提供了一整套内置的设计系统外,也支持传入特定值来支持特殊的样式,既保留了系统的优雅简洁,也保留了开放和灵活性。目前很多UI框架都使用TailwindCSS实现。

大量使用了TailwindCSS编程后,也会觉得抽象粒度不够,写起来费劲。我们终究是需要抽象出来一套标准的UI组件复用在系统中。那么怎么去设计这一套UI组件呢?

Radix UI Themes:完善的样式系统都有哪些东西

在研究样式系统时,我看到了Radix UI , 精致漂亮色彩丰富,完完全全是我喜欢的样子。阅读它Theme 的源码,让我体悟到了样式系统的设计原则。

语义化:Design Token的运用

我需要一个创建的按钮,希望它足够醒目、重要。它是蓝色的(蓝色是我们的主色调!)

别再写background-color: blue了!

对,用tailwindcss写起来简洁一些……bg-blue-600……不,也别这么写了!

应该写的是: bg-accent-6,意思是第六梯度的主色调,这样主色调变了,也不需要去改动这一部分的代码。

这就是我们应该做的:在写样式时,我们写语义化的语句(给它重要的颜色,重要的字重,重要的阴影,hover时颜色变深),然后再通过给变量赋值的方式,来使其真正生效(重要的颜色是什么颜色?hover时具体颜色变深多少?),这样我们只需要写一次,就可以实现各种不同的主题。

业务层逻辑的剥离:样式系统只负责样式

对于一个重要的按钮,我们写出来的可能是这样:

bg-accent-6 hover:bg-accent-7 shadow-md text-md ……

把这些封装在工具类中,最终给这个重要的按钮的类名就是 ——

btn-primary

不。这样就是样式系统与业务逻辑再一次重叠和混淆了。primary 的意思是主要的,包含着一种业务逻辑层面的判断。正确的命名应该是只说明样式,这样上层调用时不会造成任何歧义和混淆。

Radix UI的做法是给出了六种变体(variant):classicsolidsoftsurfaceghostoutline。 在使用的时候,当然可以根据业务逻辑去判断出,classicsolid一般用于一级按钮,softsurface用于二级按钮……但那是业务层关心的事。

Pasted image 20250916152917

足够低的优先度,给扩展提供的可能性

Radix Theme中所提供出的所有组件,都支持外部传入的样式进行覆盖和改写,保留了足够的灵活度。我们尽可以继续使用TailwindCSS来加上自己喜欢的样式。

总结

样式系统必不可少!

将复杂琐碎的样式细节隐藏起来,写业务代码时只需要关注交互逻辑、数据流向等,而无需再关注样式……这也就是样式系统的意义所在。

而对于跨端开发的交互方面的抽象,又是另一个问题了……等我踩完坑,再写一篇。

以 NestJS 为原型看懂 Node.js 框架设计:Module

前言

在上一章中,我们实现了一个简化版的依赖注入(DI)容器,能够自动化地创建并管理依赖。

这解决了组件之间的耦合问题,但还留有一个更大的挑战:如何在应用规模变大时,保持清晰的结构与边界?

本章我们将聚焦在 Module 的设计与实现。通过模块化,我们可以把 Controller 和 Provider 按照业务领域划分,形成一个个独立的单元,并通过 imports 和 exports 来组织模块之间的依赖关系。这也是从「DI 容器」迈向「模块化应用」的关键一步。

为什么需要模块化?

在实际项目中,当 Controller / Service 越来越多时,手动注册会非常痛苦,比如下面这样:

const app = createApp();
app.registerController(UserController);
app.registerController(PostController);
app.registerProvider(UserService);
app.registerProvider(PostService);

当我们将项目按照业务划分成不同的模块,用 @Module 后,可以:

// config.module.ts
@Module({
  providers: [ConfigService],
  exports: [ConfigService]
})
export class ConfigModule {}

// user.module.ts
@Module({
  imports: [ConfigModule],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

// app.module.ts
@Module({
 imports: [UserModule, xxxModule]
})
export class AppModule {}

// main.ts
const app = createApp(AppModule);

模块化可以带来:

  1. providerscontrollersModule划分,职责分明,作用域清晰。
  2. Module 之间相互独立,但可以通过配置exports暴露相关ProviderModule 供外部使用

如何复用模块?

模块之间是相互独立的,而 exports 是模块之间的桥梁,它有两种用法:

  1. export provider:供直接上级使用,此时 BModule 内部可注入AService。(只能export 自身的provider)
@Module({
  providers: [AService],
  exports: [AService]
})
export class AModule {}


@Module({
  imports: [AModule],
  controllers: [BController],
  providers: [BService],
})
export class BModule {}
  1. export module:不仅自己用,上级也要用, 此时BModule,CModule 内部皆可注入AService。(只能export 自身import的module)
@Module({
  providers: [AService],
  exports: [AService]
})
export class AModule {}

@Module({
  imports: [AModule],
  controllers: [BController],
  providers: [BService],
  exports: [AModule]
})
export class BModule {}

@Module({
  imports: [BModule],
  controllers: [CController],
  providers: [CService], 
})
export class CModule {}

Warning: 根据源码,一个模块只能导出 provider 或 imports 中的 配置项,否则会报错噢。

// Nest: packages/core/injector/module.ts
class Module {
  public validateExportedProvider(token: InjectionToken) {
    if (this._providers.has(token)) {
      return token;
    }
    const imports = iterate(this._imports.values())
      .filter(item => !!item)
      .map(({ metatype }) => metatype)
      .filter(metatype => !!metatype)
      .toArray();

    if (!imports.includes(token as Type<unknown>)) {
      const { name } = this.metatype;
      const providerName = isFunction(token) ? (token as Function).name : token;
      throw new UnknownExportException(providerName as string, name);
    }
    return token;
  }
}

功能实现

在实现Module和exports 功能之前,有两个概念需要达成共识。

准备一:Module 定义

根据当前的需求,我们可以定义Module对象如下:

export type InjectionToken<T = any> = 
  | string
  | symbol
  | Type<T> // 类
  | Abstract<T> // 抽象类
  | Function;

export interface InstanceWrapper<T = any> {
  token: InjectionToken; // 普通provider或controller时: token === metatype;自定义provider时,token === typeof provider.provide
  metatype: Type<T> | Function; // 通过该属性进行实例化; inject 存在时, metatype为普通函数,否则为class。
  instance: T | null; // singleton 实例
  inject?: InjectionToken[]; // 只有 factory provider 和 useExisting provider 才有
}

export class Module {
  private _providers = new Map<InjectionToken, InstanceWrapper>();
  private _controllers = new Map<Type, InstanceWrapper>();
  private _exports = new Set<InjectionToken>();
  private _imports = new Set<Module>();
  
  constructor(public metatype: Type<any>) {}

  public addProvider(){}
  public addController(){}
  public addExportedProviderOrModule() {}
  public addImport(){}
}

Modules 是实现控制反转的核心,因此在 DI container 中定义modules如下:

export class Container {
  private modules = new Map<Type, Module>();

  addModule(moduleClass: Type): Module {
    if (!this.modules.has(moduleClass)) {
      this.modules.set(moduleClass, new Module(moduleClass));
    }
    return this.modules.get(moduleClass)!;
  }

  getModules() {
    return this.modules;
  }
}

准备二:Module 装饰器

通过装饰器可以拿到用户配置的 providers / controllers / imports / exports,将这几个属性保存至元数据:

interface Type<T = any> extends Function {
  new (...args: any[]): T;
}
type Provider<T = any> =
  | Type<any>
  | ClassProvider<T>
  | ValueProvider<T>
  | FactoryProvider<T>
  | ExistingProvider<T>;
  
interface ModuleMetadata {
  controllers?: Type<any>[];
  providers?: Provider[];
  imports?: Type<any>[];
  exports?: Type<any>[];
}

export function Module(metadata: ModuleMetadata): ClassDecorator {
  return (target: Function) => {
    for (const property in metadata) {
      if (Object.hasOwnProperty.call(metadata, property)) {
        Reflect.defineMetadata(property, (metadata as any)[property], target);
        // 后续通过Reflect.getMetadata("controllers" | "providers" | "imports" | "exports", target) 拿到对应的数据
      }
    }
  };
}

现在已经有了数据源以及基本数据结构,接下来就是解析和实例化,并实现exports的功能。

解析与实例化(相对源码有所简化)

从NestFactory.create 开始,流程分为4步:

// Nest 推崇单例模式,原则上只会实例化一次,因此源码中多次遍历modules并不会太耗性能。
export class NestFactory {
  static async create(moduleCls: IEntryNestModule) {
    const httpServer = createHttpServer();
    // 创建DI容器
    const container = new Container();

    // 1. 递归扫描模块(构建模块依赖图);
    // 参考源码:dependenciesScanner.scanForModules
    this.scanModules(moduleCls, container);

    // 2. 注册模块的 providers/controllers/exports
    // 参考源码:dependenciesScanner.scanModulesForDependencies
    this.registerModules(container);

    // 3. 实例化所有依赖
    // 参考源码:instanceLoader.createInstances
    await container.createInstancesOfDependencies();

    // 4. 注册路由(入口模块)
    const routerExplorer = new RouterExplorer(httpServer, container);
    routerExplorer.registerAllRoutes();
    return httpServer;
  }
}

Step1:递归扫描模块,构建模块依赖关系(仅添加模块)

export class NestFactory {
  private static scanModules(moduleCls: Type<any>, container: Container) {
    container.addModule(moduleCls);
    const imports = Reflect.getMetadata("imports", moduleCls) || [];
    for (const importedModule of imports) {
      this.scanModules(importedModule, container);
    }
  }

}

Step2:遍历所有模块,注册 imports/providers/controllers/exports 到 Module 实例

这里要结合上面的Module定义理解:

export class NestFactory { 
  private static registerModules(container: Container) {
    const modules = container.getModules();
    modules.forEach((moduleRef) => {
      const moduleClass = moduleRef.metatype;

      const imports = Reflect.getMetadata("imports", moduleClass) || [];
      for (const c of imports) {
        const importedModule = container.getModule(c);
        importedModule && moduleRef.addImport(importedModule);
      }

      const providers = Reflect.getMetadata("providers", moduleClass) || [];
      for (const p of providers) {
        moduleRef.addProvider(p); // 内部会针对不同类型的provider单独处理
      }
      const controllers = Reflect.getMetadata("controllers", moduleClass) || [];
      for (const c of controllers) {
        moduleRef.addController(c);
      }
      const exportsList = Reflect.getMetadata("exports", moduleClass) || [];
      for (const e of exportsList) {
        moduleRef.addExportedProviderOrModule(e); // 内部对exports进行合法校验
      }
    });
  }
}

这一步完成后,modules 结构长这样:

Map(3) {
  [class AppModule] => Module {
    metatype: [class AppModule],
    _providers: Map(0) {},
    _controllers: Map(1) { [class AppController] => [InstanceWrapper] },
    _exports: Set(0) {},
    _imports: Set(1) { [Module] }
  },
  [class UserModule] => Module {
    metatype: [class UserModule],
    _providers: Map(1) { 'customUserService' => [InstanceWrapper] },
    _controllers: Map(1) { [class UserController] => [InstanceWrapper] },
    _exports: Set(0) {},
    _imports: Set(1) { [Module] }
  },
  [class LoggerModule] => Module {
    metatype: [class LoggerModule],
    _providers: Map(1) { [class LoggerService] => [InstanceWrapper] },
    _controllers: Map(0) {},
    _exports: Set(1) { [class LoggerService] },
    _imports: Set(0) {}
  }
}

Step3:实例化所有依赖(provider + controller)

这一步通过createInstancesOfDependencies 扫描所有 Module,依次初始化 provider 和 controller。并且赋值给InstanceWrapper.instance,过程如下:

  1. 通过instanceWrapper.metatype,生成构造函数参数类型列表(token list)
  2. 根据参数类型(token)找到对应provider,必要时递归imports查找
  3. 若provider没有依赖,直接实例化;若有依赖,重复1,2

递归imports查找provider,源码位于 Injector.lookupComponentInImports

provider_instantiate.png

export class Container {
  private resolve<T>(token: InjectionToken, moduleRef: Module): T {}
  
  // 参考源码:createInstances in packages/core/injector/instance-loader.ts
  public async createInstancesOfDependencies() {
    for (const module of this.modules.values()) {
      for (const instanceWrapper of module.providers.values()) {
        this.loadProvider(instanceWrapper, module);
      }
      for (const instanceWrapper of module.controllers.values()) {
        this.loadProvider(instanceWrapper, module);
      }
    }
  }
}

除了构造函数注入依赖,我们还可以通过绑定属性的方式来注入依赖,使用方法如下:

@Injectable()
export class UserService {
  @Inject(LoggerService)
  private loggerService!: LoggerService;
  constructor() {}
}

那么实现起来也很简单,只要在实例化后手动绑定对应的provider即可:

//实现:applyProperties
export class Container {
  private loadProvider(wrapper: InstanceWrapper, moduleRef: Module) {
    if (wrapper.instance) {
      return;
    }
    if (
      wrapper.metatype &&
      typeof wrapper.metatype === "function" &&
      Array.isArray(wrapper.inject)
    ) {
      this.instantiateFactoryAndExistingProvider(wrapper, moduleRef);
    } else if (wrapper.metatype && typeof wrapper.metatype === "function") {
      const instance = this.instantiateProvider(wrapper, moduleRef);
      this.applyProperties(instance, wrapper.metatype as Type, moduleRef);
    }
  }
  /**
   * 注入属性依赖
   */
  private applyProperties(instance: any, metatype: Type, moduleRef: Module) {
    const properties: Array<{ key: string; type: InjectionToken }> =
      Reflect.getMetadata(PROPERTY_DEPS_METADATA, metatype) || [];
    for (const { key, type: token } of properties) {
      const resolved = this.resolveSingleParam(token, moduleRef);
      instance[key] = resolved;
    }
  }
}

这种属性注入依赖的场景更多是用于 循环依赖 和 可选依赖,避免在构造函数注入该依赖时阻塞。

Step4:注册路由(入口模块)

原理:遍历Object.getOwnPropertyNames(controller.prototype),依靠元数据生成路由,在request 回调中执行实例的相关方法。

参考源码:PathsExplorer.scanForPaths

基于之前的实现,这里不再按需解析模块,而是直接获取上一步生成的的instance,其他逻辑不变。如有兴趣,可点击这里查看相关实现。

// before
export class RouterExplorer {
  private registerRoutes(controllerClass: Type<any>, moduleRef: Module) {
    const instance: any = this.container.resolve(controllerClass, moduleRef);
    // ...
}
// after
export class RouterExplorer {
  private registerRoutes(controllerClass: Type<any>, moduleRef: Module) {
    const wrapper = moduleRef.controllers.get(controllerClass);
    if (!wrapper || !wrapper.instance) return;
    const instance = wrapper.instance;
    // ...
}

小结

到目前为止,我们可以得知:

  1. module 之间相互独立,provider 的复用是通过 exports 来实现的。
  2. 默认情况下,module、controller、provider 都是单例 Singleton(只实例化一次)。可以通过设置不同injectionToken 使 provider 实例化多次。

总结

目前我们在应用初始化时就完成了所有 provider 与 controller 的实例化,并将实例对象存放在对应的 InstanceWrapper.instance 中。这与 Nest 官方的默认策略一致 —— 单例(Singleton)在启动阶段会被统一预实例化

但由于单例在实例化时还没有请求上下文(如 req 对象),因此无法在构造函数里直接注入与请求相关的依赖。为了解决这类问题,下一章我们将引入 provider 的作用域(Scope) ,通过调整实例化的时机(如 Request / Transient)来支持更灵活的依赖注入。

99% 的人没搞懂:Semaphore 到底是干啥的?

原文来自于:zha-ge.cn/java/73

99% 的人没搞懂:Semaphore 到底是干啥的?

有次组里聚餐,聊着聊着突然绕到了多线程的“老三样”——synchronizedLock、还有一个神秘的Semaphore。 说起前两个,大伙点头如捣蒜。结果一问Semaphore,全桌人突然鸦雀无声,场面一度非常尴尬。

Semaphore,究竟是只什么“妖”

打个比方,Semaphore就像是景区门口的检票员。这个景区昼夜24小时营业,门口最多只能放进N个人。每进来一个,检票员记一次,进满后只能等人出来,剩下的人只能在门口干瞪眼。出了一个,检票员挥手:可以进一个新的啦!

用程序话说,Semaphore其实最适合用在“限流或者控制并发访问数量”的场景。比如线程池里,你不想让太多线程同时访问一段敏感代码,就塞个Semaphore

实操:抢茅坑的名额

有段日子新项目量大,大家都有点上头。偏巧需求搞了个“高并发抢资源”场景。我的第一反应是:线程同步加锁呗,谁怕谁? 可是!项目同事(技术雷达贼灵)提醒说:“兄弟,这个地方是要控制‘一组线程里只能有三个人同时进’,用Semaphore不是刚刚好?” Emmmm,细想也是哈。

搞了两行代码,思路清晰无比:

Semaphore semaphore = new Semaphore(3); // 最多能有3人占坑
semaphore.acquire(); // 没坑就等
try {
    // ……“抢坑”逻辑……
} finally {
    semaphore.release(); // 用完要让座
}

结果代码一上,就像给茅坑贴了计数牌,谁也进不来多的。

踩坑瞬间

说起来简单,真用的时候还真有俩小坑:

  • 手欠忘了 release 有次测试数据突然卡死,大家都傻眼。后来才发现:一哥们写完业务逻辑直接return,中间忘了release,茅坑永远“被站住”,后来只能重启服务祭天。

  • 不懂公平性 Semaphore 默认是“非公平”模式,也就是新来的未必排在等候区最前。领导强行搞“先到先得”,切了“公平”模式,结果性能直接暴跌…… 小伙伴一顿 Google,才明白:公平只是看起来“公平”,实际开销贼大!

  • 误以为 release 一次一定对应 acquire 一次 举个栗子,有同事搞了个循环释放,release 多次让名额飞涨,线程都涌进去了——排队机制直接失效,场面一度非常凶险。

再来点“暗黑代码”片段,未 release 的坑:

semaphore.acquire(); 
if(badLogic()) {
    return; // 忘记 release!造成死锁。
}
// ...
semaphore.release();

经验启示

满打满算,玩了几年Semaphore,最大的感觉就是: 它不是“线程互斥万能钥匙”,更多是“限流阀门”!

给大家总结几点血泪经验:

  • 适用场景 多线程限资源,比如数据库连接池、抢占物理资源、非全局锁场景。
  • 一定仔细 release 最好放 try-finally 里,不然真等着服务“假死”吧。
  • 想要让线程“公平”进”?三思! 性能真掉队,没极特殊需求建议别乱设成公平。
  • 解释用法要用比喻 不然很多同事还是以为是个奇怪的锁。

最后想说的是

写到这儿,其实就像亲身“抢茅坑”奋斗史,基本姿势一遍遍踩出血才明白—— Semaphore和加锁不是一回事,别哪都套上。

以后谁再问Semaphore干啥——你就说:“排队神器,放行计数,茅坑专用!” 没啥好玄乎的,真弄明白了,它可有趣

前端基础避坑:3 个实用知识点的简单用法

前端基础避坑:3 个实用知识点的简单用法

做前端这些年,发现不少问题不是框架难,是基础没玩明白 —— 比如知道事件委托,却搞不定动态元素点击;用 localStorage 存对象,取出来成了[object Object]。今天说几个自己踩过的坑,都是日常能用的,不绕虚的。

1. 事件委托:别再给每个列表项绑事件

刚入行写 Todo 列表,我会循环给每个 item 加 click 事件,结果产品加了 “动态加任务” 功能,新任务点了没反应。后来才懂,事件委托得找对父容器,还得处理子元素的情况。

小白常写错的

// 错1:只给现有item绑事件,动态加的没用
const todoItems = document.querySelectorAll(".todo-item");
todoItems.forEach((item) => {
  item.onclick = () => item.classList.toggle("done");
});

// 错2:绑到body上,冒泡太远,可能出问题
document.body.onclick = (e) => {
  if (e.target.classList.has("todo-item")) {
    e.target.classList.toggle("done");
  }
};

正确的做法

找 “不会动的父容器”(比如.todo-list),再用closest找真正的 item(防止点到 item 里的子元素,比如删除按钮):

const todoList = document.querySelector(".todo-list");
todoList.onclick = (e) => {
  // 找到最近的.todo-item,不管点的是item还是它的子元素
  const targetItem = e.target.closest(".todo-item");
  if (!targetItem) return; // 不是目标就退出
  // 区分点的是删除按钮还是任务本身
  const delBtn = e.target.closest(".todo-del");
  if (delBtn) {
    targetItem.remove();
    return;
  }
  targetItem.classList.toggle("done");
};

小提醒

别把事件绑到bodyhtml上,找最近的固定父容器就行,省得浪费性能。

2. localStorage:别直接存对象!简单封装下

很多人存对象直接写localStorage.setItem('user', userInfo),取的时候傻了 ——localStorage 只能存字符串。还有个问题,它没过期时间,存的 token 过期了还在,容易出 bug。 分享 3 个常用的封装函数,解决这些问题:

1. 基础版:能存对象 / 数组

const storage = {
  // 存数据:对象转成JSON字符串
  set(key, value) {
    try {
      const str = typeof value === "object" ? JSON.stringify(value) : value;
      localStorage.setItem(key, str);
    } catch (e) {
      console.error("存不进去:", e);
    }
  },

  // 取数据:JSON字符串转回来
  get(key) {
    try {
      const val = localStorage.getItem(key);
      return val ? JSON.parse(val) : null;
    } catch (e) {
      // 不是JSON就直接返回原字符串
      return localStorage.getItem(key);
    }
  },

  // 删除数据
  remove(key) {
    localStorage.removeItem(key);
  },
};

// 用的时候这样写
storage.set("user", { name: "张三", age: 28 });
const user = storage.get("user"); // 能拿到完整对象

2. 进阶版:带过期时间

比如存 token,想让它 2 小时后过期:

// 存的时候加过期时间(单位:分钟)
storage.setWithExpire = (key, value, expireMin) => {
  const data = {
    value: value,
    expire: Date.now() + expireMin * 60 * 1000, // 过期时间戳
  };
  this.set(key, data);
};

// 取的时候判断是否过期

storage.getWithExpire = (key) => {
  const data = this.get(key);
  if (!data) return null;
  if (Date.now() > data.expire) {
    this.remove(key); // 过期了就删掉
    return null;
  }
  return data.value;
};

// 示例:存token,2小时过期
storage.setWithExpire("token", "abc123", 120);

小提醒

  • 别存太大的东西,localStorage 就 5MB,满了会报错;
  • 密码之类的敏感数据别存在这,用sessionStorage或者httpOnly Cookie

3. CSS 优先级:别乱加!important!记个简单算法

新手调样式常遇到 “写了不生效”,然后就加!important,最后样式乱成一团。其实算清楚优先级,不用!important也能搞定。

简单算法:算三个数

按 “ID 个数(1 个算 100)、类 / 伪类个数(1 个算 10)、元素个数(1 个算 1)” 加起来,数大的生效:

  • 内联样式(style属性):1000(最高)
  • #box(ID):100
  • .red:hover(类 / 伪类):10
  • div:before(元素 / 伪元素):1

举几个例子:

  • div.box → 1(元素)+10(类)=11
  • #app .list li → 100(ID)+10(类)+1(元素)=111
  • <div style="color: red"> → 1000

实际场景:改组件库样式

比如用 Element UI 的按钮,写.el-button { color: blue }不生效 —— 因为组件库的样式是.el-button--primary { color: white }(优先级 10),和你的一样,谁后加载谁生效。 想覆盖的话,提高优先级就行:

/* 父容器类 + 目标类 → 10+10=20,比10高 */
.app .el-button--primary {
  color: blue;
}

什么时候能加!important?

只有一种情况:要覆盖内联样式,还改不了 HTML(比如第三方组件)。比如:

<!-- 第三方组件的内联样式 -->
<div class="third-box" style="width: 200px;"></div>

这时才能用:

.third-box {
  width: 300px !important;
}

记住:加了!important,以后改这个样式也得加,能不用就不用。

最后:基础不是会了就行,是用对

框架更新快,但 DOM、存储、CSS 这些基础不变。我之前因为事件委托没处理好,查了半小时才发现动态元素没反应;也因为 localStorage 没加过期时间,导致用户看了过期数据 —— 这些坑都不是不会,是没吃透。

要是觉得这些有用,项目里试试;你们踩过哪些基础坑,也可以聊聊~

❌