阅读视图

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

一文搞懂:localhost和局域网 IP 的核心区别与使用场景

前端项目运行时给出的 localhost:3000 和 192.168.1.41:3000 本质上指向同一项目服务,但适用场景和访问范围不同,具体区别及选择建议如下:

一、核心区别

维度 localhost:3000 192.168.1.41:3000
指向对象 仅指向「当前运行项目的本机」(通过本地回环地址 127.0.0.1 实现) 指向本机在局域网中的 IP 地址(192.168.1.41 是本机在路由器分配的私有 IP)
访问范围 只能在「本机」上访问(其他设备无法通过 localhost 访问) 同一局域网内的所有设备(如手机、其他电脑、平板)均可访问
依赖条件 无需网络(断网也能访问),仅依赖本机服务是否启动 需保证本机和其他设备在同一局域网(如同一 WiFi / 网线),且本机防火墙允许端口访问

二、选择建议:根据场景决定

  1. 开发调试时优先用 localhost:3000

    • 优势:访问速度更快(本地回环不经过网络路由),且不受局域网波动影响(断网也能工作),更稳定。
    • 适用场景:自己在电脑上写代码、调试功能、修改样式等。
  2. 需要跨设备测试时用 192.168.1.41:3000

    • 优势:可以在手机、平板或同事的电脑上访问你的项目,验证响应式布局、多设备兼容性等。
    • 适用场景:测试移动端显示效果、让团队成员临时查看项目进度、跨设备联调(如手机扫码测试支付流程)。

三、注意事项

  • 若用局域网地址访问失败,可能是本机防火墙阻止了 3000 端口,或项目配置限制了仅本地访问(部分框架需手动开启局域网访问权限)。
  • 局域网 IP 可能会变化(路由器重启可能重新分配),若后续访问失败,可重新运行项目获取新的局域网地址。

总之,日常开发用 localhost 更高效,跨设备测试时再用局域网 IP 即可。

你支持游戏内显示电量、信号或时间吗?

点击上方亿元程序员+关注和★星标

素材源于网络

引言

哈喽大家好,周末的时候,我看到了一个非常有趣的话题,就是我们的游戏里面,应该/不应该显示电量、信号或时间?

不知道大家在玩王者荣耀的时候有没有这样类似的经历:当你打得正起劲的时候,勇冠三军,即将超神,突然间被一个弹框秒了。

素材源于网络

没错,这个弹框就是天下苦iOS久矣的电池电量不足弹框。

还剩10%的电量

这个和显不显示电量有什么关系,难道王者荣耀没有显示电量吗?--的确没什么关系,只是吐槽一下这个电量不足的弹框。

硬要说有关系的话,那就是电量没有显示数值,没办法提前预防弹框。

言归正传,今天一起来聊一聊游戏内应该/不应该显示电量、信号或时间?

本文源工程可在文末获取,小伙伴们自行前往。

沉浸式的体验

首先,我们来看看反对方(游戏内不应该显示电量、信号或时间)的观点:

1.游戏的核心价值在于提供沉浸式的体验

经常做游戏的小伙伴都知道,我们做游戏的目的,很多时候就是为了让玩家能够“沉迷”进去游戏,产生共鸣。

可以理解,玩家们为了得到放松,会暂时逃离现实世界,进入到游戏世界中,上演一个不一样的自己。

但是,游戏内的电量、信号或时间,这些现实中的元素就会不断提醒玩家还在现实,没办法沉浸式代入游戏。

2.时间焦虑、电量焦虑

图片由AI生成

往往游戏内不显示时间、电池的目的为了避免玩家不必要心理干扰和焦虑。

时间的显示,会让人产生“我玩了多久了?”的焦虑;电量的显示则会引发“我还能玩多久?”的担忧。

这些与游戏没太大关联的焦虑和担忧,很容易让玩家分心,送人头。

游戏应该是放松和享受的,而不是另一个焦虑的来源。

3.多此一举

如果玩家实在是想看看电池电量、当前时间或者网络信号,顶部轻轻下滑就能看到,何必多此一举的优化呢?

笔者的想法

我觉得游戏内显示电量、信号或时间挺好的:

1.提醒玩家

游戏内显示时间,目的就是为了方便提醒玩家。例如活动的开始和结束时间,某个Boss刷新的时间,或者副本的剩余时间,这些都是比较有效和玩家乐意收到的提醒。

2.转移压力

网络信号信息,当玩家,网络状态见红,或者高延迟时,那么玩家能够意识到自己的网络环境可能不太好,假如没有这个显示,那么玩家可能就对游戏开喷了,这是一种常见的转移压力手段。

素材源于网络

3.心理预防

正如开篇的例子,假如游戏内有电池电量预防,那么我会在将要没电的时候去充电,或者避免一些重要的操作,因电量不足造成损失。例如“等等再开团,等我先充个电!

聊着聊着又要上例子了

既然如此,我们在Cocos游戏开发中,如何显示电量、信号和时间信息呢?下面一起来看个例子。

1.相关API

  • 获取网络类型:常用于优化弱网体验以及网络调优,通过接口我们可以判断当前是是否连接wifi,是否是弱网环境。 来源于微信官方文档

  • 获取设备电池信息:常用于获取设备电池信息,通过接口我们可以获取当前电量,是否正在充电,是否处于省电模式。 来源于微信官方文档

2.资源准备

老规矩,先找AI搭子搞几个Wifi4G信号、电池资源。

然后在场景中简单拼接一下。

3.写代码

首先创建一个Main脚本,因为本次演示环境是微信小游戏,所以我们声明一下declare const wx: any; 使用微信的API

定时获取信息。

设置时间。

设置网络状态,由于延迟数据的判断涉及服务端,小伙伴们可以通过心跳包等方式计算,笔者这里不做详细演示。

甚至可以使用Math.random,简单快捷,建议不要学

设置电池信息。

4.效果演示

Wifi:

4G:

正在充电:

不在充电:

结语

看到这里的小伙伴们,你们支持游戏内显示电量、信号或时间吗?

本文源工程可通过私信发送 TopBar 获取。

我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐专栏:

知识付费专栏

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

100个Cocos实例

8年主程手把手打造Cocos独立游戏开发框架

和8年游戏主程一起学习设计模式

从零开始开发贪吃蛇小游戏到上线系列

点击下方绿色按钮+关注。

Qt6 QML 实现DateTimePicker组件

Qt6 QML 实现DateTimePicker组件

实现代码

基于QT6.10

