普通视图

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

在线地图支持天地图和腾讯地图,仪表板和数据大屏支持发布功能,DataEase开源BI工具v2.10.7 LTS版本发布

2025年4月13日 20:27

2025年4月11日,人人可用的开源BI工具DataEase正式发布v2.10.7 LTS版本。

这一版本的功能变动包括:数据源方面,Oracle数据源支持获取和查询物化视图;图表方面,在线地图支持天地图、腾讯地图;新增子弹图;图表新增排序优先级设置;仪表板和数据大屏方面,新增发布功能,支持仪表板、数据大屏的编辑与发布操作分离。

X-Pack增强包的功能变动包括:数据填报方面,支持按日期清理提交记录;移动端方面,支持数据填报模块,可以查看和填写数据报表;其他方面,OAuth2支持client_secret_jwt方式认证,认证方式对接用户信息支持多级属性映射等。

▲图1 DataEase的四大优势

新增功能

■ 图表:在线地图支持天地图和腾讯地图

DataEase v2.10.7 LTS版本在线地图正式支持高德地图、天地图(国家地理信息公共服务平台)和腾讯地图三大地图服务,用户可基于业务需求自由切换地图引擎,提升地理数据的可视化能力。

对于以上三种在线地图,在政务或测绘场景建议优先使用天地图,满足国家地理信息合规要求;在品牌定制需求场景建议选用腾讯地图的“个性地图”功能,强化视觉一致性;在通用场景中推荐使用高德地图,适配常规导航与位置服务。

▲图2 DataEase的在线地图设置

■ 图表:新增子弹图

DataEase v2.10.7 LTS版本新增子弹图图表类型。子弹图可以通过简洁的样式展示单一指标的实际值、目标值以及不同性能区间的对比,适合在有限空间内高效呈现数据。

▲图3 子弹图示例图

■ 图表:支持排序优先级设置

DataEase v2.10.7 LTS版本支持对多个图表字段(维度和指标)的排序优先级进行设置,系统将根据设置的优先级顺序依次对图表字段进行排序。

▲图4 图表字段排序优先级设置

■ 仪表板、数据大屏:支持发布功能

在DataEase v2.10.7 LTS版本中,仪表板和数据大屏新增发布功能。

仪表板和数据大屏提供发布版本与草稿版本两种版本,支持保存、发布、取消发布、恢复及预览操作。其中发布与取消发布操作会影响发布版本,保存和恢复至发布版本操作则作用于草稿版本。用户可以在不影响当前发布版本的情况下制作新版本的仪表板或数据大屏,从而实现版本发布的无缝衔接。

▲图5 仪表板和数据大屏发布功能

除了上述新增功能外,DataEase v2.10.7 LTS版本还包含了很多其他的功能更新和优化,欢迎进入DataEase的官方文档及GitHub仓库的Release页面查看更加详细的更新日志。

功能优化

