普通视图

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

uni-app 也能远程调试?使用 PageSpy 打开调试的新大门!

2025年12月6日 23:01

大家好,我是不如摸鱼去,wot-ui 的“主理人”,欢迎来到我的 uni-app 分享专栏。

如果你是一个前端开发,我想你肯定经历过测试、项目验收时报障,本地却无法复现的情况,又或者本地无法满足复现环境的情况。这时候的你是不是抓耳挠腮不知所措?

等到测试反馈,流程慢如闪电?

当然,解决方案肯定是有的,埋点、vConsole 以及远程调试工具可以帮我们解决这一问题,本文将会使用 PageSpy 结合 uni-app 模板 wot-starter 实践 uni-app 的远程调试。

为什么要远程调试?

以下内容来自 PageSpy 官网,我认为非常有道理。

任何无法在本地使用控制台调试的场景,都是 PageSpy 可以大显身手的时候! 一起来看下面的几个场景案例:

  • 本地调试 H5、Webview 应用:移动端屏幕太小,传统调试面板操作不便、显示不友好,且容易出现信息截断;
  • 远程办公、跨地区协同:传统沟通方式(邮件、电话、视频会议)效率低,故障信息不完整,容易误解误判;
  • 用户终端白屏问题排查:数据监控、日志分析等传统方式依赖排障人员对业务和技术的深入理解,定位效率低;

PageSpy 是什么?

PageSpy 是一款用来调试 Web / ReactNative / 小程序 / 鸿蒙 APP 等平台项目的工具。支持 Web 的远程调试工具不少,但能支持 uni-app 的调试工具真的是凤毛麟角了。

PageSpy 如何使用?

部署

部署文档见部署说明:www.pagespy.org/#/docs/depl… Docker 部署(正好买了个轻量级服务器没用):

docker run -d --restart=always -v ./log:/app/log -v ./data:/app/data -p 6752:6752 --name="pageSpy" ghcr.io/huolalatech/page-spy-web:latest

执行完成后,打开浏览器访问 http://localhost:6752 即可访问服务。

使用

参见小程序快速上手文档:www.pagespy.org/#/docs/mini…

在 wot-starter 中安装 @huolala-tech/page-spy-uniapp

pnpm add @huolala-tech/page-spy-uniapp@latest

在 main.ts 中引入 SDK 并实例化,可通过配置项 www.pagespy.org/#/docs/api 自定义 SDK 的行为:

// 引入 SDK
import PageSpy from '@huolala-tech/page-spy-uniapp'
import { createSSRApp } from 'vue'
import App from './App.vue'
import router from './router'

import 'uno.css'

const $pageSpy = new PageSpy({
  api: '<your-pagespy-host>', // 这里替换成你的 pagespy 地址
  enableSSL: false, // 视情况开启
})

const pinia = createPinia()
pinia.use(persistPlugin)
export function createApp() {
  const app = createSSRApp(App)
  // 全局挂载
  app.config.globalProperties.$pageSpy = $pageSpy
  app.use(router)
  app.use(pinia)
  return {
    app,
  }
}

执行 pnpm dev:mp-weixin后,我们打开部署的 PageSpy 点击开始调试就可以使用了!

我们可以看到有输出、网络、存储等内容。

总结

今天我们在 wot-starter 中简单尝试了下 PageSpy 的功能,其实 PageSpy 在 web 上有更强大的能力,不过我们主要关注它在小程序/uni-app 上的表现,更多能力大家可以自行探索了。还有大家可以多多关注 PageSpy 和 wot-starter 为他们点赞哦👍。

参考资料

PageSpy: www.pagespy.org/#/
wot-starter: starter.wot-ui.cn/

往期精彩

Trae SOLO 正式发布了?我用它将像老乡鸡那样做饭小程序开源了!

老乡鸡也开源?我用 Trae SOLO 做了个像老乡鸡那样做饭小程序!

当年偷偷玩小霸王,现在偷偷用 Trae Solo 复刻坦克大战

告别 HBuilderX,拥抱现代化!这个模板让 uni-app 开发体验起飞

uni-app 还在手写请求?alova 帮你全搞定!

uni-app 无法实现全局 Toast?这个方法做到了!

Vue3 uni-app 主包 2 MB 危机?1 个插件 10 分钟瘦身

欢迎评论区沟通、讨论👇👇

【Virtual World 03】上帝之手

作者 大怪v
2025年12月6日 22:58

这是纯前端手搓虚拟世界第三篇。

小小抱怨一下,感觉没啥人关注这种哇。

悲伤~

a4.jpg

本期代码量超标!!慎重!

gogogo!

欸,朋友,我们已经在前面构建了点(Point2D)和线(Segment)这两个类了嘛。

image.png

咳咳~这两天看新疆风味视频有点多了。脑子里大概模仿了下。

回归正题,在前两篇中,我们搞出了一个分形树还有一个喷绘效果。但那些玩意,从艺术角度来说就很空洞。

为什么?因为它无法交互,光看不能摸

一个真正的虚拟世界,没有“上帝”,那是不完美的。本篇要解决的,就是给这个虚拟世界装上“上帝之手”,而这个上帝之手的连接线,就是鼠标。这样,就能从“看画模式”进化到“编辑模式”。


战略思考

好了,暂停下吹牛,先思考下,如果是鼠标交互目前的虚拟世界,该怎么弄???

98b9a8d586cc43f2a6248b86dab2de2b.gif

思考完毕,我给出我的方案。再撸一个类**图形编辑器(Graph Editor)**,总体如下:

  1. 数据容器(graph):我们需要一个容器,专门管理所有的Point(点)和Segment(线段)。不能再像画分型图那样写死坐标了。
  2. 交互控制器(graphEditor):监听鼠标的点击(Left Click)、移动(Move)、右键(Right Click)。
  3. 视觉交互
    • 鼠标悬停在点上,点要变亮(Hover 态)。
    • 选中一个点后,鼠标移动要带出一条虚线(Intention,意图)。
    • 点击右键,取消操作或删除元素。

嗯~跟你想的差不多吧?

战术制定

好了,思路有了,开始具体的代码实现。

为了代码的健壮性,不能把代码全堆在 index.js 里,不然后续,就会变成屎山,身为一个有抱负的前端佬,我们得先抽个象。

详细步骤如下:

  • Graph。它像一个数据库,只管存点、存线、加、删、画。
  • GraphEditor。它是逻辑大脑,负责处理整个鼠标事件和交互,判断“我现在点到了谁”。
  • 构建一个数学工具。计算鼠标靠近哪个点以及距离,用于判断是否选中(也就是高中数学:两点间距离)。
  • 重写 World。启用 requestAnimationFrame 动画循环,因为交互是实时的,要绘制线的拉伸效果,画面需要每次都去更新,目前定为每秒刷新 60 次,后续会重点说明这个。

先不解释,上代码!

数据容器:Graph

src 下新建 math 文件夹,创建 graph.js。 它的职责非常单纯:管数据

// src/math/graph.js
export default class Graph {
  constructor(points = [], segments = []) {
    this.points = points;
    this.segments = segments;
  }

  // 添加点
  addPoint(point) {
    this.points.push(point);
  }

  // 判断是否已经有点在这个位置了(防止重叠点)
  containsPoint(point) {
    return this.points.find((p) => p.equals(point));
  }

  // 添加线段
  addSegment(seg) {
    this.segments.push(seg);
  }

  // 判断线段是否已存在
  containsSegment(seg) {
    return this.segments.find((s) => s.equals(seg));
  }

  // 尝试添加点(去重)
  tryAddPoint(point) {
    if (!this.containsPoint(point)) {
      this.addPoint(point);
      return true;
    }
    return false;
  }

  // 尝试添加线段(去重)
  tryAddSegment(seg) {
    if (!this.containsSegment(seg)) {
      this.addSegment(seg);
      return true;
    }
    return false;
  }

  // 删除点(非常重要:删点的时候,连着这个点的线也要一起删掉!)
  removePoint(point) {
    // 1. 从 points 数组移除
    this.points.splice(this.points.indexOf(point), 1);
    // 2. 过滤掉所有包含这个点的线段
    const segs = this.segments.filter((s) => s.includes(point));
    for (const seg of segs) {
      this.removeSegment(seg);
    }
  }

  // 删除线
  removeSegment(seg) {
    this.segments.splice(this.segments.indexOf(seg), 1);
  }

  // 这里的 draw 只是一个简单的代理,把任务分发给具体的元素
  draw(ctx) {
    for (const seg of this.segments) {
      seg.draw(ctx);
    }
    for (const point of this.points) {
      point.draw(ctx);
    }
  }
}

补充修改: 我们的 PointSegment 类之前比较简单,为了支持上面的去重和删除,我们需要在 Point2D.jsSegment.js 加两个小 方法(不用重写,加方法即可)。

手动给 src/primitives/point2D.js 添加:

  // 判断两个点坐标是否一样
  equals(point) {
    return this.x === point.x && this.y === point.y;
  }

手动给 src/primitives/segment.js 添加:

  // 判断线段是否包含某个点
  includes(point) {
    return this.p1.equals(point) || this.p2.equals(point);
  }
  
  // 判断两条线是否一样(方向不同也算同一条线)
  equals(seg) {
    return this.includes(seg.p1) && this.includes(seg.p2);
  }

交互控制器:GraphEditor

这是今天的重头戏。在 src 下新建 editors 文件夹,创建 graphEditor.js

逻辑有点绕,我给你理一下:

  1. 鼠标移动 (Move) -> 检查附近有没有点 -> 有就高亮 (hovered)。
  2. 鼠标点击 (Down) ->
    • 左键
      • 如果点在空地 -> 创建新点。
      • 如果之前选中了一个点 (selected) -> 连线。
      • 最后把当前点设为 selected(作为下一次连线的起点)。
    • 右键
      • 如果正在连线(有 selected) -> 取消选中(停止连线)。
      • 如果没有连线,但鼠标下有点 (hovered) -> 删除这个点。
// src/editors/graphEditor.js
import Point2D from "../primitives/point2D.js";
import Segment from "../primitives/segment.js";

export default class GraphEditor {
  constructor(canvas, graph) {
    this.canvas = canvas;
    this.graph = graph;

    this.ctx = canvas.getContext("2d");

    // 状态机
    this.selected = null; // 当前选中的点(用于连线起点)
    this.hovered = null;  // 鼠标悬停的点
    this.dragging = false; // 预留给未来拖拽用
    this.mouse = null;    // 当前鼠标位置

    // 启动监听
    this.#addEventListeners();
  }

  #addEventListeners() {
    // 1. 鼠标按下事件
    this.canvas.addEventListener("mousedown", (evt) => {
      // 只有左键(0)和右键(2)才处理
      if (evt.button == 2) { 
        // 右键逻辑
        if (this.selected) {
           this.selected = null; // 取消当前选中,停止连线
        } else if (this.hovered) {
           this.#removePoint(this.hovered); // 删除点
        }
      } 
      
      if (evt.button == 0) {
        // 左键逻辑
        // 如果鼠标在某个点上,就选中它;如果不在,就新建一个点并选中它
        if (this.hovered) {
          this.#select(this.hovered);
          this.dragging = true;
          return;
        }
        this.graph.tryAddPoint(this.mouse);
        this.#select(this.mouse); // 自动选中新点,方便连续画线
        this.hovered = this.mouse;
        this.dragging = true;
      }
    });

    // 2. 鼠标移动事件
    this.canvas.addEventListener("mousemove", (evt) => {
      // 获取鼠标在 Canvas 里的坐标(即使 Canvas 缩放或偏移也能用)
      // 这里先简化处理,假设 Canvas 铺满或者无偏移
      // 实际上我们应该写个 getViewportPoint,但暂时先直接读取 offsetX/Y
      this.mouse = new Point2D(evt.offsetX, evt.offsetY);
      
      // 检查鼠标有没有悬停在某个点上
      this.hovered = this.#getNearestPoint(this.mouse);
      
      // 移动的时候不需要重绘吗?需要的,但我们会在 World 里统一驱动动画循环
    });
    
    // 3. 禁止右键菜单弹出
    this.canvas.addEventListener("contextmenu", (evt) => evt.preventDefault());
    
    // 4. 鼠标抬起(结束拖拽状态)
    this.canvas.addEventListener("mouseup", () => this.dragging = false);
  }

  #select(point) {
    // 如果之前已经选中了一个点,现在又选了一个点,说明要连线
    if (this.selected) {
      // 尝试添加线段
      this.graph.tryAddSegment(new Segment(this.selected, point));
    }
    this.selected = point;
  }

  #removePoint(point) {
    this.graph.removePoint(point);
    this.hovered = null;
    if (this.selected == point) {
        this.selected = null;
    }
  }

  // 辅助函数:找离鼠标最近的点
  #getNearestPoint(point, minThreshold = 15) {
     let nearest = null;
     let minDist = Number.MAX_SAFE_INTEGER;
     
     for (const p of this.graph.points) {
       const dist = Math.hypot(p.x - point.x, p.y - point.y);
       if (dist < minThreshold && dist < minDist) {
         minDist = dist;
         nearest = p;
       }
     }
     return nearest;
  }

  // 专门负责画编辑器相关的 UI(比如高亮、虚线)
  display() {
    this.graph.draw(this.ctx);

    // 如果有悬停的点,画个特殊的样式
    if (this.hovered) {
      this.hovered.draw(this.ctx, { outline: true });
    }
    
    // 如果有选中的点,也高亮一下
    if (this.selected) {
        // 获取鼠标位置作为意图终点
        const intent = this.hovered ? this.hovered : this.mouse;
        // 画出“虚拟线条”:从选中点 -> 鼠标位置
        new Segment(this.selected, intent).draw(this.ctx, { color: "rgba(0,0,0,0.5)", width: 1, dash: [3, 3] });
        this.selected.draw(this.ctx, { outline: true, outlineColor: "blue" });
    }
  }
}

注意:上面的代码里用到了 Segmentdash 属性,你需要去 segment.jsdraw 方法里微调一下,加个 setLineDash

微调 src/primitives/segment.js 的 draw 方法:

  draw(ctx, { width = 2, color = "black", dash = [] } = {}) {
    ctx.beginPath();
    ctx.lineWidth = width;
    ctx.strokeStyle = color;
    ctx.setLineDash(dash); // 新增:支持虚线
    ctx.moveTo(this.p1.x, this.p1.y);
    ctx.lineTo(this.p2.x, this.p2.y);
    ctx.stroke();
    ctx.setLineDash([]); // 重置,防止影响其他绘制
  }

组装世界:重构 World

现在我们把 GraphGraphEditor 装进 World 里,并启动动画循环。

修改 src/index.js

import Point2D from "./primitives/point2D.js";
import Segment from "./primitives/segment.js";
import Graph from "./math/graph.js";
import GraphEditor from "./editors/graphEditor.js";

export default class World {
  constructor(canvas, width = 600, height = 600) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");
    this.canvas.width = width;
    this.canvas.height = height;

    // 1. 初始化空图
    this.graph = new Graph();
    // 2. 初始化编辑器
    this.editor = new GraphEditor(this.canvas, this.graph);

    // 3. 启动动画循环
    this.animate();
  }

  animate() {
    // 清空画布(重要!否则画面会重叠)
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    // 让编辑器去决定画什么(它包含图和交互UI)
    this.editor.display();

    // 递归调用,保持 60FPS
    requestAnimationFrame(() => this.animate());
  }
  
  // 原来的 display 方法可以删了,或者留着作纪念
}

效果讲解

现在,当你运行页面时,用鼠标点点看看:

  1. 高亮 (Hover):创建一个点后,鼠标移到点上会有蓝色高亮。
  2. 虚拟连线 (Intent):会有虚拟连线,引导直线方向。
  3. 删除点和线:当你右键删除点时,Graph 类的 removePoint 不仅删了点,还顺手把连接这个点的所有线段都干掉了。

如果看解释有点晕,试一试就知道了!!!!

image.png


最后秀一把?微抖的上帝之手。

实在没啥可以秀的,非要秀,我直接给你画个爱心:

image.png

vxe-gantt 甘特图实现产品进度列表,自定义任务条样式和提示信息

2025年12月6日 22:32

vxe-gantt 甘特图实现产品进度列表,自定义任务条样式和提示信息

查看官网:gantt.vxeui.com/
gitbub:github.com/x-extends/v…
gitee:gitee.com/x-extends/v…

效果

image

代码

通过 task-view-config.viewStyle.cellStyle 设置任务视图单元格样式,使用 taskBar、taskBarTooltip 插槽来自定义模板

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions">
      <template #task-bar="{ row }">
        <div class="custom-task-bar" :style="{ backgroundColor: row.bgColor }">
          <div class="custom-task-bar-img">
            <vxe-image :src="row.imgUrl" width="60" height="60"></vxe-image>
          </div>
          <div>
            <div>{{ row.title }}</div>
            <div>开始日期:{{ row.start }}</div>
            <div>结束日期:{{ row.end }}</div>
            <div>进度:{{ row.progress }}%</div>
          </div>
        </div>
      </template>

      <template #task-bar-tooltip="{ row }">
        <div>
          <div>任务名称:{{ row.title }}</div>
          <div>开始时间:{{ row.start }}</div>
          <div>结束时间:{{ row.end }}</div>
          <div>进度:{{ row.progress }}%</div>
        </div>
      </template>
    </vxe-gantt>
  </div>
</template>

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

const ganttOptions = reactive({
  border: true,
  height: 600,
  cellConfig: {
    height: 100
  },
  taskViewConfig: {
    tableStyle: {
      width: 380
    },
    showNowLine: true,
    scales: [
      { type: 'month' },
      {
        type: 'day',
        headerCellStyle ({ dateObj }) {
          // 周日高亮
          if (dateObj.e === 0) {
            return {
              backgroundColor: '#f9f0f0'
            }
          }
          return {}
        }
      },
      {
        type: 'date',
        headerCellStyle ({ dateObj }) {
          // 周日高亮
          if (dateObj.e === 0) {
            return {
              backgroundColor: '#f9f0f0'
            }
          }
          return {}
        }
      }
    ],
    viewStyle: {
      cellStyle ({ dateObj }) {
        // 周日高亮
        if (dateObj.e === 0) {
          return {
            backgroundColor: '#f9f0f0'
          }
        }
        return {}
      }
    }
  },
  taskBarConfig: {
    showTooltip: true,
    barStyle: {
      round: true
    }
  },
  columns: [
    { field: 'title', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 }
  ],
  data: [
    { id: 10001, title: '任务1', start: '2024-03-03', end: '2024-03-10', progress: 20, bgColor: '#c1c452', imgUrl: 'https://vxeui.com/resource/productImg/product9.png' },
    { id: 10002, title: '任务2', start: '2024-03-05', end: '2024-03-12', progress: 15, bgColor: '#fd9393', imgUrl: 'https://vxeui.com/resource/productImg/product8.png' },
    { id: 10003, title: '任务3', start: '2024-03-10', end: '2024-03-21', progress: 25, bgColor: '#92c1f1', imgUrl: 'https://vxeui.com/resource/productImg/product1.png' },
    { id: 10004, title: '任务4', start: '2024-03-15', end: '2024-03-24', progress: 70, bgColor: '#fad06c', imgUrl: 'https://vxeui.com/resource/productImg/product3.png' },
    { id: 10005, title: '任务5', start: '2024-03-20', end: '2024-04-05', progress: 50, bgColor: '#e78dd2', imgUrl: 'https://vxeui.com/resource/productImg/product11.png' },
    { id: 10006, title: '任务6', start: '2024-03-22', end: '2024-03-29', progress: 38, bgColor: '#8be1e6', imgUrl: 'https://vxeui.com/resource/productImg/product7.png' },
    { id: 10007, title: '任务7', start: '2024-03-28', end: '2024-04-04', progress: 24, bgColor: '#78e6d1', imgUrl: 'https://vxeui.com/resource/productImg/product5.png' },
    { id: 10008, title: '任务8', start: '2024-04-05', end: '2024-04-18', progress: 65, bgColor: '#edb695', imgUrl: 'https://vxeui.com/resource/productImg/product4.png' }
  ]
})
</script>

<style lang="scss" scoped>
.custom-task-bar {
  display: flex;
  flex-direction: row;
  padding: 8px 16px;
  width: 100%;
  font-size: 12px;
}
.custom-task-bar-img {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  width: 70px;
  height: 70px;
}
</style>

gitee.com/x-extends/v…

玩转小程序AR-实战篇

作者 深红
2025年12月6日 20:15

《玩转小程序AR》系列教程

声明: 本文所载内容仅限于学习交流之目的。所有抓包内容、敏感网址及数据接口均已进行脱敏处理,严禁将其用于商业或非法用途。任何因此产生的后果,作者不承担任何责任。若涉及侵权,请及时联系作者以便立即删除。

逆向小程序

接着前文: 《玩转小程序AR-基础篇》,体验过原神官方AR小程序后,我也比较好奇他们实现AR Live2d 动画的原理。

123

出于技术学习的目的,在开源社区搜寻微信小程序反编译工具,发现 KillWxapkgunveilr 暂时仍可使用

逆向 某神AR 小程序

首先我们需要找到某神AR微信小程序的本地小程序包地址

注意:本人电脑 Mac (Windows电脑同理)

  1. 进入电脑微信小程序目录
# Mac 目录地址
cd /Users/你的电脑用户名/Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/radium/Applet/packages

# Windows 目录地址
cd C:\Users\你的电脑用户名\AppData\Roaming\Tencent\xwechat\radium\Applet\packages


# 打开文件夹
open .

如果发现目录下有较多文件夹,建议先把所有的文件夹都删除,为后续定位要逆向的小程序做好准备

screenshot-20251125-212338.png

  1. 用电脑微信打开需要逆向的小程序

screenshot-20251125-211252.png

  1. 再次打开微信小程序目录

screenshot-20251125-213047.png

这时候,恭喜你已经定位到了小程序的AppID了!

  1. 反编译小程序

screenshot-20251125-213556.png

小程序文件夹下的__APP__.wxapkg就是编译后的小程序,现在我们需要使用工具反编译它了

  • unveilr 工具

安装地址

# 安装反编译工具
npm i unveilr -g

# 运行反编译命令
unveilr wx -i wxb2618d769d6f5143  "/Users/你的电脑用户名/Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/radium/Applet/packages/wxb2618d769d6f5143/3/__APP__.wxapkg" -f
  • KillWxapkg 工具

安装地址

./KillWxapkg -id="wxb2618d769d6f5143" -in="/Users/你的电脑用户名/Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/radium/Applet/packages/wxb2618d769d6f5143/3"  -restore

于是在小程序文件夹下,就新生成了一个反编译后的源码文件夹

screenshot-20251125-214601.png

当然,反编译后的源码也不是100%还原:

  • 缺失 wxml
  • js 代码被babel转码后,语义不是特别清晰

screenshot-20251201-195547.png

screenshot-20251201-200158.png

当然,在AI的加持下,如今这些问题已经完全难不倒我们了。AI分分钟就能根据混淆过的js原始逻辑,还原清晰可读的语义化代码

screenshot-20251201-200736.png

从源码中可以看到,原神AR小程序使用的正是XR-FRAME框架

逆向 某cube 小程序插件

官方提供的kivicube插件

{
  "usingComponents": {
    "kivicube-scene": "plugin://kivicube/kivicube-scene"
  },
  "disableScroll": true,
  "navigationStyle": "custom"
}
<kivicube-scene
  wx:if="{{showAR}}"
  class="kivicube"
  scene-id="{{sceneId}}"
  bind:ready="ready"
  bind:error="error"
  bind:downloadAssetStart="downloadStart"
  bind:downloadAssetProgress="downloadProgress"
  bind:downloadAssetEnd="downloadEnd"
  bind:loadSceneStart="loadStart"
  bind:loadSceneEnd="loadEnd"
  bind:sceneStart="sceneStart"
  bind:openUrl="openUrl"
  bind:photo="photo"
/>

使用同样的方法,我们可以得到小程序插件逆向的源码

screenshot-20251201-201949.png

通过源码分析可得知,Kivicube 使用的是底层VisionKit + 自研的Threejs封装

screenshot-20251201-202405.png

着色器

Shader,中文称为“着色器”,是一种在图形处理单元(GPU)上运行的计算机程序,用于定义和控制图形渲染过程中的各种视觉效果

使用 GLSL 的着色器(shader),GLSL 是一门特殊的有着类似于 C 语言的语法,在图形管道 (graphic pipeline) 中直接可执行的 OpenGL 着色语言。

更多详情见MDN上的解释:GLSL Shaders

XR-FRME 提供了自定义效果的能力: 定制一个效果

序列帧 SHADER

XR-FRAME 官方的《序列帧动画(雪碧图、GIF)》示例,实现了一个简单的可配置的序列帧效果

而原神小程序AR的源码中,正是使用了这个序列帧效果实现了伪Live2d

首先我们需要将序列帧动画合成到一张M行*N列大小的PNG图片上(注意:微信小程序最大能渲染8000x8000左右分辨率的序列帧图片)

1png

如上图所示,我们这次的图片是8行x4列,共32张序列帧图片合成而来

源码示例: 序列帧 SHADER

<xr-scene bind:ready="handleReady">
  <xr-assets></xr-assets>
  <xr-node>
    <xr-node node-id="center" />
    <xr-mesh visible="{{meshesVisible}}" id="animation-mesh" node-id="animation-mesh" position="0 0 0" scale="1 1 1.3" rotation="90 0 0" geometry="plane" />
  </xr-node>
  <xr-camera target="center" clear-color="0.4 0.8 0.6 1" position="0 0 2.5" camera-orbit-control />
</xr-scene>
Component({
  /**
   * 组件的初始数据
   */
  data: {
    meshesVisible: false
  },

  /**
   * 组件的方法列表
   */
  methods: {
    handleReady: function ({ detail }) {
      const xrFrameSystem = wx.getXrFrameSystem()
      const createFrameEffect = (scene) => {
        return scene.createEffect({
          name: 'frame-effect',
          properties: [
            {
              key: 'columCount', // 列数
              type: xrFrameSystem.EUniformType.FLOAT,
              default: 1
            },
            {
              key: 'rowCount', // 行数
              type: xrFrameSystem.EUniformType.FLOAT,
              default: 1
            },
            {
              key: 'during', // 持续时间
              type: xrFrameSystem.EUniformType.FLOAT,
              default: 1
            }
          ],
          images: [
            {
              key: 'u_baseColorMap',
              default: 'white',
              macro: 'WX_USE_BASECOLORMAP'
            }
          ],
          // 透明物体需要大于`2500`!
          defaultRenderQueue: 2501,
          passes: [
            {
              renderStates: {
                blendOn: false,
                depthWrite: true,
                cullOn: false,
                // 基础库 v3.0.1 开始 默认的 plane 切为适配 cw 的顶点绕序
              },
              lightMode: 'ForwardBase',
              useMaterialRenderStates: true,
              shaders: [0, 1]
            }
          ],
          shaders: [
            // 顶点着色器 Vertex shaders
            `#version 100

          precision highp float;
          precision highp int;
    
          attribute vec3 a_position;
          attribute highp vec2 a_texCoord;
      
          uniform mat4 u_view;
          uniform mat4 u_projection;
          uniform mat4 u_world;
          varying highp vec2 v_uv;
          void main()
          {
            v_uv = a_texCoord;
            gl_Position = u_projection * u_view * u_world * vec4(a_position, 1.0);
          }`,
            // 片段着色器 Fragment shaders
            `#version 100
            precision highp float;
            precision highp int;

            uniform sampler2D u_baseColorMap;
            uniform highp float u_gameTime;
            uniform highp float rowCount;
            uniform highp float columCount;
            uniform highp float during;
            varying highp vec2 v_uv;
            void main()
            {
              float loopTime = mod(u_gameTime, during);

              float tickPerFrame = during / (columCount * rowCount);
              
              float columTick = mod(floor(loopTime / tickPerFrame), columCount);
              float rowTick = floor(loopTime / tickPerFrame / columCount);

              vec2 texCoord = vec2(v_uv.x / columCount + (1.0 / columCount) * columTick , v_uv.y / rowCount + (1.0 / rowCount) * rowTick);
              vec4 color = texture2D(u_baseColorMap, texCoord);
              gl_FragColor = color;
            }`
          ],
        });
      }
      xrFrameSystem.registerEffect('frame-effect', createFrameEffect)
      this.scene = detail.value

      this.loadAsset()
    },

    async loadAsset() {
      const xrFrameSystem = wx.getXrFrameSystem();
      const xrScene = this.scene;

      await xrScene.assets.loadAsset({
        type: 'texture',
        assetId: 'lzy',
        src: 'https://assets.xxxx.com/resources/cdn/20251022/0ac5e7c80c0fc262.png',
      })

      // 第一个参数是效果实例的引用,第二个参数是默认`uniforms`
      const frameMaterial = xrScene.createMaterial(
        // 使用定制的效果
        xrScene.assets.getAsset('effect', 'frame-effect'),
        { u_baseColorMap: xrScene.assets.getAsset('texture', 'lzy') }
      )

      // 可以将其添加到资源系统中备用
      xrScene.assets.addAsset('material', 'frame-effect', frameMaterial)

      const meshElement = xrScene.getElementById('animation-mesh').getComponent(xrFrameSystem.Mesh)
      frameMaterial.setFloat('columCount', 4)
      frameMaterial.setFloat('rowCount', 8)
      frameMaterial.setFloat('during', 1)
      frameMaterial.alphaMode = "BLEND"
      meshElement.material = frameMaterial

      this.setData({
        meshesVisible: true
      })
    },
  }
})

frame.gif

透明视频 SHADER

一般的透明视频:

  1. 自带透明通道的视频格式: mov (小程序默认不支持mov格式播放)
  2. 特殊处理后的左右分屏视频格式: mp4 (小程序默认支持mp4格式播放)
  • 左边是视频的 RGB
  • 右边是视频的 Alpha
  • 左右叠加即可渲染透明视

更多详情见前文《更高效的web动效解决方案 - 背景视频》

v.gif

XR-FRAME 官方的《过滤黑色背景视频》示例,正好演示了左右分屏视频的过滤黑色背景能力

源码示例: 透明视频 SHADER

<xr-scene bind:ready="handleReady">
  <xr-assets bind:progress="handleAssetsProgress" bind:loaded="handleAssetsLoaded">
    <xr-asset-load type="video-texture" asset-id="lzy" src="https://assets.xxxx.com/resources/cdn/20251022/bd7cb6ba6546d697.mp4" options="autoPlay:true,loop:true" />
    <xr-asset-material asset-id="removeBlack-mat" effect="removeBlack" />
  </xr-assets>
  <xr-node>
    <xr-node node-id="center" />
    <xr-node wx:if="{{loaded}}">
      <xr-mesh node-id="video-item" position="0 0 0" rotation="90 0 0" scale="1 1 1.3" geometry="plane" material="removeBlack-mat" uniforms="u_videoMap: video-lzy" />
    </xr-node>
  </xr-node>
  <xr-camera target="center" clear-color="0.4 0.8 0.6 1" position="0 0 3" camera-orbit-control />
</xr-scene>
const xrFrameSystem = wx.getXrFrameSystem();

