普通视图

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

前端实现表格下拉框筛选和表头回显和删除

作者 成小白
2025年8月18日 17:23

前言

公司项目要实现在表格上通过表头添加筛选条件,筛选后再表格上方回显筛选后的数据可单独删除和全部删除。 效果如下:

动画.gif

实现思路

下拉筛选
  • 传递的给后端的数据为单个值时(如多选地区等):使用v-model双向绑定来传递数据
  • 传递的给后端的数据为两个值时(如时间段筛选 开始时间-结束时间等):使用v-bind传递数组来进行获取和设置
表头筛选条件
  • 通过下拉筛选改变的值所组装的数据来进行回显
  • 在表格组件里面传递所有传递给后端的值一个对象用来改变传递的数据

代码实现

下拉筛选代码

<template>
    <div class="dropDownFilter-container">
        <el-dropdown class="elDropdownBox" ref="dropDownFilterElDropDownRef" trigger="click" :hide-on-click="false" @visible-change="visibleChange">
            <div class="titleBox" ref="dropDownFilterTitleBoxRef">
                <div class="title">{{ title }}</div>
                <div class="titleIcon">
                    <i class="iconfont  iconBox" :class="[isFiltered?'icon-yishaixuan':'icon-sangedian']"></i>
                </div>
            </div>
            <el-dropdown-menu slot="dropdown" placement="bottom-end">
                <el-dropdown-item class="elDropContainer">
                    <!--单个 日期选择器 -->
                    <template v-if="['date','dateYear','dateMonth'].includes(type)">
                        <el-date-picker v-model="internalValue" :format="dateFormat" :value-format="dateFormat" :type="dateType" placeholder="选择日期" @change="dateChange" :popper-class="className">
                        </el-date-picker>
                    </template>
                    <!-- 日期时间范围选择器 -->
                    <template v-if="['dateRange','dateRangeMonth','dateRangeTime'].includes(type)">
                        <el-date-picker v-model="internalValue" :format="dateRangeFormat" :value-format="dateRangeFormat" :type="dateRangeType" @change="dateRangeChange" :append-to-body="false" range-separator="~" start-placeholder="开始日期" end-placeholder="结束日期" :clearable="false" :popper-class="className">
                        </el-date-picker>
                    </template>
                    <!-- 多选复选框 -->
                    <template v-if="type === 'checkbox'">
                        <div class="searchBox">
                            <el-input v-model="searchValue" placeholder="请输入内容"></el-input>
                        </div>
                        <div class="container">
                            <el-checkbox-group v-model="checkValue" @change="checkChange">
                                <div class="checkboxBox">
                                    <div v-for="item in selectOptions" :key="item[optValue]" class="checkItem">
                                        <el-checkbox :label="item[optValue]">{{item[optLabel]}}</el-checkbox>
                                    </div>
                                </div>
                            </el-checkbox-group>
                        </div>
                    </template>
                    <!-- 省市区 父子关联 -->
                    <template v-if="type ==='area'">
                        <el-cascader v-model="areaValue" ref="areaCascadredropDownFilterRef" placeholder="请选择" :options="areaList" @change="areaChange" :props="{label:optLabel,value:optValue,children:optChildren,multiple:true}" :append-to-body="false" :show-all-levels="false" clearable size="mini" collapse-tags></el-cascader>
                    </template>
                    <!-- 多级选择器 -->
                    <template v-if="type ==='cascader'">
                        <el-cascader v-model="areaValue" ref="cascadredropDownFilterRef" placeholder="请选择" :options="selectOptions" @change="cascaderChange" :props="{label:optLabel,value:optValue,children:optChildren,multiple:true}" :append-to-body="false" :popper-class="className" :show-all-levels="false" clearable size="mini" collapse-tags></el-cascader>
                    </template>
                    <!-- 输入框范围 -->
                    <template v-if="type ==='inputRange'">
                        <div class="inpitRangeBox">
                            <div class="iColBox">
                                <el-input v-model.number.trim="minNum" placeholder="请输入最小值" class="focusVisibleOutLine" style="width: 110px"></el-input>
                                <span class="iline" style="margin: 0 5px"></span>
                                <el-input v-model.number.trim="maxNum" placeholder="请输入最大值" class="focusVisibleOutLine" style="width: 110px"></el-input>
                            </div>
                            <div class="tipBox" v-if="tipText">{{ tipText }}</div>
                            <div class="inBtnBox">
                                <span class="clearBtn" @click="clearInputRange">清空</span>
                                <el-button size="mini" type="primary" @click="inputRangeChange">确定</el-button>
                            </div>
                        </div>
                    </template>
                </el-dropdown-item>
            </el-dropdown-menu>
        </el-dropdown>
    </div>
</template>

<script>
/**
 *  下拉框组件
 * 
 *  使用方法: 配合headerFilter使用,headerFilter是表头筛选条件组件 
 *     
 *  @type: 类型 
 *      
 *   1.单个日期选择器------date 日        dateMonth 月            dateYear 年
 *   2.日期范围选择器------dateRange 日   dateRangeTime 时分秒    dateRangeMonth 月 
 *   3.复选框-------------checkbox
 *   4.省市区选择器--------area
 *   5.多级选择器---------cascader
 *   6.输入框范围选择器----inputRange
 *  
 *  
 *   1.单个日期选择器
 *    <DropDownFilter type="date" title="日期" v-model="reqParams.dateData" valueKey="dateData"  @dropDownFilterChange="dropDownFilterChange" />
 *   2.日期范围选择器
 *    <DropDownFilter type="dateRange" title="时间" :valueKey="['startSubmitTime','endSubmitTime']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
 *   3.复选框
 *     <DropDownFilter type="checkbox" title="来源" v-model="reqParams.source"  valueKey="source"  :options="aOpt" optLabel="dictName" optValue="dictCode" @dropDownFilterChange="dropDownFilterChange" />
 *   4.省市区选择器
 *     <DropDownFilter type="area" title="地区" v-model="reqParams.cityList"  valueKey="cityList"  optValue="id" optChildren="children" @dropDownFilterChange="dropDownFilterChange" />
 *   5.多级选择器
 *     <DropDownFilter type="cascader" title="国标行业" v-model="reqParams.industryList" valueKey="industryList" :options="industryNameIdList" optValue="id" @dropDownFilterChange="dropDownFilterChange" />
 *   6.输入框范围选择器
 *     <DropDownFilter type="inputRange" title="积分" :valueKey="['pointsCountStart','pointsCountEnd']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
 * 
 * 
 *  // 下拉筛选改变触发
    dropDownFilterChange(params, assembleData) {
        this.selectedFilters = this.selectedFilters.filter(item => item.title !== assembleData.title);
        if (assembleData.value) {
            this.selectedFilters.push(assembleData);
        }
        // 自己的逻辑
        // this.reqParams.startIndex = 1
        // this.getPageList()
    }
    
    属性
 * @valueKey 选中值key 如果是单个日期选择器,则传字符串 ,如果是日期范围选择器,则传数组
 * @params  对象参数 
 * @options 下拉框选项
 * @optLabel 下拉框选项名字key
 * @optValue 下拉框选项vakue-key
 * @optChildren 下拉框选项子级key
 * 
 * 方法
 * @dropDownFilterChange 下拉筛选改变触发
 * 
 *
 */
import { getCityTree } from '@/api/common'
export default {
    props: {
        // 类型
        type: {
            type: String,
            default: ''
        },
        // 标题
        title: {
            type: String,
            default: ''
        },
        // 选中值key 如果是单个日期选择器,则传字符串 ,如果是日期范围选择器,则传数组
        valueKey: {
            type: [String, Array],
            default: ''
        },
        //  v-model双向绑定
        value: {
            type: [String, Array, Number, Object],
            default: ''
        },
        // 下拉框选项
        options: {
            type: Array,
            default: () => []
        },
        // 下拉框选项名字key
        optLabel: {
            type: String,
            default: 'label'
        },
        // 下拉框选项vakue-key
        optValue: {
            type: String,
            default: 'value'
        },
        // 下拉框选项子级key
        optChildren: {
            type: String,
            default: 'children'
        },
        // 对象参数
        params: {
            type: Object,
            default: () => { }
        },
        // 下拉框选项是否右对齐
        isPopperRight: {
            type: Boolean,
            default: false
        }
    },
    data() {
        return {
            className: '',
            // 多选框选中的数据
            checkValue: [],
            // 单项选择选中的数据
            internalValue: '',
            // 搜索框
            searchValue: '',
            // 下拉框选项
            selectOptions: [],
            // 地区数据
            areaList: [],
            areaValue: [],
            // 选择后回显的名字
            selectNameList: [],
            minNum: '',
            maxNum: '',
            tipText: '',
            // 是否被筛选
            isFiltered: false
        }
    },
    watch: {
        // 监听下拉框选项数据
        options: {
            handler(val) {
                this.selectOptions = val
            },
            immediate: true,
            deep: true
        },
        // v-model双向绑定 
        value: {
            handler(val) {
                let typeS = this.type
                if (!val || val.length == 0) {
                    this.isFiltered = false
                }
                if (typeS == 'checkbox') {
                    this.checkValue = val
                } else if (typeS == 'area' || typeS == 'cascader') {
                    if (val.length == 0) {
                        this.areaValue = []
                        this.isFiltered = false
                    }
                } else {
                    this.internalValue = val

                }
            },
            deep: true,
        },
        // 监听 headerFilter表头筛选删除触发清空已选的数据
        params: {
            handler(val) {
                if (['dateRange', 'dateRangeMonth', 'dateRangeTime'].includes(this.type)) {
                    if (!val[this.valueKey[0]]) {
                        this.internalValue = ''
                        this.isFiltered = false
                    }
                }
                if (this.type == 'inputRange') {
                    if (!val[this.valueKey[0]]) {
                        this.internalValue = ''
                        this.minNum = ''
                        this.maxNum = ''
                        this.isFiltered = false
                    }
                }
            },
            deep: true,
        }
    },
    computed: {
        // 时间格式转换
        dateFormat() {
            switch (this.type) {
                case 'date':
                    return 'yyyy-MM-dd'
                case 'dateYear':
                    return 'yyyy'
                case 'dateMonth':
                    return 'yyyy-MM'
            }
        },
        // 时间类型转换
        dateType() {
            switch (this.type) {
                case 'date':
                    return 'date'
                case 'dateYear':
                    return 'year'
                case 'dateMonth':
                    return 'month'
            }
        },
        // 时间范围格式转换
        dateRangeFormat() {
            switch (this.type) {
                case 'dateRange':
                    return 'yyyy-MM-dd'
                case 'dateRangeMonth':
                    return 'yyyy-MM'
                case 'dateRangeTime':
                    return 'yyyy-MM-dd HH:mm:ss'
            }
        },
        // 时间范围类型转换
        dateRangeType() {
            switch (this.type) {
                case 'dateRange':
                    return 'daterange'
                case 'dateRangeMonth':
                    return 'monthrange'
                case 'dateRangeTime':
                    return 'datetimerange'
            }
        }
    },
    mounted() {
        if (this.type === 'area') {
            this.getAreaList()
        }
        this.getElementPostion()
    },
    methods: {
        // 输入框点击清除按钮触发
        clearInputRange() {
            this.minNum = ''
            this.maxNum = ''
        },
        // 输入框点击确定按钮触发
        inputRangeChange() {
            if (!this.minNum && !this.maxNum) {
                this.tipText = '请输入最小值或最大值'
                return
            }
            if (this.minNum && this.maxNum && this.minNum > this.maxNum) {
                this.tipText = '最小值不能大于最大值'
                return
            }
            this.tipText = ''
            if (this.valueKey && this.valueKey.length == 2) {
                this.params[this.valueKey[0]] = this.minNum
                this.params[this.valueKey[1]] = this.maxNum
            }
            this.internalValue = [this.minNum, this.maxNum]
            this.selectNameList = this.internalValue
            this.visibleChange(false);
            this.triggerChange()
        },
        // 多级选择器选择触发
        cascaderChange() {
            let list = this.$refs['cascadredropDownFilterRef'].getCheckedNodes()
            list = list.filter(item => !(item.parent && item.parent.checked))
            this.internalValue = list.map(v => v.value)
            this.selectNameList = list.map(v => v.label)
            this.triggerChange()
        },
        // 地区选择触发
        areaChange(val) {
            let list = this.$refs['areaCascadredropDownFilterRef'].getCheckedNodes()
            list = list.filter(item => !(item.parent && item.parent.checked))
            this.internalValue = list.map(v => v.value)
            this.selectNameList = list.map(v => v.label)
            this.triggerChange()
        },
        // 复选框选择触发
        checkChange(val) {
            this.internalValue = val
            this.selectNameList = this.getSelectName()
            this.triggerChange()
        },
        // 时间选择器触发
        dateRangeChange(val) {
            for (let i = 0; i < this.valueKey.length; i++) {
                this.params[this.valueKey[i]] = val[i]
            }
            this.selectNameList = this.internalValue
            this.visibleChange(false);
            this.triggerChange()
        },
        // 时间选择器触发
        dateChange(val) {
            this.selectNameList = [val]
            this.visibleChange(false);
            this.triggerChange()
        },
        // 获取选中的名字
        getSelectName() {
            let selectName = []
            if (this.checkValue.length) {
                for (let i = 0; i < this.checkValue.length; i++) {
                    let item = this.selectOptions.find(v => v[this.optValue] == this.checkValue[i])
                    selectName.push(item)
                }
            }
            selectName = selectName.map(v => v[this.optLabel])
            return selectName
        },
        // 选择完成后触发
        triggerChange() {
            this.$emit('input', this.internalValue)
            // 获取当前选中值
            let dataKeyMap = {}
            // 组装数据
            let assembleData = {
                // 类型
                type: this.type,
                // 标题
                title: this.title,
                // 选中的值
                value: {},
                // 当前的key
                key: this.valueKey,
                // 选中的名字
                nameList: this.selectNameList,
            }
            // 根据类型组装数据
            if (Array.isArray(this.valueKey)) {
                for (let i = 0; i < this.valueKey.length; i++) {
                    dataKeyMap[this.valueKey[i]] = this.internalValue[i]
                }
                assembleData.value = dataKeyMap

            } else {
                dataKeyMap = { [this.valueKey]: this.internalValue }
                assembleData.value = dataKeyMap[this.valueKey]
            }
            this.isFiltered = true
            // console.log(assembleData, 'assembleData');
            this.$emit('dropDownFilterChange', dataKeyMap, assembleData)
        },
        // 下拉框显示隐藏
        visibleChange(val) {
            if (!val) {
                this.$refs.dropDownFilterElDropDownRef.hide();
            }
        },
        // 获取地区列表
        getAreaList() {
            getCityTree().then(res => {
                if (res.code == 0) {
                    this.areaList = res.data
                }
            })
        },
        // 获取当前元素所在位置来判断下拉是在左开还是右开
        getElementPostion() {
            let el = this.$refs.dropDownFilterTitleBoxRef
            if (el) {
                let elLeft = el.getBoundingClientRect().left
                let sceenWidth = window.innerWidth
                if (sceenWidth - elLeft < 400) {
                    this.className = 'elDropDownFilterPopstionRight'
                }
            }
            if (this.isPopperRight && (this.type == 'area' || this.type == 'cascader')) {
                this.className = 'elDropDownFilterPopstionRight'
            }
        }
    }
}
</script>

