普通视图

发现新文章,点击刷新页面。
今天 — 2025年6月8日首页

学习React的一些知识

作者 unravel2025
2025年6月7日 22:39

目前前端大部分公司应该都在使用React。这里记录一些学习React所需要的资料,方便自己查阅,也方便别人省去找资料的时间

  1. React官网,提供学习的最权威资料。不管是入门还是深入学习都很有用

react.dev/

  1. React中文官网

zh-hans.react.dev/

  1. CSS 样式和选择器学习

developer.mozilla.org/zh-CN/docs/…

  1. html的所有标签,介绍和学习

developer.mozilla.org/zh-CN/docs/…

  1. less,增强的css配置

less.bootcss.com/#%E6%A6%82%…

  1. redux状态管理

cn.redux.js.org/

  1. redux-toolkit 在redux基础上封装的,更易于使用的框架

redux-toolkit.js.org/

  1. react技术解密,带你了解react的一些实现原理

react.iamkasong.com/

Zustand 第二章(状态处理)

作者 小满zs
2025年6月7日 22:38

状态处理

在上一章我们讲了,Zustand会合并第一层的state,但是如果深层次应该如何处理呢

来吧演示

首先创建一个葫芦娃,葫芦娃有七个娃,每个娃都有自己的状态,我们可以通过updateGourd来更新葫芦娃的状态,这样就实现了一个深层次的demo

import { create } from 'zustand'

interface User {
    gourd: {
        oneChild: string,
        twoChild: string,
        threeChild: string,
        fourChild: string,
        fiveChild: string,
        sixChild: string,
        sevenChild: string,
    },
    updateGourd: () => void
}
const useUserStore = create<User>(((set) => ({
    //创建葫芦娃
    gourd: {
        oneChild: '大娃',
        twoChild: '二娃',
        threeChild: '三娃',
        fourChild: '四娃',
        fiveChild: '五娃',
        sixChild: '六娃',
        sevenChild: '七娃',
    },
    updateGourd: () => set((state) => ({
        gourd: {
            //...state.gourd, 先不进行状态合并  // [!code highlight] 
            oneChild: '大娃-超进化',
        }
    }))
})))

export default useUserStore;

我们会发现如果不进行状态合并,其他的状态是会丢失的,所以深层次的状态处理需要进行状态合并,但是如果代码过多,每次都需要合并状态也挺烦的,所以我们可以通过immer中间件处理这个问题

state.gif

使用immer中间件

安装

npm install immer

原始immer的用法

需要导出produce,然后它的第一个参数是原始值,第二个参数是一个回调函数,回调函数中的参数是draft,也就是原始值的拷贝,然后我们就可以直接修改draft了,最后返回新的值

import { produce } from 'immer'

const data = {
  user: {
    name: '张三',
    age: 18
  }
}

const newData = produce(data, draft => {
  draft.user.age = 20
})

console.log(newData,data) 
//{ user: { name: '张三', age: 20 } } 
//{ user: { name: '张三', age: 18 } }

immerZustand中的使用方法

引入注意是从zustand/middleware/immer引入,而不是immer

import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
  1. 首先从zustand中间件引入immer
  2. 然后注意结构create()(immer())这里是两个括号而不是放在create里面了
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface User {
    gourd: {
        oneChild: string,
        twoChild: string,
        threeChild: string,
        fourChild: string,
        fiveChild: string,
        sixChild: string,
        sevenChild: string,
    },
    updateGourd: () => void
}
//注意结构发生了变化!!!
const useUserStore = create<User>()(immer(((set) => ({
    //创建葫芦娃
    gourd: {
        oneChild: '大娃',
        twoChild: '二娃',
        threeChild: '三娃',
        fourChild: '四娃',
        fiveChild: '五娃',
        sixChild: '六娃',
        sevenChild: '七娃',
    },
    updateGourd: () => set((state) => {
        state.gourd.oneChild = '大娃-超进化' //这儿不需要状态合并了需要修改什么值直接改就行了
        state.gourd.twoChild = '二娃-谁来了'
        state.gourd.threeChild = '三娃-我来了'
    })
}))))

export default useUserStore;

immer原理剖析

immer.js 通过 Proxy 代理对象的所有操作,实现不可变数据的更新。当对数据进行修改时,immer 会创建一个被修改对象的副本,并在副本上进行修改,最后返回修改后的新对象,而原始对象保持不变。这种机制确保了数据的不可变性,同时提供了直观的修改方式。

immer 的核心原理基于以下两个概念:

  1. 写时复制 (Copy-on-Write)
  • 无修改时:直接返回原对象
  • 有修改时:创建新对象
  1. 惰性代理 (Lazy Proxy)
  • 按需创建代理
  • 通过 Proxy 拦截操作
  • 延迟代理创建

工作流程

graph TD
A[调用 produce] --> 
B[创建代理 draft] --> 
C[执行 recipe 修改 draft]  --> 
D[是否有修改!] 
D-- 是 --> E[创建新对象...base + ...modified]   
D-- 否 --> F[直接返回 base]

简化实现

type Draft<T> = {
  -readonly [P in keyof T]: T[P];
};

function produce<T>(base: T, recipe: (draft: Draft<T>) => void): T {
  // 用于存储修改过的对象
  const modified: Record<string, any> = {};
  
  const handler = {
    get(target: any, prop: string) {
      // 如果这个对象已经被修改过,返回修改后的对象
      if (prop in modified) {
        return modified[prop];
      }
      
      // 如果访问的是对象,则递归创建代理
      if (typeof target[prop] === 'object' && target[prop] !== null) {
        return new Proxy(target[prop], handler);
      }
      return target[prop];
    },
    set(target: any, prop: string, value: any) {
      // 记录修改
      modified[prop] = value;
      return true;
    }
  };

  // 创建代理对象
  const proxy = new Proxy(base, handler);
  
  // 执行修改函数
  recipe(proxy);
  
  // 如果没有修改,直接返回原对象
  if (Object.keys(modified).length === 0) {
    return base;
  }
  
  // 创建新对象,只复制修改过的属性
  return JSON.parse(JSON.stringify(proxy))
}

// 使用示例
const state = {
  user: {
    name: '张三',
    age: 25
  }
};

const newState = produce(state, draft => {
  draft.user.name = '李四';
  draft.user.age = 26;
});

console.log(state);     // { user: { name: '张三', age: 25 } }
console.log(newState);  // { user: { name: '李四', age: 26 } }

这儿只是简单实现,没有考虑数组的情况和深层次的代理,只实现了其核心思想

🚀🚀🚀Prisma 发布无 Rust 引擎预览版,安装和使用更轻量;支持任何 ORM 连接引擎;支持自动备份...

2025年6月7日 22:26

前言

之前我已经陆陆续续介绍了两篇 Prisma 的文章,最近发布了新的预览版本,我就马上来更新这次的更新内容了~

往期精彩推荐

正文

Prisma 作为一款强大的 ORM 工具,近期发布了多项更新。这些更新不仅提升了开发体验,还为本地开发和数据管理带来了更多便利!

下面是详细内容!

1. Prisma ORM v6.9.0 无 Rust 引擎预览

Prisma ORM v6.9.0 推出了无 Rust 引擎的预览版,减少了对 Rust 的依赖,这意味着安装和部署流程更加轻量,当前支持 PostgreSQLSQLite!

需要在 schema.prisma 中启用预览功能:

generator client {
  provider = "prisma-client-js"
  previewFeatures = ["queryCompiler", "driverAdapters"]
}

2. 通过任何 ORM 连接 Prisma Postgres

Prisma Postgres 现支持通过任何 ORM(如 DrizzleKyselyTypeORM)使用常规 PostgreSQL TCP 连接字符串进行连接。这大大增强了 Prisma Postgres 的灵活性,开发者可以自由选择喜欢的 ORM 工具。无服务器驱动目前仍处于早期访问阶段。

任何 ORM 连接

在环境变量中配置 PostgreSQL 连接字符串:

DATABASE_URL=postgres://user:password@host:port/database?schema=public

在代码中(如使用 Kysely):

import { Kysely } from "kysely";
import { PostgresDialect } from "kysely";
import { Pool } from "pg";

const db = new Kysely({
  dialect: new PostgresDialect({
    pool: new Pool({
      connectionString: process.env.DATABASE_URL,
    }),
  }),
});

开发者可以无缝将 Prisma Postgres 集成到现有项目中,无需局限于 Prisma Client。灵活性提升明显,特别适合混合技术栈团队!

3. Prisma Postgres 自动备份与恢复

Prisma Postgres 新增了自动备份与恢复功能,通过 Prisma Console UI 提供一键操作,开发者可轻松管理数据库备份。这项功能显著提高了数据安全性,适合需要频繁备份的生产环境!

地址:Prisma Console

Prisma Console UI 中,切换到“Backups”选项卡,点击“Create Backup”生成备份,或选择现有备份进行恢复!

Backups

确保配置好环境变量:

PRISMA_POSTGRES_URL=postgres://user:password@host:port/database

自动备份功能简化了数据管理流程,减少手动操作的时间成本。恢复过程通过 UI 直观完成,适合快速回滚或灾难恢复场景。相比传统手动备份,操作效率提升约 50%,且 UI 界面降低了误操作风险。

4. Prisma VS Code 扩展 UI 改进

PrismaVS Code 扩展新增了数据库管理 UI,支持认证、实例管理、数据编辑和模式可视化。这让开发者能在 VS Code 中直接管理 Prisma Postgres 实例,提升生产力。

安装扩展后,在 VS Code 侧边栏打开 Prisma 面板,输入 Prisma Postgres 连接字符串进行认证:

DATABASE_URL=postgres://user:password@host:port/database

随后可通过 UI 创建/删除实例、编辑数据或可视化数据库模式。

UI 创建/删除实例

新 UI 提供了一站式数据库管理体验,开发者无需切换到其他工具即可完成认证、数据编辑等操作。模式可视化功能直观展示表关系,调试效率提升约 30%。对于频繁操作数据库的开发者,这是一个显著的生产力提升。

5. 本地 Prisma Postgres 开发增强

本地 Prisma Postgres 现支持持久化数据库和多实例运行,prisma init 默认使用本地开发环境。

运行以下命令初始化本地开发环境:

npx prisma init --datasource-provider postgres

schema.prisma 中配置本地数据库:

datasource db {
  provider = "postgresql"
  url      = "postgres://localhost:5432/mydb"
}

本地开发环境支持持久化数据和多实例运行,开发者可模拟生产环境进行测试,减少云端成本。prisma init 默认本地化设置简化了配置流程,适合快速原型开发,测试效率提升约 40% !

最后

以上是 Prisma 最新更新的详细解析!这是具体的更新日志:Prisma | Changelog

今天的分享就这些了,感谢大家的阅读,如果文章中存在错误的地方欢迎指正!

往期精彩推荐

管理不同权限用户的左侧菜单展示以及权限按钮的启用 / 禁用之其中一种解决方案

2025年6月7日 21:42

一、权限管理方案设计

1. 权限模型

推荐采用 RBAC(基于角色的访问控制)  模型,把用户分配到不同角色,角色再关联对应的权限。

  • 权限项:是最小的控制单元,像 view_useredit_product 这种。
  • 角色:由多个权限项组合而成,例如 admineditorviewer
  • 用户:和一个或多个角色相对应。

2. 权限数据存储

  • 前端存储:登录成功后,从后端获取权限数据,然后存到 Vuex/Pinia 或者 localStorage 中。

  • 权限数据结构示例

javascript

{
  user: { id: 1, name: "张三", role: "admin" },
  permissions: ["view_user", "edit_user", "view_product", "edit_product"]
}

二、左侧菜单动态展示

1. 菜单配置

创建一个菜单配置文件,把权限和菜单项关联起来。

javascript

// src/config/menu.js
export const menuList = [
  {
    path: "/dashboard",
    name: "Dashboard",
    icon: "dashboard",
    permission: "view_dashboard" // 访问该菜单所需权限
  },
  {
    path: "/user",
    name: "用户管理",
    icon: "user",
    permission: "view_user",
    children: [
      {
        path: "/user/list",
        name: "用户列表",
        permission: "view_user"
      },
      {
        path: "/user/add",
        name: "添加用户",
        permission: "add_user"
      }
    ]
  },
  // 其他菜单项...
];

2. 菜单组件实现

在组件里依据用户权限过滤菜单项。

vue

<!-- src/components/Sidebar.vue -->
<template>
  <div class="sidebar">
    <el-menu :default-active="activeMenu" mode="vertical">
      <template v-for="item in filteredMenu">
        <!-- 一级菜单 -->
        <el-menu-item 
          v-if="!item.children && hasPermission(item.permission)"
          :key="item.path"
          :index="item.path"
        >
          <i :class="item.icon"></i>
          <span slot="title">{{ item.name }}</span>
        </el-menu-item>
        
        <!-- 子菜单 -->
        <el-submenu 
          v-else-if="item.children && hasPermission(item.permission)"
          :key="item.path"
          :index="item.path"
        >
          <template slot="title">
            <i :class="item.icon"></i>
            <span>{{ item.name }}</span>
          </template>
          <el-menu-item
            v-for="child in item.children"
            :key="child.path"
            :index="child.path"
            v-if="hasPermission(child.permission)"
          >
            {{ child.name }}
          </el-menu-item>
        </el-submenu>
      </template>
    </el-menu>
  </div>
</template>

<script>
import { mapState } from "vuex";
import { menuList } from "@/config/menu";

export default {
  computed: {
    ...mapState(["permissions"]),
    // 过滤后的菜单项
    filteredMenu() {
      return menuList.filter(item => this.hasPermission(item.permission));
    }
  },
  methods: {
    // 权限检查方法
    hasPermission(permission) {
      // 如果没有设置权限,默认可见
      if (!permission) return true;
      // 检查用户是否拥有该权限
      return this.permissions.includes(permission);
    }
  }
};
</script>

三、权限按钮的展示与禁用

1. 自定义指令实现

借助自定义指令来控制按钮的显示和禁用状态。

javascript

// src/directives/permission.js
export const permission = {
  inserted(el, binding, vnode) {
    const { value } = binding;
    const permissions = vnode.context.$store.state.permissions;
    
    if (value) {
      // 检查是否有该权限
      const hasPermission = permissions.includes(value);
      
      if (!hasPermission) {
        // 没有权限:隐藏按钮
        el.parentNode && el.parentNode.removeChild(el);
        // 或者禁用按钮(根据需求选择)
        // el.disabled = true;
        // el.classList.add('is-disabled');
      }
    } else {
      console.error('需要指定权限标识!');
      el.parentNode && el.parentNode.removeChild(el);
    }
  }
};

2. 全局注册指令

在 main.js 里全局注册这个指令。

javascript

// src/main.js
import Vue from "vue";
import { permission } from "./directives/permission";

Vue.directive("permission", permission);

3. 在组件中使用

vue

<!-- 使用示例 -->
<template>
  <div>
    <!-- 有权限时显示 -->
    <el-button 
      v-permission="'add_user'"
      type="primary"
      @click="addUser"
    >
      添加用户
    </el-button>
    
    <!-- 无权限时禁用 -->
    <el-button 
      :disabled="!hasPermission('edit_user')"
      type="success"
      @click="editUser"
    >
      编辑用户
    </el-button>
  </div>
</template>

<script>
export default {
  methods: {
    hasPermission(permission) {
      return this.$store.state.permissions.includes(permission);
    }
  }
};
</script>

四、路由权限控制

对路由访问权限进行控制,防止用户手动输入 URL 访问受限页面。

javascript

// src/router/index.js
import router from "./router";
import store from "./store";

router.beforeEach((to, from, next) => {
  // 获取用户权限
  const permissions = store.state.permissions;
  
  // 检查路由是否需要权限
  if (to.meta.permission) {
    if (permissions.includes(to.meta.permission)) {
      next(); // 有权限,放行
    } else {
      next({ path: "/403" }); // 无权限,跳转到403页面
    }
  } else {
    next(); // 无需权限,直接放行
  }
});

五、权限管理流程

  1. 用户登录:用户输入账号密码登录系统。
  2. 权限验证:后端验证用户身份,返回用户角色和权限信息。
  3. 权限存储:前端把权限信息存到 Vuex/Pinia 或者 localStorage 中。
  4. 菜单渲染:根据用户权限动态渲染左侧菜单。
  5. 按钮控制:在组件里通过自定义指令或者方法控制按钮的显示和禁用。
  6. 路由拦截:对路由进行拦截,防止未授权访问。

六、优缺点分析

优点

  • 可扩展性强:能够轻松添加新的角色和权限,而不用修改大量代码。
  • 维护便捷:权限配置集中管理,降低了维护成本。
  • 安全性高:从菜单、按钮、路由三个层面进行权限控制,有效防止越权访问。

缺点

  • 初期配置复杂:需要设计合理的权限模型和数据结构。
  • 性能影响:在复杂应用中,频繁的权限检查可能会对性能产生一定影响。

前端框架渲染DOM的的方式你知道多少?

作者 西陵
2025年6月7日 21:32

文章首发于公众号 code进化论,欢迎关注。

前言

