普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月20日技术

Bash Strict Mode: set -euo pipefail Explained

By default, a Bash script will keep running after a command fails, treat unset variables as empty strings, and hide errors inside pipelines. That is convenient for one-off commands in an interactive shell, but inside a script it is a recipe for silent corruption: a failed backup step that still reports success, an unset path that expands to rm -rf /, a curl | sh pipeline that swallows a 500 error.

Bash strict mode is a short set of flags you put at the top of a script to make the shell fail loudly instead. This guide explains each flag in set -euo pipefail, shows what it changes with real examples, and covers the cases where you need to turn it off.

The Strict Mode Line

You will see this line at the top of many modern shell scripts:

script.shsh
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

Each flag addresses a different class of silent failure:

  • set -e exits immediately when a command returns a non-zero status.
  • set -u treats references to unset variables as an error.
  • set -o pipefail makes a pipeline fail if any command in it fails, not just the last.
  • IFS=$'\n\t' narrows word splitting so unquoted expansions do not split on spaces.

You can enable the options separately, but in practice they are almost always used together.

set -e: Exit on Error

Without set -e, a failing command in a script does not stop execution. The script happily continues to the next line:

no-strict.shsh
#!/usr/bin/env bash
cp /does/not/exist /tmp/dest
echo "Backup complete"

Running it prints both the error and the success message:

Terminal
./no-strict.sh
output
cp: cannot stat '/does/not/exist': No such file or directory
Backup complete

The script exits with status 0, so any caller thinks the backup worked. Adding set -e changes the behavior:

strict.shsh
#!/usr/bin/env bash
set -e
cp /does/not/exist /tmp/dest
echo "Backup complete"
Terminal
./strict.sh
echo $?
output
cp: cannot stat '/does/not/exist': No such file or directory
1

The script stops at the failing cp, never prints “Backup complete”, and exits with a non-zero status. Anyone scheduling this through cron or a CI pipeline now gets a real failure signal.

Opting Out for a Single Command

Sometimes you expect a command to fail and want to handle the result yourself. Append || true to tell set -e to ignore the exit status of that one command:

sh
grep "pattern" config.txt || true

You can also use if or &&, which set -e treats as deliberate checks:

sh
if ! grep -q "pattern" config.txt; then
 echo "pattern missing"
fi

This is the canonical way to check for something without exiting the script when it is not there.

set -u: Fail on Unset Variables

A classic shell footgun is a typo in a variable name. Without strict mode, Bash silently expands the misspelled variable to an empty string:

unset.shsh
#!/usr/bin/env bash
TARGET_DIR="/var/backups"
rm -rf "$TARGE_DIR/old"

The script deletes /old because $TARGE_DIR is empty. With set -u, the same script exits before the rm runs:

unset-strict.shsh
#!/usr/bin/env bash
set -u
TARGET_DIR="/var/backups"
rm -rf "$TARGE_DIR/old"
output
./unset-strict.sh: line 4: TARGE_DIR: unbound variable

Handling Optional Variables

Environment variables that may or may not be set need a default to play nicely with set -u. The ${VAR:-default} syntax provides one without touching the original:

sh
PORT="${PORT:-8080}"
echo "Listening on $PORT"

If $PORT is unset or empty, the script uses 8080. If it is set, the original value is kept.

set -o pipefail: Catch Failures Inside Pipelines

By default, the exit status of a pipeline is the exit status of the last command. Everything to the left can fail silently:

sh
curl https://bad.example.com/data | tee data.txt

If curl fails with a 404, the pipeline still exits 0 because tee wrote its (empty) input successfully. With set -o pipefail, the pipeline adopts the first non-zero exit status from any command in it:

pipefail.shsh
#!/usr/bin/env bash
set -o pipefail
curl https://bad.example.com/data | tee data.txt
echo "Exit: $?"
output
curl: (6) Could not resolve host: bad.example.com
Exit: 6

This is the one flag that fixes the most “my script said it succeeded but clearly did not” bugs, and it is the one most often forgotten when people add just set -e.

IFS: Safer Word Splitting

The Internal Field Separator (IFS) controls how Bash splits unquoted expansions into words. The default is space, tab, and newline, which means a filename with a space in it gets split into two arguments:

sh
files="one two.txt three.txt"
for f in $files; do
 echo "$f"
done
output
one
two.txt
three.txt

Setting IFS=$'\n\t' removes the space from the separator list. You still get clean splitting on newlines (useful for reading find or ls output) and on tabs (useful for TSV data), but spaces inside values are preserved.

Even with a narrower IFS, always quote your expansions ("$var", "${array[@]}"). IFS tightening is a safety net, not a replacement for quoting.

When Strict Mode Gets in the Way

Strict mode is opinionated, and a few situations push back. The most common one is reading a file line by line with a while loop and a counter:

sh
count=0
while read -r line; do
 count=$((count + 1))
done < input.txt

That works fine, but if you increment with ((count++)) inside set -e, the script exits on the first iteration because ((count++)) returns the pre-increment value (0), which Bash treats as a failure. Use count=$((count + 1)) or ((count++)) || true to stay compatible.

Another common case is probing for a command:

sh
if ! command -v jq >/dev/null; then
 echo "jq is not installed"
 exit 1
fi

Using command -v inside an if is safe even under set -e, because set -e does not trigger on commands in conditional contexts.

If you need to temporarily disable a flag for a specific block, turn it off and back on:

sh
set +e
some_flaky_command
result=$?
set -e

This is cleaner than sprinkling || true everywhere when you have a block of commands that need softer error handling.

A Minimal Strict Script Template

Use this as a starting point for new scripts:

template.shsh
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

# Script body goes here.

It is four lines, and it catches the majority of silent failures that cause shell scripts to misbehave in production.

Quick Reference

Flag What it does
set -e Exit when any command returns a non-zero status
set -u Treat unset variables as an error
set -o pipefail A pipeline fails if any command in it fails
IFS=$'\n\t' Word-split only on newline and tab, not on spaces
set +e / set +u Turn the matching flag back off for a block
command || true Ignore the exit status of a single command
${VAR:-default} Provide a default for optional variables

Troubleshooting

Script exits silently with no error message
A command is failing under set -e without printing anything. Run the script with bash -x script.sh to trace execution and see which line aborts.

unbound variable on a variable that sometimes is set
Replace the reference with ${VAR:-} to provide an empty default, or ${VAR:-fallback} to provide a meaningful one.

Pipeline fails on a command that is supposed to stop early
Commands like head can cause the producer to receive SIGPIPE, which pipefail treats as a failure. Either redesign the pipeline or wrap the producer in || true when the early exit is expected.

Strict mode breaks a sourced script
The set flags apply to the current shell. If you source a script that expects the old behavior, either fix the sourced script or wrap the source call between set +e and set -e.

FAQ

Should every Bash script use strict mode?
For scripts that run unattended (cron jobs, CI steps, deployment scripts), yes. For short interactive helpers, the flags are still useful, but the cost of a missed edge case is lower.

Does strict mode work in sh or dash?
set -e and set -u are POSIX, so they work in any compliant shell. set -o pipefail is a Bash extension that also works in Zsh and Ksh, but not in pure POSIX sh or dash.

Is set -e really that unreliable?
set -e has well-known edge cases, especially around functions and subshells. It is not a replacement for explicit error handling in critical paths, but combined with pipefail and -u it catches far more bugs than it causes.

How do I pass set -euo pipefail to a one-liner?
Use bash -euo pipefail -c 'your command' when invoking Bash from another program such as ssh or a Makefile.

Conclusion

set -euo pipefail and a tighter IFS catch many of the silent failures that make shell scripts hard to trust. Pair strict mode with clear error handling using Bash functions and a proper shebang line when you want scripts to fail early and predictably.

uni-app 全能日历组件,支持农历、酒店预订、打卡签到、价格日历多种场景

2026年4月20日 15:15

一、uView Pro 的 Calendar 组件

在 uni-app 开发中,日期选择是一个高频需求场景。无论是酒店预订的入住离店时间选择、电商平台的商品预约、还是日常应用的打卡签到,一个功能完善、体验优秀的日历组件都是必不可少的。

uView Pro 作为 uni-app 生态中备受关注的 Vue3 组件库,其 Calendar 日历组件 经过了多个版本的迭代优化,从最初的基础日期选择,逐步演进为支持农历显示、打卡签到、节假日标记、自定义价格日历等丰富功能的综合型组件。

本文将深入解析 uView Pro Calendar 组件的核心特性、实现原理以及实际应用场景,帮助你快速掌握这个强大的日期选择利器。

二、组件概览:功能特性总览

0.png

uView Pro 的 Calendar 日历组件具有以下核心特性:

基础功能

  • ✅ 支持单日期选择和日期范围选择两种模式
  • ✅ 底部弹窗和页面嵌入两种展示方式
  • ✅ 年月切换导航,支持自定义年份范围
  • ✅ 日期范围限制,防止选择无效日期

进阶功能

  • ✅ 农历显示支持,自动计算农历日期
  • ✅ 打卡签到模式,支持已打卡/未打卡状态展示
  • ✅ 节假日和加班日标记,显示"休"/"班"标识
  • ✅ 内置中国传统节日,支持自定义节日配置
  • ✅ 自定义日期内容插槽,适用于价格日历等场景

交互优化

  • ✅ 默认选中今天,支持指定默认日期
  • ✅ 只读模式,禁止日期选择
  • ✅ 选中效果可配置,适应不同视觉需求

三、基础使用:快速上手

3.1 单日期选择模式

单日期选择是最常用的场景,比如选择生日、预约日期等。

1.png

<template>
    <view>
        <u-calendar v-model="show" mode="date" @change="onChange"></u-calendar>
        <u-button @click="show = true">选择日期</u-button>
    </view>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { CalendarChangeDate } from 'uview-pro/types/global'

const show = ref(false)

function onChange(e: CalendarChangeDate) {
    console.log('选择的日期:', e.result)
    console.log('星期:', e.week)
    console.log('是否今天:', e.isToday)
}
</script>

回调参数说明:

属性 说明 类型
year 选择的年份 number
month 选择的月份 number
day 选择的日期 number
result 格式化的日期字符串,如 "2024-06-15" string
week 星期文字,如 "星期六" string
isToday 是否选择了今天 boolean

3.2 日期范围选择模式

范围选择适用于酒店预订、行程规划等需要起止时间的场景。

2.png

<template>
    <u-calendar 
        v-model="show" 
        mode="range" 
        start-text="入住"
        end-text="离店"
        @change="onRangeChange"
    >
        <template #tooltip>
            <view class="tip">请选择入住和离店时间</view>
        </template>
    </u-calendar>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { CalendarChangeRange } from 'uview-pro/types/global'

const show = ref(false)

function onRangeChange(e: CalendarChangeRange) {
    console.log('入住日期:', e.startDate)
    console.log('离店日期:', e.endDate)
    console.log('共', e.endDay - e.startDay + 1, '晚')
}
</script>

范围模式回调参数:

属性 说明
startDate / endDate 起始/结束日期字符串
startYear / endYear 起始/结束年份
startMonth / endMonth 起始/结束月份
startDay / endDay 起始/结束日期
startWeek / endWeek 起始/结束星期

四、进阶功能详解

4.1 农历显示

Calendar 组件内置了农历计算功能,开启后会自动显示农历日期。

6.png

<u-calendar 
    v-model="show" 
    mode="date" 
    :show-lunar="true"
    @change="onLunarChange"
></u-calendar>

开启农历后,回调参数会增加 lunar 对象:

{
    day: 15,
    month: 6,
    result: "2024-06-15",
    lunar: {
        dayCn: '初十',      // 农历日
        monthCn: '五月',    // 农历月
        year: 2024,         // 农历年
        weekCn: "星期六"    // 农历星期
    }
}

农历显示会自动处理闰月、大小月等复杂逻辑,无需开发者关心底层实现。

4.2 页面嵌入模式

除了弹窗模式,组件还支持直接嵌入页面显示,适用于需要常驻展示日历的场景。

<template>
    <view class="calendar-page">
        <u-calendar 
            :is-page="true" 
            mode="date"
            @change="onChange"
        ></u-calendar>
    </view>
</template>

页面模式的特点:

  • 不显示弹窗和确定按钮
  • 选择日期后自动触发 change 事件
  • 支持所有其他功能(农历、打卡、节假日等)

7.png

4.3 打卡签到模式

打卡签到日历也是近期咨询我比较多的功能,Calendar 组件专门为此设计了打卡模式。

3.png

<template>
    <u-calendar
        :is-page="true"
        :checkin-mode="true"
        :checked-dates="checkedDates"
        :today-checked="todayChecked"
    ></u-calendar>
</template>

<script setup>
import { ref } from 'vue'

// 已打卡日期列表
const checkedDates = ref([
    '2024-01-01', 
    '2024-01-02', 
    '2024-01-03',
    '2024-01-05'
])

// 今日打卡状态(优先级高于自动判断)
const todayChecked = ref(true)
</script>

打卡模式的显示规则:

  1. 今日已打卡:绿色圆形背景,显示白色对勾
  2. 其他已打卡日期:橙色圆形背景,显示日期
  3. 未打卡日期checkin-mode 为 true 时):灰色圆形背景

颜色自定义:

属性 说明 默认值
checked-bg-color 已打卡日期背景色 橙色(warning)
today-checked-bg-color 今日已打卡背景色 绿色(success)
unchecked-bg-color 未打卡日期背景色 灰色(light)

4.4 节假日与加班日标记

组件支持显示节假日和加班日标记,方便用户了解日期属性。

<template>
    <u-calendar
        :is-page="true"
        :holidays="holidays"
        :workdays="workdays"
    ></u-calendar>
</template>

<script setup>
import { ref } from 'vue'

// 节假日(元旦假期)
const holidays = ref(['2024-01-01', '2024-01-02'])

// 加班日(调休上班)
const workdays = ref(['2024-01-06', '2024-01-07'])
</script>

显示效果:

  • 节假日:日期右上角显示红色"休"字
  • 加班日:日期右上角显示蓝色"班"字
  • 选中状态下,"休"/"班"字变为白色

4.png

4.5 节日显示

组件内置了中国传统节日,同时支持自定义节日配置。

内置节日(show-festival 为 true 时自动显示):

  • 元旦(1月1日)
  • 情人节(2月14日)
  • 妇女节(3月8日)
  • 植树节(3月12日)
  • 愚人节(4月1日)
  • 劳动节(5月1日)
  • 青年节(5月4日)
  • 儿童节(6月1日)
  • 建党节(7月1日)
  • 建军节(8月1日)
  • 教师节(9月10日)
  • 国庆节(10月1日)
  • 光棍节(11月11日)
  • 圣诞节(12月25日)

自定义节日:

<template>
    <u-calendar
        :is-page="true"
        :show-festival="true"
        :festivals="customFestivals"
    ></u-calendar>
</template>

<script setup>
import { ref } from 'vue'

const customFestivals = ref({
    // 每年固定节日(MM-DD 格式)
    '04-04': '清明节',
    '05-05': '端午节',
    '08-15': '中秋节',
    
    // 特定年份节日(YYYY-MM-DD 格式)- 优先级更高
    '2025-04-04': '清明节(2025)',
    
    // 覆盖内置节日(传入空字符串不显示)
    '02-14': '',
})
</script>

优先级规则:

  1. 特定年份格式(YYYY-MM-DD)优先级最高
  2. 每年固定格式(MM-DD)次之
  3. 内置节日优先级最低

4.6 自定义日期内容:价格日历

通过 date 插槽,可以完全自定义每个日期的显示内容,常用于电商价格日历场景。

5.png

<template>
    <u-calendar 
        :is-page="true" 
        mode="date"
        :use-date-slot="true"
    >
        <template #date="{ date }">
            <text :class="getPriceClass(date)">
                {{ getPriceText(date) }}
            </text>
        </template>
    </u-calendar>
</template>

<script setup>
import { ref } from 'vue'

// 价格数据
const priceMap = ref({
    '2024-01-01': 299,
    '2024-01-02': 399,
    '2024-01-03': 359,
    // ...
})