<style>
.elDropDownFilterPopstionRight {
    right: 0 !important;
    left: auto !important;
}
</style>

<style lang="scss" scoped>
.inpitRangeBox,
.iline,
.tipBox,
.clearBtn,
.focusVisibleOutLine {
    &:focus,
    &:focus-visible {
        outline: unset;
    }
    &:hover {
        background: #fff;
    }
}
.el-dropdown-menu__item:focus,
.dropDownFilter-container {
    width: 100%;
    &:focus,
    &:focus-visible {
        outline: unset;
    }
    .elDropdownBox {
        width: 100%;
        .titleBox {
            display: flex;
            align-items: center;
            justify-content: space-between;
            &:focus,
            &:focus-visible {
                outline: unset;
            }
            .iconBox {
                cursor: pointer;
            }
        }
    }
}
.elDropContainer {
    // all: unset;
    padding: 0px 10px !important;
    border-radius: 4px;
    &:hover {
        background: #fff;
    }

    .container {
        width: 264px;
        .checkboxBox {
            .checkItem {
                width: 100%;
                line-height: 30px;
                height: 30px;
                font-size: 12px;
                &:hover {
                    background: #edecf0;
                }
            }
        }
    }
    .inpitRangeBox {
        padding: 10px 0;
        position: relative;
        .tipBox {
            font-size: 12px;
            color: red;
            position: absolute;
            line-height: 16px;
        }
        .inBtnBox {
            margin-top: 10px;
            display: flex;
            justify-content: flex-end;
            align-items: center;
            .clearBtn {
                margin-right: 10px;
                color: #0052cc;
            }
        }
    }
}
</style>

表头筛选代码

<template>
    <div class="headerFilter-container" v-if="selectData.length">
        <div class="container">
            <span>表头筛选条件:</span>
            <div class="switchBox">
                <i class="el-icon el-icon-caret-left"></i>
            </div>
            <div class="rowBox">
                <div class="colBox" v-for="item in selectData" :key="item.title">
                    <div class="left">
                        <div class="name"> {{ item.title }}:</div>
                        <div class="value">
                            <template v-if="['dateRange','inputRange','dateRangeMonth','dateRangeTime'].includes(item.type) ">
                                {{ item.nameList.join('~') }}
                            </template>
                            <template v-else>
                                {{ item.nameList.join(',') }}
                            </template>

                        </div>
                    </div>
                    <div class="iconBox" @click="removeFilter(item)">
                        <i class="el-icon-close"></i>
                    </div>
                </div>
            </div>
            <div class="switchBox">
                <i class="el-icon el-icon-caret-right"></i>
            </div>
            <div class="blueText mt4 cura" @click="clearAll">清除全部</div>
        </div>
    </div>
</template>

<script>
/**
 *  表头筛选条件组件 
 * 
 *    使用方法:配合dropDownFilter组件使用
 * 
 *       <HeaderFilter :option="selectedFilters" :params="reqParams" @change="headerChange" />
 *  
 *      // 表头筛选改变触发
        headerChange(val, data) {
            for (const key in val) {
                this.reqParams[key] = val[key]
            }
            this.selectedFilters = data
            // 自己的逻辑
            // this.reqParams.startIndex = 1
            // this.getPageList()
        }

        // 下拉筛选改变触发
        dropDownFilterChange(params, assembleData) {
            this.selectedFilters = this.selectedFilters.filter(item => item.title !== assembleData.title);
            if (assembleData.value) {
                this.selectedFilters.push(assembleData);
            }
            // 自己的逻辑
            // this.reqParams.startIndex = 1
            // this.getPageList()
        }

        属性
        @option 筛选条件数组
        @params 请求参数

        方法
        @change 筛选条件改变触发
 */
export default {
    props: {
        option: {
            type: Array,
            default: () => []
        },
        params: {
            type: Object,
            default: () => { }
        }
    },
    data() {
        return {
            selectData: this.option
        }
    },
    watch: {
        option: {
            handler(val) {
                if (val && val.length) {
                    this.selectData = val.filter(v => v.nameList && v.nameList.length)
                }
            },
            immediate: true,
            deep: true
        }
    },
    methods: {
        // 点击清除全部按钮
        clearAll() {
            for (let i = 0; i < this.selectData.length; i++) {
                this.changeData(this.selectData[i])
            }
            this.selectData = []
            this.$emit('change', this.params, [])
        },
        // 点击删除按钮
        removeFilter(item) {
            let index = this.selectData.findIndex((i) => i.title === item.title)
            if (index !== -1) {
                this.changeData(item)
                this.selectData.splice(index, 1)
                this.$emit('change', this.params, this.selectData)
            }
        },
        // 改变数据
        changeData(item) {
            if (Array.isArray(item.value)) {
                this.params[item.key] = []
            } else if (typeof item.value === 'string') {
                this.params[item.key] = ''
            } else {
                for (const key in item.value) {
                    this.params[key] = ''
                }
            }
        }
    }
}   
</script>

<style lang="scss" scoped>
.headerFilter-container {
    .container {
        display: flex;
        align-items: center;
        .rowBox {
            display: flex;
            align-items: center;
            gap: 10px;
            .colBox {
                width: 230px;
                padding: 0 10px;
                height: 24px;
                line-height: 24px;
                background-color: #f2f5f9;
                border-radius: 4px;
                display: flex;
                align-items: center;
                font-size: 12px;
                overflow: hidden;
                .left {
                    flex: 1;
                    flex-shrink: 0;
                    display: flex;
                    align-items: center;
                    overflow: hidden;
                    .name {
                        white-space: nowrap;
                    }
                    .value {
                        overflow: hidden;
                        text-overflow: ellipsis;
                        white-space: nowrap;
                    }
                }
                .iconBox {
                    width: 16px;
                    margin-left: 6px;
                    cursor: pointer;
                }
            }
        }
        .switchBox {
            width: 20px;
            height: 24px;
            line-height: 24px;
            margin: 0 10px;
            cursor: pointer;
            background-color: #fff;
            text-align: center;

            .el-icon-caret-left {
                color: #172b4d;
            }
        }
    }
}
</style>

使用

  • 在表格页面分别引用这两个组件
  • import HeaderFilter from '@/components/headerFilter/index.vue'
  • import DropDownFilter from '@/components/dropDownFilter/index.vue'
  <template>
    <div style="padding: 20px;">
        <!-- 表头筛选选择项 -->
        <div class="headBox" v-if="selectedFilters.length">
            <HeaderFilter :option="selectedFilters" :params="reqParams" @change="headerChange" />
        </div>
        <!-- 表格 -->
        <el-table :data="tableData">
            <el-table-column prop="address">
                <template #header>
                    <DropDownFilter v-model="reqParams.cityList" title="地区1" valueKey="cityList" type="area" optValue="id" optChildren="children" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column prop="date">
                <template #header>
                    <DropDownFilter type="date" title="日期" valueKey="dateData" v-model="reqParams.dateData" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column prop="date">
                <template #header>
                    <DropDownFilter title="年份" valueKey="year" type="dateYear" v-model="reqParams.year" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column prop="date">
                <template #header>
                    <DropDownFilter title="月份" valueKey="month" type="dateMonth" v-model="reqParams.month" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column>
                <template #header>
                    <DropDownFilter type="dateRange" title="时间" :valueKey="['startSubmitTime','endSubmitTime']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column>
                <template #header>
                    <DropDownFilter type="dateRangeMonth" title="时间月份" :valueKey="['startYear','endYear']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column>
                <template #header>
                    <DropDownFilter type="dateRangeTime" title="时间分钟" :valueKey="['startRanTime','endRanTime']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>

            <el-table-column prop="name">
                <template #header>
                    <DropDownFilter type="inputRange" title="积分" :valueKey="['pointsCountStart','pointsCountEnd']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column prop="address">
                <template #header>
                    <DropDownFilter type="cascader" title="地区" v-model="reqParams.cityList1" isPopperRight valueKey="cityList1" :options="industryNameIdList" optValue="id" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column prop="address">
                <template #header>
                    <DropDownFilter type="dateRange" title="提交时间" :valueKey="['startTime','endTime']" :params="reqParams" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>
            <el-table-column prop="address">
                <template #header>
                    <DropDownFilter v-model="reqParams.address" title="地址" valueKey="address" type="checkbox" :options="aOpt" optLabel="dictName" optValue="dictCode" @dropDownFilterChange="dropDownFilterChange" />
                </template>
            </el-table-column>

        </el-table>
    </div>
</template>

<script>
import Drag from './drag.vue'
import NoPower from '@/components/noPower/index.vue'
import { getDictList } from '@/api/common.js'
import HeaderFilter from '@/components/headerFilter/index.vue'
import DropDownFilter from '@/components/dropDownFilter/index.vue'
import { findIndustryList } from '@/api/user.js'
export default {
    components: {
        NoPower,
        DropDownFilter,
        HeaderFilter,
        Drag
    },
    data() {
        return {
            selectedFilters: [],
            tableData: [{
                date: '2016-05-02',
                name: '王小虎',
                address: '上海市普陀区金沙江路 1518 弄'
            }, {
                date: '2016-05-04',
                name: '王小虎',
                address: '上海市普陀区金沙江路 1517 弄'
            }, {
                date: '2016-05-01',
                name: '王小虎',
                address: '上海市普陀区金沙江路 1519 弄'
            }, {
                date: '2016-05-03',
                name: '王小虎',
                address: '上海市普陀区金沙江路 1516 弄'
            }],
            reqParams: {
                startRanTime: '',
                endRanTime: '',
                startYear: '',
                endYear: '',
                month: '',
                year: '',
                pointsCountStart: '',
                pointsCountEnd: '',
                cityList1: [],
                cityList: [],
                startTime: '',
                endTime: '',
                dateData: [],
                userName: '',
                address: [],
                startSubmitTime: '',
                endSubmitTime: '',
                startIndex: 1,
                pageSize: 10
            },
            nameOpt: [{
                label: '张三',
                value: 'zhangsan'
            },
            {
                label: '李四',
                value: 'lisi'
            },
            {
                label: '王五',
                value: 'wangwu'
            }],
            aOpt: [],

            industryNameIdList: []
        }
    },
    mounted() {
        this.getInit()
    },
    methods: {
        // 表头筛选改变触发
        headerChange(val, data) {
            for (const key in val) {
                this.reqParams[key] = val[key]
            }
            this.selectedFilters = data
            this.reqParams.startIndex = 1
        },
        // 下拉筛选改变触发
        dropDownFilterChange(params, assembleData) {
            this.selectedFilters = this.selectedFilters.filter(item => item.title !== assembleData.title);
            if (assembleData.value) {
                this.selectedFilters.push(assembleData);
            }
            // 自己的逻辑
            this.reqParams.startIndex = 1
        },
        handleClick() {
            console.log(this.reqParams);
        },
        getInit() {
            getDictList('info_source').then(res => {
                if (res.code == 0) {
                    this.aOpt = res.data
                }
            })
            // 查询国标行业
            findIndustryList().then(res => {
                this.industryNameIdList = res.data
            })
        }
    }
}
</script>

<style>
.headBox {
    padding-bottom: 20px;
}
</style>

async/await 的优雅外衣下:Generator 的核心原理与 JavaScript 执行引擎的精细管理

作者 karrigan
2025年8月18日 17:17

async/await 的优雅外衣下:Generator 的核心原理与 JavaScript 引擎的精细管理

在现代 JavaScript 的异步编程中,async/await 几乎成了主流。开发者们喜欢用它来编写逻辑清晰、易于维护的异步代码。然而,很少有人深入探究 async/await 背后强大的技术支撑——Generator(生成器)机制,以及 JavaScript 引擎在编译和运行阶段是如何巧妙管理这些复杂流程的。本文将系统性地揭开这层神秘面纱,带你从语法、原理一直深入到引擎内部的运作机制。


1. async/await:让异步世界感觉像同步

async/await 是 ES2017 引入的语法糖,它为基于 Promise 的异步操作带来了同步代码般的编写体验。典型的写法如下:

async function getData() {
  const user = await fetchUser();
  const posts = await fetchPosts(user.id);
  return { user, posts };
}

await 关键字遇到 Promise 时会暂停当前函数的执行,等待这个 Promise 完成(resolved 或 rejected),然后再继续向下执行。开发者可以用近乎同步的顺序来表达异步逻辑,不再需要繁琐的 .then()/.catch() 链或者嵌套的回调函数。


2. async/await 的底层基石:Generator 的自动调度

2.1 async 函数与 Promise 的本质

每个 async 函数本质上都会返回一个 Promise。函数内部任何未被捕获的异常都会导致这个返回的 Promise 变为 rejected 状态。因此,async/await 本质上是一种语法上的便捷包装:

// async/await 写法
async function foo() {
  const res = await bar();
  return res;
}

// 转换后的等效 Promise 写法
function foo() {
  return bar().then(res => res);
}

2.2 Generator:支撑 async/await 的核心机制

Generator(生成器)是 ES6 引入的一种特殊函数类型,它可以暂停执行,之后又能从暂停的地方恢复。使用 function* 声明,yield 关键字用于“暂停”函数的执行,并保留函数当前的执行状态(包括局部变量、上下文等)。

function* sequence() {
  yield 1;
  yield 2;
  return 3;
}

const it = sequence();
it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
it.next(); // { value: 3, done: true }

每次在 yield 处暂停时,所有状态都被完整保存。当通过 .next() 方法恢复时,函数会从上次暂停的位置继续执行。这种“暂停与恢复”的能力,正是 async/await 实现顺序化异步操作的技术基础。

2.3 Generator 自动化控制流程

在 async/await 成为标准之前,社区库(如 co.js)就利用 Generator 实现了自动化的异步流程控制:

function* asyncFlow() {
  const user = yield fetchUser(); // 暂停,等待 fetchUser 结果
  const posts = yield fetchPosts(user.id); // 暂停,等待 fetchPosts 结果
  return { user, posts };
}

// 自动执行 Generator 的函数
function run(gen) {
  const iterator = gen();
  function step(prev) {
    const { value, done } = iterator.next(prev); // 恢复执行,传入上一个结果
    if (done) return Promise.resolve(value); // 如果结束,返回最终值
    return Promise.resolve(value).then(step); // 等待 Promise 完成,然后继续下一步
  }
  return step(); // 开始执行
}

// 使用
run(asyncFlow).then(result => console.log(result));

async/await 在底层本质上就是引擎自动帮你实现了类似 run 函数的功能,将 Generator 和 Promise 完美结合,只是语法上更加简洁直观。

2.4 Babel / 引擎的编译转换

现代的 JavaScript 引擎(或 Babel 这样的转译器)在内部会将 async/await 代码编译转换。转换的目标通常是类似上面 run 函数的逻辑(基于 Generator)或者是纯粹的 Promise 链。关键点在于:

  • 每当遇到 await,引擎会在运行时暂停函数的执行(相当于 Generator 的 yield),等待后面的 Promise 完成。
  • 编译阶段会生成管理函数执行状态(比如当前执行到哪里了)的代码,并确保函数恢复执行时,局部变量和作用域都能正确还原。

3. Generator 的本质:状态机与作用域快照

Generator 的技术核心是一个自带状态的迭代器

  • 每个 yield 语句对应函数执行中的一个特定状态点。
  • 当执行到 yield 暂停时,函数当前的所有局部变量、执行上下文状态都会被完整保存下来
  • 通过调用 .next()(传入值恢复)或 .throw()(抛出异常恢复),可以从暂停点恢复执行,并可以传入新的值或异常。