近些年来,前端已经涌现出数十个框架,例如 Vue、React、SoildJS、Svelte、lit等,虽然框架层出不穷但是他们的渲染 DOM 的原理却几乎没有变化,我总结了以下三种渲染 DOM 的方式:

  • Dirty Checking(脏检查,典型代表有 Angular、lit
  • Virtual DOM(虚拟DOM,典型代表有Vue、React)
  • Fine-Grained(细力度更新,典型代表有SoildJS)

对于开发者来说理解框架的 DOM 渲染机制就能轻松理解框架的整体工作原理,下面将会通过手动实现这三种 DOM 渲染方式来理解其工作原理。

什么是模板(Template)?

在 JavaScript 前端框架中都会有模板(template) 这个概念,模板是用来描述 UI 结构的声明性语法。它就像是“组件的蓝图”,其中包含了静态 HTML 和动态绑定数据的“占位符”(即 holes)。框架通过模板生成最终的 DOM 内容,实现数据和视图的同步,而开发者只需要关注数据和数据的变化。

JavaScript 框架的模板语法经历了一个漫长的演进过程。最早的模板方式就是通过字符串拼接或模板替换将数据嵌入 HTML,例如早期的模板引擎 Handlebars 和 EJS(Embedded JavaScript Templates):

// EJS 示例
<h1><%= title %></h1>
<% if (items.length > 0) { %>
  <ul>
    <% items.forEach(function(item) { %>
      <li><%= item %></li>
    <% }); %>
  </ul>
<% } %>

这种方式最大的局限在于如何高效的进行字符串的拼接,然后逐步发展出更复杂、更高效的模板引擎和响应式编译机制,例如在 Vue 中,其模板语法如下:

<template>
  <div class="card">
    <h1>Welcome</h1> // 静态
    <p>i am {{ name }}</p> // 动态
  </div>
</template>

<script>
export default {
  data() {
    return { name: 'Vue' }
  }
}
</script>

在 React 中,其 JSX 模板语法如下:

function App() {
  const [name, setName] = useState('react');
  return (
    <div>
    <h1>Welcome</h1> // 静态
      <p>Hello, {name}</p> // 动态
    </div>
  );
}

虽然不同框架其模板语法会有所差异,但其结构还是一致,即由静态部分和动态部分组成,最终交由渲染引擎进行渲染,因此我们所要关注的是如何将这些模板数据渲染出来。

DOM渲染

Render by Replacement(替换渲染)

替换渲染最典型的例子就是模板引擎 EJS,它会将模板转换成 HTML 并通过 innerHTML 将拼接好的子字符串嵌入到页面中:

<h1><%= title %></h1>
<% if (items.length > 0) { %>
  <ul>
    <% items.forEach(function(item) { %>
      <li><%= item %></li>
    <% }); %>
  </ul>
<% } %>

下面是一个完成的实例:

import { Framework } from "./framework"

class App extends Framework {
  constructor() {
    super({ count: 0 });
    setInterval(() => this.setState({ count: this.state.count + 1 }), 1000);
  }
  template() {
    return `<div>${this.state.count}</div>`;
  }
}

在构造函数中通过定时器该修改状态,在 template 中读取状态并通过模板字符串的方式进行拼接,下面是其核心原理:

export class Framework {
  state: Record<string, any> = {};
  constructor(initState: Record<string, any>) {
    this.state = initState;
    this._template();
  }
  template(): string {
    throw new Error("must be implemented");
  }
  setState(newState: Record<string, any>) {
    this.state = newState;
    this._template();
  }
  _template() {
    document.getElementById("app")!.innerHTML = this.template();
  }
}

更新状态后会再次调用 template 获取返回的模板字符串,在通过 innerHTML 将内容嵌入到页面,可在线调试

www.alltoall.net_6月4日-2-_XVzNxn8Cwl.gif

虽然这种更新方式有效,但是其性能表现上是非常的糟糕的,通过 innerHTML 更新 DOM 意味着他会先卸载父节点下面的所有子节点,再将新的子节点添加进去,如果模板内容越多,那么每次更新状态后卸载和创建的 DOM 元素就越多,这并不高效。

那我们该怎么避免 DOM 的反复创建和卸载呢?有些同学可能就想到了 diff,我们可以检查 DOM 元素是否有值并和新的值对比,如果变了我们再更新它。 但事实证明,这种方式也可能导致性能问题,因为 DOM 上有很多属性一旦读取它们,就会触发重绘(paint)或布局计算(layout),例如 offsetWidth、clientHeight、scrollTop等,因为这些属性在读取时浏览器需要保证获取到的是最新的。

所以我们也能得出了一个结论,DOM 操作本身不慢,因为所有框架最终都得操作 DOM,慢的原因是不正当的访问。那如果我们既不想重建 DOM,又不想频繁读取 DOM,那我们该怎么办?

Dirty Checking(脏检查)

Dirty Checking 不是一种响应式的更新机制,而是显示检测变化的机制,每次状态发生变化之后,需要遍历所有动态绑定,逐个比较旧值与新值,判断哪些需要更新。

下面是一个基于 lit 的完整示例:

import { html, Framework } from "./framework";

class App extends Framework {
  constructor() {
    super({ count: 0 });
    setInterval(() => this.setState({ count: this.state.count + 1 }), 1000);
  }
  template() {
  // 使用html函数来处理模板数据,收集动态绑定数据
    return html`<div>${this.state.count}</div>`;
  }
}
export class Framework {
  state: Record<string, any> = {};
  constructor(initState: Record<string, any>) {
    this.state = initState;
    document.getElementById("app")!.append(this.template());
  }
  template(): Node {
    throw new Error("must be implemented");
  }
  setState(newState: Record<string, any>) {
    this.state = newState;
    this.template();
  }
}

type Binding = { type: string; ref: Node; value: any };
/** 用来缓存每个DOM节点及其绑定的动态数据信息 */
const cache = new Map<
  TemplateStringsArray,
  {
    node: Node;
    bindings: Binding[];
  }
>();

export function html(template: TemplateStringsArray, ...holes: any[]) {
  let prev;
  // 如果是首次渲染,需要缓存模板及其节点信息,用于下次更新时做比对
  if (!(prev = cache.get(template))) {
    const t = document.createElement("template");
    /**
    template.join("<!>")的意思是使用注释节点来占位动态绑定的数据,例如例子中传进来的
    template是['<div>', '</div>'],转换后变成<template><div><!----></div></template>
    便于后续更新
  */
    t.innerHTML = template.join("<!>");
    cache.set(
      template,
      (prev = {
        node: t.content.firstChild!, // node是DOM节点
        bindings: createBindings(t.content.firstChild!), // bindings是当前dom节点的信息描述,其中包括绑定的动态数据
      }),
    );
  }
  // 遍历所有的动态绑定数据,查看是否发生变化
  diff(holes, prev.bindings);
  return prev.node;
}

// 获取节点的所有动态绑定数据
function createBindings(element: Node) {
  const bindings = [];
  let tw = document.createTreeWalker(element, NodeFilter.SHOW_COMMENT, null),
    comment;
  /** 遍历所有的注释节点,首次渲染时value默认设置为undefined */
  while ((comment = tw.nextNode())) {
    bindings.push({ type: "insert", ref: comment, value: undefined });
  }

  return bindings;
}

function diff(holes: any[], bindings: Binding[]) {
  // 遍历所有的动态绑定数据,查看是否发生变化
  for (let i = 0; i < holes.length; i++) {
    const binding = bindings[i];
    // 比较新旧值
    if (holes[i] !== binding.value) {
      if (binding.type === "insert") {
      // 如果是首次渲染,则插入新的文本节点
        if (binding.value == null) {
          binding.ref.parentNode!.insertBefore(
            document.createTextNode(holes[i]),
            binding.ref,
          );
        } else {
       // 如果是更新状态,则直接更新节点的值
         binding.ref.previousSibling!.nodeValue = holes[i]
        };
      }
      // other cases;
      binding.value = holes[i];
    }
  }
}

在这个例子中我们用了注释节点来作为占位符,描述动态绑定的位置,便于后续比对更新。

在 Dirty Checking 模式下,首先会收集 Template 中的所有动态绑定的状态,当下次发生状态更新时会去遍历所有的动态绑定的状态,去和新的状态比对,如果发生变化则更新 DOM,可在线调试

www.alltoall.net_6月5日_A13YiZOeuR.gif

Virtual DOM(虚拟DOM)

虚拟 DOM(Virtual DOM)本质上是 JS 和 DOM 之间的一个映射缓存,它在形态上表现为一个能够描述 DOM 结构及其属性信息的 JS 对象。如下图所示:

image.png

当状态更新时,框架底层会借助算法先对比出具体哪些真实 DOM 需要被改变,然后再将这些改变作用于真实 DOM。下面是一个简单的例子:

import { h, Framework } from "./framework"

class App extends Framework {
  constructor() {
    super({ count: 0 });
    setInterval(() => this.setState({ count: this.state.count + 1 }), 1000);
  }
  template() {
    return h("div", this.state.count);
  }
}

这里的 template 参考了 React,例如下面 React 的 jsx 语法:

<div className='class' title='title'>
  <p>xiling</p>
</div>

编译之后如下(在React17 之后已经用了新的 JSX 转换方式):

React.createElement("div", {
  className: "class",
  title: "title"
},React.createElement("p", null, "xiling"));

Framework 类的实现如下,核心功能就是 h 和 diff 方法,一个是虚拟 DOM 的构造方法,一个是常说的 dif 算法。

type VNode = {
  type: string;
  attrs?: Record<string, string>;
  children?: VNode[];
  value?: string;
  _el?: Node;
};

export class Framework {
  state: Record<string, any> = {};
  _node: VNode | undefined;
  constructor(initState: Record<string, any>) {
    this.state = initState;
    this._diff(this.template());
  }
  template(): VNode {
    throw new Error("must be implemented");
  }
  setState(newState: Record<string, any>) {
    this.state = newState;
    this._diff(this.template());
  }
  _diff(newNode: VNode) {
    diff(this._node, newNode, document.getElementById("app")!);
    this._node = newNode;
  }
}

export function h(...args: any[]) {
  let node: VNode | null = null;
  function item(value: any) {
    if (value == null) {
    } else if ("string" === typeof value) {
    /** value类型为字符串要么是 DOM 节点,要么是 DOM 节点的文本内容,需要作为其子节点 */
      if (!node) node = { type: value, attrs: {}, children: [] };
      else node.children!.push({ type: "#text", value });
    } else if (
      "number" === typeof value ||
      "boolean" === typeof value ||
      value instanceof Date
    ) {
    /** 将数字或者日期类型的数据作为dom节点的子节点处理 */
      node!.children!.push({ type: "#text", value: value.toString() });
    } else if (Array.isArray(value)) value.forEach(item);
    else if ("object" === typeof value) {
    /** 对于object类型的值,可能是子dom节点,可能是dom节点的属性 */
      if (value.type) {
        node!.children!.push(value);
      } else {
        // attributes
        for (var k in value) {
          node!.attrs![k] = value[k];
        }
      }
    }
  }
  while (args.length) item(args.shift());
  return node!;
}

/** 查找变更点并更新真实DOM */
function diff(node: VNode | undefined, newNode: VNode, root: Node) {
  let element;
  // diff element
  if (!node || node.type !== newNode.type) {
    if (node && node._el) (node._el as Element).remove();
    element =
      newNode.type === "#text"
        ? document.createTextNode(newNode.value!)
        : document.createElement(newNode.type);
    (root as Element).append(element);
  } else element = node._el!;
  newNode._el = element;
  if (newNode.type === "#text") {
    element.textContent = newNode.value!;
    return;
  }

  // diff attributes
  for (let k in newNode.attrs) {
    if (node?.attrs![k] !== newNode.attrs[k]) {
      (element as Element).setAttribute(k, newNode.attrs[k]);
    }
  }
  // diff children
  if (newNode.children) {
    for (let i = 0; i < newNode.children.length; i++) {
      diff(node?.children?.[i], newNode.children[i], element);
    }
  }
}

在虚拟 DOM 的模式下,首先会将模板内容转换成虚拟 DOM,当下次状态发生变化时会将新旧虚拟 DOM 进行对比,通过算法找到最小的更新集,最终更新真实 DOM,可在线调试

www.alltoall.net_6月7日_Kajh35IsUD.gif

Fine-Grained(细力度更新)

Fine-Grained(细粒度) 更新策略是指在状态更新时,不对整个组件进行整体重新渲染,例如像 React 这种基于虚拟 DOM 的方案,当状态发生变化时需要重新执行 template 方法获取新的虚拟 DOM,而 Fine-Grained 是只精确更新那些真正发生变化的部分。这种策略提高了性能,减少了不必要的计算和重绘,这才实现了真正意义上的响应式。

下面是根据 soildjs 实现的一个简单的例子,我们可以先看一下 soildjs 的实现:

image.png

soildjs 官方提供了一个 playground 支持开发者在线调试 soildjs 代码,并在右侧的 output 中可以直接查看编译后的结果。

从编译结果中可以看出,soildjs 组件返回的 jsx 会被提取到组件外,并调用 template 函数返回对应的 DOM 节点,在函数内部通过 insert 将动态数据插入到节点中,从而建立绑定关系。下面是对其简单的实现:

import { createSignal, createEffect } from "./framework"

function App() {
  const [count, setCount] = createSignal(0);
  setInterval(() => setCount(count() + 1), 1000);
  
  const el = document.createElement("div");
  
  /** $insert方法内部的核心代码,当触发setCount操作后会执行该回调函数 */
  createEffect(() => {
    el.textContent = String(count());
  });
  return el;
}

document.getElementById("app")!.append(App());
type Observer = () => void;

let Observer: Observer | null = null;

export function createSignal<T>(value: T): [() => T, (v: T) => void] {
  const observers = new Set<Observer>();
  return [
    () => {
      if (Observer) observers.add(Observer);
      return value;
    },
    (v: T) => {
      value = v;
      for (let o of observers) {
        o();
      }
    },
  ];
}

export function createEffect(fn: () => void) {
  function compute() {
    try {
      /** */
      Observer = compute;
      fn();
    } finally {
      Observer = null;
    }
  }
  return compute();
}j

createSignal 会创建一个 observers 集合,用来存储该变量的依赖者,当更新变量时通知所有的依赖者进行更新,对应到代码里就是 el.textContent = String(count()) 操作,从而实现细粒度的更新,可在线调试

www.alltoall.net_6月7日_-1-_ahmu983tMz.gif

总结

本篇文章主要介绍了当前前端框架中所使用的三种 DOM 渲染方式,显然每种 DOM 渲染方式都有其优势和劣势,像虚拟 DOM 一个最大的优势就是支持跨平台,而 Fine-Grained 在性能上表现突出,开发者最终选择哪种框架也需要结合自己的实际场景。

eval:JavaScript里的双刃剑,用好了封神,用不好封号!

2025年6月7日 20:55

        大家好,我是江城开朗的豌豆,一名拥有6年以上前端开发经验的工程师。我精通HTML、CSS、JavaScript等基础前端技术,并深入掌握Vue、React、Uniapp、Flutter等主流框架,能够高效解决各类前端开发问题。在我的技术栈中,除了常见的前端开发技术,我还擅长3D开发,熟练使用Three.js进行3D图形绘制,并在虚拟现实与数字孪生技术上积累了丰富的经验,特别是在虚幻引擎开发方面,有着深入的理解和实践。

Snipaste_2025-06-03_13-45-06.png

        我一直认为技术的不断探索和实践是进步的源泉,近年来,我深入研究大数据算法的应用与发展,尤其在数据可视化和交互体验方面,取得了显著的成果。我也注重与团队的合作,能够有效地推动项目的进展和优化开发流程。现在,我担任全栈工程师,拥有CSDN博客专家认证及阿里云专家博主称号,希望通过分享我的技术心得与经验,帮助更多人提升自己的技术水平,成为更优秀的开发者。

技术qq交流群:906392632

大家好,我是小杨,一个写了6年前端的老司机。今天要聊一个让人又爱又恨的JavaScript特性——eval()。这玩意儿就像编程界的"瑞士军刀",功能强大但危险系数极高,新手容易滥用,老手又避之不及。到底该不该用?怎么安全地用?今天我就用几个血泪教训带大家彻底搞懂它!


一、eval是什么?代码里的"魔术师"

简单说,eval()把字符串当代码执行

const codeStr = 'console.log("Hello eval!")';
eval(codeStr); // 输出:Hello eval!

我第一次见到这功能时惊为天人:"这不就是动态执行代码的黑科技吗?!" 于是兴冲冲地写了个"万能计算器":

function calculate(expr) {
  return eval(expr); // 千万别学我!
}

console.log(calculate('2 + 2 * 3')); // 8
console.log(calculate('Math.pow(2, 10)')); // 1024

结果上线三天就被安全团队约谈了... (后面会讲为什么)


二、为什么说eval是"危险分子"?

1. 安全漏洞:XSS攻击直通车

如果执行用户输入的字符串:

const userInput = "alert('你的cookie是:'+document.cookie)";
eval(userInput); // 完蛋,用户脚本被执行了!

2. 性能杀手:引擎优化全失效

JS引擎原本可以预编译和优化代码,但遇到eval()就不得不:

  • 启动解释器
  • 创建新作用域
  • 放弃静态分析

3. 调试噩梦

错误堆栈会显示eval at <anonymous>,根本找不到问题源头!


三、安全使用eval的三大法则

虽然危险,但在某些场景下不得不用(比如解析JSON的老浏览器环境)。这时要遵守:

法则1:永远不要直接执行用户输入

// 错误示范
eval(req.body.userCode);

// 正确做法
function safeEval(code) {
  if (/alert|document|window/.test(code)) {
    throw new Error('危险代码!');
  }
  return eval(code);
}

法则2:用Function构造器替代

const calculator = new Function('expr', 'return (' + expr + ')');
console.log(calculator('2 + 2')); // 4

优点

  • 只在全局作用域执行
  • 稍微安全一丢丢

法则3:严格模式限制

"use strict";
eval = 1; // 报错!严格模式下不能覆盖eval

四、真实案例:我踩过的三个坑

案例1:动态生成函数

// 需求:根据API返回的函数名执行对应方法
const funcName = apiResponse.method; // 比如"showDialog"

// 菜鸟时期的我:
eval(funcName + '()'); // 可能执行任意代码!

// 现在的我:
const allowedMethods = { showDialog: true };
if (allowedMethods[funcName]) {
  window[funcName]?.();
}

案例2:JSON解析(上古时期)

// 2008年的老代码(那时候没有JSON.parse):
const data = eval('(' + jsonStr + ')');

// 2023年的正确姿势:
const data = JSON.parse(jsonStr);

案例3:沙箱环境

// 用Proxy做个简单沙箱
function safeEval(code) {
  const sandbox = new Proxy({}, {
    has: () => true, // 欺骗in操作符
    get: (target, key) => {
      if (['window','document'].includes(key)) 
        throw new Error(`禁止访问 ${key}`);
      return target[key];
    }
  });
  return (new Function('with(this){return ' + code + '}')).call(sandbox);
}

五、现代替代方案

场景 eval做法 更优方案
动态执行代码 eval(str) Function构造函数
JSON解析 eval('('+json+')') JSON.parse
动态属性访问 eval('obj.'+key) obj[key]
模板引擎 eval拼接字符串 模板字面量${}
数学表达式计算 eval('1+1') 第三方库(如math.js)

六、什么时候非用eval不可?

  1. 开发调试工具(如浏览器控制台本身)
  2. 编写DSL语言(如某些低代码平台)
  3. 教学演示(比如教人理解AST解析)

我在写在线代码编辑器时,最终选择了new Function()+Web Worker的方案,既安全又不会阻塞主线程。


总结:eval如老虎,摸前要三思

  • ✅ 能用别的方案就别用eval
  • ✅ 必须用时严格过滤输入
  • ✅ 优先考虑Function构造函数

你们在项目里用过eval吗?有没有因此翻过车?欢迎在评论区分享你的"惊魂时刻"~

我是小杨,下期可能会讲《如何安全地动态执行代码》,感兴趣的话点个关注不迷路! 🔐

JavaScript篇:前端定时器黑科技:不用setInterval照样玩转循环任务

2025年6月7日 20:34

        大家好,我是江城开朗的豌豆,一名拥有6年以上前端开发经验的工程师。我精通HTML、CSS、JavaScript等基础前端技术,并深入掌握Vue、React、Uniapp、Flutter等主流框架,能够高效解决各类前端开发问题。在我的技术栈中,除了常见的前端开发技术,我还擅长3D开发,熟练使用Three.js进行3D图形绘制,并在虚拟现实与数字孪生技术上积累了丰富的经验,特别是在虚幻引擎开发方面,有着深入的理解和实践。

Snipaste_2025-06-03_13-45-06.png

        我一直认为技术的不断探索和实践是进步的源泉,近年来,我深入研究大数据算法的应用与发展,尤其在数据可视化和交互体验方面,取得了显著的成果。我也注重与团队的合作,能够有效地推动项目的进展和优化开发流程。现在,我担任全栈工程师,拥有CSDN博客专家认证及阿里云专家博主称号,希望通过分享我的技术心得与经验,帮助更多人提升自己的技术水平,成为更优秀的开发者。

技术qq交流群:906392632

大家好,我是小杨,一个干了快6年的前端老司机。今天要和大家分享一个特别实用的定时器技巧——用setTimeout实现setInterval。这个方案不仅能解决setInterval的一些痛点,还能让我们的定时任务更加可控。

一、为什么不用setInterval?

先说说我为什么研究这个方案。去年在做一个大屏数据实时刷新功能时,发现直接用setInterval会有个恶心的问题:

setInterval(() => {
  // 模拟网络请求
  console.log('执行任务', new Date().getSeconds());
}, 1000);

看起来每1秒执行一次对吧?但如果网络卡顿导致函数执行超过1秒呢?这时候就会发现多个任务挤在一起执行,就像早高峰的地铁一样让人崩溃。

二、setTimeout的救场方案

后来我改用setTimeout递归调用的方式,完美解决了这个问题:

function 我的循环任务() {
  console.log('执行任务', new Date().getSeconds());
  
  // 在函数末尾重新调用自己
  setTimeout(我的循环任务, 1000);
}

// 启动任务
setTimeout(我的循环任务, 1000);

这个方案的精妙之处在于:每次都是等上次任务完全执行完,才重新计时。就像排队上厕所,必须等前一个人出来,下个人才能进去。

三、升级版:可控定时器

后来我又做了个加强版,加上了启动/停止功能:

let timer = null;
let count = 0;

function 我的可中断任务() {
  console.log(`执行第${++count}次`, new Date().getSeconds());
  
  if(count < 5) { // 只执行5次
    timer = setTimeout(我的可中断任务, 1000);
  }
}

// 启动
timer = setTimeout(我的可中断任务, 1000);

// 随时可以停止
// clearTimeout(timer);

这样写有三个好处:

  1. 避免任务堆积
  2. 可以精确控制执行次数
  3. 随时能终止任务

四、实战中的应用场景

这个技巧在我工作中帮了大忙,比如:

  1. 轮询接口:检查订单状态,直到支付成功
  2. 动画序列:实现复杂的多段动画效果
  3. 倒计时:更精准的秒表功能
// 倒计时示例
function 倒计时(剩余秒数) {
  console.log(`剩余:${剩余秒数}秒`);
  
  if(剩余秒数 > 0) {
    setTimeout(() => 倒计时(剩余秒数 - 1), 1000);
  }
}

倒计时(10); // 开始10秒倒计时

五、注意事项

虽然这个方案很香,但也要注意:

  1. 记得保存timer变量,否则没法清除
  2. 递归调用要注意停止条件,避免内存泄漏
  3. 长时间运行的任务可能会造成调用栈过深

六、总结

setTimeout实现setInterval的方案,就像是用乐高积木拼出了现成玩具的功能,虽然多写几行代码,但获得了更大的灵活性和可控性。特别适合需要精确控制执行时机的场景。

大家如果有更好的实现方案,欢迎在评论区交流~如果觉得有用,别忘了点赞收藏!

JavaScript篇:自定义事件:让你的代码学会'打小报告'

2025年6月7日 20:05

        大家好,我是江城开朗的豌豆,一名拥有6年以上前端开发经验的工程师。我精通HTML、CSS、JavaScript等基础前端技术,并深入掌握Vue、React、Uniapp、Flutter等主流框架,能够高效解决各类前端开发问题。在我的技术栈中,除了常见的前端开发技术,我还擅长3D开发,熟练使用Three.js进行3D图形绘制,并在虚拟现实与数字孪生技术上积累了丰富的经验,特别是在虚幻引擎开发方面,有着深入的理解和实践。

Snipaste_2025-06-03_13-45-06.png

        我一直认为技术的不断探索和实践是进步的源泉,近年来,我深入研究大数据算法的应用与发展,尤其在数据可视化和交互体验方面,取得了显著的成果。我也注重与团队的合作,能够有效地推动项目的进展和优化开发流程。现在,我担任全栈工程师,拥有CSDN博客专家认证及阿里云专家博主称号,希望通过分享我的技术心得与经验,帮助更多人提升自己的技术水平,成为更优秀的开发者。

技术qq交流群:906392632

大家好,我是小杨,一个在前端界摸爬滚打6年的老油条。今天我要和大家聊聊怎么让代码组件之间"说悄悄话"——没错,就是自定义事件!

一、为什么需要自定义事件?

想象一下这个场景:

// 传统写法:直接调用
function updateUser() {
    updateProfile();
    updateAvatar();
    updateSettings();
    // 我加了新功能还得回来改这里!
}

// 事件驱动写法
document.dispatchEvent(new CustomEvent('userUpdated'));

小杨解说:自定义事件就像办公室里的广播系统,谁想听就自己接,不用挨个通知!

二、基础用法:创建和监听

1. 创建自定义事件

// 简单版
const event = new Event('myEvent');

// 高级版(可以带数据)
const event = new CustomEvent('myEvent', {
    detail: {
        name: '我',
        age: 18
    }
});

2. 监听事件

document.addEventListener('myEvent', function(e) {
    console.log(`收到事件!数据:${e.detail.name} ${e.detail.age}`);
});

三、实战案例:购物车系统

// 商品组件
class Product {
    addToCart() {
        document.dispatchEvent(new CustomEvent('cartAdd', {
            detail: { id: 123, name: '前端秘籍' }
        }));
    }
}

// 购物车组件
document.addEventListener('cartAdd', function(e) {
    console.log(`把 ${e.detail.name} 加入购物车`);
});

// 用户组件
document.addEventListener('cartAdd', function() {
    console.log('更新用户购物车数量');
});

小杨踩坑记:曾经没加detail导致数据传丢,debug到怀疑人生!

四、高级技巧

1. 事件命名空间

// 避免冲突
document.dispatchEvent(new CustomEvent('me:cartAdd'));

2. 事件冒泡控制

const event = new CustomEvent('bubbleEvent', {
    bubbles: true,  // 允许冒泡
    cancelable: true // 允许取消
});

3. 移除监听

function handleEvent() {
    console.log('我只执行一次!');
    document.removeEventListener('oneTimeEvent', handleEvent);
}
document.addEventListener('oneTimeEvent', handleEvent);

五、Vue/React中的自定义事件

1. Vue版

// 子组件
this.$emit('me-event', { data: 123 });

// 父组件
<Child @me-event="handleEvent" />

2. React版

// 父组件
<Child onMeEvent={handleEvent} />

// 子组件
props.onMeEvent({ data: 123 });

六、性能优化

  1. 避免滥用:太多事件会让代码变成"广播体操"
  2. 及时销毁:SPA记得在组件卸载时移除监听
  3. 事件池:高频事件考虑复用事件对象

七、与原生事件的区别

特性 自定义事件 原生事件
触发方式 手动dispatch 浏览器自动触发
事件类型 任意自定义名称 click/keydown等
数据传递 通过detail 有限的事件对象

八、总结

  • 自定义事件是解耦神器
  • 适合组件通信、插件开发等场景
  • 记得给事件起个清晰的名字
  • 移除不需要的监听防止内存泄漏

思考题

const event = new CustomEvent('meetup', {
    detail: { time: new Date() }
});

document.addEventListener('meetup', function(e) {
    console.log(e.detail.time.toLocaleString());
});

setTimeout(() => {
    document.dispatchEvent(event);
}, 1000);
// 1秒后事件触发时,输出的时间是创建时还是触发时的时间?

欢迎在评论区讨论你的答案!下期我会分享更多前端设计模式的实战技巧。

视野修炼-技术周刊第122期 | 发光图片制作

2025年6月7日 19:05

欢迎来到第 122 期的【视野修炼 - 技术周刊】,下面是本期的精选内容简介

🔥强烈推荐

  1. HDRify - 发光 HDR 图片制作
  2. qnm - node_modules分析工具

🔧开源工具&技术资讯

  1. Glow UI - 开源SVG图标
  2. 在线的图片编辑器

📚教程&文章

  1. 面向 JS 开发的 Go 学习指南
  2. CSS 聚光灯效果

🤖AI工具&资讯

  1. Arc 浏览器停止开发

下面开始本期内容的介绍,预计阅读时间 5 分钟。

🔥强烈推荐

1. HDRify - 发光 HDR 图片制作

可以将普通图片转为 HDR 图片。

前段时间刷屏(闪瞎iOS)的青蛙图制作!

2. qnm - node_modules分析工具

🔧开源工具&技术资讯

3. Glow UI - 开源SVG图标

收藏!

4. 在线的图片编辑器

支持在线添加滤镜,裁剪的 WebGL 驱动的编辑器器。

📚教程&文章

5. 面向 JS 开发的 Go 学习指南

看一下 Hello World 对比。

涵盖了基础入门的知识。

6. CSS 聚光灯效果

看上去十分nb,收藏一下

🤖AI工具&资讯

7. Arc 浏览器停止开发

“浏览器公司” 解释停止开发 Arc 的原因。

转向开发新的浏览器 Dia (AI浏览器),不再为 Arc 开发新功能

笔者拿这个一直作为主力浏览器,贼好用哎。

Dia 浏览器 先排上队了。

⭐️强力推荐关注

周刊部分内容来源如下渠道,推荐大家关注。

OpenLayers:封装Tooltip

作者 Carlos_sam
2025年6月7日 18:36

在我之前写的一篇文章 OpenLayers:封装Overlay的方法_openlayer overlay-CSDN博客 中我封装一个工具方法addOverlay,当时我通过这个方法给Overlay增加一些额外的功能,如:控制显隐、分组、支持传入DOMString作为element等。但是之前的封装方式还是太过简陋有种种缺陷,使用起来也不甚方便。

最近我封装了一个Overlay的子类Tooltip类 ,在其中集成了之前的那些功能,并且还新增了一些功能。接下来我将在这篇文章中阐述各个功能的实现思路以及实现过程中我的一些收获。

一、Tooltip功能概述

首先我要简单的说明一下我将要封装的Tooltip类,首先我会让它作为Overlay的子类,这样就可以继承Overlay本身的各项功能,并且Tooltip的实例也可以直接被Map.addOverlay添加到地图中。

import Overlay from "ol/Overlay";

class Tooltip extends Overlay {
  ......
}

---------------------------------------

const tooltip = new Tooltip()
map.addOverlay(tooltip) // 直接可以将Tooltip的实例也可以被 `addOverlay` 等地图方法使用

我想要在Tooltip类中实现的功能主要有以下这些:

1.默认模版样式

我希望我的提示框可以默认有如下的容器样式(有一个蓝色的背景,有一个指向地图位置的 '小箭头' 等)。这样以后再不同项目中复用Tooltip的时候就不用总是自己写样式了。

2.支持 "上、下、左、右" 四个方位

虽然Overlay中有positioning属性可以自由设置Overlay的相对位置但是我认为这个属性并不好用。因为positioning属性是用来表示位置点相对与Overlay的位置,例如将positioning设置为'bottom-center'表示位置点位于Overlay的正下方(即Overlay位于位置点的正上方)。

我个人还是更习惯以Overlay为“主体”来描述其与位置点的位置关系,例如'top'表示Overlay位于位置点的正上方。所以我希望可以创建一个新的属性来替代positioning属性的功能。

另外由于我的提示框有一个指向位置点的小箭头,这个小箭头的位置也要随着提示框位置的变化而变化,因此就要做对应的处理。

3.支持以DOMString作为element属性的值

Overlayelement属性只支持接收一个 HTMLElement。

如果像下面这样传入一个DOMString作为element`,则会报错。

new Overlay({
 element: `<div>我是一个Overlay</div>` 
})

在实际使用过程中我感觉这十分不便,很多时候我在创建一些简单的Overlay时,我希望可以支持直接传入一个DOMString。

4.分组

在实际的开发中常常会向地图中添加几种不同的Overlay,并需要对同一种类的Overlay进行统一的操作(例如,全部移除、全部隐藏等)。而由于Overlay中只有一个id属性来标识不同的个体,所以导致批量的操作非常不方便,我很难将某一种类的Overlay筛选出来。因此我希望Overlay可以有一个groupId属性来标识它们的种类。

希望可以达到类似于下面这样的效果:

// 添加某一类的Tooltip,类标识为tooltip-test
const positions = [];
positions.forEach((position, index) => {
 const tooltip = new Tooltip({
    id: `tooltip-${index}`,
    groupId: "tooltip-test",
    element: `tooltip-${index}`,
    position: position,
  });

  map.addOverlay(tooltip);
});

// 获取某一类的Tooltip,类标识为tooltip-test
const tooltips = map.getOverlays().getArray().filter(overlay => {
  let groupId = overlay.getGroupId ? overlay.getGroupId() : overlay.groupId;
  return groupId ? groupId === "tooltip-test" : false;
});

5.控制显隐

我在实际的开发常遇到需要暂时隐藏(关闭)Overlay的需求,但是苦于Overlay不像Layer那样有visible属性来控制显隐。

我只能通过移除Overlay或者是将position置空的方式来实现隐藏的效果。但是使用这些方法隐藏容易,重新显示就难了。需要将Overlay缓存起来以便重新添加到地图中,或者将position的值缓存起来以便重新设置Overlay的位置。

// 移除Overlay
map.removeOverlay(tooltip)

// 将Overlay的position置空
tooltip.setPosition(null)

希望可以达到类似于下面这样的效果:

6.限制显示的缩放层级

另一个常见的需求是希望Tooltip只在某些缩放层级中被显示。就像LayerminZoommaxZoom那样

希望可以达到类似于下面这样的效果:

7.设置层级

Overlay本身不具有像zIndex这样设置层级的属性,因此当多个Overlay重叠在一起时我无法设置哪个在上哪个在下。

二、实现默认模版样式与支持DOMString

我的基本实现思路是创建一个容器元素来包裹传入的element属性值,然后给这个默认容器元素设置模版样式。Overlay的源码中也是遵循这样的思路,会给传入的element包裹一层.ol-overlay-container 元素。

1.编写一个formatElement方法

这个方法可以格式element,它接受一个参数element(可以是 HTMLElement 或 DOMString),然后对其进行处理。会将element由 DOMString 转换为 HTMLElement 并为其包裹一个.ol-custom-tooltip元素以应用默认模版样式。


/**
 * 格式化元素
 * @param {HTMLElement | string} element - 元素
 * @returns {HTMLElement} 格式化后的元素
 *
 */
formatElement(element) {
  let _element = element;

  // DOMString 转换为 HTMLElement
  if (typeof element === "string") {
    const div = document.createElement("div");
    div.innerHTML = element;
    _element = div.firstElementChild;
  }

  // 使用自定义的模版
  if (this.isUseContainer) {
    const container = document.createElement("div");
    container.className = `ol-custom-tooltip ${this.getPlacement()}`;
    container.appendChild(_element);
    _element = container;
  }

  return _element;
}

2.编写CSS样式

我在一个 Tooltip.css 文件中编写相应的默认样式,这个文件会被导入 Tooltip 类所在的js文件。

/* 基础样式 */
.ol-custom-tooltip {
  --bg: rgba(25, 82, 78, 0.8);
  --arrowSize: 10px;

  position: relative;
  z-index: 100;
  min-width: 120px;
  color: #fff;
  padding: 5px;
  background-color: var(--bg);
  border: none;
  border-radius: 4px;
  line-height: 1;
  font-size: 12px;
  text-align: start;
  box-sizing: border-box;
  user-select: none;
}

.ol-custom-tooltip::after {
  border: solid transparent;
  content: " ";
  height: 0;
  width: 0;
  position: absolute;
  pointer-events: none;
  border-width: var(--arrowSize);
}

.ol-custom-tooltip.top::after {
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border-top-color: var(--bg) !important;
}

.ol-custom-tooltip.bottom::after {
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  border-bottom-color: var(--bg) !important;
}

.ol-custom-tooltip.left::after {
  left: 100%;
  top: 50%;
  transform: translateY(-50%);
  border-left-color: var(--bg) !important;
}

.ol-custom-tooltip.right::after {
  right: 100%;
  top: 50%;
  transform: translateY(-50%);
  border-right-color: var(--bg) !important;
}

3.重写setElement方法

由于Overlay是一个可写属性,可以通过overlay.setElement方法来修改它。因此我的计划是提前拦截传入的element值,在它被设置之前对其进行 ' 格式化 ' 。

因此我在Tooltip类中重写setElement方法,以覆盖它的父类Overlay类中setElement方法。在新的setElement方法中提前对element进行格式化。

/**
*
* @override
*/
setElement(element) {
  let _element = this.formatElement(element);

  super.setElement(_element);
}

小技巧:钩子函数

这里我就要讲一个我自己总结的技巧了:

在OpenLayers中开发一个新类的时候,如果需要在父类的某个属性被设置前执行一个钩子函数 beforeHook,可以通过重写属性的set方法来实现,在新的set中先执行钩子函数,再执行父类的set方法(即super.set)。

// 子类上的新 set 方法
setProperty(property){
  // 要执行的 `设置前钩子函数`
  beforeHook()

  // 父类上的 set 方法
  super.setProperty(property)
}

如果需要在父类的某个属性被设置后执行一个钩子函数afterHook,可以将钩子函数作为该属性的change事件(即change:property事件)的事件处理函数。

overlay.on('change:property' , afterHook)

三、实现对 ' 四方位 ' 的支持

基本思路是我新增一个placement属性,它部分替代positioning功能,会设置placement属性与positioning属性进行 “双向绑定”(其中一个属性发生改变后另一个也会跟着变化)。另外,在设置placement属性值的时候也还会同步更改提示框小箭头的方向。

小技巧:普通属性和Property属性

在OpenLayers的类中的属性分为两种:普通属性Property属性

可以看下图中是Overlay类中的两个属性的get方法,其中element属性是使用this.get('element')的方式读取,而id属性则是通过this.id的方式读取。element就是Property属性,而id则是普通属性。

普通属性和Property属性区别主要有两点:

  1. 普通属性直接添加到class中通过this.xxx进行读写。Property属性会被添加到一个特殊的存储对象value_中,只能通过getsetsetProperties等方法读写。
  2. Property属性会自动注册对应的事件'change:xxx',Property属性的改变会触发对应的事件。而普通属性则不具备这样的功能。

因此在添加一个新属性时如果该属性是一个 ' 常量 ' 则应该设置为普通属性,如果该属性是一个' 变量 ' 则应该设置为Property属性。

1.创建placement属性

placement属性表示Tooltip相对于position的位置,它有'top' | 'bottom' | 'left' | 'right'四个可选的值。placement属性与positioning属性有对应关系,它的四个值分别对应了positioning'bottom-center' | 'top-center' | 'center-right' | 'center-left'

const PLACEMENTS = {
  TOP: "top",
  BOTTOM: "bottom",
  LEFT: "left",
  RIGHT: "right",
};

const POSITIONINGS = {
  TOP: "bottom-center",
  BOTTOM: "top-center",
  LEFT: "center-right",
  RIGHT: "center-left",
};

我还编写了placementpositioning相互转换的方法。

/**
 * 根据positioning获取placement
 * @param {string} positioning - positioning值
 * @returns {string} placement值
 */
const toPlacement = positioning => {
  switch (positioning) {
    case POSITIONINGS.TOP:
      return PLACEMENTS.TOP;
    case POSITIONINGS.RIGHT:
      return PLACEMENTS.RIGHT;
    case POSITIONINGS.BOTTOM:
      return PLACEMENTS.BOTTOM;
    case POSITIONINGS.LEFT:
      return PLACEMENTS.LEFT;
    default:
      return "";
  }
};

/**
 * 根据placement获取positioning
 * @param {string} placement - placement值
 * @returns {string} positioning值
 */
const toPositioning = placement => {
  switch (placement) {
    case PLACEMENTS.TOP:
      return POSITIONINGS.TOP;
    case PLACEMENTS.RIGHT:
      return POSITIONINGS.RIGHT;
    case PLACEMENTS.BOTTOM:
      return POSITIONINGS.BOTTOM;
    case PLACEMENTS.LEFT:
      return POSITIONINGS.LEFT;
    default:
      return "";
  }
};

之后为placement属性编写了读写的方法。

  /**
   * 设置Tooltip的位置方位
   * @param {string} placement - 位置方位,可选值:'top' | 'bottom' | 'left' | 'right'
   * @description 设置Tooltip的位置方位
   */
  setPlacement(placement = "top") {
    this.set("placement", placement);
  }

  /**
   * 获取Tooltip的位置方位
   * @returns {string} 当前位置方位
   * @description 返回Tooltip的当前位置方位
   */
  getPlacement() {
    return this.get("placement");
  }

2.实现placement与positioning的双向绑定

实现双向绑定的思路就是使用变量设置后的钩子函数,当placement的值发生变化的时候调用钩子同步修改positioning的值,反之亦然。

首先在构造函数中添加两个属性的变化事件。

this.on("change:placement", this.handleTooltipPlacementChanged);
this.on("change:positioning", this.handleTooltipPositioningChanged);

在事件处理函数中修改另一个属性的值。

/**
 * @protected
 *
 */
handleTooltipPlacementChanged() {
  const placement = this.getPlacement();
  const positioning = toPositioning(placement);
  if (positioning) {
    this.setPositioning(positioning);
  }
}

/**
 * @protected
 */
handleTooltipPositioningChanged() {
  const positioning = this.getPositioning();
  const placement = toPlacement(positioning);
  if (placement) {
    this.setPlacement(placement);
  }
}

3.修改Tooltip的样式(小箭头的位置)

placement变化时我还要同步修改小箭头的位置,方法是给element添加一个样式类来给小箭头设置不同的样式。另外由于我设置的小箭头本质上是一个伪元素,它不会被计算在tooltip的尺寸内,所以必需根据小箭头的尺寸(我这里是10像素)设置一个偏移,才能让小箭头正好指向position位置。

  /**
   * @protected
   *
   */
  handleTooltipPlacementChanged() {
    const placement = this.getPlacement();
    const positioning = toPositioning(placement);
    if (positioning) {
      this.setPositioning(positioning);
    }

    if (this.isUseContainer) {
      // 添加样式类
      this.getElement().className = `ol-custom-tooltip ${placement}`;
      // 设置偏移
      switch (placement) {
        case PLACEMENTS.TOP:
          this.setOffset([0, -10]);
          break;
        case PLACEMENTS.BOTTOM:
          this.setOffset([0, 10]);
          break;
        case PLACEMENTS.LEFT:
          this.setOffset([-10, 0]);
          break;
        case PLACEMENTS.RIGHT:
          this.setOffset([10, 0]);
          break;
        default:
          this.setOffset([0, 0]);
      }
    }
  }

四、实现分组功能

思路还是给Tooltip增加一个GroupId属性。

class Tooltip extends Overlay {
  constructor(options){
    ......
    /**
     * 提示框分组ID
     * @type {string}
     * @protected
    */
    this.groupId = options.groupId !== undefined ? options.groupId : "default";
  }

  /**
   * 获取Tooltip的组ID
   * @returns {string} 当前组ID
   * @description 返回Tooltip所属的组ID
   */
  getGroupId() {
    return this.groupId;
  }
}

之后可以采用如下的方式获取某一个组别的Overlay

const overlays = map
  .getOverlays()
  .getArray()
  .filter(overlay => {
    let groupId = overlay.getGroupId ? overlay.getGroupId() : overlay.groupId;
    return groupId ? groupId === "tooltip-test" : false;
  });

console.log(overlays);

五、实现控制显隐功能

控制显隐的功能也很简单,我的思路是新增一个Property属性visiblity,当visiblity的值变化时就去修改element的 样式属性display

class Tooltip extends Overlay {
  constructor(options){
    ......
    this.on("change:visibility", this.handleTooltipVisibilityChanged);

    // 设置可见性
    this.set(
      "visibility",
      options.visibility !== undefined ? options.visibility : true
    );
  }

  /**
   * @protected
   */
  handleTooltipVisibilityChanged() {
    const element = this.getElement();
    if (element) {
      element.style.display = this.getVisibility() ? "flex" : "none";
    } else {
      this.once("change:element", this.handleTooltipVisibilityChanged);
    }
  }

  /**
   * 设置Tooltip的可见性
   * @param {boolean} visibility - 是否可见
   * @description 设置Tooltip的可见性
   */
  setVisibility(visibility) {
    this.set("visibility", visibility);
  }

  /**
   * 获取Tooltip的可见性
   * @returns {boolean} 当前可见性状态
   * @description 返回Tooltip的当前可见性状态
   */
  getVisibility() {
    return this.get("visibility");
  }
}

六、实现显示缩放级别限制功能

这个功能在我实现的过程中难度是最大的。当然基本的思路很简单,首先新增两个属性minZoommaxZoom用于设置tooltip的显示范围,然后侦听mapmoveend事件,若zoom在设置的范围内则显示tooltip,反之则隐藏tooltip。

但是有两个问题需要解决:

第一,绑定moveend事件容易,但是想解绑就比较麻烦了。因为我需要在tooltip被添加到地图中时绑定事件,tooltip被从地图中移除时解绑事件,但是既然tooltip都已经被从地图中移除了,又怎么获取到地图对象呢?我最初使用的方法的用一个oldMap属性将地图对象缓存起来,但是总感觉这种方法不太好,代码质量太低。

第二,与显隐功能的冲突,原本用户只能通过调用tooltip的setVisibility()方法才能控制显隐。现在根据地图缩放级别的变化也能够自动调整显隐了,因此有的时候就会有冲突。例如,地图的当前的zoom已经超出了tooltip的显示范围,tooltip被隐藏了,但是用户又执行了tooltip.setVisibility(true),结果tooltip又显示了。这种情况就是错误的,正确的应该是只要地图的缩放级别处于规定的范围之外,无论怎样都无法让tooltip显示出来。

1.新增minZoom与maxZoom属性

minZoommaxZoom分别表示Tooltip的最小缩放级别和最大缩放级别,只要在这个范围内Tooltip就可以正常显示(也可以通过setVisibility()方法控制显隐),超出这个范围Tooltip就会被隐藏(此时setVisibility()方法应当失效)。

minZoom的默认值为0maxZoom的默认值为Infinity

class Tooltip extends Overlay {
  constructor(options){
    ......
    /**
     * 最小缩放级别
     * @type {number}
     * @protected
     */
    this.minZoom = options.minZoom !== undefined ? options.minZoom : 0;

    /**
     * 最大缩放级别
     * @type {number}
     * @protected
     */
    this.maxZoom = options.maxZoom !== undefined ? options.maxZoom : Infinity;
  }

  /**
   * 获取Tooltip的最大缩放级别
   * @returns {number} 当前最大缩放级别
   * @description 返回Tooltip的最大缩放级别
   */
  getMaxZoom() {
    return this.maxZoom;
  }

  /**
   * 获取Tooltip的最小缩放级别
   * @returns {number} 当前最小缩放级别
   * @description 返回Tooltip的最小缩放级别
   */
  getMinZoom() {
    return this.minZoom;
  }
}

2.实现地图的zoom变化时自动调整tooltip显隐

我的实现方式是侦听tooltip的change:map事件,当为tooltip设置了一个map时,给map绑定moveend事件。

this.on("change:map", this.handleTooltipMapChanged);

为了方便进行事件管理,我使用OpenLayers中两个事件方法listenunlistenByKeylisten方法用于绑定事件,它会返回一个EventsKey对象,其中存储了事件的信息(例如事件对象、事件处理函数)。unlistenByKey方法则会接收一个EventsKey对象然后移除该事件的绑定。

/**
 * Key to use with {@link module:ol/Observable.unByKey}.
 * @typedef {Object} EventsKey
 * @property {ListenerFunction} listener Listener.
 * @property {import("./events/Target.js").EventTargetLike} target Target.
 * @property {string} type Type.
 * @api
 */
  /**
   * @protected
   */
  handleTooltipMapChanged() {
    const map = this.getMap();

    // 移除所有事件监听
    this.removeAllListeners();

    if (map) {
      // 监听地图缩放事件(实现对Tooltip的缩放范围控制)
      if (this.minZoom > 0 && this.maxZoom < Infinity) {
        this.handleMapZoomChange();

        this.listenKeys.push(
          listen(map, "moveend", this.handleMapZoomChange, this)
        );
      }
    }
  }

  /**
   * @protected
   */
  handleMapZoomChange() {
    const zoom = this.getMap().getView().getZoom();
    const visibility = zoom >= this.minZoom && zoom <= this.maxZoom;

    this.isOnZoomRange = visibility;
    this.set("visibility", visibility);
  }

/**
 * 移除所有事件监听
 * @description 移除所有事件监听
 */
  removeAllListeners() {
    this.listenKeys.forEach(key => {
      unlistenByKey(key);
    });
    this.listenKeys = [];
  }

3.解决显隐冲突的问题

为了解决“显隐冲突”的问题,我专门设置了一个isOnZoomRange属性,它用来表示当前地图的zoom是否在Tooltip的minZoommaxZoom的范围内。

  /**
   * 是否在缩放范围内
   * @type {boolean}
   * @private
   */
  this.isOnZoomRange = true;

isZoomOnRange = false,则setVisibility()方法将会被禁用。

  /**
   * 设置Tooltip的可见性
   * @param {boolean} visibility - 是否可见
   * @description 设置Tooltip的可见性
  */
  setVisibility(visibility) {
    if (!this.isOnZoomRange) return;
    this.set("visibility", visibility);
  }

七、实现层级控制功能

基本思路是新增一个zIndex属性,将.ol-overlay-container 元素的z-index样式属性设置为zIndex的值。具体的原理和实现方式可以参考我之前写的这篇文章:

OpenLayers:如何控制Overlay的层级?_openlayer设置overlay在最上面-CSDN博客

1.新增zIndex属性

zIndex属性用来设置tooltip在z方向上的层级,我设置zIndex的取值范围在09999之间。

const MIN_Z_INDEX = 0;
const MAX_Z_INDEX = 9999;
  /**
   * 设置Tooltip的z-index
   * @param {number} zIndex - z-index值
   * @description 设置Tooltip的z-index,同时更新DOM元素的z-index
   */
  setZIndex(zIndex) {
    let _zIndex = zIndex;

    if (zIndex < MIN_Z_INDEX) {
      _zIndex = MIN_Z_INDEX;
    } else if (zIndex > MAX_Z_INDEX) {
      _zIndex = MAX_Z_INDEX;
    }

    this.set("zIndex", _zIndex);
  }

  /**
   * 获取Tooltip的z-index
   * @returns {number} 当前z-index值
   * @description 返回Tooltip的当前z-index值
   */
  getZIndex() {
    return this.get("zIndex");
  }

2.为.ol-overlay-container元素设置层级

class Tooltip extends Overlay {
  constructor(options){
    ......
  
  }

  /**
   * 获取Tooltip的最大缩放级别
   * @returns {number} 当前最大缩放级别
   * @description 返回Tooltip的最大缩放级别
   */
  getMaxZoom() {
    return this.maxZoom;
  }

  /**
   * 获取Tooltip的最小缩放级别
   * @returns {number} 当前最小缩放级别
   * @description 返回Tooltip的最小缩放级别
   */
  getMinZoom() {
    return this.minZoom;
  }
}

完整代码

import Overlay from "ol/Overlay";
import { listen, unlistenByKey } from "ol/events";

import "./Tooltip.css";

const MIN_Z_INDEX = 0;
const MAX_Z_INDEX = 9999;

const PLACEMENTS = {
  TOP: "top",
  BOTTOM: "bottom",
  LEFT: "left",
  RIGHT: "right",
};

const POSITIONINGS = {
  TOP: "bottom-center",
  BOTTOM: "top-center",
  LEFT: "center-right",
  RIGHT: "center-left",
};

const PLACEMENT_MAP_POSITIONING = [
  [PLACEMENTS.TOP, POSITIONINGS.TOP],
  [PLACEMENTS.RIGHT, POSITIONINGS.RIGHT],
  [PLACEMENTS.BOTTOM, POSITIONINGS.BOTTOM],
  [PLACEMENTS.LEFT, POSITIONINGS.LEFT],
];

/**
 * 根据positioning获取placement
 * @param {string} positioning - positioning值
 * @returns {string} placement值
 */
const toPlacement = positioning => {
  switch (positioning) {
    case POSITIONINGS.TOP:
      return PLACEMENTS.TOP;
    case POSITIONINGS.RIGHT:
      return PLACEMENTS.RIGHT;
    case POSITIONINGS.BOTTOM:
      return PLACEMENTS.BOTTOM;
    case POSITIONINGS.LEFT:
      return PLACEMENTS.LEFT;
    default:
      return "";
  }
};

/**
 * 根据placement获取positioning
 * @param {string} placement - placement值
 * @returns {string} positioning值
 */
const toPositioning = placement => {
  switch (placement) {
    case PLACEMENTS.TOP:
      return POSITIONINGS.TOP;
    case PLACEMENTS.RIGHT:
      return POSITIONINGS.RIGHT;
    case PLACEMENTS.BOTTOM:
      return POSITIONINGS.BOTTOM;
    case PLACEMENTS.LEFT:
      return POSITIONINGS.LEFT;
    default:
      return "";
  }
};

/**
 * @class Tooltip
 * @extends {Overlay}
 * @classdesc 自定义的 OpenLayers 提示框覆盖物类,继承自 ol/Overlay
 *
 * @param {Object} options - 配置选项
 * @param {string} [options.groupId='default'] - 提示框分组ID
 * @param {boolean} [options.visibility=true] - 是否默认可见
 * @param {string} [options.placement='top'] - 提示框位置,可选值:'top' | 'bottom' | 'left' | 'right'
 * @param {HTMLElement|string} options.element - 提示框内容元素或HTML字符串
 * @param {number} [options.maxZoom=Infinity] - 最大显示缩放级别
 * @param {number} [options.minZoom=0] - 最小显示缩放级别
 * @param {boolean} [options.isUseContainer=true] - 是否使用自定义的提示框容器
 * @param {number} [options.zIndex=0] - 提示框的z-index
 *
 * @example
 * // 创建一个顶部显示的提示框
 * const tooltip = new Tooltip({
 *   element: '<div>提示内容</div>',
 *   placement: 'top'
 * });
 *
 * @property {boolean} isUseContainer - 是否使用自定义的Tooltip容器
 * @property {number} minZoom - 最小缩放级别
 * @property {number} maxZoom - 最大缩放级别
 * @property {Array<number>} listenKeys - 事件监听器数组
 * @property {boolean} isOnZoomRange - 是否在缩放范围内
 */
class Tooltip extends Overlay {
  constructor(options) {
    const { element, ...rest } = options;
    super(rest);

    /**
     * 提示框分组ID
     * @type {string}
     * @protected
     */
    this.groupId = options.groupId !== undefined ? options.groupId : "default";

    /**
     * 是否使用自定义的Tooltip容器
     * @type {boolean}
     * @protected
     */
    this.isUseContainer =
      options.isUseContainer !== undefined ? options.isUseContainer : true;

    /**
     * 最小缩放级别
     * @type {number}
     * @protected
     */
    this.minZoom = options.minZoom !== undefined ? options.minZoom : 0;

    /**
     * 最大缩放级别
     * @type {number}
     * @protected
     */
    this.maxZoom = options.maxZoom !== undefined ? options.maxZoom : Infinity;

    /**
     * 事件监听器
     * @type {Array}
     * @private
     */
    this.listenKeys = [];

    /**
     * 是否在缩放范围内
     * @type {boolean}
     * @private
     */
    this.isOnZoomRange = true;

    // 注册 `change:visibility` 事件侦听器
    this.on("change:visibility", this.handleTooltipVisibilityChanged);
    this.on("change:placement", this.handleTooltipPlacementChanged);
    this.on("change:positioning", this.handleTooltipPositioningChanged);
    this.on("change:map", this.handleTooltipMapChanged);
    this.on("change:zIndex", this.handleTooltipZIndexChanged);
    

    // 设置元素
    this.setElement(options.element);

    // 设置可见性
    this.set(
      "visibility",
      options.visibility !== undefined ? options.visibility : true
    );

    // 设置位置方位
    this.set(
      "placement",
      options.placement !== undefined ? options.placement : "top"
    );
    // 设置z-index
    this.setZIndex(options.zIndex !== undefined ? options.zIndex : MIN_Z_INDEX);
  }

  /**
   * @protected
   */
  handleTooltipVisibilityChanged() {
    const element = this.getElement();
    if (element) {
      element.style.display = this.getVisibility() ? "flex" : "none";
    } else {
      this.once("change:element", this.handleTooltipVisibilityChanged);
    }
  }

  /**
   * @protected
   *
   */
  handleTooltipPlacementChanged() {
    const placement = this.getPlacement();
    const positioning = toPositioning(placement);
    if (positioning) {
      this.setPositioning(positioning);
    }

    if (this.isUseContainer) {
      this.getElement().className = `ol-custom-tooltip ${placement}`;
      // 设置偏移
      switch (placement) {
        case PLACEMENTS.TOP:
          this.setOffset([0, -10]);
          break;
        case PLACEMENTS.BOTTOM:
          this.setOffset([0, 10]);
          break;
        case PLACEMENTS.LEFT:
          this.setOffset([-10, 0]);
          break;
        case PLACEMENTS.RIGHT:
          this.setOffset([10, 0]);
          break;
        default:
          this.setOffset([0, 0]);
      }
    }
  }

  /**
   * @protected
   */
  handleTooltipPositioningChanged() {
    const positioning = this.getPositioning();
    const placement = toPlacement(positioning);
    if (placement) {
      this.setPlacement(placement);
    }
  }

  /**
   * @protected
   */
  handleTooltipMapChanged() {
    const map = this.getMap();

    // 移除所有事件监听
    this.removeAllListeners();

    if (map) {
      // 监听地图缩放事件(实现对Tooltip的缩放范围控制)
      if (this.minZoom > 0 && this.maxZoom < Infinity) {
        this.handleMapZoomChange();

        this.listenKeys.push(
          listen(map, "moveend", this.handleMapZoomChange, this)
        );
      }
    }
  }

  /**
   * @protected
   */
  handleMapZoomChange() {
    const zoom = this.getMap().getView().getZoom();
    const visibility = zoom >= this.minZoom && zoom <= this.maxZoom;

    this.isOnZoomRange = visibility;
    this.set("visibility", visibility);
  }

  /**
   * @protected
   */
  handleTooltipZIndexChanged() {
    const element = this.getElement();
    const zIndex = this.getZIndex();
    if (element) {
      const parentElement = element.parentElement;
      parentElement.style.zIndex = zIndex;

      parentElement.addEventListener("mouseenter", function () {
        this.style.zIndex = MAX_Z_INDEX;
      });

      parentElement.addEventListener("mouseleave", function () {
        this.style.zIndex = zIndex;
      });
    } else {
      this.once("change:element", this.handleTooltipZIndexChanged);
    }
  }

  /**
   *
   * @override
   */
  setElement(element) {
    let _element = this.formatElement(element);

    super.setElement(_element);
  }

  /**
   * 格式化元素
   * @param {HTMLElement | string} element - 元素
   * @returns {HTMLElement} 格式化后的元素
   *
   */
  formatElement(element) {
    let _element = element;

    // DOMString 转换为 HTMLElement
    if (typeof element === "string") {
      const div = document.createElement("div");
      div.innerHTML = element;
      _element = div.firstElementChild;
    }

    // 使用自定义的模版
    if (this.isUseContainer) {
      const container = document.createElement("div");
      container.className = `ol-custom-tooltip ${this.getPlacement()}`;
      container.appendChild(_element);
      _element = container;
    }

    return _element;
  }

  /**
   * 获取Tooltip的组ID
   * @returns {string} 当前组ID
   * @description 返回Tooltip所属的组ID
   */
  getGroupId() {
    return this.groupId;
  }

  /**
   * 获取Tooltip的最大缩放级别
   * @returns {number} 当前最大缩放级别
   * @description 返回Tooltip的最大缩放级别
   */
  getMaxZoom() {
    return this.maxZoom;
  }

  /**
   * 获取Tooltip的最小缩放级别
   * @returns {number} 当前最小缩放级别
   * @description 返回Tooltip的最小缩放级别
   */
  getMinZoom() {
    return this.minZoom;
  }

  /**
   * 设置Tooltip的可见性
   * @param {boolean} visibility - 是否可见
   * @description 设置Tooltip的可见性
  */
  setVisibility(visibility) {
    if (!this.isOnZoomRange) return;
    this.set("visibility", visibility);
  }

  /**
   * 获取Tooltip的可见性
   * @returns {boolean} 当前可见性状态
   * @description 返回Tooltip的当前可见性状态
   */
  getVisibility() {
    return this.get("visibility");
  }

  /**
   * 设置Tooltip的位置方位
   * @param {string} placement - 位置方位,可选值:'top' | 'bottom' | 'left' | 'right'
   * @description 设置Tooltip的位置方位
   */
  setPlacement(placement = "top") {
    this.set("placement", placement);
  }

  /**
   * 获取Tooltip的位置方位
   * @returns {string} 当前位置方位
   * @description 返回Tooltip的当前位置方位
   */
  getPlacement() {
    return this.get("placement");
  }

  /**
   * 设置Tooltip的z-index
   * @param {number} zIndex - z-index值
   * @description 设置Tooltip的z-index,同时更新DOM元素的z-index
   */
  setZIndex(zIndex) {
    let _zIndex = zIndex;

    if (zIndex < MIN_Z_INDEX) {
      _zIndex = MIN_Z_INDEX;
    } else if (zIndex > MAX_Z_INDEX) {
      _zIndex = MAX_Z_INDEX;
    }

    this.set("zIndex", _zIndex);
  }

  /**
   * 获取Tooltip的z-index
   * @returns {number} 当前z-index值
   * @description 返回Tooltip的当前z-index值
   */
  getZIndex() {
    return this.get("zIndex");
  }

  /**
   * 移除所有事件监听
   * @description 移除所有事件监听
   */
  removeAllListeners() {
    this.listenKeys.forEach(key => {
      unlistenByKey(key);
    });
    this.listenKeys = [];
  }

  /**
   * 销毁Tooltip实例
   * @description 清理所有资源,包括事件监听器、DOM元素和属性
   */
  dispose() {
    const map = this.getMap();
    map && map.removeOverlay(this);

    // 移除所有事件监听
    this.removeAllListeners();

    // 移除Tooltip自身的事件监听

    // 清理DOM元素
    const element = this.getElement();
    if (element && element.parentNode) {
      element.parentNode.removeChild(element);
    }

    // 清理属性
    this.set("groupId", null);
    this.set("placement", null);
    this.set("visibility", null);

    // 检查父类是否有dispose方法
    if (typeof super.dispose === "function") {
      super.dispose();
    }
  }
}

export {
  PLACEMENTS,
  POSITIONINGS,
  PLACEMENT_MAP_POSITIONING,
  toPlacement,
  toPositioning,
};

export default Tooltip;

参考资料

  1. OpenLayers v10.5.0 API - Class: Overlay
  2. OpenLayers之 OverLay问题汇总_openlayers overlay zindex-CSDN博客

Flutter造轮子系列:flutter_permission_kit

作者 MaoJiu
2025年6月7日 18:14

仓库:github.com/maojiu-bb/f…

插件:pub.dev/packages/fl…

前言

在之前的博客中,我介绍过一个名为 PermissionsSwiftUI的 Swift 包,它是基于 SwiftUI 的 iOS 权限管理方案,设计简洁且功能全面。最初我尝试直接适配 PermissionsSwiftUI 来满足 Flutter 项目中 iOS 权限请求的需求,希望能快速复用它的核心功能和 UI 设计。但在实际开发过程中,发现直接适配存在诸多限制和兼容性问题。

因此,我决定从头打造一个专门针对 Flutter 的 iOS 权限管理插件——flutter_permission_kit。这个插件参考了 PermissionsSwiftUI 的设计灵感,结合 Flutter 的开发特点,提供了一个统一的权限请求接口和多种 UI 样式(包括Alert 和Modal),并且自动适配系统深色模式,保证在 iOS 15 及以上系统环境下的良好体验。

功能

权限 描述 使用的iOS框架
Camera Photo and video capture AVFoundation
Photos Photo library access Photos
Microphone Audio recording AVFoundation
Speech Recognition Voice-to-text conversion Speech
Contacts Address book access Contacts
Notifications Push notifications UserNotifications
Location GPS and location services CoreLocation
Calendar Calendar events access EventKit
Tracking App tracking transparency AppTrackingTransparency
Reminders Reminders app access EventKit
Bluetooth Bluetooth device access CoreBluetooth
Apple Music Music library access MediaPlayer
Siri Siri integration Intents

预览

安装

将以下内容添加到项目的 pubspec.yaml 文件中:

dependencies:
  flutter_permission_kit: ^1.0.1

然后运行:

flutter pub get

iOS 设置

修改 Info.plist 文件

在 ios/Runner/Info.plist 中添加所需的权限描述信息:

<!-- 相机权限 -->
<key>NSCameraUsageDescription</key>
<string>我们需要访问你的相机以拍摄照片</string>

<!-- 照片库权限 -->
<key>NSPhotoLibraryUsageDescription</key>
<string>我们需要访问你的照片以保存头像</string>

<!-- 麦克风权限 -->
<key>NSMicrophoneUsageDescription</key>
<string>我们需要访问你的麦克风以录制语音</string>

<!-- 语音识别权限 -->
<key>NSSpeechRecognitionUsageDescription</key>
<string>我们需要访问你的语音以进行转写</string>

<!-- 通讯录权限 -->
<key>NSContactsUsageDescription</key>
<string>我们需要访问你的联系人以帮助你找到朋友</string>

<!-- 定位权限 -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>我们需要访问你的位置以显示附近内容</string>

<!-- 日历权限 -->
<key>NSCalendarsUsageDescription</key>
<string>我们需要访问你的日历以安排事件</string>

<!-- 跟踪权限 -->
<key>NSUserTrackingUsageDescription</key>
<string>我们需要访问你的跟踪数据以提供个性化广告</string>

<!-- 提醒事项权限 -->
<key>NSRemindersFullAccessUsageDescription</key>
<string>我们需要访问你的提醒事项以帮助你保持条理</string>

<!-- 蓝牙权限 -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>我们需要访问蓝牙以连接附近设备</string>

<!-- Apple Music 权限 -->
<key>NSAppleMusicUsageDescription</key>
<string>我们需要访问你的 Apple Music 音乐库</string>

<!-- Siri 权限 -->
<key>NSSiriUsageDescription</key>
<string>我们需要访问 Siri 以实现语音控制</string>

Siri 特殊配置

重要提示: 集成 Siri 除了在 Info.plist 配置外,还需在 Xcode 进行额外设置。

在 Xcode 中启用 Siri 功能

  1. 用 Xcode 打开你的项目(路径:ios/Runner.xcodeproj)
  2. 选中你的 App Target(一般为 Runner)
  3. 切换到 Signing & Capabilities 标签
  4. 点击 + Capability
  5. 添加 SiriKit 功能

这将自动创建必要的 entitlements 文件并配置 Siri 集成。

最低 iOS 版本要求

修改 Podfile

确保在 ios/Podfile 中设置平台版本为 iOS 15.0

# 取消注释这一行以设置全局平台版本
platform :ios, '15.0'

修改 Xcode 工程设置

同样需要确保 ios/Runner.xcodeproj/project.pbxproj 中的部署目标至少为 iOS 15.0

IPHONEOS_DEPLOYMENT_TARGET = 15.0;

使用

基本使用

import 'package:flutter/material.dart';
import 'package:flutter_permission_kit/flutter_permission_kit.dart';
import 'package:flutter_permission_kit/core/flutter_permission_kit_config.dart';
import 'package:flutter_permission_kit/core/permission.dart';
import 'package:flutter_permission_kit/enums/display_type.dart';
import 'package:flutter_permission_kit/enums/permission_type.dart';

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) async {
      if (!mounted) return;
      _initializePermissions();
    });
  }

  Future<void> _initializePermissions() async {
    final success = await FlutterPermissionKit.init(
      config: FlutterPermissionKitConfig(
        displayType: DisplayType.modal, // 也可以使用 DisplayType.alert
        displayTitle: '应用权限请求',
        displayHeaderDescription: '为了给你提供最佳体验,我们需要访问以下权限:',
        displayBottomDescription: '你可以稍后在系统设置中更改这些权限。',
        permissions: [
          Permission(
            name: '相机权限',
            description: '用于拍摄照片和录制视频',
            type: PermissionType.camera,
          ),
          Permission(
            name: '照片库',
            description: '从相册中选择照片',
            type: PermissionType.photos,
          ),
          Permission(
            name: '麦克风',
            description: '录制语音信息',
            type: PermissionType.microphone,
          ),
          Permission(
            name: '定位',
            description: '显示附近的内容和地点',
            type: PermissionType.location,
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Flutter Permission Kit'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('iOS 权限管理'),
              SizedBox(height: 20),
              ElevatedButton(
                onPressed: _initializePermissions,
                child: Text('请求权限'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

配置示例

FlutterPermissionKitConfig(
  displayType: DisplayType.modal,
  displayTitle: '权限请求',
  displayHeaderDescription: '我们需要你授予以下权限以继续使用:',
  displayBottomDescription: '这些权限将帮助我们提供更好的功能体验。',
  permissions: [
    // 自定义你需要的权限列表
  ],
)

RoadMap

  • 脱离 UI 的纯权限请求接口
  • HealthPermissionKit
  • 更加简洁的配置权限列表

结语

造轮子本就是一个学习的过程。从最初的思考设计,到逐步打磨功能,再到不断优化,每一步都充满了挑战与收获。

前端响应式网站编写套路

作者 _木木夕_
2025年6月7日 17:58

不知道你有没有和我有一样的疑问,像那种响应式的网站是怎么开发?用到了什么技术?这是一篇带你进入响应式开发的套路,看完基本上就能知道响应式网站开发套路,加上现在以组件化开发更是如虎添翼,废话不多说开始我们的开发之旅

环境

  • 前端:Next
  • Css框架:tailwindcss
  • UI框架:antd(其实也不用,在这个用到的很少,几乎没用)
  • 语言:TypeScript

前言

这次我们要开发的是个响应式官网首页只有首页部分,我们此次要模仿的是影视飓风首页主要包括头部、底部、中间部分三大块,这里只对头部和底部做讲解,首页全部的代码我已经全部上传 源码地址

在这也提醒各位不要频繁去刷别人的官网!!!!!!!!!

tailwindcss

这里简单的介绍下 【Next,antd,TS就不做介绍】,如果你用过像Bootstrap这类的UI框架应该了解

尺寸

响应式尺寸的前缀有 md lg xl等等,这些都代表对应的尺寸

前缀 尺寸
sm 640px及以上
md 768px及以上
lg 1024px及以上
xl 1280px及以上
2xl 15360px及以上

如果没有你要的尺寸或与你需要对应的尺寸有出入你也可以自己设置,tailwindcss 给开发者提供的修改的地方

import type { Config } from 'tailwindcss';

export default {
  content: [
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
    './app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      screens: {
        xs: '349px', // 设置你需要的尺寸
      },
      colors: {
        background: 'var(--background)',
        foreground: 'var(--foreground)',
      },
    },
  },
  plugins: [],
} satisfies Config;

常用的class名称

  • text- fontSize
  • bg- background
  • mx- my- m- mb- ml- mr- margin
  • px- py- p- pl- pr- padding
  • w- h- width height
  • 还有其他类名在实战中再做介绍,上面一些用法也在实战中做介绍,还有一些组合用法也会在实战做介绍

tailwind CSS 规则 非常重要!!!!!

  • 非响应式类(如 hidden, flex)在所有屏幕尺寸下生效。
  • 响应式类(如 lg:flex, md:hidden)只在指定尺寸及以上生效。响应式类是以断点前缀: + 属性类
  • 优先级规则:当非响应式类与响应式类冲突时,响应式类会在其生效的屏幕尺寸范围内覆盖非响应式类

分析网页

欲先善其事,必先利其器,我先分析下页面

250607 141330.gif 通过上面的gif图我们看到网页便没有随着宽度变化而发生变化,在切换到移动模式后需要再次刷新页面才会发生变化,我们第一步需要将其改造为随着页面变化自动变化,底部导航也是一样这就不截图了

gif录制工具

头部编码与分析

image.png 头部导航主要分为三块左右布局,左边我又将其分为两块,为后面响应式做好准备,现在开始编码与分析,下面展示的都是部分代码,但不影响布局分析

<div
  className="fixed w-dvw overflow-hidden z-50 bg-white md:px-4  xs:px-2   sm:px-1.5  lg:px-20   flex justify-between items-center h-[70px]
">
 
    {/*左侧开始*/}
  <div className="flex ">
    <img className="w-[7.5rem] h-[2.25rem] mr-[70px]" src="/images/banner-login1.png" alt=""/>
    <YSJFNav isTrue={isTrue}/>
  </div>
    {/*左侧结束*/}

  {/*右侧开始*/}
  <div className=" text-gray-300">
    <svg onClick={() => setIsShow(!isShow)} className="lg:flex hidden size-6 rounded-full"
         xmlns="http://www.w3.org/2000/svg"
         fill="none" viewBox="0 0 24 24"
         strokeWidth={1.5} stroke="currentColor"
         style={{color: isTrue ? 'gray' : 'black', background: isTrue ? '#cecece' : '#f4f6f7',}}>
      <path strokeLinecap="round" strokeLinejoin="round"
            d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
    </svg>

    <svg onClick={() => setIsNav(!isNav)} className="lg:hidden size-6" xmlns="http://www.w3.org/2000/svg" fill="none"
         viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
      <path strokeLinecap="round" strokeLinejoin="round"
            d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/>
    </svg>
  </div>
  {/*右侧结束*/}
  
</div>
<ul className="lg:flex hidden  items-center  text-[12px] transition text-[#c9c9c9]">
  <li
    className="w-[70px] mr-[60px] cursor-pointer hover:scale-110 transition hover:tracking-[2px]">影视飓风
  </li>
  <li
    className="w-[70px] mr-[60px] cursor-pointer hover:scale-110 transition hover:tracking-[2px]>飓风素材库
  </li>
  <li
    className="w-[70px] mr-[60px] cursor-pointer hover:scale-110 transition hover:tracking-[2px]>闪电分镜
  </li>
  <li
    className="w-[70px] mr-[60px] cursor-pointer hover:scale-110 transition hover:tracking-[2px]>太空之眼
  </li>
  <li
    className={isTrue ? "w-[70px]  cursor-pointer  hover:scale-110 transition hover:tracking-[2px]" : "w-[70px]  cursor-pointer  hover:scale-110 transition hover:tracking-[2px] hover:text-black"}>加入我们
  </li>
</ul>

最外层分析

  • 在最外层我们设置了fixed属性让他在页面滚动的时候一直固定在头部,设置宽度w-dvw= width: 100dvw让它宽度一直都是和设备宽度一样,同时我们加上 overflow-hidden 让超出部分直接隐藏
    • 在头部X轴我们加了响应式内边距,分别在md xs lg 对应的尺寸设置不同内边距
    • 头部导航高度我们这就直接写死一个高度h-[70px],这就是tailwindcss一个好处,不仅可以使用内置已有属性,还可以自己写需要的单位
    • flex justify-between items-center 两边居中排列

头部导航右侧分析

  • 这里用到两个图表,一个是PC像登录按钮,一个是移动端点击呼出菜单的按钮
  • 这里是需要设置响应式,在不同尺寸下显示不同的图标和事件
    • lg:felx hidde在屏幕大于等1024px显示,小于就不显示
    • lg:hidde在屏幕大于等1024px隐藏,小于显示

头部导航左侧分析

  • 使用felx布局只为了让他们在一行
  • 我们给图片添加了w-[7.5rem]``w-[2.25rem]直接将宽高写死,如果想做成响应式可以自行添加响应式
  • 右侧的导航YSJFNav.tsx
    • 这里也同样需要添加响应式,我们给ul添加响应式:lg:flex hidden在屏幕大于等1024px显示,小于就不显示,因为是在PC下显示我们就将字体大小写死,text-[#c9c9c9] 字体颜色
    • liw-[70px]就不作解释,cursor-pointer hover:scale-110 transition hover:tracking-[2px]鼠标显示成手,在移上去的时候文字放大字间距变宽
    • 到这头部布局和用到的样式就解释完毕

登录界面变化与分析

如果你看过原来官网会发现也是没有进行响应式设置,我们也是要对其进行改造,我们先看下效果

250607 160712.gif

<div className='fixed top-0 left-0 overflow-hidden w-full h-full bg-[#000000b3] text-white'>
  <div className='md:w-[50rem]  xs:w-11/12 absolute top-2/4 left-2/4 -translate-x-2/4 -translate-y-2/4 bg-white text-black '>
    <div className='p-1 flex justify-end cursor-pointer ' onClick={() => closeModal()}>
      <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className=" size-6">
        <path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
      </svg>
    </div>

    <div className='flex'>

      <img className='w-96 md:flex hidden' src="images/login.jpg" />

      <div className='pt-[70px] pr-5 ml-5 w-full'>
        {/*tab*/}
        <ul className='flex text-gray-300 text-[12px] mb-3 lg:w-full relative'>
          <li className={flag ? 'mr-4 cursor-pointer' : "mr-4 cursor-pointer text-black"} onClick={() => loginOrRegister()}>密码登录</li>
          <li className={flag ? 'mr-4 cursor-pointer text-black' : "mr-4 cursor-pointer"} onClick={() => loginOrRegister()}>验证码登录</li>
        </ul>

        {/*登录注册*/}
        {flag ? <Register /> : <Login toRegister={() => loginOrRegister()} />}

        <Divider><span className='text-[12px] text-gray-400'>或</span></Divider>

        {/*微信登录*/}
        <div className='mb-5 flex justify-center  bg-white text-black rounded-md p-[10px] border-[#eee] border text-[12px] text-center'>
          <WechatFilled style={{ fontSize: 16, color: 'green' }} />
          <span className='ml-2'>微信登录</span>
        </div>

        {/*条款*/}
        <Clause />
      </div>
    </div>
  </div>
</div>

最外层分析

在底部我们加了一层遮罩同时为了脱离文档流我们用到了定位,fixed top-0 left-0 overflow-hidden w-full h-full bg-[#000000b3] text-white,固定定位 上左都是0 高度宽度100%,bg-[#000000b3]一种背景色,文字白色

表单区域分析

  • absolute top-2/4 left-2/4 -translate-x-2/4 -translate-y-2/4 居中布局
  • top-2/4 left-2/4 = top:50% left:50%
  • -translate-x-2/4 -translate-y-2/4 = translate-y:-50% translate-x:-50%
  • 如果把-translate-x-2/4前面的负号去掉translate-x-2/4 = translate-x:50%`,同理我们可以推出类似的类,如果要取负数只需要在类名前面添加负号就行
  • 同理这里也需要用到响应式:md:w-[50rem] xs:w-11/12,md是框架自带的xs是我自定义的在前面tailwindcss尺寸哪有介绍
  • 内容区域两块左右结构,在图片部分我们需要做响应式在指定尺寸下隐藏
    • md:flex hidden这里就不解释了

移动端菜单显示

好在现在的前端是以组件化进行开发,我们只需要将移动端单独编写就行了,通过点击事件显示就好了,这里主要还是用到了antdDrawer

 <Drawer width='85%' placement="right" closable={false} open={true} bodyStyle={{ padding: 0 }} >
  <div className='flex justify-between mb-4 p-[24px]'>
    <svg onClick={() => onClose('close')} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6">
      <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12H12m-8.25 5.25h16.5" />
    </svg>
    <div onClick={() => onClose('login')} className='bg-[#f4f6f7] rounded-[5px] font-semibold px-[11px] py-[6px] text-[#24252c] text-[12px]'>登录</div>
  </div>
  <ul className=' text-base '>
    <li className='px-[24px] font-semibold py-[24px] active:bg-[#F4F6F7]'>影视飓风</li>
    <li className='px-[24px] font-semibold py-[24px] active:bg-[#F4F6F7]'>飓风素材库</li>
    <li className='px-[24px] font-semibold py-[24px] active:bg-[#F4F6F7]'>闪电风景</li>
    <li className='px-[24px] font-semibold py-[24px] active:bg-[#F4F6F7]'>太空之眼</li>
    <li className='px-[24px] font-semibold py-[24px] active:bg-[#F4F6F7]'>加入我们</li>
  </ul>
</Drawer>

在编写头部的时候,我们在右侧留下可以点击的部分

<div className=" text-gray-300">

    {/*PC登录按钮 */} 
  <svg onClick={() => setIsShow(!isShow)} className="lg:flex hidden size-6 rounded-full"
       xmlns="http://www.w3.org/2000/svg"
       fill="none" viewBox="0 0 24 24"
       strokeWidth={1.5} stroke="currentColor"
       style={{color: isTrue ? 'gray' : 'black', background: isTrue ? '#cecece' : '#f4f6f7',}}>
    <path strokeLinecap="round" strokeLinejoin="round"
          d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
  </svg>
  
  {/*移动端呼出菜单按钮 */} 
  <svg onClick={() => setIsNav(!isNav)} className="lg:hidden size-6" xmlns="http://www.w3.org/2000/svg" fill="none"
       viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
    <path strokeLinecap="round" strokeLinejoin="round"
          d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/>
  </svg>
</div>

底部编码与粗略分析

我们直接看修改后的响应式效果

250607 162749.gif

 <div className='w-full lg:flex justify-around hidden'>

  {listArray.length && listArray.map((item, index) => {
    return <div key={item.text} className='flex justify-center flex-col items-center mt-[40px] ' onMouseLeave={() => setIsShow(index)} onMouseEnter={() => setIsShow(index)}>
      <img className={`${item.isShow ? styles.entrance : styles.field}  w-[88px] h-[66px]`} src={item.isShow ? item.activeImg : item.img} alt="" />
      <p className={`${item.isShow ? styles['text-entrance'] : styles['text-field']} mt-[40px]`}>{item.text}</p>

      <div className={`{isShow && ${item.isShow ? styles['slogan-entrance'] : styles['slogan-field']} flex flex-col  items-center`}>
        <p className={`text-[12px]`}>{item.slogan}</p>
        <div className='flex'>
          {item.icon.map((img, index) => {
            return <img key={index} className='w-10 h-10  hover:scale-150 transition' src={img} alt="" />
          })}
        </div>
      </div>
    </div>
  })}
</div >
<div className="lg:hidden mt-12 ">

  <div className='px-4 mb-4'>
    <p className='text-sm text-[#646464 ] font-[600]'>ACCOUNT</p>
    <p className="text-[#24252C] font-[600] text-2xl">官方账号</p>
  </div>

  <ul className='px-4'>
    {
      listArray.map((item, index) => {
        return <div key={index} className='mb-6'>
          <li className=" bg-[#F4F6F7] p-4">
            <div className="flex justify-between items-center">
              <div className="flex items-center">
                <img className="w-16 h-12 mr-2" src={item.activeImg} alt=""/>
                <div>
                  <p className="text-[0.85rem] text-[#24252C] font-[600]">{item.text}</p>
                  <p className="text-[0.75rem] text-[#646464] font-[300]">{item.slogan}</p>
                </div>
              </div>
              {!item.isShow &&
                <svg onClick={() => isShow(index)} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
                     strokeWidth={1.5}
                     stroke="currentColor" className="size-6">
                  <path strokeLinecap="round" strokeLinejoin="round"
                        d="M3 4.5h14.25M3 9h9.75M3 13.5h9.75m4.5-4.5v12m0 0-3.75-3.75M17.25 21 21 17.25"/>
                </svg>}


              {item.isShow &&
                <svg onClick={() => isShow(index)} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
                     strokeWidth={1.5}
                     stroke="currentColor" className="size-6">
                  <path strokeLinecap="round" strokeLinejoin="round"
                        d="M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12"/>
                </svg>}

            </div>
          </li>

          {
            item.isShow &&
            <div className="bg-[#F4F6F7]  p-4 justify-between"
                 style={{display: 'grid', gridTemplateColumns: 'repeat(4, auto)', gap: '20px'}}>
              {item.icon.map((pItem, index) => {
                return <img key={index} src={pItem} alt=""
                            className="w-10 h-10 hover:scale-110 transition hover:tracking-[2px]"/>
              })}
            </div>
          }

        </div>
      })
    }
  </ul>
</div>

这次我们将底部分为PC和移动端两个组件编写,在两个组件之间进行,只需要在两个组件最外层的上加上lg:flexlg:hidden就能实现在不同尺寸上的显示,是不是很简单

文章只对头部和底部进行的分析,看到这你也就应该知道的响应式的开发套路,对于中间部分你应该也有思路怎么去开发

总结

  • 现在做下总结,响应式的网站就是通过给在不同尺寸下显示不同的内容和布局,但在移动下一些问题还需要特殊处理
  • 如果PC和移动端有较大出路,可以选择分为两个组件进行维护,还可以将一些相同的方法抽成一个HOOK
  • 源码地址

记住这张表

前缀 尺寸
sm 640px及以上
md 768px及以上
lg 1024px及以上
xl 1280px及以上
2xl 15360px及以上

重点记住规则 非常重要!!!!!

  • 非响应式类(如 hidden, flex)在所有屏幕尺寸下生效。
  • 响应式类(如 lg:flex, md:hidden)只在指定尺寸及以上生效。响应式类是以断点前缀: + 属性类
  • 优先级规则:当非响应式类与响应式类冲突时,响应式类会在其生效的屏幕尺寸范围内覆盖非响应式类

如何更好的实现业务中图片批量上传需求

2025年6月7日 17:29

作为一名前端开发人员,相信大家都做过pc端管理后台中图片上传的需求,一般无非就是按照prd实现图片(单张或者多张),然后引入elementUI等框架,然后使用其中的Upload组件去实现。这么做针对一般要求不高或者图片数量较少的情况下是没什么问题的。

But 如果你接受的是以下需求呢?

1、电商平台

  • 商家批量上传商品图片,如不同角度的商品展示图、规格图等。
  • 在编辑商品时上传多张图片方便商品展示,提升用户体验。

2、教育平台

  • 老师上传多张课件图片、讲义或作业参考材料供学生参考。
  • 学生上传多个作业文件,如笔记、照片、实验图片等。

2、房地产、旅游等行业平台

  • 房地产平台上传房源照片,如房间的各个角度、社区环境等。
  • 旅游平台上传景点照片、活动照片等,展示更丰富的视觉内容。

实现上面这些需求有一个核心点就是:提高用户上传大量文件的效率,并通过适当的并发控制避免对服务器造成负担。 想一下,如果让各位大佬实现上述需求的话,相信大家也都能将功能做出来,但是用户体验或者其他方面可能就会牺牲一点点。

WX20250607-170212@2x.png

话不多说,我这里提供一种方案仅供各位大佬参考。

具体如下:

第一步:

  • 并发限制:控制同时上传的图片数量,避免服务器压力过大。
  • 文件大小限制:在上传前检查文件大小,超出限制则阻止上传。
<template>
  <div>
    <el-upload
      ref="uploadRef"
      :http-request="customHttpRequest"
      :on-change="handleFileChange"
      :before-upload="beforeUpload"
      :file-list="fileList"
      multiple
      :limit="10"
      :auto-upload="false"
      >
      <el-button type="primary">批量上传图片</el-button>
    </el-upload>
    <div>
      <el-button type="primary" @click="startUpload">开始上传</el-button>
    </div>
  </div>
</template>

<script setup>
  import { ref } from 'vue';
  import { ElButton, ElUpload, ElMessage } from 'element-plus';

  const fileList = ref([]);
  const maxConcurrentUploads = 3; // 最大并发上传数量
  const maxSizeInMB = 2; // 文件大小限制,单位:MB
  let uploadQueue = [];
  let currentUploads = 0;

  const handleFileChange = (file, files) => {
    // 将文件加入到队列中
    uploadQueue.push(file);
  };

  // 文件大小限制检查
  const beforeUpload = (file) => {
    const isUnderLimit = file.size / 1024 / 1024 < maxSizeInMB;
    if (!isUnderLimit) {
      ElMessage.error(`文件 ${file.name} 超出大小限制(最大 ${maxSizeInMB} MB)`);
    }
    return isUnderLimit;
  };

  // 控制并发上传,限制同时上传数量
  const customHttpRequest = (options) => {
    if (currentUploads >= maxConcurrentUploads) return;

    currentUploads++;
    const { file, onProgress, onSuccess, onError } = options;

    // 创建XMLHttpRequest并配置上传进度
    const xhr = new XMLHttpRequest();
    xhr.open('POST', options.action, true);

    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) {
        const progress = (event.loaded / event.total) * 100;
        onProgress({ percent: progress });
      }
    };

    xhr.onload = () => {
      currentUploads--;
      processQueue();
      onSuccess(xhr.response);
    };

    xhr.onerror = () => {
      currentUploads--;
      processQueue();
      onError(xhr.response);
    };

    const formData = new FormData();
    formData.append('file', file);
    xhr.send(formData);
  };

  // 处理队列,限制同时上传数量
  const processQueue = () => {
    while (uploadQueue.length > 0 && currentUploads < maxConcurrentUploads) {
      const file = uploadQueue.shift();
      customHttpRequest({
        action: 'https://your-upload-endpoint',
        file,
        onProgress: (event) => console.log('progress:', event.percent),
        onSuccess: () => ElMessage.success(`文件 ${file.name} 上传成功`),
        onError: () => ElMessage.error(`文件 ${file.name} 上传失败`),
      });
    }
  };

  // 开始上传
  const startUpload = () => {
    processQueue();
  };
