阅读视图

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

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

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 的核心原理,再到引擎的精密运作机制,全方位提升你的技术洞察力!

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

前言

在上一篇中,我们已经通过<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。

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

微信闪照小程序实现

已经有一年半没有写文章了,今天给掘友们写一个闪照实现的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>

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

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

前言

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

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

有没有插件能解决这个问题呢?答案就在这篇文章里。本系列将带大家从 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:联合类型可以转化为元组类型吗?数组如何用联合类型逐项约束?

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

联合类型能转化为元组类型吗.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 工具类型判断类型集合是否完全一致。

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

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

✨ 前言

源码地址: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')是通过正弦函数拟火把的自然闪烁效果

🌟 最终效果

聚光灯演示效果

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

🌰栗子前端技术周刊第 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 等,周刊内容也会不断优化改进,希望你们能够喜欢。

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

React状态更新踩坑记:我是这样优雅修改参数的

大家好,我是小杨,一名有6年经验的前端开发工程师。在React开发中,状态(State)和参数(Props)的修改是最基础但也最容易踩坑的部分。今天我就来分享几种常见的React参数修改方法,以及我在项目中总结的最佳实践,避免大家走弯路。


1. 直接修改State?大忌!

新手常犯的一个错误是直接修改state,比如:

// ❌ 错误示范:直接修改state
this.state.count = 10;  

React的state不可变(Immutable) 的,直接修改不会触发重新渲染。正确的做法是使用setState(类组件)或useState的更新函数(函数组件)。


2. 类组件:setState的正确姿势

在类组件里,修改状态必须用setState

class Counter extends React.Component {
  state = { count: 0 };

  increment = () => {
    // ✅ 正确方式:使用setState
    this.setState({ count: this.state.count + 1 });
  };

  render() {
    return <button onClick={this.increment}>Count: {this.state.count}</button>;
  }
}

注意setState异步的,如果依赖前一个状态,应该用函数式更新:

this.setState((prevState) => ({ count: prevState.count + 1 }));

3. 函数组件:useState + 不可变更新

在函数组件里,我们使用useState,同样要遵循不可变原则:

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  const increment = () => {
    // ✅ 正确方式:使用useState的更新函数
    setCount(count + 1);
  };

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

如果新状态依赖旧状态,推荐使用函数式更新:

setCount((prevCount) => prevCount + 1);

4. 修改对象或数组:避免引用突变

React要求状态更新必须是不可变的,所以直接修改对象或数组的属性是不行的:

const [user, setUser] = useState({ name: 'Alice', age: 25 });

// ❌ 错误:直接修改对象
user.age = 26;  
setUser(user); // 不会触发更新!

// ✅ 正确:创建新对象
setUser({ ...user, age: 26 });

数组的更新也要遵循不可变原则:

const [todos, setTodos] = useState(['Learn React', 'Write Blog']);

// ✅ 正确:使用展开运算符或map/filter
setTodos([...todos, 'New Task']); // 添加
setTodos(todos.filter((todo) => todo !== 'Learn React')); // 删除

5. 性能优化:useState vs useReducer

如果状态逻辑较复杂,useState可能会变得臃肿,这时可以用useReducer

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error('Unknown action');
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <div>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
    </div>
  );
}

useReducer适合管理复杂状态逻辑,比如表单、全局状态等。


6. 常见坑点 & 解决方案

① 连续setState不会立即更新

// ❌ 连续调用setState,count只会+1
setCount(count + 1);
setCount(count + 1);

// ✅ 使用函数式更新
setCount(prev => prev + 1);
setCount(prev => prev + 1); // 现在会+2

② useEffect依赖问题

如果useEffect依赖state,但忘记加进依赖数组,可能导致闭包问题:

useEffect(() => {
  console.log(count); // 可能拿到旧值
}, []); // ❌ 缺少依赖

useEffect(() => {
  console.log(count); // ✅ 正确
}, [count]); // 依赖正确

总结

  • 不要直接修改state,使用setStateuseState的更新函数
  • 对象/数组更新时,创建新引用
  • 复杂状态逻辑用useReducer
  • 连续更新用函数式setState
  • useEffect依赖要写全

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

vite和webpack打包结构控制

概述

在工程化项目中,Vite 和 Webpack 作为当前最流行的两大构建工具,它们在打包输出目录结构的配置上各有特点,webpack和vite默认打包构建的输出目录结构可能不满足我们的需求,因此需要根据实际情况进行控制。

默认输出结构对比

webpack

默认情况下,基本上都不会根据情况进行分块,所有资源都是默认被打包到了一个文件中。

dist/
  ├── main.js
  ├── index.html
  |—— .....

vite

dist/
  ├── assets/
  │   ├── index.[hash].js
  │   ├── vendor.[hash].js
  │   └── style.[hash].css
  └── index.html

Vite目录结构精细控制

文件指纹策略

Webpack 提供了多种 hash 类型:

  • [hash]: 项目级hash
  • [chunkhash]: chunk级hash
  • [contenthash]: 内容级hash

基础配置方案

// vite.config.js
export default {
  build: {
    outDir: 'dist',
    assetsDir: 'static',
    emptyOutDir: true
  }
}

Rollup 输出配置

由于vite内部打包使用rollup,因此打包输出相关配置需参考rollup的配置

export default {
  build: {
    rollupOptions: {
      output: {
          //资源块输出目录配置
        chunkFileNames: 'static/js/[name]-[hash].js',
        //入口文件输出目录配置
        entryFileNames: 'static/js/[name]-[hash].js',
        //静态输出目录配置(图片、音频、字体)
        assetFileNames: ({ name }) => {
          const ext = name.split('.').pop()
          //函数形式动态返回文件输出名及其位置
          return `static/${ext}/[name]-[hash].[ext]`
        }
      }
    }
  }
}

webpack 目录结构精细控制

基础输出配置

// webpack.config.js
module.exports = {
  output: {
    path: path.resolve(__dirname, 'build'), // 修改输出目录
    filename: 'js/[name].[contenthash:8].js', // JS文件输出路径
    chunkFilename: 'js/[name].[contenthash:8].chunk.js', // 异步chunk
    assetModuleFilename: 'media/[name].[hash:8][ext]', // 静态资源
    clean: true // 构建前清空目录
  }
}

高级资源管理