// DateTimePicker.qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Popup {
    id: dateTimePicker

    width: 650
    height: 480

    modal: true
    closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
    padding: 0
    focus: true

    readonly property color colorBackground: "#2b2b2b"
    readonly property color colorSurface: "#1e1e1e"
    readonly property color colorSurfaceVariant: "#222"
    readonly property color colorBorder: "#444"
    readonly property color colorBorderLight: "#555"

    readonly property color colorPrimary: "#007AFF"
    readonly property color colorPrimaryHover: "#0066CC"

    readonly property color colorTextPrimary: "white"
    readonly property color colorTextSecondary: "#aaa"
    readonly property color colorTextTertiary: "#888"
    readonly property color colorTextDisabled: "#444"

    readonly property color colorHover: "#333"
    readonly property color colorHoverLight: "#444"
    readonly property color colorHoverDark: "#555"

    readonly property color colorScrollbar: "#555"
    readonly property color colorScrollbarHover: "#666"
    readonly property color colorScrollbarPressed: "#888"

    readonly property color colorDisabled: "#555"

    property string dateTime: Qt.formatDateTime(new Date(), "yyyy-MM-dd hh:mm:ss")
    property string dateFormat: "yyyy-MM-dd"
    property string timeFormat: "hh:mm:ss"
    property string minDateTime: ""
    property string maxDateTime: ""

    property date selectedDate: new Date()
    property int selectedHour: 0
    property int selectedMinute: 0
    property int selectedSecond: 0

    property int currentYear: new Date().getFullYear()
    property int currentMonth: new Date().getMonth()

    property bool hasSelection: false  // 选择状态标记

    // 焦点区域属性
    property int focusArea: 0  // 0: 日期, 1: 时, 2: 分, 3: 秒
    property int focusedDateIndex: 0  // 日历格子索引

    signal confirmed(date datetime)

    background: Rectangle {
        color: dateTimePicker.colorBackground
        radius: 8

        FocusScope {
            id: keyboardHandler
            anchors.fill: parent
            focus: true

            Keys.onPressed: function (event) {
                if (event.key === Qt.Key_Left) {
                    if (dateTimePicker.focusArea === 0) {
                        dateTimePicker.focusedDateIndex = Math.max(0, dateTimePicker.focusedDateIndex - 1)
                        dateTimePicker.selectedDate = getDateForCell(dateTimePicker.focusedDateIndex)
                        dateTimePicker.hasSelection = true
                    } else if (dateTimePicker.focusArea > 0) {
                        dateTimePicker.focusArea = Math.max(0, dateTimePicker.focusArea - 1)
                    }
                    event.accepted = true
                } else if (event.key === Qt.Key_Right) {
                    if (dateTimePicker.focusArea === 0) {
                        dateTimePicker.focusedDateIndex = Math.min(41, dateTimePicker.focusedDateIndex + 1)
                        dateTimePicker.selectedDate = getDateForCell(dateTimePicker.focusedDateIndex)
                        dateTimePicker.hasSelection = true
                    } else if (dateTimePicker.focusArea < 3) {
                        dateTimePicker.focusArea = Math.min(3, dateTimePicker.focusArea + 1)
                    }
                    event.accepted = true
                } else if (event.key === Qt.Key_Up) {
                    if (dateTimePicker.focusArea === 0) {
                        dateTimePicker.focusedDateIndex = Math.max(0, dateTimePicker.focusedDateIndex - 7)
                        dateTimePicker.selectedDate = getDateForCell(dateTimePicker.focusedDateIndex)
                        dateTimePicker.hasSelection = true
                    } else {
                        if (dateTimePicker.focusArea === 1) {
                            dateTimePicker.selectedHour = (dateTimePicker.selectedHour - 1 + 24) % 24
                        } else if (dateTimePicker.focusArea === 2) {
                            dateTimePicker.selectedMinute = (dateTimePicker.selectedMinute - 1 + 60) % 60
                        } else {
                            dateTimePicker.selectedSecond = (dateTimePicker.selectedSecond - 1 + 60) % 60
                        }
                        dateTimePicker.hasSelection = true
                    }
                    event.accepted = true
                } else if (event.key === Qt.Key_Down) {
                    if (dateTimePicker.focusArea === 0) {
                        dateTimePicker.focusedDateIndex = Math.min(41, dateTimePicker.focusedDateIndex + 7)
                        dateTimePicker.selectedDate = getDateForCell(dateTimePicker.focusedDateIndex)
                        dateTimePicker.hasSelection = true
                    } else {
                        if (dateTimePicker.focusArea === 1) {
                            dateTimePicker.selectedHour = (dateTimePicker.selectedHour + 1) % 24
                        } else if (dateTimePicker.focusArea === 2) {
                            dateTimePicker.selectedMinute = (dateTimePicker.selectedMinute + 1) % 60
                        } else {
                            dateTimePicker.selectedSecond = (dateTimePicker.selectedSecond + 1) % 60
                        }
                        dateTimePicker.hasSelection = true
                    }
                    event.accepted = true
                } else if (event.key === Qt.Key_Tab) {
                    dateTimePicker.focusArea = (dateTimePicker.focusArea + 1) % 4
                    event.accepted = true
                } else if (event.key === Qt.Key_Backtab) {
                    dateTimePicker.focusArea = (dateTimePicker.focusArea - 1 + 4) % 4
                    event.accepted = true
                } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
                    if (dateTimePicker.hasSelection) {
                        let dt = new Date(dateTimePicker.selectedDate)
                        dt.setHours(dateTimePicker.selectedHour)
                        dt.setMinutes(dateTimePicker.selectedMinute)
                        dt.setSeconds(dateTimePicker.selectedSecond)
                        dateTimePicker.confirmed(dt)
                        dateTimePicker.close()
                    }
                    event.accepted = true
                } else if (event.key === Qt.Key_Escape) {
                    dateTimePicker.close()
                    event.accepted = true
                }
            }
        }
    }

    ColumnLayout {
        anchors.fill: parent
        spacing: 0

        // 顶部日期时间显示
        Rectangle {
            Layout.fillWidth: true
            Layout.preferredHeight: 50
            color: dateTimePicker.colorSurface
            radius: 8

            Rectangle {
                anchors.left: parent.left
                anchors.right: parent.right
                anchors.bottom: parent.bottom
                height: 8
                color: parent.color
            }

            Label {
                id: dateTimeLabel

                anchors.centerIn: parent
                color: dateTimePicker.colorPrimary
                font.pixelSize: 16
                font.bold: true
                text: Qt.formatDateTime(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate(), selectedHour, selectedMinute, selectedSecond), dateFormat + " " + timeFormat)
            }
        }

        // 主内容区域
        RowLayout {
            Layout.fillWidth: true
            Layout.fillHeight: true
            Layout.margins: 20
            spacing: 20

            // 左侧日历区域
            ColumnLayout {
                Layout.fillWidth: true
                Layout.fillHeight: true
                spacing: 12

                // 月份导航栏
                RowLayout {
                    Layout.fillWidth: true
                    spacing: 8

                    Button {
                        text: "<<"
                        Layout.preferredWidth: 40
                        Layout.preferredHeight: 32
                        onClicked: {
                            currentYear -= 1
                        }
                        background: Rectangle {
                            color: parent.hovered ? dateTimePicker.colorHoverDark : dateTimePicker.colorBorder
                            radius: 4
                        }
                        contentItem: Text {
                            text: parent.text
                            color: dateTimePicker.colorTextPrimary
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }
                    Button {
                        text: "<"
                        Layout.preferredWidth: 40
                        Layout.preferredHeight: 32
                        onClicked: {
                            currentMonth -= 1
                            if (currentMonth < 0) {
                                currentMonth = 11
                                currentYear -= 1
                            }
                        }
                        background: Rectangle {
                            color: parent.hovered ? dateTimePicker.colorHoverDark : dateTimePicker.colorBorder
                            radius: 4
                        }
                        contentItem: Text {
                            text: parent.text
                            color: dateTimePicker.colorTextPrimary
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }
                    Label {
                        text: Qt.formatDate(new Date(currentYear, currentMonth), "yyyy年 M月")
                        color: dateTimePicker.colorTextPrimary
                        font.pixelSize: 16
                        font.bold: true
                        Layout.fillWidth: true
                        horizontalAlignment: Text.AlignHCenter
                    }
                    Button {
                        text: ">"
                        Layout.preferredWidth: 40
                        Layout.preferredHeight: 32
                        onClicked: {
                            currentMonth += 1
                            if (currentMonth > 11) {
                                currentMonth = 0
                                currentYear += 1
                            }
                        }
                        background: Rectangle {
                            color: parent.hovered ? dateTimePicker.colorHoverDark : dateTimePicker.colorBorder
                            radius: 4
                        }
                        contentItem: Text {
                            text: parent.text
                            color: dateTimePicker.colorTextPrimary
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }
                    Button {
                        text: ">>"
                        Layout.preferredWidth: 40
                        Layout.preferredHeight: 32
                        onClicked: {
                            currentYear += 1
                        }
                        background: Rectangle {
                            color: parent.hovered ? dateTimePicker.colorHoverDark : dateTimePicker.colorBorder
                            radius: 4
                        }
                        contentItem: Text {
                            text: parent.text
                            color: dateTimePicker.colorTextPrimary
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }
                }

                // 星期标题行
                GridLayout {
                    Layout.fillWidth: true
                    columns: 7
                    rowSpacing: 0
                    columnSpacing: 0

                    Repeater {
                        model: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
                        Label {
                            text: modelData
                            color: dateTimePicker.colorTextTertiary
                            font.pixelSize: 12
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                            Layout.fillWidth: true
                            Layout.preferredHeight: 30
                        }
                    }
                }

                // 日期网格
                GridLayout {
                    Layout.fillWidth: true
                    Layout.fillHeight: true
                    columns: 7
                    rowSpacing: 8
                    columnSpacing: 8

                    Repeater {
                        model: 42
                        Rectangle {
                            Layout.fillWidth: true
                            Layout.fillHeight: true
                            Layout.minimumHeight: 40
                            radius: 4

                            color: {
                                if (!isInRange) return dateTimePicker.colorSurfaceVariant

                                let date = getDateForCell(index)
                                if (date.getDate() === selectedDate.getDate() && date.getMonth() === selectedDate.getMonth() && date.getFullYear() === selectedDate.getFullYear()) {
                                    return dateTimePicker.colorPrimary
                                }
                                return dateMouseArea.containsMouse ? dateTimePicker.colorBorder : "transparent"
                            }

                            border.color: {
                                let date = cellDate
                                if (date.getDate() === selectedDate.getDate() && date.getMonth() === selectedDate.getMonth() && date.getFullYear() === selectedDate.getFullYear()) {
                                    return "white"
                                }
                                return "transparent"
                            }
                            border.width: 2

                            property var cellDate: getDateForCell(index)

                            property bool isInRange: {
                                let testDate = new Date(cellDate)
                                testDate.setHours(selectedHour, selectedMinute, selectedSecond)
                                return isDateTimeInRange(testDate)
                            }

                            Label {
                                anchors.centerIn: parent
                                text: parent.cellDate.getDate()
                                color: {
                                    if (!parent.isInRange) return dateTimePicker.colorTextDisabled
                                    return parent.cellDate.getMonth() === currentMonth ? dateTimePicker.colorTextPrimary : dateTimePicker.colorBorderLight
                                }
                                font.pixelSize: 14
                            }

                            MouseArea {
                                id: dateMouseArea
                                anchors.fill: parent
                                hoverEnabled: true
                                enabled: parent.isInRange
                                cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                onClicked: {
                                    if (parent.isInRange) {
                                        let clickedDate = parent.cellDate
                                        selectedDate = clickedDate

                                        // 如果点击的日期不在当前月,自动切换到该月
                                        if (clickedDate.getMonth() !== currentMonth || clickedDate.getFullYear() !== currentYear) {
                                            currentMonth = clickedDate.getMonth()
                                            currentYear = clickedDate.getFullYear()
                                        }

                                        dateTimePicker.hasSelection = true
                                    }
                                }
                            }
                        }
                    }
                }
            }

            // 分隔线
            Rectangle {
                Layout.preferredWidth: 1
                Layout.fillHeight: true
                color: dateTimePicker.colorBorder
            }

            // 右侧时间选择区域
            RowLayout {
                Layout.preferredWidth: 210
                Layout.fillHeight: true
                spacing: 8

                // 时间列组件
                Repeater {
                    model: [{label: "时", value: selectedHour, max: 24}, {
                        label: "分", value: selectedMinute, max: 60
                    }, {label: "秒", value: selectedSecond, max: 60}]

                    ColumnLayout {
                        Layout.fillWidth: true
                        Layout.fillHeight: true
                        spacing: 8

                        property string timeLabel: modelData.label

                        Label {
                            text: timeLabel
                            color: dateTimePicker.colorTextTertiary
                            font.pixelSize: 12
                            Layout.alignment: Qt.AlignHCenter
                        }

                        Rectangle {
                            Layout.fillWidth: true
                            Layout.fillHeight: true
                            color: dateTimePicker.colorSurface
                            radius: 4
                            border.color: dateTimePicker.colorBorder
                            border.width: 1

                            ScrollView {
                                anchors.fill: parent
                                anchors.margins: 2
                                clip: true
                                ScrollBar.horizontal.policy: ScrollBar.AlwaysOff

                                ScrollBar.vertical: ScrollBar {
                                    policy: ScrollBar.AsNeeded
                                    width: 8

                                    contentItem: Rectangle {
                                        implicitWidth: 8
                                        radius: 4
                                        color: parent.pressed ? dateTimePicker.colorScrollbarPressed : (parent.hovered ? dateTimePicker.colorScrollbarHover : dateTimePicker.colorScrollbar)
                                    }

                                    background: Rectangle {
                                        implicitWidth: 6
                                        color: "transparent"
                                    }
                                }

                                ListView {
                                    id: timeListView

                                    model: modelData.max
                                    highlightFollowsCurrentItem: false
                                    highlightMoveDuration: 0
                                    snapMode: ListView.SnapToItem

                                    readonly property string timeType: timeLabel

                                    readonly property int currentValue: {
                                        if (timeType === "时") return dateTimePicker.selectedHour
                                        if (timeType === "分") return dateTimePicker.selectedMinute
                                        return dateTimePicker.selectedSecond
                                    }

                                    function selectTime(value) {
                                        console.log("Selected " + timeListView.timeType + ": " + value + ", hasSelection: " + dateTimePicker.hasSelection)

                                        dateTimePicker.hasSelection = true

                                        if (timeType === "时") {
                                            dateTimePicker.selectedHour = value
                                        } else if (timeType === "分") {
                                            dateTimePicker.selectedMinute = value
                                        } else {
                                            dateTimePicker.selectedSecond = value
                                        }

                                        // 滚动到列表顶部
                                        positionViewAtIndex(value, ListView.Beginning)
                                    }

                                    delegate: Rectangle {
                                        width: timeListView.width
                                        height: 44
                                        radius: 4

                                        color: {
                                            if (index === timeListView.currentValue) return dateTimePicker.colorPrimary
                                            return timeItemMouseArea.containsMouse ? dateTimePicker.colorHover : "transparent"
                                        }

                                        Label {
                                            anchors.centerIn: parent
                                            text: index.toString().padStart(2, '0')
                                            color: index === timeListView.currentValue ? dateTimePicker.colorTextPrimary : dateTimePicker.colorTextSecondary
                                            font.pixelSize: 16
                                            font.bold: index === timeListView.currentValue
                                        }

                                        MouseArea {
                                            id: timeItemMouseArea
                                            anchors.fill: parent
                                            hoverEnabled: true
                                            cursorShape: Qt.PointingHandCursor
                                            onClicked: {
                                                timeListView.selectTime(index)
                                            }
                                        }
                                    }

                                    Component.onCompleted: {
                                        positionViewAtIndex(modelData.value, ListView.Beginning)
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }

        // 底部按钮区域
        Rectangle {
            Layout.fillWidth: true
            Layout.preferredHeight: 60
            color: dateTimePicker.colorSurface

            Rectangle {
                anchors.left: parent.left
                anchors.right: parent.right
                anchors.top: parent.top
                height: 8
                color: parent.color
            }

            RowLayout {
                anchors.fill: parent
                anchors.margins: 15
                spacing: 10

                Button {
                    text: qsTr("今天")
                    Layout.preferredWidth: 80
                    Layout.preferredHeight: 36
                    onClicked: {
                        var now = new Date()
                        selectedDate = now
                        selectedHour = now.getHours()
                        selectedMinute = now.getMinutes()
                        selectedSecond = now.getSeconds()
                        currentYear = now.getFullYear()
                        currentMonth = now.getMonth()
                        hasSelection = true
                    }
                    background: Rectangle {
                        color: parent.hovered ? dateTimePicker.colorHoverDark : dateTimePicker.colorBorder
                        radius: 4
                        border.color: dateTimePicker.colorBorderLight
                        border.width: 1
                    }
                    contentItem: Text {
                        text: parent.text
                        color: dateTimePicker.colorTextSecondary
                        font.pixelSize: 14
                        horizontalAlignment: Text.AlignHCenter
                        verticalAlignment: Text.AlignVCenter
                    }
                }

                Item {
                    Layout.fillWidth: true
                }

                Button {
                    text: qsTr("取消")
                    Layout.preferredWidth: 80
                    Layout.preferredHeight: 36
                    onClicked: dateTimePicker.close()
                    background: Rectangle {
                        color: parent.hovered ? dateTimePicker.colorHoverDark : dateTimePicker.colorBorder
                        radius: 4
                        border.color: dateTimePicker.colorBorderLight
                        border.width: 1
                    }
                    contentItem: Text {
                        text: parent.text
                        color: dateTimePicker.colorTextSecondary
                        font.pixelSize: 14
                        horizontalAlignment: Text.AlignHCenter
                        verticalAlignment: Text.AlignVCenter
                    }
                }

                Button {
                    text: qsTr("确定")
                    Layout.preferredWidth: 80
                    Layout.preferredHeight: 36
                    enabled: dateTimePicker.hasSelection
                    onClicked: {
                        let dt = new Date(selectedDate)
                        dt.setHours(selectedHour)
                        dt.setMinutes(selectedMinute)
                        dt.setSeconds(selectedSecond)

                        if (isDateTimeInRange(dt)) {
                            dateTimePicker.dateTime = formatDateTime(dt)
                            confirmed(dt)
                            close()
                        }
                    }
                    background: Rectangle {
                        color: {
                            if (!parent.enabled) return dateTimePicker.colorDisabled
                            return parent.hovered ? dateTimePicker.colorPrimaryHover : dateTimePicker.colorPrimary
                        }
                        radius: 4
                    }
                    contentItem: Text {
                        text: parent.text
                        color: parent.enabled ? dateTimePicker.colorTextPrimary : dateTimePicker.colorTextTertiary
                        font.pixelSize: 14
                        font.bold: true
                        horizontalAlignment: Text.AlignHCenter
                        verticalAlignment: Text.AlignVCenter
                    }
                }
            }
        }
    }

    function getDateForCell(index) {
        let firstDay = new Date(currentYear, currentMonth, 1)
        let dayOfWeek = (firstDay.getDay() + 6) % 7
        let cellDate = new Date(currentYear, currentMonth, 1 - dayOfWeek + index)
        return cellDate
    }

    function isDateTimeInRange(date) {
        if (minDateTime) {
            let minDt = new Date(minDateTime)
            if (!isNaN(minDt.getTime()) && date < minDt) {
                return false
            }
        }
        if (maxDateTime) {
            let maxDt = new Date(maxDateTime)
            if (!isNaN(maxDt.getTime()) && date > maxDt) {
                return false
            }
        }
        return true
    }

    function formatDateTime(date) {
        return Qt.formatDateTime(date, dateFormat + " " + timeFormat)
    }

    onDateTimeChanged: {
        if (!hasSelection) {
            let dt = new Date(dateTime)
            if (!isNaN(dt.getTime())) {
                selectedDate = dt
                selectedHour = dt.getHours()
                selectedMinute = dt.getMinutes()
                selectedSecond = dt.getSeconds()
                currentYear = dt.getFullYear()
                currentMonth = dt.getMonth()
            }
        }
    }

    Component.onCompleted: {
        if (dateTime) {
            let dt = new Date(dateTime)
            if (!isNaN(dt.getTime())) {
                selectedDate = dt
                selectedHour = dt.getHours()
                selectedMinute = dt.getMinutes()
                selectedSecond = dt.getSeconds()
                currentYear = dt.getFullYear()
                currentMonth = dt.getMonth()

                hasSelection = true
            }
        }

        // 初始化焦点位置
        let firstDay = new Date(currentYear, currentMonth, 1)
        let dayOfWeek = (firstDay.getDay() + 6) % 7
        focusedDateIndex = dayOfWeek + selectedDate.getDate() - 1

        // 延迟设置焦点,确保 FocusScope 已完全初始化
        Qt.callLater(function () {
            keyboardHandler.forceActiveFocus()
        })
    }
}

使用代码

DateTimePicker {
    id: dateTimePicker

    width: 250
    height: 40

    // 初始值为当前日期时间
    dateTime: Qt.formatDateTime(new Date(), "yyyy-MM-dd hh:mm:ss")

    // 日期格式
    dateFormat: "yyyy-MM-dd"
    // 时间格式
    timeFormat: "hh:mm:ss"

    // 最小可选日期时间
    minDateTime: "2023-01-01 00:00:00"
    // 最大可选日期时间
    maxDateTime: "2024-12-31 23:59:59"

    onConfirmed: function (datetime) {
        startDateField.text = Qt.formatDateTime(datetime, "yyyy-MM-dd HH:mm")
    }
}

显示效果:

后台用户登录界面

Canvas学习笔记(一)

什么是 Canvas

Canvas 是 HTML5 中新增的一种标签,表示一个画布,只是图形容器,绘制功能必须使用js来实现。

<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>基础的HTML5页面</title> 
</head>
<body>
<canvas id="canvas">
            这里是canvas标签,当你的浏览器不支持canvas时,会显示这行文字
</canvas>
</body> 

</html>

打开后会是一个完全空白的画面,因为canvas本意就是一块画布,画布在html5中是透明的,不可见的。

image.png


绘制前的准备

获取 Canvas 对象:

const canvas = document.getElementById('canvas');

获取画笔

const context = canvas.getContext(contextType, contextAttributes?);
const context = canvas.getContext('2d');
const ctx = canvas.getContext('2d', {
  alpha: true,          // 启用透明通道(默认就是true)
});

getContext方法用于获取 Canvas 元素的绘图上下文,来对 Canvas 元素进行绘制操作,通常是 2D 类型,用于二维绘图。第一个参数是必须传的,第二个参数代表配置上下文的行为,不同的上下文的配置属性不同。

  • 2d
属性 说明 默认值
alpha 是否包含alpha通道(透明度) true
colorSpace 指定渲染上下文的色彩空间(srgbdisplay-p3 srgb
desynchronized 是否将画布绘制周期与事件循环解耦以减少延迟 false
willReadFrequently 是否频繁读取像素(用于优化getImageData()性能) false
  • webgl
属性 作用 说明
alpha 是否包含alpha通道 和2D一样
antialias 是否开启抗锯齿 让图形边缘更平滑
depth 是否包含深度缓冲区 用于3D场景的深度检测
failIfMajorPerformanceCaveat 性能低时是否创建上下文 true:性能差时失败
powerPreference GPU电源偏好 default/high-performance/low-power
premultipliedAlpha 是否预混合alpha 用于图像合成
preserveDrawingBuffer 是否保存缓冲区 true:可以多次读取
stencil 是否包含模版缓冲区 用于复杂遮罩效果

小结一下:准备工作就分为三步:

  1. 布置画布:通过添加 <canvas> 标签,添加canvas元素
  2. 获取画布:通过 <canvas> 标签的id,获得canvas对象
  3. 获得画笔:通过canvas对象的getContext("2d") 方法,获得2D环境

绘制线段:

在Canvas中,是基于状态的绘制,所以前面几步都是在确定状态,直到最后一步才会具体绘制。

绘画步骤和现实中画画差不多,可以分为四步:

1. 首先移动画笔至绘画的起始位置

context.moveTo(x, y)
// 将笔画移至 x, y 这个位置,canvas中是以画布的左上角为坐标原点,x轴的正方向向右,y轴的正方向向下

image.png

2. 确定第一笔的停止点

context.lineTo(x, y)
// 从上一个笔的停止点,移动至x,y这里

3. 规划好路线后,选择画笔(粗细,颜色,线条等)

因为 Canvas 是基于状态的,所以我们在选择画笔粗细和颜色的同时,其实也是选择了线条的粗细和颜色。

属性 说明 默认值 示例
context.lineWidth 线条粗细(宽度) ,单位是像素 1.0 ctx.lineWidth = 5;
context.strokeStyle 描边颜色/样式(可为颜色、渐变、图案) #000000(黑色) ctx.strokeStyle = "#AA394C";``ctx.strokeStyle = "red";``ctx.strokeStyle = gradient;
context.lineCap 线段末端样式 "butt" "butt"(平头) "round"(圆头) "square"(方头)
context.lineJoin 两条线相交处的连接样式 "miter" "miter"(尖角) "round"(圆角) "bevel"(斜角)
context.miterLimit 尖角(miter)的最大长度限制(防止过长尖角) 10 ctx.miterLimit = 5;

4. 进行绘制

确定绘制有两种方法:

context.fill() // 填充
context.stroke() // 描边

5. 画一个线条!

<body>
    <canvas id="myCanvas" style="border: 1px solid black;"></canvas>
    <script>
        const canvas = document.getElementById('myCanvas');
        const ctx = canvas.getContext('2d');
        
        ctx.moveTo(0, 0);
        ctx.lineTo(100, 100);

        ctx.lineWidth = 10;
        ctx.strokeStyle = 'red';

        ctx.stroke();
    </script>
</body>

image.png

一条红色的线条就画好了。但是我们现在只给 Canvas 设置了边框,那它的宽高是从哪来的呢?

这是浏览器默认给 Canvas 分配的尺寸 300x150 像素的画布。如果我们要自己设置 Canvas 宽高的话有两种方法

  1. 通过标签内部设置
 <canvas id="myCanvas" style="border: 1px solid black;" width = "500" height = "500"></canvas>
  1. 通过 JS 设置
const canvas = document.getElementById('myCanvas');

canvas.width = 500;
canvas.height = 500;

这两种方式设置的宽高是等效的

image.png这里有一个特别需要注意的点:我们设置的是画布的物理宽高,也就是 Canvas 元素的真实宽高,并不是通过 Css 设置的样式宽高,如果我们通过 Css 设置了 Canvas 的宽高,效果其实是在物理宽高的基础上进行了等比的缩小或者拉伸

<canvas id="myCanvas" 
    style="border: 1px solid black; width: 100px; height: 100px;" width="500" height="500">
</canvas>

image.png

线条组成图形

绘制折线

当我们学会绘制线条后,就可以用线条来组成图形了,方法其实很简单,就是复用上文用过的lineTo() 方法即可:

    ctx.moveTo(0, 0);
    ctx.lineTo(50, 100);
    ctx.lineTo(100, 0);

    ctx.lineWidth = 10;
    ctx.strokeStyle = 'red';

    ctx.stroke();

image.png

绘制多条折线

如果需要绘制多条不连接的线条,只需在绘制完一次后,再重新移动画笔,重新绘制一次即可

        ctx.moveTo(0, 0);
        ctx.lineTo(50, 100);
        ctx.lineTo(100, 0);
        ctx.lineWidth = 10;
        ctx.strokeStyle = 'red';
        ctx.stroke();

        ctx.moveTo(100, 0);
        ctx.lineTo(150, 100);
        ctx.lineTo(200, 0);
        ctx.lineWidth = 10;
        ctx.strokeStyle = 'blue';
        ctx.stroke(); 

image.png 诶?这是不是很奇怪,明明是先红色再蓝色,为什么就全变为蓝色了呢?是因为上文说过的 Canvas 是基于状态绘制的。我们每次使用stroke() 时,它都会把之前设置的状态再次绘制一遍,第一次stroke时会绘制一条红色折线,第二次stroke时,会再次重新绘制之前那条红色的折线,但是画笔已经变为蓝色的了,所以画出的折线全是蓝色的

为了解决这个问题,我们需要在每次重新绘制时加上beginPath(),这个 API 代表下次绘制的起始处为beginPath()之后的代码。

        ctx.moveTo(0, 0);
        ctx.lineTo(50, 100);
        ctx.lineTo(100, 0);
        ctx.lineWidth = 10;
        ctx.strokeStyle = 'red';
        ctx.stroke();
        
        ctx.beginPath(); // 重新定义起始位置
        ctx.moveTo(100, 0);
        ctx.lineTo(150, 100);
        ctx.lineTo(200, 0);
        ctx.lineWidth = 10;
        ctx.strokeStyle = 'blue';
        ctx.stroke(); 

image.png

绘制矩形

我们先使用绘制线条的方式绘制一个矩形

        // 绘制一个矩形
        ctx.beginPath();
        ctx.moveTo(50,50);
        ctx.lineTo(100,50);
        ctx.lineTo(100,100);
        ctx.lineTo(50,100);
        ctx.lineTo(50,50);

        ctx.lineWidth = 5;
        ctx.strokeStyle = "black";

        ctx.stroke();   

image.png 会发现此时最后一笔画时有一个小缺口,这种情况是因为我们设置了lineWidth导致的,如果是默认笔触为1的话是不会有问题的,但是如果笔触越大,线条越宽,这个小缺口就会越明显。此时需要使用closePath()闭合图形。

        // 绘制一个矩形
        ctx.beginPath();
        ctx.moveTo(50,50);
        ctx.lineTo(100,50);
        ctx.lineTo(100,100);
        ctx.lineTo(50,100);
        // ctx.lineTo(50,50); // 最后一笔可以不画
        ctx.closePath();

image.png

所以我们在绘制图形时需要使用beginPath()closePath()包裹起来。

给矩形上色

上文中有提过绘制的两种方法分别是stroke()fill(),我们需要使用fill()方法给矩形上色。和使用stroke前相同,我们需要先给它设置好属性。

        ctx.beginPath();
        ctx.moveTo(50,50);
        ctx.lineTo(100,50);
        ctx.lineTo(100,100);
        ctx.lineTo(50,100);
        ctx.closePath();

        ctx.lineWidth = 5;
        ctx.strokeStyle = "black";

        ctx.fillStyle = "red";

        ctx.fill();
        ctx.stroke();   

image.png

使用rect()方法绘制矩形

由于矩形是常用的图形,所以 Canvas API 封装好了一个定义矩形位置信息的方法,这个方法接受四个参数,分别表示矩形的起点坐标和宽高。

        ctx.rect(x,y,width,height);

        ctx.beginPath();
        ctx.rect(50,50,100,100);
        
        ctx.lineWidth = 5;
        ctx.strokeStyle = "black";
        ctx.fillStyle = "red";

        ctx.fill();
        ctx.stroke();

image.png

今天 Canvas 的学习就到这啦

CSS奇技淫巧:用你意想不到的4种属性实现裁剪遮罩效果

背景

在公司开发图片裁剪功能时,需要实现一个裁剪框效果:选中区域清晰显示,而周围区域用半透明遮罩覆盖。正常我实现会用4个div分别盖住上、下、左、右四个区域用于裁剪框周围的遮罩。

本文将介绍几种纯CSS实现的巧妙实现方案,主要内容有:

  1. box-shadow 阴影方法
  2. outline 轮廓法
  3. clip-path 裁剪方法
  4. background 渐变方法

展示的效果如下:

image.png

下面分别介绍下每种方式的实现。

1. box-shadow 方法

原理是利用 box-shadow 的扩散半径特性,设置一个超大的阴影覆盖整个画布。 优点是:代码量最少,只需一行CSS,实现简单。

.crop-box {
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
}

2. outline 方法

原理和box-shadow 类似,使用超大的 outline 实现遮罩。 outline(轮廓)与border类似,但不占用文档流空间,且不会影响元素尺寸。通过超大outline覆盖非裁剪区域,实现遮罩效果。

.crop-box {
  outline: 9999px solid rgba(0, 0, 0, 0.5);
}

3. 伪元素 + clip-path 方法

原理是使用 ::before 伪元素创建遮罩,通过 clip-path 的 polygon 裁剪出中间镂空区域。

  .wrapper::before {
      content: '';
      background: rgba(0, 0, 0, 0.5);
      clip-path: polygon(
          0 0, 0 100%, 50px 100%, 50px 50px,
          250px 50px, 250px 200px, 50px 200px,
          50px 100%, 100% 100%, 100% 0
      );
  }

4. background 渐变方法

原理是使用 linear-gradient 创建十字交叉的渐变遮罩。利用线性渐变(linear-gradient)的“颜色断层”特性,生成四宫格状的遮罩效果,非裁剪区域用半透明色覆盖,目标区域为透明。

.wrapper {
    position: absolute;
    inset: 0;
    background-image:
        /* 上方遮罩 */
        linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)),
        /* 下方遮罩 */
        linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)),
        /* 左侧遮罩 */
        linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)),
        /* 右侧遮罩 */
        linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5));
    background-size:
        100% 50px,      /* 上方 */
        100% calc(100% - 200px), /* 下方 */
        50px 150px,     /* 左侧 */
        calc(100% - 250px) 150px; /* 右侧 */
    background-position:
        0 0,            /* 上方 */
        0 200px,        /* 下方 */
        0 50px,         /* 左侧 */
        250px 50px;     /* 右侧 */
    background-repeat: no-repeat;
    pointer-events: none;
}

总结

最后总结一下:实现裁剪框遮罩效果有多种方案,在实际项目中,推荐使用 box-shadow 方法,简单高效。

🔥 99%由 Trae AI 开发的 React KeepAlive 组件,竟然如此优雅!✨

🔥 99%由 Trae AI 开发的 React KeepAlive 组件,竟然如此优雅!✨

温馨提示:除本文第一行提示之外,本项目的代码 99%由 Trae AI 开发,如您在使用过程中遇到任何问题,欢迎提交 Issue 或 Pull Request。

—— 项目 README

Hey 各位前端小伙伴们~ ଘ(੭ˊᵕˋ)੭ ੈ✩

今天要给大家介绍一个超级有趣的 React 组件——react-activity-keep-alive!这个项目可不简单呢,它有 99% 的代码都是由 Trae AI 生成的,是不是很厉害?~ (。♡‿♡。)

🎯 为啥要做这个组件?

用过 Vue 的小伙伴们都知道,Vue 有个超好用的 <KeepAlive> 组件,可以在路由切换时缓存页面状态。但是 React 一直缺少这样的优雅解决方案,对不对?٩(˘◡˘)۶

直到 React 19.2 引入了 Activity API,这个局面才有所改变。而我们这个项目,就是基于这个新 API 打造的 React 版 KeepAlive!

🌟 项目亮点一览

让我来给大家介绍这个项目的几大亮点吧~ (≧∇≦)/

1. 🚀 99% AI 开发,质量还这么高!

没错,你没看错!这个项目的源代码几乎全部由 Trae AI 生成。从架构设计到代码实现,从类型定义到注释文档,都是 AI 的杰作!是不是很震撼? Σ(っ °Д °;)っ

2. 📱 完整的 KeepAlive 语义支持

import { KeepAlive } from "react-activity-keep-alive";

<KeepAlive
  activeKey={pathname}
  include={[/^\/demo/]} // 只缓存 /demo 开头的路由
  exclude={[/^\/admin/]} // 不缓存 /admin 开头的路由
  max={3} // 最多缓存 3 LRU 策略
>
  <Outlet />
</KeepAlive>;

是不是很眼熟?没错,它完美复刻了 Vue KeepAlive 的 API 设计!☆*:.。. o(≧▽≦)o .。.:*☆

3. 🎪 丰富的演示场景

项目内置了一个超棒的 playground,包含各种演示:

🌐 在线演示:你可以直接访问 liujiayii.github.io/react-keep-… 体验所有功能!不需要本地搭建~ ✨

  • LRU 缓存演示:观察缓存淘汰策略
  • 生命周期演示:看看激活/失活钩子的效果
  • 嵌套缓存演示:多层 KeepAlive 的复杂场景
  • 状态保持演示:输入框数据、滚动位置都能完美保持

4. 🎛️ 强大的控制能力

import { useAliveController } from "react-activity-keep-alive";

function CacheController() {
  const { drop, dropScope, refresh, refreshScope, clear } = useAliveController();

  return (
    <div>
      <button onClick={() => drop("/demo/a")}>删除缓存</button>
      <button onClick={() => clear()}>清空所有</button>
      <button onClick={() => refresh("/demo/b")}>刷新缓存</button>
      {/* 等等... */}
    </div>
  );
}

想要主动控制缓存?没问题!随意删除、刷新、清空,统统支持~ ( ◕‿◕ )

🔧 技术实现揭秘

现在来聊聊技术实现吧~ ✧(≖ ◡ ≖)

历史背景:致敬 react-activation

在 React 19.2 之前,社区里有一个非常优秀的库叫 react-activation!它为 React 生态带来了 KeepAlive 的概念,让无数开发者受益~ (´∀`)

react-activation 的贡献

  • ✅ 开创了 React KeepAlive 的先河,满足了无数开发者的需求
  • ✅ 提供了完整的生命周期管理和状态保持功能
  • ✅ 虽然使用了快照技术,但在当时的技术条件下是个很好的解决方案
  • ✅ API 设计清晰,学习成本可控

由于它基于 React 18 的技术栈设计,自然无法适配 React 19.2 的新特性。但正是它的存在,让我们看到了 KeepAlive 的巨大价值!

当 React 19.2 带来了原生的 Activity API 时,我们有了更好的技术基础来重新实现这个功能~ ✨ 这就是站在巨人肩膀上的力量!٩(˘◡˘)۶

基于 React 19.2 Activity

这个组件的核心在于 React 19.2 的 Activity API:

<Activity active={isActive}>
  <div style={{ display: isActive ? undefined : "none" }}>
    {/* 组件内容 */}
  </div>
</Activity>;

Activity 的巧妙之处在于:失活的组件不会 unmount,只会隐藏!这就为 KeepAlive 提供了技术基础~ (。◕‿◕。)

LRU 缓存策略实现

// LRU 队列:最旧在前,最新在后
const orderRef = useRef<string[]>([]);

// 淘汰超出容量的缓存
while (cache.size > max) {
  const victim = orderRef.current[0];
  if (!victim || victim === activeKey)
    break;
  orderRef.current.shift();
  cacheRef.current.delete(victim);
}

LRU 算法确保最近最少使用的缓存会被优先淘汰,保持缓存的高效利用~ ଘ(੭ˊᵕˋ)੭* ੈ

作用域嵌套管理

作用域嵌套管理

项目内部实现了作用域管理机制来支持多层 <KeepAlive> 的嵌套:

// 内部实现(用户无需关心)
<AliveScopeProvider name={key}>
  <AliveItemProvider active={isActive}>
    {node}
  </AliveItemProvider>
</AliveScopeProvider>;

这个机制让项目能够优雅地处理复杂的嵌套缓存场景,自动管理不同层级的作用域~ ヾ(≧∇≦*)ゝ

🎮 实际使用效果

让我给大家展示一下实际的使用效果吧!

假设我们有这样一个计数器页面:

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

  return (
    <div>
      <h1>
        计数器:
        {count}
      </h1>
      <button onClick={() => setCount(count + 1)}>
        点我增加
      </button>
      <p>切换路由再回来,看看数据还在不在~ (◕‿◕)</p>
    </div>
  );
}

当你点击按钮增加到 10,然后切换到其他页面,再回来的时候:

数据完全保持!还是 10!没有重新渲染!

这就是 KeepAlive 的魅力所在~ (≧∇≦)/

🏗️ 项目结构一览

react-keep-alive/
├── src/                    # 核心库代码
│   ├── KeepAlive.tsx      # 主组件(244 行精华代码)
│   ├── hooks.ts           # 生命周期钩子
│   └── context.tsx        # 作用域管理
├── playground/            # 演示应用
│   ├── src/pages/         # 各种演示页面
│   └── src/App.tsx        # 主应用
└── README.md              # 超详细的文档

整个项目只有 4 个主要文件,却实现了完整的 KeepAlive 功能,是不是很精简? Σ(っ °Д °;)っ

🛠️ 开发体验

开发这个项目的感觉真的很棒~ (。♡‿♡。)

  • TypeScript 支持:完整的类型定义,IDE 友好
  • ESLint 配置:代码质量有保障
  • Husky + lint-staged:提交前自动检查
  • Playground 演示:边开发边验证

最关键的是,有了 Trae AI 的加持,开发效率真的太高了!几乎所有的代码逻辑、类型定义、注释文档,都是 AI 自动生成的!

🎊 总结

这个 react-activity-keep-alive 项目真的很让人兴奋呢~ ✨

  1. 技术先进:基于 React 19.2 Activity,性能优秀
  2. API 友好:完全对齐 Vue KeepAlive,学习成本低
  3. 功能完整:LRU 缓存、生命周期、控制接口一应俱全
  4. AI 加持:99% 代码由 AI 生成,开发效率惊人
  5. 演示丰富:内置 playground,所见即所得

如果你的项目正在使用 React 19.2+,真的推荐试试这个组件!让你的路由切换体验更上一层楼~ ( ◕‿◕ )

🔗 相关链接


小贴士:项目要求 React 19.2+ 版本哦,如果你的项目还在用旧版本,建议升级一下~ ଘ(੭ˊᵕˋ)੭ ੈ✩

好啦,今天的介绍就到这里!希望这个由 Trae AI 开发的 KeepAlive 组件能给大家带来帮助~ 如果觉得有用的话,记得给项目点个 Star 哦!☆*:.。. o(≧▽≦)o .。.:*☆

(◕‿◕)ノbye bye~

Uniapp如何下载图片到本地相册

uniapp如何下载图片到本地相册?

在一些特定的情况下面,我们需要在uniapp开发的小程序中将图片保存到本地相册,比如壁纸小程序其他小程序,这个使用情况还是十分普遍的,现在我来分享一下我的实现方法和思路

实现方案

文档地址: saveImageToPhotosAlbum:uniapp.dcloud.net.cn/api/media/i… getImageInfo: uniapp.dcloud.net.cn/api/media/i…

我们通过查找uniapp官方文档可以发现里面有一个uni.saveImageToPhotosAlbum({})保存图片到系统相册的API接口可以让我们使用,其中最关键的就是filePath参数,这个参数不支持网络地址就是我们服务器返回的图片地址不可以使用

所以我们需要使用另外一个API接口来帮助我们生成一个临时的filePath路径这个api就是uni.getImageInfo 使用这个api就可以将我们接口返回的网络地址生成一个临时的file地址,这样我就可以配合uni.saveImageToPhotosAlbum({})来实现图片保存到本地相册的功能了

实例代码:

uni.getImageInfo({
src: currentInfo.value.picurl,
success: (res) => {
uni.saveImageToPhotosAlbum({
filePath: res.path,
success: (fileRes) => {
console.log(fileRes, "图片");
}
})
}
})

需要注意的问题

我们在小程序实现下载功能的时候我们还需要到小程序的配置当中将downloadFile合法域名行一个配置不然是无法完成下载的,同时还需要在小程序后台配置中进行一个授权不然也是无法实现保存到微信相册的。

1.downloadFile合法域名配置步骤

进入到小程序配置信息页面 点击开发管理 同时在显示中的页面里面下滑到服务器域名位置 可以看见downloadFile合法域名的位置信息 点击右上角修改 然后将我们的downloadFile域名添加就可以了

image.png

image.png

2. 进行授权配置

进入小程序后台设置 鼠标移入到头像上 根据你的小程序信息进行一个填写和备案 后面找到服务内容声明用户隐私保护指引 进入到里面根据信息提示就可以完成下载图片并且保存到手机本地相册的功能了!

学习React-DnD:实现多任务项拖动-维护多任务项数组

一、功能承接与需求概述

上一篇文档中,我们已完成单个任务项的拖动排序功能。本次迭代的核心需求是实现多个任务项的批量选中,具体需达成以下目标:

  • 扩展状态管理,支持存储选中的任务项集合
  • 新增选中、取消选中任务项的操作逻辑
  • 在任务项组件中关联单选框与选中状态的交互

二、核心状态扩展(TodoProvider)

要实现多任务选中与拖拽,首先需要在全局状态中新增“选中任务集合”字段,并同步更新上下文供组件调用。

2.1 初始化状态修改

initialState中添加selectedTodos数组,用于存储当前选中的任务项,同时确保任务项模型的完整性:

const initialState = {
  todos: [
    { id: 1, text: 'Learn React', completed: false, selected: false },
    { id: 2, text: 'Build a Todo App', completed: false, selected: false },
    { id: 3, text: 'Build a Demo', completed: true, selected: false },
    { id: 4, text: 'Fix a Bug', completed: false, selected: true },

  ],
  // 当前选中的任务数组
  selectedTodos: [{ id: 4, text: 'Fix a Bug', completed: false, selected: true }],
};

2.2 上下文同步更新

将新增的selectedTodos状态注入上下文,确保子组件能获取到选中任务集合:

// TodoProvider内部的上下文值配置
const contextValue = {
  todos: state.todos,
  selectedTodos: state.selectedTodos,
  ...actions,
};

三、核心操作逻辑实现(Reducer)

基于需求新增两个核心操作类型:ADD_SELECT_TODOS(添加选中任务)、REMOVE_SELECT_TODOS(移除选中任务),以下是完整实现逻辑。

3.1 操作类型常量定义(建议单独维护)

// ActionTypes.js
export const ActionTypes = {
  // 原有操作类型...
  ADD_SELECT_TODOS: 'ADD_SELECT_TODOS',
  REMOVE_SELECT_TODOS: 'REMOVE_SELECT_TODOS',
};

3.2 添加选中任务(ADD_SELECT_TODOS)

通过任务ID查找目标任务,确保任务存在且未被选中后,添加到selectedTodos集合中,避免重复选中:

// ADD_SELECT_TODOS
case ActionTypes.ADD_SELECT_TODOS:
  {
    // 找到要添加的todo对象
    const todoToAdd = state.todos.find(todo => todo.id === action.payload.id);
    // 确保todo存在且尚未被选中(避免重复添加)
    if (todoToAdd && !state.selectedTodos.some(todo => todo.id === todoToAdd.id)) {
      return {
        ...state,
        selectedTodos: [...state.selectedTodos, todoToAdd],
      };
    }
    return state;
  }

3.3 移除选中任务(REMOVE_SELECT_TODOS)

根据任务ID从selectedTodos集合中过滤掉目标任务:

// REMOVE_SELECT_TODOS
case ActionTypes.REMOVE_SELECT_TODOS:
  return {
    ...state,
    selectedTodos: state.selectedTodos.filter(todo => todo.id !== action.payload.id),
  };

四、任务项组件交互(TodoItem)

在TodoItem组件中,通过Ref绑定单选框,并在单选框状态变化时,触发选中/取消选中的操作,实现视图与状态的同步。

4.1 组件核心逻辑

import { useContext, useRef } from 'react';
import useTodoContext from '@/context/TodoContext/useTodoContext';

const TodoItem = ({ todo }) => {
  const { addSelectTodos, removeSelectTodos } = useTodoContext();
  // 用Ref绑定单选框元素,便于后续操作(如主动获取状态)
  const checkboxRef = useRef(null);

  // 单选框状态变化处理函数
  const handleCheckboxChange = (e) => {
    toggleSelected(todo.id);
    if (e.target.checked) {
      // 选中:调用添加选中任务的action
      addSelectTodos(todo.id);
    } else {
      // 取消选中:调用移除选中任务的action
      removeSelectTodos(todo.id);
    }
  };

  return (
    <div className={`todo-item${isDragging ? ' isDragging' : ''}`} ref={divRef}>
      <div className="todo-item-content">
        <input
          type="checkbox"
          id={`todo-${todo.id}`}
          checked={todo.selected}
          onChange={handleCheckboxChange}
          className="todo-checkbox"
          ref={checkboxRef}
        />
      </div>
      {/* 原有单个任务拖拽相关逻辑 */}
    </div>
  );
};

export default TodoItem;

注意:仅保留必需内容,不包含TodoItem组件的所有内容。

4.2 关键交互说明

  • Ref绑定:通过checkboxRef可在需要时主动控制单选框(如全选/取消全选功能),提升组件灵活性。
  • 状态双向绑定:单选框的checked属性直接绑定任务项的selected状态,确保视图与全局状态一致。
  • 操作触发:状态变化时通过上下文获取的addSelectTodosremoveSelectTodos方法更新全局状态,实现跨组件状态同步。

五、配套Action函数实现

为了让组件更便捷地调用上述操作,需在TodoProvider中定义对应的action函数,并注入上下文:

const actions = {
  // 原有action...
  
    // 添加选中任务
    addSelectTodos: (id) => {
      dispatch({
        type: ActionTypes.ADD_SELECT_TODOS,
        payload: { id },
      });
    },

    // 移除选中任务
    removeSelectTodos: (id) => {
      dispatch({
        type: ActionTypes.REMOVE_SELECT_TODOS,
        payload: { id },
      });
    },
    
};

六、测试要点

  1. 单个任务选中/取消:勾选单选框后,selectedTodos应同步增减,任务项selected状态正确。
  2. 多个任务选中:选中多个任务后,selectedTodos应包含所有选中项,无重复数据。

js深入之从原型到原型链

构造函数创建对象

function A {
}
let a=new A();
a.name="abc";
console.log(a.name);

在这个例子中A就是一个构造函数,我们使用new创建了一个实例对象a

prototype

每个函数都有一个prototype属性

function A {
}
A.prototype.name="张三"
let a1=new A();
let a2=new A();
console.log(a1.name,a2.name)

prototype指向的是调用该函数创建的实例的原型,也就是例子中a1,a2的原型。 每一个对象(null除外)创建的时候,都会关联另外一个对象,这个对象就是原型,每个对象都会从原型“继承”属性

3bdfe951-d6de-4e73-ae8b-8db05e5d54bf.jpeg

_proto_

每个对象(除了null)都有一个__proto__属性,这个属性指向该对象的原型

function A {
}
let a=new A();
console.log(a.__proto__===A.prototype)

a8d6b4f1-e328-44f6-8c7b-421bcfe8ac22.jpeg

constructor

每个原型都有一个constructor属性指向关联的构造函数

function A {
}
console.log(A.prototype.constructor===A)

4e507f00-af31-4942-bf0a-ffc0c89cd49c.jpeg

function A {
}
let a=new A();
console.log(A.prototype.constructor===A)
console.log(a.__proto__===A.prototype)
console.log(a.contructor===A.prototype.constructor)
// 顺便学习一个ES5的方法,可以获得对象的原型
console.log(Object.getPrototypeOf(a) === A.prototype)

当读取实例属性时,如果找不到就会从与对象关联的原型上查找,如果还查不到就会查找原型的原型,一直找到最顶层为止。

function A {
}
A.prototype.name="test";
let a=new A();
a.name="aaa";
console.log(a.name);//aaa
delete a.name;
console.log(a.name);//test

原型的原型,其实是有Object构造函数生成

a15399d7-b5ff-4fbc-afa3-9026ca9cddb7.jpeg

原型链

红色部分就代表了原型链的形成

dd4ff7f1-95d8-4007-9c1e-551d3ea83545.jpeg

element-ui:el-autocomplete实现滚动触底翻页

需求描述

element-ui的 el-autocomplete 组件支持远程搜索输入建议,不过不能翻页,只能搜索到首页内容

要求实现下滑到底部的时候,触发翻页请求,查看更多结果

实现思路

想办法监听到滚动事件,就能判断是否到达底部,然后进行page++ 搜索

通过自定义指令v-autocomplete-scroll 来监听滚动事件,需要注意事件的监听与移除监听

<template>
  <el-autocomplete
    popper-class="mo-autocomplete"
    v-model="keyword"
    style="width: 300px"
    :fetch-suggestions="querySearch"
    :debounce="800"
    ref="autocomplete"
    v-autocomplete-scroll="handleScroll"
    placeholder="搜索"
    @blur="handleBlur"
  >
  </el-autocomplete>
</template>

<script>
import apiData from './data.json'

export default {
  name: '',

  props: {},

  computed: {},

  data() {
    return {
      page: 1,
      size: 20,
      keyword: '',
    }
  },

  directives: {
    'autocomplete-scroll': {
      bind(el, binding, vnode) {
        console.log('bind')

        // 此处为了简单,直接判断触底了
        function handleScroll(e) {
          let isBottom =
            e.target.clientHeight + e.target.scrollTop == e.target.scrollHeight
          if (isBottom && !vnode.context.loading) {
            binding.value()
          }
        }

        // 监听滚动
        let wrapDom = el.querySelector('.el-autocomplete-suggestion__wrap')
        el.__handleScroll__ = handleScroll
        el.__wrapDom__ = wrapDom
        wrapDom.addEventListener('scroll', handleScroll, false)
      },

      unbind(el, binding, vnode) {
        console.log('unbind')
        // 解除事件监听
        el.__wrapDom__.removeEventListener('scroll', el.__handleScroll__, false)
      },
    },
  },

  methods: {
    // 分页搜索
    async search(keywords) {
      console.log('page', this.page)
      let start = (this.page - 1) * this.size
      let end = start + this.size
      return apiData.slice(start, end)
    },

    // 焦点触发搜索第一页
    async querySearch(queryString, cb) {
      this.page = 1
      let list = await this.search(queryString)

      // 调用 callback 返回建议列表的数据
      cb(list)
    },

    // 滚动触发翻页
    async handleScroll() {
      console.log('handleScroll')

      // 限制翻页限度
      if (this.page > 20) {
        return
      }

      this.page++

      let list = await this.search(this.keywords)

      this.$refs['autocomplete'].$data.suggestions.push(...list)
    },

    handleBlur() {
      console.log('handleBlur')
      // 避免上次数据影响
      this.$refs['autocomplete'].$data.suggestions.splice(
        0,
        this.$refs['autocomplete'].$data.suggestions.length
      )
    },
  },

  created() {},
}
</script>

<style lang="less"></style>



测试数据 data.json

[  { "value": "三全鲜食(北新泾店)", "address": "长宁区新渔路144号" },  {    "value": "Hot honey 首尔炸鸡(仙霞路)",    "address": "上海市长宁区淞虹路661号"  },  {    "value": "新旺角茶餐厅",    "address": "上海市普陀区真北路988号创邑金沙谷6号楼113"  },  { "value": "泷千家(天山西路店)", "address": "天山西路438号" },  {    "value": "胖仙女纸杯蛋糕(上海凌空店)",    "address": "上海市长宁区金钟路968号1幢18号楼一层商铺18-101"  },  { "value": "贡茶", "address": "上海市长宁区金钟路633号" },  {    "value": "豪大大香鸡排超级奶爸",    "address": "上海市嘉定区曹安公路曹安路1685号"  },  { "value": "茶芝兰(奶茶,手抓饼)", "address": "上海市普陀区同普路1435号" },  { "value": "十二泷町", "address": "上海市北翟路1444弄81号B幢-107" },  { "value": "星移浓缩咖啡", "address": "上海市嘉定区新郁路817号" },  { "value": "阿姨奶茶/豪大大", "address": "嘉定区曹安路1611号" },  { "value": "新麦甜四季甜品炸鸡", "address": "嘉定区曹安公路2383弄55号" },  {    "value": "Monica摩托主题咖啡店",    "address": "嘉定区江桥镇曹安公路2409号1F,2383弄62号1F"  },  {    "value": "浮生若茶(凌空soho店)",    "address": "上海长宁区金钟路968号9号楼地下一层"  },  { "value": "NONO JUICE  鲜榨果汁", "address": "上海市长宁区天山西路119号" },  { "value": "CoCo都可(北新泾店)", "address": "上海市长宁区仙霞西路" },  {    "value": "快乐柠檬(神州智慧店)",    "address": "上海市长宁区天山西路567号1层R117号店铺"  },  {    "value": "Merci Paul cafe",    "address": "上海市普陀区光复西路丹巴路28弄6号楼819"  },  {    "value": "猫山王(西郊百联店)",    "address": "上海市长宁区仙霞西路88号第一层G05-F01-1-306"  },  { "value": "枪会山", "address": "上海市普陀区棕榈路" },  { "value": "纵食", "address": "元丰天山花园(东门) 双流路267号" },  { "value": "钱记", "address": "上海市长宁区天山西路" },  { "value": "壹杯加", "address": "上海市长宁区通协路" },  {    "value": "唦哇嘀咖",    "address": "上海市长宁区新泾镇金钟路999号2幢(B幢)第01层第1-02A单元"  },  { "value": "爱茜茜里(西郊百联)", "address": "长宁区仙霞西路88号1305室" },  {    "value": "爱茜茜里(近铁广场)",    "address": "上海市普陀区真北路818号近铁城市广场北区地下二楼N-B2-O2-C商铺"  },  {    "value": "鲜果榨汁(金沙江路和美广店)",    "address": "普陀区金沙江路2239号金沙和美广场B1-10-6"  },  { "value": "开心丽果(缤谷店)", "address": "上海市长宁区威宁路天山路341号" },  { "value": "超级鸡车(丰庄路店)", "address": "上海市嘉定区丰庄路240号" },  { "value": "妙生活果园(北新泾店)", "address": "长宁区新渔路144号" },  { "value": "香宜度麻辣香锅", "address": "长宁区淞虹路148号" },  { "value": "凡仔汉堡(老真北路店)", "address": "上海市普陀区老真北路160号" },  { "value": "港式小铺", "address": "上海市长宁区金钟路968号15楼15-105室" },  { "value": "蜀香源麻辣香锅(剑河路店)", "address": "剑河路443-1" },  { "value": "北京饺子馆", "address": "长宁区北新泾街道天山西路490-1号" },  {    "value": "饭典*新简餐(凌空SOHO店)",    "address": "上海市长宁区金钟路968号9号楼地下一层9-83室"  },  {    "value": "焦耳·川式快餐(金钟路店)",    "address": "上海市金钟路633号地下一层甲部"  },  { "value": "动力鸡车", "address": "长宁区仙霞西路299弄3号101B" },  { "value": "浏阳蒸菜", "address": "天山西路430号" },  { "value": "四海游龙(天山西路店)", "address": "上海市长宁区天山西路" },  {    "value": "樱花食堂(凌空店)",    "address": "上海市长宁区金钟路968号15楼15-105室"  },  { "value": "壹分米客家传统调制米粉(天山店)", "address": "天山西路428号" },  {    "value": "福荣祥烧腊(平溪路店)",    "address": "上海市长宁区协和路福泉路255弄57-73号"  },  {    "value": "速记黄焖鸡米饭",    "address": "上海市长宁区北新泾街道金钟路180号1层01号摊位"  },  { "value": "红辣椒麻辣烫", "address": "上海市长宁区天山西路492号" },  { "value": "(小杨生煎)西郊百联餐厅", "address": "长宁区仙霞西路88号百联2楼" },  { "value": "阳阳麻辣烫", "address": "天山西路389号" },  {    "value": "南拳妈妈龙虾盖浇饭",    "address": "普陀区金沙江路1699号鑫乐惠美食广场A13"  }]


参考
el-autocomplete 滚动到底部加载数据
www.runoob.com/jsref/met-e…
vue 解除鼠标的监听事件的方法

Vue3 响应式原理:从零实现 Reactive

前言

还记得第一次使用 Vue 时的那种惊艳吗?数据变了,视图自动更新,就像魔法一样!但作为一名有追求的前端开发者,我们不能只停留在"会用"的层面,更要深入理解背后的原理。

今天,我将带你从零实现一个 Vue3 的响应式系统,手写代码不到 200 行,却能覆盖核心原理。读完本文,你将彻底明白:

  • 🤔 为什么 Vue3 放弃 Object.defineProperty 选择 Proxy?
  • 🔥 依赖收集和触发更新的精妙设计
  • 🎯 数组方法的重写背后隐藏的智慧
  • 💡 Vue3 响应式相比 Vue2 的性能优势

什么是响应式?

简单来说,响应式是当数据变化时,自动执行依赖数据的代码

const state = reactive({ count: 0 });
effect(() => {
  console.log(`count值变化:${state.count}`);
});
state.count++; // count值变化:1
state.count++; // count值变化:2

vue2和vue3响应式区别

特性 vue2(Object.defineProperty) vue3(proxy)
对象新增属性 $set api实现响应式 直接支持
对象删除属性 $delete api 实现响应式 直接支持
数组拦截 改写数组原型方法 原生支持,重新包装
性能 递归遍历所有属性 懒代理,访问时才代理

综上所述Proxy的优势非常的明显,这就是Vue3选择重构响应式系统的根本原因。

手写实现:从零构建响应式

1. 项目结构

├── reactive.js           // reactive 核心
├── effect.js             // 副作用管理
├── baseHandler.js        // Proxy 处理器
├── arrayInstrumentations.js // 数组方法重写
├── utils.js              // 工具函数
└── index.js              // 入口文件

响应式入口

我们先从reactive函数着手,使用过vue3应该对reactive并不陌生。此函数接收一个对象,然后返回一个代理对象。

// reactive.js
export function reactive(target) {
  // 判断target是否一个对象
  if (!isObject(target)) {
    return target;
  }
  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      // 后续收集依赖
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      if (oldValue != value) {
        // 后续触发更新
      }
    }
  })
  return proxy;
}

目前已经搭建了reactive函数的框架,但是目前还有些问题:

  1. 同一个对象代理多次,会返回不同的代理对象,这样性能上带来不必要的开销。
const originalObj = { name: 'Vue', version: 3 }; // 第一次调用 reactive 
const proxy1 = reactive(originalObj); // 第二次调用 reactive(传入同一个对象)
const proxy2 = reactive(originalObj); // 验证两个代理是同一个实例
console.log(proxy1 === proxy2); // false

可以通过缓存代理对象解决此类问题,采用WeakMap来缓存代理对象,keytarget,value为代理对象。

// 缓存代理对象,避免重复代理
const reactiveMap = new WeakMap();
export function reactive(target) {
  // 判断target是否一个对象
  if (!isObject(target)) {
    return target;
  }
  // 将target是否已经代理过,如果代理则返回缓存的代理对象。
  const existsProxy = reactiveMap.get(target);
  if (existsProxy) {
    return existsProxy;
  }
  const proxy = new Proxy(target, {
    /* 此处暂时省略 */
  })
  // 缓存代理对象
  reactiveMap.set(target, proxy);
  return proxy;
}

💡 提示
在上述代码中之所以采用WeakMap主要考虑key是一个对象并且WeakMap可以当target不再引用时会自动清理。

  1. 当已经被reactive处理后,再次调用reactive时,又被代理。
const originalObj = { count: 1 }; // 第一次创建响应式对象 
const proxy1 = reactive(originalObj);
const proxy2 = reactive(proxy1); // 将代理对象再次代理

Vue3的源码中通过__v_isReactive标记来判断:

export const ReactiveFlags = {
  IS_REACTIVE: "__v_isReactive",
};
export function reactive(target) {
  // 判断target是否一个对象
  if (!isObject(target)) {
    return target;
  }
  // 避免重复代理
  if (target[ReactiveFlags.IS_REACTIVE]) {
    return target;
  }
  // 将target是否已经代理过,如果代理则返回缓存的代理对象。
  const existsProxy = reactiveMap.get(target);
  if (existsProxy) {
    return existsProxy;
  }
  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      if (key === ReactiveFlags.IS_REACTIVE) {
        return true;
      }
    },
    /* 此处暂时省略 */
  })
  // 缓存代理对象
  reactiveProxy.set(target, proxy);
  return proxy;
}
  • 当第一次调用reactive时,检查target中是否已经存在__v_isReactive标记,正常情况下是undefined,返回一个Proxy代理对象。
  • 如果将返回的Proxy代理对象,再次调用reactive函数,再次检查__v_isReactive是否存在,将会进入Proxy代理对象的get方法中,进入判断返回true。从而达到无论将相同代理对象调用多少次reactive都不会产生多层代理对象嵌套。

Vue3getset包裹的对象是抽离到一个单独的文件baseHandlers中的,我们也进行相同调整:

// baseHandlers.js
import { ReactiveFlags } from "./reactive"; 
export const mutableHandlers = {
  get(target, key, receiver) {
    // 1. 响应式标识判断(Vue3 源码标准逻辑)
    if (key === ReactiveFlags.IS_REACTIVE) {
      return true;
    }
    /* 后续实现依赖收集 */
  },

  set(target, key, value, receiver) {
    const oldValue = target[key];
    if (oldValue !== value) {
     // 后续触发更新
    }
  },
};
// reactive.js
import { mutableHandlers } from "./baseHandler.js";
export const ReactiveFlags = {
  IS_REACTIVE: "__v_isReactive",
};
export function reactive(target) {
  // 判断target是否一个对象
  if (!isObject(target)) {
    return target;
  }
  // 避免重复代理
  if (target[ReactiveFlags.IS_REACTIVE]) {
    return target;
  }
  // 将target是否已经代理过,如果代理则返回缓存的代理对象。
  const existsProxy = reactiveMap.get(target);
  if (existsProxy) {
    return existsProxy;
  }
  const proxy = new Proxy(target, mutableHandlers)
  // 缓存代理对象
  reactiveProxy.set(target, proxy);
  return proxy;
}

副作用管理

Vue3中提供了一个effect函数,接收一个函数,提供给用户获取数据渲染视图,数据变化后再次调用该函数更新视图。effect具体实现如下:

// 当前响应器
export let activeEffect;
// 清理依赖
export function cleanupEffect(effect) {
  effect.deps.forEach((dep) => {
    dep.delete(effect);
  });
  effect.deps.length = 0;
}
class ReactiveEffect {
  active = true; // 是否激活状态
  deps = []; // 依赖集合数组
  parent = undefined; // 父级effect 处理嵌套effect
  constructor(fn, scheduler) {
    this.fn = fn; // 用户提供的函数
    this.scheduler = scheduler // 调度器(用于computed、watch)
  }
  run() {
    if (!this.active) {
      return this.fn();
    }
    try {
      // 建立effect的父子关系 确保依赖收集的准确性
      this.parent = activeEffect;
      activeEffect = this;
      // 清除旧依赖 避免不必要的更新
      cleanupEffect(this);
      return this.fn();
    } finally {
      // 恢复父级effect
      activeEffect = this.parent;
      this.parent = undefined;
    }
  }
}
export function effect(fn, options = {}) {
  const e = new ReactiveEffect(fn, options.scheduler);
  e.run();
  // 给到用户自行控制响应
  const runner = e.run.bind(e); // 确保this的指向
  runner.effect = e;
  return runner;
}
// 收集依赖函数
export function track(target, key) {}
// 触发依赖
export function trigger(target, key) {}

实现收集依赖

const state = reactive({ name: 'jim '});
effect(() => {
  document.getElementById('app').innerHTML = `${state.name}`;
})

当调用effect函数时,将会执行用户提供的函数逻辑,如上述代码执行state.name时将会进入代理对象的get方法,该方法中进行依赖收集。即调用track函数。

// baseHandler.js
import { isObject } from "./utils";
import { ReactiveFlags, reactive } from "./reactive";
import { track } from "./effect";
export const mutableHandlers = {
  get(target, key, receiver) {
    // 响应式标识判断(Vue3 源码标准逻辑)
    if (key === ReactiveFlags.IS_REACTIVE) {
      return true;
    }
    // 收集依赖(所有属性访问都需要追踪)
    track(target, key);
    // 执行原生 get 操作 
    const result = Reflect.get(target, key, receiver);
    // 深层响应式:嵌套对象/数组自动转为响应式(Vue3 懒代理特性)
    if (result && isObject(result)) {
      return reactive(result);
    }

    return result;
  },
  /* set方法在此省略 */
};

effect.jstrack函数中实现依赖收集

// 当前响应器
export let activeEffect;
export const targetMap = new WeakMap(); //  收集依赖
export function track(target, key) {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set())); // Vue3内部是一个Dep类
  }
  trackEffects(dep);
}
export function trackEffects(dep) {
  let shouldTrack = !dep.has(activeEffect);
  if (shouldTrack) {
    dep.add(activeEffect);
    activeEffect.deps.push(dep); // 双向记录
  }
}

收集完毕后的依赖关系结构:

WeakMap {
  target1: Map {
    key1: Set[effect1, effect2],
    key2: Set[effect3]
  },
  target2: Map { ... }
}

实现触发依赖

当用户对数据进行了修改时,需要根据收集的依赖自动对应执行effect的用户函数。

state.name = 'tom'

baseHandle.js中调用trigger函数。该函数实现具体的触发依赖

// baseHandle.js
import { isObject } from "./utils";
import { ReactiveFlags, reactive } from "./reactive";
import { track, trigger } from "./effect";
export const mutableHandlers = {
  /* get方法实现省略 */
  set(target, key, value, receiver) {
    const oldValue = target[key];
    const success = Reflect.set(target, key, value, receiver);
    // 7. 只有值变化且是自身属性时,才触发更新(避免原型链干扰)
    if (success && oldValue !== value) {
      trigger(target, key); // 触发依赖
    }
    return success;
  },
};

effect.js中实现trigger函数的实现

// 触发依赖
export function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);
  if (dep) triggerEffects(dep);
}
export function triggerEffects(dep) {
  const effects = [...dep]; // 避免在遍历 Set 过程中修改 Set 本身导致的迭代器异常问题
  effects.forEach((effect) => {
    // 避免无限递归:当前正在执行的effect不再次触发
    if (effect != activeEffect) {
      if (!effect.scheduler) {
        effect.run();
      } else {
        effect.scheduler();
      }
    }
  });
}

对数组响应式处理

Vue3源码中单独一个文件arrayInstrumentations对数组的方法重新包装了一下。我的处理与源码有点不同毕竟是简易版本,但是原理都是一样的

// arrayInstrumentations.js
import { reactive } from "./reactive";
import { trigger } from "./effect";
import { isArray } from "./utils";

// 需要特殊处理的数组修改方法(Vue3 源码中也是用 Set 存储)
export const arrayInstrumentations = new Set([
  "push",
  "pop",
  "shift",
  "unshift",
  "splice",
  "sort",
  "reverse",
]);

/**
 * 包装数组修改方法,添加响应式能力
 * @param {string} method - 数组方法名
 * @returns 包装后的函数
 */
function createArrayMethod(method) {
  // 获取原生数组方法
  const originalMethod = Array.prototype[method];

  return function (...args) {
    // 1. 执行原生数组方法(保证原有功能不变)
    const result = originalMethod.apply(this, args);

    // 2. 处理新增元素的响应式转换(push/unshift/splice 可能添加新元素)
    let inserted;
    switch (method) {
      case "push":
      case "unshift":
        inserted = args; // 这两个方法的参数就是新增元素
        break;
      case "splice":
        inserted = args.slice(2); // splice 第三个参数及以后是新增元素
        break;
    }
    // 新增元素转为响应式(递归处理对象/数组)
    if (inserted) {
      inserted.forEach((item) => {
        if (typeof item === "object" && item !== null) {
          reactive(item);
        }
      });
    }

    // 3. 触发依赖更新(Vue3 源码中会触发 length 和对应索引的更新)
    trigger(this, "length");
    return result;
  };
}

// 生成所有包装后的数组方法(键:方法名,值:包装函数)
export const arrayMethods = Object.create(null);
arrayInstrumentations.forEach((method) => {
  arrayMethods[method] = createArrayMethod(method);
});

/**
 * 判断是否是需要拦截的数组方法
 * @param {unknown} target - 目标对象
 * @param {string} key - 属性名/方法名
 * @returns boolean
 */
export function isArrayInstrumentation(target, key) {
  return isArray(target) && arrayInstrumentations.has(key);
}

然后在baseHandler中添加数组情况下的逻辑

// baseHandler.js
import { isObject } from "./utils";
import { ReactiveFlags, reactive } from "./reactive";
import { track, trigger } from "./effect";
// 引入抽离的数组工具
import { isArrayInstrumentation, arrayMethods } from "./arrayInstrumentations";

export const mutableHandlers = {
  get(target, key, receiver) {
    // 1. 响应式标识判断(Vue3 源码标准逻辑)
    if (key === ReactiveFlags.IS_REACTIVE) {
      return true;
    }
    // 2. 收集依赖(所有属性访问都需要追踪)
    track(target, key);
    // 3. 执行原生 get 操作
    const result = Reflect.get(target, key, receiver);
    // 4. 数组方法拦截:如果是需要处理的数组方法,返回包装后的函数
    if (isArrayInstrumentation(target, key)) {
      // 绑定 this 为目标数组,确保原生方法执行时上下文正确
      return arrayMethods[key].bind(target);
    }
    // 5. 深层响应式:嵌套对象/数组自动转为响应式(Vue3 懒代理特性)
    if (result && isObject(result)) {
      return reactive(result);
    }

    return result;
  },

  set(target, key, value, receiver) {
    const oldValue = target[key];
    const isArrayTarget = Array.isArray(target);
    // 6. 执行原生 set 操作
    const success = Reflect.set(target, key, value, receiver);
    // 7. 只有值变化且是自身属性时,才触发更新(避免原型链干扰)
    if (success && oldValue !== value) {
      // 数组索引设置:触发对应索引和 length 更新(Vue3 源码逻辑)
      if (isArrayTarget && key !== "length") {
        const index = Number(key);
        if (index >= 0 && index < target.length) {
          trigger(target, key); // 触发索引更新
          trigger(target, "length"); // 触发长度更新
          return success;
        }
      }
      // 普通对象/数组 length 设置:触发对应 key 更新
      trigger(target, key);
    }
    return success;
  },
};

完整代码使用示例

import { reactive, effect } from "./packages/index";
const state = reactive({
  name: "vue",
  version: "3.4.5",
  author: "vue team",
  friends: ["jake", "james"],
});
effect(() => {
  app.innerHTML = `
    <div> Welcome ${state.name} !</div>
    <div> ${state.friends} </div>
  `;
});
setTimeout(() => {
  state.name = "vue3"; 
  state.friends.push("jimmy");
}, 1000);
// 一开始显示:
//    Welcome vue 
//    'jake,james'
// 1秒钟后:
//    Welcome vue3
//    'jake,james,jimmy'

总结

通过这 200 行代码,我们实现了一个完整的 Vue3 响应式系统核心:

  • 响应式代理: 基于 Proxy 的懒代理机制
  • 依赖收集: 精准的 effect 追踪
  • 批量更新: 避免重复执行的调度机制
  • 数组处理: 重写数组方法保持响应性
  • 嵌套支持: 自动的深层响应式转换

完整代码和资源 本文所有代码已开源,包含详细注释和测试用例:

GitHub 仓库:github.com/gardenia83/…

这为我们理解 Vue3 的响应式原理提供了坚实的基础,也为学习更高级的特性如 computed、watch 等打下了基础。

React Native 自定义字体导致 Text / TextInput 文本垂直不居中的终极解决方案


📝 React Native 自定义字体导致 Text / TextInput 文本垂直不居中的终极解决方案

适用
✔ React Native(0.71+)
✔ Expo(所有版本)
✔ iOS + Android
✔ 自定义字体(TTF / OTF)


📌 一、问题背景

当项目使用自定义字体(从设计师那拿的 TTF/OTF)时,Text 和 TextInput 常会出现这些问题:

  • 文字偏上 / 偏下
  • placeholder 和输入的文字不对齐
  • lineHeight 设置了但不生效
  • iOS 看似正常,Android 完全偏
  • 同一个字体在不同组件间高度不一致

这些问题在系统字体下不会出现,但是换成自定义字体后几乎必现。


📌 二、为什么会发生?——关键因素是 字体度量(Font Metrics)

每个字体内部都带有一套关键数据:

  • ascent:字体向上的高度
  • descent:字体向下的高度
  • lineGap:字体推荐的行间距
  • unitsPerEm:字体的“单位体系”,决定比例
  • baseline:文字真实摆放的位置

React Native 的 Text / TextInput 会根据这些 metrics 来排版文字。

而自定义字体常常存在:

  • ascent 特别高
  • descent 太大
  • lineGap > 0(设计师字体常见)
  • unitsPerEm 不是系统常用的 1000 / 2048
  • baseline 偏移

🔍 结果:文字在组件内部的位置发生偏移

为什么系统字体没问题?
因为系统字体(San Francisco / Roboto)对移动端做过特殊优化,而自定义字体没有。


📌 三、平台差异(非常关键)

iOS:比较宽容

  • 会自动平滑 baseline
  • 会人为修正部分 ascent/descent
  • 所以问题不明显

Android:非常严格

  • 绝对使用字体真实 fontMetrics
  • lineHeight 必须由 dev 明确指定
  • 不自动居中 TextInput 文字

👉 所以大部分“文字偏上”的问题都出现在 Android。


📌 四、终极解决方案(按效果排序)


方案 1:为自定义字体设置合理的 lineHeight(最推荐)

<TextInput
  style={{
    fontFamily: "YourFont",
    fontSize: 16,
    lineHeight: 20, // 常用: fontSize * 1.25
  }}
/>

经验公式:

iOS:

lineHeight = fontSize * 1.2

Android:

lineHeight = fontSize * 1.3

👉 这是能解决 90% 自定义字体问题的方案。


方案 2:给 TextInput 外面套一层 View 并控制垂直居中

因为 TextInput 自己不擅长对齐 —— 尤其是 Android。

<View style={{ height: 48, justifyContent: "center" }}>
  <TextInput
    style={{
      fontFamily: "YourFont",
      fontSize: 16,
      lineHeight: 20,
      padding: 0,
      textAlignVertical: "center", // Android 必须加
    }}
  />
</View>

⚠️ 关键点:

  • padding 必须设为 0(否则 Android 会额外加)
  • textAlignVertical=center(Android 不加就会偏)

方案 3:统一 placeholder 与文字行高

React Native 的 placeholder 不跟随文字行高(尤其 Android)。

强制同步:

placeholderTextColor="#888"

并保持同样高度的 wrapper:

<View style={{ height: 48, justifyContent: "center" }}>
  <TextInput
    // same styles as above
  />
</View>

🔧 方案 4(重量但最彻底):修复字体文件的 metrics

如果一个字体怎么调都不对齐,那它就是 本身度量有问题

可以用字体编辑器修:

  • FontForge(免费)
  • Glyphs(macOS)
  • FontTools(CLI)

需要修的字段:

  1. ascent → 调整到合理比例
  2. descent → 调整到合理比例
  3. lineGap → 设为 0(移动端最佳实践)
  4. unitsPerEm → 1000 或 2048(常用规范)

导出后 RN 的 Text/TextInput 高度立即正常。


📌 五、最佳实践组件(可直接复制到项目)

import { Platform, View, TextInput } from "react-native";

export function Input(props) {
  const lineHeight = Platform.OS === "android" ? 22 : 20;

  return (
    <View style={{ height: 48, justifyContent: "center" }}>
      <TextInput
        {...props}
        style={[
          {
            fontFamily: "YourFont",
            fontSize: 16,
            lineHeight,
            padding: 0,
            textAlignVertical: "center",
          },
          props.style,
        ]}
      />
    </View>
  );
}

📌 六、快速判断是哪种问题(排查表)

现象 原因 解决方案
文字偏上 ascent 太大 增加 lineHeight
placeholder 偏位置 TextInput baseline 变化 包 wrapper
Android 完全不对齐 不支持自动 baseline 加 textAlignVertical
iOS 正常、Android 不正常 Android 用真实 metrics 统一 lineHeight
不同设备看起来不一样 字体 degree scaling 不一致 wrapper + lineHeight
怎么调都不对 字体文件本身错误 修字体 metrics

📌 七、总结

React Native 使用自定义字体后高度/对齐异常 不是 RN 的 bug,而是:

  • 自定义字体度量不规范
  • RN 必须遵守字体真实 metrics
  • Android 更严格
  • TextInput 本身对行高处理简单

最有效的办法是:

  1. 统一 lineHeight
  2. 外包 wrapper
  3. Android 加 textAlignVertical=center
  4. 不行就 修字体度量

跨域问题深度剖析:为何CORS设置了还是报错?

问题背景

最近在项目中遇到了一个棘手的跨域问题:B域名的网页被嵌入在A域名内部,B域名的接口请求也出现了跨域错误。尽管后端同学已经配置了Access-Control-Allow-Origin等CORS相关头部,但控制台仍然持续报跨域错误。

问题排查过程

初步排查

一开始我们按照常规跨域问题的排查思路:

  • 检查后端CORS配置是否正确
  • 验证Access-Control-Allow-Origin头是否包含A域名
  • 确认Access-Control-Allow-Credentials设置为true

但所有这些配置都正确,问题依然存在。

深入分析

经过仔细排查,发现了问题的本质:B域名没有成功携带A域名的Cookie,导致请求被服务端的passport权限拦截中间件拦截,从而触发了跨域错误。

这里的关键点是:即使后端正确配置了CORS,如果请求因为权限问题被前端中间件拦截,同样会表现为跨域错误。

解决方案

方案一:种植Cookie方案

javascript

// B页面中的请求处理
function makeRequest() {
  const xhr = new XMLHttpRequest();
  
  // 关键:添加x-requested-with头,阻止浏览器自动处理302重定向
  xhr.setRequestHeader('x-requested-with', 'XMLHttpRequest');
  
  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4) {
      if (xhr.status === 302) {
        // 手动处理302重定向
        const redirectUrl = window.location.href;
        window.location.href = `/sso/logon?redirectUrl=${encodeURIComponent(redirectUrl)}`;
      } else if (xhr.status === 200) {
        // 正常处理响应
        console.log('请求成功', xhr.responseText);
      }
    }
  };
  
  xhr.open('GET', '/api/data', true);
  xhr.send();
}

后端配合调整:

java

// 当检测到x-requested-with头时,不自动重定向
if ("XMLHttpRequest".equals(request.getHeader("x-requested-with"))) {
    response.setStatus(302);
    response.setHeader("Location", ""); // 清空或不设置Location头
    // 通过其他方式传递重定向URL,如响应体
} else {
    response.sendRedirect("/sso/logon?redirectUrl=" + url);
}

方案缺陷:

  • 部分浏览器出于安全策略考虑,禁止跨域名种植Cookie
  • 实现相对复杂,需要前后端协同调整

方案二:同源部署方案(推荐)

将B页面的模板直接部署到A域名的服务器上,从根本上消除跨域问题。

实施步骤:

  1. 将B页面的前端资源打包部署到A域名服务
  2. 调整路由配置,确保B页面的访问路径在A域名下
  3. 更新所有相关的资源引用路径

nginx

# Nginx配置示例
server {
    listen 80;
    server_name a-domain.com;
    
    location /b-page/ {
        # 代理到B页面服务或直接服务静态资源
        proxy_pass http://b-service/;
        # 或者直接服务本地文件
        root /path/to/b-page-static-files;
    }
    
    location /api/ {
        proxy_pass http://b-api-service/;
    }
}

优势:

  • 彻底解决跨域问题
  • Cookie自然共享,无需特殊处理
  • 更好的性能和用户体验

技术要点总结

1. CORS与Cookie的关系

  • 跨域请求默认不携带Cookie
  • 需要设置withCredentials: true(前端)和Access-Control-Allow-Credentials: true(后端)
  • Access-Control-Allow-Origin不能为*,必须指定具体域名

2. 302重定向的Ajax处理

  • 默认情况下,浏览器会自动处理302重定向,前端无法拦截
  • 通过x-requested-with: XMLHttpRequest头告知后端需要特殊处理
  • 后端据此决定是否让前端手动处理重定向

3. 跨域问题的本质

跨域问题不仅仅是技术配置问题,更是安全策略的体现。浏览器的同源策略是为了保护用户数据安全。

经验教训

  1. 不要盲目相信错误信息:跨域错误可能是其他问题的表象
  2. 系统化排查:从前端到后端,从网络到安全策略全面排查
  3. 优先选择根本解决方案:相比各种workaround,从架构层面解决问题更可靠

结语

跨域问题是前端开发中的常见挑战,理解其背后的原理和浏览器安全策略至关重要。通过这次问题的解决,我们不仅修复了bug,更重要的是加深了对Web安全机制的理解。

别再死磕框架了!你的技术路线图该更新了

先说结论:

前端不会凉,但“只会几个框架 API”的前端,确实越来越难混
这两年“前端要凉了”“全栈替代前端”的声音此起彼伏,本质是门槛重新洗牌:

  • 简单 CRUD、纯样式开发被低代码、模板代码和 AI 模型快速蚕食;
  • 复杂业务、工程体系、跨端体验、AI 能力集成,反而需要更强的前端工程师去撑住。

如果你对“前端的尽头是跑路转管理”已经开始迷茫,那这篇就是给你看的:别再死磕框架版本号,该更新的是你的技术路线图。


一、先搞清楚:2025 的前端到底在变什么?

框架红海:从“会用”到“用得值”

React、Vue、Svelte、Solid、Qwik、Next、Nuxt……Meta Framework 一大堆,远远超过岗位需求。
现在企业选型更关注:

  • 生态成熟度(如 Next.js 的 SSR/SSG 能力)
  • 框架在应用生命周期中的角色(渲染策略、数据流转、SEO、部署)

趋势:

  • 框架 Meta 化(Next.js、Nuxt)将路由、数据获取、缓存策略整体纳入规范;
  • 约定优于配置,不再是“一个前端库”,而是“一套完整解决方案”。

以前是“你会 Vue/React 就能干活”,现在是“你要理解框架在整个应用中的角色”。


工具有 AI,开发方式也在变

AI 工具(如 Cursor、GitHub Copilot X)可以显著提速,甚至替代重复劳动。
真正拉开差距的变成了:

  • 你能给 AI 写出清晰、可实现的需求描述(Prompt);
  • 你能判断 AI 生成代码的质量、潜在风险、性能问题;
  • 你能基于生成结果做出合理抽象和重构。

AI 不是来抢饭碗,而是逼你从“码农”进化成“架构和决策的人”。


业务侧:前端不再是“画界面”,而是“做体验 + 做增长”

  • B 端产品:交互工程师 + 低代码拼装师 + 复杂表单处理专家;
  • C 端产品:与产品运营深度捆绑,懂 A/B 测试、埋点、Funnel 分析、广告投放链路;
  • 跨平台:Web + 小程序 + App(RN/Flutter/WebView)混合形态成为常态。

那些还在喊“切图仔优化 padding”的岗位确实在消失,但对“懂业务、有数据意识、能搭全链路体验”的前端需求更高。


二、别再死磕框架 API:2025 的前端核心能力长什么样?

基石能力:Web 原生三件套,得真的吃透

重点不是“会用”,而是理解底层原理:

  • JS:事件循环、原型链、Promise 执行模型、ESM 模块化;
  • 浏览器:渲染流程(DOM/CSSOM/布局/绘制/合成)、HTTP/2/3、安全防护(XSS/CSRF)。

这块扎实了,你在任何框架下都不会慌,也更能看懂“框架为什么这么设计”。


工程能力:从“会用脚手架”到“能看懂和调整工程栈”

Vite、Rspack、Turbopack 等工具让工程构建从“黑魔法”变成“可组合拼装件”。
你需要:

  • 看懂项目的构建配置(Vite/Webpack/Rspack 任意一种);
  • 理解打包拆分、动态加载、CI/CD 流程;
  • 能排查构建问题(路径解析、依赖冲突)。

如果你在团队里能主动做这些事,别人对你的“级别判断”会明显不一样。


跨端和运行时:不只会“写 Web 页”

2025 年前端视角的关键方向:

  • 小程序/多端框架(Taro、Uni-app);
  • 混合方案(RN/Flutter/WebView 通信机制);
  • 桌面端(Electron、Tauri)。

建议:

  • 至少深耕一个“跨端主战场”(如 Web + 小程序 或 Web + Flutter)。

数据和状态:从“会用 Vuex/Redux”到“能设计状态模型”

现代前端复杂度 70% 在“数据和状态管理”。
进阶点在于:

  • 设计合理的数据模型(本地 UI 状态 vs 服务端真相);
  • 学会用 Query 库、State Machine 解耦状态与视图。

当你能把“状态设计清楚”,你在复杂业务团队里会非常吃香。


性能、稳定性、可观测性:高级前端的硬指标

你需要系统性回答问题,而不是“瞎猜”:

  • 性能优化:首屏加载(资源拆分、CDN)、运行时优化(减少重排、虚拟列表);
  • 稳定性:错误采集、日志上报、灰度发布;
  • 工具:Lighthouse、Web Vitals、Session Replay。

这块做得好的人往往是技术骨干,且很难被低代码或 AI 直接替代。


AI 时代的前端:不是“写 AI”,而是“让 AI 真正跑进产品”

你需要驾驭:

  • 基础能力:调用 AI 平台 API(流式返回处理、增量渲染);
  • 产品思维:哪些场景适合 AI(智能搜索、文档问答);如何做权限控制、错误兜底。

三、路线图别再按“框架学习顺序”排了,按角色来选

初中级:从“会用”到“能独立负责一个功能”

目标:

  • 独立完成中等复杂度模块(登录、权限、表单、列表分页)。

建议路线:

  • 夯实 JS + 浏览器基础;
  • 选择 React/Vue + Next/Nuxt 做完整项目;
  • 搭建 eslint + prettier + git hooks 的开发习惯。

进阶:从“功能前端”到“工程前端 + 业务前端”

目标:

  • 优化项目、推进基础设施、给后端/产品提技术方案。

建议路线:

  • 深入构建工具(Webpack/Vite);
  • 主导一次性能优化或埋点方案;
  • 引入 AI 能力(如智能搜索、工单回复建议)。

高级/资深:从“高级前端”到“前端技术负责人”

目标:

  • 设计技术体系、推动长期价值。

建议路线:

  • 明确团队技术栈(框架、状态管理、打包策略);
  • 主导跨部门项目、建立知识分享机制;
  • 评估 AI/低代码/新框架的引入价值。

四、2025 年不要再犯的几个错误

  1. 只跟着热点学框架,不做项目和抽象

    • 选一个主战场 + 一个备胎(React+Next.js,Vue+Nuxt.js),用它们做 2~3 个完整项目。
  2. 完全忽略业务,沉迷写“优雅代码”

    • 把重构和业务迭代绑一起,而不是搞“纯技术重构”。
  3. 对 AI 持敌视和逃避态度

    • 把重复劳动交给 AI,把时间投到架构设计、业务抽象上。
  4. 把“管理”当成唯一出路

    • 做前端架构、性能优化平台、低代码平台的技术专家,薪资和自由度不输管理岗。

五、一个现实点的建议:给自己的 2025 做个“年度规划”

Q1:

  • 选定主技术栈(React+Next 或 Vue+Nuxt);
  • 做一个完整小项目(登录、权限、列表/详情、SSR、部署)。

Q2:

  • 深入工程化方向(优化打包体积、搭建监控埋点系统)。

Q3:

  • 选一个业务场景引入 AI 或配置化能力(如智能搜索、低代码表单)。

Q4:

  • 输出和沉淀(写 3~5 篇技术文章、踩坑复盘)。

最后:别问前端凉没凉,先问问自己“是不是还停在 2018 年的玩法”

  • 如果你还把“熟练掌握 Vue/React”当成简历亮点,那确实会焦虑;
  • 但如果你能说清楚:
    • 在复杂项目里主导过哪些工程优化;
    • 如何把业务抽象成可复用的组件/平台;
    • 如何在产品里融入 AI/多端/数据驱动;
      那么,在 2025 年的前端市场,你不仅不会“凉”,反而会成为别人眼中的“稀缺”。

别再死磕框架了,更新你的技术路线图,从“写页面的人”变成“打造体验和平台的人”。这才是 2025 年前端真正的进化方向。

Ract Router v7:最全基础与高级用法指南(可直接上手)

React Router v7 已经正式成为现代 React 应用的默认路由方案。相比过去的版本,v7 在数据加载、路由懒加载、错误边界、路由模块化等方面做了更统一、更现代化的设计。

本文带你快速掌握 基础用法 + 高级用法,适合从 v5/v6 升级或新项目使用

一、核心理念与变化概览

React Router v7 主要围绕三个关键词:

1. 路由是 UI 的一部分

-router 是组件

  • 不再是配置式为主,组件式更自然
  • Layout + Outlet 成为主流

2. 数据路由(Data Router)统一化

提供统一 API:

  • loader 数据加载
  • action 数据提交
  • lazy 懒加载路由资源
  • errorElement 错误边界

3. 组件即路由

v7 保留了 v6 的 createBrowserRouterRouterProvider,并继续强化嵌套路由的概念

二、基础用法

1. 安装

npm install react-router-dom

2. 最简路由结构

import { createBrowserRouter, RouterProvider } from "react-router-dom";

const router = createBrowserRouter([
  {
    path: "/",
    element: <HomePage />,
  },
  {
    path: "/about",
    element: <AboutPage />,
  },
]);

export default function App() {
  return <RouterProvider router={router} />;
}

3. Layout + Outlet(嵌套路由)

Layout 是 v7 强调的最佳实践:

import { Outlet, Link } from "react-router-dom";

function Layout() {
  return (
    <div>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/profile">个人中心</Link>
      </nav>

      <main>
        <Outlet /> {/* 子路由在这里渲染 */}
      </main>
    </div>
  );
}

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      { index: true, element: <HomePage /> },
      { path: "profile", element: <ProfilePage /> },
    ],
  },
]);

三、懒加载(async lazy)——强烈推荐写法

React Router v7 官方推荐使用 async lazy() 来进行路由级按需加载。

🔥 推荐写法:async lazy()

const router = createBrowserRouter([
  {
    path: "/",
    async lazy() {
      const Component = (await import("./pages/Layout")).default;
      return { Component };
    },
    children: [
      {
        index: true,
        async lazy() {
          const Component = (await import("./pages/Home")).default;
          return { Component };
        },
      },
      {
        path: "profile",
        async lazy() {
          const Component = (await import("./pages/Profile")).default;
          return { Component };
        },
      },
    ],
  },
]);

优势:
✔ 语法干净
✔ 和 Data Router 完整兼容
✔ 自动 Suspense
✔ 支持 SSR(未来版本)
✔ 大型项目结构清晰

四、跳转与参数读取

1. 跳转

const navigate = useNavigate();
navigate("/profile");

2. 获取路径参数

const { id } = useParams();

3. 获取查询参数

const [searchParams] = useSearchParams();
const keyword = searchParams.get("keyword");

五、跳转与参数读取(加强版)

这一部分很多人用得不全,这里讲 跳转方式、路径参数、查询参数、状态传参 4 大类。


📌 1. 跳转导航

(1)useNavigate:最常用跳转

import { useNavigate } from "react-router-dom";

const nav = useNavigate();

nav("/profile");                 // 普通跳转
nav(-1);                         // 返回上一页
nav("/login", { replace: true }); // 不留历史记录

(2)链接跳转 Link

import { Link } from "react-router-dom";

<Link to="/about">关于我们</Link>

(3)按钮跳转 Navigate 组件

适合条件跳转:

{ isLogin ? <Dashboard /> : <Navigate to="/login" replace /> }

📌 2. 路径参数(URL Params)

例如访问:
/user/123

定义路由:

{
  path: "user/:id",
  element: <UserDetail />
}

读取参数:

import { useParams } from "react-router-dom";

const { id } = useParams(); // id === "123"

注意:

  • params 一定是字符串类型
  • 可用 Zod/Number() 做转换

📌 3. 查询参数(Search Params)

例如访问:
/list?page=2&keyword=test

读取:

import { useSearchParams } from "react-router-dom";

const [search] = useSearchParams();

const page = search.get("page"); 
const keyword = search.get("keyword");

修改:

const [search, setSearch] = useSearchParams();

setSearch({ page: 3 });

支持 append:

search.append("type", "A");
setSearch(search);

📌 4. 跳转时携带 state(非 URL)

类似 history.push 的 state:

nav("/detail", {
  state: { from: "list", id: 123 }
});

读取:

import { useLocation } from "react-router-dom";

const { state } = useLocation();
// state.from === "list"

用法:

  • 搜索列表 → 详情
  • 跨页面临时数据
  • 不污染 URL
  • 仅会话内有效

六、Data Router:loader 与 action(高级)

📌 为什么需要 Data Router?

传统 SPA 数据加载流程:

渲染组件 → useEffect → fetch 数据 → setState →再渲染

但这有几个问题:

  • 多层嵌套时 useEffect 非常乱
  • SSR、预加载、切换页面时不能保证一致性
  • 异步错误很难集中处理
  • 首屏渲染过慢
  • React 渲染后才请求数据,白屏时间更长

React Router v7 引入 Data Router 后,流程变成:

进入某个路由 → 执行 loader → 数据准备好 → 再渲染组件

好处:

  • 页面渲染 之前 就拿到数据(首屏更快)
  • 数据逻辑从 UI 分离
  • 错误自动走路由 errorElement
  • 多个 loader 并行执行
  • 支持自动 revalidate(自动刷新)
  • SSR & CSR 统一开发体验

📌 1. loader:加载页面数据

loader 是路由级数据加载函数:

{
  path: "/detail/:id",
  loader: async ({ params, request }) => {
    const res = await fetch(`/api/detail/${params.id}`);
    return res.json();
  },
  element: <DetailPage />
}

组件内读取:

import { useLoaderData } from "react-router-dom";

const data = useLoaderData();

2. action:表单提交逻辑

{
  path: "/create",
  action: async ({ request }) => {
    const form = await request.formData();
    return createItem(form);
  },
  element: <CreatePage />
}

配合 <Form>

<Form method="post">
  <input name="title" />
  <button>提交</button>
</Form>

七、错误边界 errorElement

v7 支持路由级错误 UI:

{
  path: "/",
  element: <Layout />,
  errorElement: <ErrorPage />,
}

读取异常:

const err = useRouteError();

八、handle:路由元信息(meta)

可用于:

  • 页面标题
  • 面包屑
  • 权限
  • 路由描述
{
  path: "settings",
  handle: { title: "系统设置", auth: true },
  element: <SettingsPage />
}

读取所有匹配路由的 handle:

import { useMatches } from "react-router-dom";

const matches = useMatches();

九、模块化路由(大型项目最佳实践)

假设有三个模块:Dashboard、User、Project

1️⃣ Dashboard 模块

// routes/dashboard.routes.ts
export const dashboardRoutes = [
  {
    path: "dashboard",
    async lazy() {
      const Component = (await import("@/pages/Dashboard")).default;
      return { Component };
    },
    handle: { title: "Dashboard" },
  },
];

2️⃣ User 模块(带 loader/action)

// routes/user.routes.ts
export const userRoutes = [
  {
    path: "user",
    async lazy() {
      const Layout = (await import("@/pages/User/Layout")).default;
      return { Component: Layout };
    },
    children: [
      {
        index: true,
        async lazy() {
          const Component = (await import("@/pages/User/List")).default;
          return { Component };
        },
        handle: { title: "用户列表" },
      },
    ],
  },
];

3️⃣ Project 模块

// routes/project.routes.ts
export const projectRoutes = [
  {
    path: "project",
    async lazy() {
      const Layout = (await import("@/pages/Project/Layout")).default;
      return { Component: Layout };
    },
    children: [
      {
        index: true,
        async lazy() {
          const Component = (await import("@/pages/Project/List")).default;
          return { Component };
        },
        handle: { title: "项目列表" },
      },
     
    ],
  },
];

4️⃣ 主路由统一整合

// routes/index.ts
import { createBrowserRouter } from "react-router-dom";
import { dashboardRoutes } from "./dashboard.routes";
import { userRoutes } from "./user.routes";
import { projectRoutes } from "./project.routes";

const router = createBrowserRouter([
  {
    path: "/",
    async lazy() {
      const Layout = (await import("@/pages/Layout")).default;
      return { Component: Layout };
    },
    children: [
      ...dashboardRoutes,
      ...userRoutes,
      ...projectRoutes,
    ],
  },
  {
    path: "/login",
    async lazy() {
      const Component = (await import("@/pages/Login")).default;
      return { Component };
    },
  },
  {
    path: "*",
    async lazy() {
      const Component = (await import("@/pages/NotFound")).default;
      return { Component };
    },
  },
]);

export default router;

十、权限控制(结合 loader + handle)

{
  path: "admin",
  handle: { auth: true },
  loader: async () => {
    const login = await checkLogin();
    if (!login) throw redirect("/login");
  },
  element: <Admin />
}

满足所有权限场景。


结语

React Router v7 是目前 React 生态最全面的 SPA/SSR 路由方案,它的优势包括:

  • 统一的页面加载方式
  • 按需加载(lazy)
  • 模块化路由
  • 真正意义的路由数据层
  • 强大的错误边界和 handle

无论是小项目还是大型管理后台,它都足够可控且可扩展。

vue3+Cesium开发教程(14)---Cesium加载与删除geojson、kml、glb、3dtiles、czml数据

本学习系列将以Cesium + Vue3 + Vite +Typescript+elementplus作为主要技术栈展开,后续会循序渐进,持续探索Cesium的高级功能,相关源码全部免费公开。详情请查看原文Cesium+Vue3学习系列(14)---Cesium加载与删除geojson、kml、glb、3dtiles、czml数据

1、geojson数据配置与加载方式


//geojson测试数据
export const geojsonTestInfo:ILayerItem = {
    id: LayerIdFlag.GEOMSON_TEST,
    name: "geojsonTest",
    type: "geojson",
    url: "/testdata/line.geojson",
    layer: "geojsonTest"
}

case 'geojson':
                handle = await Cesium.GeoJsonDataSource.load(item.url!, {
                    clampToGround: true,
                });
                (handle as Cesium.GeoJsonDataSource).show = show;
                this.viewer.dataSources.add(handle)
                //视角
                this.viewer.zoomTo(handle)
                break

2、kml数据配置与加载方式


//kml测试数据
export const kmlTestInfo:ILayerItem = {
    id: LayerIdFlag.KML_TEST,
    name: "kmlTest",
    type: "kml",
    url: "/testdata/gdpPerCapita2008.kmz",
    layer: "kmlTest"
}

            case 'kml':
                handle = await Cesium.KmlDataSource.load(item.url!, {
                    clampToGround: true,
                });
                (handle as Cesium.KmlDataSource).show = show
                this.viewer.dataSources.add(handle)
                this.viewer.zoomTo(handle)
                break

3、glb数据配置与加载方式


//glb测试数据
export const glbTestInfo:ILayerItem = {
    id: LayerIdFlag.GLB_TEST,
    name: "glbTest",
    type: "glb",
    url: "/testdata/CesiumMilkTruck.glb",
    layer: "glbTest",
    scale: 5,
    lon: 114.314521,
    lat: 22.543062,
    height: 0,
    heading:45,
    pitch: 0,
    roll: 0   
}

            case 'glb':
                let position = Cesium.Cartesian3.fromDegrees(item.lon, item.lat, item.height || 0);
                // 设置模型方向
                let hpRoll = new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(item.heading || 45), Cesium.Math.toRadians(item.pitch || 0), Cesium.Math.toRadians(item.roll || 0));
                // 生成一个函数,该函数从以提供的原点为中心的参考帧到提供的椭圆体的固定参考帧计算4x4变换矩阵。
                let fixedFrame = Cesium.Transforms.localFrameToFixedFrameGenerator('north', 'west');
                handle = await Cesium.Model.fromGltfAsync({
                    url: item.url!,
                    modelMatrix: Cesium.Transforms.headingPitchRollToFixedFrame(position, hpRoll, Cesium.Ellipsoid.WGS84, fixedFrame),
                    scale: item.scale || 1,
                });
                handle.show = show;
                this.viewer.scene.primitives.add(handle)
                this.viewer.camera.flyTo({
                    destination: Cesium.Cartesian3.fromDegrees(item.lon, item.lat, 100),
                    duration: 2
                })
                break;

4、3dtiles配置与加载方式。由于本示例中的3dtiles 数据高度较低,这里需要将其高度提升了500米。后续会出一个动态调整3dtiles位置的章节,敬请期待。


//3dtile测试数据
export const model3dtilesTestInfo:ILayerItem = {
    id: LayerIdFlag.MODEL_3DTILES_TEST,
    name: "model3dtilesTest",
    type: "3dtiles",
    url: "/testdata/dayanta/tileset.json",
    layer: "model3dtilesTest"
}

 case '3dtiles':
                handle = await Cesium.Cesium3DTileset.fromUrl(item.url!);                
                (handle as Cesium.Cesium3DTileset).show = show;
                this.viewer.scene.primitives.add(handle)
                this.viewer.zoomTo(handle, new Cesium.HeadingPitchRange(0.0, -0.3, 0.0))
               
                let cartographic = Cesium.Cartographic.fromCartesian(
                    handle.boundingSphere.center
                );
                let surface = Cesium.Cartesian3.fromRadians(
                    cartographic.longitude,
                    cartographic.latitude,
                    0.0
                );
                let offset = Cesium.Cartesian3.fromRadians(
                    cartographic.longitude,
                    cartographic.latitude,
                    500.0
                );
                let translation = Cesium.Cartesian3.subtract(
                    offset,
                    surface,
                    new Cesium.Cartesian3()
                );
                handle.modelMatrix = Cesium.Matrix4.fromTranslation(translation);
                break

5、czml数据配置与加载方式


export const czmlTestInfo:ILayerItem = {
    id: LayerIdFlag.CZML_TEST,
    name: "czmlTest",
    type: "czml",
    url: "/testdata/simple.czml",
    layer: "czmlTest"
}

          case 'czml':
                handle = await Cesium.CzmlDataSource.load(item.url!);
                (handle as Cesium.CzmlDataSource).show = show
                this.viewer.dataSources.add(handle)
                 this.viewer.zoomTo(handle)
                break

转存失败,建议直接上传图片文件

6、数据删除。

    Remove(id: string): void {
        const item = this.layersIdMap.get(id)
        if (!item) return;
        const { type, handle } = item;
        switch (type) {
            case 'imagery_wms':
            case 'imagery_wmts':
            case 'imagery_xyz':
                this.viewer.imageryLayers.remove(handle)
                break
            case 'terrain':
                // 回到默认椭球
                this.viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider()
                break
            case 'geojson':
            case 'kml':
            case 'czml':
                this.viewer.dataSources.remove(handle)
                break;
            case '3dtiles':
            case 'glb':
                this.viewer.scene.primitives.remove(handle)
                break;
        }
        this.layersIdMap.delete(id);
    }

7、前端实现:

<template>
    <div class="custom-data-container">
        <span class="custom-data-title"> 自定义数据 </span>
        <div style="text-align: left;">
           
            <div class="data-items">
                <span>图层信息</span>
                <div>
                    <el-checkbox v-model="geojsonChecked" label="geojson" size="large" @change="changeGeojson"/>
                    <el-checkbox v-model="kmlChecked" label="KML" size="large" @change="changeKml"/>
                    <el-checkbox v-model="glbChecked" label="glb" size="large" @change="changeGlb"/>
                    <el-checkbox v-model="model3dtilesChecked" label="3dtiles" size="large" @change="changeModel3dtiles"/>
                    <el-checkbox v-model="czmlChecked" label="czml" size="large" @change="changeCzml"/>
                </div>
            </div>
        </div>
    </div>
</template>
<script lang="ts" setup>
import { czmlTestInfo, geojsonTestInfo, glbTestInfo, kmlTestInfo, LayerIdFlag, model3dtilesTestInfo } from '@/system/LayerManager/LayerConfig'
import LayerManager from '@/system/LayerManager/LayerManager'
import CesiumViewer from '@/Viewer/CesiumViewer'
const viewer = CesiumViewer.viewer
let mLayerManager: LayerManager | null = null
const geojsonChecked = ref(false)
const kmlChecked = ref(false)
const glbChecked = ref(false)
const model3dtilesChecked = ref(false)
const czmlChecked = ref(false)
onMounted(() => {
    mLayerManager = LayerManager.getInstance(viewer!)
})
const changeGeojson = (val: string | number | boolean) => {
  if (val) {
    // 加载geojson图层
    mLayerManager?.Add(geojsonTestInfo)
  } else {
    // 移除geojson图层
    mLayerManager?.Remove(LayerIdFlag.GEOMSON_TEST)
  }
}
const changeKml = (val: string | number | boolean) => {
  if (val) {
    // 加载kml图层
    mLayerManager?.Add(kmlTestInfo)
  } else {
    // 移除kml图层
    mLayerManager?.Remove(LayerIdFlag.KML_TEST)
  }
}
const changeGlb = (val: string | number | boolean) => {
  if (val) {
    // 加载glb图层
    mLayerManager?.Add(glbTestInfo)
  } else {
    // 移除glb图层
    mLayerManager?.Remove(LayerIdFlag.GLB_TEST)
  }
}
const changeModel3dtiles = (val: string | number | boolean) => {
  if (val) {
    // 加载3dtiles图层
    mLayerManager?.Add(model3dtilesTestInfo)
  } else {
    // 移除3dtiles图层
    mLayerManager?.Remove(LayerIdFlag.MODEL_3DTILES_TEST)
  }
}
const changeCzml = (val: string | number | boolean) => {
  if (val) {
    // 加载czml图层
    mLayerManager?.Add(czmlTestInfo)
  } else {
    // 移除czml图层
    mLayerManager?.Remove(LayerIdFlag.CZML_TEST)
  }
}
</script>
<style lang="scss" scoped>
.custom-data-container {
    padding: 10px;
    .custom-data-title {
        font-size: 16px;
        font-weight: bold;
        margin-bottom: 10px;
    }
    
    .data-items {
        .el-checkbox {
            margin-left: 20px;
            height: 30px;
        }
        :deep(.el-checkbox__label) {
            color: rgb(206, 194, 194);
        }
    }
}
</style>

更多代码请查看原文Cesium+Vue3学习系列(14)---Cesium加载与删除geojson、kml、glb、3dtiles、czml数据

H5 WebView 文件下载到手机中(仅安卓与 iOS)

H5 WebView 文件下载(仅安卓与 iOS)

原理

  • 使用 H5+ 原生接口 plus.downloader.createDownload 将文件下载到本地;下载完成后通过 plus.runtime.openFile 打开。
  • 不同平台保存路径不同:
    • Android:保存到系统公共 Download 目录(需要存储权限)。
    • iOS:保存到应用沙箱 _doc 持久目录(无需额外权限)。

使用方法(代码)

  • 页面按钮“原生下载PDF”触发函数:src/views/w-success.vue:323
  • 关键实现(节选):
// src/views/w-success.vue:323-388
const downloadByPlus = async () => {
  const p = (window as any).plus;
  if (!p) { alert("当前不在App环境"); return; }
  if (isDownloading.value) return;
  isDownloading.value = true;
  downloadStatus.value = "原生下载中...";

  try {
    const fileOnly = getFileName(downloadUrl);
    const isAndroid = p.os && p.os.name === "Android";
    const isiOS = p.os && p.os.name === "iOS";
    const filename = isAndroid
      ? `file:///storage/emulated/0/Download/${fileOnly}`
      : isiOS
      ? `_doc/${fileOnly}`
      : `_downloads/${fileOnly}`;

    const startDownload = () =>
      p.downloader.createDownload(
        downloadUrl,
        { filename },
        (d: any, status: number) => {
          isDownloading.value = false;
          if (status === 200) {
            let localPath = "";
            try { localPath = p.io.convertLocalFileSystemURL(d.filename); } catch (err) { localPath = ""; }
            downloadStatus.value = localPath ? `下载完成,路径:${localPath}` : "下载完成";
            try { p.runtime.openFile(d.filename); } catch (e: any) { downloadStatus.value = `${downloadStatus.value},打开失败: ${e?.message || e}`; }
          } else {
            downloadStatus.value = `下载失败,状态码:${status}`;
          }
        }
      ).start();

    if (isAndroid) {
      try {
        await new Promise((resolve, reject) => {
          p.android.requestPermissions(
            ["android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.READ_EXTERNAL_STORAGE"],
            () => resolve(null),
            (err: any) => reject(err)
          );
        });
      } catch (err: any) {
        isDownloading.value = false;
        downloadStatus.value = `缺少存储权限: ${err?.message || err}`;
        return;
      }
    }
    startDownload();
  } catch (e: any) {
    isDownloading.value = false;
    downloadStatus.value = `原生下载失败: ${e?.message || e}`;
  }
};

保存路径

  • Android:/storage/emulated/0/Download/<文件名>(绝对路径显示为 file:///storage/emulated/0/Download/<文件名>)。
  • iOS:_doc/<文件名>(绝对路径通过 plus.io.convertLocalFileSystemURL 转换后显示为 file:///.../Documents/<文件名> 等沙箱路径)。

平台要求

  • Android:需要申请 WRITE_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGE 权限;代码中已请求(src/views/w-success.vue:356-371)。
  • iOS:若下载地址为 http://,需在原生打包的 ATS 配置中允许该域名的非 HTTPS 访问;文件仅可写入应用沙箱。

在Cesium中实现飘动的红旗

smr4d-ls7t2.gif

前言

实现飘动红旗的效果整体分两部,一是利用三角函数的周期性让红旗摆动起来,二是根据每个片元的摆动幅度来计算对应位置的阴影
这是我在一个园区项目中收到的需求,在此记录及分享实现过程。

基础场景搭建(创建cesium场景和必要的实体)

这里使用gltf模型作为红旗,因为需要获得平滑的摆动效果,因此使用的模型面数较多,同时为了旗子与旗杆可以使用相同的坐标位置,我将模型的定位锚地放到了左上角(见下图,来自建模软件blender)。同样的,飘动的功能也可以手动创建Cesium中polygonrectangle实体来完成,核心部分与使用gltf模型无异。
image.png 创建基础场景

Cesium.Ion.defaultAccessToken = "your token";
const viewer = new Cesium.Viewer("cesiumContainer");
viewer.camera.setView({
    destination: Cesium.Cartesian3.fromDegrees(116.39122232836966, 39.90701265936752, 4.813199462406734),
    orientation: {
        heading: Cesium.Math.toRadians(26.754499635799313),
        pitch: Cesium.Math.toRadians(5.094600937875728),
        roll: 0,
    },
});
const modelPosition = [116.39124568344667, 39.90705858625655, 6]
//绘制旗子
const flag = viewer.entities.add({
    position: Cesium.Cartesian3.fromDegrees(...modelPosition),
    model: {
        uri: '../source/models/旗子/旗子.gltf',
    },
});
//绘制旗杆
viewer.entities.add({
        position: Cesium.Cartesian3.fromDegrees(modelPosition[0], modelPosition[1]),
        ellipse: {
            semiMinorAxis: 0.01,
            semiMajorAxis: 0.01,
            extrudedHeight: modelPosition[2] + 0.05,
            material: Cesium.Color.fromCssColorString('#eee'),
        },
    });

image.png

让旗子飘动起来

请注意,下文中所有着色器的坐标控制都是基于模型自身的局部坐标系。如果使用不同的模型,可能需要根据模型的具体坐标系统调整相关参数。

const customShader = new Cesium.CustomShader({
    uniforms: {
        // 自增变量,让动画持续执行
        u_time: {
            type: Cesium.UniformType.FLOAT,
            value: 0.0
        },
        u_texture: {
            type: Cesium.UniformType.SAMPLER_2D,
            value: new Cesium.TextureUniform({
                url: "../source/models/旗子/hongqi.jpg",
            })
        }
    },
    varyings: {
        // 旗子的片元偏移量需要传递给片元着色器计算阴影,因此定义为varying变量
        v_offset: Cesium.VaryingType.FLOAT
    },
    vertexShaderText: `
        void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) {
            // 根据模型uv坐标的x和y坐标来确定摆动的频率(对应sin曲线的波频)
            // 这里控制波频时,分别用到了x和y轴坐标,并让x坐标权重大于y坐标,使得摆动更加自然
            // 最后乘以0.13是为了控制摆动的幅度(对应sin曲线的波高)
            float offset = sin(vsInput.attributes.texCoord_0.x * 8.0 +  vsInput.attributes.texCoord_0.y * 1.5 - u_time) * 0.13;
            v_offset = offset - offset * smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x);
            // 为片元赋予新的x坐标,新的x坐标为原始x坐标加上摆动偏移量
            vsOutput.positionMC.x += vsOutput.positionMC.x + v_offset;
            // 因为旗子在x方向上有前后摆动,因此在视觉上z轴应当适当缩短一些
            vsOutput.positionMC.z *= 0.95;
        }`,
    fragmentShaderText: `
        void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
                
        }`
})
flag.model.customShader = customShader;
viewer.clock.onTick.addEventListener(function () {
    // 使动画持续进行
    customShader.uniforms.u_time.value += 0.1;
});

rpwhz-mq5ij.gif

从上图可以看出,虽然旗子已经实现了摆动效果,但与预期不符:靠近旗杆的一侧本应保持不动。接下来进一步优化顶点着色器代码。

void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) {
    float offset = sin(vsInput.attributes.texCoord_0.x * 8.0 +  vsInput.attributes.texCoord_0.y * 1.5 - u_time) * 0.13;
    // 这是关键的一步,使用平滑阶梯函数smoothstep函数来控制摆动的范围
    // smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x)表达式在uv的x轴坐标靠近起点时返回1,到达x轴的0.4时返回0
    // 再用offset减去offset乘以smoothstep函数的结果,就可以得到在x轴坐标靠近0时,offset值为0的效果,往x轴的0.4靠近时再渐渐回到完全的摆动
    // 关于smoothstep函数的更多信息,请参考https://zhuanlan.zhihu.com/p/157758600
    v_offset = offset - offset * smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x);
    vsOutput.positionMC.x += vsOutput.positionMC.x + v_offset;
    vsOutput.positionMC.z *= 0.95;
}

此时smoothstep函数已经把旗杆一侧固定住了。

s6cp1-0sa4x.gif

为旗子添加贴图和阴影

void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
    // 这一步是为了uv贴图的正确映射
    fsInput.attributes.texCoord_0.y *= -1.0;
    // (1.0 - v_offset * 3.0)表达式用来决定片元的亮度,往x轴的正方向偏移越大,这个表达式输出的值越小,则越暗
    // v_offset * 3.0是为了放大偏移量从而增大明暗对比
    // 贴图颜色乘以亮度系数就是最终色(这里控制亮度系数的范围在0-1之间,不去增加凸起部分的亮度会更逼真一些)
    vec4 color = texture(u_texture,fsInput.attributes.texCoord_0) * min((1.0 - v_offset * 2.0),1.0);
    material.diffuse=vec3(color.rgb);     
}

