阅读视图

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

锚点跳转-附带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);
 }
}

效果图

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赋值不同,使用useRouterpush方法不会导致整个页面的完全重加载,这对性能更加有利。

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.jstemplate.jslayout.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允许你在同一个项目中同时使用apppages目录。这为渐进式迁移提供了一条平滑的路径。你可以先在现有项目中引入app目录来开发新功能,逐步体验App Router的优势。

实战建议

从Pages Router迁移到App Router

如果你计划从Pages Router迁移到App Router,可以考虑以下步骤:

  1. 逐步迁移:利用混合模式,逐步将页面从pages目录迁移到app目录
  2. 组件适配:将使用React钩子或浏览器API的组件标记为客户端组件
  3. 数据获取:将getStaticProps/getServerSideProps重构为组件级数据获取
  4. 布局重构:利用App Router的布局系统简化你的布局代码

开发技巧

  1. 明确组件边界:在App Router中,要清晰界定服务器组件和客户端组件的职责
  2. 利用流式渲染:App Router支持流式渲染,可以显著提升用户体验
  3. 合理使用缓存:App Router提供了更精细的缓存控制机制

结语

App Router无疑是Next.js的未来。它通过React Server Components带来的架构革新,在性能、代码组织和开发体验上都提供了巨大的飞跃。对于新项目,除非有特定的生态限制,否则强烈建议从App Router开始。

希望这篇文章能帮助你理解Next.js中两种路由系统的区别,如果你有任何问题,欢迎在评论区留言讨论!


作者:大鱼,前端开发者,专注于大前端圈技术栈。欢迎关注我的微信公众号<大前端历险记>获取更多前端技术干货。

SCSS新手教学(知识点概览)

scss:sass3以后的新语法,功能完全等价,语法上{ scss使用大括号,sass使用缩进 }不能混用,会报错 并且scss中可以直接写css

Sass有三个版本Dart SasslibsassRuby 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合并成一个新的mapmap-values()映射中的所有值

selector selector-append()可以把一个选择符附加到另一个选择符,selector-unify()将两组选择器合成一个复合选择器

vue3学习笔记

1. Vue3简介

1695089947298-161c1b47-eb86-42fb-b1f8-d6a4fcab8ee2.png

1.1. 【性能的提升】

  • 打包大小减少41%

  • 初次渲染快55%, 更新渲染快133%

  • 内存减少54%

1.2.【 源码的升级】

  • 使用Proxy代替defineProperty实现响应式。

  • 重写虚拟DOM的实现和Tree-Shaking

1.3. 【拥抱TypeScript】

  • Vue3可以更好的支持TypeScript

1.4. 【新的特性】

  1. Composition API(组合API):

    • setup

    • refreactive

    • computedwatch

      ......

  2. 新的内置组件:

    • Fragment

    • Teleport

    • Suspense

      ......

  3. 其他改变:

    • 新的生命周期钩子

    • 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.cnvite的优势如下:

  • 轻量快速的热重载(HMR),能实现极速的服务启动。
  • TypeScriptJSXCSS 等支持开箱即用。
  • 真正的按需编译,不再等待整个应用编译完成。
  • webpack构建 与 vite构建对比图如下: 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插件:

volar.png

image-20231218085906380.png 总结:

  • 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】

  • Vue2API设计是Options(配置)风格的。
  • Vue3API设计是Composition(组合)风格的。

Options API 的弊端

Options类型的 API,数据、方法、计算属性等,是分散在:datamethodscomputed中的,若想新增或者修改一个需求,就需要分别修改:datamethodscomputed,不便于维护和复用。

1696662197101-55d2b251-f6e5-47f4-b3f1-d8531bbf9279.gif

1696662200734-1bad8249-d7a2-423e-a3c3-ab4c110628be.gif

Composition API 的优势

可以用函数的方式,更加优雅的组织代码,让相关功能的代码更加有序的组织在一起。