</script>

  • 文件大小检查beforeUpload函数会在文件加入队列前判断文件大小是否超过maxSizeInMB限制,如果超出则阻止上传,并给出提示信息。
  • 并发上传限制
  • 定义了maxConcurrentUploads限制同时上传的文件数量。
  • uploadQueue用于保存待上传的文件队列,currentUploads记录当前上传中的文件数量。
  • customHttpRequest是自定义的上传请求,在每次上传完毕后递减currentUploads,并调用processQueue继续处理队列中的文件。
  • processQueue函数会检查uploadQueue并发起新的上传请求,确保不会超过并发上传限制。

第二步:

  • 使用Web Worker对图片文件在上传前进行压缩格式转换。通过Web Worker来处理这些耗时操作,避免主线程阻塞,提升用户体验。
  • 创建一个Web Worker文件 imageWorker.js,用于处理图片的压缩和格式转换操作。使用Canvas API实现图片压缩,并将图片格式转换为JPEGPNG等常见格式。
// imageWorker.js
self.onmessage = async function (e) {
  const { file, quality, targetFormat } = e.data;

  const compressImage = async (file, quality, format) => {
    return new Promise((resolve) => {
      const img = new Image();
      img.src = URL.createObjectURL(file);
      img.onload = () => {
        const canvas = document.createElement('canvas');
        canvas.width = img.width;
        canvas.height = img.height;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0, img.width, img.height);

        // 设置格式和质量,转换图片
        canvas.toBlob(
          (blob) => {
            const compressedFile = new File([blob], file.name, {
              type: `image/${format}`,
              lastModified: Date.now(),
            });
            resolve(compressedFile);
          },
          `image/${format}`,
          quality
        );
      };
    });
  };

  // 调用压缩方法
  const processedFile = await compressImage(file, quality, targetFormat);
  self.postMessage(processedFile);
};