■ refactor(图表):优化组件事件弹窗的窗口打开方式(#15087);

■ refactor(仪表板):分享设置中的Ticket参数格式适配外部参数;

■ refactor(仪表板):创建文件夹或资源时,左侧资源树保持原状(#15423);

■ refactor(仪表板、数据大屏):地图点击激活后,鼠标滚动可以缩放地图大小;

■ refactor(数据集):计算字段中的SQL片段在传输过程中进行加密处理(#15446);

■ refactor(X-Pack):OAuth2支持client_secret_jwt方式认证;

■ refactor(X-Pack):OAuth2跳转state参数时禁用横线(#15039);

■ refactor(X-Pack):“数据填报”功能对于批量导入的操作优化为在日志中只记录一次;

■ refactor:资源名称区分字母大小写(#15426)。

Bug修复

■ fix(图表):修复分组柱状图的子维度在为空时不显示的问题;

■ fix(图表):修复地图在部分设备上显示异常的问题;

■ fix(图表):修复地图设置图例为等分区间时,钻取后显示数值不正确的问题;

■ fix(图表):修复嵌入式场景下表格无法复制的问题;

■ fix(图表):修复明细表和汇总表开启总计后,序号列计算错误以及图片不显示的问题;

■ fix(图表):修复表格显示总计的时候,滚动条遮挡内容的问题(#15244、#15210);

■ fix(图表):修复透视表树形模式下,自定义汇总列小计不显示的问题;

■ fix(图表):修复透视表自定义汇总列总计中的行小计不显示的问题;

■ fix(图表):修复明细表表头合并后,导出数据时出现的样式问题;

■ fix(图表):修复NVARCHAR字段类型无法过滤的问题(#15597);

■ fix(查询组件):修复日期范围组件在配置查询范围时,设置结束时间为月底会导致组件无法生效的问题(#15465);

■ fix(查询组件):修复查询条件名称设置为上方显示并进行隐藏时,选项框的位置未变化的问题(#15491);

■ fix(查询组件):修复查询组件在级联时,下级配置了默认值和必填选项会导致错误的问题(#14949);

■ fix(仪表板):修复切换Tab页时明细表无法显示的问题;

■ fix(仪表板):修复动态路径场景下,移动端分享链接无法访问的问题;

■ fix(仪表板、数据大屏):修复跳转目标是空仪表板或者含有空过滤组件时,可能无法加载的问题;

■ fix(数据源):修复通过HTTP方式添加远程文件,若文件名中包含空格,则获取数据会失败的问题;

■ fix(数据源):修复上传的Excel数据源中,文本类数字字段会被转换成数值类数字字段的问题;

■ fix(X-Pack):修复在“同步管理”页面新建任务时,自定义SQL中无法识别注释的问题;

■ fix(X-Pack):修复定时报告导出的图表数据只有一页的问题;

■ fix(X-Pack):修复定时报告中查询组件没有作用于导出的图表数据的问题;

■ fix(X-Pack):修复移动端开启MFA认证后,使用CAS和OIDC方式无法登录的问题;

■ fix(X-Pack):修复定时报告下载附件时出现错误的问题;

■ fix:修复Token失效后会停留在空白页面的问题;

■ fix:修复数据大屏导出的图表数据没有表头的问题;

■ fix:修复Demo数据源被删除后,重启系统会被恢复的问题(#15393)。

防抖(Debounce)与节流(Throttle)

2025年4月13日 18:22

一、核心概念对比

维度 防抖(Debounce) 节流(Throttle)
定义 事件触发后,等待固定时间再执行回调;若期间再次触发,则重新计时。 事件触发后,固定时间内只执行一次回调,后续触发被忽略。
目标 确保高频事件(如输入)的最终结果只触发一次。 确保高频事件(如滚动)按固定频率执行。
执行时机 最后一次触发后的等待时间结束时执行。 按固定时间间隔执行(如每秒一次)。
类比 电梯关门:有人进出时重新计时关门。 水龙头滴水:无论拧多快,水滴按固定频率落下。

二、代码实现

1. 防抖(Debounce)

function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer); // 清除之前的计时
    timer = setTimeout(() => {
      fn.apply(this, args); // 延迟执行
    }, delay);
  };
}

// 示例:输入框搜索建议
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce(function(e) {
  console.log('发起搜索请求:', e.target.value);
}, 500));

2. 节流(Throttle)

function throttle(fn, interval) {
  let lastTime = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastTime >= interval) { // 判断是否达到时间间隔
      fn.apply(this, args);
      lastTime = now;
    }
  };
}

// 示例:滚动加载更多
window.addEventListener('scroll', throttle(function() {
  console.log('检查滚动位置,加载数据...');
}, 1000));

三、使用场景

场景 防抖(Debounce) 节流(Throttle)
输入框实时搜索 ✅ 用户停止输入后发起请求(如500ms无输入) ❌ 不适合(输入期间仍需响应)
窗口调整(resize) ✅ 调整结束后计算布局 ✅ 按固定频率更新布局(如每秒一次)
按钮防重复点击 ✅ 避免用户快速点击多次提交 ✅ 固定时间内只允许提交一次
滚动事件(scroll) ❌ 最后一次滚动后触发可能不符合预期 ✅ 滚动期间定期检查位置(如触底加载)
鼠标移动(mousemove) ❌ 需要连续响应时不适用 ✅ 限制高频更新(如拖拽元素时降低渲染频率)

四、高频面试问题

1. 如何手写防抖和节流?

  • 参考上述代码实现,注意闭包和apply绑定上下文。

2. 防抖和节流的本质区别是什么?

  • 防抖关注“最后一次触发后的结果”,节流关注“固定间隔内的执行频率”。

3. 如何选择防抖或节流?

  • 防抖:适合结果导向型场景(如搜索建议、提交按钮)。
  • 节流:适合过程控制型场景(如滚动加载、实时定位)。

4. 防抖的立即执行版本如何实现?

  • 添加参数控制是否立即执行第一次触发:

    function debounce(fn, delay, immediate) {
      let timer = null;
      return function(...args) {
        if (immediate && !timer) {
          fn.apply(this, args); // 立即执行
        }
        clearTimeout(timer);
        timer = setTimeout(() => {
          if (!immediate) fn.apply(this, args);
          timer = null;
        }, delay);
      };
    }
    

五、常见误区

  • 过度依赖第三方库:虽然Lodash的_.debounce_.throttle功能完善,但面试需掌握原生实现。
  • 忽视this和参数绑定:回调函数中需正确传递this和事件参数(如event)。
  • 时间间隔设置不合理:防抖的延迟过长会导致响应迟钝,节流的间隔过短会失去优化意义。

六、总结回答技巧

  • 一句话概括
    “防抖是延迟执行,确保连续触发只执行最后一次;节流是限频执行,确保连续触发按固定频率执行。”
  • 结合项目举例
    “在商品搜索页中,输入框用防抖减少请求次数;图片懒加载用节流控制滚动检查频率。”
  • 扩展思考
    “防抖和节流本质是闭包的应用,通过控制计时器和时间戳管理事件触发逻辑。”

Get与Post区别

2025年4月13日 18:21

1. 核心特性对比

维度 GET POST
语义 获取数据(幂等操作) 提交数据(非幂等操作)
参数位置 URL查询字符串(?key=value 请求体(Body)
数据长度限制 受URL长度限制(通常2KB~8KB,因浏览器/服务器而异) 无明确限制(适合传输大文件或表单数据)
安全性 参数明文暴露在URL中(易被缓存、日志记录) 参数在请求体中(相对安全,但仍需HTTPS加密)
缓存 可被浏览器缓存 默认不可缓存
历史记录 保留在浏览器历史记录中 不保留
书签 可收藏为书签(含参数) 不可收藏
编码类型 仅支持URL编码(application/x-www-form-urlencoded 支持多种编码(如multipart/form-dataJSON
幂等性 幂等(多次请求结果相同) 非幂等(多次请求可能产生副作用)

2. 使用场景

方法 典型场景 示例
GET - 获取资源(如搜索、分页) - 无副作用的操作 - 数据可公开或非敏感 GET /api/users?page=2 获取用户列表第二页数据
POST - 提交表单或文件 - 创建资源(如新增订单) - 敏感数据操作(如登录、支付) POST /api/login 提交用户名密码;POST /api/upload 上传文件

3. 技术细节

  • 幂等性
    GET的幂等性使其适合重复请求(如刷新页面),而POST重复提交可能导致重复创建资源(需后端防重处理)。

  • 性能
    GET请求可以被CDN或浏览器缓存,减少服务器压力;POST每次需服务端处理。

  • RESTful API规范

    • GET对应查询(Read)
    • POST对应创建(Create)
      (注:实际开发中可能不严格遵循,但语义清晰利于协作)

4. 安全性误区

  • GET“不安全”
    参数在URL中暴露,易被浏览器历史、服务器日志记录,但安全性最终取决于是否使用HTTPS。
    示例:GET传密码(错误)→ 即使HTTPS加密,URL中的密码仍可能被泄露。
  • POST“绝对安全”
    请求体数据仍可被抓包工具截获,敏感数据(如密码)需结合HTTPS和加密传输。

5. 面试回答技巧

  • 简明回答
    “GET用于获取数据,参数在URL中,可缓存且幂等;POST用于提交数据,参数在请求体中,适合敏感或大数据传输。”
  • 结合场景
    “在实现搜索功能时用GET,参数暴露便于分享链接;提交订单时用POST,避免重复下单和参数泄露。”
  • 深入扩展
    “从RESTful角度,GET对应Read,POST对应Create;实际开发中还可通过请求头区分语义(如X-HTTP-Method-Override)。”

Token、Cookie、Session的区别

2025年4月13日 18:10

1. 存储位置与机制

类型 存储位置 依赖关系 特点
Cookie 客户端(浏览器) 由服务器通过Set-Cookie响应头设置,自动随请求发送 大小受限(约4KB),可设置过期时间;存在安全风险(如CSRF攻击)。
Session 服务端(服务器内存/数据库) 依赖Cookie中的Session ID与服务端数据关联 用户状态存储在服务端,安全性更高;但服务器需维护Session数据,高并发时可能影响性能。
Token 客户端(如LocalStorage) 由服务端生成并返回,客户端手动在请求头(如Authorization)添加 无状态(服务端不存储),可自定义存储位置(如Header、URL参数);无跨域限制,适合分布式系统(如JWT)。

2. 安全性对比

类型 安全风险 防护措施
Cookie - CSRF攻击(跨站请求伪造) - XSS窃取Cookie内容(若未设置HttpOnly - 设置SameSite属性限制跨站发送 - 标记HttpOnly防止JS访问 - 使用Secure仅HTTPS传输
Session - Session劫持(若Session ID泄露) - 服务端存储压力大(可能被攻击者耗尽资源) - 定期清理过期Session - 结合IP/User-Agent验证 - 加密Session ID
Token - XSS窃取Token(若存储不当) - Token泄露后无法快速失效(需依赖黑名单或短有效期) - Token加密签名(如JWT的签名机制) - 存储于非持久化位置(如内存) - 设置合理过期时间

3. 使用场景

类型 适用场景 示例
Cookie - 简单的用户状态保持(如登录态) - 需要服务端自动管理的小型数据(如语言偏好) 电商网站的购物车ID存储
Session - 对安全性要求高的场景(如支付系统) - 需要服务端完全控制用户状态的系统 银行系统登录后的会话管理
Token - 前后端分离架构(如SPA应用) - 跨域/分布式系统(如微服务API鉴权) - 无状态身份验证(如移动端APP) JWT用于用户登录后访问API资源

4. 核心区别总结

维度 Cookie Session Token
存储位置 客户端 服务端 客户端
通信方式 自动随请求发送 依赖Cookie中的Session ID 手动添加到请求头/参数
跨域支持 受同源策略限制(需CORS) 同Cookie 无限制(可手动传递)
服务端压力 低(数据在客户端) 高(需存储所有Session数据) 低(无状态)
扩展性 受限(大小和安全性) 依赖服务端扩展 高(适合分布式系统)
典型应用 会话管理、跟踪用户行为 敏感操作的身份验证 API鉴权、移动端身份验证

5. 面试回答技巧

  • 简明回答
    “Cookie由服务端设置,存储在浏览器,自动随请求发送;Session数据在服务端,通过Session ID关联;Token(如JWT)由服务端生成,客户端存储并手动发送,无状态且适合分布式系统。”
  • 结合场景
    “在单页面应用(SPA)中,常用Token实现无状态鉴权,避免Cookie的跨域限制;而传统Web应用可能用Session管理敏感会话。”
  • 安全补充
    “Cookie需防范CSRF(如设置SameSite),Token需防范XSS(避免LocalStorage存储敏感数据)。”

面向企业级应用的React路由管理体系:react-router-mannge实践

作者 Shinpei
2025年4月13日 17:40

当今复杂的企业级前端应用开发中,尤其是中后台应用中,路由管理已不再是简单的页面切换,而是演变为一套完整的状态管理、权限控制、导航构建和用户体验优化的综合解决方案。react-router-manage 应运而生,它在 react-router v6 的基础上构建了一个更高维度的路由管理生态,为开发者提供了一种优雅而高效的前端架构范式。

该库在网易云商多个项目中已稳定运行4年之久,在2年前也把它开源出来,但没有做过推广。但是两年过去了,react-router社区还是没有一套完整的解决方案,反而在react-router v7中自己演变成了一个和remix高度融合的一个框架项目,不由的想,这已经不是我想要的路由,于是,我想和和大家一起共建react-router生态型路由

突破传统路由管理的局限

传统的React应用路由管理面临多重挑战:分散式的路由配置导致维护困难;权限与路由耦合度高;动态路由管理复杂;缺乏统一的路由守卫机制。这些问题在大型应用中尤为突出,严重影响了开发效率和代码质量,相信大家用了我这个库,开发效率一定能有很大的提升。详细的使用文档可参考 reac-router-manage使用文档或者 github

react-router-manage 通过一种集中式声明式的配置方式,彻底颠覆了传统路由管理的思维模式,先来看如下示例

import React from "react";
import { MRouter, defineRouterConfig } from "react-router-manage";

const Users = () => {
  return <div>Users</div>;
};
const Profile = () => {
  return <div>Profile</div>;
};

const appRouterConfig = defineRouterConfig({
  basename: "/",
  // 配置层级导航
  routes: [
    {
      name: "user", // 每个路由对应一个全局唯一的name
      path: "user", // 路径会自动在内部转换为 /user, 由于这里没有配置component,进入 /user 会重定向到 /user/list
      title: "用户中心", // 路由名字
      items: [
        // items用于配置具有层级结构的导航,例如面包屑导航,
        {
          name: "userList",
          path: "list", // 路径会自动在内部转换为 /user/list
          component: Users,
          title: "用户列表"
        },
        {
          name: "profile",
          path: "profile", // 路径会自动在内部转换为 /user/list
          component: Profile,
          title: "个人中心"
        }
      ]
    }
  ]
});

function App() {
  return <MRouter routeConfig={routeConfig}>{children => children}</MRouter>;
}

安装

npm install react-router-manage

核心优势:超越简单的路由切换

1. 声明式配置与中央管理

告别以往零散和嵌套的路由定义方式,react-router-manage 采用集中声明式配置,通过单一数据源的方式实现路由的全局管理,大幅提升了路由系统的可维护性和可扩展性。

自动解析获取当前路由参数

import React from "react";
import { useRouter } from "react-router-manage";

cconst User = () => {
  // 若url为 /user/profile?id=9527
  const {query, params} = useRouter();
  
  // 这里query.id为9527
  return <div><{query.id}</div>;
};

export default UserList

2.便捷式的路由导航

路由跳转不再需要知道具体pathname

import React from "react";
import { useRouter } from "react-router-manage";

cconst UserList = () => {
  const {routesMap, naviagete} = useRouter();
  const onClick = () => {
      // 所有路由的配置都会放到routesMap对象里
      navigate(routesMap.profile.pathname)
  }
  return <div><button>跳转我的个人中心</button></div>;
};

export default UserList

快捷拿到子级路由

例如我要渲染 /user下的自己路由菜单,点击跳转到对应的路由

import React from "react";
import { useRouter } from "react-router-manage";

cconst UserCenter = () => {
  const {routesMap, navigate} = useRouter();
  
  // 拿到 /user下的 /user/profile 和/user/list
  
  const items = routesMap.user.items;
  
  return <div>{
        items.map(item => {
            return <button onClick={() => navigate(item.pathname)}>跳转到{item.name}</button>
        })
    }</div>;
};

export default UserCenter

便携式参数传递

navigate对象支持传路由参数query, params,navigate: (to: string, {query: Record<string, any>; params: Record<string, any>; state: any}) => void},如下示例

import React from "react";
import { useRouter } from "react-router-manage";

cconst UserList = () => {
  const {routesMap, naviagete} = useRouter();
  const onClick = () => {
      // 所有路由的配置都会放到routesMap对象里
      navigate(routesMap.profile.pathname, {
         query: {id: 9527} // 会自动拼成 /user/profile?id=9527
      })
  }
  return <div><button>跳转用户A的个人中心</button></div>;
};

export default UserList

3. 动态路由操作的优雅解决方案

在复杂应用的微前端架构或权限变化场景中,动态调整路由结构是一项常见需求。react-router-manage 提供了三个强大的 hooks, useAddRoutes,useUpdateRoutes, useRemoveRoutes。你可以很简单的动态操作路由

// 添加路由
const addRoutes = useAddRoutes();
// 动态更新路由配置
const updateRoutes = useUpdateRoutes();
// 移除路由
const removeRoutes = useRemoveRoutes();

4. 多层次的权限控制体系

react-router-manage 突破了传统路由权限的局限性,提供了从全局到局部的多级权限控制机制:

  • 声明式权限配置:通过 code 属性简洁地定义路由访问权限
  • 权限继承模式:支持 parent 和 children 两种权限继承模式
  • 动态权限校验:结合 beforeEachMount 和 beforeEnter 实现精细的动态权限控制

5. 全面的路由守卫系统

借鉴了Vue Router 的优秀设计,react-router-manage 引入了完整的路由守卫机制:

  • 全局前置守卫:beforeEachMount
  • 路由级前置守卫:beforeEnter
  • 组件级离开守卫:useBeforeLeave

这种分层的守卫架构使得开发者可以精确控制路由导航的每个环节,优雅处理权限验证、数据预加载和离开确认等复杂场景。

6.智能导航生成引擎

可自动生成符合企业规范的面包屑导航、菜单导航

从 React Router v5 到 v6 的平滑迁移

React Router v6 带来了诸多改进,但也带来了升级成本。react-router-manage 通过提供兼容层,大幅降低了迁移门槛:

  • 保留部分 v5 风格的 API
  • 透明处理 v6 的新特性
  • 引入 useHistory 等过渡性 hook

企业级应用的首选路由解决方案

React Router Manage 已在网易云商等多个企业级应用中经受了实战检验,证明了其在大型复杂应用中的可靠性和扩展性。它特别适合以下场景:

  • 大型管理后台:复杂权限系统、多层级导航
  • 微前端架构:动态路由管理、模块隔离
  • 多团队协作:标准化的路由配置、一致的导航体验

JavaScript 事件委托详解与面试指南

2025年4月13日 17:21

一、事件委托核心概念

1. 基本定义

事件委托(Event Delegation)是一种利用事件冒泡机制,将子元素的事件处理委托给父元素统一管理的技术。

2. 实现原理

  • 事件冒泡:事件从触发元素向上传播到DOM树
  • 事件捕获:事件从window向下传递到目标元素(可选阶段)
  • 目标阶段:事件到达实际触发元素

3. 三阶段图示

捕获阶段: window -> document -> ... -> 父元素
目标阶段: 目标元素
冒泡阶段: 目标元素 -> ... -> document -> window

二、面试常见问题

基础问题

  1. 什么是事件委托?它的原理是什么?

    • 参考答案:事件委托是利用事件冒泡机制,在父元素上统一处理子元素事件的技术。原理是通过event.target识别实际触发事件的元素。
  2. 为什么要使用事件委托?

    • 参考答案:

      • 减少内存消耗(避免为每个子元素绑定事件)
      • 动态添加的元素自动拥有事件处理
      • 提高初始化性能(减少事件绑定次数)
  3. 如何实现基本的事件委托?

    document.getElementById('parent').addEventListener('click', function(event) {
      if (event.target.classList.contains('child')) {
        // 处理逻辑
      }
    });
    

进阶问题

  1. event.target 和 event.currentTarget 有什么区别?

    • event.target:实际触发事件的元素
    • event.currentTarget:当前正在处理事件的元素(委托的父元素)
  2. 如何处理动态生成的元素事件?

    // 使用closest方法处理动态内容
    document.addEventListener('click', function(event) {
      const btn = event.target.closest('.dynamic-btn');
      if (btn) {
        // 处理逻辑
      }
    });
    
  3. 哪些事件不支持冒泡?如何委托这些事件?

    • 不冒泡事件:focus、blur、load、unload等

    • 解决方案:

      • 使用支持冒泡的替代事件(focusin/focusout)
      • 手动在元素上绑定事件

深度问题

  1. 事件委托对性能有什么影响?如何优化?

    • 优点:减少事件监听器数量,降低内存占用

    • 缺点:深层嵌套时事件传播路径长

    • 优化:

      • 在最近的公共父元素上委托
      • 使用事件委托+节流处理高频事件
  2. 如何阻止事件委托?

    • event.stopPropagation():停止事件传播
    • event.stopImmediatePropagation():停止传播并阻止同元素其他处理程序
  3. 编写一个通用的事件委托函数

    function delegate(parent, eventType, selector, handler) {
      parent.addEventListener(eventType, function(event) {
        if (event.target.matches(selector)) {
          handler.call(event.target, event);
        }
      });
    }
    

三、实战编码题

题目1:实现表格行点击高亮

// 方案
document.querySelector('table').addEventListener('click', function(event) {
  const row = event.target.closest('tr');
  if (row) {
    // 移除其他行高亮
    document.querySelectorAll('tr.active').forEach(r => {
      r.classList.remove('active');
    });
    // 添加当前行高亮
    row.classList.add('active');
  }
});

题目2:动态列表删除功能

document.getElementById('list').addEventListener('click', function(event) {
  if (event.target.classList.contains('delete-btn')) {
    const item = event.target.closest('li');
    item.remove();
  }
});

四、面试加分点

  1. 对比直接绑定和委托的性能

    • 直接绑定:O(n)内存占用,初始化耗时长
    • 委托:O(1)内存占用,初始化快
  2. 事件委托的局限性

    • 不适合所有事件类型(如不冒泡的事件)
    • 深层嵌套时可能影响性能
    • 需要额外代码判断目标元素
  3. 现代替代方案

    • 使用框架内置功能(如React的合成事件)
    • 第三方事件委托库
  4. 内存泄漏防范

    • 及时移除不需要的委托监听器
    • 使用WeakMap存储处理函数

五、常见误区

  1. 在document上绑定所有委托

    • 问题:事件传播路径过长
    • 解决:在最近的公共父元素上绑定
  2. 过度使用event.stopPropagation()

    • 问题:破坏事件流,影响其他监听器
    • 解决:仅在必要时使用
  3. 忽略事件委托对动态内容的优势

    • 典型反模式:在动态添加元素后重新绑定事件

next使用字体文件时,路径使用@路径别名的问题

作者 hhdy
2025年4月13日 17:04

不能使用@路径别名否则会报错

image.png

正确做法,使用相对路径

image.png

不能使用@路径别名原因

  1. next/font/local 是一个特殊的字体加载器,它在构建时处理字体文件
  2. 虽然 tsconfig.json 中配置了 @ 路径别名,但这个别名主要用于 JavaScript/TypeScript 模块的导入
  3. 对于字体文件这样的资源文件,我们需要使用相对路径,从当前文件位置(app/layout.tsx)指向字体文件的位置(public/fonts/)

我在公司整前端监控(二)

作者 逸_
2025年4月13日 16:56

上一章主要讲了前端监控的三个方向,本章继续。上一章有个遗留问题,为什么用navigator.sendBeacon去发送数据 ?这就涉及到如何上报数据的问题,下面我具体讲下。

一、五种数据上报的形式

  1. XMLHttpRequest:经典的 AJAX 请求方式,用于发送数据到服务器。可以配置为同步或异步请求,但在页面卸载时可能不可靠。
  2. Fetch API:现代浏览器中推荐的方式,用于发送网络请求。相比XMLHttpRequest,它提供了更简洁的语法,但在页面卸载时仍可能会被中断。
  3. Navigator.sendBeacon:专门用于在页面卸载时发送数据的 API。sendBeacon是非阻塞的,确保数据在页面关闭时也能可靠地发送。
  4. Image Ping:通过动态创建图像对象并设置其src属性来发送 GET 请求。这种方法常用于简单的数据上报,不会受到跨域限制,但只能发送少量数据。
  5. WebSocket:用于建立持久连接,实现双向通信。适合需要实时数据传输的场景,但对于简单的数据上报来说可能过于复杂。

一共有5种形式上报,我来分析下各有什么优劣势:

  • 其中可以XMLHttpRequestFetch没有什么太大本质的区别属于一类,他们都是比较常规的上报形式,但是他有个致命问题可能会被中断,导致数据丢失,特别在页面跳出的情况,还有就是在请求里面优先级高,在页面加载的前期当有大量的上报的时候会阻塞页面的请求看下图:

  • Image Ping图片上报,在跨域和调用方式上比较好,但是有个致命的缺点数据量带不多。
  • WebSocket我见过的很少,可能在某些场景用的多,但是我很少见到
  • Navigator.sendBeacon专为页面卸载设计,确保数据在页面关闭时发送。非阻塞:不会影响页面卸载速度。在上图里面的优先级属于低,使用简单,但是有个问题,兼容性有点问题,可以配合fetch使用。他不会应为页面切换丢数据,调用起来非常方便。

二、上报时机

数据上报的形式可以从不同的角度来分类和理解。一般来说,数据上报主要有以下几种形式:

  1. 实时上报
    • 数据在产生后立即发送到服务器。这种方式确保数据的实时性,适用于需要即时分析和处理的场景。
  1. 批量上报
    • 数据在客户端累积到一定数量或满足特定条件后,再一次性发送到服务器。这种方式可以减少网络请求次数,适用于对时效性要求不高的场景。
  1. 定时上报
    • 客户端在固定的时间间隔内发送数据到服务器。这种方式可以平衡实时性和网络资源的消耗。
  1. 页面卸载上报
    • 在页面关闭或卸载时发送数据,通常使用sendBeaconAPI。这种方式确保在用户离开页面时也能可靠地将数据发送到服务器。
  1. 事件驱动上报
    • 基于特定事件触发数据上报,例如用户点击按钮、表单提交等。这种方式适用于需要对特定用户行为进行监控的场景。

一共有5种形式时机,我来分析下各有什么优劣势:

  1. 实时上报
    • 优点
      • 实时性:数据在产生后立即发送,适合需要即时分析的场景。
      • 准确性:可以立即捕捉用户行为,减少数据丢失的风险。
    • 缺点
      • 网络开销:频繁发送请求可能增加网络负担。
      • 服务器压力:需要服务器能够处理高频率的数据请求。
  1. 批量上报
    • 优点
      • 减少请求次数:通过合并多条数据减少网络请求。
      • 提高效率:降低网络和服务器的负担。
    • 缺点
      • 时效性:数据不是实时发送,可能导致分析滞后。
      • 数据丢失风险:如果客户端崩溃或断网,未发送的数据可能丢失。
  1. 定时上报
    • 优点
      • 平衡实时性和效率:通过定时发送,减少频繁请求。
      • 可控性:可以根据需求调整上报频率。
    • 缺点
      • 复杂性:需要实现定时器逻辑。
      • 数据可能不够实时:与实时上报相比,数据可能有延迟。
  1. 页面卸载上报
    • 优点
      • 可靠性:利用sendBeacon确保数据在页面关闭时发送。
      • 简单性:不需要复杂的逻辑,只需在页面卸载时触发。
    • 缺点
      • 数据量限制:通常只能发送较小的数据量。
      • 依赖浏览器支持:如果浏览器不支持sendBeacon,需要备用方案。
  1. 事件驱动上报
    • 优点
      • 精确性:针对特定用户行为进行上报,适合精准分析。
      • 灵活性:可以根据业务需求定制上报逻辑。
    • 缺点
      • 复杂性:需要为每个事件设置监听器和上报逻辑。
      • 潜在的性能问题:过多的事件监听可能影响页面性能。

看你业务需要那种形式,去评估那种形式上报。

我来总结下我在狗东遇到的几种情况:狗东H5目前用的最多的就是第一种,但是在客户端里面是批量,我理解客户端的形式是最好的,H5的页面如果在端里面可以借助端的能力去实现批量上报。我们也可以在h5里面页面卸载的时候去上报,我自己测试过好像有一些兼容问题,最后还是放弃了用的实时上报。

三、最后来个小问题,如何对你的页面程序方法运行时间进行监控?

页面程序方法的运行时间进行监控是性能优化的重要一环,可以帮助识别性能瓶颈和优化点。以下是一些常用的方法来监控方法的运行时间:

  1. 使用console.timeconsole.timeEnd
    • 这是最简单的方法之一,适用于开发和调试阶段。
    • 使用console.time('label')开始计时,console.timeEnd('label')结束计时,并在控制台输出运行时间。
console.time('myFunction');myFunction();console.timeEnd('myFunction');
  1. 使用performance.now()
    • performance.now()提供高精度的时间戳,可以用于精确测量函数的执行时间。
const start = performance.now();myFunction();const end = performance.now();console.log(`myFunction took ${end - start} milliseconds`);
  1. 使用PerformanceAPI
    • 浏览器的PerformanceAPI 提供了更全面的性能测量工具,可以记录和分析多个时间点。
    • 可以使用performance.mark()performance.measure()来标记和测量代码片段。
performance.mark('start');myFunction();
performance.mark('end');
performance.measure('myFunction', 'start', 'end');const measures = performance.getEntriesByName('myFunction');console.log(measures[0].duration);
  1. 使用第三方库
    • 有一些第三方库可以帮助监控和分析性能,如stats.jsNew Relic等。
    • 这些工具通常提供更详细的性能分析和报告功能。
  1. 自定义监控工具
    • 如果需要更复杂的监控,可以编写自定义的监控工具,结合performance.now()和日志记录,将数据发送到服务器进行分析。

目前用的比较多的是performance.mark() 因为他是用的比new date 提供高精度的时间戳,具体的可以自己去查文档,我懒得讲performance.mark()是 Web Performance API 提供的一个方法,用于在浏览器的性能时间线上创建一个标记。这个标记可以帮助开发者测量特定代码段的执行时间,尤其是在需要精确测量性能的复杂应用中。

用法

  1. 创建标记: 使用performance.mark(name)来创建一个标记,其中name是一个字符串,用于标识这个标记。例如:
performance.mark('start');// 执行一些代码
performance.mark('end');
  1. 测量时间: 使用performance.measure(name, startMark, endMark)方法来测量两个标记之间的时间间隔。name是测量的名称,startMarkendMark是之前创建的标记。例如:
performance.measure('myMeasure', 'start', 'end');
  1. 获取测量结果: 使用performance.getEntriesByName(name)来获取测量结果。这个方法返回一个数组,其中包含所有与指定名称匹配的测量结果。例如:
const measures = performance.getEntriesByName('myMeasure');
measures.forEach((measure) => {
    console.log(`${measure.name}: ${measure.duration}ms`);
});

我以前用过一个蠢方法就是用mark形式对接口进行打点监控,其实前面见过用资源监控就能完成,后面我为了精准的监控页面真实的渲染时间,用过mack在页面渲染完成的时间点去打mark,后面发现有lcp后放弃,但是我认为在有些特殊场景还是能用上,特别在性能优化到后期实在找不到优化点的时候可以对具体的某些方法进行监控优化。

总结:

讲完了监控基础理论知识,具体怎么去写一套前端监控等哪天我有时间我再继续写

移动端适配

2025年4月13日 16:51

为什么要做移动端适配?

目前市面上移动端屏幕尺寸非常的繁多,很多时候我们希望一个元素在不同的屏幕上显示不同的大小以此来更好的还原效果图,因此我们需要通过移动端适配,通过自适应和响应式,确保应用在不同设备上所占据视口的空间比例是相同的

  • 自适应:根据不同的设备屏幕大小来自动调整尺寸、大小
  • 响应式:会随着屏幕的实时变动而自动调整,是一种更强的自适应

当前流行的几种适配方案

方案一:百分比设置(不推荐),因为不同属性的百分比值,相对的可能是不同参照物,所以百分比往往很难统一

方案二:rem单位+动态html的font-size (兼容性更好)

方案三:vw单位(新项目推荐)

方案四:flex的弹性布局

rem + 动态设置 font-size

rem 单位是相对于 html 元素的 font-size 来设置的,通过在不同屏幕尺寸下,动态的修改 html 元素的 font-size 以此来达到适配效果

在开发中,我们只需要考虑两个问题:

  • 针对不同的屏幕,设置 html 不同的 font-size
  • 将原来设置的尺寸,转化成 rem 单位

px 与 rem 的单位换算

方案一:手动换算
  • 根元素 html 的文字大小 = 视口宽度/分成的份数(一般为10份,方便计算)
  • rem 值 = 元素的 px 值 / 根元素 html 的文字大小

比如有一个在375px屏幕上,100px宽度和高度的盒子,我们需要将100px转成对应的rem值:100/37.5=2.6667,其他也是相同的方法计算即可

.pxToRem(@px) {
  result: 1rem * (@px / 37.5);
}

.box {
  width: .pxToRem(100)[result];
  height: .pxToRem(100)[result];
  background-color: orange;
}

p {
  font-size: .pxToRem(14)[result];
}
方案二: postcss-pxtorem
  • 目前在前端的工程化开发中,我们可以借助于webpack的工具来完成自动的转化
  • npm install postcss-pxtorem
方案三: VSCode插件

设置不同 font-size

方案一:媒体查询

思路:通过媒体查询来设置不同尺寸屏幕下 html 的 Root font-size

缺点:

  • 需要针对不同的屏幕编写大量的媒体查询
  • 如果动态改变尺寸,不会实时更新,只是一个个区间
@media screen and (min-width: 320px) {
  html {
    font-size: 20px;
  }
}

@media screen and (min-width: 375px) {
  html {
    font-size: 24px;
  }
}

@media screen and (min-width: 414px) {
  html {
    font-size: 28px;
  }
}

@media screen and (min-width: 480px) {
  html {
    font-size: 32px;
  }
}

.box {
  width: 5rem;
  height: 5rem;
  background-color: blue;
}
方案二:编写 js 代码

思路:通过监听屏幕尺寸的变化来动态修改 html 元素的 font-size 大小

方法:

  • 根据 html 的宽度计算出 font-size 的大小,并设置到 html 上
  • 监听页面尺寸的变化,实时修改 font-size 大小
(function flexible (window, document) {
  var docEl = document.documentElement
  var dpr = window.devicePixelRatio || 1

  // adjust body font size
  function setBodyFontSize () {
    if (document.body) {
      document.body.style.fontSize = (12 * dpr) + 'px'
    }
    else {
      document.addEventListener('DOMContentLoaded', setBodyFontSize)
    }
  }
  setBodyFontSize();

  // set 1rem = viewWidth / 10
  function setRemUnit () {
    var rem = docEl.clientWidth / 10
    docEl.style.fontSize = rem + 'px'
  }

  setRemUnit()

  // reset rem unit on page resize
  window.addEventListener('resize', setRemUnit)
  window.addEventListener('pageshow', function (e) {
    if (e.persisted) {
      setRemUnit()
    }
  })

  // detect 0.5px supports
  if (dpr >= 2) {
    var fakeBody = document.createElement('body')
    var testElement = document.createElement('div')
    testElement.style.border = '.5px solid transparent'
    fakeBody.appendChild(testElement)
    docEl.appendChild(fakeBody)
    if (testElement.offsetHeight === 1) {
      docEl.classList.add('hairlines')
    }
    docEl.removeChild(fakeBody)
  }
}(window, document))
方案三:lib-flexible 库

lib-flexible 是淘宝团队出品的一个移动端自适应解决方案,通过动态计算 viewport 设置 font-size 实现不同屏幕宽度下的 UI 自适应缩放。

(function flexible (window, document) {
  var docEl = document.documentElement
  var dpr = window.devicePixelRatio || 1

  // adjust body font size
  function setBodyFontSize () {
    if (document.body) {
      document.body.style.fontSize = (12 * dpr) + 'px'
    }
    else {
      document.addEventListener('DOMContentLoaded', setBodyFontSize)
    }
  }
  setBodyFontSize();

  // set 1rem = viewWidth / 10
  function setRemUnit () {
    var rem = docEl.clientWidth / 10
    docEl.style.fontSize = rem + 'px'
  }

  setRemUnit()

  // reset rem unit on page resize
  window.addEventListener('resize', setRemUnit)
  window.addEventListener('pageshow', function (e) {
    if (e.persisted) {
      setRemUnit()
    }
  })

  // detect 0.5px supports
  if (dpr >= 2) {
    var fakeBody = document.createElement('body')
    var testElement = document.createElement('div')
    testElement.style.border = '.5px solid transparent'
    fakeBody.appendChild(testElement)
    docEl.appendChild(fakeBody)
    if (testElement.offsetHeight === 1) {
      docEl.classList.add('hairlines')
    }
    docEl.removeChild(fakeBody)
  }
}(window, document))

vw 适配方案

100vw 相当于整个视口的宽度 innerWidth,1vw 相当于视口宽度的 1%,将 px 转换为 vw 即可完成适配,其实上面的 rem 就是模仿 vw 方案

vw相比于rem的优势:

  • 不需要去计算 html 的 font-size 大小,也不需要去给 html 设置 font-size
  • 不会因为设置 html 的 font-size 大小,而必须再给 body 设置一个 font-size 防止继承
  • 因为不依赖 font-size 的尺寸,所以不用担心某些原因的 html 的 font-size 尺寸被篡改,导致页面尺寸混乱
  • vw 更加语义话,1vw 相当于 1/100 viewport 的大小
  • rem 事实上作为一种过渡的方案,它利用的也是 vw 的思想

px 与 vw 的单位转换

方案一:手动换算

比如屏幕尺寸为 375px,元素大小为 150px,我们需要将 150px 转换成对应的 vw 值:150 / 3.75=40

@vwUnit: 3.75;
.pxToVw(@px) {
  result: (@px / @vw) * 1vw
}
.box {
  width: .pxToVw(100)[result];
  height: .pxToVw(100)[result];
}
方案二: postcss-px-to-viewport-8-plugin
  • 和rem一样,在前端的工程化开发中,我们可以借助于webpack的工具来完成自动的转化
  • npm install postcss-px-to-viewport-8-plugin
方案三: VS Code 插件

px to vw 插件,在编写时自动转化:

H5高清适配方案

为什么需要高清适配

  • 高清:在不同 dpr 的高清屏幕下,不让页面看上去有模糊感、粗颗粒的劣质感
  • 适配:一份设计稿能同时支持在不同机型分辨率下带来的视觉变形、扭曲感(即希望一个元素在不同的屏幕上显示不同的大小以此来更好的还原效果图)

核心原理

根据 dpr,将 viewport 中 scale 改为 1/dpr, 将 px 逻辑像素处理成物理像素大小;同时根据设备宽度来动态设置Root font-size

/** dpr = 1 */
<meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,viewport-fit=cover">
/** dpr = 2 */
<meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=0.5,maximum-scale=0.5,minimum-scale=0.5,viewport-fit=cover">
/** dpr = 3 */
<meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=0.3333333333333333,maximum-scale=0.3333333333333333,minimum-scale=0.3333333333333333,viewport-fit=cover">

以 iphone X 为例

  1. 从设计稿(750px)读数,直接写到 css
  2. 经过构建后,css 单位变成:(px/100)rem
  3. 根据 css 高清方案:当设备宽度为 375px ,对应的下 font-size 为 50px (1rem = 50px)
  4. 在设备上呈现对应尺寸

常见移动端兼容问题

移动端 1px 适配

在做移动端开发过程中,我们除了针对不同分辨率屏幕进行适配以外,通常还会经常遇到 1px 细线问题。

产生原因

视觉稿上的 1px 物理像素,当 dpr=2 时,物理像素应该为 0.5px,但是这种小数写法只在 ios 8+支持,部分旧安卓系统不支持,因此不同机型分辨率下可能 会出现 1px 变粗 或者直接消失不见的问题;

解决办法

使用伪元素+transform:

.line {
  position: relative; /* 相对定位 */

  &::after {
    content: ' ';
    position: absolute;
    top: 0;
    left: 0;
    border: 1px solid #000;
    width: 300%; /* 宽度变成 dpr x 100% */
    height: 300%; /* 高度变成 dpr x 100% */
    transform: scale(0.33333); /* scale变成 1/dpr */
    transform-origin: left top; /* 更改元素的转换原点为左上角 */
  }
}

IOS 底部安全区域

产生原因

IOS 部分机型如 IPhoneX,会有底部小黑条

解决办法

使用 safe-area-inset-*

.safe-area-inset(@marginBottom: 0px) {
  margin-bottom: @marginBottom;
  margin-bottom: calc(@marginBottom + constant(safe-area-inset-bottom));
  margin-bottom: calc(@marginBottom + constant(safe-area-inset-bottom));
}
 

前端动画方案全面对比

2025年4月13日 16:47

问题:

之前用setTimeout实现了一个弹幕动画,动画跑着跑着画面越来越快,这是为什么?怎样解决?

问题原因分析

当使用 setTimeout 实现弹幕动画时出现越来越快的情况,通常由以下几个原因导致:

  1. 时间累积误差setTimeout 不能保证精确的时间间隔,每次执行可能有微小延迟,这些延迟会累积
  2. 回调执行时间:动画逻辑本身的执行时间会影响下一次调用的时机
  3. 事件循环机制setTimeout 受浏览器事件循环影响,优先级低于渲染等任务
  4. 未考虑帧同步:没有与屏幕刷新率同步,导致动画速度不稳定

怎样解决

  1. 对于现代浏览器,始终优先使用 requestAnimationFrame
  2. 如需支持旧浏览器,可使用以下polyfill:
window.requestAnimationFrame = window.requestAnimationFrame || 
  window.webkitRequestAnimationFrame || 
  window.mozRequestAnimationFrame ||
  function(callback) {
    window.setTimeout(callback, 1000 / 60);
  };
  1. 对于需要精确时间控制的动画,使用基于时间戳的计算方式
  2. 避免在动画中执行耗时操作,保持每帧执行时间在3-4ms以内

为什么requestAnimationFrame可以解决这个问题

1. 与浏览器刷新率硬同步

  • setTimeout 的问题
    即便设置为 16ms(模拟60fps),由于 JavaScript 单线程特性,setTimeout 回调可能被其他任务阻塞,导致实际执行间隔不稳定(可能变成 17ms、20ms 甚至更长)。这些延迟累积会让动画越来越快(因为代码中通常用固定步长位移,而非基于时间计算)。
  • requestAnimationFrame 的解决方案
    requestAnimationFrame 直接绑定到浏览器的渲染周期,在每次屏幕刷新前执行(通常严格保持 16.7ms/帧,60Hz 屏幕)。浏览器会智能合并 requestAnimationFrame 回调,确保动画与硬件刷新率同步,避免时间漂移。

2. 基于时间戳的自动补偿

  • requestAnimationFrame 的回调函数会自动接收一个 timestamp 参数(高精度时间戳),开发者可以用它计算真实的时间差,实现帧率无关的动画:

    let lastTime;
    function animate(timestamp) {
      if (!lastTime) lastTime = timestamp;
      const deltaTime = timestamp - lastTime; // 实际经过的时间
      lastTime = timestamp;
      
      // 根据 deltaTime 计算位移(避免固定步长导致的加速)
      element.style.left = (element.offsetLeft + speed * (deltaTime / 16.67)) + 'px';
      
      requestAnimationFrame(animate);
    }
    

    即使某帧延迟,deltaTime 也会按实际时间调整位移量,保持速度恒定。


3. 后台自动休眠

  • setTimeout 的问题
    即使页面隐藏,setTimeout 仍会继续执行,导致不必要的计算和电量消耗。
  • requestAnimationFrame 的解决方案
    当页面不可见(如切换标签页或最小化),浏览器会自动暂停 rAF 回调,恢复可见时继续执行。这既节省资源,又避免了不可见时的动画逻辑堆积(堆积的 setTimeout 回调会在页面恢复时集中执行,导致动画瞬间跳跃)。

4. 浏览器级优化

  • requestAnimationFrame 的优先级高于 setTimeout,浏览器会优先调度动画相关的渲染任务。
  • 对连续多个 requestAnimationFrame 调用,浏览器会合并处理,避免冗余计算(例如快速连续调用 requestAnimationFrame 时,浏览器可能只执行一次回调)。

对比代码示例

❌ setTimeout 的问题实现(会加速)
let pos = 0;
function move() {
  pos += 2; // 固定步长,时间漂移时必然加速
  element.style.left = pos + 'px';
  setTimeout(move, 16); // 无法保证严格 16ms
}
move();
✅ requestAnimationFrame 的正确实现
let pos = 0, lastTime;
function move(timestamp) {
  if (!lastTime) lastTime = timestamp;
  const deltaTime = timestamp - lastTime;
  lastTime = timestamp;
  
  pos += 2 * (deltaTime / 16.67); // 根据实际时间调整步长
  element.style.left = pos + 'px';
  
  requestAnimationFrame(move);
}
requestAnimationFrame(move);

总结表

问题根源 setTimeout 的表现 requestAnimationFrame 的解决方案
时间不同步 延迟累积导致动画加速 严格同步屏幕刷新率
后台资源浪费 隐藏页面仍执行动画 自动暂停回调
位移计算不精确 固定步长导致速度不稳定 基于时间戳动态计算位移
优先级低 可能被其他任务阻塞 浏览器优先调度

结论requestAnimationFrame 是专为动画设计的 API,从底层解决了 setTimeout 的时间同步问题,是现代 Web 动画的首选方案。

目前端动画实现方案概览

目前前端实现动画主要有以下几种方案:

  1. CSS 动画/过渡transitionanimation
  2. JavaScript 定时器setTimeoutsetInterval
  3. requestAnimationFrame:浏览器专为动画提供的API
  4. Web Animations API:较新的原生动画API
  5. 动画库:GSAP、Anime.js、Velocity.js等
  6. Canvas/SVG 动画:通过绘图API实现

requestAnimationFrame 详解

官方解释

window.requestAnimationFrame()  方法会告诉浏览器你希望执行一个动画。它要求浏览器在下一次重绘之前,调用用户提供的回调函数。

对回调函数的调用频率通常与显示器的刷新率相匹配。虽然 75hz、120hz 和 144hz 也被广泛使用,但是最常见的刷新率还是 60hz(每秒 60 个周期/帧)。为了提高性能和电池寿命,大多数浏览器都会暂停在后台选项卡或者隐藏的 <iframe> 中运行的 requestAnimationFrame()

原理

  1. 与浏览器刷新率同步:通常以 60fps (每16.7ms执行一次) 的速率执行,与屏幕刷新率保持一致
  2. 自动暂停:当页面不可见或最小化时,动画会自动暂停,节省 CPU/GPU 资源
  3. 浏览器优化:浏览器会合并多个 rAF 请求,进行统一处理

优势

对比 setTimeout/setInterval

  1. 更精确的时机控制:与屏幕刷新同步,避免丢帧
  2. 更高效:浏览器会优化执行,页面不可见时暂停
  3. 更省电:非活动页面自动停止动画

对比 CSS 动画

  1. 更灵活的控制:可以处理复杂逻辑和交互
  2. 更丰富的效果:可以实现 CSS 难以表达的动画
  3. 更好的性能监控:可以精确控制每一帧

各方案对比表格

对比维度 CSS 动画/过渡 JavaScript 定时器 requestAnimationFrame Web Animations API GSAP等动画库
实现复杂度 简单 中等 中等 中等 简单(API友好)
控制精度 低(关键帧之间) 最高
性能 高(浏览器优化) 低(可能丢帧) 最高(与渲染同步) 高(优化过)
资源消耗 中-高 中(库体积)
兼容性 好(需前缀) 极好 好(IE10+) 一般(较新浏览器) 好(兼容处理)
适用场景 简单UI动画 简单定时任务 复杂交互动画 复杂动画 专业复杂动画
能否暂停/继续 可以 可以 可以 可以 可以
时间控制 有限 精确 非常精确 精确 非常精确
GPU加速
代码示例 [见下方] [见下方] [见下方] [见下方] [略]

react-router-manage企业级路由方案使用文档

作者 Shinpei
2025年4月13日 16:41

react-router-manage

功能简介

react-router-manage基于react-router v6版本实现,通过配置可实现路由的鉴权、路由守卫、路由的增删改查等功能。由于react-router v5升级到v6有较大的成本,react-router-manage提供了原来 v5 部分的 api 用于兼容使用 v5 的项目,用于平滑升级 v6

  • 🛠 [config router] - 集中配置路由,快捷、方便管理。
  • + [addRoutes] - 动态增加路由:可使用 hookuseAddRoutes添加路由,自动刷新视图。
  • ➖ [removeRoutes] - 动态删除路由:可使用 hookuseRemoveRoutes删除路由,自动刷新视图。
  • 🖇 [updateRoutes] - 动态修改路由:可使用 hookuseUpdateRoutes修改路由。
  • 🔐 [permission] - 权限控制:配置路由的 code,自动管理路由的权限
  • 👨‍✈️‍ [Route guard] - 提供路由进入时的回调 beforeEnter and beforeEachMount, 路由离开时的钩子 useBeforeLeave
  • 🌲 [navigation] - 层级导航:支持层级导航,自动实现父子级路由的导航栏生成,例如面包屑、菜单导航

安装


npm install react-router-manage --save

示例

配置项

routerConfig

路由的全局配置

字段名 说明 类型 是否必填
basename 路由的路由前缀 string 非必填,默认 /
routes 路由的层级配置 RouteTypeI[] 必填
beforeEachMount 每个路由在渲染前调用 (to: RouteTypeI | undefined, next: ({path?: string; name: string} | React.ComponentType<any>) => void): void 非必填
autoDocumentTitle 文档的 title 会根据路由切换而改变 boolean | (RouteTypeI[]) => string 非必填, 默认 false
LoadingComponent 用于 Suspense 加载异步组件时配置 fallback 或在有 beforeEnter 钩子的next时,显示加载中 React.FunctionComponent not required
路由模式

路由模式目前有两种

  • history模式
  • hash模式

一个简单的全局配置(history模式

import React from 'react';
import { MRouter, defineRouterConfig } from 'react-router-manage';

const routerConfig = defineRouterConfig({
  basename: '/',
  routes: [{...}], // 请查看下方路由配置 routes
  // autoDocumentTitle: true, // 设置true,会自动设置变换document.title,
  // autoDocumentTitle: (routes) => return `网易云商-${routes.map((i) => i.title).join('-')}`, // 自定已配置document.title的设置

  // beforeEachMount: (to, next) => { // 配置全局的路由进入守卫,可查看下方全局路由守卫介绍
  //   console.log(to)
  //   next();
  // }
})

function App () {
  return (
    <MRouter routeConfig={routeConfig}>
      {(children) => children}
    </MRouter>
  )
}

一个简单的全局配置(hash模式

import React from 'react';
import { MHRouter, defineRouterConfig } from 'react-router-manage';

const routerConfig = defineRouterConfig({
  basename: '/',
  routes: [{...}], // 请查看下方路由配置 routes
  // autoDocumentTitle: true, // 设置true,会自动设置变换document.title,
  // autoDocumentTitle: (routes) => return `网易云商-${routes.map((i) => i.title).join('-')}`, // 自定已配置document.title的设置

  // beforeEachMount: (to, next) => { // 配置全局的路由进入守卫,可查看下方全局路由守卫介绍
  //   console.log(to)
  //   next();
  // }
})

function App () {
  return (
    <MHRouter routeConfig={routeConfig}>
      {(children) => children}
    </MHRouter>
  )
}

路由配置 routes

字段名 说明 类型 是否必填
name 路由的名称, 名称全局唯一、不能重复,用于获取路由 string 必填
path 路由的路径,组合后的完整路径全局唯一、不能重复,但是如果是嵌套的子路由,可以不配置, 相当于Route组件中设置index属性 string 必填
title 路由的中文名称,显示的名称,用于自动生成导航和面包屑中 string 非必填
index 同级有多个路由,会找带 index 的路由作为进入的路由 boolean 非必填
component 路由匹配的组件, 如果没有配置,则会跳到下一级有权限的路由 React.Component | React.FunctionComponent 非必填
items 视觉上的子级路由,用于导航时的父子级关系,实际为同一级路由 RouteTypeI[] 非必填
children 子级路由、在 v6 版本中渲染在 Outlet组件中 RouteTypeI[] 非必填
props 渲染组建时候会自动注入 Props 里面的内容, <Component {...props}/> Record<string, any> 非必填
hidden 导航的显示与隐藏 boolean 非必填, 默认 false
code 用于权限校验,会对比permissionList里的值 string| string[]| (route: RouteTypeI) => boolean 非必填,默认无
redirect 路由重定向到指定路由,优先级高于 component string 非必填,默认无
beforeEnter 渲染该路由钱调用的方法,如果调用next中传入了组件,则会渲染该组件,路由配置的组件则不会渲染 (to: RouteTypeI | undefined, next: (options?: {name?: string; path?: string} | React.ComponentType<any>) => void): void 非必填, 默认无
beforeLeave 离开路由前调用的回调,需主动调用next (to: RouteTypeI | undefined,from: RouteTypeI | undefined, next: () => void): void 非必填
meta 一些自定义的信息可以放这里,currentRoute.meta可以获取到该字段 Record<string, any> 非必填
fullscreen 是否全屏,在base-layout-router中监测到当前route fullscreentrue,则会隐藏导航栏 boolean 非必填,默认无
icon 用于显示导航的 icon string 非必填, 默认无
type 如果typenull字符串,则此路由不会真正渲染,但是可以设置正确的 currentRoute real | null 非必填,默认 real
bredcrumbs 用于配置路由中面包屑的配置, antd-breadcrumbs BreadcrumbsI 非必填
BreadcrumbsI
字段名 说明 类型 是否必填
isRoot 是否是面包屑的根节点,如果是,则从下一级开始算 boolean false
text 面包屑的名称,如果不配置,则默认使用route.title string | React.ReactNode | (route: RouteTypeI) => React.ReactNode 非必填
hidden 是否隐藏本级面包屑显示 boolean false

注意事项

  • 如果在code里配置了一个函数,由于在路由初始化会批量调用,请不要进行异步调用,如果需要建议使用beforeEnter达到同样效果
  • 如果父级路由没有配置 component, 跳转到该路由则会寻找 items,children 下第一个有权限的路由,若找不到,则会显示无权限页面
  • 如果redirectcomponent同时进行了配置,则component会被忽略
  • beforeEnterbeforeEachMountnext可传入一个组件,若传入则会渲染该组件, 如果在 react 使用严格模式,则函数可能会调用两次,这个是正常情况
items 与 children

通过 ys-router,你可以使用children, items配置来表达路由导航的父子关系。

items 同一级别的路由,父级与子级渲染一个

/**
 *  文章列表页和文章详情页在不同的页面
 *  /user/article/list  文章列表页面
 *  /user/article/detail 文章详情页
 * */
/user/article/list                     /user/article/detail
+------------------+                  +-----------------+
| +--------------+ |                  | +-------------+ |
| | ------------ | |  +------------>  | | content     | |
| | ------------ | |                  | |             | |
| | ------------ | |                  | |             | |
| | ------------ | |                  | |             | |
| +--------------+ |                  | +-------------+ |
+------------------+                  +-----------------+

一个有基本路由配置的示例

import React from "react";
import { MRouter, defineRouterConfig } from "react-router-manage";

const Users = () => {
  return <div>Users</div>;
};
const Profile = () => {
  return <div>Profile</div>;
};

const appRouterConfig = defineRouterConfig({
  basename: "/",
  // 配置层级导航
  routes: [
    {
      name: "user", // 每个路由对应一个全局唯一的name
      path: "user", // 路径会自动在内部转换为 /user, 由于这里没有配置component,进入 /user 会重定向到 /user/list
      title: "用户中心", // 路由名字
      items: [
        // items用于配置具有层级结构的导航,例如面包屑导航,
        {
          name: "userList",
          path: "list", // 路径会自动在内部转换为 /user/list
          component: Users,
          title: "用户列表"
        },
        {
          name: "profile",
          path: "profile", // 路径会自动在内部转换为 /user/list
          component: Profile,
          title: "个人中心"
        }
      ]
    }
  ]
});

function App() {
  return <MRouter routeConfig={routeConfig}>{children => children}</MRouter>;
}
children 嵌套路由

一些应用程序的 UI 由多层嵌套的组件组成。在这种情况下,URL 的片段通常对应于特定的嵌套组件结构,例如:

/**
 * 文章列表页和文章详情页在相同的页面
 *  /user/article/list  文章列表页面
 *  /user/article/detail 文章详情页
 * */
/user/article/list                     /user/article/detail
+------------------+                  +-----------------+
| user             |                  | user          |
| +--------------+ |                  | +-------------+ |
| |     list     | |  +------------>  | | content     | |
| | ------------ | |                  | |             | |
| | ------------ | |                  | |             | |
| | ------------ | |                  | |             | |
| +--------------+ |                  | +-------------+ |
+------------------+                  +-----------------+

一个有嵌套路由配置的示例

import React from "react";
import Outlet from "react-router";
import { MRouter, defineRouterConfig } from "react-router-manage";

const Users = () => {
  return (
    <div>
      <div>Users</div>
      <Outlet />
    </div>
  );
};
const Profile = () => {
  return <div>Profile</div>;
};

const UserProfile = () => {
  return <div>UserProfile</div>;
};
const UserArticles = () => {
  return <div>UserArticles</div>;
};

const appRouterConfig = defineRouterConfig({
  basename: "/",
  // 配置层级导航
  routes: [
    {
      name: "user", // 每个路由对应一个全局唯一的name
      path: "user", // 路径会自动在内部转换为 /user, 由于这里没有配置component,进入 /user 会重定向到 /user/list
      title: "用户中心", // 路由名字
      items: [
        // items用于配置具有层级结构的导航,例如面包屑导航,
        {
          name: "userList",
          path: "list", // 路径会自动在内部转换为 /user/list
          component: Users,
          title: "用户列表",
          children: [
            {
              name: "userProfile",
              path: "profile",
              title: "用户信息",
              component: UserProfile
            },
            {
              name: "userArticle",
              path: "article",
              title: "用户文章列表",
              component: UserArticles
            }
          ]
        },
        {
          name: "profile",
          path: "profile", // 路径会自动在内部转换为 /user/list
          component: Profile,
          title: "个人中心"
        }
      ]
    }
  ]
});

function App() {
  return <MRouter routeConfig={routeConfig}>{children => children}</MRouter>;
}

权限路由

权限permissionMode支持两种模式 parent adn children, 默认是parent

  • 如果 permissionModeparent,如果父路由没有权限,那么子路由都没有权限
  • 如果 permissionModechildren,如果子路由有权限,那么父路由不管配置的有无权限,都会自动转为有权限

配置字符串 code 的批量校验

  • 需要在MRouter组件中传入permissionList,并设置 hasAuthtrue, 默认为 true
  • 需要在路由配置中配置 code,如果不配置,则默认有权限

一个鉴权配置的示例

const permissionList = [`admin`, "staff"]; // 代表当前用户是admin
// const permissionList = ['staff'] // 代表当前用户是员工

const appRouterConfig = defineRouterConfig({
  basename: "/",
  // 配置层级导航
  routes: [
    {
      name: "user", // 每个路由对应一个全局唯一的name
      path: "user", // 路径会自动在内部转换为 /user, 由于这里没有配置component,进入 /user 会重定向到 /user/list
      title: "用户中心", // 路由名字
      code: [`admin`, "staff"],
      items: [
        // items用于配置具有层级结构的导航,例如面包屑导航,
        {
          name: "userList",
          path: "list", // 路径会自动在内部转换为 /user/list
          component: Users,
          title: "用户列表",
          code: "admin" // 此路由如果是是员工,则会被过滤
          // code: (currentRoute) => {
          //   // 也可以在这里进行自定义的校验,
          //   // 不要在这里进行校验,因为这里是再初始化时进行批量的校验,如果要实现进入该路由才校验,请使用 beforeEachMount
          //   return getHasAuth(currentRoute);
          // }
        },
        {
          name: "profile",
          path: "profile", // 路径会自动在内部转换为 /user/list
          component: Profile,
          title: "个人中心",
          code: [`admin`, "staff"] // 都有个人中心
        }
      ]
    }
  ]
});

// hasAuth 可以不配置,默认为true
function App() {
  return (
    <MRouter routeConfig={routeConfig} permissionList={permissionList} hasAuth={true} permissionMode="parent">
      {children => children}
    </MRouter>
  );
}

beforeEachMount 的路由鉴权

修改上述的 appRouterConfig

const NoAuth = () => {
  return <div>无权限</div>;
};
const appRouterConfig = defineRouterConfig({
  basename: "/",
  // 配置层级导航
  routes: [
    {
      name: "user", // 每个路由对应一个全局唯一的name
      path: "user", // 路径会自动在内部转换为 /user, 由于这里没有配置component,进入 /user 会重定向到 /user/list
      title: "用户中心", // 路由名字
      items: [
        // items用于配置具有层级结构的导航,例如面包屑导航,
        {
          name: "userList",
          path: "list", // 路径会自动在内部转换为 /user/list
          component: Users,
          title: "用户列表"
        },
        {
          name: "profile",
          path: "profile", // 路径会自动在内部转换为 /user/list
          component: Profile,
          title: "个人中心"
        }
      ]
    }
  ],
  beforeEachMount: (to, next) => {
    if (to.name === "userList") {
      next();
    } else {
      next(NoAuth);
    }
  }
});

导出的 hooks

hooks 名 类型 用途
useAddRoutes () => (routes: RouteTypeI[]) => void 动态添加路由
useUpdateRoutes () => (routes: { routeName: string; routeData: Partial<RouteTypeI> }[]) => void 动态更新路由
useRemoveRoutes () => (routeNames: string[]) => void 动态删除路由
useBeforeLeave (fn: BeforeLeaveI, options: {beforeunload?: ((event?: Event) => any)}) => void 路由离开时的守卫,需调用 next 才可以正常跳转
useRouter () => RoutesStateStruct 路由存储的一些状态
useHistory () => BrowserHistory 获取historyreact-router v6 没有暴露,用户 v5 升级 v6 的平滑过度, 不推荐使用

路由导航 useRouter

useRouter可在组件中获取各类状态数据

useRouter() 返回的 state:RoutesStateStruct

字段名 说明 类型
currentRoute 当前路由对象 RouteTypeI
routesMap 所有的路由 name,path 对应的路由都存储在这个对象中 Record<string, RouteTypeI>
navigate 用于跳转路由 (to: string, {query: Record<string, any>; params: Record<string, any>; state: any}) => void}
authRoutes 认证后有权限的路由对象 RouteTypeI[]
routes 传入的路由对象 routes RouteTypeI[]
query 当前地址栏查询参数 Record<string, string>
params 当前地址栏动态路由查询参数 Record<string, string>
navigate

useRouter 返回的 navigate 是在 react-routeruseNavigate上进行的扩展,对路由的跳转做了一些拦截处理,所以大家不要使用react-router中的userNavigate

navigate 有两个参数,第一个参数为要跳转的路径,第二个参数为跳转的路由配置, 类型如下

(to: string, {query: Record<string, any>; params: Record<string, any>; state: any}) => void}

  • query, 在跳转路由的时候会自动把查询参数添加到地址中,例如 navigate('/user/detail', { query: {id: 13}}), 会在跳转的时候转为 /user/detail?id=13
  • params, 当配置了带有参数的动态路由,会自动替换, 例如 navigate('/user/detail/:id', { params: {id: 13}}), 会在跳转的时候转为 /user/detail/13
  • state, 这个是 history 原始的 state

currentRoute

currentRoute 包含配置的时候传入的路由信息,内部会自动添加 parent`` 用来标识父级路由, 例如: parentRoute = currentRoute.parent`

...
import { useRouter } from 'react-router-manage'
...

function Item() {
  const { currentRoute, routesMap } = useRouter();

  const onClick = () => {
    navigate(routesMap.LIST.path); // navigate接收一个字符串
  }
  return (
    <div><Button onClick={onClick}>跳转到LIST</Button></div>
  )
}

useBeforeLeave 组件中的路由守卫

useBeforeLeave 需要调用 next 才可以正常跳转

import { useBeforeLeave, useRouter } from 'react-router-manage';
import { Modal } from 'ppfish';

const function Item() {
  const {navigate, routesMap} = useRouter();
  useBeforeLeave((to, from, next) => {
    Modal.confirm({
      title: '您确定要跳转吗?',
      onOk: () => {
        next();
      }
    })
  })
  const onClick = () => {
    navigate(routesMap.List.path);
  }
  return (<div>
    <Button onClick={onClick}>跳转</Button>
  </div>)
}

动态路由

useAddRoutes

useAddRoutes 添加路由

const AddRoutesWrapComponent = ({ children }) => {
  const addRoutes = useAddRoutes();

  useEffect(() => {
    addRoutes([
      {
        parentName: "PAGE1", // 需传入parentName,不传则会插入到第一层级下
        title: "动态添加的页面",
        name: "add",
        path: "add",
        component: Page,
        code: "staff"
      }
    ]);
  }, []);
  return <div data-testid="__router-children">{children}</div>;
};
useUpdateRoutes

useUpdateRoutes 更新路由

const UpdateRoutesWrapComponent = ({ children }) => {
  const updateRoutes = useUpdateRoutes();

  useEffect(() => {
    updateRoutes([
      {
        routeName: "PAGE1",
        routeData: {
          title: "修改后的页面" // 修改title
        }
      }
    ]);
  }, [updateRoutes]);
  return <div data-testid="__router-children">{children}</div>;
};
useRemoveRoutes

useRemoveRoutes 删除路由

const RemoveRoutesWrapComponent = ({ children }) => {
  const removeRoutes = useRemoveRoutes();

  useEffect(() => {
    removeRoutes(["PAGE1"]); // 传入要删除的 route的name字段
  }, []);
  return <div data-testid="__router-children">{children}</div>;
};

路由守卫

全局路由守卫

| 名称 | 说明 | 类型 | | ----------------- | ---------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------- | | beforeEachMount | 在每一个路由渲染之前调用, next必须调用才会渲染组件 | (to: RouteTypeI \| undefined, next: {name?: string; path?: string} | React.ComponentType<any>) =void |


import NoAuth from './NoAuth', // 无权限组件

const appRouterConfig = {
    basename: '/',
    routes: [
        {
            name: 'root',
            title: '根路径',
            path: '/',
            items: [
                {
                    name: 'page1',
                    title: '页面1',
                    path: 'page1',
                    components: Page,
                    custom: 'aaa',
                },
                {
                    name: 'page2',
                    title: '页面2',
                    path: 'page2',
                    components: Page2,
                    custom: 'bbb',
                }
            ]
        }
    ],
    beforeEachMount(to, next) {
        if (to.custom === 'aaa) {
            next(); // 调用,则会正常渲染该路由对应的组件
        } else {
            next(NoAuth) // 则渲染无权限组件
        }
    }
}

局部路由守卫

名称 说明 类型
beforeEnter 在当前路由渲染之前调用(在beforeEachMount之后), next 必须调用才会渲染组件 (to: RouteTypeI | undefined, next: {name?: string; path?: string} | React.ComponentType<any>): void
beforeLeave 离开路由前调用的回调, 需主动调用next才会正常跳转 (to: RouteTypeI | undefined,from: RouteTypeI | undefined, next: {name?: string; path?: string} | React.ComponentType<any>): void
import NoAuth from './NoAuth', // 无权限组件

const appRouterConfig = {
    basename: '/',
    routes: [
        {
            name: 'root',
            title: '根路径',
            path: '/',
            items: [
                {
                    name: 'page1',
                    title: '页面1',
                    path: 'page1',
                    components: Page,
                    custom: 'aaa',
                    beforeEnter: (to, next) => {
                        //...
                        next(); // 需要跳转则调用
                    },
                    beforeLeave: (to, next) => {
                        //...
                        next(); // 需要跳转则调用
                    }
                },
                {
                    name: 'page2',
                    title: '页面2',
                    path: 'page2',
                    components: Page2,
                     custom: 'bbb',
                }
            ]
        }
    ],
    beforeEachMount(to, next) {
        if (to.custom === 'aaa) {
            next(); // 调用,则会正常渲染该路由对应的组件
        } else {
            next(NoAuth) // 则渲染无权限组件
        }
    }
}

打算开发的内容

  • KeepAlive 的支持
  • 示例代码的完善
  • 路由切换过渡动画

Vue3源码解析(三):如何代理Set和Map数据结构

作者 小航哥Sir
2025年4月13日 16:40

前言

本节介绍Vue3源码是如何拦截Set和Map数据结构的,参考《Vue.js设计与实现》Vue3的源码。如下给出了回答简介版

简洁版

  1. vue3源码如何代理Set和Map数据结构?
  • 还是利用Set和Map,在get拦截函数中拦截访问操作,在set拦截函数中拦截设置操作
  • 只是代理Set和Map时会出现一些问题,所以还是对Set和Map的方法进行了重新拦截设置
  1. 代理set时,如何避免访问size属性报错?
  • Proxy包装后,会去代理对象身上找size属性,但是代理对象身上没有,就会报错
  • 通过Reflect.get(target, key, target)修改this指向,当访问size属性时去原始对象身上找
  1. vue3源码如何避免污染原始数据
  • 如果把原始对象设置到代理对象身上,会造成访问原始对象也触发响应
  • 这时需要在set函数触发时,通过raw属性获取到原始值,设置给target对象
  • 不管是Map数据结构的set还是Set数据结构的add
  1. 如何拦截forEach操作
  • 使用Proxy进行拦截,当访问forEach方法时会触发get拦截函数进行依赖收集,并对forEach方法进行重写
  • 当map的响应式数据发生新增、删除、修改值时,会触发forEach对应的依赖
  • 使用wrap函数对遍历时访问到的值和键进行递归处理
  • 使用ITERATE_KEY这个Symbol类型的key来作为追踪标识
  1. 如何拦截for of协议、entries、values、keys
  • 部署了Symbol.iterator方法,才可以被for of方法遍历
  • 由于代理对象proxy上没有这个方法,所以必须要重写来执行原始对象身上自己的这个方法,并返回Symbol.iterator方法。注意,这几个方法的拦截都需要重写
  • 如果要拦截entries,还需要重写的对象中返回next迭代器协议。注意可迭代协议是Symbol.iterator方法,迭代器协议是next方法,这两个不一样!
  • values和keys需要调用原始对象身上对应的values方法和keys方法。for of, entries, values都需要在新增 设置 删除触发,keys只需要在新增和删除的时候触发,所以keys要设置新的symobl的key区分开

更多Vue源码文章:

1. Vue3 源码解析(一):响应式数据和副作用函数、计算属性原理、侦听器原理

2. Vue3源码解析(二):响应式原理,如何拦截对象

3. Vue3源码解析(三):响应式原理,如何拦截数组

4. Vue2源码解析(一):响应式原理,如何拦截对象

5. Vue2源码解析(二):响应式原理,如何拦截数组

1 代理Set和Map

代理Set和Map的方式与代理普通对象大体相同,在get时进行track依赖追踪,在set时进行trigger触发对应依赖,但是需要解决一些问题

1.1 解决访问Set的size属性报错

当使用Proxy代理Set,并且访问代理对象的size属性会报错

let set = new Set([1, 2, 3]);
let p1 = reactive(set, {});
effect(() => {
    console.log(p1.size, "p1.size");
});

image.png

报错原因是,访问p1.size属性时,其内部的this会指向p1,并且其内部会检测是否存在[[SetData]]内部槽,由于p1代理对象上不存在,只有Set原始对象上存在,所以会报错。

修改方式是:get访问器函数触发时,如果访问的size属性,在Reflect.get函数里面修改this指向为target,这样就会去原始对象身上找[[SetData]]内部方法,能够找到就解决了报错问题,

let proxy1 = new Proxy(set, {
    get(target, key, receiver) {
      if (key === "size") {
+        return Reflect.get(target, key, target);
      }
      return Reflect.get(target, key, receiver);
    },
});

1.2 解决调用set.delete()方法报错

当调用delete方法时报错:

p1.delete(1);

image.png

调用p1.delete调用方法不是访问属,不能像下面这样写,因为delete函数内部的this依然指向proxy代理对象

 let proxy1 = new Proxy(set, {
    get(target, key, receiver) {
+      if (key === "size" || key === "delete") {
        return Reflect.get(target, key, target);
      }
      return Reflect.get(target, key, receiver);
    },
  });

应该通过.bind方法修改函数内部调用的this

let proxy1 = new Proxy(set, {
    get(target, key, receiver) {
      if (key === "size") {
        return Reflect.get(target, key, target);
      }
+      return target[key].bind(target);
    },
});

现在能修改成功

image.png

修改createReactive函数

function createReactive(obj, isShallow = false, isReadonly = false) {
    return new Proxy(obj, {
      get(target, key, receiver) {
        if (key === "raw") {
          return target;
        }
        // 针对Set数据结构的拦截
        if (key === "size") {
          return Reflect.get(target, key, target);
        }
        // delete走这里
        return target[key].bind(target);
      },
    })
}

之前的调用改为这样

let set = new Set([1, 2, 3]);
+let proxy1 = reactive(set);
effect(() => {
    console.log(proxy1.size, "proxy1.size");
});

2 建立响应连接

接下来目标是,当执行代理对象的.add.delete方法时,收集所有访问其size属性的副作用函数并执行

第一步,需要在get拦截函数里面,触发track收集依赖

get(target, key, receiver) {
    if (key === "raw") {
      return target;
    }
    if (key === "size") {
      // 触发拦截依赖
+      track(target, ITERATE_KEY);
      return Reflect.get(target, key, target);
    }
    return target[key].bind(target);
},

注意,track传入的key必须是ITERATE_KEY,之前在拦截数组for infor of遍历操作时,增加了这个Symbol键,当时是只要往数组里面增加值或者删除值都会触发这个键对应的副作用函数。现在也用这个键,只要往set里面增加或者删除值,都要触发size重新响应。

你可以这样理解,假设页面有一个模版访问了set.size,当你在js中往set里面增加了值,模版的size也要进行更新,他不涉及具体的key,所以用ITERATE_KEY这个key

接着我们声明一个对象,里面存储set会用到的方法

const mutableInstrumentations = {
    add(key) {
    },
};

修改get里面的拦截函数,在get拦截函数里面如果访问proxy.set或者proxy.delete,都会执行上面对象里面的方法。 注意,在对Proxy拦截时,这个get的return mutableInstrumentations[key];很关键,后续执行map或者set的任何属性或者方法,都会触发mutableInstrumentations[key]里面对应的函数

get(target, key, receiver) {
    if (key === "raw") {
      return target;
    }
    // 针对Set数据结构的拦截
    if (key === "size") {
      track(target, ``);
      return Reflect.get(target, key, target);
    }
    // 这里
+    return mutableInstrumentations[key];
},

add方法实现如下:

add(key) {
  // 通过.raw属性来访问原始对象
  const target = this.raw;
  // 判断值在不在
  const hadKey = target.has(key);
  // 使用原始对象执行add方法
  const res = target.add(key);
  // 触发trigger响应,指定操作类型为ADD
  if (!hadKey) {
    trigger(target, key, "ADD");
  }
  return res;
},
  • 因为在get拦截函数的末尾,使用的是return mutableInstrumentations[key];没有绑定bind函数,所以add里面的this还是指向proxy代理对象,
  • 直接通过this.raw获取原生对象,执行add操作,同时判断如果已经有这个值,不执行trigger函数触发副作用函数
  • trigger函数传入ADD类型,就会把ITERATE_KEY对应的副作用函数拿出来执行

delete方法的实现如下,和add几乎一样,只是执行target.delete,并且要判断有这个值,才能删除

delete(key) {
  // 通过.raw属性来访问原始对象
  const target = this.raw;
  // 判断值在不在
  const hadKey = target.has(key);
  // 使用原始对象执行delete方法
  const res = target.delete(key);
  // 值不在时才去触发trigger响应,指定操作类型为DELETE
  if (hadKey) {
    trigger(target, key, "DELETE");
  }
  return res;
},

image.png

3 避免污染原始数据

3.1 对Map的set和get方法拦截

如下先实现针对Map数据结构的get和set方法的拦截:

const mutableInstrumentations = {
    ……
    get (key) {
      const target = this.raw;
      const hadKey = target.has(key);
      // 追踪key建立响应响应的联系
      track(target, key);
      if (hadKey) {
        const res = target.get(key);
        // 如果res是对象,则继续执行reactive递归生成响应式数据
        return typeof res === "object" ? reactive(res) : res;
      }
    },
    set (key, value) {
      // 原始对象
      const target = this.raw;
      // 判断读取的key是否存在
      const had = target.has(key)
      // 旧值
      const oldVal = target[key]
      target.set(key, value)
      // 触发trigger响应,指定操作类型为ADD
      if (!had) {
        // 新增
        trigger(target, key, "ADD");
      } else if (oldVal !== value && (oldVal === oldVal && value === value)) {
        // 修改
        trigger(target, key, "SET");
      }
    }
};

get方法的逻辑和之前的has类似,需要注意:第一需要使用track进行依赖收集,第二如果拿到的结果是对象,则需要进行递归处理继续调用reactive方法

set方法中,需要判断是否有这个值,如果有操作类型就是SET,如果没有操作类型就是ADD

此时实现的效果是,当你在副作用函数里面访问对应的key时,之后在调用set方法,就会再次触发副作用函数

  let map = new Map([
    ['key', 1],
    ['name', 2],
  ]);
  let proxy = reactive(map);
  effect(() => {
    console.log(proxy.get('key'), "proxy1.key");
  });

我们在控制台操作效果如下,成功打印了key

image.png

3.2 数据污染

原始数据污染是指:当我们把响应式数据设置给原始数据对象时,对原始数据对象的修改也会触发响应。目前来看 Proxy的拦截会造成数据污染

let map2 = new Map();
let p1 = reactive(map2)
let p2 = reactive(map2)
// 为p1设置一个键值对
p1.set('p2', p2)
effect(() => {
    console.log(map2.get('p2').size, 'map2.get p2 . size');
})

如上,p1和p2都是基于map2生成响应式数据,我们进行的操作:

  • 第一,把p2作为值设置给p1;
  • 第二,访问原始对象的p2的size属性,这之后,当我们对原始map对象进行设置时,也触发了副作用函数:

image.png

原始对象变成了响应式数据,问题的原因如下:

set (key, value) {
  // 原始对象
  const target = this.raw;
  // 判断读取的key是否存在
  const had = target.has(key)
  // 旧值
  const oldVal = target[key]
+  // 这里把value响应式数据原封不动赋值给target了
+  target.set(key, value)
  // 触发trigger响应,指定操作类型为ADD
  if (!had) {
    // 新增
    trigger(target, key, "ADD");
  } else if (oldVal !== value && (oldVal === oldVal && value === value)) {
    // 修改
    trigger(target, key, "SET");
  }
}

应该这样解决:通过raw属性拿到响应式数据的原始值,并将原始数据赋值给target

set (key, value) {
  // 原始对象
  const target = this.raw;
  // 判断读取的key是否存在
  const had = target.has(key)
  // 旧值
  const oldVal = target[key]
  // target.set(key, value) // 这样赋值就是变量污染
+  const rawValue = value.raw || value
+  target.set(key, rawValue)
  // 触发trigger响应,指定操作类型为ADD
  if (!had) {
    // 新增
    trigger(target, key, "ADD");
  } else if (oldVal !== value && (oldVal === oldVal && value === value)) {
    // 修改
    trigger(target, key, "SET");
  }
}

如上,如果value是个响应式数据,那么通过.raw能够拿到原始数据。如下操作后就没有触发副作用函数

image.png

Set数据结构封装的add方法也会出现响应式数据:

let set = new Set([1,2,3])
let p3 = reactive(set)
let p4 = reactive(set)

image.png

应该如下修改:

add(key) {
  // 通过.raw属性来访问原始对象
  const target = this.raw;
  // 判断值在不在
  const hadKey = target.has(key);
  // 使用原始对象执行add方法
+  const rawKey = key.raw || key
+  const res = target.add(rawKey);
  // 触发trigger响应,指定操作类型为ADD
  if (!hadKey) {
    trigger(target, key, "ADD");
  }
  return res;
},

此时原始数据正常了

image.png

对应源码如下,我截取了片段

/packages/reactivity/src/collectionHandlers.ts

set(this: MapTypes, key: unknown, value: unknown) {
    if (!shallow && !isShallow(value) && !isReadonly(value)) {
+      value = toRaw(value)
    }
    // 获取原始值
    const target = toRaw(this)
    const { has, get } = getProto(target)

    let hadKey = has.call(target, key)
    if (!hadKey) {
      key = toRaw(key)
      hadKey = has.call(target, key)
    } else if (__DEV__) {
      checkIdentityKeys(target, has, key)
    }

    const oldValue = get.call(target, key)
+    target.set(key, value)
    if (!hadKey) {
      trigger(target, TriggerOpTypes.ADD, key, value)
    } else if (hasChanged(value, oldValue)) {
      trigger(target, TriggerOpTypes.SET, key, value, oldValue)
    }
    return this
},

4 处理forEach

拦截forEach的操作如下:

  • 需要将该对象和ITERATE_KEY绑定,因为任何修改map长度的操作都会影响forEach操作
const mutableInstrumentations = {
    ……
    forEach (callback) {
        const target = this.raw
        // 与原始值建立响应练习
+        track(target, ITERATE_KEY)
        // 执行原始值的forEach,将回调传过去
        target.forEach(callback)
    }
};

测试代码如下:

  let map = new Map([
    ['key', 1],
    ['name', 2],
  ]);
  let p1 = reactive(map);
  effect(() => {
    p1.forEach(function (value, key) {
        console.log(value, 'forEach触发了 value');
        console.log(key, 'forEach触发了 value');
    })
  });

image.png

4.1 递归处理响应值

当执行p1.get(key).delete(2)时并不会触发副作用函数执行,因为value是Set数据结构,是一个原始数据类型,访问value.size无法建立响应链接

let key = {key: 1}
let value = new Set([1,2,3])
let map = new Map([
    [key, value],
]);
let p1 = reactive(map);
effect(() => {
    p1.forEach(function (value, key) {
        console.log(value.size, '副作用函数 value.size');
    })
});
p1.get(key).delete(2)

在forEach拦截函数中,将数据深层次递归,转化为响应式。并将最新的this传递过去

+forEach (callback, thisArg) {
+    const wrap = (val) => typeof val === 'object' ? reactive(val) : val
    const target = this.raw
    // 与原始值建立响应练习
    track(target, ITERATE_KEY)
    // 执行原始值的forEach,将回调传过去
+    target.forEach((v, k) => {
+        callback.call(thisArg, wrap(v), wrap(k), this)
+    })
}

image.png

4.2 区分for in和 forEach遍历

  1. for in遍历只关心键,只有对象数量发生变化,新增和删除才会触发对应副作用函数,修改不会
  2. forEach会访问值,所以当SET操作触发修改值,也应该要触发forEach对应的副作用函数

测试数据如下:

let map = new Map([
    ['a', 1],
    ['b', 2],
]);
let p1 = reactive(map);
effect(() => {
    p1.forEach(function (value, key) {
        console.log(value, '副作用函数 value');
    })
});

此时修改'a'的值并没有触发forEach遍历, image.png

trigger函数是这样修改:

  • 判断是SET操作,并且是Map数据结构,则应该也要触发对应的副作用函数
function trigger(target, key, type, newVal) {
    let depsMap = bucket.get(target);
    if (!depsMap) return;
    // 取得与key相关联的副作用函数
    const effects = depsMap.get(key);
    const effectsToRun = new Set();
    // 将与key相关联的副作用函数添加到effectsToRun
    effects &&
      effects.forEach((effectFn) => {
        if (effectFn !== activeEffect) {
          effectsToRun.add(effectFn);
        }
      });

    // 操作类型是ADD的时候,把length对应的副作用函数取出来,加入到effectsToRun中拿出来执行
    if (Array.isArray(target) && type === "ADD") {
      const lengthOfEffects = depsMap.get("length");
      lengthOfEffects &&
        lengthOfEffects.forEach((effectFn) => {
          if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn);
          }
        });
    }
+    if (type === "ADD" || type === "DELETE" || (type ===  'SET' && +Object.prototype.toString.call(target) === '[object Map]')) {
      // 取得与ItERATE_KEY关联的副作用函数
      const iterateEffects = depsMap.get(ITERATE_KEY);
      // 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun
      iterateEffects &&
        iterateEffects.forEach((effectFn) => {
          if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn);
          }
        });
    }
    // 如果操作目标是数组,并且修改了数组的key属性
    if (Array.isArray(target) && key === "length") {
      depsMap.forEach((effects, effectKey) => {
        if (effectKey >= newVal) {
          effects.forEach((effectFn) => {
            if (effectFn !== activeEffect) {
              effectsToRun.add(effectFn);
            }
          });
        }
      });
    }
    effectsToRun &&
      effectsToRun.forEach((effectFn) => {
        if (effectFn.options && effectFn.options.scheduler) {
          effectFn.options.scheduler(effectFn);
        } else {
          effectFn();
        }
      });
  }

源码如下:

// packages/reactivity/src/dep.ts
switch (type) {
    case TriggerOpTypes.ADD:
      if (!targetIsArray) {
        run(depsMap.get(ITERATE_KEY))
        if (isMap(target)) {
          run(depsMap.get(MAP_KEY_ITERATE_KEY))
        }
      } else if (isArrayIndex) {
        // new index added to array -> length changes
        run(depsMap.get('length'))
      }
      break
    case TriggerOpTypes.DELETE:
      if (!targetIsArray) {
        run(depsMap.get(ITERATE_KEY))
        if (isMap(target)) {
          run(depsMap.get(MAP_KEY_ITERATE_KEY))
        }
      }
      break
+        case TriggerOpTypes.SET:
           // 如果是SET操作,并且是Map,执行run触发依赖
+          if (isMap(target)) {
+            run(depsMap.get(ITERATE_KEY))
+          }
+          break
  }
}

5. 迭代器方法

5.1 处理for of

Proxy直接拦截会报错

let map = new Map([
    ['a', 1],
    ['b', 2],
]);
let p1 = reactive(map);
effect(() => {
    for (const [key, value] of p1.entries()) {
        console.log(key, 'key');
        console.log(value, 'value');
    }
});

image.png

当使用for of遍历代理对象,会试图从代理对象身上找迭代器协议Symbol.iterator方法,找不到就报错了。应该重写该方法

[Symbol.iterator] () {
    // 1. 包装写法1
    // const target = this.raw
    // const itr = target[Symbol.iterator]()
    // return itr

    // 2. 包装写法2
    const target = this.raw
    const itr = target[Symbol.iterator]()
    const wrap = (val) => {
        return typeof val === 'object' && val !== null ? reactive(val) : val 
    }
    
    // 建立响应依赖联系
    track(target, ITERATE_KEY)
    
    return {
        next () {
            // itr.next()的结果是['a', 1]和['b', 2]
            const { value, done} = itr.next()
            return {
                // 非undefined则进行包裹
                value: value ? [wrap(value[0]), wrap(value[1])] : value,
                done
            }
        }
    }
}

测试复杂数据类型的值

let map = new Map([
    ['a', new Set([1,2,3])],
    ['b', new Set([1,2,3,4])],
]);
let p1 = reactive(map);
effect(() => {
    for (const [key, value] of p1) {
        console.log(value.size, 'value.size');
    }
});

能够触发响应:

image.png

5.2 处理entries

首先需要理解一点,在map中map2[Symbol.iterator]map2.entries是等价的:

image.png

但是直接拦截p1.entries会报错,如下:

let map = new Map([
    ['a', new Set([1,2,3])],
    ['b', new Set([1,2,3,4])],
]);
let p1 = reactive(map);
effect(() => {
    for (const [key, value] of p1.entries()) {
        console.log(value.size, 'value.size');
    }
});

image.png

原因在于,该返回值有next方法,有迭代器协议,但是没有可迭代协议。迭代器协议就是next方法,而可迭代协议是Symbol.iterator方法(数组、set、map都部署了)。

之前写法中返回了next方法,但是没有返回Symbol.iterator方法。

return {
+    next () { // 有next
        // itr.next()的结果是['a', 1]和['b', 2]
        const { value, done} = itr.next()
        return {
            // 非undefined则进行包裹
            value: value ? [wrap(value[0]), wrap(value[1])] : value,
            done
        }
    }
+   Symbol.iterator() {} // 还应该有这个
}

修改如下:

return {
    // 迭代器协议
    next () {
        // itr.next()的结果是['a', 1]和['b', 2]
        const { value, done} = itr.next()
        return {
            // 非undefined则进行包裹
            value: value ? [wrap(value[0]), wrap(value[1])] : value,
            done
        }
    },
    // 可迭代协议
+    [Symbol.iterator] () {
+        return this // this返回的是代理对象
+    }
}

备注:在set中,这两个是不等价的,如下图所示

image.png

5.3 处理values和keys方法

代理values方法,如下增加valueMethod方法,

function valueMethod () {
    const target = this.raw
+    const itr = target.values()
    const wrap = (val) => {
        return typeof val === 'object' && val !== null ? reactive(val) : val 
    }

    // 建立响应依赖联系
    track(target, ITERATE_KEY)

    return {
        // 迭代器协议
        next () {
            
            const { value, done} = itr.next()
            return {
                // 非undefined则进行包裹
+                value: wrap(value), // // value的值是1 2
                done
            }
        },
        // 可迭代协议
        [Symbol.iterator] () {
            return this
        }
    }
  }

拦截keys方法

function keyMethod () {
    const target = this.raw
+    const itr = target.keys()
    const wrap = (val) => {
        return typeof val === 'object' && val !== null ? reactive(val) : val 
    }

    // 建立响应依赖联系
    track(target, ITERATE_KEY)

    return {
        // 迭代器协议
        next () {
            // itr.next()的结果是['a', 1]和['b', 2]
            const { value, done} = itr.next()
            return {
                // 非undefined则进行包裹
+                value: wrap(value),
                done
            }
        },
        // 可迭代协议
        [Symbol.iterator] () {
            return this
        }
    }
}

修改拦截对象:

const mutableInstrumentations = {
    ……
    [Symbol.iterator]: iterationMethod,
+    entries: iterationMethod,
+    values: valueMethod,
+    keys: keyMethod
};

测试数据

  let map = new Map([
    ['a', 1],
    ['b', 2],
  ]);
  let p1 = reactive(map);
  effect(() => {
    for (const value of p1.values()) {
        console.log(value, 'value');
    }
  });
  effect(() => {
    for (const key of p1.keys()) {
        console.log(key, 'key');
    }
  });

效果:

image.png

5.4 解决keys存在的问题

目前来看,修改map的值也会触发keys对应的副作用函数执行,但是key的数量是没有增加或者减少的,不应该触发

image.png

解决方式是:

keys拦截用新的key

+const MAP_KEY_ITERATOR_KEY = Symbol()
function keyMethod () {
    const target = this.raw
    const itr = target.keys()
    const wrap = (val) => {
        return typeof val === 'object' && val !== null ? reactive(val) : val 
    }

    // 建立响应依赖联系
+    track(target, MAP_KEY_ITERATOR_KEY)

    return {
        // 迭代器协议
        next () {
            // itr.next()的结果是['a', 1]和['b', 2]
            const { value, done} = itr.next()
            return {
                // 非undefined则进行包裹
                value: wrap(value),
                done
            }
        },
        // 可迭代协议
        [Symbol.iterator] () {
            return this
        }
    }
}

只有新增和删除,执行这个key对应的副作用函数,这样当SET操作时不会触发keys拦截对应的副作用函数啦

function trigger(target, key, type, newVal) {
    ……
    // map的keys拦截,只有add和delete时才能触发,走这里
+    if ((type === "ADD" || type === "DELETE") && (Object.prototype.toString.call(target) +=== '[object Map]')) {
+      // 取得与ItERATE_KEY关联的副作用函数
+      const iterateEffects = depsMap.get(MAP_KEY_ITERATOR_KEY);
+      // 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun
+      iterateEffects &&
+        iterateEffects.forEach((effectFn) => {
+          if (effectFn !== activeEffect) {
+            effectsToRun.add(effectFn);
+          }
+        });
+    }
    // 如果是set类型则触发这里`ITERATE_KEY`
    if (type === "ADD" || type === "DELETE" || (type ===  'SET' && Object.prototype.toString.call(target) === '[object Map]')) {
      // 取得与ItERATE_KEY关联的副作用函数
      const iterateEffects = depsMap.get(ITERATE_KEY);
      // 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun
      iterateEffects &&
        iterateEffects.forEach((effectFn) => {
          if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn);
          }
        });
    }
    ……
}

请看源码

// packages/reactivity/src/collectionHandlers.ts
function createIterableMethod(
  method: string | symbol,
  isReadonly: boolean,
  isShallow: boolean,
) {
  return function (
    this: IterableCollections,
    ...args: unknown[]
  ): Iterable<unknown> & Iterator<unknown> {
    const target = this[ReactiveFlags.RAW]
    const rawTarget = toRaw(target)
    const targetIsMap = isMap(rawTarget)
    // 在这里区分entries values kyes方法
+    const isPair =
      method === 'entries' || (method === Symbol.iterator && targetIsMap)
+    const isKeyOnly = method === 'keys' && targetIsMap
    const innerIterator = target[method](...args)
    // 包装方法
+    const wrap = isShallow ? toShallow : isReadonly ? toReadonly : toReactive
    !isReadonly &&
      track(
        rawTarget,
        TrackOpTypes.ITERATE,
+        isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY, // 如果是keys方法,则执行MAP_KEY_ITERATE_KEY的key
      )
    // return a wrapped iterator which returns observed versions of the
    // values emitted from the real iterator
    return {
      // iterator protocol
      next() {
        const { value, done } = innerIterator.next()
        return done
          ? { value, done }
          : {
+              value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value), // 区分(entries)和(values、keys)
              done,
            }
      },
      // iterable protocol
+      [Symbol.iterator]() { // 封装的可迭代协议
        return this
      },
    }
  }
}

Reflection(Reflect对象 和 Proxy对象)

作者 FlowD
2025年4月13日 16:34

Reflection(Reflect 和 Proxy)

注意⚠️:Reflection是技术思想,Reflect是指es6实现的具体对象。

ReflectProxy 是成对设计的,它们一起用于拦截和操控Javascript的低层方法

为什么这样设计?

  1. Reflect提供了一致性:让你可以像调用函数一样使用各种低层操作(而不是语法糖,语法糖包含了太多默认行为是我们不可改变的) 例如:我们可以用 target[prop] 语法糖 获取 某个对象的属性,但是如果遇到getter函数,那么getter函数默认的this就是target对象,但是我们在Proxy代理时,我们希望getter函数执行时内部的this依然指向proxy实例(这样getter内部的访问行为就也可以被拦截到啦~),那我们就可以通过 Reflect.get(target, prop, receiver) 第三个参数 receiver 修改默认this指向,使其指向proxy。 image.png

  2. Proxy提供了可控性:让你可以插手这些低层操作,并决定是否、如何处理它们。

  3. 所以,Reflect是为了支持Proxy拦截机制而设计的。

当我们可以拦截修改JS操作时,其实就是触发了元编程的思想。那么元编程是什么?为什么需要元编程?

元编程

维基百科:元编程是一种编程技术思想,在这种技术中计算机程序可以将其他程序视为自己的一种数据。这意味着一个程序可以被设计用来读取、生成、分析、转换其他程序,甚至在运行时修改自身。

(反射)Reflection 就是一种元编程的实现机制,也可以说是元编程的分支之一,因为Reflection有以下特征:

  • 代码能够自我检查(通过Reflect)
  • 代码能够自我修改(通过Reflect)
  • 可以通过封装、陷阱、拦截来代替原来的对象。(通过Proxy+Reflect)

总之就是让程序在运行时获取或修改自身结构,如类、方法、属性

举个“不太恰当”的例子🌰:我们可以在函数上记录一些东西,在运行时去影响函数的执行。 image.png Reflect在函数身上记录的数据,可以被称为一种元数据(实际上只是一种普通数据),通过修改元数据 影响程序行为,可以称之为元编程注意:(直接用 eatWhich.which 设置修改属性其实也可以做到,但是实际开发中 这两种方式都不建议,都不足够可靠,因为这种所谓的“元数据”会污染对象本身。)

NestJS中的各种装饰器、控制反转Ioc的实现 就是基于 reflect-metadata一种第三方库做类似的行为:通过 Reflect.defineMetadata 给类和属性添加 元数据(真正的元数据 ——给程序看的,而不是给业务逻辑直接使用的)。这些元数据是通过一种“元数据表”存储,而不是直接存到对象属性上,更加可靠。 image.png

Reflect

Reflect推出目的是提供一组操作对象底层方法(类似 Object,但更一致、函数化)。 在 JavaScript 中,有很多底层操作(其实也算一种语法糖),比如:

  • 读取属性(obj.prop
  • 设置属性(obj.prop = value
  • 删除属性(delete obj.prop
  • 函数调用(func.apply(...)) 这些操作在 ES6 之前没有统一的“底层 API”,只能通过语法来完成。

Chrome插件开发:从零开始了解Chrome Extension

作者 那小孩儿
2025年4月13日 16:30

Chrome Extension官方开发文档:developer.chrome.com/docs/extens…

什么是Chrome Extension

首先给出一些官方定义和解释:

官方定义:Chrome 扩展程序可通过自定义界面、监控浏览器事件和修改网页来提升浏览体验。

如何构建:您可以使用创建 Web 应用时所用的 Web 技术构建扩展程序:HTMLCSS 和 JavaScript

可以做些什么:除了 Web API 之外,扩展程序还可以使用 Chrome 扩展程序 API 来执行不同的任务

综上,我觉得Chrome Extension其实就是一个由 HTML、CSS、JavaScript 和配置文件组成的包,通过Chrome 的扩展 API 与浏览器深度交互,完成一些正常web页面不能完成的功能,比如操作浏览器界面、后台任务处理,以及一些硬件交互等,所以能做什么,不能做什么,都是是扩展API决定的

基本组成结构

一个典型 Chrome 扩展包含以下核心组件:

  • manifest.json:配置文件,定义扩展名称、版本、权限、资源路径等。
  • 内容脚本(Content Scripts) :注入到网页中的脚本,可直接操作 DOM。
  • 后台脚本(Background Scripts) :独立于页面的 JavaScript 环境,用于处理全局逻辑。
  • 用户界面组件:如弹出页(Popup)、选项页(Options Page)、侧边栏等。
  • 静态资源:图标、图片、样式表等。

以上只有manifest.json文件是必须的,当然插件的图标也是要提供的,算半必须吧,后面这些组成都会详细介绍,这里了解一下就好

Manifest V3 与 V2 的主要区别

其实我觉得能点进来看这篇文章的人,没有必要去详细了解V2,因为V3 是当前强制要求的最新版本(自 2023 年起新扩展必须使用 V3),所以V2相当于已经被淘汰了,但是毕竟没有过去很久,可能有一些项目需要做一些迁移,还是给大家介绍一下,后面也会专门出一篇迁移的文章

manifest.json 文件其实是Extension的“身份证”和“说明书”,决定了Extension如何与浏览器交互。不同版本的 Manifest 对应不同的 API 和权限规则。那又为什么放弃V2,需要V3呢?

首先得说说V2存在的问题,在V2版本存续期间,Chrome Extension生态长期面临两大问题:

  • 安全风险:恶意Extension滥用权限,窃取用户数据
  • 性能损耗:常驻后台脚本导致内存占用过高,性能损耗过于严重

Manifest V3 的变革本质是浏览器厂商对扩展生态的一次“技术纠偏”,其核心目标是通过限制自由度换取更高的安全性和性能。

V3版本又是怎么解决问题的呢?

  • 资源效率:优化后台任务的生命周期管理
  • 最小权限模型:限制Extension的过度权限申请
  • 代码可信性:禁止远程代码动态执行

接下来,详细说说V2→V3究竟有哪些核心变革

1. 后台脚本的颠覆:Service Worker 取代 Background Pages

  • V2 的 Background Pages

    本质是一个常驻内存的隐藏页面,通过 background.html 和 background.js 运行,即使无页面活动也可持续执行代码,只要扩展安装后,就会持续运行,直至浏览器关闭或扩展被禁用

    典型问题

    // V2 示例:常驻后台监听消息
    chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
      // 长期占用内存的逻辑
    });
    
  • V3 的 Service Worker

    改用 Service Worker 作为后台脚本载体,其生命周期由浏览器严格管理:

    • 无持久化运行:仅在需要时激活(如收到事件、定时任务触发)。
    • 无 DOM 访问权限:无法操作页面元素。
    • 内存限制:执行完成后会被终止以节省资源。

开发者影响:需重构后台逻辑,避免依赖长时运行的任务,改用事件驱动模式。

2. 权限模型收紧

网络请求处理

V2:通过 chrome.webRequest API 动态拦截和修改请求

// V2 动态广告拦截示例
chrome.webRequest.onBeforeRequest.addListener(
  (details) => ({ cancel: details.url.includes("adserver.com") }),
  { urls: ["<all_urls>"] },
  ["blocking"]
);

V3:改用 Declarative Net Request 静态规则

// manifest.json 中声明规则文件
"declarative_net_request": {
  "rule_resources": [{
    "id": "block_ads",
    "enabled": true,
    "path": "rules.json"
  }]
}
// rules.json 定义静态规则
[{
  "id": 1,
  "priority": 1,
  "action": { "type": "block" },
  "condition": {
    "urlFilter": "||adserver.com^",
    "resourceTypes": ["script"]
  }
}]
权限声明收紧

V2:允许运行时动态申请权限

chrome.permissions.request({ permissions: ["tabs"] });

V3:所有权限必须在 manifest.json 中预声明,无法动态添加

{
  "permissions": ["activeTab", "scripting"],
  "optional_permissions": ["downloads"] // 仅部分权限可动态请求
}

开发者影响:需提前规划权限需求,放弃动态注入远程脚本的“偷懒”方案。

3. 安全策略:代码可信性与执行限制

禁止远程代码

V2:允许通过 eval() 或远程加载脚本

// V2 中动态执行远程代码(高风险!)
fetch("https://example.com/code.js")
  .then(response => response.text())
  .then(code => eval(code));

V3:所有逻辑必须打包至扩展本地,禁止以下操作

  • eval()new Function()
  • 动态插入 <script> 标签加载远程 JS。
  • 使用 chrome.scripting.executeScript 执行远程代码。
内容安全策略(CSP)强化

V2:允许宽松策略

"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"

V3:强制禁用 unsafe-eval 和内联脚本

"content_security_policy": {
  "extension_pages": "script-src 'self'; object-src 'self'"
}

Chrome Extension开发和一般网页开发

Extension的开发语言就是前端中大家都会接触的HTML、CSS和JavaScript,那这两者究竟有什么区别呢

1. 运行环境与执行上下文差异

Chrome Extension 普通网页
执行上下文 多环境并行: - 内容脚本(Content Scripts) - Service Worker(后台脚本) - 弹出页(Popup)、选项页等 单一页面上下文: 所有代码在页面 DOM 环境中运行
DOM 访问权限 内容脚本可直接操作页面 DOM,但和页面原始脚本隔离(独立 JS 环境) 直接操作当前页面的 DOM
浏览器 API 访问 可调用 Chrome 扩展专属 API(如 chrome.tabschrome.storage 仅限 Web 标准 API(如 fetchlocalStorage
跨域请求限制 可通过声明权限(如 "<all_urls>")绕过同源策略 严格受同源策略限制,需依赖 CORS 或代理服务
生命周期管理 Service Worker 按需激活,浏览器控制其启停 页面关闭即终止所有逻辑

2. 架构设计差异

Chrome Extension 普通网页
代码组织 多文件协同: - manifest.json 配置文件 - 内容脚本、Service Worker、UI 组件分离 通常集中在单页应用(SPA)或传统多页面架构
通信机制 依赖 chrome.runtime API 实现不同组件间通信: - sendMessageconnect 基于 window.postMessage 或自定义事件总线
状态管理 需跨组件同步状态(如使用 chrome.storage 持久化) 可通过 Redux、Context API 等前端框架管理

3. 异步编程与事件驱动差异

Chrome API 几乎全部以异步形式提供(回调或 Promise),需处理多组件协作的异步逻辑;一般网页开发异步操作集中在数据请求(如 fetch)和用户交互事件,架构相对简单

4. 权限模型差异

Chrome Extension 普通网页
权限范围 可申请浏览器底层权限: - 访问所有网站内容 - 管理下载、书签、历史记录等 仅限用户交互授权: - 地理位置 - 摄像头/麦克风 - 通知权限等
权限申请方式 必须在 manifest.json 中静态声明(Manifest V3 限制动态申请) 可通过 Permissions API 动态请求(如 navigator.permissions.request()
安全审查强度 上架需通过 Chrome 商店严格审核(代码扫描 + 人工复核) 部署在自有服务器,无强制审核(依赖用户信任)

5. 安全策略差异

Chrome Extension 普通网页
内容安全策略(CSP) 强制严格策略: - 禁止内联脚本(如 onclick="...") - 禁止 eval() 可自由定义 CSP(如允许内联脚本)
代码来源限制 所有逻辑必须打包在扩展内,禁止远程代码执行(Manifest V3) 可自由加载外部 CDN 资源(如 <script src="https://cdn.example.com/lib.js">
沙箱隔离 内容脚本与页面脚本隔离(无法直接共享变量) 所有脚本共享同一全局环境

6. 调试与部署差异

Chrome Extension 普通网页
调试环境 需分别调试多个组件: - Service Worker(通过 chrome://serviceworker-internals) - 内容脚本(在网页开发者工具中) - 弹出页(独立窗口) 单一页面调试(浏览器开发者工具)
热重载支持 无原生热重载,需手动刷新扩展或使用第三方工具(如 webpack-chrome-extension-reloader 主流框架(如 React、Vue)支持热重载
错误追踪 错误分散在多个上下文,需结合 chrome://extensions 的日志和浏览器控制台 所有错误集中在页面控制台
打包方式 必须压缩为 ZIP 文件(不含根目录),依赖 manifest.json 定义结构 无强制打包格式,通常直接部署 HTML/CSS/JS 文件或使用构建工具(如 Webpack)打包
发布流程 必须提交至 Chrome 网上应用店,通过审核后发布(1-7 天) 直接部署到服务器或 CDN,实时生效
版本更新 用户自动接收更新,但需重新审核新版本 用户访问时立即获取最新版本(可能受缓存影响)

以上差不多就是宏观上的一些了解了,打个预告,下一篇文章会介绍环境搭建,完成经典的“Hello World”,同时介绍基本的本地加载与开发调试技巧

npm、yarn、pnpm实现monorepo并使用changset管理版本

作者 追日出
2025年4月13日 16:18

在前端开发领域中有很多库和框架他都是使用的monorepo架构实现的,它的好处有很多,可以很方便的管理自己的目录结构,还可以实现分包的功能等。比如在vue3中就使用到了这个架构,它会将runtime阶段和complier阶段和响应式阶段进行更细致的划分。当我们需要什么功能就可以引入单独的包来实现。

像这种多包的概念,我们每次更新都要去手动更改package.json中的版本,这样会很麻烦。可以使用changeset来管理我们每个包的版本。接下来我们就来实现一下。

mkdir monorepo-npm-yarn-pnpm
cd monorepo-npm-yarn-pnpm

我有这样一个场景,我有一个logger方法就是用来记录函数调用日志或者接口日志等等的一些日志信息的。我们用monorepo架构的方式来实现一下,这种场景确实不需要monorepo架构,但是为了掩饰使用我们就用这一个工具吧。

npm monorepo

先来看npm如何实现monorepo

mkdir monorepo-npm
cd monorepo-npm

npm init -y

npm install typescript @types/node --save-dev // 安装typescript和node类型提示

npx tsc --init // 初始化tsconfig

修改tsconfig.json修改成如下内容

{
  "compilerOptions": {
    "outDir": "dist",
    "types": [ "node" ],
    "target": "es2016", 
    "module": "NodeNext", 
    "moduleResolution": "NodeNext",
    "declaration": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
  }
}

然后创建两个包

npm init -w packages/core -y
npm init -w packages/logger -y

可以看到两个包已经创建完成,package.json也自动加了workspaces配置项。

image.png

我们需要在core中添加logger的依赖

npm install logger --workspace core          

可以看到core的依赖中引入了logger的包并且在node_modules中创建了looger的软链接。

image.png

改下包的名称,然后install一下。

image.png

image.png

接下来我们初始化子包的tsconfig文件。这里我们需要使用npm exec --workspace 子包名称 -- 命令这样的命令来找到子包并运行命令。

npm exec --workspace @spring-npm/core -- npx tsc --init
npm exec --workspace @spring-npm/logger -- npx tsc --init

然后修改tsconfig.json文件和上面的一样就行。

接下来我们来写logger日志的方法。

这里需要使用chalk第三方工具库来为我们的日志信息添加颜色。当我们需要为子包安装包的时候需要使用命令npm install --workspace 包名 库名

npm install --workspace @spring-npm/logger chalk

首先在package.json中添加type: 'module'的配置,让nodejs支持esmodule的模块规范。

image.png

这个loggerFun方法就根据日志类型打印不同的颜色的日志信息,非常简单。信息包含了函数调用的时间、函数名称以及函数的返回值。

import chalk from "chalk";

export enum LogLevel {
  DEBUG,
  INFO,
  WARN,
  ERROR,
}

export function loggerFun<T>(type: LogLevel, funName: string, message: string, reutrnValue: T = {} as T): void {
  const time = new Date().toLocaleTimeString();

  let resultValue: string = "";
  if (Object.prototype.toString.call(reutrnValue) == "[object Object]") {
    resultValue = JSON.stringify(reutrnValue);
  } else if (Object.prototype.toString.call(reutrnValue) == "[object Array]") {
    resultValue = "[" + (reutrnValue as Array<any>).join(", ") + "]";
  } else {
    resultValue = reutrnValue + "";
  }

  if (type === LogLevel.DEBUG) {
    debugLogger(funName, message, resultValue, time);
  } else if (type === LogLevel.INFO) {
    infoLogger(funName, message, resultValue, time);
  } else if (type === LogLevel.WARN) {
    warnLogger(funName, message, resultValue, time);
  } else if (type === LogLevel.ERROR) {
    errorLogger(funName, message, resultValue, time);
  } else {
    console.log(chalk.whiteBright(`[] ${time} - ${funName} - returned: ${reutrnValue}`));
  }
}

function debugLogger(funName: string, message: string, reutrnValue: string, time: string) {
  console.log(chalk.blueBright(`[DEBUG] ${time} - ${funName} - ${message} - returned: ${reutrnValue}`));
}

function infoLogger(funName: string, message: string, reutrnValue: string, time: string) {
  console.log(chalk.greenBright(`[INFO] ${time} - ${funName} - ${message} - returned: ${reutrnValue}`));
}

function warnLogger(funName: string, message: string, reutrnValue: string, time: string) {
  console.log(chalk.yellowBright(`[WARN] ${time} - ${funName} - ${message} - returned: ${reutrnValue}`));
}

function errorLogger(funName: string, message: string, reutrnValue: string, time: string) {
  console.log(chalk.redBright(`[ERROR] ${time} - ${funName} - ${message} - returned: ${reutrnValue}`));
}

接下来我们对这个方法进行tsc编译成js文件,然后修改成package.json将入口指向变异后的文件。

npm exec --workspace @spring-npm/logger -- npx tsc

修改package.json

image.png

这里logger包就完成了。

接下来写core的代码。

import { loggerFun, LogLevel } from "@spring-npm/logger";

function add(a: number | string, b: number | string): number {
  loggerFun(LogLevel.DEBUG, add.name, "进入函数");
  if (typeof a !== "number" || typeof b !== "number") {
    loggerFun(LogLevel.ERROR, add.name, "参数错误");
    throw new Error("参数错误");
  }
  loggerFun(LogLevel.INFO, add.name, "完成", a + b);
  return a + b;
}

add(1, 2);
add(1, '2');

image.png

changeset 版本管理

到这里monorepo的架构就完成了,功能也实现了。接下来我们结合changeset来进行版本管理。

登陆npm,然后添加一个组织

image.png

因为我们是@spring-npm开头的包,所以我们就叫spring-npm

image.png

接下来将corelogger包在package.json添加属性,都改成公有的

  "publishConfig": {
    "access": "public"
  }

执行npm adduser登陆npm

npm adduser

如果你npm弹出来的是中文页面,那么就需要将淘宝镜像改成npm的镜像。我是使用nrm来改的。

nrm use npm

image.png

这样就登陆成功了。

然后安装changeset

npm install --save-dev @changesets/cli

初始化changeset,会生产一个.changeset目录,里面就是记录你的版本信息的。

npx changeset init

image.png

因为changeset是基于git代码提交变动的,所以我们要初始化git仓库。

git init

git add . 

git commit -m 'first commit'

然后执行npx changeset add,创建一次changeset记录

npx changeset add

image.png

这里我们选择更改小版本minor

然后npx changset version 来修改版本

npx changeset version

image.png

image.png

可以看到版本全部修改,修改的是小版本。

image.png

然后提交git,并发布到npm上

git add .
git commit -m 'version 1.1.0'

npx changeset publish

image.png

这里就发布完成。

然后将coreloggerindex.ts都加个回车,我们修改大版本试一下。

image.png

然后上面的步骤都是一样的。

image.png

这里升级大版本也完成了。这里就是npm + monorepo + changeset就完成了。

yarn

yarn 和 pnpm 其实都是差不多的,只有命令不同。这里就不带着大家一一去写了。只有执行子包的命令和安装子包的第三方库有些命令上的不同。其它都是一样的,使用changeset也是一样的。

yarn 初始化两个目录

npm init -w packages/logger -y 
npm init -w packages/core -y

yarn core引用logger包

yarn workspace core add logger@1.0.0

yarn 为子包安装依赖

yarn workspace cli add chalk commander

yarn 执行子包命令

cd packages/core
npx tsc --init

pnpm

pnpm添加pnpm-workspace.yaml文件,指定子包目录。

packages: - 'packages/*'

创建两个包

mkdir packages packages/core packages/logger 
cd packages/core 
npm init -y 
cd ../logger 
npm init -y

为core包添加logger的依赖

pnpm --filter logger add core --workspace

执行子包的命令

pnpm --filter logger exec npx tsc --init

子包添加依赖

pnpm --filter logger add chalk

这里pnpm为主包添加依赖需要使用-w

pnpm add --save-dev -w @changesets/cli

总结

pnpm monorepo会更好用一点,npm和yarn都是存在幽灵依赖的问题,同时npm的monorepo受版本的影响。pnpm安装包更快没有幽灵依赖的问题。

无论是npm、yarn还是pnpm它们的monorepo架构都是相同的,只有执行子包的命令不同。只需要知道执行子包的命令就行了。写法上都是通用了。大家有时间可以将yarn和pnpm都实现一遍。如果遇到什么问题可以评论区讨论一下。

如何从0到1搭建基于antd的monorepo库——子包初始化(二)

作者 Kincy
2025年4月13日 16:17

文章系列

上一章:# 如何从0到1搭建基于antd的monorepo库——初始化(一)

作者有话说

目前已经实现了一部分功能,源代码在 github,欢迎大家 Star 和 PR,一些待实现的功能都在 issue 中,感兴趣的同学可以一起加入进来。

看完这个系列可以收获什么:

  1. 如何使用 pnpm workspace + lerna 搭建 monorepo 仓库
  2. antd 的单个组件怎么进行文件结构的设计
  3. 基于 antd form 实现一个 Json 渲染表单
  4. antd 的打包方式以及如何使用 rollup 实现
  5. 如何发布 monorepo 包到 npm

前瞻

组件库技术选型:

  1. pnpm 10
  2. node 20
  3. lerna 8
  4. react 18
  5. antd 5
  6. dumi 2

正片开始

basic 包初始化

修改 package.json 文件。

{
  "dependencies": {
    "@ant-design/icons": "^6.0.0"
  },
  "devDependencies": {
    "typescript": "^5.8.2"
  },
  "peerDependencies": {
    "antd": "catalog:",
    "react": "catalog:",
    "react-dom": "catalog:"
  },
}

运行 pnpm i 安装依赖。

在根目录下新增 tsconfig.json 文件。

{
  "compilerOptions": {
    "target": "ES5",
    "module": "ESNext",
    "moduleResolution": "node",
    "baseUrl": ".",
    "paths": {
      "@kc-components/*": ["packages/*/src"]
    },
    "jsx": "react-jsx",
    "jsxImportSource": "react",
    "skipLibCheck": true,
    "types": ["react", "react-dom"],
    "strict": true,
    "noImplicitAny": false,
    "forceConsistentCasingInFileNames": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "sourceMap": true,
    "inlineSources": true,
    "importHelpers": false
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "es", "lib"]
}

在根目录下新增 src 文件夹,以及 src/index.ts 文件。

utils 包初始化

修改 package.json 文件。

{
  "devDependencies": {
    "typescript": "^5.8.2"
  },
}

执行 pnpm i 安装依赖。

新增 tsconfig.json 文件。

{
  "compilerOptions": {
    "target": "ES5",
    "module": "ESNext",
    "moduleResolution": "node",
    "baseUrl": ".",
    "paths": {
      "@kc-components/*": ["packages/*/src"]
    },
    "jsx": "react-jsx",
    "jsxImportSource": "react",
    "skipLibCheck": true,
    "types": ["react", "react-dom"],
    "strict": true,
    "noImplicitAny": false,
    "forceConsistentCasingInFileNames": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "sourceMap": true,
    "inlineSources": true,
    "importHelpers": false
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "es", "lib"]
}

新增 src 文件夹,以及 src/index.ts 文件。

总结

至此,整个组件库的架子已经搭好,接下来,我们将会基于 antd form 实现一个 Json 渲染表单组件。

如果想提前知道更多内容可以直接查看github,欢迎大家 Star 和 PR,如有疑问可以评论或私信。

JS知识点汇总

2025年4月13日 16:00

jS中几种常见的类型判断方法****

  • 基本数据类型:string,number,boolean,null,undefinded,bigint,symbol
  • 引用数据类型:对象,函数,数组,日期
  1. Typeof    判断原始数据类型
  2. Instanceof 来判断引用数据类型
  3. Object.prototype.toString.call(this)  判断所有类型
  4. 判断数组  Array.isArray()

==和===

==(宽松相等):会在比较两个操作数时执行 类型转换尝试将两者转换为相同类型后再比较。 ===(严格相等):不会执行类型转换,仅在类型和值完全相同的情况下返回 true。 推荐使用 ===:因为它更严格、更符合预期,能避免潜在的错误。尤其是在需要精确判断值和类型时。

深拷贝和浅拷贝区别

浅拷贝,只拷贝了最外面的一层 ,只相当赋值给了 新的,新的改值,旧的值不会改变* 但是如果对象里面包对象,那么也只能拷贝最外面的一层,新的改值,旧的值也会改变!!!因为拷贝的还是地址,而不是对象****

  • 深拷贝和浅拷贝只针对引用类型
  • 浅拷贝:拷贝的是地址
  • 深拷贝:拷贝的是对象,不是地址

数组实现浅拷贝:concat  【...arr】 slice()

对象实现浅拷贝:Object.assgin  / ...展开运算 {。。。Obj}

深拷贝:Stringify  lodash/clonedeep  递归实现深拷贝

变量提升

变量提升是 JavaScript 的一种机制,在该机制下,变量和函数的声明会被提升到当前作用域的顶部。这意味着,无论变量和函数在代码中实际声明的位置如何,它们都可以在声明之前被访问。

函数声明提升

函数声明会被完整提升,这意味着在函数声明之前就可以调用该函数。

// 可以在函数声明之前调用函数
sayHello();

function sayHello() {
    console.log('Hello!');
}
变量声明提升

使用 var 声明的变量会被提升,但不会赋值,在变量声明之前访问该变量,其值为 undefined

// 变量提升,输出 undefined
console.log(message); 
var message = 'Hello, world!';

暂时性死区

暂时性死区是 ES6 引入 let 和 const 后出现的概念。使用 let 和 const 声明的变量不会像 var 那样被提升到作用域顶部并赋值为 undefined,而是存在于暂时性死区内。在变量声明之前访问这些变量会导致 ReferenceError

/ 报错:ReferenceError
console.log(myVariable); 
let myVariable = 'Hello';

在上面的代码中,myVariable 从块级作用域开始到它被声明之前都处于暂时性死区内。只有在声明之后,变量才能被正常访问

两者的区别

  • 变量提升:使用 var 声明的变量和函数声明会被提升到作用域顶部,函数可以在声明前调用,变量在声明前值为 undefined
  • 暂时性死区:使用 let 和 const 声明的变量不会被赋值为 undefined,在声明之前访问会导致 ReferenceError

var、let、const区别

var没有块级作⽤域,只有函数作⽤域。var只有在function{ }内部才有作⽤域的概念,其他地⽅没有。意味着函数以外⽤var定义的变量是同⼀个,我们所有的修改都是针对他的

  1. let和const增加块级作⽤域(JS没有块级作⽤域)

  2. let和const存在暂时性死区,不存在变量提升,不能在初始化前引⽤,调⽤ 返回 uninitialized

  3. let和const禁⽌重复声明,不能重新声明

  4. let和const不会成为全局对象属性,var声明的变量⾃动成为全局对象属性

  5. var 存在变量提升(执⾏前,编译器对代码预编译,当前作⽤域的变量/函数提升到作⽤域顶部),let约束变量提升。let和var都发⽣了变量提升,只是es6进⾏了约束,在我们看来,就像let禁⽌了变量提升

  6. 使⽤var,我们能对变量多次声明,后⾯声明的变量会覆盖前⾯的声明

WeakMap与Map的区别是什么

1. 什么是WeakMap

WeakMap 是 JavaScript 中的一种集合类型,它存储键值对,且键必须是对象,并且键是弱引用的。这意味着,如果键对象没有其他引用,它会被垃圾回收器回收,对应的键值对也会被自动删除。

2. 与Map的区别

键的类型

  • Map:键可以是任意类型,包括基本数据类型(像字符串、数字等)和引用类型(如对象、函数)。
  • WeakMap:键只能是对象,不能使用基本数据类型作为键。

垃圾回收机制

  • Map:对键所引用的对象是强引用。只要 Map 存在,键引用的对象就不会被垃圾回收,即便其他地方无该对象的引用。
  • WeakMap:对键所引用的对象是弱引用。若对象没有其他强引用,垃圾回收时对象会被回收,WeakMap 里对应的键值对也会自动移除。

可遍历性

  • Map:是可迭代的,能使用 for...of 循环、forEach 方法等遍历其键值对。
  • WeakMap:不可迭代,没有 keys()values()entries() 这些迭代方法,也不能用 for...of 或 forEach 遍历。

方法和属性

  • Map:有 size 属性来获取键值对数量,还有 set()get()has()delete()clear() 等方法。
  • WeakMap:只有 set()get()has()delete() 方法,没有 size 属性和 clear() 方法。

使用场景

  • Map:适用于需存储任意类型键值对,且要对这些键值对进行遍历和操作的场景,如缓存数据。
  • WeakMap:常用于避免内存泄漏的场景,例如给对象添加私有数据,当对象被销毁时,WeakMap 里相关数据也会被清理。

This指向

// this指向的情况

  1. 函数调用模式 fn(),this 指向 window(默认绑定)

  2. 方法调用模式 obj.fn(),this 指向调用者(隐式绑定),虽然没有刻意的绑定,但是在执行时会自动将函数的 this 指向该对象。

  3. 上下文调用模式:想指向谁就指向谁(显式绑定/硬绑定)

// call, apply, bind

fn.call(this指向的内容, 参数1, 参数2, ...)

fn.apply(this指向的内容, [参数1, 参数2, ...])

const newFn = fn.bind(this指向的内容)

call apply bind 总结

  • 相同点:都可以改变函数内部的this指向,
  • 区别点:
  1. call 和 apply 会调用函数,并且改变函数内部this指向,
  2. call 和 apply 传递的参数不一样,call 传递参数 aru1, aru2..形式
  3. apply 必须数组形式[arg]bind 不会调用函数,可以改变函数内部this指向
  • 主要应用场景:
  1. call 调用函数并且可以传递参数
  2. apply 经常跟数组有关系.比如借助于数学对象实现数组最大值最小值
  3. bind 不调用函数,但是还想改变this指向.比如改变定时器内部的this指向

闭包

闭包:内层函数+外层函数的变量

闭包的核心特性:

  • 访问外部函数作用域的变量
  • 即使外部函数执行结束,变量依然被保留
  • 不会被垃圾回收,直到闭包不再被引用

闭包的应用场景:

  • 私有变量(模拟封装)
function createcount(){
let count=0
return{
add:()=>++count }
}

const count=createcount()
count.add()  // 1
count.count  undefinded  外部无法直接访问
  • 回调 & 事件监听
  • 定时器 & 异步操作

闭包的缺点:

  • 可能导致内存泄漏:
  • 闭包会持有外部变量的引用,导致变量无法被垃圾回收
  • 解决方案:手动将变量置为 null 或谨慎管理作用域

内存泄露

内存泄露是指在程序运行过程中,程序未能释放不再使用的内存空间,导致内存资源被浪费

JS 内存泄露的常见原因

l 意外的全局变量:忘记使用 var,let,const 声明变量时,变量会被挂载到全局对象上

l 闭包:闭包中引用了不再需要的外部变量,导致这些变量无法被垃圾回收

l 未清理的 DOM 引用:删除 DOM 元素时,未能清理相关的事件监听器或引用

l 定时器和回调:未能清理不再需要的 setInterval 或 setTimeout 回调  

JS 作用域和作用域链

作用域:变量的可访问范围,分为 全局作用域、函数作用域、块级作用域。

作用域链:变量查找机制,从当前作用域 逐级向上查找,直到全局作用域或 ReferenceError。

原型和原型链

  1. 原型(Prototype)

每个 函数(构造函数)都有一个 prototype 属性,指向其 原型对象。

每个 对象 都有一个 proto 指向其构造函数的 prototype,形成继承关系。   2. 原型链(Prototype Chain)

查找⼀个属性先在⾃身查找,如果找不到,就沿着 proto 属性在原型对象上进⾏查找,如果还找不到,就沿着原型对象的 proto 属性进⾏查找,直到查找到直到找到Object的原型对象,如果还没有找到就会返回undefined

Object.prototype.proto === null,原型链的顶端是 是 Object.prototype.proto

  • 使⽤ hasOwnProperty() ⽅法来判断属性是否属于原型链的属性:

每个实例对象都有私有属性(proto)指向它构造函数的原型对象。

每个构造函数都有prototype原型对象

prototype原型对象的constructor指向构造函数本身

有默认constructor属性,记录实例由哪个构造函数创建

ES6新特性

  1. const、let

  2. 模板字符串

  3. 箭头函数

  4. 函数参数默认值

  5. 解构赋值

  6. for...of ⽤于数组,for...in⽤于对象

  7. Promise

  8. 展开运算符(...)

  9. 对象字⾯量、class(原型链的语法糖)

箭头函数和普通函数区别

  • 1.语法更加简洁,没有function =》
  • 2.没有动态参数,但是有剩余参数
  • 3.没有自己的this,会继承外层的this
  • 4.不支持bind/apply/call
  • 5.不能用作构造函数,没有prototype属性

js数组⽅法

  • forEach map
  • push pop shift unshift
  • splice slice concat join
  • sort reverse some every filter
### 改变原数组的方法

-   `push`:把一个或多个元素添加到数组末尾,并且返回新的数组长度。
-   `pop`:移除数组的最后一个元素,同时返回该元素。
-   `shift`:移除数组的第一个元素,然后返回该元素。
-   `unshift`:在数组开头添加一个或多个元素,并且返回新的数组长度。
-   `splice`:从数组中添加或删除元素,然后返回被删除的元素组成的数组。
-   `sort`:对数组的元素进行排序,并且返回排序后的数组。
-   `reverse`:颠倒数组中元素的顺序,并且返回颠倒后的数组。
`map``slice``concat``join``some``every``filter` 这些方法不会改变原数组  会返回新数组
- `map`:创建一个新数组,其结果是该数组中的每个元素都调用一个提供的函数后返回的结果。
- `slice`:返回一个新的数组对象,这一对象是一个由 `begin` 和 `end` 决定的原数组的浅拷贝(包括 `begin`,不包括 `end`)。
`concat`:用于合并两个或多个数组,此方法不会更改现有数组,而是返回一个新数组
`join`:将一个数组(或一个类数组对象)的所有元素连接成一个字符串并返回这个字符串
- `some`:测试数组中是不是至少有 1 个元素通过了被提供的函数测试,它返回的是一个布尔值。
`every`:测试一个数组内的所有元素是否都能通过某个指定函数的测试,它返回一个布尔值
- `filter`:创建一个新数组,其包含通过所提供函数实现的测试的所有元素。

防抖节流

  • 防抖:单位时间内,频繁触发事件,只执行最后一次
  • 使用场景:手机号,邮箱输入检测,用户最后一次输入完,再检测
  • 节流:单位时间内,频繁触发事件,只执行一次
  • 使用场景:鼠标移动,滚动条滚动
  • 手撕:
  1. 防抖函数:声明定时器变量,判断是否有定时器,如果有,清除定时器,重新声明定时器,执行函数
  2. 节流函数:声明定时器变量为空,判断如果没有定时器,声明定时器,执行函数,定时器清空
  3. Lodash库的 防抖和节流函数     _.debounce  _.throttle

如何理解 JS 单线程?

  • js是一门单线程的语言。因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个。
  • 渲染主线程要执行很多任务,解析html,执行js等。
  • 如果主线程采用同步的方式执行代码,可能会阻塞主线程的执行,导致消息队列中的其他任务无法执行。会浪费时间,以及导致页面无法及时更新卡死。
  • 所以采用异步的方式去解决。当遇到一些异步的任务,渲染主线程将这些任务交给其他线程去处理,自身立即结束执行任务,执行后续代码。当其他线程完成,将事先传递的回调函数包装成任务,放入消息队列的末尾,等待主线程的调度执行。
  • 在这种异步模式下,浏览器就不会阻塞,单线程也可以流畅运行

js的异步理解

  • js是一门单线程的语言。因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个。
  • 渲染主线程要执行很多任务,解析html,执行js等。
  • 如果主线程采用同步的方式执行代码,可能会阻塞主线程的执行,导致消息队列中的其他任务无法执行。会浪费时间,以及导致页面无法及时更新卡死。
  • 所以采用异步的方式去解决。当遇到一些异步的任务,渲染主线程将这些任务交给其他线程去处理,自身立即结束执行任务,执行后续代码。当其他线程完成,将事先传递的回调函数包装成任务,放入消息队列的末尾,等待主线程的调度执行。
  • 在这种异步模式下,浏览器就不会阻塞,单线程也可以流畅运行。

事件循环

事件循环又称之为消息循环,是浏览器渲染主线程的工作方式。 在chrome的源码中,它开启一个死循环,每次循环从消息队列中取出第一个任务执行,其他线程只需要在合适的时候把任务放入消息队列的末尾即可。 过去就是微任务队列和宏任务队列。目前的浏览器情况更加复杂,灵活的处理。 最新的W3c解释如下:

  • 每个任务都有一种任务类型,相同类型的任务必须放在一个队列中,不同类型的任务放在不同的队列中。
  • 不同的任务队列有不同的优先级
  • 在一次事件循环中,浏览器可根据实际情况选取队列执行任务。
  • 但是浏览器必须准备好一个微任务队列,微任务队列中的任务优先其他所有任务执行。必须优先调度。

最开始的时候,渲染主线程会开启无限循环。

  • 每一次循环,都会判断消息队列中是否有任务存在,如果有,就执行取出第一个任务执行。执行完以后,进入下一次循环,没有的话,就会进入休眠状态。
  • 其他所有线程可以随时向消息队列里面添加任务,新任务会加入消息队列的末尾。如果是休眠状态,会唤醒主线程,继续循环执行任务。 整个过程,就是事件循环(消息循环)

事件循环机制

宏任务主要包括:setTimeout、setInterval

微任务主要包括:promise、process.nextTick()

执⾏规则:同步代码直接进⼊主线程执⾏,JS引擎判断主线程是否为空,如果为空,则读取 微任务Event

Queue 中所有的消息,并依次执⾏。主线程和微任务 Event Queue 都为空后,读取 宏任务Event Queue 中的第⼀个消息进⼊主线程执⾏,来回微宏。

async/await

async/await 是 ES2017(ES8)引入的 基于 Promise 的语法糖,用于更清晰地编写异步代码,使其看起来像同步代码,提高可读性。

async 关键字:用于声明一个异步函数,返回值始终是 Promise。

await 关键字:只能在 async 函数中使用,等待 Promise 解析(resolve)并返回结果,而不会阻塞线程。

Promise

  • Promise 对象是异步编程的⼀种解决⽅案。Promise 是⼀个构造函数,接收⼀个函数作为参数,返回⼀个 Promise 实例。
  • ⼀个 Promise 实例有三种状态,分别是pending、fulfilled 和 rejected。
  • 实例的状态只能由 pending 转变 fulfilled 或者 rejected 状态,并且状态⼀经改变,⽆法再被改变了。
  • 状态的改变是通过传⼊的 resolve() 和 reject() 函数来实现的,当我们调⽤resolve回调函数时,会执⾏Promise对象的then⽅法传⼊的第⼀个回调函数,当我们调⽤reject回调函数时,会执⾏Promise对象的then⽅法传⼊的第⼆个回调函数,或者catch⽅法传⼊的回调函数。

Promise的实例有两个过程:

pending -> fulfilled : Resolved(已完成)

pending -> rejected:Rejected(已拒绝)

⼀旦从进⾏状态变成为其他状态就永远不能更改状态了。

在通过new创建Promise对象时,我们需要传⼊⼀个回调函数,我们称之为executor

✓ 这个回调函数会被⽴即执⾏,并且给传⼊另外两个回调函数resolve、reject;

✓ 当我们调⽤resolve回调函数时,会执⾏Promise对象的then⽅法传⼊的回调函数;

✓ 当我们调⽤reject回调函数时,会执⾏Promise对象的catch⽅法传⼊的回调函数;

resolve的三种状态

情况⼀:如果resolve传⼊⼀个普通的值或者对象,那么这个值会作为then回调的参数;

情况⼆:如果resolve中传⼊的是另外⼀个Promise,那么这个新Promise会决定原Promise的状态:

情况三:如果resolve中传⼊的是⼀个对象,并且这个对象有实现then⽅法,那么会执⾏该then⽅法,并且根据then⽅法的结 果来决定Promise的状态:

then⽅法接受两个参数:

fulfilled的回调函数:当状态变成fulfilled时会回调的函数;

reject的回调函数:当状态变成reject时会回调的函数;

Promise 串行执行

多个异步操作依次执行(避免回调地狱)

function step1() {
  return new Promise((resolve) => setTimeout(() => resolve('Step 1 完成'), 1000))
}
function step2() {
  return new Promise((resolve) => setTimeout(() => resolve('Step 2 完成'), 1000))
}

step1()
  .then((result) => {
    console.log(result)
    return step2() // 返回 Promise
  })
  .then((result) => console.log(result))
  .catch((error) => console.error('错误:', error))
  
  输出:
  Step 1 完成
  Step 2 完成

Promise 并行执行

多个异步任务同时执行,全部完成后再处理

const p1 = new Promise((resolve) => setTimeout(() => resolve('任务 1'), 1000))
const p2 = new Promise((resolve) => setTimeout(() => resolve('任务 2'), 1500))

Promise.all([p1, p2])
  .then((results) => console.log('所有任务完成:', results))
  .catch((error) => console.error('任务失败:', error))

如果只要最快完成的结果

Promise.race([p1, p2])
  .then((result) => console.log('最先完成的:', result))
  .catch((error) => console.error('失败:', error))

总结

Promise 解决异步回调问题,提供 .then().catch().finally() 处理状态变化。支持 Promise.all() 并行执行,Promise.race() 竞争执行。用 async/await 可以让异步代码更清晰。

不依赖插件,用原生js和canvas实现一个移动端图片编辑器

作者 lijibing
2025年4月13日 15:17

一、介绍

用原生js和canvas写的一个移动端图片编辑器

二、功能说明

  1. 支持 vue、react、原生 js 项目的 h5 页面
  2. 可以双指放大缩小图片
  3. 可以在图片上进行涂鸦
  4. 可以输入文字和输入法表情
  5. 文字后可以单指移动文字
  6. 点击文字后,点击操作右上角的按钮拖拽可以放大缩小文字。点击操作底部图标可以旋转文字
  7. 可以撤回内容
  8. 支持原图导出,也可以导出图片压缩质量

三、在线体验地址

ai.xkcoin.cn/index.html

四、源码地址(欢迎star)

github.com/lijibing01/…

五、演示

ezgif-3316bfddd8bd29.gif

六、使用方法

  1. 参考源码中的index.html

七、参数说明

参数名称 默认值 类型 说明
spaceLeft 30 number 初始化图片的左右间距
spaceTop 30 number 距离顶部和底部的距离
fontSize 14 number 文字大小
fontPaddingLeft 3 number 文字上下padding
fontPaddingTop 5 number 文字左右padding
fontRaduis 5 number 文字圆角半径
fontLineHeight 1.2 number 文字行高比例
fontLineWidth 5 number 线条宽度
fontShadowBlur 5 number 线条阴影
operatePaddingLeft 28 number 操作模块的左右边距
operatePaddingTop 10 number 操作模块的上下边距
fontColor '#000000' string 文字颜色
fontBgColor '#ffffff' string 文字背景颜色
fontFamily "Arial" string 字体
textOperateColor "#884cf3" string 操作模块的边框颜色
getDataURL () => {} function 回调函数,获取编辑后的图片
outType 'image/jpeg' string image/png、image/webp、image/jpeg
encoderOptions 1 number outType 为 image/jpeg 或 image/webp 的情况下,可以从 0 到 1 的区间内选择导出图片的质量

个人网站v0.1.0版本上线复盘

作者 岛投掷
2025年4月13日 15:02

背景介绍

使用AI开发个人网站的想法来源是个人的矫情。对于我的需求,其实有一些现成的工具可以满足,但是在尝试之后,感觉都有点太重了,不够优雅。 我喜欢snipaste和listary的设计思路,不追求全能,就只专注核心功能,即开即用,用完不再打扰。虽然这两个软件最近更新通知有点多,但是还是很好用的。

先说说我遇到的问题

频繁切换网页,没法快速定位:

  • 在工作中我会通过与AI沟通来探索需求设计的思路,在对话过程中会不断延伸出新的讨论点,我不希望一个对话的内容太过分散,需要新开一个对话进行新的讨论。
  • 在同一网站上同时进行多轮对话,会排队,因此我选择换一个AI,开启新的讨论。
  • kimi,deepseek,千问,秘塔,Gemini,免费使用确实香,但是手动切换起来比较麻烦。 我希望有一个页面,记录正在进行的对话,点击可以快速切换到对应的页面,自动选中输入框

重复输入太多:

  • 除了探索性的讨论外,AI主要还用于解释代码/解释术语/解释报错/翻译文档/变量命名/写dify的python代码/写n8n的js代码/提取图片中的文字/生成架构图/生成next.js代码
  • 这些场景通常都会带有一段固定的描述,每次手动复制粘贴,还有新建一个文档,不够优雅 我希望有一个页面,进入页面先获取剪贴板信息,然后列出常用场景,点击自动拼接固定描述,然后跳转到指定AI对话页面,自动粘贴文本

快速记录:

  • 在AI的沟通过程中以及日常生活中,有一些东西我希望能够快速下来
  • 最开始我使用的obsidian,但是遇到了多端同步的问题,用Self-hosted LiveSync能解决一部分问题,但是稳定性不是太好
  • 然后准备换成飞书文档,通过API更新图片时遇到了一点点问题,但是打开网站页面再手动粘贴又有点傻 我希望有一个页面,进入页面先获取剪贴板信息,然后可以一键更新到飞书的现有文档中,文档按周命名,每周整理一次快速记录的信息

然后就是动手尝试

第一次尝试

我从quicker使用文档里,了解到autohotkey的存在,autohotkey支持一定的GUI。于是我开始考虑能不能用autohotkey脚本和python脚本来实现。借助AI实现了最基础的脚本,通过一个右键弹窗触发对应的脚本。但是这样太不优雅了,而且确实不好用,使用过程中要等待脚本执行。

  • 打开kimi翻译用的对话(在对话中提前要求翻译成中文),先输入"翻译:"然后自动粘贴并回车
#Requires AutoHotkey v2.0

Run("chrome.exe https://kimi.moonshot.cn/chat/cu9j3p72cre4...")

Sleep(3000) ; 等待浏览器打开

Send("翻译:")

Sleep(500) ; 等待输入完成

Send("^v") ; 触发粘贴操作

Sleep(500) ; 等待粘贴完成

Send("{Enter}") ; 回车

ExitApp
  • 打开obsidian用于记录的文档,切换到文档底部,自动粘贴并回车
#Requires AutoHotkey v2.0

; 构建Obsidian URL

obsidianUrl := "obsidian://new?vault=obdoc&file=临时文件夹%2F今日临时文件&append"

; 使用Obsidian URL静默打开Obsidian

Run(obsidianUrl)

Sleep(500) ; 等待Obsidian打开

; 触发一次Tab键

Send("{Tab}")

Sleep(500) ; 等待Tab操作完成

; 在打开的Obsidian文档内触发粘贴操作

Send("^v") ; 触发粘贴操作

Sleep(500) ; 等待粘贴完成

; 输入换行符

Send("{Enter}")

; 结束脚本

ExitApp

第二次尝试

khoj有一个桌面端应用,除了同步本地文件到khoj知识库外,还提供了一个快捷对话的窗口。 复制文本或图片后,通过快捷键唤起窗口,窗口内会展示当前复制内容,内容可以修改,下方展示按钮,点击发送该信息开始对话 有点类似了,那么下一步基于这个开源应用改造改造就能满足我的需求吧。然后我就发现不太行,根本看不懂代码,无从下手。 还是一步步来,项目是electron的,那我先做个网页,然后再封装成桌面应用,大概也许可以。那就从做网页开始

1. 确定技术栈

  • 前端:next.js,可以直接部署到vercel上
  • 后端:python,本来是打算用python写后端服务的,结果实际尝试之后,直接用next.js写api,更好调试一点
  • 数据库:supabase,还能把用户登录与认证+数据桶一起解决了

2. 创建项目

创建项目没啥好说的 npx create-next-app@latest 遇到1个点是,npx shadcn@latest init时要进入项目目录才行

3. 登录页面和用户状态获取

这一步最开始我没有理解客户端和服务端,要求AI用python封装supabase接口来实现登录鉴权,AI非常认可,但是就是调试不出来。后面自己看文档发现了问题,重新搭建很快就调通了。 AI不太会拒绝,要么你确定你的方案是正确的,要么不要给AI提供方案。质疑可能导致AI按照错误的思路开发

  • 登录页面直接使用supabaseui提供的示例,包括登录状态回显、退出登录的逻辑也是直接复用 npx shadcn@latest add https://supabase.com/ui/r/password-based-auth-nextjs.json
  • 登录状态获取
    1. 使用 createClient() 创建 Supabase 客户端
    2. 通过 supabase.auth.getSession() 获取当前会话
    3. 使用 onAuthStateChange 监听认证状态变化
    4. 将用户信息存储在 React 状态中

4. 创建工具列表页面和列表数据编辑页面

前期我不太希望将工具和内容直接开放访问,所以增加了用户体系。 登录后才能使用,每个用户可查看和编辑公开工具和自己创建的工具:基于supabase的RLS实现

在调试的过程中发现,AI对项目的目录结构不敏感,目录经常建错层级,或者在已有对应文件的情况下,自行新建一个类似功能的文件 因此我在readme中将完整目录结构写入,每次修改的时候都带上这个文件 同时next.js单页面是一个文件夹,AI提供的修改中,如果涉及到文件夹外的文件,我会让它给出完整方案和原因,然后去网页上粘贴给deepseek检查

5. 需求的拆解和简单验证

将需求拆解出了几个基础的能力,便于我进行前期验证

  • 进入页面自动获取剪贴板内容,显示在页面上。支持图片和文本。在剪贴板内容下方展示功能按钮,可点击快速跳转网页
  • 进入页面自动选中输入框,输入内容后回车保存。右侧展示保存的历史信息 在这过程中,增加了全局的header组件,用于标识登录状态和快速返回工具列表页面 简单调整了ui。后续需要找一套ui规范可以快速喂给ai,来优化页面效果 增加了示例页面,一些公共方法我添加到示例,便于后面给AI作为参考,避免AI自行发挥

6. vercel部署

部署时遇到了几个问题,感觉需要记录一下

  1. 推送代码到github后,vercel部署失败。将提示喂给AI后,AI无法解决,后面kimi给了一个解决方案,尝试后莫名其妙解决了,感觉是代码格式的问题npx @next/codemod@canary next-async-request-api
  2. 刚开始直接推送github,报错信息少,改完一个又有一个,后面发现可以本地模拟vercel部署vercel build,通过后再推送github
❌
❌