xrFrameSystem.registerEffect('removeBlack', scene => scene.createEffect({
  name: "removeBlack",
  images: [{
    key: 'u_videoMap',
    default: 'white',
    macro: 'WX_USE_VIDEOMAP'
  }],
  defaultRenderQueue: 2000,
  passes: [{
    "renderStates": {
      cullOn: false,
      blendOn: true,
      blendSrc: xrFrameSystem.EBlendFactor.SRC_ALPHA,
      blendDst: xrFrameSystem.EBlendFactor.ONE_MINUS_SRC_ALPHA,
      cullFace: xrFrameSystem.ECullMode.BACK,
    },
    lightMode: "ForwardBase",
    useMaterialRenderStates: true,
    shaders: [0, 1]
  }],
  shaders: [
    // 顶点着色器 Vertex shaders
    `#version 100

uniform highp mat4 u_view;
uniform highp mat4 u_viewInverse;
uniform highp mat4 u_vp;
uniform highp mat4 u_projection;
uniform highp mat4 u_world;

attribute vec3 a_position;
attribute highp vec2 a_texCoord;

varying highp vec2 v_UV;

void main()
{
  v_UV = a_texCoord;
  vec4 worldPosition = u_world * vec4(a_position, 1.0);
  gl_Position = u_projection * u_view * worldPosition;
  }`,
    // 片段着色器 Fragment shaders
    `#version 100

precision mediump float;
precision highp int;
varying highp vec2 v_UV;

#ifdef WX_USE_VIDEOMAP
  uniform sampler2D u_videoMap;
#endif

void main()
{
#ifdef WX_USE_VIDEOMAP
  // 左右分屏透明视频处理:
  // 左半边 (0-0.5) 为彩色内容,右半边 (0.5-1.0) 为透明度遮罩
  
  // 1. 采样左半边获取 RGB 颜色
  vec2 colorUV = vec2(v_UV.x * 0.5, v_UV.y);
  vec4 color = texture2D(u_videoMap, colorUV);
  
  // 2. 采样右半边获取 Alpha 遮罩
  vec2 alphaUV = vec2(v_UV.x * 0.5 + 0.5, v_UV.y);
  vec4 alphaSample = texture2D(u_videoMap, alphaUV);
  float alpha = alphaSample.r; // 使用红色通道作为透明度(灰度值)
  
  // 3. 输出颜色 + 遮罩透明度(不做伽马校正,避免变暗)
  gl_FragData[0] = vec4(color.rgb, alpha);
#else
  gl_FragData[0] = vec4(1.0, 1.0, 1.0, 1.0);
#endif
}
`],
}));

Component({
  /**
   * 组件的初始数据
   */
  data: {

  },

  /**
   * 组件的方法列表
   */
  methods: {
    handleReady({
      detail
    }) {
      console.log('handleReady', detail.value)
    },
    handleAssetsProgress({ detail }) {
      console.log('assets progress', detail.value)
    },
    handleAssetsLoaded({ detail }) {
      console.log('assets loaded', detail.value)
      this.setData({ loaded: true })
    },
  }
})

v2.gif

实战案例

work.gif

源码示例: 序列帧 SHADER

同层渲染

目前XR-FRAME尚未支持和小程序的UI元素混写,但我们可以使用同层方案

<view>
  <demo8
    disable-scroll
    id="main-frame"
    width="{{renderWidth}}"
    height="{{renderHeight}}"
    style="width:{{width}}px;height:{{height}}px;top:{{top}}px;left:{{left}}px;"
    bind:arTrackerSwitch="handleTrackerSwitch"
    markerImg="{{markerImg}}"
  />
  <view class="marker-tip-container" hidden="{{hiddenTip}}">
    <view class="marker-img-container">
      <image mode="aspectFit" class="marker-img" src="{{markerImg}}" />
    </view>
    <view class="marker-text-container">
      <text class="marker-text">请对准识别图</text>
    </view>
  </view>
</view>

demo8是XR-FRAME组件,它和viewUI组件处在同一层,这既是所谓的同层渲染方案。而同层方案,就必然涉及到组件通信

XR-FRAME组件需要将AR的识别状态同步给父级,父级根据不同的AR状态,展示不同的UI界面。而这些通信方式,和传统的组件通信方式基本一致:父级传递函数和属性到子级,子级通过执行回调函数传递数据

框架维护

比较尴尬的是,核心技术负责人已离开团队,XR-FRAME框架处于暂停维护状态

screenshot-20251201-214919.png

资料

JavaScript 今天30 岁了,但连自己的名字都不属于自己

2025年12月6日 19:06

image.png

12 月 4 号,JavaScript 迎来 30 岁生日。

一门 10 天赶出来的语言,现在跑在 98.9% 的网站上,有 1650 万开发者在用它。从浏览器脚本到服务端运行时,从桌面应用到移动端,甚至嵌入式设备都有它的身影。TIOBE 2024 年度编程语言排行榜上,JavaScript 排第 6。

但 30 周年这天,社区没怎么庆祝。大家更关心的是另一件事:JavaScript 这个名字,到底能不能从 Oracle 手里抢回来。


10 天写出来的语言

1995 年 5 月,Netscape 的工程师 Brendan Eich 接到一个任务:给浏览器加一门脚本语言。

时间表很紧——Navigator 2.0 Beta 版要发布了,必须赶上。

Eich 花了 10 天(据他回忆是 5 月 6 日到 15 日),搞出了第一个原型。这不是夸张,是真的 10 天。

他后来自己说:

当你看我 10 天写的东西,它像一颗种子。是一种有力的妥协,但仍然是一个非常强大的内核,后来长成了一门更大的语言。

这门语言最开始叫 Mocha,后来改叫 LiveScript,最后因为市场原因蹭了 Java 的热度,改名 JavaScript。

1995 年 12 月 4 日,Netscape 和 Sun 联合发布公告,宣布 JavaScript 正式诞生。28 家公司为这门新语言背书,包括 America Online、Apple、AT&T、Borland、HP、Oracle、Macromedia、Intuit、Toshiba 等科技巨头。

有意思的是,Oracle 当时是 JavaScript 的支持者之一,新闻稿的媒体联系人里还有 Mark Benioff(后来创办了 Salesforce)。没想到 30 年后,Oracle 成了社区想要摆脱的"商标持有者"。

Sun 联合创始人 Bill Joy 说:

JavaScript 是 Java 平台的完美补充,天生就是为互联网和全球化设计的。

America Online 技术总裁 Mike Connors:

JavaScript 带来了跨平台的快速多媒体应用开发能力。

HP 的 Jan Silverman:

JavaScript 代表了专门为互联网设计的下一代软件。

Netscape 和 Sun 还计划把 JavaScript 提交给 W3C 和 IETF 作为开放标准。后来 JavaScript 确实标准化了,但官方名字叫 ECMAScript——因为商标问题。

1996 年 3 月发布 1.0 版本后,JavaScript 的野心远不止当初设想的"胶水语言"。


从玩具到基础设施

当年 JavaScript 的定位是"胶水语言",让不会编程的人也能在网页上加点交互。

没人想到它会变成今天这样。

几个关键节点:

2009 年 - Node.js 诞生

Ryan Dahl 把 V8 引擎搬到服务端,JavaScript 不再只是浏览器里的玩具。前后端同构成为可能。

2015 年 - ES6 发布

let/const 替代 var,箭头函数,Promise,Class 语法... JavaScript 终于像个正经语言了。

2012 年 - TypeScript 发布

微软给 JavaScript 加了类型系统。2017 年只有 12% 的 JavaScript 开发者用 TypeScript,到 2024 年这个数字涨到了 35%。现在大型项目几乎都是 TypeScript。

框架时代

React、Vue、Angular 轮番登场。整个前端生态围绕 JavaScript 建立起来。现在有人的整个职业生涯都建立在某个特定的 JS 框架上。

嵌入式领域

JavaScript 甚至跑到了微控制器上。Espruino 项目让你可以在 24.95 美元的小板子上写 JavaScript,功耗低到 0.06mA,还能跑蓝牙。有个智能手表 Bangle.js 2,一块电池能用 4 周,上面跑的就是 JavaScript。


名字的问题

JavaScript 这个名字,商标属于 Oracle。

Oracle 2009 年收购 Sun 的时候一起拿到的。但 Oracle 自己根本不做 JavaScript 相关的产品,商标就这么放着。

问题来了:因为商标在 Oracle 手里,社区做事很尴尬。

  • 不能叫 JavaScript Conference,只能叫 JSConf
  • 官方规范叫 ECMAScript,不叫 JavaScript
  • 写书、办会议、做项目,用 JavaScript 这个词都有法律风险

Brendan Eich 2006 年写过:"ECMAScript 一直是个没人想要的商业名称,听起来像皮肤病。"

讽刺的是,Oracle 甚至不是 OpenJS Foundation 的成员,跟 Node.js 的开发也没有任何关系。

Node.js 和 Deno 的创始人 Ryan Dahl 看不下去了。2024 年 9 月他发起了 "Free the Mark" 运动,发布了一封公开信,28,600 多名开发者签名支持。

image.png 签名的人里有几个重量级的:

  • Brendan Eich - JavaScript 创造者本人
  • Ryan Dahl - Node.js 创造者
  • Michael Ficarra、Shu-yu Guo - JavaScript 规范编辑
  • Rich Harris - Svelte 作者
  • Isaac Z. Schlueter - npm 创始人
  • James M Snell - Node.js TSC 成员
  • Jordan Harband - JavaScript 规范荣誉编辑
  • Matt Pocock - Total TypeScript 课程作者
  • Wes Bos、Scott Tolinski - Syntax.fm 播客主持人

11 月正式向美国专利商标局提交申请,要求撤销 Oracle 的商标。

理由有三:

  1. 通用化 - JavaScript 已经变成通用名词了,就像 aspirin(阿司匹林)一样
  2. 弃用 - Oracle 三年多没用这个商标做任何商业用途
  3. 欺诈 - Oracle 2019 年续期商标时,提交的使用证据是 Node.js 的截图。Node.js 跟 Oracle 没有半毛钱关系

公开信里说得很直白:

Oracle 从来没有认真推出过叫 JavaScript 的产品。GraalVM 的产品页面甚至都没提"JavaScript"这个词,得翻文档才能找到它支持 JavaScript。

公开信还指出,Oracle 2019 年续期商标时提交的"使用证据"是 nodejs.org 的截图和 Oracle JET 库。Node.js 根本不是 Oracle 的产品,JET 只是 Oracle Cloud 服务的一个 JavaScript 库,跟市面上成千上万的 JS 库没什么区别。

按美国法律,商标 3 年不用就算放弃。Oracle 既没用这个商标,又眼睁睁看着它变成通用名词,两条都占了。

image.png

2025 年 2 月,Oracle 申请驳回诉讼中的欺诈指控。6 月,商标审判和上诉委员会驳回了欺诈指控,但撤销申请继续审理。8 月,Oracle 首次正式回应,否认 JavaScript 是通用名词。

官司预计要打到 2026 年。

Deno 团队正在众筹 20 万美元的法律费用,用于发现阶段的调查取证,包括做公众调查来证明普通人不会把 JavaScript 和 Oracle 联系在一起。


30 年后的 JavaScript

现在的 JavaScript 和 1995 年的已经是两门语言了。

当年的 varlet/const 取代。当年的原型继承有了 Class 语法糖。当年的回调地狱有了 Promise 和 async/await。

ES2025 刚发布,又加了一堆新特性。

工具链也完全不同了:

  • 打包器从 webpack 到 Vite,Vite 8 刚用上 Rolldown,速度又快了一大截
  • 运行时从只有浏览器,到 Node.js、Deno、Bun 三足鼎立
  • TypeScript 成了事实上的标准
  • 1650 万开发者,比很多国家的人口都多

Brendan Eich 当年 10 天写的种子,长成了一片森林。


顺手推几个项目

既然聊到 JavaScript 生态,推一下我做的几个开源项目:

chat_edit - 一个双模式 AI 应用,聊天 + 富文本编辑。Vue 3.5 + TypeScript + Vite 8 技术栈,可以自己配 API key 部署。

code-review-skill - Claude Code 的代码审查技能,覆盖 React、Vue、TypeScript 等主流技术栈,按需加载不浪费 token。

5-whys-skill - 根因分析技能,排查问题的时候用"5 个为什么"方法论。

first-principles-skill - 第一性原理思考技能,适合架构设计和技术方案选型。帮你拆解问题本质。

感兴趣可以去 GitHub 看看。


相关链接

Vite8来啦,告别 esbuild + Rollup,Vite 8 统一用 Rolldown 了

2025年12月6日 18:42

vite8.webp

用 Vite 做项目的应该都有感觉:开发体验一直很顺,但生产构建这块,项目一大就开始拉胯。

问题出在 Vite 的双引擎架构——开发用 esbuild,生产用 Rollup。两套东西,行为不一致,偶尔还会出现"本地没问题,构建就炸"的玄学 bug。

12 月 3 号,Vite 8 Beta 发布了,底层换成了 Rolldown。我第一时间把手上的项目升级了,踩了几个坑,这里记录一下。


Rolldown 是什么

简单说:一个 Rust 写的打包器,目标是同时替代 esbuild 和 Rollup。

尤雨溪的 VoidZero 团队搞的,拿了 1700 多万美金融资。整个工具链是这样的:

  • Vite(构建工具)
  • Rolldown(打包器)
  • Oxc(编译器、压缩器)

三个项目同一个团队维护,行为一致性有保障。

Rolldown 和 esbuild 速度差不多,比 Rollup 快 10-30 倍。尤雨溪自己测 Vue 核心代码的打包,Rolldown 比 Rollup 快 7 倍,比 esbuild 还快将近 2 倍。


真实项目数据

看看早期用户的反馈:

项目 构建时间变化 提升倍数
Linear 46s → 6s 7.6x
Excalidraw 22.9s → 1.4s 16x
GitLab 2.5min → 40s 3.75x
Beehiiv - 64% 更快

GitLab 还有个离谱的数据:内存占用降了 100 倍。


升级步骤

升级本身不复杂:

pnpm add -D vite@8.0.0-beta.0 @vitejs/plugin-vue@latest

Node.js 版本要求 20.19+ 或 22.12+,18 不支持了。

装完大概率能跑,但如果你用了 manualChunks,会遇到问题。


踩坑:manualChunks 不能用了

我的项目之前用对象形式配置 chunk 分割:

// 旧写法,Vite 8 直接报错
rollupOptions: {
  output: {
    manualChunks: {
      'vue-vendor': ['vue', 'pinia'],
      'monaco': ['monaco-editor'],
    }
  }
}

跑构建直接炸:TypeError: manualChunks is not a function

Rolldown 不支持对象形式的 manualChunks,得改成 advancedChunks

// 新写法
rollupOptions: {
  output: {
    advancedChunks: {
      groups: [
        { name: 'vue-vendor', test: /[\\/]node_modules[\\/](vue|pinia)[\\/]/ },
        { name: 'monaco', test: /[\\/]node_modules[\\/]monaco-editor[\\/]/ },
      ]
    }
  }
}

用正则匹配模块路径,比之前的数组形式更灵活,但迁移需要手动改一遍。


其他变化

配置项重命名

build.rollupOptions 以后要改成 build.rolldownOptions,目前还兼容但会有警告。

CSS 压缩

默认从 esbuild 换成了 Lightning CSS。想换回去可以设置 build.cssMinify: 'esbuild'

JS 压缩

从 esbuild 换成了 Oxc Minifier。

插件兼容性

大部分 Vite 插件直接能用。少数依赖 esbuild 特定选项的需要适配。


开发服务器

开发服务器启动速度没太大变化,本来就快。

但后面会有个 Full Bundle Mode,开发阶段也打包。官方初步数据:

  • 启动快 3 倍
  • 热更新快 40%
  • 网络请求少 10 倍

大型项目福音,不过目前还没正式发布。


要不要升

我的建议:

现在可以升的

  • 个人项目、实验性项目
  • 构建时间已经成为痛点的大型项目
  • 愿意踩坑反馈问题的

等等再升的

  • 生产环境稳定性优先的项目
  • 依赖大量 Rollup 特定配置的
  • 等正式版发布(估计一两个月内)

顺手推几个开源项目

既然聊到 Vite 和前端工具链,推一下我做的几个开源项目:

chat_edit - 双模式 AI 应用,聊天 + 富文本编辑整合在一起。技术栈刚升级到 Vue 3.5 + TypeScript + Vite 8,可以作为 Vite 8 实战参考。自己配置 API key 就能部署,支持导出 PDF、DOCX、Markdown。