function getPriceText(date) {
    if (date.isToday) return '今天'
    const price = priceMap.value[date.date]
    return price ? ${price}` : ''
}

function getPriceClass(date) {
    if (date.isSelected) return 'price-selected'
    if (date.isToday) return 'price-today'
    return 'price-normal'
}
</script>

<style scoped>
.price-today {
    color: #19be6b;
    font-weight: bold;
}
.price-normal {
    color: #909399;
    font-size: 22rpx;
}
.price-selected {
    color: #ffffff;
}
</style>

插槽作用域参数:

属性 说明 类型
date.year 年份 number
date.month 月份 number
date.day 日期 number
date.date 完整日期字符串 string
date.week 星期文字 string
date.isToday 是否今天 boolean
date.isHoliday 是否节假日 boolean
date.isWorkday 是否加班日 boolean
date.isChecked 是否已打卡 boolean
date.isSelected 是否选中 boolean
date.lunar 农历信息 object

五、核心实现原理浅析

5.1 日历渲染逻辑

Calendar 组件的日历渲染基于以下核心算法:

// 获取某月天数
function getMonthDay(year: number, month: number) {
    return new Date(year, month, 0).getDate()
}

// 获取某月第一天星期几(0-6)
function getWeekday(year: number, month: number) {
    let date = new Date(`${year}/${month}/01 00:00:00`)
    return date.getDay()
}

渲染流程:

  1. 计算当月第一天是星期几,生成前置空白格子
  2. 计算当月总天数,生成日期格子
  3. 根据选中状态计算每个格子的样式
  4. 如果有农历,调用农历转换库计算农历日期

5.2 农历计算

组件使用了独立的农历计算工具 Calendar.solar2lunar,将公历日期转换为农历:

function getLunar(year: any, month: any, day: any) {
    const val = Calendar.solar2lunar(year, month, day)
    return {
        dayCn: val.IDayCn,      // 农历日(初十、廿三等)
        monthCn: val.IMonthCn,  // 农历月(正月、五月等)
        weekCn: val.ncWeek,     // 农历星期
        day: val.lDay,          // 农历日数字
        month: val.lMonth,      // 农历月数字
        year: val.lYear         // 农历年
    }
}

5.3 范围选择逻辑

范围选择采用两次点击确定起止时间的交互方式:

function dateClick(dayIdx: number) {
    const d = dayIdx + 1
    const date = `${year.value}-${month.value}-${d}`
    
    if (props.mode == 'range') {
        // 判断是设置开始日期还是结束日期
        const compare = new Date(date).getTime() < new Date(startDate.value).getTime()
        
        if (isStart.value || compare) {
            // 设置开始日期
            startDate.value = date
            isStart.value = false
        } else {
            // 设置结束日期
            endDate.value = date
            isStart.value = true
            // 触发回调
            if (props.isPage) btnFix(true)
        }
    }
}

六、实际应用场景

6.1 酒店预订日历

<u-calendar 
    v-model="show" 
    mode="range"
    start-text="入住"
    end-text="离店"
    :min-date="minDate"
    :max-date="maxDate"
    @change="onDateChange"
>
    <template #tooltip>
        <view class="hotel-tip">
            <text>请选择入住和离店日期</text>
            <text class="sub">入住时间14:00后,离店时间12:00前</text>
        </view>
    </template>
</u-calendar>

6.2 健身打卡应用

<u-calendar
    :is-page="true"
    :checkin-mode="true"
    :checked-dates="monthCheckins"
    :today-checked="todayChecked"
    :show-lunar="true"
    @change="onCheckin"
></u-calendar>

6.3 航班价格日历

<u-calendar 
    :is-page="true"
    mode="date"
    :use-date-slot="true"
    :default-select-today="false"
    :is-active-current="false"
>
    <template #date="{ date }">
        <view class="flight-price">
            <text class="day">{{ date.day }}</text>
            <text class="price" v-if="getPrice(date.date)">
                ¥{{ getPrice(date.date) }}
            </text>
        </view>
    </template>
</u-calendar>

6.4 日程管理应用

<u-calendar
    :is-page="true"
    :show-festival="true"
    :festivals="customFestivals"
    :holidays="holidays"
    :workdays="workdays"
    :default-date="selectedDate"
    @change="onSelectDate"
></u-calendar>

七、API 完整参考

Props 属性

参数 说明 类型 默认值
v-model 控制弹窗显示/隐藏 boolean false
mode 选择模式:date 单选 / range 范围 string date
is-page 是否在页面中直接显示 boolean false
show-lunar 是否显示农历 boolean false
readonly 是否只读 boolean false
default-date 默认选中日期(单选模式) string -
start-date 默认开始日期(范围模式) string -
end-date 默认结束日期(范围模式) string -
default-select-today 默认选中今天 boolean true
min-date 最小可选日期 string 1950-01-01
max-date 最大可选日期 string 今天
min-year 最小可选年份 number/string 1950
max-year 最大可选年份 number/string 2050
change-year 是否显示年份切换按钮 boolean true
change-month 是否显示月份切换按钮 boolean true
active-bg-color 选中日期背景色 string 主题色
active-color 选中日期文字颜色 string 白色
range-bg-color 范围内日期背景色 string 主题色浅
range-color 范围内日期文字颜色 string 主题色
start-text 开始日期提示文字 string 开始
end-text 结束日期提示文字 string 结束
tool-tip 顶部提示文字 string 选择日期
closeable 是否显示关闭图标 boolean true
mask-close-able 点击遮罩是否关闭 boolean true
safe-area-inset-bottom 底部安全区适配 boolean false
border-radius 弹窗圆角 number/string 20
z-index 弹窗层级 number/string 10075
is-active-current 选中日期是否高亮 boolean true
checkin-mode 是否启用打卡模式 boolean false
checked-dates 已打卡日期列表 array []
today-checked 今日是否已打卡 boolean false
checked-bg-color 已打卡背景色 string 橙色
today-checked-bg-color 今日已打卡背景色 string 绿色
unchecked-bg-color 未打卡背景色 string 灰色
holidays 节假日列表 array []
workdays 加班日列表 array []
holiday-color 节假日文字颜色 string 红色
workday-color 加班日文字颜色 string 蓝色
show-festival 是否显示内置节日 boolean false
festivals 自定义节日配置 object {}
festival-color 节日文字颜色 string 主题色
use-date-slot 是否启用日期插槽 boolean false

Events 事件

事件名 说明 回调参数
change 日期选择完成时触发 CalendarChangeDate / CalendarChangeRange

Slots 插槽

名称 说明
tooltip 自定义顶部提示内容
date 自定义日期内容(作用域插槽)

更多功能及用法参考 uView Pro 官方文档 uviewpro.cn

八、总结

uView Pro 的 Calendar 日历组件是一个功能全面、设计精良的日期选择解决方案。从基础的单日期选择到复杂的打卡签到、价格日历,这些都能轻松应对。

使用建议:

  1. 选择合适的展示模式:弹窗模式适合临时选择,页面模式适合常驻展示
  2. 合理利用默认选中:通过 default-datedefault-select-today 提升用户体验
  3. 注意日期格式:所有日期参数统一使用 YYYY-MM-DD 格式
  4. 自定义插槽优先级:使用 date 插槽时会覆盖农历、节日等默认显示
  5. 打卡模式注意today-checked 优先级高于 checkedDates 的自动判断

功能使用建议:

  • 如需农历功能,请确保使用支持该功能的版本
  • 如需打卡签到、节假日、自定义插槽等高级功能,请使用最新版本

如果你正在开发 uni-app 项目,需要一个功能强大、易于定制的日历组件,uView Pro 的 Calendar 值得一试,快来体验一下。

九、资源

  • 📚 uView Pro 官方文档:uviewpro.cn
  • 📦 开源地址:GithubGitee,欢迎 Star
  • 💬 技术交流:如有问题欢迎在评论区留言讨论

本文基于 uView Pro v0.5.17 版本编写,部分功能可能需要更新版本支持。

Vue 3 Composition API 最佳实践:从项目实战中汲取的经验

作者 果然123
2026年4月20日 14:30

Vue 3 Composition API 最佳实践:从项目实战中汲取的经验

前言

随着 Vue 3 的发布,Composition API(组合式 API)已成为现代 Vue 开发的核心特性。它提供了更好的代码组织、可复用性和类型推断支持。然而,在实际项目中,许多开发者仍面临如何高效使用 Composition API 的挑战。本文基于我在市场监督 Vue 项目中的实战经验,分享一些 Composition API 的最佳实践,帮助你避免常见坑点,提升代码质量。

目录

  • Composition API 基础回顾
  • 最佳实践 1:逻辑复用与 composables
  • 最佳实践 2:响应式数据管理
  • 最佳实践 3:生命周期与副作用处理
  • 最佳实践 4:类型安全与 TypeScript 集成
  • 性能优化技巧
  • 总结

Composition API 基础回顾

Composition API 允许我们将组件的逻辑按功能分组,而不是按选项分组。核心函数包括 refreactivecomputedwatch 等。与 Options API 相比,它更灵活,适合大型应用。

// 基础示例
import { ref, computed } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const doubleCount = computed(() => count.value * 2)
    
    return { count, doubleCount }
  }
}

最佳实践 1:逻辑复用与 composables

在项目中,我们经常需要复用逻辑,如数据获取、表单验证。Composition API 通过 composables(组合函数)实现这一点。

实践建议:

  • 将可复用逻辑提取到独立的 composables 文件中。
  • 使用 use 前缀命名,如 useFetchData
  • 避免在 composables 中直接操作 DOM,确保纯逻辑。
// composables/useFetchData.js
import { ref, onMounted } from 'vue'
import axios from 'axios'

export function useFetchData(url) {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const fetchData = async () => {
    loading.value = true
    try {
      const response = await axios.get(url)
      data.value = response.data
    } catch (err) {
      error.value = err
    } finally {
      loading.value = false
    }
  }

  onMounted(fetchData)

  return { data, loading, error, refetch: fetchData }
}

在组件中使用:

// components/DataList.vue
import { useFetchData } from '@/composables/useFetchData'

export default {
  setup() {
    const { data, loading, error } = useFetchData('/api/data')
    return { data, loading, error }
  }
}

经验分享: 在市场监督项目中,我们将 API 调用逻辑抽象为 composables,大大减少了重复代码。记得处理错误边界,避免 composables 耦合过多。

最佳实践 2:响应式数据管理

响应式是 Vue 的核心。Composition API 提供了 refreactive,选择取决于数据结构。

实践建议:

  • 对于基本类型使用 ref,对象使用 reactive
  • 避免深层嵌套响应式对象,使用 shallowRefshallowReactive 优化性能。
  • 使用 toRefs 将 reactive 对象解构为 ref,避免丢失响应性。
import { reactive, toRefs } from 'vue'

export default {
  setup() {
    const state = reactive({
      user: { name: 'John', age: 30 },
      settings: { theme: 'dark' }
    })

    // 正确解构
    return { ...toRefs(state) }
  }
}

坑点提醒: 直接解构 reactive 对象会丢失响应性。项目中曾因忘记 toRefs 导致数据不更新,调试了半天。

最佳实践 3:生命周期与副作用处理

Composition API 使用 onMountedonUnmounted 等钩子管理生命周期。

实践建议:

  • 将副作用逻辑(如定时器、事件监听)封装在 composables 中。
  • 使用 onBeforeUnmount 清理资源,防止内存泄漏。
  • 对于异步操作,使用 watchEffectwatch 监听依赖。
import { ref, onMounted, onUnmounted } from 'vue'

export function useInterval(callback, delay) {
  const intervalId = ref(null)

  onMounted(() => {
    intervalId.value = setInterval(callback, delay)
  })

  onUnmounted(() => {
    if (intervalId.value) {
      clearInterval(intervalId.value)
    }
  })

  return { intervalId }
}

经验分享: 在实时监控页面,我们用这个 composable 管理数据轮询。记得在组件销毁时清理,避免后台运行。

最佳实践 4:类型安全与 TypeScript 集成

TypeScript 与 Composition API 完美配合,提供更好的开发体验。

实践建议:

  • 为 composables 定义接口。
  • 使用 Ref<T>ComputedRef<T> 类型。
  • 利用 Vue 3 的类型推断,减少显式类型声明。
import { ref, computed, type Ref } from 'vue'

interface User {
  id: number
  name: string
}

export function useUser(): { user: Ref<User | null>, isLoggedIn: ComputedRef<boolean> } {
  const user = ref<User | null>(null)
  const isLoggedIn = computed(() => !!user.value)

  return { user, isLoggedIn }
}

经验分享: 项目中引入 TypeScript 后,IDE 提示更准确,减少了运行时错误。建议从核心 composables 开始逐步迁移。

性能优化技巧

  • 使用 shallowRefshallowReactive:对于大型对象,避免深层响应式。
  • 合理使用 computed:缓存计算结果,避免重复计算。
  • 拆分大型组件:将逻辑拆分为多个 composables,减少单个 setup 函数的复杂度。
  • 使用 nextTick:在 DOM 更新后执行逻辑。
import { nextTick } from 'vue'

// 示例:等待 DOM 更新后聚焦输入框
await nextTick()
inputRef.value.focus()

总结

Composition API 让 Vue 开发更现代化,但需要良好的架构思维。通过 composables 复用逻辑、正确管理响应式和生命周期,我们可以写出更可维护的代码。在市场监督项目中,这些实践帮助我们从 2.x 顺利迁移到 3.x,提升了开发效率。

希望这篇文章对你有帮助!如果你有其他 Vue 3 经验,欢迎在评论区分享。代码示例可在 GitHub 上找到完整项目。

Web Components:未来的前端组件化标准?

作者 鱼人
2026年4月20日 14:04

Web Components:未来的前端组件化标准?——基于Custom Elements与Shadow DOM的跨框架复用实践

在2026年的前端技术生态中,Web Components已从实验性技术演变为企业级应用的核心基础设施。GitHub、Salesforce等科技巨头已在其产品中大规模部署Web Components,其跨框架复用能力正在重塑前端开发范式。本文将深入解析Custom Elements与Shadow DOM的技术原理,揭示它们如何让开发者像搭积木一样构建可复用的组件体系。

一、Custom Elements:组件化的原子单位

1.1 自定义标签的标准化实现

Custom Elements作为Web Components的核心规范,通过customElements.define()方法实现HTML词汇表的扩展。开发者可定义如<data-visualization><user-profile>等语义化标签,这些标签需遵循W3C标准:

  • 命名规范:必须包含短横线(如my-component),避免与原生标签冲突
  • 继承机制:支持继承自HTMLElement或特定子类(如HTMLButtonElement
  • 生命周期管理:提供connectedCallbackattributeChangedCallback等钩子函数
javascript
class DataVisualization extends HTMLElement {
  static get observedAttributes() { return ['type', 'data']; }
  
  connectedCallback() {
    this.render();
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'data') this.updateChart(newValue);
  }
  
  render() {
    this.innerHTML = `<canvas id="chart"></canvas>`;
    // 初始化Canvas渲染逻辑
  }
}
customElements.define('data-visualization', DataVisualization);

1.2 跨框架复用的技术突破

现代前端框架已全面支持Web Components的互操作:

  • React 18+ :通过React.createElement直接渲染自定义元素,解决属性传递的驼峰命名转换问题
  • Vue 3:在<template>中直接使用自定义标签,支持v-model双向绑定
  • Angular:通过CUSTOM_ELEMENTS_SCHEMA配置实现无缝集成

Salesforce的Lightning Web Components框架已验证:同一套Web Components可在React、Vue、Angular应用中保持行为一致性,组件复用率提升60%以上。

二、Shadow DOM:组件化的隔离屏障

2.1 样式与结构的物理隔离

Shadow DOM通过创建独立的DOM子树实现真正的封装:

  • 样式隔离:内部<style>标签仅作用于影子树,外部CSS无法穿透
  • DOM隔离document.querySelector无法访问影子节点,避免命名冲突
  • 事件封装:事件冒泡在影子边界处停止,防止意外捕获
javascript
class UserProfile extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        .avatar { border-radius: 50%; }
        ::slotted(h2) { color: var(--primary-color); }
      </style>
      <div class="profile">
        <img class="avatar" src="${this.getAttribute('avatar')}">
        <slot name="username"><h2>Default User</h2></slot>
      </div>
    `;
  }
}

2.2 有限穿透的样式控制

通过特殊伪类实现必要的样式交互:

  • :host:选择宿主元素,允许外部通过CSS变量控制组件外观
  • ::slotted():选择通过<slot>插入的内容,保持外部样式控制权
  • :host-context():根据宿主祖先元素的样式调整组件表现

Adobe的Spectrum设计系统利用Shadow DOM的样式隔离特性,确保其Web Components组件在不同应用中保持视觉一致性,同时允许开发者通过CSS变量自定义主题。

三、积木式组件开发实践

3.1 组件组合模式

Web Components支持三种层次的组合:

  1. 内容投影:通过<slot>实现动态内容插入
  2. 嵌套组件:自定义元素内部使用其他自定义元素
  3. 模板复用:利用<template>标签定义可复用的DOM结构
html
<!-- 父组件 -->
<user-card avatar="/avatar.jpg">
  <h2 slot="username">John Doe</h2>
  <data-visualization slot="stats" type="bar" data="[1,2,3]"></data-visualization>
</user-card>

<!-- user-card组件定义 -->
<template id="user-card-template">
  <div class="card">
    <slot name="avatar"></slot>
    <div class="content">
      <slot name="username"></slot>
      <slot name="stats"></slot>
    </div>
  </div>
</template>

3.2 性能优化策略

针对大数据可视化场景,Web Components提供独特优势:

  • 增量渲染:通过requestAnimationFrame分批更新Canvas元素
  • 硬件加速:Chrome 128+支持Web Components的GPU加速渲染
  • 按需加载:结合ES Modules实现组件的动态导入

某金融交易平台采用Web Components构建实时数据看板,通过分层渲染技术将8000+元素拆分为5个逻辑层,在Canvas上实现52fps的稳定帧率,内存占用降低40%。

四、未来展望:标准化与生态演进

4.1 标准演进方向

W3C工作组正在推进以下关键提案:

  • Custom Elements v2:增强生命周期管理,支持异步初始化
  • CSS Shadow Parts:提供更精细的样式穿透控制
  • Scoped Custom Element Registries:解决大型应用中的命名冲突问题

4.2 生态系统整合

Web Components正成为微前端架构的首选技术:

  • 框架无关性:不同技术栈的微应用可无缝集成
  • 样式隔离:Shadow DOM天然支持CSS边界
  • 版本控制:独立部署与版本管理

Material Design 3.0已完全基于Web Components重构,其组件库可在React、Vue、Angular应用中保持行为一致性,安装体积减少65%。

结语:组件化的终极形态

Web Components通过Custom Elements的标准化扩展能力和Shadow DOM的物理隔离机制,正在构建前端开发的"乐高体系"。开发者可像搭积木一样组合组件,无需担心样式冲突或框架锁定问题。随着浏览器原生支持的完善和生态工具的成熟,Web Components有望在3-5年内成为Web开发的默认组件标准,彻底改变前端工程化实践。对于数据可视化等复杂场景,这种技术范式提供的性能优势和开发效率提升,正在重新定义大规模Web应用的构建方式。

移动端适配必杀技:Viewport与响应式布局全解

作者 二月龙
2026年4月20日 14:03

移动端适配必杀技:Viewport与响应式布局全解

引言:移动优先时代的适配挑战

当移动设备流量占比超过PC端时,"如何让网页在手机上完美显示"已成为前端开发的核心命题。从iPhone初代320px宽度到如今各种折叠屏设备,屏幕尺寸的碎片化让传统固定布局彻底失效。本文将深入解析viewport原理,结合CSS媒体查询,提供一套完整的移动端适配解决方案。


一、Viewport:移动端视口的终极控制

1.1 移动浏览器的双重视口困境

移动浏览器存在两个关键视口:

  • 布局视口(Layout Viewport) :浏览器默认的渲染区域(通常980px宽)
  • 视觉视口(Visual Viewport) :用户实际看到的区域(随缩放变化)

问题根源:早期移动浏览器通过虚拟布局视口解决PC网页显示问题,但导致文字过小需要手动缩放,形成"能显示但难用"的尴尬局面。

1.2 <meta name="viewport"> 魔法标签

html
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

核心属性解析

属性 作用
width=device-width 将布局视口宽度设为设备宽度(CSS像素)
initial-scale=1.0 初始缩放比例(1:1显示)
maximum-scale=1.0 禁止用户缩放(需谨慎使用)
user-scalable=no 禁用缩放功能(影响无障碍访问,建议仅在特殊场景使用)
viewport-fit=cover 适配iPhone X等异形屏(配合CSS的safe-area-inset-*使用)

1.3 物理像素 vs CSS像素 vs 设备像素比

  • 物理像素(PP) :设备最小发光单元(如iPhone 12的2532×1170)
  • CSS像素(DP) :Web开发使用的逻辑像素
  • 设备像素比(DPR)DPR = 物理像素 / CSS像素(iPhone 12的DPR=3)

计算示例

javascript
// 获取设备像素比
const dpr = window.devicePixelRatio || 1;
console.log(`当前设备DPR: ${dpr}`); // iPhone 12输出3

二、响应式布局实战:从媒体查询到现代方案

2.1 CSS媒体查询基础语法

css
/* 基础语法 */
@media (max-width: 768px) {
  .container {
    width: 100%;
    padding: 0 15px;
  }
}

/* 范围查询 */
@media (min-width: 768px) and (max-width: 1024px) {
  .sidebar {
    display: none;
  }
}

/* 方向检测 */
@media (orientation: portrait) {
  .header {
    height: 60px;
  }
}

2.2 移动端常用断点设计

设备类型 宽度范围 典型设备
超小屏幕 < 576px iPhone SE/5/4
小屏幕 576px - 768px iPhone 12 Pro
中等屏幕 768px - 992px iPad Mini
大屏幕 992px - 1200px iPad/iPad Air
超大屏幕 > 1200px iPad Pro/桌面显示器

实战案例

css
/* 移动端优先的响应式设计 */
.product-card {
  width: 100%;
  margin-bottom: 20px;
}

@media (min-width: 768px) {
  .product-card {
    width: 48%;
    margin-right: 2%;
  }
}

@media (min-width: 1024px) {
  .product-card {
    width: 23.5%;
    margin-right: 2%;
  }
}

2.3 现代响应式方案对比

方案 原理 适用场景
媒体查询 根据视口宽度应用不同CSS 传统响应式布局
Flexbox 弹性盒子布局 一维布局(行/列)
CSS Grid 网格布局 二维复杂布局
Relative Units 使用vw/vh/rem等相对单位 需要动态缩放的组件
Container Queries 根据容器尺寸而非视口布局 组件级响应式(实验性特性)

三、移动端适配常见问题解决方案

3.1 1px边框问题

问题:在高DPR设备上,CSS的1px会显示为物理多个像素,导致边框变粗

解决方案

css
/* 方案1:使用transform缩放 */
.border-1px {
  position: relative;
}
.border-1px::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 1px;
  background: #ddd;
  transform: scaleY(0.5);
}

