阅读视图

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

pkg.pr.new 快速验证第三方包-最新修复

 pkg.pr.new 是什么?

  • pkg.pr.new 是一个为 GitHub 仓库提供**即时预览包(preview packages)**的服务。

  • 每当有新的 commit 推送或 Pull Request 创建时,它会自动生成一个可通过 npm 兼容 URL 直接安装的临时包。

    • 示例:npm i https://pkg.pr.new/vite@main
  • 支持与 StackBlitz WebContainers 集成,允许用户在完全隔离的浏览器环境中试用这些预览包。

 存储管理策略

  • pkg.pr.new 不是永久包注册表,而是临时预览服务。

  • 自动清理机制:

    • 超过 1 个月未被下载 的包会被删除。
    • 超过 6 个月 的包也会被自动移除。
  • 这一策略有效控制了存储成本,未来在 Cloudflare 支持下可适当放宽限制。


用 pkg.pr.new 快速验证 Element Plus 的 Tree 内存泄漏修复

背景:一个真实的性能问题

在 Element Plus 的 PR #23055 中,开发者 @rzzf 发现并修复了一个 el-tree 组件在数据更新后可能引发内存泄漏 的问题。

这个问题的表现是:

  • 当频繁更新 tree 的 data 时,旧节点引用未被正确释放;
  • 导致内存持续增长,尤其在 Chrome 130 等新版本浏览器中更为明显(见 Issue #23059)。

修复本身并不复杂——关键在于确保事件监听器和节点缓存在数据变更时被清理。但作为用户或 Reviewer,我们最关心的是:

这个修复真的有效吗?我能不能在自己的项目里快速试一下?

传统方式 vs pkg.pr.new

过去,要测试一个未发布的 PR,你需要:

  1. 拉取 PR 分支;
  2. 本地构建 element-plus;(切对应的node环境,繁琐)
  3. 链接到你的项目(npm link 或 yalc);
  4. 重启开发服务器……

整个过程繁琐且容易出错。

而现在,有了 pkg.pr.new,一切变得极其简单。

一行命令,直接安装 PR 构建包

对于 PR #23055,你可以直接运行:

npm install https://pkg.pr.new/element-plus/element-plus@23055

pnpm add https://pkg.pr.new/element-plus/element-plus@23055

yarn add https://pkg.pr.new/element-plus/element-plus@23055

✅ 这个 URL 会自动指向该 PR 对应的最新 CI 构建产物,完全兼容 npm 安装流程。

等到官方发布了 最新的版本, 可以重新回归到 正常的版本号的 正轨

生态支持

  • 已生成 超过 100 万个预览包

结语

PR #23055 不仅修复了一个关键的内存问题,更展示了现代开源项目的高效协作范式

代码提交 → 自动构建 → 在线预览 → 社区验证 → 合并发布

相关链接

TanStack Router 文件命名约定

基于文件的路由要求你遵循一些简单的文件命名约定,以确保正确生成路由。这些约定所启用的概念在 路由树与嵌套 指南中有详细介绍。

特性 (Feature) 描述 (Description)
__root.tsx 根路由文件必须命名为 __root.tsx,并且必须放置在配置的 routesDirectory 的根目录下。
. 分隔符 路由可以使用 . 字符来表示嵌套路由。例如,blog.post 将生成为 blog 的子路由。
$ 标记 带有 $ 标记的路由片段是参数化的,并将从 URL 路径名中提取值作为路由 param(参数)。
_ 前缀 带有 _ 前缀的路由片段被视为无路径布局路由,在将子路由与 URL 路径名匹配时不会使用它。
_ 后缀 带有 _ 后缀的路由片段将该路由排除在任何父路由的嵌套之外(即非嵌套路由)。
- 前缀 带有 - 前缀的文件和文件夹将从路由树中排除。它们不会被添加到 routeTree.gen.ts 文件中,可用于在路由文件夹中共存逻辑代码。
(folder) 文件夹名模式 匹配此模式的文件夹被视为路由组,防止该文件夹包含在路由的 URL 路径中。
[x] 转义 方括号用于转义文件名中具有路由含义的特殊字符。例如,script[.]js.tsx 变为 /script.jsapi[.]v1.tsx 变为 /api.v1
index 标记 index 标记结尾(在任何文件扩展名之前)的路由片段,当 URL 路径名精确匹配父路由时,将匹配父路由。这可以通过 indexToken 配置选项进行配置,参见 [suspicious link removed]。
.route.tsx 文件类型 当使用目录组织路由时,route 后缀可用于在目录路径处创建路由文件。例如,blog.post.route.tsxblog/post/route.tsx 可用作 /blog/post 路由的路由文件。这可以通过 routeToken 配置选项进行配置,参见 [suspicious link removed]。

💡 记住: 项目的文件命名约定可能会受到所配置的 [suspicious link removed] 的影响。

[!NOTE]

要转义尾随下划线(例如 /posts[_].tsx),需要使用升级后的 非嵌套路由。

动态路径参数 (Dynamic Path Params)

动态路径参数可用于扁平路由和目录路由,以创建可以匹配 URL 路径动态片段的路由。动态路径参数由文件名中的 $ 字符表示:

文件名 (Filename) 路由路径 (Route Path) 组件输出 (Component Output)
... ... ...
ʦ posts.$postId.tsx /posts/$postId <Root><Posts><Post>

我们将在 路径参数 指南中了解更多关于动态路径参数的信息。

无路径路由 (Pathless Routes)

无路径路由用逻辑或组件包裹子路由,而不需要 URL 路径。无路径路由由文件名中的 _ 字符表示:

文件名 (Filename) 路由路径 (Route Path) 组件输出 (Component Output)
ʦ _app.tsx
ʦ _app.a.tsx /a <Root><App><A>
ʦ _app.b.tsx /b <Root><App><B>

要了解更多关于无路径路由的信息,请参阅 路由概念 - 无路径路由 指南。

TanStack Router 基于文件的路由

TanStack Router 的大部分文档都是基于“基于文件的路由”这一视角编写的,旨在帮助你深入理解如何配置它以及其背后的技术细节。虽然基于文件的路由是配置 TanStack Router 的首选和推荐方式,但如果你愿意,也可以使用 基于代码的路由

什么是基于文件的路由?

基于文件的路由是一种利用文件系统来配置路由的方式。你不需要通过代码定义路由结构,而是通过一系列代表应用程序路由层级的文件和目录来定义路由。这带来了许多好处:

  • 简单性 (Simplicity):对于新手和经验丰富的开发者来说,基于文件的路由都在视觉上更直观且易于理解。

  • 组织性 (Organization):路由的组织方式直接镜像了应用程序的 URL 结构。

  • 可扩展性 (Scalability):随着应用程序的增长,基于文件的路由使得添加新路由和维护现有路由变得容易。

  • 代码分割 (Code-Splitting):基于文件的路由允许 TanStack Router 自动对路由进行代码分割,以获得更好的性能。

  • 类型安全 (Type-Safety):基于文件的路由通过自动生成和管理路由的类型链接,极大提升了类型安全的上限,而在基于代码的路由中,这通常是一个繁琐的过程。

  • 一致性 (Consistency):基于文件的路由强制执行一致的路由结构,使得维护、更新应用程序以及在不同项目间迁移变得更加容易。

/ 还是 .

虽然目录长期以来一直用于表示路由层级,但基于文件的路由引入了一个额外的概念:在文件名中使用 . 字符来表示路由嵌套。这允许你避免为少量深度嵌套的路由创建目录,同时继续为更广泛的路由层级使用目录。让我们看一些例子!

目录路由 (Directory Routes)

目录可用于表示路由层级,这对于将多个路由组织成逻辑组以及减少大量深度嵌套路由的文件名长度非常有用。

请看下面的例子:

文件名 (Filename) 路由路径 (Route Path) 组件输出 (Component Output)
ʦ __root.tsx <Root>
ʦ index.tsx / (精确匹配) <Root><RootIndex>
ʦ about.tsx /about <Root><About>
ʦ posts.tsx /posts <Root><Posts>
📂 posts
┄ ʦ index.tsx /posts (精确匹配) <Root><Posts><PostsIndex>
┄ ʦ $postId.tsx /posts/$postId <Root><Posts><Post>
📂 posts_
┄ 📂 $postId
┄ ┄ ʦ edit.tsx /posts/$postId/edit <Root><EditPost>
ʦ settings.tsx /settings <Root><Settings>
📂 settings
┄ ʦ profile.tsx /settings/profile <Root><Settings><Profile>
┄ ʦ notifications.tsx /settings/notifications <Root><Settings><Notifications>
ʦ _pathlessLayout.tsx <Root><PathlessLayout>
📂 _pathlessLayout
┄ ʦ route-a.tsx /route-a <Root><PathlessLayout><RouteA>
┄ ʦ route-b.tsx /route-b <Root><PathlessLayout><RouteB>
📂 files
┄ ʦ $.tsx /files/$ <Root><Files>
📂 account
┄ ʦ route.tsx /account <Root><Account>
┄ ʦ overview.tsx /account/overview <Root><Account><Overview>

扁平路由 (Flat Routes)

扁平路由赋予你使用 . 来表示路由嵌套层级的能力。

当你有大量独特的深度嵌套路由,并且希望避免为每一个路由都创建目录时,这非常有用:

请看下面的例子:

文件名 (Filename) 路由路径 (Route Path) 组件输出 (Component Output)
ʦ __root.tsx <Root>
ʦ index.tsx / (精确匹配) <Root><RootIndex>
ʦ about.tsx /about <Root><About>
ʦ posts.tsx /posts <Root><Posts>
ʦ posts.index.tsx /posts (精确匹配) <Root><Posts><PostsIndex>
ʦ posts.$postId.tsx /posts/$postId <Root><Posts><Post>
ʦ posts_.$postId.edit.tsx /posts/$postId/edit <Root><EditPost>
ʦ settings.tsx /settings <Root><Settings>
ʦ settings.profile.tsx /settings/profile <Root><Settings><Profile>
ʦ settings.notifications.tsx /settings/notifications <Root><Settings><Notifications>
ʦ _pathlessLayout.tsx <Root><PathlessLayout>
ʦ _pathlessLayout.route-a.tsx /route-a <Root><PathlessLayout><RouteA>
ʦ _pathlessLayout.route-b.tsx /route-b <Root><PathlessLayout><RouteB>
ʦ files.$.tsx /files/$ <Root><Files>
ʦ account.tsx /account <Root><Account>
ʦ account.overview.tsx /account/overview <Root><Account><Overview>

混合扁平路由和目录路由

极有可能 100% 的目录结构或 100% 的扁平路由结构都不完全适合你的项目,这就是为什么 TanStack Router 允许你将扁平路由和目录路由混合使用,从而创建一个结合两者优点的路由树。

请看下面的例子:

文件名 (Filename) 路由路径 (Route Path) 组件输出 (Component Output)
ʦ __root.tsx <Root>
ʦ index.tsx / (精确匹配) <Root><RootIndex>
ʦ about.tsx /about <Root><About>
ʦ posts.tsx /posts <Root><Posts>
📂 posts
┄ ʦ index.tsx /posts (精确匹配) <Root><Posts><PostsIndex>
┄ ʦ $postId.tsx /posts/$postId <Root><Posts><Post>
┄ ʦ $postId.edit.tsx /posts/$postId/edit <Root><Posts><Post><EditPost>
ʦ settings.tsx /settings <Root><Settings>
ʦ settings.profile.tsx /settings/profile <Root><Settings><Profile>
ʦ settings.notifications.tsx /settings/notifications <Root><Settings><Notifications>
ʦ account.tsx /account <Root><Account>
ʦ account.overview.tsx /account/overview <Root><Account><Overview>

扁平路由和目录路由可以混合在一起,以便在合理的地方结合两者的优点来创建路由树。

[!TIP]

如果你发现默认的基于文件的路由结构不符合你的需求,你始终可以使用 虚拟文件路由 (Virtual File Routes) 来控制路由的来源,同时仍然享受基于文件路由带来的出色性能优势。

开始使用基于文件的路由

要开始使用基于文件的路由,你需要配置你项目的打包工具(Bundler)以使用 TanStack Router Plugin 或 TanStack Router CLI。

要启用基于文件的路由,你需要在使用 React 的同时使用受支持的打包工具。请查看下面的配置指南,看看你的打包工具是否在列表中。

当通过受支持的打包工具使用 TanStack Router 的基于文件的路由时,插件将通过打包工具的开发和构建过程自动生成你的路由配置。这是使用 TanStack Router 路由生成功能最简单的方法。

TanStack Router 路由匹配

路由匹配遵循一致且可预测的模式。本指南将解释路由树是如何进行匹配的。

当 TanStack Router 处理你的路由树时,你的所有路由都会被自动排序,以便优先匹配**最具体(most specific)**的路由。这意味着无论你定义路由树的顺序如何,路由总是会按照以下顺序进行排序:

  1. 索引路由 (Index Route)

  2. 静态路由 (Static Routes)(从最具体到最不具体)

  3. 动态路由 (Dynamic Routes)(从最长到最短)

  4. Splat/通配符路由 (Splat/Wildcard Routes)

考虑以下的伪路由树(定义时的顺序):

Root
  - blog
    - $postId
    - /
    - new
  - /
  - *
  - about
  - about/us

排序后,这个路由树将变为:

Root
  - /          (根索引路由,优先级最高)
  - about/us   (最具体的静态路由)
  - about      (较短的静态路由)
  - blog       (静态路由)
    - /        (Blog 的索引路由)
    - new      (Blog 下的静态路由)
    - $postId  (Blog 下的动态路由)
  - * (Splat/通配符,优先级最低)

这个最终顺序代表了基于特异性(specificity)进行路由匹配的顺序。

使用上述排序后的路由树,让我们来看看几个不同 URL 的匹配过程:

  • /blog
    Root
      ❌ /
      ❌ about/us
      ❌ about
      ⏩ blog
        ✅ /
        - new
        - $postId
      - *
    
  • /blog/my-post
    Root
      ❌ /
      ❌ about/us
      ❌ about
      ⏩ blog
        ❌ /
        ❌ new
        ✅ $postId
      - *
    
  • /
    Root
      ✅ /
      - about/us
      - about
      - blog
        - /
        - new
        - $postId
      - *
    
  • /not-a-route
    Root
      ❌ /
      ❌ about/us
      ❌ about
      ❌ blog
        - /
        - new
        - $postId
      ✅ *
    

TanStack Router 路由概念

TanStack Router 支持许多强大的路由概念,使你能够轻松构建复杂且动态的路由系统。

每一个概念都非常实用且强大,我们将在这个章节深入探讨它们。

路由的剖析

除了 根路由(Root Route) 之外,所有其他路由都是使用 createFileRoute 函数配置的,这在基于文件的路由中提供了类型安全:

import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
  component: PostsComponent,
})

