阅读视图

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

双非同学校招笔记——离开字节入职小📕

本文纯属个人碎碎念,想到哪写到哪,不喜勿喷,也请不要上升到“公司员工文笔水平”这种维度。

一些概括

接触前端也两年了。还记得 23 年初跟着 B 站黑马的课程学 HTML、CSS,从 <div /><span /> 这些最基础的标签开始,一点点摸索,再到后来接触 JavaScript、Vue、React。

学习的过程中,也在网上加入了不少学习群,认识了许多同路人、前辈和朋友。

当时写下这篇 👉 《快手 ks 前端实习小记》
还是我在快手的 last day。转眼 9 个多月过去,现在我又来到小红书,只能说——人生无常,大肠包小肠。

image.png

关于秋招

其实秋招我没投很多公司,一方面是在字节实习,另一方面很多公司也确实不太想去。当然,说实话,也没有几家公司来约我面试😄。

cdf952d901750c159e868b437ba05b73.jpg

我在字节待过两个部门:直播和生服。
在直播待了半年,但因为 HC 的原因,连答辩流程都没推进。后来转去杭州的生服,两个月提前答辩通过了,但因为薪资和业务方向原因,最后还是没有过去,选择离职。

离职后,TT 直播连续给我发了 5 次面试邀请。虽然我说明了拒绝原因,但 HR 和老板还是希望我再面一下。于是 3 天推进了 3 轮。
记得 3 面老板问我最后的一个问题:

“为什么你都过了,还离职拒了?”

我如实说是薪资原因(但是没说业务原因)。老板说他下播后会跟另外两个面试官聊聊。
第二天我就收到了感谢信。

聊聊面试

关于面试,我真的想说一句:
双非同学完全没必要太自卑。

在字节、快手、小红书,我遇到的双非同事真的很多。能见到面试官这一关,学历的劣势基本就过去了;
真正会被卡掉的学历问题,大多都发生在简历筛选阶段。如果两个人技术能力差不多,HR 是会优先看学历的,这是现实;但只要你已经走到技术面了,那就只比实力。

4fa9faa2d8e2b8e2dcb48281c7832d98.jpg

b5c9478e559be882920d45b168f25f44.jpg

328f52e70e24a6e4da6297dacc6e328f.jpg

结尾

“红黄蓝”三家公司进进出出,一年多了。虽然我还是大四学生,但更多时候已经把自己当成一个刚踏入职场一年的年轻人。

以前在掘金看到那些感慨文,说不上不理解,但也没太有共鸣。那时候我以为掘金应该是讨论技术的地方。但现在逐渐理解了:工作占据了我们绝大多数时间,程序员要终身学习,而学习往往发生在为数不多的休息时间里,投入和产出常常不成正比,还伴随着所谓“中年危机”,于是,大家自然就会有更多感慨。

我也慢慢明白了(特别是在直播没转正、秋招又不算顺利的那段时间):

生活没有那么多必须烦恼的事。
把工作做好,不要过度焦虑未来。
把握当下——在自己的能力范围内做自己真正想做的事
不要因为浪费了时间、花了点钱而懊恼。

我们应该感受生活,而不是被生活推着走。

使用简单 JSON + 自定义 t 函数实现轻量多语言国际化(无需 next-intl)

在 Next.js 开发国际化(i18n)项目时,许多人会直接安装 next-intl、react-intl、i18next 这样的重量级库。 但对于一些结构简单、只需要基础文本翻译的网站来说,使用这些库反而会带来额外的复杂性。

如果你只想让项目支持:

✔ 标题多语言 ✔ 按钮多语言 ✔ 文案多语言 ✔ 扩展灵活、无需复杂配置

那么你完全可以自己写一个 超轻量 i18n 方案

今天我们来介绍一个非常简单、方便扩展、可在任何环境使用的多语言实现方式: 基于 JSON 语言包 + t() 翻译函数。


目录结构:简单、清晰、可扩展

首先在项目中新建 i18n 文件夹:

/i18n
  ├─ en.json
  ├─ de.json
  ├─ fr.json

内容例如:

en.json

{
  "hotTags": "Hot Tags",
  "hotArticles": "Hot Articles",
  "latestNews": "Latest News"
}

de.json

{
  "hotTags": "Beliebte Tags",
  "hotArticles": "Beliebte Artikel",
  "latestNews": "Neueste Nachrichten"
}

fr.json

{
  "hotTags": "Tags Populaires",
  "hotArticles": "Articles Populaires",
  "latestNews": "Dernières Nouvelles"
}

自定义 t() 翻译函数

这一小段代码就是整个多语言系统的核心:

import en from "@/i18n/en.json";
import de from "@/i18n/de.json";
import fr from "@/i18n/fr.json";

const locales = { en, de, fr };

export function t(locale: string, key: string): string {
  const dict =
    (locales as Record<string, Record<string, string>>)[locale] || locales["en"];

  return dict[key] ?? key;
}

工作原理

  1. 根据传入的 locale(en / de / fr)选择对应字典
  2. 如果找不到语言,自动使用英文作为 fallback
  3. 如果 key 不存在,原样返回 key(方便调试)

类型安全(解决 TS 报错)

上面代码使用了:

(locales as Record<string, Record<string, string>>)

让 TypeScript 明确知道字典是:

  • key: 语言代码
  • value: 文本映射表(键值对)

这样就不会出现:

「元素隐式具有 any 类型」


在页面中使用

示例: 你在首页 HomePage 中传入 locale:

<h2 className="text-2xl font-bold">
  {t(locale, "hotTags")}
</h2>

渲染效果将根据语言不同自动变化:

  • English → Hot Tags
  • Deutsch → Beliebte Tags
  • Français → Tags Populaires

无需额外库,无需复杂配置。


优点总结

特点 说明
极轻量 无需安装任何 i18n 库
纯 JSON 文件 非技术人员也能直接编辑
完全可控 不会被第三方库的 API 或复杂行为束缚
易扩展 想加语言?加个 JSON 文件即可
易维护 键值结构简单不容易出错

适合用在:

  • 企业官网
  • 展示性网站
  • CMS 文章网站(如 Strapi + Next.js)
  • SEO 友好的多语言站点
  • 需要快速上线 MVP 的项目

还能进一步增强!

要是你想扩展成更强的国际化系统,你还可以加入:

✔ 支持嵌套 key(如 home.title

✔ 支持变量插值(如 Hello {{name}}

✔ 自动检测浏览器语言

✔ 服务端自动注入 locale(Next.js App Router)

我可以进一步为你升级成 完整简易版 i18n 框架


最后

这种方式简单却非常实用,特别适合 已经有后端(例如 Strapi) + 前端 Next.js 结构的多语言站点。

nextjs 16 基础完全指南!(一) - 初步安装

注,以下内容过于基础,对于“老鸟”可以跳过文章前面,直接看最后最需要了解的知识点: React 的服务器组件和客户端组件。

前言

首先很多人疑惑为什么要学习 next.js 技术,我从客观角度帮你分析,你需不需要:

  • 一个是未来你想做 remote 也就是远程开发者,next.js 几乎是必备的,因为它最大的优点之一就是全栈,可以将前后端在一起做

这里我简单解释一下,全栈是指前端页面和后端接口服务都可以做,但实际上复杂应用一般还是会前后端分离,但鉴于国外很多小公司,面对的用户数量并不多,所以使用一个技术快速验证商业想法才是最主要的,这是 next.js 很受欢迎的原因。

  • 一个是对 seo 有强烈需求的,seo 你简单理解就是你的网站内容能被搜索引擎爬虫爬到,然后用户搜索的时候,你的页面有机会呈现在搜索引擎中,这是我们传统的单页面应用(SPA)做不到的,当然也有一些其他办法,但没有 next.js 直接。

但是任何技术有利有弊,国内为什么不流行 next.js 的最大原因就是很多 B端 后台系统,根本用不着 next.js ,首先是前后端分离在国内是常见的技术合作模式,其次 B端 后台系统不需要 seo, 只有首页和官网可能需要。

Next 和 React 的区别

简单说一下 reactnext.js 的区别。

本质:Next.js 是一个用于构建全栈 Web 应用程序的 React 框架。

  • React 是一个库,只负责应用的视图层。开发者需要自行选择和配置路由、数据获取等生产级功能。

  • Next.js 是一个框架,它在 React 的基础上,提供了一整套开箱即用的生产就绪功能,并遵循特定的约定和最佳实践。

这里咋们就不说太多概念的东西,例如 Next.js 提供了哪些常见的开箱即用的功能,我们直接上手(毕竟初学看到了这些概念也不知道具体是什么意思)。

学习前提

  • 只需要基本的 HTMLCSSJavaScript 知识,和基本会使用一点 React 就可以开始学习 Next.js
  • 需要你安装了 Node.js (版本建议 20 以上)
  • 安装命令:npx create-next-app@latest
    • 然后会让你输入项目名称,你可以直接回车默认,也可以输入你喜欢的名称。
    • 最后设置一些选项,等待下载完依赖包,就可以完成安装了

image.png

最后使用 npm run dev(你用 pnpmpnpm run dev) 就可以启动了:

image.png

对于访问的 URL(如 /),Next.js 在 app 目录下找到对应的 page.tsx 组件。

Next.js 项目结构解析

一个基础的 Next.js 项目包含以下核心部分:

  • 4个主要文件夹:.next, node_modules, public, src (或 app)

    • .next: 是构建输出目录,自动生成,无需手动修改。这是当你运行 npm run dev、npm run build 或 npm run start 时,Next.js 构建工具链生成的优化后的生产就绪代码,例如包含编译后的 JavaScript 和 CSS 文件。
    • node_modules: 是安装第三包包的目录
    • public: 静态资源目录, 例如图片和字体都可以放进来。
    • src/ 或 app/ - 应用源代码目录(你的主战场):这是你作为开发者最常工作和编写代码的地方。
  • 10个左右配置文件:包括 package.json、Next.js 和各类工具的配置文件。

    • package.json,主要包含以下两部分:

      • 依赖管理:列出 next, react, react-dom 等核心库。
      • 脚本命令:定义 dev (开发), build (构建), start (生产启动), lint (代码检查)具体使用的命令是什么。
    • image.png

    • next.config.js (Next.js 配置)

    • tsconfig.json (TypeScript 配置)

    • eslint.config.js (代码规范检查)

    • tailwind.config.js (Tailwind CSS 配置)

    • .gitignore:版本控制忽略文件。

    • README.md:项目说明文档。

    • next-env.d.ts:Next.js 的 TypeScript 类型声明。

    • favicon.ico浏览器标签页图标。

    • globals.css全局样式文件。

    • layout.tsx根布局:定义所有页面共享的 UI(如导航栏、页脚)。

    • page.tsx首页组件:对应路由 / (localhost:3000) 的页面内容。

服务器从根布局 layout.tsx 开始渲染(也就是 app 下的 layout 文件开始渲染 )。虽然是从 layout.tsx 渲染,但 layout.tsx 主要作用是共享 UI,也就是例如头部导航栏所有组件一般都会用到,所以 layout.tsx 里面的代码会共享给所有组件,但如果你只看当前路由渲染的内容,是在 page.tsx 文件中。

对于访问的 URL(如 /),Next.js 在 app 目录下找到对应的 page.tsx 组件。

image.png

React 服务器组件核心概

根本性变革

RSC(React Server Component 也就是 React 服务端组件) 是 React 团队引入的新架构,并已被 Next.js 广泛采用。

它彻底改变了 React 组件的构建方式,将组件明确划分为两种类型:服务器组件和客户端组件。

两种组件对比

特性 服务器组件 客户端组件
默认状态 Next.js 中所有组件默认都是服务器组件 需要明确使用 'use client' 指令
运行环境 在服务器端渲染 在浏览器端渲染
能力/优势 执行服务端任务: • 直接读取文件 • 从数据库获取数据 实现交互性: • 使用 React Hooks • 处理用户交互事件
限制 不能使用 React Hooks 或处理用户交互 不能执行服务端任务

关键:

  • 服务器组件用于等待异步操作(如数据获取)完成后渲染内容。其中不能使用 React 的 Hook,只有客户端组件才可以,并且需要在文件开头,写上 'use client' 字符串,表明是客户端组件!

ts学习D1---超基础语法导入

在跟练小兔鲜的时候发现常用ts语法来定义接口返回的数据类型,所以来补习一下ts语法

1.js是一种弱脚本文件,数据类型定义和使用不严格,所以便于后期维护,就诞生了ts

2.编写

2.1 ts会以给出的变量类型初始值进行变量类型定义(ts的类型约束功能+类型推断)

image.png

2.2 ts的类型注解

// 1.使用类型注解,在变量后加‘:’的符号,指定变量的类型
let str:string='abc'

// 2.或者先预声明一个变量,之后需要使用的时候再进行赋值
let num:number
num=123

2.3 ts的类型断言

let numArr=[1,2,3]
const result=numArr.find(item=>item>2)
// 结合unArr中的元素,这里的‘item=>item>2’有可能不成立,
// 会被判定为undefined类型,所以result会标红
result*5

image.png
如果你想筛选掉undefined的情况,就需要使用类型断言‘as number’,断言为一个数值类型

let numArr=[1,2,3]
const result=numArr.find(item=>item>2) as number
// 结合unArr中的元素,这里的‘item=>item>2’有可能不成立,
// 会被判定为undefined类型,所以result会标红
result*5

2.4 ts的基础类型和联合类型

// 1.ts的基础类型
let v1:string='abc'
let v2:number=123
let v3:boolean=true
let nu:null=null
let ud:undefined=undefined

// 2.ts的联合类型
// 2.1通过竖线的方式来定义联合类型(如v4:可以赋值string或者number)
let v4:string|number=123

// 2.2 希望这个数值只能被限定为以下几个数值之一
let v5:1|2|3=1  // 赋值成功
let v6:1|2|3=4  // error 4不是v6的成员

// 3.ts的数组
// 通过类型来约束数组中的类型
let v7:number[]=[1,2,3]
let v8:Array<string>=['a','b','c']

// 4.ts的元组
// 4.1元组类型允许表示一个已知元素数量和类型的数组,各元素的类型不必相同
let v9:[string,number]=['a',1]
// 4.2 取出元祖值
v9[0] //输出a

// 4.3 让一些元祖中的元素为可选的
let v10:[string,number,boolean?]=['a',1]   // ?表示可选


// 5.ts的枚举
//在js中就是将其编译为键、值的形式
enum MyEnum{   // 枚举类型
    a,
    b,
    c
}
// 5.1 访问枚举类型的两种方式
console.log(MyEnum.a)
console.log(MyEnum[0])  //等于MyEnum.a

2.5 ts的函数

2.5.1 void

// 6. void类型(当函数没有返回值的时候,返回值就是undefined,所以函数返回值类型为void)
function fn():void{  // void类型表示没有任何返回值
    console.log('void')
}

2.5.2 给函数的参数分配类型

function MyFn(a:number,b:string){
return a+b;
}

2.5.3 给函数的返回值分配类型(用于严格规范函数返回值类型)

2.5.3.1
function MyFn(a:number,b:string):number{   //报错,因为这里返回值类型不是number
return a+b; 
}
## ```
#### 2.5.3.2
```ts
function MyFn(a:number,b:string):number{
return 100;
}
2.5.3.3 将参数设置为可选(和ES6有点像)

注意;可选参数写在必选参数左侧(后面)

function MyFn(a:number,b?:string):number{
return 100;
}
2.5.3.4 剩余参数
function MyFn(a:number,b?:string, ...rest:number[]):number{
return 100;
}

const f=MyFn(20,'abc',1,2,3,4,5,6,7) //1,2,3,4,5,6,7就是剩余函数' ...rest:number[]'的实参

2.6 ts的接口

// 定义一个接口
interface Obj{
    name:string,
    age:number
}

// 使用接口的时候必须要有name和age
const obj:Obj = {
    name:'张三',
    age:18
}

2.7 ts的type(类型别名)---很常用

type MyuserName=string|number
let username:MyuserName="123"

2.8 ts的泛型---很常用

定义一个结构体,但是想适配多中数据类型,可以使用泛型

function myFn<T>(a:T,b:T):T[]{
    return [a,b];
}
// 使用数值类型进行处理
myFn<number>(1,2)
// 使用字符串类型进行处理
myFn<string>('1','2')
// 或者不手动定义类型,因为在ts中函数myFn()会自动进行类型推导
myFn(1,2)  //自动将类型判定为number

React + ECharts 动态折线图实现

上周项目接到一个新的需求,实现一个每3秒动态更新数据 的折线图,刚开始有点懵逼了,echarts如何能做成动态的,最终实现的效果如下:

  •  图表不断向左平滑滚动
  •  xAxis 始终保持 180 个点的滑动窗口
  •  第一次加载一批历史数据,之后每 3 秒追加新数据
  •  新旧数据无缝衔接

image.png

实现思路:

  1. 初始化阶段:创建 ECharts 实例
  2. 第一次数据加载(loadData):填满 180 点窗口
  3. 定时更新(smoothAdvance):每 3 秒滑动窗口
  4. 图表渲染(updateChart)
  5. 图表自适应(ResizeObserver)

遇到的问题:

1. 第一次初始化数据的最后一个点和后续第一个点之间断裂(不连线)

2. 第一次初始化数据的最后的连接点跟3s后更新数据的第一个点的连接线Tooltip显示undifiend

代码实现如下:

初始化阶段:创建 ECharts 实例

const initChart = () => {  
    if (!chartRef.current) return;  
    chartInstance.current = echarts.init(chartRef.current);  

    chartInstance.current.setOption({  
        title: {text: labelList.realTime_rx_or_tx_rate, left: "center"},  
        tooltip: {  
            trigger: "axis",  
            formatter: params => {  
               //避免Tooltip显示undifiend
                const validParams = params.filter(p => p.data != null && !Number.isNaN(p.data));  
                if (!validParams.length) return "";  
                let text = `${params[0].axisValue}<br/>`;  
                validParams.forEach(item => {  
                    text += `${item.marker}${item.seriesName}: ${formatSpeedForStats(item.data, "bit")}<br/>`;  
                });  
                return text;  
            }  
        },  
        grid: {left: 120, right: 20, bottom: 40, top: 80},  
        xAxis: {type: "category", boundaryGap: false, data: []},  
        yAxis: {  
            type: "value",  
            axisLabel: {  
                formatter: value => formatSpeedForStats(value, "bit")  
            },  
            splitLine: {lineStyle: {type: "dashed"}}  
        },  
        series: [  
            {  
                name: labelList.tx,  
                type: "line",  
                smooth: false,  
                showSymbol: false,  
                connectNulls: true,  
                areaStyle: {color: "rgba(0, 180, 0, 0.2)"},  
                lineStyle: {color: "#00c853", width: 2},  
                data: []  
            },  
            {  
                name: labelList.rx,  
                type: "line",  
                smooth: false,  
                showSymbol: false,  
                connectNulls: true,  
                areaStyle: {color: "rgba(0, 102, 255, 0.2)"},  
                lineStyle: {color: "#0066ff", width: 2},  
                data: []  
            }  
        ]  
    });  
};

 

echarts数据处理方法

const processDataPoints = result => {  
    if (!*Array*.isArray(result) || result.length < 2) return {newTimeData: [], newTxData: [], newRxData: []};  

    const newTimeData = [];  
    const newTxData = [];  
    const newRxData = [];  

    for (let i = 1; i < result.length; i++) {  
        const [t2, rx2, , tx2] = result[i];  
        const [t1, rx1, , tx1] = result[i - 1];  

        const txRate = ((tx2 - tx1) * 8) / 1024 / (t2 - t1);  
        const rxRate = ((rx2 - rx1) * 8) / 1024 / (t2 - t1);  
        const timeStr = new *Date*(t2 * 1000).toLocaleTimeString("zh-CN", {hour12: false});  

        newTimeData.push(timeStr);  
        newTxData.push(parseFloat(txRate.toFixed(2)));  
        newRxData.push(parseFloat(rxRate.toFixed(2)));  
    }  

    return {newTimeData, newTxData, newRxData};  
};

 

第一次数据加载:填满180点窗口

const loadData = async () => {  
    try {  
        const res = await *api*.request({  
            luci: {  
                getRealtimeStats: {mode: "interface", device}  
            }  
        });  

        const result = res?.result;  


        const {newTimeData, newTxData, newRxData} = processDataPoints(result);  

        // 初始化用 NaN 填充,避免折线断裂  
        const fullTime = *Array*(TOTAL_POINTS).fill("");  
        const fullTx = *Array*(TOTAL_POINTS).fill(*NaN*);  
        const fullRx = *Array*(TOTAL_POINTS).fill(*NaN*);  

        const len = newTimeData.length;  
        const start = TOTAL_POINTS - len;  

        for (let i = 0; i < len; i++) {  
            fullTime[start + i] = newTimeData[i];  
            fullTx[start + i] = newTxData[i];  
            fullRx[start + i] = newRxData[i];  
        }  
        setTimeData(fullTime);  
        setTxData(fullTx);  
        setRxData(fullRx);  
    } catch (err) {  
        *console*.error(err);  
    }  
};

定时更新:每3s滑动窗口 删除旧的三个点,添加最新点

const smoothAdvance = async () => {  
    try {  
        const res = await *api*.request({  
            luci: {  
                getRealtimeStats: {mode: "interface", device}  
            }  
        });  

        const result = res?.result;  
               const {newTimeData, newTxData, newRxData} = processDataPoints(result.slice(-4));  
        setTimeData(prev => {  
            const kept = prev.slice(3);  
            const lastTime = kept[kept.length - 1];
            
            //避免重复点

            const filtered = newTimeData.filter(t => t !== lastTime);  

            return [...kept, ...filtered];  
        });  
      

        //null值用 NaN 填充,避免折线断裂 同时避免在连线触发tooltip显示(应该在每个点显示)
      
        setTxData(prev => {  
            const kept = prev.slice(3).map(v => (v == null ? "NaN" : v));  
            return [...kept, ...newTxData];  
        });  

        setRxData(prev => {  
            const kept = prev.slice(3).map(v => (v == null ? "NaN": v));  
            return [...kept, ...newRxData];  
        });  
    } catch (e) {  
        *console*.error("smoothAdvance error:", e);  
    }  
};

 

图表渲染 只更新坐标轴 数据数组

const updateChart = (timeArr, txArr, rxArr) => {  
    if (!chartInstance.current) return;  
    chartInstance.current.setOption(  
        {  
            xAxis: {data: timeArr},  
            series: [{data: txArr}, {data: rxArr}]  
        },  
        false  
    );  
};

useEffect(() => {  
    if (chartInstance.current) {  
        updateChart(timeData, txData, rxData);  
    }  
}, [timeData, txData, rxData]);

 

图表自适应 (ResizeObserver)

useEffect(() => {  
    const observer = new ResizeObserver(() => {  
        chartInstance.current?.resize();  
    });  
    if (chartRef.current) observer.observe(chartRef.current);  
    return () => observer.disconnect();  
}, []);

 

图表初始化渲染(第一次加载数据渲染)

useEffect(() => {  
  if (!chartInstance.current) initChart();  
    loadData();  
    const timer = setInterval(smoothAdvance, 3000);  
    return () => clearInterval(timer);  
}, []);

忘掉"发请求",声明你要的数据:TanStack Query 带来的思维革命

声明你要的数据

使用 TanStack Query 后,你不再需要"发请求",你只是在"声明数据"。

更重要的是:你不再需要"管理状态",只需要"消费数据"。

这是一次思维范式的根本转变:

  • 传统方式:你在管理"请求的过程"和"状态的生命周期" - 什么时候发、怎么发、失败了怎么办、要不要重试、loading/error/data 怎么更新
  • TanStack Query:你在声明"数据的需求" - 我需要这个数据,剩下的自动处理

具体改变:

传统方式 TanStack Query
100 行代码管理一个请求 20 行代码实现更多功能
手动定义 useState 管理 loading、error、data 自动提供完整状态,无需手动管理
每个组件都要写 loading/error 逻辑 状态自动同步,写一次到处用
多组件重复发请求 自动去重和共享缓存
手写定时刷新逻辑 配置即可自动更新
复杂的乐观更新和回滚 内置乐观更新机制
需要 Redux/Context 管理服务器数据 内置服务器状态管理

结果:代码量减少 80%,状态管理复杂度降低 90%,但功能更强大、更可靠。

你从"请求的搬运工"变成"数据的消费者",可以将更多精力专注于构建出色的用户体验。


传统开发的困境:状态管理的噩梦

当我们谈论前端数据获取时,脑海中总是浮现这样的画面:

// 组件挂载时发请求
function UserProfile() {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(null)
  
  useEffect(() => {
    setLoading(true)
    setError(null)
    
    fetch('/api/user')
      .then(res => res.json())
      .then(data => {
        setUser(data)
        setLoading(false)
      })
      .catch(err => {
        setError(err)
        setLoading(false)
      })
  }, [])
  
  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
  
  return <div>{user?.name}</div>
}

这里有两个根深蒂固的思维模式:

  1. 我们在"发请求"
  2. 我们在"管理状态" - loading、error、data 三个状态要手动定义、手动更新、手动同步

每个请求都需要:

  • 定义 3 个 useState
  • 在请求前后更新这些状态
  • 处理各种边界情况(取消请求、组件卸载、竞态条件...)
  • 在 JSX 里写一堆条件渲染

一个简单的数据获取,就要写 20+ 行样板代码。

更可怕的是,如果多个组件需要同样的数据,你要么:

  • 把状态提升到父组件(组件结构被绑架)
  • 用 Context 传递(样板代码更多)
  • 用 Redux/MobX(引入整个状态管理库)

但这真的是我们应该关心的吗?我们真正需要的只是那个数据而已。

范式转变:从"发请求+管理状态"到"访问数据"

TanStack Query 带来的最大思维转变是:

  1. 你不再需要"发请求",你只是在"声明数据"
  2. 你不再需要"管理状态",你只是在"消费数据"

看看这段代码:

function UserProfile() {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser
  })
  
  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
  
  return <div>{user?.name}</div>
}

注意到了吗?

  • ✅ 没有 useEffect,没有手动调用 fetch
  • ✅ 没有 useState,没有手动管理 loading、error、data
  • ✅ 状态自动管理,自动更新,自动同步

你只是在说:"我需要用户数据",然后直接用。

至于这个数据:

  • 从哪来(缓存?网络?)
  • 什么时候来(现在?稍后?)
  • 状态如何更新(loading → success?loading → error?)
  • 过期了要不要更新
  • 失败了要不要重试

这些都不再是你的负担,TanStack Query 会帮你处理。

不用写 useState,不用写状态更新逻辑,不用担心状态不同步。我只是需要这个数据,状态管理和我有什么关系?

数据的"存在性"而非"获取过程"和"状态管理"

传统方式让我们陷入了"过程式思维":

// 我们关心的是"过程"和"状态"
1. 定义状态:useState(loading), useState(error), useState(data)
2. 发起请求:setLoading(true)
3. 等待响应
4. 处理结果:setLoading(false), setData() 或 setError()
5. 在 JSX 中根据状态条件渲染

这就像你要喝一杯咖啡,却要自己管理咖啡机的每一个零件状态。

而 TanStack Query 让我们回归"声明式思维":

// 我们关心的是"结果"
const { data, isLoading, error } = useQuery(['user'], fetchUser)
// "用户数据应该存在,请给我"
// 状态?TanStack Query 自动管理

这就像你走进咖啡店,只需要说"我要一杯咖啡",咖啡师会处理所有细节。

对比:

传统方式 TanStack Query
你是"状态管理员" 你是"数据消费者"
手动定义 loading/error/data 自动提供,直接用
手动更新每个状态 自动更新,状态同步
担心状态不一致 状态始终一致
每个组件重复写状态逻辑 写一次,到处用

实际场景:威力显现

场景一:多组件共享数据(状态同步噩梦)

传统方式的噩梦

// 组件 A
function ComponentA() {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(false)
  
  useEffect(() => {
    setLoading(true)
    fetchUser().then(data => {
      setUser(data)
      setLoading(false)
    })
  }, [])
  
  return <div>{user?.name}</div>
}

// 组件 B(同样的数据!)
function ComponentB() {
  const [user, setUser] = useState(null)  // 又定义一次状态
  const [loading, setLoading] = useState(false)
  
  useEffect(() => {
    setLoading(true)
    fetchUser().then(data => {  // 又发了一次请求
      setUser(data)
      setLoading(false)
    })
  }, [])
  
  return <div>{user?.email}</div>
}

// 问题:
// 1. 发了两次请求
// 2. 定义了两套状态
// 3. 如果 A 更新了数据,B 不会同步
// 4. 需要用 Context 或状态管理库来解决

// 你需要:
// - Context 传递数据(写一堆 Provider)
// - 或者状态提升(破坏组件结构)
// - 或者 Redux/MobX(引入整个状态管理体系)

为了共享一个数据,要引入整个状态管理库?这也太重了吧...

TanStack Query 的优雅

// 组件 A
function ComponentA() {
  const { data: user } = useQuery(['user'], fetchUser)
  return <div>{user?.name}</div>
}

// 组件 B(自动共享!)
function ComponentB() {
  const { data: user } = useQuery(['user'], fetchUser)
  return <div>{user?.email}</div>
}

// 结果:
// ✅ 只发一次请求
// ✅ 不需要定义任何 useState
// ✅ 数据自动共享
// ✅ A 组件更新数据,B 组件自动同步
// ✅ 不需要 Context、Provider、Redux

同一个 queryKey,自动共享数据和状态。不用写 useState,不用担心状态不同步,就这么简单。

场景二:数据过期更新

传统方式

// 30秒后数据可能过期了,需要重新获取吗?
// 用户切换标签页回来,需要刷新吗?
// 网络重连了,需要更新吗?

// 你需要自己写定时器、监听器、重试逻辑……

TanStack Query

const { data } = useQuery(['user'], fetchUser, {
  staleTime: 30000,  // 30秒内是新鲜的
  refetchOnWindowFocus: true,  // 窗口聚焦时更新
  refetchOnReconnect: true,  // 重连时更新
})

// 就这样。其他的都自动处理。

场景三:乐观更新

传统方式

// 点赞功能
const handleLike = async () => {
  // 先更新UI
  setLikes(likes + 1)
  
  try {
    await likePost(postId)
  } catch (error) {
    // 失败了要回滚
    setLikes(likes - 1)
    // 但这时 likes 可能已经被其他操作改变了...
  }
}

TanStack Query

const mutation = useMutation(likePost, {
  onMutate: async (postId) => {
    // 乐观更新
    queryClient.setQueryData(['post', postId], old => ({
      ...old,
      likes: old.likes + 1
    }))
  },
  onError: (err, variables, context) => {
    // 自动回滚到之前的数据
    queryClient.setQueryData(['post', postId], context.previousPost)
  }
})

// 失败自动回滚,不需要手动管理

心智模型的转变

使用 TanStack Query 后,你的思考方式会发生根本变化:

以前思考

  • ❌ "我需要在这里发个请求"
  • ❌ "我要定义 loading、error、data 三个状态"
  • ❌ "请求前设置 loading=true,完成后设置 loading=false"
  • ❌ "要不要显示 loading?"
  • ❌ "数据过期了要重新请求吗?"
  • ❌ "多个组件的状态怎么同步?"

现在思考

  • ✅ "我需要这个数据"
  • ✅ "这个数据的新鲜度要求是什么?"
  • ✅ "数据变化时 UI 应该如何响应?"

你从"请求的搬运工"和"状态的管理员"变成了"数据的消费者"。

代码对比:一目了然

传统方式(100行代码)

function TodoList() {
  const [todos, setTodos] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(null)
  
  // 初始加载
  useEffect(() => {
    fetchTodos()
  }, [])
  
  // 定期刷新
  useEffect(() => {
    const interval = setInterval(fetchTodos, 30000)
    return () => clearInterval(interval)
  }, [])
  
  // 窗口聚焦刷新
  useEffect(() => {
    const handleFocus = () => fetchTodos()
    window.addEventListener('focus', handleFocus)
    return () => window.removeEventListener('focus', handleFocus)
  }, [])
  
  const fetchTodos = async () => {
    setLoading(true)
    try {
      const data = await fetch('/api/todos').then(r => r.json())
      setTodos(data)
      setError(null)
    } catch (err) {
      setError(err.message)
    } finally {
      setLoading(false)
    }
  }
  
  const addTodo = async (text) => {
    try {
      const newTodo = await fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify({ text })
      }).then(r => r.json())
      
      setTodos([...todos, newTodo])
    } catch (err) {
      setError(err.message)
    }
  }
  
  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error}</div>
  
  return (
    <div>
      {todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
    </div>
  )
}

TanStack Query 方式(20行代码)

function TodoList() {
  const { data: todos } = useQuery(['todos'], fetchTodos, {
    staleTime: 30000,
    refetchOnWindowFocus: true,
  })
  
  const addTodoMutation = useMutation(addTodo, {
    onSuccess: () => {
      queryClient.invalidateQueries(['todos'])
    }
  })
  
  return (
    <div>
      {todos?.map(todo => <TodoItem key={todo.id} todo={todo} />)}
    </div>
  )
}

代码量减少 80%,但功能更强大、更可靠。

深层价值:架构层面的提升

使用 TanStack Query 不仅仅是代码更简洁,它还带来架构层面的好处:

1. 服务器状态与客户端状态分离

// 服务器状态(TanStack Query 管理,不需要你写 useState)
const { data: user } = useQuery(['user'], fetchUser)

// 客户端状态(React 管理,这才需要 useState)
const [isModalOpen, setIsModalOpen] = useState(false)

关键洞察:服务器数据根本不应该用 useState 管理!

  • 服务器数据:需要缓存、更新、同步、重试 → TanStack Query
  • 客户端 UI 状态:只在本地,组件卸载就消失 → useState

职责清晰,不再混乱。

之前用 useState 管理服务器数据,就像用螺丝刀钉钉子,工具用错了。

2. 告别状态管理样板代码

传统方式需要的状态管理代码:

// 每个数据获取都要写这些
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)

// 请求前
setLoading(true)
setError(null)

// 请求成功
setData(result)
setLoading(false)

// 请求失败
setError(err)
setLoading(false)

// 条件渲染
if (loading) return <Loading />
if (error) return <Error error={error} />

TanStack Query:

// 一行搞定
const { data, isLoading, error } = useQuery(['key'], fetcher)

// 条件渲染(可选)
if (isLoading) return <Loading />
if (error) return <Error error={error} />

你不需要写 Redux、MobX、Zustand 来管理服务器数据,TanStack Query 就是你的服务器状态管理器。

而且,它比 Redux 更简单、更强大、更自动化。

3. 性能优化自动化

  • 请求去重
  • 并行请求优化
  • 窗口聚焦时更新
  • 后台数据预取
  • 这些都是开箱即用

4. 更好的用户体验

const { data, isLoading, isFetching } = useQuery(['user'], fetchUser)

// isLoading: 首次加载
// isFetching: 后台更新
// 可以显示"正在刷新"而不是整个页面重新加载

结语:思维的跃迁

TanStack Query 最大的价值不是它的 API,而是它改变了我们对数据获取和状态管理的认知。

从"我要发请求 + 我要管理状态"到"我要数据",这是一次思维的跃迁。

就像从命令式编程到声明式编程,从手动档到自动档,你不需要关心底层的每一个齿轮如何转动,你只需要说出你的目的地。

当你不再纠结于"请求"和"状态",你会发现自己有更多精力专注于真正重要的事情:构建出色的用户体验。


开始使用 TanStack Query,忘掉"发请求"和"管理状态"这两个概念吧。你只需要记住:

"我需要这个数据" — 请求怎么发、状态怎么管,交给 TanStack Query。

npm install @tanstack/react-query

你的前端开发体验,即将焕然一新。

Vue 深度选择器 `:deep` 使用说明

Vue 深度选择器 :deep 使用说明

一、基本概念

1. 什么是 :deep

:deep 是 Vue 3 提供的深度选择器(Deep Selector),用于在 scoped 样式中穿透到子组件的元素。

2. 为什么需要 :deep

在 Vue 中,<style scoped> 会为每个组件的样式添加唯一的 data-v-* 属性,确保样式只作用于当前组件。但有时候我们需要修改子组件(特别是第三方组件库如 Element Plus)的样式,这时就需要使用 :deep 来穿透样式作用域。

二、工作原理

1. Scoped 样式的作用机制

普通 scoped 样式:

<style lang="scss" scoped>
.my-class {
    color: red;
}
</style>

编译后:

[data-v-7ba5bd90] .my-class[data-v-7ba5bd90] {
    color: red;
}
  • 只会匹配当前组件内的 .my-class
  • 不会匹配子组件中的元素

2. 使用 :deep 穿透样式

<style lang="scss" scoped>
:deep {
    .el-form-item__content {
        align-items: self-start;
    }
}
</style>

编译后:

[data-v-7ba5bd90] .el-form-item__content {
    align-items: self-start;
}
  • 选择器中的 .el-form-item__content 不会被添加 data-v-* 属性
  • 可以匹配子组件中的 .el-form-item__content 元素

3. 关键区别对比

方式 编译前 编译后 能否匹配子组件
普通 scoped .my-class { } [data-v-xxx] .my-class[data-v-xxx] { } ❌ 否
使用 :deep :deep { .my-class { } } [data-v-xxx] .el-form-item__content { } ✅ 是

三、实际应用场景

1. 修改 Element Plus 组件样式

场景: 修改表格搜索表单中表单项的对齐方式

<template>
    <zh-table>
        <!-- zh-table 内部使用 Element Plus 的 el-form-item -->
    </zh-table>
</template>

<style lang="scss" scoped>
:deep {
    .el-form-item__content {
        align-items: self-start; /* 顶部对齐 */
    }
}
</style>

DOM 结构:

<!-- index.vue 的根元素 -->
<div data-v-7ba5bd90>
    <!-- zh-table 组件 -->
    <div data-v-abc123>
        <el-form>
            <el-form-item>
                <div class="el-form-item__content">
                    <!-- 👈 被匹配到 -->
                    <input />
                </div>
            </el-form-item>
        </el-form>
    </div>
</div>

CSS 匹配过程:

/* 编译后的选择器 */
[data-v-7ba5bd90] .el-form-item__content {
    align-items: self-start;
}

/* 匹配过程:
   1. 找到带有 data-v-7ba5bd90 的元素(index.vue 的根元素)
   2. 在该元素的后代中查找 .el-form-item__content
   3. 找到子组件中的 .el-form-item__content 元素
   4. 应用样式
*/

2. 修改第三方组件库样式

<template>
    <el-input />
</template>

<style lang="scss" scoped>
:deep {
    .el-input__wrapper {
        border-radius: 6px;
        background-color: #f5f5f5;
    }

    .el-input__inner {
        height: auto;
        color: #000000e0;
    }
}
</style>

3. 嵌套使用 :deep

<style lang="scss" scoped>
.zh-default-input {
    :deep {
        .el-input__wrapper {
            border-radius: 6px !important;
        }
        .el-input__inner {
            height: auto;
        }
    }
}
</style>

编译后:

[data-v-xxx] .zh-default-input .el-input__wrapper {
    border-radius: 6px !important;
}
[data-v-xxx] .zh-default-input .el-input__inner {
    height: auto;
}

四、语法说明

1. 基本语法

:deep(选择器) {
    样式规则
}

// 或者使用块语法
:deep {
    选择器 {
        样式规则
    }
}

2. 两种写法对比

写法1:函数式(推荐)

:deep(.el-form-item__content) {
    align-items: self-start;
}

写法2:块语法

:deep {
    .el-form-item__content {
        align-items: self-start;
    }
}

两种写法编译结果相同,块语法更清晰易读。

3. 组合使用

:deep {
    .el-input__wrapper,
    .el-textarea__inner {
        border-radius: 6px !important;
    }

    .el-input.is-disabled .el-input__wrapper {
        background-color: #fafafa;
    }
}

五、为什么能找到子组件中的元素?

1. CSS 选择器的作用域

:deep 生成的选择器是后代选择器(Descendant Selector):

[data-v-7ba5bd90] .el-form-item__content

这个选择器表示:

  • data-v-7ba5bd90 元素的后代中
  • 查找 .el-form-item__content 元素
  • 不限制层级,可以匹配任意深度的后代

2. DOM 树结构

index.vue (data-v-7ba5bd90)          ← 样式作用域的根
└── zh-table (data-v-abc123)         ← 子组件
    └── SearchForm                   ← 子组件的子组件
        └── el-form                  ← Element Plus 组件
            └── el-form-item
                └── .el-form-item__content  ← 被匹配到

3. 样式穿透机制

  • :deep 让选择器中的目标类不被添加 data-v-* 属性
  • 因此可以匹配到子组件中的同名类
  • 通过后代选择器在 DOM 树中查找匹配的元素

六、浏览器中的实际效果

1. 实际 DOM 结构

<!-- 浏览器渲染后的 DOM -->
<div data-v-7ba5bd90 class="...">
    <div data-v-abc123 class="table-search">
        <form class="el-form table-search--left">
            <div class="el-form-item">
                <div class="el-form-item__content">
                    <!-- 👈 被匹配 -->
                    <input class="el-input__inner" />
                </div>
            </div>
        </form>
    </div>
</div>

2. CSS 规则应用

/* 编译后的 CSS */
[data-v-7ba5bd90] .el-form-item__content {
    align-items: self-start;
}

匹配过程:

  1. 浏览器找到所有带有 data-v-7ba5bd90 属性的元素
  2. 在这些元素的后代中查找 .el-form-item__content
  3. 找到匹配的元素并应用样式

七、注意事项

1. 作用域限制

:deep 仍然受到父组件 scoped 的作用域限制:

/* ✅ 正确:可以匹配子组件 */
:deep {
    .el-form-item__content {
        align-items: self-start;
    }
}

/* ❌ 错误:无法匹配子组件 */
.el-form-item__content {
    align-items: self-start; /* 只会匹配当前组件 */
}

2. 性能考虑

:deep 选择器会遍历整个 DOM 树,应该:

  • 推荐:使用具体的选择器,减少匹配范围
:deep {
    .el-form-item__content {  /* 具体类名 */
        ...
    }
}
  • ⚠️ 避免:使用过于宽泛的选择器
:deep {
    div {  /* 太宽泛,可能影响很多元素 */
        ...
    }
}

3. 优先级问题

:deep 生成的样式优先级可能不够高,必要时使用 !important

:deep {
    .el-input__wrapper {
        border-radius: 6px !important; /* 确保覆盖默认样式 */
    }
}

4. 替代方案

如果只需要修改少量样式,也可以使用:

方案1:不使用 scoped

<style lang="scss">
/* 不使用 scoped,样式会全局生效 */
.el-form-item__content {
    align-items: self-start;
}
</style>

方案2:使用全局样式文件

// styles/element.scss
.el-form-item__content {
    align-items: self-start;
}

八、常见使用场景

1. 修改 Element Plus 组件样式

<style lang="scss" scoped>
:deep {
    .el-button {
        border-radius: 6px;
    }

    .el-input__wrapper {
        background-color: #f5f5f5;
    }

    .el-select__wrapper {
        border-radius: 6px;
    }
}
</style>

2. 修改自定义组件样式

<template>
    <zh-table>
        <!-- 需要修改 zh-table 内部的样式 -->
    </zh-table>
</template>

<style lang="scss" scoped>
:deep {
    .table-search {
        padding: 16px;
    }

    .el-form-item {
        margin-bottom: 0;
    }
}
</style>

3. 响应式样式穿透

:deep {
    .el-form-item__content {
        @media (max-width: 768px) {
            flex-direction: column;
        }
    }
}

九、Vue 2 vs Vue 3

Vue 2 语法

/* Vue 2 使用 /deep/ 或 >>> */
/deep/ .el-form-item__content {
    align-items: self-start;
}

/* 或者 */
>>> .el-form-item__content {
    align-items: self-start;
}

Vue 3 语法

/* Vue 3 使用 :deep() */
:deep(.el-form-item__content) {
    align-items: self-start;
}

/* 或者块语法 */
:deep {
    .el-form-item__content {
        align-items: self-start;
    }
}

十、总结

:deep 的核心要点

  1. 作用:穿透 scoped 样式,修改子组件样式
  2. 原理:编译时不为目标选择器添加 data-v-* 属性
  3. 机制:通过后代选择器在 DOM 树中查找匹配元素
  4. 使用:在需要修改子组件样式时使用
  5. 注意:合理使用,避免过度穿透影响性能

最佳实践

  1. ✅ 优先使用具体的选择器
  2. ✅ 只在必要时使用 :deep
  3. ✅ 注意样式优先级
  4. ✅ 避免过度穿透影响性能
  5. ✅ 考虑使用全局样式文件作为替代方案

适用场景

  • 修改第三方组件库(如 Element Plus)的样式
  • 修改自定义子组件的样式
  • 需要保持 scoped 样式隔离的同时修改子组件

:deep 是 Vue 3 中处理样式穿透的标准方式,理解其工作原理有助于更好地使用和调试样式。

JavaScript 作用域:从执行机制到块级作用域的演进

JavaScript 作用域:从执行机制到块级作用域的演进

在 JavaScript 中,作用域是控制变量和函数可见性与生命周期的核心机制。理解作用域不仅能帮助我们写出更可预测的代码,还能规避因变量提升、作用域污染等问题导致的 bugs。本文将结合具体代码示例,从执行机制、变量提升到块级作用域的实现,全面解析 JavaScript 作用域的核心概念。

一、作用域的本质:变量的 "生存规则"

作用域指的是程序中定义变量的区域,它决定了变量在哪些地方可以被访问(可见性),以及变量会存在多久(生命周期)。JavaScript 中有三种主要的作用域类型:

  • 全局作用域:在代码的任何地方都能访问,生命周期与页面一致(页面关闭前始终存在)。
  • 函数局部作用域:仅在函数内部可访问,生命周期随函数执行结束而销毁。
  • 块级作用域:ES6 新增,在 {} 包裹的块(如 iffor、单独的 {} 等)内部生效,生命周期随块执行结束而销毁。

二、JS 执行机制:编译与执行的 "两步走"

JavaScript 引擎(如 V8)执行代码分为编译执行两个阶段,这一过程直接影响作用域的表现:

  1. 编译阶段:引擎会扫描代码,确定变量和函数的声明位置,创建执行上下文(包含变量环境、词法环境等信息)。
  2. 执行阶段:引擎按顺序执行代码,处理变量赋值、函数调用等操作,依赖编译阶段创建的执行上下文。

执行上下文是作用域的具体载体,每个函数调用都会创建一个新的执行上下文并压入调用栈,执行完成后出栈并销毁(变量随之回收)。

三、变量提升:设计缺陷与现象

早期 JavaScript(ES5 及之前)存在 "变量提升"(hoisting)特性:变量和函数的声明会被 "提升" 到其作用域的顶部,但赋值不会。这是 JS 设计初期为简化实现留下的缺陷,可能导致与直觉不符的结果。

代码示例:变量提升的表现

showName(); // 输出:"函数showName 执行了"
console.log(myname); // 输出:undefined(变量声明提升,赋值未提升)

var myname = "路飞"; // 变量声明被提升到作用域顶部
function showName() { // 函数整体被提升
    console.log('函数showName 执行了');
}

image.png

解释

  • 函数 showName 的声明被完整提升,因此可以在定义前调用。
  • var 声明的 myname 被提升到作用域顶部,但赋值 ="路飞" 留在原地,因此执行 console.log(myname) 时为 undefined

四、全局作用域与函数局部作用域

1. 全局作用域

在函数外声明的变量属于全局作用域,可在任何地方访问。

2. 函数局部作用域

在函数内用 var/let/const 声明的变量属于局部作用域,仅在函数内可见。

代码示例:全局与局部作用域

var globalVar = "我是全局变量"; // 全局作用域
function myFunction(){
    var localVar = "我是局部变量"; // 函数局部作用域
    console.log(globalVar); // 输出:"我是全局变量"(可访问全局变量)
    console.log(localVar); // 输出:"我是局部变量"(可访问局部变量)
}
myFunction();
console.log(globalVar); // 输出:"我是全局变量"(全局可见)
console.log(localVar); // 报错:localVar is not defined(局部变量外部不可见)

image.png

解释

  • globalVar 在全局作用域声明,因此函数内外都可访问。
  • localVar 在 myFunction 内部声明,属于局部作用域,函数外部访问会报错。

五、块级作用域:ES6 的改进

ES5 不支持块级作用域,var 声明的变量会 "穿透" iffor 等块结构,导致变量污染。ES6 引入 let 和 const,正式支持块级作用域,变量仅在 {} 内部有效。

1. var 无块级作用域的问题

var name = "流萤"; // 全局变量
function showName(){
    console.log(name); // 输出:undefined(var声明的name被提升到函数顶部)
    if(true){ 
        var name = "大厂的苗子"; // var无块级作用域,声明被提升到函数作用域
    }
    console.log(name); // 输出:"大厂的苗子"(块内赋值影响函数内变量)
}
showName();

image.png

解释var 声明的 name 会被提升到 showName 函数作用域顶部(而非块级作用域),因此第一次打印时变量已声明但未赋值(undefined),块内赋值后函数内的 name 被覆盖。

2. let 支持块级作用域

let name = "流萤"; // 全局变量
function showName(){
    console.log(name); // 输出:流萤
    if(false){ 
        let name = "大厂的苗子"; // let有块级作用域,仅在if块内有效
    }
}
showName();

image.png

解释let 声明的 name 被限制在 if 块级作用域内,函数作用域中不存在该变量。但由于 "暂时性死区"(let 变量声明前不可访问),函数内打印 name 时会向上查找全局 name

3. 暂时性死区

let name = '流萤';
{
    console.log(name); // 报错:Cannot access 'name' before initialization
    let name = '大厂的苗子'; // let声明触发暂时性死区
}

image.png

解释:块内用 let 声明 name 后,整个块会形成 "暂时性死区",在 let 声明前访问 name 会直接报错(即使外部有同名全局变量)。这避免了变量提升导致的意外覆盖。

六、for 循环中的块级作用域

for 循环是块级作用域的典型场景,let 和 var 的表现差异显著:

function foo() {
    // 若用var声明i:
    // for(var i=0;i<7;i++){} 
    // console.log(i); // 输出7var无块级作用域,i泄漏到函数作用域)

    // 用let声明i:
    for(let i=0;i<7;i++){} 
    console.log(i); // 报错:i is not defined(let限制i在for块内)
}
foo();

解释

  • var 声明的 i 会穿透 for 循环块,泄漏到函数作用域,循环结束后仍可访问(值为 7)。
  • let 声明的 i 被限制在 for 块级作用域内,循环外访问会报错,避免了变量泄漏。

七、执行上下文视角:var 与 let 的 "一国两制"

ES6 为了兼容旧代码,采用了 "一国两制" 的处理方式:在执行上下文中,var 和 let 被分别存储在不同区域:

  • 变量环境:存储 var 声明的变量(支持变量提升,无块级作用域)。
  • 词法环境:存储 let/const 声明的变量(支持块级作用域,有暂时性死区),内部维护一个栈结构,块级作用域执行时入栈,执行完出栈。

代码示例:var 与 let 的作用域混合

function foo (){
    var a = 1; // 存储在变量环境(函数作用域)
    let b = 2; // 存储在词法环境(函数作用域)
    {
        let b = 3; // 词法环境栈顶(块级作用域)
        var c = 4; // 变量环境(函数作用域,穿透块)
        let d = 5; // 词法环境栈顶(块级作用域)
        console.log(a); // 1(访问变量环境的a)
        console.log(b); // 3(访问词法环境栈顶的b)
    }
    console.log(b); // 2(词法环境栈顶出栈,访问函数作用域的b)
    console.log(c); // 4(变量环境的c,块外可访问)
    console.log(d); // 报错:d is not defined(词法环境栈顶出栈,d已销毁)
}
foo();

解释

  • var 声明的 a 和 c 存储在变量环境,属于函数作用域,块内外均可访问。
  • let 声明的 b(函数内)和 b(块内)、d 存储在词法环境的栈结构中:块执行时,块内 b 和 d 入栈,优先访问栈顶变量;块执行完后栈顶出栈,函数内的 b 恢复可见,d 则被销毁。

八、总结

JavaScript 作用域的发展反映了语言的进化:从 ES5 仅有全局和函数作用域、依赖变量提升的简单设计,到 ES6 引入块级作用域、通过 let/const 解决变量污染问题。核心要点包括:

  1. 作用域控制变量的可见性和生命周期(全局、函数、块级)。
  2. 变量提升是 ES5 的设计缺陷,var 声明的变量会被提升到作用域顶部。
  3. ES6 的 let/const 通过块级作用域和暂时性死区解决了变量提升的问题。
  4. 执行上下文中的变量环境(var)和词法环境(let/const)实现了新旧特性的兼容。
  5. 在实际开发中,建议优先使用 let/const 替代 var,利用块级作用域减少变量污染,写出更可靠的代码。

Vue3使用vuedraggable实现拖拽排序

安装依赖

npm install vuedraggable@next

引入组件

import draggable from 'vuedraggable'

基础用法

<template>
    <draggable v-model="innerList" item-key="props" handle=".sort-icon" :disabled="false" :animation="300">
        <template #item="{ element }">
            <div class="flexAlign">
                <div class="sort-icon" style="margin-right: 5px;cursor: pointer;font-size: 18px;">⠿</div>
                {{ element.label }}
            </div>
        </template>
    </draggable>
</template>

<script setup>
    import draggable from 'vuedraggable';
    const innerList = ref([
        {label:"名称"}
    ])
</script>

常用属性说明

属性名 类型 说明
v-model Array 绑定数组,拖拽后自动更新顺序
item-key String 每个 item 的唯一 key
tag String 渲染的容器标签,默认div
group String/Object 分组设置,支持跨列表拖拽
disabled Boolean 是否禁用拖拽
handle String 拖拽手柄选择器
animation Number 拖拽的动画过渡时间

事件名

事件名 说明 参数
@start 拖拽开始 event
@end 拖拽完成 event
@change 列表顺序发生变化 { moved: { oldIndex, newIndex } }
@add 从别的列表添加到当前 event
@remove 当前列表被移除元素 event

实战进阶场景

拖拽手柄限制(只能通过图标拖动)

<draggable v-model="list" item-key="id" handle=".sort-icon">
  <template #item="{ element }">
    <div>
      <span class="sort-icon"></span>
      {{ element.name }}
    </div>
  </template>
</draggable>

禁用某项拖动

<draggable v-model="list" item-key="id" :move="checkMove" @change="logChange">
</draggable>

<script setup>
const checkMove = (e) => {
  return !e.draggedContext.element.fixed
}
const logChange = (e) => console.log(e)
</script>

跨列表拖拽

<draggable v-model="list1" item-key="id" group="all">
  ...
</draggable>

<draggable v-model="list2" item-key="id" group="all">
  ...
</draggable>

也可以使用对象方式更灵活控制:

group="{ name: 'all', pull: true, put: true }"

嵌套拖拽(如树形结构)

<draggable v-model="tabList" item-key="id">
  <template #item="{ element }">
    <div>{{ element.name }}</div>
    <draggable
      v-if="element.children"
      v-model="element.children"
      item-key="id"
      :group="{ name: 'all' }"
    >
      <template #item="{ element }">
        <div class="child">{{ element.name }}</div>
      </template>
    </draggable>
  </template>
</draggable>

示例:拖拽后保存顺序

<el-button @click="save">保存顺序</el-button>
<draggable v-model="list" item-key="id" @end="save" />

<script setup>
const save = () => {
  console.log('新顺序:', list.value.map(i => i.id))
}
</script>

常见问题排查

  1. 拖不动?

    • 是否设置了 item-key
    • 是否被 CSS 覆盖了事件(例如 overflow、position)
    • 是否启用了 disabled 属性
  2. 拖动异常(闪烁/跳动)?

    • 确保子项具有稳定的 key
    • 不要在拖拽中操作 DOM
  3. 样式塌陷?

    • 添加必要的 min-heightborder 保持布局正常

Webpack高级之常用配置项

常用配置项——Mode

默认值是 production(什么都不设置的情况下);可选值有:'none' | 'development' | 'production'。

  • development:会将 DefinePlugin 中 process.env.NODE_ENV 的值设置为 development,为模块和 chunk 启用有效的名。
  • production:会将 DefinePlugin 中 process.env.NODE_ENV 的值设置为 production,为模块和 chunk 启用确定性的混淆名称。
  • none:不使用任何默认优化选项

常用配置项——Source Map

一、如何在 Webpack 中启用 Source Map

webpack.config.js 中,通过配置 devtool 选项来控制是否生成 source map 以及生成的类型:

module.exports = {
  // ...
  devtool: 'source-map', // 或其他值
};

二、常见的 devtool 选项及其区别

💡 实践建议

  • 开发/测试环境:常用 eval-cheap-module-source-map(速度快 + 能定位到原始模块)
  • 生产环境
    • 如果需要错误监控(如 Sentry),可使用 hidden-source-mapnosources-source-map
      • 通过 sentry-cli.map 文件上传到 Sentry
      • 用户报错时,Sentry 利用 source map 自动还原原始错误位置
    • 一般不直接暴露完整 source-map,以防源码泄露

三、Source Map 的工作原理简述

  1. Webpack 在打包时根据 devtool 配置生成 .map 文件(如 main.js.map
  2. 在输出的 bundle 文件(转换后的文件)末尾添加一行魔法注释:
//# sourceMappingURL=main.js.map
  1. 浏览器 DevTools 自动加载该 .map 文件,并将压缩代码映射回原始源码
  2. 开发者可以在 DevTools 中像调试原始代码一样设置断点、查看变量等

常用配置项——Babel、Browserslist、Polyfill

见之前的文章:《身为大厂前端的你,不能不知道 Babel + Polyfill!》

常用配置项——devServer

你有没有想过一个问题,我们干前端的,为什么要在构建工作内嵌一个服务器(devServer)。

原因其实有这么两个:

  1. 文件变化时完成自动构建
  2. 启动一个服务查看页面展示(最最之前我们学 html 的时候也是通过 live server 启动的一个内置服务器查看页面)

webpack-dev-server 在编译之后不会写入到任何输出文件,而是将 bundle 文件保留在内存中。

常见配置:

devServer: {
  host: '0.0.0.0', // 允许外部访问(如手机调试)
  port: 3000,
  open: true, // 启动后自动打开默认浏览器
  hot: true, // 启用 HMR(模块热更新)
  static: { // 指定静态资源目录(如 public 文件夹)
    directory: path.join(__dirname, 'public'), // 静态资源根目录
    publicPath: '/', // 访问路径前缀
    watch: true,     // 监听变化并刷新
  },
  compress: true, // 是否为静态文件开启 gzip压缩,默认为false
  proxy: { // 一般用于开发环境反向代理避免CORS
    '/api': {
      target: 'http://localhost:8080',
      changeOrigin: true, // 改变请求头中的 host
      pathRewrite: {
        '^/api': '', // 重写路径,去掉 /api 前缀
      },
    },
  },
  historyApiFallback: true, // 解决SPA页面在路由跳转之后,进行页面刷新时,返回404的错误
}
  • 默认 localhost(仅本机访问),这里我们在实际开发中可能会遇到一个问题,就是后端同学无法通过我们项目的 ip 地址访问,这是因为你和他的主机处于同一网段
  • 设为 '0.0.0.0' 可局域网访问(常用于真机调试)
  • hot: true:支持 HMR,若失败则 fallback 到页面刷新
  • hotOnly: true:仅 HMR,编译失败不刷新页面(hot: true 编译失败会重新刷新整个页面)

常用配置——哈希

在我们给打包的文件进行命名的时候,会使用 placeholder 占位符,这里详细说说占位符的这几个属性:

hash 本身是通过 MD4 的散列函数处理后,生成一个 128 位的 hash 值(32 个十六进制)

  • hash 值的生成和整个项目有关系:
    • 比如我们现在有两个入口 index.js 和 main.js;
    • 它们分别会输出到不同的 bundle 文件中,并且在文件名称中我们有使用 hash;
    • 这个时候,如果修改了 index.js 文件中的内容,那么 hash 会发生变化,两个文件的名称都会发生变化;
  • chunkhash 可以有效的解决上面的问题,它会根据不同的入口进行借来解析来生成 hash 值:
    • 比如我们修改了 index.js,那么 main.js 的 chunkhash 是不会发生改变的;
  • contenthash 表示生成的文件 hash 名称,只和内容有关系:
    • 比如我们的 index.js,引入了一个 style.css,style.css 有被抽取到一个独立的 css 文件中;
    • 这个 css 文件在命名时,如果我们使用的是 chunkhash,那么当 index.js 文件的内容发生变化时,css 文件的命名也会发生变化;
    • 这个时候我们可以使用 contenthash,不影响 css 文件名

深入 JavaScript 事件机制:从冒泡到事件委托的高效实践

在前端开发中,用户与页面的每一次点击、滚动或输入,背后都依赖于一套精密而强大的事件系统。JavaScript 的事件机制不仅是交互功能的核心,更是理解 DOM 行为、优化性能的关键。本文将带你深入剖析事件的传播过程,并展示如何通过事件委托显著提升代码效率。

事件的生命周期:捕获、目标与冒泡

当用户点击页面上的一个元素时,事件并非只在该元素上“发生”一次,而是沿着 DOM 树经历三个阶段:

  1. 捕获阶段(Capture) :事件从 document 根节点开始,逐层向下传递,直到目标元素的父级。
  2. 目标阶段(Target) :事件到达实际被点击的元素(即 event.target)。
  3. 冒泡阶段(Bubble) :事件从目标元素向上回溯,依次经过其祖先节点,直至 document

默认情况下,我们通过 addEventListener 注册的监听器会在冒泡阶段触发:

document.getElementById('parent').addEventListener('click', () => {
    console.log('parent click');
});
document.getElementById('child').addEventListener('click', (event) => {
    console.log('child click');
});

此时点击蓝色子元素(#child),控制台会依次输出:

child click
parent click

这是因为事件先在子元素触发(目标阶段),随后“冒泡”到父元素。

若希望在捕获阶段执行逻辑,可传入第三个参数 true

element.addEventListener('click', handler, true); // 捕获阶段

但绝大多数场景下,冒泡已足够使用。

阻止事件传播:精准控制行为

有时,我们不希望事件继续向上冒泡。例如,点击子元素时仅执行其自身逻辑,而不触发父容器的响应。这时可调用 event.stopPropagation()

document.getElementById('child').addEventListener('click', (event) => {
    event.stopPropagation(); // 阻止冒泡
    console.log('child click');
});

现在点击子元素,只会输出 child click,父元素的监听器不再被触发。这种控制能力对于模态框、下拉菜单等组件至关重要,能有效避免“穿透点击”问题。

事件委托:性能与维护性的双赢策略

传统做法中,若要为多个相似元素(如列表项)绑定点击事件,往往会遍历每个节点单独注册监听器:

// 不推荐:为每个 <li> 单独绑定
const items = document.querySelectorAll('#list li');
items.forEach(item => {
    item.addEventListener('click', () => {
        console.log(item.textContent);
    });
});

这种方式存在两个明显缺陷:

  • 内存开销大:每个元素都持有一个独立的监听函数引用;
  • 动态内容无法覆盖:后续通过 JavaScript 新增的 <li> 不会自动绑定事件。

事件委托(Event Delegation) 巧妙地利用了事件冒泡特性,将监听器统一挂载到父容器上:

document.getElementById('list').addEventListener('click', (event) => {
    if (event.target.tagName === 'LI') {
        console.log(event.target.textContent);
    }
});

无论列表中有多少项,甚至未来动态添加新项,都只需一个监听器即可处理所有点击。event.target 始终指向实际被点击的元素,从而实现精准识别。

这种模式不仅节省内存,还极大提升了代码的可维护性——新增或删除子元素时,无需重新绑定事件。

为什么事件必须绑定到单个 DOM 节点?

值得注意的是,addEventListener 只能作用于单个 DOM 元素,不能直接应用于 NodeList 或 HTMLCollection。这也是为何早期开发者常误写:

// 错误!querySelectorAll 返回的是类数组,没有 addEventListener 方法
document.querySelectorAll('li').addEventListener('click', handler); // 报错

正确的做法要么遍历绑定(低效),要么采用事件委托(高效)。这也从侧面印证了事件委托的优越性。

异步与注册时机

JavaScript 事件是典型的异步机制:监听器需提前注册,待用户交互发生时才被调用。这意味着:

  • 事件处理函数不会立即执行;
  • 若在 DOM 尚未加载完成时尝试绑定,可能因元素不存在而失败。

因此,通常将事件绑定代码置于 DOMContentLoaded 之后,或使用现代框架的生命周期钩子确保 DOM 就绪。

总结:构建健壮交互的基石

JavaScript 的事件机制远不止“点击一下弹个窗”那么简单。理解捕获与冒泡的流程,掌握 stopPropagation 的使用时机,善用事件委托优化性能,是每一位前端开发者进阶的必经之路。

在实际项目中:

  • 对于静态、少量元素,可直接绑定;
  • 对于列表、表格、动态内容,优先使用事件委托
  • 在需要隔离行为的嵌套组件中,合理使用 stopPropagation 避免干扰。

事件系统如同城市的交通网络——看似无形,却支撑着整个界面的流畅运转。掌握其规则,你便能在复杂的交互需求中游刃有余,写出既高效又稳定的前端代码。

Electron 的打包

Electron Forge 是 Electron apps 一体化的 打包和分发工具,及将开发好的 Electron 应用打包为可安装,可执行的文件,最终交付给终端用户的核心过程

如何打包(mac 为🌰)

  1. 安装 Electron Forge's CLI 到 devDependencies,并且使用脚本将项目导入到 Forge 中
// 安装 Electron Forge's CLI 到 `devDependencies`
npm install --save-dev @electron-forge/cli

// 将项目导入到 Forge 中
npx electron-forge import

一旦将项目导入到 Forge 的脚本完成了,Forge 将会添加一些脚本到 your package.json

//...
"scripts": {
    "start": "electron-forge start",
    "package": "electron-forge package",
    "make": "electron-forge make"
},
//...
  1. 创建分发文件
// 执行这个脚本的时候将会根据当前系统来打包对应的可运行文件,
// 比如 mac 在项目的 out/make/zip/darwin/arm64/$packageName-darwin-arm64-1.0.0.zip 中
pnpm run make
  1. 打开对应的文件夹,解压 zip 文件,双击文件即可

image.png

image.png

打包的问题

  1. 打包不同的格式来适应不同的机器

答:使用 GitHub Actions / CI 生成在不同的 runner 生成不同的包,比如 Windows 的就使用 Windows 的 runner

  1. 如何自定义 icon 答:直接参考 www.electronforge.io/guides/crea… ,需要注意的是要为不同的系统准备不同的 icon 后缀,配置的时候不要指定 icon 的后缀
// 比如我是将文件放到 public/images 中
public
  - images
     - icon.icns
     - icon.xxx

// forge.config.js
module.exports = {
  // ...
  packagerConfig: {
    icon: 'public/images/icon' // no file extension required
  }
  // ...
};

image.png

最终结果

image.png

  1. 怎么代码签名 代码签名是发布桌面应用程序的重要环节,并且在自动更新步骤中是必不可少的,需要去对应的系统申请不同的认证方式,macOS 的 TEAM_ID 需要钱,如有需要自行申请

Vue 3 定时器清理的最佳实践

Vue 3 定时器清理的最佳实践

在 Vue 3 中,清理定时器的最佳位置取决于组件的使用场景和定时器的用途。我将设计一个直观的示例来演示不同生命周期钩子中定时器的清理方式。设计的定时器截图如下

image.png

设计思路

  • 展示不同生命周期钩子中定时器的创建和清理
  • 提供可视化界面展示定时器状态
  • 允许用户手动创建和清理定时器
  • 演示组件卸载时的自动清理

功能说明

这个示例演示了在 Vue 3 中管理定时器的最佳实践:

  1. 定时器创建和清理

    • onMounted 钩子中创建定时器
    • onUnmounted 钩子中清理定时器(必须)
    • 也可在 onBeforeUnmount 中清理
  2. 生命周期演示

    • 通过切换子组件显示/隐藏来演示组件卸载时的定时器清理
    • 在控制台输出生命周期事件
  3. 手动管理

    • 提供手动创建和清理定时器的功能
    • 显示所有活动定时器的状态和进度
  4. 最佳实践代码示例

    • 展示在 Vue 3 组件中正确管理定时器的代码模式

这个示例强调了在 Vue 3 中,无论定时器是在哪个生命周期创建的,都必须在 onUnmountedonBeforeUnmount 中清理,以防止内存泄漏。

最终实现代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vue 3 定时器生命周期管理</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        }
        
        body {
            background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
            color: #fff;
            min-height: 100vh;
            padding: 20px;
        }
        
        .container {
            max-width: 1200px;
            margin: 0 auto;
            background: rgba(0, 0, 0, 0.7);
            border-radius: 15px;
            padding: 30px;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
        }
        
        header {
            text-align: center;
            margin-bottom: 30px;
        }
        
        h1 {
            font-size: 2.5rem;
            margin-bottom: 10px;
            text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
        }
        
        .subtitle {
            font-size: 1.2rem;
            opacity: 0.8;
            margin-bottom: 20px;
        }
        
        .content {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 30px;
        }
        
        @media (max-width: 768px) {
            .content {
                grid-template-columns: 1fr;
            }
        }
        
        .card {
            background: rgba(255, 255, 255, 0.1);
            border-radius: 10px;
            padding: 20px;
            margin-bottom: 20px;
        }
        
        .card h2 {
            margin-bottom: 15px;
            color: #fdbb2d;
            border-bottom: 1px solid rgba(255, 255, 255, 0.2);
            padding-bottom: 10px;
        }
        
        .timer-controls {
            display: flex;
            flex-wrap: wrap;
            gap: 15px;
            margin-bottom: 20px;
        }
        
        .control-group {
            flex: 1;
            min-width: 200px;
        }
        
        label {
            display: block;
            margin-bottom: 8px;
            font-weight: 600;
        }
        
        input, select, button {
            width: 100%;
            padding: 12px;
            border: none;
            border-radius: 5px;
            font-size: 1rem;
        }
        
        input, select {
            background: rgba(255, 255, 255, 0.9);
        }
        
        button {
            background: #4CAF50;
            color: white;
            cursor: pointer;
            transition: all 0.3s;
            font-weight: bold;
            margin-top: 10px;
        }
        
        button:hover {
            background: #45a049;
            transform: translateY(-2px);
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
        }
        
        .danger-btn {
            background: #f44336;
        }
        
        .danger-btn:hover {
            background: #d32f2f;
        }
        
        .warning-btn {
            background: #ff9800;
        }
        
        .warning-btn:hover {
            background: #f57c00;
        }
        
        .timer-list {
            margin-top: 20px;
        }
        
        .timer-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            background: rgba(255, 255, 255, 0.1);
            padding: 15px;
            margin-bottom: 10px;
            border-radius: 8px;
            transition: all 0.3s;
        }
        
        .timer-item:hover {
            background: rgba(255, 255, 255, 0.15);
            transform: translateX(5px);
        }
        
        .timer-info {
            flex: 1;
        }
        
        .timer-id {
            font-weight: bold;
            font-size: 1.1rem;
        }
        
        .timer-details {
            display: flex;
            gap: 15px;
            margin-top: 5px;
            font-size: 0.9rem;
            opacity: 0.8;
        }
        
        .timer-actions {
            display: flex;
            gap: 10px;
        }
        
        .timer-actions button {
            margin: 0;
            padding: 8px 15px;
            width: auto;
        }
        
        .status {
            padding: 5px 10px;
            border-radius: 20px;
            font-size: 0.8rem;
            font-weight: bold;
        }
        
        .status-active {
            background: #4CAF50;
        }
        
        .status-cleared {
            background: #f44336;
        }
        
        .lifecycle-info {
            margin-top: 30px;
            padding: 20px;
            background: rgba(0, 0, 0, 0.3);
            border-radius: 10px;
        }
        
        .lifecycle-info h3 {
            margin-bottom: 15px;
            color: #fdbb2d;
        }
        
        .lifecycle-info ul {
            padding-left: 20px;
            margin-bottom: 15px;
        }
        
        .lifecycle-info li {
            margin-bottom: 8px;
            line-height: 1.5;
        }
        
        .highlight {
            color: #fdbb2d;
            font-weight: bold;
        }
        
        .component-demo {
            margin-top: 30px;
            padding: 20px;
            border: 2px dashed rgba(255, 255, 255, 0.3);
            border-radius: 10px;
        }
        
        .toggle-btn {
            background: #2196F3;
            width: 100%;
        }
        
        .toggle-btn:hover {
            background: #0b7dda;
        }
        
        .stats {
            display: flex;
            justify-content: space-between;
            margin-top: 30px;
            padding-top: 20px;
            border-top: 1px solid rgba(255, 255, 255, 0.2);
        }
        
        .stat-box {
            text-align: center;
            flex: 1;
        }
        
        .stat-value {
            font-size: 2rem;
            font-weight: bold;
            margin-bottom: 5px;
        }
        
        .stat-label {
            font-size: 0.9rem;
            opacity: 0.8;
        }
        
        .empty-state {
            text-align: center;
            padding: 40px;
            opacity: 0.7;
        }
        
        .pulse {
            animation: pulse 2s infinite;
        }
        
        @keyframes pulse {
            0% { transform: scale(1); }
            50% { transform: scale(1.05); }
            100% { transform: scale(1); }
        }
        
        .progress-bar {
            height: 5px;
            background: rgba(255, 255, 255, 0.2);
            border-radius: 5px;
            margin-top: 10px;
            overflow: hidden;
        }
        
        .progress {
            height: 100%;
            background: #4CAF50;
            width: 0%;
            transition: width 0.5s;
        }
        
        .code-block {
            background: rgba(0, 0, 0, 0.5);
            padding: 15px;
            border-radius: 5px;
            font-family: monospace;
            margin: 15px 0;
            overflow-x: auto;
        }
    </style>
</head>
<body>
    <div id="app">
        <div class="container">
            <header>
                <h1>Vue 3 定时器生命周期管理</h1>
                <p class="subtitle">演示在不同生命周期钩子中创建和清理定时器的最佳实践</p>
            </header>
            
            <div class="content">
                <div>
                    <div class="card">
                        <h2>定时器控制面板</h2>
                        <div class="timer-controls">
                            <div class="control-group">
                                <label for="timerType">定时器类型</label>
                                <select id="timerType" v-model="timerType">
                                    <option value="timeout">setTimeout (一次性)</option>
                                    <option value="interval">setInterval (重复)</option>
                                </select>
                            </div>
                            
                            <div class="control-group">
                                <label for="timerDuration">持续时间 (毫秒)</label>
                                <input type="number" id="timerDuration" v-model.number="timerDuration" min="100" max="100000">
                            </div>
                            
                            <div class="control-group">
                                <label for="timerMessage">定时器消息</label>
                                <input type="text" id="timerMessage" v-model="timerMessage" placeholder="输入定时器执行时显示的消息">
                            </div>
                        </div>
                        
                        <button @click="addTimer" class="pulse">添加定时器</button>
                        <button @click="clearAllTimers" class="danger-btn">清理所有定时器</button>
                    </div>
                    
                    <div class="card">
                        <h2>活动定时器 ({{ activeTimersCount }})</h2>
                        <div class="timer-list">
                            <div v-if="activeTimers.length === 0" class="empty-state">
                                暂无活动定时器
                            </div>
                            <div v-else v-for="timer in activeTimers" :key="timer.id" class="timer-item">
                                <div class="timer-info">
                                    <div class="timer-id">定时器 #{{ timer.id }}</div>
                                    <div class="timer-details">
                                        <span>类型: {{ timer.type === 'timeout' ? 'setTimeout' : 'setInterval' }}</span>
                                        <span>持续时间: {{ timer.duration }}ms</span>
                                        <span>消息: "{{ timer.message }}"</span>
                                    </div>
                                    <div class="progress-bar">
                                        <div class="progress" :style="{ width: timer.progress + '%' }"></div>
                                    </div>
                                </div>
                                <div class="timer-actions">
                                    <span class="status status-active">活动</span>
                                    <button class="danger-btn" @click="clearTimer(timer.id)">清理</button>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
                
                <div>
                    <div class="card">
                        <h2>生命周期演示</h2>
                        <div class="lifecycle-info">
                            <h3>Vue 3 定时器清理最佳实践</h3>
                            <ul>
                                <li><span class="highlight">onMounted</span> - 在组件挂载后创建定时器</li>
                                <li><span class="highlight">onUnmounted</span> - 在组件卸载前清理定时器(必须)</li>
                                <li><span class="highlight">onBeforeUnmount</span> - 在组件卸载前清理定时器的替代方案</li>
                                <li><span class="highlight">watchEffect</span> - 响应式地创建和清理定时器</li>
                                <li><span class="highlight">手动清理</span> - 在需要时手动清理特定定时器</li>
                            </ul>
                            
                            <div class="code-block">
// 最佳实践示例<br>
import { onMounted, onUnmounted, ref } from 'vue'<br><br>

const timerId = ref(null)<br><br>

onMounted(() => {<br>
&nbsp;&nbsp;// 创建定时器<br>
&nbsp;&nbsp;timerId.value = setInterval(() => {<br>
&nbsp;&nbsp;&nbsp;&nbsp;// 定时器逻辑<br>
&nbsp;&nbsp;}, 1000)<br>
})<br><br>

onUnmounted(() => {<br>
&nbsp;&nbsp;// 清理定时器<br>
&nbsp;&nbsp;if (timerId.value) {<br>
&nbsp;&nbsp;&nbsp;&nbsp;clearInterval(timerId.value)<br>
&nbsp;&nbsp;}<br>
})
                            </div>
                        </div>
                        
                        <div class="component-demo">
                            <h3>组件卸载演示</h3>
                            <p>点击按钮切换子组件显示/隐藏,观察控制台输出</p>
                            <button class="toggle-btn" @click="toggleComponent">
                                {{ showChildComponent ? '隐藏' : '显示' }}子组件
                            </button>
                            
                            <div v-if="showChildComponent">
                                <child-component></child-component>
                            </div>
                        </div>
                    </div>
                    
                    <div class="stats">
                        <div class="stat-box">
                            <div class="stat-value">{{ activeTimersCount }}</div>
                            <div class="stat-label">活动定时器</div>
                        </div>
                        <div class="stat-box">
                            <div class="stat-value">{{ clearedTimersCount }}</div>
                            <div class="stat-label">已清理定时器</div>
                        </div>
                        <div class="stat-box">
                            <div class="stat-value">{{ totalTimersCount }}</div>
                            <div class="stat-label">总定时器</div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        const { createApp, ref, onMounted, onUnmounted, computed, onBeforeUnmount } = Vue;
        
        // 子组件演示生命周期
        const ChildComponent = {
            template: `
                <div class="card" style="margin-top: 15px;">
                    <h3>子组件生命周期演示</h3>
                    <p>组件状态: <span style="color: #4CAF50;">已挂载</span></p>
                    <p>定时器ID: {{ timerId || '无' }}</p>
                    <p>计时: {{ count }} 秒</p>
                    <button class="warning-btn" @click="stopTimer">停止定时器</button>
                </div>
            `,
            setup() {
                const timerId = ref(null);
                const count = ref(0);
                
                // 在 onMounted 中创建定时器
                onMounted(() => {
                    console.log('子组件已挂载,创建定时器');
                    timerId.value = setInterval(() => {
                        count.value++;
                        console.log(`子组件定时器执行: ${count.value} 秒`);
                    }, 1000);
                });
                
                // 在 onUnmounted 中清理定时器 (最佳实践)
                onUnmounted(() => {
                    console.log('子组件即将卸载,清理定时器');
                    if (timerId.value) {
                        clearInterval(timerId.value);
                        console.log('定时器已清理');
                    }
                });
                
                // 也可以在 onBeforeUnmount 中清理
                onBeforeUnmount(() => {
                    console.log('onBeforeUnmount: 清理定时器');
                });
                
                const stopTimer = () => {
                    if (timerId.value) {
                        clearInterval(timerId.value);
                        timerId.value = null;
                        console.log('手动停止定时器');
                    }
                };
                
                return {
                    timerId,
                    count,
                    stopTimer
                };
            }
        };
        
        const app = createApp({
            components: {
                ChildComponent
            },
            setup() {
                // 定时器数据
                const timers = ref([]);
                const timerIdCounter = ref(1);
                const timerType = ref('timeout');
                const timerDuration = ref(3000);
                const timerMessage = ref('定时器已触发!');
                const showChildComponent = ref(false);
                
                // 计算属性
                const activeTimers = computed(() => 
                    timers.value.filter(t => t.status === 'active')
                );
                
                const activeTimersCount = computed(() => activeTimers.value.length);
                
                const clearedTimersCount = computed(() => 
                    timers.value.filter(t => t.status === 'cleared').length
                );
                
                const totalTimersCount = computed(() => timers.value.length);
                
                // 添加定时器
                const addTimer = () => {
                    if (timerDuration.value < 100) {
                        alert('请输入有效的持续时间(至少100毫秒)');
                        return;
                    }
                    
                    const timerId = timerIdCounter.value++;
                    let timerRef;
                    
                    // 创建定时器对象
                    const timerObj = {
                        id: timerId,
                        type: timerType.value,
                        duration: timerDuration.value,
                        message: timerMessage.value,
                        status: 'active',
                        startTime: Date.now(),
                        progress: 0
                    };
                    
                    // 根据类型设置定时器
                    if (timerType.value === 'timeout') {
                        timerRef = setTimeout(() => {
                            handleTimerCompletion(timerId);
                            console.log(`定时器 #${timerId}: ${timerMessage.value}`);
                        }, timerDuration.value);
                        
                        timerObj.ref = timerRef;
                    } else {
                        timerRef = setInterval(() => {
                            console.log(`定时器 #${timerId}: ${timerMessage.value}`);
                        }, timerDuration.value);
                        
                        timerObj.ref = timerRef;
                    }
                    
                    timers.value.push(timerObj);
                    updateProgressBars();
                };
                
                // 处理定时器完成
                const handleTimerCompletion = (timerId) => {
                    const timer = timers.value.find(t => t.id === timerId);
                    if (timer) {
                        timer.status = 'completed';
                    }
                };
                
                // 清理单个定时器
                const clearTimer = (timerId) => {
                    const timer = timers.value.find(t => t.id === timerId);
                    if (timer && timer.status === 'active') {
                        if (timer.type === 'timeout') {
                            clearTimeout(timer.ref);
                        } else {
                            clearInterval(timer.ref);
                        }
                        timer.status = 'cleared';
                        console.log(`定时器 #${timerId} 已清理`);
                    }
                };
                
                // 清理所有定时器
                const clearAllTimers = () => {
                    if (timers.value.length === 0) {
                        alert('没有活动定时器可清理');
                        return;
                    }
                    
                    if (confirm(`确定要清理所有 ${timers.value.length} 个定时器吗?`)) {
                        timers.value.forEach(timer => {
                            if (timer.status === 'active') {
                                if (timer.type === 'timeout') {
                                    clearTimeout(timer.ref);
                                } else {
                                    clearInterval(timer.ref);
                                }
                                timer.status = 'cleared';
                            }
                        });
                        
                        console.log('所有定时器已清理');
                    }
                };
                
                // 更新进度条
                const updateProgressBars = () => {
                    const activeTimersList = timers.value.filter(t => t.status === 'active');
                    
                    activeTimersList.forEach(timer => {
                        const elapsed = Date.now() - timer.startTime;
                        const progress = Math.min(100, (elapsed / timer.duration) * 100);
                        timer.progress = progress;
                        
                        // 如果是interval类型,进度条会循环
                        if (timer.type === 'interval' && progress >= 100) {
                            timer.startTime = Date.now();
                        }
                    });
                };
                
                // 切换子组件显示
                const toggleComponent = () => {
                    showChildComponent.value = !showChildComponent.value;
                };
                
                // 设置一个定时器来更新进度条
                onMounted(() => {
                    setInterval(updateProgressBars, 100);
                });
                
                return {
                    timers,
                    timerType,
                    timerDuration,
                    timerMessage,
                    showChildComponent,
                    activeTimers,
                    activeTimersCount,
                    clearedTimersCount,
                    totalTimersCount,
                    addTimer,
                    clearTimer,
                    clearAllTimers,
                    toggleComponent
                };
            }
        });
        
        app.mount('#app');
    </script>