使用 Web Worker 进行图片预处理

<template>
  <div>
    <el-upload
      ref="uploadRef"
      :http-request="customHttpRequest"
      :on-change="handleFileChange"
      :before-upload="beforeUpload"
      :file-list="fileList"
      multiple
      :limit="10"
      :auto-upload="false"
    >
      <el-button type="primary">批量上传图片</el-button>
    </el-upload>
    <div>
      <el-button type="primary" @click="startUpload">开始上传</el-button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';

const fileList = ref([]);
const maxConcurrentUploads = 3; // 最大并发上传数
let uploadQueue = [];
let currentUploads = 0;
let worker;

const handleFileChange = (file, files) => {
  uploadQueue.push(file);
};

// 初始化 Web Worker
onMounted(() => {
  worker = new Worker(new URL('./imageWorker.js', import.meta.url));
});

onUnmounted(() => {
  if (worker) worker.terminate();
});

// 图片预处理,压缩和格式转换
const beforeUpload = (file) => {
  return new Promise((resolve) => {
    worker.postMessage({
      file,
      quality: 0.7, // 图片压缩质量,0到1之间
      targetFormat: 'jpeg', // 目标格式,可以是 'jpeg' 或 'png'
    });

    // 监听 Web Worker 返回的压缩文件
    worker.onmessage = (e) => {
      const processedFile = e.data;
      resolve(processedFile); // 返回压缩后的文件
    };
  });
};