使用 mini-css-extract-plugin 控制 CSS 输出:

  module: {
    rules: [
      { test: /\.css$/, use: [MiniCssExtractPlugin.loader, "css-loader"] },
     
    ],
  },

Webpack 5 引入了资源模块类型,取代了传统的 file-loader/url-loader,用来处理之前繁琐的配置

 module: {
   rules: [
{
       test: /.(png|jpe?g|gif|svg)$/i,
       type: 'asset/resource' // 替换 file-loader
     },
     {
       test: /.(mp4|webm|ogg)$/i,
       type: 'asset/resource'
     }
   ],
 },

四种资源模块类型

类型 作用 等价 loader
asset/resource 导出单独文件并生成 URL file-loader
asset/inline 导出资源的 Data URI url-loader
asset/source 导出资源的源代码 raw-loader
asset 自动选择 resource 或 inline url-loader + 限制

总结

上面列举的部分配置,更多的详细配置,可以查阅官网解析。

前端必会:如何创建一个可随时取消的定时器

一、原生的取消方式

JavaScript 原生就提供了取消定时器的方法。setTimeoutsetInterval 在调用时都会返回一个数字类型的 ID,我们可以将这个 ID 传递给 clearTimeoutclearInterval 来取消它。

// 1. 设置一个定时器
const timerId: number = setTimeout(() => {
  console.log("这个消息可能永远不会被打印");
}, 2000);

// 2. 在它触发前取消它
clearTimeout(timerId);

常见痛点:

  • timerId 变量需要被保留在组件或模块的作用域中,状态分散。
  • 启动、暂停、取消的逻辑是割裂的,代码可读性和可维护性差。

二、封装一个可取消的定时器类

我们可以简单的封装一个 CancellableTimer 类,将定时器的状态和行为内聚在一起。后续可以扩展,把项目中的所有定时器进行统一管理。

// 定义定时器ID类型
type TimeoutId = ReturnType<typeof setTimeout>;

class CancellableTimer {
    private timerId: TimeoutId | null = null;

    constructor(private callback: () => void, private delay: number) {}

    public start(): void {
        // 防止重复启动
        if (this.timerId !== null) {
            this.cancel();
        }

        this.timerId = setTimeout(() => {
            this.callback();
            // 执行完毕后重置 timerId
            this.timerId = null;
        }, this.delay);
    }

    public cancel(): void {
        if (this.timerId !== null) {
            clearTimeout(this.timerId);
            this.timerId = null;
        }
    }
}

// 使用示例
console.log('定时器将在3秒后触发...');
const myTimer = new CancellableTimer(() => {
    console.log('定时器任务执行!');
}, 3000);

myTimer.start();

// 模拟在1秒后取消
setTimeout(() => {
    console.log('用户取消了定时器。');
    myTimer.cancel();
}, 1000);

三、实现可暂停和恢复的定时器

在很多场景下,我们需要的不仅仅是取消,还有暂停恢复

要实现这个功能,我们需要在暂停时记录剩余时间

type TimeoutId = ReturnType<typeof setTimeout>;

class AdvancedTimer {
    private timerId: TimeoutId | null = null;
    private startTime: number = 0;
    private remainingTime: number;
    private callback: () => void;
    private delay: number;


    constructor(callback: () => void, delay: number) {
        this.remainingTime = delay;
        this.callback = callback;
        this.delay = delay;
    }

    public resume(): void {
        if (this.timerId) {
            return; // 已经在运行
        }

        this.startTime = Date.now();
        this.timerId = setTimeout(() => {
            this.callback();
            // 任务完成,重置
            this.remainingTime = this.delay;
            this.timerId = null;
        }, this.remainingTime);
    }

    public pause(): void {
        if (!this.timerId) {
            return;
        }

        clearTimeout(this.timerId);
        this.timerId = null;
        // 计算并更新剩余时间
        const timePassed = Date.now() - this.startTime;
        this.remainingTime -= timePassed;
    }

    public cancel(): void {
        if (this.timerId) {
            clearTimeout(this.timerId);
        }
        this.timerId = null;
        this.remainingTime = this.delay; // 重置
    }
}

// 使用示例
console.log('定时器启动,5秒后执行...');
const advancedTimer = new AdvancedTimer(() => console.log('Done!'), 5000);
advancedTimer.resume();

setTimeout(() => {
    console.log('2秒后暂停定时器');
    advancedTimer.pause();
}, 2000);

setTimeout(() => {
    console.log('4秒后恢复定时器 , 应该还剩3秒');
    advancedTimer.resume();
}, 4000);

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript/TypeScript开发干货

Oxc 最新 Transformer Alpha 功能速览! 🚀🚀🚀

前言

刚刚看到尤雨溪推特转发了 OXC 团队的最新成果,并介绍了该成果背后的一些故事!

尤雨溪推特

今天介绍下这些详细成果!

往期精彩推荐

正文

Oxc Transformer Alpha 内置 React Refresh,以及无需 TypeScript 编译器的独立 .d.ts 文件生成。相较于 SWC 和 Babel,Oxc 在性能、内存占用和包体积上表现出色,堪称前端构建的实用利器。

以下是其核心特性的详细解析。

1. TypeScript 和 JSX 到 ESNext 转换

Oxc 支持将 TypeScript 和 React JSX 代码转换为 ESNext,性能显著优于传统工具:

  • 3-5 倍于 SWC:处理 100 到 10,000 行代码,Oxc 耗时仅 0.14ms 至 14.9ms,而 SWC 为 0.7ms 至 35.9ms。
  • 20-50 倍于 Babel:Babel 处理同样代码耗时 11.5ms 至 492ms,Oxc 效率遥遥领先。

2. 内置 React Refresh

Oxc 集成了 React Refresh,支持开发中的热重载,速度比 SWC 快 5 倍,比 Babel 快 50 倍。这让 React 开发更流畅,减少等待时间。

3. TypeScript 独立声明生成

Oxc 提供无需 TypeScript 编译器的 .d.ts 文件生成,性能惊人:

  • 40 倍于 TSC:处理 100 行代码仅需 0.1ms(TSC 为 23.1ms)。
  • 20 倍于大文件:10,000 行代码耗时 3.5ms(TSC 为 115.2ms)。

示例

import { transform } from 'oxc-transform';
const transformed = transform('file.ts', sourceCode, {
  typescript: {
    onlyRemoveTypeImports: true,
    declaration: { stripInternal: true },
  },
});
await fs.writeFile('out.js', transformed.code);
await fs.writeFile('out.d.ts', transformed.declaration);