code-review-skill - Claude Code 的代码审查技能,覆盖 React 19、Vue 3、Rust、TypeScript 等主流技术栈。采用按需加载设计,审查 React 代码时只加载 React 相关规则,不浪费 token。大概 9000 行最佳实践。

5-whys-skill - "5 个为什么"根因分析技能。遇到 bug 或者系统问题时,说"找根因"就能自动激活,输出结构化的分析报告。排查问题挺好用的。

first-principles-skill - 第一性原理思考技能,适合架构设计和技术方案选型。不是套模板,是真的帮你拆解问题本质。

感兴趣的可以去 GitHub 看看。


相关链接

昨天 — 2025年12月6日掘金 前端

Event Loop 教你高效 “划水”:JS 单线程的“摸鱼”指南

2025年12月6日 18:05

前言

各位前端打工人,有没有过这种经历:明明写了 setTimeout(() => console.log('摸鱼')),结果同步代码还没跑完,摸鱼计划就被打断?其实 JS 单线程就像一个只能专注干一件事的打工人,而 Event Loop 就是它的 “高效摸鱼手册”—— 既能按时完成核心工作,又能把耗时任务 “挂起摸鱼”,今天咱们就一起好好聊聊这份手册!

一、先搞懂:JS 打工人为啥不能 “硬卷”?(进程线程的底层逻辑)

要想摸鱼,得先知道 “工作台” 的规矩:

  • 进程:好比公司的独立部门 —— 比如浏览器开个新标签页,就是开了个新部门,每个部门都有自己的办公资源(电脑、文件)。

  • 线程:部门里真正干活的打工人 —— 浏览器部门里就有三个核心员工:

    1. 渲染线程(负责画页面,比如给按钮上色、排版文字);
    2. JS 引擎线程(咱们的主角,负责跑代码);
    3. HTTP 请求线程(负责发接口,比如向服务器要数据)。

但这里有个 “办公室规定”:JS 引擎线程和渲染线程是 “互斥同事” ——JS 能修改 DOM(比如把按钮改成红色),要是它俩同时干活,页面就会出现 “排版错乱”(比如按钮画到一半被改成红色),所以必须 “你歇我干”。

更关键的是:JS 引擎线程是个 “独生子” (V8 引擎默认只开一个线程)。这就意味着:如果 JS 遇到一个耗时 10 秒的计算任务(比如统计 100 万条数据),它就会一直死磕这个任务,导致渲染线程没法干活,页面直接卡成 “PPT”—— 这就是 “硬卷” 的下场!

所以 JS 打工人的生存法则是:能摸鱼就不硬卷,耗时任务先 “挂起”,等核心工作做完再处理—— 这就是 “异步摸鱼” 的核心逻辑。

二、Event Loop:摸鱼任务的 “优先级排序”

JS 里的 “摸鱼任务”(异步任务) 分两类,就像公司里的 “紧急任务”“常规任务”,得按顺序处理,不能乱摸鱼:

  • 微任务:紧急摸鱼任务(优先级高)—— 比如 Promise.then()async/await 后续代码、process.nextTick()(Node 环境),相当于 “老板临时交代的小任务,必须在下班前做完”;
  • 宏任务:常规摸鱼任务(优先级低)—— 比如 setTimeoutsetInterval、ajax 请求、I/O 操作、UI 渲染,相当于 “下周要交的报告,先放一放”;
  • 还有个特殊角色:同步任务—— 核心工作(比如写代码、算结果),必须优先做完,相当于 “当天要交的核心 KPI”。

Event Loop 就是这套摸鱼规则的 “监督者”,它的工作流程就像打工人的一天,记好这 4,摸鱼不翻车:

  1. 先清核心 KPI:先把当天的同步任务 (核心工作) 全部做完,遇到异步任务 (摸鱼任务),就按类型扔进 “微任务队列” (紧急摸鱼) 和 “宏任务队列” (常规摸鱼)
  2. 再处理紧急摸鱼:核心 KPI 做完后,把 “微任务队列” 里的所有任务一次性清完(比如老板临时交代的 3 个小任务,必须连续做完,不能中途打断);
  3. 中场休息(渲染页面) :紧急摸鱼任务处理完,浏览器会进行 “页面渲染”(比如更新 DOM、刷新页面),相当于打工人喝杯咖啡歇一歇;
  4. 开启下一轮摸鱼:从 “宏任务队列” 里拿一个任务执行,然后重复 1-3 步,直到所有任务做完。

三、实战摸鱼:用代码例子验证规则

光说不练假把式,咱们用真实代码模拟 JS 打工人的 “摸鱼一天”,看看 Event Loop 是怎么安排任务的!

例子 1:setTimeout为啥 “跑不赢” 同步代码?

先看这串经典代码:

let a = 1;
setTimeout(() => {
    a = 2
}, 1000)
console.log(a);

分析摸鱼过程

  • 同步代码(属于宏任务)先跑:let a=1 → 执行console.log(a),此时a还是 1;
  • setTimeout是宏任务,被扔进 “宏任务队列” 排队;
  • 同步跑完后,微任务队列为空,直接执行下一个宏任务(也就是 1 秒后的a=2)。

所以结果是:先输出 1,1 秒后a才变成 2

image.png

例子 2:Promise.then的 “VIP 特权”

我们看一道经典面试题:

console.log(1);
new Promise((resolve) => {
    console.log(2);
    resolve();
})
.then(() => {
    console.log(3);
    setTimeout(() => {
        console.log(4);
    }, 0)
})
setTimeout(() => {
    console.log(5);
    setTimeout(() => {
        console.log(6);
    }, 0)
}, 0)
console.log(7);

是不是已经头皮发麻了?根本不清楚打印顺序是啥,但是这道面试题我们必须拿下!

摸鱼步骤拆解

  1. 常规摸鱼(宏任务)开跑

    • 先执行console.log(1) → 输出1
    • 遇到new PromisePromise 构造函数里的代码是同步的,执行console.log(2) → 输出2,然后resolve()
    • then是微任务,扔进 “微任务队列”;
    • 遇到外层setTimeout:宏任务,扔进 “宏任务队列”;
    • 最后执行console.log(7) → 输出7
  2. 紧急摸鱼(微任务)接棒

    • 微任务队列里只有then的回调,执行它:console.log(3) → 输出3
    • 回调里的setTimeout(4)是宏任务,扔进 “宏任务队列”。
  3. 宏任务队列开跑(下一轮摸鱼)

    • 先拿第一个宏任务(外层setTimeout):执行console.log(5) → 输出5
    • 里面的setTimeout(6)扔进宏任务队列;
    • 再拿下一个宏任务(then里的setTimeout(4)):执行console.log(4) → 输出4
    • 最后拿setTimeout(6):执行console.log(6) → 输出6

最终输出顺序1 → 2 → 7 → 3 → 5 → 4 → 6

image.png

上图更清晰:

image.png

例子 3:async/await 是 “优雅摸鱼” 的语法糖

async/await 本质是 Promise 的语法糖,相当于给摸鱼任务加了 “自动排队” 功能,先搞懂它的用法

console.log('script start');
async function async1() {
    await async2()
    console.log('async1 end');
}
async function async2() {
    console.log('async2 end');
}
async1();

关键规则

  • async函数本身相当于 “返回 Promise 的函数”;
  • await fn()的本质是:await后面的代码,塞进了fn()返回的 Promise 的then里(也就是微任务队列)

拿这段代码分析:

  1. 同步执行console.log('script start') → 输出;

  2. 执行async1()

    • 进入async1,遇到await async2() → 先执行async2()(同步),输出async2 end
    • await把后续的console.log('async1 end')扔进微任务队列
  3. 继续执行同步代码

image.png

OK既然知道了原理我们就实战摸鱼

// 模拟耗时任务:向服务器要数据(宏任务)
function fetchData() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('常规摸鱼:发接口请求(耗时 1 秒)');
            resolve('接口返回数据:用户列表');
        }, 1000);
    });
}
// 核心工作函数(async 标记为异步函数)
async function work() {
    console.log('核心工作:开始处理用户数据');
    // await 相当于“等待摸鱼任务完成,再继续核心工作”
    const data = await fetchData();
    // 这行代码会被扔进微任务队列,相当于“紧急摸鱼后的收尾工作”
    console.log(`核心工作:使用${data}完成报表`);
}
// 执行核心工作
work();
// 其他同步任务
console.log('核心工作:处理其他紧急事务');

摸鱼流程拆解:

  1. 执行同步任务:

    • 调用 work() 函数,打印 核心工作:开始处理用户数据
    • 遇到 await fetchData(),先执行 fetchData(),里面的 setTimeout 被扔进 “宏任务队列”(常规摸鱼);
    • await 会暂停 work 函数,跳出去执行其他同步任务,打印 核心工作:处理其他紧急事务 → 同步任务完成。
  2. 微任务队列为空,直接进入中场休息。

  3. 处理宏任务队列(常规摸鱼):

    • 1 秒后,执行 setTimeout 回调,打印 常规摸鱼:发接口请求(耗时 1 秒)Promise resolve 后,await 后面的代码被扔进 “微任务队列”。
  4. 再次处理微任务队列:

    • 执行 console.log(核心工作:使用 ${data} 完成报表) → 核心工作收尾。

image.png

这里的关键是:await 后面的代码会被自动塞进微任务队列,相当于 “摸鱼结束后,优先处理收尾工作”,不用手动写 then 回调,摸鱼更优雅!

大家可以复制代码去运行一下,时间延迟照片体现不出来~~

四、摸鱼避坑:这些误区千万别踩

  1. 误区 1:setTimeout 延迟时间是 “准确时间”

错! setTimeout(() => {}, 1000) 不是 “1 秒后立即执行”,而是 “1 秒后把任务扔进宏任务队列”,得等同步任务和微任务全部完成后才会执行。如果前面的任务耗时 2 秒,那摸鱼就得等 2 秒后才开始。

  1. 误区 2:Promise 构造函数里的代码是异步的

错! new Promise((resolve) => { 同步代码 }) 里的代码是同步执行的,只有 thencatch 回调才是微任务(异步)。比如下面的代码,会先打印 同步代码,再打印 微任务

new Promise((resolve) => {
    console.log('同步代码');
    resolve();
})
.then(() => {
    console.log('微任务')
});

image.png 3. 误区 3:async 函数返回值是 “原始数据”

错! async 函数默认返回一个 Promise 对象,哪怕你写 async function fn() { return 1; },调用 fn() 得到的也是 Promise { 1 },需要用 await 或 then 才能拿到值。

五、总结:Event Loop 摸鱼口诀(记熟直接用)

同步任务先干完,微任务队列清干净;

渲染页面歇一歇,宏任务来轮着干;

await 后藏微任务,Promise 构造是同步;

Event Loop 掌节奏,摸鱼工作两不误!

结语

其实 JS 单线程的 “摸鱼哲学”,本质是 “优先级管理”—— 核心工作优先做,耗时任务排队做,既不耽误事,又不浪费时间。掌握了 Event Loop,你不仅能看懂 JS 异步代码的执行顺序,还能写出更高效的代码,就像打工人掌握了摸鱼技巧,工作效率翻倍,摸鱼也不心慌!

Vue 3 Keep-Alive 深度实践:从原理到最佳实践

作者 cindershade
2025年12月6日 17:47

Vue 3 Keep-Alive 深度实践:从原理到最佳实践

前言

初入职场,我被安排用 Vue3 制作公司官网,有 5-6 个静态页面。开发完成后,领导在测试时提出一个问题:“为什么页面滑动后再切换到其它页面,返回时没有回到顶部?”调试后发现,是因为使用了 <keep-alive> 组件缓存页面导致的。这引发了我对 Vue 3 Keep-Alive 的浓厚兴趣。Keep-Alive 能帮助我们在页面间切换时保留组件的状态,使用户体验更加流畅。特别是在带有筛选和滚动列表的页面中,使用 Keep-Alive 可以在返回时保留用户之前的筛选条件和滚动位置,无需重新加载或初始化。

在本文中,我将结合实例,从基础到深入地解析 Vue 3 中的 Keep-Alive 组件原理、常见问题及最佳实践,帮助大家全面掌握这一功能。


一、了解 Keep-Alive:什么是组件缓存?

1.1 Keep-Alive 的本质

<keep-alive> 是 Vue 的内置组件,用于缓存组件实例,避免在切换时重复创建和销毁组件实例。换言之,当组件被包裹在 <keep-alive> 中离开视图时,它不会被销毁,而是进入缓存;再次访问时,该组件实例会被重新激活,状态依然保留。

示例场景:用户从列表页进入详情页后再返回列表页。

没有 Keep-Alive 的情况

  • 用户操作:首页 → 探索页 → 文章详情 → 探索页

  • 组件生命周期:

    • 首页:创建 → 挂载 → 销毁
    • 探索页:创建 → 挂载 → 销毁 → 重新创建 → 重新挂载
    • 文章详情:创建 → 挂载 → 销毁
    • 探索页(再次):重新创建 → 重新挂载(状态丢失)

有 Keep-Alive 的情况

  • 用户操作:首页 → 探索页 → 文章详情 → 探索页

  • 组件生命周期:

    • 首页:创建 → 挂载 → 停用(缓存)
    • 探索页:创建 → 挂载 → 停用(缓存)
    • 文章详情:创建 → 挂载 → 销毁
    • 探索页(再次):激活(从缓存恢复,状态保持)

使用 <keep-alive> 包裹的组件,在离开时不会销毁,而是进入「停用(deactivated)」状态;再次访问时触发「激活(activated)」状态,原先所有的响应式数据都仍然保留。这意味着,探索页中的筛选条件和滚动位置都还能保留在页面返回时显示,提高了用户体验。

1.2 Keep-Alive 的工作原理

Keep-Alive 通过以下机制来实现组件缓存:

  • 缓存机制:当组件从视图中被移除时,如果包裹在 <keep-alive> 中,组件实例不会被销毁,而是存放在内存中。下次访问该组件时,直接复用之前缓存的实例。
  • 生命周期钩子:被缓存组件在进入和离开时,会触发两个特殊的钩子 —— onActivated / onDeactivatedactivated / deactivated。可以在这些钩子中执行恢复或清理操作,例如刷新数据或保存状态。
  • 组件匹配<keep-alive> 默认会缓存所有包裹其中的组件实例。但如果需要精确控制,就会用到 includeexclude 属性,匹配组件的 name 选项来决定是否缓存。注意,这里的匹配依赖于组件的 name 属性,与路由配置无关。

1.3 核心属性

  • include:字符串、正则或数组,只有 name 匹配的组件才会被缓存。
  • exclude:字符串、正则或数组,name 匹配的组件将不会被缓存。
  • max:数字,指定最多缓存多少个组件实例,超过限制时会删除最近最少使用的实例。

注意:include/exclude 匹配的是组件的 name 选项。在 Vue 3.2.34 及以后,如果使用了 <script setup>,组件会自动根据文件名推断出 name,无需手动声明。


二、使用 Keep-Alive:基础到进阶

2.1 基础使用

最简单的使用方式是将动态组件放在 <keep-alive> 里面:

<template>
  <keep-alive>
    <component :is="currentComponent" />
  </keep-alive>
</template>

这样每次切换 currentComponent 时,之前的组件实例会被缓存,状态不会丢失。

2.2 在 Vue Router 中使用

在 Vue Router 配置中,为了让路由页面支持缓存,需要将 <keep-alive> 放在 <router-view> 的插槽中:

<template>
  <router-view v-slot="{ Component }">
    <keep-alive>
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

这样 <keep-alive> 缓存的是路由对应的组件,而非 <router-view> 自身。不要包裹整个 <router-view>,而是通过插槽嵌套其渲染的组件。

2.3 使用 include 精确控制

如果只想缓存特定组件,可利用 include 属性:

<template>
  <router-view v-slot="{ Component }">
    <keep-alive include="Home,Explore">
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

include 中的名称必须与组件的 name 完全一致,否则不起作用。

2.4 滑动位置缓存示例