</body>
</html>

搞懂虚拟列表实现原理与步骤

虚拟列表已经说烂了,此篇文章仅作记录使用,通俗的拆解每一步的逻辑和每一个变量的意义。

一、原理和基本构成

首先,虚拟滚动就是为了解决渲染大量数据到页面上造成的性能问题,一千个dom元素同时渲染到页面上必然出现卡顿,但是一千条或者一万条真的渲染出来了,一般的显示器也是显示不出来的,那我们能不能只渲染可视区域中出现的数据缩小渲染数据量呢,比如我的可视区域只能展示十条,那我把这十条拿出来只渲染这十条不就好了。那这种虚拟列表就应运而生了。

他的结构如下图:

image.png

没看懂没关系,它的结构代码如下:

<template>
 <!-- 可视区域 -->
  <div class="virtua_main">
    <!-- 虚拟元素 -->
    <div class="occupy_pace"></div>
     <!-- 内容区域 -->
    <div class="virtua_content">
     
      <p class="virtua_item">
        item1
      </p>
      
    </div>
    
  </div>
</template>

<style scoped>
.virtua_main {
  width: 500px;
  height: 500px;
  overflow-y: auto;
  position: relative;
  background: greenyellow;
  color: red;
}

.virtua_content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtua_item {
  margin: 0;
  border: 1px solid #e0e0e0;
  box-sizing: border-box;
}
</style>

