阅读视图
升级了SOLO,然后……走不下去了!
CSS 像素≠物理像素:0.5px 效果的核心密码是什么?
救命!这个低代码工具太香了 ——TinyEngine 物料自动导入上手
🚫循环里写return,浏览器当场沉默!
Copilot新模型GPT-5.1太强了!自动生成完美Axios封装,同事都看傻了
根据视频播放秒数,让echarts有交互播放效果
锚点跳转-附带CSS样式 & 阻止页面刷新技术方案
问题:触发浏览器默认锚点行为,首次点击,刷新页面,虽然回到顶部,但未保存数据被清空。
<!-- 原始 -->
<span id="topAnchor"></span>
<!-- 回到顶部按钮 -->
<a href="#topAnchor" class="back-top-btn">
<a-icon type="arrow-up" />
</a>
解决方案:阻止默认行为 + 编程控制
<a @click.prevent="scrollToTop">回到顶部</a>
scrollToTop() {
const anchor = document.getElementById('topAnchor')
if (anchor) {
anchor.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
关键技术点
- @click.prevent - 阻止默认链接行为
- scrollIntoView() - 编程式控制滚动
- behavior: 'smooth' - 添加平滑动画
- URL保持不变 - 避免路由重载
适用场景
- 单页应用(SPA)
- 需要平滑滚动效果
- 希望保持URL稳定的场景
配合CSS:
<a @click.prevent="scrollToTop">回到顶部</a>
scrollToTop() {
const anchor = document.getElementById('topAnchor')
if (anchor) {
anchor.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
// css:回到顶部按钮样式
.back-top-btn {
position: fixed;
right: 80px;
bottom: 100px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #1890ff;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: all 0.3s;
cursor: pointer;
z-index: 1000;
&:hover {
background-color: #40a9ff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
}
效果图
nextjs文件路由、路由组
Next.js路由全解析:Pages Router 与 App Router,你选对了吗?
大家好,我是大鱼,一名热爱前端的普通开发者。
今天我们来深入探讨Next.js中两个重要的路由系统:Pages Router和App Router。
什么是Next.js路由?
在开始深入比较之前,让我们先简单回顾一下Next.js的路由系统。Next.js使用基于文件系统的路由,这意味着你文件目录的结构决定了你应用程序的路由结构。
多年来,Next.js开发者习惯将页面放置在"pages"目录中,这就是我们所说的Pages Router。
后来,Next.js推出了新的App Router,显著改变了我们创建页面的方式。
Pages Router:经典而成熟的选择
基本结构
在Pages Router中,你的项目结构通常是这样:
└── pages
├── about.js
├── index.js
└── team.js
每个JavaScript/TypeScript文件对应一个路由,例如pages/about.js对应/about路由。
特点与使用方式
Pages Router采用"客户端优先"的思维模式,通过getServerSideProps等函数"拉取"服务端能力。数据获取主要在页面级别,集中在getStaticProps/getServerSideProps中。
在Pages Router中,你可以使用useRouterHook来实现页面跳转:
import { useRouter } from 'next/router';
function MyComponent() {
const router = useRouter();
const handleClick = () => {
router.push('/about');
};
return (
<button onClick={handleClick}>
跳转到关于页面
</button>
);
}
与浏览器原生的window.location.href赋值不同,使用useRouter的push方法不会导致整个页面的完全重加载,这对性能更加有利。
App Router:面向未来的新范式
基本结构
App Router引入了全新的目录结构:
src/
└── app
├── about
│ └── page.js
├── globals.css
├── layout.js
├── login
│ └── page.js
├── page.js
└── team
└── route.js
创建应用程序的约定如下:
- 应用中的每个页面都有自己的目录,目录名称定义URL路径
- 浏览器中访问路径时渲染的组件是page.js
- 每个页面的目录中可以放置几个具有保留名称的文件,如
loading.js、template.js和layout.js
核心特性
服务器组件与客户端组件
App Router中最重大的变化是:默认情况下,应用程序目录中的任何组件都是服务器组件。这意味着:
- 服务器组件在服务器上呈现,所有代码都保留在服务器上
- 不能使用客户端功能如window对象或React中的典型钩子
- 可以通过在文件顶部声明"use client"来声明客户端组件
布局系统
App Router原生支持布局,通过layout.js文件实现。布局组件可以应用于多个页面,如果子目录没有单独指定布局,则使用顶级布局。
// layout.js
export default function LoginLayout({ children }) {
return <div className='login-area'>{children}</div>
}
布局会自动应用于其下的所有页面,无需在页面文件中额外指定。
数据获取
App Router引入了组件级数据获取,与Pages Router的页面级数据获取形成鲜明对比。在Server Component中可以直接使用async/await和fetch:
async function BlogPost({ slug }) {
const post = await fetch(`/api/posts/${slug}`).then(res => res.json());
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
深度对比:Pages Router vs App Router
功能特性对比
| 特性 | Pages Router | App Router |
|---|---|---|
| 默认组件类型 | 客户端组件 | 服务器组件 |
| 思维模式 | "客户端优先" | "服务器优先" |
| 数据获取 | 页面级别,集中在getStaticProps/getServerSideProps中 | 组件级别,在各个Server Component内部 |
| 布局系统 | 通过_app.js和getLayout模式实现,非原生 | 原生支持,文件系统结构天然支持嵌套 |
| 客户端JS | 整个页面的所有组件代码都会被发送到客户端 | 只有标记为"use client"的组件及其子组件的JS会被发送 |
性能对比
在性能方面,App Router的架构优势是压倒性的。通过默认使用Server Components,它从根本上解决了客户端JavaScript过大的问题。
学习曲线与生态系统
-
Pages Router:生态系统极其成熟,学习曲线较低,对于有React SPA经验的开发者来说非常熟悉
-
App Router:生态系统正在快速发展中,学习曲线较高,需要理解Server/Client组件的区别等新概念
决策指南:如何选择?
选择 App Router,如果:
- ✅ 你正在开始一个全新的项目,没有历史包袱
- ✅ 性能是你的首要考量,你希望从一开始就构建最快、最轻量的应用
- ✅ 你的应用有复杂的、嵌套的布局和数据依赖关系
- ✅ 你的团队乐于学习和拥抱新技术
- ✅ 你正在构建一个数据密集型、内容驱动的应用(如仪表盘、电商网站、CMS前端)
选择 Pages Router,如果:
- 🟡 你正在维护或迭代一个现有的、基于Pages Router的项目
- 🟡 你的项目高度依赖某个尚未完全支持App Router的第三方库
- 🟡 团队成员对RSC不熟悉,且项目交付时间非常紧张
- 🟡 你的应用是一个非常简单、以交互为主的轻量级SPA,服务端渲染的需求很低
混合模式的可能性
Next.js允许你在同一个项目中同时使用app和pages目录。这为渐进式迁移提供了一条平滑的路径。你可以先在现有项目中引入app目录来开发新功能,逐步体验App Router的优势。
实战建议
从Pages Router迁移到App Router
如果你计划从Pages Router迁移到App Router,可以考虑以下步骤:
- 逐步迁移:利用混合模式,逐步将页面从pages目录迁移到app目录
- 组件适配:将使用React钩子或浏览器API的组件标记为客户端组件
- 数据获取:将getStaticProps/getServerSideProps重构为组件级数据获取
- 布局重构:利用App Router的布局系统简化你的布局代码
开发技巧
- 明确组件边界:在App Router中,要清晰界定服务器组件和客户端组件的职责
- 利用流式渲染:App Router支持流式渲染,可以显著提升用户体验
- 合理使用缓存:App Router提供了更精细的缓存控制机制
结语
App Router无疑是Next.js的未来。它通过React Server Components带来的架构革新,在性能、代码组织和开发体验上都提供了巨大的飞跃。对于新项目,除非有特定的生态限制,否则强烈建议从App Router开始。
希望这篇文章能帮助你理解Next.js中两种路由系统的区别,如果你有任何问题,欢迎在评论区留言讨论!
作者:大鱼,前端开发者,专注于大前端圈技术栈。欢迎关注我的微信公众号<大前端历险记>获取更多前端技术干货。
SCSS新手教学(知识点概览)
scss:sass3以后的新语法,功能完全等价,语法上{ scss使用大括号,sass使用缩进 }不能混用,会报错 并且scss中可以直接写css
Sass有三个版本Dart Sass、libsass和Ruby Sass
npm install -g sass 全局安装sass
scss选择器嵌套语法 避免重复输入父选择器,提高开发效率,减少样式覆盖可能造成的异常问题 属性也可以嵌套,例.container { font: { family: fantasy; size: 30em; weight: bold; } } 等价于.container { font-family: fantasy; font-size: 30em; font-weight: bold; }避免重复输入相同的开头
父选择器& 就是向上找一层,选择上外层的父选择器 用法 &: 即可
注释/* */编译时会被编译到css文件中 注释// 编译时不会编译到css文件中(scss专属注释)
变量 $ 赋值写法与css相同 {
1. 变量以美元符号`$`开头,后面跟变量名;
2. 变量名是不以数字开头的可包含字母、数字、下划线、横线(连接符);
3. 通过连接符`-`与下划线`_`定义的同名变量为同一变量;
4. 变量一定要先定义,后使用;
5. 写法同`css`,即变量名和值之间用冒号`:`分隔;
}
变量作用域:嵌套规则内定义的变量只能在嵌套规则内使用,但声明在最外层的变量可以在任何地方使用,属于全局变量 !global可以将局部变量提升为全局变量
scss数据类型{
number;string;color;boolean;null;数组(用空格或逗号分开);maps(类似于js的object 键值对)
}
!default“温柔默认值”,如果没有其他赋值,就使用!default所标注的赋值,有其他赋值就当!default不存在
== != 正常规则
> < >= <= 正常规则
and or not
加减乘除余(纯数字与百分号或单位运算时会自动转化成相应的百分比与单位值)
加还可连接字符串{
1. `+`可用于连接字符串;
2. 如果有引号字符串(位于 + 左侧)连接无引号字符串,运算结果是有引号的;
3. 无引号字符串(位于 + 左侧)连接有引号字符串,运算结果则没有引号。
}
插值语句#{} 规则与js模版字符串类似
@import 导入文件 以下情况下,@import 仅作为普通的css语句,不会导入scss文件:
1. 文件拓展名是`.css`;
2. 文件名以 `http://`开头;
3. 文件名是`url()`;
4. `@import`包含媒体查询。
@media媒体查询增强
@media 指令允许在css规则中嵌套,编译时,@media 将被编译到文件的最外层,包含嵌套的父选择器。
@media允许互相嵌套使用,编译时,scss自动添加 and
mixin 混入 定义可重复使用的样式 例 @mixin block { width: 96%; margin-left: 2%; border-radius: 8px; border: 1px #f6f6f6 solid; } .container { .block { @include block; } } 还可以传入参数 参数还可制定默认值,调用时携带参数则使用携带的参数,不携带则使用默认值
总结
1. `mixin`是可以重复使用的一组`css`声明,有助于减少重复代码,只需声明一次,就可在文件中引用;
2. 混合指令可以包含所有的 `css`规则,绝大部分`scss`规则,可以传递参数,输出多样化的样式;
3. 使用参数时建议加上默认值;
4. `@import`导入局部模块化样式(类似功能、同一组件);
5. `@minix`定义的是可重复使用的样式
@function 用于封装复杂的操作 用法与mixin类似 @function一般用来计算,而mixin用来封装样式
@extend 继承 @function和@mixin都是封装好一个函数,然后后续调用 而@extend则是写好一个样式后,在要调用这个样式的时候@extend+这个样式 可以使用多个@extend 继承也可以嵌套,实现多层继承
@use 代替 @import
@at-root 用来跳出嵌套进行操作
@without和with
默认@at-root只会跳出选择器嵌套,而不能跳出@media或@support,如果要跳出这两种,则需使用@at-root (without: media)或@at-root (without: support),@at-root的关键词有四个:
1. `all`表示所有;
2. `rule`表示常规`css`选择器;
3. `media` 表示`media`;
4. `support`表示`support`(`@support`主要是用于检测浏览器是否支持`css`的某个属性)。
scss内置扩展
color lighten()与 darken()可用于调亮或调暗颜色,opacify()使颜色透明度减少,transparent()使颜色透明度增加,mix()用来混合两种颜色
String 向字符串添加引号的quote()、获取字符串长度的string-length()和将内容插入字符串给定位置的string-insert()
math percentage()将无单元的数值转换为百分比,round()将数字四舍五入为最接近的整数,min()和max()获取几个数字中的最小值或最大值,random()返回一个随机数
list length()返回列表长度,nth()返回列表中的特定项,join()将两个列表连接在一起,append()在列表末尾添加一个值
Map map-get()根据键值获取map中的对应值,map-merge()来将两个map合并成一个新的map,map-values()映射中的所有值
selector selector-append()可以把一个选择符附加到另一个选择符,selector-unify()将两组选择器合成一个复合选择器
vue3学习笔记
1. Vue3简介
-
2020年9月18日,
Vue.js发布版3.0版本,代号:One Piece(n -
截止2023年10月,最新的公开版本为:
3.3.4
1.1. 【性能的提升】
-
打包大小减少
41%。 -
初次渲染快
55%, 更新渲染快133%。 -
内存减少
54%。
1.2.【 源码的升级】
-
使用
Proxy代替defineProperty实现响应式。 -
重写虚拟
DOM的实现和Tree-Shaking。
1.3. 【拥抱TypeScript】
-
Vue3可以更好的支持TypeScript。
1.4. 【新的特性】
-
Composition API(组合API):-
setup -
ref与reactive -
computed与watch......
-
-
新的内置组件:
-
Fragment -
Teleport -
Suspense......
-
-
其他改变:
-
新的生命周期钩子
-
data选项应始终被声明为一个函数 -
移除
keyCode支持作为v-on的修饰符......
-
2. 创建Vue3工程
2.1. 【基于 vue-cli 创建】
点击查看官方文档
备注:目前
vue-cli已处于维护模式,官方推荐基于Vite创建项目。
## 查看@vue/cli版本,确保@vue/cli版本在4.5.0以上
vue --version
## 安装或者升级你的@vue/cli
npm install -g @vue/cli
## 执行创建命令
vue create vue_test
## 随后选择3.x
## Choose a version of Vue.js that you want to start the project with (Use arrow keys)
## > 3.x
## 2.x
## 启动
cd vue_test
npm run serve
2.2. 【基于 vite 创建】(推荐)
vite 是新一代前端构建工具,官网地址:vitejs.cn,vite的优势如下:
- 轻量快速的热重载(
HMR),能实现极速的服务启动。 - 对
TypeScript、JSX、CSS等支持开箱即用。 - 真正的按需编译,不再等待整个应用编译完成。
-
webpack构建 与vite构建对比图如下:
- 具体操作如下(点击查看官方文档)
## 1.创建命令
npm create vue@latest
## 2.具体配置
## 配置项目名称
√ Project name: vue3_test
## 是否添加TypeScript支持
√ Add TypeScript? Yes
## 是否添加JSX支持
√ Add JSX Support? No
## 是否添加路由环境
√ Add Vue Router for Single Page Application development? No
## 是否添加pinia环境
√ Add Pinia for state management? No
## 是否添加单元测试
√ Add Vitest for Unit Testing? No
## 是否添加端到端测试方案
√ Add an End-to-End Testing Solution? » No
## 是否添加ESLint语法检查
√ Add ESLint for code quality? Yes
## 是否添加Prettiert代码格式化
√ Add Prettier for code formatting? No
自己动手编写一个App组件
<template>
<div class="app">
<h1>你好啊!</h1>
</div>
</template>
<script lang="ts">
export default {
name:'App' //组件名
}
</script>
<style>
.app {
background-color: #ddd;
box-shadow: 0 0 10px;
border-radius: 10px;
padding: 20px;
}
</style>
安装官方推荐的vscode插件:
总结:
-
Vite项目中,index.html是项目的入口文件,在项目最外层。 - 加载
index.html后,Vite解析<script type="module" src="xxx">指向的JavaScript。 -
Vue3**中是通过 **createApp函数创建一个应用实例。
2.3. 【一个简单的效果】
Vue3向下兼容Vue2语法,且Vue3中的模板中可以没有根标签
<template>
<div class="person">
<h2>姓名:{{name}}</h2>
<h2>年龄:{{age}}</h2>
<button @click="changeName">修改名字</button>
<button @click="changeAge">年龄+1</button>
<button @click="showTel">点我查看联系方式</button>
</div>
</template>
<script lang="ts">
export default {
name:'App',
data() {
return {
name:'张三',
age:18,
tel:'13888888888'
}
},
methods:{
changeName(){
this.name = 'zhang-san'
},
changeAge(){
this.age += 1
},
showTel(){
alert(this.tel)
}
},
}
</script>
3. Vue3核心语法
3.1. 【OptionsAPI 与 CompositionAPI】
-
Vue2的API设计是Options(配置)风格的。 -
Vue3的API设计是Composition(组合)风格的。
Options API 的弊端
Options类型的 API,数据、方法、计算属性等,是分散在:data、methods、computed中的,若想新增或者修改一个需求,就需要分别修改:data、methods、computed,不便于维护和复用。
Composition API 的优势
可以用函数的方式,更加优雅的组织代码,让相关功能的代码更加有序的组织在一起。
说明:以上四张动图原创作者:大帅老猿
3.2. 【拉开序幕的 setup】
setup 概述
setup是Vue3中一个新的配置项,值是一个函数,它是 Composition API “表演的舞台***”***,组件中所用到的:数据、方法、计算属性、监视......等等,均配置在setup中。
特点如下:
-
setup函数返回的对象中的内容,可直接在模板中使用。 -
setup中访问this是undefined。 -
setup函数会在beforeCreate之前调用,它是“领先”所有钩子执行的。
<template>
<div class="person">
<h2>姓名:{{name}}</h2>
<h2>年龄:{{age}}</h2>
<button @click="changeName">修改名字</button>
<button @click="changeAge">年龄+1</button>
<button @click="showTel">点我查看联系方式</button>
</div>
</template>
<script lang="ts">
export default {
name:'Person',
setup(){
// 数据,原来写在data中(注意:此时的name、age、tel数据都不是响应式数据)
let name = '张三'
let age = 18
let tel = '13888888888'
// 方法,原来写在methods中
function changeName(){
name = 'zhang-san' //注意:此时这么修改name页面是不变化的
console.log(name)
}
function changeAge(){
age += 1 //注意:此时这么修改age页面是不变化的
console.log(age)
}
function showTel(){
alert(tel)
}
// 返回一个对象,对象中的内容,模板中可以直接使用
return {name,age,tel,changeName,changeAge,showTel}
}
}
</script>
setup 的返回值
- 若返回一个对象:则对象中的:属性、方法等,在模板中均可以直接使用**(重点关注)。**
- 若返回一个函数:则可以自定义渲染内容,代码如下:
setup(){
return ()=> '你好啊!'
}
setup 与 Options API 的关系
-
Vue2的配置(data、methos......)中可以访问到setup中的属性、方法。 - 但在
setup中不能访问到Vue2的配置(data、methos......)。 - 如果与
Vue2冲突,则setup优先。
setup 语法糖
setup函数有一个语法糖,这个语法糖,可以让我们把setup独立出去,代码如下:
<template>
<div class="person">
<h2>姓名:{{name}}</h2>
<h2>年龄:{{age}}</h2>
<button @click="changName">修改名字</button>
<button @click="changAge">年龄+1</button>
<button @click="showTel">点我查看联系方式</button>
</div>
</template>
<script lang="ts">
export default {
name:'Person',
}
</script>
<!-- 下面的写法是setup语法糖 -->
<script setup lang="ts">
console.log(this) //undefined
// 数据(注意:此时的name、age、tel都不是响应式数据)
let name = '张三'
let age = 18
let tel = '13888888888'
// 方法
function changName(){
name = '李四'//注意:此时这么修改name页面是不变化的
}
function changAge(){
console.log(age)
age += 1 //注意:此时这么修改age页面是不变化的
}
function showTel(){
alert(tel)
}
</script>
扩展:上述代码,还需要编写一个不写setup的script标签,去指定组件名字,比较麻烦,我们可以借助vite中的插件简化
- 第一步:
npm i vite-plugin-vue-setup-extend -D - 第二步:
vite.config.ts
import { defineConfig } from 'vite'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
export default defineConfig({
plugins: [ VueSetupExtend() ]
})
- 第三步:
<script setup lang="ts" name="Person">
3.3. 【ref 创建:基本类型的响应式数据】
- **作用:**定义响应式变量。
-
语法:
let xxx = ref(初始值)。 - **返回值:**一个
RefImpl的实例对象,简称ref对象或ref,ref对象的value属性是响应式的。 -
注意点:
-
JS中操作数据需要:xxx.value,但模板中不需要.value,直接使用即可。 - 对于
let name = ref('张三')来说,name不是响应式的,name.value是响应式的。
-
<template>
<div class="person">
<h2>姓名:{{name}}</h2>
<h2>年龄:{{age}}</h2>
<button @click="changeName">修改名字</button>
<button @click="changeAge">年龄+1</button>
<button @click="showTel">点我查看联系方式</button>
</div>
</template>
<script setup lang="ts" name="Person">
import {ref} from 'vue'
// name和age是一个RefImpl的实例对象,简称ref对象,它们的value属性是响应式的。
let name = ref('张三')
let age = ref(18)
// tel就是一个普通的字符串,不是响应式的
let tel = '13888888888'
function changeName(){
// JS中操作ref对象时候需要.value
name.value = '李四'
console.log(name.value)
// 注意:name不是响应式的,name.value是响应式的,所以如下代码并不会引起页面的更新。
// name = ref('zhang-san')
}
function changeAge(){
// JS中操作ref对象时候需要.value
age.value += 1
console.log(age.value)
}
function showTel(){
alert(tel)
}
</script>
3.4. 【reactive 创建:对象类型的响应式数据】
-
作用:定义一个响应式对象(基本类型不要用它,要用
ref,否则报错) -
语法:
let 响应式对象= reactive(源对象)。 - **返回值:**一个
Proxy的实例对象,简称:响应式对象。 -
注意点:
reactive定义的响应式数据是“深层次”的。
<template>
<div class="person">
<h2>汽车信息:一台{{ car.brand }}汽车,价值{{ car.price }}万</h2>
<h2>游戏列表:</h2>
<ul>
<li v-for="g in games" :key="g.id">{{ g.name }}</li>
</ul>
<h2>测试:{{obj.a.b.c.d}}</h2>
<button @click="changeCarPrice">修改汽车价格</button>
<button @click="changeFirstGame">修改第一游戏</button>
<button @click="test">测试</button>
</div>
</template>
<script lang="ts" setup name="Person">
import { reactive } from 'vue'
// 数据
let car = reactive({ brand: '奔驰', price: 100 })
let games = reactive([
{ id: 'ahsgdyfa01', name: '英雄联盟' },
{ id: 'ahsgdyfa02', name: '王者荣耀' },
{ id: 'ahsgdyfa03', name: '原神' }
])
let obj = reactive({
a:{
b:{
c:{
d:666
}
}
}
})
function changeCarPrice() {
car.price += 10
}
function changeFirstGame() {
games[0].name = '流星蝴蝶剑'
}
function test(){
obj.a.b.c.d = 999
}
</script>
3.5. 【ref 创建:对象类型的响应式数据】
- 其实
ref接收的数据可以是:基本类型、对象类型。 - 若
ref接收的是对象类型,内部其实也是调用了reactive函数。
<template>
<div class="person">
<h2>汽车信息:一台{{ car.brand }}汽车,价值{{ car.price }}万</h2>
<h2>游戏列表:</h2>
<ul>
<li v-for="g in games" :key="g.id">{{ g.name }}</li>
</ul>
<h2>测试:{{obj.a.b.c.d}}</h2>
<button @click="changeCarPrice">修改汽车价格</button>
<button @click="changeFirstGame">修改第一游戏</button>
<button @click="test">测试</button>
</div>
</template>
<script lang="ts" setup name="Person">
import { ref } from 'vue'
// 数据
let car = ref({ brand: '奔驰', price: 100 })
let games = ref([
{ id: 'ahsgdyfa01', name: '英雄联盟' },
{ id: 'ahsgdyfa02', name: '王者荣耀' },
{ id: 'ahsgdyfa03', name: '原神' }
])
let obj = ref({
a:{
b:{
c:{
d:666
}
}
}
})
console.log(car)
function changeCarPrice() {
car.value.price += 10
}
function changeFirstGame() {
games.value[0].name = '流星蝴蝶剑'
}
function test(){
obj.value.a.b.c.d = 999
}
</script>
3.6. 【ref 对比 reactive】
宏观角度看:
ref用来定义:基本类型数据、对象类型数据;
reactive用来定义:对象类型数据。
- 区别:
ref创建的变量必须使用.value(可以使用volar插件自动添加.value)。
reactive重新分配一个新对象,会失去响应式(可以使用Object.assign去整体替换)。
- 使用原则:
- 若需要一个基本类型的响应式数据,必须使用
ref。- 若需要一个响应式对象,层级不深,
ref、reactive都可以。- 若需要一个响应式对象,且层级较深,推荐使用
reactive。
3.7. 【toRefs 与 toRef】
- 作用:将一个响应式对象中的每一个属性,转换为
ref对象。 - 备注:
toRefs与toRef功能一致,但toRefs可以批量转换。 - 语法如下:
<template>
<div class="person">
<h2>姓名:{{person.name}}</h2>
<h2>年龄:{{person.age}}</h2>
<h2>性别:{{person.gender}}</h2>
<button @click="changeName">修改名字</button>
<button @click="changeAge">修改年龄</button>
<button @click="changeGender">修改性别</button>
</div>
</template>
<script lang="ts" setup name="Person">
import {ref,reactive,toRefs,toRef} from 'vue'
// 数据
let person = reactive({name:'张三', age:18, gender:'男'})
// 通过toRefs将person对象中的n个属性批量取出,且依然保持响应式的能力
let {name,gender} = toRefs(person)
// 通过toRef将person对象中的gender属性取出,且依然保持响应式的能力
let age = toRef(person,'age')
// 方法
function changeName(){
name.value += '~'
}
function changeAge(){
age.value += 1
}
function changeGender(){
gender.value = '女'
}
</script>
3.8. 【computed】
作用:根据已有数据计算出新数据(和Vue2中的computed作用一致)。
<template>
<div class="person">
姓:<input type="text" v-model="firstName"> <br>
名:<input type="text" v-model="lastName"> <br>
全名:<span>{{fullName}}</span> <br>
<button @click="changeFullName">全名改为:li-si</button>
</div>
</template>
<script setup lang="ts" name="App">
import {ref,computed} from 'vue'
let firstName = ref('zhang')
let lastName = ref('san')
// 计算属性——只读取,不修改
/* let fullName = computed(()=>{
return firstName.value + '-' + lastName.value
}) */
// 计算属性——既读取又修改
let fullName = computed({
// 读取
get(){
return firstName.value + '-' + lastName.value
},
// 修改
set(val){
console.log('有人修改了fullName',val)
firstName.value = val.split('-')[0]
lastName.value = val.split('-')[1]
}
})
function changeFullName(){
fullName.value = 'li-si'
}
</script>
3.9.【watch】
- 作用:监视数据的变化(和
Vue2中的watch作用一致) - 特点:
Vue3中的watch只能监视以下四种数据:
ref定义的数据。reactive定义的数据。- 函数返回一个值(
getter函数)。- 一个包含上述内容的数组。
我们在Vue3中使用watch的时候,通常会遇到以下几种情况:
* 情况一
监视ref定义的【基本类型】数据:直接写数据名即可,监视的是其value值的改变。
<template>
<div class="person">
<h1>情况一:监视【ref】定义的【基本类型】数据</h1>
<h2>当前求和为:{{sum}}</h2>
<button @click="changeSum">点我sum+1</button>
</div>
</template>
<script lang="ts" setup name="Person">
import {ref,watch} from 'vue'
// 数据
let sum = ref(0)
// 方法
function changeSum(){
sum.value += 1
}
// 监视,情况一:监视【ref】定义的【基本类型】数据
const stopWatch = watch(sum,(newValue,oldValue)=>{
console.log('sum变化了',newValue,oldValue)
if(newValue >= 10){
stopWatch()
}
})
</script>
* 情况二
监视ref定义的【对象类型】数据:直接写数据名,监视的是对象的【地址值】,若想监视对象内部的数据,要手动开启深度监视。
注意:
若修改的是
ref定义的对象中的属性,newValue和oldValue都是新值,因为它们是同一个对象。若修改整个
ref定义的对象,newValue是新值,oldValue是旧值,因为不是同一个对象了。
<template>
<div class="person">
<h1>情况二:监视【ref】定义的【对象类型】数据</h1>
<h2>姓名:{{ person.name }}</h2>
<h2>年龄:{{ person.age }}</h2>
<button @click="changeName">修改名字</button>
<button @click="changeAge">修改年龄</button>
<button @click="changePerson">修改整个人</button>
</div>
</template>
<script lang="ts" setup name="Person">
import {ref,watch} from 'vue'
// 数据
let person = ref({
name:'张三',
age:18
})
// 方法
function changeName(){
person.value.name += '~'
}
function changeAge(){
person.value.age += 1
}
function changePerson(){
person.value = {name:'李四',age:90}
}
/*
监视,情况一:监视【ref】定义的【对象类型】数据,监视的是对象的地址值,若想监视对象内部属性的变化,需要手动开启深度监视
watch的第一个参数是:被监视的数据
watch的第二个参数是:监视的回调
watch的第三个参数是:配置对象(deep、immediate等等.....)
*/
watch(person,(newValue,oldValue)=>{
console.log('person变化了',newValue,oldValue)
},{deep:true})
</script>
* 情况三
监视reactive定义的【对象类型】数据,且默认开启了深度监视。
<template>
<div class="person">
<h1>情况三:监视【reactive】定义的【对象类型】数据</h1>
<h2>姓名:{{ person.name }}</h2>
<h2>年龄:{{ person.age }}</h2>
<button @click="changeName">修改名字</button>
<button @click="changeAge">修改年龄</button>
<button @click="changePerson">修改整个人</button>
<hr>
<h2>测试:{{obj.a.b.c}}</h2>
<button @click="test">修改obj.a.b.c</button>
</div>
</template>
<script lang="ts" setup name="Person">
import {reactive,watch} from 'vue'
// 数据
let person = reactive({
name:'张三',
age:18
})
let obj = reactive({
a:{
b:{
c:666
}
}
})
// 方法
function changeName(){
person.name += '~'
}
function changeAge(){
person.age += 1
}
function changePerson(){
Object.assign(person,{name:'李四',age:80})
}
function test(){
obj.a.b.c = 888
}
// 监视,情况三:监视【reactive】定义的【对象类型】数据,且默认是开启深度监视的
watch(person,(newValue,oldValue)=>{
console.log('person变化了',newValue,oldValue)
})
watch(obj,(newValue,oldValue)=>{
console.log('Obj变化了',newValue,oldValue)
})
</script>
* 情况四
监视ref或reactive定义的【对象类型】数据中的某个属性,注意点如下:
- 若该属性值不是【对象类型】,需要写成函数形式。
- 若该属性值是依然是【对象类型】,可直接编,也可写成函数,建议写成函数。
结论:监视的要是对象里的属性,那么最好写函数式,注意点:若是对象监视的是地址值,需要关注对象内部,需要手动开启深度监视。
<template>
<div class="person">
<h1>情况四:监视【ref】或【reactive】定义的【对象类型】数据中的某个属性</h1>
<h2>姓名:{{ person.name }}</h2>
<h2>年龄:{{ person.age }}</h2>
<h2>汽车:{{ person.car.c1 }}、{{ person.car.c2 }}</h2>
<button @click="changeName">修改名字</button>
<button @click="changeAge">修改年龄</button>
<button @click="changeC1">修改第一台车</button>
<button @click="changeC2">修改第二台车</button>
<button @click="changeCar">修改整个车</button>
</div>
</template>
<script lang="ts" setup name="Person">
import {reactive,watch} from 'vue'
// 数据
let person = reactive({
name:'张三',
age:18,
car:{
c1:'奔驰',
c2:'宝马'
}
})
// 方法
function changeName(){
person.name += '~'
}
function changeAge(){
person.age += 1
}
function changeC1(){
person.car.c1 = '奥迪'
}
function changeC2(){
person.car.c2 = '大众'
}
function changeCar(){
person.car = {c1:'雅迪',c2:'爱玛'}
}
// 监视,情况四:监视响应式对象中的某个属性,且该属性是基本类型的,要写成函数式
/* watch(()=> person.name,(newValue,oldValue)=>{
console.log('person.name变化了',newValue,oldValue)
}) */
// 监视,情况四:监视响应式对象中的某个属性,且该属性是对象类型的,可以直接写,也能写函数,更推荐写函数
watch(()=>person.car,(newValue,oldValue)=>{
console.log('person.car变化了',newValue,oldValue)
},{deep:true})
</script>
* 情况五
监视上述的多个数据
<template>
<div class="person">
<h1>情况五:监视上述的多个数据</h1>
<h2>姓名:{{ person.name }}</h2>
<h2>年龄:{{ person.age }}</h2>
<h2>汽车:{{ person.car.c1 }}、{{ person.car.c2 }}</h2>
<button @click="changeName">修改名字</button>
<button @click="changeAge">修改年龄</button>
<button @click="changeC1">修改第一台车</button>
<button @click="changeC2">修改第二台车</button>
<button @click="changeCar">修改整个车</button>
</div>
</template>
<script lang="ts" setup name="Person">
import {reactive,watch} from 'vue'
// 数据
let person = reactive({
name:'张三',
age:18,
car:{
c1:'奔驰',
c2:'宝马'
}
})
// 方法
function changeName(){
person.name += '~'
}
function changeAge(){
person.age += 1
}
function changeC1(){
person.car.c1 = '奥迪'
}
function changeC2(){
person.car.c2 = '大众'
}
function changeCar(){
person.car = {c1:'雅迪',c2:'爱玛'}
}
// 监视,情况五:监视上述的多个数据
watch([()=>person.name,person.car],(newValue,oldValue)=>{
console.log('person.car变化了',newValue,oldValue)
},{deep:true})
</script>
3.10. 【watchEffect】
-
官网:立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行该函数。
-
watch对比watchEffect-
都能监听响应式数据的变化,不同的是监听数据变化的方式不同
-
watch:要明确指出监视的数据 -
watchEffect:不用明确指出监视的数据(函数中用到哪些属性,那就监视哪些属性)。
-
-
示例代码:
<template> <div class="person"> <h1>需求:水温达到50℃,或水位达到20cm,则联系服务器</h1> <h2 id="demo">水温:{{temp}}</h2> <h2>水位:{{height}}</h2> <button @click="changePrice">水温+1</button> <button @click="changeSum">水位+10</button> </div> </template> <script lang="ts" setup name="Person"> import {ref,watch,watchEffect} from 'vue' // 数据 let temp = ref(0) let height = ref(0) // 方法 function changePrice(){ temp.value += 10 } function changeSum(){ height.value += 1 } // 用watch实现,需要明确的指出要监视:temp、height watch([temp,height],(value)=>{ // 从value中获取最新的temp值、height值 const [newTemp,newHeight] = value // 室温达到50℃,或水位达到20cm,立刻联系服务器 if(newTemp >= 50 || newHeight >= 20){ console.log('联系服务器') } }) // 用watchEffect实现,不用 const stopWtach = watchEffect(()=>{ // 室温达到50℃,或水位达到20cm,立刻联系服务器 if(temp.value >= 50 || height.value >= 20){ console.log(document.getElementById('demo')?.innerText) console.log('联系服务器') } // 水温达到100,或水位达到50,取消监视 if(temp.value === 100 || height.value === 50){ console.log('清理了') stopWtach() } }) </script>
3.11. 【标签的 ref 属性】
作用:用于注册模板引用。
用在普通
DOM标签上,获取的是DOM节点。用在组件标签上,获取的是组件实例对象。
用在普通DOM标签上:
<template>
<div class="person">
<h1 ref="title1">尚硅谷</h1>
<h2 ref="title2">前端</h2>
<h3 ref="title3">Vue</h3>
<input type="text" ref="inpt"> <br><br>
<button @click="showLog">点我打印内容</button>
</div>
</template>
<script lang="ts" setup name="Person">
import {ref} from 'vue'
let title1 = ref()
let title2 = ref()
let title3 = ref()
function showLog(){
// 通过id获取元素
const t1 = document.getElementById('title1')
// 打印内容
console.log((t1 as HTMLElement).innerText)
console.log((<HTMLElement>t1).innerText)
console.log(t1?.innerText)
/************************************/
// 通过ref获取元素
console.log(title1.value)
console.log(title2.value)
console.log(title3.value)
}
</script>
用在组件标签上:
<!-- 父组件App.vue -->
<template>
<Person ref="ren"/>
<button @click="test">测试</button>
</template>
<script lang="ts" setup name="App">
import Person from './components/Person.vue'
import {ref} from 'vue'
let ren = ref()
function test(){
console.log(ren.value.name)
console.log(ren.value.age)
}
</script>
<!-- 子组件Person.vue中要使用defineExpose暴露内容 -->
<script lang="ts" setup name="Person">
import {ref,defineExpose} from 'vue'
// 数据
let name = ref('张三')
let age = ref(18)
/****************************/
/****************************/
// 使用defineExpose将组件中的数据交给外部
defineExpose({name,age})
</script>
3.12. 【props】
// 定义一个接口,限制每个Person对象的格式 export interface PersonInter { id:string, name:string, age:number } // 定义一个自定义类型Persons export type Persons = Array<PersonInter>
App.vue中代码:<template> <Person :list="persons"/> </template> <script lang="ts" setup name="App"> import Person from './components/Person.vue' import {reactive} from 'vue' import {type Persons} from './types' let persons = reactive<Persons>([ {id:'e98219e12',name:'张三',age:18}, {id:'e98219e13',name:'李四',age:19}, {id:'e98219e14',name:'王五',age:20} ]) </script>
Person.vue中代码:<template> <div class="person"> <ul> <li v-for="item in list" :key="item.id"> {{item.name}}--{{item.age}} </li> </ul> </div> </template> <script lang="ts" setup name="Person"> import {defineProps} from 'vue' import {type PersonInter} from '@/types' // 第一种写法:仅接收 // const props = defineProps(['list']) // 第二种写法:接收+限制类型 // defineProps<{list:Persons}>() // 第三种写法:接收+限制类型+指定默认值+限制必要性 let props = withDefaults(defineProps<{list?:Persons}>(),{ list:()=>[{id:'asdasg01',name:'小猪佩奇',age:18}] }) console.log(props) </script>
3.13. 【生命周期】
-
概念:
Vue组件实例在创建时要经历一系列的初始化步骤,在此过程中Vue会在合适的时机,调用特定的函数,从而让开发者有机会在特定阶段运行自己的代码,这些特定的函数统称为:生命周期钩子 -
规律:
生命周期整体分为四个阶段,分别是:创建、挂载、更新、销毁,每个阶段都有两个钩子,一前一后。
-
Vue2的生命周期创建阶段:
beforeCreate、created挂载阶段:
beforeMount、mounted更新阶段:
beforeUpdate、updated销毁阶段:
beforeDestroy、destroyed -
Vue3的生命周期创建阶段:
setup挂载阶段:
onBeforeMount、onMounted更新阶段:
onBeforeUpdate、onUpdated卸载阶段:
onBeforeUnmount、onUnmounted -
常用的钩子:
onMounted(挂载完毕)、onUpdated(更新完毕)、onBeforeUnmount(卸载之前) -
示例代码:
<template> <div class="person"> <h2>当前求和为:{{ sum }}</h2> <button @click="changeSum">点我sum+1</button> </div> </template> <!-- vue3写法 --> <script lang="ts" setup name="Person"> import { ref, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted } from 'vue' // 数据 let sum = ref(0) // 方法 function changeSum() { sum.value += 1 } console.log('setup') // 生命周期钩子 onBeforeMount(()=>{ console.log('挂载之前') }) onMounted(()=>{ console.log('挂载完毕') }) onBeforeUpdate(()=>{ console.log('更新之前') }) onUpdated(()=>{ console.log('更新完毕') }) onBeforeUnmount(()=>{ console.log('卸载之前') }) onUnmounted(()=>{ console.log('卸载完毕') }) </script>
3.14. 【自定义hook】
-
什么是
hook?—— 本质是一个函数,把setup函数中使用的Composition API进行了封装,类似于vue2.x中的mixin。 -
自定义
hook的优势:复用代码, 让setup中的逻辑更清楚易懂。
示例代码:
-
useSum.ts中内容如下:import {ref,onMounted} from 'vue' export default function(){ let sum = ref(0) const increment = ()=>{ sum.value += 1 } const decrement = ()=>{ sum.value -= 1 } onMounted(()=>{ increment() }) //向外部暴露数据 return {sum,increment,decrement} } -
useDog.ts中内容如下:import {reactive,onMounted} from 'vue' import axios,{AxiosError} from 'axios' export default function(){ let dogList = reactive<string[]>([]) // 方法 async function getDog(){ try { // 发请求 let {data} = await axios.get('https://dog.ceo/api/breed/pembroke/images/random') // 维护数据 dogList.push(data.message) } catch (error) { // 处理错误 const err = <AxiosError>error console.log(err.message) } } // 挂载钩子 onMounted(()=>{ getDog() }) //向外部暴露数据 return {dogList,getDog} } -
组件中具体使用:
<template> <h2>当前求和为:{{sum}}</h2> <button @click="increment">点我+1</button> <button @click="decrement">点我-1</button> <hr> <img v-for="(u,index) in dogList.urlList" :key="index" :src="(u as string)"> <span v-show="dogList.isLoading">加载中......</span><br> <button @click="getDog">再来一只狗</button> </template> <script lang="ts"> import {defineComponent} from 'vue' export default defineComponent({ name:'App', }) </script> <script setup lang="ts"> import useSum from './hooks/useSum' import useDog from './hooks/useDog' let {sum,increment,decrement} = useSum() let {dogList,getDog} = useDog() </script>
4. 路由
4.1. 【对路由的理解】
4.2. 【基本切换效果】
-
Vue3中要使用vue-router的最新版本,目前是4版本。 -
路由配置文件代码如下:
import {createRouter,createWebHistory} from 'vue-router' import Home from '@/pages/Home.vue' import News from '@/pages/News.vue' import About from '@/pages/About.vue' const router = createRouter({ history:createWebHistory(), routes:[ { path:'/home', component:Home }, { path:'/about', component:About } ] }) export default router
-
main.ts代码如下:import router from './router/index' app.use(router) app.mount('#app')
-
App.vue代码如下<template> <div class="app"> <h2 class="title">Vue路由测试</h2> <!-- 导航区 --> <div class="navigate"> <RouterLink to="/home" active-class="active">首页</RouterLink> <RouterLink to="/news" active-class="active">新闻</RouterLink> <RouterLink to="/about" active-class="active">关于</RouterLink> </div> <!-- 展示区 --> <div class="main-content"> <RouterView></RouterView> </div> </div> </template> <script lang="ts" setup name="App"> import {RouterLink,RouterView} from 'vue-router' </script>
4.3. 【两个注意点】
路由组件通常存放在
pages或views文件夹,一般组件通常存放在components文件夹。通过点击导航,视觉效果上“消失” 了的路由组件,默认是被卸载掉的,需要的时候再去挂载。
4.4.【路由器工作模式】
-
history模式优点:
URL更加美观,不带有#,更接近传统的网站URL。缺点:后期项目上线,需要服务端配合处理路径问题,否则刷新会有
404错误。const router = createRouter({ history:createWebHistory(), //history模式 /******/ }) -
hash模式优点:兼容性更好,因为不需要服务器端处理路径。
缺点:
URL带有#不太美观,且在SEO优化方面相对较差。const router = createRouter({ history:createWebHashHistory(), //hash模式 /******/ })
4.5. 【to的两种写法】
<!-- 第一种:to的字符串写法 -->
<router-link active-class="active" to="/home">主页</router-link>
<!-- 第二种:to的对象写法 -->
<router-link active-class="active" :to="{path:'/home'}">Home</router-link>
4.6. 【命名路由】
作用:可以简化路由跳转及传参(后面就讲)。
给路由规则命名:
routes:[
{
name:'zhuye',
path:'/home',
component:Home
},
{
name:'xinwen',
path:'/news',
component:News,
},
{
name:'guanyu',
path:'/about',
component:About
}
]
跳转路由:
<!--简化前:需要写完整的路径(to的字符串写法) -->
<router-link to="/news/detail">跳转</router-link>
<!--简化后:直接通过名字跳转(to的对象写法配合name属性) -->
<router-link :to="{name:'guanyu'}">跳转</router-link>
4.7. 【嵌套路由】
-
编写
News的子路由:Detail.vue -
配置路由规则,使用
children配置项:const router = createRouter({ history:createWebHistory(), routes:[ { name:'zhuye', path:'/home', component:Home }, { name:'xinwen', path:'/news', component:News, children:[ { name:'xiang', path:'detail', component:Detail } ] }, { name:'guanyu', path:'/about', component:About } ] }) export default router -
跳转路由(记得要加完整路径):
<router-link to="/news/detail">xxxx</router-link> <!-- 或 --> <router-link :to="{path:'/news/detail'}">xxxx</router-link> -
记得去
Home组件中预留一个<router-view><template> <div class="news"> <nav class="news-list"> <RouterLink v-for="news in newsList" :key="news.id" :to="{path:'/news/detail'}"> {{news.name}} </RouterLink> </nav> <div class="news-detail"> <RouterView/> </div> </div> </template>
4.8. 【路由传参】
query参数
-
传递参数
<!-- 跳转并携带query参数(to的字符串写法) --> <router-link to="/news/detail?a=1&b=2&content=欢迎你"> 跳转 </router-link> <!-- 跳转并携带query参数(to的对象写法) --> <RouterLink :to="{ //name:'xiang', //用name也可以跳转 path:'/news/detail', query:{ id:news.id, title:news.title, content:news.content } }" > {{news.title}} </RouterLink> -
接收参数:
import {useRoute} from 'vue-router' const route = useRoute() // 打印query参数 console.log(route.query)
params参数
-
传递参数
<!-- 跳转并携带params参数(to的字符串写法) --> <RouterLink :to="`/news/detail/001/新闻001/内容001`">{{news.title}}</RouterLink> <!-- 跳转并携带params参数(to的对象写法) --> <RouterLink :to="{ name:'xiang', //用name跳转 params:{ id:news.id, title:news.title, content:news.title } }" > {{news.title}} </RouterLink> -
接收参数:
import {useRoute} from 'vue-router' const route = useRoute() // 打印params参数 console.log(route.params)
备注1:传递
params参数时,若使用to的对象写法,必须使用name配置项,不能用path。备注2:传递
params参数时,需要提前在规则中占位。
4.9. 【路由的props配置】
作用:让路由组件更方便的收到参数(可以将路由参数作为props传给组件)
{
name:'xiang',
path:'detail/:id/:title/:content',
component:Detail,
// props的对象写法,作用:把对象中的每一组key-value作为props传给Detail组件
// props:{a:1,b:2,c:3},
// props的布尔值写法,作用:把收到了每一组params参数,作为props传给Detail组件
// props:true
// props的函数写法,作用:把返回的对象中每一组key-value作为props传给Detail组件
props(route){
return route.query
}
}
4.10. 【 replace属性】
-
作用:控制路由跳转时操作浏览器历史记录的模式。
-
浏览器的历史记录有两种写入方式:分别为
push和replace:-
push是追加历史记录(默认值)。 -
replace是替换当前记录。
-
-
开启
replace模式:<RouterLink replace .......>News</RouterLink>
4.11. 【编程式导航】
路由组件的两个重要的属性:$route和$router变成了两个hooks
import {useRoute,useRouter} from 'vue-router'
const route = useRoute()
const router = useRouter()
console.log(route.query)
console.log(route.parmas)
console.log(router.push)
console.log(router.replace)
4.12. 【重定向】
-
作用:将特定的路径,重新定向到已有路由。
-
具体编码:
{ path:'/', redirect:'/about' }
5. pinia
5.1【准备一个效果】
5.2【搭建 pinia 环境】
第一步:npm install pinia
第二步:操作src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
/* 引入createPinia,用于创建pinia */
import { createPinia } from 'pinia'
/* 创建pinia */
const pinia = createPinia()
const app = createApp(App)
/* 使用插件 */{}
app.use(pinia)
app.mount('#app')
此时开发者工具中已经有了pinia选项
5.3【存储+读取数据】
-
Store是一个保存:状态、业务逻辑 的实体,每个组件都可以读取、写入它。 -
它有三个概念:
state、getter、action,相当于组件中的:data、computed和methods。 -
具体编码:
src/store/count.ts// 引入defineStore用于创建store import {defineStore} from 'pinia' // 定义并暴露一个store export const useCountStore = defineStore('count',{ // 动作 actions:{}, // 状态 state(){ return { sum:6 } }, // 计算 getters:{} }) -
具体编码:
src/store/talk.ts// 引入defineStore用于创建store import {defineStore} from 'pinia' // 定义并暴露一个store export const useTalkStore = defineStore('talk',{ // 动作 actions:{}, // 状态 state(){ return { talkList:[ {id:'yuysada01',content:'你今天有点怪,哪里怪?怪好看的!'}, {id:'yuysada02',content:'草莓、蓝莓、蔓越莓,你想我了没?'}, {id:'yuysada03',content:'心里给你留了一块地,我的死心塌地'} ] } }, // 计算 getters:{} }) -
组件中使用
state中的数据<template> <h2>当前求和为:{{ sumStore.sum }}</h2> </template> <script setup lang="ts" name="Count"> // 引入对应的useXxxxxStore import {useSumStore} from '@/store/sum' // 调用useXxxxxStore得到对应的store const sumStore = useSumStore() </script><template> <ul> <li v-for="talk in talkStore.talkList" :key="talk.id"> {{ talk.content }} </li> </ul> </template> <script setup lang="ts" name="Count"> import axios from 'axios' import {useTalkStore} from '@/store/talk' const talkStore = useTalkStore() </script>
5.4.【修改数据】(三种方式)
-
第一种修改方式,直接修改
countStore.sum = 666 -
第二种修改方式:批量修改
countStore.$patch({ sum:999, school:'atguigu' }) -
第三种修改方式:借助
action修改(action中可以编写一些业务逻辑)import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { /*************/ actions: { //加 increment(value:number) { if (this.sum < 10) { //操作countStore中的sum this.sum += value } }, //减 decrement(value:number){ if(this.sum > 1){ this.sum -= value } } }, /*************/ }) -
组件中调用
action即可// 使用countStore const countStore = useCountStore() // 调用对应action countStore.incrementOdd(n.value)
5.5.【storeToRefs】
- 借助
storeToRefs将store中的数据转为ref对象,方便在模板中使用。 - 注意:
pinia提供的storeToRefs只会将数据做转换,而Vue的toRefs会转换store中数据。
<template>
<div class="count">
<h2>当前求和为:{{sum}}</h2>
</div>
</template>
<script setup lang="ts" name="Count">
import { useCountStore } from '@/store/count'
/* 引入storeToRefs */
import { storeToRefs } from 'pinia'
/* 得到countStore */
const countStore = useCountStore()
/* 使用storeToRefs转换countStore,随后解构 */
const {sum} = storeToRefs(countStore)
</script>
5.6.【getters】
-
概念:当
state中的数据,需要经过处理后再使用时,可以使用getters配置。 -
追加
getters配置。// 引入defineStore用于创建store import {defineStore} from 'pinia' // 定义并暴露一个store export const useCountStore = defineStore('count',{ // 动作 actions:{ /************/ }, // 状态 state(){ return { sum:1, school:'atguigu' } }, // 计算 getters:{ bigSum:(state):number => state.sum *10, upperSchool():string{ return this. school.toUpperCase() } } }) -
组件中读取数据:
const {increment,decrement} = countStore let {sum,school,bigSum,upperSchool} = storeToRefs(countStore)
5.7.【$subscribe】
通过 store 的 $subscribe() 方法侦听 state 及其变化
talkStore.$subscribe((mutate,state)=>{
console.log('LoveTalk',mutate,state)
localStorage.setItem('talk',JSON.stringify(talkList.value))
})
5.8. 【store组合式写法】
import {defineStore} from 'pinia'
import axios from 'axios'
import {nanoid} from 'nanoid'
import {reactive} from 'vue'
export const useTalkStore = defineStore('talk',()=>{
// talkList就是state
const talkList = reactive(
JSON.parse(localStorage.getItem('talkList') as string) || []
)
// getATalk函数相当于action
async function getATalk(){
// 发请求,下面这行的写法是:连续解构赋值+重命名
let {data:{content:title}} = await axios.get('https://api.uomg.com/api/rand.qinghua?format=json')
// 把请求回来的字符串,包装成一个对象
let obj = {id:nanoid(),title}
// 放到数组中
talkList.unshift(obj)
}
return {talkList,getATalk}
})
6. 组件通信
Vue3组件通信和Vue2的区别:
- 移出事件总线,使用
mitt代替。
-
vuex换成了pinia。 - 把
.sync优化到了v-model里面了。 - 把
$listeners所有的东西,合并到$attrs中了。 -
$children被砍掉了。
常见搭配形式:
6.1. 【props】
概述:props是使用频率最高的一种通信方式,常用与 :父 ↔ 子。
- 若 父传子:属性值是非函数。
- 若 子传父:属性值是函数。
父组件:
<template>
<div class="father">
<h3>父组件,</h3>
<h4>我的车:{{ car }}</h4>
<h4>儿子给的玩具:{{ toy }}</h4>
<Child :car="car" :getToy="getToy"/>
</div>
</template>
<script setup lang="ts" name="Father">
import Child from './Child.vue'
import { ref } from "vue";
// 数据
const car = ref('奔驰')
const toy = ref()
// 方法
function getToy(value:string){
toy.value = value
}
</script>
子组件
<template>
<div class="child">
<h3>子组件</h3>
<h4>我的玩具:{{ toy }}</h4>
<h4>父给我的车:{{ car }}</h4>
<button @click="getToy(toy)">玩具给父亲</button>
</div>
</template>
<script setup lang="ts" name="Child">
import { ref } from "vue";
const toy = ref('奥特曼')
defineProps(['car','getToy'])
</script>
6.2. 【自定义事件】
- 概述:自定义事件常用于:子 => 父。
- 注意区分好:原生事件、自定义事件。
- 原生事件:
- 事件名是特定的(
click、mosueenter等等) - 事件对象
$event: 是包含事件相关信息的对象(pageX、pageY、target、keyCode)
- 事件名是特定的(
- 自定义事件:
- 事件名是任意名称
- 事件对象
$event: 是调用emit时所提供的数据,可以是任意类型!!!
-
示例:
<!--在父组件中,给子组件绑定自定义事件:--> <Child @send-toy="toy = $event"/> <!--注意区分原生事件与自定义事件中的$event--> <button @click="toy = $event">测试</button>//子组件中,触发事件: this.$emit('send-toy', 具体数据)
6.3. 【mitt】
概述:与消息订阅与发布(pubsub)功能类似,可以实现任意组件间通信。
安装mitt
npm i mitt
新建文件:src\utils\emitter.ts
// 引入mitt
import mitt from "mitt";
// 创建emitter
const emitter = mitt()
/*
// 绑定事件
emitter.on('abc',(value)=>{
console.log('abc事件被触发',value)
})
emitter.on('xyz',(value)=>{
console.log('xyz事件被触发',value)
})
setInterval(() => {
// 触发事件
emitter.emit('abc',666)
emitter.emit('xyz',777)
}, 1000);
setTimeout(() => {
// 清理事件
emitter.all.clear()
}, 3000);
*/
// 创建并暴露mitt
export default emitter
接收数据的组件中:绑定事件、同时在销毁前解绑事件:
import emitter from "@/utils/emitter";
import { onUnmounted } from "vue";
// 绑定事件
emitter.on('send-toy',(value)=>{
console.log('send-toy事件被触发',value)
})
onUnmounted(()=>{
// 解绑事件
emitter.off('send-toy')
})
【第三步】:提供数据的组件,在合适的时候触发事件
import emitter from "@/utils/emitter";
function sendToy(){
// 触发事件
emitter.emit('send-toy',toy.value)
}
注意这个重要的内置关系,总线依赖着这个内置关系
6.4.【v-model】
-
概述:实现 父↔子 之间相互通信。
-
前序知识 ——
v-model的本质<!-- 使用v-model指令 --> <input type="text" v-model="userName"> <!-- v-model的本质是下面这行代码 --> <input type="text" :value="userName" @input="userName =(<HTMLInputElement>$event.target).value" > -
组件标签上的
v-model的本质::moldeValue+update:modelValue事件。<!-- 组件标签上使用v-model指令 --> <AtguiguInput v-model="userName"/> <!-- 组件标签上v-model的本质 --> <AtguiguInput :modelValue="userName" @update:model-value="userName = $event"/>AtguiguInput组件中:<template> <div class="box"> <!--将接收的value值赋给input元素的value属性,目的是:为了呈现数据 --> <!--给input元素绑定原生input事件,触发input事件时,进而触发update:model-value事件--> <input type="text" :value="modelValue" @input="emit('update:model-value',$event.target.value)" > </div> </template> <script setup lang="ts" name="AtguiguInput"> // 接收props defineProps(['modelValue']) // 声明事件 const emit = defineEmits(['update:model-value']) </script> -
也可以更换
value,例如改成abc<!-- 也可以更换value,例如改成abc--> <AtguiguInput v-model:abc="userName"/> <!-- 上面代码的本质如下 --> <AtguiguInput :abc="userName" @update:abc="userName = $event"/>AtguiguInput组件中:<template> <div class="box"> <input type="text" :value="abc" @input="emit('update:abc',$event.target.value)" > </div> </template> <script setup lang="ts" name="AtguiguInput"> // 接收props defineProps(['abc']) // 声明事件 const emit = defineEmits(['update:abc']) </script> -
如果
value可以更换,那么就可以在组件标签上多次使用v-model<AtguiguInput v-model:abc="userName" v-model:xyz="password"/>
6.5.【$attrs 】
-
概述:
$attrs用于实现当前组件的父组件,向当前组件的子组件通信(祖→孙)。 -
具体说明:
$attrs是一个对象,包含所有父组件传入的标签属性。注意:
$attrs会自动排除props中声明的属性(可以认为声明过的props被子组件自己“消费”了)
父组件:
<template>
<div class="father">
<h3>父组件</h3>
<Child :a="a" :b="b" :c="c" :d="d" v-bind="{x:100,y:200}" :updateA="updateA"/>
</div>
</template>
<script setup lang="ts" name="Father">
import Child from './Child.vue'
import { ref } from "vue";
let a = ref(1)
let b = ref(2)
let c = ref(3)
let d = ref(4)
function updateA(value){
a.value = value
}
</script>
子组件:
<template>
<div class="child">
<h3>子组件</h3>
<GrandChild v-bind="$attrs"/>
</div>
</template>
<script setup lang="ts" name="Child">
import GrandChild from './GrandChild.vue'
</script>
孙组件:
<template>
<div class="grand-child">
<h3>孙组件</h3>
<h4>a:{{ a }}</h4>
<h4>b:{{ b }}</h4>
<h4>c:{{ c }}</h4>
<h4>d:{{ d }}</h4>
<h4>x:{{ x }}</h4>
<h4>y:{{ y }}</h4>
<button @click="updateA(666)">点我更新A</button>
</div>
</template>
<script setup lang="ts" name="GrandChild">
defineProps(['a','b','c','d','x','y','updateA'])
</script>
6.6. 【parent】
-
概述:
-
$refs用于 :父→子。 -
$parent用于:子→父。
-
-
原理如下:
属性 说明 $refs值为对象,包含所有被 ref属性标识的DOM元素或组件实例。$parent值为对象,当前组件的父组件实例对象。
6.7. 【provide、inject】
-
概述:实现祖孙组件直接通信
-
具体使用:
- 在祖先组件中通过
provide配置向后代组件提供数据 - 在后代组件中通过
inject配置来声明接收数据
- 在祖先组件中通过
-
具体编码:
【第一步】父组件中,使用
provide提供数据<template> <div class="father"> <h3>父组件</h3> <h4>资产:{{ money }}</h4> <h4>汽车:{{ car }}</h4> <button @click="money += 1">资产+1</button> <button @click="car.price += 1">汽车价格+1</button> <Child/> </div> </template> <script setup lang="ts" name="Father"> import Child from './Child.vue' import { ref,reactive,provide } from "vue"; // 数据 let money = ref(100) let car = reactive({ brand:'奔驰', price:100 }) // 用于更新money的方法 function updateMoney(value:number){ money.value += value } // 提供数据 provide('moneyContext',{money,updateMoney}) provide('car',car) </script>注意:子组件中不用编写任何东西,是不受到任何打扰的
【第二步】孙组件中使用
inject配置项接受数据。<template> <div class="grand-child"> <h3>我是孙组件</h3> <h4>资产:{{ money }}</h4> <h4>汽车:{{ car }}</h4> <button @click="updateMoney(6)">点我</button> </div> </template> <script setup lang="ts" name="GrandChild"> import { inject } from 'vue'; // 注入数据 let {money,updateMoney} = inject('moneyContext',{money:0,updateMoney:(x:number)=>{}}) let car = inject('car')
6.8. 【pinia】
参考之前pinia部分的讲解
6.9. 【slot】
1. 默认插槽
父组件中:
<Category title="今日热门游戏">
<ul>
<li v-for="g in games" :key="g.id">{{ g.name }}</li>
</ul>
</Category>
子组件中:
<template>
<div class="item">
<h3>{{ title }}</h3>
<!-- 默认插槽 -->
<slot></slot>
</div>
</template>
2. 具名插槽
父组件中:
<Category title="今日热门游戏">
<template v-slot:s1>
<ul>
<li v-for="g in games" :key="g.id">{{ g.name }}</li>
</ul>
</template>
<template #s2>
<a href="">更多</a>
</template>
</Category>
子组件中:
<template>
<div class="item">
<h3>{{ title }}</h3>
<slot name="s1"></slot>
<slot name="s2"></slot>
</div>
</template>
3. 作用域插槽
-
理解:数据在组件的自身,但根据数据生成的结构需要组件的使用者来决定。(新闻数据在
News组件中,但使用数据所遍历出来的结构由App组件决定) -
具体编码:
父组件中: <Game v-slot="params"> <!-- <Game v-slot:default="params"> --> <!-- <Game #default="params"> --> <ul> <li v-for="g in params.games" :key="g.id">{{ g.name }}</li> </ul> </Game> 子组件中: <template> <div class="category"> <h2>今日游戏榜单</h2> <slot :games="games" a="哈哈"></slot> </div> </template> <script setup lang="ts" name="Category"> import {reactive} from 'vue' let games = reactive([ {id:'asgdytsa01',name:'英雄联盟'}, {id:'asgdytsa02',name:'王者荣耀'}, {id:'asgdytsa03',name:'红色警戒'}, {id:'asgdytsa04',name:'斗罗大陆'} ]) </script>
7. 其它 API
7.1.【shallowRef 与 shallowReactive 】
shallowRef
-
作用:创建一个响应式数据,但只对顶层属性进行响应式处理。
-
用法:
let myVar = shallowRef(initialValue); -
特点:只跟踪引用值的变化,不关心值内部的属性变化。
shallowReactive
-
作用:创建一个浅层响应式对象,只会使对象的最顶层属性变成响应式的,对象内部的嵌套属性则不会变成响应式的
-
用法:
const myObj = shallowReactive({ ... }); -
特点:对象的顶层属性是响应式的,但嵌套对象的属性不是。
总结
通过使用
shallowRef()和shallowReactive()来绕开深度响应。浅层式API创建的状态只在其顶层是响应式的,对所有深层的对象不会做任何处理,避免了对每一个内部属性做响应式所带来的性能成本,这使得属性的访问变得更快,可提升性能。
7.2.【readonly 与 shallowReadonly】
readonly
-
作用:用于创建一个对象的深只读副本。
-
用法:
const original = reactive({ ... }); const readOnlyCopy = readonly(original); -
特点:
- 对象的所有嵌套属性都将变为只读。
- 任何尝试修改这个对象的操作都会被阻止(在开发模式下,还会在控制台中发出警告)。
-
应用场景:
- 创建不可变的状态快照。
- 保护全局状态或配置不被修改。
shallowReadonly
-
作用:与
readonly类似,但只作用于对象的顶层属性。 -
用法:
const original = reactive({ ... }); const shallowReadOnlyCopy = shallowReadonly(original); -
特点:
-
只将对象的顶层属性设置为只读,对象内部的嵌套属性仍然是可变的。
-
适用于只需保护对象顶层属性的场景。
-
7.3.【toRaw 与 markRaw】
toRaw
-
作用:用于获取一个响应式对象的原始对象,
toRaw返回的对象不再是响应式的,不会触发视图更新。官网描述:这是一个可以用于临时读取而不引起代理访问/跟踪开销,或是写入而不触发更改的特殊方法。不建议保存对原始对象的持久引用,请谨慎使用。
何时使用? —— 在需要将响应式对象传递给非
Vue的库或外部系统时,使用toRaw可以确保它们收到的是普通对象 -
具体编码:
import { reactive,toRaw,markRaw,isReactive } from "vue"; /* toRaw */ // 响应式对象 let person = reactive({name:'tony',age:18}) // 原始对象 let rawPerson = toRaw(person) /* markRaw */ let citysd = markRaw([ {id:'asdda01',name:'北京'}, {id:'asdda02',name:'上海'}, {id:'asdda03',name:'天津'}, {id:'asdda04',name:'重庆'} ]) // 根据原始对象citys去创建响应式对象citys2 —— 创建失败,因为citys被markRaw标记了 let citys2 = reactive(citys) console.log(isReactive(person)) console.log(isReactive(rawPerson)) console.log(isReactive(citys)) console.log(isReactive(citys2))
markRaw
-
作用:标记一个对象,使其永远不会变成响应式的。
例如使用
mockjs时,为了防止误把mockjs变为响应式对象,可以使用markRaw去标记mockjs -
编码:
/* markRaw */ let citys = markRaw([ {id:'asdda01',name:'北京'}, {id:'asdda02',name:'上海'}, {id:'asdda03',name:'天津'}, {id:'asdda04',name:'重庆'} ]) // 根据原始对象citys去创建响应式对象citys2 —— 创建失败,因为citys被markRaw标记了 let citys2 = reactive(citys)
7.4.【customRef】
作用:创建一个自定义的ref,并对其依赖项跟踪和更新触发进行逻辑控制。
实现防抖效果(useSumRef.ts):
import {customRef } from "vue";
export default function(initValue:string,delay:number){
let msg = customRef((track,trigger)=>{
let timer:number
return {
get(){
track() // 告诉Vue数据msg很重要,要对msg持续关注,一旦变化就更新
return initValue
},
set(value){
clearTimeout(timer)
timer = setTimeout(() => {
initValue = value
trigger() //通知Vue数据msg变化了
}, delay);
}
}
})
return {msg}
}
组件中使用:
8. Vue3新组件
8.1. 【Teleport】
- 什么是Teleport?—— Teleport 是一种能够将我们的组件html结构移动到指定位置的技术。
<teleport to='body' >
<div class="modal" v-show="isShow">
<h2>我是一个弹窗</h2>
<p>我是弹窗中的一些内容</p>
<button @click="isShow = false">关闭弹窗</button>
</div>
</teleport>
8.2. 【Suspense】
- 等待异步组件时渲染一些额外内容,让应用有更好的用户体验
- 使用步骤:
- 异步引入组件
- 使用
Suspense包裹组件,并配置好default与fallback
import { defineAsyncComponent,Suspense } from "vue";
const Child = defineAsyncComponent(()=>import('./Child.vue'))
<template>
<div class="app">
<h3>我是App组件</h3>
<Suspense>
<template v-slot:default>
<Child/>
</template>
<template v-slot:fallback>
<h3>加载中.......</h3>
</template>
</Suspense>
</div>
</template>
8.3.【全局API转移到应用对象】
app.componentapp.configapp.directiveapp.mountapp.unmountapp.use
8.4.【其他】
-
过渡类名
v-enter修改为v-enter-from、过渡类名v-leave修改为v-leave-from。 -
keyCode作为v-on修饰符的支持。 -
v-model指令在组件上的使用已经被重新设计,替换掉了v-bind.sync。 -
v-if和v-for在同一个元素身上使用时的优先级发生了变化。 -
移除了
$on、$off和$once实例方法。 -
移除了过滤器
filter。 -
移除了
$children实例propert。......
基于Nextjs15的学习手记
基于Nextjs15的学习手记
Next.js 是一个基于 React 的 全栈框架,由 Vercel 维护。它提供了 服务器端渲染(SSR) 、静态站点生成(SSG) 、增量静态再生(ISR) 等特性,使得开发者可以更轻松地构建高性能、SEO 友好的 Web 应用。目前Nextjs 15已经使用React 19版本,服务器组件更加强悍。
一、了解CSR/SSR/SSG
CSR(Client-Side Rendering,客户端渲染)、SSR(Server-Side Rendering,服务器端渲染)和SSG(Static Site Generation,静态站点生成)是构建现代Web应用的不同的渲染方式。主要区别在于 页面的 HTML 何时生成、在哪里生成,以及对 SEO 和性能的影响,每种方式都有其优缺点,适用于不同的场景。
1.CSR(客户端渲染)
将网页的内容生成和渲染都放在客户端(即浏览器)完成。初始的HTML文档中通常只包含基本的骨架和一些静态资源链接,如CSS和JavaScript文件。然后,浏览器会下载这些文件,并在客户端解析和执行JavaScript代码,动态地获取数据,并使用数据来生成和渲染页面的内容。
适用于单页应用(SPA) ,比如 React、Vue 项目
CSR渲染过程:
①.下载初始HTML
②.解析HTML
③.下载和执行JavaScript文件
④.数据获取
⑤.数据处理和页面渲染
⑥.更新DOM
⑦.用户交互和动态更新
优点:
-
富交互性:客户端渲染允许创建富交互性的应用,因为所有的逻辑都在用户的浏览器中执行。
-
减轻服务器负载:服务器只需提供静态文件,大部分工作由客户端完成,减轻了服务器的负载。
-
更快的页面导航:在单页面应用(SPA)中,页面之间的导航可以无需重新加载整个页面,提供了更流畅的用户体验。
缺点:
-
SEO挑战:由于内容是在客户端动态生成的,搜索引擎可能难以抓取页面内容,这可能对SEO造成不利影响。
-
慢首屏加载:客户端渲染通常需要加载JavaScript框架和应用代码,然后才能渲染内容,这可能导致慢首屏加载时间。
-
JavaScript依赖:完全依赖于客户端JavaScript执行,如果用户禁用了JavaScript,或者JavaScript文件加载失败,那么用户将看不到任何内容。
适用场景:
- 后台管理系统(如 CMS、Admin 面板)。
- 需要大量交互的 SPA(如 Gmail、React/Vue App)。
- 对 SEO 要求不高的应用。
2.SSR(服务器端渲染)
在SSR中,服务器会在接收到客户端请求后,执行网页的渲染逻辑,并生成完整的HTML页面。生成的HTML页面包含了所有初始化的数据和已经渲染好的页面内容,然后服务器将该HTML页面发送给客户端浏览器进行展示。
SSR渲染过程:
①.客户端请求
②.路由处理
③.数据获取
④.数据处理和页面渲染
⑤.HTML页面发送
⑥.客户端渲染
⑦.数据绑定和事件处理
⑧.用户交互和动态更新
优点:
-
SEO优化:服务器端渲染的页面可以提供完整的HTML内容,有助于搜索引擎更好地抓取和索引网站,从而提高SEO表现。
-
快速首屏加载:用户可以更快地看到完整渲染的页面,因为内容是在服务器上生成的,这提高了 perceived performance(用户感知的性能)。
-
更好的社交媒体共享:由于页面的元数据(如标题、描述、图片等)在服务器上已经渲染,分享链接时社交媒体平台能够正确显示预览信息。
缺点:
-
服务器负载:服务器端渲染可能会增加服务器的负载,因为每个页面请求都需要服务器处理和渲染。
-
延迟:页面更新或导航可能会有延迟,因为需要从服务器获取新的HTML页面。
-
复杂的构建和部署:SSR应用可能需要特殊的服务器配置和更复杂的构建过程。
适用场景:
-
SEO 要求高的应用(如官网、博客、新闻站点)。
-
内容动态变化的页面(如用户个性化主页)。
3.SSG (静态网站生成)
在构建(build)阶段生成静态HTML文件,将这些文件直接提供给客户端,而无需在每个请求上动态生成内容。
SSG渲染过程:
①.构建阶段
②.生成静态文件
③.服务器提供静态文件
④.客户端渲染
优点:
-
最快的加载速度:用户访问时,直接从 CDN 获取 HTML,加载速度快。
-
SEO 友好:完整 HTML 可被爬虫抓取。
-
服务器压力低:只需要提供静态文件。
缺点:
-
缺少灵活性:无法处理实时数据,页面内容不会随请求变化。
-
更新繁琐:如果数据更新,需要重新构建页面。
适用场景:
- 博客、文档、产品展示页(如 Next.js + Markdown 博客)。
- 新闻站点(非实时) 。
- 静态电商页面(如 Next.js + Shopify)。
4.CSR / SSR / SSG 对比
| 渲染方式 | HTML 生成时机 | 是否支持 SEO | 首屏速度 | 适用场景 |
|---|---|---|---|---|
| CSR | 浏览器端 | ❌ 差 | 🐢 慢 | SPA、后台管理系统 |
| SSR | 请求时 | ✅ 好 | 🚀 快 | SEO 需求高、动态数据 |
| SSG | 构建时 | ✅ 最优 | ⚡ 超快 | 博客、文档、静态页面 |
CSR提供富交互和动态内容,能够实现流畅的用户体验,适用于单页应用和需要复杂前端逻辑的应用。
SSR提供更快的首次加载速度、SEO友好和较好的性能,适用于需要复杂交互和对SEO重视的应用。
SSG提供更快的加载速度、SEO友好和较好的可访问性,适用于内容相对稳定、对SEO要求较高或需要更快加载速度的应用。
5.同构(Isomorphic)渲染
也叫 混合渲染(Hybrid Rendering),结合了SSR和CSR的优点,首次请求由服务器渲染页面,提供快速的首屏加载和良好的SEO,之后的页面交互由客户端接管,提供富交互性。这种方式需要更复杂的配置和架构设计,但可以提供更好的用户体验和性能。
6.ISR(增量静态再生)
ISR(Incremental Static Regeneration),SSG 的增强版,允许部分页面在后台自动更新,无需手动重新构建。
7.汇总
Next.js 是一个 React 框架,可用于构建 SSR、CSR 和 SSG 网站和应用程序,且支持同构渲染和增量静态再生。 以及提供了许多内置功能,例如路由、数据获取和预渲染,Nextjs不仅当前强大,还在不断进化适合企业长期使用。
二、React 19新特性
三、基于React 19的Nextjs 15
- dev:运行next dev启动 Next.js开发模式。
- build:运行next build构建生产应用包。
- start:运行next start启动 Next.js 生产应用包。
- lint:运行next lint设置 Next.js 的内置 ESLint 配置。
1.核心规则
| 组件类型 | 是否需要标记 | 说明 |
|---|---|---|
| 服务端组件 | 不需要标记(默认启用) |
.jsx/.tsx 文件默认就是服务端组件 |
| 客户端组件 | 必须加 'use client'
|
任何需要 useState/Effect/浏览器 API 的组件 |
| 服务端动作 | 必须加 'use server'
|
处理表单提交等服务器端操作的函数 |
CSR客户端组件
文件起头添加use client标识,可以使用浏览器APi(如:localStorage等),添加交互性事件和事件侦听器。
// pages/test01
"use client"
import {useEffect, useState} from "react";
export default function Test01 () {
const [name, setName] = useState('');
useEffect(() => {
fetch(...).then(res => setName(res));
}, [])
return <div>
<div>name: {name}</div>
</div>
}
浏览器第一时间得到的结果没有userName实际值,不利于SEO,结果需要等待请求到真实数据后,通过js操作dom进行回显
SSR服务端组件
.jsx/.tsx 文件默认就是服务端组件,可以使用内置方法getServerSideProps获取服务端数据,并以props形式回传给当前组件
// pages/test02
export default function Test02 ({ name }: { name: string }) {
return <div>
<div>name: {name}</div>
</div>
}
export async function getServerSideProps() {
const name = await fetch(...)
return { props: { name } }
}
在服务器中请求数据后,直接将数据拼接到html中,并将组合好数据的html结构传给浏览器,有利于SEO
RSC服务器组件
React Server Components也是服务端渲染,是基于SSR之上的方案,在/app目录下创建的页面,默认就是RSC服务器组件
// app/page.jsx (默认RSC)
export default function Page() {
// 直接使用 async/await 获取数据、访问数据库
const data = await fetch(...)
return <div>{data}</div>
}
通过流式处理(Suspense + lazy),您可以从服务器逐步呈现 UI。工作被拆分为块,并在客户端准备就绪时流式传输到客户端。这允许用户在整个内容完成渲染之前立即看到页面的某些部分。
通过浏览器插件React Developer Tools,查看当前app/page.jsx页面组件层级,即可看到其底层组件层级。
2.服务端组件和客户端组件使用核规则
尽量整个页面为服务器组件(RSC),即可灵活引入各类型组件。
客户端组件
- 客户端组件子组件必须是客户端组件
// src/components/Header
export default async function Header() {
const data = await fetch(...);
return (
<div>
header
</div>
);
}
// src/pages/page1
"use client"
import Header from "@/components/Header";
export default function Page1() {
return (
<div>
<div>Page1</div>
<Header />
</div>
);
}
Header为服务端组件,作为子组件被客户端组件引用时,页面报错。
-
服务器组件可以作为客户端组件的插槽
-
服务器组件可以作为客户端组件的属性
-
Context组件通信必须在客户端组件中来完成
服务器组件
- 服务器组件和客户端组件都能作为服务器组件子组件
外部依赖组件
- 如果当前页面需要引入外部依赖中的组件报错,可能是外部依赖组件没有使用
use client标识,导致将其作为服务端组件进行使用从而你报错,可创建客户端组件包裹依赖组件即可。
使用 MediaPipe 在 Flutter web 中识别姿势
咱们就来像这样识别视频里的姿势吧!
有一个名为 MediaPipe的库,它可以在图像、文本和声音上完成许多识别和检测任务。其中包括一个用于在图像上识别姿势的模型。
你可以点击这里,试试它的官方演示: mediapipe-studio.webapps.google.com/demo/pose_l…
它还有一个 CodePen 代码片段,方便你用 JavaScript 快速进行实践操作(或“上手”): codepen.io/mediapipe-p…
然而,该模型目前仅支持在 Android、iOS、Python 和 JavaScript 环境下运行,并不能直接在 Flutter 中使用。
有人曾创建了一个名为 flutter_mediapipe 的 package,但它已在四年前被弃用,并且不支持 Web 端。
因此,我们来将官方的 JavaScript 实现封装到我们自己的 Flutter 插件中。
你可以在这里查看第一个截图中的最终演示应用(仅支持 Chrome 浏览器): alexeyinkin.github.io/flutter-med…
下载源代码以便跟着操作(我会跳过一些内容): github.com/alexeyinkin…
🔌 创建插件 (Creating the plugin)
插件(Plugin)是一种特殊的 Dart package,它会根据你编译的目标平台来置换(或“切换”)不同的实现。
📚 学习资源 (References)
这份官方文章是编写插件的优秀教程: docs.flutter.dev/packages-an…
另外,url_launcher 官方 package 的作者也撰写了一篇精彩的入门文章,专门介绍如何编写 Web 插件。它解释了 Flutter 刚开始支持 Web 时,他们是如何首次为该 package 添加 Web 支持的:
- Part 1 解释了基本方法,这与 Android 和 iOS 插件的做法相同,即使用一种称为 Method Channel(方法通道) 的机制,将任务委托给这些平台上的原生代码。
- Part 2 通过移除方法通道简化了流程,因为 Web 实现代码无论如何都是用 Dart 编写的,因此你可以直接调用特定实现的方法。
这两篇文章都只使用了标准的浏览器 API,没有调用任何自定义的 JavaScript。因此,本文将在它们的基础上构建,并增加了导入和调用自定义 JavaScript 的功能。
遵循最新的 url_launcher 文章中的架构,我创建了三个 Dart packages:
-
flutter_mediapipe_vision是主 package。所有想要在图像上识别姿势的应用都需要且仅需将其添加为依赖项。它会引入其他 package 作为依赖,并根据平台将调用转发给特定的实现。同时,Flutter 会对其他平台的实现进行摇树优化 (tree-shakes) 。 -
flutter_mediapipe_vision_platform_interface定义了所有平台实现必须遵循的接口。这个 package 本身不执行任何实际工作,它的全部作用是在第一个 package 不知情的情况下置换(或“切换”)不同的实现。 -
flutter_mediapipe_vision_web是针对 Web 的特定实现,也是本文的主要关注点。它依赖于第二个 package,因为它实现了相同的接口。它对第一个 package 一无所知。反过来,第一个 package 依赖于它,只是为了递归地将其带入项目中。
lutter_mediapipe_vision
我们希望为面向用户的 package 设计一个怎样的接口呢?如果我们想要可置换的实现,静态函数是最好的选择:
class FlutterMediapipeVision {
static Future<void> ensureInitialized() async {
await FlutterMediapipeVisionPlatform.instance.ensureInitialized();
}
static Future<PoseLandmarkerResult> detect(Uint8List bytes) async {
return await FlutterMediapipeVisionPlatform.instance.detect(bytes);
}
}
这个类会将静态函数调用转换为对特定实现上的实例方法调用。
🚀 接口函数设计
第一个函数将用于初始化模型。我们可以随意命名,但 ensureInitialized() 是这种操作的约定俗成的名称。可以参考 WidgetsFlutterBinding.ensureInitialized() 的命名方式。
第二个函数将接收图像的字节数据(例如来自摄像头的每一帧),并调用模型上的 detect() 函数。在所有平台实现中,它的命名都保持一致。
另外,请注意我们很快将定义的返回类型。
flutter_mediapipe_vision_platform_interface
📊 数据类型 (Data types)
我们从数据类型开始。我们将要连接的 JavaScript 库有用于已识别点和总聚合结果的数据类型。然而,我们需要插件返回与平台无关的结果,因此我们需要定义自己的类型。
这是一个地标点(landmark),即姿势中被识别出来的一个点:
class NormalizedLandmark {
final double x;
final double y;
const NormalizedLandmark({required this.x, required this.y});
Offset get offset => Offset(x, y);
}
它被称为归一化(normalized),是因为如果 x 和 y 坐标在画面帧内,它们的值范围就在 0 到 1 之间。
如果图像被裁剪,并且模型认为某个特定的点在图像外部,那么这些坐标也可能小于零或大于 1,就像下面这个网络摄像头示例一样:
为什么不直接使用来自 dart:ui 的 Offset 类型呢?
这个库(指 MediaPipe)还会给我们 z 轴坐标(即到摄像机的距离)以及一些我们目前不需要的其他信息,但以后能添加它们会很有益处。因此,Offset 类型是不够的。
另外,这个 NormalizedLandmark 类型是在每个独立的实现中都有定义的:TypeScript、Java 等。所以,让我们保持一致性。
接下来,这是识别结果:
class PoseLandmarkerResult {
final List<List<NormalizedLandmark>> landmarks;
const PoseLandmarkerResult.empty() : landmarks = const [];
const PoseLandmarkerResult({required this.landmarks});
}
该库返回已识别姿势的列表(即列表的第一个维度)。每个姿势都是一个地标点列表,它们位于特定的索引上(即列表的第二个维度):
💻 平台接口 (The platform interface)
在数据类型定义完毕后,我们就可以定义每个插件都必须扩展(继承)的接口了:
abstract class FlutterMediapipeVisionPlatform extends PlatformInterface {
FlutterMediapipeVisionPlatform() : super(token: _token);
static final Object _token = Object();
static FlutterMediapipeVisionPlatform _instance =
FlutterMediapipeVisionMethodChannel();
static FlutterMediapipeVisionPlatform get instance => _instance;
static set instance(FlutterMediapipeVisionPlatform instance) {
PlatformInterface.verify(instance, _token);
_instance = instance;
}
Future<void> ensureInitialized() {
throw UnimplementedError();
}
Future<PoseLandmarkerResult> detect(Uint8List bytes) {
throw UnimplementedError();
}
}
📌 关键点解析
这里涉及许多内容。
最重要的是,我们定义了两个业务逻辑函数:ensureInitialized 和 detect。
接下来,_instance 需要有一个默认值,因此我们创建了一个实例,我们稍后会讨论它。
最后,请注意名为 _token 的对象。这是我们需要它的原因。Flutter 保留向其 PlatformInterface 类添加内容的权利,而这对我们来说不应该成为一个破坏性变更 (breaking change) 。因此,规则是始终使用 extends(继承),而不是 implements(实现) 。
我们确保在这里使用了 extends,但通常任何人都可以为另一个平台编写我们平台接口的实现(甚至可以覆盖我们在同一平台上的实现),而我们无法控制他们使用 extends 还是 implements。如果他们使用了 implements,程序可能暂时能正常工作,但随后可能会突然停止对特定平台上的这个 package 进行构建。
因此,我们确保不等到那时,而是提前破坏(报错) 。为此,我们使用了 _token 对象,它唯一的职责就是确保一致性。如果别人的插件实现了我们的接口,它就不会拥有相同的 _token,并且 set instance 中的检查将会失败。
那么,那个默认实例到底是什么呢?
const MethodChannel _channel = MethodChannel('ainkin.com/flutter_mediapipe_vision');
class FlutterMediapipeVisionMethodChannel
extends FlutterMediapipeVisionPlatform {
@override
Future<void> ensureInitialized() async {
await _channel.invokeMethod<void>('ensureInitialized');
}
@override
Future<PoseLandmarkerResult> detect(Uint8List bytes) async {
final native = await _channel.invokeMethod<void>('detect');
throw UnimplementedError('TODO: Convert.');
}
}
🔙 回顾 Flutter 早期 (Back when Flutter only supported Android and iOS)
回顾 Flutter 仅支持 Android 和 iOS 的时期,调用任何平台特定功能的唯一方式是创建一个名为 MethodChannel 的对象,并使用 invokeMethod(name) 在其上“调用方法”。Flutter 会处理 Channel(通道) 和 Method(方法) 的名称,并将调用路由到特定的原生代码。Dart 代码中没有可置换的实例,因为所有的置换都是在构建应用时完成的。
为了保持向后兼容性,如果 Flutter 没有要求我们的插件做任何不同的事情,这就是我们需要默认采用的方式。这也是我们将此作为默认实例的原因。
🚧 默认实例的实现 (Default Instance Implementation)
不过,我们暂时不会支持 Web 以外的平台。因此,我们不需要让 MethodChannel 的实现能够正常工作。调用一个假设的原生 ensureInitializee() 并等待它返回并不会有什么坏处。但是,我们不能在 detect() 中做任何有意义的事情,因为那需要一个用于与原生实现之间传递数据的契约。因此,我们可以在那里抛出一个错误。
💻 flutter_mediapipe_vision_web
让我们用以下代码开始我们的插件:
class FlutterMediapipeVisionWeb extends FlutterMediapipeVisionPlatform {
static void registerWith(Registrar registrar) {
FlutterMediapipeVisionPlatform.instance = FlutterMediapipeVisionWeb();
}
Future<void>? _initFuture;
@override
Future<void> ensureInitialized() =>
_initFuture ?? (_initFuture = _initOnce());
Future<void> _initOnce() async {
// ...
}
@override
Future<PoseLandmarkerResult> detect(Uint8List bytes) async {
// ...
}
}
✨ 注册与初始化 (registerWith())
registerWith() 是一个“神奇”的函数。如果应用是为 Web 构建的,Flutter 会在很早的时候调用它。然后,我们创建当前类的一个实例,并将其设置为用于所有平台调用。
🌐 欢迎来到 Web!(Welcome to Web!)
Dart 代码会被转译 (transpile) 成 JavaScript 或 WASM (WebAssembly)。无论是哪种方式,它都可以通过 dart:js_interop 导入提供的 globalContext 变量,直接访问浏览器的全局作用域。因此,Dart 对象和 JavaScript 对象之间几乎没有区别,它们对于运行我们应用的浏览器来说都只是对象。
📦 加载 MediaPipe 的 JavaScript (Loading MediaPipe’s JavaScript)
我借鉴了 Firebase 的这段代码并做了一些简化。遗憾的是,我们需要重复这段较长的代码片段,而 Flutter 尚未为我们准备好一个单行代码 (one-liner) 解决方案。
这段代码从 src 加载一个脚本,并将其模块对象存储在一个由 windowVar 确定的全局变量中。
Future<void> _injectSrcScript(String src, String windowVar) async {
final web.HTMLScriptElement script =
web.document.createElement('script') as web.HTMLScriptElement;
script.type = 'text/javascript';
script.crossOrigin = 'anonymous';
final stringUrl = src;
script.text =
'''
window.my_trigger_$windowVar = async (callback) => {
console.debug("Initializing MediaPipe $windowVar");
callback(await import("$stringUrl"));
};
''';
web.console.log('Appending a script'.toJS);
web.document.head!.appendChild(script);
Completer completer = Completer();
globalContext.callMethod(
'my_trigger_$windowVar'.toJS,
(JSAny module) {
globalContext[windowVar] = module;
globalContext.delete('my_trigger_$windowVar'.toJS);
completer.complete();
}.toJS,
);
await completer.future;
}
现在,我们可以定义 _windowVar,让它的命名不会与 Flutter 或 MediaPipe 产生冲突,并开始用加载 MediaPipe 代码的方式来编写我们的 _initOnce() 函数:
const _windowVar = 'flutter_mediapipe_vision';
// ...
Future<void> _initOnce() async {
await _injectSrcScript(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/vision_bundle.js',
_windowVar,
);
// ...
这会加载最新版本的脚本。另一种方法是下载它并与我们的资源(assets)一起打包,以减少外部依赖,但目前这种方式(直接加载最新版)就可以了。
这段加载完成后,MediaPipe 模块就会存在于全局变量中,并且可以通过 globalContext[_windowVar] 进行访问。我们现在就可以开始从它那里调用函数了:
globalContext[_windowVar]['PoseLandmarker'].callMethod(
'createFromOptions',
...
);
但是,最好对它进行某种类型安全的约束。
🛡️ 对 JavaScript 对象施加 Dart 接口 (Imposing Dart interfaces on JavaScript objects)
还记得我们声明的 NormalizedLandmark 类吗?
在 JavaScript 端,它是一个带有 x 和 y 属性的常规对象,在我们的 Dart 代码中可以通过 landmark['x'] 和 landmark['y'] 这样的方式来访问。这很容易出错。幸运的是,我们可以像这样定义一个 Dart 接口:
extension type NormalizedLandmark._(JSObject _) implements JSObject {
external num get x;
external num get y;
}
如果我们将这样一个地标对象强制转换(cast)为这个类,我们就能以类型安全的方式来访问它的属性了:
final landmark = unsafeLandmark as NormalizedLandmark;
print(landmark.x);
🌟 扩展类型 (Extension types)
这个接口到底是什么呢?它是一种名为扩展类型(extension type)的构造,它在字面上给对象施加了一个接口,但并不会创建一个额外的包装器。它作为一种编译时抽象(compile-time abstraction)而存在,但在运行时并不存在。你可以在Dart 文档中阅读有关此构造的更多信息:
我们先稍微绕个弯,学习更多关于扩展类型的知识,然后再带着这些新知识回到你的 JavaScript 工作中。
Dart 关于扩展类型的文档展示了这样一个示例,它将 int 类型的接口范围缩小,只允许进行一个操作:
extension type IdNumber(int id) {
// Wraps the 'int' type's '<' operator:
operator <(IdNumber other) => id < other.id;
// Doesn't declare the '+' operator, for example,
// because addition does not make sense for ID numbers.
}
// ...
final safeId = IdNumber(42);
这段代码表明:
-
我们将使用一个名为
IdNumber的东西来处理一些 ID。 -
它不是一个在运行时存在的类,因为那样开销太大,因此我们使用了
extension type(扩展类型) 。 -
相反,我们将使用
int来存储这些 ID,因为int是存储数字的最有效方式。因此,类型名称后面紧跟的(int id)表示这个抽象包装了什么。 -
这个接口剥离了
int的所有方法、操作符和属性,只保留了我们明确定义的部分。 -
我们定义了
operator <,这也是你对这种 ID 唯一能做的操作。
💡 关于扩展类型的构造函数
扩展类型的构造函数不像常规类型那样作为成员定义,因为常规类型可能有多个构造函数,因为构造对它们来说是实际的工作,我们可能希望以不同的方式完成。
另一方面,对于扩展类型来说,构造只是编译时的一种包装,它不会转换成任何实际的运行时操作,所以它总是只有一个构造函数。因此,将其作为成员来定义就没有意义了,所以它们的语法是将构造函数 (int id) 直接放在类型名称之后。
🧐 那么,这对我们的例子有什么用呢?
(即如何将扩展类型应用于我们的 JavaScript 互操作场景?)
extension type NormalizedLandmark._(JSObject _) implements JSObject {
external num get x;
external num get y;
}
在我们的扩展类型中:
- 我们包装了一个
JSObject并立即实现了JSObject接口。这意味着我们没有剥离该接口中的任何内容,而只是添加了新功能。我们需要这样做是因为我们很快就会有JSArray<NormalizedLandmark>,而JSArray只能包含JSObject及其子类。 - 我们使用
_作为名称,因为与 ID 的示例不同,我们没有将任何功能委托给我们包装的对象,因此不需要一个名称。 - 我们使用私有构造函数,这是由于
._的存在。
因此,这个包装器永远不能像我们创建 IdNumber 那样(如 final safeId = IdNumber(42);)来创建。
相反,我们只能使用 as 关键字对其进行强制转换 (cast) 。
我们把 getter 标记为 external。这意味着“它们已经在 JavaScript 中存在,可以直接工作”。
当我们使用扩展类型来表示来自外部 JavaScript 或 WASM 库的对象时,它被称为 “互操作类型”(interop type) ,源自 inter-operation(互操作) 。
✍️ 定义互操作类型 (Defining the interop types)
我们需要更多的互操作类型来从 MediaPipe 库中创建 Landmarker 对象、调用它的方法并从结果对象中获取数据。
这些类型可以通过查看 MediaPipe 的 TypeScript 源代码手动编写出来:
- fileset_resolver.ts.template
landmark.d.ts- pose_landmarker.ts
pose_landmarker_options.d.tspose_landmarker_result.tstask_runner_options.d.tsvision_task_options.d.ts
Dart 中的互操作类型有可能可以从 TypeScript 源码生成,但我还没有探索过这一点。手动实践一段时间是有益的。
以下是我从 TypeScript 中提取出来的部分,仅包含我们将实际使用的方法和属性。
detect 函数的结果是:
extension type PoseLandmarkerResult._(JSObject _) implements JSObject {
external JSArray<JSArray<NormalizedLandmark>> get landmarks;
}
地标检测器 (The landmarker):
extension type PoseLandmarker._(JSObject _) implements JSObject {
external JSPromise<PoseLandmarker> createFromOptions(
WasmFileset fileset,
PoseLandmarkerOptions options,
);
external void detect(HTMLImageElement img, JSFunction callback);
}
创建地标检测器的选项:
extension type PoseLandmarkerOptions._(JSObject _) implements JSObject {
external PoseLandmarkerOptions({
BaseOptions baseOptions,
int numPoses,
String runningMode,
});
external BaseOptions get baseOptions;
external int get numPoses;
external String get runningMode;
}
PostLandmarkerOptions 中的基础选项:
extension type BaseOptions._(JSObject _) implements JSObject {
external BaseOptions({String modelAssetPath});
external String get modelAssetPath;
}
WasmFileset,不管它是什么:
extension type WasmFileset._(JSObject _) implements JSObject {}
Fileset resolver:
extension type FilesetResolver._(JSObject _) implements JSObject {
external JSPromise<WasmFileset> forVisionTasks(String basePath);
}
MediaPipe 模块的根对象:
import 'fileset_resolver.dart' as fsr;
import 'pose_landmarker.dart' as plm;
extension type MediaPipe._(JSObject _) implements JSObject {
external fsr.FilesetResolver get FilesetResolver;
external plm.PoseLandmarker get PoseLandmarker;
}
⚙️ 模型初始化
让我们继续编写设置插件的函数,并初始化模型:
MediaPipe get mp => globalContext[_windowVar] as MediaPipe;
PoseLandmarker? _landmarker;
Future<void> _initOnce() async {
await _injectSrcScript(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/vision_bundle.js',
_windowVar,
);
final fs = await mp.FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm',
).toDart;
final options = PoseLandmarkerOptions(
baseOptions: BaseOptions(
modelAssetPath:
"packages/flutter_mediapipe_vision_platform_interface/assets/"
"assets/models/pose_landmarker_lite.task",
),
numPoses: 5,
runningMode: "IMAGE",
);
_landmarker = await mp.PoseLandmarker.createFromOptions(fs, options).toDart;
}
💾 模型文件与存储
模型文件本身可以在这里下载,我选择了 lite(轻量)版本:
由于模型是在不同实现之间共享的,最好将它们放入一个共享 package 中。flutter_mediapipe_vision_platform_interface 是最合适的,尽管从技术上讲它不属于接口范畴,但所有实现都已经依赖于它。
无论如何,当这个函数执行完毕时,我们就把 Landmarker 对象存储在了字段变量 _landmarker 中。
🧍 检测姿势 (Detecting poses)
这就是执行实际工作的方法:
@override
Future<PoseLandmarkerResult> detect(Uint8List bytes) async {
final el = await _createImageFromBytes(bytes);
// ...
}
我们首先使用字节数据创建一个 HTMLImageElement 元素,因为这是 MediaPipe 的 detect 函数实际接受的输入类型。我们这样做:
Future<web.HTMLImageElement> _createImageFromBytes(Uint8List bytes) async {
final completer = Completer();
final blob = web.Blob(
[bytes.toJS].toJS,
web.BlobPropertyBag(type: _detectImageFormat(bytes)),
);
final imageUrl = web.URL.createObjectURL(blob);
final el = web.document.createElement('img') as web.HTMLImageElement;
el.onload = () {
web.URL.revokeObjectURL(imageUrl);
completer.complete();
}.toJS;
el.onerror = () {
web.URL.revokeObjectURL(imageUrl);
completer.completeError('Cannot load the image.');
}.toJS;
el.src = imageUrl;
await completer.future;
return el;
}
💾 创建 Blob 对象
JavaScript 的 Blob(Binary Large Object,二进制大对象)构造函数接受一个二维字节数组。因此,我们首先对 Uint8List 调用 .toJS,将其转换为一个 JavaScript 数组。许多 Dart 类型都有这个 getter 方法,用于生成可以传递给 JavaScript 函数的数据。接着,我们将这个数组包装到另一个列表中,并也将其转换为 JavaScript 数组。
然后,我们通过读取前几个字节来确定图像类型,这里我将跳过 _detectImageFormat 函数的细节。
🔗 生成 Blob URL
接下来,我们需要生成一个 URL 来设置给我们的 img 对象,因为这是将图像放入 HTML 元素的唯一方式。
这里涉及到一个概念叫做 Blob URL。我们基本上是在告诉浏览器:“嘿,我们需要在 img 元素中显示这些字节。请给我们一个指向它们的虚拟 URL。 ”
浏览器随后会将这些字节存储到某个内部表中,并生成一个看起来像这样的 URL:
blob:http://localhost:40000/fd108f07-5e55-43d1-b5cd-691b973c03d6
这个 URL 是当前浏览器会话私有的,可以用于获取图像。有趣的是,你甚至可以在另一个标签页中打开它:
总之,我们创建了一个 img 元素,并将其 src 属性设置为那个 URL。现在,我们需要等待它加载完成。为此,我们需要设置以下这些监听器:
el.onload = () {
web.URL.revokeObjectURL(imageUrl);
completer.complete();
}.toJS;
el.onerror = () {
web.URL.revokeObjectURL(imageUrl);
completer.completeError('Cannot load the image.');
}.toJS;
这两个监听器(onload 和 onerror)都会使 Completer 完成,这样函数就可以返回准备就绪的 img 元素,或者因错误而中断。
它们也都清理(dispose)了 URL,这样它就不会浪费浏览器的内存。毕竟,我们将在每一帧都执行这个操作。
请注意,当我们向任何 JavaScript 例程传递 Dart 函数时,我们需要使用 .toJS getter 将其转换为常规的 JavaScript 函数。
当我们拿到 img 元素后,就可以继续进行 detect 函数的操作了:
import 'src/interop/pose_landmarker_result.dart' as js_plr;
// ...
@override
Future<PoseLandmarkerResult> detect(Uint8List bytes) async {
PoseLandmarkerResult r = PoseLandmarkerResult.empty();
final el = await _createImageFromBytes(bytes);
_landmarker!.detect(
el,
(js_plr.PoseLandmarkerResult? result) {
r = result?.toDart ?? PoseLandmarkerResult.empty();
}.toJS,
);
return r;
}
🔄 处理异步检测结果
请注意,JavaScript 的 detect 函数并不会直接返回结果。相反,我们会向它传递一个回调函数(callback),数据准备好后会通过这个回调函数被调用。
这种设计允许该函数在回调返回时释放资源,这有可能改进垃圾回收机制 (garbage collection) 。但在实践中,我注意到该对象在回调完成后仍然存在,不过我们不能依赖这种现象。我们必须将 JavaScript 返回的结果对象转换成我们定义的那个平台无关的对象(即我们在第二个 package 中定义的类型)。
至此,所有 package 的代码就完成了!
🔗 整合 Packages (Tying the packages together)
Web 实现 package 需要在其 pubspec.yaml 文件中声明它包含一个插件实现,这样 Flutter 才知道启动时应该调用哪个方法来置换(swap in)这个实现:
flutter:
plugin:
platforms:
web:
pluginClass: FlutterMediapipeVisionWeb
fileName: flutter_mediapipe_vision_web.dart
平台接口(platform interface)需要在其代码中声明它的资源(assets),这样在使用它的应用的最终构建中,这些资源才能被打包进去:
flutter:
assets:
- assets/models/pose_landmarker_lite.task
而面向用户的 package 需要正式认可(或“推荐使用”)这个插件:
flutter:
plugin:
platforms:
web:
default_package: flutter_mediapipe_vision_web
📱 应用
📹 显示摄像头视频 (Showing the camera video)
我们需要做的第一件事是在屏幕上显示来自摄像头的视频流。让我们创建并初始化摄像头控制器,然后显示 CameraPreview 组件:
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
late CameraController cameraController;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await FlutterMediapipeVision.ensureInitialized();
cameraController = CameraController(
(await availableCameras()).first,
ResolutionPreset.low,
enableAudio: false,
);
await cameraController.initialize();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('MediaPipe demo')),
body: Center(
child: CameraPreview(cameraController),
),
),
);
}
}
📹 最小化应用示例
这是一个在屏幕上显示摄像头视频的最小化应用。它并不完美,因为它会在显示任何内容之前阻塞(block),直到获得使用摄像头的权限,并且如果访问被拒绝,它也不会重试。但它能完成工作:
📸 捕获和分析静止图像 (Capturing and analyzing stills)
让我们创建一个控制器来进行识别:
class InferenceController extends ChangeNotifier {
final CameraController cameraController;
PoseLandmarkerResult get lastResult => _lastResult;
PoseLandmarkerResult _lastResult = PoseLandmarkerResult.empty();
InferenceController({required this.cameraController});
Future<void> start() async {
while (true) {
await _tick();
}
}
Future<void> _tick() async {
final file = await cameraController.takePicture();
final bytes = await file.readAsBytes();
_lastResult = await FlutterMediapipeVision.detect(bytes);
notifyListeners();
}
}
🏃 启动与循环 (Starting the Recognition)
一旦调用了 start() 函数,它就会永远运行下去。这对于移动设备来说不太理想(因为应用可能会被系统从内存中清除),但对于这个最小化的 Web 版本来说是可以接受的。
在循环中,我们使用 cameraController.takePicture() 捕获一帧图像,然后将其作为字节数据传递给我们的插件,并获取经过分析的结果。
现在,让我们在 main() 函数中创建这个控制器:
late InferenceController inferenceController; // CHANGED
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await FlutterMediapipeVision.ensureInitialized();
final cameraController = CameraController(
(await availableCameras()).first,
ResolutionPreset.low,
enableAudio: false,
);
await cameraController.initialize();
// NEW:
inferenceController = InferenceController(cameraController: cameraController);
unawaited(inferenceController.start());
runApp(const MyApp());
}
🦴 显示骨架覆盖层 (Showing the skeleton overlay)
让我们创建一个 CameraOverlayWidget 组件来完成这项工作:
class CameraOverlayWidget extends StatelessWidget {
final InferenceController inferenceController;
const CameraOverlayWidget({required this.inferenceController});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: inferenceController,
child: CameraPreview(inferenceController.cameraController),
builder: (context, child) {
return CustomPaint(
foregroundPainter: CameraOverlayPainter(
inferenceController: inferenceController,
),
willChange: true,
child: child,
);
}
);
}
}
👂 监听与重绘 (Listening and Rebuilding)
这个组件监听来自控制器的通知,并在每次收到通知时进行重建(rebuilds)。
请注意,我们在 builder 函数的外部创建了 CameraPreview 组件,并将其作为 child 传递给 ListenableBuilder。这样做可以将 CameraPreview 排除在重建过程之外,从而使性能稍微快一些。
🎨 自定义绘制 (Custom Painting)
CustomPaint 组件使用 foregroundPainter 在 child 组件的上方覆盖一层进行绘制。
现在,让我们来创建这个 CameraOverlayPainter:
class CameraOverlayPainter extends CustomPainter {
final InferenceController inferenceController;
static final _paint = Paint()
..color = Colors.white
..isAntiAlias = true
..style = PaintingStyle.fill
..strokeWidth = 5;
static const _pointRadius = 5.0;
CameraOverlayPainter({required this.inferenceController});
@override
void paint(Canvas canvas, Size size) {
_paintPose(canvas, size);
}
void _paintPose(Canvas canvas, Size size) {
final pose = inferenceController.lastResult.landmarks.firstOrNull;
if (pose == null) {
return;
}
final leftShoulder = pose[Points.leftShoulder].offset.timesSize(size);
final rightShoulder = pose[Points.rightShoulder].offset.timesSize(size);
// Same for every point.
_paintLine(canvas, leftShoulder, rightShoulder);
// Same for every line.
_paintPoint(canvas, leftShoulder);
_paintPoint(canvas, rightShoulder);
// Same for every point.
}
void _paintPoint(Canvas canvas, Offset offset) {
canvas.drawCircle(offset, _pointRadius, _paint);
}
void _paintLine(Canvas canvas, Offset pt1, Offset pt2) {
canvas.drawLine(pt1, pt2, _paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
extension on Offset {
Offset timesSize(Size size) => Offset(dx * size.width, dy * size.height);
}
abstract final class Points {
static const leftShoulder = 11;
static const rightShoulder = 12;
// Same for every point.
}
🎨 骨架绘制逻辑
这个类(CameraOverlayPainter)只是从识别结果中选取所有兴趣点(points of interest),并用线条连接相邻的点。
由于坐标是从 0 到 1 的归一化值,所以它将这些坐标乘以 size——一个持有当前组件尺寸的参数。因为这个覆盖层(overlay)与摄像头预览是相同尺寸的,所以一切都恰到好处。
🎉 最终成果
这最终为我们实现了我们想要的效果:
🚀 再次展示已部署的演示 (Deployed Demo Once Again)
这是已部署的演示应用链接:
alexeyinkin.github.io/flutter-med…
🌐 浏览器兼容性 (Browser compatibility)
- 在 Chrome 浏览器中运行良好。
- 在 Firefox 144 中,它由于一个 camera package 的 bug 而崩溃,我很快会定位并提交这个问题。
- 在 Safari 浏览器中,它就是无法运行,没有任何明显的症状或错误信息。如果你知道问题出在哪里,请告诉我。
HarmonyOS preview 预览文件 Kit 的入门讲解
本文以实际工程为例,快速上手 HarmonyOS 元服务 的文件预览能力(PreviewKit),并配套一个后端用于提供示例文件。示例工程路径:
- 客户端(HarmonyOS 端):
client - 后端(Node.js):
server
image-20251112090708795
image-20251112091151694
上图是将 1个pdf文件和3个图片一起预览,那么就只会现实第1个预览窗口。
下图是移除pdf文件,将3个同类型的图片放在一起预览
image-20251112091518239
为了方便演示功能,需要先将一些可以预览的文件下载到元服务的沙箱内,是基于这个原因我们才需要引入后端来模拟这个下载的环境,所以元服务内需要先实现下载文件,存储到沙箱,然后再使用预览API filePreview.openPreview预览沙箱内的文件。
1. 工程结构与目标
-
client/entry/src/main/ets/pages/Index.ets:演示并发下载 4 个文件(1.pdf、1.png、2.png、3.png)并一次性预览。 -
server/index.js与server/public/:提供静态文件下载接口/file/:filename。
目标:
- 点击“下载”按钮,并发下载上述 4 个文件到应用沙箱目录。
- 下载成功后点击“预览”,一次性打开最多 4 个文件的预览窗口。
2. PreviewKit 的核心:filePreview.openPreview
HarmonyOS 提供了预览能力包 @kit.PreviewKit。在 ETS 代码中引入:
import { filePreview } from '@kit.PreviewKit';
import { fileUri } from '@kit.CoreFileKit';
核心调用是:
// 先准备多个文件的预览信息
const prewList: filePreview.PreviewInfo[] = []
for (let i = 0; i < count; i++) {
const item = this.lastDownloadedList[i];
const fileInfo: filePreview.PreviewInfo = {
title: item.name, // 预览标题
uri: fileUri.getUriFromPath(item.path), // 将沙箱路径转成 Uri
mimeType: item.mime || 'application/octet-stream', // MIME 类型
};
prewList.push(fileInfo)
}
// 一次性打开多个预览窗口
filePreview.openPreview(uiContext, prewList)
.then(() => {
// 打开成功
})
.catch((err: BusinessError) => {
// 打开失败处理
});
说明:
-
PreviewInfo至少需要title、uri、mimeType。 -
uri使用fileUri.getUriFromPath(沙箱文件路径)构造。 - 支持一次性传入一个
PreviewInfo[],实现多文件预览。
图片占位:请补充一次性预览 4 个文件的窗口布局截图,标注窗口标题与 MIME 类型展示位置。
3. 并发下载与状态反馈(客户端)
示例使用 Promise.allSettled 并发下载 4 个后端文件,并按项展示“成功/失败”状态:
// 计划 + 状态
@Local private plannedFiles: DownloadPlan[] = [];
@Local private itemStatuses: string[] = [];
@Local private isDownloading: boolean = false;
@Local private statusMessage: string = '';
// 初始化计划(aboutToAppear)
this.plannedFiles = [
new DownloadPlan('1.pdf', `${this.serverBase}/1.pdf`),
new DownloadPlan('1.png', `${this.serverBase}/1.png`),
new DownloadPlan('2.png', `${this.serverBase}/2.png`),
new DownloadPlan('3.png', `${this.serverBase}/3.png`)
];
this.itemStatuses = ['未下载','未下载','未下载','未下载'];
// 点击“下载”
this.isDownloading = true;
this.statusMessage = '下载中...';
this.itemStatuses = new Array(this.plannedFiles.length).fill('下载中...');
const promises: Promise<DownloadInfo>[] = this.plannedFiles.map(p => this.downloadFile(p.url));
const settled = await Promise.allSettled(promises);
// 汇总结果并一次性触发 UI 刷新
const successes: DownloadInfo[] = [];
const nextStatuses: string[] = new Array(this.plannedFiles.length).fill('未下载');
for (let i = 0; i < settled.length; i++) {
const name = this.plannedFiles[i].name;
const r = settled[i];
if (r.status === 'fulfilled') {
successes.push(r.value);
nextStatuses[i] = `✓ 下载成功:${name}`;
} else {
nextStatuses[i] = `✗ 下载失败:${name}(${this.errorToString(r.reason as Object)})`;
}
}
this.itemStatuses = nextStatuses; // 重新赋值以触发 UI 刷新
this.lastDownloadedList = successes;
this.isDownloading = false;
UI 渲染建议:
- 使用
ForEach(this.plannedFiles, ...)动态渲染状态行,避免硬编码索引。 - 将与 UI 绑定的字段用
@Local或@State修饰,并“重新赋值数组”以触发刷新(不要在原数组上就地修改元素)。
图片占位:请补充“下载中→成功/失败”逐项状态变化的截图,便于读者理解响应式刷新。
4. HTTP 下载的细节与 ArkTS 限制规避
- MIME 与扩展名:示例通过扩展名推断 MIME,若扩展名缺失则从响应头的
Content-Type推断。 - ArkTS 限制:不建议直接
data.header['Content-Type']索引;示例使用序列化 + 正则方式提取避免 ArkTS 索引限制。
// 通过序列化响应头并用正则提取 Content-Type
private tryGetContentTypeHeader(headerObj: Object | null): string {
if (!headerObj) return '';
try {
const json = JSON.stringify(headerObj);
if (!json) return '';
const match = json.match(/"content-type"\s*:\s*"([^"]+)"/i);
return match && match.length > 1 ? match[1] : '';
} catch (_) {
return '';
}
}
保存文件:
const filePath = `${this.filesDir}/${fileName}`;
if (fileIo.accessSync(filePath)) {
fileIo.unlinkSync(filePath);
}
const file = fileIo.openSync(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY);
const bytesWritten = fileIo.writeSync(file.fd, fileBuffer);
fileIo.closeSync(file);
权限:
- 客户端需要在
entry/src/main/module.json5声明ohos.permission.INTERNET才能进行网络请求。
5. 后端:简单的静态文件下载接口
示例后端路径:d:\code\atoStudy\server,目录 public/ 放置 4 个演示文件。
核心路由:GET /file/:filename
后端的简单目录结构:
image-20251112092243514
// index.js(简版示例)
const express = require('express');
const path = require('path');
const app = express();
app.get('/file/:filename', (req, res) => {
const filename = req.params.filename;
const filePath = path.join(__dirname, 'public', filename);
res.sendFile(filePath); // 或根据需要设置 Content-Type
});
app.listen(3000, () => {
console.log('Server listening on http://localhost:3000');
});
客户端请求地址示例:
private serverBase: string = "http://192.168.5.2:3000/file";
// 组合完整 URL 示例:`${this.serverBase}/1.pdf`
注意:请按真实局域网 IP 替换
192.168.5.2,并保证手机/模拟器与后端在同一网络。
6. 快速运行与验证
后端:
- 安装依赖并启动:
npm install && node index.js - 确认
public/下存在1.pdf、1.png、2.png、3.png
客户端:
- 在
module.json5中确保已声明ohos.permission.INTERNET - 构建并安装到设备/模拟器
- 点击“下载”,观察逐项状态变化
- 下载成功后点击“预览”,验证多窗口预览是否正常
图片占位:请补充上述过程的关键截图(如“权限声明处”、“下载成功状态”、“多窗口预览”)。
7. 常见问题与排查
- 权限错误(如 code=201 / “Permission denied”):检查
ohos.permission.INTERNET是否声明;确认真机/模拟器的网络可达性。 - 404 或下载失败:确认后端路由
/file/:filename存在且文件确实在public/目录内;检查客户端serverBase地址是否正确。 - MIME 与扩展名错配:优先使用后端返回的
Content-Type;如果缺失,则按扩展名推断。 - UI 不刷新:在 ArkUI 中对数组进行“重新赋值”来触发刷新,避免原地修改元素(例如使用
this.itemStatuses = [...nextStatuses])。
8. 小结
filePreview.openPreview 是 HarmonyOS 文件预览能力的核心,支持一次性打开多文件预览。结合简单的后端静态文件服务与并发下载、响应式状态刷新,能够快速搭建一个“下载即预览”的演示工程。本文的示例工程完整覆盖了从后端文件提供、客户端下载与保存、到预览窗口打开的关键路径,适合作为入门教程与二次扩展的基础。
Vibe Coding:人机共生时代的开发革命 —— 从概念到 Chrome 扩展实战
Vibe Coding:用氛围编程解锁零代码创造
Vibe Coding(氛围编程)是由 OpenAI 联合创始人 Andrej Karpathy 提出的全新编程理念,核心是通过自然语言交互与 AI 协作,让创造不再受限于代码编写能力。这种模式下,低代码、零代码工具与编程智能体(如 Trae)成为核心载体,普通人只需明确想法,就能快速落地各类产品,打破了技术壁垒对创意的束缚。
从工具到创意:Vibe Coding 的落地场景
Vibe Coding 的应用范围极具弹性,小到轻量工具,大到商用产品,都能通过 AI 协作快速实现。其中,Chrome 扩展程序是最直观的落地场景之一 —— 就像经典的 JSONView 扩展,仅需聚焦 “接口 JSON 数据格式化展示” 这一核心需求,就能通过 AI 生成工具,解决开发中的实际查看痛点。
而借助 Vibe Coding 模式,我们能更进一步:无需深陷代码细节,只需清晰传递功能目标,AI 就能完成从逻辑到实现的全流程,让每一个实用想法都能快速转化为可用工具。
实战:Vibe Coding 协作型 Chrome 扩展开发
以 Chrome 扩展开发为例,Vibe Coding 的核心是 “协作” 而非 “独写”,具体落地路径简单高效,且已通过实际测试验证可行性:
1. 明确协作输入
- 用自然语言 prompt 精准描述任务,比如 “开发一个 Chrome 扩展,实现接口 JSON 数据的格式化展示与折叠查看”。
- 补充设计稿或线框图,明确界面布局与交互逻辑,减少 AI 理解偏差。
AI编辑器关键词:
请根据@instruction.txt当中的需求帮我开发对应的chrome扩展程序。
首先列出项目的文件目录,然后把对应的文件代码补充完整。
instruction.txt
你是一个经验丰富的Chrome 拓展程序开发者,请帮我开发一个名为Hulk的拓展程序
UX 设计图参考ux.jpg。
具体交互步骤如下:
Step1:点击程序图标打开弹出窗口,在窗口中默认提示:“改变背景颜色”、“点击下方按钮将当前页面背景色改为绿色”和一个“改变颜色”按钮
Step2:点击按钮,网页背景改变为绿色。
注意以下两点:
请使用icons文件夹的图标作为应用程序的图标
然后我们看cursor给我们生成的代码就好。 popup.css文件
:root {
color-scheme: light dark;
font-size: 16px;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
}
body {
margin: 0;
padding: 16px;
min-width: 240px;
background-color: #0f1115;
color: #f1f5f9;
display: flex;
flex-direction: column;
gap: 16px;
}
.hero h1 {
margin: 0 0 4px;
font-size: 1.125rem;
}
.hero p {
margin: 0;
font-size: 0.95rem;
line-height: 1.4;
color: #cbd5f5;
}
button {
padding: 12px 16px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
color: #0f172a;
background-color: #22c55e;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(34, 197, 94, 0.4);
}
button:active {
transform: translateY(1px);
box-shadow: none;
}
.status {
margin: 12px 0 0;
font-size: 0.9rem;
line-height: 1.4;
color: #34d399;
}
.status.error {
color: #f87171;
}
popup.html文件
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Hulk</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<header class="hero">
<h1>改变背景颜色</h1>
<p>点击下方按钮将当前页面背景色改为绿色</p>
</header>
<main>
<button id="changeColorBtn" type="button">改变颜色</button>
<p id="statusMessage" class="status" role="status" aria-live="polite"></p>
</main>
<script src="popup.js"></script>
</body>
</html>
popup.js文件
const changeColorBtn = document.getElementById("changeColorBtn");
const statusMessage = document.getElementById("statusMessage");
function setStatus(message, isError = false) {
if (!statusMessage) return;
statusMessage.textContent = message;
statusMessage.classList.toggle("error", isError);
}
async function changeBackgroundToGreen() {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab || !tab.id) {
console.warn("未找到活动标签页。");
setStatus("未找到可操作的标签页。", true);
return;
}
if (!tab.url || /^chrome:|^chrome-extension:|^chrome-devtools:/.test(tab.url)) {
setStatus("无法修改受保护页面(如 chrome://)。", true);
return;
}
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
document.body.style.setProperty("background-color", "#16a34a", "important");
}
});
setStatus("已应用绿色背景。");
} catch (error) {
console.error("注入脚本失败:", error);
setStatus("修改失败,请检查权限或重试。", true);
}
}
changeColorBtn?.addEventListener("click", changeBackgroundToGreen);
重构协作逻辑:文档大于代码
与 Trae 等 AI 编程智能体合作时,编码本身不再是核心,完整的上下文文档才是高效产出的关键。这些文档会成为 AI 生成代码的决策依据,避免反复修改:
- 需求文档:明确扩展的核心功能、使用场景与预期效果。
- 技术文档:指定开发规范、依赖工具与适配要求。
- 设计稿 / 线框图:定义界面元素、交互逻辑与视觉风格。
- 接口文档:说明扩展所需调用的接口、参数与返回格式。
- 测试文档:列出功能测试点与兼容性要求。
打开chrome://extensions/
点开开发者目录,在浏览器打开得到:
完成代码生成后,通过 Chrome 浏览器的 “开发者模式” 加载扩展目录,即可快速验证效果 —— 从需求描述到功能落地,全程无需手动编写大量代码,仅需聚焦核心逻辑与质量监督。
Vibe 协作:人机共生的创造新范式
Vibe Coding 的本质是 “人机分工”,找到与 AI 编辑器的最佳合作节奏,就能最大化创造效率:
- 核心原则:提供清晰、完整的上下文文档,让 AI 明确方向。
- 分工逻辑:将 AI 擅长的代码生成、语法校验等工作交给 Trae,人类则聚焦 AI 不擅长的需求拆解、逻辑把关与质量监督。
- 成长路径:在协作中同步积累能力,重点掌握需求文档撰写、线框图绘制、项目目录架构设计等核心技能。
- 核心心态:保持耐心 “氛围编程”,人机磨合的过程也是创意逐步落地的过程,无需追求一步到位。
Javascript的Iterator和Generator
Iterator和Generator
Iterator
简介
遍历器:Iterator是一种机制。可以把它理解成一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。
Iterator 的作用有三个:
- 一是为各种数据结构,提供一个统一的、简便的访问接口;
- 二是使得数据结构的成员能够按某种次序排列;
- 三是 ES6 创造了一种新的遍历命令
for...of循环,Iterator 接口主要供for...of消费。
机制
Iterator 的遍历过程是这样的。
(1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,遍历器对象本质上,就是一个指针对象。
(2)第一次调用指针对象的next方法,可以将指针指向数据结构的第一个成员。
(3)第二次调用指针对象的next方法,指针就指向数据结构的第二个成员。
(4)不断调用指针对象的next方法,直到它指向数据结构的结束位置。
每一次调用next方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含value和done两个属性的对象。其中,value属性是当前成员的值,done属性是一个布尔值,表示遍历是否结束。
下面是一个模拟next方法返回值的例子:
const arr = [1, 2, 3, 4, 5];
function myIterator(array) {
let nextIndex = 0;
return {
next: function () {
return nextIndex < array.length ? { value: arr[nextIndex++], done: false } : { value: undefined, done: true };
}
}
}
const it = myIterator(arr);
it.next(); // {value: 1, done: false}
it.next(); // {value: 2, done: false}
it.next(); // {value: 3, done: false}
it.next(); // {value: 4, done: false}
it.next(); // {value: 5, done: false}
it.next(); // {value: undefined, done: true}
当然 Iterator 只是把接口规格加到数据结构之上,所以,遍历器与它所遍历的那个数据结构,实际上是分开的。
默认 Iterator 接口
当使用for...of循环遍历某种数据结构时,该循环会自动去寻找 Iterator 接口。一种数据结构只要部署了 Iterator 接口,我们就称这种数据结构是“可遍历的”(iterable)。
ES6 的有些数据结构原生具备 Iterator 接口(比如数组),即不用任何处理,就可以被for...of循环遍历。原因在于,这些数据结构原生部署了Symbol.iterator属性,另外一些数据结构没有(比如对象)。凡是部署了Symbol.iterator属性的数据结构,就称为部署了遍历器接口。调用这个接口,就会返回一个遍历器对象。Symbol.iterator在任何作用域使用值都一样。
原生具备 Iterator 接口的数据结构如下:
- Array
- Map
- Set
- String
- TypedArray
- 函数的 arguments 对象
- NodeList 对象
下面的例子是数组的Symbol.iterator属性:
const arr = [1, 2, 3, 4, 5];
let iter = arr[Symbol.iterator]();
it.next(); // {value: 1, done: false}
it.next(); // {value: 2, done: false}
it.next(); // {value: 3, done: false}
it.next(); // {value: 4, done: false}
it.next(); // {value: 5, done: false}
it.next(); // {value: undefined, done: true}
综上表现,本质上,遍历器是一种线性处理,对于任何非线性的数据结构,部署遍历器接口,就等于部署一种线性转换。
实现一个 Iterator 接口
一个对象如果要具备可被for...of循环调用的 Iterator 接口,就必须在Symbol.iterator的属性上部署遍历器生成方法(原型链上的对象具有该方法也可)。
class RangeIterator {
constructor(start, stop) {
this.start = start;
this.stop = stop;
}
[Symbol.iterator]() {
return this;
}
next() {
let start = this.start;
if (start <= this.stop) {
this.start ++;
return { value: start, done: false };
}
return { value: undefined, done: true };
}
}
const obj = new RangeIterator(1, 5);
const ite = obj[Symbol.iterator]();
ite.next(); // { value: 1, done: false }
ite.next(); // { value: 2, done: false }
ite.next(); // { value: 3, done: false }
ite.next(); // { value: 4, done: false }
ite.next(); // { value: 5, done: false }
ite.next(); // { value: 6, done: false }
遍历器对象的return、throw
遍历器对象除了具有next()方法,还可以具有return()方法和throw()方法。如果你自己写遍历器对象生成函数,那么next()方法是必须部署的,return()方法和throw()方法是否部署是可选的。
return()方法的使用场合是,如果for...of循环提前退出(通常是因为出错,或者有break语句),就会调用return()方法。如果一个对象在完成遍历前,需要清理或释放资源,就可以部署return()方法。
调用 Iterator 接口的场合
-
数组和 Set 结构进行解构赋值时,会默认调用Symbol.iterator
-
扩展运算符(...)也会调用默认的 Iterator 接口(某个数据结构部署了 Iterator 接口,就可以对它使用扩展运算符)
-
yield* 后面跟的是一个可遍历的结构,它会调用该结构的遍历器接口
-
数组的遍历会调用遍历器接口,所以任何接受数组作为参数的场合,其实都调用了遍历器接口,如下:
- for...of
- Array.from()
- Map(), Set(), WeakMap(), WeakSet()(比如new Map([['a',1],['b',2]]))
- Promise.all()
- Promise.race()
Generator
简介
Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同。执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。
机制
形式上,Generator 函数是一个普通函数,但是有两个特征。一是,function关键字与函数名之间有一个星号;二是,函数体内部使用yield表达式,定义不同的内部状态(yield在英语里的意思就是“产出”)。
function* testGenerator() {
yield 1;
yield 2;
return 3;
}
let test = testGenerator();
test.next(); // { value: 1, done: false }
test.next(); // { value: 2, done: false }
test.next(); // { value: 3, done: true }
test.next(); // { value: undefined, done: true }
上面代码定义了一个 Generator 函数testGenerator,它内部有三个yield表达式(1、2、3),即该函数有四个状态:1,2,3 和 return 语句(结束执行)。
然后,Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是上面介绍的遍历器对象Iterator。下一步,必须调用遍历器对象的next方法,使得指针移向下一个状态。也就是说,每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。换言之,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行。
yield 表达式
由于 Generator 函数返回的遍历器对象,只有调用next方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield表达式就是暂停标志。
遍历器对象的next方法的运行逻辑如下。
-
遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值。
-
下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式。
-
如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。
-
如果该函数没有return语句,则返回的对象的value属性值为undefined。
yield表达式只能用在 Generator 函数里面,用在其他地方都会报错。
function* testGenerator() {
// 可以在for循环中,但是不是在forEach等函数中
for (let i = 0;;i ++) {
if (i === 3) {
return i;
}
yield i;
}
}
let test = testGenerator();
test.next(); // { value: 0, done: false }
test.next(); // { value: 1, done: false }
test.next(); // { value: 2, done: true }
test.next(); // { value: undefined, done: true }
yield表达式如果用在另一个表达式之中,必须放在圆括号里面。
function* testGenerator() {
for (let i = 0;i < 3;i ++) {
console.log('next:' + (yield i));
}
}
let test = testGenerator();
test.next();
// 先:{ value: 0, done: false }
// 后:next:undefined
yield表达式用作函数参数或放在赋值表达式的右边,可以不加括号。
function* testGenerator() {
testFunc(yield 1);
let a = yield 2; // { value: 2, done: false }
console.log('next:', a); // next: undefined
yield 3;
}
let test = testGenerator();
test.next(); // 先 { value: 1, done: false },后 test: undefined
test.next(); // { value: 2, done: false }
test.next(); // 先 next: undefined,后 { value: 3, done: false }
与 Iterator 接口的关系
任意一个对象的Symbol.iterator方法,等于该对象的遍历器生成函数,调用该函数会返回该对象的一个遍历器对象。Generator 函数就是遍历器生成函数,因此可以把 Generator 赋值给对象的Symbol.iterator属性,从而使得该对象具有 Iterator 接口。
let obj = {};
obj[Symbol.iterator] = function* () {
yield 1;
yield 2;
yield 3;
}
console.log([...obj]); // [1, 2, 3]
// 具有Symbol.iterator属性,即可使用拓展运算符
Generator 函数执行后,返回一个遍历器对象。该对象本身也具有Symbol.iterator属性,执行后返回自身。
function* test() {
yield true;
}
const iter = test();
console.log(iter[Symbol.iterator]() === iter); // true
next 方法的参数
上面的示例中yield 2本身并没有返回值,即为undefined。next方法可以带一个参数,该参数就会被当作上一个状态yield表达式的返回值。
function* test() {
let res1 = yield 1;
console.log(res1);
let res2 = yield 2;
console.log(res2);
let res3 = yield 3;
console.log(res3);
}
const iter = test();
iter.next('a'); // { value: 1, done: false }
iter.next('b'); // 先 b,后 { value: 2, done: false }
iter.next('c'); // 先 c,后 { value: 3, done: false }
iter.next('d'); // 先 d,后 { value: undefined, done: true }
所以正常情况来说,一个Generator函数中,第一个yield传递参数是没有作用的,因为并没有上一个状态去接收它的参数。
for...of 循环
for...of循环可以自动遍历 Generator 函数运行时生成的Iterator对象,且此时不再需要调用next方法。
function* test() {
yield 1;
yield 2;
yield 3;
}
for (let key of test()) {
console.log(key); // 1 2 3
}
throw 和 return
Generator 函数返回的遍历器对象,都有一个throw方法,可以在函数体外抛出错误,然后在 Generator 函数体内捕获。
function* test() {
try {
yield 1;
} catch (error) {
console.log('test:', error);
}
yield console.log(2);
}
const iter = test();
iter.next(); // { value: 1, done: false }
try {
iter.throw(new Error('出错了!')); // test: Error: 出错了! 2
iter.throw(new Error('出错了!')); // catch: Error: 出错了!
} catch (error) {
console.log('catch:', error);
}
上面代码中,遍历器对象iter连续抛出两个错误。第一个错误被 Generator 函数体内的catch语句捕获(前提是必须至少执行过一次next方法)。iter第二次抛出错误,由于 Generator 函数内部的catch语句已经执行过了,不会再捕捉到这个错误了,所以这个错误就被抛出了 Generator 函数体,被函数体外的catch语句捕获。
throw方法可以接受一个参数,该参数会被catch语句接收,建议抛出Error对象的实例。throw方法被捕获以后,会附带执行下一条yield表达式。也就是说,会附带执行一次next方法。
function* test() {
yield 1;
yield 2;
yield 3;
}
const iter = test();
iter.next(); // { value: 1, done: false }
iter.return('end'); // { value: 'end', done: true }
iter.next(); // { value: undefined, done: true }
上面代码中,遍历器对象调用return()方法后,返回值的value属性就是return()方法的参数。并且,Generator 函数的遍历就终止了,返回值的done属性为true,以后再调用next()方法,done属性总是返回true.如果return()方法调用时,不提供参数,则返回值的value属性为undefined。
function* test() {
yield 1;
try {
yield 'try';
} finally {
yield 'finally';
}
yield 3;
}
const iter = test();
iter.next(); // { value: 1, done: false }
iter.next(); // { value: 'try', done: false }
iter.return('end'); // { value: 'finally', done: false }
iter.next(); // { value: 'end', done: true }
如果 Generator 函数内部有try...finally代码块,且正在执行try代码块,那么return()方法会导致立刻进入finally代码块,执行完以后,整个函数才会结束。
yield* 表达式
如果在 Generator 函数内部,调用另一个 Generator 函数。需要在前者的函数体内部,自己手动完成遍历。
如下:
function* a() {
yield 1;
yield 2;
}
function* b() {
yield 'a';
for (let i of a()) {
console.log(i);
}
yield 'b';
}
for (let j of b()) {
console.log(j);
}
// a 1 2 b
yield*表达式,作为解决办法,用来在一个 Generator 函数里面执行另一个 Generator 函数。简化上述代码
function* a() {
yield 1;
yield 2;
}
function* b() {
yield 'a';
yield* a();
yield 'b';
}
for (let j of b()) {
console.log(j);
}
// a 1 2 b
可以通过yield*实现多层数组的扁平化处理,如下:
function* iterTree(tree) {
if (Array.isArray(tree)) {
for(let item of tree) {
yield* iterTree(item);
}
} else {
yield tree;
}
}
const arr = [1, 2, ['a', 'b'], 4, ['name', 'age']];
console.log([...iterTree(arr)]);
// [1, 2, 'a', 'b', 4, 'name', 'age'];
VNBarcodeObservation的结果中observation.boundingBox 是什么类型?
大家好,我的开源项目PakePlus可以将网页/Vue/React项目打包为桌面/手机应用并且小于5M只需几分钟,官网地址:pakeplus.com
observation.boundingBox 的类型是 CGRect。
CGRect 结构
CGRect 是 Core Graphics 框架中的结构体,表示一个矩形区域:
public struct CGRect {
public var origin: CGPoint
public var size: CGSize
}
在 Vision 框架中的特性
在 Vision 框架中,boundingBox 使用归一化坐标系统:
let barcodeRequest = VNDetectBarcodesRequest { request, error in
guard let results = request.results as? [VNBarcodeObservation] else { return }
for observation in results {
let boundingBox: CGRect = observation.boundingBox
print("boundingBox: \(boundingBox)")
// 访问具体属性
print("原点: \(boundingBox.origin)") // CGPoint
print("尺寸: \(boundingBox.size)") // CGSize
print("x: \(boundingBox.origin.x)") // CGFloat
print("y: \(boundingBox.origin.y)") // CGFloat
print("宽度: \(boundingBox.size.width)") // CGFloat
print("高度: \(boundingBox.size.height)") // CGFloat
// 其他便捷属性
print("最小X: \(boundingBox.minX)")
print("最小Y: \(boundingBox.minY)")
print("最大X: \(boundingBox.maxX)")
print("最大Y: \(boundingBox.maxY)")
print("中心X: \(boundingBox.midX)")
print("中心Y: \(boundingBox.midY)")
}
}
归一化坐标系统
Vision 框架的 boundingBox 使用归一化坐标:
-
坐标范围: (0.0, 0.0) 到 (1.0, 1.0)
-
原点: 左下角 (与 UIKit 的左上角不同!)
-
x: 从左到右 (0.0 = 左边缘, 1.0 = 右边缘)
-
y: 从下到上 (0.0 = 下边缘, 1.0 = 上边缘)
// 示例 boundingBox 值
let exampleBox = CGRect(x: 0.2, y: 0.3, width: 0.4, height: 0.3)
// 表示:
// - 从图像左边 20% 的位置开始
// - 从图像底部 30% 的位置开始
// - 宽度为图像的 40%
// - 高度为图像的 30%
坐标转换
由于归一化坐标和 UIKit 坐标系统不同,需要进行转换:
1. 转换为图像像素坐标
func convertToImageCoordinates(boundingBox: CGRect, imageSize: CGSize) -> CGRect {
// Vision → 图像坐标 (左下角原点)
let imageRect = VNImageRectForNormalizedRect(
boundingBox,
Int(imageSize.width),
Int(imageSize.height)
)
return imageRect
}
2. 转换为 UIKit 视图坐标
func convertToViewCoordinates(boundingBox: CGRect, viewSize: CGSize) -> CGRect {
// Vision → UIKit 坐标 (左上角原点)
let viewRect = CGRect(
x: boundingBox.origin.x * viewSize.width,
y: (1 - boundingBox.origin.y - boundingBox.size.height) * viewSize.height,
width: boundingBox.size.width * viewSize.width,
height: boundingBox.size.height * viewSize.height
)
return viewRect
}
完整的使用示例
import Vision
import UIKit
class BarcodeDetector {
func processBarcodeObservation(_ observation: VNBarcodeObservation,
imageSize: CGSize,
targetView: UIView) {
let boundingBox: CGRect = observation.boundingBox
// 1. 打印原始 boundingBox
print("原始 boundingBox: \(boundingBox)")
// 2. 转换为图像坐标
let imageRect = VNImageRectForNormalizedRect(
boundingBox,
Int(imageSize.width),
Int(imageSize.height)
)
print("图像坐标: \(imageRect)")
// 3. 转换为视图坐标 (用于在屏幕上绘制)
let viewRect = convertToViewRect(boundingBox: boundingBox,
viewSize: targetView.bounds.size)
print("视图坐标: \(viewRect)")
// 4. 在界面上绘制边界框
drawBoundingBox(on: targetView, rect: viewRect)
}
private func convertToViewRect(boundingBox: CGRect, viewSize: CGSize) -> CGRect {
return CGRect(
x: boundingBox.origin.x * viewSize.width,
y: (1 - boundingBox.origin.y - boundingBox.size.height) * viewSize.height,
width: boundingBox.size.width * viewSize.width,
height: boundingBox.size.height * viewSize.height
)
}
private func drawBoundingBox(on view: UIView, rect: CGRect) {
// 移除之前的边界框
view.layer.sublayers?.removeAll(where: { $0.name == "boundingBox" })
// 创建新的边界框图层
let boxLayer = CAShapeLayer()
boxLayer.name = "boundingBox"
boxLayer.frame = rect
boxLayer.borderColor = UIColor.green.cgColor
boxLayer.borderWidth = 2.0
boxLayer.backgroundColor = UIColor.clear.cgColor
view.layer.addSublayer(boxLayer)
}
}
重要注意事项
-
坐标系统差异: Vision 使用左下角原点,UIKit 使用左上角原点
-
归一化范围: 坐标值在 0.0-1.0 范围内
-
空矩形检查: 检查 boundingBox 是否有效
-
边界处理: 确保转换后的坐标在有效范围内
// 检查 boundingBox 是否有效
if boundingBox.isNull || boundingBox.isInfinite {
print("无效的 boundingBox")
return
}
// 检查是否在有效范围内
if boundingBox.minX < 0 || boundingBox.maxX > 1 ||
boundingBox.minY < 0 || boundingBox.maxY > 1 {
print("boundingBox 超出有效范围")
}
总结:observation.boundingBox 是 CGRect 类型,使用归一化坐标系统表示检测对象在图像中的位置和大小,需要进行适当的坐标转换才能在 UIKit 界面中使用。
函数柯里化(curry)是什么?🤔
什么是函数柯里化?
函数柯里化是一种将多参数函数转换为一系列单参数函数的技术。简单来说,柯里化后的函数不会立即求值,而是每次接受一个参数,并返回一个新函数来接收剩余参数,直到所有参数都被提供,最终返回结果。
基本示例
让我们通过一个简单的例子来理解柯里化:
// 普通函数
function add(a, b, c) {
return a + b + c;
}
// 柯里化版本
function curriedAdd(a) {
return function(b) {
return function(c) {
return a + b + c;
};
};
}
// 使用方式对比
console.log(add(1, 2, 3)); // 6
console.log(curriedAdd(1)(2)(3)); // 6
实现通用的柯里化函数
手动为每个函数编写柯里化版本显然不现实,我们可以创建一个通用的柯里化工具函数: 思路就是创建一个自动柯里化函数可以接收一个函数作为参数,然后返回一个它的柯里化后的函数。
//自动柯里化函数,接收一个函数的参数
const autoCurryFn = function(fn){
//边界判断
//是否是函数
if(typeof fn !== 'function'){
throw new Error('传进来的参数必须是一个函数')
}
//返回一个新的函数,接收新的参数,这里用gras剩余参数收集
return function curryFn(...args){
//如果收集的参数个数少于原fn函数的参数个数,则返回这个新函数继续收集
if(args.length < fn.length){
return function(...newGras){
return curryFn(...args,...newGrgs)
}
}else{
//如果收集的参数大于等于原函数的参数就可以执行原函数,并返回对应结果
return fn(...args)
}
}
}
柯里化的实际应用场景
1. 参数复用
柯里化非常适合创建可复用的函数模板:
// 创建特定前缀的日志函数
function createLogger(prefix) {
return function(message) {
console.log('[' + prefix + '] ' + message);
};
}
const infoLogger = createLogger('INFO');
const errorLogger = createLogger('ERROR');
infoLogger('系统启动成功'); // [INFO] 系统启动成功
errorLogger('数据库连接失败'); // [ERROR] 数据库连接失败
2. 延迟执行
柯里化允许我们分步提供参数,这在事件处理等场景中特别有用:
// 事件处理器工厂
function createEventHandler(eventType, element) {
return function(handler) {
element.addEventListener(eventType, handler);
};
}
// 为特定元素创建点击事件处理器
const createClickHandler = createEventHandler('click', document.getElementById('myButton'));
// 稍后添加具体的处理逻辑
createClickHandler(function(event) {
console.log('按钮被点击了!');
});
总结
函数柯里化其实就是将多参数函数转换为单参数函数序列,为我们提供了更灵活的函数组合方式和更高的代码复用性。