/* 方案2:使用border-image(复杂边框不适用) */
.border-1px {
  border: 1px solid transparent;
  border-image: linear-gradient(#ddd, #ddd) 1 stretch;
}

/* 方案3:使用box-shadow(简单边框) */
.border-1px {
  box-shadow: 0 0 0 1px #ddd inset;
}

3.2 点击延迟问题

问题:移动端浏览器300ms延迟等待双击缩放

解决方案

html
<!-- 方案1:添加viewport meta标签(推荐) -->
<meta name="viewport" content="width=device-width, initial-scale=1">

<!-- 方案2:使用fastclick.js库 -->
<script src="https://cdn.jsdelivr.net/npm/fastclick@1.0.6/lib/fastclick.min.js"></script>
<script>
  if ('addEventListener' in document) {
    document.addEventListener('DOMContentLoaded', function() {
      FastClick.attach(document.body);
    }, false);
  }
</script>

3.3 图片适配方案

html
<!-- 方案1:响应式图片(srcset+sizes) -->
<img src="small.jpg"
     srcset="medium.jpg 1000w, large.jpg 2000w"
     sizes="(max-width: 600px) 480px, 800px"
     alt="响应式图片">

<!-- 方案2:使用picture元素 -->
<picture>
  <source media="(min-width: 1200px)" srcset="large.jpg">
  <source media="(min-width: 768px)" srcset="medium.jpg">
  <img src="small.jpg" alt="自适应图片">
</picture>

<!-- 方案3:CSS背景图适配 -->
<div class="hero-image"></div>

<style>
.hero-image {
  background-image: url('mobile-bg.jpg');
  background-size: cover;
}

@media (min-width: 768px) {
  .hero-image {
    background-image: url('desktop-bg.jpg');
  }
}
</style>

3.4 异形屏适配(iPhone X等)

css
/* 使用safe-area-inset-*属性 */
.header {
  padding-top: constant(safe-area-inset-top); /* iOS < 11.2 */
  padding-top: env(safe-area-inset-top); /* iOS >= 11.2 */
  padding-bottom: constant(safe-area-inset-bottom);
  padding-bottom: env(safe-area-inset-bottom);
}

/* 完整适配方案 */
<meta name="viewport" content="width=device-width, viewport-fit=cover">

<style>
body {
  padding: 0;
  margin: 0;
}

.container {
  min-height: 100vh;
  padding: env(safe-area-inset-top) env(safe-area-inset-right) 
          env(safe-area-inset-bottom) env(safe-area-inset-left);
}
</style>

四、性能优化与调试技巧

4.1 移动端性能关键指标

  • 首屏加载时间:< 1秒(3G网络下)
  • 可交互时间:< 3秒
  • 资源大小:首屏资源 < 500KB

4.2 调试工具推荐

  1. Chrome DevTools

    • 设备模式(Ctrl+Shift+M)
    • 网络节流模拟
    • CPU节流模拟
  2. Safari Web Inspector

    • iOS设备调试必备
    • 实时查看Web内容进程
  3. Eruda

    html
    <!-- 移动端调试控制台 -->
    <script src="https://cdn.jsdelivr.net/npm/eruda"></script>
    <script>eruda.init();</script>
    

4.3 常见性能陷阱

javascript
// 避免在滚动事件中执行重计算操作
window.addEventListener('scroll', function() {
  // 错误示范:每次滚动都触发重排
  // document.getElementById('box').style.height = window.innerHeight + 'px';
  
  // 正确做法:使用节流或防抖
  throttle(() => {
    // 实际处理逻辑
  }, 200);
});

function throttle(fn, delay) {
  let lastCall = 0;
  return function(...args) {
    const now = new Date().getTime();
    if(now - lastCall < delay) return;
    lastCall = now;
    return fn.apply(this, args);
  }
}

结语:构建移动优先的未来

移动端适配已从"可选功能"变为"基础要求"。通过掌握viewport原理和响应式布局技术,你能确保网页在各种设备上提供一致的用户体验。记住这些核心原则:

  1. 移动优先:先设计移动端,再逐步增强桌面体验
  2. 渐进增强:确保基础功能在所有设备可用
  3. 性能至上:移动网络环境要求更严格的资源控制

Next.js第十七课 - 部署

2026年4月20日 13:54

前面我们学习了很多 Next.js 的功能和特性,本节来聊聊部署。把应用部署到生产环境是开发流程的最后一步,也是让用户能够访问你应用的关键步骤。

1776664446927.png

部署概述

Next.js 可以部署到多个平台:

  1. Vercel - 官方平台,零配置
  2. 自托管 - Node.js 服务器
  3. Docker - 容器化部署
  4. 静态导出 - 纯静态托管
  5. 其他平台 - Netlify、Railway、AWS 等

Vercel 是 Next.js 的官方平台,提供最佳的性能和体验。

准备部署

构建应用

在部署前,先在本地测试构建:

# 安装依赖
npm install

# 构建生产版本
npm run build

# 测试生产版本
npm start

环境变量

创建 .env.production

# 数据库
DATABASE_URL=postgresql://...

# API 密钥
API_KEY=your_production_api_key
API_SECRET=your_production_secret

# 应用配置
NEXT_PUBLIC_APP_URL=https://yourdomain.com

检查清单

部署前检查:

  • 所有环境变量都已配置
  • 构建成功无错误
  • 生产环境下测试正常
  • 数据库迁移已完成
  • 静态资源(图片、字体)已优化

部署到 Vercel

安装 Vercel CLI

npm install -g vercel

登录 Vercel

vercel login

部署项目

vercel

按照提示操作,Vercel 会自动:

  1. 检测项目类型(Next.js)
  2. 构建项目
  3. 部署到边缘网络
  4. 提供一个 HTTPS URL

配置环境变量

在 Vercel 控制台中配置环境变量:

  1. 进入项目设置
  2. 找到 Environment Variables
  3. 添加所有环境变量
  4. 重新部署项目

自定义域名

在 Vercel 控制台中添加自定义域名:

  1. 进入项目设置
  2. 找到 Domains
  3. 添加你的域名
  4. 按照提示配置 DNS

部署到其他平台

部署到 Netlify

创建 netlify.toml

[build]
  command = "npm run build"
  publish = ".next"

[[plugins]]
  package = "@netlify/plugin-nextjs"

部署到 Railway

Railway 支持 Docker 和直接部署,推荐使用 Docker:

# Dockerfile
FROM node:20-alpine AS base

# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD ["node", "server.js"]

部署到 Docker

构建 Docker 镜像:

docker build -t my-nextjs-app .

运行容器:

docker run -p 3000:3000 my-nextjs-app

自托管

如果你有自己的服务器,可以自托管 Next.js 应用。

使用 standalone 模式

next.config.js 中启用 standalone 模式:

module.exports = {
  output: 'standalone',
}

构建后,会生成一个独立的服务器:

npm run build

启动服务器:

node .next/standalone/server.js

使用 PM2 管理进程

安装 PM2:

npm install -g pm2

启动应用:

pm2 start .next/standalone/server.js --name my-nextjs-app

静态导出

如果你的应用完全是静态的,可以导出为静态 HTML。

配置静态导出

next.config.js 中配置:

module.exports = {
  output: 'export',
  images: {
    unoptimized: true,
  },
}

构建静态站点

npm run build

构建完成后,out 目录包含所有静态文件,可以部署到任何静态托管服务。

部署到静态托管

部署到 Nginx、Apache、GitHub Pages、Netlify 等静态托管服务。

性能优化

启用压缩

next.config.js 中启用压缩:

module.exports = {
  compress: true,
}

配置 CDN

Vercel 默认提供 CDN,其他平台需要手动配置。

监控性能

使用 Vercel Analytics 或其他监控工具:

// app/layout.tsx
import { Analytics } from '@vercel/analytics/react'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

CI/CD

GitHub Actions

创建 .github/workflows/deploy.yml

name: Deploy to Vercel

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.ORG_ID }}
          vercel-project-id: ${{ secrets.PROJECT_ID }}
          vercel-args: '--prod'

实用建议

这里分享几个在部署应用时特别有用的技巧。

使用环境变量

实际开发中,使用环境变量管理敏感信息是最基本也最重要的实践:

# 推荐这样做 - 使用环境变量
DATABASE_URL=postgresql://...
API_KEY=secret_key

# 避免这种情况 - 硬编码敏感信息非常危险

定期备份

这里有个小建议:定期备份数据库和重要文件,这个习惯能在关键时刻救你一命。

监控应用

这个技巧特别有用——使用日志和监控工具跟踪应用状态,能帮你快速发现和解决问题。

安全配置

最后分享几个安全方面的建议:

  • 始终使用 HTTPS
  • 正确配置 CORS
  • 设置 CSP 头增强安全性
  • 定期更新依赖包修复漏洞

总结

本节我们学习了 Next.js 应用的部署方法,包括 Vercel、自托管、Docker、静态导出等。选择合适的部署方式取决于你的需求和预算。Vercel 是最简单的选择,自托管提供更多控制权。

到这里,我们的 Next.js 教程就结束了。如果你对任何内容有疑问,欢迎在评论区提出来,我们一起学习讨论。祝你在 Next.js 的学习之路上越走越远!

原文地址: blog.uuhb.cn/archives/ne…

后台管理项目中关于新增、编辑弹框使用的另一种展示形式

作者 只会写Bug
2026年4月20日 13:45

目前大家项目中使用的弹框是以什么形式展现的呢?不知还记不记得以前在使用layui时,使用的layer.open中的iframe形式的弹框。本文编写的就是复刻这一形式的弹框类型,感兴趣的话可以接着往下看哦。

业务背景:目前公司写的大多数页面是这种弹框的类型(都是基于一个老项目的vue2.0版本的模板开发,还有引入jQuery),弹框是基于layer.open二次封装实现的,所以后面我就自己仿照写了一个简易版本直接引入的,去掉不必要的依赖。

废话不多说直接上效果图! 9c830be5-c7a1-4c6d-918f-02e1e0565e81.png 可以全屏及拖动。目前我感觉比较麻烦的是需要维护更多的路由

代码展示: 在main.js中全局引入 31b8f2b4-dd0c-4f2b-b24d-78891be2d3c7.png页面使用: 列表页面中的新增及编辑按钮

// 新增及编辑按钮
const handleAdd = (row) => {
  openDialog(
    {
      title: row? "编辑" : "新增",
      path: "/#/testPageadd",
      width: "800px",
      height: "600px",
      fullscreen: true,
      drag: true,
      close: true,
    },
    (res) => {
      console.log(res, "resres");
    },
  );
};

新增testPageadd页面中回调事件:

//取消
const handleClose = (res) => {
  closeDialog();
};
//确定
const handleSubmit = async () => {
  closeDialog({ valid: true });
};

openDialog完整代码:

!(function (W) {
  ("use strict");
  const keyframes = `.zDialog{display: inline-block;box-sizing: border-box;border-radius: 6px;} 
    @keyframes zcentre_in {
    0% {
        opacity: 0;
         transform: scale(0);
        -webkit-transform: scale(0);
        -moz-transform: scale(0);
        -ms-transform: scale(0);
        -o-transform: scale(0);
    }100% {
        opacity: 1;
        transform: scale(1);
        -webkit-transform: scale(1);
        -moz-transform: scale(1);
        -ms-transform: scale(1);
        -o-transform: scale(1);
        }
    }
    @keyframes zcentre_out {
    0% {
        opacity: 1;
         transform: scale(1);
        -webkit-transform: scale(1);
        -moz-transform: scale(1);
        -ms-transform: scale(1);
        -o-transform: scale(1);
    }100% {
        opacity: 0;
        transform: scale(0);
        -webkit-transform: scale(0);
        -moz-transform: scale(0);
        -ms-transform: scale(0);
        -o-transform: scale(0);
        display:none
        }
    }`;
  // 创建style标签
  const stylekeyframes = document.createElement("style");
  // 设置style属性
  stylekeyframes.type = "text/css";
  // 将 keyframes样式写入style内
  stylekeyframes.innerHTML = keyframes;
  // 将style样式存放到head标签
  document.head.appendChild(stylekeyframes);
  // 样式合集
  let style = {
    Dialog: `position: fixed;top: 0;left: 0;height: 100vh;width: 100vw;overflow: hidden;`, // 主体样式
    Dialog2: `position: absolute;overflow: hidden;`, // 主体样式2
    Dialog3: `top: 50%;transform: translateY(-50%);`, // 主体样式3
    titleStyle: `justify-content: space-between;`,
    titleText: `padding: 10px 20px;width:0;flex:1;font-size: 16px;box-sizing: border-box;`,
    titleClose: `margin: 10px 20px;text-align: right;cursor: pointer;`,
    full: `margin: 10px 0;text-align: right;cursor: pointer;`,
    shade: `position: fixed;top: 0;bottom: 0;left: 0px;right: 0;`,
    iframe: `width: 100%;border: none; `,
  };
  //缓存常用字符
  var doms = [
    "zDialog",
    "zDialog-title",
    "zDialog-iframe",
    "zDialog-content",
    "zDialog-btn",
    "zDialog-close",
    "zDialog-iframe-box",
  ];
  // 弹框框数组
  let openArray = [];
  // 默认方法。
  let zDialog = {
    index: window.zDialog && window.zDialog.v ? 100000 : 0,
    open: "",
  };
  class openClass {
    constructor(setings, callback) {
      this.index = ++zDialog.index;
      this.dialogId = doms[0] + this.index;
      let csetings = JSON.parse(JSON.stringify(setings));
      this.setingsTop = setings.top;
      this.config = {
        v: "1.0.0",
        zIndex: 19961025,
        index: 0,
        closeShow: true, // 是否显示关闭
        needShade: true, // 遮罩
        shadoClick: false, // 遮罩关闭
        shadoColor: "rgba(0, 0, 0, .5)", // 遮罩颜色
        animationTime: 300, // 动画时间
        dtitleshow: true, // 弹框标题显示隐藏
        drag: false, // 拖拽
        fullscreen: false, // 全屏
        isFullscreen: false, // 是否全屏
        time: null, // 动画时间
        top: "100px", // 离顶高度
        left: "100px", // 离左宽度
        width: "800px", // 宽
        height: "600px", // 高
        close: false, // 关闭执行(点击右上角关闭也执行回调)
      };
      if (csetings.top && typeof csetings.top == "number") {
        csetings.top = csetings.top + "px";
      }
      if (csetings.width && typeof csetings.width == "number") {
        csetings.width = csetings.width + "px";
      }
      if (csetings.height && typeof csetings.height == "number") {
        csetings.height = csetings.height + "px";
      }
      this.config = { ...this.config, ...csetings };
      this.callback = callback;
      document.body
        ? this.creat()
        : setTimeout(function () {
            this.createanimation();
            this.creat();
          }, 30);
    }
    creat() {
      if (!this.config.path) {
        alert("请填写路径参数(path)");
        return;
      }
      // 判断黑白
      let scheme = localStorage.getItem("vueuse-color-scheme");
      const dark = "dark";
      let dialogBg = scheme == dark ? "rgba(41,34.2,24,0)" : "#fff"; //弹框背景
      let titleBg = scheme == dark ? "rgb(33.2, 61.4, 90.5)" : "#eee"; //标题背景
      let closeBg = scheme == dark ? "#fff" : "#000"; //标题背景

      // 添加动画样式 js创建@keyframes
      const closeSvg = `<svg t="1703816731858" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6721" width="14" height="14"><path d="M927.435322 1006.57265l-415.903813-415.903814L95.627695 1006.57265a56.013982 56.013982 0 1 1-79.20377-79.231777l415.903814-415.875807L16.423925 95.58926A56.013982 56.013982 0 0 1 95.627695 16.357483l415.903814 415.903813L927.435322 16.357483a55.985975 55.985975 0 1 1 79.175763 79.231777L590.763286 511.465066l415.847799 415.875807a55.985975 55.985975 0 1 1-79.175763 79.231777z" fill="${closeBg}" p-id="6722"></path></svg>`;
      const fullScreenSvg = `<svg t="1703816632687" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5441" width="16" height="16"><path d="M704 1024v-128h192v-192h128v320h-320z m192-808.064L215.936 896H448v128H0V576h128v232.064L808.064 128H576V0h448v448h-128V215.936zM128 320H0V0h320v128H128v192z" fill="${closeBg}" p-id="5442"></path></svg>`;

      const titleImgBgc =
        scheme == dark ? "" : `background: rgba(95, 119, 255, 0.51);`;

      this.config.time = this.config.animationTime / 1000;
      // 创建主体盒子
      let zDialogBox = `
            <div class="${doms[0]} ${doms[0] + this.index}" style='${
              style.Dialog2
            }z-index:${this.config.zIndex + this.index};animation: zcentre_in ${
              this.config.time
            }s;width: ${this.config.width};height: ${
              this.config.height
            };background: ${dialogBg}'>
                <div class='${doms[1] + this.index}' style='${
                  style.titleStyle
                }${titleImgBgc}display:${this.config.dtitleshow ? "flex" : "none"};user-select: none;'>
                    <div class='title${this.index}' style='${
                      style.titleText
                    };cursor: ${this.config.drag ? "move" : "initial"}'>${
                      this.config.title || "标题"
                    }</div>
                    <div class='fullScreen${this.index}' style='${
                      style.full
                    };display:${this.config.fullscreen ? "block" : "none"};user-select: none;'>${fullScreenSvg}</div>
                    <div class='close${this.index}' style='${
                      style.titleClose
                    };display:${this.config.closeShow ? "block" : "none"};user-select: none;'>${closeSvg}</div>
                </div>
                <div class="${
                  doms[6] + this.index
                }" style='background: var(--container-box-bg-color)'><iframe class="${
                  doms[2] + this.index
                }" style="${style.iframe}" src='${this.config.path}'></iframe></div>
            </div>`;
      openArray.push({ index: this.index, dialog: this });
      let div = document.createElement("div");
      div.id = doms[0] + this.index;
      this.config.needShade &&
        (div.style =
          style.Dialog + `z-index:` + (this.config.zIndex + this.index));
      !this.config.needShade && (div.style.position = "fixed");
      !this.config.needShade && (div.style.top = "0px");
      div.innerHTML = zDialogBox;
      if (W.parent) {
        W.parent.document.body.appendChild(div);
      } else {
        document.body.appendChild(div);
      }
      // 拖动
      let querySelector = div.querySelector(".title" + this.index);
      querySelector.onmousedown = (event) => {
        this.config.drag && this.move(event);
      };
      //关闭
      let querySelector2 = div.querySelector(".close" + this.index);
      querySelector2.onclick = () => {
        this.cancel();
      };
      // 全屏
      let querySelector3 = div.querySelector(".fullScreen" + this.index);
      querySelector3.onclick = () => {
        this.config.isFullscreen = !this.config.isFullscreen;
        this.isFullscreen();
      };

      // 执行计算宽度的方法
      this.calculatedHeight();
      W.addEventListener("resize", () => {
        this.calculatedHeight("resize");
      });
      this.config.needShade && this.shadeo();
    }
    // 添加遮罩
    shadeo() {
      setTimeout(() => {
        // 添加背景板,根据needShade判断是否显示蒙版 true/false
        let divs = document.getElementById(doms[0] + this.index);
        let div = [divs][0];
        let shade = document.createElement("div");
        shade.id = doms[2] + this.index;
        let shadeStyle =
          style.shade + "z-index:" + (this.config.zIndex + this.index - 1);
        shade.style = shadeStyle;
        shade.style.background = this.config.shadoColor;
        shade.onclick = () => {
          this.config.shadoClick && zDialog.close();
        };
        div.appendChild(shade);
      }, this.config.time - 100);
    }
    cancel() {
      if (this.config.close) {
        zDialog.close({ close: true });
      } else {
        zDialog.close();
      }
    }
    // 回调函数
    callback() {
      config.close && config.close();
    }
    // 拖动
    move(event) {
      let div = document.getElementsByClassName(doms[0] + this.index);
      let moveElement = div[0];
      let windowHeight = W.innerHeight;
      let windowWidth = W.innerWidth;
      document.onmousemove = function (ent) {
        let evt = ent || window.event;
        // 获取鼠标移动的坐标位置
        let ele_top = evt.clientY - event.offsetY;
        let ele_left = evt.clientX - event.offsetX;
        // 将移动的新的坐标位置进行赋值
        // 限制拖动范围
        if (ele_top < 0) {
          ele_top = 0;
        }
        if (ele_left < 0) {
          ele_left = 0;
        }
        // 右边和右下也限制拖动范围
        if (ele_top > windowHeight - moveElement.clientHeight) {
          ele_top = windowHeight - moveElement.clientHeight;
        }
        if (ele_left > windowWidth - moveElement.clientWidth) {
          ele_left = windowWidth - moveElement.clientWidth;
        }

        moveElement.style.top = ele_top + "px";
        moveElement.style.left = ele_left + "px";
      };
      document.onmouseup = function (ent) {
        document.onmousemove = function () {
          return false;
        };
      };
    }

    // 全屏
    isFullscreen() {
      let div = document.getElementsByClassName(doms[0] + this.index);
      let windowHeight = W.innerHeight;
      let windowWidth = W.innerWidth;
      if (this.config.isFullscreen) {
        div[0].style.width = "100%";
        div[0].style.height = "100%";
        div[0].style.top = "0px";
        div[0].style.left = "0px";
      } else {
        div[0].style.width = this.config.width;
        div[0].style.height = this.config.height;
        div[0].style.left = this.config.left;
        div[0].style.top = this.config.top;
      }
    }

    // 计算整个宽度,左右居中
    calculatedHeight(type) {
      let windowHeight = W.innerHeight;
      let windowWidth = W.innerWidth;
      let div = document.getElementsByClassName(doms[0] + this.index);
      if (div && div[0]) {
        let left = (windowWidth - div[0].clientWidth) / 2;
        div[0].style.left = left + "px";
        this.config.left = left + "px";
        // 计算iframe 的高度
        let title = document.getElementsByClassName(doms[1] + this.index);
        let t_h = title[0].clientHeight;
        let all = div[0].clientHeight;
        let iframeh2 = document.getElementsByClassName(doms[6] + this.index);
        let iframeh = document.getElementsByClassName(doms[2] + this.index);
        // 距离顶部
        if (!this.setingsTop) {
          let top = (windowHeight - div[0].clientHeight) / 2;
          div[0].style.top = top + "px";
          this.config.top = top + "px";
        } else {
          div[0].style.top = this.config.top;
        }
        if (!type) {
          iframeh[0].style.opacity = 0;
        }
        if (iframeh[0].attachEvent) {
          // IE 浏览器使用 attachEvent 方法
          iframeh[0].attachEvent("onload", function () {
            iframeh[0].style.opacity = 1;
          });
        } else {
          // 非 IE 浏览器使用 onload 事件
          iframeh[0].onload = function () {
            iframeh[0].style.opacity = 1;
          };
        }
        iframeh2[0].style.height = all - t_h + "px";
        iframeh[0].style.height = all - t_h + "px";
      }
    }
  }
  // 关闭当前
  zDialog.close = function (rcode) {
    let aindex = openArray.pop();
    let nindex = aindex.index;
    if (rcode && aindex.dialog.callback) {
      rcode && aindex.dialog.callback(rcode);
    }
    try {
      W.removeEventListener("resize", () => {});
    } catch (error) {}
    close(nindex);
  };
  // 关闭全部弹框
  zDialog.closeAll = function (callback) {
    openArray.forEach((ele) => {
      close(ele.index);
    });
    openArray = [];
    callback && callback();
  };
  // 根据索引关闭弹框
  close = function (index) {
    let div = document.getElementsByClassName(doms[0] + index);
    if (div && div[0]) {
      div[0].style.animation = "zcentre_out 0.3s";
    }
    setTimeout(() => {
      if (W.parent) {
        let re = W.parent.document.getElementById(doms[0] + index);
        W.parent.document.body.removeChild(re);
      } else {
        let re = document.getElementById(doms[0] + index);
        document.body.removeChild(re);
      }
    }, 200);
  };
  zDialog.open = function (deliver, callback) {
    let z = new openClass(deliver, callback);
    return z.index;
  };
  // 多层嵌套 只在父级添加
  if (W.parent.zDialog) {
    zDialog = W.parent.zDialog;
    zDialog.open = W.parent.zDialog.open;
    zDialog.close = W.parent.zDialog.close;
  }
  //暴露模块
  W.zDialog = zDialog;
  W.openDialog = zDialog.open;
  W.closeDialog = zDialog.close;
})(window);