4. 轻量级与低内存占用

Oxc 仅需 2 个 npm 包(总计 2MB),对比 SWC 的 37.5MB 和 Babel 的 21MB(170 个包)。内存占用上,Oxc 处理 10,777 行代码仅用 51MB 内存,SWC 用 67MB,Babel 高达 172MB。

5. 实际应用案例

  • Vue.js:实验性使用 oxc-transform 优化构建流程。
  • vue-macros:通过 unplugin-isolated-decl.d.ts 生成时间从 76s 降至 16s。
  • Airtable:在 Bazel 构建中集成 Oxc 的 .d.ts 生成。
  • Rolldown:直接使用 Rust oxc_transformer crate。

最后

Oxc Transformer Alpha 以 Rust 的高性能和轻量级设计,为 JavaScript 编译带来新可能。无论是加速 TypeScript 转换还是优化 React 开发体验,它都展现了朴实无华的实用力量!

今天的分享就这些了,感谢大家的阅读!如果文章中存在错误的地方欢迎指正!

往期精彩推荐

拯救你的app/小程序审核!一套完美避开审核封禁的URL黑名单机制

app/微信小程序审核又双叒叕被拒了?因为一个历史遗留页面导致整个小程序被封禁搜索?别让精心开发的app/小程序毁在几个不起眼的URL上!本文将揭秘我们在多次惨痛教训后总结出的终极解决方案。

前言:每个小程序开发者都经历过的噩梦

凌晨两点,微信审核通知:"您的小程序因存在违规页面,搜索功能已被限制"。看着辛苦运营的用户量断崖式下跌,排查三天才发现是因为一个早已下架但还能访问的历史页面。这不是假设,而是真实发生的灾难场景

在经历多次微信审核失败后,我们意识到:必须有一套灵活、实时的URL黑名单机制,能够在app/微信审核发现问题前,快速屏蔽任何违规页面。这套系统需要:

  1. 分钟级响应:新发现的违规URL,1分钟内全局生效

  2. 精准打击:既能拦截整个页面,也能封禁特定参数组合

  3. 零误杀:确保正常页面不受影响

  4. 优雅降级:被拦截用户跳转到友好提示页,且可一对一设置兜底页。

下面是我们用血泪教训换来的完整解决方案,已成功帮助我们通过n多次app审核。

app/微信小程序审核的致命陷阱:你未必意识到的风险点

真实审核失败案例

  • 案例1:三年前的活动页仍可通过直接URL访问(违反现行规则)

  • 案例2:用户生成内容包含敏感关键词(UGC页面)

  • 案例3:第三方合作伙伴的H5页面突然变更内容

  • 最致命案例:历史页面被微信爬虫索引,导致整个小程序搜索功能被封禁

核心需求清单(微信审核视角)

  1. 实时封堵能力:无需发版即可封禁任意URL

  2. 精准匹配:支持完整URL和带参数的URL匹配

  3. 全类型覆盖:原生页面 + H5页面统一处理

  4. 优雅降级:被封禁用户看到友好提示而非404

  5. 安全兜底:系统异常时自动放行,不影响正常业务

系统架构设计:三重防护盾

核心流程

  1. 所有跳转请求经过黑名单检查

  2. 命中规则则跳转到兜底页

  3. 系统异常时降级放行

  4. 后台配置秒级生效

核心技术实现

参数级精准打击 - 只封禁违规内容

// 黑名单配置
["pages/user/content?type=sensitive"]

// 结果:
"pages/user/content?type=normal" => 放行 ✅
"pages/user/content?type=sensitive" => 拦截 ⛔

微信审核场景:当只有特定参数组合违规时,最小化业务影响

匹配规则详解:如何应对app审核

场景1:紧急封禁整个页面(后台配置示例)

{
  "YourBlackList": [
    {
      "nowUrl": "https://baidu.com",
      "ToUrl": "www.juejin.cn"
    }
  ]
}

只要命中 baidu.com 无论实际跳转页面后面参数是什么,都命中了黑名单,直接跳转到自己的兜底页](url)

场景2:精准封禁违规内容

// 配置黑名单
{
  "YourBlackList": [
    {
      "nowUrl": "pages/news/detail?id=12345",
      "ToUrl": "www.baidu.com"
    }
  ]
}
// 效果:
仅拦截id=12345的新闻,如果命中,则跳转到百度(你设置的兜底页)。其他正常展示

场景3:批量处理历史内容

// 配置黑名单
{
  "YourBlackList": [
    {
      "nowUrl": "pages/history/?year=2020",
      "ToUrl": "www.baidu.com"
    }
  ]
}

// 效果:
拦截2020年的所有历史页面,其他年份正常

实际应用:拯救审核的最后一公里

在路由跳转处拦截

async function myNavigateTo(url) {
  const { isBlocked, ToUrl } = checkUrlBlacklist(url);
  if (isBlocked) {
    console.warn('审核风险页面被拦截:', url);
    // 跳转到安全页
    return wx.navigateTo({ url: ToUrl });
  }
  
  // 正常跳转逻辑...
}

性能与安全:双保险设计

二重保障机制

  1. 性能优化:黑名单为空时短路返回
if (!blackUrlList.length) return { isBlocked: false };
  1. 频率控制:避免相同URL重复解析

更新时机

app/小程序初始化时,如果想更精细一些,可以监听app/小程序后台切到前台onShow时

  // 获取阿波罗接口配置
      const resp = await request({
        url: 'https://你的后台配置接口',
      });
      // 这里blackUrlInfoList需要保存在全局,可以放在本地存储下
      blackUrlInfoList = res.blackUrlInfoList || []

校验时机:每次跳转时。

具体判断逻辑在此不做阐述,

总结:从此告别审核噩梦

通过实施这套URL黑名单系统,我们实现了:

  • 审核通过率从63% → 98%

  • 问题响应时间从2天 → 5分钟

  • 搜索封禁事故0发生

关键收获

  1. 提前拦截比事后补救更重要

  2. 参数级控制最大化保留正常功能

  3. 实时配置能力是应对审核的关键

现在点击右上角收藏本文,当app审核再次亮红灯时,你会感谢今天的自己!


分享你的审核故事:你在微信/app审核中踩过哪些坑?欢迎在评论区分享你的经历和解决方案!

nextjs项目build导出静态文件