createFileRoute 函数接受一个参数,即作为字符串的文件路由路径。

❓❓❓ “等等,你要我把路由文件的路径传给 createFileRoute?”

是的!但别担心,这个路径是由路由器通过 TanStack Router Bundler Plugin 或 Router CLI 为你自动写入和管理的。 因此,当你创建新路由、移动路由或重命名路由时,路径会自动为你更新。

需要这个路径名的原因完全在于 TanStack Router 神奇的类型安全机制。如果没有这个路径名,TypeScript 根本不知道我们在哪个文件里!(我们希望 TypeScript 对此有内置支持,但目前还没有 🤷‍♂️)

根路由 (The Root Route)

根路由是整个树中最顶层的路由,并将所有其他路由作为子路由封装起来。

  • 它没有路径

  • 总是被匹配

  • 它的 component 总是被渲染

即使它没有路径,根路由也可以访问与其他路由相同的所有功能,包括:

  • 组件 (components)

  • 加载器 (loaders)

  • 搜索参数验证 (search param validation)

  • 等等

要创建一个根路由,请调用 createRootRoute() 函数并将其作为 Route 变量在你的路由文件中导出:

// 标准根路由
import { createRootRoute } from '@tanstack/react-router'

export const Route = createRootRoute()

// 带 Context 的根路由
import { createRootRouteWithContext } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'

export interface MyRouterContext {
  queryClient: QueryClient
}
export const Route = createRootRouteWithContext<MyRouterContext>()

要了解更多关于 TanStack Router 中的 Context,请参阅 路由上下文 (Router Context) 指南。

基础路由 (Basic Routes)

基础路由匹配特定的路径,例如 /about/settings/settings/notifications 都是基础路由,因为它们精确匹配路径。

让我们看一个 /about 路由:

// about.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/about')({
  component: AboutComponent,
})

function AboutComponent() {
  return <div>About</div>
}

基础路由简单直接。它们精确匹配路径并渲染提供的组件。

索引路由 (Index Routes)

索引路由专门用于当父路由被精确匹配且没有子路由被匹配的情况。

让我们看一个针对 /posts URL 的索引路由:

// posts.index.tsx
import { createFileRoute } from '@tanstack/react-router'

// 注意末尾的斜杠,它用于定位索引路由
export const Route = createFileRoute('/posts/')({
  component: PostsIndexComponent,
})

function PostsIndexComponent() {
  return <div>Please select a post!</div>
}

当 URL 精确为 /posts 时,此路由将被匹配。

动态路由片段 (Dynamic Route Segments)

$ 开头并跟随一个标签的路由路径片段是动态的,会将 URL 的该部分捕获到 params 对象中,供你的应用程序使用。例如,路径名 /posts/123 将匹配 /posts/$postId 路由,并且 params 对象将是 { postId: '123' }

这些参数随后可以在你的路由配置和组件中使用!让我们看一个 posts.$postId.tsx 路由:

import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({
  // 在 loader 中使用
  loader: ({ params }) => fetchPost(params.postId),
  // 或者在组件中使用
  component: PostComponent,
})

function PostComponent() {
  // 在组件中!
  const { postId } = Route.useParams()
  return <div>Post ID: {postId}</div>
}

🧠 动态片段在路径的每个片段都能工作。例如,你可以有一个路径为 /posts/$postId/$revisionId 的路由,每个 $ 片段都会被捕获到 params 对象中。

Splat / 捕获所有 (Catch-All) 路由

路径仅为 $ 的路由被称为 “splat” 路由,因为它 总是 捕获从 $ 开始到结束的 URL 路径名的 任何 剩余部分。捕获的路径名随后可在 params 对象中的特殊 _splat 属性下获得。

例如,针对 files/$ 路径的路由就是一个 splat 路由。如果 URL 路径名是 /files/documents/hello-world,则 params 对象将在特殊 _splat 属性下包含 documents/hello-world

{
  '_splat': 'documents/hello-world'
}

⚠️ 在路由器的 v1 版本中,为了向后兼容,splat 路由也可以用 * 代替 _splat 键来表示。这将在 v2 中移除。

🧠 为什么要用 $?多亏了像 Remix 这样的工具,我们知道尽管 * 是表示通配符的最常见字符,但它们与文件名或 CLI 工具的配合并不好,所以就像他们一样,我们决定使用 $ 代替。

可选路径参数 (Optional Path Parameters)

可选路径参数允许你定义 URL 中可能存在也可能不存在的路由片段。它们使用 {-$paramName} 语法,并提供灵活的路由模式,其中某些参数是可选的。

// posts.{-$category}.tsx - 可选的 category 参数
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/{-$category}')({
  component: PostsComponent,
})

function PostsComponent() {
  const { category } = Route.useParams()

  return <div>{category ? `Posts in ${category}` : 'All Posts'}</div>
}

此路由将同时匹配 /posts(category 为 undefined)和 /posts/tech(category 为 "tech")。

你也可以在单个路由中定义多个可选参数:

// posts.{-$category}.{-$slug}.tsx
export const Route = createFileRoute('/posts/{-$category}/{-$slug}')({
  component: PostsComponent,
})

此路由匹配 /posts/posts/tech/posts/tech/hello-world

🧠 带有可选参数的路由优先级低于精确匹配,确保像 /posts/featured 这样更具体的路由会在 /posts/{-$category} 之前被匹配。

布局路由 (Layout Routes)

布局路由用于使用额外的组件和逻辑包裹子路由。它们通常用于:

  • 用布局组件包裹子路由

  • 在显示任何子路由之前强制执行 loader 要求

  • 验证并向子路由提供搜索参数 (search params)

  • 为子路由提供错误组件或挂起 (pending) 元素的回退 (fallbacks)

  • 向所有子路由提供共享上下文 (context)

  • 等等!

让我们看一个名为 app.tsx 的布局路由示例:

routes/
├── app.tsx
├── app.dashboard.tsx
├── app.settings.tsx

在上面的树中,app.tsx 是一个布局路由,它包裹了两个子路由:app.dashboard.tsxapp.settings.tsx

这种树结构用于用布局组件包裹子路由:

import { Outlet, createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/app')({
  component: AppLayoutComponent,
})

function AppLayoutComponent() {
  return (
    <div>
      <h1>App Layout</h1>
      <Outlet />
    </div>
  )
}

下表显示了基于 URL 将渲染哪些组件:

URL Path Component
/app <AppLayout>
/app/dashboard <AppLayout><Dashboard>
/app/settings <AppLayout><Settings>

由于 TanStack Router 支持混合扁平路由和目录路由,你也可以在目录中使用布局路由来表达应用程序的路由:

routes/
├── app/
│   ├── route.tsx
│   ├── dashboard.tsx
│   ├── settings.tsx

在这个嵌套树中,app/route.tsx 文件是布局路由的配置,它包裹了两个子路由:app/dashboard.tsxapp/settings.tsx

布局路由还允许你对动态路由片段强制执行组件和加载器逻辑:

routes/
├── app/users/
│   ├── $userId/
|   |   ├── route.tsx
|   |   ├── index.tsx
|   |   ├── edit.tsx

无路径布局路由 (Pathless Layout Routes)

布局路由 一样,无路径布局路由用于使用额外的组件和逻辑包裹子路由。但是,无路径布局路由不需要在 URL 中有匹配的 path,它们用于包裹子路由而无需 URL 路径匹配。

无路径布局路由以前划线 (_) 为前缀,表示它们是“无路径”的。

🧠 _ 前缀之后的部分用作路由的 ID,这是必需的,因为每个路由必须是唯一可识别的,特别是在使用 TypeScript 时,以避免类型错误并有效地实现自动完成。

让我们看一个名为 _pathlessLayout.tsx 的路由示例:


routes/
├── _pathlessLayout.tsx
├── _pathlessLayout.a.tsx
├── _pathlessLayout.b.tsx

在上面的树中,_pathlessLayout.tsx 是一个无路径布局路由,它包裹了两个子路由:_pathlessLayout.a.tsx_pathlessLayout.b.tsx

_pathlessLayout.tsx 路由用于用无路径布局组件包裹子路由:

import { Outlet, createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_pathlessLayout')({
  component: PathlessLayoutComponent,
})

function PathlessLayoutComponent() {
  return (
    <div>
      <h1>Pathless layout</h1>
      <Outlet />
    </div>
  )
}

下表显示了基于 URL 将渲染哪个组件:

URL Path Component
/ <Index>
/a <PathlessLayout><A>
/b <PathlessLayout><B>

由于 TanStack Router 支持混合扁平路由和目录路由,你也可以在目录中使用无路径布局路由来表达应用程序的路由:

routes/
├── _pathlessLayout/
│   ├── route.tsx
│   ├── a.tsx
│   ├── b.tsx

但是,与布局路由不同,由于无路径布局路由不基于 URL 路径片段进行匹配,这意味着这些路由不支持将 动态路由片段 作为其路径的一部分,因此无法在 URL 中进行匹配。

这意味着你不能这样做:

routes/
├── _$postId/ ❌
│   ├── ...

相反,你必须这样做:

routes/
├── $postId/
├── _postPathlessLayout/ ✅
│   ├── ...

非嵌套路由 (Non-Nested Routes)

非嵌套路由可以通过在父文件路由片段后加上 _ 后缀来创建,用于将路由从其父级中取消嵌套 (un-nest) 并渲染其自己的组件树。

考虑以下的扁平路由树:

routes/
├── posts.tsx
├── posts.$postId.tsx
├── posts_.$postId.edit.tsx

下表显示了基于 URL 将渲染哪个组件:

URL Path Component
/posts <Posts>
/posts/123 <Posts><Post postId="123">
/posts/123/edit <PostEditor postId="123">
  • posts.$postId.tsx 路由正常嵌套在 posts.tsx 路由下,将渲染 <Posts><Post>

  • posts_.$postId.edit.tsx 路由与其他路由不共享相同的 posts 前缀,因此将被视为顶层路由,并将渲染 <PostEditor>

[!NOTE]

虽然在基于文件的路由中使用非嵌套路由已经非常出色,但在某些条件下可能会出现异常行为。

其中许多限制已经在下一主要版本的 TanStack Router 中得到解决并将发布。

要尽早享受这些好处,你可以在路由器插件配置中启用实验性的 nonNestedRoutes 标志:

export default defineConfig({
   plugins: [
     tanstackRouter({
       // some config,
       experimental: {
         nonNestedRoutes: true,
       },
     }),
   ],
})

重要提示:这确实会导致在 useParams、useNavigate 等中引用非嵌套路由的方式发生轻微变化。因此,这已作为功能标志发布。

路径中不再预期包含尾随下划线:

以前:

useParams({ from: '/posts_/$postId/edit' })

现在:

useParams({ from: '/posts/$postId/edit' })

从路由中排除文件和文件夹

可以通过在文件名后附加 - 前缀来将文件和文件夹从路由生成中排除。这使你能够在路由目录中共存逻辑代码。

考虑以下的路由树:

routes/
├── posts.tsx
├── -posts-table.tsx // 👈🏼 被忽略
├── -components/ // 👈🏼 被忽略
│   ├── header.tsx // 👈🏼 被忽略
│   ├── footer.tsx // 👈🏼 被忽略
│   ├── ...

我们可以从排除的文件中导入内容到我们的 posts 路由中:

import { createFileRoute } from '@tanstack/react-router'
import { PostsTable } from './-posts-table'
import { PostsHeader } from './-components/header'
import { PostsFooter } from './-components/footer'

export const Route = createFileRoute('/posts')({
  loader: () => fetchPosts(),
  component: PostComponent,
})

function PostComponent() {
  const posts = Route.useLoaderData()

  return (
    <div>
      <PostsHeader />
      <PostsTable posts={posts} />
      <PostsFooter />
    </div>
  )
}

排除的文件不会被添加到 routeTree.gen.ts 中。

无路径路由组目录 (Pathless Route Group Directories)

无路径路由组目录使用 () 作为一种将路由文件分组的方式,无论其路径如何。它们纯粹是组织性的,不会以任何方式影响路由树或组件树。