至此结束!!!谢谢观看!!!

简单Canvas指纹示例

作者 lion10
2026年4月20日 13:45

`

简单Canvas指纹示例

简单Canvas指纹示例

请打开控制台(F12)查看结果

<script>
    // 创建一个简单的Canvas指纹生成函数
    function generateCanvasFingerprint() {
        // 创建canvas元素
        const canvas = document.createElement('canvas');
        canvas.width = 200;
        canvas.height = 100;
        
        // 获取绘图上下文
        const ctx = canvas.getContext('2d');
        
        // 填充背景
        ctx.fillStyle = 'white';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        
        // 绘制一些图形和文字
        // 绘制红色矩形
        ctx.fillStyle = 'red';
        ctx.fillRect(20, 20, 50, 50);
        
        // 绘制蓝色圆形
        ctx.fillStyle = 'blue';
        ctx.beginPath();
        ctx.arc(120, 45, 25, 0, Math.PI * 2);
        ctx.fill();
        
        // 绘制文本
        ctx.fillStyle = 'black';
        ctx.font = '16px Arial';
        ctx.fillText('Canvas指纹', 60, 80);
        
        // 获取canvas数据URL
        const dataURL = canvas.toDataURL();
        
        // 简单哈希函数
        function simpleHash(str) {
            let hash = 0;
            for (let i = 0; i < str.length; i++) {
                const char = str.charCodeAt(i);
                hash = ((hash << 5) - hash) + char;
                hash = hash & hash; // 转换为32位整数
            }
            return hash.toString(16); // 转换为16进制
        }
        
        // 计算指纹
        const fingerprint = simpleHash(dataURL);
        
        return {
            fingerprint: fingerprint,
            dataURL: dataURL
        };
    }
    
    // 生成并输出指纹
    const result = generateCanvasFingerprint();
    console.log('Canvas指纹:', result.fingerprint);
    console.log('Canvas数据URL前100个字符:', result.dataURL.substring(0, 100) + '...');
    
    // 如果浏览器支持更安全的哈希算法,也可以使用它
    if (window.crypto && window.crypto.subtle) {
        const encoder = new TextEncoder();
        const data = encoder.encode(result.dataURL);
        
        window.crypto.subtle.digest('SHA-256', data)
            .then(hashBuffer => {
                // 将哈希缓冲区转换为十六进制字符串
                const hashArray = Array.from(new Uint8Array(hashBuffer));
                const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
                
                console.log('Canvas指纹(SHA-256):', hashHex);
            });
    }
</script>
`

接口为什么越写越难改:从一开始就能避免的设计问题

作者 LeonGao
2026年4月20日 13:41

很多开发者都有过这样的体验:初期写的接口简洁清晰,修改起来得心应手;但随着业务迭代、需求变更,接口越写越臃肿,修改一个小功能,要牵动好几个地方,甚至引发线上故障,最后陷入“改不动、不敢改”的困境。其实,接口难改的根源,从来不是“业务太复杂”,而是从设计初期就埋下了隐患——忽视了接口的可维护性、兼容性和规范性,导致后续迭代时“牵一发而动全身”。

接口的本质,是服务提供者与消费者之间的行为契约,明确定义了服务的能力边界、调用方式、返回规则与异常处理机制,其设计质量直接决定了系统的可维护性和扩展性,绝大多数接口难改的问题,根源都在于初期设计的不规范。本文总结了4个最常见的设计隐患,以及对应的规避方法,帮你从一开始就写出“好改、易用”的接口,避免后期陷入被动。

一、隐患一:命名与风格混乱,理解成本飙升

命名与风格的不一致,是接口设计中最常见也最容易被忽视的问题,也是导致接口难改的首要原因。很多团队在开发初期没有统一规范,每个开发者按自己的习惯命名,导致接口风格杂乱无章,后续无论是自己修改,还是其他开发者接手,都需要花费大量时间理解接口含义,修改时极易出错。

常见的混乱场景的有三种:一是URL路径命名不统一,有的用小写字母加连字符(如/user-info),有的用驼峰(如/userInfo),有的用下划线(如/user_info),甚至同一个系统中同时存在多种风格;二是请求参数命名混乱,布尔类型参数有的用isEnabled、hasPermission,有的直接用enabled、flag,列表类型参数有的用userIds,有的用userIdList;三是响应数据结构不一致,同一个业务数据在不同接口中字段名称不同,比如用户头像URL,在用户列表接口叫avatarUrl,在详情接口叫headImage,迫使调用方编写多套解析逻辑。

规避方法:从一开始就制定统一的接口规范,明确命名规则和风格,且严格执行。比如RESTful接口遵循“资源为中心”的原则,URL用名词复数标识资源,用HTTP方法定义动作,禁止在URL中出现get、update等动词,多单词用连字符分隔,层级控制在3级以内;参数和响应字段命名统一用驼峰式,布尔类型参数统一加is/has前缀,列表类型参数统一用复数形式;响应数据采用统一格式,包含状态码、提示信息和数据体,确保所有接口的响应结构一致。同时,通过代码评审机制,及时纠正不规范的命名和风格,避免问题累积。

二、隐患二:忽视向后兼容,迭代即“踩坑”

接口难改的核心痛点之一,是“改新功能,毁老功能”——很多开发者在迭代接口时,只关注新需求的实现,随意删除字段、修改参数类型或含义,忽视了向后兼容,导致依赖该接口的前端、第三方服务出现解析错误、功能异常,最后不得不回滚代码,或做大量兼容处理,增加了修改成本和故障风险。

常见的不兼容操作有:直接删除接口中已有的返回字段,导致老版本客户端解析失败;修改字段类型,比如将字符串类型的用户ID改为整数类型,引发客户端类型转换异常;修改参数的校验规则,让老版本客户端的合法请求被拒绝;新增枚举值时未考虑老版本客户端的处理逻辑,导致业务逻辑错误。这些看似微小的修改,都可能引发连锁反应,让接口修改陷入“两难”。

规避方法:始终将“向后兼容”作为接口设计的核心原则,遵循“对扩展开放,对修改关闭”的开闭原则,接口迭代优先通过扩展实现,而非修改原有契约。具体做法的有三点:一是废弃字段不删除,而是标记为“废弃”,并在接口文档中说明,待所有调用方迁移后再删除;二是新增字段时设置合理的默认值,确保老版本客户端能正常解析;三是修改参数或逻辑时,优先新增接口(如增加版本号,/v1/users、/v2/users),保留老接口,逐步迁移调用方,避免直接修改原有接口逻辑。同时,可通过契约测试,检测接口变更是否破坏原有契约,提前规避兼容问题。

三、隐患三:职责混乱,接口成“万能容器”

很多开发者为了图方便,将多个不相关的业务逻辑塞进一个接口,导致接口职责混乱、逻辑臃肿——比如一个接口既处理用户登录,又处理用户注册,还负责获取用户信息,成为“万能接口”。这种设计初期看似高效,后期修改时会极其麻烦:修改登录逻辑,可能影响注册功能;调整用户信息返回字段,可能导致登录接口异常,甚至引发连锁故障。

除此之外,接口职责混乱还会导致代码复用性差,不同业务场景需要重复编写类似逻辑,后续修改时需要多处同步修改,增加了维护成本。同时,这种“大而全”的接口会让接口文档晦涩难懂,调用方需要花费大量时间梳理接口逻辑,也增加了沟通成本和出错概率。

规避方法:严格遵循“单一职责”原则,一个接口只负责一个业务场景、一个核心功能,杜绝“万能接口”。比如将用户登录、注册、获取信息拆分为三个独立接口,每个接口只处理对应逻辑,接口之间互不干扰。同时,提炼公共逻辑,将高频复用的逻辑(如参数校验、权限校验)封装成公共组件,供所有接口调用,既减少重复代码,又便于后续统一修改。此外,接口设计时要明确边界,避免跨业务域的逻辑耦合,确保接口的独立性和可维护性。

四、隐患四:文档缺失或不规范,“改接口全靠猜”

接口文档是开发者之间、前后端之间的沟通桥梁,也是后续修改接口的重要依据。但很多团队忽视接口文档的编写,要么文档缺失,要么文档更新不及时,导致后续修改接口时,开发者只能通过阅读代码推测接口逻辑、参数含义和返回格式,不仅效率低下,还极易出错——比如误改参数含义、遗漏必填参数,引发线上故障。

常见的文档问题有:文档缺失,没有明确的接口用途、参数说明、返回示例;文档与代码不同步,接口修改后未及时更新文档,导致文档与实际接口逻辑不一致;文档描述模糊,没有说明参数的校验规则、异常场景的返回结果,调用方和修改方都无法准确理解接口行为。这些问题会让接口修改陷入“盲改”状态,难度大幅提升。

规避方法:从接口设计初期就重视文档编写,将“接口文档同步更新”纳入开发流程,作为“完成的定义”的一部分,确保文档与代码一致。接口文档需明确包含5个核心内容:接口用途(明确接口解决的业务问题)、请求参数(名称、类型、是否必填、说明)、返回参数(名称、类型、说明)、异常响应(状态码、提示信息、场景)、调用示例(正常和异常场景的调用示例)。同时,可借助自动化工具(如Swagger)生成接口文档,减少手动编写成本,确保文档实时同步代码变更,让后续修改接口时“有章可循”,无需依赖代码推测。

总结:接口越写越难改,从来不是偶然,而是初期设计时忽视了规范、兼容、职责和文档这四个核心点。接口设计的核心,是“为后续迭代留余地”,而非“快速实现当前需求”。从一开始就遵循统一规范、重视向后兼容、明确接口职责、完善接口文档,就能写出“好改、易用”的接口,避免后期陷入“改不动、不敢改”的困境。同时,将接口设计规范纳入团队的自动化评审流程,通过代码扫描、契约测试等工具,提前规避设计隐患,才能让接口随着业务迭代持续保持可维护性,降低后续修改成本。**

不用学微服务,也能设计不崩的系统:最小可行思路

作者 LeonGao
2026年4月20日 13:40

很多开发者都有一个误区:认为只有用微服务架构,才能设计出高可用、不崩溃的系统。尤其是中小型团队、初创项目,往往陷入“为了微服务而微服务”的困境——明明业务规模小、团队人力有限,却硬要拆分服务,最终导致架构复杂、运维成本飙升,反而更容易出现故障。

事实上,系统崩溃的核心原因,从来不是“没有用微服务”,而是 资源耗尽、单点故障、流量失控、逻辑臃肿 这四类问题。微服务只是解决这些问题的一种方案,而非唯一方案。对于大多数中小型项目、非高并发场景,只要抓住“最小可行”的核心,用单体架构也能设计出稳定不崩的系统,甚至比微服务更简洁、更易维护。这里的“最小可行”,本质是为最小可行产品(MVP)提供稳定的架构基础(MVA),无需追求过度复杂的设计,聚焦核心需求即可。

所谓“最小可行思路”,就是放弃过度设计,聚焦“防崩溃、保可用”的核心需求,用最简单的技术手段,解决最关键的问题。同时可将“完成的定义(DoD)”扩展至架构层面,在每次产品发布时评估系统的可维护性、可扩展性,确保架构的可持续性。以下4个核心思路,无需微服务知识,落地成本低、效果直接,适合绝大多数团队参考。

一、先保“单点稳定”:杜绝基础层故障

很多系统崩溃,根源不是业务逻辑复杂,而是基础组件没做好容错。对于单体系统来说,最容易出问题的就是数据库、缓存、网络这三个“基础支柱”,只要把这三者的稳定性守住,系统崩溃的概率就会降低80%。

  1. 数据库:拒绝“裸奔”,做好基础防护。无需复杂的分库分表,重点做好两点:一是开启读写分离,将查询请求分流到从库,减轻主库压力,避免主库因高查询量宕机,这一点即使是3台机器的小型部署也能实现,通过MySQL主从复制即可搭建基础架构;二是做好慢查询优化,定期排查执行时间超过1秒的SQL,避免全表扫描、无索引查询,同时合理设置连接池参数,防止连接耗尽。

  2. 缓存:用对缓存,避免“帮倒忙”。缓存的核心作用是减轻数据库压力,但用不好反而会引发雪崩。最小可行的做法是:只缓存高频读取、低频修改的数据(如字典表、用户基础信息);设置合理的过期时间,避免缓存过期瞬间大量请求穿透到数据库;增加缓存降级逻辑,当缓存服务(如Redis)故障时,直接返回默认数据或提示,而非直接崩溃,可借助Redis哨兵模式实现主从切换,提升缓存可用性。

  3. 网络:做好超时与重试,避免“卡死”。系统中所有外部调用(如第三方接口、内部服务调用),必须设置超时时间(建议不超过3秒),避免因外部服务卡顿导致自身线程阻塞;同时增加重试机制(最多3次,每次间隔1秒),应对网络波动,但要注意重试需保证幂等性,避免重复操作。

二、流量“削峰填谷”:拒绝被突发流量击垮

系统崩溃的常见场景的是“突发流量过载”——比如活动促销、热点事件,瞬间涌入的请求超过系统承载能力,导致CPU、内存飙升,最终宕机。微服务的弹性伸缩能解决这个问题,但单体系统也有更简单的实现方式,核心是“拒绝峰值冲击,平滑流量曲线”。

  1. 限流:给系统设置“安全阈值”。无需复杂的分布式限流,用单机限流即可满足需求。比如用Guava的RateLimiter组件,限制每秒请求数(根据自身服务器配置调整,如100QPS),超过阈值的请求直接返回“请求过忙,请稍后再试”,避免系统被压垮。对于对外接口,还可按用户ID、IP设置更精细的限流规则,防止恶意刷量。

  2. 异步:非核心流程“后台处理”。将不需要实时返回结果的操作(如日志记录、消息推送、数据统计),通过消息队列(如RabbitMQ、RocketMQ)异步处理,减少同步请求的响应时间,释放系统资源。比如用户下单后,同步返回“下单成功”,异步处理库存扣减、订单通知,既提升用户体验,又避免因非核心流程阻塞导致系统崩溃。

  3. 静态资源:交给CDN,减轻应用服务器压力。将图片、视频、CSS、JS等静态资源,部署到CDN(如阿里云OSS+CDN),用户请求时直接从CDN获取,无需经过应用服务器,尤其适合静态资源占比较大的系统,能大幅降低服务器负载,避免因静态资源请求过多导致系统卡顿。

三、简化逻辑:拒绝“臃肿代码”拖垮系统

单体系统的优势是“逻辑集中、维护简单”,但如果代码臃肿、逻辑混乱,同样会导致系统不稳定——比如一个接口包含几十行业务逻辑、大量重复代码、无节制的全局变量,不仅难以维护,还会增加系统运行压力,甚至引发隐藏bug。同时,臃肿的代码会增加技术债务,后续“还债”成本极高,影响架构的可持续性。

最小可行的简化思路:一是遵循“单一职责”原则,一个接口只做一件事,一个方法只实现一个功能,避免“大而全”的接口(如一个接口既处理用户登录,又处理用户注册),同时减少模块间的耦合,遵循迪米特法则,降低对象间的依赖;二是清除重复代码,将高频复用的逻辑封装成工具类或公共方法,既减少代码量,又便于后续维护和修改;三是控制全局变量的使用,避免全局变量被随意修改,引发不可预测的bug;四是定期做代码审查,结合自动化代码扫描工具,排查代码复杂度、规范问题,将架构评估融入日常开发流程。

四、做好监控与兜底:最后一道“安全防线”

即使做好了前面三点,也无法完全避免故障,此时监控与兜底机制就成为守护系统稳定的最后一道防线。最小可行的监控与兜底方案,无需复杂的监控平台,聚焦“能及时发现故障、能快速止损”即可。

  1. 监控:重点监控核心指标。无需监控所有指标,聚焦CPU、内存、磁盘使用率、数据库连接数、接口响应时间这5个核心指标,设置预警阈值(如CPU使用率超过80%、接口响应时间超过3秒触发预警),通过邮件或短信及时通知开发人员,避免故障扩大。同时可借助持续交付流水线,实现监控的自动化,及时捕捉架构退化问题。

  2. 兜底:给核心流程“留退路”。针对核心业务流程(如用户支付、下单),设计兜底方案,比如数据库宕机时,暂时使用本地缓存存储核心数据,待数据库恢复后同步;接口调用失败时,返回默认数据或降级提示,避免整个流程崩溃。兜底逻辑无需复杂,核心是“不影响用户核心操作,不导致系统彻底宕机”。

总结来说,设计稳定不崩的系统,核心不是“用什么架构”,而是“解决核心故障点”。对于中小型团队、初创项目,与其盲目跟风微服务,不如采用“最小可行思路”,守住基础稳定、控制流量、简化逻辑、做好兜底,用最低的成本实现系统高可用,待业务规模扩大、并发量提升后,再逐步迭代架构也不迟。同时,将架构评估融入“完成的定义”,通过自动化工具保障架构的可持续性,才能实现系统的长期稳定。

Vite 开发代理里的 `ws` 是什么,什么时候该开

作者 antkang
2026年4月20日 13:19

Vite 开发代理里的 ws 是什么,什么时候该开