83e16bd946d643dc8374d800d678666e.png

上图可以看出,旗子凹陷部分已经有了阴影,但是此时阴影和片元的偏移程度为线性关系,阴影处的对比不够强烈,下面分享另一种阴影算法。

优化阴影

接下来通过实现阴影随片元偏移量的指数级增长来增强阴影部分的对比度,使效果更加逼真。

void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
    // 计算片元偏移量的平方用于后续的阴影计算
    float offsetSquared = v_offset * v_offset;
    // 获取片元偏移量的符号,用于在后续的计算中保留偏移量的正负性
    float offsetSign = v_offset >= 0.0 ? 1.0 : -1.0;
    fsInput.attributes.texCoord_0.y *= -1.0;
    // 1.0 - offsetSquared * offsetSign * 30.0表达式用来决定片元的亮度,原理与上面的着色器一致
    // 不过此时片元的亮度与偏移量为指数级增长关系,会在阴影区域获得更加大的反差,增强逼真度
    vec4 color = texture(u_texture, fsInput.attributes.texCoord_0) * min(1.0 - offsetSquared * offsetSign * 30.0, 1.0);
    material.diffuse=vec3(color.rgb);
}

比刚才逼真多了

8f311d85487d462fa5cac31a6f71550e.png

smr4d-ls7t2.gif