伪代码模拟底层状态机:

function* taskFlow() {
  const a = yield step1(); // 状态 0: 开始执行,调用 step1
  const b = yield step2(a); // 状态 1: 接收到 step1 结果 a,调用 step2(a)
  return b; // 状态 2: 接收到 step2 结果 b,结束
}

// 编译后可能类似于 (概念性伪代码):
function compiledTaskFlow() {
  let state = 0;
  let a, b;
  return {
    next: function (value) {
      switch (state) {
        case 0:
          state = 1;
          return { value: step1(), done: false }; // 启动 step1
        case 1:
          a = value; // 接收 step1 的结果
          state = 2;
          return { value: step2(a), done: false }; // 启动 step2(a)
        case 2:
          b = value; // 接收 step2 的结果
          state = -1;
          return { value: b, done: true }; // 结束
        default:
          return { value: undefined, done: true };
      }
    }
  };
}

Generator 的强大之处在于它高效地保存和恢复了函数执行环境的“快照”,特别是在处理并发异步逻辑时,为复杂的控制流提供了坚实基础。


4. JavaScript 引擎的编译与运行管理

4.1 编译期(准备阶段)

  • 分析代码: 引擎识别出 async/awaitfunction*/yield 语法。
  • 代码转换: 将这些语法结构转换为底层可执行的代码,通常是基于状态机的实现(如上文的伪代码概念)或 Promise 链。
  • 生成管理代码: 为每个暂停点(await/yield)生成管理执行状态(当前进行到哪一步)、保存/恢复局部变量和作用域链的代码。
  • 处理异常与外部控制: 设置好处理异常传播的路径以及外部控制(如 .next(), .throw())的接入点。

4.2 运行期(执行阶段)

  • 暂停与恢复: 当执行到 awaityield 时,引擎会挂起当前函数的整个执行上下文(包括变量、作用域链等)
  • 事件循环集成: 引擎将等待的 Promise 纳入事件循环的微任务队列管理。当 Promise 完成(resolved/rejected)时,对应的恢复操作(继续执行 Generator 或 async 函数)会被安排到微任务队列中。
  • 状态恢复: 引擎从微任务队列取出恢复任务,利用编译期生成的管理代码,精准地还原之前保存的执行上下文和状态,并从暂停点继续执行。
  • 异常处理: 如果等待的 Promise 被拒绝(rejected),引擎会将异常注入到暂停点,使其能被 async 函数内部的 try/catch 或 Generator 的 .catch / try/catch 捕获。
  • 性能与体验: 这套机制实现了“用同步语法写异步代码”的效果(非阻塞),在保证开发者良好体验的同时,也尽可能提升了性能。

5. 总结

Generator 是 JavaScript 异步编程能力实现飞跃的关键技术内核。 async/await 作为其上层封装,提供了一层优雅易用的语法糖衣。其底层核心依赖于 Generator 的暂停/恢复机制和 Promise 的异步状态管理。

在这个过程中,JavaScript 引擎扮演着至关重要的角色:在编译期,它进行复杂的代码分析和转换,生成状态管理逻辑;在运行期,它通过事件循环和微任务队列,精确地调度函数的暂停与恢复,并确保执行环境(作用域、变量)的正确保存与还原。这套精巧的协作机制,不仅让开发者能够编写出清晰、易维护的异步代码,也为构建高性能的现代 Web 应用提供了强大的底层支撑。


关键点回顾:

  • 理解 Generator 的工作原理(暂停、恢复、状态保存)是深入掌握 JavaScript 高级异步编程本质的关键。
  • async/await 的简洁性 得益于 JavaScript 引擎在幕后高效地实现了状态机管理和执行环境的保存/恢复。
  • 了解引擎在编译期和运行期如何协作管理异步流程,有助于开发者编写出性能更好、结构更优的复杂异步代码。

希望这篇解析能帮你真正看透 JavaScript 异步编程背后的“魔法”,从优雅的语法表面,深入到 Generator 的核心原理,再到引擎的精密运作机制,全方位提升你的技术洞察力!

node版本切换

2025年8月18日 17:03

目前,Node.js 版本管理工具主要有 Voltanvmn 以及一些可视化工具(如 nvm-desktop)。以下是它们的对比和推荐:

1. Volta(推荐)

特点

  • 自动切换:进入项目目录时自动检测并切换到正确的 Node.js 版本,无需手动操作。
  • 跨平台:支持 Windows、macOS 和 Linux。
  • 管理全局工具:可同时管理 npm、yarn、pnpm 的版本。
  • Rust 编写,性能高:比 nvm 更快、更稳定。

安装

curl https://get.volta.sh | bash  # Unix/macOS
winget install Volta.Volta       # Windows

使用

volta pin node@18  # 固定项目 Node 版本
volta install node@20  # 全局安装

2. nvm(传统选择)

特点

  • 成熟稳定:社区广泛使用,支持多版本管理。
  • 仅限 Unix 系统:Windows 需使用 nvm-windows(非官方)。
  • 需手动切换:需运行 nvm use <version> 切换版本。

安装

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash

使用

nvm install 18
nvm use 18

3. n(轻量级)

特点

  • 简单易用:适合个人开发,仅需几个命令。
  • 仅支持 Unix/macOS:Windows 不友好。

安装

curl -L https://raw.githubusercontent.com/mklement0/n-install/stable/bin/n-install | bash

使用

n install 20
n 20  # 切换版本

4. nvm-desktop(可视化工具)

特点

  • 图形界面:适合不熟悉命令行的用户。
  • 支持 Windows/macOS:可分组管理项目版本。

下载

  • 官网或第三方镜像(如 Quark 网盘)。

总结推荐

  • 个人/团队开发首选 Volta:自动化、跨平台、高性能。
  • 习惯传统工具选 nvm:稳定但需手动切换。
  • 简单轻量选 n:适合 Unix/macOS 用户。
  • 图形界面需求选 nvm-desktop:适合新手。

如果需要更详细的对比或安装指南,可以参考各工具的官方文档或相关教程。

「Flink」业务搭建方法总结

作者 淡酒交魂
2025年8月18日 16:56

1.  合理设置并行度和TaskManager 的任务槽数

1.1 核心概念:

1.  并行度:指 Flink 作业中特定算子(Operator)或整个作业的执行并行实例(即子任务)的数量。例如,map 算子的并行度为 5,意味着这个 map 操作会被拆分成 5 个完全相同的任务,同时在集群的不同地方处理数据流的不同分区。

2.  JobManager: Flink 集群的管理节点,负责调度作业、协调检查点、故障恢复等

3.  TaskManager: Flink 的工作节点(Worker Node)。每个 TaskManager 是一个独立的 JVM 进程,负责执行实际的任务(即算子子任务),Slot 是 TM 上执行任务的基本资源单元。

4.  任务槽(slot): Flink 集群(如 TaskManager)中的基本资源单元。每个 TaskManager 是一个 JVM 进程,它可以提供一定数量的任务槽。一个任务槽可以执行一个算子并行度实例(即一个子任务)。JM 管理 Slot 的分配,TM 提供 Slot 的实际执行环境

1.2 并行度与资源的关系

1.  并行度决定所需任务槽总数:

● 作业中所有算子并行度实例的总和(即整个作业图的所有子任务)必须小于或等于集群中可用任务槽的总数。

● 总子任务数 = 所有算子并行度实例之和 <= 总可用任务槽数 = TaskManager 数量 * 每个 TaskManager 的任务槽数

● 开启 Slot Sharing 时总槽数 = 所有算子中最大并行度值 (或关键路径所需槽数)(Flink 作业运行时,所有算子子任务会被分配到槽位。通常以作业图中 最宽算子 的并行度作为总槽数需求,因为 Flink 会尝试 Slot Sharing 将多个算子子任务链化到同一个槽位)

● 示例:

○ 作业含 Source(并行度=4) → Map(并行度=8) → Sink(并行度=2)

○ 实际所需槽数 = max(4,8,2) = 8(开启 Slot Sharing 时)

● 更高的并行度需要更多的任务槽

2.  任务槽需要资源(CPU 和内存):

● 任务槽需要运行在一个 TaskManager JVM 进程中

● 每个 TaskManager 配置了CPU 核心数和内存

● 任务槽的资源占用: 每个任务槽会占用其所在 TaskManager 的一部分 CPU 和内存资源。

● 结论:更多的任务槽意味着每个任务槽分得的 CPU 时间片和内存(尤其是用户代码内存)更少

3.  关系总结:

并行度↑ → 所需任务槽总数↑ → 所需 TaskManager 数量↑ 或 每个 TaskManager 的任务槽数↑

每个 TaskManager 的任务槽数↑ → 每个任务槽可用的 CPU 资源↓ (竞争加剧)

每个 TaskManager 的任务槽数↑ → 每个任务槽可用的用户堆内存↓

每个 TaskManager 的任务槽数↑ → 每个任务槽可用的用户堆外内存↓

每个 TaskManager 的任务槽数↑ → 共享内存区域(网络、托管)压力↑

1.3 常见问题与陷阱:

● 并行度过高,任务槽不足: 作业无法启动(NoResourceAvailableException)。

● 每个 TaskManager 任务槽数过多:

○ CPU 不足: 线程竞争激烈,CPU 利用率达到 100%,但吞吐量不增反降,延迟增大。

○ 内存不足: 每个槽分到的内存太少,导致用户代码频繁 GC 或 OutOfMemoryError (Java Heap Space)。

○ 网络内存不足: 导致反压加剧,吞吐量下降。

● 每个 TaskManager 任务槽数过少: 资源利用率低(CPU、内存闲置),成本高

2.  数据输入源

2.1 数据输入源设置uid

dataSource输入数据源默认都要设置uid,方便后续Checkpoint启动系统可以使用同一个uid,避免发送因输入源uid由系统随机产生,而后续更新无法使用Checkpoint启动,导致数据紊乱

2.2 uid和checkpoint的关系

● 状态恢复的桥梁: 当 Flink 从 Checkpoint/Savepoint 恢复作业时,它需要知道如何将 Checkpoint 里保存的状态数据“分配”给新运行的作业拓扑中的哪个算子实例。uid 就是这个分配的匹配依据。

● 匹配过程:

○ 恢复作业时,Flink 会读取 Checkpoint/Savepoint 的元数据,其中记录了每个状态片段对应的算子 uid。

○ 启动新的作业实例(可能是修改后的代码版本)。

○ Flink 将新作业拓扑中具有相同 uid 的算子与Checkpoint 中保存的对应 uid 的状态进行匹配。

○ 匹配成功,则该算子的状态从 Checkpoint 中恢复。

○ 匹配失败(找不到对应 uid 的算子),则根据配置(allowNonRestoredState)决定是失败还是忽略该部分状态继续启动

2.3 数据源处理和流转

多数据源处理时,建议所有数据源优先根据各自数据源的业务逻辑(如:联表查询字段)查出业务所需数据,最后各数据源整合为统一数据格式,进行最终的业务合并计算和处理

3.  流水线

3.1 系统时间(TumblingProcessingTimeWindows):

以Flink系统接收到这批数据的时间为准,通常与业务系统产生这批数据的实际时间有一定的时间差

3.2 事件时间(TumblingEventTimeWindows):

取业务数据中某个时间字段值作为流水线标准,相对于系统时间会更为精准计算业务数据

3.3 流水线设置

将所有数据源整合为统一数据格式后,可以以数据格式中的时间字段设为统一流水线,确保所有数据源合并(union)后使用统一流水线进行输出

WatermarkStrategy<InputModel> watermarkStrategy = WatermarkStrategy
                //表示允许的最大乱序时间为 5 秒
                .<InputModel>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                // 取InputModel中time字段数据作为流水线
                .withTimestampAssigner((o, t) -> {
                    return o.getTime();
                })
                //表示如果某分区5秒没有数据,则标记为空闲
                .withIdleness(Duration.ofSeconds(5));
3.4 流水线使用
dataSource
    .union(otherDataSource1, otherDataSource2, otherDataSource3)
     // 设置流水线
    .assignTimestampsAndWatermarks(watermarkStrategy)
    // 根据Key进行分区
    .keyBy(InputModel::getKey)
    // 设置流水线窗口大小  5秒为一个窗口
    .window(TumblingEventTimeWindows.of(Time.seconds(5)))
    // 业务数据计算处理  Integer为Key的数据类型
    .process(new ProcessWindowFunction<InputModel, OutputModel, Integer, TimeWindow>())

4.  数据业务处理

主要数据通过collect输出, 次要数据通过sideOut侧输出流输出

// 定义侧输出流
OutputTag<OutputModel> outputTag = new OutputTag<OutputModel>("public") {};
 