什么是 ws

ws 指的是 WebSocket

它和普通 HTTP 请求不一样,不是一次请求一次响应,而是浏览器和服务端建立一条持续连接,后续双方都可以持续收发消息。

常见用途:

  • 聊天
  • 实时通知
  • 推送消息
  • 开发环境里的热更新(HMR)

proxy 里的 ws: true 是什么意思

在 Vite server.proxy 里:

proxy: {
  '/ws': {
    target: 'http://xxx.com',
    ws: true
  }
}

这里的 ws: true 不是"开启 WebSocket 功能",而是:

允许这条代理规则去转发 WebSocket 请求。

也就是这条规则不仅代理 HTTP,也代理 ws。


为什么没开 ws: true,热更新还是正常

因为 Vite 的 HMR WebSocket 是 Vite dev server 自己提供的,不需要你在 proxy 里手动开启。

也就是说:

  • 你没开的是 proxy 是否转发 WebSocket
  • 不是 Vite 自己有没有 WebSocket

所以即使 proxy 里没写 ws: true

  • 浏览器还是会直接连接本地 5173
  • Vite 还是会建立 HMR WebSocket
  • 热更新照样正常

一句话:

ws: true 只影响代理,不影响 Vite 自己的 HMR。


什么时候该开 ws: true

只有一种情况:

你的业务真的有 WebSocket 接口,需要通过 Vite 代理转发。

例如前端会连接:

  • /ws
  • /socket.io
  • /websocket

这时候才应该写:

proxy: {
  '/ws': {
    target: 'ws://backend-server',
    changeOrigin: true,
    ws: true
  }
}

什么时候不该开

下面这些情况一般都不该开:

1. 普通接口代理

比如:

  • /api
  • /gateway

这些是普通 HTTP 请求,不是 WebSocket,不需要 ws: true

2. 页面壳代理

比如你只是想:

  • /xx-ui 走子应用
  • / 走父应用壳

这本质是页面请求代理,也不需要 ws: true

3. 大范围兜底代理

比如:

'^/(?!xx-ui|@vite|src|...)'

这种规则范围很大,如果再开 ws: true,很容易把不该代理的 ws 也带进去。


你的场景里为什么会出问题

你的意图是:

  • /xx-ui 走子应用自己
  • / 代理到父应用壳
  • /api/gateway 走后端

这个思路本身没问题。

问题出在你用了大范围兜底代理,同时开了 ws: true

这样一来,页面加载后,一些 WebSocket 请求也可能命中这条规则,被转发到 target。

而 Vite 开发环境本身就有一条重要的 WebSocket:HMR 热更新通道

你这次其实不是业务 ws 出问题,而是:

Vite 的 HMR WebSocket 被代理规则误伤了。


为什么会一直刷新

因为 HMR 依赖这条 ws 连接。

如果它被代理到错误的 target:

  • 连接失败
  • 不断重连
  • 热更新失效
  • 页面反复 reload

所以现象就是:

  • /xx-ui 首屏能打开
  • 但页面停一会儿就开始一直刷新

这说明问题不是首屏 HTML,而是页面起来后 HMR 的 ws 链路坏了


为什么本地 8080 没问题,线上 target 有问题

不是因为 8080 配对了,而是:

  • 本地环境对错误 ws 更宽容
  • 线上网关 / nginx / 代理更严格
  • 同样的错误配置,线上更容易直接暴露

所以本质上不是"线上有问题",而是:

这条 ws 本来就不该被代理。


正确做法

普通接口单独代理

proxy: {
  '/api': {
    target: 'https://xxx.com',
    changeOrigin: true
  },
  '/gateway': {
    target: 'https://xxx.com',
    changeOrigin: true
  }
}

只有明确业务 ws 才单独开

proxy: {
  '/ws': {
    target: 'wss://xxx.com',
    changeOrigin: true,
    ws: true
  }
}

大兜底规则不要开 ws: true

proxy: {
  '^/(?!xx-ui(/|$)|@vite/|@id/|@fs/|src/|node_modules/|public/)': {
    target: 'http://localhost:8080',
    changeOrigin: true
  }
}

一句话结论

  • ws: true = 这条代理规则也处理 WebSocket
  • 只有明确业务 ws 路径时才开
  • 不要给大范围兜底代理开 ws: true
  • 你这次的问题,本质是 Vite 的 HMR WebSocket 被误代理了

前端请求三部曲:Ajax / Fetch / Axios 演进与 Vue 工程化封装

作者 忆往wu前
2026年4月20日 12:33

从 Ajax → Fetch → Axios:前端网络请求演进史与工程化封装

前言

本篇是 Vue项目实战三板斧系列第一篇,专门聊聊前端最基础的网络请求。

不少同学上来就用 axios,会写但不太明白它到底是怎么来的。 这篇我就带大家简单走一遍进化路线:从最原始的 Ajax,到原生 Fetch,再到我们现在常用的 Axios,一步步看清它们的优缺点,最后一起封装一套简洁、好维护的工程化请求方案。 不求花里胡哨,只求看完能真正理解“我们为什么要这么写请求”。  

一、最原始的网络请求:原生 XMLHttpRequest

要说网络请求,老祖宗必须是 XMLHttpRequest,也就是我们常说的 Ajax。 它实现了页面不刷新就能拿数据,在当年简直是黑科技。

1.1 原生手写 Ajax(最底层写法)

// 1. 创建一个 ajax 实例
const xhr = new XMLHttpRequest();

// 2. 配置请求:请求方式、地址、异步(true)
xhr.open('GET','/api/data',true);

// 3. 监听请求状态变化(旧版常用写法)
xhr.onreadystatechange = function(){
  // readyState === 4 表示请求完成
  if(xhr.readyState === 4){
    // status 200~299 代表请求成功
    if(xhr.status >= 200 && xhr.status < 300){
      // 把后端返回的 JSON 字符串转成对象
      const result = JSON.parse(xhr.responseText);
      console.log('请求成功',result)
    }else{
      console.log('请求失败',xhr.status);
    }
  }
}

// 网络异常、跨域失败时触发
xhr.onerror = function(){
  console.log('网络异常或跨域错误')
}

// 4. 发送请求
xhr.send()

1.2 简单封装一下 Ajax

原生写法太啰嗦,我们简单封装一版,方便复用。

// 封装一个自己的 ajax 函数
function myajax(options) {
  // 1. 创建请求实例
  const xhr = new XMLHttpRequest()

  // 2. 解构配置参数,给默认值
  const {
    method = 'GET',  // 默认 GET 请求
    url,             // 请求地址
    data = null,     // 参数(这里演示无参)
    success,         // 成功回调
    error            // 失败回调
  } = options

  // 3. 初始化请求,转大写防止小写出错
  xhr.open(method.toUpperCase(), url, true)

  /*
    旧写法:onreadystatechange 需要判断 readyState
    新写法:onload 等价于 readyState=4,直接用更简单
  */
  xhr.onload = function () {
    // 判断 HTTP 状态码是否成功
    if (xhr.status >= 200 && xhr.status < 300) {
      // 解析后端返回的 JSON
      const res = JSON.parse(xhr.responseText)
      // 有成功回调就执行
      success && success(res)
    } else {
      // 失败把状态码抛出去
      error && error(xhr.status)
    }
  }

  // 网络异常触发
  xhr.onerror = function () {
    error && error('网络异常或跨域')
  }

  // 发送请求(这里不传参数,避免 GET 报错)
  xhr.send()
}

1.3 Ajax 的缺点(为啥我们不用它了)

缺点一:配置繁琐,全手动判断

详细解释:每发送一个请求,都要重复创建 XMLHttpRequest 实例、调用 open 配置请求、监听状态/错误、调用 send 发送请求,步骤多且冗余。而且要手动判断 readyState 请求状态、手动判断 status HTTP状态码、手动执行 JSON.parse 解析后端返回的字符串,没有任何自动处理逻辑,代码量极大,每写一个请求都要重复大量代码。

缺点二:回调一多直接回调地狱

详细解释: Ajax基于回调函数处理结果,一旦遇到连续多个依赖请求(比如先获取用户ID,再用ID获取详情,再用详情获取订单),就需要在success回调里嵌套下一个myajax请求。代码会层层嵌套、缩进不断加深,可读性极差,后期根本无法维护和修改,这就是典型的回调地狱问题。

  
// 回调地狱示例
myajax({
  url:'/api/user',
  success(res){
    // 第一层回调
    myajax({
      url:`/api/detail?id=${res.id}`,
      success(res){
        // 第二层回调
        myajax({
          url:`/api/order?did=${res.detailId}`,
          success(res){
            // 第三层回调,代码彻底混乱
          }
        })
      }
    })
  }
})
缺点三:没有拦截器、没有超时、没有统一处理

详细解释: 原生XHR没有全局请求/响应拦截机制,每个请求都要单独写错误处理、单独加请求头、单独处理返回结果。比如要给所有接口加token,必须在每个 xhr.open 之后,手动写 setRequestHeader ;想要设置请求超时,需要额外写定时器手动中断请求,无法做到一处配置、全局生效。

缺点四:不支持 Promise

详细解释: 原生Ajax不支持Promise语法,无法使用 async/await 、 then/catch 这种现代化异步写法,只能用传统回调函数。异步流程完全不可控,代码书写不优雅,也无法和现代前端的异步语法接轨,和后续的Fetch、Axios生态完全脱节。

总结:理解底层即可,真实项目没人直接写原生 Ajax。

 

二、现代浏览器原生:Fetch API

时代在进步,浏览器终于看不下去了,推出了Fetch。基于 Promise,告别回调,写法清爽多了。不用从头开始造,省时省力。

2.1 GET 请求(带参数拼接)

// 定义参数
const params = {
  id: 123,
  name: "text"
}

// 把对象转成 ?id=123&name=text 这种格式
const query = new URLSearchParams(params).toString();

// 发送请求
fetch(`/api/user?${query}`)
  .then(res => {
    // fetch 很坑:只有网络失败才 reject,404/500 依然走 then
    if (!res.ok) throw new Error("请求失败:" + res.status)
    // 解析 JSON
    return res.json()
  })
  .then(data => {
    console.log("获取数据成功", data)
  })
  .catch(err => {
    console.error("请求异常", err)
  })

2.2 POST 请求

// fetch 的 post 请求
fetch("/api/user", {
  method: "POST",
  headers: {
    // 必须声明传递 JSON 格式
    "Content-Type": "application/json"
  },
  // 对象转 JSON 字符串
  body: JSON.stringify({
    username: "admin",
    password: "123456"
  })
})
  .then(res => {
    if (!res.ok) throw new Error(res.status)
    return res.json()
  })
  .then(data => {
    console.log("请求成功", data)
  })
  .catch(err => {
    console.error("请求失败", err)
  }) 

2.3 async/await 语法糖更香

// 用 async/await 让代码看起来像同步
async function fetchData() {
  try {
    // 请求参数
    const postData = {
      username: "zhangsan",
      password: "123456"
    }

    // 发送请求
    const response = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(postData)
    })

    // 判断请求是否成功
    if (!response.ok) {
      throw new Error("请求失败,状态码:" + response.status)
    }

    // 解析数据
    const result = await response.json()
    console.log("请求成功", result)
  } catch (error) {
    // 统一捕获错误
    console.error("请求错误", error)
  }
}

// 执行
fetchData();

2.4 简单封装一版 Fetch

// 封装一个通用的 fetch 请求函数
async function request(url, options = {}) {
  // 解构参数
  const { method = 'GET', data, headers = {}, ...rest } = options;
  // 方法转大写
  const upperMethod = method.toUpperCase();
  // 最终请求地址
  let fetchUrl = url;

  // 配置 fetch 参数
  let fetchOptions = {
    method,
    headers,
    ...rest
  }

  // GET 请求:参数拼接到地址栏
  if (upperMethod === 'GET' && data) {
    const queryStr = new URLSearchParams(data).toString()
    fetchUrl += `?${queryStr}`
  }

  // POST/PUT/DELETE 处理 JSON 格式
  if (['POST', 'PUT', 'DELETE'].includes(upperMethod) && data) {
    // 设置请求头
    fetchOptions.headers['Content-Type'] = 'application/json';
    // 转 JSON 字符串
    fetchOptions.body = JSON.stringify(data)
  }

  try {
    // 发送请求
    const res = await fetch(fetchUrl, fetchOptions)
    // 判断状态
    if (!res.ok) { throw new Error(`请求错误:${res.status}`) }
    // 解析并返回数据
    return await res.json()
  } catch (err) {
    // 打印并抛出异常,外部可以继续 catch
    console.error('请求失败', err)
    throw err
  }
}

2.5 Fetch 有哪些硬伤?

硬伤一:网络错误才 reject,404 / 500 依然走 then,必须手动判断

详细解释: Fetch 的“成功”只看网络是否发出去,只要浏览器收到了 HTTP 响应,哪怕是 401、404、500 错误, fetch  依然认为请求“成功”,会走进  then  而不是  catch 。 所以你必须每次手动判断  res.ok ,否则会把错误当正常数据处理,导致页面报错。

// 不写这句,404/500 不会进 catch
if (!res.ok) throw new Error("请求失败")
硬伤二:没有请求、响应拦截器,所有逻辑必须手写重复

详细解释: Fetch 原生不支持拦截器。如果你想给所有接口加 token、加请求头、统一处理返回值、统一报错,每个 fetch 都要写一遍,无法像 axios 那样全局配置一次到处生效。

// 每个请求都要重复写一遍
headers: {
  "Content-Type": "application/json",
  Authorization: "Bearer " + token
}

 

硬伤三:无法取消请求,没有 abort 方案(必须额外用 AbortController)

详细解释: 原生 fetch 自身不支持取消请求。想要取消必须手动搭配  AbortController ,写一堆额外代码,切换页面、重复请求时无法自动中断,容易造成内存泄漏、重复请求、旧数据覆盖新数据等问题。

硬伤四:没有自带超时处理,超时要自己写定时器包装

详细解释: Axios 直接配置  timeout: 5000  就可以自动超时中断。Fetch 没有超时配置,想实现超时必须自己包一层  Promise.race  +  setTimeout ,每个请求都要重复造轮子,非常麻烦。

硬伤五:请求 body 不会自动处理,必须手动 JSON.stringify

详细解释: Axios 会自动帮你把对象转成 JSON、自动加  Content-Type: application/json 。 Fetch 完全不处理,你必须手动:

body: JSON.stringify(data)
headers: { "Content-Type": "application/json" }

少一句后端就收不到数据,非常容易漏写。

硬伤六:无法监听请求进度(上传/下载进度很难实现)

详细解释: Axios 自带  onUploadProgress  可以直接监听上传进度做进度条。 Fetch 原生不支持,只能通过  ReadableStream  自己手动解析流,实现复杂、成本极高,普通项目基本没法用。

结论:小 demo 能用,中大型项目顶不住。

三、项目主流方案:Axios 全面上手

前面我们了解了:

  • 最底层:XMLHttpRequest,功能强但写起来巨麻烦
  • 现代原生:Fetch,语法好看,但能力残缺

那有没有一个东西,既保留 XHR 的强大能力,又拥有 Fetch 的 Promise 优雅语法,还把所有坑都填了?

它就是我们现在前端项目的事实标准 —— Axios。

重点来了: Axios 并不是什么新底层技术,它本质上就是对原生 XMLHttpRequest 再次封装、增强、Promise 化之后的终极工具库。 相当于把我们刚才手写的简陋 myajax、简陋 fetch 封装,做到了工业级极致。

它解决了所有痛点:支持 Promise、自动处理 JSON、拦截器、取消请求、超时、进度监听…… 所以现在 Vue、React、小程序、Node 项目里,大家几乎都默认用 Axios。

3.1 基础使用

import axios from 'axios'

// 完整写法
axios({
  method: 'get',
  url: '/user',
  params: { id: 10 }
})
  .then(res => {
    // axios 自动帮你解析了 JSON,直接拿 data
    console.log(res.data)
  })
  .catch(err => {
    console.log('请求失败', err)
  }) 

3.2 简写 GET / POST

// 简写 GET
axios.get('/user', {
  params: { id: 10 }
}).catch(err => {
  console.log(err)
})

// 简写 POST(自动处理 JSON,不用自己 stringify)
axios.post('/login', {
  username: 'admin',
  password: '123456'
}).catch(err => {
  console.log(err)
})

3.3 async/await 优雅版

// 登录请求
async function login() {
  try {
    const res = await axios.post('/login', {
      username: 'admin',
      password: '123456'
    })
    console.log(res.data)
  } catch (err) {
    // 请求失败、状态码错误都会进这里
    console.log('请求失败', err)
  }
}

 

四、工程化核心:Axios 二次封装(重点)

真实项目里,我们不会到处直接写 axios.get, 必须封装一次,统一处理:token、超时、状态码、错误提示。

4.1 封装 request.js

import axios from 'axios'

// 创建 axios 实例
const request = axios.create({
  baseURL: '/api',    // 统一接口前缀
  timeout: 5000       // 超时时间 5 秒
})

// =================== 请求拦截器 ===================
request.interceptors.request.use(config => {
  // 从本地拿到 token
  const token = localStorage.getItem('token')
  // 如果有 token,就加到请求头里
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  // 必须 return config
  return config
})

// =================== 响应拦截器 ===================
request.interceptors.response.use(
  res => {
    // 直接返回后端数据,页面不用再 .data
    return res.data
  },
  err => {
    // 统一错误提示
    console.log('请求出错', err)
    // 抛出异常,让页面可以自己 catch 处理
    return Promise.reject(err)
  }
)

// 导出实例,页面引入使用
export default request

这一封装,好处直接拉满:

- 统一 baseURL,后期改地址只改一处

- 所有接口自动带 token,不用每个请求写

- 统一错误处理,不用每个接口 catch

- 响应直接返回 data,代码更干净

 

五、接口模块化管理(真正工程化)

封装完 axios 还不够,工程化必须接口模块化。

5.1 按业务拆分文件

src/
└── api/
    ├── request.js   # axios 封装
    ├── user.js      # 用户相关接口
    ├── goods.js     # 商品相关接口
    └── order.js     # 订单相关接口

5.2 user.js 示例

import request from './request'

// 登录接口
export function loginApi(data) {
  return request({
    url: '/login',
    method: 'post',
    data
  })
}

// 获取用户信息
export function getUserInfo() {
  return request({
    url: '/user/info',
    method: 'get'
  })
}

5.3 组件中使用

import { loginApi } from '@/api/user'

async function login() {
  try {
    const res = await loginApi({
      username: 'admin',
      password: '123456'
    })
    console.log('登录成功', res)
  } catch (err) {
    console.log('登录失败')
  }
}

优点:

- 接口统一管理,便于维护

- 页面逻辑更干净

- 方便 mock、方便重复调用

六、在 Vue3 组件中实战使用

Vue

<template>
  <div>
    <button @click="getUser">获取用户信息</button>
  </div>
</template>

<script setup>
import { getUserInfo } from '@/api/user'
import { ref } from 'vue'

const userInfo = ref({})

// 获取数据
const getUser = async () => {
  try {
    const res = await getUserInfo()
    userInfo.value = res
  } catch (err) {
    console.log('请求失败')
  }
}
</script>

可以看到,组件中已经完全看不到底层的  axios  调用,只需要调用封装好的接口方法即可完成数据请求。代码更加简洁清晰,职责更加单一,真体现了前端工程化低耦合、高复用、易维护的优势。

七、总结

这一篇我们完整走完了前端请求进化之路:

1. Ajax(XMLHttpRequest):底层基石,所有请求的根

2. Fetch:浏览器原生 Promise 方案,但能力有限

3. Axios:基于 XHR 深度封装,现代前端工程化最佳实践

工具一直在变,但核心思路没变:

从繁琐难用,到语法简化,再到功能完善。 Axios 之所以成为主流,正是因为它在原生 XHR 的基础上做了大量贴心封装,让我们不用再重复处理各种细节。

而封装和工程化的意义,也远不止“省事”这么简单:

统一的配置、统一的错误处理、按模块拆分接口,本质上都是为了让代码更简洁、更好维护、更容易协作。 一个项目是否规范,往往从请求层就能看出来。