routes/
├── index.tsx
├── (app)/
│   ├── dashboard.tsx
│   ├── settings.tsx
│   ├── users.tsx
├── (auth)/
│   ├── login.tsx
│   ├── register.tsx

在上面的示例中,appauth 目录纯粹是组织性的,不会以任何方式影响路由树或组件树。它们用于将相关的路由分组在一起,以便于导航和组织。

下表显示了基于 URL 将渲染哪个组件:

URL Path Component
/ <Index>
/dashboard <Dashboard>
/settings <Settings>
/users <Users>
/login <Login>
/register <Register>

如你所见,appauth 目录纯粹是组织性的,不会以任何方式影响路由树或组件树。

原生js实现高性能列表拖拽排序

效果图 支持电脑和手机移动端,纯基于transform的移动,无任务dom操作

sort.gif

在线体验

完整代码见下面的码上掘金,移动端点击查看详情预览

image.png

我是如何实现一个基于 Pointer Events 的可拖拽排序列表的

这篇文章记录了我在实现一个 不依赖任何第三方库 的拖拽排序列表时的完整思考过程与实现细节。与其说是代码讲解,不如说是一份 设计动机 + 踩坑记录 + 实现原理 的总结。该实现主要基于 Pointer EventsgetBoundingClientRect 以及 translateY 位移动画,并解决了拖拽排序中常见的几个难点:

  • 快速拖动时 pointer 事件丢失
  • 元素跨越多个位置时的“漏位”问题
  • 不直接操作 DOM 顺序,而是通过位移实现顺滑动画

如果你对 SortableJS、拖拽交互或底层实现原理感兴趣,这篇文章会非常适合你。


一、为什么我要自己实现一个拖拽排序

在一开始,其实我也考虑过直接使用 SortableJS 之类的成熟方案。但在实际需求和学习过程中,我发现几个问题:

  • 我想完全掌控拖拽过程,而不是黑盒调用
  • 我需要理解 快速拖动、跨多个位置时到底发生了什么
  • 我之前实现的简单拖拽 demo,在快速滑动时经常出现 pointermove 丢失、元素顺序错乱 的问题

所以最终我决定:

自己从零实现一个拖拽排序,并把每一个细节都想清楚。

在这个过程中,我踩了不少坑,也逐渐总结出一套比较稳定的实现方式。


二、整体思路概览

这套拖拽排序的核心思想可以总结为一句话:

DOM 顺序保持不变,通过 translateY 模拟位置变化,并在拖拽过程中动态维护一个“逻辑顺序数组”。

具体拆解如下:

  1. 初始化阶段

    • 记录每个 item 的初始位置信息(bounding rect)
    • 建立一个 listChildren 数组,用来表示当前“逻辑顺序”
  2. 拖拽开始(pointerdown)

    • 记录拖拽起点
    • 捕获 pointer,避免快速滑动导致事件丢失
  3. 拖拽中(pointermove)

    • 实时更新被拖拽元素的 translateY
    • 根据指针位置判断进入了哪个 item 区域
    • 批量移动中间元素,制造自动让位效果
  4. 拖拽结束(pointerup)

    • 将拖拽元素吸附到最终 slot
    • 通过 transition 实现回弹动画

三、HTML 与 CSS:为拖拽打基础

1. HTML 结构

<div class="list">
  <div class="item">0</div>
  <div class="item">1</div>
  <div class="item">2</div>
  <div class="item">3</div>
  <div class="item">4</div>
</div>
  • list:拖拽容器
  • item:可拖拽元素

2. 关键 CSS 点

.list {
  touch-action: none;
}

.item {
  transition: transform 0.3s ease-out;
  user-select: none;
}

重点说明:

  • touch-action: none

    • 禁止浏览器默认的滚动、缩放等手势
    • 否则 pointermove 在移动端会被系统劫持
  • transition: transform

    • 非拖拽元素产生“自动位移”动画
  • user-select: none

    • 防止拖拽时选中文本

四、初始化:建立位置信息模型

const listChildren = Array.from(list.children);
const rectInfo = [];

Array.from(list.children).forEach((item, index) => {
  item.dataset.index = index;
  const rect = item.getBoundingClientRect();
  Object.assign(rect, {
    id: index,
    index,
    located: {
      x: rect.x,
      y: rect.y,
    },
  });
  rectInfo.push(rect);
});

这里做了三件事:

  1. 记录初始顺序

    • dataset.index 表示逻辑索引
  2. 缓存位置信息

    • getBoundingClientRect() 只在初始化时调用一次
    • 避免拖拽过程中频繁触发回流
  3. rectInfo 是“静态坐标系”

    • 所有位移计算都基于初始 top 值

五、pointerdown:拖拽的起点(第一个大坑)

list.addEventListener("pointerdown", (e) => {
  if (e.target.classList.contains("list")) return;

  isPointerDown = true;
  e.target.setPointerCapture(e.pointerId);

  e.target.style.zIndex = 1;
  e.target.style.transition = "none";

  slotIndex = dragIndex = Number(e.target.dataset.index);
  dragPos.startY = e.clientY;
  dragPos.startTranslateY = Number(e.target.dataset.translateY || 0);
});

核心点解析

1. setPointerCapture

这是我在实现过程中踩到的第一个大坑,也是这套方案里最关键的一点。

  • 指针即使移动到元素外
  • 事件依然会派发给当前元素

这在快速拖拽、多点触控场景中非常重要。

2. 禁用 transition
e.target.style.transition = "none";
  • 拖拽元素需要 1:1 跟随手指
  • 不能有动画延迟

六、pointermove:排序的核心逻辑(也是最复杂的部分)

1. 拖拽元素位移

dragPos.diffY = e.clientY - dragPos.startY + dragPos.y;

e.target.style.transform = `translateY(${dragPos.diffY}px)`;
  • diffY = 当前指针位置 - 起点 + 初始偏移
  • 使用 transform 不会触发布局

2. 判断进入哪个 slot

if (
  e.clientX > r.left &&
  e.clientX < r.left + r.width &&
  e.clientY > r.y &&
  e.clientY < r.y + r.height
)

这是一个典型的指针命中检测(Hit Test)

  • 不依赖 DOM 顺序
  • 只看指针是否进入某个 rect

3. 批量位移:解决“跨多个位置”问题

向下拖动(i > slotIndex)
for (let j = slotIndex + 1; j <= i; j++) {
  listChildren[j].dataset.translateY =
    rectInfo[j - 1].top - rectInfo[j].top +
    Number(listChildren[j].dataset.translateY || 0);
}
  • 中间所有元素依次向上让位
  • 即使 pointermove 触发频率较低,也不会漏掉中间项
向上拖动同理

这正是很多“简单拖拽 demo”做不到的地方。


4. 逻辑顺序数组的维护

listChildren.splice(j - 1, 0, listChildren[j]);
listChildren.splice(j + 1, 1);
  • 不操作 DOM
  • 只维护一个 JS 数组
  • dataset.index 永远和逻辑顺序保持一致

这也是后续回弹计算的关键。


七、pointerup:吸附与回弹

dragPos.y =
  rectInfo[slotIndex].top -
  rectInfo[dragIndex].top +
  dragPos.startTranslateY;

e.target.style.transition = "transform .3s ease-out";
e.target.style.transform = `translateY(${dragPos.y}px)`;

这里发生了什么?

  • 根据 最终 slot 与初始 slot 的 top 差值
  • 计算拖拽元素应该落到的位置
  • 重新启用 transition,形成回弹动画

整个排序过程在视觉上完成闭环。


八、核心流程示意图:逻辑顺序 vs DOM 顺序

这是我在实现过程中最重要的一个认知转变

1️⃣ DOM 顺序(始终不变)

DOM:
[item0]
[item1]
[item2]
[item3]
[item4]

在整个拖拽过程中:

  • DOM 节点从未被插入 / 删除
  • 浏览器布局不会反复重排

2️⃣ 逻辑顺序(JS 数组维护)

拖拽 item1 向下移动:

逻辑数组 listChildren:
[item0] [item2] [item3] [item1] [item4]
  • listChildren 才是真正的“当前顺序”
  • dataset.index 始终和逻辑顺序一致

3️⃣ 视觉位置(translateY)

视觉层:
item1  ─────────▶  translateY(+120px)
item2  ◀─────────  translateY(-60px)
item3  ◀─────────  translateY(-60px)

DOM 不动,数组变,transform 在动

理解了这一点,后面的所有位移、动画和回弹计算都会非常自然。


九、总结:这套方案解决了什么问题

这份代码的几个亮点:

  • ✅ 使用 Pointer Events,统一鼠标 / 触摸
  • setPointerCapture 解决快速拖拽问题
  • ✅ 不操作 DOM 顺序,动画性能极佳
  • ✅ 支持跨多个位置的稳定排序

如果你理解了这套逻辑,再去看 SortableJS 的源码,会发现很多思想是相通的。


十、可以继续优化的方向

  • 拖拽结束后真正更新 DOM 顺序
  • 支持横向 / Grid 排序
  • 增加 placeholder 占位元素
  • 增加拖拽阈值、防抖处理

【实战】Flex布局-上下自适应

经典的布局场景上下固定、中间填充剩余空间。

思路

将 Flex 主轴设为垂直方向(column),通过 flex: 1 让中间区域自动填充剩余空间,上下区域固定高度或由内容自动撑开。

原理

  1. 父容器设置
    display: flex
    flex-direction: column- 垂直方向排列
    min-height: 100vh- 确保容器至少占满整个视口高度
  2. 顶部 / 底部区域设置固定高度(如 50px),不指定高度由内容自动撑开。
  3. 中间区域设置 flex: 1,自动占据父容器剩余高度;
  4. overflow-y: auto - 内容超出时显示滚动条

示例

<header class="header">头部</header>
  <main class="main">内容区</main>
  <footer class="footer">底部</footer>
</div>
* {
  margin: 0;
  padding: 0;
}
 
html, body {
  height: 100%;
}
 
.container {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}
 
.header {
  /* 头部高度由内容决定,不设固定高度 */
  background: #333;
  color: white;
  padding: 20px;
}
 
.main {
  flex: 1;  /* 关键:这将使主内容区填充剩余空间 */
  padding: 20px;
  overflow-y: auto; /* 内容过多时可滚动 */
}
 
.footer {
  background: #333;
  color: white;
  padding: 20px;
}

浏览器兼容性

Flex 布局在现代浏览器中有很好的支持(IE10+),如需兼容旧版浏览器,可考虑使用传统方法作为降级方案。

Java传参还在瞎传?这3个进阶基础技巧少走1年弯路

大家好,我是掘金打码仔~Java传参看着简单,实则藏着不少坑,新手容易懵、老鸟偶尔踩,今天分享3个进阶偏基础的传参技巧,实用性拉满,代码短小精悍,看完就能用!

1. 分清值传递vs引用传递,告别传参玄学

Java只有值传递!很多人搞混引用类型传参,误以为是引用传递,其实本质是传引用的副本,记住2个核心结论不踩坑。

✅ 核心结论

  • 基本类型:传副本,方法内修改不影响原变量
  • 引用类型:传地址副本,改对象属性影响原对象,改引用指向不影响
// 示例1:基本类型(值传递,互不影响)
public static void changeInt(int num) {
    num = 100; // 改的是副本
}
int a = 10;
changeInt(a);
System.out.println(a); // 输出10,不变

// 示例2:引用类型(改属性影响原对象)
class Student {String name;}
public static void changeName(Student stu) {
    stu.name = "掘金"; // 改对象内容,影响原对象
}
Student s = new Student();
s.name = "小白";
changeName(s);
System.out.println(s.name); // 输出掘金

// 示例3:引用类型(改指向不影响原对象)
public static void changeStu(Student stu) {
    stu = new Student(); // 改引用指向,和原对象无关
    stu.name = "大佬";
}
changeStu(s);
System.out.println(s.name); // 还是掘金

 

2. 可变参数:告别数组传参,灵活又优雅

Java5+支持可变参数,用 ... 表示,适配参数个数不固定的场景,替代数组传参更简洁,底层还是数组,但写法更舒服。

✅ 核心用法&注意事项

  • 格式:类型... 参数名,必须放参数列表最后
  • 调用:可传多个同类型参数,也可传数组
  • 取值:按数组方式取值即可
// 传统数组传参(麻烦)
public static int sumArr(int[] nums) {
    int sum = 0;
    for (int num : nums) sum += num;
    return sum;
}

// 可变参数写法(简洁)
public static int sum(int... nums) { // 可变参数放最后
    int sum = 0;
    for (int num : nums) sum += num; // 按数组遍历
    return sum;
}

// 调用超灵活
int s1 = sum(1,2,3); // 传多个参数
int s2 = sum(1,2,3,4,5); // 个数随意
int[] arr = {6,7,8};
int s3 = sum(arr); // 传数组也能行
System.out.println(s1+s2+s3); // 输出45

 

3. 传参兜底:默认参数这么写,优雅不啰嗦

Java没有原生默认参数,但2种替代方案超实用,不用写重载方法也能实现参数兜底,进阶基础必备小技巧。

✅ 2种实用方案

  • 方案1:方法内三元运算符兜底(简单场景)
  • 方案2:重载方法+this调用(复杂场景)
// 方案1:三元运算符兜底(简单好用)
public static void printMsg(String msg) {
    // 传null/空字符串,兜底给默认值
    String finalMsg = (msg == null || msg.isEmpty()) ? "默认消息" : msg;
    System.out.println(finalMsg);
}

// 调用
printMsg("掘金干货"); // 输出掘金干货
printMsg(null); // 输出默认消息