SingleOutputStreamOperator<OutputModel> process = dataSource
    .union(otherDataSource1, otherDataSource2, otherDataSource3)
     // 设置流水线
    .assignTimestampsAndWatermarks(watermarkStrategy)
    // 根据Key进行分区
    .keyBy(InputModel::getKey)
    // 设置流水线窗口大小  5秒为一个窗口
    .window(TumblingEventTimeWindows.of(Time.seconds(5)))
    // 业务数据计算处理  Integer为Key的数据类型
    .process(.process(new ProcessWindowFunction<InputModel, OutputModel, Integer, TimeWindow>() {
    @Override
    public void process(Integer key, ProcessWindowFunction<InputModel, OutputModel, Integer, TimeWindow>.Context context, Iterable<InputModel> iterable, Collector<OutputModel> collector) throws Exception {
        // 获取窗口结束时间点
        long end = context.window().getEnd();
        LocalDateTime windowEndTime = Instant.ofEpochMilli(end).atZone(ZoneOffset.ofHours(8)).toLocalDateTime();
    
        // 业务逻辑计算
        
        // 侧输出流统计
        contect.output(outputTag, new OutPutModel(xxx, xxx, xxx, xxx));
        
        // 主要统计数据
        collector.collect(new OutPutModel(xxx,xxx,xxx, xxx));
    }
});

5.  数据输出

5.1 数据Sink输出

使用JDBC连接池进行连接提交至数据库,建议继承AbstractSink<OutPutModel>类,进行连接池资源共用,减少资源浪费

示例:

OutputSink

public class OutputSink extends AbstractSink<OutputModel> {
    //SQL
    private static final String OUTPUT_SQL = "insert into table_name(id,price,window_end_time,create_time) values(?,?,?,?)";
 
    @Override
    public void invoke(GMVResultOutput value, SinkFunction.Context context) throws Exception {
        //  获取写入数据库连接资源
        Connection imsConn = connManager.getImsConnection();
        PreparedStatement outputStmt = null;
 
        try {
            outputStmt = imsConn.prepareStatement(OUTPUT_SQL);
            outputStmt.setLong(1, IdUtil.nextId());
            outputStmt.setBigDecimal(2, value.getPrice());
            outputStmt.setTimestamp(3, java.sql.Timestamp.valueOf(value.getWindowEndTime()));
            outputStmt.setTimestamp(4, java.sql.Timestamp.valueOf(LocalDateTime.now()));
            int i = outputStmt.executeUpdate();
 
            System.out.println("写入数据成功:" + i + "条");
 
        } catch (Exception e) {
            e.printStackTrace();
 
        } finally {
            closeResources(outputStmt);
            if (imsConn != null) {
                imsConn.close();
            }
        }
    }
}

AbstractSink<I>

public abstract class AbstractSink<I> extends RichSinkFunction<I> {
 
    protected transient JdbcConnectionManager connManager;
 
    @Override
    public void open(Configuration parameters) throws Exception {
        connManager = new JdbcConnectionManager();
        connManager.open(); // 初始化连接
    }
 
    @Override
    public void close() throws Exception {
        connManager.close(); // 关闭连接
    }
 
    // 辅助方法:关闭资源
    protected void closeResources(AutoCloseable... resources) {
        for (AutoCloseable res : resources) {
            if (res != null) {
                try { res.close(); } catch (Exception e) { /* Ignore */ }
            }
        }
    }
}
5.2 连接池

数据库连接使用连接池进行系统统一管理

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
 
import java.io.Serializable;
import java.sql.Connection;
import java.sql.SQLException;
 
public class JdbcConnectionManager implements Serializable {
    private transient HikariDataSource omsDataSource;
    private transient HikariDataSource imsDataSource;
 
    public void open() throws SQLException {
        // MySQL连接池配置(OMS)
        HikariConfig omsConfig = new HikariConfig();
        omsConfig.setJdbcUrl(OmsConstant.JDBC_URL);
        omsConfig.setUsername(OmsConstant.MYSQL_USER_NAME);
        omsConfig.setPassword(OmsConstant.MYSQL_PASSWORD);
        omsConfig.setMaximumPoolSize(20);       // 最大连接数(按需调整)
        omsConfig.setMinimumIdle(5);            // 最小空闲连接
        omsConfig.setConnectionTimeout(2000);    // 连接超时2秒
        omsConfig.setIdleTimeout(30000);        // 空闲超时30秒
        omsDataSource = new HikariDataSource(omsConfig);
 
        // MySQL连接池配置(IMS)
        HikariConfig imsConfig = new HikariConfig();
        imsConfig.setJdbcUrl(ImsConstant.IMS_JDBC_URL);
        imsConfig.setUsername(ImsConstant.IMS_USER_NAME);
        imsConfig.setPassword(ImsConstant.IMS_PASSWORD);
        imsConfig.setMaximumPoolSize(20);       // 最大连接数(按需调整)
        imsConfig.setMinimumIdle(5);            // 最小空闲连接
        imsConfig.setConnectionTimeout(2000);    // 连接超时2秒
        imsConfig.setIdleTimeout(30000);        // 空闲超时30秒
        imsDataSource = new HikariDataSource(imsConfig);
    }
 
    // 关闭连接池
    public void close() {
        if (omsDataSource != null) {
            omsDataSource.close();
        }
        if  (imsDataSource != null) {
            imsDataSource.close();
        }
    }
 
    // 从连接池获取连接(非物理关闭)
    public Connection getOmsConnection() throws SQLException {
        return omsDataSource.getConnection();
    }
 
    public Connection getImsConnection() throws SQLException {
        return imsDataSource.getConnection();
    }
}
5.3 数据输出源设置uid

业务数据处理好之后,使用Sink进行输出,输出时与输入源一样,需设置uid,以确保CheckPoint启动时可以正常启动

//  初始化outputSink
OutputSink outputSink = new OutputSink();
 
 
// 明细
process.getSideOutput(outputTag)
    .addSink(outputSink).name("side output").setParallelism(1).uid("side output Sink");
// 总和
process
    .addSink(outputSink).name("total output").setParallelism(1).uid("total output Sink");

6. 总结

以上的一些方法是近期基于业务的开发中遇到的一些坑点后,总结出来的一套相对比较完善的业务开发方法,便于后续Flink实时计算业务数据使用。如果大家有更好的建议和方法,也欢迎共同讨论学习!

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

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

前言

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

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

功能实现

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

在这里我选用SelectInputSetting来做例子

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

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

分析

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

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

实现拖拽交互

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

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

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

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

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

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

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

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

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

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

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

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

        .form-component {
            width: 300px;
        }

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

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

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

    label {
        margin-bottom: 5px;
    }

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

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

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

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

功能完善

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

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

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

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

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

小结

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

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

【渲染流水线】[光栅阶段]-[光栅插值]以UnityURP为例

作者 SmalBox
2025年8月18日 16:48
  • 作用‌:将几何图元(三角形)转换为片元(Fragment),并插值顶点属性(如纹理坐标)
  • 可配置‌:通过 Cull 指令控制面片剔除模式(Back/Front/Off)。

【从UnityURP开始探索游戏渲染】专栏-直达

三角形设置 Triangle Setup

预先计算全局系数

  • 优化遍历效率;实时计算则结合像素位置完成‌动态插值‌,确保透视正确性‌。

输入:

  • 屏幕坐标系下的三角形顶点坐标(包含深度值 z
  • 关联属性:法线、纹理坐标、顶点颜色等‌

生成:

  • 计算边界框‌:确定三角形在屏幕上的最小/最大像素范围(x_miny_min 至 x_maxy_max),限定后续遍历区域‌。
  • 生成边方程(效率低,用点积求重心方式判断替代的话这里不用求解这个):构建三角形三条边的线性方程(如 Ax+By+C=0),用于后续像素覆盖判断‌
  • 重心坐标分母项‌:预计算重心坐标插值所需的公共分母(如 1/(w0·α + w1·β + w2·γ)),避免遍历阶段重复计算‌(由于使用点积重心坐标求解重心和像素判断,其中解方程组时有一个共同的行列式分母需要点积计算,这里可以把分母预计算,下面阶段就可以直接用。 点积重心求解坐标下面的三角形遍历阶段有解释。)
  • 与像素位置无关的数学系数,供遍历阶段复用。

三角形遍历 Triangle Traversal (扫描变换 ScanConversion)

输入数据

  • 三角形设置阶段输出的网格表示数据‌

输出数据

像素覆盖检测

  • 遍历边界框内所有像素,通过边方程重心坐标判断(点积重心坐标求解代替叉乘面积求解加速优化运算)中心点是否在三角形内部‌

片元生成

对覆盖的像素创建片元(Fragment),包含以下状态:

  • 屏幕坐标 (x, y)
  • 插值后的深度值 z
  • 插值后的属性:法线、纹理坐标、颜色等‌

深度初筛

  • 初步计算片元深度,供后续深度测试使用‌

输出 片元序列(每个片元包含像素位置、深度及插值属性),传递至片元着色器‌

利用重心坐标权重混合顶点属性,确保纹理、颜色等平滑过渡‌:

属性pixel=α属性A+β属性B+γ属性C属性pixel=α⋅属性A+β⋅属性B+γ⋅属性C

点积法重心坐标公式解析

image

URP中光栅化的具体过程举例

Unity URP的光栅化阶段,几何图元通过重心坐标公式转化为片元的过程:

三角形设置阶段

  • 首先计算三角形在屏幕空间的包围盒,确定潜在覆盖的像素范围。
  • 例如一个三角形顶点坐标为(100,200)、(300,400)、(200,500),其包围盒范围为x∈[100,300],y∈[200,500]。

三角形遍历与片元生成

  • 遍历包围盒内所有像素,通过重心坐标判断是否在三角形内。重心坐标公式为:
  • α+β+γ=1(α,β,γ0)α + β + γ = 1 (α,β,γ ≥ 0)
  • 若像素(150,300)的重心坐标计算结果为α=0.4, β=0.3, γ=0.3,则该像素属于三角形。

透视校正插值

  • 使用公式对顶点属性进行插值:
  • f=(αf0/w0+βf1/w1+γf2/w2)/(α/w0+β/w1+γ/w2)f = (αf₀/w₀ + βf₁/w₁ + γf₂/w₂) / (α/w₀ + β/w₁ + γ/w₂)
  • 例如插值纹理坐标时,若三个顶点的w值为1.0、1.2、1.1,UV为(0,0)、(1,0)、(0,1),则像素(150,300)的UV插值结果为(0.28, 0.23)45。

具体案例:

  • 一个红色渐变三角形,顶点颜色分别为红(1,0,0)、绿(0,1,0)、蓝(0,0,1)。
  • 片元(200,350)的重心坐标为(0.5,0.3,0.2),插值后颜色为(0.5,0.3,0.2)。

光栅化在URP中的具体实现

核心类与方法

在URP管线中,相关逻辑由以下部分实现:

  • UniversalRenderer类‌:负责组织渲染流程,通过RenderOpaqueGeometry等方法触发光栅化
  • ShaderLibrary/Core.hlsl‌:包含插值计算的底层实现,如InterpolateBarycentricCoords等工具函数
  • GPU固定功能单元‌:实际计算由硬件光栅化器完成,Unity通过CommandBuffer.DrawProcedural等接口触发

GPU加速机制

重心坐标计算通过以下方式实现硬件加速:

  • 由GPU的‌光栅化引擎(Rasterizer Engine)自动执行,属于固定管线功能
  • Unity通过ShaderPass中的HLSLPROGRAM声明插值变量(如Varyings结构体),驱动GPU完成插值
  • 计算过程优化为并行处理,每个流处理器(SM)同时处理多个像素的重心坐标

典型数据流路径为: UniversalRenderer → Shader.Draw → GPU光栅化器 → 片元着色器(接收插值后数据


【从UnityURP开始探索游戏渲染】专栏-直达

(欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

本文由博客一文多发平台 OpenWrite 发布!

微信闪照小程序实现

作者 幸运是我
2025年8月18日 16:32

已经有一年半没有写文章了,今天给掘友们写一个闪照实现的demo,纯前端开发技术栈为uniapp+uni云开发;先贴出代码

首先是闪照的几个要点(小程序申请注册啥的就不说了,只说功能)

  1. 上传图片到uni云存储空间
  2. 上传图片需要做违规检测
  3. 闪照需要分享出去,微信分享功能
  4. 查看闪照时需要限时查看和防止手机截屏

image.png

image.png

<view wx:if="{{!isBlackScreen}}" class="page-container {{isBlackScreen ? 'black-screen' : ''}} container">
<view class="upload-area">
<up-upload :fileList="fileList" @afterRead="afterRead" @delete="deletePic" name="1" multiple :maxCount="1"
width="400" height="500">
</up-upload>
</view>
<!-- 按钮区域 -->
<view class="button-group">
<!-- 隐藏的上传组件 -->
<up-upload ref="uploadRef" :fileList="fileList" @afterRead="afterRead" @delete="deletePic" name="1" multiple
:maxCount="1" style="display: none;"></up-upload>
<u-button class="action-button" shape="circle" icon="photo" text="选择照片" @click="handleSelectPhoto" />
<u-button class="action-button share-button" shape="circle" icon="share" text="分享" open-type="share" :disabled="!canShare" />
</view>
<custom-tabbar :current="currentTab"></custom-tabbar>
</view>
<view wx:if="{{isBlackScreen}}" class="black-screen-overlay">
<text>禁止截图或录屏</text>
</view>
</template>

<script setup>
import {
ref
} from 'vue'
import {
onLoad,
onShow,
onNavigationBarButtonTap,
onPullDownRefresh,
onReachBottom,
onUnload,
onShareAppMessage
} from '@dcloudio/uni-app';
import CustomTabbar from '../components/custom-tabber.vue'
const currentTab = ref(0) //tabbar
const fileList = ref([]);
const subscribeNotify = ref(false);
const allowForward = ref(false);
const uploadRef = ref(null);
const canShare = ref(false); // 新增:控制是否允许分享
const handleSelectPhoto = () => {
// 手动触发上传组件的选择文件
uploadRef.value?.chooseFile();
};
const isBlackScreen = ref(false) // 是否显示黑屏
onLoad(() => {
wx.showShareMenu({
menus: ['shareAppMessage', 'shareTimeline'],
success() {
console.log('分享功能已启用')
}
})
wx.onUserCaptureScreen(() => {
this.setData({
isBlackScreen: true
}); // 触发黑屏

// 3秒后恢复(可选)
setTimeout(() => {
this.setData({
isBlackScreen: false
});
}, 3000);
});

})
onLoad(() => {

})
onUnload(() => {
wx.offUserCaptureScreen(); // 移除监听
});
onShareAppMessage(() => {
if (!canShare.value || !fileID.value) {
uni.showToast({
title: '请先上传图片',
icon: 'none'
});
return {};
}
console.log(fileID.value); // 查看 fileID 是否正常
return {
title: '查看闪照',
path: '/pages/viewImg/viewImg?fileID=' + fileID.value, // 带参数的分享路径
imageUrl: '/static/sz.png', // 分享图片
success(res) {
uni.showToast({
title: '分享成功'
})
},
fail(err) {
console.log('分享失败', err)
}
}
})
// 删除图片
const deletePic = (event) => {
fileList.value.splice(event.index, 1);
canShare.value = false; // 删除图片后禁止分享
};
const toview = () => {
uni.navigateTo({
url: '/pages/viewImg/viewImg?fileID=' + fileID.value, // 带参数的分享路径
})
}
const handleToTop = () => {
uni.navigateTo({
url: '/pages/wgbtop/wgbtop',
})
}
const afterRead = async (event) => {
fileList.value = []
canShare.value = false; // 开始上传时先禁止分享
let lists = [].concat(event.file);
console.log('选择的文件:', lists);
let fileListLen = fileList.value.length;

// 更新UI状态
lists.map((item) => {
fileList.value.push({
...item,
status: 'checking',
message: '安全检测中',
});
});

// 显示加载中状态
uni.showLoading({
title: '正在加载中...',
mask: true
});

// 读取文件的辅助函数
const readFileContent = async (fileItem) => {
// H5环境
if (fileItem.file && fileItem.file instanceof File) {
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = reject;
reader.readAsArrayBuffer(fileItem.file);
});
}
// 小程序环境
else if (fileItem.url) {
return await new Promise((resolve, reject) => {
uni.getFileSystemManager().readFile({
filePath: fileItem.url,
encoding: 'binary',
success: res => resolve(res.data),
fail: reject
});
});
}
throw new Error('不支持的文件类型');
};

for (let i = 0; i < lists.length; i++) {
let uploadResult = null;
try {
// 更新状态为上传中
fileList.value[i].status = 'uploading';
fileList.value[i].message = '正在加载...';

// 读取文件内容
const fileContent = await readFileContent(lists[i]);

// 更新状态为检测中
fileList.value[i].status = 'checking';
fileList.value[i].message = '安全检测中...';
// 调用云函数进行安全检测
const checkResult = await uniCloud.callFunction({
name: 'imgSecCheck',
data: {
fileContent: fileContent
}
});
if (checkResult.result.code !== 0) {
throw new Error(checkResult.result.message || '图片安全检测未通过');
}
console.log('安全检测通过', checkResult);
// 更新状态为上传中
fileList.value[i].status = 'uploading';
// fileList.value[i].message = '正在上传...';
// 安全检测通过后再上传到uniCloud
uploadResult = await uploadToUniCloud(lists[i]);

let item = fileList.value[fileListLen];
fileList.value.splice(fileListLen, 1, {
...item,
status: 'success',
message: '加载成功',
url: uploadResult.fileID,
});
fileListLen++;

// 上传成功,允许分享
canShare.value = true;

// 隐藏加载中
uni.hideLoading();
uni.showToast({
title: '加载成功',
icon: 'success',
duration: 2000
});
} catch (error) {
console.error('检测或上传失败:', error);
let item = fileList.value[fileListLen];

let message = '上传失败,图片可能包含违规内容';
if (error.message && error.message.includes('违规')) {
message = '图片包含违规内容';
} else if (error.message && error.message.includes('大小')) {
message = '图片大小超过限制(10MB)';
} else if (error.errMsg && error.errMsg.includes('fail')) {
message = '安全检测服务异常';
}
fileList.value.splice(fileListLen, 1, {
...item,
status: 'failed',
message: message,
});
fileListLen++;
// 上传失败,禁止分享
canShare.value = false;
// 隐藏加载中并显示错误
uni.hideLoading();
uni.showToast({
title: message,
icon: 'none',
duration: 3000
});
// 如果上传了文件但检测失败,删除已上传的文件
if (uploadResult && uploadResult.fileID) {
try {
await uniCloud.deleteFile({
fileList: [uploadResult.fileID]
});
console.log('已删除未通过检测的文件');
} catch (deleteError) {
console.error('删除文件失败:', deleteError);
}
}
}
}
};
// 上传到uniCloud云存储
const fileID = ref()
const uploadToUniCloud = async (fileItem) => {
// 如果是H5环境且有原始File对象
if (fileItem.file && process.env.VUE_APP_PLATFORM === 'h5') {
// H5方式上传
const cloudPath = 'uploads/' + Date.now() + '-' + fileItem.file.name + '.png';
const res = await uniCloud.uploadFile({
filePath: fileItem.file,
cloudPath: cloudPath
});
fileID.value = res.fileID
return res;
} else {
// 小程序/APP方式上传
const cloudPath = 'uploads/' + Date.now() + '-' + Math.random().toString(36).substring(2) + '.png';
const res = await uniCloud.uploadFile({
filePath: fileItem.url,
cloudPath: cloudPath
});
fileID.value = res.fileID
console.log(fileID.value)
return res;
}
};
onShow(() => {
uni.hideTabBar()
// 根据当前页面设置currentTab
const pages = getCurrentPages()
const page = pages[pages.length - 1]
const route = page.route
if (route === 'pages/index/index') {
currentTab.value = 0
} else if (route === 'pages/wgbtop/wgbtop') {
currentTab.value = 1
} else if (route === 'pages/user/user') {
currentTab.value = 2
}
})
</script>

<style lang="scss" scoped>
.container {
// padding: 24rpx;
padding: 20rpx;
box-sizing: border-box;
background-color: #f8f8f8;
min-height: 100vh;
}

.black-screen-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: black;
color: white;
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}

.upload-area {
height: 1000rpx;
// background-color: #fff;
border-radius: 16rpx;
margin-bottom: 32rpx;
display: flex;
align-items: center;
justify-content: center;
// box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}

.button-group {
display: flex;
justify-content: space-between;
margin-bottom: 32rpx;

.action-button {
flex: 1;
height: 80rpx;
font-size: 28rpx;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8eb 100%);
border: none;
color: #333;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);

&:active {
opacity: 0.9;
}

&.share-button {
margin-left: 24rpx;
background: linear-gradient(135deg, #3c9cff 0%, #2b85e4 100%);
color: #fff;

&.u-button--disabled {
opacity: 0.6;
}
}
}
}

.settings-section {
background-color: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);

:deep(.u-cell) {
padding: 28rpx 32rpx;
}

:deep(.u-cell_title) {
font-size: 30rpx;
color: #333;
font-weight: 500;
}
}
</style>
1.上传图片到uni云存储空间

首先上传图片用两个地方,一个是上传组件,一个是点击上传的按钮,所以我写了两个up-upload组件,一个是显示的,一个是隐藏的,隐藏的组件用于实现按钮点击上传,使用uploadRef.value?.chooseFile();来手动触发;我使用的是uni的云存储方法为uniCloud.uploadFile(),需要这个uniapp账号开通了云存储空间,可以免费开通看不懂的话可以点击这段去uni的云存储板块看教程

2.上传图片需要做违规检测

第二点就是上传时需要做违规检测,如果用户上传了色情恐怖等等就不让检测上传分享了,这一块微信有提供检测的api————api.weixin.qq.com/wxa/img_sec… ;检测的api有两个一个只需要token就可以了;我使用的就是这个。另一个需要用户的openid,由于我没有做登录所以要openid的我就没有使用,这一块主要是获取token去调用这个检测接口,我使用的是uni的云函数代码如下

exports.main = async (event, context) => {
  // 获取微信access_token
  const getAccessToken = async () => {
    const res = await uniCloud.httpclient.request(
      'https://api.weixin.qq.com/cgi-bin/token', 
      {
        method: 'GET',
        data: {
          grant_type: 'client_credential',
          appid: 替换为你的小程序AppID
          secret:替换为你的小程序AppSecret
        },
        dataType: 'json'
      }
    )
    return res.data.access_token
  }

  try {
    const access_token = await getAccessToken()
    
    // 阿里云不支持downloadFile,直接从event中获取文件内容
    const fileContent = event.fileContent
    
    // 调用微信安全检测接口
    const result = await uniCloud.httpclient.request(
      `https://api.weixin.qq.com/wxa/img_sec_check?access_token=${access_token}`,
      {
        method: 'POST',
        content: fileContent,
        headers: {
          'Content-Type': 'application/octet-stream'
        },
        dataType: 'json'
      }
    )
    
    if (result.data.errcode === 0) {
      return {
        code: 0,
        message: '检测成功',
        data: result.data
      }
    } else {
      return {
        code: result.data.errcode || -1,
        message: result.data.errmsg || '检测失败',
        data: result.data
      }
    }
  } catch (error) {
    return {
      code: -2,
      message: error.message || '检测异常',
      data: error
    }
  }
}
3.闪照需要分享出去,微信分享功能

微信的分享功能这一块没有啥好说的很简单,给按钮加上open-type="share",然后吧分享功能打开通过onShareAppMessage方法就可以分享了 主要代码如下

<u-button class="action-button share-button" shape="circle" icon="share" text="分享" open-type="share" :disabled="!canShare" />


wx.showShareMenu({
menus: ['shareAppMessage', 'shareTimeline'],
success() {
console.log('分享功能已启用')
}
})
                
                onShareAppMessage(() => {
if (!canShare.value || !fileID.value) {
uni.showToast({
title: '请先上传图片',
icon: 'none'
});
return {};
}
return {
title: '查看闪照',
path: '/pages/viewImg/viewImg?fileID=' + fileID.value, // 带参数的分享径
imageUrl: '/static/sz.png', // 分享图片
success(res) {
uni.showToast({
title: '分享成功'
})
},
fail(err) {
console.log('分享失败', err)
}
}
})
4.查看闪照时需要限时查看和防止手机截屏

第四点主要是通过css模糊效果结合定时器来实现;判断是否看过的字段我存储在了本地存储中,防君子不防小人。防截屏使用的是微信提供的wx.setVisualEffectOnCapture方法;具体代码如下

<template>
<view class="image-container">
<!-- 使用两层图片结构,一层模糊层,一层清晰层 -->
<image v-if="isBlurred" :src="imageSrc" mode="widthFix" class="blur-layer" 
@touchstart="handleTouchStart" @touchend="handleTouchEnd" @touchcancel="handleTouchEnd" />
<image :src="imageSrc" mode="widthFix" :class="['sharp-layer', { 'visible': !isBlurred }]" 
@touchstart="handleTouchStart" @touchend="handleTouchEnd" @touchcancel="handleTouchEnd" />
<view v-if="hasViewed" class="hint-text">
<up-button :plain="true" class="" style="margin-top: 40rpx;width: 180rpx;" size='mini'
@click="toIndex">我也要发照片</up-button>
</view>
<up-modal :show="show" :title="title" :content='content' @confirm="confirm" :closeOnClickOverlay="true"
showCancelButton='true' @cancel='cancel'></up-modal>
<view v-if="showBlackScreen" class="black-screen">
<text class="hint-text">禁止截屏</text>
</view>
</view>
</template>

<script setup>
import {
ref
} from 'vue'
import {
onLoad,
onShow,
onNavigationBarButtonTap,
onPullDownRefresh,
onUnload,
onHide
} from '@dcloudio/uni-app';
import {
onUnmounted
} from 'vue';
const imageSrc = ref(
'https://mp-57911374-353d-4222-b8c2-1a8948d61be7.cdn.bspapp.com/cloudstorage/4e16e15d-6660-4c24-af36-d6886d1e3a7e.'
)
const isBlurred = ref(true)
const hasViewed = ref(false) // 是否已经查看过
let timer = null
const show = ref(false);
const title = ref('提示');
const content = ref('您已经查看过该图片');
const imgArray = ref([])

onLoad((options) => {
if (uni.getStorageSync('imgArray')) {
imgArray.value = uni.getStorageSync('imgArray')
}
if (options) {
imageSrc.value = options.fileID
const isExist = imgArray.value.some(item => item === imageSrc.value);
if (isExist) {
hasViewed.value = true
isBlurred.value = true // 修改这里:已经查看过的图片保持模糊状态
} else {
hasViewed.value = false
isBlurred.value = true
}
}
wx.setVisualEffectOnCapture({
visualEffect: 'hidden',
});
})

onHide(() => {
wx.setVisualEffectOnCapture({
visualEffect: 'none',
});
})

onUnload(() => {
wx.setVisualEffectOnCapture({
visualEffect: 'none',
});
})

const handleTouchStart = () => {
// 已经查看过,直接显示提示
if (hasViewed.value) {
show.value = true
return
}

// 清除之前的定时器
clearTimeout(timer)
// 立即显示清晰图片
isBlurred.value = false

// 设置2秒后自动恢复模糊
timer = setTimeout(() => {
isBlurred.value = true
hasViewed.value = true // 标记为已查看
imgArray.value.push(imageSrc.value)
uni.setStorageSync('imgArray', imgArray.value); //存本地
}, 2000)
}

const handleTouchEnd = () => {
// 已经查看过的不处理
if (hasViewed.value) return
// 如果触摸时间不足2秒就松手,也恢复模糊并标记为已查看
clearTimeout(timer)
isBlurred.value = true
hasViewed.value = true
imgArray.value.push(imageSrc.value)
uni.setStorageSync('imgArray', imgArray.value);
}

// 去看广告
const confirm = () => {
show.value = false
};
// 不看
const cancel = () => {
show.value = false
};

onShow(() => {
wx.setVisualEffectOnCapture({
visualEffect: 'hidden',
});
})

const toIndex = () => {
uni.switchTab({
url: '/pages/index/index'
})
}

onUnmounted(() => {
clearTimeout(timer)
});
</script>

<style scoped>
/* 容器确保图片比例不变形 */
.image-container {
width: 100%;
height: 80vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}

.black-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #000;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}

/* 模糊层 */
.blur-layer {
width: 100%;
display: block;
position: absolute;
filter: blur(22px);
transform: scale(1.02);
transition: opacity 0.5s ease;
}

/* 清晰层 */
.sharp-layer {
width: 100%;
display: block;
position: absolute;
opacity: 0;
transition: opacity 0.5s ease;
}

.sharp-layer.visible {
opacity: 1;
}

.hint-text {
position: absolute;
bottom: 50%;
left: 0;
right: 0;
text-align: center;
color: white;
padding: 10rpx 20rpx;
border-radius: 10rpx;
margin: 0 auto;
width: max-content;
z-index: 10;
}

/* 性能优化 */
@media (prefers-reduced-motion: reduce) {
.blur-layer, .sharp-layer {
transition: none;
}
}
</style>

到这里整个功能就已经实现完了,主要两个页面一个是上传图片和分享的页面;一个是查看闪照的页面。整篇文章都是干货无划水;喜欢的朋友可以点赞收藏一下,感谢了。下一篇我会分享纯前端实现的情侣互动点餐小程序。

《利用 Python 爬虫获取 Amazon 商品详情实战指南》

作者 onejason
2025年8月18日 16:31

在电商领域,获取 Amazon 商品详情数据对于市场分析、竞品研究和商业决策具有极高的价值。Python 爬虫技术可以帮助我们高效地抓取这些数据。本文将详细介绍如何利用 Python 爬虫技术获取 Amazon 商品详情数据。

一、准备工作

(一)环境搭建

确保你的开发环境中已经安装了以下必要的 Python 库:

  • requests:用于发送 HTTP 请求。
  • BeautifulSoup:用于解析 HTML 页面。
  • pandas:用于数据存储和处理。

可以通过以下命令安装这些库:

pip install requests beautifulsoup4 pandas

(二)目标网站分析

在开始编写爬虫之前,需要对目标网站(Amazon 商品详情页面)进行分析,了解页面结构和数据存储方式。使用浏览器的开发者工具(如 Chrome DevTools),查看商品详情页面的 HTML 结构,找到商品名称、价格、描述等信息。

二、爬虫代码实现

(一)发送 HTTP 请求并解析 HTML

使用 requests 库发送 HTTP 请求,获取商品详情页的 HTML 内容。然后使用 BeautifulSoup 解析 HTML,提取商品详情数据。

import requests
from bs4 import BeautifulSoup

def get_product_details(url):
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'
    }
    response = requests.get(url, headers=headers)
    if response.status_code == 200:
        soup = BeautifulSoup(response.text, 'html.parser')
        product_name = soup.find('span', {'id': 'productTitle'}).text.strip()
        product_price = soup.find('span', {'id': 'priceblock_ourprice'}).text.strip()
        product_description = soup.find('div', {'id': 'productDescription'}).text.strip()
        return {
            'name': product_name,
            'price': product_price,
            'description': product_description
        }
    else:
        print(f"请求失败,状态码:{response.status_code}")
        return None