以“探索”列表页为例:用户在该页设置筛选条件并滚动列表后,跳转到文章详情页,再返回“探索”页。如果没有使用 Keep-Alive,列表页组件会被重新创建,筛选条件和滚动位置会重置。

使用 <keep-alive> 缓存“探索”页后,返回时组件从缓存中激活,之前的 ref 值和 DOM 滚动位置依然保留。这保证了用户回到列表页时,能够看到原先浏览到的内容和筛选状态。

可以在组件中配合路由导航守卫保存和恢复滚动条位置:

  • onBeforeRouteLeave 钩子中记录 scrollTop
  • onActivated 钩子中恢复滚动条位置。

三、使用中的问题:Name 匹配的陷阱

3.1 问题场景

我们经常希望缓存某些页面状态,同时让某些页面不被缓存,例如:

  • “探索”列表页:需要缓存。
  • 登录/注册页:不需要缓存。
  • 文章详情页:通常不缓存。

3.2 第一次尝试:手动定义 Name

<script setup>
defineOptions({ name: 'Explore' })
</script>

然后在主组件中使用 include 指定名称:

<router-view v-slot="{ Component }">
  <keep-alive include="Home,Explore,UserCenter">
    <component :is="Component" />
  </keep-alive>
</router-view>

理论上只缓存 HomeExploreUserCenter

3.3 问题出现:为什么 Include 不生效?

  • 组件名称不匹配:include/exclude 匹配的是组件自身的 name 属性,而非路由配置中的 name
  • 自动生成的 Name:Vue 3.2.34+ 使用 <script setup> 会自动根据文件路径生成组件名,手动写的 name 可能与自动生成冲突。
  • 路由包装机制:Vue Router 渲染组件时可能进行包装,导致组件实际名称与原始组件不同。

依赖组件名匹配容易出错,需要更灵活的方法。


四、解决方式:深入理解底层逻辑

4.1 理解组件 Name 的生成机制

Vue 3.2.34+ 使用 <script setup> 的单文件组件会自动根据文件名推断组件的 name

  • src/pages/Explore/index.vue → 组件名 Explore
  • src/pages/User/Profile.vue → 组件名 Profile

无需手动定义 name,避免与自动推断冲突。

4.2 问题根源分析

  • 自动 Name 与路由名不一致。
  • Router 的组件包装可能导致 <keep-alive> 无法捕获组件原始 name。

4.3 解决方案:路由 Meta 控制缓存

  1. 移除手动定义的 Name
<script setup lang="js">
// Vue 会自动根据路径生成 name
</script>
  1. 在路由配置中设置 Meta
const routes = [
  {
    path: '/explore',
    name: 'Explore',
    component: () => import('@/pages/Explore/index.vue'),
    meta: { title: '探索', keepAlive: true }
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/pages/Auth/index.vue'),
    meta: { title: '登录', keepAlive: false }
  },
  {
    path: '/article/:id',
    name: 'ArticleDetail',
    component: () => import('@/pages/ArticleDetail/index.vue'),
    meta: { title: '文章详情', keepAlive: false }
  }
]
  1. 在 App.vue 中根据 Meta 控制
<script setup lang="js">
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const shouldCache = computed(() => route.meta?.keepAlive !== false)
</script>

<template>
  <router-view v-slot="{ Component }">
    <keep-alive v-if="shouldCache">
      <component :is="Component" />
    </keep-alive>
    <component v-else :is="Component" />
  </router-view>
</template>

默认缓存所有页面,只有 meta.keepAlive 明确为 false 时才不缓存。

4.4 方案优势

  • 灵活性强:缓存策略直接写在路由配置中。
  • 可维护性好:缓存策略集中管理。
  • 避免匹配失败:不依赖手动 name。
  • 默认友好:设置默认缓存,仅对不需要缓存页面标记即可。

五、最佳实践总结

5.1 缓存策略建议

页面类型 是否缓存 缓存原因
首页(静态) ❌ 不缓存 内容简单,一般无需缓存
列表/浏览页 ✅ 缓存 保持筛选条件、分页状态、滚动位置等
详情页 ❌ 不缓存 每次展示不同内容,应重新加载
表单页 ❌ 不缓存 避免表单数据残留
登录/注册页 ❌ 不缓存 用户身份相关,每次重新初始化
个人中心/控制台 ✅ 缓存 保留子页面状态,提升体验

5.2 代码规范

  • 不要手动定义 Name,在 Vue 3.2.34+ 中自动推断。
<script setup>
// Vue 会自动推断 name
</script>
  • 使用路由 Meta 控制缓存。
  • 统一在 App.vue 中处理缓存逻辑。

5.3 生命周期钩子的使用

<script setup>
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  console.log('组件被激活(从缓存恢复)')
})

onDeactivated(() => {
  console.log('组件被停用(进入缓存)')
})
</script>

5.4 性能考虑

  • 内存占用:不要无限制缓存过多页面,可使用 max 限制。
  • 数据刷新:在 onActivated 中进行必要更新。
  • 缓存清理:登出或不常用页面可手动清除缓存。
  • 动画与过渡:确保 <keep-alive><transition> 嵌套顺序正确。

六、总结

6.1 关键要点

  • <keep-alive> 缓存组件实例,通过停用保留状态。
  • include/exclude 功能依赖组件 name
  • 推荐使用路由 meta.keepAlive 控制缓存。
  • 缓存组件支持 onActivated / onDeactivated 钩子。
  • 默认缓存大部分页面,只对需刷新页面明确禁用。

6.2 技术演进

手动定义 Name → 自动 Name → Meta 控制

  • 冗长易错 → 简化代码 → 灵活可靠

6.3 最终方案

  • 利用自动生成的组件名取消手动命名。
  • 通过路由 meta.keepAlive 控制缓存。
  • 在根组件统一处理缓存逻辑。
  • 默认缓存,明确例外。

这样既保持了代码简洁,又实现了灵活可控的缓存策略,确保用户在页面切换时能获得更好的体验。


参考资料

  • Vue 3 Keep-Alive 官方文档
  • Vue Router 官方文档
  • Vue 3.2.34 更新日志

【基础】Unity着色器网格和计算对象介绍

作者 SmalBox
2025年12月6日 17:47

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

Mesh网格定义与核心概念

顶点(Vertex)的本质与特性

顶点是构成3D模型的基本几何单元,每个顶点在三维空间中具有明确的坐标位置(x,y,z)。在Unity中,顶点不仅包含位置信息,还承载着模型渲染所需的多维数据:

  • 法线(Normal):垂直于表面的单位向量,决定光照计算的反射方向。平滑着色时,法线通过相邻面计算;硬边着色则直接使用面法线。
  • UV坐标:二维纹理映射坐标,将2D纹理精准贴合到3D表面。UV值范围通常为0-1,超出部分通过纹理环绕模式处理。
  • 顶点颜色:支持RGBA通道的颜色数据,常用于实现渐变纹理或动态光照效果。

程序化顶点生成

通过Shader Graph的Position节点和数学运算,可动态生成顶点位置。例如,创建波浪效果:

// 伪代码示例:顶点位置偏移

float4 position = TransformPosition(float4(input.position.x, sin(input.position.x * 10) * 0.1, input.position.z, 1));

此代码通过正弦函数沿X轴生成周期性波动,实现水面扭曲效果。

面(Face)的构成与渲染优化

三角形面片的优势

三角形作为3D建模的最小单位,具有以下核心特性:

  • 平面性:三个顶点必然共面,简化碰撞检测和光照计算。
  • 固定朝向:通过顶点顺序(顺时针/逆时针)定义正面/背面,支持背面剔除提升渲染效率。
  • 计算高效:三角形仅需3个顶点和3条边,比多边形更适合GPU并行处理。

多边形的实现原理

虽然多边形面片(如四边形)在建模中更直观,但渲染时会被分解为三角形。例如,Unity的网格渲染器会自动将四边形拆分为两个三角形,确保硬件兼容性。

URP Shader Graph中的网格数据处理

顶点属性节点详解

在Shader Graph中,通过以下节点访问顶点数据:

  • Position:获取模型空间或世界空间坐标。
  • Normal:读取法线向量,用于光照计算。
  • UV:访问纹理坐标,支持多通道UV(如UV1、UV2)。
  • Color:读取顶点颜色,支持与纹理混合。

示例:动态法线修改

创建凹凸效果时,可通过修改法线改变光照表现:

// 伪代码示例:法线扰动

float3 normal = normalize(input.normal + float3(0, sin(input.position.x * 10) * 0.1, 0));

此代码沿Y轴添加正弦波动,模拟表面起伏。

纹理映射与UV坐标实践

UV坐标的工作原理

UV坐标通过将3D表面展开为2D平面实现纹理映射。例如,立方体需6组UV坐标,而球体通常使用球形投影或立方体映射。

多通道UV应用

复杂模型可能使用多组UV坐标:

  • UV1:主纹理通道。
  • UV2:辅助纹理(如法线贴图)。
  • UV3:顶点动画或动态遮罩。

在Shader Graph中,通过UV节点选择通道,结合Sample Texture 2D实现多纹理混合。

顶点颜色与动态效果

顶点颜色的应用场景

  • 渐变纹理:通过顶点颜色控制材质过渡。
  • 动态光照:结合顶点颜色实现局部光照变化。
  • 调试工具:可视化法线或UV坐标。

示例:顶点颜色驱动透明度

创建渐隐效果时,可通过顶点颜色控制透明度:

// 伪代码示例:颜色驱动透明度

float4 color = input.color * float4(1, 1, 1, smoothstep(0.5, 0.8, input.color.a));

此代码根据顶点Alpha值平滑调整透明度,实现边缘渐隐。

URP Shader Graph的优化技巧

性能优化策略

  • 减少动态计算:将顶点属性计算移至顶点着色器。
  • 合并属性:通过Attributes节点打包数据,减少采样次数。
  • 使用LOD:根据距离简化网格复杂度。

移动端适配

  • 简化着色器:避免复杂数学运算。
  • 压缩纹理:使用ASTC或ETC2格式。
  • 动态批处理:启用URP的自动批处理功能。

进阶应用:程序化网格生成

动态网格创建

通过Create Mesh节点和Set Mesh节点,可在运行时生成网格:

// 伪代码示例:生成平面网格

Mesh mesh = new Mesh(); 
mesh.vertices = new Vector3[] {
          Vector3.zero,
          Vector3.right,
          Vector3.up,
          Vector3.right + Vector3.up
          };
mesh.triangles = new int[] { 0, 1, 2, 0, 2, 3 };

此代码创建了一个包含两个三角形的平面。

实例化渲染

使用Instancing节点和Set Mesh节点,可高效渲染大量相同网格:

// 伪代码示例:实例化渲染` 

MaterialPropertyBlock props = new MaterialPropertyBlock();
props.SetVector("_Color", Color.red);
Renderer renderer = GetComponent<Renderer>();
renderer.SetPropertyBlock(props); 
renderer.SetMaterial(material, 0);

此代码为所有实例设置统一颜色,减少Draw Calls。

常见问题与解决方案

法线错误

  • 现象:模型出现光照异常。
  • 解决:检查法线方向,使用Normalize节点修正。

UV拉伸

  • 现象:纹理在模型表面扭曲。
  • 解决:优化UV展开,或使用Tiling And Offset节点调整。

性能瓶颈

  • 现象:帧率下降。
  • 解决:简化着色器,减少动态计算,启用批处理。

总结与最佳实践

URP Shader Graph通过可视化节点系统,大幅降低了着色器开发门槛。掌握网格数据处理的核心要点:

  • 顶点属性:灵活运用位置、法线、UV和颜色。
  • 三角形优势:利用其平面性和计算效率优化渲染。
  • 程序化生成:通过动态创建实现复杂效果。
  • 性能优化:减少计算,合并数据,适配移动端。

结合URP的渲染管线特性和Shader Graph的节点化设计,开发者可快速实现从简单材质到复杂视觉效果的全方位创作。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Vue3 + Keep-Alive:实习中遇到的 window 滚动问题与实践

作者 cindershade
2025年12月6日 17:47

Vue3 + Keep-Alive:实习中遇到的 window 滚动问题与实践

前景:实习项目中的困扰

在实习期间,我参与了公司项目的前端开发,页面主要包括首页(Home)和探索页(Explore)。在项目中,这两个页面都使用 window 作为滚动容器。测试时发现一个问题:

首页和探索页都使用 window 作为滚动容器
↓
它们共享同一个 window.scrollY(全局变量)
↓
用户在探索页滚动到 500px
↓
window.scrollY = 500(全局状态)
↓
切换到首页(首页组件被缓存,状态保留)
↓
但 window.scrollY 仍然是 500(全局共享)
↓
首页显示时,看起来也在 500px 的位置 ❌

这个问题的原因在于:

  • <keep-alive> 只缓存组件实例和 DOM,不管理滚动状态。
  • window.scrollY 是全局浏览器状态,不会随组件缓存自动恢复。
  • 结果就是组件被缓存后,滚动位置被错误共享,导致用户体验不佳。

我的思路:滚动位置管理工具

为了在自己的项目中解决类似问题,我考虑了手动管理滚动位置的方案:

/**
 * 滚动位置管理工具
 * 用于在 keep-alive 缓存页面时,为每个路由独立保存和恢复滚动位置
 */
const scrollPositions = new Map()

export function saveScrollPosition(routePath) {
  const y = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop
  scrollPositions.set(routePath, y)
}

export function restoreScrollPosition(routePath, defaultY = 0) {
  const saved = scrollPositions.get(routePath) ?? defaultY
  requestAnimationFrame(() => {
    window.scrollTo(0, saved)
    document.documentElement.scrollTop = saved
    document.body.scrollTop = saved
  })
}

在组件中配合 Vue 生命周期钩子使用:

import { onActivated, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { saveScrollPosition, restoreScrollPosition } from './scrollManager'

export default {
  setup() {
    const route = useRoute()

    // 组件激活时恢复滚动
    onActivated(() => {
      restoreScrollPosition(route.path, 0)
    })

    // 组件离开前保存滚动
    onBeforeUnmount(() => {
      saveScrollPosition(route.path)
    })
  }
}

公司项目的简化处理

在公司项目中,由于页面结构简单,不需要为每个路由保存独立滚动位置,因此我采用了统一重置滚动到顶部的方式:

// 路由切换后重置滚动位置
router.afterEach((to, from) => {
  if (to.path !== from.path) {
    setTimeout(() => {
      window.scrollTo(0, 0)
      document.documentElement.scrollTop = 0
      document.body.scrollTop = 0
    }, 0)
  }
})

这样可以保证:

  • 切换页面时始终从顶部开始。
  • 简单易维护,符合公司项目需求。
  • 避免了 Keep-Alive 缓存滚动穿透的问题。

总结

  1. <keep-alive> 缓存组件实例,但不管理 window 滚动状态,导致全局滚动共享问题。
  2. 自己项目中,可以通过滚动位置管理工具为每个路由独立保存和恢复滚动。
  3. 公司项目中,为简化处理,只需在路由切换后重置滚动到顶部即可。
  4. 总体经验:滚动管理要根据项目复杂度和需求选择方案,既保证用户体验,又保证可维护性。

ElementUI组件出现大量重复样式

作者 魂祈梦
2025年12月6日 17:25

情况

image.png

点进去,是一个style标签,里面有六万多行样式 进去使用正则查找,发现有11处一模一样的样式

^.el-textarea__inner \{

image.png

过程

经过简单排查,发现问题在于element-variables.scss这个文件中,我框选的这一条代码。
image.png

但是把它注释掉,样式就没了,因为项目引入样式的方式是scss。
于是乎去查看官方文档,确实没啥问题。

image.png

于是我起了一个新的vue2+element-ui+scss项目,用同样的方式引入。
结果发现,是一样的,也有重复的样式说明这是Element的问题。

image.png

原因

element官方的scss文件中重复定义了样式 比如我引入以下样式 image.png 可以发现有两个重复样式

image.png

解决方法

Element早已停更,假如你不是迫不得已,应该停止使用这个UI库。
以下的所有方法都并不是一种优雅的解决方式,但是他们可以解决当前的问题。
解决方法来自github,但是位于以下文章的引用让我发现这个问题。
[vue.js - ElementUI重复引入样式问题 - 学习前端历程 - SegmentFault 思否] (segmentfault.com/a/119000002…)
令人遗憾的是,这篇文章里的方法根本不起作用。

postcss的cssnano(推荐)

github.com/ElemeFE/ele…
你只需要创建postcss.config.js文件,添加cssnano: {}即可去掉重复的样式。

// postcss.config.js
module.exports = {
  plugins: {
    autoprefixer: {},
    cssnano: {}
  },
};

编译出css避开问题(不推荐)

假如我要新加一个scss变量呢?
不推荐这种削足适履的方式

我没有尝试这种方式,但这种方式在原理上是可行的,因为他完全避开了问题,当使用css文件时,就不会编译,自然也就不会引发重复样式的问题。

github.com/ElemeFE/ele…
github.com/ElemeFE/ele…

fast-sass-loader(不推荐)

更换依赖为项目引入了额外的复杂性,所以这并不是推荐的方法

核心在于chainWebpack的配置,代码来自如下链接。
github.com/yibn2008/fa…
忽略下面的注释,这是我之前做的尝试。

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  chainWebpack: (config) => {
    config.module.rules.delete('scss')

    let scssRule = config.module.rule('scss')
      .test(/\.scss$/);

    [
      { name: 'vue-style-loader' },
      { name: 'css-loader' },
      { name: 'postcss-loader' },
      { name: 'fast-sass-loader' }
    ].forEach((load) => {
      scssRule
        .use(load.name)
        .loader(load.loader || load.name)
        .options(load.options || {})
    })
  },
  // configureWebpack: {
  //   module: {
  //     rules: [
  //       {
  //         test: /\.(scss|sass)$/,
  //         use: [
  //           'css-loader',
  //           {
  //             loader: 'fast-sass-loader',
  //             options: {
  //               // includePaths: [... ]
  //             }
  //           }
  //         ]
  //       },
  //       // other loaders ...
  //     ]
  //   }
  // }
})