// 方案2:重载+this调用(多参数场景)
public static void add(int a) {
    add(a, 0); // 调用下面的方法,给b默认值0
}

public static void add(int a, int b) {
    System.out.println(a + b);
}

// 调用
add(5); // 传1个参数,默认b=0,输出5
add(5,3); // 传2个参数,输出8

基于vant3的搜索选择组件

效果如下:

使用:

<search-list
  :list="List"
  :isShow="true"
  :isAddShow="true"
  :keyName="key"
  :typeName="name"
  @item-check="itemCheck"
  @show-status="showStatus"
  @call-back="callBack"
/>

参数值:

List: any[] = [{},{}];
isAddShow: 新增的模块是否显示  不填默认为false
keyName: 搜素及显示的键名
typeName: 分类名
itemCheck: 返回选中数据
showStatus: 返回弹窗是否show  param: boolean
callBack: 点击新增按钮返回数据  param: 搜素框中的输入值

例:

List= [
  {
    driverId: '111011101',
    driverName: '司机姓名',
    driverTel: '13112341234',
  }
]
keyName='driverName' 搜素列表显示driverName的数据,搜索页搜索driverName
typeName='司机' 搜索弹窗:【以下不是我的司机】 【点击建立新的司机】
itemCheck: 返回数据为: params: {driverId:'',driverName:'',driverTel:''}
callBack: 返回数据为'司机'

vue:

<template>
  <van-popup v-model:show="show" round position="bottom" :closeable="true" :close-icon="closeImg">
    <div class="list-box">
      <div class="top-box">
        <p class="title">请选择{{ typeName }}</p>
        <div class="search-box">
          <van-search
            v-model="searchValue"
            show-action
            autocomplete="off"
            autofocus
            placeholder="请输入搜索关键词"
            @search="onSearch"
            @clear="onClear"
          >
            <template #action>
              <div @click="onSearch" class="search-btn">搜索</div>
            </template>
          </van-search>
        </div>
      </div>

      <div class="list-box">
        <van-list finished-text="没有更多了">
          <van-cell class="add-box" v-if="addShow ? addShow : false">
            <div class="cell-title">以下不是我的{{ typeName }}</div>
            <div class="cell-info" @click="target">
              点击建立
              <span class="blue">新的{{ typeName }}</span>
            </div>
          </van-cell>
          <template v-for="item in listArr" :key="item">
            <van-cell @click="checkItem(item)">
              <span
                v-for="res in item[keyName]"
                :key="res"
                :class="
                  searchValue.length > 0 &&
                  searchValue.indexOf(res) > -1 &&
                  item[keyName].includes(searchValue)
                    ? 'active'
                    : ''
                "
              >
                {{ res }}
              </span>
            </van-cell>
          </template>
        </van-list>
      </div>
    </div>
  </van-popup>
</template>

ts:

<script lang="ts">
  import { defineComponent, ref, watch, watchEffect } from 'vue';
  import { Popup, Cell, Search, List, Toast } from 'vant';

  import closeImg from '/@/assets/images/close-1.png';

  export default defineComponent({
    name: 'SearchDriver',
    components: {
      [Popup.name]: Popup,
      [Cell.name]: Cell,
      [Search.name]: Search,
      [List.name]: List,
    },
    props: {
      list: {
        type: Array,
        required: true,
        default: () => {
          return [''];
        },
      },
      isShow: {
        type: Boolean,
        default: false,
      },
      keyName: {
        type: String,
        default: '',
      },
      typeName: {
        type: String,
        default: '司机',
      },
      isAddShow: {
        type: Boolean,
        default: false,
      },
    },
    emits: ['itemCheck', 'showStatus', 'callBack'],
    setup(props, { emit }) {
      const searchValue = ref<string>('');
      // 数组
      let propList = ref<string[]>(props.list as string[]);
      let newList = ref<string[]>(props.list as string[]);
      // 弹窗是否显示
      let show = ref<boolean>(props.isShow);
      // 新增模块是否显示
      let addShow = ref<boolean>(props.isAddShow);
      // 列表显示数据
      let listArr = ref<any>();
      // 类型
      let type = ref<string>(props.typeName);

      const dataMap = (arr: any[]) => {
        if (!arr || arr.length < 1) {
          return [];
        }
        return arr;
      };

      listArr.value = dataMap(propList.value);

      const onSearch = () => {
        console.log(searchValue.value);
      };

      // 点击新增按钮后
      const target = () => {
        emit('callBack', searchValue.value);
      };

      // 过滤掉不符合条件的数据
      const filterData = (list: any[]) => {
        newList.value = list.filter(
          (item) => item[props.keyName] && item[props.keyName].indexOf(searchValue.value) > -1
        );
        listArr.value = dataMap(newList.value);
      };

      // 选中数据
      const checkItem = (item: any) => {
        if (!item || item.length < 1) {
          Toast('请重新选择');
          return;
        }
        searchValue.value = item[props.keyName];
        show.value = false;
        emit('itemCheck', item);
        emit('showStatus', false);
      };

      const onClear = () => {
        searchValue.value = '';
        show.value = false;
        emit('itemCheck', '');
        emit('showStatus', false);
      };

      watchEffect(() => {
        show.value = props.isShow;
        propList.value = props.list as string[];
        newList.value = props.list as string[];
        type.value = props.typeName;
        filterData(newList.value);
      });

      watch(show, () => {
        emit('showStatus', show.value);
      });

      watch(type, (old, newValue) => {
        if (old !== newValue) {
          searchValue.value = '';
        }
      });

      watch(searchValue, (val, oldVal) => {
        const len = val.length;
        const oldLen = oldVal.length;
        if (len > oldLen) {
          // 写入
          filterData(newList.value);
        } else {
          // 删除
          filterData(propList.value);
        }
      });

      return {
        searchValue,
        show,
        listArr,
        onSearch,
        onClear,
        checkItem,
        target,
        addShow,
        closeImg,
      };
    },
  });
</script>

scss:

@charset "UTF-8";

$topHeight: 160px;
$vnoTop: 36px;
$checkColor: #1777f2;

.list-box {
  position: relative;
  min-height: 50vh;
  padding-top: $vnoTop;

  .top-box {
    position: absolute;
    top: $vnoTop;
    left: 0;
    z-index: 1;
    width: 100%;
    height: $topHeight;
    background-color: #fff;
    border-bottom: 1px solid #e5e5e5;

    .title {
      margin-left: 40px;
      font-size: 36px;
      line-height: 50px;
      color: rgb(0 0 0 / 85%);
    }

    .search-box {
      width: 696px;
      margin-left: 15px;

      .search-btn {
        font-size: 28px;
        font-weight: 400;
        color: $checkColor;
      }
    }
  }

  .list-box {
    height: 76vh;
    margin-top: $topHeight;
    overflow: auto;

    .add-box {
      padding-left: 84px;
      background-image: url('../../assets/images/search.png');
      background-position: 40px 30px;
      background-repeat: no-repeat;
      background-size: 30px 30px;

      .cell-title {
        font-size: 32px;
        font-weight: 400;
        color: #1c1c1c;
      }

      .cell-info {
        font-size: 28px;
        font-weight: 400;
        color: rgb(0 0 0 / 25%);

        .blue {
          color: #1777f2;
        }
      }
    }
  }

  .van-cell {
    position: relative;

    &::before {
      position: absolute;
      right: var(--van-padding-md);
      bottom: 0;
      left: var(--van-padding-md);
      pointer-events: none;
      border-bottom: 1px solid var(--van-cell-border-color);
      content: ' ';
      transform: scaleY(0.5);
      box-sizing: border-box;
    }

    span.active {
      color: $checkColor;
    }
  }
}

博客园:www.cnblogs.com/wttt123/p/1…

以上。

SVG描边 - CSS3实现动画绘制矢量图

使用 SVG 的 stroke-dasharray 及 stroke-dashoffset,结合 CSS3 animation 实现画笔绘制矢量图的动画效果,如下:

html

<svg
    xmlns="http://www.w3.org/2000/svg"
    pointer-events="none"
    class="leaflet-zoom-animated"
    width="1452"
    height="857"
    viewBox="-121 -71 1452 857"
    style="transform: translate3d(-121px, -71px, 0px)"
  >
    <g>
      <path
        class="leaflet-interactive"
        stroke="black"
        stroke-opacity="1"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        fill="red"
        fill-opacity="0"
        fill-rule="evenodd"
        d="M605 623L605 625L609 621L610 623L613 622L616 624L624 616L630 612L637 610L638 608L641 610L639 614L642 616L645 616L646 613L652 610L652 608L653 610L657 610L657 602L659 602L660 600L656 584L653 580L652 575L654 569L652 561L655 555L656 557L660 555L663 556L666 549L670 547L670 544L676 540L680 535L680 531L683 527L681 527L680 524L677 523L678 518L682 516L686 511L690 514L697 509L695 499L693 498L696 494L696 490L698 493L699 492L701 494L706 492L708 494L711 491L722 492L722 494L720 494L718 497L714 497L716 507L721 508L721 510L724 511L723 514L721 514L722 516L724 516L724 519L720 522L717 522L726 531L730 533L737 530L743 530L745 528L745 525L746 526L748 524L748 521L751 520L757 521L757 525L761 525L762 527L759 530L760 533L763 534L762 540L769 536L776 539L778 542L784 539L787 540L788 538L800 536L803 537L810 534L808 530L809 527L805 524L806 520L801 516L798 516L795 518L796 519L793 519L792 517L789 519L789 512L784 507L782 511L778 508L778 502L776 500L777 498L780 497L780 494L789 494L789 491L793 485L796 489L798 489L799 492L801 491L803 495L809 500L811 500L811 497L809 496L809 490L804 478L796 478L796 475L794 474L789 478L788 476L787 477L783 473L784 472L782 468L783 466L781 464L781 458L779 460L777 458L776 460L774 458L772 459L770 458L770 456L768 457L769 452L766 451L766 449L763 450L764 449L762 443L767 439L766 438L769 438L770 436L773 437L775 434L775 430L782 428L781 425L783 420L780 419L778 415L776 415L775 412L774 413L774 411L778 411L777 406L782 407L781 404L785 398L790 399L791 401L796 401L800 405L801 404L804 410L808 410L808 408L813 408L814 405L820 404L823 407L822 409L825 412L826 417L830 420L832 418L836 419L839 416L841 419L844 417L843 416L848 412L855 398L863 387L860 385L861 383L859 382L863 375L869 377L871 374L873 374L874 378L876 377L877 379L885 373L882 370L885 366L888 366L886 363L889 358L889 355L886 355L894 350L892 348L895 346L894 343L896 341L900 341L901 339L903 339L902 337L905 334L908 334L908 329L910 327L903 324L903 322L901 322L897 316L901 316L901 311L906 310L902 305L906 305L909 303L907 300L900 302L895 301L893 303L887 303L885 307L882 304L879 304L874 296L872 297L871 293L867 292L864 294L860 292L860 286L858 285L854 288L852 293L850 292L843 285L845 282L842 277L843 276L828 275L823 278L821 277L815 279L813 281L810 278L802 277L797 273L798 270L796 268L795 270L786 270L784 274L781 273L777 277L774 274L771 265L769 264L767 265L767 270L770 274L762 279L760 278L755 282L753 282L750 279L745 282L743 282L742 279L734 279L722 273L716 268L716 264L719 264L723 260L719 258L720 254L717 244L722 243L718 242L711 235L711 227L706 223L697 223L693 225L687 221L681 224L683 220L681 216L677 220L674 220L669 216L665 216L666 211L663 209L665 206L664 202L666 201L662 196L656 195L657 193L655 189L652 189L651 191L642 195L642 197L639 198L642 200L633 202L632 201L630 207L626 207L627 206L618 208L623 213L623 217L627 219L625 221L628 221L626 224L627 226L634 231L632 232L632 237L629 239L625 238L621 242L621 248L617 245L615 250L610 250L606 255L604 254L605 250L602 246L606 244L606 241L610 236L605 228L603 231L602 230L602 236L598 237L596 243L599 244L600 247L595 255L589 248L584 249L585 252L580 246L575 246L574 248L572 248L574 253L573 256L575 258L575 261L572 261L572 265L577 268L577 275L574 279L572 280L572 278L570 278L564 282L562 280L556 280L553 278L554 281L551 282L552 286L550 286L545 282L540 275L539 277L539 275L536 276L533 274L531 282L530 280L527 280L525 278L527 276L527 269L524 266L522 266L523 264L519 263L519 261L516 258L514 264L511 263L510 265L512 267L508 275L508 273L503 272L501 269L498 269L493 266L488 267L484 258L471 254L468 251L465 244L466 240L464 238L464 234L460 231L458 227L460 225L454 218L452 218L453 217L449 213L450 204L448 203L448 201L440 202L435 199L432 200L428 195L421 194L420 197L415 199L417 203L414 206L415 208L413 210L403 212L403 214L401 215L404 221L404 228L408 229L410 235L413 235L419 239L417 239L417 241L415 241L411 248L409 249L407 253L409 257L409 259L407 259L408 261L401 264L402 272L406 279L412 281L416 286L426 288L440 296L440 301L444 308L450 314L450 319L448 319L449 324L453 327L455 333L470 348L466 354L464 354L462 350L459 350L458 358L467 368L465 373L474 382L471 385L473 390L472 393L474 397L475 413L478 416L477 419L479 424L476 430L475 437L478 443L479 458L481 460L480 464L482 476L480 483L482 487L482 492L484 494L483 503L489 510L488 511L494 517L493 508L496 504L494 502L494 498L500 492L501 488L504 486L503 485L508 481L508 486L521 498L521 500L525 503L524 504L529 508L528 510L530 512L528 514L523 515L522 517L525 523L530 528L529 529L533 532L535 531L535 538L536 539L541 532L546 534L549 532L550 529L552 529L554 532L553 535L560 542L559 543L563 549L562 553L563 552L565 554L569 566L574 566L573 575L579 578L578 580L585 584L583 588L584 589L586 590L588 589L588 587L590 588L587 591L589 593L588 596L585 596L585 599L596 607L595 609L597 609L593 612L594 614L599 615L604 622zM806 410L806 410zM839 416L839 416zM774 411L774 411z"
        id="四川省"
      />
    </g>
  </svg>