搞懂这些来龙去脉,以后再写接口、做封装,就不再是机械复制代码,而是真正知道自己在做什么、为什么这么做。

这也是 Vue 项目工程化的第一步。 下一篇我们继续三板斧第二篇:VueRouter 路由与路由守卫,配合今天的 token 实现登录鉴权。

为什么我不建议普通前端盲目卷全栈?

作者 ErpanOmer
2026年4月20日 12:00

Gemini_Generated_Image_s62j8ys62j8ys62j (1).png

周末,一个半年前从我们组离职去当 独立开发者 的小伙子,突然约我出来喝了顿大酒。

半年前他提离职的时候,眼里是有光的。当时他手里拿着一个用 Next.js + Node + MongoDB 拼凑出来的 AI 翻译 SaaS 雏形,满脸兴奋地跟我说,现在有了 AI 辅助,前端搞全栈简直易如反掌,他马上就要去赚美金、做数字游民了😊。

半年后的饭桌上,他头发肉眼可见地稀疏了,整个人透着一股被掏空的疲惫😢。

我问他产品跑得怎么样? 他倒了一堆苦水: 上线第二周,因为忘了配置 MongoDB 的白名单,数据库被黑产端了,留了个比特币勒索地址; 换了云数据库后,上个月被海外羊毛党用并发脚本刷爆了注册接口,由于 Node.js 端没做事务锁和限流,AI 服务的 Token 余额一夜之间被刷欠费了两千多刀。

他长叹一口气:老大,写后端真他妈不是人干的活😖😖😖。

这两年,前端圈有一股极其狂热的风气,大厂在逼着前端转全栈,各类博主在教你怎么用 Cursor 一键生成后端 API,似乎只要会写几句 JS,连上个数据库,你就能凭一己之力抗下整个商业闭环。

但作为一个写了 9 年代码、搞过出海独立站、也无数次给新人擦过屁股的老兵,我今天必须把这层窗户纸捅破: 绝大多数普通前端理解的全栈,根本就是个一戳就破的纸老虎。我不建议你盲目去卷全栈🤷‍♂️。


你以为的全栈,只是在写玩具后端

很多前端对后端的认知,还停留在用 Express 或者 NestJS 写一个 router.get('/api/user'),然后调用一下 ORM 查查数据库。代码能跑通,能返回 JSON,就觉得自己是全栈了。

这是巨大的错觉。

真实的后端工程,最难的从来不是业务逻辑(CRUD),而是高并发下的数据一致性、资源隔离与灾备防御。

举个最经典的例子。很多刚转全栈的前端,在处理 用户消耗积分调用 AI 这个逻辑时,代码往往是这么写的:

// 前端思维写出来的后端代码
app.post('/api/generate', async (req, res) => {
  const user = await User.findById(req.userId);
  
  // 判断余额
  if (user.points < 1) {
    return res.status(403).send('积分不足');
  }
  
  // 扣减积分并保存
  user.points -= 1;
  await user.save();
  
  // 调用 AI 接口...
});

本地单步调试,毫无问题。 但只要把它扔到线上,稍微遇到点网络延迟,或者有黑客同时发来 10 个并发请求。这 10 个请求会同时读到 user.points === 1,然后各自往下执行,最终用户的 1 个积分被成功扣减了 1 次,但你的 AI 接口被免费调用了 10 次。

在真正的后端视野里,这叫竞态条件(Race Condition)。解法是利用数据库层面的原子更新(比如 MongoDB$inc),或者是加分布式锁。

但很多前端根本不懂什么是事务隔离级别,什么是乐观锁悲观锁,什么是慢查询引发的连接池打满。他们拿着一套写 UI 的心智模型去搞后端,最后搭出来的系统,防得住正人君子,防不住任何一次稍微猛烈的流量冲击。


2026 年独立开发者真实生存状况

现在的年轻人动不动就想搞独立 SaaS,觉得有个好点子就能变现。 我带你看一眼 2026 年前端做独立开发者的真实时间线:

Gemini_Generated_Image_xi8phxi8phxi8phx.png

第一周: 激情澎湃,花 5 天时间用 Tailwind CSS 把落地页的动效调得丝滑无比,深色模式完美适配,觉得自己真是个产品天才。

第二周: 开始搭后端环境。在 Docker、Nginx 配置、SSL 证书续签里痛苦挣扎。为了省几十块钱服务器钱,买了个廉价 VPS,每天提心吊胆怕宕机。

第四周: 产品终于上线了。发到 Product HuntV2EX 上,迎来了 500 个独立访客。

第五周: 被俄罗斯或者印度的 Bot 盯上了。恶意脚本疯狂轰炸你的登录接口,你那单节点的 Node.js 进程直接 CPU 飙到 100% OOM 死机。你大半夜爬起来看日志,临时去搜 Node.js 怎么做 IP 频控。

第二个月: 热情耗尽,服务器吃灰,域名到期不续费🤷‍♂️。。。

这才是赤裸裸的真相。 很多前端做独立开发,90% 的精力消耗在了配环境、查后端 Bug、修服务器配置上,真正花在打磨核心产品功能和做营销推广上的时间,连 10% 都不到。

你以为你是产品 CEO,其实你只是个免费的初级兼职运维。


普通前端该怎么破局?学会借力,而不是造轮子

说了这么多,难道前端就只能老老实实切图,彻底告别独立开发和全栈了吗?

错❌❌❌。

我的核心观点是:放弃传统后端的玩法,拥抱 Serverless 和 BaaS(后端即服务)。

2026 年了,前端的护城河绝对不是去学怎么配置 K8s 集群,也不是去死磕如何调优 MySQL 索引。你的核心价值是 极速交付业务逻辑

要做全栈,就把脏活累活全甩给成熟的云基础设施。 比如这两年我在搞出海项目时,几乎抛弃了所有传统的自建 Node 服务器部署 (比如 Render, fly.io),全盘转向了 Cloudflare Workers + D1(Serverless SQLite) 或者 Supabase

不用管服务器运维,不用管 Nginx 负载均衡,自带企业级防 DDOS,把代码推到边缘节点(Edge),全球毫秒级生效。

给你看一眼在 Cloudflare Workers 里,如何用极简的代码实现极其硬核的 IP 频控(Rate Limit),这在传统后端里要搭一套 Redis 才能搞定:

// 基于 Cloudflare 的现代前端全栈玩法
export default {
  async fetch(request, env) {
    const ip = request.headers.get('cf-connecting-ip');
    
    // 调用平台自带的限流服务,一行代码解决防刷问题
    const { success } = await env.RATE_LIMITER.limit({ key: ip });
    if (!success) {
      return new Response('请求过于频繁,请稍后再试', { status: 429 });
    }

    // 处理核心业务逻辑...
    return new Response('业务处理成功');
  }
};

发现了吗?这种工程维度的跨越,才是前端走向全栈的正确姿势。 你不需要去理解底层的流量网关是怎么实现的,你只需要站在巨人的肩膀上,把 API 串起来,把精力留在如何优化用户的产品体验上。


别被技术焦虑绑架

很多技术社区都在制造焦虑,好像你不懂点微服务、不懂点高并发,你就不配做一个现代的前端。

但真实的世界是:没有任何一个商业项目,是因为用了多牛逼的后端架构才成功的;绝大部分死掉的项目,都是因为产品根本没人用,或者在早期就被无意义的基础设施消耗拖垮了团队🤔。

如果你是一个前端,有极强的业务嗅觉,想自己做点东西。 那就用熟你手里的 VueReact,用好 Tailwind 快速构建 UI,把后端托管给 Supabase 或者 Firebase,把边缘逻辑交给 Cloudflare。用两周时间把 MVP(最小可行性产品)跑通,直接推向市场验证🫡。

不要去盲目卷传统后端。 把时间留给产品,留给用户,留给真正的商业闭环。这才是 2026 年,一个有独立思考能力的前端老兵,最该具备的技术品味。

你们说是不是?😊

Suggestion (2).gif

手搓你的 AI 外置记忆,连接飞书体验直接脚踢龙虾

作者 imoo
2026年4月20日 11:54

前言

在做这套东西前,我一直是有个疑虑的。ai 这些持续的习惯、记忆积累是否是无用功,会不会刚做的差不多,这个工具就过时了,又要花时间切一套新的重新积累(比如之前的 cursor)。而且多设备的记忆并不同步,做这件事的性价比可能并不高。

但最近我有了另一条思路:可以将记忆做成一个完整的项目,给不同的平台做不同的适配方案。这样一来,切换工具时只需要让 ai 处理一下适配层即可,可以放心的存储记忆或者迁移。

并且同样的,你的 skills、agents 乃至于临时搓的工具脚本,都可以纳入这个架构下。切换设备时,只需要你把这个仓库 clone 下来,就能拥有和之前完全相同的能力了。

原理

  1. claude code、codex 等工具,一定是有一个记忆文件的,用于存储你强调的习惯

在 claude code 中为:根目录/.claude/CLAUDE.md

在 codex 中为:.codex/AGENTS.md

我们可以利用这个机制,将这些入口,都软链至我们的记忆项目,这样一来,无论在哪个平台运行 ai,都能进入到这个项目的入口中,载入我们的记忆。

  1. 渐进式披露上下文

渐进加载是指,claude 会开始对话时,会自动读取 文件,将其加入上下文并开始对话。随后每读取文件夹时,都会尝试加载该文件夹下的 claude.md 并加入上下文中。

我们可以将记忆项目进行合理的规划与分层,使记忆结构更加有条理,方便管理

渐进机制仅在 claude code 存在,在非 claude 平台,比如 codex,就只能靠提示词进行约束了,例如:

##claude.md 规则

进入目录时,必须先读取当前目录下对应 claude.md

原则:先读 claude.md,再读写具体文件 — 没有例外,违反此规则 = 任务失败

不过目前看下来效果还不错,codex 能完全遵守该规则去读文件

项目结构

  • src/brain 负责记忆层
  • src/config 负责工具层
  • src/projects 负责业务层
  • src/temp 用于存储一些临时文件

外层的作用是引入各类工具文件,比如 node_modules、husky、.gitgnore 等,不引入这一层的话, 这些文件会和上述核心层同级,占据额外的上下文。

实现思路

记忆层

当前的 brain 层,主要是结合了分享会上的长期记忆方案以及 claude 的渐进加载机制。我们只需要在入口增加这样的说明,它就会自动进到 brain 层:

---

## Session Init(读取规则)

**新会话启动时,按顺序读取:**

1. **必选**`/Users/imoo/work/data/project/ai-config/src/brain/claude.md`
2. **必选**`/Users/imoo/work/data/project/ai-config/src/brain/memory/claude.md` + `/Users/imoo/work/data/project/ai-config/src/brain/knowledge/claude.md`
3. **条件**`/Users/imoo/work/data/project/ai-config/src/brain/memory/YYYY-MM-DD.md`(今日)
4. **条件**`/Users/imoo/work/data/project/ai-config/src/config/skills/claude.md`(技能索引)
5. **按需**:根据 Index Tags 读取 `/Users/imoo/work/data/project/ai-config/src/brain/knowledge/<topic>.md`
6. **写入前必读**:新增或改写 `knowledge/*.md` 前,先读取 `/Users/imoo/work/data/project/ai-config/src/brain/knowledge/knowledge-format.md`


→ 完成后回复:`记忆系统已加载`

---

brain 层里有两部分是核心:

  • knowledge:存放关键规则,可以手动维护

  • memory:存放每次对话的一些关键记录,有点像日记本

knowledge 和 memory 层,会有对应的 claude.md 及每天的日期文件,通过 claude 的自动读取机制,能很方便的读到对当前层级设定的规则。如

# Memo Index

| Date  | Summary | Tags |
|-------|---------|------|

---
**说明:**
- 每日 memory 存放在 `memory/YYYY-MM-DD.md`
- `checkpoint` 时若未更新当日 `memory`,视为 `checkpoint` 未完成
- memory 只记录今日完成的工作清单(Done),不含反思或经验
- 每条 Done 尽量写结果,不写流水账式命令记录
- 若某条结论未来可复用,应拆到 knowledge,并在 memory 中只保留简述
- Index 用于快速检索,不加载全部 memory

# Knowledge Index

| Usage | File | Summary | Tags |
|-------|------|---------|------|

---
**说明:**
- knowledge 存放可复用的稳定结论、方法论、用户偏好
- Agent 启动时只加载 `claude.md` 索引
- 新增或改写 knowledge 前,先读取 `knowledge-format.md`
- `Usage` 记录 knowledge 文件被读取和实际使用的次数,默认按次数降序排序
- 每次 checkpoint 时,基于本轮实际读取过的 knowledge 文件更新 `Usage`
- 只收录稳定规则、用户偏好、项目长期结论
- 避免记录仅对单次会话有价值的过程细节
- 需要某个主题时,按 Tags 匹配到对应文件再读取

---

记忆层的设计其实相当简单,于我而言这两块即可应对大部分情况,可根据需要进行拓展。不过过于复杂的设计,很可能导致模型上下文庞大时更新知识流程出错。

有同学可能发现了,这里只介绍了如何让 agent 读取记忆,写入记忆单独放在了后文,先介绍大致层级。

工具层

  • skill 和 agents 层其实就是做个迁移。
  • tools 层则是各种封装好的工具

这里介绍几个例子:

  1. bb-browser:也就是 ai 操控浏览器的一个浏览器插件 + cli。

  这个工具可以让 agent 直接操纵你现在浏览器,从而跳过鉴权等操作,我将使用说明置入到了 skill 中。这里会有一个很好的开发体验,在当前架构下, ai 可以同时读取插件内容、cli 内容、skill 内容,一旦出现变更,可以同步处理这三端的代码与上下文

  1. 飞书中转能力:也就是龙虾的将飞书消息与 claude 打通的能力。

  这里实际上需要本地起一个 ws 服务,用于持续让 bot 的接收飞书发来的消息,并起一个 claude 进行处理。这一步需要你提前去建好飞书应用,并且开通以下权限才能正常。

{
  "scopes": {
    "tenant": [
      "contact:user.employee_id:readonly",
      "docs:doc",
      "docs:permission.member:create",
      "docs:permission.member:delete",
      "docs:permission.member:retrieve",
      "docx:document",
      "im:chat",
      "im:chat:read",
      "im:chat:update",
      "im:message",
      "im:message.group_at_msg:readonly",
      "im:message.group_msg",
      "im:message.p2p_msg:readonly",
      "im:resource"
    ],
    "user": []
  }
}

projects 层

负责链接你的各种业务项目,能让模型获得充分的上下文

image.png

每个项目我切了两层,work 放需求级别的文件夹,code 则是放项目源码。这里我的 code 基本是软链过来的,因为我的工作目录划分了不同的文件夹,不太好直接扔到这里来。

另外我也不希望子项目的改动直接带到我的记忆系统的改动中,最多留个需求文档供复盘,所以这里的 git 我是直接忽略了 code 层。

这里涉及到了比较多的规则,还有频繁使用的软链接、需求写法等,所以我直接封装了一个 project-work 的 skill 来处理这件事,也避免给 agent 带来噪音,所有需求通用类的要求会被收敛到一起

这一层值得一提的是,由于记忆系统的存在,就算你只开了某个子项目,你也能直接在子项目的 ai 终端中读取到其他项目的上下文。

temp 层

这层就放各类的临时文件了,比如我的计划、报告、临时 git 仓库等,没有说明放置位置的文件都会被收到这里,用日期进行划分,这里只临时存放,所以也同样会被 git 忽略。

关于知识增长

上文中谈到了记忆层的读取,这里则着重于记忆层的写入。

理想情况下,我们需要大模型每次被纠错 / 了解了我们的新习惯时,都自动写入记忆到知识层

但随着上下文的增长,自动处理会逐渐跑偏甚至失效,所以我们要两手准备,即自动和手动。

手动记录

我增加了一个 checkpoint skill,当我完成当前任务,并且觉得此次对话有价值时,会发送 checkpoint 给 ai,由于在 claude.md 中约定了这个规范,ai 会自动执行该 skill

---

## Checkpoint

用户输入 "checkpoint" 时,使用 `checkpoint` skill 执行。

---

该 skill 就是一个工作流:

---
name: checkpoint workflow
 description: checkpoint 总流程 - 先沉淀 memory 与 knowledge,再更新索引,最后执行 add/commit/push
---

# Checkpoint Workflow

## 结论

- `checkpoint` 只在用户明确输入 `checkpoint` 时触发,不能按“收尾”“结束 session”等语义自动推断。
- checkpoint 的顺序固定为:回顾本轮内容 -> 写 memory -> 写 knowledge -> 更新索引 -> 自检 -> `git add` -> `git commit` -> `git push`- 不写 memory,checkpoint 不完整;没有完成知识沉淀,checkpoint 也不完整;未完成 `push`,checkpoint 也不完整。
- 用户明确输入 `checkpoint` 后,先运行 `git status`,识别已有脏改动和本轮工作范围。
- `git add` / `git commit` / `git push` 必须放在沉淀完成之后统一执行,提交前再运行一次 `git status`,确认没有遗漏文件。

## 适用规则

- 执行前先读取:
  - `src/brain/memory/claude.md`
  - `src/brain/knowledge/claude.md`
  - `src/brain/knowledge/knowledge-format.md`
  - `src/brain/knowledge/reinforced-rules.md`
- 如需补充用户偏好或错误模式,再按索引读取相关 `prefer-*.md` / `error-*.md` 文件。
- 如本轮涉及 `temp/` 写入,再读取 `src/temp/claude.md`- 写入 `memory/YYYY-MM-DD.md` 时只写 Done,不混入偏好、规则、反思。
- 写入 knowledge 时区分三类目标:
  - `knowledge/<topic>.md`:稳定结论、方法论、排查结论
  - `prefer-*.md`:稳定协作偏好
  - `reinforced-rules.md`:反复犯错且需要强制检查的规则
- 更新完内容后同步更新对应索引:
  - 新增或改写 knowledge 时,更新 `src/brain/knowledge/claude.md`
  - 更新当日 memory 时,更新 `src/brain/memory/claude.md`
- 更新 `src/brain/knowledge/claude.md` 时,同步把本轮实际读取过的 knowledge 文件 `Usage` 加一,并按 `Usage` 降序重排。
- 如果 checkpoint 开始时已存在与本轮无关的改动,先识别边界,不把无关文件误纳入提交。
- 若改动包含 `src/projects/`,不能直接提交,必须先展示 `git diff` 并等待用户确认。
- `git push` 默认属于 checkpoint 标准流程,除非用户明确说这次只提交不推送,或远端/网络阻塞导致无法完成。
- 完成后报告:
  - memory 写入位置
  - 更新过的 knowledge 文件
  - 更新过的索引
  - 执行的 git 流程
  - 若未提交,说明阻塞原因

其中,根据本轮对话更新 knowledge 与 memory 是核心,也是 agent 能持续成长的关键。

利用 checkpoint 新增的 commit,前缀我规定为了 cp-,这是为了防止跟自己手动更新的部分产生混淆,另外后续也需要根据这个标识,进行一些特殊的处理

这里 checkpoint 用其他的词也行,不过如果太常见可能容易令模型歧义

自动记录

自动记录是高频触发项,需要记录的点主要是 knowledge,此类约束需要在每次对话中都用到,所以需要加到我们的入口文件 claude.md 里

## 记忆系统(Memory Protocol)

**核心原则**:所有工作过程必须沉淀到记忆系统,不写 memory 或者知识没正确积累都视为任务未完成

| 类型 | 位置 | 要求 |
|------|------|------|
| **memory** | `memory/YYYY-MM-DD.md` | 今日完成的工作清单(Done) |
| **knowledge** | `knowledge/<topic>.md` | 可复用的稳定结论、用户偏好、方法论 |

**即时沉淀:**
- 如果在对话过程中被用户纠正,或已经确认形成了可复用的稳定结论,必须先更新对应 knowledge,再继续当前工作
- 不要等到 checkpoint 才补记这类内容,避免遗漏或表述失真
- 写入时只记录稳定结论本身,不把一次性上下文和命令过程塞进 knowledge

合理性评估

脚本侧约束

众所周知,模型的幻觉是相当严重的,在知识增长的时候很容易写错文件,比如我们已经指定了 src/brain 为记忆层的情况,它仍然可能写到外层的 /brain。