fast-sass-loader解决了这个问题,但是官方并没有给出vue-cli中的合理使用方式。
我找了很久如何在vue中使用这个东西。
当我直接修改vue中的webpack配置,卸载了sass-loader,完全没有作用。
包括github issue中有部分人也尝试使用这个工具,他们的配置也失败了,说明这不是个例。
image.png

  • 不支持~@别名
Syntax Error: Error: import file cannot be resolved: "@import "~@/assets/styles/mixin.scss";"
  • 4年未更新,基本可以认为弃坑

image.png

image.png

  • 不支持source Map

image.png

总结

如果可以,我真不想用vue2和element。

全栈项目:宠物用品购物系统及后台管理

作者 温暖全栈
2025年12月6日 16:20

基于Vue3和Node.js的宠物用品购物系统设计与实现

一、项目描述

随着互联网技术的快速发展和宠物经济的持续升温,宠物用品电商平台已成为宠物主人购买宠物用品的主要渠道。设计并实现了一个基于Vue3和Node.js的全栈宠物用品购物系统,该系统采用前后端分离架构,包含用户购物系统和后台管理系统两个子系统。

系统前端采用Vue 3框架,结合TypeScript、Pinia状态管理、Vue Router路由管理和Element UI Plus组件库,实现了响应式的用户界面和流畅的交互体验。后端采用Node.js和Express框架,使用MongoDB作为数据库,通过JWT实现用户身份认证,构建了RESTful风格的API接口。系统实现了用户注册登录、商品浏览搜索、购物车管理、订单处理、社交互动、后台管理等核心功能。

1. 项目截图

2. 技术栈

前端

  • Vue 3 + TypeScript
  • Vue Router 4 (路由管理)
  • Pinia (状态管理)
  • Element UI Plus (UI组件库)
  • Axios (HTTP请求)

后端

  • Node.js + Express (服务器框架)
  • MongoDB + Mongoose (数据库)
  • JWT (身份验证)
  • Multer (文件上传)
  • Bcryptjs (密码加密)

二、项目启动

前置要求

  • Node.js >= 16
  • pnpm >= 8
  • MongoDB >= 5.0

1.安装依赖

# 安装根目录依赖
pnpm install

2. 启动 MongoDB

确保 MongoDB 服务已启动并运行在 localhost:27017

3. 导入测试数据

pnpm run import

这将自动导入:

  • ✅ 4个测试用户(1个管理员 + 3个普通用户)
  • ✅ 完整的商品分类体系
  • ✅ 10个示例商品
  • ✅ 用户地址数据
  • ✅ 订单数据
  • ✅ 社交帖子数据

4. 启动开发服务器

pnpm run dev

启动后访问:

三、项目总体设计

1. 系统架构设计

1.1 架构模式选择

本系统采用前后端分离的架构模式,具有以下优势:

1. 职责分离

  • 前端专注于用户界面和交互体验
  • 后端专注于业务逻辑和数据处理
  • 前后端可以独立开发、测试、部署

2. 技术独立

  • 前端可以选择最适合的框架和技术
  • 后端可以选择最适合的语言和框架
  • 技术栈升级互不影响

3. 团队协作

  • 前端团队和后端团队可以并行开发
  • 通过API接口约定进行协作
  • 提高开发效率

4. 可扩展性

  • 前端和后端可以独立扩展
  • 支持多端应用(Web、移动端、小程序)
  • 便于向微服务架构演进
1.2 系统架构图
┌─────────────────────────────────────────────────────────┐
│                      客户端层                             │
│  ┌──────────────┐         ┌──────────────┐              │
│  │  用户系统     │         │  管理系统     │              │
│  │  (Vue 3)     │         │  (Vue 3)     │              │
│  └──────────────┘         └──────────────┘              │
└─────────────────────────────────────────────────────────┘
                          │
                          │ HTTP/HTTPS
                          │ RESTful API
                          ▼
┌─────────────────────────────────────────────────────────┐
│                      服务端层                             │
│  ┌──────────────────────────────────────────────────┐   │
│  │              Express 应用服务器                    │   │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐       │   │
│  │  │ 路由层    │  │ 中间件层  │  │ 控制器层  │       │   │
│  │  └──────────┘  └──────────┘  └──────────┘       │   │
│  └──────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘
                          │
                          │ Mongoose ODM
                          ▼
┌─────────────────────────────────────────────────────────┐
│                      数据层                               │
│  ┌──────────────────────────────────────────────────┐   │
│  │              MongoDB 数据库                        │   │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐       │   │
│  │  │ 用户集合  │  │ 商品集合  │  │ 订单集合  │       │   │
│  │  └──────────┘  └──────────┘  └──────────┘       │   │
│  └──────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘
1.3技术架构

前端架构

用户系统 / 管理系统
├── Vue 3 (核心框架)
├── TypeScript (类型系统)
├── Pinia (状态管理)
├── Vue Router (路由管理)
├── Element UI Plus (UI组件库)
├── Axios (HTTP客户端)
└── Vite (构建工具)

后端架构

API服务器
├── Node.js (运行环境)
├── Express (Web框架)
├── MongoDB (数据库)
├── Mongoose (ODM)
├── JWT (身份认证)
├── Bcrypt (密码加密)
└── Multer (文件上传)

2. 系统功能模块设计

用户购物系统功能模块

用户购物系统
├── 用户管理模块
│   ├── 用户注册
│   ├── 用户登录
│   ├── 个人信息管理
│   └── 收货地址管理
├── 商品展示模块
│   ├── 首页展示
│   ├── 商品列表
│   ├── 商品详情
│   └── 商品搜索
├── 购物功能模块
│   ├── 购物车管理
│   ├── 订单创建
│   ├── 订单查询
│   └── 订单评价
└── 社交功能模块
    ├── 动态发布
    ├── 动态浏览
    ├── 点赞评论
    └── 用户关注

后台管理系统功能模块

后台管理系统
├── 系统概览模块
│   ├── 数据统计
│   ├── 销售图表
│   └── 订单统计
├── 商品管理模块
│   ├── 商品列表
│   ├── 商品编辑
│   ├── 分类管理
│   └── 库存管理
├── 订单管理模块
│   ├── 订单列表
│   ├── 订单详情
│   ├── 发货处理
│   └── 退款处理
├── 用户管理模块
│   ├── 用户列表
│   ├── 用户详情
│   └── 用户状态管理
└── 数据统计模块
    ├── 销售统计
    ├── 商品排行
    └── 用户分析

3. 数据库设计

系统主要包含以下实体:

  1. 用户(User) :存储用户基本信息和统计数据
  2. 商品(Product) :存储商品信息、价格、库存等
  3. 订单(Order) :存储订单详情、支付信息、物流状态
  4. 动态(Post) :存储用户发布的社交动态
  5. 评论(Comment) :存储动态评论信息
  6. 地址(Address) :存储用户收货地址

实体关系:

  • 一个用户可以有多个订单(1:N)
  • 一个订单包含多个商品(N:M)
  • 一个用户可以发布多个动态(1:N)
  • 一个动态可以有多个评论(1:N)
  • 一个用户可以有多个收货地址(1:N)

四、用户认证模块设计

1. 功能流程图

用户注册流程:
用户填写信息 → 前端验证 → 发送注册请求 → 后端验证 → 密码加密 → 
存入数据库 → 生成 Token → 返回用户信息和 Token → 前端存储 Token → 
自动登录 → 跳转首页

用户登录流程:
用户输入账号密码 → 前端验证 → 发送登录请求 → 后端查询用户 → 
验证密码 → 生成 Token → 返回用户信息和 Token → 前端存储 Token → 
跳转首页

核心技术点:

  1. 密码加密(bcrypt)

bcrypt 是一种专门为密码存储设计的哈希算法,具有以下特点:

  • 加盐(Salt) :自动生成随机盐值,防止彩虹表攻击
  • 慢速哈希:计算速度慢,增加暴力破解难度
  • 自适应:可调整计算复杂度,应对硬件性能提升
// 密码加密实现
import bcrypt from 'bcryptjs';

// 注册时加密密码
const hashPassword = async (password) => {
  // 生成盐值,10 是成本因子(cost factor)
  // 成本因子越高,计算越慢,安全性越高
  const salt = await bcrypt.genSalt(10);
  
  // 使用盐值加密密码
  const hashedPassword = await bcrypt.hash(password, salt);
  
  return hashedPassword;
};

// 登录时验证密码
const verifyPassword = async (inputPassword, storedPassword) => {
  // bcrypt.compare 会自动提取盐值进行比较
  const isMatch = await bcrypt.compare(inputPassword, storedPassword);
  
  return isMatch;
};

为什么不使用 MD5 或 SHA?

  • MD5 和 SHA 是快速哈希算法,容易被暴力破解
  • 没有内置盐值机制,需要手动实现
  • bcrypt 专为密码设计,更安全

2. JWT 身份认证

JWT(JSON Web Token)是一种无状态的身份认证方案,特别适合前后端分离架构。

JWT 结构:

JWT = Header.Payload.Signature

Header(头部):
{
  "alg": "HS256",  // 签名算法
  "typ": "JWT"     // Token 类型
}

Payload(载荷):
{
  "userId": "64f8a1b2c3d4e5f6a7b8c9d0",
  "username": "testuser",
  "role": "user",
  "iat": 1704067200,  // 签发时间
  "exp": 1704672000   // 过期时间
}

Signature(签名):
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

JWT 工作流程:

1. 用户登录成功
   ↓
2. 服务器生成 JWT Token
   - 将用户信息编码到 Payload
   - 使用密钥签名,防止篡改
   ↓
3. 返回 Token 给客户端
   ↓
4. 客户端存储 Token(localStorage 或 sessionStorage)
   ↓
5. 后续请求携带 Token
   - 在 HTTP Header 中添加:Authorization: Bearer <token>6. 服务器验证 Token
   - 验证签名是否有效
   - 检查是否过期
   - 提取用户信息
   ↓
7. 处理业务逻辑

JWT 实现代码:

import jwt from 'jsonwebtoken';

// 密钥(生产环境应使用环境变量)
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

// 生成 Token
const generateToken = (user) => {
  const payload = {
    userId: user._id,
    username: user.username,
    role: user.role
  };
  
  // 签发 Token,设置 7 天过期
  const token = jwt.sign(payload, JWT_SECRET, {
    expiresIn: '7d'
  });
  
  return token;
};

// 验证 Token 中间件
const authenticateToken = async (req, res, next) => {
  try {
    // 从请求头获取 Token
    const authHeader = req.headers.authorization;
    const token = authHeader && authHeader.split(' ')[1]; // Bearer <token>
    
    if (!token) {
      return res.status(401).json({
        success: false,
        message: '访问令牌缺失'
      });
    }
    
    // 验证 Token
    const decoded = jwt.verify(token, JWT_SECRET);
    
    // 查询用户是否存在且状态正常
    const user = await User.findById(decoded.userId);
    if (!user || user.status !== 'active') {
      return res.status(401).json({
        success: false,
        message: '用户不存在或已被禁用'
      });
    }
    
    // 将用户信息附加到请求对象
    req.user = {
      userId: decoded.userId,
      username: decoded.username,
      role: decoded.role
    };
    
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        success: false,
        message: '令牌已过期,请重新登录'
      });
    }
    
    return res.status(401).json({
      success: false,
      message: '无效的访问令牌'
    });
  }
};

JWT vs Session 对比:

特性 JWT Session
存储位置 客户端 服务器
扩展性 好(无状态) 差(需要共享 Session)
性能 好(无需查询) 一般(需要查询 Session)
安全性 一般(Token 泄露风险) 好(服务器控制)
适用场景 前后端分离、微服务 传统 Web 应用

JWT 安全注意事项:

  1. 不要在 Payload 中存储敏感信息(密码、信用卡号等)
  2. 使用 HTTPS 传输,防止 Token 被窃取
  3. 设置合理的过期时间
  4. 实现 Token 刷新机制
  5. 考虑使用 Refresh Token 提升安全性

LLM 原理 - 输入预处理

作者 子洋
2025年12月6日 16:01

前言

最近在学习大模型的实现原理,为了更好地理解整个流程,我把学习中的关键概念和实现细节整理成了笔记。一方面帮助自己梳理思路、加深印象,另一方面也作为日后复习和查阅的基础。内容会按照模型的实际处理流程展开,希望能以清晰易懂的方式记录大模型的核心机制。

大模型原理概述

大模型最经典的架构图来自《Attention Is All You Need》,从这张图可以看到 Transformer 的基础结构是由“编码器”和“解码器”两部分组成的。虽然现在的大模型(像 GPT、LLaMA 这种)大多只保留了右侧的解码器结构,但它们整体的工作流程仍然遵循 Transformer 的思路。

整体原理可以简单理解成:

  1. 用户输入的文本会先经过 tokenizer 切成 token,再通过 embedding 转成向量。
  2. 这些向量会被送入 Transformer 的多层结构中处理。
    每一层都会做自注意力(Mulit-Head Attention,多头自注意力,让模型去关注上下文里的其他词)、前馈网络(Feed-Forward Network)、残差连接(Add)、层归一化(Norm)等操作,层数越多,模型对上下文的理解就越深。
  3. 最后一层会把处理后的向量经过线性变换,然后通过 softmax 得到一个概率分布。
    这个概率分布表示:“在所有 token 里,下一步最可能是哪个”。
  4. 模型会根据这个概率分布选出下一个 token(可能是选最高概率,也可能按概率采样)。
  5. 选出来的这个 token 会被加回当前输入,让模型继续推理下一个。
    模型就是这样不断循环:一步一步预测下一个 token,逐渐拼出完整的句子。
  6. 当所有 token 都生成完成后,再通过 tokenizer 解码,就得到了最终的可读文本。

整体来说,大模型的生成过程并不是一次性输出整段文本,而是每次只预测一个 token,然后把它接回去继续算,直到生成结束

输入预处理