url = "https://www.amazon.com/dp/B08N5WRWNW"
product_details = get_product_details(url)
if product_details:
    print(product_details)

(二)数据存储

将获取到的商品详情数据存储到 CSV 文件中,便于后续分析和使用。

import pandas as pd

def save_to_csv(data, filename="product_details.csv"):
    df = pd.DataFrame([data])
    df.to_csv(filename, index=False, encoding='utf-8')

if product_details:
    save_to_csv(product_details)
    print("数据已保存到 product_details.csv")

(三)搜索商品

编写函数,通过关键字搜索 Amazon 商品。

from selenium import webdriver

def search_amazon(keyword):
    url = "https://www.amazon.com/s"
    driver = webdriver.Chrome()
    driver.get(url)
    search_box = driver.find_element_by_name('k')
    search_box.send_keys(keyword)
    search_box.submit()
    return driver.page_source

keyword = "python books"
html_content = search_amazon(keyword)

(四)解析搜索结果

解析搜索结果页面,提取商品标题、价格和链接。

def parse_products(html_content):
    soup = BeautifulSoup(html_content, 'html.parser')
    products = []
    for product in soup.find_all('div', {'data-component-type': 's-search-result'}):
        try:
            title = product.find('span', class_='a-size-medium a-color-base a-text-normal').get_text()
            price = product.find('span', class_='a-price-whole').get_text()
            link = product.find('a', class_='a-link-normal')['href']
            products.append({'title': title, 'price': price, 'link': link})
        except AttributeError:
            continue
    return products

products = parse_products(html_content)
for product in products:
    print(product)

三、注意事项与优化建议

(一)遵守法律法规

在进行爬虫操作时,必须严格遵守相关法律法规,尊重 Amazon 的数据使用政策。

(二)合理设置请求频率

避免过高的请求频率导致服务器过载或 IP 被封。可以使用 time.sleep() 或随机延时。

(三)处理反爬虫机制

Amazon 可能有反爬虫机制,如验证码等。可以尝试使用代理 IP 或模拟正常用户行为。

(四)动态内容处理

对于动态加载的内容,可以使用 Selenium 或第三方 API。

四、总结