// 自定义上传请求,限制并发数量
const customHttpRequest = (options) => {
  if (currentUploads >= maxConcurrentUploads) return;

  currentUploads++;
  const { file, onProgress, onSuccess, onError } = options;

  const xhr = new XMLHttpRequest();
  xhr.open('POST', options.action, true);

  xhr.upload.onprogress = (event) => {
    if (event.lengthComputable) {
      const progress = (event.loaded / event.total) * 100;
      onProgress({ percent: progress });
    }
  };

  xhr.onload = () => {
    currentUploads--;
    processQueue();
    onSuccess(xhr.response);
  };

  xhr.onerror = () => {
    currentUploads--;
    processQueue();
    onError(xhr.response);
  };

  const formData = new FormData();
  formData.append('file', file);
  xhr.send(formData);
};

// 处理队列,限制同时上传数量
const processQueue = () => {
  while (uploadQueue.length > 0 && currentUploads < maxConcurrentUploads) {
    const file = uploadQueue.shift();
    customHttpRequest({
      action: 'https://your-upload-endpoint',
      file,
      onProgress: (event) => console.log('progress:', event.percent),
      onSuccess: () => ElMessage.success(`文件 ${file.name} 上传成功`),
      onError: () => ElMessage.error(`文件 ${file.name} 上传失败`),
    });
  }
};