输入预处理虽然在 Transformer 的架构图中只占了一小块,但如果把整个生成流程拆开来看,它其实是整个大模型的第一步,也是后面所有计算的基础。输入处理得好不好,直接影响到模型能不能正确理解你的话。

1. 训练阶段先要把词表准备好

在模型训练之前,会先收集海量的文本数据,然后训练一个 tokenizer。它的作用就是:

  • 把人类的自然语言拆成模型可接受的最小单位(叫 token)
  • 给每个 token 分配一个唯一的 token id
  • 形成一个固定的词表(vocab)

token 不一定是字,也不一定是词,更不是固定长度。现代 tokenizer 通常是“子词模式”,比如:

我 | 今天 | 吃 | 了 | 橙 | 子
happy → hap | py
unbelievable → un | believe | able

也就是说,词表中既可能有完整的词,也可能是词的一部分,这样可以极大减少词表大小,让模型处理能力更灵活。

2. 用户输入时,先把句子拆成 token

当用户输入一句话,比如:

我今天想吃火锅

模型不会直接拿这个句子处理,而是:

  • 按照训练好的 tokenizer 规则进行切分
  • 得到对应的 token 序列
  • 再查词表,把它们转成 token id

得到的结果类似这样的一个数组:

[123, 520, 11, 98, 8801]

也就是数字形式的 token 序列。

3. token id 需要转成向量(Embedding)

模型不能直接理解 token id,因为 token id 只是一个“编号”,不包含任何语义。所以下一步是通过 embedding table,把 token id 转换成对应的向量:

  • 每个 token 变成一个高维向量(例如 4096 维)
  • 所有 token 向量堆在一起就形成输入矩阵

向量的意义是:

让模型通过数字之间的关系来“理解”语言,比如相似的词向量更接近。

4. 生成位置 embedding 告诉模型位置顺序

Transformer 最大的特点是:

它的注意力机制没有顺序意识。

换句话说,如果没有额外的位置信息,它只知道有哪些 token,不知道谁在前、谁在后。

这会导致严重的问题,比如:

  • “我吃了橙子”
  • “橙子吃了我”

对模型来说,单看 token 本身完全一样,只是顺序不同,所以必须把位置告诉模型。

因此,模型会为每个 token 生成一个位置 embedding。

早期 Transformer 位置 embedding是正弦余弦序列,现代模型常用更先进的 RoPE(旋转位置编码)。但无论哪种方法,目的都是:

告诉模型“你现在看到的是第 1 个、第 2 个、第 3 个 token…”

5. token embedding 和 position embedding 合并

模型最终接收的是:

token 本身表达的含义(token embedding)
+ 
它在句子中的顺序(position embedding)

早期 Transformer 是直接做向量加法:

final_embedding = token_embedding + position_embedding

现代模型虽然底层机制更复杂(比如 RoPE 会作用到注意力的 Q、K 上),但整体来说:它们都是在让模型同时知道“词的语义”和“词的位置”。

这两个 embedding 合并之后,就是最终送入 Transformer Block 的输入。

6. 最终得到完整的输入矩阵

假设一句话拆成 10 个 token,每个 embedding 是 4096 维,那么模型的实际输入会是一个:

10 × 4096 的矩阵

这就是 Transformer 后面所有自注意力、多头机制和深层计算的起点。

总结一下

输入预处理的整个流程可以总结为:

把文本 → token → token id → token embedding → 加上位置 embedding → 得到最终的输入向量矩阵,送进 Transformer。

它解决了三件关键问题:

  1. 文本如何变成模型能算的数字
  2. 模型如何知道每个 token 的意思
  3. 模型如何知道 token 的顺序

当这三步都准备好了,Transformer 才真正进入“理解和生成”的阶段。

别让你那 5MB 的 JS 文件把用户吓跑:React 代码分割(Code Splitting)实战指南

2025年12月6日 15:46

前言:你的网页为什么像个吃撑了的胖子?

兄弟们,咱们先看一眼你们项目的 build 产物。 是不是有个 index.js 或者 main.js,体积高达 2MB、3MB 甚至更大?

这就好比你去餐厅吃饭,你只是想点一盘花生米(首屏登录页),结果服务员把后厨里所有的鱼翅燕窝鲍鱼(后台管理系统、富文本编辑器、Echarts 图表库)全部端上了桌,还把门堵住说:“不吃完不许走!”。

用户的 4G 信号在哭泣,手机 CPU 在发烫。 首屏加载时间(FCP)长达 5 秒,用户早就关掉页面去看抖音小姐姐了。

今天,我们要给你的 React 项目做个抽脂手术。我们要用到 Code Splitting(代码分割)Lazy Loading(懒加载),把那个巨大的 JS 文件切成无数个小块,只让用户加载他当前需要的东西


手术刀一:路由级别的“大卸八块”

绝大多数的 React 项目都是 SPA(单页应用)。 默认情况下,打包工具(Webpack/Vite)会把所有页面的代码打包进一个文件。 哪怕用户只访问首页,他也得下载“个人中心”、“设置”、“关于我们”的代码。

这是最大的浪费。

❌ 传统的梭哈写法(All in One):

import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// 💀 致命伤:静态引入。
// 只要 App.js 被加载,Dashboard 和 Settings 的代码也就跟着被下载了
import Home from './pages/Home';
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';

const App = () => (
  <BrowserRouter>
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/settings" element={<Settings />} />
    </Routes>
  </BrowserRouter>
);

✅ 懒加载写法(按需取用):

我们要用 React.lazy 配合 import() 动态引入,再加个 Suspense 来处理加载过程中的空窗期。

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import LoadingSpinner from './components/LoadingSpinner';

// ✨ 魔法在这里:动态引入
// 只有当路由匹配到 /dashboard 时,浏览器才会去下载 Dashboard.js
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

const App = () => (
  <BrowserRouter>
    {/* ⏳ Suspense 是必须的:在组件下载下来之前,先给用户看个转圈圈 */}
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  </BrowserRouter>
);

就改了这么几行代码,你的 main.js 体积可能瞬间减少 50% 以上。首屏速度直接起飞。

手术刀二:组件级别的“精细微雕”

切完路由就完事了吗? No No No。有些时候,同一个页面里也有巨大的胖子。

场景:你有一个“数据分析”页面,平时只展示列表。只有当用户点击“查看图表”按钮弹出一个 Modal 时,才需要渲染一个巨大的 ECharts 或者 Recharts 图表。 这玩意儿动不动就几百 KB。

如果用户根本不点那个按钮,这几百 KB 不就白下载了?

❌ 笨重写法:

// 💀 哪怕不渲染,import 进来了就会被打包
import HeavyChart from './components/HeavyChart'; 
import HeavyEditor from './components/HeavyEditor';

const AnalysisPage = () => {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <button onClick={() => setShowChart(true)}>看图</button>
      {/* 虽然条件渲染了,但代码早就下载好了 */}
      {showChart && <HeavyChart />}
    </div>
  );
};

✅ 极致懒人写法:


// ✨ 只有用到我的时候,才来喊我
const HeavyChart = lazy(() => import('./components/HeavyChart'));

const AnalysisPage = () => {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <button onClick={() => setShowChart(true)}>看图</button>
      
      {showChart && (
        <Suspense fallback={<div>图表加载中...</div>}>
          <HeavyChart />
        </Suspense>
      )}
    </div>
  );
};

注意:别切得太碎了(避坑指南)

听到切代码能优化性能,有些兄弟兴奋了,拿起刀就是一顿乱切。 把 ButtonIconText 全部懒加载。

千万别!

  1. HTTP 请求开销:每个 lazy 组件都会发起一个新的网络请求。如果你把一个 1KB 的按钮切出来,光是 HTTP 握手的时间都比下载它的时间长。
  2. 闪屏体验:如果页面全是 Suspense,用户一进来看到满屏的 Loading 转圈,体验比白屏还差。

切割原则

  • 按路由切:这是必须的。
  • 按“重型组件”切:富文本编辑器、图表库、3D 模型渲染、地图组件。
  • 按“交互后展示”切:弹窗(Modal)、侧边栏(Drawer)、折叠面板(Collapse)。

进阶技巧:预加载(Preload)—— 预判你的预判

懒加载有一个小缺点:用户点击的时候才开始下载,会有几百毫秒的延迟。 如果要在性能和体验之间求极致,我们可以玩预加载

比如:用户鼠标悬停在“查看图表”按钮上时,我们猜他大概率要点击了,这时候偷偷开始下载。


// 或者写个简单的函数
const prefetchChart = () => {
  const component = import('./components/HeavyChart');
};

<button 
  onMouseEnter={prefetchChart} // 鼠标放上去就开始下
  onClick={() => setShowChart(true)}
>
  看图
</button>

总结

现在的打包工具(Vite/Webpack)已经非常智能了,但它们不懂你的业务。它们不知道哪个页面是核心,哪个组件是冷门。

Code Splitting 就是把你对业务的理解告诉工具: “这个首页要最快速度出来!” “那个富文本编辑器,等用户真要写文章了再去加载!”

把你的应用从“一块大石头”变成“一堆小积木”,按需拿取。这才是现代前端工程化的精髓。

好了,我要去把那个引入了整个 lodash 却只用了一个 debounce 函数的屎山代码给优化了。


下期预告:你还在用 console.log 调试代码吗?你还在面对满屏的红字不知所措吗? 下一篇,我们要聊聊 “React 调试神技” 。带你深入 React DevTools,看看那些你从未点过的按钮,是如何让你像 X 光一样看穿组件的。

Rust入门系列(三):生命周期 - 编译器的"算命先生"

作者 土豆1250
2025年12月6日 14:51

大家好,我是土豆,欢迎关注我的公众号:土豆学前端

前情回顾:在前两篇文章中,我们学习了Rust的所有权与借用机制,以及Copy、Clone、Send、Sync等trait。今天,我们要探索Rust中最让初学者"闻风丧胆"的概念——生命周期(Lifetime)。

Why - 为什么需要生命周期?

场景重现:悬垂引用的噩梦

想象你在一家图书馆借书。你拿到一张借书卡(引用),兴高采烈地准备去书架找书。结果走到半路,图书管理员突然把那本书给烧了(数据被释放)。你拿着借书卡傻眼了——这不是空指针吗?

fn main() {
    let r;
    {
        let x = 5;
        r = &x;  // 编译器:停!x马上要死了,你不能引用它!
    }  // x的生命在此结束
    println!("r: {}", r);  // 💥 悬垂引用!
}

在C/C++中,这段代码会编译通过,然后在运行时给你一个"惊喜"。但Rust编译器会直接拒绝编译:

error[E0597]: `x` does not live long enough

这就是生命周期存在的意义:在编译期就确保所有引用都是有效的,彻底消除悬垂引用、野指针等内存安全问题。

借用检查器的困惑

考虑这个看似简单的函数:

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

编译器看到这段代码会陷入沉思:

  • 返回值是一个引用,但我不知道它来自x还是y
  • 如果来自x,那返回值的生命周期应该跟x一样
  • 如果来自y,那返回值的生命周期应该跟y一样
  • 但我不能在编译时确定会走哪个分支...

编译器:我太难了😭

这时就需要我们显式地告诉编译器生命周期关系。

What - 生命周期到底是什么?

生命周期的本质

生命周期不是什么玄学,它就是引用保持有效的作用域范围。可以把它想象成:

  • 生命周期标注('a'b等):像给引用贴上有效期标签
  • 借用检查器:像一个严格的质检员,确保没有过期引用被使用
fn main() {
    let string1 = String::from("long string");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
        // result的生命周期不能超过string2
    }  // string2在这里结束
    // println!("{}", result);  // 💥 string2已经不在了!
}

生命周期标注语法

&i32        // 普通引用
&'a i32     // 带生命周期标注的引用
&'a mut i32 // 带生命周期标注的可变引用

'a读作"tick a",就像给引用贴了个标签:"嘿,我的有效期是'a"。

How - 如何正确使用生命周期?

1. 函数中的生命周期标注

回到之前的longest函数:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

这个标注告诉编译器:

  • xy都在生命周期'a内有效
  • 返回值也在生命周期'a内有效
  • 实际上'axy生命周期的交集(较短的那个)

用大白话说就是:"返回值的有效期不会超过两个参数中最短的那个"。

fn main() {
    let string1 = String::from("long string is long");
    
    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result);
        // ✅ 在string2的作用域内使用result,完全OK
    }
}

2. 生命周期省略规则

好消息!大多数情况下不需要手动标注生命周期,编译器会自动推导。这得益于三条生命周期省略规则:

规则1: 每个引用参数都有自己的生命周期

// 你写的
fn first_word(s: &str) -> &str {

// 编译器理解的
fn first_word<'a>(s: &'a str) -> &str {

规则2: 如果只有一个输入生命周期参数,它被赋予所有输出生命周期

// 你写的
fn first_word(s: &str) -> &str {

// 编译器理解的
fn first_word<'a>(s: &'a str) -> &'a str {

规则3: 如果有多个输入生命周期参数,但其中一个是&self&mut self,那么self的生命周期被赋予所有输出生命周期

impl<'a> ImportantExcerpt<'a> {
    // 你写的
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention: {}", announcement);
        self.part
    }
    
    // 编译器理解的(self的生命周期赋给返回值)
    fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'a str {
        println!("Attention: {}", announcement);
        self.part
    }
}

3. 结构体中的生命周期

结构体中包含引用时,必须标注生命周期:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let excerpt = ImportantExcerpt {
        part: first_sentence,
    };
    // excerpt的生命周期不能超过novel
}

这个标注意味着:ImportantExcerpt的实例不能比它引用的part活得更久。

4. 多个生命周期参数

有时需要不同的生命周期参数:

fn announce<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
    println!("Announcement: {}", y);
    x  // 只返回x,所以返回值生命周期只跟'a关联
}

5. 静态生命周期 'static

'static是一个特殊的生命周期,表示"活到程序结束":

let s: &'static str = "I have a static lifetime.";
// 字符串字面量存储在程序的二进制文件中,永远有效

注意:不要滥用'static!看到生命周期错误就加'static是新手常犯的错误。

实战演练:常见模式

模式1: 返回引用

// ❌ 错误:返回局部变量的引用
fn dangle() -> &str {
    let s = String::from("hello");
    &s
}  // s在这里被释放,返回悬垂引用

// ✅ 正确:返回所有权
fn no_dangle() -> String {
    let s = String::from("hello");
    s
}

模式2: 结构体方法

struct Book<'a> {
    title: &'a str,
    author: &'a str,
}

impl<'a> Book<'a> {
    fn new(title: &'a str, author: &'a str) -> Self {
        Book { title, author }
    }
    
    fn get_title(&self) -> &str {
        // 省略了生命周期,编译器自动推导为&'a str
        self.title
    }
}

模式3: 生命周期边界

结合泛型使用:

use std::fmt::Display;

fn longest_with_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement: {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

避坑指南

坑1: 过度使用 'static

// ❌ 错误思路
fn bad_fix<'a>(x: &'a str) -> &'static str {
    x  // 💥 生命周期不匹配!
}

// ✅ 正确思路
fn good_fix<'a>(x: &'a str) -> &'a str {
    x
}

坑2: 混淆生命周期和作用域

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {}", r); //          |
}                         // ---------+

'b'a短,所以r不能引用x

坑3: 结构体的自引用

// ❌ 这个不能直接编译
struct SelfRef<'a> {
    value: String,
    pointer: &'a String,  // 想引用自己的value
}

自引用需要使用Pin等高级技巧,初学者建议避免。

生命周期的哲学思考

生命周期本质上是所有权系统的延伸:

  • 所有权:确保资源有且只有一个主人
  • 借用:允许临时访问资源
  • 生命周期:确保借用在资源有效期内

它们共同构成了Rust内存安全的铁三角。

小结

生命周期是Rust的"杀手锏",也是初学者的"拦路虎"。但记住:

  1. 生命周期是编译期概念,运行时没有性能开销
  2. 大多数情况不需要手动标注,感谢生命周期省略规则
  3. 编译器错误是你的朋友,它阻止你犯错
  4. 实践是最好的老师,多写多改就能掌握