通过上述步骤,你可以使用 Python 编写一个简单的爬虫程序,快速获取 Amazon 商品详情数据。这些数据对于电商从业者来说具有重要的商业价值,可以帮助我们更好地了解市场动态,优化运营策略。在开发过程中,务必遵守相关法律法规,合理设置请求频率,以确保爬虫的稳定运行。希望本文的介绍和代码示例能够帮助你更好地利用爬虫技术,解锁 Amazon 数据的更多价值。

页面点击跳转源代码?——element-jumper插件实现

作者 Zestia
2025年8月17日 22:29

前言

在开发公司或个人大型项目时,很多人都会碰到这样的困扰:

明明是简单的功能需求,比如在页面底部加个按钮,却不知道该从代码里的哪个组件入手。我们往往要花大量时间去寻找页面内容和源代码的对应关系,这种耗时在简单功能开发时显得尤为突出。

有没有插件能解决这个问题呢?答案就在这篇文章里。本系列将带大家从 0 开始,深入原理,一步步实现一个能从页面直接跳转至对应源代码的实用插件——element-jumper

本项目已经开源以及发布npm包,欢迎小伙伴们自行测试:gitbub传送门点点star谢谢喵

系列文章(WIP)

  1. 页面点击跳转源代码?——element-jumper插件实现(本文)
  2. element-jumper插件实现之BabelPlugin
  3. element-jumper插件实现之WebpackPlugin

通过这篇文章能学到什么?

  1. element-jumper的基本概念以及功能
  2. element-jumper整体功能拆解思路
  3. element-jumper各部分原理概述

一、基本概念

相信经过前文的简单介绍依然有不少同学对此插件的功能存在困惑,所以本章节我们来讲讲本插件的基本概念以及这个想法是如何产生的等等。

  • 想法诞生——从 “找代码困境” 到 “自制插件”

主播刚结束为期三个月的第一段实习,刚进公司的时候由于新人文档还不是特别完善以及本人有一点小小的社恐,导致landing期间就只是配置了环境,而对于很多提效的插件了解的很少。

于是在接到第一个需求准备大展身手时,陷入了找不到代码的困境,这个时候mt向我介绍了公司研发的代码定位插件,处于开发模式下,按住快捷键(Ctrl+Shift+某字母),再点击页面上对应的组件,就直接跳转到了vscode对应的代码中。从此,我不用在浩如烟海的代码里苦苦搜寻,能愉快地投入需求开发了。

这个插件给当时的我带来了极大震撼,于是我决定花时间研究其原理,复刻一个属于自己的代码定位插件 ——element-jumper(虽然翻译不算专业,但 “jumper” 一词自带灵动感,便沿用了这个名字)。经过两个月的学习和编码,终于成功做出了一个能通过自测的插件。

  • 功能概述——页面与代码的 “一键直达”

通过刚刚不清不楚的描述相信大家对此插件的功能已经有了初步的理解,下面对element—jumper的概念和功能做一个小小的总结:

该插件专为解决开发中的 “代码定位难题” 设计,在开发模式下,用户只需按住特定快捷键并点击页面上的目标组件(如按钮、文本框等),插件就能自动定位到该组件在源代码中的位置,并直接跳转至 VS Code对应的代码区域,帮助开发者跳过 “找代码” 的耗时环节,快速进入功能开发阶段。

二、需求拆解

对功能有了了解之后,正在看文章的你或许很激动的想要投入开发。但是在这之前,我们需要对功能进行“拆解”,这个步骤不管是在平时项目的练习,还是公司需求的开发中都显得尤为关键,能够帮助开发者评估工作量以及为后续开发过程奠定思路。

对此我总结出了一套流程来快速的拆解一个比较大的需求,大家可以对比学习:

  1. 明确功能:即用一段文字去准确的描述需求的功能,这个步骤我们在前文已经完成了;

  2. 提炼关键词:提取刚才那段文字中最核心的词语,这里比如:点击页面组件、源代码位置、跳转vscode等等;

  3. 逻辑连接:将提炼到的关键词进行逻辑组合(比如时间顺序,先A后B等等),那么针对代码定位插件,逻辑连接如下:首先需要点击页面上的元素或者组件,其次获取它在源代码中的位置,最后根据位置进行vscode的跳转。(时间顺序)

  4. 针对每一个分句进行提问和回答:这一步可以借助工具和查阅文档,针对本插件,提问和回答如下,

    • 怎么确定点击的是哪一个组件?——可以在每一个组件外部都包裹一个自定义的透明深色组件,在hover的时候予以显示,类似浏览器开发者工具,如图就代表你的点击覆盖的范围。

    123.png

    • 怎么获取组件的位置?—— 可以通过在打包等时候遍历代码的AST,并对对应的行列信息进行存储。
    • 怎么跳转VSCode?——VSCode 支持通过特殊协议链接被外部调用,格式为vscode://,比如vscode://file/{文件路径}:{行号}:{列号}这个链接能直接让 VS Code 打开指定文件,并定位到具体行列位置。

至此,我们便完成了基本的需求拆解,可以通过画图来加深理解,后续也方便参考。

456.png

三、功能概述

由于本插件的实现涉及 Webpack 插件、Babel 插件、AST 等较多复杂技术知识点,因此作为系列文章的开篇,本文更侧重于从整体视角展开介绍,仅对核心功能的实现原理进行关键提示,暂不做过于细致的技术阐述。

后续章节将针对前文拆解的各个步骤(如组件与源代码的关联机制、协议链接的生成逻辑等),分别进行深入的技术分析和具体代码实现的详细讲解。

3.1 遮罩组件

该环节的实现逻辑相对清晰,核心可拆解为两大步骤:遮罩组件的本体开发组件的自动化注入机制。简单来说,需先自定义一个无实际内容、只有颜色遮罩组件,随后在开发环境模式下,为应用中所有渲染的组件自动包裹一层该自定义遮罩组件。

在遮罩组件的实现层面,需根据项目所采用的技术栈选择对应的开发方式。如 React 或者 Vue 组件,值得注意的是,若需实现跨框架兼容 —— 即让遮罩组件能在 ReactVue 等不同框架构建的项目中通用,WebComponent 技术方案会是更优选择。采用纯 JavaScript 编写的 WebComponent 组件,具备原生HTML标签的使用特性,可直接通过<组件标签名>的形式在页面中引用,无需依赖特定框架的编译或运行时环境,从而有效降低跨框架适配的复杂度。

这里用react组件做示例(element—jumper中使用的是webcomponent

const MaskOverlay = ({ children }) => {
    return (
     // 遮罩容器:通过定位覆盖子内容,不影响原始布局
     <div className="mask-overlay-container">
       <div className="mask-overlay-content">
           {children}
       </div>
     </div>
    );
};
export default MaskOverlay;

//css
.mask-overlay-container {
    /* 半透明背景*/
    background-color: rgba(230, 230, 230, 0.3);
    /* 继承父元素尺寸,确保完全覆盖子内容 */
    width: 100%;
    height: 100%;
    /* 相对定位:避免影响页面布局流 */
    position: relative;
}

.mask-overlay-content {
    /* 子内容容器:保持原始内容布局 */
    width: 100%;
    height: 100%;
}

在组件注入的实现上,核心逻辑与前文提及的 AST(抽象语法树) 密切相关,而与遮罩组件自身的业务代码关联度较低。这部分内容将在后续小节中,结合 AST 的具体操作进行简要说明。

3.2 babel—pluginAST的遍历与操作

(不清楚的同学可以先学习babelAST相关知识)

结合前文内容,想必你已清晰这个 Babel 插件的核心目标 ——精准获取组件在源代码中的行列位置信息,并自动完成遮罩组件的注入操作。

这里有个值得思考的细节:行列信息获取后该如何存储?既要保证每个组件的信息独立不混淆,又不能对页面其他功能产生干扰。此时我们会发现,即将注入的遮罩组件恰好是理想的存储载体:每个组件外层都有独立的遮罩组件包裹,且不会影响原始内容的展示与交互。因此,将行列信息以属性形式挂载到遮罩组件上,无疑是巧妙且合理的解决方案,这也是整个项目实现中的一个关键亮点。

这部分功能的逻辑框架并不复杂,但需要扎实掌握 Babel 插件开发和 AST 处理的相关知识,例如通过path对象获取节点位置信息等操作。下面为获取组件行列信息的实现思路举例说明:

module.exports = function({ types: t }) {
  return {
    visitor: {
      JSXElement(path, state) {
        //通过this.file获取当前文件信息
        const filename = this.file.opts.filename;
        // 跳过特定文件(如开发覆盖层组件本身)
        if (filename && filename.endsWith('devOverlay.jsx')) return;
        
        //通过path获取JSX元素的位置信息
        const loc = path.node.openingElement.loc;
        if (!loc || !loc.start) return;
        
        // 获取行列信息并生成唯一的debugId
        const { line, column } = loc.start;
        const debugId = `cmp-${line}-${column}`;
        //...后续代码
      }
    }
  };
};
3.3 webpack-plugin:跳转实现

由于webpack插件hooks的多样化,这里的跳转实现思路有很多,由于是复刻的项目,所以我选择了直接向项目资产html文件(即emit钩子)注入全局点击事件监听以及跳转逻辑。

前文提到我们已经把行列以及文件信息注入到了遮罩组件的属性中,现在直接对应取出并补充完整vscode协议即可,以下是关键代码实现:

 apply(compiler) {
    // 使用Webpack的emit钩子(资产输出前触发)
    compiler.hooks.emit.tapAsync('VscodeJumpPlugin', (compilation, callback) => {
      try {
        // 找到所有HTML资产(通常是index.html)
        const htmlAssets = Object.keys(compilation.assets).filter(filename => 
          filename.endsWith('.html')
        );
        // 处理每个HTML文件
        htmlAssets.forEach(filename => {
          // 获取原始HTML内容
          const originalHtml = compilation.assets[filename].source();

          // 注入点击监听脚本
          const injectScript = `
            <script>
              document.addEventListener('click', (e) => {
                // 查找带目标属性的元素
                const attrNames = ['${this.attrs.file}', '${this.attrs.line}', '${this.attrs.column}'];
                //处理内容
                const targetEl = e.target.closest(
                  attrNames.map(attr => \`[\${attr}]\`).join('')
                );
                if (!targetEl) return;
                // 提取属性信息
                const file = targetEl.getAttribute('${this.attrs.file}');
                const line = targetEl.getAttribute('${this.attrs.line}');
                const column = targetEl.getAttribute('${this.attrs.column}');

                if (!file || !line || !column) return;

                // 处理Windows路径并跳转
                const normalizedFile = file.replace(/\\\\/g, '/');
                const encodedFile = encodeURIComponent(normalizedFile);
                const vscodeUrl = \`vscode://file/\${encodedFile}:\${line}:\${column}\`;
                window.open(vscodeUrl, '_blank');
              });
            </script>
          `;

          // 将脚本插入到</body>前
          const modifiedHtml = originalHtml.replace('</body>', `${injectScript}</body>`);

          // 更新资产内容
          compilation.assets[filename] = {
            source: () => modifiedHtml,
            size: () => modifiedHtml.length
          };
        });
      } catch (e) {
        console.error('插件处理失败:', e);
      }
      callback();
    });
  }
}

至此,代码定位功能的核心实现步骤已为大家梳理完毕,相信你对整体开发思路已有了初步框架。但正如前文所说,本文作为系列开篇更侧重思路概述,对技术细节的展开较为有限。这里提前抛出几个关键细节问题(后续文章会逐一深入解答并实战演示):

  1. 怎么手动实现代码定位模式的开关控制?(即快捷键功能)
  2. 怎么实现开发模式(dev mode)的判断和注入?
  3. babel-plugin中如何对组件进行“筛选”?(<div> <p>等原生标签怎么排除?)

四、总结

  • 再次强调文章定位,本文遵循 “问题→目标→拆解→方案→展望” 的技术分享逻辑,分享了代码定位插件的相关内容,侧重逻辑思路而略写了技术性和知识性的相关内容,这些隐去的内容也会在后续文章进行补充。感兴趣的同学可以在评论区写下问题,后面会发文章解答。
  • github传送门(Zestia-l (Juicetone) · GitHub) ~
  • element-jumper传送门(GitHub - Zestia-l/element-jumper) ~

TypeScript:联合类型可以转化为元组类型吗?数组如何用联合类型逐项约束?

2025年8月17日 22:27

如何用联合类型约束数组类型

联合类型能转化为元组类型吗.png

TypeScript 类型体操中,我希望能用联合类型约束数组(或元组)类型,实现更强的类型安全。本文将围绕这个主题展开,介绍常见的思路、局限与解决方案。

1. 联合类型与元组类型的关系

我们知道,TypeScript 可以很容易地将元组类型转化为联合类型:

const tuple = ['a', 'b', 'c'] as const; // as const 不能遗漏哦
type Union = (typeof tuple)[number]; // 'a' | 'b' | 'c'

但反过来,在进行了一些徒劳的尝试和资料查询后发现:

无法直接将联合类型转化为元组类型(;´д`)ゞ。

因为联合类型本质上是无序的集合,而元组类型是有序的列表,TypeScript 类型系统无法保证顺序和长度。

2. 如何实现精准的类型约束

虽然不能直接将联合类型转为元组,但我们依然可以做到:

  • 让数组的元素类型受联合类型约束
  • 任何一方缺失都会在类型检查阶段暴露

3. 开始操作

假设这样一个场景,我们在做参数校验:

有一个如下的联合类型的入参。现在,我们要设定一个数组,使用includes方法来确定入参的有效性,但是入参类型可能会随着版本变化而变化,我们希望类型提示能帮助我们快速发现、更新。

type InputVersion = 'latest' | 1 | 2;
  1. 先给出想要的数组,以as const约束它并提取元组类型
const v = ['latest', 1, 2] as const;
type ArrVersion = (typeof v)[number]; // 'latest' | 1 | 2
  1. 对比类型ArrVersionInputVersion是否相同
type What = IsSameType<ArrVersion, InputVersion>;
  1. 给一个变量显式声明此类型并赋值
const what: What = 1; // 如果ArrVersion和InputVersion不一致,这行代码会因类型不匹配而报错。
// 严谨版本这里可以赋值为true,具体什么值都没有关系,选择自己喜欢的就好

Note: 这个赋值语句将会在编译期间被打包工具terser消除,因其未使用过。所以无需担心最终结果多出赋值语句。 (o゜▽゜)o

4. 实现 IsSameType 工具类型

直观版本

从数学理论讲,关键字extends类似于“偏序关系”,因此对于偏序关系而言,只要a ≤ bb ≤ a同时成立,那么就可以得到a = b。因此,我们以三元运算符来做到这件事,一个直接的想法是这样:

// 这是直观版本,但不是最优,最优请见下方的“严谨版本”
type IsSameTypeIntuitionistic<A, B> = A extends B ? (B extends A ? 1 : 2) : 2;

Note: 如果用truefalse,那么此泛型工具会永远返回boolean类型从而失去判断能力。

Note: 不使用10是因为0作为falsy的值性质略有区别,可能使得推断结果和约束行为不如预期。

严谨版本

TypeScript 存在“分布式展开”行为,当类型入参存在 never 时,分布式展开会直接返回 never,不会进入分支。以下是社区体操之神( (#°Д°)?)提供的严谨版本:

// 严谨版本
type IsSameType<A, B> =
  (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;

此写法阻止了“分布式展开”行为,边界情况都可以照顾到,适用范围更广。

总结

  • 元组类型可以转为联合类型,但联合类型无法直接转为元组类型。
  • 可以用联合类型约束数组元素类型,实现基本的类型安全。
  • 进一步约束时,可以用 IsSameType 工具类型判断类型集合是否完全一致。

(❁´◡`❁) 感谢你读到这里!