完整的CustomShader代码

const customShader = new Cesium.CustomShader({
    uniforms: {
        u_time: {
            type: Cesium.UniformType.FLOAT,
            value: 0.0
        },
        u_texture: {
            type: Cesium.UniformType.SAMPLER_2D,
            value: new Cesium.TextureUniform({
                url: "../source/models/旗子/hongqi.jpg",
            })
        }
    },
    varyings: {
        v_offset: Cesium.VaryingType.FLOAT
    },
    vertexShaderText: `
            void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) {
                // 根据模型uv坐标的x和y坐标来确定摆动的频率(对应sin曲线的波频)
                // 这里控制波频时,分别用到了x和y轴坐标,并让x坐标权重大于y坐标,使得摆动更加自然
                // 最后乘以0.13是为了控制摆动的幅度(对应sin曲线的波高)
                float offset = sin(vsInput.attributes.texCoord_0.x * 8.0 +  vsInput.attributes.texCoord_0.y * 1.5 - u_time) * 0.13;
                // 这是关键的一步,使用平滑阶梯函数smoothstep函数来控制摆动的范围
                // smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x)表达式在uv的x轴坐标靠近起点时返回1,到达x轴的0.4时返回0
                // 再用offset减去offset乘以smoothstep函数的结果,就可以得到在x轴坐标靠近0时,offset值为0的效果
                // 关于smoothstep函数的更多信息,请参考https://zhuanlan.zhihu.com/p/157758600
                v_offset = offset - offset * smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x);
                // 为片元赋予新的x坐标,新的x坐标为原始x坐标加上摆动偏移量
                vsOutput.positionMC.x += vsOutput.positionMC.x + v_offset;
                // 因为旗子在x方向上有前后摆动,因此在视觉上z轴应当适当缩短一些
                vsOutput.positionMC.z *= 0.95;
            }`,
    fragmentShaderText: `
            void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
                // 计算片元偏移量的平方用于后续的阴影计算
                float offsetSquared = v_offset * v_offset;
                // 获取片元偏移量的符号,用于在后续的计算中保留偏移量的正负性
                float offsetSign = v_offset >= 0.0 ? 1.0 : -1.0;
                fsInput.attributes.texCoord_0.y *= -1.0;
                // 1.0 - offsetSquared * offsetSign * 30.0表达式用来决定片元的亮度,原理与上面的着色器一致
                // 不过此时片元的亮度与偏移量为指数级增长关系,会在阴影区域获得更加大的反差,增强逼真度
                vec4 color = texture(u_texture, fsInput.attributes.texCoord_0) * min(1.0 - offsetSquared * offsetSign * 30.0, 1.0);
                material.diffuse=vec3(color.rgb);
            }`
})