// 开始上传
const startUpload = () => {
  processQueue();
};
</script>
  • Web Worker压缩和格式转换imageWorker.js文件中,使用Canvas API压缩图片并转换格式,通过postMessage返回处理后的文件。
  • compressImage函数将图片绘制到Canvas上,并将其转换为指定的格式和质量。
  • beforeUpload钩子中,图片会传入Web Worker进行预处理(压缩和格式转换)。
  • worker.onmessage监听预处理完成后的文件,并将其加入到上传队列。
  • 自定义上传和并发控制:通过customHttpRequestprocessQueue方法,控制同时上传的数量,确保不会超出maxConcurrentUploads的限制。

第三步:

实现上传进度管理的UI展示断点续传多文件进度管理,我们可以做以下几项优化:

  1. 上传进度展示 :为每张图片添加进度条,实时显示上传进度。
  2. 断点续传 :对已上传的数据做断点标记,当上传中断时可以从中断的部分继续上传。
  3. 多文件进度管理 :管理每个文件的上传进度状态,并在UI上展示。
<template>
  <div>
    <el-upload
      ref="uploadRef"
      :http-request="customHttpRequest"
      :on-change="handleFileChange"
      :before-upload="beforeUpload"
      :file-list="fileList"
      multiple
      :auto-upload="false"
    >
      <el-button type="primary">批量上传图片</el-button>
    </el-upload>
    <div>
      <el-button type="primary" @click="startUpload">开始上传</el-button>
    </div>
    <div v-for="(file, index) in uploadStatus" :key="file.uid" class="upload-item">
      <span>{{ file.name }}</span>
      <el-progress :percentage="file.progress" v-if="file.status === 'uploading'" />
      <span v-if="file.status === 'completed'">上传完成</span>
      <span v-if="file.status === 'failed'">上传失败</span>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';

const fileList = ref([]);
const uploadStatus = ref([]);
const maxConcurrentUploads = 3; // 最大并发上传数
let uploadQueue = [];
let currentUploads = 0;
let worker;

const handleFileChange = (file) => {
  uploadQueue.push(file);  // 注意可能是 uploadQueue.push(file.raw);  
  uploadStatus.value.push({
    uid: file.uid,
    name: file.name,
    progress: 0,
    status: 'pending',
  });
};

// 文件预处理,压缩和格式转换
const beforeUpload = (file) => {
  return new Promise((resolve) => {
    worker.postMessage({
      file,
      quality: 0.7,
      targetFormat: 'jpeg',
    });

    worker.onmessage = (e) => {
      const processedFile = e.data;
      resolve(processedFile);
    };
  });
};

// 自定义上传请求,限制并发数量,支持断点续传
const customHttpRequest = (options) => {
  if (currentUploads >= maxConcurrentUploads) return;

  currentUploads++;
  const { file, onProgress, onSuccess, onError } = options;
  const storedProgress = localStorage.getItem(`upload-progress-${file.uid}`) || 0;
  let uploadedBytes = parseInt(storedProgress, 10);

  // 上传进度更新
  const updateProgress = (event) => {
    const progress = ((uploadedBytes + event.loaded) / file.size) * 100;
    const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
    if (fileStatus) fileStatus.progress = progress;
    onProgress({ percent: progress });
    localStorage.setItem(`upload-progress-${file.uid}`, uploadedBytes + event.loaded);
  };

  // 自定义分块上传实现断点续传
  const chunkSize = 1024 * 1024; // 1MB的分块大小
  const totalChunks = Math.ceil(file.size / chunkSize);
  let currentChunk = Math.floor(uploadedBytes / chunkSize);

  const uploadChunk = () => {
    if (currentChunk >= totalChunks) {
      localStorage.removeItem(`upload-progress-${file.uid}`);
      currentUploads--;
      processQueue();
      onSuccess();
      const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
      if (fileStatus) fileStatus.status = 'completed';
      return;
    }

    const start = currentChunk * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);
    const formData = new FormData();
    formData.append('file', chunk);
    formData.append('filename', file.name);
    formData.append('chunkNumber', currentChunk);
    formData.append('totalChunks', totalChunks);

    const xhr = new XMLHttpRequest();
    xhr.open('POST', options.action, true);

    xhr.upload.onprogress = updateProgress;
    xhr.onload = () => {
      uploadedBytes += chunk.size;
      currentChunk++;
      uploadChunk();
    };
    xhr.onerror = () => {
      currentUploads--;
      const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
      if (fileStatus) fileStatus.status = 'failed';
      onError();
    };

    xhr.send(formData);
  };

  uploadChunk();
};

// 处理队列,限制同时上传数量
const processQueue = () => {
  while (uploadQueue.length > 0 && currentUploads < maxConcurrentUploads) {
    const file = uploadQueue.shift();
    const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
    if (fileStatus) fileStatus.status = 'uploading';

    customHttpRequest({
      action: 'https://your-upload-endpoint',
      file,
      onProgress: (event) => console.log('progress:', event.percent),
      onSuccess: () => ElMessage.success(`文件 ${file.name} 上传成功`),
      onError: () => ElMessage.error(`文件 ${file.name} 上传失败`),
    });
  }
};

// 开始上传
const startUpload = () => {
  processQueue();
};

// 初始化 Web Worker
onMounted(() => {
  worker = new Worker(new URL('./imageWorker.js', import.meta.url));
});

onUnmounted(() => {
  if (worker) worker.terminate();
});
</script>

<style>
.upload-item {
  display: flex;
  align-items: center;
  margin-top: 10px;
}
</style>
  • 断点续传:我们将每个文件按1MB大小分块上传。localStorage中记录上传进度,当网络中断或页面关闭后,可以从最后一次成功上传的分块继续。
  • customHttpRequest方法根据存储的进度决定从哪个分块开始上传。
  • 每完成一块上传,更新uploadedBytes并保存到localStorage中,以便下次继续上传。
  • 上传进度管理
  • uploadStatus数组用于跟踪每个文件的上传状态和进度。
  • 每次分块上传进度通过onProgress事件更新。
  • 通过el-progress展示每个文件的上传进度,更新UI。
  • 错误处理
  • 如果某个分块上传失败,标记为failed并展示在UI中,用户可以选择手动重试。

最后一步:增加错误重试机制,优化加载进度展示

  • 错误提示 :为每个文件记录错误信息并展示在UI上。
  • 错误分类和重试 :实现精细化的错误回调,通过retryCount控制每个文件的重试次数。如果超过最大重试次数,则提供手动重试选项。
  • 重试按钮 :在上传失败的文件上显示“重试”按钮,让用户在手动点击时可以重新上传该文件。
<template>
  <div>
    <el-upload
      ref="uploadRef"
      :http-request="customHttpRequest"
      :on-change="handleFileChange"
      :before-upload="beforeUpload"
      :file-list="fileList"
      multiple
      :auto-upload="false"
    >
      <el-button type="primary">批量上传图片</el-button>
    </el-upload>
    <div>
      <el-button type="primary" @click="startUpload">开始上传</el-button>
    </div>
    <div v-for="(file, index) in uploadStatus" :key="file.uid" class="upload-item">
      <span>{{ file.name }}</span>
      <el-progress :percentage="file.progress" v-if="file.status === 'uploading'" />
      <span v-if="file.status === 'completed'">上传完成</span>
      <span v-if="file.status === 'failed'" class="error-message">
        上传失败:{{ file.error }}&nbsp;
        <el-button type="text" @click="retryUpload(file)">重试</el-button>
      </span>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';

const fileList = ref([]);
const uploadStatus = ref([]);
const maxConcurrentUploads = 3;
const maxRetries = 3; // 最大重试次数
let uploadQueue = [];
let currentUploads = 0;
let worker;

const handleFileChange = (file) => {
  uploadQueue.push(file);
  uploadStatus.value.push({
    uid: file.uid,
    name: file.name,
    progress: 0,
    status: 'pending',
    error: null,
    retryCount: 0,
  });
};

const beforeUpload = (file) => {
  return new Promise((resolve) => {
    worker.postMessage({
      file,
      quality: 0.7,
      targetFormat: 'jpeg',
    });

    worker.onmessage = (e) => {
      const processedFile = e.data;
      resolve(processedFile);
    };
  });
};

// 自定义上传请求,限制并发数量,支持断点续传和错误处理
const customHttpRequest = (options) => {
  if (currentUploads >= maxConcurrentUploads) return;

  currentUploads++;
  const { file, onProgress, onSuccess, onError } = options;
  const storedProgress = localStorage.getItem(`upload-progress-${file.uid}`) || 0;
  let uploadedBytes = parseInt(storedProgress, 10);

  // 上传进度更新
  const updateProgress = (event) => {
    const progress = ((uploadedBytes + event.loaded) / file.size) * 100;
    const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
    if (fileStatus) fileStatus.progress = progress;
    onProgress({ percent: progress });
    localStorage.setItem(`upload-progress-${file.uid}`, uploadedBytes + event.loaded);
  };

  // 自定义分块上传实现断点续传
  const chunkSize = 1024 * 1024; // 1MB的分块大小
  const totalChunks = Math.ceil(file.size / chunkSize);
  let currentChunk = Math.floor(uploadedBytes / chunkSize);

  const uploadChunk = () => {
    if (currentChunk >= totalChunks) {
      localStorage.removeItem(`upload-progress-${file.uid}`);
      currentUploads--;
      processQueue();
      onSuccess();
      const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
      if (fileStatus) {
        fileStatus.status = 'completed';
        fileStatus.error = null;
      }
      return;
    }

    const start = currentChunk * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);
    const formData = new FormData();
    formData.append('file', chunk);
    formData.append('filename', file.name);
    formData.append('chunkNumber', currentChunk);
    formData.append('totalChunks', totalChunks);

    const xhr = new XMLHttpRequest();
    xhr.open('POST', options.action, true);

    xhr.upload.onprogress = updateProgress;
    xhr.onload = () => {
      uploadedBytes += chunk.size;
      currentChunk++;
      uploadChunk();
    };
    xhr.onerror = () => handleUploadError(file);
    xhr.send(formData);
  };

  uploadChunk();
};

// 处理上传错误,重试或记录错误
const handleUploadError = (file) => {
  const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
  if (fileStatus.retryCount < maxRetries) {
    fileStatus.retryCount++;
    ElMessage.warning(`文件 ${file.name} 上传失败,重试第 ${fileStatus.retryCount} 次`);
    customHttpRequest({
      action: 'https://your-upload-endpoint',
      file,
      onProgress: (event) => console.log('progress:', event.percent),
      onSuccess: () => ElMessage.success(`文件 ${file.name} 上传成功`),
      onError: () => handleUploadError(file),
    });
  } else {
    fileStatus.status = 'failed';
    fileStatus.error = '网络错误或服务器问题,上传失败';
    ElMessage.error(`文件 ${file.name} 上传失败,请检查网络或稍后重试`);
  }
};

// 重试上传
const retryUpload = (file) => {
  const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
  fileStatus.retryCount = 0;
  fileStatus.status = 'uploading';
  fileStatus.error = null;
  customHttpRequest({
    action: 'https://your-upload-endpoint',
    file,
    onProgress: (event) => console.log('progress:', event.percent),
    onSuccess: () => ElMessage.success(`文件 ${file.name} 上传成功`),
    onError: () => handleUploadError(file),
  });
};

// 处理队列,限制同时上传数量
const processQueue = () => {
  while (uploadQueue.length > 0 && currentUploads < maxConcurrentUploads) {
    const file = uploadQueue.shift();
    const fileStatus = uploadStatus.value.find((item) => item.uid === file.uid);
    if (fileStatus) fileStatus.status = 'uploading';

    customHttpRequest({
      action: 'https://your-upload-endpoint',
      file,
      onProgress: (event) => console.log('progress:', event.percent),
      onSuccess: () => ElMessage.success(`文件 ${file.name} 上传成功`),
      onError: () => handleUploadError(file),
    });
  }
};

// 开始上传
const startUpload = () => {
  processQueue();
};

// 初始化 Web Worker
onMounted(() => {
  worker = new Worker(new URL('./imageWorker.js', import.meta.url));
});

onUnmounted(() => {
  if (worker) worker.terminate();
});
</script>

<style>
.upload-item {
  display: flex;
  align-items: center;
  margin-top: 10px;
}
.error-message {
  color: red;
  font-weight: bold;
}
</style>
  1. 错误处理和重试逻辑
  • handleUploadError 方法对上传失败的文件进行错误处理。若retryCount小于maxRetries,则自动重试。超过最大重试次数时,将文件状态更新为failed并记录错误信息。
  1. 重试按钮
  • 在文件状态为failed时,显示“重试”按钮,用户可手动点击重新上传。retryUpload 方法重置重试次数,并重新调用customHttpRequest来重新上传失败文件。
  1. 上传进度和状态管理
  • 每个文件的状态在uploadStatus中管理,状态包括pendinguploadingcompletedfailed,UI根据状态更新显示。
  1. 精细的错误信息展示
  • 每个文件错误类型独立记录并展示在UI中,避免因多个文件错误导致混淆。这样可以更好地支持批量上传过程中各个文件的精细管理、进度监控以及错误处理。

经过上述处理,是不是就能更好的实现文章开头中的图片批量上传需求。

WX20250607-172202@2x.png

游戏开发零散知识点和优化记录

作者 鱼___鱼
2025年6月7日 16:58

简介

用来记录各类游戏开发中零散的知识点,和一些优化体验的方式等~

一、线性插值(Lerp)

之前看项目中有个动画用到线性插值的处理,好奇之下有什么作用效果,线性插值是什么意思,这里做学习记录。

是什么:

  • 简单了解来说,线性插值是知道两个值之间,按照比例计算中间值的方法。
  • 基本公式为: result = a + (b - a) * t,其中 a 为起始值, b 为终点值,而 t 是插值系数,通常为 0 - 1 之间。

怎么做: 那么我们来了解这里的使用案例来看看。这里我们做的一个功能是有一个锤子下落,并且会砸碎砖块,同时摄像机跟随锤子下落移动,那么我们每一次移动有一个下落距离,我们使用线性插值来调整动画时间,代码如下


    ....
    if (isNeedLerp) {
        time = this.lerp(0.3, 1, Math.abs(space) * 0.003);
    }
    ...

    /** 线性插值 */
    private lerp(a: number, b: number, space: number) {
        return a + (b - a) * this.clamp01(t)
    }
    /** 限制范围 0-1 之间 */
    private clamp01(value: number) {
        return this.clamp(value, 0, 1)
    }

    private clamp(num: number, min = 0, max = 1) {
        return Math.min(Math.max(num, min), max)
    }
    

这么做有什么效果如何呢~

1、 距离自适应:移动距离 space 越大,动画时间越长,但会在有一个我们的合理范围 [a-b] 之间

-   小距离移动:接近最小时间(a秒),感觉更快捷
-   大距离移动:接近最大值(b秒),移动更平滑

2、 平滑感知:根据Math.abs(space) * 0.003,将物理距离转换为合适的时间比例

-   确保短距离快速响应
-   长距离不会太快导致眩晕感

3、 提升用户体验

-   短距离迅速反馈,减少等待感
-   长距离平缓过渡,减少突兀感

为什么:

如果不使用线性插值处理这个动画时间呢?那么我们应该只有两种方式

1、移动时间是固定的

  • 短距离的小幅移动会显得缓慢拖沓
  • 长距离的大幅移动会显得突兀、眩晕

2、移动距离和时间呈线性关系

  • 短时间移动过快的闪烁感
  • 长距离缓慢拖沓

小结:

对于移动,人眼的感知是非线性的,使用线性插值,能从感知的视觉更自然的加速/减速的移动效果,快速的响应短距离,平稳的处理长距离,从玩家体验上带来更自然舒适的体验~上说的的是摄像机的跟随移动,另外其实很多地方我们也能用到,比如进度条的增长,或者一下平滑过动,控制速率的地方。可以说游戏的体验都是这些细节处理,带来的舒适体验!


二、关于按钮优化

怎么说呢~这个其实是个挺小挺简单的东西,但我感觉从用户体验上,能带来的收益很大,往往我们很多界面有关闭按钮,或者规则页按钮,这一类的按钮美术往往不会特别大,从美术风格出发,是为了美观协调。

但是那是美术的事情,我们程序开发的时候其实可以做成点击区域比美术按钮更大的,为什么呢!因为如果按钮点击区域过小,玩家不易点击,特别是钟爱小屏的玩家,或者平板用户,我们在观感上不用改变美术尺寸,只是把点击区域适当放大,就能很好的解决了。

image.png


三、做一个红点跳动效果

宝,很常见也很需要的一个效果了,实现一个红点弹跳的效果~其实也不难的东西,就是记录一下,方便后续要用到也不用再实现了。


private _originalPosition = v3();
private _originalScale = v3();

onLoad() {
  this.record();
}

record() {
    // @ts-ignore
    this._originalPosition.set(this.node._lpos);
    // @ts-ignore
    this._originalScale.set(this.node._scale);
}

private playLittleAnimation() {
      tween(this.node)
          .to(0.2, { scale: v3(this._originalScale.x * 1.05, this._originalScale.y * 0.95, this._originalScale.z) })
          .parallel(
              tween(this.node)
                  .to(0.2, { position: v3(this._originalPosition.x, this._originalPosition.y + 15) }, { easing: "circOut" }),
              tween(this.node)
                  .to(0.12, { scale: v3(this._originalScale.x * 0.95, this._originalScale.y * 1.05, this._originalScale.z) })
          )
          .parallel(
              tween(this.node)
                  .to(0.15, { position: v3(this._originalPosition) }, { easing: "circIn" }),
              tween(this.node)
                  .delay(0.15)
                  .to(0.15, { scale: v3(this._originalScale.x * 1.05, this._originalScale.y * 0.95, this._originalScale.z) }, { easing: "backOut" })
          )
          .to(0.1, { scale: v3(this._originalScale) }, { easing: "backOut" })
          .delay(this.interval)
          .union()
          .repeatForever()
          .start();
  }

效果如图下:

20240103-003349.gif


四、cocos creator 如何做渐隐渐现

听起来好像这么简单的东西,这里我们来了解一下为啥子,要说这个 论坛中实现的方式有三种

可能我们一般上都是直接用到了 UIOption 组件来处理,但其实这不是最正确的方式,只能说是最合适的方式罢了,为什么呢,因为官方说了

那么这是为什么呢?

  • 方式1: 的问题在于我们的 color 暂时不支持 单独设置 alpha 值,所以暂时是没有效果的。

  • 方式2: 的问题是由于 tween 系统带来的问题,tween 系统会对值进行插值运算,但是和不凑巧的是,color 的各个颜色通道值只是我们为了方便理解的一个封装过的值,而实际存储 color 的值为其 _val,所以对于 tween 来说,他的 lerp 是针对 _val 值进行插值运算的,所以会出现颜色的五彩斑斓变化。

  • 方式3: 目前是可以使用的,但是为何我们不推荐使用呢,这是由于我们在系统设计中认为组件本身有 color 值,那么可以直接控制 color 来完成效果,而 UIOpacity 是毫无必要的,且会造成两个数据来源,所以我们不推荐使用,但由于之前两个方式存在问题,可以暂时使用这种方式。

目前正确的实现方式如下,但就没必要这么繁琐了~