JavaScript生成随机数的全面指南:别再只会用Math.random()了!

作者 小小愿望
2025年8月17日 17:05

在JavaScript开发中,生成随机数的需求几乎无处不在,但不同场景对随机数的质量、安全性和性能要求差异巨大。本文将系统梳理多种随机数生成方法,分析其原理、优缺点及适用场景,并通过代码示例帮助理解。


一、基础方法:Math.random()

1. 原理与基本用法

Math.random() 是JavaScript内置的伪随机数生成器,每次调用返回一个 [0, 1) 区间的浮点数。其底层基于线性同余算法(Linear Congruential Generator),具有实现简单、性能高的特点。

示例1:生成0到1的随机浮点数

console.log(Math.random()); // 输出类似 0.723456

示例2:生成 [min, max] 范围内的随机整数

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}
console.log(getRandomInt(1, 10)); // 输出类似 7

2. 优缺点与适用场景

  • 优点
    • 简单易用,无需额外依赖
    • 性能高,适合频繁调用
  • 缺点
    • 安全性低:随机性不足,不适用于密码学、加密等场景
    • 分布不均匀:生成的随机数为伪随机序列,长期运行可能暴露规律
    • 功能局限:仅能生成浮点数,需手动转换整数或其他格式

适用场景:游戏开发、模拟数据、一般前端交互(模拟下雪、下雨、随机展示)


二、安全随机数:Crypto API

1. 原理与基本用法

crypto.getRandomValues() 是Web Crypto API提供的加密安全随机数生成方法,基于操作系统熵源(如硬件噪声、鼠标移动的精确时机、轨迹、键盘输入时机等),适合密码学场景。其返回值为填充了加密安全整数的数组。

示例1:生成安全随机整数

function getSecureRandomInt(min, max) {
  const range = max - min + 1;
  const randomBuffer = new Uint32Array(1);
  window.crypto.getRandomValues(randomBuffer);
  return min + (randomBuffer[0] % range);
}
console.log(getSecureRandomInt(1, 100)); // 输出类似 42

示例2:Node.js环境生成安全随机数

const crypto = require('crypto');
function getSecureRandomInt(min, max) {
  const randomBuffer = crypto.randomBytes(4); // 生成4字节随机数据
  const randomNumber = randomBuffer.readUInt32BE(0);
  return min + (randomNumber % (max - min + 1));
}
console.log(getSecureRandomInt(1, 100)); // 输出类似 78

2. 优缺点与适用场景

  • 优点
    • 安全性高:基于物理熵源,抵抗攻击能力强
    • 适用于敏感场景:如密码生成、加密密钥、身份验证
  • 缺点
    • 兼容性受限crypto.getRandomValues() 仅支持现代浏览器,Node.js需用 crypto.randomBytes
    • 使用复杂度高:需处理二进制数据(如 Uint32Array
    • 仅生成整数:需手动转换为浮点数或其它格式

适用场景:加密通信、区块链、金融交易、高安全性需求业务


三、可复现随机数:Seedable Generators

1. 原理与工具

在某些场景(如游戏存档、数据模拟)中,需要随机数可复现以保证一致性。seedrandom 是典型的第三方库,通过固定种子生成伪随机序列。

示例:使用 seedrandom 生成可复现随机数

const seedrandom = require('seedrandom');
const rng = seedrandom('my-seed'); // 固定种子
console.log(rng()); // 输出类似 0.723456(每次固定)
console.log(rng()); // 输出类似 0.345678(每次固定)

2. 优缺点与适用场景

  • 优点
    • 可复现性:相同种子生成相同序列,便于调试和测试
    • 灵活性:可自定义种子,支持复杂场景
  • 缺点
    • 安全性低:不适合加密场景
    • 依赖外部库:需安装 seedrandom 或自实现算法

适用场景:游戏开发(如关卡随机化)、数据科学(模拟实验)、测试用例生成


四、特殊场景与实用工具

1. 生成随机布尔值

function getRandomBoolean() {
  return Math.random() >= 0.5;
}
console.log(getRandomBoolean()); // true 或 false

2. 生成随机颜色(十六进制格式)

function getRandomColor() {
  return `#${Math.floor(Math.random() * 16777215).toString(16)}`;
}
console.log(getRandomColor()); // 输出类似 #3e4f1b

3. 生成随机 UUID

function getUUID() {
  return crypto.randomUUID(); // 现代浏览器支持
}
console.log(getUUID()); // 输出类似 '3d7a3b44-5a10-4d72-bf58-c13f01b4a4a6'

4. 生成不重复随机数组

function getUniqueRandomArray(length, min, max) {
  const set = new Set();
  while (set.size < length) {
    set.add(getRandomInt(min, max));
  }
  return Array.from(set);
}
console.log(getUniqueRandomArray(5, 1, 10)); // 输出类似 [3, 7, 1, 9, 5]

五、方法对比与选型建议

方法 安全性 性能 兼容性 适用场景
Math.random() 全平台(含Node) 一般用途、非安全场景
crypto.getRandomValues() 现代浏览器 密码学、高安全性需求
Node.js crypto Node.js 服务器端安全场景
seedrandom 全平台(需引入) 可复现场景(游戏、测试)

六、注意事项

  1. 安全性优先:涉及用户密码、加密的场景必须使用 crypto 系列方法。
  2. 性能优化:高频调用时(如游戏循环),优先选择 Math.random()
  3. 避免隐式偏差Math.random() 生成的浮点数在取整时可能分布不均(如 Math.round(Math.random()*10) 两端概率减半)。
  4. 种子管理:使用可复现随机数时,需妥善保存种子值。

七、总结

JavaScript的随机数生成工具链丰富,开发者需根据场景权衡:

  • 快速原型:直接使用 Math.random()
  • 安全需求:选用 crypto 系列API
  • 可复现性:引入 seedrandom 或自建种子机制
  • 特殊格式:通过数学运算或第三方库转换(如UUID、颜色)

掌握这些工具的核心原理与适用边界,能有效提升代码的健壮性和安全性。

推箱子初体验,Trae如何复刻经典游戏?

2025年8月17日 17:03

前言

今天来还原初中时期记忆中的推箱子游戏,主要是让 Trae 用代码实现这个游戏的核心功能,

你是否还记得曾经初中时期在课余时间沉迷推箱子,今天就看看 Trae 是怎么实现。

游戏的核心逻辑

先把这个核心逻辑发给 Trae,看看他完成的是不是你想要的经典的推箱子游戏。

  1. 游戏开始时,玩家会看到一个由方格组成的网格,其中一些方格上有箱子,部分方格是目标位置。
  2. 玩家通过控制角色推动箱子,当所有箱子都被推到目标位置时,游戏胜利。
  3. 玩家一次只能推动一个箱子,不能斜推。
  4. 玩家在推动箱子时,若前方有障碍物或箱子挡住,则无法推动。
  5. 玩家需要在有限的步数内或规定时间内,将所有箱子推到目标位置,才算通关。

启动页面,发现推箱子的小人不见了,也无法进行操作,只能让Trae修复一下这个致命的bug image.png

将控制台的报错发给Trae,把CSS类名中包含空格字符,DOMTokenList.add()方法不接受包含空格的类名处理掉即可完美的运行

image.png

image.pngimage.png

Trae 代码解读

通过设定网格的行列数、箱子和目标位置的分布,来初始化游戏的布局,生成一个二维数组来表示游戏面板,通过 for 循环来填充网格,合理摆放箱子、目标位置和障碍物。

for (let i = 0; i < rows; i++) {
    const row = [];
    for (let j = 0; j < cols; j++) {
        row.push({
            hasBox: false,
            isTarget: false,
            isObstacle: false,
            isPlayer: false
        });
    }
    grid.push(row);
}

// 放置箱子
for (let i = 0; i < boxCount; i++) {
    const row = Math.floor(Math.random() * rows);
    const col = Math.floor(Math.random() * cols);
    if (!grid[row][col].isTarget && !grid[row][col].isObstacle) {
        grid[row][col].hasBox = true;
    }
}

// 放置目标位置
for (let i = 0; i < targetCount; i++) {
    const row = Math.floor(Math.random() * rows);
    const col = Math.floor(Math.random() * cols);
    if (!grid[row][col].hasBox && !grid[row][col].isObstacle) {
        grid[row][col].isTarget = true;
    }
}

// 放置障碍物
for (let i = 0; i < obstacleCount; i++) {
    const row = Math.floor(Math.random() * rows);
    const col = Math.floor(Math.random() * cols);
    if (!grid[row][col].hasBox && !grid[row][col].isTarget) {
        grid[row][col].isObstacle = true;
    }
}

// 放置玩家
let playerPlaced = false;
while (!playerPlaced) {
    const row = Math.floor(Math.random() * rows);
    const col = Math.floor(Math.random() * cols);
    if (!grid[row][col].hasBox && !grid[row][col].isObstacle) {
        grid[row][col].isPlayer = true;
        playerPlaced = true;
    }
}

通过事件监听实现玩家控制角色移动来推动箱子,并判断游戏是否胜利。

board.addEventListener('keydown', (e) => {
    let newRow = playerRow;
    let newCol = playerCol;

    switch (e.key) {
        case 'ArrowUp':
            newRow--;
            break;
        case 'ArrowDown':
            newRow++;
            break;
        case 'ArrowLeft':
            newCol--;
            break;
        case 'ArrowRight':
            newCol++;
            break;
    }

    // 检查移动是否合法
    if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols && !grid[newRow][newCol].isObstacle) {
        // 如果当前位置有箱子
        if (grid[newRow][newCol].hasBox) {
            // 检查箱子前方是否有空位
            let boxNewRow = newRow;
            let boxNewCol = newCol;

            switch (e.key) {
                case 'ArrowUp':
                    boxNewRow--;
                    break;
                case 'ArrowDown':
                    boxNewRow++;
                    break;
                case 'ArrowLeft':
                    boxNewCol--;
                    break;
                case 'ArrowRight':
                    boxNewCol++;
                    break;
            }

            if (boxNewRow >= 0 && boxNewRow < rows && boxNewCol >= 0 && boxNewCol < cols && !grid[boxNewRow][boxNewCol].hasBox && !grid[boxNewRow][boxNewCol].isObstacle) {
                // 移动箱子
                grid[boxNewRow][boxNewCol].hasBox = true;
                grid[newRow][newCol].hasBox = false;
                // 移动玩家
                grid[playerRow][playerCol].isPlayer = false;
                grid[newRow][newCol].isPlayer = true;
                playerRow = newRow;
                playerCol = newCol;
                checkGameStatus();
            }
        } else {
            // 移动玩家
            grid[playerRow][playerCol].isPlayer = false;
            grid[newRow][newCol].isPlayer = true;
            playerRow = newRow;
            playerCol = newCol;
            checkGameStatus();
        }
    }
});

最后是来自 Trae 自己对这款推箱子的总结,主要是游戏功能和设计,还有考虑到游戏体验,有撤销上一步的操作,非常的人性化。

image.png Trae 在生成时,考虑的情况,主要是箱子、目标位置、障碍物、步数限制、计时器、难度选择等因素。

image.png

总结

1、这个游戏的核心功能,主要是靠玩家来推动箱子,Trae 非常人性化的支持多种操作方式,来让高级玩家有更好的游戏体验。

image.png

2、考虑到游戏玩家可能没玩过,Trae 也是帮我们设计了三个游戏难度以及游戏关卡,不会让新手玩家没有体验感直接进入到地狱难度/高难度关卡,可以一步步的体验到游戏的关卡难度。

image.png

焕新中国象棋体验,Trae如何让传统游戏更现代?

2025年8月17日 16:14

前言

今天来还原童年记忆中的中国象棋游戏,主要是让Trae用代码实现这个游戏的核心功能,你是否还记得小时候,一到下午或者是早晨去公园看一群老爷爷下中国象棋游戏。

今天就看看Trae怎么实现这一款属于中国人的象棋游戏。

这个游戏的核心功能

先把这个核心逻辑发给Trae,看看他完成的是不是你想要的童年记忆的中国象棋。

  1. 游戏开始时,玩家会看到一个棋盘,上面摆放着红方和黑方的棋子,有着楚河汉界作为分割。
  2. 玩家通过点击棋子并选择移动位置来进行走棋,需要遵循每种棋子的走法规则。
  3. 玩家可以尝试将死对方,即让对方的将或帅无路可走,从而获得胜利。
  4. 游戏中包含不同种类的棋子,如将、士、象、车、马、炮、兵等,每种棋子都有独特的走法和作用。
  5. 玩家可以选择不同的难度与电脑对战,或者与另一位玩家对战。

image.png

Trae的输出,对应考虑的方案 image.png

这效果、这完成度看起来有些专业棋类游戏的韵味了,右上角还很贴心的安排上计时器,为玩家营造紧张刺激的对弈氛围,但是有个缺点,页面不够直观,部分内容被挡住了。

棋盘中间还多出一个楚河汉界的字样

image.png

让Trae把布局的样式清掉,不要有明显的bug即可 image.png

先来试试炮的走位是不是正确的,点击炮,出现的黄色圆圈,是符合炮的走位的

image.png

左侧是ai的困难程度

image.png

Trae代码解读

通过设定棋盘的行列数和棋子的初始位置,来初始化游戏的布局,生成一个二维数组来表示棋盘,通过for循环来填充棋盘,摆放棋子的位置。

for (let i = 0; i < rows; i++) {
    const row = [];
    for (let j = 0; j < cols; j++) {
        row.push(null); // 初始化为空棋盘
    }
    board.push(row);
}

// 摆放棋子
board[0][0] = { color: 'black', type: 'chariot' };
board[0][1] = { color: 'black', type: 'horse' };
// 依次摆放其他棋子...

通过逻辑判断实现玩家点击选择棋子并移动的动作,判断走法是否符合规则。

board.addEventListener('click', (e) => {
    const col = Math.floor((e.clientX - boardRect.left) / cellSize);
    const row = Math.floor((e.clientY - boardRect.top) / cellSize);
    selectAndMovePiece(row, col);
    checkGameStatus();
});

最后是来自Trae自己对这款中国象棋的总结,主要是游戏功能和设计,还有考虑到游戏体验,非常的人性化。

Trae在生成时,考虑的情况,主要是棋子走法、胜负判定、难度选择等因素。

总结

1、这个游戏的核心功能,主要是靠玩家来走棋,Trae非常人性化的支持悔棋功能,让新手玩家也能轻松上手。

2、考虑到游戏玩家可能没玩过,Trae也是帮我们设计了详细的游戏规则说明,不会让新手玩家一头雾水,可以一步步的了解游戏的玩法。

image.png

你是否还记得那年夏天,看着一群老爷爷下棋,一下午就很快过去了呢?

【前端特效系列】css+js实现聚光灯效果

作者 黑心皮蛋
2025年8月17日 11:58

✨ 前言

源码地址:leixq1024/FrontEndSnippetHub: ✨html+css+js的前端特效合集

本次灵感来源:codepen.io/zorgos/pen/…

这个系列主要分享一些用css+html+js制作的前端特效或动画的代码实现思路和解析。如果对你有帮助请给仓库点一个✨