只需要在next.config.ts中配置output为export即可,可以配置三种模式。

undefined:默认的构建输出,.next目录,适用于生产模式下的next start或Vercel等托管提供商

“standalone”:一个独立的构建输出,.next/stalone目录,仅包含必要的文件/依赖项。适用于Docker容器中的自托管。

'export':导出的构建输出,输出目录,仅包含静态HTML/CSS/JS。适用于没有Node.js服务器的自托管

配置完成后,再build:

JavaScript 日期的奇妙冒险:当 UTC 遇上 el-date-picker

前情提要:程序员小明在使用 Element Plus 的日期选择器时,发现同一个时间用不同方式设置默认值,居然显示出了三个不同的日期。这到底是闹哪样?

🌍 UTC:时间界的"标准答案"

首先,我们来认识一下 UTC(协调世界时),它就像是时间界的"标准答案"。

想象一下,如果全世界的手表都不统一,那该多乱:

  • 北京的程序员说:"我们下午 3 点开会"
  • 纽约的程序员回复:"哪个下午 3 点?"
  • 伦敦的程序员插话:"我这里已经晚上了..."

为了避免这种尴尬,聪明的人类发明了 UTC:

  • UTC = 全世界公认的"标准时间"
  • 可以理解为"0 时区"的时间
  • 所有其他时区都是 UTC ± 几小时
// UTC 就像是时间界的"普通话"
const utcTime = new Date().toISOString(); // "2023-12-25T08:30:45.123Z"
// 末尾的 Z 就是 UTC 的标志,意思是"Zulu Time"(军事术语)

🎭 三种日期格式的"变脸"表演

第一幕:toISOString().split('T')[0] - "我是个好孩子"

const format1 = new Date().toISOString().split('T')[0];
console.log(format1); // "2023-12-25"

这家伙的内心独白

"我看起来很无辜,就是个普通的日期字符串。但是!我其实是基于 UTC 时间的。如果你在东八区,而现在是北京时间凌晨 2 点,我可能会给你显示昨天的日期。惊不惊喜?意不意外?"

问题所在:看起来人畜无害的 "2023-12-25",但 el-date-picker 会按本地时区解释,可能出现日期偏移。

第二幕:toUTCString() - "我很正式但很难懂"

const format2 = new Date().toUTCString();
console.log(format2); // "Mon, 25 Dec 2023 08:30:45 GMT"

这家伙的内心独白

"我是正宗的 UTC 格式,很正式很标准。但是 el-date-picker 看到我就懵了:'这是什么鬼格式?Mon?Dec?GMT?我该怎么解析你?'"

问题所在:格式太"学术",el-date-picker 可能无法正确解析或解析结果不符合预期。

第三幕:dayjs().format("DD MMM YYYY HH:mm:ss") - "我很个性"

const format3 = dayjs(new Date()).format("DD MMM YYYY HH:mm:ss");
console.log(format3); // "25 Dec 2023 16:30:45"

这家伙的内心独白

"我很有个性,用的是自定义格式。我基于本地时间,没有时区信息。el-date-picker 看到我:'你到底是哪个时区的?算了,我随便猜一个吧。'"

问题所在:自定义格式 + 没有时区信息 = 解析结果不可预测。

🎪 时区的"魔术表演"

让我们看看时区是如何"变魔术"的:

// 假设现在是北京时间 2023-12-25 16:30:45 (UTC+8)
const now = new Date();

console.log('北京时间:', now.toString());
// "Mon Dec 25 2023 16:30:45 GMT+0800 (中国标准时间)"

console.log('UTC时间:', now.toUTCString()); 
// "Mon, 25 Dec 2023 08:30:45 GMT"  <- 注意:比北京时间早8小时!

console.log('ISO格式:', now.toISOString());
// "2023-12-25T08:30:45.123Z"  <- Z表示UTC时间

时区换算公式

  • 北京时间 = UTC + 8 小时
  • 纽约时间 = UTC - 5 小时(冬令时)
  • 伦敦时间 = UTC + 0 小时

🚑 救命稻草:el-date-picker 的正确喂食方法

经过一番折腾,我们总结出了 el-date-picker 的正确"喂食"方法:

🥇 金牌方案:直接喂 Date 对象

// 最简单粗暴,不会出错
const dateValue = ref(new Date());

为什么好用

  • Date 对象自带时区信息
  • el-date-picker 天生就认识它
  • 不需要任何格式转换

🥈 银牌方案:完整的 ISO 字符串

// 保留完整的时区信息
const dateValue = ref(new Date().toISOString());

为什么靠谱

  • ISO 格式是国际标准
  • 包含完整的时区信息(那个 Z)
  • 大部分组件都认识

🥉 铜牌方案:dayjs 标准格式

// 使用标准的日期格式
const dateValue = ref(dayjs().format('YYYY-MM-DD'));

适用场景

  • 只需要日期,不需要时间
  • 想要更多控制权

🏆 特别奖:精确控制方案

// 我要的就是今天的开始时间!
const dateValue = ref(dayjs().startOf('day').toDate());

// 注意:dayjs().toDate() 本质上也是 Date 对象,和 new Date() 一样安全
console.log(dayjs().toDate() instanceof Date); // true

🎯 实战代码

<template>
  <div>
    <h3>正确的日期选择器使用姿势</h3>
    
    <!-- 推荐方式 -->
    <el-date-picker
      v-model="dateValue"
      type="date"
      placeholder="选择日期"
    />
  </div>
</template>

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

// 🎉 最推荐:简单直接,不会出错
const dateValue = ref(new Date())

// 其他可行方案:
// const dateValue = ref(dayjs().startOf('day').toDate())  // 精确控制
// const dateValue = ref(new Date().toISOString())         // 完整 ISO
</script>

🤔 思考题

看完这篇文章,你能回答这个问题吗:

如果现在是北京时间 2023-12-25 凌晨 02:00,new Date().toISOString().split('T')[0] 会返回什么?

答案是:"2023-12-24"

为什么?因为:

  1. 北京时间 2023-12-25 02:00 - 8小时 = UTC 时间 2023-12-24 18:00
  2. toISOString() 返回 "2023-12-24T18:00:00.000Z"
  3. split('T')[0] 取日期部分:"2023-12-24"

所以虽然你的本地时间已经是 25 号了,但这个方法返回的还是 24 号!

🎊 总结

记住这个简单的口诀:

日期选择器要喂饱,
Date 对象是最好。
时区问题很头疼,
UTC 标准要记牢!

最后,如果你还在纠结用哪种格式,就直接用 new Date() 吧。简单粗暴,永不出错!

Happy Coding! 愿你的日期永远准确,时区永远正确! 🎈

也是用上webworker了

React 16.8,我自己写的的足球应用,问题是模态框没有立即弹出,反而是等了一会才弹出。

  useImperativeHandle(ref, () => ({
    showModal: (id: string) => {
      const newState = {
        ...state,
        id,
        teamCount: teamOddList.length,
        tableLoading: true,
        open: true,
      };
      setState(newState);
      setAddedItems(new Map());

      // 初始化已存在的奖金项目
      initializeAddedItems(id);

      getOddResultList(newState, teamOddList);
    },
  }));

我开始以为是 React 响应式设计导致的,因为此时 setState 的是异步的,虽然 showModal 中设置了 open 为 true,后续处理不当,还是会导致 open 隐式设置为 false

仔细检查一番发现没有,并且我已经把 newSate 传递过去了,通过注释代码发现,是getOddResultList导致的,其实在之前的写法中我是加了setTimeout的,只不过时间太久了忘记了为什么加。

如今再写一来觉得setTimeout这种解决方式并不好。而且延迟 20ms 是没用的,看起来像是在等计算完成后再显示模态框。

于是我问了下 Trace,他说是因为getOddResultList有大量的同步计算,建议我用 webworker 来处理。

  • 多重嵌套循环 :对每个球队的赔率信息进行多维度组合计算(胜平负、让球、比分、进球数、半场等)
  • 指数级复杂度 :通过递归函数 getTeamCombinationList 和 againForEach 生成所有可能的投注组合
  • 大量数据处理 :每个组合都需要计算赔率乘积,并进行排序
  • JavaScript 是单线程的,同步计算会完全阻塞主线程
  • 模态框的 open 状态虽然已设置为 true ,但 React 无法进行重新渲染
  • 用户界面会出现"卡顿",模态框无法立即显示

这个函数确实会消耗大量的时间,因为有 4 只球队会产生 9 百万种结果。

使用 webworker 的话,需要将 js 文件放到 public 目录下,通过onmessage来接收消息,通过postMessage来发送消息。

self.onmessage = function (e) {
  const { teamOddList } = e.data;
  try {
    const result = calculateOddResultList(teamOddList);
    self.postMessage({ success: true, data: result });
  } catch (error) {
    self.postMessage({ success: false, error: error.message });
  }
};

在 jsx 文件中使用 webworker

useEffect(() => {
    // 创建 Web Worker

    workerRef.current = new Worker('/oddResultWorker.js');
    workerRef.current.onmessageerror = (e) => {
      console.error('Worker message error:', e);
    };

    // 监听 Worker 消息
    workerRef.current.onmessage = (e) => {
      const { success, data, error } = e.data;
      if (success) {
        allOddResultListRef.current = data;
        setState((preState) => ({
          ...preState,
          total: data.length,
          tableLoading: false,
          oddResultList: data.slice(
            (preState.currentPage - 1) * pageSize,
            preState.currentPage * pageSize
          ),
        }));
      } else {
        console.error('Worker error:', error);
      }
    };

    return () => {
      if (workerRef.current) {
        workerRef.current.terminate();
      }
    };
  }, []);

调用时

    showModal: (id: string, teamOddList: Array<NFootball.ITeamRecordOdds>) => {
      ...
      setAddedItems(new Map());
      initializeAddedItems(id);

      workerRef.current.postMessage({ teamOddList });
    },

React 牵手 Ollama:本地 AI 服务对接实战指南

在这个 AI 大模型如雨后春笋般涌现的时代,让前端应用与本地大模型来一场 “亲密接触”,就像给你的 React 应用装上一个 “本地智囊团”。今天,我们就来实现一个看似高深实则简单的需求:用 React 对接本地 Ollama 服务。这就好比教两个素未谋面的朋友打招呼,Ollama 是守在本地的 “AI 达人”,React 则是活泼的 “前端信使”,我们要做的就是搭建它们之间的沟通桥梁。

底层原理:通信的奥秘

在开始编码前,我们得先搞明白这两个 “朋友” 是如何交流的。Ollama 作为本地运行的大模型服务,会在你的电脑上开启一个 “通信窗口”—— 也就是 HTTP 服务器,默认情况下这个窗口的地址是 http://localhost:11434。而 React 应用要做的,就是通过 HTTP 协议向这个窗口发送 “消息”(请求),并等待 “回复”(响应)。

这就像你去餐厅吃饭,Ollama 是后厨的厨师,React 是前厅的服务员,http://localhost:11434 就是厨房的传菜口。服务员把顾客的订单(请求)通过传菜口递给厨师,厨师做好菜后再通过传菜口把菜(响应)送回给服务员。

准备工作:工具就位

在正式开始前,我们需要准备好 “食材” 和 “厨具”:

  1. 安装 Ollama:去 Ollama 官网下载并安装,这一步就像把厨师请到厨房里。安装完成后,打开命令行,输入 ollama run llama3 来启动一个基础模型,这里我们用 llama3 作为示例,你也可以换成其他喜欢的模型。
  1. 创建 React 应用:如果你还没有 React 项目,可以用 Create React App 快速创建一个,命令是 npx create-react-app ollama-demo,这就像搭建好前厅的场地。

代码实现:搭建沟通桥梁

一切准备就绪,现在我们来编写核心代码,实现 React 与 Ollama 的通信。

首先,我们需要一个发送请求的函数。在 React 组件中,我们可以用 fetch API 来发送 HTTP 请求到 Ollama 的 API 端点。Ollama 的聊天接口是 http://localhost:11434/api/chat,我们需要向这个接口发送包含模型名称和消息内容的 JSON 数据。