1696662249851-db6403a1-acb5-481a-88e0-e1e34d2ef53a.gif

1696662256560-7239b9f9-a770-43c1-9386-6cc12ef1e9c0.gif

说明:以上四张动图原创作者:大帅老猿

3.2. 【拉开序幕的 setup】

setup 概述

setupVue3中一个新的配置项,值是一个函数,它是 Composition API “表演的舞台***”***,组件中所用到的:数据、方法、计算属性、监视......等等,均配置在setup中。

特点如下:

  • setup函数返回的对象中的内容,可直接在模板中使用。
  • setup中访问thisundefined
  • 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 的配置(datamethos......)中可以访问到 setup中的属性、方法。
  • 但在setup不能访问到Vue2的配置(datamethos......)。
  • 如果与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>

扩展:上述代码,还需要编写一个不写setupscript标签,去指定组件名字,比较麻烦,我们可以借助vite中的插件简化

  1. 第一步:npm i vite-plugin-vue-setup-extend -D
  2. 第二步:vite.config.ts
import { defineConfig } from 'vite'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'

export default defineConfig({
  plugins: [ VueSetupExtend() ]
})
  1. 第三步:<script setup lang="ts" name="Person">

3.3. 【ref 创建:基本类型的响应式数据】

  • **作用:**定义响应式变量。
  • 语法:let xxx = ref(初始值)
  • **返回值:**一个RefImpl的实例对象,简称ref对象refref对象的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】

宏观角度看:

  1. ref用来定义:基本类型数据对象类型数据

  2. reactive用来定义:对象类型数据

  • 区别:
  1. ref创建的变量必须使用.value(可以使用volar插件自动添加.value)。

自动补充value.png

  1. reactive重新分配一个新对象,会失去响应式(可以使用Object.assign去整体替换)。
  • 使用原则:
  1. 若需要一个基本类型的响应式数据,必须使用ref
  2. 若需要一个响应式对象,层级不深,refreactive都可以。
  3. 若需要一个响应式对象,且层级较深,推荐使用reactive

3.7. 【toRefs 与 toRef】

  • 作用:将一个响应式对象中的每一个属性,转换为ref对象。
  • 备注:toRefstoRef功能一致,但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作用一致)。

computed.gif

<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只能监视以下四种数据
  1. ref定义的数据。
  2. reactive定义的数据。
  3. 函数返回一个值(getter函数)。
  4. 一个包含上述内容的数组。

我们在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定义的对象中的属性,newValueoldValue 都是新值,因为它们是同一个对象。

  • 若修改整个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>

* 情况四

监视refreactive定义的【对象类型】数据中的某个属性,注意点如下:

  1. 若该属性值不是【对象类型】,需要写成函数形式。
  2. 若该属性值是依然是【对象类型】,可直接编,也可写成函数,建议写成函数。

结论:监视的要是对象里的属性,那么最好写函数式,注意点:若是对象监视的是地址值,需要关注对象内部,需要手动开启深度监视。

<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

    1. 都能监听响应式数据的变化,不同的是监听数据变化的方式不同

    2. watch:要明确指出监视的数据

    3. 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的生命周期

    创建阶段:beforeCreatecreated

    挂载阶段:beforeMountmounted

    更新阶段:beforeUpdateupdated

    销毁阶段:beforeDestroydestroyed

  • Vue3的生命周期

    创建阶段:setup

    挂载阶段:onBeforeMountonMounted

    更新阶段:onBeforeUpdateonUpdated

    卸载阶段:onBeforeUnmountonUnmounted

  • 常用的钩子: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. 【对路由的理解】

image-20231018144351536.png

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. 【两个注意点】

  1. 路由组件通常存放在pagesviews文件夹,一般组件通常存放在components文件夹。

  2. 通过点击导航,视觉效果上“消失” 了的路由组件,默认是被卸载掉的,需要的时候再去挂载