style

path {
  stroke-dasharray: 2810;
  stroke-dashoffset: 2810;
  fill-opacity: 0;
  /* animation: name duration timing-function delay iteration-count direction fill-mode play-state; */
  animation: dash 10s linear 3s 1 forwards,
              dashOpacity 5s linear 14s 1 forwards;
}
@keyframes dash {
  to {
    stroke-dashoffset: 0;
  }
}
@keyframes dashOpacity {
  to {
    fill-opacity: 0.3;
  }
}

script

用于获取 svg 矢量图的长度

var path = document.querySelector("path");
var length = path.getTotalLength();
console.log(length);

拓展:在以上基础上实现流光效果

html

<svg
  xmlns="http://www.w3.org/2000/svg"
  pointer-events="cursor"
  width="1452"
  height="857"
  viewBox="-121 -71 1452 857"
>
  <g>
    <path
      class="animation-0"
      stroke-opacity="1"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      fill-opacity="0"
      fill-rule="evenodd"
      d="M605 623L605 625L609 621L610 623L613 622L616 624L624 616L630 612L637 610L638 608L641 610L639 614L642 616L645 616L646 613L652 610L652 608L653 610L657 610L657 602L659 602L660 600L656 584L653 580L652 575L654 569L652 561L655 555L656 557L660 555L663 556L666 549L670 547L670 544L676 540L680 535L680 531L683 527L681 527L680 524L677 523L678 518L682 516L686 511L690 514L697 509L695 499L693 498L696 494L696 490L698 493L699 492L701 494L706 492L708 494L711 491L722 492L722 494L720 494L718 497L714 497L716 507L721 508L721 510L724 511L723 514L721 514L722 516L724 516L724 519L720 522L717 522L726 531L730 533L737 530L743 530L745 528L745 525L746 526L748 524L748 521L751 520L757 521L757 525L761 525L762 527L759 530L760 533L763 534L762 540L769 536L776 539L778 542L784 539L787 540L788 538L800 536L803 537L810 534L808 530L809 527L805 524L806 520L801 516L798 516L795 518L796 519L793 519L792 517L789 519L789 512L784 507L782 511L778 508L778 502L776 500L777 498L780 497L780 494L789 494L789 491L793 485L796 489L798 489L799 492L801 491L803 495L809 500L811 500L811 497L809 496L809 490L804 478L796 478L796 475L794 474L789 478L788 476L787 477L783 473L784 472L782 468L783 466L781 464L781 458L779 460L777 458L776 460L774 458L772 459L770 458L770 456L768 457L769 452L766 451L766 449L763 450L764 449L762 443L767 439L766 438L769 438L770 436L773 437L775 434L775 430L782 428L781 425L783 420L780 419L778 415L776 415L775 412L774 413L774 411L778 411L777 406L782 407L781 404L785 398L790 399L791 401L796 401L800 405L801 404L804 410L808 410L808 408L813 408L814 405L820 404L823 407L822 409L825 412L826 417L830 420L832 418L836 419L839 416L841 419L844 417L843 416L848 412L855 398L863 387L860 385L861 383L859 382L863 375L869 377L871 374L873 374L874 378L876 377L877 379L885 373L882 370L885 366L888 366L886 363L889 358L889 355L886 355L894 350L892 348L895 346L894 343L896 341L900 341L901 339L903 339L902 337L905 334L908 334L908 329L910 327L903 324L903 322L901 322L897 316L901 316L901 311L906 310L902 305L906 305L909 303L907 300L900 302L895 301L893 303L887 303L885 307L882 304L879 304L874 296L872 297L871 293L867 292L864 294L860 292L860 286L858 285L854 288L852 293L850 292L843 285L845 282L842 277L843 276L828 275L823 278L821 277L815 279L813 281L810 278L802 277L797 273L798 270L796 268L795 270L786 270L784 274L781 273L777 277L774 274L771 265L769 264L767 265L767 270L770 274L762 279L760 278L755 282L753 282L750 279L745 282L743 282L742 279L734 279L722 273L716 268L716 264L719 264L723 260L719 258L720 254L717 244L722 243L718 242L711 235L711 227L706 223L697 223L693 225L687 221L681 224L683 220L681 216L677 220L674 220L669 216L665 216L666 211L663 209L665 206L664 202L666 201L662 196L656 195L657 193L655 189L652 189L651 191L642 195L642 197L639 198L642 200L633 202L632 201L630 207L626 207L627 206L618 208L623 213L623 217L627 219L625 221L628 221L626 224L627 226L634 231L632 232L632 237L629 239L625 238L621 242L621 248L617 245L615 250L610 250L606 255L604 254L605 250L602 246L606 244L606 241L610 236L605 228L603 231L602 230L602 236L598 237L596 243L599 244L600 247L595 255L589 248L584 249L585 252L580 246L575 246L574 248L572 248L574 253L573 256L575 258L575 261L572 261L572 265L577 268L577 275L574 279L572 280L572 278L570 278L564 282L562 280L556 280L553 278L554 281L551 282L552 286L550 286L545 282L540 275L539 277L539 275L536 276L533 274L531 282L530 280L527 280L525 278L527 276L527 269L524 266L522 266L523 264L519 263L519 261L516 258L514 264L511 263L510 265L512 267L508 275L508 273L503 272L501 269L498 269L493 266L488 267L484 258L471 254L468 251L465 244L466 240L464 238L464 234L460 231L458 227L460 225L454 218L452 218L453 217L449 213L450 204L448 203L448 201L440 202L435 199L432 200L428 195L421 194L420 197L415 199L417 203L414 206L415 208L413 210L403 212L403 214L401 215L404 221L404 228L408 229L410 235L413 235L419 239L417 239L417 241L415 241L411 248L409 249L407 253L409 257L409 259L407 259L408 261L401 264L402 272L406 279L412 281L416 286L426 288L440 296L440 301L444 308L450 314L450 319L448 319L449 324L453 327L455 333L470 348L466 354L464 354L462 350L459 350L458 358L467 368L465 373L474 382L471 385L473 390L472 393L474 397L475 413L478 416L477 419L479 424L476 430L475 437L478 443L479 458L481 460L480 464L482 476L480 483L482 487L482 492L484 494L483 503L489 510L488 511L494 517L493 508L496 504L494 502L494 498L500 492L501 488L504 486L503 485L508 481L508 486L521 498L521 500L525 503L524 504L529 508L528 510L530 512L528 514L523 515L522 517L525 523L530 528L529 529L533 532L535 531L535 538L536 539L541 532L546 534L549 532L550 529L552 529L554 532L553 535L560 542L559 543L563 549L562 553L563 552L565 554L569 566L574 566L573 575L579 578L578 580L585 584L583 588L584 589L586 590L588 589L588 587L590 588L587 591L589 593L588 596L585 596L585 599L596 607L595 609L597 609L593 612L594 614L599 615L604 622zM806 410L806 410zM839 416L839 416zM774 411L774 411z"
      id="四川省"
      stroke="#00ffd0"
      stroke-dasharray="2810"
      stroke-dashoffset="2810"
    />
    <path
      class="animation-1"
      stroke-opacity="0"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      fill-opacity="0"
      fill-rule="evenodd"
      d="M605 623L605 625L609 621L610 623L613 622L616 624L624 616L630 612L637 610L638 608L641 610L639 614L642 616L645 616L646 613L652 610L652 608L653 610L657 610L657 602L659 602L660 600L656 584L653 580L652 575L654 569L652 561L655 555L656 557L660 555L663 556L666 549L670 547L670 544L676 540L680 535L680 531L683 527L681 527L680 524L677 523L678 518L682 516L686 511L690 514L697 509L695 499L693 498L696 494L696 490L698 493L699 492L701 494L706 492L708 494L711 491L722 492L722 494L720 494L718 497L714 497L716 507L721 508L721 510L724 511L723 514L721 514L722 516L724 516L724 519L720 522L717 522L726 531L730 533L737 530L743 530L745 528L745 525L746 526L748 524L748 521L751 520L757 521L757 525L761 525L762 527L759 530L760 533L763 534L762 540L769 536L776 539L778 542L784 539L787 540L788 538L800 536L803 537L810 534L808 530L809 527L805 524L806 520L801 516L798 516L795 518L796 519L793 519L792 517L789 519L789 512L784 507L782 511L778 508L778 502L776 500L777 498L780 497L780 494L789 494L789 491L793 485L796 489L798 489L799 492L801 491L803 495L809 500L811 500L811 497L809 496L809 490L804 478L796 478L796 475L794 474L789 478L788 476L787 477L783 473L784 472L782 468L783 466L781 464L781 458L779 460L777 458L776 460L774 458L772 459L770 458L770 456L768 457L769 452L766 451L766 449L763 450L764 449L762 443L767 439L766 438L769 438L770 436L773 437L775 434L775 430L782 428L781 425L783 420L780 419L778 415L776 415L775 412L774 413L774 411L778 411L777 406L782 407L781 404L785 398L790 399L791 401L796 401L800 405L801 404L804 410L808 410L808 408L813 408L814 405L820 404L823 407L822 409L825 412L826 417L830 420L832 418L836 419L839 416L841 419L844 417L843 416L848 412L855 398L863 387L860 385L861 383L859 382L863 375L869 377L871 374L873 374L874 378L876 377L877 379L885 373L882 370L885 366L888 366L886 363L889 358L889 355L886 355L894 350L892 348L895 346L894 343L896 341L900 341L901 339L903 339L902 337L905 334L908 334L908 329L910 327L903 324L903 322L901 322L897 316L901 316L901 311L906 310L902 305L906 305L909 303L907 300L900 302L895 301L893 303L887 303L885 307L882 304L879 304L874 296L872 297L871 293L867 292L864 294L860 292L860 286L858 285L854 288L852 293L850 292L843 285L845 282L842 277L843 276L828 275L823 278L821 277L815 279L813 281L810 278L802 277L797 273L798 270L796 268L795 270L786 270L784 274L781 273L777 277L774 274L771 265L769 264L767 265L767 270L770 274L762 279L760 278L755 282L753 282L750 279L745 282L743 282L742 279L734 279L722 273L716 268L716 264L719 264L723 260L719 258L720 254L717 244L722 243L718 242L711 235L711 227L706 223L697 223L693 225L687 221L681 224L683 220L681 216L677 220L674 220L669 216L665 216L666 211L663 209L665 206L664 202L666 201L662 196L656 195L657 193L655 189L652 189L651 191L642 195L642 197L639 198L642 200L633 202L632 201L630 207L626 207L627 206L618 208L623 213L623 217L627 219L625 221L628 221L626 224L627 226L634 231L632 232L632 237L629 239L625 238L621 242L621 248L617 245L615 250L610 250L606 255L604 254L605 250L602 246L606 244L606 241L610 236L605 228L603 231L602 230L602 236L598 237L596 243L599 244L600 247L595 255L589 248L584 249L585 252L580 246L575 246L574 248L572 248L574 253L573 256L575 258L575 261L572 261L572 265L577 268L577 275L574 279L572 280L572 278L570 278L564 282L562 280L556 280L553 278L554 281L551 282L552 286L550 286L545 282L540 275L539 277L539 275L536 276L533 274L531 282L530 280L527 280L525 278L527 276L527 269L524 266L522 266L523 264L519 263L519 261L516 258L514 264L511 263L510 265L512 267L508 275L508 273L503 272L501 269L498 269L493 266L488 267L484 258L471 254L468 251L465 244L466 240L464 238L464 234L460 231L458 227L460 225L454 218L452 218L453 217L449 213L450 204L448 203L448 201L440 202L435 199L432 200L428 195L421 194L420 197L415 199L417 203L414 206L415 208L413 210L403 212L403 214L401 215L404 221L404 228L408 229L410 235L413 235L419 239L417 239L417 241L415 241L411 248L409 249L407 253L409 257L409 259L407 259L408 261L401 264L402 272L406 279L412 281L416 286L426 288L440 296L440 301L444 308L450 314L450 319L448 319L449 324L453 327L455 333L470 348L466 354L464 354L462 350L459 350L458 358L467 368L465 373L474 382L471 385L473 390L472 393L474 397L475 413L478 416L477 419L479 424L476 430L475 437L478 443L479 458L481 460L480 464L482 476L480 483L482 487L482 492L484 494L483 503L489 510L488 511L494 517L493 508L496 504L494 502L494 498L500 492L501 488L504 486L503 485L508 481L508 486L521 498L521 500L525 503L524 504L529 508L528 510L530 512L528 514L523 515L522 517L525 523L530 528L529 529L533 532L535 531L535 538L536 539L541 532L546 534L549 532L550 529L552 529L554 532L553 535L560 542L559 543L563 549L562 553L563 552L565 554L569 566L574 566L573 575L579 578L578 580L585 584L583 588L584 589L586 590L588 589L588 587L590 588L587 591L589 593L588 596L585 596L585 599L596 607L595 609L597 609L593 612L594 614L599 615L604 622zM806 410L806 410zM839 416L839 416zM774 411L774 411z"
      id="四川省"
      stroke="blue"
      stroke-dasharray="20 2810"
      stroke-dashoffset="2810"
    />
  </g>