import { useState } from 'react';
function OllamaChat() {
  const [message, setMessage] = useState('');
  const [response, setResponse] = useState('');
  const sendMessage = async () => {
    try {
      // 构建请求体,指定模型和消息
      const requestBody = {
        model: 'llama3',
        messages: [{ role: 'user', content: message }],
        stream: false // 不使用流式响应,等待完整回复
      };
      // 发送 POST 请求到 Ollama 的聊天接口
      const response = await fetch('http://localhost:11434/api/chat', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(requestBody),
      });
      // 解析响应数据
      const data = await response.json();
      
      // 提取并显示 AI 的回复
      if (data.message && data.message.content) {
        setResponse(data.message.content);
      }
    } catch (error) {
      console.error('与 Ollama 通信出错:', error);
      setResponse('抱歉,无法连接到 AI 服务,请检查 Ollama 是否正在运行。');
    }
  };
  return (
    <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
      <h2>React × Ollama 聊天 Demo</h2>
      <div style={{ marginBottom: '20px' }}>
        <input
          type="text"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          placeholder="输入你的问题..."
          style={{ width: '70%', padding: '8px', marginRight: '10px' }}
        />
        <button onClick={sendMessage} style={{ padding: '8px 16px' }}>
          发送
        </button>
      </div>
      <div style={{ border: '1px solid #ccc', padding: '10px', borderRadius: '4px' }}>
        <h3>AI 回复:</h3>
        <p>{response}</p>
      </div>
    </div>
  );
}
export default OllamaChat;

代码解析:庖丁解牛

让我们来仔细看看这段代码的工作原理,就像拆解一台精密的机器。

  1. 状态管理:我们用 useState 钩子创建了两个状态变量,message 用来存储用户输入的消息,response 用来存储 AI 的回复。这就像两个储物盒,分别存放要发送的消息和收到的回复。
  1. 发送消息函数:sendMessage 是核心函数,它通过 fetch 发送请求到 Ollama。请求体中指定了要使用的模型(llama3)和用户的消息。这里的 stream: false 表示我们希望一次性收到完整的回复,而不是逐字接收。
  1. 处理响应:当 Ollama 处理完请求后,会返回一个 JSON 格式的响应。我们从中提取出 AI 的回复内容,并更新 response 状态,这样页面上就会显示出 AI 的回答了。
  1. 错误处理:如果通信过程中出现错误(比如 Ollama 没有运行),我们会捕获错误并显示友好的提示信息。

运行测试:见证奇迹的时刻

现在,让我们来测试一下这个 Demo 是否能正常工作。

  1. 确保 Ollama 正在运行:打开命令行,输入 ollama run llama3,等待模型加载完成。
  1. 启动 React 应用:在项目目录下运行 npm start,打开浏览器访问 http://localhost:3000
  1. 发送消息:在输入框中输入一个问题,比如 “你好,Ollama!”,然后点击 “发送” 按钮。稍等片刻,你应该就能看到 AI 的回复了。

如果一切顺利,你会看到 React 应用和 Ollama 成功 “牵手”,完成了一次愉快的对话。如果遇到问题,先检查 Ollama 是否正在正常运行,模型名称是否正确,网络连接是否通畅。

进阶思考:拓展可能性

这个简单的 Demo 只是一个开始,就像我们只是搭建了一座简陋的小桥。你可以基于这个基础进行很多拓展:

  1. 实现流式响应:将 stream 设置为 true,然后处理流式响应,让 AI 的回复像打字一样逐字显示,提升用户体验。
  1. 增加聊天历史:用状态管理存储聊天记录,让对话可以上下文连贯。
  1. 切换不同模型:在界面上增加模型选择功能,让用户可以根据需要切换不同的 Ollama 模型。
  1. 优化错误处理:增加更详细的错误提示,帮助用户排查问题。

总结:本地 AI 的魅力

通过这个 Demo,我们展示了 React 对接本地 Ollama 服务的全过程。相比于调用云端的 AI 服务,本地部署的 Ollama 具有隐私性好、响应速度快、无需网络连接等优点,就像把 AI 助手请到了自己家里,随时可以交流。

希望这篇文章能帮助你理解 React 与本地 AI 服务对接的原理和方法。现在,你可以基于这个基础,开发出更强大、更有趣的本地 AI 应用了。让我们一起探索前端与 AI 结合的无限可能吧!