总结

  1. 摆动核心
    用三角函数把 UV 坐标映射成随时间变化的偏移量,再用 smoothstep 把旗杆侧固定,就能让红旗只在自由端飘动。

  2. 阴影核心
    把顶点着色器算出的偏移量 v_offset 传进片元着色器,用“1 − 偏移量² × 符号 × 放大系数”做指数级压暗,褶皱阴影逼真立体。

  3. 扩展和思考

    • 如何使用噪声实现?
    • 如何为摆动的增加随机性?

祝玩旗愉快!

wangEditor5在vue中自定义菜单栏--格式刷,上传图片,视频功能

一、安装相关插件

npm install @wangeditor/editor
npm install @wangeditor/editor-for-vue

二、官方关键文档

  1. ButtonMenu:www.wangeditor.com/v5/developm…
  2. 注册菜单到wangEditor:自定义扩展新功能 | wangEditor
  3. insertKeys自定义功能的keys:www.wangeditor.com/v5/toolbar-…
  4. 自定义上传图片视频功能:菜单配置 | wangEditor
  5. 源码地址:GitHub - wangeditor-team/wangEditor: wangEditor —— 开源 Web 富文本编辑器

三、初始化编辑器(wangEdit.vue) 

<template>
  <div style="border: 1px solid #ccc">
    <Toolbar
      style="border-bottom: 1px solid #ccc"
      :editor="editor"
      :defaultConfig="toolbarConfig"
      :mode="mode"
    />
    <Editor
      style="height: 500px; overflow-y: hidden"
      v-model="html"
      :defaultConfig="editorConfig"
      :mode="mode"
      @onCreated="onCreated"
      @onChange="onChange"
    />
  </div>