</svg>

style

.animation-0 {
  animation: dash 10s linear 0.1s 1 forwards;
}
@keyframes dash {
  to {
    stroke-dashoffset: 0;
  }
}

/* 实现流光 */
.animation-1 {
  /* animation: name duration timing-function delay iteration-count direction fill-mode play-state; */
  animation: strokedash0 0.1s linear 10.1s 1 forwards,
              dash1 10s linear 10.2s infinite forwards;
}
@keyframes strokedash0 {
  to {
    stroke-opacity: 1;
  }
}
@keyframes dash1 {
  to {
    stroke-dashoffset: 0;
  }
}
@keyframes strokedash1 {
  to {
    stroke-opacity: 0;
  }
}

博客园:www.cnblogs.com/wttt123/p/1…

以上。

前端实现页面截图及截图内容包含跨域图片时的处理

项目中遇到需要实现指定位置的截图,采取使用依赖 html2canvas 实现。 参考:html2canvas.hertzen.com/

一、实现步骤:

1、下载依赖或者使用官方js文件链接,本文使用的js链接; 2、代码 style

.screen-box {
  padding: 20px;
  background-color: #f5f5f5;
}

html

<div id="screen-box" class="screen-box">
  <h1>这是一个需要截图的区域</h1>
</div>
<button onclick="screenShot()">点击截图</button>

script

<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></srcipt>
<script>
function screenShot() {
  const element = document.getElementById('screen-box');
  html2canvas(element).then(canvas => {
    const img = canvas.toDataURL('image/png');
    downloadImage(img);
  })
}

function downloadImage(imgUrl, fileName = 'image.png') {
  // 1. 创建a标签
  const link = document.createElement('a');
  // 2. 设置下载属性(指定文件名)
  link.download = fileName;
  // 3. 设置图片地址为a标签的href
  link.href = imgUrl;
  // 4. 隐藏a标签(可选,避免页面渲染)
  // link.style.display = 'none';
  // 5. 将a标签添加到DOM中(部分浏览器需要)
  document.body.appendChild(link);
  // 6. 触发点击事件下载
  link.click();
  // 7. 下载后移除a标签(清理DOM)
  document.body.removeChild(link);
}

二、易错:

1、当 html 中包含有同源图片时,此代码可下载的图片文件中包含html的图片; 2、当 html 中包含跨域图片时,此代码下载的图片文件会中不会显示html包含的文件。 例如,当需截图的代码片段中含有由第三方引入的图片地址: html:

<div id="screen-box" class="screen-box">
  <h1>这是一个需要截图的区域</h1>
  <img src="https://gips3.baidu.com/it/u=1821127123,1149655687&fm=3028&app=3028&f=JPEG&fmt=auto?w=720&h=1280"
      height="300px" title="截图区图片" alt="图片" />
</div>
<button onclick="screenShot()">点击截图</button>

截图:

原因:

这是由于 canvas 受限于 CORS 策略, 会存在跨域问题, 虽然可以使用图像, 但是绘制到画布上会污染画布, 一旦一个画布被污染, 就无法提取画布的数据。比如无法使用画布 toBlod() , toDataURL() 或者 getImageData() 方法。

解决办法:

增加代码:

// 将文件读入到 blob 文件对象, 然后使用 URL.createObjectURL 转换成 src 可用的地址
async function toBlob() {
  const domUrl = document.getElementsByTagName('img')[0];
  const response = await fetch(domUrl.src, { mode: 'cors' });
  if (!response.ok) throw new Error('图片请求失败');
  const blob = await response.blob();
  const blobUrl = URL.createObjectURL(blob);
  domUrl.setAttribute('src', blobUrl);
}

修改 screenShot 函数:

async function screenShot() {
  await toBlob();
  ...
}

修改代码后,截图如下:

三、本地地址的图片(地址为"file://"开头)不支持此方法

解决办法:

可将图片转码后赋值img标签的src属性再进行操作。

博客园:www.cnblogs.com/wttt123/p/1…

以上。

CSS3 clip-path+animation实现不规则容器中的粒子下落

使用CSS3的clip-path实现不规则图形裁剪,结合CSS3 animation实现粒子下落动画效果,如下:

html: 创建不规则容器及下落的粒子节点;

<div class="particle">
  <i v-for="item of 20" :key="item" class="particle-item"></i>
</div>

style: 1、此demo使用less实现样式;

/* 不规则容器样式 */
.particle {
  position: absolute;
  top: 90px;
  left: 110px;
  width: 200px;
  height: 236px;
  background: linear-gradient(180deg, #F44336 0%, rgba(250, 33, 245, 0.4) 100%);
  clip-path: polygon(0 0, 100px 0, 100px 200px, 46px 236px, 0 200px);
}

/* 下落粒子样式 */
.particle-item {
  &::before,
  &::after {
    position: absolute;
    width: 4px;
    height: 4px;
    background: #fff;
    border-radius: 50%;
    content: '';
    box-shadow: 0 0 5px 0 rgba(255, 255, 255, 0.5);
  }
  /* 调用粒子下落样式函数 */
  .particle-selectors(20);
}

2、粒子下落样式函数主要计算粒子的初始位置及下落路径;

.particle-selectors(@n, @i:1) when (@i <= @n) {
  &:nth-child(@{i}) {
    &::before ,
    &::after {
      @w: `Math.floor(Math.random() * 100) `;
      @h: `Math.floor(Math.random() * -100) `;
      @d: `Math.random() * 0.2 `;
      @du: calc(~'@{d}s + 5s');
      @t: `Math.random() * -10 `;
      @ti: calc(~'@{t} * 0.6s');

      left: calc(~'@{w} * 1px');
      transform: translateY(calc(~'@{h} * 2px'));
      .animation(@du, @ti);
    }
  }
  .particle-selectors(@n,(@i + 1));
}

3、粒子下落动画;

.animation(@du, @de) {
  @keyframes frame {
    from {
      transform: translateY(-20px);
    }
    to {
      opacity: 0;
      transform: translateY(280px);
    }
  }
  animation: frame 10s infinite;
  animation-delay: @de;
  animation-duration: @du;
}

博客园地址:www.cnblogs.com/wttt123/p/1…

以上。

CSS3 实现16:9大屏居中显示

大屏项目中,一般需要在不同分辨率上显示居中显示大屏内容,且不出现滚动条。实际展示大屏的硬件设备比例不一,为了兼容,并且不出现UI被拉伸的情况,发现可以使用CSS3的transfrom-scale属性,并配合CSS变量实现。 其中transfrom-scale用在大屏绘制最外层盒子上,盒子内的样式按照UI给出的16:9的比例绘制。 效果图:

代码展示最外层盒子的缩放样式及比例计算:

style

<style>
  :root {
    --transformScale: 1;
    --positionWidth: 1920px;
    --positionHeight: 1080px;
  }

  * {
    margin: 0;
    padding: 0;
  }

  .container {
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    height: 100vh;
    width: 100vw;
  }

  .position {
    width: var(--positionWidth);
    height: var(--positionHeight);
  }

  .box {
    height: 1080px;
    width: 1920px;
    background-color: aquamarine;
    transform: scale(var(--transformScale));
    transform-origin: 0% 0%;
  }
</style>

html

<!-- 为了获取屏幕宽高添加的元素 -->
<div class="container">
  <!-- 为了定位添加的元素 -->
  <div class="position">
    <div class="box"></div>
  </div>
</div>

script

<script>
  // 全局缩放比基础宽
  const width = 1920;
  // 全局缩放比基础高
  const height = 1080;
  // 宽高比
  const ratio = 16 / 9;

  const getBaseScale = () => {
    const element = document.getElementsByClassName("container")[0];
    // 获取可视区域的宽度
    const w = element.clientWidth;
    // 获取可视区域的高
    const h = element.clientHeight;
    // 根据宽高计算比例
    let s = 1;
    if (w / h >= ratio) {
      // 设备左右留白 以高度为基础计算缩放比
      s = h / height;
    } else {
      s = w / width;
    }

    const pw = s * 1920 + "px";
    const ph = s * 1080 + "px";

    // 赋值
    document
      .getElementsByTagName("body")[0]
      .style.setProperty("--transformScale", s);
    document
      .getElementsByTagName("body")[0]
      .style.setProperty("--positionWidth", pw);
    document
      .getElementsByTagName("body")[0]
      .style.setProperty("--positionHeight", ph);
  };

  // 窗口变化
  onresize = getBaseScale;

  // 加载
  onload = getBaseScale;
</script>

补充

一、JavaScript 操作 CSS 变量
const root = document.querySelector(":root");
// 设置 CSS 变量
root.style.setProperty("--transformScale", 0.2);
// 读取 CSS 变量
const computedStyle = getComputedStyle(root);
const transformScale = computedStyle.getPropertyValue("--transformScale");
console.log(transformScale);
// 删除 CSS 变量
root.style.removeProperty("--transformScale");
二、CSS3 transform: scale

语法:transform: scale(x, y) transform: scaleX(x) transform: scaleY(y) 1、scale(x, y) 对元素进行缩放; ① x表示水平方向,y表示竖直方向; ② y是一个可选参数,如果不写的话,X,Y 两个方向缩放一样; 2、scaleX(x) 对元素只在x轴(水平方向)进行缩放; 3、scaleY(y) 对元素只在y轴(竖直方向)进行缩放; 4、存在 2D 转换或 3D 转换。

兼容性:参考 caniuse.com/?search=tra…

三、CSS3 transform-origin

语法:transform-origin: x y z; 1、改变被转换元素的位置; 2、存在 2D 转换或 3D 转换; 3、相对于父节点改变位置。

属性值
x leftcenterrightlength%
y topcenterbottomlength%
z length

兼容性:参考 caniuse.com/?search=tra…

博客园地址:www.cnblogs.com/wttt123/p/1…

以上。

Vue的“小外挂”:玩转自定义指令

🔥 以龙息淬炼代码,在时光灰烬中重铸技术星河 !

欢迎来到 晷龙烬的博客小窝✨! 这里记录技术学习点滴,分享实用技巧,偶尔聊聊奇思妙想~

原创内容✍️,转载请注明出处~感谢支持❤️!请尊重原创📩! 欢迎在评论区交流🌟!

引言

你好呀!在Vue的世界里,我们每天都在用 v-modelv-if 这些内置指令,它们就像官方给我们的“瑞士军刀”,特别好用。但有时候,我们总想搞点“个性化”操作,比如一进页面就让某个输入框自动聚焦,或者给按钮加个“防抖”防止用户疯狂点击。

这时候,Vue的“自定义指令”就该闪亮登场了!它就像是你给Vue安装的“小外挂”,让你能自己定义一些特殊的DOM行为。今天,咱们就把它聊明白。

一、 自定义指令是啥?能吃吗?

简单说,自定义指令就是一套你自己写的、能用在HTML元素上的“操作说明书” ‍。

Vue的核心思想是“数据驱动视图”,我们通常通过改变数据来让视图自动更新。但有些时候,我们不得不直接去操作真实的DOM元素(比如让输入框聚焦、初始化一个第三方库),这些“副作用”操作,就是自定义指令的用武之地。

你可以把它理解成一个可复用的“DOM操作工具箱” ‍。哪里需要,就“v-你的指令名”一下,工具箱里的工具就会自动在那个元素上工作。

二、 怎么创建一个“小外挂”?

创建自定义指令主要有两种方式:全局注册和局部注册。咱们先看最常用的全局注册,一次定义,全项目通用。

1. 全局注册:给Vue加个“全家桶”工具 在你的主文件(比如 main.js)里,可以这样写:

 // 定义一个名为 `v-focus` 的指令
 Vue.directive('focus', {
   // 当被绑定的元素插入到 DOM 中时……
   inserted: function (el) {
     // 聚焦元素
     el.focus()
   }
 })

用起来超简单,在模板里直接:

 <input v-focus>

这样,只要这个 input 元素被插入页面,它就会自动获得焦点,用户可以直接打字,体验满分!

2. 局部注册:某个组件私有的“小工具” ‍ 如果这个指令只在某个特定组件里用,可以把它定义在该组件内部:

 export default {
   directives: {
     focus: {
       inserted: function (el) {
         el.focus()
       }
     }
   }
 }

用法和全局的一样。这种方式让指令的作用范围更清晰。

三、 指令的“生命周期钩子”

一个指令不是简单执行一下就完了,它也有自己的“人生阶段”,Vue提供了几个钩子函数让我们在关键时刻介入:

  • bind只调用一次,指令第一次绑定到元素时调用。可以在这里做一次性的初始化设置。
  • inserted:被绑定元素插入父节点时调用(仅保证父节点存在,但不一定已被插入文档)。上面让输入框聚焦的例子,就用这个钩子。
  • update所在组件的 VNode 更新时调用,但可能发生在其子 VNode 更新之前。指令的值可能发生了变化,也可能没有。
  • componentUpdated:指令所在组件的 VNode 及其子 VNode 全部更新后调用。
  • unbind只调用一次,指令与元素解绑时调用。适合在这里做清理工作,比如移除事件监听器,防止内存泄漏。

这些钩子函数都会接收到几个参数,最常用的是 el(指令绑定的元素)和 binding(一个包含指令信息的对象)。

四、 钩子函数的参数详解

让我们深入看看 binding 这个对象,它包含了指令的所有关键信息:

  • name:指令名,不包括 v- 前缀。
  • value:指令的绑定值。例如 v-my-directive="1 + 1" 中,绑定值是 2
  • oldValue:指令绑定的前一个值,仅在 updatecomponentUpdated 钩子中可用。
  • expression:字符串形式的指令表达式。例如 v-my-directive="1 + 1" 中,表达式是 "1 + 1"
  • arg:传给指令的参数。例如 v-my-directive:foo 中,参数是 "foo"
  • modifiers:一个包含修饰符的对象。例如 v-my-directive.foo.bar 中,修饰符对象是 { foo: true, bar: true }

理解这些参数,你就能写出更灵活、强大的指令。

五、 来点更深入实用的例子

光说不练假把式,咱们看几个深入、并且实用的场景。

例子1:按钮权限控制

假设我们有不同权限的用户,有些按钮只有管理员能点。

 Vue.directive('permission', {
   inserted: function (el, binding) {
     // binding.value 就是我们传给指令的值,比如用户角色
     const userRole = 'user' // 假设当前用户是普通用户
     const requiredRole = binding.value // 指令要求的管理员角色 'admin'
 
     if (userRole !== requiredRole) {
       // 如果不是管理员,就把按钮禁用或者隐藏
       el.style.display = 'none'
       // 或者 el.disabled = true
     }
   }
 })

模板里可以这样用:

 <button v-permission="'admin'">删除文章</button>

普通用户就看不到这个删除按钮啦,权限管理轻松实现。

例子2:按钮防重复点击

 // main.js(Vue2 全局注册)
 Vue.directive('throttle', {
   // 元素插入DOM时绑定事件
   inserted(el, binding) {
     // 1. 基础配置:默认节流间隔500ms,支持通过参数自定义(如 v-throttle:1000)
     const delay = binding.arg ? Number(binding.arg) : 500;
     // 2. 校验回调函数(避免非函数报错)
     const callback = typeof binding.value === 'function' ? binding.value : () => {};
     
     // 3. 核心变量:记录上一次执行时间
     let lastClickTime = 0;
 
     // 4. 节流点击事件处理函数
     const handleClick = () => {
       const now = Date.now();
       // 距离上次点击超过设定间隔,才执行回调
       if (now - lastClickTime >= delay) {
         callback(); // 执行业务逻辑
         lastClickTime = now; // 更新最后点击时间
       }
     };
 
     // 5. 绑定点击事件 + 缓存函数(方便后续销毁)
     el.addEventListener('click', handleClick);
     el._throttleClick = handleClick; // 把函数存到元素上
   },
 
   // 元素销毁时清理事件(避免内存泄漏)
   unbind(el) {
     el.removeEventListener('click', el._throttleClick);
     el._throttleClick = null; // 清空缓存
   }
 });

使用方式:

 <template>
   <!-- 1. 默认500ms节流 -->
   <button v-throttle="handleClick">默认节流点击</button>
 
   <!-- 2. 自定义1000ms节流(通过参数指定) -->
   <button v-throttle:1000="handleClick">1秒节流点击</button>
 </template>

例子3:图片懒加载 这是一个非常经典的自定义指令应用场景,可以大幅提升页面加载性能。

 Vue.directive('lazy', {
   inserted: function (el, binding) {
     const observer = new IntersectionObserver((entries) => {
       entries.forEach(entry => {
         if (entry.isIntersecting) {
           // 图片进入视口
           const img = entry.target
           img.src = binding.value // 将data-src的值赋给src
           observer.unobserve(img) // 停止观察
         }
       })
     }, {
       rootMargin: '0px',
       threshold: 0.1
     })
 
     observer.observe(el)
     // 存储observer,以便在unbind时清理
     el._lazyObserver = observer
   },
   unbind: function (el) {
     if (el._lazyObserver) {
       el._lazyObserver.disconnect()
     }
   }
 })

HTML中使用:

 <img v-lazy="'https://example.com/image.jpg'" alt="懒加载图片">

六、 自定义指令的适用场景总结

  1. DOM操作封装:聚焦、选择文本、内容复制等。
  2. 事件处理优化:防抖、节流、长按、双击等。
  3. UI功能增强:拖拽、滚动监听、无限滚动、懒加载。
  4. 权限与状态控制:按钮权限、元素可见性、功能开关。
  5. 第三方库集成:图表库初始化、富文本编辑器、地图组件。
  6. 样式与动画:动态样式绑定、动画触发、主题切换。

结语

自定义指令不是什么高深莫测的黑魔法,它就是一个帮你封装DOM操作、提升代码复用性和可维护性的好帮手。下次当你发现自己在多个地方重复写着相同的DOM操作代码时,不妨停下来想想:“是不是可以抽象成一个自定义指令?”

从简单的自动聚焦,到复杂的权限管理、性能优化,自定义指令都能让你的Vue项目代码更干净、更专业。希望这篇文章能帮你打开思路,在实际项目中用起来!


—— 完 ——

✨ 至此结束 ✨

💡 点赞关注,解锁更多技术干货!

我是 晷龙烬 期待与你的下次相遇~

CSS 有什么奇技淫巧?

转载自作者:独元殇

aspect-ratio

宽高比

<style>
    div { 
        width: 190px; /* 注意,这个数字要能容纳完内容才有效 */ 
        aspect-ratio: 16 / 9; 
        background: tomato; 
    }
</style> 
<div>我永远保持 16:9</div>

如果,你 aspect-ratio 的值写成 1 ,那么就可以得到一个完美的正方形了!

object-fit

它有两个值,一个是 cover (图片完全覆盖容器,且图片长宽不失真),一个是 scale-down (只保证图片长宽不失真)。

<style> 
    img { 
        width: 200px; 
        height: 200px; 
        object-fit: cover; /* 会裁切,只剩中间 */ 
        border: 2px solid #000; 
    } 
</style> 
<img src="https://placehold.co/300x200" alt="demo">

color-scheme: dark light;

启动浏览器自适应 深浅色模式!

<style> 
    :root { 
        color-scheme: dark light; /* 自动变色 */ 
    } 
</style> 
<h3>切换系统深浅模式来测试</h3> 
<p>在深色模式下,下面的原生控件会自动变黑,文字变白:</p> 
<label> 输入框: <input type="text"> </label>

accent-color:red;

它会自动计算出不同焦点下,表单控件的颜色

<style>
    body { 
        accent-color:red; 
    } 
</style> 
<input type="checkbox" checked> 选择框
<br><br> 
<input type="radio" checked> 单选 
<br><br> 
<input type="range"> 拖动滑块

fit-content

它是根据内容,来控制容器的大小的

<style>
    div {
        background: skyblue; 
        width: fit-content; 
        margin: auto; 
        padding: 20px; 
    } 
</style>
<div>我是一个 div,使用缩水大法</div>

overscroll-behavior: contain;

解决一个 div,有自己的滚动条。然后用户在这个 div 里滚动到底部时,,整个页面会开始滚动。

<style> 
    body { 
        height: 150vh; 
        background: #eee; 
        padding: 50px; 
    } 
    .scroll-box { 
        width: 200px; 
        height: 200px; 
        overflow: auto;
        border: 3px solid #333;
        background: white; 
        overscroll-behavior: contain; /* 关键代码 */ 
    } 
    .inner { 
        height: 500px; 
        background: linear-gradient(to bottom, tomato, gold); 
    } 
</style> 
<div class="scroll-box"> 
    <div class="inner">内部滚动条</div>
</div> 
<p>滚动上面的盒子到底部,再继续滚动试试...</p>

text-wrap: balance;

它可以平衡行数之前的词语长度,使其做到尽可能的均衡,整体观感上,要舒服很多! 注意,这个属性,只支持 6 行以内,所以尽可能用在一些短小的地方,比如标题!

<style> 
    h2 { 
        width: 200px; 
        background: gold; 
        text-align: center; 
        padding: 10px; 
    } 
    #test { 
        text-wrap: balance; 
    } 