这种情况其实在项目开发中是挺常见的,比如【git 分支命名规范不对的时候,直接拦截,不允许提交】,和这个其实是类似的情况,所以理所当然的请出了我们的 husky

husky 主要负责在 git 提交过程中加入钩子,在这里的应用就是拦截不合规范的提交

我在外层的 /script 中增加了一个脚本,并配有白名单机制,只允许设置好的文件类型、目录等被提交

如此一来,模型即使出了幻觉,也会在 checkpoint 提交时被卡住,返回的错误会让 ai 重新去读取当前设置的文件规则,从而在大体架构上约束住模型。

模型侧评估

根据上面的 husky 拦截原理,我们还可以再加入一个评估 agent,用于整体评估该 checkpoint 是否符合预期。它会按照以下步骤执行:

  1. 新开一个空白的 agent,避免给自己生成的结果开后门
  2. 收集当前对话的完整流程,git diff 作为上下文
  3. 根据设定好的评估规则进行评估

评估若拒绝,则 commit 会被打断,并且将错误抛回给主流程继续处理

关于飞书豪华加强版

不得不吹的一点,当你舍弃了龙虾这些封装的好的库,你将得到无穷的灵活度。

case1: 当到饭点的时候,你的 claude code 只跑了一半,这时候就可以用手机接过该对话,放心去吃饭,原理是使用 session id 进行对话同步。

此处应该有图片,但是飞书有水印,遂放弃,看下文字版本吧。首先是 /list 指令

(我发送)/list 
(agent 开始回复)最近 5 条本地会话:  


1.  ai-config | 04-20 11:13  
    提问:好像如果 /load 回复太多的时候, 会弹出来一大串消息, 有点影响正常使用, 将这一串消息合成一条吧, 不过注意飞书消息一次最多18000…  
    回答:已经改了。 `/load` 现在会优先把“已加载对话 + 最后一个提问 + 回放内容”合成一条消息发送;只有合并后超过飞书单条 18000 字…

2.  fe-monorepo | 04-20 10:57  
    提问:先改一下我看看  
    回答:我先给你落了一版最小改动…


3.  fe-monorepo | 04-17 20:50  
    提问:是说 website 模式下  
    回答:对,website 模式下这两个接口分工是这样的…