</template>

<script>
// import Location from "@/utils/location";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import { Boot, IModuleConf, DomEditor } from "@wangeditor/editor";
import { getToken } from "@/utils/auth";
import MyPainter from "./geshi";
const menu1Conf = {
  key: "geshi", // 定义 menu key :要保证唯一、不重复(重要)
  factory() {
    return new MyPainter(); // 把 `YourMenuClass` 替换为你菜单的 class
  },
};
const module = {
  // JS 语法
  menus: [menu1Conf],

  // 其他功能,下文讲解...
};
Boot.registerModule(module);
export default {
  components: { Editor, Toolbar },
  props: {
    relationKey: {
      type: String,
      default: "",
    },
  },
  created() {
    console.log(this.editorConfig.MENU_CONF.uploadImage.meta.activityKey);
  },
  data() {
    return {
      // 富文本实例
      editor: null,
      // 富文本正文内容
      html: "",
      // 编辑器模式
      mode: "default", // or 'simple'
      // 工具栏配置
      toolbarConfig: {
        //新增菜单
        insertKeys: {
          index: 32,
          keys: ["geshi"],
        },
        //去掉网络上传图片和视频
        excludeKeys: ["insertImage", "insertVideo"],
      },
      // 编辑栏配置
      editorConfig: {
        placeholder: "请输入相关内容......",
        // 菜单配置
        MENU_CONF: {
          // ===================
          // 上传图片配置
          // ===================
          uploadImage: {
            // 文件名称
            fieldName: "contentAttachImage",
            server: Location.serverPath + "/editor-upload/upload-image",
            headers: {
              Authorization: "Bearer " + getToken(),
            },
            meta: {
              activityKey: this.relationKey,
            },
            // 单个文件的最大体积限制,默认为 20M
            maxFileSize: 20 * 1024 * 1024,
            // 最多可上传几个文件,默认为 100
            maxNumberOfFiles: 10,
            // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
            allowedFileTypes: ["image/*"],
            // 跨域是否传递 cookie ,默认为 false
            withCredentials: true,
            // 超时时间,默认为 10 秒
            timeout: 5 * 1000,
            // 自定义插入图片操作
            customInsert: (res, insertFn) => {
              if (res.errno == -1) {
                this.$message.error("上传失败!");
                return;
              }
              insertFn(Location.serverPath + res.data.url, "", "");
              this.$message.success("上传成功!");
            },
          },
          // =====================
          // 上传视频配置
          // =====================
          uploadVideo: {
            // 文件名称
            fieldName: "contentAttachVideo",
            server: Location.serverPath + "/editor-upload/upload-video",
            headers: {
              Authorization: "Bearer " + getToken(),
            },
            meta: {
              activityKey: this.relationKey,
            },
            // 单个文件的最大体积限制,默认为 60M
            maxFileSize: 60 * 1024 * 1024,
            // 最多可上传几个文件,默认为 100
            maxNumberOfFiles: 3,
            // 选择文件时的类型限制,默认为 ['video/*'] 。如不想限制,则设置为 []
            allowedFileTypes: ["video/*"],
            // 跨域是否传递 cookie ,默认为 false
            withCredentials: true,
            // 超时时间,默认为 10 秒
            timeout: 15 * 1000,
            // 自定义插入图片操作
            customInsert: (res, insertFn) => {
              if (res.errno == -1) {
                this.$message.error("上传失败!");
                return;
              }
              insertFn(Location.serverPath + res.data.url, "", "");
              this.$message.success("上传成功!");
            },
          },
        },
      },

      // ===== data field end =====
    };
  },
  methods: {
    // =============== Editor 事件相关 ================
    // 1. 创建 Editor 实例对象
    onCreated(editor) {
      this.editor = Object.seal(editor); // 一定要用 Object.seal() ,否则会报错
      this.$nextTick(() => {
        const toolbar = DomEditor.getToolbar(this.editor);
        const curToolbarConfig = toolbar.getConfig();
        console.log("【 curToolbarConfig 】-39", curToolbarConfig);
      });
    },
    // 2. 失去焦点事件
    onChange(editor) {
      this.$emit("change", this.html);
    },

    // =============== Editor操作API相关 ==============
    insertText(insertContent) {
      const editor = this.editor; // 获取 editor 实例
      if (editor == null) {
        return;
      }
      // 执行Editor的API插入
      editor.insertText(insertContent);
    },

    // =============== 组件交互相关 ==================
    // closeEditorBeforeComponent() {
    //   this.$emit("returnEditorContent", this.html);
    // },
    closeContent(){
        this.html=''
    },
    // ========== methods end ===============
  },
  mounted() {
    // ========== mounted end ===============
  },
  beforeDestroy() {
    const editor = this.editor;
    if (editor == null) {
      return;
    }
    editor.destroy();
    console.log("销毁编辑器!");
  },
};
</script>
<style lang="scss" scoped>
// 对默认的p标签进行穿透
::v-deep .editorStyle .w-e-text-container [data-slate-editor] p  {
  margin: 0 !important;
}
</style>
<style src="@wangeditor/editor/dist/css/style.css"></style>
自定义上传图片接口
 uploadImage: {
                        // 文件名称
                        fieldName: "contentAttachImage",
                        // server: '/api/v1/public/uploadFile',
                        headers: {
                            Authorization: "Bearer " + getToken(),
                        },
                        meta: {
                            activityKey: this.relationKey,
                        },
                        // 单个文件的最大体积限制,默认为 20M
                        maxFileSize: 20 * 1024 * 1024,
                        // 最多可上传几个文件,默认为 100
                        maxNumberOfFiles: 10,
                        // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
                        allowedFileTypes: ["image/*"],
                        // 跨域是否传递 cookie ,默认为 false
                        withCredentials: true,
                        // 超时时间,默认为 10 秒
                        timeout: 5 * 1000,
                         这里设置
                        customUpload: async (file, insertFn) => {
                            console.log(file, "file");
                            let formData = new FormData()
                            const sub = "order";
                            formData.append('file', file)
                            formData.append("sub", sub);
                            formData.append("type", "1");
                            let res = await getUploadImg(formData)
                            insertFn(res.data.full_path, '', '');
                        },
                        customInsert: (res, insertFn) => {
                            if (res.errno == -1) {
                                this.$message.error("上传失败!");
                                return;
                            }
                            // insertFn(res.data.url, "", "");
                            this.$message.success("上传成功!");
                        },
                    },