Next.js 全栈开发基础:在 pages/api/*.ts 中创建接口的艺术

在 Web 开发的世界里,前端与后端就像一对需要默契配合的舞者。前端负责优雅地展示数据,后端则默默在幕后准备数据,而接口就是它们之间传递信号的乐谱。在 Next.js 的舞台上,pages/api/*.ts就是谱写这份乐谱的最佳创作室。今天,我们就来揭开在 Next.js 中创建接口的神秘面纱,用 TypeScript 为你的全栈应用搭建起高效的数据桥梁。

接口的本质:数据交换的高速公路

在深入技术细节之前,让我们先理解接口的本质。想象你在餐厅点餐,你(前端)告诉服务员(接口)想要什么,服务员把需求传达给厨房(数据库 / 业务逻辑),然后把做好的食物(数据)端给你。这个过程中,服务员就是接口,负责规范请求格式、处理业务逻辑并返回结果。

在计算机科学中,接口本质上是客户端与服务器之间约定的数据交换格式和规则。Next.js 的 API 路由之所以强大,是因为它允许我们在同一个项目中同时编写前端页面和后端接口,就像在同一个屋檐下同时拥有餐厅大堂和厨房,大大提高了开发效率。

初探 pages/api:Next.js 的接口魔法

Next.js 的 API 路由基于一个简单而强大的约定:在pages/api目录下创建的文件会自动成为 API 接口。这个机制背后其实是 Next.js 的文件系统路由在起作用,当服务器启动时,它会扫描pages/api目录下的所有文件,为每个文件创建对应的路由端点。

比如我们创建pages/api/hello.ts文件,访问http://localhost:3000/api/hello就能调用这个接口。这种设计就像给每个接口分配了独立的办公室,它们互不干扰又能协同工作。

第一个接口:Hello World 的进阶版

让我们从经典的 Hello World 开始,创建一个能返回个性化问候的接口。在pages/api目录下新建greet.ts文件,输入以下代码:

export default function handler(req, res) {
  // 从请求中获取查询参数name
  const { name = "World" } = req.query;
  
  // 设置响应状态码为200(成功)
  res.status(200).json({ 
    message: `Hello, ${name}!`,
    timestamp: new Date().toISOString()
  });
}

这个接口做了三件事:

  1. 从请求的查询参数中获取 name,如果没有提供则默认使用 "World"
  1. 设置 HTTP 响应状态码为 200,表示请求成功
  1. 返回一个 JSON 对象,包含问候消息和当前时间戳

运行你的 Next.js 应用,访问http://localhost:3000/api/greet?name=Next.js,你会看到类似这样的响应:

{
  "message": "Hello, Next.js!",
  "timestamp": "2025-08-17T12:34:56.789Z"
}

处理不同的 HTTP 方法:接口的多面手

一个健壮的接口应该能处理不同的 HTTP 方法,就像一个多才多艺的演员能胜任不同的角色。常见的 HTTP 方法有 GET(获取数据)、POST(创建数据)、PUT(更新数据)和 DELETE(删除数据)。

让我们创建一个简单的任务管理接口,支持 GET 和 POST 方法:

// pages/api/tasks.ts
let tasks = [
  { id: 1, title: "学习Next.js", completed: false },
  { id: 2, title: "创建API接口", completed: true }
];
export default function handler(req, res) {
  // 获取请求方法
  const { method } = req;
  switch (method) {
    case 'GET':
      // 处理GET请求:返回所有任务
      res.status(200).json(tasks);
      break;
    case 'POST':
      // 处理POST请求:创建新任务
      const { title } = req.body;
      
      // 验证请求数据
      if (!title) {
        return res.status(400).json({ error: "任务标题不能为空" });
      }
      
      // 创建新任务
      const newTask = {
        id: tasks.length + 1,
        title,
        completed: false
      };
      
      // 添加到任务列表
      tasks.push(newTask);
      
      // 返回创建的任务,状态码201表示资源创建成功
      res.status(201).json(newTask);
      break;
    default:
      // 处理不支持的方法
      res.setHeader('Allow', ['GET', 'POST']);
      res.status(405).end(`方法 ${method} 不被允许`);
  }
}

这个接口展示了如何根据不同的 HTTP 方法执行不同的操作:

  • 当使用 GET 方法访问时,它返回所有任务列表
  • 当使用 POST 方法并发送包含 title 的 JSON 数据时,它创建一个新任务
  • 当使用不支持的方法(如 PUT 或 DELETE)时,它返回 405 错误

你可以使用工具如 Postman 或 curl 来测试这个接口:

# 测试GET请求
curl http://localhost:3000/api/tasks
# 测试POST请求
curl -X POST -H "Content-Type: application/json" -d '{"title":"新任务"}' http://localhost:3000/api/tasks

接口参数处理:精准获取请求数据

在实际开发中,我们经常需要从不同位置获取请求数据。Next.js 的 API 路由提供了多种方式来获取这些数据,就像有多个入口可以进入一个建筑:

  1. 查询参数(Query Parameters) :位于 URL 中?后面的键值对,通过req.query获取
  1. 路径参数(Path Parameters) :URL 路径中的动态部分,通过文件名中的[param]定义
  1. 请求体(Request Body) :POST、PUT 等方法发送的数据,通过req.body获取

让我们创建一个支持路径参数的接口,用于获取单个任务:

// pages/api/tasks/[id].ts
// 假设tasks数组与前面的例子相同
let tasks = [
  { id: 1, title: "学习Next.js", completed: false },
  { id: 2, title: "创建API接口", completed: true }
];
export default function handler(req, res) {
  const { id } = req.query;
  // 将id转换为数字
  const taskId = parseInt(id, 10);
  
  // 验证id是否有效
  if (isNaN(taskId)) {
    return res.status(400).json({ error: "无效的任务ID" });
  }
  
  // 查找任务
  const task = tasks.find(t => t.id === taskId);
  
  if (task) {
    res.status(200).json(task);
  } else {
    res.status(404).json({ error: "任务不存在" });
  }
}

现在,访问http://localhost:3000/api/tasks/1会返回 ID 为 1 的任务,而访问http://localhost:3000/api/tasks/99会返回 404 错误。

错误处理:接口的安全网

就像现实生活中需要应急预案一样,接口也需要完善的错误处理机制。一个好的错误处理策略应该:

  • 返回适当的 HTTP 状态码
  • 提供清晰的错误信息
  • 避免暴露敏感信息

让我们改进前面的任务接口,添加更完善的错误处理:

// pages/api/tasks/[id].ts(改进版)
let tasks = [
  { id: 1, title: "学习Next.js", completed: false },
  { id: 2, title: "创建API接口", completed: true }
];
export default function handler(req, res) {
  try {
    const { id } = req.query;
    const taskId = parseInt(id, 10);
    
    if (isNaN(taskId)) {
      // 400 Bad Request:请求参数无效
      return res.status(400).json({ 
        error: "无效的任务ID",
        details: "ID必须是数字"
      });
    }
    
    const task = tasks.find(t => t.id === taskId);
    
    if (task) {
      // 200 OK:请求成功
      res.status(200).json(task);
    } else {
      // 404 Not Found:资源不存在
      res.status(404).json({ 
        error: "任务不存在",
        details: `没有ID为${taskId}的任务`
      });
    }
  } catch (error) {
    // 500 Internal Server Error:服务器内部错误
    console.error("处理请求时出错:", error);
    res.status(500).json({ 
      error: "服务器内部错误",
      details: "请稍后再试"
    });
  }
}

这个改进版接口使用 try-catch 块捕获可能的错误,并为不同类型的错误返回相应的状态码和详细信息,同时避免将内部错误直接暴露给客户端。

接口的性能考量:让数据流动更快

随着应用规模的增长,接口的性能变得越来越重要。以下是一些提高 API 路由性能的小贴士:

  1. 数据缓存:对于不经常变化的数据,可以使用缓存减少重复计算
  1. 请求验证:尽早验证请求数据,避免不必要的处理
  1. 分页处理:对于大量数据,使用分页减少数据传输量
  1. 异步处理:对于耗时操作,考虑使用异步处理避免阻塞

让我们实现一个带分页功能的任务列表接口:

// pages/api/tasks/paginated.ts
let tasks = [
  // 假设这里有很多任务...
  { id: 1, title: "任务1", completed: false },
  { id: 2, title: "任务2", completed: true },
  // ...更多任务
];
export default function handler(req, res) {
  try {
    // 获取分页参数,默认页码为1,每页10条
    const { page = 1, limit = 10 } = req.query;
    const pageNum = parseInt(page, 10);
    const limitNum = parseInt(limit, 10);
    
    // 验证分页参数
    if (isNaN(pageNum) || isNaN(limitNum) || pageNum < 1 || limitNum < 1) {
      return res.status(400).json({ 
        error: "无效的分页参数",
        details: "页码和每页数量必须是正整数"
      });
    }
    
    // 计算总页数
    const totalPages = Math.ceil(tasks.length / limitNum);
    
    // 计算起始索引
    const startIndex = (pageNum - 1) * limitNum;
    
    // 获取当前页的任务
    const paginatedTasks = tasks.slice(startIndex, startIndex + limitNum);
    
    res.status(200).json({
      data: paginatedTasks,
      pagination: {
        total: tasks.length,
        page: pageNum,
        limit: limitNum,
        totalPages
      }
    });
  } catch (error) {
    console.error("分页查询出错:", error);
    res.status(500).json({ error: "服务器内部错误" });
  }
}

这个接口支持通过page和limit参数控制返回的数据量,减轻了服务器和网络的负担。

部署与注意事项:让接口飞向生产环境

当你的接口准备好部署到生产环境时,有几个重要的注意事项:

  1. 环境变量:敏感信息如数据库连接字符串应该使用环境变量,而不是硬编码在代码中
  1. CORS 设置:如果你的前端和后端不在同一个域名下,需要配置跨域资源共享(CORS)
  1. 速率限制:为了防止滥用,考虑添加速率限制功能
  1. 日志记录:添加适当的日志记录以便调试和监控

在 Next.js 中配置 CORS 非常简单,你可以使用cors中间件:

// pages/api/with-cors.ts
import cors from 'cors';
// 初始化cors中间件
const corsMiddleware = cors({
  origin: process.env.NEXT_PUBLIC_FRONTEND_URL || '*',
  methods: ['GET', 'POST', 'PUT', 'DELETE']
});
// 辅助函数:将中间件转换为Promise
function runMiddleware(req, res, fn) {
  return new Promise((resolve, reject) => {
    fn(req, res, (result) => {
      if (result instanceof Error) {
        return reject(result);
      }
      return resolve(result);
    });
  });
}
export default async function handler(req, res) {
  // 应用CORS中间件
  await runMiddleware(req, res, corsMiddleware);
  
  // 处理请求
  res.status(200).json({ message: "这个接口支持跨域请求!" });
}

总结:接口开发的艺术与科学

在 Next.js 中创建 API 接口就像在构建一座连接前端和后端的桥梁,它需要扎实的技术基础,也需要对用户需求的深刻理解。通过pages/api/*.ts文件,我们可以快速创建功能完善的接口,处理各种 HTTP 方法,获取不同来源的请求数据,并返回结构化的响应。

记住,一个好的接口应该是清晰、健壮、高效且安全的。它不仅要能正确处理正常情况,还要能优雅地应对错误;不仅要能满足当前需求,还要为未来的扩展留有余地。

随着你对 Next.js API 路由的深入了解,你可以尝试更高级的功能,如数据库集成、身份验证、文件上传等。全栈开发的世界充满了可能性,而接口就是打开这个世界的钥匙。现在,拿起这把钥匙,开始构建你的全栈应用吧!

p5.js 3D 形状 "预制工厂"——buildGeometry ()

点赞 + 关注 + 收藏 = 学会了

如果你已经会用box()sphere()画简单 3D 形状,想组合它们做出复杂模型,又担心画面卡顿,那么buildGeometry()就是你的 "性能救星"。这个函数能把多个简单形状 "焊接" 成一个自定义 3D 模型,让绘制效率飙升。

什么是 buildGeometry ()?

buildGeometry()是 p5.js 中用于组装复杂 3D 模型的工具函数。它的核心作用就像 "预制构件厂":

  • 把多个简单 3D 形状(比如box()sphere())组合成一个完整的p5.Geometry对象(可以理解为 "自定义 3D 零件");
  • 这个 "零件" 只需要在程序启动时制作一次,之后每次绘制直接调用即可,大幅减少重复计算;
  • 必须在WebGL 模式下使用(和所有 3D 函数一样)。

buildGeometry()就是来解决这个问题的:它能把多个简单 3D 形状 “打包” 成一个p5.Geometry对象,只需创建一次,之后反复绘制都不会卡顿。就像快递打包,把多个小包裹捆成一个大包裹,搬运起来更高效~

基础用法

buildGeometry打包一个球体,然后绘制它。

01.png

let myShape; // 存储打包好的3D对象

function setup() {
  // 开启WebGL模式(3D绘图必备)
  createCanvas(400, 400, WEBGL);
  // 用buildGeometry创建3D对象,回调函数是makeShape
  myShape = buildGeometry(makeShape);
}

function draw() {
  background(200); // 灰色背景
  orbitControl(); // 允许鼠标拖动旋转视角
  lights(); // 添加光照(3D物体需要光照才看得见)
  model(myShape); // 绘制打包好的3D对象
}

// 回调函数:定义要打包的形状
function makeShape() {
  sphere(50); // 画一个半径50的球体
}

旋转的几何花朵

buildGeometry组合多个锥体,形成一朵 “花”,然后让它随时间旋转并变色,展示高性能复杂 3D 动画的实现。

02.gif

let flower;
let hueValue = 0; // 色相值(用于颜色变化)

function setup() {
  createCanvas(600, 600, WEBGL);
  // 创建几何花朵
  flower = buildGeometry(makeFlower);
}

function draw() {
  background(0); // 黑色背景
  orbitControl(); // 允许鼠标旋转视角
  lights(); // 光照
  
  // 颜色随时间变化(HSB模式:色相、饱和度、亮度)
  colorMode(HSB);
  fill(hueValue % 360, 80, 90);
  hueValue += 0.5;
  
  // 整体旋转(X和Y轴同时转,更有动感)
  // rotateX(frameCount * 0.005);
  rotateY(frameCount * 0.008);
  
  model(flower); // 绘制花朵
}

// 构建花朵形状的回调函数
function makeFlower() {
  // 中心球体
  sphere(15);
  
  // 周围的“花瓣”:12个锥体
  for (let i = 0; i < 12; i++) {
    push();
    // 绕Y轴均匀分布(360度/12=30度一个)
    rotateY(i * PI / 6);
    // 沿Z轴向外移动
    translate(0, 0, 40);
    // 锥体:底面半径10,高30,朝上
    cone(10, 30);
    pop();
  }
  
}

以上就是本文的全部内容啦,想了解更多 P5.js 用法欢迎关注 《P5.js中文教程》

也可以➕我 green bubble 吹吹水咯

qrcode.jpeg

点赞 + 关注 + 收藏 = 学会了

158.gif

❌