首先明确,可视区域(virtua_main)的高度是固定的,超出高度出现滚动条,所以需要一个虚拟元素(occupy_pace)撑起高度从而显示滚动条,而且滚动高度要与数据条数相匹配。内容区域(virtua_content)通过绝对定位覆盖在虚拟元素之上。

二、需要思考的几个数字

到目前为止,只有可视区域的高度是固定的,那么内容区域的高度呢,虚拟元素的高度呢;下面列阵:

内容区域的高度 = 要展示的条数 * 每一条内容的高度

虚拟元素的高度 = 需要渲染数组的总长度 * 每一条内容的高度

截取展示内容数据开始的索引 = 滚动条移动的高度 / 每一条内容的高度

截取展示内容数据结束的索引 = 截取展示内容数据开始的索引 + 要展示的条数

三、代码实现基础逻辑

虽然但是那我还是不知道,要展示的条数、每一条内容的高度、滚动条移动的高度、需要渲染数组的总长度从哪里来呢?那还是直接上代码吧🙄🙄🙄

<template>
  <!-- 可视区域 -->
  <div class="virtua_main" @scroll="onScroll">
    <!-- 虚拟元素 -->
    <div class="occupy_pace" :style="{ height: virtualHeight }"></div>
    <!-- 内容区域 -->
    <div
      class="virtua_content"
      :style="{ height: contentHeight, '--row-height': rowHeight + 'px' }"
    >
      <p class="virtua_item" v-for="item in visibleItems" :key="item.id">
        {{ item.label }}
      </p>
    </div>
  </div>