4.  ai-config | 04-17 16:32  
    提问:你得写全, 可以自己运行看看有哪些, 并且给我补上中文介绍  
    回答:已经补完,README 现在是基于我本地实际运行 `gws --help`、`gws --help`、`gws schem…

5.  ai-config | 04-17 16:28  
    提问:apps.googleusercontent.co…  
    回答:已经写进项目了: - 新增 [`.env`](/Users/imoo/work/data/project/ai-config/src/temp…

使用 `/load <序号>` 加载指定对话。

接着是 /load 加载

(我发送)/load 2
(agent 开始回复)已加载对话 fe-monorepo | 04-20 10:57  
最后一个提问: 先改一下我看看  
后续在这个话题里的普通消息会基于这条上下文继续。  
  
我先按最小改动做一版,只碰... (这里是完整的,不过不占用篇幅了)

还有的同学会说,会不会像龙虾一样停不下来,所以我这里还提供了一个 /abort 功能,供你写错或者它理解错的时候直接终止。

case2: 当群里某个话题下讨论的热火朝天,需要用 agent 排查时,能直接 @ 介入并自动读取上下文,原理是话题 id 可以拉到完整消息

消息1:你好,现在几点

消息2:我忘了 @你

消息3:@claude200


claude200:我先按仓库规则完成会话初始化,读取必需的记忆与目录说明文件,再基于线程上下文继续回复用户。

claude200:记忆系统已加载,我补一下线程里漏掉的回复,先取当前时间,然后按飞书对话语气续上。

claude200:现在是 2026-04-20 11:34:47(CST,上海时区)。你刚才忘了 `@` 我也没事,这里补上回复。

case3:通过 mcp 直接读取飞书文档

这个倒是挺常见的,让 agent 直接读业务文档。

新增的好处则是让他记录了一下,当 mcp 授权过期时,它会直接提醒你去哪个链接再点一下,省去了查路径的问题。

case4:当你遇到新功能还需要加新特性时

比如说最近在改造对话功能时,想让他读取到对话,就先贴一个小表情。

这个功能能让我知道它正在正常工作,我直接一句话发给了它,它库库改造完成后,自动进行了服务重启。这样一来我想要什么新功能,只需要直接告诉它即可,灵活度极高,而且处理成本极低。

总之用起来一个字,爽!

screenshot-20260420-115324.png

总结

本文有两个关键点:

  1. 用一个简单的方式搭建记忆管理系统,并将记忆这部分做成不依赖平台、可迁移的方式
  2. 手搓一个链接飞书的方式,体验绝对远高于龙虾的飞书

就是这些,感谢阅读~

功能区代码块一直不能优雅折叠?2026年,我终于用这个 VS Code 插件解决了

作者 ZeroAnon
2026年4月20日 11:29

简介

沉浸编码工具集合,帮助你更专注地组织代码结构、管理常用操作并扩展编辑器能力。

官网说明页:
zeroanon.com/others/z-co…

安装后,你可以直接获得:

  • 区块模板快速插入
  • S / E#region / #endregion 双格式支持
  • 编辑器圆点提示、文本高亮、概览标尺定位
  • 区块异常提示与折叠
  • JSON / JS 转 type / interface
  • 活动栏设置面板与状态栏入口
  • 常用区块 snippets

演示预览

代码片段演示

z-code-tools 代码片段演示

演示内容:通过代码片段快速插入区块模板,支持通用、HTML、CSS、React JSX,以及 S / E#region / #endregion 两种区块风格。
完整视频:YouTube - 代码片段演示

快捷键生成演示

z-code-tools 快捷键生成演示

演示内容:通过快捷键快速选择区块类型、输入区块名称,并自动生成对应注释模板。
完整视频:YouTube - 快捷键生成演示

JSON 转 TS 演示

z-code-tools JSON 转 TS 演示

演示内容:将 JSON 或 JavaScript 对象代码快速转换为 TypeScript 的 typeinterface
完整视频:YouTube - JSON 转 TS 演示

为什么用 z-code-tools

当一个文件逐渐变大,问题往往不是“写不下去”,而是:

  • 找不到逻辑边界
  • 折叠之后仍然难定位
  • 团队区块风格不一致
  • 临时对象想转成类型时还得手工改

z-code-tools 把这些高频动作收成统一工作流,让结构整理和类型生成都更直接。

功能一览

功能 说明
区块模板插入 一键插入成对区块注释并包裹选中内容
双格式区块支持 同时支持 S / E#region / #endregion
原生折叠 区块自动注册为可折叠区域
装饰显示 圆点、文字高亮、概览标尺同步显示
异常提示 缺失开始或结束标记时给出高亮与 hover 反馈
JSON / JS 转 TS 将对象字面量快速转为 typeinterface
设置面板 在活动栏统一管理开关、主题色与常用操作
状态栏入口 右下角快速打开设置面板
Snippets 常用区块模板可直接通过片段前缀插入

快速上手

插入区块

  1. 执行命令 z-code-tools: 插入功能区块注释
  2. 选择区块类型
  3. 输入区块名称

扩展会自动生成对应注释模板,并将当前选中内容包裹进去。

生成 TypeScript 类型

  1. 执行命令 z-code-tools: json转ts类型
  2. 选择生成 typeinterface
  3. 粘贴 JSON 或 JavaScript 对象代码

扩展会自动规范化输入内容并插入生成结果。

区块类型

当前支持以下区块类型:

  • 通用注释:S / E 区块
  • 通用注释:#region 区块
  • CSS 注释:S / E 区块
  • CSS 注释:#region 区块
  • HTML 注释:S / E 区块
  • HTML 注释:#region 区块
  • React JSX 注释:S / E 区块
  • React JSX 注释:#region 区块

编辑器体验

开启装饰后,扩展会在编辑器中显示:

  • 区块起始位置的圆点标识
  • S 名称 / E 名称 / #region 名称 文本高亮
  • 概览标尺中的区块定位提示
  • 区块异常的错误高亮与悬停说明

当前支持识别:

  • 块注释形式的区块标记
  • 行注释形式的 #region 区块
  • HTML 注释形式的区块标记
  • React JSX 注释形式的区块标记
  • 带装饰星号或不带装饰星号的区块标记

JSON / JavaScript 转 TypeScript

支持两种输出形式:

  • type
  • interface

支持的输入示例:

const data = {
  id: 1,
  name: "Tom",
};
let payload = {
  list: [{ id: 1, title: "A" }],
};
export default {
  user: {
    id: 1,
    nickname: "Tom",
  },
};
{
  "success": true,
  "items": [
    {
      "id": 1
    }
  ]
}

处理流程:

  1. 选择 typeinterface
  2. 粘贴 JSON 或 JavaScript 对象代码
  3. 自动解析并规范化为标准 JSON
  4. 推导嵌套对象、数组、联合类型和命名子类型
  5. 将结果插入当前编辑器

命令

命令 ID 显示名称 说明
z-code-tools.insertRegionBlock z-code-tools: 插入功能区块注释 交互式插入区块模板
z-code-tools.openSettingsPanel z-code-tools: 打开设置页 打开活动栏设置面板
z-code-tools.insertTransType z-code-tools: json转ts类型 将 JSON / JS 对象转换为 TypeScript 类型

默认快捷键

功能 Windows / Linux macOS
插入功能区块注释 Ctrl+Alt+Z Cmd+Alt+Z
打开设置页 Ctrl+Alt+O Cmd+Alt+O
JSON 转 TS 类型 Ctrl+Alt+T Cmd+Alt+T

Snippets

片段前缀 说明
zcode 通用 S / E 区块注释模板
zcode-region 通用 #region 区块注释模板
zcode-html HTML S / E 区块注释模板
zcode-html-region HTML #region 区块注释模板
zcode-css CSS S / E 区块注释模板
zcode-css-region CSS #region 区块注释模板
zcode-react-dom React JSX S / E 区块注释模板
zcode-react-dom-region React JSX #region 区块注释模板

扩展设置

配置项 类型 默认值 说明
z-code-tools.enableDecorations boolean true 是否启用区块装饰高亮
z-code-tools.accentTheme string primary 区块圆点与开始/结束标记使用的主题色
z-code-tools.showRightBadge boolean true 是否显示区块圆点标识与概览标尺定位
z-code-tools.autoRevealSettings boolean true 扩展激活后是否自动打开设置页

可选主题色:

  • primary
  • secondary
  • tertiary
  • warm
  • sky
  • mint
  • coral
  • peach
  • rose
  • lilac

适用场景

z-code-tools 适合这些工作流:

  • 大型单文件组件整理
  • 组件模板区块拆分
  • JavaScript / TypeScript 长文件结构维护
  • CSS / SCSS / LESS 样式区域划分
  • 接口返回结构快速生成 TypeScript 类型
  • 团队统一代码区块风格

更多工具与网站

个人网站

如果你想继续看看我正在做的其他项目、文章和工具,可以访问我的个人网站:

zeroanon.com/

Horizon-Hop 浏览器插件

如果你希望把一些高频小工具留在浏览器里完成,也可以看看我做的另一款浏览器插件 Horizon-Hop

它是一套轻量但很实用的浏览器工具工作台,主要覆盖这些场景:

  • 当前网页二维码生成
  • 二维码工坊与分享海报
  • JSON 工具箱
  • 图片压缩
  • 变量命名助手
  • 表格结构整理
  • 书签搜索与功能面板

详细介绍可以查看:

Horizon-Hop 使用帮助

安装 Horizon-Hop

问题反馈

如果你在使用过程中遇到问题,或者有好的建议,欢迎提交到下方评论区 z-code-tools-help私人微信

License

MIT

别再花钱买HTTPS证书了!永久免费自动更新证书-Let's Encrypt。三步无脑安装。

2026年4月20日 11:17

Hi, 我是程序员蓝莓。HTTPS证书每次需要续费,发现Let's Encrypt的证书是免费的,可以自动更新。今天分享一下安装Let's Encrypt的3个步骤,非常简单,无脑安装就行。

  • 安装Let's Encrypt源,安装Certbot 和 Nginx 插件
  • 申请配置证书并且配置HTTPS
  • 验证自动续费

然后讲一下Let's Encrypt的故事。一、安装过程1.安装 EPEL 源(Certbot 通常在这个源里)登录服务器,服务器是CentOS 7/8。加上 --allowErasing 参数,意思是允许 yum 在冲突时卸载旧包(防止阿里云,华为云定制版)。出现Complete就是成功了。sudo

 yum install -y epel-release --allowErasing

安装Certbot 及 Nginx 插件(python3-certbot-nginx)

sudo yum install -y certbot python3-certbot-nginx

2.申请配置证书并且配置HTTPS

sudo certbot --nginx -d 域名(我的是lovecode.fun)

执行过程中的交互提示:

  1. 1.输入邮箱:系统会提示你输入邮箱地址,主要用于证书即将过期时的紧急通知。
  2. 2.同意协议:输入 A 或 Y 同意 Let's Encrypt 的服务协议。
  3. 3.是否分享邮箱:输入 N 或 Y 均可(建议 N 减少垃圾邮件)。出现下面已经成功了。Congratulations! You have successfully enabled HTTPS on lovecode.fun 现在网站已经可以用https打开了。。

3.验证自动续期

sudo certbot renew --dry-run

Certbot会自动续费,但是需要确定这个任务是成功的。二、准备工作:1.域名解析到对应的服务器上2.nginx配置中,server_name 绑定域名3.安全组:开启443端口配置0.0.0.0的ip。三.为什么Let's Encrypt免费版本这么好呢?1.2015年前,30%的网站有https。又贵:传统的CA 每个证书每年要几百美刀。又慢:流程长,需要人工审核。还得手工进行更换。2.2014年, Let's Enrypt英雄登场。

电子前哨基金会(EFF)、Mozilla、谷歌、思科、密歇根大学等联合发起,非营利组织 ISRG运营,让每一个网站用上免费的https,而且还可以自动化安装,无需手动移动证书,没有任何暗箱操作。

他的出现,让收费CA也降低收费了。

此外,Vercel也有免费额度。

上面问题,如果有问题,欢迎指出。

后续继续分析,AI,产品相关内容。欢迎多多交流。

Windows 下执行 pnpm install 报 EBUSY: resource busy or locked,我最后用这一招解决了

2026年4月20日 11:00

大家好我是舒一笑不秃头,喜欢写作和分享,更多精彩内容~

一次看起来像“依赖安装失败”的问题,最后定位下来,其实不是依赖冲突,也不是版本不兼容,而是 Windows 文件锁 + pnpm 链接机制 触发的经典问题。
如果你也遇到过下面这种报错,这篇文章可以帮你少走很多弯路。


一、问题现场

最近我在本地初始化一个前端 monorepo 项目,执行:

pnpm i

结果安装过程前面都很顺利,依赖解析、下载、写入基本都完成了,最后却在收尾阶段直接翻车:

EBUSY: resource busy or locked, symlink 
'E:\WebstormProjects\rag_web_ais\node_modules.pnpm\vue-eslint-parser@10.4.0_eslint@8.57.1\node_modules\vue-eslint-parser' 
-> 
'E:\WebstormProjects\rag_web_ais\node_modules\vue-eslint-parser'

完整报错堆栈里还能看到:

pnpm: EBUSY: resource busy or locked, symlink ...
at async Object.symlink ...
at async forceSymlink ...
at async symlinkHoistedDependency ...

看到这里,很多人第一反应可能是:

  • 是不是依赖冲突了?
  • 是不是 vue-eslint-parser 版本不对?
  • 是不是 lock 文件坏了?
  • 是不是 eslint 版本不兼容?

但实际上,这些方向大概率都不是根因。


二、先说结论:这不是“安装失败”,而是“链接失败”

这个问题的关键点在于:

pnpm 并不是像 npm/yarn 那样简单地把所有依赖平铺到 node_modules
它会先把真实依赖存到 .pnpm 目录,再通过符号链接或类似链接机制,把包映射到根 node_modules

也就是说,这次失败不是:

  • 包没下载下来
  • 依赖没解析成功
  • 某个包本身装不上

而是:

.pnpm 里的包已经准备好了,但 pnpm 在把它链接到根 node_modules 时,被 Windows 拦住了。

这个认知很重要。
因为如果你一开始方向就错了,后面会一直在版本、锁文件、依赖树上浪费时间。


三、我怎么判断它不是依赖问题?

我当时进一步做了几步验证。

1. 先看根目录目标是否存在

dir node_modules\vue-eslint-parser

结果:

File Not Found

这说明什么?

说明 pnpm 想创建的目标路径 node_modules\vue-eslint-parser,压根还没创建成功。


2. 再看 .pnpm 里面的真实包是否已经存在

dir node_modules.pnpm | findstr vue-eslint-parser

结果能看到:

vue-eslint-parser@10.4.0_eslint@8.57.1
vue-eslint-parser@9.4.3_eslint@8.57.1

这一步几乎已经把问题坐实了:

  • 包已经在 .pnpm
  • 根节点链接没建出来
  • 报错点正好是 symlink

所以根因很清楚:

不是依赖装不上,而是 Windows 在创建链接这一刻返回了 EBUSY


四、为什么 Windows 更容易出现这个问题?

这个问题在 Windows 上很典型,尤其是下面这些场景叠加时:

  • WebStorm / VS Code 正在索引项目
  • TypeScript / ESLint 后台服务扫描 node_modules
  • Windows Defender 实时查杀正在扫描新创建的目录和链接
  • 资源管理器正打开项目目录
  • 某些同步软件或文件监控工具正在监听变更

你表面上看到的是:

EBUSY: resource busy or locked

翻译成人话其实就是:

“我现在想动这个文件/目录/链接,但有别的进程正在碰它。”

这也是为什么很多人反复执行 pnpm install,永远解决不了。
因为你没有处理“占用者”,只是不断重试同一个动作。


五、我一开始也走了几条弯路

一开始我也试过这些常规动作:

1. 杀 node.exe

taskkill /F /IM node.exe

结果:

ERROR: The process "node.exe" not found.

2. 杀 WebStorm64.exe

taskkill /F /IM WebStorm64.exe

结果:

ERROR: The process "WebStorm64.exe" not found.

这说明两件事:

  • 当前不是前台 Node 进程在占用
  • 也不一定是 WebStorm 主进程名直接锁住

也就是说, “占用者存在”不代表你一定能第一时间猜对进程名。


六、真正关键的排查思路

当时我把问题拆成了两部分去判断:

第一类:是不是残留目录导致的?

比如之前失败后留下了半成品目录,下一次安装冲突。

但检查后发现:

  • 根节点 node_modules\vue-eslint-parser 不存在
  • 删除它时提示找不到文件

说明不是“旧目标没删干净”。


第二类:是不是在创建链接时被锁住了?

这一类和错误现象完全吻合:

  • .pnpm 内部包存在
  • 根层链接不存在
  • 失败点是 symlink
  • 报错是 EBUSY

所以我最后把排查重点从“依赖本身”切换到了:

Windows 文件锁 + pnpm 链接机制兼容性


七、最后真正解决我的方案:切换为 hoisted linker

我最后采用的方式非常简单:

在项目根目录 .npmrc 中加一行:

node-linker=hoisted

然后删掉 node_modules,重新安装:

rmdir /S /Q node_modules
pnpm install

结果:直接通过。


八、为什么这个方案有效?

因为默认情况下,pnpm 更依赖它自己的链接式 node_modules 结构。
而我这次出问题的恰恰就是“根层链接创建”这一步。

切换成:

node-linker=hoisted

之后,node_modules 的组织方式会更接近传统的扁平安装结构,很多 Windows 下的链接创建问题就被绕开了。

你可以把它理解成:

  • 默认模式:更严格、更节省空间、链接更多
  • hoisted 模式:兼容性更强,尤其适合某些 Windows 环境

所以这不是“乱改配置”,而是一个很典型的环境兼容性兜底策略


九、哪些信息看起来很吓人,但其实不是主因?

安装日志里还有很多 warning,比如:

  • DeprecationWarning: url.parse()
  • deprecated eslint
  • deprecated vue-i18n
  • deprecated subdependencies found

这些都很容易把人带偏。

但这类 warning 的特点是:

  • 它们是告警,不是中断点
  • 它们不会直接导致 EBUSY
  • 它们和“资源被占用”不是一个问题域

真正导致安装终止的,是最后那个:

EBUSY ... symlink ...

所以遇到这类日志时,一定要学会抓主因,不要被“满屏 warning”带跑。


十、给大家一个最短解决路径

如果你在 Windows 下执行 pnpm install,遇到类似这种错误:

EBUSY: resource busy or locked, symlink ...

我建议你直接按下面顺序处理。

方案一:先尝试常规清理

rmdir /S /Q node_modules
pnpm store prune
pnpm install

如果不行,再继续。


方案二:直接切换为 hoisted linker

.npmrc 中加:

node-linker=hoisted

然后重新安装:

rmdir /S /Q node_modules
pnpm install

这个是我最终解决问题的方案。


方案三:检查是否有后台进程占用

重点怀疑这些:

  • Windows Defender
  • IDE 索引进程
  • 资源管理器
  • 同步软件
  • 文件监控工具

如果你想更严谨地查,可以用资源监视器搜项目目录名,或者搜报错里的包名。


十一、我的建议:什么时候该用这个方案?

适合直接上 node-linker=hoisted 的情况

  • 你在 Windows 环境开发
  • 项目是 monorepo
  • pnpm install 经常在链接阶段报错
  • 你当前目标是先把环境稳定装起来

先别急着改的情况

  • 你在 Linux / macOS
  • 团队对 pnpm 默认结构依赖很强
  • 当前只是偶发一次文件锁问题
  • 你更想优先治理 IDE/杀毒/同步软件占用

也就是说:

node-linker=hoisted 更像是一种工程上的稳定性兜底方案,而不是唯一正确答案。


十二、这次问题给我的最大启发

很多安装问题,表面上看是“包管理器报错”,但本质上可能是:

  • 操作系统文件锁
  • 工具链目录结构机制
  • IDE/安全软件/同步进程的干扰
  • 环境兼容性问题

真正的排障思路应该是:

1. 先判断失败发生在哪一层

  • 依赖解析?
  • 包下载?
  • 文件写入?
  • 链接创建?

2. 再判断是“内容问题”还是“机制问题”

  • 是版本冲突?
  • 还是文件系统行为?

3. 最后再决定是“修根因”还是“换路径”

  • 治理 Defender / IDE 占用
  • 或者改用 hoisted 这种更兼容的模式

这比一上来就删锁文件、换镜像、降版本,要高效得多。


十三、最终结论

这次问题的本质不是:

  • vue-eslint-parser 有问题
  • eslint 有问题
  • pnpm 坏了
  • 依赖冲突了

而是:

Windows 环境下,pnpm 在创建根层链接时被系统占用机制拦住了。

我最终通过下面这行配置解决:

node-linker=hoisted

然后重新安装,问题消失。


十四、给同样踩坑同学的一句话建议

如果你在 Windows 下遇到:

EBUSY: resource busy or locked, symlink ...

不要一上来怀疑依赖版本。

先想一件事:

“是不是包已经装好了,只是在建立链接的时候被系统锁住了?”

一旦你从这个角度切进去,定位速度会快很多。


十五、可直接复制的最终解决方案

# .npmrc
node-linker=hoisted
rmdir /S /Q node_modules
pnpm install

十六、结尾

如果你也在 Windows + pnpm + monorepo 环境里踩过类似的坑,欢迎在评论区聊聊你遇到的是:

  • EBUSY
  • EPERM
  • symlink
  • rename
  • unlink

这类问题我后面也可以继续整理一篇 Windows 前端工程环境疑难杂症排障手册

【节点】[Lerp节点]原理解析与实际应用

作者 SmalBox
2026年4月20日 10:59

【Unity Shader Graph 使用与特效实现】专栏-直达

在 Unity URP 渲染管线中,ShaderGraph 作为一款可视化着色器编辑工具,使开发者无需编写复杂代码即可实现高级材质效果。Lerp 节点作为 ShaderGraph 的核心数学运算节点之一,承担线性插值的关键功能,广泛应用于颜色过渡、纹理混合与动画控制等场景。本文将从原理、应用及实战技巧三个维度深入解析 Lerp 节点,并通过新增的案例分析帮助开发者掌握这一重要工具。

Lerp 节点核心原理

线性插值数学基础

Lerp 节点基于线性插值公式 a + (b - a) * t 实现平滑过渡,其中:

  • ab 为输入值(可为颜色、向量、浮点数等)
  • t 为插值器(取值范围为 0 到 1 的浮点数,用于控制权重分配)

t = 0 时输出 at = 1 时输出 bt = 0.5 时输出 ab 的中间值。这一特性使其成为参数平滑调节的理想选择。

节点功能特性

  • 多维度支持:可处理颜色(RGBA)、向量(2D/3D/4D)及浮点数等多种数据类型
  • 动态控制:通过外部参数(如粒子系统、顶点颜色)实时调整 t
  • 性能优化:底层通过 HLSL 代码实现高效计算,适用于移动端及主机平台
  • 精度控制:支持半精度和全精度浮点运算,可根据目标平台灵活调整

Lerp 节点应用场景

颜色过渡动画

昼夜交替效果:通过随时间变化的 t 值,实现天空颜色从日落到黑夜的平滑过渡。例如:

  • 输入 a 为白天蓝色(0, 0.8, 1)
  • 输入 b 为夜晚深蓝色(0.05, 0.1, 0.2)
  • 使用 Time 节点驱动 t 值,实现自动渐变

角色生命值指示器:在游戏UI或角色材质中,通过 Lerp 节点实现生命值颜色从绿色(满血)到红色(低血量)的直观变化,t 值由角色当前生命值比例驱动,增强游戏反馈的视觉表现力。

环境氛围调节:在开放世界游戏中,通过 Lerp 节点实现不同生物群系间的颜色过渡,例如从森林的翠绿色渐变到沙漠的金黄色,t 值由玩家位置与区域边界的距离决定,创造无缝的世界体验。

纹理混合

基于遮罩的混合:利用黑白纹理作为 t 值,实现两幅纹理的像素级混合:

  • 输入 a 为基础纹理(如草地)
  • 输入 b 为叠加纹理(如雪地)
  • 输入 t 为遮罩纹理(白色区域显示 b,黑色区域显示 a
  • 通过 Remap 节点将纹理灰度值转换为 0-1 范围

动态水面反射:结合法线贴图与反射纹理,使用 Lerp 节点根据视角角度混合水面高光与反射细节,t 值由视角向量与水面法线的点积计算得出,实现更真实的水面光学效果。

材质磨损效果:在写实类游戏中,通过 Lerp 节点混合新旧两种材质状态,t 值由使用时间或物理碰撞次数驱动,实现武器、装备的自然磨损表现。

位置动画

物体移动控制:使物体从起始位置线性移动到目标位置:

  • 输入 a 为起始位置(如 (0, 0, 0)
  • 输入 b 为目标位置(如 (5, 0, 0)
  • 输入 t 为动画进度(0-1 的浮点数)
  • 输出位置可驱动物体变换组件实现平滑移动

摄像机轨道运动:在过场动画中,通过多个 Lerp 节点串联实现摄像机沿预定路径的平滑移动,每个节点控制一段路径的过渡,结合 Smoothstep 节点优化运动曲线,消除机械感。

布料模拟辅助:在角色服装系统中,通过 Lerp 节点在布料物理模拟的关键点之间进行插值,减少计算开销的同时保持视觉上的自然摆动。

进阶技巧与优化

与自定义节点配合

通过创建 Custom 节点扩展功能:

  • 复刻蓝图节点:编写 HLSL 代码实现 lerp(A, B, L) 功能,保留参数化接口
  • 模糊效果:结合 SceneColor 节点实现热扭曲效果,t 值控制扭曲强度
  • 高级混合模式:通过自定义节点实现非线性插值,如正弦曲线过渡,为特定艺术风格提供支持

性能优化策略

  • 预计算序列图:将多张模糊纹理合成为 2×2 序列图,减少采样次数
  • 动态参数控制:使用粒子系统 Custom 节点或顶点颜色单通道驱动 t 值,降低计算开销
  • 条件优化:在移动端项目中,对于不需要实时变化的插值效果,可将 t 值烘焙为常量或使用预计算纹理,显著减少片元着色器的动态分支开销。
  • LOD系统集成:根据物体与摄像机的距离,使用不同复杂度的 Lerp 节点网络,远距离时使用简化的单次插值,近距离时启用多层混合,实现性能与质量的智能平衡。

常见问题解决

  • 透明材质兼容性:启用 RenderPipelineAssess 中的 OpaqueTexture 选项,确保 SceneColor 节点正确采样
  • UV 偏移控制:通过 Gather Texture 2D 节点获取相邻像素,实现精细的纹理过渡效果
  • HDR颜色处理:当处理高动态范围颜色时,建议在插值前对输入颜色进行 Tonemapping 处理,避免插值过程中出现不自然的过曝或色偏现象。
  • 伽马校正:在涉及颜色插值时,需注意线性空间与伽马空间的转换,确保插值结果在视觉上的准确性。

实战案例:景深模糊效果

实现步骤

  1. 创建材质:新建 URP 材质,选择 PBR Graph 模板
  2. 构建节点网络
    • 使用 TexObject 节点采样主纹理
    • 创建四个 Texture Sample 节点,分别采样不同模糊程度的序列图
    • 通过 Lerp 节点实现多级模糊过渡,t 值由距离参数驱动
  3. 参数化控制:将 t 值暴露为材质属性,支持运行时动态调整

效果对比

  • 近距离t 值 0.2,模糊强度弱
  • 中距离t 值 0.5,模糊强度中
  • 远距离t 值 0.8,模糊强度强

扩展应用:动态天气系统

通过组合多个 Lerp 节点,实现雨雪天气的渐进变化:

  • 第一级 Lerp 控制降水强度,t 值由天气系统参数驱动
  • 第二级 Lerp 混合干湿路面材质,t 值由降水强度和持续时间共同决定
  • 第三级 Lerp 调整环境光颜色,模拟阴天到晴天的过渡

总结与拓展

Lerp 节点作为 ShaderGraph 的基石,其应用远不止于基础过渡。开发者可结合以下方向深入探索:

  • 高级混合技术:与 Gather Texture 2D 节点配合实现边缘检测
  • 动态效果:通过 Time 节点驱动 t 值,创建周期性动画
  • 跨平台优化:针对移动端简化插值计算,保持性能与质量的平衡
  • VR/AR适配:在虚拟现实和增强现实应用中,通过 Lerp 节点实现虚实融合的平滑过渡,增强沉浸感
  • 性能分析工具:结合 Unity Profiler 监控 Lerp 节点在不同硬件上的执行效率,为优化提供数据支持

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

管理后台框架 AI 时代的版本答案,Fantastic-admin 6.0 它来了!

作者 Hooray
2026年4月20日 10:24

之前写了一篇《AI 时代的管理后台框架,应该是什么样子?》文章,我的一些见解得到了蛮多人的认同。

如果说那篇文章是我对 AI 时代管理后台框架的全部理解,那这篇文章就是从理论到落地的一份完美答卷。

那就少废话,直接看东西。

文章内包含部分专业版特性。

v6-released.png

AI Skills

Fantastic-admin 6.0 最核心的一点,是把后台开发里常见的高频操作沉淀成了一套可复用的 AI Skills,这是一套和 Fantastic-admin 目录结构、组件用法、路由方式、设置体系绑定的工作流,让 AI 从一开始就按框架规则工作。

更重要的是,我想解决的不是“AI 能不能写后台页面”,而是“当你在使用 Fantastic-admin 时,AI 能不能像我(作者)一样,熟悉并按照框架的规则稳定交付”。这是两个完全不同的问题,而前者是一个概率问题,后者才是真正能落地的生产力。

以下是目前提供的 Skills :

  • fa-crud-page-generator:生成完整 CRUD 模块
  • fa-form-builder:生成独立表单页
  • fa-framework-settings:修改框架设置
  • fa-i18n-manager:管理国际化
  • fa-page-optimizer:优化页面并替换为框架内建组件
  • fa-route-generator:创建或修改路由
  • fa-slot-creator:创建布局插槽
  • fa-store-generator:生成 Store 模块
  • fa-theme-customizer:定制主题配色

你可以非常直接地告诉它:

  • 主题切换成蓝色,默认深色模式,不需要圆角;导航菜单改为顶部模式,风格为圆点;启用标签栏,风格选择现代,并且要在工具栏下方展示;工具栏开启收藏夹;最后开启页面水印
  • 生成一个黑客帝国风格的主题,创建好后直接使用,同时默认为深色模式
  • 做一个商品管理模块,支持搜索、分页、新增、编辑、删除,并使用假数据,最后配置一个可访问的一级路由
  • 给xx页面增加国际化支持

通常 AI 会根据你的描述信息,自动调用相关的 skill ,当然你也可以更明确的告诉 AI 使用哪一个 skill ,就像这样:

  • claude code:/fa-framework-settings 改为顶部导航栏模式
  • codex:$fa-framework-settings 改为顶部导航栏模式

这里我也上传了几个视频,方便大家能直观的看到使用 skill 的方式和效果:

fa-framework-settings 演示视频 fa-theme-customizer 演示视频 fa-crud-page-generator 演示视频 fa-i18n-manager 演示视频

Monorepo

Fantastic-admin 6.0 采用了 pnpm monorepo 架构。

这么做有工程治理层面的考虑,但更重要的是,我希望“代码、文档、约定、技能”能够在同一个仓库里形成闭环。对于人来说,这是更清楚的工程边界;对于 Agent 来说,这是一张更完整的信息地图。

fantastic-admin/
├── apps/              # 应用目录
│   ├── core           # 应用源码
│   └── example        # 示例应用
├── packages/          # 公共包目录
├── docs/              # 文档站点
├── scripts/           # 脚本工具
├── skills/            # AI 技能
└── package.json       # 根目录 package.json

这样的结构对长期项目特别重要,因为它天然更适合多应用扩展、公共能力沉淀和后续维护,也更方便 AI 理解“哪些是业务代码,哪些是框架能力”。

了解更多点这里

80+ 内建组件

Fantastic-admin 6.0 给 5.0 的内建组件做了全方位的重构,并且新增了以下组件:

至此,Fantastic-admin 的内建组件数量也来到了 80+ ,即便你不使用 Element-plus / Ant Design Vue / NaiveUI 这些第三方 UI 组件库,仅靠框架提供的内建组件,也能构建出大部分业务页面。

并且更重要的一点是,比起第三方组件库的“可调用”,内建组件是“可修改”的,并且每个组件目录内都有完整的 markdown 使用文档。

因为在 AI 时代,一个被黑盒包裹得太深的组件体系,长期价值其实会下降。并且 AI 擅长的也不是调用 API ,而是:

  • 阅读现有代码
  • 理解现有代码
  • 修改现有代码
  • 基于现有代码继续延展

组件满足不了业务需求?随时可以让 AI 来先读再改,分分钟定制一份专属组件,这是使用第三方组件库基本不敢想的事。

说明:Fantastic-admin 内建组件的定位并不是代替第三方组件库,而是提供了一些更贴合业务场景、美化视觉交互、风格尽量和框架保持一致的组件,通常是作为第三方组件库的补充。

了解更多点这里

19 个预留插槽

Fantastic-admin 6.0 增加了布局顶部和底部插槽,支持的插槽数量也来到了 19 个。

你问什么是预留插槽?就是允许开发者在一定限度内满足客制化的需求,并且无需修改框架核心部分源码,这也是大部分后台框架没有提供的能力。

而通过这个能力,可以在框架各个区域扩展属于自己产品的内容,比如:

  • 网站顶部的横幅公告

  • 标题右侧的切换组织功能

  • 网站底部支持伸缩的站点地图

了解更多点这里

锁屏

了解更多点这里

多账号管理

了解更多点这里

路由级的页面布局配置

除了全局设置页面布局,现在可以针对每个路由单独设置页面布局。

了解更多点这里

区域权限控制

在做国际化业务场景时,可以对某个路由做区域访问限制。例如某个模块,只允许中文用户访问,其他语言则无法访问。

了解更多点这里

RTL 模式跟随语言设置

在 v5.x 里,RTL 模式是一个配置项,可以开启或关闭,但这其实并不合理,因为可能会出现明明是中文界面,却误开启了 RTL 。

现在将 RTL 这个开关移除并收纳进了语言信息中,也就是当用户切换语言的时候,如果该语言是需要 RTL 的,框架会自动开启。

了解更多点这里

偏好设置支持更细粒度的自定义

几乎所有同类的后台框架都没有提供偏好设置这个能力,而是固定将几个配置项做了本地存储,例如主题、导航栏模式。

而我在 v5.x 里就已经提供了一份偏好设置的方案,只不过当时的方案并不完美,需要通过注释或取消注释代码的方式,才能将部分框架能力开放给用户自定义,并且也不支持更细粒度的自定义。

但在 6.0 里一切都解决了,除应用配置外,框架其余 40+ 个配置项(涵盖主题、导航菜单、顶栏、标签栏、工具栏、页面),均可以轻松开启偏好设置,开启的配置项则用户可以根据使用习惯自行调整。

了解更多点这里

还有吗?

没有了,6.0 的新特性大概就是以上这些。

但考虑到大部分人可能是第一次了解到 Fantastic-admin ,我再介绍几个 6.0 版本之前就有提供,并且也是广受好评的特性。

7 款导航菜单模式

自由选择 UI 组件库

框架提供了 Ant Design Vue / Antdv Next / Arco Design Vue / Naive UI / Tdesign / Vexip UI 6 款组件库的预设模版,开箱即用,免去你自己集成。

当然你也可以自行集成其他的 UI 组件库,比如公司内部的,框架提供了统一的接入入口,方便快速更换。

了解更多点这里

可控的保活策略

页面保活这件事,很多框架都做得太粗糙了,通常只提供一个 keepAlive: true 的开关,虽然能解决一部分问题,但真实后台项目的诉求往往更复杂:

  • 从列表进详情,希望列表保活
  • 从列表跳其他模块,希望列表不保活
  • 标签页合并(Fantastic-admin专有功能)后,进入某些页面要保活,进入某些页面又必须释放保活

框架提供了一套精细化的保活策略配置,满足复杂业务场景。

了解更多点这里

标签页合并

提供了两种合并模式:

  1. 根据 routeName 合并,比如反复从列表页多次打开详情页,始终保持一个详情页(标签页)

  1. 根据 activeMenu 合并,比如一个模块内,列表、详情、编辑页(或者更多相关页面),始终保持只有一个标签页

标签页行为和路由行为保持一致

后台框架通常会提供一些标签页的 API ,比如打开、关闭等,但在 Fantastic-admin 里,提供了进一步的加强。

  • 后退自动关闭标签页,调用 router.go(delta) 时会关闭当前标签页,通常在详情页返回列表页时会用到
  • 替换当前标签页,调用 router.replace(to) 时会直接更新当前标签页,而不是新打开一个
  • 关闭标签页,扩展了一个路由的 API ,调用 router.close(to) 时会关闭当前标签页,并新打开一个目标路由的标签页

这3个行为和路由的行为预期保持了一致,优势就是开发者通常不再需要关注标签页的 API 了,正常处理路由跳转时,标签栏会自动做处理。

了解更多点这里

所以,为什么说 Fantastic-admin 是 AI 时代的版本答案?

相信看到这里,答案已经不言而喻了。

围绕着 monorepo 搭建的工程底座,让“代码、文档、约定、技能”能够在同一个仓库里形成闭环,从而实现长期演进。

结合 AGENTS.md 和 Skills ,让 AI 每次执行任务不再是重新了解,而是有明确的指导方针。

最后搭配上 Fantastic-admin 出色的系统设计,兼顾“人类开发者的效率”和“AI 协作的稳定性”。

fantastic-admin.hurui.me_.png

如果你需要一个要长期维护、持续扩展、并且希望真正把 AI 引入开发流程的项目,那么 Fantastic-admin 6.0 全新版本值得你看看。

❌
❌