const originColor = this.sprite.color;
const targetAlpha = { value: 255 };
tween(targetAlpha)
    .to(
        1,
        { value: 0 },
        {
            onUpdate: (target, ratio) => {
                this.sprite.color = new Color(originColor.r, originColor.g, originColor.b, (ratio ?? 1) * 255);
            },
        }
    )
    .start();

五、如何做游戏表现快进

两种方式实现,首先说最简单粗暴的,修改导演的时间类

1、修改导演(director)的时间(tick)

这种方式最爽了,啥也不用改,简单,但是因为改的是总的速率,危险性比较高,避免离开加速后,离开或者出错后没有恢复原本的

// 缓存 director 的 tick 方法
private oldTick = director.tick;

protected start(): void {
     // 重写 tick 方法
     director.tick = (dt: number) => {
         console.log("------------>tick", this.isSpeed);
         this.oldTick.call(director, dt * (this.isSpeed ? 1 : 5));
     }
}

// 结束恢复
protected onDestroy(): void {
 director.tick = this.oldTick;
}

2、过来就是对所有地方加速的操作了

一般加速都是动画的表现地方,比如使用了缓动 / spine / 音效,那么就需要对这些地方判断当前状态来加速了

// spine 动画

let skeleton = this.anim.getComponent(sp.Skeleton);
skeleton.timeScale = this.isSpeed ? 2 : 1;

// 缓动的话就是直接执行的时间缩小了

tween(this.icon.node)
    .to(0.15 * this.speedValue, { scale: v3(1.15, 1.15, 1) })
    .to(0.15 * this.speedValue, { scale: v3(1, 1, 1) })
    .start();

六、如何做对称分布

有些时候我们需要对数组重新排序,使得这个排序后的物品能够对称分布,场景在一些物品摆放之类地方常见。那么如何实现这么一个算法处理呢

/**对称排序宝石 */
private makeSymmetric(arr) {
    let counts = {};
    let singleArray = [];    // 单
    let doubleArray = [];    // 双
    let temp = {};

    // 把宝石分组
    for (let i = 0; i < arr.length; i++) {
        let id = arr[i].id;
        counts[id] = (counts[id] || 0) + 1;
        if (temp[id]) {
            temp[id].push(arr[i]);
        } else {
            temp[id] = [arr[i]];
        }
    }

    for (let id in counts) {
        while (counts[id] >= 2) {
            doubleArray.push(temp[id].shift(), temp[id].shift());
            counts[id] -= 2;
        }
    }

    for (let id in counts) {
        while (counts[id]-- > 0) {
            singleArray.push(temp[id].shift());
        }
    }

    // 实现对称摆放排列
    const resultArray = [];
    if (arr.length % 2 == 0) {
        // 特殊处理四个的时候不是两列对称
        if (doubleArray.length == 4) {
            resultArray.push(doubleArray[0], doubleArray[2], doubleArray[1], doubleArray[3]);
        } else {
            this.chunk(doubleArray, 2).forEach((item) => {
                resultArray.push(item[0]);
                resultArray.unshift(item[1]);
            })
            singleArray.forEach((item) => {
                resultArray.splice(resultArray.length / 2, 0, item)
            })
        }
    } else {
        this.chunk(doubleArray, 2).forEach((item) => {
            resultArray.push(item[0]);
            resultArray.unshift(item[1]);
        })
        let tempArr = singleArray.pop()
        singleArray.forEach((item) => {
            resultArray.splice(resultArray.length / 2, 0, item)
        })
        resultArray.unshift(tempArr);
    }

    // 小容错吧
    if (resultArray.length !== arr.length) {
        Logger.error("宝石排序出错", JSON.stringify(resultArray), JSON.stringify(arr));
        return arr;
    }
    return resultArray;
}

private chunk(arr, num) {
    let _arr = [];
    while (arr.length) {
        _arr.push(arr.splice(0, num))
    }
    return _arr
}

记录uniapp开发安卓使用webRTC实现语音推送

作者 张老爷子
2025年6月7日 16:56

最近遇到一个需求,需要使用webRTC向监控摄像头发送语音。这个功能很常见,市面上的监控摄像头(比如我家的小米监控)都有这个互相通话的功能。

但是对于要开发这个功能可以说是毫无头绪,网上找了好多也基本上是使用第三方实时通话方案。但是对于监控来说它不适用啊。于是自己慢慢踩坑记录一下从摸索到实现的过程。

首先说结论,如果你是编译APP,是无法传递音频流的。只能获取一段音频文件然后提交给后端。

app向设备发送音频

const recorderManager = uni.getRecorderManager()
// 监听录音开始
recorderManager.onStart(() => {
  console.log('recorder start');
});
recorderManager.onStop((res) => {
  const { tempFilePath } = res;
  console.log('recorder stop', tempFilePath);
  // 处理录音文件 传给后端
});
recorderManager.start({
  format: 'mp3' // 音频格式,有效值 aac/mp3/wav/PCM。App默认值为mp3,小程序默认值aac
});
// 实际需要绑定停止事件,这里模拟结束
setTimeout(() => {
  recorderManager.stop()
}, 1000)

web应用发送音频

如果是web应用则可通过webRTC直接向rtc地址推送一段流实现实时对讲:

  1. 获取用户音频流:
async function start() {
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
}

这一步用于获取音频流,获取到的stream可以直接用于audio标签播放。

  1. 创建RTCPeerConnection:
const pc = new RTCPeerConnection();
pc.addTrack(stream.getAudioTracks()[0], stream);

创建RTCPeerConnection实例并添加音频流。

  1. 创建Offer并发送到服务端:
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

// 发送Offer到服务器
const response = await axios.post(rtcUrl, offer.sdp, {
  timeout: 10000, // 设置合理的超时时间
})

//传参形式看实际场景
//const response = await axios.post(rtcUrl, {sdp:offer.sdp}, {
//  timeout: 10000, // 设置合理的超时时间
//})

此处rtcUrl就是的音频推送地址,这里由于后端定是这样定义的,所以直接将获取到的offer.sdp作为参数传递给过去(别忘了引入axios)。

4.处理返回结果

// 处理服务器的Answer
const answer = await response.data;
await pc.setRemoteDescription(new RTCSessionDescription(answer));

//上一步的返回结果
{
  code: 0
  id: "CgAAjB9BH0E=_54"
  sdp:'xxx'
  type: "answer"
}

第三步成功以后接口会返回这样的结果,获取到type:answer和具体的sdp以后音频就成功推送到服务器了。

vxe-table 在项目中的实践!【附源码】

2025年6月6日 16:29

大家好,我是 前端架构师 - 大卫

更多优质内容请关注微信公众号 @程序员大卫

初心为助前端人🚀,进阶路上共星辰✨,

您的点赞👍与关注❤️,是我笔耕不辍的灯💡。

背景

vxe-table 是一个基于 Vue 的 PC 端表格组件,功能非常丰富,并且支持 Vue 2.6+Vue 3.x 最新版本。从它的 issues 可以看出,作者解决了很多问题,但目前看起来是由一个人维护。随着版本功能不断增加,有 issues 反馈性能相较于旧版本有所下降。

下面是支持 Vue2 和 Vue3 的源码地址:

支持 Vue 2.6+vxe-table 的源码地址:github.com/x-extends/v…

支持 Vue 3.xvxe-table 的源码地址:github.com/x-extends/v…

说说 Vue2 的问题

我之前遇到的一些老项目使用的是 Vue2,因为这些项目可能还需要兼容 IE11 浏览器,所以 Vue3 不太合适。而 Vue2 可以说是后端开发者写前端代码的便利框架,有以下几点原因,同时也是 Vue2 的缺点:

  • Vue2 不强制使用 Typescript,因此写代码的门槛较低,但这也导致后期的重构和维护成本较高。
  • Vue2 可以往 Vue.configVue.prototype 添加任意全局配置属性和实例方法,导致全局污染严重;而 Vue3 已经不再支持这种做法。
Vue.config.productionTip = false
Vue.prototype.$Alert = Alert
  • Vue2 对子组件的控制权限过大,只要给子组件加上 ref,就可以获取其所有的 data 和 methods,这对项目的维护和重构造成很大困扰,不敢随意改动子组件中的数据和方法。而 Vue3 需要使用 defineExpose 显式暴露属性和方法,React 则是使用 useImperativeHandle
<template>
  <div id="app">
    <ChildCom ref="childRef" />
  </div>
</template>

<script>
import ChildCom from "./components/ChildCom.vue";

export default {
  name: "App",
  components: {
    ChildCom,
  },
  mounted() {
    console.log(this.$refs.childRef.num);
    console.log(this.$refs.childRef.increace);
  },
};
</script>

vxe-table 里的结构

vxe-table 一共包含 5 个组件,下面简单介绍它们的作用:

  • vxe-table:基础表格组件。虽然 v3.9+ 版本已将表格与 UI 组件分离,但其 dependencies 中仍然依赖 vxe-pc-ui,因为 vxe-table 的某些功能仍使用了 vxe-pc-ui
  • vxe-pc-ui:一个 PC 端组件库,你可以把它理解成类似 element ui 的 UI 库,它的 dependencies 中依赖 @vxe-ui/corevxe-table 的部分功能依赖它。例如在 vxe-table 3.15.34 中,如果 <vxe-table show-overflow> 组件使用了 show-overflow 属性,就会调用 vxe-pc-ui 中的 vxe-tooltip 组件。
  • @vxe-ui/core:封装了和表格相关的一些公共方法,比如 i18npermissionthemeVxeGlobalConfig 等。它依赖于 dom-zindexxe-utils
  • dom-zindex:一个用来简单控制和设置 zIndex 的工具包。
  • xe-utils:封装了一些常用工具方法,你可以将其类比为 lodash

⚠️ 注意:vxe-table v3.9+ 已将纯表格和 UI 组件分离,而之前是未分离的。具体可参考:vxetable.cn/v3.8/#/tabl…

vxe-table 里的 5 个组件

最常用的组合是 vxe-tablevxe-column,其他几个组件相对使用频率较低。

1. vxe-table

基础表格功能。

<template>
  <div>
    <vxe-table
      :data="tableData">
      <vxe-column type="seq" width="70"></vxe-column>
      <vxe-column field="name" title="Name"></vxe-column>
      <vxe-column field="sex" title="Sex"></vxe-column>
      <vxe-column field="age" title="Age"></vxe-column>
    </vxe-table>
  </div>
</template>

<script>
export default {
  data () {
    const tableData = [
      { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'test abc' },
      { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
      { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
      { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 24, address: 'Shanghai' }
    ]
    return {
      tableData
    }
  }
}
</script>

2. vxe-column

表格列的标识,可参考上面 vxe-table 示例。

3. vxe-colgroup

分组表头,用于多级表头展示。

<template>
  <div>
    <vxe-table
      border
      height="400"
      :data="tableData">
      <vxe-colgroup title="基本信息">
        <vxe-column type="seq" width="70"></vxe-column>
        <vxe-column field="name" title="Name"></vxe-column>
      </vxe-colgroup>
      <!-- other code... -->
    </vxe-table>
  </div>
</template>

具体可参考:vxetable.cn/#/component…

4. vxe-grid

配置式表格:以 JSON 方式调用,适合用于低代码平台,纯 JSON 生成表格。

具体可参考: vxetable.cn/#/component…

5. vxe-toolbar

工具栏。

具体可参考: vxetable.cn/#/global/co…

如何让 vxe-table 3.5.9 和 3.15.34 共存

由于公司老项目使用的是 vxe-table@3.5.9,它不支持虚拟滚动下的自适应行高,而部分页面组件需要使用该功能。

如果将 vxe-table3.5.9 一次性升级到 3.15.34,风险较大。下面我们将采用工程化的思路,实现这两个版本在项目中共存。

1. 下载 vxe-table 3.15.34 的代码

访问 vxe-table 官方地址:github.com/x-extends/v…,从 master 分支切换到 v3 分支,下载并解压代码,目录命名为 vxe-table-v3

执行以下命令:

npm install --legacy-peer-deps // 安装依赖
npm run lib // 打包

最终会生成三个目录:eshelperlib。其中,es 模块的入口文件为 es/index.esm.js,通常用于按需引入或 ES Module 规范的引用场景。

⚠️ 注意:建议使用 Node.js 版本 18.20.8(对应的 npm 版本是 10.8.2),不要使用 pnpm 安装依赖,该项目较老,使用 pnpm 会安装失败。

2. 修改 vxe-table 3.15.34 代码

为了避免和 3.5.9 版本的代码冲突,需要修改以下几点。

1. 修改 packages/table/index.ts

主要修改点在于:将组件注册到 app 时添加版本后缀 3,以避免未来全局注册与原版本发生冲突。

export const VxeTable = Object.assign({}, VxeTableComponent, {
  install (app: VueConstructor) {
    // ...
    app.component(VxeTableComponent.name as string + '3', VxeTableComponent)
  }
})

然后删除一些冗余的代码,这些代码是为了调试用途而注入到 Vue.prototype 上的:

if (!Vue.prototype.$vxe) {
  Vue.prototype.$vxe = { t: VxeUI.t, _t: VxeUI._t }
} else {
  Vue.prototype.$vxe.t = VxeUI.t
  Vue.prototype.$vxe._t = VxeUI._t
}

2. 修改 packages/column/index.ts

与上面类似,添加版本标识 3

export const VxeColumn = Object.assign({}, VxeColumnComponent, {
  install (app: VueConstructor) {
    app.component(VxeColumnComponent.name as string + '3', VxeColumnComponent)
  }
})

3. 修改 packages/colgroup/index.ts

同样添加版本标识 3

export const VxeColgroup = Object.assign({}, VxeColgroupComponent, {
  install (app: VueConstructor) {
    app.component(VxeColgroupComponent.name as string + '3', VxeColgroupComponent)
  }
});

4. 修改 packages/grid/index.ts

继续添加版本标识 3

export const VxeGrid = Object.assign({}, VxeGridComponent, {
  install (app: VueConstructor) {
    app.component(VxeGridComponent.name as string + '3', VxeGridComponent)
  }
});

5. 修改 packages/toolbar/index.ts

同理,添加版本标识 3

export const VxeToolbar = Object.assign({}, VxeToolbarComponent, {
  install (app: VueConstructor) {
    app.component(VxeToolbarComponent.name as string + '3', VxeToolbarComponent)
  }
});

3. 初始化打包库的代码

创建文件夹 vxe-table-lib,在其上级目录执行以下 Vite 脚手架命令:

pnpm create vite vxe-table-lib --template vue-ts
cd vxe-table-lib
pnpm install

安装 postcss-prefix-selector,用于给所有样式类添加前缀,避免与 vxe-table 3.5.9 样式冲突:

pnpm i postcss-prefix-selector @types/postcss-prefix-selector -D

4. 修改打包库的代码

创建文件 lib/main.ts,内容如下:

import "../../vxe-table-v3/lib/style.css";

export {
  VxeColumn as VxeColumn3,
  VxeColgroup as VxeColgroup3,
  VxeGrid as VxeGrid3,
  VxeTable as VxeTable3,
  VxeToolbar as VxeToolbar3,
  VxeUI as VxeUI3,
} from "../../vxe-table-v3";

修改 vite.config.ts 如下:

import vue from "@vitejs/plugin-vue";
import postcssPrefixSelector from "postcss-prefix-selector";
import { defineConfig } from "vite";

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      entry: "./lib/main.ts",
      formats: ["es"],
    },
    commonjsOptions: {
      include: [/vxe-table-v3/],
    },
    rollupOptions: {
      external: ["vue", "vue/jsx-runtime"],
      output: {
        assetFileNames: "style.css",
        entryFileNames: "[name].js",
      },
    },
    copyPublicDir: false,
  },
  css: {
    postcss: {
      plugins: [
        postcssPrefixSelector({
          prefix: `.vxe-table-wrapper-v3`,
          transform(_prefix, selector, prefixedSelector) {
            // 只对 .xxx class 类起作用,而对于 :root, @keyframe, [data-xxx] 不起作用。
            if (selector.startsWith(".") || selector.startsWith("#")) {
              return prefixedSelector;
            }
            return selector;
          },
        }),
      ],
    },
  },
});

postcssPrefixSelector 插件的作用是给所有样式添加一个类名前缀 .vxe-table-wrapper-v3

注意其中传递了一个 commonjsOptions 属性,它是专门用于处理 CommonJS 模块(CJS)的配置。Vite 会借助该插件将 CJS 模块转换为 ES 模块,从而在构建过程中正常处理。如果不加这个配置,打包会失败,因为 vxe-table-3 中的部分代码导出方式不符合 ES 规范,具体会报如下错误:

✗ Build failed in 176ms
error during build:
lib/main.ts (4:2): "VxeColumn" is not exported by "../vxe-table-3/es/index.esm.js", imported by "lib/main.ts".
file: /Users/zhengming/git/wechat-oa/examples/vxe-table-3-build/vxe-table-build/lib/main.ts:4:2