</template>

<script setup>
import { ref } from "vue";
//模拟一千条数据
const allList = ref(
  Array.from({ length: 1000 }).map((_, index) => ({
    id: index,
    label: `Item ${index + 1}`,
  }))
);
//可视区域显示条数
const showSize = ref(10);
//每行高度
const rowHeight = ref(50);
//当前滚动高度
const scrollTop = ref(0);
//虚拟元素高度
const virtualHeight = computed(() => {
  return allList.value.length * rowHeight.value + "px";
});
//内容区高度
const contentHeight = computed(() => {
  return showSize.value * rowHeight.value + "px";
});
//截取展示内容数据开始的索引
const startIndex = computed(() => {
  //开始的索引要考虑边界问题,不能比0小,同时不能大于数据总长度减显示条数
  //使用floor向下取整数
  const index = Math.floor(scrollTop.value / rowHeight.value);
  const maxStartIndex = Math.max(0, allList.value.length - showSize.value);
  return Math.min(index, maxStartIndex);
});
//截取展示内容数据结束的索引
const endIndex = computed(() => {
  //也有边界问题,不能大于数据总长度
  return Math.min(startIndex.value + showSize.value, allList.value.length);
});
//内容区域展示的数据,从所有数据中截取showSize条
const visibleItems = computed(() => {
  return allList.value.slice(startIndex.value, endIndex.value);
});
//滚动事件
const onScroll = (event) => {
  //将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
  scrollTop.value = event.target.scrollTop;
};
</script>