</style> 
<h2 id=test>很长很长 so long 很长的 titletitle 标题</h2>
<h2>很长很长 so long 很长的 titletitle 标题</h2>

text-underline-offset: 0.25em;

英文中的 g、y ,是不是这些字母,下面会拖一个尾巴。而 < a > 的原生效果是,这个 underline 线会重叠到这些小尾巴上。

<style> 
    a:not([class]) {
        text-underline-offset: 0.25em; 
    } 
</style> 
<p> <a href="#">这是一个正文链接 (g/y)</a> </p> 
<p> <a href="#" class="btn">这是一个按钮链接(不使用该 CSS) (g/y)</a> </p>

outline-offset !

不计入盒模型尺寸的轮廓线。 把鼠标移上去,就会有扩散效果了。而且,只是扩散,不会影响各种尺寸。

<style>
    button { 
        margin: 30px; 
        padding: 10px 20px;
        border: 1px solid #333; 
        outline: 2px dashed blue;
        outline-offset: var(--offset, 2px);
        transition: outline-offset 0.2s; 
    } 
    button:hover { 
        --offset: 10px; 
    } 
</style>
<button>鼠标悬停看扩散效果</button>

scroll-margin-top

怎么形容这个场景呢?你有个固定的顶部导航栏。它 高度是 100px 。然后你单击链接,是一个滚动到某个 #part 标题的链接,你会发现默认滚动后,它是紧挨着顶部的。那么导航栏就挡住了(我也不太能说清,大家看下面的例子吧)..... 如何不介入 js 来解决这个问题呢?就是scroll-margin-top !

注意,要在本地建一个 html 来运行,否则会乱跳转!

<style> 
    nav { 
        position: fixed;
        top: 0; 
        width: 100%; 
        height: 100px; 
        background: rgba(255,0,0,0.5); 
    } 
    h2[id] { 
        scroll-margin-top: 110px; /* 100px + 10px 缝隙 */ 
        background: yellow; 
    } 
    body { 
        height: 2000px; 
        padding-top: 120px; 
    } 
</style> 
<nav>我是 100px 高的固定栏 (半透明)</nav> 
<a href="#part-1">>>> 点我跳转到目标 <<<</a> 
<div style="height: 500px;"></div> 
<h2 id="part-1">目标标题:你看不到红色的遮挡</h2>

scrollbar-gutter: stable;

解决 一个滚动条跳动 Bug 。就是页面的内容,动态变多,会突然出现滚动条。然后画面会跳动一下。

<style>
    div { 
        width: 200px; 
        height: 150px; 
        overflow-y: auto; 
        border: 2px solid #333; 
        scrollbar-gutter: stable; /* 关键 */
    }
</style> 
<div> 虽然内容很少,不需要滚动, 但请注意右侧预留的空白槽。 这保证了内容增加时不会发生位移。 </div>

每日一题-删列造序🟢

给你由 n 个小写字母字符串组成的数组 strs,其中每个字符串长度相等。

这些字符串可以每个一行,排成一个网格。例如,strs = ["abc", "bce", "cae"] 可以排列为:

abc
bce
cae

你需要找出并删除 不是按字典序非严格递增排列的 列。在上面的例子(下标从 0 开始)中,列 0('a', 'b', 'c')和列 2('c', 'e', 'e')都是按字典序非严格递增排列的,而列 1('b', 'c', 'a')不是,所以要删除列 1 。

返回你需要删除的列数。

 

示例 1:

输入:strs = ["cba","daf","ghi"]
输出:1
解释:网格示意如下:
  cba
  daf
  ghi
列 0 和列 2 按升序排列,但列 1 不是,所以只需要删除列 1 。

示例 2:

输入:strs = ["a","b"]
输出:0
解释:网格示意如下:
  a
  b
只有列 0 这一列,且已经按升序排列,所以不用删除任何列。

示例 3:

输入:strs = ["zyx","wvu","tsr"]
输出:3
解释:网格示意如下:
  zyx
  wvu
  tsr
所有 3 列都是非升序排列的,所以都要删除。

 

提示:

  • n == strs.length
  • 1 <= n <= 100
  • 1 <= strs[i].length <= 1000
  • strs[i] 由小写英文字母组成

工程化必备!SVG 雪碧图的最佳实践:ID 引用 + 缓存友好,无需手动算坐标

✂️ 图标“合体”大法:SVG 雪碧图如何终结 HTTP 请求地狱,让你的图标秒速加载?

前端性能优化专栏 - 第八篇

在上一篇中,我们探讨了图片加载策略,解决了大图的性能问题。但对于网页中那些零散的小图标,比如点赞、分享、设置等,它们虽然体积小,却有一个致命的性能痛点:每个图标都会发起一个独立的 HTTP 请求!

当页面有几十甚至上百个图标时,浏览器就会发起几十或上百个请求,这在 HTTP/1.1 时代是性能杀手,即使在 HTTP/2 中,过多的请求也会增加 TCP/TLS 握手的开销。

今天,我们就来学习一个古老而又现代的优化技巧——雪碧图(CSS Sprites) ,以及它在现代 SVG 图标体系中的最佳实践。


什么是雪碧图(CSS Sprites)?

雪碧图,最早是为 PNG 这类位图图标的优化而生,在 HTTP/1.1 的时代,它几乎是前端性能优化的标配

  • 定义:将多个小图标合并为一张大图,通过 CSS 的 background-position 属性来显示指定区域。
  • 目的:将多次 HTTP 请求合并为一次,大幅减少网络开销,提高网页加载速度。