四、格式刷功能类js文件

import {
  SlateEditor,
  SlateText,
  SlateElement,
  SlateTransforms,
  DomEditor,
  //   Boot,
} from "@wangeditor/editor";
// Boot.registerMenu(menu1Conf);
import { Editor } from "slate";
export default class MyPainter {
  constructor() {
    this.title = "格式刷"; // 自定义菜单标题
    // 这里是设置格式刷的样式图片跟svg都可以,但是注意要图片大小要小一点,因为要应用到鼠标手势上
    this.iconSvg = ``;
    this.tag = "button"; //注入的菜单类型
    this.savedMarks = null; //保存的样式
    this.domId = null; //这个可要可不要
    this.editor = null; //编辑器示例
    this.parentStyle = null; //储存父节点样式
    this.mark = "";
    this.marksNeedToRemove = []; // 增加 mark 的同时,需要移除哪些 mark (互斥,不能共存的)
  }
  clickHandler(e) {
    console.log(e, "e"); //无效
  }
  //添加或者移除鼠标事件
  addorRemove = (type) => {
    const dom = document.body;
    if (type === "add") {
      dom.addEventListener("mousedown", this.changeMouseDown);
      dom.addEventListener("mouseup", this.changeMouseup);
    } else {
      //赋值完需要做的清理工作
      this.savedMarks = undefined;
      dom.removeEventListener("mousedown", this.changeMouseDown);
      dom.removeEventListener("mouseup", this.changeMouseup);
      document.querySelector("#w-e-textarea-1").style.cursor = "auto";
    }
  };