<style scoped>
.virtua_main {
  width: 300px;
  height: 500px;
  overflow-y: auto;
  position: relative;
  background: #e0e0e0;
  color: red;
}

.virtua_content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtua_item {
  height: var(--row-height);
  margin: 0;
  border: 1px solid #000;
  box-sizing: border-box;
}
</style>

这样更直观的回答了数据从哪来、如何计算、如何使用、何时更新的问题。但是但是但是如果这样写了之后会发现一个问题,内容数据确实一直在变,但是内容区域怎么上去了,位置不对,如图:

嘻嘻嘻.gif

上图出现的原因是因为虽然内容区域已经开启了定位,但是它处于virtua_main的滚动空间之中,所以也会随着滚动,那这样,既然他要向上滚动,那我们就再加一步,手动通过transform:translateY()把他纵向向下移动,那么应该移动的距离怎么计算呢,理一理,比如当前从0到19展示了10条,我们向下滚动到1此时0隐藏了,那他是不是就向上滚动了1 * rowHeight的距离呢,1是当前切割数据的开始索引,由此推断 位移距离 = startIndex * rowHeight==

在滚动事件增加这么一行

//通过ref的方式取到内容区域
const virtuaContent = ref(null)
//滚动事件
const onScroll = (event) => {
  //将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
  scrollTop.value = event.target.scrollTop;
  //向下位移掩盖空白
  virtuaContent.value.style.transform = `translateY(${
    startIndex.value * rowHeight.value
  }px)`;
};

目前为止,完成了一个基础的虚拟滚动。

四、细节问题

1.空白

大家是否想过,如果我滚动高度挪到了一个不能整除rowHeight的数字怎么办,当然我们使用了Math.floor向下取整,比如我挪动到了624,我设置的rowHeight是50,startIndex = 624 / 50结果是12.48,Math.floor(12.48)得出12,很好数字没有问题,但是我们使用transform进行位移是用12 * 50得600,也就是说我向上滚动了624px但是向下却位移了600px,那尾部自然会出现24px的空白,如图:

image.png

两种解决方案

其一,简单粗暴,算位移距离直接用scrollTop:

//通过ref的方式取到内容区域
const virtuaContent = ref(null)
//滚动事件
const onScroll = (event) => {
  //将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
  scrollTop.value = event.target.scrollTop;
  //向下位移掩盖空白
  virtuaContent.value.style.transform = `translateY(${
    (scrollTop.value / rowHeight.value) * rowHeight.value
  }px)`;
};

其二、更简单更粗暴,尾部多切几条数据:

//定义缓冲数据大小
const buffer = 3;
//截取展示内容数据结束的索引
const endIndex = computed(() => {
  //也有边界问题,不能大于数据总长度
  //每次多加载buffer条数据
  return Math.min(
    startIndex.value + showSize.value + buffer,
    allList.value.length
  );
});

这两种方式都能用,但使用 scrollTop 会产生半行高度空白,需要 buffer 补齐

2.加载更多

我们做到这里基本能用了,但是都说了大数据量了,只有一千条吗,我有十万条!一百万!但是一个接口返回给你这个请求的时间是不是有点长呢,那么一页一千条拉到底部继续加载是不是极好的呢,如果再加一个等待效果是不是极好的呢。如果马上滑到底部就发送请求到底部时数据已经加载出来了是不是堪称完美,那我们来实现他。

想一想,我们如何来判断要到底部或者即将到底部了呢,有小伙伴说如果scrollTop如果大于等于虚拟元素的总高不就说明到底了吗,no scrollTop可以理解为虚拟元素顶部到可视区域顶部的距离,还差一个可视区域的高度,所以,scrollTop + clientHeight >= virtualHeight才说明到底了,此时走过来一个老伙伴问,那我怎么才能实现即将到底时就发送求情呢,问得好,比如我们想距离底部100px时就发送请求,我们只要再加100就好了呀,又又又来了个小东西问加载效果呢,问的也好,但是没有刚才好,答:只要在内容区最下方加一条内容用一个状态来控制它的显隐就好了呀。完整版如下:

<template>
  {{ `startindex:${startIndex}` }}&nbsp;&nbsp;&nbsp;&nbsp;{{
    `endIndex:${endIndex}`
  }}&nbsp;&nbsp;&nbsp;&nbsp;{{ `scrollTop:${scrollTop}` }}
  {{ currentPage }}
  <!-- 可视区域 -->
  <div class="virtua_main" @scroll="onScroll">
    <!-- 虚拟元素 -->
    <div class="occupy_pace" :style="{ height: virtualHeight }"></div>
    <!-- 内容区域 -->
    <div
      class="virtua_content"
      ref="virtuaContent"
      :style="{ height: contentHeight, '--row-height': rowHeight + 'px' }"
    >
      <p class="virtua_item" v-for="item in visibleItems" :key="item.id">
        {{ item.label }}
      </p>
      <p v-if="loading" class="virtua_item loading-indicator">加载中。。。</p>
    </div>
  </div>
</template>

<script setup>
import { ref } from "vue";
//模拟一千条数据
const allList = ref(
  Array.from({ length: 1000 }).map((_, index) => ({
    id: index,
    label: `Item ${index + 1}`,
  }))
);
//内容区域元素
const virtuaContent = ref(null);
//可视区域显示条数
const showSize = ref(10);
//每行高度
const rowHeight = ref(50);
//当前滚动高度
const scrollTop = ref(0);
//虚拟元素高度
const virtualHeight = computed(() => {
  return allList.value.length * rowHeight.value + "px";
});
//内容区高度
const contentHeight = computed(() => {
  return showSize.value * rowHeight.value + "px";
});
//截取展示内容数据开始的索引
const startIndex = computed(() => {
  //开始的索引要考虑边界问题,不能比0小,同时不能大于数据总长度减显示条数
  const index = Math.floor(scrollTop.value / rowHeight.value);
  const maxStartIndex = Math.max(0, allList.value.length - showSize.value);
  return Math.min(index, maxStartIndex);
});
//定义缓冲数据大小
const buffer = 3;
//截取展示内容数据结束的索引
const endIndex = computed(() => {
  //也有边界问题,不能大于数据总长度
  //每次多加载buffer条数据
  return Math.min(
    startIndex.value + showSize.value + buffer,
    allList.value.length
  );
});
//内容区域展示的数据,从所有数据中截取showSize条
const visibleItems = computed(() => {
  return allList.value.slice(startIndex.value, endIndex.value);
});
//记录当前页,模拟状态下没啥用,真实情况下要发送给后端一般还要带一个pageSize
const currentPage = ref(1);
//定义加载状态
const loading = ref(false);
//定义距离下边界多少像素时触发加载更多
const loadMoreThreshold = 100;
//模拟一个加载更多数据函数
const loadMoreData = () => {
  loading.value = true;
  setTimeout(() => {
    //模拟网络请求
    allList.value.push(
      ...Array.from({ length: 1000 }).map((_, index) => ({
        id: allList.value.length + index,
        label: `Item ${allList.value.length + index + 1}`,
      }))
    );
    currentPage.value++;
    loading.value = false;
    scrollTop.value -= 10;
  }, 200);
};
//滚动事件
const onScroll = (event) => {
  const target = event.target;
  //将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
  scrollTop.value = target.scrollTop;
  virtuaContent.value.style.transform = `translateY(${
    startIndex.value * rowHeight.value
  }px)`;
  //判断是否触底
  const isBoundary =
    target.scrollTop + target.clientHeight + loadMoreThreshold >=
    virtualHeight.value.replace("px", "");

  if (isBoundary && !loading.value) {
    loadMoreData();
  }
};
</script>

<style scoped>
.virtua_main {
  width: 300px;
  height: 500px;
  overflow-y: auto;
  position: relative;
  background: #e0e0e0;
  color: red;
}

.virtua_content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtua_item {
  height: var(--row-height);
  margin: 0;
  border: 1px solid #000;
  box-sizing: border-box;
}
</style>

四、结语

这个代码很基础,我们可以把它运用到业务场景中,封装一个组件,或者把计算逻辑抽离出hooks,还有可以改为动态高度的版本,并且滚动事件应该添加防抖。有些地方可以更为精简,但是为了一些好兄弟能看的更明白所以这样写了。 代码中还隐藏了一些bug,老伙子们如果发现了可以给我留言,因为第一次写这么长的文章,文中如果有些逻辑不严谨或者错误、还有可以有优化的地方,也欢迎留言指正一起交流。

深入理解 JavaScript 原型机制:从“如何拿到小米 SU7”说起

“孔子是韩国人?”——JS 的原型链不是血缘,而是委托。

在前端开发中,JavaScript 的原型(prototype)机制常常让人困惑又着迷。它不像 Java、C++ 那样基于“类”的继承,而是一种基于原型的委托式面向对象模型。今天,我们就用一个轻松的例子——“如何拿到一辆小米 SU7”——来揭开原型机制的神秘面纱。


一、构造函数:造车的第一步

假设你想拥有一辆小米 SU7,首先得有个“造车蓝图”。在 JS 中,这个蓝图就是构造函数

Javascript
编辑
1function Car(color) {
2    this.color = color;
3}

当你执行 new Car('霞光紫') 时,JS 引擎会:

  1. 创建一个新对象;
  2. 将 this 指向这个新对象;
  3. 执行构造函数中的代码(如设置 color);
  4. 返回这个新对象。

此时,你得到了一辆“独属于你”的 SU7,颜色是霞光紫。

但问题来了:每辆车都要有 drive 方法、长宽高参数吗? 如果每个实例都存一份,岂不浪费内存?


二、prototype:共享的“公共配置”

于是 JS 引入了 prototype(原型) ——所有实例共享的方法和属性存放地。

Javascript
编辑
1Car.prototype = {
2    name: 'su7',
3    height: 1.4,
4    weight: 1.5,
5    long: 4800,
6    drive() {
7        console.log('drive, 下赛道');
8    }
9};

现在,无论你创建多少辆 SU7,它们都共享同一份原型对象。调用 car1.drive() 时,JS 会在 car1 自身找不到 drive,就沿着 __proto__ 链向上查找,最终在 Car.prototype 上找到。

关键点

  • 实例属性(如 color)存在实例自身;
  • 共享方法/属性(如 drivename)存在原型上;
  • 查找顺序:实例 → 原型 → 原型的原型 → … → Object.prototype → null

三、原型链的本质:委托,而非继承

很多人误以为 JS 是“类继承”,其实它是对象委托

  • 传统 OOP(如 Java):子类“继承”父类,形成血缘关系。
  • JS 原型式 OOP:对象“委托”给另一个对象,说:“你有这方法吗?借我用用。”

比如:

Javascript
编辑
1var obj = {};
2console.log(obj.toString()); // 能调用!

obj 自己没有 toString,但它通过 __proto__ 委托给了 Object.prototype,于是成功调用。

这种设计更灵活:你可以随时修改原型,所有实例立即“感知”到变化(除非实例自己覆盖了该属性)。


四、constructor 与原型的“双向绑定”

每个函数都有 prototype 属性,而 prototype 对象默认有一个 constructor 属性,指回构造函数:

Javascript
编辑
1Car.prototype.constructor === Car; // true

但注意!如果你直接重写整个 prototype(如 Car.prototype = { ... }),这个链接会被切断:

Javascript
编辑
1Car.prototype = { drive() {} };
2console.log(Car.prototype.constructor === Car); // false! 现在是 Object

修复方式:

Javascript
编辑
1Car.prototype = {
2    constructor: Car, // 手动修复
3    drive() { /*...*/ }
4};

或者使用 Object.defineProperty 设置不可枚举的 constructor


五、多层原型链:Animal → Person → su

JS 支持原型链的嵌套,模拟“多级继承”:

Javascript
编辑
1function Animal() {}
2Animal.prototype.species = '动物';
3
4function Person() {}
5Person.prototype = new Animal(); // Person 委托给 Animal 实例
6
7var su = new Person();
8console.log(su.species); // '动物'

这里 su.__proto__Person.prototype(即一个 Animal 实例),而 Animal 实例的 __proto__ 又指向 Animal.prototype,最终通向 Object.prototype

🌟 这就是为什么 su.toString() 依然有效——原型链一路向上,直到 Object.prototype


六、思考:为什么 JS 选择原型?

  • 动态性:运行时可修改原型,行为实时生效;
  • 轻量:无需预定义“类”,对象可自由扩展;
  • 哲学差异:JS 认为“对象之间可以互相学习”,而非“必须出自某个模板”。

正如道家思想:“上善若水,水善利万物而不争。”
JS 的原型链,也是一种“无为而治”的委托之道。


结语

下次当你写下 new Car() 时,不妨想想:
你不仅创建了一个对象,还开启了一段沿着原型链向上委托的旅程
而那辆小米 SU7,或许不在展厅,而在 Car.prototype 的深处,静静等待被“驱动”。

mescroll老用户亲测z-paging:这些功能让我果断切换!

在uni-app生态中,有两个备受关注的分页组件:z-pagingmescroll。它们都致力于解决列表分页的痛点,但各有特色。今天,我们就来全面介绍一下z-paging,并与mescroll进行深入对比,帮助你做出最佳选择。

什么是z-paging?

z-paging是一款专为uni-app打造的超高性能、全平台兼容的分页组件。它使用wxs+renderjs实现,支持自定义下拉刷新、上拉加载更多、虚拟列表等数百项配置,让列表分页变得异常简单。