当你习惯了生命周期,你会发现它就像一位严格但负责的老师——虽然严厉,但确实让你写出更安全的代码。

下一篇我们将探索Rust的错误处理机制——如何优雅地处理ResultOption。Stay tuned!


练习题:试着理解并修复以下代码的生命周期问题

fn main() {
    let string1 = String::from("abcd");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {}", result);
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

提示:问题出在哪里?如何调整代码结构来修复?

AI First + Mobile First:用大模型重构下一代应用开发范式

2025年12月6日 14:34

在技术演进的浪潮中,我们正站在一个关键拐点上:AI 不再只是“辅助工具”,而是成为应用的核心驱动力。与此同时,移动设备早已超越 PC,成为用户与数字世界交互的第一入口。如何将 AI FirstMobile First 的理念深度融合,打造真正智能、高效、普惠的新一代应用?本文将从实践出发,结合真实代码案例,探讨一条可落地的技术路径。


一、什么是 AI First?

“AI First” 并非口号,而是一种产品设计哲学:以大语言模型(LLM)为核心引擎,重构用户交互逻辑和系统架构

场景示例:点一杯奶茶

想象这样一个场景:

“豆包,帮我点杯少糖热奶茶,在美团、抖音、淘宝上比价,用上优惠券,选最便宜的那家下单。”

这背后涉及:

  • 多平台商品信息抓取
  • 价格与优惠策略计算
  • 用户偏好理解(少糖、热饮)
  • 自动化下单流程

传统方式需要分别调用各平台 API、维护复杂的业务规则。而在 AI Agent 架构下,LLM 作为“调度中枢”,通过自然语言理解用户意图,动态调用工具链(Tool Calling),实现端到端自动化。

这就是 AI Native 应用的雏形——用户只需表达“做什么”,系统自动完成“怎么做”。


二、让 LLM 理解你的数据库:Text-to-SQL 的实战突破

要让 AI 操作业务数据,关键一步是 打通自然语言与结构化数据的鸿沟。Text-to-SQL 正是这一桥梁。

实战:用 DeepSeek 生成 SQL 查询

我们以一个员工管理系统为例:

# 表结构
CREATE TABLE EMPLOYEES (
    id INTEGER
    name TEXT
    department TEXT
    salary INTEGER
)

当用户问:“工程部门员工的姓名和工资是多少?

我们将表结构(Schema)作为上下文注入 Prompt:

这是一个数据库的Schema:
CREATE TABLE EMPLOYEES (
    id INTEGER
    name TEXT
    department TEXT
    salary INTEGER
)
根据这个Schema,请输出一个SQL查询来回答以下问题。
只输出SQL查询语句本身……
问题:工程部门员工的姓名和工资是多少

LLM 返回:

SELECT name, salary FROM employees WHERE department = '工程';

执行后得到结果:

[('宁宁', 75000), ('悦悦', 80000), ('呆鱼', 80000)]

更惊人的是,它还能处理 增删改 操作:

  • “在销售部门增加一个新员工,姓名为张三,工资为45000”
    INSERT INTO employees (name, department, salary) VALUES ('张三', '销售', 45000);
  • “删除市场部门的黄仁勋”
    DELETE FROM employees WHERE name = '黄仁勋' AND department = '市场';

这意味着:非技术人员也能安全地操作数据库。后台管理不再局限于程序员,运营、产品、小编均可参与——这就是“数据库平权”。


三、Mobile First:不是适配,而是优先

“Mobile First” 常被误解为“先做移动端,再适配 PC”。但真正的 Mobile First 是:

  • 以触控、小屏、弱网、碎片化使用场景为设计起点
  • 利用移动端特性(摄像头、GPS、通知、生物识别)构建核心体验
  • PC 端仅作为补充(如报表查看、批量操作)

技术实践建议:

  • 使用 CSS @media 实现响应式布局,但默认样式按手机设计
  • 小程序/App 承载 80% 功能,PC Web 仅保留 20% 高效操作
  • 结合 PWA 实现“类原生”体验,降低安装门槛

在 AI 赋能下,移动端还可集成语音输入、图像识别(如拍菜单点单),进一步降低交互成本。


四、生态支撑:ModelScope 与开源模型

阿里云的 ModelScope(魔搭) 为开发者提供了强大基础设施:

  • 大模型市场:一键部署 Qwen、DeepSeek 等开源模型
  • 数据集与微调工具:针对垂直领域(如电商、医疗)定制 LLM
  • Notebook 环境:快速实验 Text-to-SQL、Agent 等能力

例如,通过 ModelScope 微调一个“奶茶点单专用模型”,可显著提升对“少糖去冰加布丁”等口语化指令的理解准确率。


五、未来已来:AI + Mobile = 新操作系统

当 LLM 能理解用户意图、操作应用、调用服务、修改数据,传统的 App 界面可能不再是必需品

未来的交互可能是:

  • 语音/文字 → AI Agent → 自动完成任务
  • 用户只关心结果,不关心过程

而移动端,因其随身性、传感器丰富性、推送能力,将成为 AI Agent 的最佳载体。

我们正在从“人适应软件”走向“软件适应人”。


结语:开发者的新角色

在 AI First 时代,开发者不再是“功能实现者”,而是:

  • Prompt 工程师:设计高质量上下文与指令
  • Agent 架构师:编排工具链与安全边界
  • 体验设计师:在自然语言交互中创造流畅感

拥抱变化,从今天开始:
让你的下一个项目,先问一句——“AI 能怎么帮用户做得更好?”

前端er Go-Frame 的学习笔记:实现 to-do 功能(二),前端项目的开发,对接后端

作者 Lovely_Ruby
2025年12月6日 14:29

效果

在这里插入图片描述

在这里插入图片描述

相关

前端er Go-Frame 的学习笔记:实现 to-do 功能(一)


目标

上一章已经把后端实现了大概的功能,目前写一下前端,在构建的过程中可能要改改后端不合理的地方,比如:

  • 请求的资源应该是复数,所以要修改后端的路由 todo => todos
  • 先把前端的页面画出来,alova 的使用要学习一下
  • 乐观更新是什么?

前端

前端的话,我想看一下 Alova 的使用方法,然后想一下如何简化前端,只用写资源名字,即可做到增删改查, 技术栈的话,选择 React 19 + Antd 6 + Alova


搭建前端基础代码

在项目根目录下,用 vite 的脚手架来搭建项目

pnpm create vite

然后输入项目名,选择框架等等 在这里插入图片描述 搭建好基础之后,把 antdalova 安装一下

在这里插入图片描述 再安装一些 eslint, antfu-eslint,具体的使用配置可以看这个:github.com/antfu/eslin…

pnpm i -D eslint @antfu/eslint-config

然后写一下 eslint-config.js

import { antfu } from '@antfu/eslint-config'

// 第一个对象是基础配置(你没写东西)
// 第二个对象是覆盖 antfu 内置规则
export default antfu({

}, {
    rules: {
        'no-console': 'off',                           // 允许使用 console.log,不再警告
        'prefer-const': 'off',                         // 允许使用 let,不强制要求用 const
        'ts/ban-ts-comment': 'off',                    // 允许使用 @ts-ignore / @ts-nocheck 等注释
        'no-case-declarations': 'off',                 // 允许在 switch/case 里直接写 const/let
        'ts/no-use-before-define': 'off',              // 允许变量或函数在定义前被使用
        'ts/no-unused-expressions': 'off',             // 允许类似条件 && 表达式的写法
        'ts/no-empty-object-type': 'off',              // 允许定义空对象类型 type A = {}
        'ts/no-unsafe-function-type': 'off',           // 允许使用 any 函数签名 (...args: any[]) => any
        'ts/consistent-type-definitions': 'off',       // 不强制只能用 type 或 interface,随便写
        'style/indent': ['error', 4],                  // 强制使用 4 空格缩进
        'style/jsx-indent-props': ['error', 4],        // JSX 属性缩进也是 4 空格
        'prefer-promise-reject-errors': 'off',         // 允许 reject('xxx'),不强制必须 new Error()
        'eslint-comments/no-unlimited-disable': 'off', // 允许写 /* eslint-disable */ 禁用所有规则
    },
})

ok,依赖安装完之后,来看看这个空的项目,配置一下环境,以及 vite 的代理 proxy,和 tsconfig.json

  • 配置 tsconfig.json,用于改变 ide 代码编辑器的配置,比方说 @ 这种 alias
  • 配置 vite.config.json 中的 @ 用于打包时候的描述,以及 proxy 代理请求后端接口

配置 tsconfig.json

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


后端:统一接口为 v1 版本,路由分组

还记的写后端时遇到的疑问吗 goframe.org/quick/scaff…,为什么用 /api/v1 当借口的前缀

目前后端是没有接口路由分组的,所以来改一下后端,让前端以后通过 /api/v1/todo 来访问

在这里插入图片描述


前端:引入 alova,配置实例

前端的话,要改这几个文件

  • Alova 的实例的 baseUrl 改一下
  • .env 文件也改改

这些就算是前端的配置了

在这里插入图片描述

// api/alova.ts
import { createAlova } from 'alova'
import adapterFetch from 'alova/fetch'
import ReactHook from 'alova/react'

export const alovaInstance = createAlova({
    baseURL: '/api/v1', // 这里会自动拼接到每个接口的前面的
    requestAdapter: adapterFetch(),
    responded: response => response.json(),
    statesHook: ReactHook, // 如果写 react 的话,要引入这个,不然白屏
})

在这里插入图片描述

然后写一下页面

// 页面 todo.tsx
import { useRequest } from 'alova/client'
import { Button } from 'antd'
import { alovaInstance } from '@/api/alova'

export function PageTodo() {
    const { data } = useRequest(
        alovaInstance.Get('/todo'),
    )
    console.log('data:>>', data)
    return (
        <>
            <div>
                这是 todo 的页面
            </div>
            <Button>点我123312</Button>
        </>
    )
}

然后再浏览器中试一下,可以看到前端能请求到接口了!

这里其实并没有那么顺利 当我没有改后端的 /api/v1 分组之前,前端是请求不到后端的,我以为是后端没有配置跨域,(后来我也没去配置 go-frame 的跨域,因为不是这个问题) 是因为什么呢,就是 vite 的代理,我以为前端请求的 /api/v1/todo,就会走代理,之后代理会把 api/v1 给去掉,但是这个想法是错误的 在这里插入图片描述

在这里插入代码片


前端页面功能的实现

我准备把 tailwindcss 也安装一下,具体怎么安装可以看一下这个 https://tailwindcss.com/docs/installation/using-vite

然后让 AI 先写一个简单的页面

在这里插入图片描述

之后把其他功能实现一下

在这里插入图片描述

不过我看每次更新之后命中了 alova 的缓存了,导致刷新之后页面的数据没有更新(但是数据库已经更改了)

在这里插入图片描述

查了一下官方文档, 这里有个强制请求,我可以在 Alova 中配置一下这个接口

在这里插入图片描述

在这里插入图片描述


源仓库

github.com/Lovely-Ruby…

接下来准备把这两个项目放到 Docker

el-button源码解读4——props color和native-type

作者 Joie
2025年12月6日 14:21
  <component
    :is="tag"
    ref="_ref"
    v-bind="_props"
    :class="buttonKls"
    :style="buttonStyle"
    @click="handleClick"
  >

:style="buttonStyle":用于在设置了 color 时,自动计算并应用按钮各状态(默认、悬停、激活、禁用)的颜色样式,无需手动设置每个状态的颜色。

const buttonStyle = useButtonCustomStyle(props)
/**
 * 获取实例中props为name的值
 */
export const useProp = <T>(name: string): ComputedRef<T | undefined> => {
  const vm = getCurrentInstance()
  return computed(() => (vm?.proxy?.$props as any)?.[name])
}


/**
 * 获取表单的disabled状态
 * @param fallback 默认值
 * @returns 表单的disabled状态
 */
export const useFormDisabled = (fallback?: MaybeRef<boolean | undefined>) => {
  const disabled = useProp<boolean>('disabled')
  const form = inject(formContextKey, undefined)
  // 如果是表单内部的button那么是有值的,如果是外部的button那么是undefined
  console.log('form', form)
  /**
   * 组件自身的 disabled prop 
      ↓ (如果没有)
      传入的 fallback 参数
      ↓ (如果没有)
      表单的 disabled 状态
      ↓ (如果没有)
      默认值 false
   */
  return computed(
    () => disabled.value || unref(fallback) || form?.disabled || false
  )
}

/**
 * 获取按钮自定义样式
 * @param props 
 * @returns 
 */
export function useButtonCustomStyle(props: ButtonProps) {
  // 获取按钮的disabled状态
  const _disabled = useFormDisabled()
  // 获取按钮的命名空间
  const ns = useNamespace('button')

  // calculate hover & active color by custom color
  // only work when custom color
  return computed(() => {
    let styles: Record<string, string> = {}

    let buttonColor = props.color

    if (buttonColor) {
      // 检测buttonColor是否为CSS变量格式 ,并提取变量名 如 var(--el-color-primary)
      const match = (buttonColor as string).match(/var\((.*?)\)/)
      if (match) {
        buttonColor = window
          .getComputedStyle(window.document.documentElement)
          .getPropertyValue(match[1])
      }
      // TinyColor: Fast, small color manipulation and conversion for JavaScript
      const color = new TinyColor(buttonColor)
      console.log('color', color)
      // tint - 变亮(添加白色)变亮20%
      // darken - 变暗(添加黑色)变暗20%
      const activeBgColor = props.dark
        ? color.tint(20).toString()
        : darken(color, 20)

      if (props.plain) {
        styles = ns.cssVarBlock({
          'bg-color': props.dark
            ? darken(color, 90)
            : color.tint(90).toString(),
          'text-color': buttonColor,
          'border-color': props.dark
            ? darken(color, 50)
            : color.tint(50).toString(),
          'hover-text-color': `var(${ns.cssVarName('color-white')})`,
          'hover-bg-color': buttonColor,
          'hover-border-color': buttonColor,
          'active-bg-color': activeBgColor,
          'active-text-color': `var(${ns.cssVarName('color-white')})`,
          'active-border-color': activeBgColor,
        })

        if (_disabled.value) {
          styles[ns.cssVarBlockName('disabled-bg-color')] = props.dark
            ? darken(color, 90)
            : color.tint(90).toString()
          styles[ns.cssVarBlockName('disabled-text-color')] = props.dark
            ? darken(color, 50)
            : color.tint(50).toString()
          styles[ns.cssVarBlockName('disabled-border-color')] = props.dark
            ? darken(color, 80)
            : color.tint(80).toString()
        }
      } else {
        const hoverBgColor = props.dark
          ? darken(color, 30)
          : color.tint(30).toString()
        const textColor = color.isDark()
          ? `var(${ns.cssVarName('color-white')})`
          : `var(${ns.cssVarName('color-black')})`
        styles = ns.cssVarBlock({
          'bg-color': buttonColor,
          'text-color': textColor,
          'border-color': buttonColor,
          'hover-bg-color': hoverBgColor,
          'hover-text-color': textColor,
          'hover-border-color': hoverBgColor,
          'active-bg-color': activeBgColor,
          'active-border-color': activeBgColor,
        })

        if (_disabled.value) {
          const disabledButtonColor = props.dark
            ? darken(color, 50)
            : color.tint(50).toString()
          styles[ns.cssVarBlockName('disabled-bg-color')] = disabledButtonColor
          styles[ns.cssVarBlockName('disabled-text-color')] = props.dark
            ? 'rgba(255, 255, 255, 0.5)'
            : `var(${ns.cssVarName('color-white')})`
          styles[ns.cssVarBlockName('disabled-border-color')] =
            disabledButtonColor
        }
      }
    }

    return styles
  })
}


==========================================

props:native-type
export const buttonNativeTypes = ['button', 'submit', 'reset'] as const

props:
  /**
   * @description native button type
   */
  nativeType: {
    type: String,
    values: buttonNativeTypes,
    default: 'button',
  },  

❌
❌