2: 
3: export {
4:   VxeColumn as VxeColumn3,
     ^
5:   VxeColgroup as VxeColgroup3,
6:   VxeGrid as VxeGrid3,
// ...
error during build:
../vxe-table-3/node_modules/@vxe-ui/core/es/src/config.js (1:7): "default" is not exported by "../vxe-table-3/node_modules/xe-utils/index.js", imported by "../vxe-table-3/node_modules/@vxe-ui/core/es/src/config.js".
file: /Users/zhengming/git/wechat-oa/examples/vxe-table-3-build/vxe-table-3/node_modules/@vxe-ui/core/es/src/config.js:1:7

1: import XEUtils from 'xe-utils';
          ^
2: import DomZIndex from 'dom-zindex';
3: import { VxeCore } from './core';

// ...

5. 完成打包,生成 dist 文件

执行以下命令,生成文件 dist/main.jsdist/style.css

pnpm build

6. 验证两个版本的共存

1. 初始化项目代码

创建文件夹 vxe-table-test,并初始化一个 Vue2 项目:

npm create vue@legacy // 根据提示一步步创建
cd vxe-table-test
pnpm install

安装 vxe-table@3.5.9

pnpm i vxe-table@3.5.9

2. 编写基于 vxe-table 3.5.9 的组件

1.在 main.js 中全局注册 vxe-table 3.5.9

import VxeUITable from "vxe-table";
import "vxe-table/lib/style.css";

Vue.use(VxeUITable);

2.在 src/components 目录下新建 VxeTable_3_5_9.vue 文件,并编写如下代码:

<template>
  <div>
    <vxe-table
      border
      show-overflow
      height="300"
      :column-config="{ resizable: true }"
      :virtual-y-config="{ enabled: true, gt: 0 }"
      :data="tableData"
    >
      <vxe-column type="seq" width="70"></vxe-column>
      <vxe-column field="name" title="Name"></vxe-column>
      <vxe-column field="role" title="Role"></vxe-column>
      <vxe-column field="sex" title="Sex"></vxe-column>
    </vxe-table>
  </div>
</template>

<script>
export default {
  data() {
    return {
      tableData: [],
    };
  },
  methods: {
    // 模拟生成表格数据
    loadList(size = 300) {
      const dataList = [];
      for (let i = 0; i < size; i++) {
        dataList.push({
          id: 10000 + i,
          name: "Test" + i,
          role: "Developer",
          sex: i === 0 ? "男".repeat(100) : "男",
        });
      }
      this.tableData = dataList;
    },
  },
  created() {
    this.loadList();
  },
};
</script>

<style>
body .vxe-table .vxe-body--column.col--ellipsis:not(.col--actived) > .vxe-cell {
  white-space: normal;
  max-height: fit-content;
}
</style>

3. 编写基于 vxe-table 3.15.34 的组件

src/components 目录下创建 VxeTable_3_15_34.vue 文件,并引入之前打包生成的 vxe-table-lib/dist 中的组件,并编写如下代码:

<template>
  <div class="vxe-table-wrapper-v3">
    <vxe-table3
      border
      height="300"
      :column-config="{ resizable: true }"
      :virtual-y-config="{ enabled: true, gt: 0 }"
      :data="tableData"
    >
      <vxe-column3 type="seq"></vxe-column3>
      <vxe-column3 field="name" title="Name"></vxe-column3>
      <vxe-column3 field="role" title="Role"></vxe-column3>
      <vxe-column3 field="sex" title="Sex"></vxe-column3>
    </vxe-table3>
  </div>
</template>

<script>
import { VxeTable3, VxeColumn3 } from "../../../vxe-table-lib/dist/main.js";
import "../../../vxe-table-lib/dist/style.css";

export default {
  components: {
    VxeTable3,
    VxeColumn3,
  },
  data() {
    return {
      tableData: [],
    };
  },
  methods: {
    // 模拟行数据
    loadList(size) {
      setTimeout(() => {
        const dataList = [];
        for (let i = 0; i < size; i++) {
          dataList.push({
            id: i,
            name: "Test" + i,
            role: "Developer",
            sex:
              i === 0
                ? "男".repeat(100)
                : "男",
          });
        }
        this.tableData = dataList;
      }, 100);
    },
  },
  mounted() {
    this.loadList(300);
  },
};
</script>

4. 在 App.vue 中引入两个版本的组件

<script setup>
import VxeTable_3_15_34 from "./components/VxeTable_3_15_34.vue";
import VxeTable_3_5_9 from "./components/VxeTable_3_5_9.vue";
</script>

<template>
  <div id="app">
    <VxeTable_3_15_34 />
    <div style="height: 40px"></div>
    <VxeTable_3_5_9 />
  </div>
</template>

5. 启动项目

执行以下命令以本地启动项目:

pnpm dev

6. 最终效果验证

启动成功后,页面上可以同时看到两个版本的 vxe-table 渲染结果:

  • vxe-table@3.15.34 支持不定高的虚拟滚动
  • vxe-table@3.5.9 则不支持该特性。

另外如果给 vxe-table@3.15.34 添加 show-overflow 属性时,控制台会提示如下警告:

main.js:3649 [vxe table v3.15.33] 缺少 "vxe-tooltip" 组件,请检查是否正确安装。

这是因为 vxe-tooltipvxe-pc-ui 包中的组件,当前项目中未安装该依赖。因此,在未安装 vxe-pc-ui 的情况下,应避免使用 show-overflow 属性。

源码地址

github.com/zm8/wechat-…

🌐 CDN跨域原理深度解析:浏览器安全策略的智慧设计

作者 大知闲闲i
2025年6月6日 09:07

引言:一个看似矛盾的常见现象

"浏览器明明有严格的同源策略,为什么我们引用CDN上的jQuery、Bootstrap却从未遇到过跨域问题?"这个看似简单的现象背后,隐藏着浏览器安全模型的精妙设计。本文将带您深入理解这一机制,揭示CDN资源"豁免"同源策略的真正原因。

一、同源策略的本质与边界

1.1 同源策略的核心职责

同源策略(Same-Origin Policy)是浏览器最基本的安全机制,但它并非全面禁止所有跨域行为,而是有明确的管辖范围:

  • 限制目标:阻止JavaScript跨域读取数据

  • 不管辖范围:不阻止跨域请求的发送

  • 三要素判定:协议(protocol)、域名(host)、端口(port)必须完全相同

    // 示例:同源检查 const origin1 = new URL('example.com/page'); const origin2 = new URL('api.example.com/data'); console.log(origin1.origin === origin2.origin); // false (域名不同)

1.2 同源策略的实际限制场景

二、豁免机制:浏览器设计的"后门"

2.1 豁免标签列表与原理

浏览器为以下HTML标签默认开放跨域资源加载权限

<!-- 全部可以跨域加载 -->
<script src="https://cdn.example/lib.js"></script>
<link rel="stylesheet" href="https://cdn.example/style.css">
<img src="https://cdn.example/logo.png">
<video src="https://cdn.example/video.mp4"></video>

设计考量

  1. 历史兼容性:早期Web需要共享公共资源

  2. 内容安全假设:静态资源被视为"被动内容"

  3. 性能优化:允许并行加载第三方资源

2.2 豁免条件的深层逻辑

  • 执行而非读取:浏览器直接处理资源内容,不暴露给页面JS

  • 无数据泄露风险:图片/CSS等资源无法被JS直接提取原始数据

  • 可控的副作用:脚本执行在独立作用域,不自动共享数据

三、CDN与CORS的关键区别

3.1 CORS的工作机制

3.2 CDN不需要CORS的原因

四、安全增强与特殊案例

4.1 子资源完整性(SRI)

<script src="https://cdn.example/lib.js"
        integrity="sha384-xxxx"
        crossorigin="anonymous"></script>
  • 作用:验证CDN资源未被篡改

  • 原理:对比哈希值

  • 注意:使用SRI时需要添加crossorigin属性

4.2 例外情况:Web字体

/* 需要CORS响应头 */
@font-face {
    font-family: 'MyFont';
    src: url('https://cdn.example/font.woff2') format('woff2');
}

限制原因:字体可能通过Canvas API被提取分析

五、面试深度回答指南

5.1 结构化回答框架

  1. 明确同源策略本质:"同源策略限制的是JS代码读取跨域数据的能力,而非网络请求本身"

  2. 指出豁免机制:"浏览器为<script><img>等标签设计了豁免规则"

  3. 关联CDN实现:"CDN资源正是通过这些豁免标签引入"

  4. 对比CORS:"CORS解决的是JS发起的跨域请求,与标签加载机制不同"

5.2 进阶知识点

  • 历史演变:从<script>跨域到JSONP,再到CORS

  • 安全扩展:Content Security Policy(CSP)的作用

  • 性能影响:CDN分片域名对并行加载的优化

结语:理解设计哲学

CDN资源的跨域加载并非"漏洞",而是浏览器精心设计的安全与功能平衡的典范。理解这一点,开发者才能:

  1. 正确评估跨域风险

  2. 合理设计资源加载策略

  3. 深入掌握浏览器安全模型

正如著名安全专家Adam Barth所说:"好的安全策略应该像骨骼系统——提供支撑而不妨碍运动。"同源策略及其豁免机制正是这一理念的完美体现。

昨天 — 2025年6月7日首页

模式验证库——zod

作者 Aphasia
2025年6月6日 23:16

引言💭

在学习react过程中接触了一下zod,这篇文章来深入了解一下什么是zod。


一、Zod 是什么?

Zod 是一个 TypeScript 优先的模式验证库,用于在运行时验证和解析数据结构,确保数据类型安全。

这是官网中给的例子:

import { z } from "zod/v4";

// 定义 Schema(模式)
// 这里定义了一个名为 User 的 Schema,它表示一个对象,
// 其中必须包含一个 name 属性,且 name 必须是字符串类型(z.string())。
const User = z.object({
  name: z.string(),
});
 
// 待验证的数据
// input 是一个未知的、可能不受信任的数据(比如来自 API 响应、用户输入等),
// 需要验证是否符合 User Schema。
const input = { /* stuff */ };
 
// 解析和验证数据
// User.parse(input) 会检查 input 是否符合 User Schema:
// 如果符合,返回解析后的数据(类型安全,TypeScript 会推断 data 的类型为 { name: string })。
// 如果不符合,Zod 会抛出一个错误(ZodError)。
const data = User.parse(input);
 
// 由于 data 已经通过 Zod 验证,可以放心使用 data.name,因为它一定是 string 类型。
console.log(data.name);

Zod 的核心思想是:

定义模式(schema) → 推导类型 → 验证数据

特征

  • 零外部依赖
  • 适用于 Node.js 和所有现代浏览器
  • 微小:2kb 核心包(gzip 压缩)
  • 不可变 API:方法返回一个新实例
  • 简洁的界面
  • 适用于 TypeScript 和纯 JS
  • 内置 JSON Schema 转换
  • 广泛的生态系统

基本用法

建议查阅官方文档,这里不作过多赘述。


看到这里是不是有种熟悉的感觉,这不是老朋友interface吗?

二、Zod Schema和Interface

Zod Schema 和 TypeScript 的 interface确实在结构上很相似,它们都用来定义对象的“形状”和“属性”。但它们的用途、时机和能力有明显区别:


🧩 1. 编译时 vs 运行时

比较项 interface Zod Schema
类型检查时机 编译时(开发时) 运行时
作用 提供类型提示、静态检查 对传入数据进行实际的运行时校验
能否在运行时使用 会被擦除 运行时可判断是否合法

🔍 2. 示例对比

interface 示例(仅开发时检查

interface User {
  name: string;
  age: number;
}

function greet(user: User) {
  console.log(`Hello, ${user.name}`);
}

// 编译时能检查,但运行时不会验证结构

Zod 示例(运行时校验)

import { z } from "zod";

const UserSchema = z.object({
  name: z.string(),
  age: z.number(),
});

function greet(user: unknown) {
  const parsed = UserSchema.parse(user); // 若不合法会抛错
  console.log(`Hello, ${parsed.name}`);
}

🧠 3. 用途对比

用途 interface Zod
类型提示 ❌(需通过 .infer 获取类型)
运行时输入验证(API、表单)
提供 IDE 自动补全 ❌(Zod Schema 可反推出类型)
数据转换(默认值、类型转换) ✅(Zod 提供 .transform()

🔁 4. 配合使用

最佳实践Zod + TypeScript 联用

const UserSchema = z.object({
  name: z.string(),
  age: z.number(),
});

type User = z.infer<typeof UserSchema>; // 自动提取类型

这样既能进行运行时校验,又能享受类型推导带来的开发体验。


总结一句话

interface 是开发阶段的类型说明,Zod 是运行阶段的数据验证器。它们可以配合使用,各司其职。

三、在 React 项目中的使用

1. 验证表单数据

Zod 可以用来验证用户在表单中输入的数据,搭配 react-hook-form 特别方便。

示例:表单验证

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {z} from "zod";

// 定义 Zod 验证 schema(运行时验证规则)
const schema = z.object({
  name: z.string().min(2, "名字至少2个字符"),
  age: z.number().min(18, "必须年满18岁"),
});

// 使用 z.infer 推导出 schema 对应的 TypeScript 类型
type FormData = z.infer<typeof schema>;

export default function MyForm() {
  // 初始化表单,使用 useForm
  const {
    register, // 注册表单字段,连接 input 元素和 React Hook Form
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    resolver: zodResolver(schema), // 指定使用 zodResolver(schema),让 Zod 参与验证
  });

  // 提交处理函数,只有通过验证时才会调用
  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  return (
  // handleSubmit(onSubmit)会拦截提交事件:
  // 验证失败 → 不提交;验证成功 → 调用 onSubmit
    <form onSubmit={handleSubmit(onSubmit)}>
    
      <input {...register("name")} placeholder="名字" />
      <!--- 如果errors.name存在则显示message:"名字至少2个字符",不存在则不显示-->
      {errors.name && <p>{errors.name.message}</p>}

      <input type="number" {...register("age", { valueAsNumber: true })} placeholder="年龄" />
      <!--如果errors.age存在则显示message:"必须年满18岁",不存在则不显示-->
      {errors.age && <p>{errors.age.message}</p>}

      <button type="submit">提交</button>
      
    </form>
  );
}

2. 校验 API 返回的数据

例如,fetch 后对返回结果进行类型和结构的校验:

const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

fetch("/api/user")
  .then(res => res.json())
  .then(data => {
    // 使用 Zod 的 safeParse 进行安全校验
    // 返回的是一个包含 success 和 data/error 的对象
    const result = userSchema.safeParse(data);
    if (result.success) {
      // 校验成功,result.data 是类型安全的对象
      console.log(result.data);
    } else {
      // 校验失败,result.error 是详细的验证错误信息
      console.error("数据结构不符合预期", result.error);
    }
  });

3. 组件 props 验证(开发阶段)

虽然 TypeScript 已经能检查 props 类型,但你可以使用 Zod 在运行时对 props 进行额外校验(比如防止外部调用者传错结构)。

import React from "react";
import {z} from "zod";

// 定义 props 的 schema
const userCardPropsSchema = z.object({
  name: z.string(),
  age: z.number().int().min(0),
});

type UserCardProps = z.infer<typeof userCardPropsSchema>;

// 定义函数组件,并使用推导出来的 props 类型
export const UserCard: React.FC<UserCardProps> = (props) => {
  // 使用 Zod 的 safeParse 方法在运行时校验 props 的合法性
  const result = userCardPropsSchema.safeParse(props);

  if (!result.success) {
    // 校验失败,打印错误信息,避免渲染非法数据
    console.error("UserCard props 验证失败:", result.error);
    return <div>传入的 props 不合法</div>;
  }

  // 解构通过验证的数据,类型安全
  const { name, age } = result.data;

  // 正常渲染组件内容
  return (
    <div>
      <h2>{name}</h2>
      <p>年龄: {age}</p>
    </div>
  );
};

四、Zod 的优势(特别适用于 React)

优点 描述
类型推导 使用 z.infer 可以自动获得类型定义
TypeScript 友好 完美支持 TS,无需重复定义类型
表单库兼容性 react-hook-form 等库配合默契
可组合性强 支持嵌套 schema、交叉验证、联合类型等
运行时验证 提供真实的运行时类型检查(TS 在编译时)

五、与其他库的比较(Yup, Joi)

特性 Zod Yup Joi
TypeScript 支持 ✅ 极好 ⚠️ 一般 ❌ 较差
学习曲线 简单 稍复杂 较复杂
表单支持 ✅ react-hook-form 优先支持
Schema 推导 ✅ 自动推导 ❌ 需手动 ❌ 需手动

结语✒️

知识点+1✨✨✨

猫抓爱心.gif

都25年啦,还有谁分不清双向绑定原理,响应式原理、v-model实现原理

2025年6月6日 21:56

一、Vue 的响应式原理

(一)Vue 2 中的响应式实现

Vue 2 使用Object.defineProperty来实现数据的响应式。当我们将一个普通的 JavaScript 对象传给 Vue 实例的data选项时,Vue 会遍历此对象所有的属性,并使用Object.defineProperty把这些属性全部转换为getter/setter。例如:

let data = {
  message: 'Hello, Vue!'
};
Object.keys(data).forEach(key => {
  let value = data[key];
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get() {
      console.log(`Getting ${key}: ${value}`);
      return value;
    },
    set(newValue) {
      if (newValue!== value) {
        value = newValue;
        console.log(`Setting ${key} to: ${newValue}`);
        // 这里触发视图更新的相关逻辑
      }
    }
  });
});

在上述代码中,通过Object.defineProperty为data对象的message属性添加了getter和setter。当获取message属性时,会触发getter,打印相关信息;当设置message属性时,会触发setter,在setter中判断新值与旧值是否不同,若不同则更新值并可以触发视图更新的逻辑(实际 Vue 中更为复杂,这里仅作示意)。

(二)Vue 3 中的响应式实现

Vue 3 改用Proxy来实现响应式。Proxy可以直接监听对象上属性的添加和删除,弥补了Object.defineProperty的一些不足。示例如下:

let data = {
  message: 'Hello, Vue 3!'
};
let handler = {
  get(target, key) {
    console.log(`Getting ${key}: ${target[key]}`);
    return target[key];
  },
  set(target, key, value) {
    if (target[key]!== value) {
      target[key] = value;
      console.log(`Setting ${key} to: ${value}`);
      // 这里触发视图更新的相关逻辑
      return true;
    }
    return false;
  }
};
let proxy = new Proxy(data, handler);

在这个例子中,通过Proxy创建了一个代理对象proxy,对data对象的属性访问和设置进行了拦截。当获取或设置proxy对象的属性时,会执行handler中的get和set方法,同样可以在set方法中触发视图更新逻辑。

(三)依赖收集与更新

无论是 Vue 2 还是 Vue 3,响应式系统的核心都包括依赖收集和更新。当数据被访问时,会进行依赖收集,将依赖该数据的 “观察者”(Watcher)添加到一个依赖容器(如 Vue 2 中的Dep)中。当数据发生变化时,依赖容器会通知所有相关的Watcher进行更新,从而重新渲染视图。例如在 Vue 2 中,每个组件实例都对应一个Watcher实例,它会在组件渲染的过程中把 “接触” 过的数据属性记录为依赖。之后当依赖项的setter触发时,会通知Watcher,使它关联的组件重新渲染。

二、Vue 的双向绑定原理

(一)双向绑定的概念

双向绑定的核心是让数据的变化自动反映到视图上,而视图的变化也能自动同步回数据。它主要应用于表单元素(如、等)。Vue 的双向绑定原理建立在响应式系统和事件监听机制上。

(二)实现流程

以为例,双向绑定的实现流程如下:

  1. 初始化绑定:当 Vue 实例被创建时,会对data选项中的message数据进行响应式处理,为其设置getter和setter。v-model指令会将message的初始值绑定到元素中,使视图显示message的当前值。
  1. 数据变化更新视图:当message的值发生改变时,Vue 的响应式系统会触发message的setter。setter通知依赖于message的Watcher去更新视图,Watcher会重新渲染依赖message的 DOM 元素,使显示的新值与数据同步。
  1. 视图变化更新数据:在使用v-model时,Vue 会为元素添加一个input事件监听器。当用户在输入框中输入内容时,会触发input事件,Vue 捕获到这个事件后,将输入框的值赋给message,触发message的setter,数据会被更新为用户输入的内容。由于数据被更新,Vue 会再次触发响应式更新过程,如果有其他依赖于message的 DOM 元素或计算属性,它们也会同步更新。

三、v-model 的实现原理

(一)v-model 是语法糖

v-model本质上是一个语法糖。它负责监听用户的输入事件以更新数据,并对一些极端场景进行特殊处理。它会忽略所有表单元素的value、checked、selected attribute 的初始值,而总是将 Vue 实例的数据作为数据来源。我们应该通过 JavaScript 在组件的data选项中声明初始值。

(二)v-model 在不同表单元素上的实现

  1. text 和 textarea 元素:使用value property 和input事件。例如,等价于<input type="text" :value="message" @input="message = event.target.value">:value="message"实现了数据到视图的单向绑定,将message的值绑定到输入框的value属性上;@input="message=event.target.value">。:value="message"实现了数据到视图的单向绑定,将message的值绑定到输入框的value属性上;@input="message = event.target.value"监听输入事件,当用户输入内容时,将新的值赋给message,实现视图到数据的同步。
  1. radio 和 checkbox 元素:使用checked property 和change事件。对于多个复选框,需要手动设置value值,以便在事件处理函数中拿到新数据来更新数据。而单个复选框绑定的是布尔值,在其事件处理函数中可以直接拿到新数据进行更新。例如:
<input type="checkbox" v-model="isChecked">
<input type="checkbox" v-model="checkboxValues" value="option1">
<input type="checkbox" v-model="checkboxValues" value="option2">
  1. select 字段:将value作为 prop 并将change作为事件。例如:
<select v-model="selectedOption">
  <option value="option1">Option 1</option>
  <option value="option2">Option 2</option>
</select>

等价于:

<select :value="selectedOption" @change="selectedOption = $event.target.value">
  <option value="option1">Option 1</option>
  <option value="option2">Option 2</option>
</select>

(三)v-model 在组件中的应用

在组件中,一个组件上的v-model默认会利用名为value的属性和名为input的事件(Vue 2),在 Vue 3 中v-model默认使用modelValue属性和update:modelValue事件来实现双向数据绑定。实现双向绑定的核心步骤如下:

  1. 父传子:数据通过父组件的props传递给子组件,子组件内将v-model拆解绑定数据。
  1. 子传父:通过自定义事件,子组件将新值传递给父组件修改。例如在 Vue 2 中:
<!-- 父组件 -->
<template>
  <MyComponent v-model="parentValue"></MyComponent>
</template>
<script>
import MyComponent from './MyComponent.vue';
export default {
  components: {
    MyComponent
  },
  data() {
    return {
      parentValue: ''
    };
  }
};
</script>
<!-- 子组件MyComponent.vue -->
<template>
  <input :value="value" @input="$emit('input', $event.target.value)">
</template>
<script>
export default {
  props: ['value']
};
</script>

在 Vue 3 中:

<!-- 父组件 -->
<template>
  <MyComponent v-model="parentValue"></MyComponent>
</template>
<script setup>
import MyComponent from './MyComponent.vue';
let parentValue = ref('');
</script>
<!-- 子组件MyComponent.vue -->
<template>
  <input :modelValue="modelValue" @update:modelValue="(newValue) => $emit('update:modelValue', newValue)">
</template>
<script setup>
defineProps(['modelValue']);
defineEmits(['update:modelValue']);
</script>

总结:

 以下是对 Vue 响应式原理、双向绑定原理及 `v-model` 实现原理的总结,帮助快速梳理核心要点:

一、响应式原理

1. Vue 2(Object.defineProperty

  • 核心机制:通过 Object.defineProperty 为数据对象的属性添加 getter/setter,拦截属性的读取和修改。
  • 依赖收集:当属性被读取时(触发 getter),收集当前组件的 Watcher 作为依赖。
  • 更新触发:当属性被修改时(触发 setter),通知所有依赖的 Watcher 重新渲染视图。
  • 局限性:无法监听数组索引和对象新增属性的变化,需通过 Vue.set 等方法手动处理。

2. Vue 3(Proxy

  • 核心机制:使用 Proxy 代理原始数据对象,拦截所有属性操作(读取、修改、删除等)。
  • 依赖收集:通过 Proxy 的 get 拦截器收集依赖(Watcher)。
  • 更新触发:通过 set 拦截器触发依赖更新,自动处理数组和对象新增属性的响应式。
  • 优势:更高效、更全面,原生支持数组和对象的所有操作。

二、双向绑定原理

核心逻辑

  • 数据 → 视图:基于响应式系统,数据变化时通过 Watcher 自动更新视图。
  • 视图 → 数据:通过监听表单元素的事件(如 inputchange),将用户输入的值同步回数据。
  • 典型场景:表单元素(如 <input><textarea><select>)的值与 Vue 实例的数据同步。

实现流程

  1. 初始化绑定:将数据初始值渲染到视图(如 :value="data")。
  2. 数据更新视图:数据变化时,响应式系统触发视图重新渲染。
  3. 视图更新数据:表单元素触发事件(如 input)时,将新值赋值给数据(如 data = $event.target.value)。

三、v-model 实现原理

1. 本质:语法糖

  • 简化表单元素的双向绑定操作,等价于 :value(或 :checked 等)与事件监听(如 @input@change)的组合。

2. 不同元素的实现

元素类型 绑定属性 监听事件 等价代码示例
input/textarea :value @input <input :value="data" @input="data=$event.target.value">
checkbox :checked @change <input :checked="data" @change="data=$event.target.checked">
radio :checked @change <input :checked="data" @change="data=$event.target.value">
select :value @change <select :value="data" @change="data=$event.target.value">

3. 组件中的 v-model

  • Vue 2:通过 props 接收父组件数据(默认属性 value),通过 $emit('input', 新值) 通知父组件更新。
  • Vue 3:通过 props 接收 modelValue,通过 $emit('update:modelValue', 新值) 实现双向绑定。

四、常见误区与注意事项

  1. 响应式边界

    • Vue 2 中,直接修改数组索引或对象新增属性不会触发更新,需用 Vue.set 或数组变异方法(如 pushsplice)。
    • Vue 3 中,Proxy 原生支持数组和对象的所有操作,无需额外处理。
  2. v-model 与单向绑定的区别

    • v-model 是双向绑定,同时包含数据到视图(:)和视图到数据(@)的逻辑。
    • 单向绑定(如 :value)仅实现数据到视图的更新,需手动添加事件监听才能反向更新数据。
  3. 组件开发注意点

    • 子组件使用 v-model 时,需显式声明 props 和触发对应事件(input 或 update:modelValue)。
    • 避免在子组件中直接修改 props 传递的数据,应通过事件通知父组件修改。

总结对比

特性 Vue 2(Object.defineProperty Vue 3(Proxy
响应式实现 getter/setter 拦截属性 Proxy 代理对象所有操作
数组 / 对象新增属性 需手动处理(Vue.set 自动支持
v-model 本质 语法糖(:value + @input 等) 同上,但组件中使用 modelValue 和 update:modelValue
❌
❌