核心亮点

  • 配置简单:只需两步——绑定网络请求方法、绑定分页结果数组,就能轻松完成完整的分页功能。
  • 低耦合,低侵入:分页自动管理,在page中无需处理任何分页相关逻辑,无需在data中定义任何分页相关变量。
  • 全平台兼容:支持vue和nvue,vue2和vue3,H5、App、鸿蒙Next以及各家小程序。
  • 功能丰富:支持虚拟列表、本地分页、聊天分页模式、下拉进入二楼、自动管理空数据图等。

z-paging与mescroll全方位对比

架构与实现方式

z-paging使用wxs+renderjs从视图层实现下拉刷新,在app-vue、h5、微信小程序、QQ小程序上具有更高的性能。它主要是一个组件,通过<z-paging>标签即可使用。

mescroll则提供了mescroll-bodymescroll-uni两个组件。mescroll-body使用页面原生滚动,而mescroll-uni基于scroll-view实现,支持局部区域滚动。

平台兼容性

z-paging:专门为uni-app打造,全面支持iOS、Android、H5、微信小程序、QQ小程序、支付宝小程序、字节跳动小程序、快手小程序以及鸿蒙Next。

mescroll:同样支持uni-app全平台,但在不同平台上的实现方式有所区分。

性能表现

z-paging:支持虚拟列表,可以轻松渲染万级甚至百万级数据,在处理大量数据时具有明显优势。

mescroll:mescroll-body使用页面滚动,性能较好;而mescroll-uni在低端机型上处理超长复杂列表时可能会出现卡顿。

使用复杂度

z-paging以简单易用著称,基本使用只需绑定数据和处理分页请求:

<template>
  <z-paging ref="paging" v-model="dataList" @query="queryList">
    <view v-for="(item,index) in dataList" :key="index" class="item">
      <text class="item-title">{{ item.title }}</text>
    </view>
  </z-paging>
</template>

<script>
export default {
  data() {
    return {
      dataList: []
    }
  },
  methods: {
    async queryList(pageNo, pageSize) {
      const params = {
        page: pageNo,
        size: pageSize
      }
      
      try {
        const res = await this.$request.queryList(params)
        this.$refs.paging.complete(res.data.list)
      } catch(e) {
        this.$refs.paging.complete(false)
      }
    }
  }
}
</script>

mescroll需要引入mixin并进行相应配置:

<template>
  <mescroll-body @init="mescrollInit" @down="downCallback" @up="upCallback">
    <view v-for="data in dataList">数据列表...</view>
  </mescroll-body>
</template>

<script>
import MescrollMixin from "@/components/mescroll-uni/mescroll-mixins.js";

export default {
  mixins: [MescrollMixin],
  methods: {
    upCallback(page) {
      // 处理分页逻辑
      this.mescroll.endByPage(curPageLen, totalPage);
    }
  }
}
</script>

功能特性对比

功能点 z-paging mescroll
下拉刷新 支持,可自定义 支持,可自定义
上拉加载 支持 支持
虚拟列表 ✅ 支持 ❌ 不支持
聊天模式 ✅ 支持 ⚠️ 有限支持
本地分页 ✅ 支持 ✅ 支持
返回顶部 ✅ 自动显示 ✅ 支持
空数据图 ✅ 自动管理 ✅ 支持
国际化 ✅ 支持 ❌ 不支持

社区生态与维护

z-paging:在uni-app插件市场拥有较高的热度,持续活跃更新,最近版本在2025年8月发布,及时适配了鸿蒙Next等新平台。

mescroll:有着较长的历史,但在uni-app版本的更新维护上相对较慢,作者已转向重点维护uni版本

快速上手demo

让我们来看一个z-paging的实际使用示例,实现一个简单的列表:

<template>
  <z-paging ref="paging" v-model="dataList" @query="queryList">
    <view v-for="(item,index) in dataList" :key="index" class="item">
      <text class="item-title">{{ item.title }}</text>
    </view>
  </z-paging>
</template>

<script>
export default {
  data() {
    return {
      dataList: []
    }
  },
  methods: {
    async queryList(pageNo, pageSize) {
      const params = {
        page: pageNo,
        size: pageSize
      }
      
      try {
        const res = await this.$request.queryList(params)
        this.$refs.paging.complete(res.data.list)
      } catch(e) {
        this.$refs.paging.complete(false)
      }
    }
  }
}
</script>

看到了吗?就是这么简洁!不需要手动管理页码,不需要处理下拉刷新和上拉加载的各种状态,一切都被z-paging自动处理了。

选择建议:什么场景用哪个?

选择z-paging,如果:

  • 你需要处理大量数据,需要虚拟列表功能
  • 项目涉及聊天界面无限滚动等复杂场景
  • 你希望极简配置,快速上手
  • 项目需要支持鸿蒙Next等最新平台
  • 你重视组件的持续更新和维护

选择mescroll,如果:

  • 你已经在使用mescroll且项目稳定,无需新功能
  • 项目相对简单,不需要虚拟列表等高级功能
  • 你需要使用原生组件(如video、map)并与分页结合

小结

在uni-app分页组件的选择上,z-paging凭借其更高的性能更丰富的功能更积极的维护,在当前阶段确实具有一定优势。特别是其虚拟列表全平台兼容能力,让它能够应对更复杂的业务场景。

mescroll作为一个成熟稳定的方案,对于简单场景和个人项目仍然是一个可靠的选择。

z-paging资源

  • 官方文档:z-paging.zxlee.cn
  • 插件市场地址:在DCloud插件市场搜索"z-paging"

mescroll资源

如果你的项目正在技术选型,不妨两个都试试,根据实际需求做出最佳选择。有什么使用经验,欢迎在评论区交流讨论!

从`new`关键字开始:精通C#类与对象

一、类 -- class关键字

1. 声明一个类

在C#中,我们使用 class 关键字来定义一个类,也就是创建一份“蓝图”。

// 这就是一份最简单的“汽车蓝图”
// public 是一个访问修舍符,意味着这个蓝图在任何地方都可以被看到和使用
public class Car
{
    // 类的成员将在这里定义...
}

恭喜你!你已经创建了你的第一个类。虽然它现在空无一物,但它已经是一个合法的C#类型了。

2. 添加类的成员

  1. 字段(Fields):数据的存储 字段是类内部用来存储数据的变量。通常,为了保护数据,我们会将字段声明为 private

    public class Car
    {
        // 私有字段,用于存储汽车的颜色和当前速度
        // 就像是内部零件,不对外直接暴露
        private string _color;
        private int _speed;
    }
    
  2. 方法(Methods):定义行为的“操作指南” 方法是定义类能做什么的函数。它们是对象的行为。

    public class Car
    {
        // ... 字段 ...
    
        // 公开的方法,定义了汽车的行为
        public void Accelerate()
        {
            _speed += 10;
            Console.WriteLine($"加速!当前速度: {_speed} km/h");
        }
    
        public void Brake()
        {
            _speed -= 10;
            if (_speed < 0) _speed = 0;
            Console.WriteLine($"刹车!当前速度: {_speed} km/h");
        }
    }
    

二、对象实例化 - new关键字

我们有了蓝图,现在需要根据它来建造一辆真正的汽车。这个过程叫做实例化(Instantiation)。我们使用 new 关键字来创建类的实例,也就是对象。

// 声明一个 Car 类型的变量 myCar
// 并使用 new 关键字创建一个 Car 类的实例(对象),然后将其赋给 myCar
Car myCar = new Car();

// 再创建一辆车
Car yourCar = new Car();

// 现在,myCar 和 yourCar 就是两个独立的 Car 对象
// 它们都拥有自己的 _color 和 _speed 字段
// 调用 myCar 的方法,不会影响 yourCar
myCar.Accelerate(); // 输出: 加速!当前速度: 10 km/h
myCar.Accelerate(); // 输出: 加速!当前速度: 20 km/h

yourCar.Brake();    // 输出: 刹车!当前速度: 0 km/h

Console.WriteLine(myCar); // 输出类似: YourProjectName.Car

myCaryourCar是两个引用,它们分别指向内存中两个不同的Car对象。每个对象都有自己独立的一套字段。


三、 类的剖析 - 深入理解核心成员

1. 构造函数(Constructors)

当你使用 new Car() 时,你实际上是在调用一个特殊的方法——构造函数。它的作用是在对象被创建时进行初始化工作,比如为字段设置初始值。

  • 构造函数没有返回类型,连void都没有。
  • 它的名字必须与类名完全相同。
public class Car
{
    private string _color;
    private int _speed;

    // 这是一个构造函数
    // 它在 new Car("Red") 时被调用
    public Car(string color)
    {
        // this 关键字代表当前正在被创建的对象实例
        this._color = color;
        this._speed = 0; // 设定初始速度为0
        Console.WriteLine($"一辆{_color}的汽车被制造出来了!");
    }

    // ... 方法 ...
}

// === 使用构造函数创建对象 ===
Car redCar = new Car("红色");   // 输出: 一辆红色的汽车被制造出来了!
Car blueCar = new Car("蓝色"); // 输出: 一辆蓝色的汽车被制造出来了!

如果你不提供任何构造函数,C#编译器会自动为你生成一个无参数的、空的默认构造函数

2. 属性(Properties)

我们之前将字段设为 private,这是封装原则的体现。但外界如何安全地读取或修改这些数据呢?答案是属性

属性看起来像字段,但内部包含了getset访问器,允许你编写逻辑来控制数据的读写。

public class Car
{
    private string _color;
    private int _speed;

    // 为 _speed 字段创建一个名为 Speed 的公开属性
    public int Speed
    {
        // get 访问器:当读取属性值时执行
        get
        {
            return _speed;
        }
        // set 访问器:当给属性赋值时执行
        set
        {
            // 'value' 是一个关键字,代表赋过来的值
            if (value < 0)
            {
                Console.WriteLine("速度不能为负数!");
            }
            else
            {
                _speed = value;
            }
        }
    }

    // ... 构造函数和方法 ...
}

// === 使用属性 ===
Car myCar = new Car("银色");
myCar.Speed = 50; // 调用 set 访问器,将 50 赋给 value
Console.WriteLine($"当前速度: {myCar.Speed}"); // 调用 get 访问器

myCar.Speed = -10; // 调用 set 访问器,触发验证逻辑
// 输出: 速度不能为负数!

自动属性(Auto-Implemented Properties) 如果你的属性不需要任何特殊的get/set逻辑,C#提供了简洁的语法:

// 编译器会自动在后台创建一个名为<Color>k__BackingField的私有字段
public string Color { get; set; }

四、高级概念 - 深入类的本质

1. static:属于类,而非对象

默认情况下,类的成员(字段、方法等)都是实例成员,每个对象都有一份独立的副本。但有时,我们需要一些成员是所有对象共享的,或者说,是属于类本身的。这时,我们使用 static 关键字。

public class Car
{
    // 静态字段:所有Car对象共享这一个字段
    public static int NumberOfCarsProduced = 0;

    public string Color { get; set; }

    public Car(string color)
    {
        this.Color = color;
        NumberOfCarsProduced++; // 每制造一辆车,就让共享的计数器加一
    }

    // 静态方法:可以直接通过类名调用,无需创建对象
    public static void DisplayProductionInfo()
    {
        Console.WriteLine($"总共生产了 {NumberOfCarsProduced} 辆汽车。");
    }
}

// === 使用静态成员 ===
Car.DisplayProductionInfo(); // 输出: 总共生产了 0 辆汽车。

Car car1 = new Car("红色");
Car car2 = new Car("黑色");

Car.DisplayProductionInfo(); // 输出: 总共生产了 2 辆汽车。

2. 值类型(Value Types)与引用类型(Reference Types)

  • 引用类型(class:

    • 变量存储的是一个引用(地址),指向堆(Heap)内存中对象的实际位置。
    • 将一个引用类型变量赋给另一个,只会复制引用,它们指向同一个对象。
    Car carA = new Car("红色");
    Car carB = carA; // 复制引用,carA 和 carB 指向同一个对象
    
    carB.Color = "蓝色";
    Console.WriteLine(carA.Color); // 输出: 蓝色
    
  • 值类型(struct, int, double, bool等):

    • 变量直接存储数据本身,通常在栈(Stack)内存中。
    • 将一个值类型变量赋给另一个,会创建数据的完整副本
    int a = 10;
    int b = a; // 复制值,a 和 b 是两个独立的 10
    
    b = 20;
    Console.WriteLine(a); // 输出: 10
    

五、类的组织与关系

1. 命名空间(Namespaces)

当项目变大,类的数量增多,可能会出现命名冲突。命名空间就像姓氏一样,用于组织和区分同名的类。

namespace MyCarFactory.Luxury
{
    public class Car { /* ... */ }
}

namespace MyCarFactory.Utility
{
    public class Car { /* ... */ }
}

2. 继承(Inheritance)

一个类可以从另一个类派生,获得其所有非私有的成员。这是OOP的另一大支柱,我们在此简单提及。

// SportsCar 继承自 Car
public class SportsCar : Car
{
    public void ActivateTurbo()
    {
        // ...
    }
}

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文

适用于 iOS 开发者的 Flutter 3.38:Xcode 与 Swift 集成新技巧

Flutter 3.38 让 iOS 集成比以往任何时候都更顺畅。 全新的 Xcode 工具链、更出色的 Swift 互操作性,以及真正实用的原生插件模式。以下是 iOS 开发者需要了解的关键信息。

image.png

🍎 针对 Swift 开发者的福音:Flutter 3.38 带来的改变

您已经使用 Swift 开发了许多出色的 iOS 应用。您可能会觉得 Flutter 是一个有风险的额外框架,需要学习更多东西,还可能带来可怕的兼容性问题。

但 Flutter 3.38 改变了这种看法。 新版本针对 iOS 的工具链是真正顶级的。Xcode 集成运行良好。Swift 互操作不再让人觉得是“投机取巧”的方案。Dart 可以和平地与您现有的原生代码共存

🛠️ Xcode 集成:实实在在的改进

有了 Flutter 3.38,生成原生的 Xcode 项目不再像是一种临时的解决方案。运行 flutter create 创建的 iOS 项目现在符合最新的 Apple 实践规范。CocoaPods 依赖项得到了妥善管理,构建设置也遵循 Xcode 的约定。

最大的亮点:快速增量构建

Dart 编译步骤不再独立运行,而是深度集成到 Xcode 的构建管道中。这使得在模拟器上使用 Cmd+R 的迭代周期更快。对于大型项目,迭代时间将加快 20% 至 30%

构建缓存也变得更智能。Xcode 能够识别 Dart 的编译产物,因此重建时不会破坏所有内容。冷构建(首次构建)仍然需要一些时间(这是跨平台的特性),但热重建的速度确实非常快。

🕊️ Swift-to-Dart 互操作性:减少样板代码

平台通道(Platform Channels)一直以来都是原生 iOS 和 Flutter 之间的连接方式。3.38 版本显著精简了这一过程。

Pigeon

这是 Flutter 的代码生成工具,可开箱即用地生成类型安全的 Swift API。您无需手动编写 MethodChannel 的样板代码,只需在 Dart 中定义一次 API 接口,然后让 Pigeon 完成其余的生成工作。

// Define your API once  
class BatteryApi {  
@async  
Future<int> getBatteryLevel();  
  
@async  
Future<bool> isLowPowerMode();  
}

Pigeon 可以生成类型安全的 Swift 代码和 Dart 接口存根。这意味着不再有序列化 Bug。您无需再猜测原生层需要什么数据类型。

// Generated Swift automatically conforms to your API
class BatteryPlugin: NSObject, FlutterPlugin, BatteryApi {
  func getBatteryLevel(completion: @escaping (Result<Int, Error>) -> Void) {
    let device = UIDevice.current
    device.isBatteryMonitoringEnabled = true
    let level = Int(device.batteryLevel * 100)
    completion(.success(level))
  }
}

🚀 Pigeon 的实用性与插件开发模式

这确实非常实用。 避免了运行时意外。类型不匹配的问题会在编译时就被 Xcode 检测出来。

插件开发模式 (Native Plugin Development Patterns)

插件模板功能在 3.38 版本中引入,它利用 Swift 最佳实践来帮助 iOS 开发者构建自定义插件。使用命令 flutter create --template=plugin 会创建一个组织结构正确的插件,并附带一个示例应用。

架构符合 Apple 的标准:它兼容 Swift Package Manager、Frameworks 和 CocoaPods。您的插件可以兼容标准的 Flutter 项目,也能兼容现有的原生 iOS 应用

所有生成的代码都保证不会触发空安全问题。 告别可选值(Optional Value)的地狱。生成的接口从底层开始就是完全类型化的。


💡 实际集成建议 (Real-World Integration Advice)

从小处着手。 Flutter 并非总是最佳选择——不要用它重写整个 iOS 应用。相反,让 Flutter 只做一件事——比如一个新的 Tab 页面、一个模态流程,或是一个独立封闭的模块。通过平台通道访问您现有的 Swift 代码。

在 Xcode 中进行性能分析。 使用 Instruments(如 Time Profiler)来分析内存、CPU 和 FPS。虽然 Flutter 提供了不错的性能,但您仍需要及早发现边缘情况。

在真实设备上测试。 模拟器的性能具有误导性。iOS 17 及以上版本在行为上与模拟器有所不同。在真实设备上测试可以立即发现这些问题。


🎯 iOS 开发者的视角 (The iOS Developer’s Perspective)

Flutter 3.38 尊重您的 iOS 技能。 它不要求您放弃原生开发。它在不要求您“孤注一掷”(All In)的情况下,为您提供了生产力上的提升

这是一次实实在在的胜利。

核心要点 (Key Takeaways)

  • 生产就绪和 Apple 规范: Xcode 集成符合 Apple 约定。
  • 构建管道集成: 原生构建管道集成将增量构建速度提升 20% 至 30%
  • Pigeon 自动实现类型安全: 通过 Pigeon 自动生成类型安全的 Swift-Dart 桥接。
  • 插件模板: 开箱即用的 iOS 最佳实践插件模板。
  • 空安全: 杜绝平台通道序列化相关的 Bug。
  • 循序渐进: 迁移到新技术通常是渐进的——一次只集成一个服务。
  • 集成障碍已消除。 在您的下一个功能中试用一下吧。

🔥100+ 天,已全面支持鸿蒙!uView Pro 近期更新盘点及未来计划

uView Pro 开源近三个月以来,收到了良好的反馈和迭代。目前 uView Pro 已经迭代了 40+ 个版本,平均每两天就会发布版本,主要是优化性能、新增\增强组件功能、bug修复、兼容性完善等。

所以目前 uView Pro 在稳定性、功能性与跨平台兼容性方面已经有了良好的表现。主要实现了 APP、鸿蒙、微信、支付宝、头条等小程序平台的兼容,后续也会继续进行迭代。

本文基于最近的 changelog 汇总,面向开发者与项目贡献者,系统介绍新增组件、关键修复、工具能力以及如何在项目中快速体验这些特性,并提供示例代码与资源链接,方便你在实际工程中落地使用。

image.png

一、总体概览

目前最新版本(0.3.16 及此前若干小版本)覆盖三大方向:

  • 平台兼容与 bug 修复:适配更多小程序平台(包括鸿蒙/各小程序支持的完善),修复了 canvas 渲染、表单响应、picker 初始化、组件兼容性等若干跨端问题。
  • 新组件与用户体验优化:推出并增强若干特色组件,如 u-fab(悬浮按钮)、u-textu-loading-popupu-textareau-safe-bottomu-status-baru-root-portal,以满足常见 UI 场景需求。
  • 工具链与框架能力:增强 http 插件与 useCompRelation(组件关系管理 Hooks),使业务层网络请求与复杂组件协作更便捷。

接下来我们把重点放在新增与优化的功能、示例使用以及工程实践建议上。

详情可查看官网及近期更新日志:uviewpro.cn/

二、亮点功能与新增组件(逐个拆解)

1) u-fab(悬浮按钮)