  //处理重复键名值不同的情况
  handlerRepeatandNotStyle = (styles) => {
    const addStyles = styles[0];
    const notVal = [];
    for (const style of styles) {
      for (const key in style) {
        const value = style[key];
        if (!addStyles.hasOwnProperty(key)) {
          addStyles[key] = value;
        } else {
          if (addStyles[key] !== value) {
            notVal.push(key);
          }
        }
      }
    }
    for (const key of notVal) {
      delete addStyles[key];
    }
    return addStyles;
  };

  // 获取当前选中范围的父级节点
  getSelectionParentEle = (type, func) => {
    if (this.editor) {
      const parentEntry = SlateEditor.nodes(this.editor, {
        match: (node) => SlateElement.isElement(node),
      });
      let styles = [];
      for (const [node] of parentEntry) {
        styles.push(this.editor.toDOMNode(node).style); //将node对应的DOM对应的style对象加入到数组
      }
      styles = styles.map((item) => {
        //处理不为空的style
        const newItem = {};
        for (const key in item) {
          const val = item[key];
          if (val !== "") {
            newItem[key] = val;
          }
        }
        return newItem;
      });
      type === "get"
        ? func(type, this.handlerRepeatandNotStyle(styles))
        : func(type);
    }
  };

  //获取或者设置父级样式
  getorSetparentStyle = (type, style) => {
    if (type === "get") {
      this.parentStyle = style; //这里是个样式对象 例如{textAlign:'center'}
    } else {
      SlateTransforms.setNodes(
        this.editor,
        { ...this.parentStyle },
        {
          mode: "highest", // 针对最高层级的节点
        }
      );
    }
  };

  //这里是将svg转换为Base64格式
  addmouseStyle = () => {
    const icon = ``; // 这里是给鼠标手势添加图标
    // 将字符串编码为Base64格式
    const base64String = btoa(icon);
    // 生成数据URI
    const dataUri = `data:image/svg+xml;base64,${base64String}`;
    // 将数据URI应用于鼠标图标
    document.querySelector(
      "#w-e-textarea-1"
    ).style.cursor = `url('${dataUri}'), auto`;
  };
  changeMouseDown = () => {}; //鼠标落下

  changeMouseup = () => {
    //鼠标抬起
    if (this.editor) {
      const editor = this.editor;
      const selectTxt = editor.getSelectionText(); //获取文本是否为null
      if (this.savedMarks && selectTxt) {
        //先改变父节点样式
        this.getSelectionParentEle("set", this.getorSetparentStyle);
        // 获取所有 text node
        const nodeEntries = SlateEditor.nodes(editor, {
          //nodeEntries返回的是一个迭代器对象
          match: (n) => SlateText.isText(n), //这里是筛选一个节点是否是 text
          universal: true, //当universal为 true 时,Editor.nodes会遍历整个文档,包括根节点和所有子节点,以匹配满足条件的节点。当universal为 false 时,Editor.nodes只会在当前节点的直接子节点中进行匹配。
        });
        // 先清除选中节点的样式
        for (const node of nodeEntries) {
          const n = node[0]; //{text:xxxx}
          const keys = Object.keys(n);
          keys.forEach((key) => {
            if (key === "text") {
              // 保留 text 属性
              return;
            }
            // 其他属性,全部清除
            SlateEditor.removeMark(editor, key);
          });
        }
        // 再赋值新样式
        for (const key in this.savedMarks) {
          if (Object.hasOwnProperty.call(this.savedMarks, key)) {
            const value = this.savedMarks[key];
            editor.addMark(key, value);
          }
        }
        this.addorRemove("remove");
      }
    }
  };

  getValue(editor) {
    // return "MyPainter"; // 标识格式刷菜单
    const mark = this.mark;
    console.log(mark, "mark");
    const curMarks = Editor.marks(editor);
    // 当 curMarks 存在时,说明用户手动设置,以 curMarks 为准
    if (curMarks) {
      return curMarks[mark];
    } else {
      const [match] = Editor.nodes(editor, {
        // @ts-ignore
        match: (n) => n[mark] === true,
      });
      return !!match;
    }
  }

  isActive(editor, val) {
    const isMark = this.getValue(editor);
    return !!isMark;
    //  return !!DomEditor.getSelectedNodeByType(editor, "geshi");
    // return false;
  }

  isDisabled(editor) {
    //是否禁用
    return false;
  }
  exec(editor, value) {
    //当菜单点击后触发
    // console.log(!this.isActive());
    console.log(value, "value");
    this.editor = editor;
    this.domId = editor.id.split("-")[1]
      ? `w-e-textarea-${editor.id.split("-")[1]}`
      : undefined;
    if (this.isDisabled(editor)) return;
    const { mark, marksNeedToRemove } = this;
    if (value) {
      // 已,则取消
      editor.removeMark(mark);
    } else {
      // 没有,则执行
      editor.addMark(mark, true);
      this.savedMarks = SlateEditor.marks(editor); // 获取当前选中文本的样式
      this.getSelectionParentEle("get", this.getorSetparentStyle); //获取父节点样式并赋值
    //   this.addmouseStyle(); //点击之后给鼠标添加样式
      this.addorRemove("add"); //处理添加和移除事件函数
      // 移除互斥、不能共存的 marks
      if (marksNeedToRemove) {
        marksNeedToRemove.forEach((m) => editor.removeMark(m));
      }
    }
    if (
      editor.isEmpty() ||
      editor.getHtml() == "<p><br></p>" ||
      editor.getSelectionText() == ""
    )
      return; //这里是对没有选中或者没内容做的处理
  }
}

五、页面应用组件

 <el-form-item label="内容">
 <WangEdit v-model="form.content" ref="wangEdit"  @change="change"></WangEdit>
  </el-form-item>


// js
const WangEdit = () => import("@/views/compoments/WangEdit.vue");
export default {
  name: "Notice",
  components: {
    WangEdit,
  },
    data(){
    return{
          form:{
         }
    }
    },

 methods: {
     change(val) {
            console.log(val,'aa');
            this.form.content=val
        },
     // 取消按钮
    cancel() {
      this.open = false;
      this.form={};
      this.$refs.wangEdit.closeContent();
    },
}

转载:wangEditor5在vue中自定义菜单栏--格式刷,上传图片,视频功能_vue.js_liang04273-Vue

❌