🎬 效果演示

聚光灯演示效果

🧰 前期准备

这里我准备了两个图片一个是地图,一个是火把gif,并且创建了index.htmlstyle.cssindex.js三个文件

image.png

🗺️ 初始化场景

index.html

<!DOCTYPE html>
<html lang="zh-cn">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>地图聚光灯</title>
    <!-- <link rel="stylesheet" href="style.css" /> -->
  </head>
  <body>
    <div class="map">
      <!-- 黑色遮罩 -->
      <div class="mask" id="mask"></div>
      <!-- 火把gif,随光圈移动 -->
      <img id="torch" src="./img/torch.gif" alt="火把" />
    </div>
    <script src="index.js"></script>
  </body>
</html>

刚开始没有设置样式效果就如下

image-20250817113125370

🎨 编写样式

先把地图放上去

html,
body {
  position: relative;
  width: 100%;
  height: 100%;
  margin: 0;
  cursor: none;
}
/* 地图 */
.map {
  position: relative;
  width: 100vw;
  height: 100vh;
  background: url('./img/map.png') no-repeat;
  background-size: 100% 100%;
}

效果如下

image-20250817113413388

接下来做一个黑色的背景,并且用mask-image做一个蒙版

.mask {
  position: absolute;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 1);
  mask-image: radial-gradient(
    circle var(--r, 110px) at var(--x, 50%) var(--y, 50%),
    transparent 0%,
    transparent 50%,
    black 100%
  );
  transition: -webkit-mask-position 0.06s linear;
  transition: mask-position 0.06s linear;
  pointer-events: auto;
}
  • 通过mask-image的径向渐变创建圆形透明区域
  • transparent 0%transparent 50%:中心区域完全透明(显示底层内容)
  • black 100%:边缘黑色区域遮挡内容
  • 最终效果:黑色背景中有一个圆形"窗口"

其中transition: mask-position 0.06s linear;中的mask-position是指遮罩位置,这样遮罩位置变化就会有一个线性的过渡

这里蒙版的一些值用css变量来控制,方便等下用js动态的更新蒙版的位置

效果如下

image-20250817114340987

🔥 火把样式

/* 火把样式 */
#torch {
  position: absolute;
  width: 100px;
  height: 100px;
  pointer-events: none;
  z-index: 10;
  left: var(--x, 50%);
  top: var(--y, 50%);
  transform: translate(-50%, -50%);
}

效果如下

image-20250817114625669

🖱️ 鼠标和滚轮事件

index.js

let radius = 110 // 光照半径
// 设置css变量
const setStyleVar = (el, key, val) => el && el.style.setProperty(key, val)
// 遮罩元素
const mask = document.getElementById('mask')
// 火把元素
const torch = document.getElementById('torch')
// 修改遮罩层光圈位置
const setPos = (clientX, clientY) => {
  const { left, top } = mask.getBoundingClientRect()
  setStyleVar(mask, '--x', clientX - left + 'px')
  setStyleVar(mask, '--y', clientY - top + 'px')
  // 火把居中显示在光圈圆心
  setStyleVar(torch, '--x', clientX - left + 'px')
  setStyleVar(torch, '--y', clientY - top + 'px')
}
// 鼠标移动时,更新遮罩层光圈位置
mask.addEventListener('mousemove', (e) => {
  setPos(e.clientX, e.clientY)
})
// 滚轮滚动时,更新光照半径
mask.addEventListener('wheel', (e) => {
  radius = Math.max(50, Math.min(200, radius + e.deltaY * 0.1))
  setStyleVar(mask, '--r', radius + 'px')
})
let flickerTime = 0
// 遮罩层呼吸效果
const maskBreathe = () => {
  flickerTime += 0.05
  setStyleVar(mask, '--r', radius + Math.sin(flickerTime * 3) * 3 + 'px')
  requestAnimationFrame(maskBreathe)
}
maskBreathe()

其中 setStyleVar(mask, '--r', radius + Math.sin(flickerTime * 3) * 3 + 'px')是通过正弦函数拟火把的自然闪烁效果

🌟 最终效果

聚光灯演示效果

2025.8.18实验室【代码跑酷指南】Jupyter Notebook程序员的魔法本:cpolar内网穿透实验室第622个成功挑战

软件名称:Jupyter Notebook 操作系统支持: 全平台通吃!Windows、Linux、macOS都能玩,甚至还能在树莓派上开个“迷你数据科学工作站”。 软件介绍 Jupyter是“交互式

nginx 如何配置防止慢速攻击 🤔🤔🤔

作者 Moment
2025年8月18日 09:01

最近在出一个前端的体系课程,里面的内容非常详细,如果你感兴趣,可以加我 v 进行联系 yunmz777:

image.png

浪费你几秒钟时间,内容正式开始

慢速攻击是一类用很少带宽就能长期占用服务器连接/资源的攻击方式。攻击者通过非常慢地发送请求头或请求体,或极慢地读取服务器响应,让每个连接都“挂着不结束”,从而耗尽 Web 服务器(或上游应用、数据库、代理)的并发与缓冲资源。

典型类型主要有以下几个方面:

  • Slowloris(慢请求头):客户端以极低速率分片发送 HTTP 头部,始终不把头部发完,服务器就一直等待。

  • Slow POST / RUDY(慢请求体):先宣称要上传较大的 Content-Length,然后以极慢速率发送请求体,服务器为其保留缓冲与上游连接。

  • Slow Read(慢读取响应):客户端窗口/读取速率极低,迫使服务器缓冲并保持连接很久(尤其响应较大时)。

  • HTTP/2 变种:滥用单连接多流(streams)和窗口控制:开很多流但每流都很慢,放大资源占用。

20250818085811

传统 DDoS 依靠高带宽与高包速直接压垮网络/设备,而慢速攻击用极低带宽长期占用连接,更隐蔽,常被误认为是网络状况差的正常用户。

现场通常会看到活跃连接(尤其 reading)持续攀升,但总体带宽并不高。

error_log 中频繁出现 client timed outclient sent invalid header while reading client request headers 等信息。

上游服务看似空闲却体验发卡,Nginx 的 429/502/504 增多,访问日志还能发现同一 IP 维持大量长期未完成的请求或异常长的响应时间。

  • 429 表示“请求过多被限流”,通常稍后或按 Retry-After 重试即可。

  • 502 表示“网关收到上游无效响应或连不上上游”,多见于上游挂掉、拒连或协议不匹配。

  • 504 表示“等待上游超时”,通常是上游处理太慢一直没回。

如何防护

核心目标就是尽快关闭拖延发送请求头/请求体或极慢读取响应的连接,限制单 IP 的并发与速率,避免慢连接占满 workerworker_connections 与上游资源。

  • 收紧超时:client_header_timeoutclient_body_timeoutsend_timeoutkeepalive_timeout

  • 超时立刻复位:reset_timedout_connection on; 减少 TIME_WAIT/ 资源滞留。

  • 限并发/限速:limit_connlimit_req(必要时返回 429 并带 Retry-After)。

  • HTTP/2 参数:降低 http2_max_concurrent_streams,设置 http2_recv_timeout/http2_idle_timeout

  • 反向代理场景:proxy_request_buffering on; 先把请求缓冲到 Nginx,避免慢上传占住上游。

  • 分路径/分人群:对登录、搜索等接口更严;对可信源/健康检查放宽或白名单。

  • 边缘清洗:结合 CDN/WAF 的连接层/应用层限速更稳。

一些相关的配置可以参考下面的 Nginx 配置:

worker_processes auto;

events {
    worker_connections  4096;
    multi_accept        on;
}

http {
    # 1) 关键超时(防慢头/慢体/慢读)
    client_header_timeout  5s;   # 等头部时间
    client_body_timeout    10s;  # 等请求体每个读周期的时间
    send_timeout           10s;  # 发送响应给客户端每个写周期的时间
    keepalive_timeout      10s;  # keep-alive 连接空闲时间
    keepalive_requests     100;  # 单连接最大请求数,防长时间占用

    # 2) 连接超时直接复位(释放资源更快)
    reset_timedout_connection on;

    # 3) 并发限制(每 IP)
    #    10m 可容纳 ~160k 键(基于 $binary_remote_addr)
    limit_conn_zone $binary_remote_addr zone=perip:10m;

    # 4) 速率限制(每 IP),按需调大/调小 rate
    limit_req_zone  $binary_remote_addr zone=req_perip:10m rate=10r/s;

    # 5) HTTP/2 专项(若开启了 http2)
    http2_max_concurrent_streams 64; # 降并发流数
    http2_recv_timeout           5s; # 接收客户端帧超时
    http2_idle_timeout          10s; # HTTP/2 空闲超时

    # 6) 合理的头部缓冲(避免过大内存占用;默认已够用,按需微调)
    large_client_header_buffers 4 8k;

    server {
        listen 443 ssl http2;
        server_name example.com;

        # 并发/速率在 server 层生效
        limit_conn       perip 20;        # 每 IP 并发连接上限
        limit_conn_status 429;

        limit_req        zone=req_perip burst=20 nodelay;  # 短突发
        limit_req_status 429;

        # 限制请求体大小(配合 body_timeout 可更快淘汰异常大/慢上传)
        client_max_body_size 10m;

        # 【反向代理站点强烈推荐】先把完整请求缓冲到 Nginx
        # 避免上游被慢上传拖住连接
        location / {
            proxy_pass http://app_backend;
            proxy_request_buffering on;

            proxy_connect_timeout  3s;
            proxy_send_timeout    10s;  # 向上游发送(写)超时
            proxy_read_timeout    30s;  # 自上游读取(读)超时
        }

        # 对静态资源可放宽速率限制以提升体验(示例)
        location ~* \.(?:css|js|png|jpg|jpeg|gif|webp|ico|svg)$ {
            root /var/www/html;
            access_log off;
            expires 30d;
        }

        # 自定义 429 页面(可选)
        error_page 429 /429.html;
        location = /429.html { internal; return 429 "Too Many Requests\n"; }
    }
}

除此之外,还有一些数值上的建议:

  1. 高频 API 可将 rate 调小、burst 适度放大;页面类流量可相反。

  2. 对上传较多的业务,将 client_body_timeoutproxy_request_buffering on; 组合尤为关键。

  3. 如果公网复杂、遭遇中等强度慢攻:client_header_timeout 2-3sclient_body_timeout 5-8ssend_timeout 8-10s 往往更稳。

考虑到移动网络或跨境访问确实可能很慢,限流需要在防护与容错间取平衡。可以适度调大 burst,并返回合理的 Retry-After,让偶发拥塞得以通过。把严格策略仅应用在登录、搜索等敏感接口,对静态资源和页面流量适当放宽。对可信来源(如办公网、监控、合作方)设置白名单或更高配额,尽量减少误杀。

之于上面的理解,我们可以针对不同慢速攻击的做不同的优化了:

  • Slowloris(慢头部):用 client_header_timeout 严控请求头收齐时间,配合较短的 keepalive_timeout 降低长连驻留,并用 limit_conn 限制每 IP 并发;一旦超时,借助 reset_timedout_connection on; 立即复位断开。

  • RUDY / Slow POST(慢体):设置较短的 client_body_timeout,并开启 proxy_request_buffering on; 先在 Nginx 缓冲请求体,慢上传直接在边缘被淘汰且不上游;必要时配合 client_max_body_size 约束体积。

  • Slow Read(客户端读超慢):通过 send_timeout 限制客户端读取过慢的连接,触发即复位释放缓冲;若是 SSE/长轮询等合法长连,为对应路径单独放宽 send_timeout,避免误伤。

20250818085955

总结

慢速攻击是用极低带宽长期占用服务器连接/缓冲的攻击:攻击者故意慢发请求头/请求体或慢读响应,让连接一直不结束,耗尽并发与内存。

常见形态有 Slowloris(慢头部)、RUDY/Slow POST(慢请求体)与 Slow Read(慢读响应),在 HTTP/2 下还能通过多流+窗口控制放大影响。

典型症状是活跃连接(尤其 reading)持续升高但总体带宽不高,日志频繁出现超时/异常头部,且 429/502/504 增多、同一 IP 大量长时间未完成请求。

防护要点是收紧超时(client_header/body/send/keepalive)、开启 reset_timedout_connection、用 limit_conn/limit_req 控制每 IP 并发与速率,反向代理时启用 proxy_request_buffering on; 并调优 HTTP/2;同时对敏感路径更严、对可信来源适度放宽或白名单以减少误杀。

栗子前端技术周刊第 94 期 - React Native 0.81、jQuery 4.0.0 RC1、Bun v1.2.20...

2025年8月18日 08:50

🌰栗子前端技术周刊第 94 期 (2025.08.11 - 2025.08.17):浏览前端一周最新消息,学习国内外优秀文章视频,让我们保持对前端的好奇心。

📰 技术资讯

  1. React Native 0.81:React Native 0.81 版本新增了对 Android 16 的支持,提升了 iOS 构建速度,并进行了一系列稳定性改进。

  2. jQuery 4.0.0 RC1:jQuery 4.0.0 首个候选版本已发布,团队认为它已接近完成,希望开发者进行广泛测试,若未发现需修复的问题,将直接推出正式版本 jQuery 4.0.0。新版本主要变化与亮点包括:不再支持 IE 11 以下版本、删减遗留代码与弃用 API、引入精简版本等等。

  3. Bun v1.2.20:Bun v1.2.20 修复了 141 个问题,并带来了显著的性能提升,包括降低空闲状态下的 CPU 使用率,以及将 AbortSignal.timeout 的速度提升 40 倍。

📒 技术文章

  1. What we learned from creating PostCSS:那些从创建 PostCSS 中所学到的经验 - 12 年前,作者创建了 PostCSS —— 一款 CSS 自动化工具,其月下载量达 4 亿次,被谷歌、维基百科、Tailwind 以及 38% 的开发者所使用。在本文中,作者将分享在维护这个热门开源项目的漫长历程中所学到的经验。

  2. How to Use innerHTML, innerText, and textContent Correctly in JavaScript:如何在 JavaScript 中正确使用 innerHTMLinnerTexttextContent - 本文将解释 JavaScript 中三个 DOM 属性的区别:innerHTML 返回包含标签的完整 HTML 内容,innerText 仅返回受 CSS 规则影响的可见样式文本,而 textContent 则返回所有文本内容,无论其在 CSS 中是否可见。这三个属性在 DOM 操作中适用于不同的使用场景。

  3. 前端必学-完美组件封装原则:此文总结了作者多年组件封装经验,以及拜读 antdelement-plusvantfusion 等多个知名组件库所提炼的完美组件封装的经验;是一个开发者在封装项目组件,公共组件等场景时非常有必要遵循的一些原则。

🔧 开发工具

  1. ReactJS Cheatsheet:一份简洁且对开发者友好的 ReactJS 速查表,汇总了核心概念、必备 Hooks、路由、性能优化技巧以及 React 18 的新特性。
image-20250816142106838
  1. vue-scan:让你的组件在每次更新时都闪现红色边框,帮助你排查性能问题。
image-20250817090008862
  1. react-json-view:react-json-view(简称 rjv)是一个用于展示和编辑 JavaScript 数组及 JSON 对象的 React 组件。
image-20250817090054910

🚀🚀🚀 以上资讯文章选自常见周刊,如 JavaScript Weekly 等,周刊内容也会不断优化改进,希望你们能够喜欢。

💖 欢迎关注微信公众号:栗子前端

❌
❌