简介:u-fab 是面向移动端常见的悬浮操作入口,支持多种预设定位、拖动吸边(autoStick)以及 gap 属性的精细化配置。该组件在交互与无障碍体验上进行了增强,能兼容多端布局差异。

主要特性:

  • 预设 position(如右下、左下、右中等)便于在不同 UI 布局中快速放置。
  • 支持 gap 的对象式配置(top/right/bottom/left),使 demo 与真实项目兼容性更好。
  • autoStick:拖动后自动吸边,提升交互体验。

示例:

示例(Vue 3 Composition API):

<template>
<u-fab position="right-bottom" :gap="gapObj" :draggable="true" :autoStick="true">
<template #default>
<u-button shape="circle" size="mini" type="primary" @click="onFabClick">
                <u-icon name="thumb-up" size="40"></u-icon>
            </u-button>
</template>
</u-fab>
</template>

<script setup lang="ts">
import { ref } from 'vue';
const gapObj = { top: 20, right: 16, bottom: 16, left: 16 };
function onFabClick() {
uni.showToast({ title: '悬浮按钮点击' });
}
</script>

建议:在移动端应结合 safe area(如 u-safe-bottom)与页面常驻按钮布局谨慎使用 u-fab,避免遮挡关键内容。

更多用法请参考文档:uviewpro.cn/zh/componen…

12.png

2) u-text

简介:u-text 提供更灵活的文字样式与插槽支持,能在长文本、富文本展示场景中替代常规标签并统一样式控制。

主要特性:

  • 支持默认插槽与多种文本截断/换行策略。
  • 更友好的样式穿透能力,方便主题化。

示例:

<!-- 主题颜色文字 -->
<u-text text="主色文字" type="primary"></u-text>

<!-- 拨打电话 -->
<u-text mode="phone" text="15019479320"></u-text>

<!-- 日期格式化 -->
<u-text mode="date" text="1612959739"></u-text>

<!-- 超链接 -->
<u-text mode="link" text="Go to uView Pro docs" href="https://uviewpro.cn"></u-text>

<!-- 姓名脱敏 -->
<u-text mode="name" text="张三三" format="encrypt"></u-text>

<!-- 显示金额 -->
<u-text mode="price" text="728732.32"></u-text>

<!-- 默认插槽 -->
<u-text class="desc">这是一个示例文本,支持自定义插槽与样式</u-text>

更多用法请参考文档:uviewpro.cn/zh/componen…

9.png

3) u-loading-popup

简介:一个可配置的加载弹窗组件,支持多种加载风格与遮罩配置,方便替代项目中散落的 loading 逻辑。

示例(最小用法):

<!-- 默认纵向加载 -->
<u-loading-popup v-model="loading" text="正在加载..." />
<!-- 横向加载 -->
<u-loading-popup v-model="loading" direction="horizontal" text="正在加载..." />

更多用法请参考文档:uviewpro.cn/zh/componen…

11.png

4) u-textarea

简介:独立的 u-textarea 组件从 u-input 中拆分而来,增强了字数统计、伸缩、和独立样式控制能力,满足复杂表单与长文本输入场景。

示例:

<!-- 字数统计 -->
<u-textarea v-model="content" :maxlength="500" count />

<!-- 自动高度 -->
<u-textarea v-model="content" placeholder="请输入内容" autoHeight></u-textarea>

更多用法请参考文档:uviewpro.cn/zh/componen…

13.png

5) u-safe-bottom 与 u-status-bar

用途:与设备安全区(notch/safearea)相关的布局组件,用来保证底部/状态栏的展示在不同平台上都不会被遮挡或错位。适配了多端差异(iOS、Android、不同小程序宿主)。

如果有需要,您可以在任何地方引用它,它会自动判断在并且在 IPhone X 等机型的时候,给元素加上一个适当 底部内边距,在 APP 上,即使您保留了原生安全区占位(offset设置为auto),也不会导致底部出现双倍的空白区域,也即 APP 上 offset 设置为 auto 时。

<template>
  <view>
    ......
    <u-safe-bottom></u-safe-bottom>
  </view>
</template>

更多用法请参考文档:uviewpro.cn/zh/componen…

6) u-root-portal

简介:提供将节点传送到根节点的能力(Portal 模式),适用于模态、全局浮层等需要脱离当前 dom 层级的场景,兼容多端实现细节。

根节点传送组件仅支持微信小程序、支付宝小程序、APP和H5平台,组件会自动根据平台选择合适的实现方式:

这类场景最常见的例子就是全屏的模态框。理想情况下,我们希望触发模态框的按钮和模态框本身的代码是在同一个单文件组件中,因为它们都与组件的开关状态有关。

<u-button type="primary" @click="show = true">显示弹窗</u-button>
<u-root-portal v-if="show">
  <view class="modal">
    <view class="modal-content">
      <text>这是一个全局弹窗</text>
      <u-button @click="show = false">关闭</u-button>
    </view>
  </view>
</u-root-portal>

更多用法请参考文档:uviewpro.cn/zh/componen…

7) 自定义主题

uView Pro 目前可以自定主题色,字体颜色,边框颜色等,所有组件内部的样式,都基于同一套主题,比如您修改了primary主题色,所有用到了primary颜色 的组件都会受影响。

由于 uView 官方版本,组件内部存在许多硬编码颜色配置,无法动态根据 scss 变量,现在,我们可以统一跟随主题配置了。

通过官网主题颜色配置完后,在页面底部下载文件,会得到一个名为uview-pro.theme.scssuview-pro.theme.ts的文件。

配置 scss 变量

/* uni.scss */
@import 'uview-pro/theme.scss';

配置 ts 变量

// main.ts
import { createSSRApp } from 'vue'
import App from './App.vue'
import theme from '@/common/uview-pro.theme'
import uViewPro from 'uview-pro'

export function createApp() {
  const app = createSSRApp(App)
  // 引入uView Pro 主库,及theme主题
  app.use(uViewPro, { theme })
  return {
    app
  }
}

以上步骤完成之后,所有颜色均跟随主题色。

更多用法请参考文档:uviewpro.cn/zh/guide/th…

8.png

8) 自定义样式

uView Pro 默认提供了一套美观且统一的组件样式,但在实际项目开发中,往往需要根据业务需求进行个性化定制。参考自定义主题。

然而,如果仅是需要覆盖组件的默认样式,或增加样式,uView Pro 则支持两种主流的自定义样式方式,灵活满足各种场景:

目前,所有组件均支持 custom-class 样式穿透和 custom-style 内联样式

<view class="my-page">
    <!-- custom-class 样式穿透 -->
    <u-button custom-class="my-btn"></u-button>

    <!-- 自定义内联样式 -->
    <u-button
        custom-style="background: linear-gradient(90deg,#2979ff,#00c6ff);color:#fff;border-radius:8px;"
    ></u-button>
</view>

<style lang="scss">
.my-page {
  :deep(.my-btn) {
    background-color: #2979ff;
    color: #fff;
    border-radius: 8px;
  }
}
</style>

更多用法请参考文档:uviewpro.cn/zh/guide/st…

三、工具链改进与新能力

1) http 插件(httpPlugin)

简介:提供统一的请求封装,支持 TypeScript、Vue3、组合式 API,插件化、全局配置、请求/响应拦截器、请求元信息类型(toast/loading 灵活控制),开箱即用,便于在项目中进行全局化网络管理。。

示例:基本请求

import { http } from 'uview-pro'

// GET
http.get('/api/user', { id: 1 }).then(res => {
  /* ... */
})

// POST
http.post('/api/login', { username: 'xx', password: 'xx' }).then(res => {
  /* ... */
})

// PUT/DELETE
http.put('/api/user/1', { name: 'new' })
http.delete('/api/user/1')

高级:支持请求拦截器、全局错误处理与 meta 配置,适合接入鉴权、重试、限流等策略。

最佳实践:定义拦截器配置 => 注册拦截器 => 统一 API 管理

定义拦截器配置

import type { RequestConfig, RequestInterceptor, RequestMeta, RequestOptions } from 'uview-pro'
import { useUserStore } from '@/store'

// 全局请求配置
export const httpRequestConfig: RequestConfig = {
  baseUrl,
  header: {
    'content-type': 'application/json'
  },
  meta: {
    originalData: true,
    toast: true,
    loading: true
  }
}

// 全局请求/响应拦截器
export const httpInterceptor: RequestInterceptor = {
  request: (config: RequestOptions) => {
    // 请求拦截
    return config
  },
  response: (response: any) => {
    // 响应拦截
    return response.data
  }
}

注册拦截器:

import { createSSRApp } from 'vue'
import uViewPro, { httpPlugin } from 'uview-pro'
import { httpInterceptor, httpRequestConfig } from 'http.interceptor'

export function createApp() {
  const app = createSSRApp(App)

  // 注册uView-pro
  app.use(uViewPro)

  // 注册http插件
  app.use(httpPlugin, {
    interceptor: httpInterceptor,
    requestConfig: httpRequestConfig
  })

  return { app }
}

统一 API 管理

// api/index.ts
import { http } from 'uview-pro'

export const login = data => http.post('/api/login', data,  { meta: { loading: true, toast: true } })
export const getUser = id => http.get('/api/user', { id },  { meta: { loading: false } })

以上示例为经典最佳实践,更多用法请查看 http 插件文档:uviewpro.cn/zh/tools/ht…

2) useCompRelation(组件关系管理 Hooks)

目的:替代传统的 provide/inject 在多平台(尤其是一些小程序宿主)可能存在的兼容问题,提供更可靠的父子组件连接和事件广播机制。

应用场景:复杂表单、级联菜单、带有子项动态增删的组件集合等。

父组件示例(伪代码):

import { useParent } from 'uview-pro';

const { children, broadcast } = useParent('u-dropdown');

// 广播调用子组件函数
broadcast('childFunctionName', { payload });

// 收集所有子组件指定值
function getChildrenValues() {
    let values: any[] = [];
    children.forEach((child: any) => {
        if (child.getExposed?.()?.isChecked.value) {
            values.push(child.getExposed?.()?.name);
        }
    });
}

子组件示例(伪代码):

const { parentExposed, emitToParent } = useChildren('u-dropdown-item', 'u-dropdown');

// 触发父组件的函数
emitToParent('parentFunctionName');

// 获取父组件的变量
const activeColor = computed(() => parentExposed.value?.activeColor);

更多用法请参考组件源码:useCompRelation.ts

3) 提供 llms.txt

llms.txt的作用是什么,一般它用来告诉大模型是否允许抓取网站数据用于训练的文件,类似于 robots.txt 控制爬虫权限,因此 uView Pro 也提供了即时更新的 llms.txt 文件,便于训练大模型,更好的为我们服务,链接如下:

uviewpro.cn/llms.txt

uviewpro.cn/llms-full.t…

四、多脚手架支持

1) create-uni

create-uni 提供一键生成、模板丰富的项目引导能力,旨在增强 uni-app 系列产品的开发体验,官网:uni-helper.cn/create-uni/…

pnpm create uni <项目名称> --ts -m pinia -m unocss -u uview-pro -e

表示:

  • 启用 TypeScript
  • 集成 ESLint 代码规范
  • 启用 pinia
  • 集成 unocss
  • 选择 uview-pro组件库

6.png

如果你想用 create-uni 交互式创建一个项目,请执行以下命令:

pnpm create uni

进入交互式选择界面,选择 uView Pro 模板或组件,其他的相关插件可按需选择:

2.png

image.png

使用 create-uni 快速创建 uView Pro Starter 启动模板,请执行以下命令:

pnpm create uni <项目名称> -t uview-pro-starter

4.png

使用 create-uni 快速创建 uView Pro 完整组件演示模板,请执行以下命令:

pnpm create uni <项目名称> -t uview-pro-demo

5.png

2) unibest

unibest 是目前最火的 uni-app 脚手架,它是菲鸽大佬联同众多 uni-app 开发者共同贡献的 uni-app 框架,集成了最新技术栈和开发工具,官网:unibest.tech/

如果你想用 unibest 和 uView Pro 来创建项目,请执行以下命令:

一行代码创建项目:

pnpm create unibest <项目名称> -t base-uview-pro

1.png

交互式创建项目:

pnpm create unibest

选择 base-uview-pro 模板:

3.png

3) 官方cli

第一种:创建以 javascript 开发的工程

npx degit dcloudio/uni-preset-vue#vite my-vue3-project

第二种:创建以 typescript 开发的工程

npx degit dcloudio/uni-preset-vue#vite-ts my-vue3-project

引入uview—pro组件库即可,不再过多介绍,可参考快速配置:uviewpro.cn/zh/componen…

五、近期修复若干关键问题

  • u-circle-progress 的 canvas 渲染问题已修复,解决了微信小程序 canvas 2D 在不同平台上下文差异导致的绘制异常。
  • u-form 相关多个修复:处理 model 替换导致校验失效、resetFields 修复、u-form-item 样式与光标问题修复,提升表单在小程序端兼容性。
  • picker、index-list、popup 等组件的跨端兼容修复,减少在头条/支付宝/微信等宿主上的差异表现。

这些修复的综合效果是:在多端使用 uView‑Pro 构建页面时,出现的平台差异与边缘 bug 大幅减少,开发成本降低。

六、跨平台支持说明

当前 uView‑Pro 已兼容并在以下平台进行适配与测试:

  • 鸿蒙(HarmonyOS)
  • Android(原生应用及 WebView)
  • iOS(原生应用及 WebView)
  • 微信小程序
  • 支付宝小程序
  • 头条小程序

后续仍然会对多端小程序兼容性的持续投入,很多修复直接针对宿主差异展开(例如 Canvas 行为、provide/inject 实现差异、样式差异等)。

近期在鸿蒙6.0系统上运行uView Pro源码,效果还不错,如下:

7.png

七、未来计划

根据规划,未来几个方向包括:

  • 持续优化现有组件,新增组件,提升用户体验;
  • 国际化(i18n)支持:统一组件的语言切换能力,方便多语言产品线接入;
  • 暗黑模式(Dark Mode):与运行时主题切换能力结合,提供暗色皮肤一键切换体验;
  • 优化现有平台兼容性,扩展更多平台的适配测试(保持对小程序宿主的兼容修复);
  • uni-app x 支持:目前还在调研中;
  • mcp 支持。

八、结语

如果你在项目中使用到以上组件或工具,并希望参与贡献,请参考仓库的贡献指南。欢迎提 issue、提交 PR,或在插件市场与社区中反馈使用体验。

❌