4.4.【路由器工作模式】

  1. history模式

    优点:URL更加美观,不带有#,更接近传统的网站URL

    缺点:后期项目上线,需要服务端配合处理路径问题,否则刷新会有404错误。

    const router = createRouter({
      history:createWebHistory(), //history模式
      /******/
    })
    
  2. 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. 【嵌套路由】

  1. 编写News的子路由:Detail.vue

  2. 配置路由规则,使用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
    
  3. 跳转路由(记得要加完整路径):

    <router-link to="/news/detail">xxxx</router-link>
    <!-- 或 -->
    <router-link :to="{path:'/news/detail'}">xxxx</router-link>
    
  4. 记得去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参数

  1. 传递参数

    <!-- 跳转并携带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>
    
  2. 接收参数:

    import {useRoute} from 'vue-router'
    const route = useRoute()
    // 打印query参数
    console.log(route.query)
    

params参数

  1. 传递参数

    <!-- 跳转并携带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>
    
  2. 接收参数:

    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属性】

  1. 作用:控制路由跳转时操作浏览器历史记录的模式。

  2. 浏览器的历史记录有两种写入方式:分别为pushreplace

    • push是追加历史记录(默认值)。
    • replace是替换当前记录。
  3. 开启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. 【重定向】

  1. 作用:将特定的路径,重新定向到已有路由。

  2. 具体编码:

    {
        path:'/',
        redirect:'/about'
    }
    

5. pinia

5.1【准备一个效果】

pinia_example.gif

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【存储+读取数据】

  1. Store是一个保存:状态业务逻辑 的实体,每个组件都可以读取写入它。

  2. 它有三个概念:stategetteraction,相当于组件中的: datacomputedmethods

  3. 具体编码:src/store/count.ts

    // 引入defineStore用于创建store
    import {defineStore} from 'pinia'
    
    // 定义并暴露一个store
    export const useCountStore = defineStore('count',{
      // 动作
      actions:{},
      // 状态
      state(){
        return {
          sum:6
        }
      },
      // 计算
      getters:{}
    })
    
  4. 具体编码: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:{}
    })
    
  5. 组件中使用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.【修改数据】(三种方式)

  1. 第一种修改方式,直接修改

    countStore.sum = 666
    
  2. 第二种修改方式:批量修改

    countStore.$patch({
      sum:999,
      school:'atguigu'
    })
    
  3. 第三种修改方式:借助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
          }
        }
      },
      /*************/
    })
    
  4. 组件中调用action即可

    // 使用countStore
    const countStore = useCountStore()
    
    // 调用对应action
    countStore.incrementOdd(n.value)
    

5.5.【storeToRefs】

  • 借助storeToRefsstore中的数据转为ref对象,方便在模板中使用。
  • 注意:pinia提供的storeToRefs只会将数据做转换,而VuetoRefs会转换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】

  1. 概念:当state中的数据,需要经过处理后再使用时,可以使用getters配置。

  2. 追加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()
        }
      }
    })
    
  3. 组件中读取数据:

    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被砍掉了。

常见搭配形式:

image-20231119185900990.png

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. 【自定义事件】

  1. 概述:自定义事件常用于:子 => 父。
  2. 注意区分好:原生事件、自定义事件。
  • 原生事件:
    • 事件名是特定的(clickmosueenter等等)
    • 事件对象$event: 是包含事件相关信息的对象(pageXpageYtargetkeyCode
  • 自定义事件:
    • 事件名是任意名称
    • 事件对象$event: 是调用emit时所提供的数据,可以是任意类型!!!
  1. 示例:

    <!--在父组件中,给子组件绑定自定义事件:-->
    <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】

  1. 概述:实现 父↔子 之间相互通信。

  2. 前序知识 —— v-model的本质

    <!-- 使用v-model指令 -->
    <input type="text" v-model="userName">
    
    <!-- v-model的本质是下面这行代码 -->
    <input 
      type="text" 
      :value="userName" 
      @input="userName =(<HTMLInputElement>$event.target).value"
    >
    
  3. 组件标签上的v-model的本质::moldeValueupdate: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>
    
  4. 也可以更换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>
    
  5. 如果value可以更换,那么就可以在组件标签上多次使用v-model

    <AtguiguInput v-model:abc="userName" v-model:xyz="password"/>
    

6.5.【$attrs 】

  1. 概述:$attrs用于实现当前组件的父组件,向当前组件的子组件通信(祖→孙)。

  2. 具体说明:$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. 【refsrefs、parent】

  1. 概述:

    • $refs用于 :父→子。
    • $parent用于:子→父。
  2. 原理如下:

    属性 说明
    $refs 值为对象,包含所有被ref属性标识的DOM元素或组件实例。
    $parent 值为对象,当前组件的父组件实例对象。

6.7. 【provide、inject】

  1. 概述:实现祖孙组件直接通信

  2. 具体使用:

    • 在祖先组件中通过provide配置向后代组件提供数据
    • 在后代组件中通过inject配置来声明接收数据
  3. 具体编码:

    【第一步】父组件中,使用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. 默认插槽

img

父组件中:
        <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. 作用域插槽

  1. 理解:数据在组件的自身,但根据数据生成的结构需要组件的使用者来决定。(新闻数据在News组件中,但使用数据所遍历出来的结构由App组件决定)

  2. 具体编码:

    父组件中:
          <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

  1. 作用:创建一个响应式数据,但只对顶层属性进行响应式处理。

  2. 用法:

    let myVar = shallowRef(initialValue);
    
  3. 特点:只跟踪引用值的变化,不关心值内部的属性变化。

shallowReactive

  1. 作用:创建一个浅层响应式对象,只会使对象的最顶层属性变成响应式的,对象内部的嵌套属性则不会变成响应式的

  2. 用法:

    const myObj = shallowReactive({ ... });
    
  3. 特点:对象的顶层属性是响应式的,但嵌套对象的属性不是。

总结

通过使用 shallowRef()shallowReactive() 来绕开深度响应。浅层式 API 创建的状态只在其顶层是响应式的,对所有深层的对象不会做任何处理,避免了对每一个内部属性做响应式所带来的性能成本,这使得属性的访问变得更快,可提升性能。

7.2.【readonly 与 shallowReadonly】

readonly

  1. 作用:用于创建一个对象的深只读副本。

  2. 用法:

    const original = reactive({ ... });
    const readOnlyCopy = readonly(original);
    
  3. 特点:

    • 对象的所有嵌套属性都将变为只读。
    • 任何尝试修改这个对象的操作都会被阻止(在开发模式下,还会在控制台中发出警告)。
  4. 应用场景:

    • 创建不可变的状态快照。
    • 保护全局状态或配置不被修改。

shallowReadonly

  1. 作用:与 readonly 类似,但只作用于对象的顶层属性。

  2. 用法:

    const original = reactive({ ... });
    const shallowReadOnlyCopy = shallowReadonly(original);
    
  3. 特点:

    • 只将对象的顶层属性设置为只读,对象内部的嵌套属性仍然是可变的。

    • 适用于只需保护对象顶层属性的场景。

7.3.【toRaw 与 markRaw】

toRaw

  1. 作用:用于获取一个响应式对象的原始对象, toRaw 返回的对象不再是响应式的,不会触发视图更新。

    官网描述:这是一个可以用于临时读取而不引起代理访问/跟踪开销,或是写入而不触发更改的特殊方法。不建议保存对原始对象的持久引用,请谨慎使用。

    何时使用? —— 在需要将响应式对象传递给非 Vue 的库或外部系统时,使用 toRaw 可以确保它们收到的是普通对象

  2. 具体编码:

    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

  1. 作用:标记一个对象,使其永远不会变成响应式的。

    例如使用mockjs时,为了防止误把mockjs变为响应式对象,可以使用 markRaw 去标记mockjs

  2. 编码:

    /* 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包裹组件,并配置好defaultfallback
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.component
  • app.config
  • app.directive
  • app.mount
  • app.unmount
  • app.use

8.4.【其他】

  • 过渡类名 v-enter 修改为 v-enter-from、过渡类名 v-leave 修改为 v-leave-from

  • keyCode 作为 v-on 修饰符的支持。

  • v-model 指令在组件上的使用已经被重新设计,替换掉了 v-bind.sync。

  • v-ifv-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) ,比如 ReactVue 项目

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>
}

image.png

浏览器第一时间得到的结果没有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 }  }
}

image.png

在服务器中请求数据后,直接将数据拼接到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。工作被拆分为块,并在客户端准备就绪时流式传输到客户端。这允许用户在整个内容完成渲染之前立即看到页面的某些部分。

image.png

通过浏览器插件React Developer Tools,查看当前app/page.jsx页面组件层级,即可看到其底层组件层级。

image.png

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为服务端组件,作为子组件被客户端组件引用时,页面报错。

image.png

  • 服务器组件可以作为客户端组件的插槽

  • 服务器组件可以作为客户端组件的属性

  • Context组件通信必须在客户端组件中来完成

服务器组件
  • 服务器组件和客户端组件都能作为服务器组件子组件
外部依赖组件
  • 如果当前页面需要引入外部依赖中的组件报错,可能是外部依赖组件没有使用use client标识,导致将其作为服务端组件进行使用从而你报错,可创建客户端组件包裹依赖组件即可。

使用 MediaPipe 在 Flutter web 中识别姿势

咱们就来像这样识别视频里的姿势吧!

image.png

有一个名为 MediaPipe的库,它可以在图像、文本和声音上完成许多识别和检测任务。其中包括一个用于在图像上识别姿势的模型。

你可以点击这里,试试它的官方演示: mediapipe-studio.webapps.google.com/demo/pose_l…

image.png

它还有一个 CodePen 代码片段,方便你用 JavaScript 快速进行实践操作(或“上手”): codepen.io/mediapipe-p…

image.png

然而,该模型目前仅支持在 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:

image.png

  • 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),是因为如果 xy 坐标在画面帧内,它们的值范围就在 0 到 1 之间。

如果图像被裁剪,并且模型认为某个特定的点在图像外部,那么这些坐标也可能小于零或大于 1,就像下面这个网络摄像头示例一样:

有些点在画面范围之外,模型试图猜测我的肘部会在哪里。

为什么不直接使用来自 dart:uiOffset 类型呢?

这个库(指 MediaPipe)还会给我们 z 轴坐标(即到摄像机的距离)以及一些我们目前不需要的其他信息,但以后能添加它们会很有益处。因此,Offset 类型是不够的

另外,这个 NormalizedLandmark 类型是在每个独立的实现中都有定义的:TypeScript、Java 等。所以,让我们保持一致性。

接下来,这是识别结果

class PoseLandmarkerResult {
  final List<List<NormalizedLandmark>> landmarks;

  const PoseLandmarkerResult.empty() : landmarks = const [];

  const PoseLandmarkerResult({required this.landmarks});
}

该库返回已识别姿势的列表(即列表的第一个维度)。每个姿势都是一个地标点列表,它们位于特定的索引上(即列表的第二个维度):

PoseLandmarkerResult中地标点(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();
  }
}

📌 关键点解析

这里涉及许多内容。

最重要的是,我们定义了两个业务逻辑函数ensureInitializeddetect

接下来,_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 端,它是一个带有 xy 属性的常规对象,在我们的 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 源代码手动编写出来:

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(轻量)版本:

ai.google.dev/edge/mediap…

由于模型是在不同实现之间共享的,最好将它们放入一个共享 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 是当前浏览器会话私有的,可以用于获取图像。有趣的是,你甚至可以在另一个标签页中打开它:

在一个页面上创建的 Blob 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;

这两个监听器(onloadonerror)都会使 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),直到获得使用摄像头的权限,并且如果访问被拒绝,它也不会重试。但它能完成工作:

image.png

📸 捕获和分析静止图像 (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 组件使用 foregroundPainterchild 组件的上方覆盖一层进行绘制。

现在,让我们来创建这个 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)与摄像头预览是相同尺寸的,所以一切都恰到好处。


🎉 最终成果

这最终为我们实现了我们想要的效果:

image.png

🚀 再次展示已部署的演示 (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-20251112090708795


image-20251112091151694

image-20251112091151694

上图是将 1个pdf文件和3个图片一起预览,那么就只会现实第1个预览窗口。

下图是移除pdf文件,将3个同类型的图片放在一起预览

image-20251112091518239

image-20251112091518239


为了方便演示功能,需要先将一些可以预览的文件下载到元服务的沙箱内,是基于这个原因我们才需要引入后端来模拟这个下载的环境,所以元服务内需要先实现下载文件,存储到沙箱,然后再使用预览API filePreview.openPreview预览沙箱内的文件。

1. 工程结构与目标

  • client/entry/src/main/ets/pages/Index.ets:演示并发下载 4 个文件(1.pdf1.png2.png3.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 至少需要 titleurimimeType
  • 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.isDownloadingtrue;
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 i0; 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.isDownloadingfalse;

UI 渲染建议:

  • 使用 ForEach(this.plannedFiles, ...) 动态渲染状态行,避免硬编码索引。
  • 将与 UI 绑定的字段用 @Local 或 @State 修饰,并“重新赋值数组”以触发刷新(不要在原数组上就地修改元素)。

图片占位:请补充“下载中→成功/失败”逐项状态变化的截图,便于读者理解响应式刷新。


4. HTTP 下载的细节与 ArkTS 限制规避

  • MIME 与扩展名:示例通过扩展名推断 MIME,若扩展名缺失则从响应头的 Content-Type 推断。
  • ArkTS 限制:不建议直接 data.header['Content-Type'] 索引;示例使用序列化 + 正则方式提取避免 ArkTS 索引限制。
// 通过序列化响应头并用正则提取 Content-Type
private tryGetContentTypeHeader(headerObjObject | 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.length1 ? 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

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.pdf1.png2.png3.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文件夹的图标作为应用程序的图标

image.png

然后我们看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/

点开开发者目录,在浏览器打开得到:

image.png

完成代码生成后,通过 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* 后面跟的是一个可遍历的结构,它会调用该结构的遍历器接口

  • 数组的遍历会调用遍历器接口,所以任何接受数组作为参数的场合,其实都调用了遍历器接口,如下:

  1. for...of
  2. Array.from()
  3. Map(), Set(), WeakMap(), WeakSet()(比如new Map([['a',1],['b',2]]))
  4. Promise.all()
  5. 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表达式(123),即该函数有四个状态: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)
    }
}

重要注意事项

  1. 坐标系统差异: Vision 使用左下角原点,UIKit 使用左上角原点

  2. 归一化范围: 坐标值在 0.0-1.0 范围内

  3. 空矩形检查: 检查 boundingBox 是否有效

  4. 边界处理: 确保转换后的坐标在有效范围内

// 检查 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 界面中使用。

大家好,我是1024小神,技术群 / 私活群 / 股票群 或 交朋友 都可以私信我。 如果你觉得本文有用,一键三连 (点赞、评论、关注),就是对我最大的支持~

函数柯里化(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('按钮被点击了!');
});

总结

函数柯里化其实就是将多参数函数转换为单参数函数序列,为我们提供了更灵活的函数组合方式和更高的代码复用性。

❌