为什么要用雪碧图优化 SVG 图标?

虽然 SVG(Scalable Vector Graphics)本身是 XML 文本,具有可缩放、清晰度高、可被 CSS 样式控制等优点,但它依然面临请求过多的性能挑战。

SVG 雪碧图的优化效果体现在:

  1. 减少 HTTP 请求:将多个图标合并成一个文件,实现一次加载,全面优化
  2. 提升缓存效率:浏览器可缓存整个图标集,加快后续页面加载。
  3. 减小文件体积:合并后的文件更容易被服务器进行 Gzip/Brotli 压缩,传输更快。

image.png

🛠️ SVG 雪碧图的两种实现方式

与传统的 PNG 雪碧图不同,SVG 雪碧图拥有更灵活、更强大的实现方式。

1. ✨ <symbol> + <use> 方式(强烈推荐)

这是现代 Web 开发中优化 SVG 图标的最佳实践,它兼具性能、可维护性和样式灵活性。

核心原理:

  1. 将所有图标定义在一个隐藏的 <svg> 容器中,每个图标用 <symbol> 标签包裹,并赋予一个唯一的 id
  2. 在页面需要使用图标的地方,通过 <use> 标签引用 <symbol>id

代码示例:

<!-- 1. 雪碧图文件(通常放在页面顶部或外部引入) -->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
  <!-- 这个svg容器本身不会在页面上被渲染出来,作用:作为图标模板的定义库 -->
  <symbol id="icon-heart" viewBox="0 0 24 24">
    <!-- path data for heart -->
  </symbol>
  <symbol id="icon-star" viewBox="0 0 24 24">
    <!-- path data for star -->
  </symbol>
</svg>

<!-- 2. 页面中使用 -->
<svg class="icon icon-red">
  <!-- 通过 xlink:href 引用雪碧图中的图标 ID -->
  <use xlink:href="#icon-heart" /> 
</svg>

优点:

  • 可样式化:可使用 CSS 轻松调整图标的 fill(颜色)和 stroke(描边),实现主题切换。
  • 可访问性:支持 <title><desc> 标签,提升可访问性。
  • 缓存友好:可内联或外部引入,利用浏览器缓存。
  • 维护性高:无需手动计算坐标,只需引用 ID。

2. 🔧 CSS 背景图方式(传统方式)

这种方式与传统的 PNG 雪碧图类似,将 SVG 文件作为背景图,通过 background-position 来控制显示区域。

代码示例:

.icon-heart {
  background-image: url('sprite.svg'); /* 指定了 svg 雪碧图 */
  background-position: 0 0; /* 将图片移动到合适的位置 */
  width: 24px; /* 必须要制定元素的宽高 */
  height: 24px;
}

特点与局限:

  • 优点:实现方式简单,兼容性好。

  • 缺点

    • 样式控制受限:无法用 CSS 改变图标颜色,失去了 SVG 的最大优势。
    • 维护困难:需要手动计算每个图标的 background-position,不适合动态图标或主题切换。

✅ 小结:SVG 雪碧图的最佳实践

方式 性能 样式控制 维护难度 推荐度
<symbol> + <use> 极佳(单次请求,可缓存) 极佳(CSS 可控颜色) 低(ID 引用) ⭐⭐⭐⭐⭐
CSS 背景图 良好(单次请求,可缓存) 差(无法改变颜色) 高(手动定位) ⭐⭐

结论: SVG 雪碧图是现代 Web 图标优化的最佳实践之一,而 <symbol> + <use> 方式兼具性能与可维护性,是图标优化的首选方案。


下一篇预告: 网页中的文字虽然是文本,但自定义字体(Web Font)的加载却是一个巨大的性能黑洞。下一篇我们将探讨如何优化字体加载,避免“FOIT(Flash of Invisible Text) ”和“FOUT(Flash of Unstyled Text) ”等问题,敬请期待!

「chrome extensions🛠️」我写了一个超级简单的浏览器插件Vue开发模板

Hi!这里是JustHappy🚀🚀,一时兴起想开发一个浏览器插件,但是找来找去发现在Vue生态下好像没有一个超轻的简单的模板或者脚手架,看了一圈感觉antFu大佬的vitesse-webext还不错,但是感觉还不够轻,于是我打算手撸仿写一个简单版本

我想要一个什么样的模板

  • 技术栈轻盈:Vue + JS 越简单越好
  • 支持“热更新”:修改后立马更新视图

于是有了这个模板...

image.png

仓库地址是这个: github.com/Simonmie/vu…

如何使用?很简单

你只需要在仓库中点击 use this template 就可以使用该模板去构建插件

image.png

开始开发吧!

安装依赖

npm install

模板结构

├── assets
├── background
│   ├── dev-hmr.js  // 开发环境下的热更新脚本
│   └── main.js  // 背景脚本
├── logic
│   └── common-setup.js  // 公共设置脚本
├── manifest.js // manifest.json 生成脚本
├── options // 选项页
│   ├── OptionsPage.vue 
│   ├── index.html
│   └── main.js
├── popup // 弹窗
│   ├── PopupComponent.vue
│   ├── index.html
│   └── main.js
├── sidepanel // 侧边栏
│   ├── SidePanel.vue
│   ├── assets
│   │   └── logo.png
│   ├── index.html
│   └── main.js
└── utils
    ├── base.js // 基础工具函数
    └── config.js // 配置文件函数

如何开发?

启动热更新

npm run dev:ext

安装扩展

  1. 打开 Chrome 浏览器。
  2. 点击浏览器菜单(通常是三个垂直点图标),选择“更多工具”>“扩展程序”。
  3. 在扩展程序页面,打开“开发者模式”。
  4. 点击“加载已解压的扩展程序”,选择项目根目录下的 extension 文件夹。

然后你就可以愉快的开始开发浏览器插件了。你几乎只需要会Vue和JS就可以开发,或者结合大模型快速生成一个插件

这是我用Gemini 3 pro结合这个模板生成的其中一个插件的效果,基本上完全可用

image.png

如果你也想尝试,这是这个插件的github仓库地址 github.com/Simonmie/Te…


下面我们来聊聊这个框架的“热更新”原理吧....

”热更新“原理

有人问我:“为什么这个模板能做到类似 HMR 的体验?浏览器插件不是不能热更新吗”

答案其实很简单:

不是模块级热替换,而是自动重建 + 自动刷新。

当你修改代码时:

  • 构建器会重建产物
  • 热更新服务会给扩展发送通知
  • 前台视图刷新、后台脚本重载
  • 浏览器扩展整体更新

完全不需要手动刷新窗口,不需要重新点击扩展图标。

更关键的是:整个机制非常轻,非常干净。

一个极小的热更新服务

模板启动后会同时启动一个本地服务,用于监听构建变化并向扩展发送消息。

这个服务通过 SSE(Server-Sent Events)工作:

SSE 的好处是:

  • 轻量
  • 无需额外依赖
  • 无需轮询
  • 特别稳定

你甚至可以把它理解为:一个特别简单的“更新广播器”。

前台页面如何刷新?

扩展里的 popup、options、sidepanel 页面都会自动注入一个监听器:

  1. 通过 EventSource 连接 SSE 服务
  2. 收到 reload 信号
  3. window.location.reload()

所以改完代码保存后:
→ UI 会立即重新加载
→ 新的代码会直接生效

不用点击,不用重打开 popup 页面,连 DevTools 都不用动。

后台脚本如何更新?

在开发模式下,后台脚本并不会直接运行正式的 background 逻辑,而是先接入一个开发专用的脚本。

这个脚本专门负责监听 SSE:

  1. 收到消息
  2. chrome.runtime.reload()

这会让整个扩展瞬间重载:

  • UI 刷新
  • 脚本刷新
  • 状态重置

这种方式非常适合开发场景,因为不用担心缓存、不一致、后台仍在运行等问题。

自动重连机制

SSE 连接如果断开,比如:

  • 小断网
  • 浏览器切换标签
  • 系统休眠
  • 构建器重启

扩展会自动重试连接。

这意味着:
你只需要改代码 → 保存 → 浏览器自动更新
不用关心底层连接是否断过、重连过。

它就是一直能用。

如果这对你有帮助,哈哈求个star✨,模板大概率还有很多不足,欢迎大家提交issue、pr等,或者单纯骚扰我😜

阿里邮件下载器使用说明

邮件下载器使用说明

📋 项目简介

这是一个基于Node.js的邮件自动下载和分类工具,能够通过IMAP协议连接到邮箱服务器,自动下载所有邮件并按照联系人进行分类保存。

🗂️ 目录结构

程序运行后会自动创建以下目录结构:

E:\邮箱下载\pds_server\分类邮件库\
├── 收件箱\                           # 所有收到的邮件
   ├── 来自_联系人A\                  # 某个联系人发来的邮件
      ├── 2024-01-01_邮件主题.eml   # 邮件原始文件
      ├── 附件\                      # 该联系人的所有附件
         ├── 1640995200000_文件1.pdf
         └── 1640995200000_图片.jpg
      └── _index.json               # 该联系人邮件索引
   ├── 来自_联系人B\
   └── ...
├── 已发送\                           # 所有发送的邮件
   ├── 发给_联系人A\                  # 发送给某个联系人的邮件
      ├── 2024-01-01_回复邮件.eml
      ├── 附件\
      └── _index.json
   └── ...
└── _下载记录.json                    # 全局下载记录文件

📁 文件说明

邮件文件 (.eml)

  • 格式:标准RFC 822邮件格式
  • 命名规则日期_主题.eml
    • 日期格式:YYYY-MM-DD
    • 主题:自动替换特殊字符,最多50个字符
  • 用途:可用Outlook、Foxmail等邮件客户端打开

附件文件

  • 存储位置:各联系人目录下的附件文件夹
  • 命名规则时间戳_原文件名
  • 时间戳:防止重名文件冲突
  • 支持格式:所有类型的邮件附件

索引文件 (_index.json)

每个联系人目录下都会生成一个索引文件,包含:

{
  "emails": [
    {
      "seqno": 123,
      "subject": "邮件主题",
      "date": "2024-01-01T12:00:00.000Z",
      "emlFile": "2024-01-01_邮件主题.eml",
      "infoFile": "邮件信息.json",
      "hasAttachments": true
    }
  ]
}

下载记录文件 (_下载记录.json)

全局下载记录,记录所有已下载邮件的UID:

{
  "downloadedUids": [12345, 12346, 12347],
  "inboxTotal": 364,
  "sentTotal": 50,
  "inboxProcessed": 100,
  "sentProcessed": 20,
  "lastUpdate": "2024-01-01T12:00:00.000Z"
}

🚀 使用方法

1. 环境准备

# 安装Node.js依赖
npm install

# 确保安装了必要的包
npm install imap mailparser cli-progress

2. 配置邮箱信息

在代码中修改邮箱配置:

const EMAIL_USER = 'your-email@example.com';
const EMAIL_PASSWORD = 'your-password';

3. 运行程序

cd E:\邮箱下载\pds_server
node routes/email.js

4. 查看下载进度

程序会实时显示:

  • 连接状态
  • 处理进度
  • 下载速度
  • 错误信息

⚙️ 功能特性

✨ 主要功能

  • 自动分类:按发件人/收件人自动分类
  • 断点续传:支持中断后继续下载
  • 附件处理:自动保存所有附件
  • 进度显示:实时显示下载进度和速度
  • 错误处理:自动跳过问题邮件,继续处理

🔧 技术特点

  • 批量处理:支持批次下载,提高效率
  • 超时保护:30秒超时机制,防止卡死
  • 并发控制:合理控制并发数量,避免服务器压力
  • 增量更新:只下载新邮件,跳过已下载邮件

📊 性能参数

  • 批次大小:5封邮件/批次(可调整)
  • 超时时间:30秒/邮件
  • 休息间隔:每20封邮件休息1秒
  • 记录保存:每10封邮件保存一次记录

🛡️ 安全说明

邮箱安全

  • 建议使用应用专用密码而非主密码
  • 支持SSL/TLS加密连接
  • 不会标记邮件为已读

数据安全

  • 所有数据保存在本地
  • 不会上传到任何云服务
  • 建议定期备份邮件文件

🔧 故障排除

常见问题

  1. 连接失败

    • 检查邮箱密码是否正确
    • 确认IMAP服务是否开启
    • 检查网络连接
  2. 下载卡住

    • 程序会自动超时跳过
    • 可以Ctrl+C中断,下次运行会继续
  3. 文件保存失败

    • 检查磁盘空间
    • 确认写入权限
    • 检查文件名是否包含特殊字符

日志分析

程序会输出详细日志:

  • ✅ 成功下载:邮件下载成功
  • ⏭️ 已跳过:邮件已存在或超时
  • ❌ 失败:处理出错,可手动查看

📈 统计信息

程序运行完成后会显示:

  • 总邮件数量
  • 成功下载数量
  • 跳过重复数量
  • 处理失败数量
  • 总耗时和平均速度
  • 联系人邮件分布统计

💡 使用建议

  1. 首次运行:建议在网络良好时运行
  2. 大量邮件:分时段运行,避免服务器限制
  3. 定期备份:定期备份分类邮件库文件夹
  4. 查看邮件:使用邮件客户端打开.eml文件

📞 技术支持

如遇到问题,请检查:

  1. Node.js版本(建议14+)
  2. 网络连接状态
  3. 邮箱IMAP设置
  4. 磁盘空间和权限

版本:1.0 更新日期:2025-12-19 开发者:江城开朗的豌豆

❌