普通视图

发现新文章,点击刷新页面。
今天 — 2025年9月17日掘金 前端

ES6 中函数的双重调用方式:fn() 与 fn\...``

作者 excel
2025年9月16日 22:45

一、概念

在 ES6 中,函数不仅可以通过传统方式 fn() 调用,还可以通过 标签模板(Tagged Template Literals) 的方式 fn...`` 调用。
两种调用方式的核心差异是参数传入的方式:

  • fn():手动传参。
  • fn...``:由 JavaScript 自动将模板字面量分解为 静态字符串数组动态表达式结果,并传递给函数。

二、原理

1. 普通函数调用

function add(a, b) {
  return a + b;
}
console.log(add(2, 3)); // -> 5

👉 参数完全由调用方提供。

2. 标签模板函数调用

function tag(strings, ...values) {
  console.log(strings); // 静态部分数组
  console.log(values);  // 插值表达式的计算结果
}

const name = "Alice";
const age = 20;
tag`Hi, my name is ${name} and next year I will be ${age + 1}`;

执行过程:

  1. 模板字面量 Hi, my name is ${name} and next year I will be ${age + 1}

  2. JS 引擎分割为:

    • strings = ["Hi, my name is ", " and next year I will be ", ""]
    • values = ["Alice", 21]
  3. 调用函数:tag(strings, "Alice", 21)

👉 这说明 模板中的 ${...} 可以是任意合法表达式,不仅仅是变量。


三、对比

特性 fn() 调用 fn...`` 调用
参数来源 显式传入 模板字面量分解自动传入
动态表达式支持 需手动写表达式并传参 模板内部直接写 ${...},自动求值
可读性 适合逻辑函数 更适合描述文本/DSL 场景
典型应用 算法、业务逻辑 SQL 构造、HTML 渲染、国际化、CSS-in-JS

四、实践

示例 1:带表达式的 SQL 构造器

function sql(strings, ...values) {
  // 将插值安全转义(演示用,真实情况需更严格处理)
  const safeValues = values.map(v => `'${v}'`);
  // 拼接完整 SQL
  return strings.reduce((result, str, i) => result + str + (safeValues[i] ?? ""), "");
}

const table = "users";
const minAge = 18;
const maxAge = 30;

// 模板中可直接放表达式:范围查询
const query = sql`SELECT * FROM ${table} WHERE age BETWEEN ${minAge} AND ${maxAge + 5}`;
console.log(query);
// 输出: SELECT * FROM 'users' WHERE age BETWEEN '18' AND '35'

👉 注意 ${maxAge + 5} 就是 表达式,计算后再传入函数。


示例 2:国际化 + 表达式

function i18n(strings, ...values) {
  const dict = { "Hello": "你好", "years old": "岁" };
  return strings.reduce((res, str, i) => {
    const translated = dict[str.trim()] || str;
    return res + translated + (values[i] ?? "");
  }, "");
}

const user = { name: "小明", birth: 2000 };
const currentYear = 2025;

// 模板中写表达式:年龄 = 当前年份 - 出生年份
console.log(i18n`Hello ${user.name}, you are ${currentYear - user.birth} years old`);
// 输出: 你好 小明, you are 25 岁

👉 模板里的 ${currentYear - user.birth} 表达式先求值,再传给标签函数。


五、拓展

  1. GraphQL

    import { gql } from "@apollo/client";
    const query = gql`
      query {
        user(id: ${42}) {
          name
        }
      }
    `;
    

    👉 gql 就是一个标签函数,接收模板片段并解析为 AST。

  2. CSS-in-JS(styled-components)

    const Button = styled.button`
      background: ${props => props.primary ? "blue" : "gray"};
      color: white;
    `;
    

    👉 通过 ${表达式} 可以根据组件属性动态生成 CSS。


六、潜在问题

  1. 表达式副作用
    ${func()} 内部函数若有副作用,模板调用时可能引发隐式执行。
  2. 调试困难
    插值表达式复杂时,最终拼接结果可能难以直观理解。
  3. 性能注意
    模板调用每次会构造新数组,若在循环内频繁使用可能带来 GC 压力。

总结

  • fn() 调用:传统传参,适合业务逻辑。
  • fn...`` 调用:标签模板调用,自动传入 字面量片段数组 + 表达式求值结果,不仅能传变量,还能传 任意 JS 表达式
  • 应用场景:SQL 构造、国际化、GraphQL、CSS-in-JS 等。

全栈框架next.js入手指南

2025年9月16日 22:34

作为基于react.js的全栈框架next.js在现在不可谓不热门,我个人也上手用了一段时间,体验上面来说还是不错的。所以在这里,给大家分享一下next.js的入手指南!

如果有理解不到位的地方,还请指正!

基本介绍

该篇落简单介绍next.js是什么,和react.js的关系,以及怎么创建一个next.js的项目。

认识next.js

这一篇落,我们来认识一下什么是next.js,为什么要使用它来进行开发。

与react.js的关系

首先,我们要了解next.js这个框架,就要知道一个点,那就是next.js是基于react.js之上构建的一个全栈框架,也可以说是react.js的框架。

并且,在react.js的基础上,next.js增加了更多的附加功能和其他优化。

为什么要使用next.js

既然next.js也是基于react.js的,那为啥用next.js而不是直接用react.js呢?

这是因为next.js它本身就有几个react.js默认没有的优点:

  1. 默认支持SSRSSG,有着更好的SEO和首屏加载速度;
  2. 内置路由系统,搭配模版和页面的使用,可以不用再去配置react-router
  3. 内置API系统,例如博客这类简单的后端功能甚至直接可以用next.js完成;

这是我认为next.js相比react.js下,体现出来的优点,当然也还有其他的地方。

SSR和SSG

这是我们经常能听到的两个名词:SSRSSG,他们对应的中文翻译叫做:服务端渲染和静态站点渲染。区别就是在于一个是实时渲染,一个是构建时预渲染,具体区别如下:

方式 SSR SSG
渲染时机 请求的时候 构建的时候
响应速度 很快
服务器压力
适合场景 需展示最新的数据 固定显示的内容

项目初始化

这一篇落,我们来说一下如何用next.js官方的脚手架创建一个项目并且简单介绍目录结构的功能。

create-next-app

创建next.js的项目,我们需要使用create-next-app来进行项目搭建,打开终端,输入以下命令:

npx create-next-app@latest demo

此时,可以看到界面询问我们创建next.js需要选择的功能,这个按个人需求来选择,选择完成后最终效果如下图:

image.png

此时,项目已经创建完毕,使用VSCode打开该项目。默认情况下,目录结构下的内容并不多,这里我们需要了解的是几个地方:

  • public/ 用于存放静态资源的目录
  • src/app 用于存放页面的目录(这里是AppRouter,是官方目前推荐的,对应的还有老版本PageRouter,不过不再推荐)
  • next.config.ts 这个是next.js的框架配置文件,功能很多也很重要
  • eslint.config.mjs 这个是eslint的配置文件

现在,我们着重关注的应该是src目录,之后的许多工作如业务代码编写,都会在该目录下进行。

从这一篇落起,就一直在提到目录,是因为在next.js项目中,目录非常重要。

next.js中,有着默认的约定,例如这里:src/app目录下就对应页面目录,每一个以page.tsx命令的文件就是一个页面。

我们要严格遵循next.js的目录约定,否则会有意想不到的问题产生!

目录结构

目录结构是next.js的一个重点,因为next.js的目录命名是约定式的,即不同的目录命令可能对应着不同的功能,若使用错误,则会导致意想不到的问题产生。

next.js的目录中,包括布局、页面、中间件等等功能的命名约定...后面我们说的理论内容,也基本都和每个目录或文件有关。

public公共目录

对于静态文件,通常在public目录下进行存放,例如图片这一类的资源,当需要访问时,直接通过/即可。

例如:在public目录下有一张图片logo.png,访问方式如下:

<Image src="/logo.png" alt="logo" width={32} height={32} />

布局

next.js中,命名为layout.tsx的文件就叫布局。他的功能在于,定义公共部分的UI,该部分UI不会受路由的切换而更新,通常用于导航栏、侧边栏或者底部。

例如下面的布局中,当我们切换路由时,变化的是main标签里面的内容,而headerfooter标签内容不会改变。

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <header>Header</header>
        <main>
          {/* 当路由切换,这里的内容将进行更新 */}
          {children}
        </main>
        <footer>Footer</footer>
      </body>
    </html>
  );
}

另外,除了默认的根布局以外,我们还可以在app下每一个页面目录重新创建layout.tsx来定义子布局,以此来创建出更多的布局效果。

如果需要创建多个根布局,那就需要用到“组”概念,我们后续来谈。

页面

页面是next.js的第二个核心功能,每一个页面我们都由page.tsx来命名。在next.js中,默认已经存在首页,即src/app/page.tsx文件。通过访问/根路径,我们将看到此页面。

如果想创建新的页面,那就需要新建一个新的目录,并且添加page.tsx文件。例如,这里我们在src/app目录下面,新建hello/page.tsx文件,编写如下代码:

function HelloPage() {
  return (
    <div>
      HelloPage
    </div>
  );
}

export default HelloPage;

此时,我们就已经成功创建了一个页面,如何访问这个页面呢?还记得前面说的next.js重在约定嘛,在app目录下面的每一个目录命名,即代表页面路由的命名。

所以这里的hello目录名对应的路由即为/hello,此时访问/hello可以看到如下内容:

image.png

动态路由

通过上面的例子可以实现页面和路由创建,那假如此时有这样一个新需求:通过访问hello/a或者hello/b或者hello/xxxx都能匹配到同一个页面,并根据匹配不同的路径来显示不同的内容。

这里就需要使用到next.js的动态路由功能了,动态路由以中括号[...]来命名,根据上面例子我们则需要更改hello目录为hello/[slug]/page.tsx

这里的[slug]就表示动态匹配(slug不是必须这个格式,但是获取参数需要根据对应名称来获取)。

此时修改hello/[slug]/page.tsx代码如下:

async function HelloPage({ params }: { params: Promise<{ slug: string }>}) {
  const { slug } = await params;
  return (
    <div>
      Hello:{ slug }
    </div>
  );
}

export default HelloPage;

此时再访问/hello/a时,将会看到如下内容:

image.png

如何匹配多层动态路由

当需要捕获多层动态路由时,例如:/hello/a/b/c,此时就需要通过[...slug]这种方式命名目录,更改hello/[slug]/page.tsxhello/[...slug]/page.tsx,修改为如下代码:

async function HelloPage({ params }: { params: Promise<{ slug: string[] }>}) {
  const { slug } = await params;
  return (
    <div>
      Hello:{...slug}
    </div>
  );
}

export default HelloPage;

此时再访问/hello/a/b/c将会看到如下页面:

image.png

这里的params是固定的写法,与searchParams不同,通过params来获取的是动态路由上面的参数!

[...slug]和[[...slug]]

动态路由的命名方式有两种,一种就是刚刚使用的[...slug],还有一种就是[[...slug]]。他们的区别就是在于,是否有匹配的动态参数。

例如当访问/hello时,[...slug]会出现404,而[[...slug]]依然呈现页面,只是没有参数。

参数获取

除了动态路由的方式能获取参数以外,还可以通过路径后面的?追加参数并获取,例如当访问hello/a?name=cola时,此时可以通过下面方式获取参数:

async function HelloPage({
  params,
  searchParams
}: {
  params: Promise<{ slug: string[]}>,
  searchParams: Promise<Record<string, string>>
}) {
  const { slug } = await params;
  const { name } = await searchParams;
  return (
    <div>
      Hello:{...slug},{name}
    </div>
  );
}

export default HelloPage;

此时页面将会更新为:

image.png

服务端渲染

默认创建的页面都是服务端渲染的页面,即不能使用react.jsuseState或者useEffect这类依赖浏览器类的api,需要定义变量直接在函数里面定义即可,也不需理会生命周期等元素,例如:

async function HelloPage({
  params,
  searchParams
}: {
  params: Promise<{ slug: string[]}>,
  searchParams: Promise<Record<string, string>>
}) {
  const { slug } = await params;
  const { name } = await searchParams;
  const animals = ['松鼠', '大象', '老虎'];
  // const [animals] = useState(['松鼠', '大象', '老虎'];); 不能使用,会报错

  return (
    <div>
      <p>Hello:{...slug},{name}</p>
      <p>{animals}</p>
    </div>
  );
}

export default HelloPage;

客户端渲染

倘若我们需要页面进行客户端渲染而不是服务端渲染,此时则需要再page.tsx文件顶部添加'use client;'来进行标记,此时next.js将对该文件进行客户端渲染

当使用客户端渲染时,此时就要用到react.js里面的useState或者useEffect这类钩子函数,而不是像服务端组件那样直接定义变量。

另外,在客户端组件中,因为不能使用async来定义函数,所以获取参数的方式也有变化,其改变如下代码所示:

'use client';

import { useSearchParams } from "next/navigation";
import { use } from "react";

function HelloPage({
  params,
}: {
  params: Promise<{ slug: string[]}>,
  searchParams: Promise<Record<string, string>>
}) {
  const { slug } = use(params);
  const name = useSearchParams().get('name');
  const animals = ['松鼠', '大象', '老虎'];

  return (
    <div>
      <p>Hello:{...slug},{name}</p>
      <p>{animals}</p>
    </div>
  );
}

export default HelloPage;

动态路由的参数params通过React.use来获取,而路径参数则通过useSearchParams方法获取之后,再通过get或者getAll获取参数。

注意事项

即使是使用了客户端渲染的页面,也可能出现window is not defined这类问题产生,这可能是由于引入的第三库直接就使用了window的原因。而next.js的页面呈现,也会在node环境下进行,所以导致该类问题产生。

解决方案:考虑在useEffect钩子函数里面动态引入。

404页面

next.js目录中,我们可以用not-found.tsx文件来命名404页面,不过情况又分为两种。

路径404页面

路径404页面是指我们访问不存在的路由时,展示出来的页面。一般来说,当访问不存在的路由时,都会返回app目录下的not-found.tsx文件。

逻辑404页面

逻辑404页面是指,在页面中可能遇到不存在的情况时,需要通过代码来跳转到404页面。例如:动态路由需要传递[id],但此时获得了不为数字的id时,就可以通过执行notFound方法来跳转。

逻辑跳转会从当前目录的not-found.tsx文件开始查找,直到根目录下的not-found.tsx文件。

加载页面

加载页面即loading.tsx是在数据还在请求或者组件正在挂载时,展示的页面。它也可以放在根目录或者其他页面目录下,其原理就等同于Suspense组件。

app/
  dashboard/
    page.tsx
    loading.tsx
// 框架内部伪代码
<Suspense fallback={<DashboardLoading />}>
  <DashboardPage />
</Suspense>

错误页面

组与私有目录

组和私有目录都是在app目录下,但不会被next.js识别为页面的两种命名形式。

通过(...)来命名,可以用于多级根布局的实现,例如有模块A模块B两个模块,需要不同的布局。此时,创建两个目录(A)(B),然后分别在其目录下创建新的layout.tsx布局文件。

私有目录

私有目录通过_来命名,可以用于表示存放组件的目录,例如页面pageA下需要创建一些只用于该页面的组件时,就可以在pageA目录下创建_components目录来存放。

并行路由和拦截路由

并行路由和拦截路由都是不被next.js识别为页面的两种命名方式,拦截路由依托于并行路由来实现效果。

并行路由

先来说一下并行路由,并行路由是在页面目录下通过@xxx/page.tsx命名的文件,该页面也可以像children一样通过layout.tsx展示,方式如下:

// pageB/@top/page.tsx
function PageBTop() {
  return (
    <div>
      PageBTop
    </div>
  );
}

export default PageBTop;
// pageB/layout.tsx
import { ReactNode } from "react";

function LayoutB({
  children,
  top,
}: {
  children: ReactNode,
  top: ReactNode,
}) {
  return (
    <div className="border border-red-400">
      <div>LayoutB-Header</div>
      <div>{top}</div>
      <div>{children}</div>
      <div>LayoutB-Footer</div>
    </div>
  );
}

export default LayoutB;

其展示的页面如下:

image.png

对比写到一个page.tsx文件的好处就是,并行路由可以单独的写其他逻辑,例如loading.tsxnot-found.tsx等文件,甚至也可以在并行路由下创建新的路由。

注意:创建并行路由没有生效时,删除.next文件重新启动。

拦截路由

拦截路由是用于,当点击某个会进行路由跳转的UI(图片、按钮等)时,不进行页面跳转,而是在当前页面中进行显示,写法为:

  • (.)匹配同一级别的段
  • (..)匹配上一级的段
  • (..)(..)匹配上两级的段
  • (...)匹配 app目录中的段

现在修改pageB目录,将@top/目录为@top/(..)pageA/page.tsx,此时当在pageB目录下的进行点击跳转到/pageA路由时,将会弹出@top/(..)pageA/page.tsx下的文件,如下:

// @top/(..)pageA/page.tsx
function PageA() {
  return (
    <div className="fixed left-1/2 top-1/2 -translate-1/2 w-lg aspect-[3/2] border border-gray-100 bg-white shadow-md p-3 rounded-md">
      PageA
    </div>
  );
}

export default PageA;

提示

上面记得要在@top目录下创建一个default.tsx文件,否则会出现404问题,内容如下即可:

// @top/default.tsx
function Page() {
  return null;
}

export default Page;
// pageB/layout.tsx
import Link from "next/link";
import { ReactNode } from "react";

function LayoutB({
  children,
  top,
}: {
  children: ReactNode,
  top: ReactNode,
}) {
  return (
    <div className="border border-red-400">
      <div>LayoutB-Header</div>
      <div>
        {children}
        <Link href="/pageA">拦截路由</Link>
      </div>
      <div>LayoutB-Footer</div>
      {top}
    </div>
  );
}

export default LayoutB;

点击拦截路由跳转时,页面效果如下:

image.png

提示

拦截路由不会影响直接在浏览器中输入路由的操作,意味着输入/pageA路由时依然展示为pageA页面。

建议

如果不是非得使用拦截路由并行路由的话,暂时不建议。我在一些论坛上看到挺多人提出问题的,自己在学习这部分时也有问题,有时需要删除.next重新启动服务才能解决。

中间件

next.js中,中间件一般是用来做路由拦截、响应或者鉴权来使用的。通过在src或者根目录下创建文件middleware.ts来使用,并且需要默认导出一个函数,如下:

// src/middleware
export default function middleware() {
  console.log('middleware.');
}

当刷新页面后,可以看到终端打印如下,输入了很多'middleware'语句:

image.png

这是因为middleware处理的不仅仅只是路由请求,还有其他资源请求,修改代码如下,再刷新页面可以看到:

// src/middleware
import { NextRequest } from "next/server";

export default function middleware(req: NextRequest) {
  console.log(req.url + ':middleware.');
}

image.png

如果需要对指定路径进行处理的话,就需要使用匹配器,其使用方法如下:

// src/middleware
import { NextRequest } from "next/server";

export default function middleware(req: NextRequest) {
  console.log(req.url + ':middleware.');
}

export const config = {
  matcher: ['/pageB'],
}

此时只有当请求路径为/pageB的路径才会被中间件处理,刷新页面后打印效果如下:

image.png

所以,通过中间件功能我们也可以实现鉴权等功能,例如:

// src/middleware
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(req: NextRequest) {
  const token = req.cookies.get('token');
  if (!token) {
    return NextResponse.redirect(new URL('/login', req.url));
  }
}

export const config = { matcher: ['/dashboard/:path*'] };

中间件的功能还是比较丰富的,就不一一赘述了,具体可以看文档:next.js中间件

服务端API

next.js中可以通过在app目录下创建api目录来作为后端接口,进行响应。

目录名称api是固定写法,可以通过next.config.ts配置文件进行修改。

api目录下,可以新建新的目录进行api接口命名,类似页面那样,例如在api目录新建hello/route.ts文件,就代表接口为:/api/hello

route.ts

现在已经知道怎么创建一个api了,如何创建对应的响应呢?通过next.js提供的写法来实现,如下:

// src/app/hello/route.ts
import { NextResponse } from "next/server"

export const GET = () => {
  return NextResponse.json({
    code: 0,
    data: 'this is data',
  }, { status: 200 });
}

上面的GET方法就表示为/api/helloGet请求处理逻辑,其他请求类型写法也一样。此时,打开浏览器访问http://localhost:3000/api/hello,可以看到显示如下:

image.png

元数据

next.js中,元数据其实就是通过js对象来管理head标签的一种方式,其作用包含对SEO的优化等,分为两种情况。

静态元数据

静态元数据,即固定显示的,无需动态修改。通过导出一个metadata对象即可,例如:

import { Metadata } from "next";

export const metadata: Metadata = {
  title: 'PageC',
  description: 'PageC description.'
}

function PageC() {
  return (
    <div>
      PageC
    </div>
  );
}

export default PageC;

上面我们定义了titledescription两个元数据,此时再看页面可以发现:

image.png

动态元数据

当元数据需要动态添加时,此时就不能直接用对象的方式定义,而是通过函数generateMetadata来实现,如下:

import { Metadata } from "next";

export const generateMetadata = async (): Promise<Metadata> => {
  const getMetaData = () => new Promise<{ title: string, description: string }>(resolve => setTimeout(() => {
    resolve({
      title: 'Async PageC',
      description: 'Async PageC description.'
    })
  }, 1e3));

  const { title, description } = await getMetaData();

  return {
    title,
    description
  }
}

function PageC() {
  return (
    <div>
      PageC
    </div>
  );
}

export default PageC;

此时在页面上可以看到动态添加的属性,如下图:

image.png

元数据的配置还有很多,具体可以看:next.js元数据

图像

next.js中内置了经过优化的Image图像组件,使用该组件时需满足以下任意一条:

  • 给定明确的widthheight属性;
  • 给定fill属性;

例如:

function PageC() {
  return (
    <div>
      PageC
      <Image src="/cover.jpg" alt="img" width={80} height={120} />
      <div className="w-[80px] h-[120px] relative">
          <Image src="/cover.jpg" alt="img" fill />
      </div>
    </div>
  );
}

export default PageC;

倘若使用远程图片的话,则需要在next.config.ts中进行配置,否则构建阶段可能出现意想不到的问题,具体配置可以看:next.js内置图像组件

缓存

next.js中,请求接口一般采用fetch方法,并在next.js对于fetch方法进行了扩展,增加了缓存的功能,而且缓存不仅与请求,对于页面来说next.js也进行了缓存功能的实现。

fetch缓存

在使用fetch时,当我们添加了如下参数,即进行了缓存:

// 此时在一分钟内的请求都会返回缓存的数据
fetch('https://...', { next: { revalidate: 60 } });

如果说不需要缓存功能的话,则用如下方式请求:

fetch('https://...', { cache: 'no-store' });

另外,在扩展的fetch请求中还可以通过打上tag来强制刷新,例如:

fetch('https://...', { next: { tags: ['test'] } });

当执行revalidateTag('test')后,下一次带有test标签的请求将会获取最新的数据。

页面缓存

在页面中,可以通过定义revalidate属性来配置缓存效果,例如:

export const revalidate = 60;

function PageC() {
  return (
    <div>
      PageC
    </div>
  );
}

export default PageC;

此时,当通过next build构建时,将会预渲染这个页面,并且能缓存60s的时间,当过了缓存时间后再请求新的页面数据,该配置有3种:

  • false 强制为SSG模式,不再对页面进行动态渲染;
  • 大于0的数字 页面的缓存时间;
  • 0 不进行页面缓存,每次请求直接通过SSR来渲染页面;

默认来说,在next.js中是通过静态生成+SSR方式来进行构建的,也就是所谓的ISR增量静态再生。

样式

对于样式来说,第一个推荐直接使用css文件,然后在页面或者组件中直接引入,类似默认next.js项目引入global.css一样。

第二个就是推荐创建next.js时,可选安装的tailwindcss工具,这个工具是一个原子化CSS的写法,用起来非常方便和简洁。

ESLint

在创建next.js时,也可选用eslint工具,此时会在根目录生成eslint.config.mjs文件,内容如下:

import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const compat = new FlatCompat({
  baseDirectory: __dirname,
});

const eslintConfig = [
  ...compat.extends("next/core-web-vitals", "next/typescript"),
  {
    ignores: [
      "node_modules/**",
      ".next/**",
      "out/**",
      "build/**",
      "next-env.d.ts",
    ],
  },
];

export default eslintConfig;

如果需要自定义规则的话,可以修改上述代码,例如:

const eslintConfig = [
  ...compat.config({
    extends: ['next/core-web-vitals', 'next/typescript'],
    // 自定义插件
    plugins: ['simple-import-sort'],
    // 自定义规则
    rules: {
      'semi': ['warn', 'always'],
      'quotes': ['error', 'single'],
      'simple-import-sort/imports': 'error',
      'simple-import-sort/exports': 'error'
    }
  }),
  {
    ignores: [
      'node_modules/**',
      '.next/**',
      'out/**',
      'build/**',
      'next-env.d.ts',
      'src/components/ui/**'
    ],
  },
];

ShadCN

这里推荐一个工具:shadcn,他不是单纯的UI库,而是将radix-uitailwindcssthemeicon等整合的一个工具平台,安装也很简单,如下:

npx shadcn@latest init

具体细节可以看:shadcn文档

结束

以上就是next.js的常用基础概念,因为其概念比较多,不能都详细说明,当然这也是优秀强大的框架带来的学习成本。其生态技术远不止这些,感兴趣的话可以通过论坛、视频等再进行学习!

AI智能问数能力全面升级,DataEase开源BI工具v2.10.13 LTS版本发布

2025年9月16日 19:19

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

重要更新:AI智能问数能力全面升级

DataEase v2.10.13 LTS版本重磅引入全新的开源智能问数系统SQLBot(*github.com/dataease/SQ… *),完全替代DataEase原有的Copilot功能。SQLBot是基于大语言模型和RAG(Retrieval Augmented Generation,检索增强生成)的开源智能问数系统,帮助用户实现数据的即问即答,有效提升数据查询的智能化水平。

与同类产品相比,SQLBot的优势体现在以下方面:

■ 更快速:通过自然语言即可快速生成复杂的SQL查询;

■ 更精准:智能理解业务意图,大幅减少查询错误;

■ 更开放:基于开源架构,支持社区贡献和自定义扩展。

DataEase嵌入SQLBot后将全面升级用户的智能问数体验,辅助用户高效挖掘数据价值,让数据分析变得更加简单和直观。

▲图1 SQLBot智能问数工作原理

▲图2 DataEase嵌入SQLBot智能问数系统

DataEase v2.10.13 LTS版本的其他功能变动包括:查询组件方面,日期查询组件增加快捷选项;仪表板/数据大屏方面,通用配色增加斑马纹配色;系统设置方面,系统变量值支持设置别名。

X-Pack增强包的功能变动包括:数据填报方面,内置数据源增加支持数据填报功能配置。

新增功能

■ feature(系统参数):支持SQLBot嵌入设置;

■ feature(仪表板、数据大屏):通用配色增加斑马纹配色;

■ feature(查询组件):日期查询组件增加快捷选项;

■ feature(系统设置):系统变量值支持设置别名(#16713);

■ feature(X-Pack,数据填报):内置数据源增加支持数据填报功能配置。

功能优化

■ refactor(图表):优化移动端跳转问题;

■ refactor(数据大屏、仪表板):资源发布时,清理无用数据(#16749);

■ refactor(X-Pack,定时报告):定时报告页面增加额外等待时间设置;

■ refactor(X-Pack,用户管理):创建用户API接口返回数据增加用户ID(#16825);

■ refactor(X-Pack,同步管理):使用Quartz统一管理任务调度;

■ refactor(X-Pack,同步管理):对Elasticsearch数据源时间类型字段进行优化,默认映射为Doris的String类型;

■ refactor(X-Pack,同步管理):优化任务状态刷新机制;

■ refactor(X-Pack,同步管理):优化同步日志提示信息;

■ refactor(X-Pack,同步管理):优化同步配置参数传递逻辑;

■ refactor:更新移动端样式。

Bug修复

■ fix(图表):修复放大Tab中图表并执行导出图片操作后,轮播提示显示异常的问题;

■ fix(图表):修复明细表合并单元格后,维度存在空值时导出失败的问题(#16777);

■ fix(图表):修复明细表基础样式勾选“自动换行”选项后,表头会出现部分信息不显示的问题(#16804);

■ fix(图表):修复明细表字段全都是维度时,导出的Excel文件中单元格合并失效的问题;

■ fix(图表):修复水波图标签被裁剪的问题(#16798);

■ fix(图表):修复查询组件首选项会影响跳转条件的问题;

■ fix(图表):修复透视表列维度为空时,带格式导出会失败的问题;

■ fix(图表):开启轮播提示的图表启用缩略轴时,禁用轮播提示;

■ fix(图表):修复透视表自定义汇总时,通过搜索添加字段会报错的问题;

■ fix(图表):修复地图中福州与福清、平潭的连线出现多余连接线的问题(#16877);

■ fix(图表):字段自定义排序去除空值(#16307);

■ fix(图表):修复明细表隐藏列后,导出数据出现错乱的问题(#16785);

■ fix(查询组件):修复设置默认值和必填项,同时隐藏查询按钮时,输入任意值回车后均会重置为默认值的问题(#16750);

■ fix(查询组件):修复手动输入型选项值在查看时,默认值显示为未勾选状态的问题(#16709);

■ fix(查询组件):修复开启首选项时未关闭查询按钮,导致查询按钮失效的问题(#16894);

■ fix(查询组件):修复文本搜索需两次回车才能返回结果,首次触发显示“暂无数据”的问题;

■ fix(查询组件):修复时间范围组件默认值提示“超出日期筛选范围”的问题(#16914);

■ fix(查询组件):修复行权限与过滤组件“首项”默认值同时使用时,会导致过滤功能失效的问题;

■ fix(仪表板):修复手动输入型查询组件出现样式错位的问题;

■ fix(仪表板):修复仪表板缩放模式切换为“按组件比例缩放”后,保存发布再次查看不生效问题(#16885);

■ fix(数据大屏):修复Tab及内部组件的右键菜单定位偏移问题;

■ fix(仪表板、数据大屏):修复设置图表背景图时出现多余滚动条问题;

■ fix(数据源):修复调整浏览器窗口大小时,Excel字段选择器的选中状态被隐藏的问题;

■ fix(数据源):修复无数据源时点击“创建数据集”按钮,页面卡死且控制台报错的问题(#16831);

■ fix(数据源):修复飞书渠道查看更新记录提示异常的问题;

■ fix(数据源):修复SQL Server数据过滤异常的问题;

■ fix(数据源):修复Calcite转SQL Server时CONCAT函数异常的问题(#16860);

■ fix(数据源):修复SQL Server下拉树组件报错问题;

■ fix(数据集):修复字段表达式引用新建计算参数失效的问题;

■ fix(数据集):修复自定义SQL未保存直接关闭时缺少提示信息的问题;

■ fix(系统设置):修复三方嵌入配置仅保存不验证时,错误触发应用状态变更的问题;

■ fix(系统设置):修复弹窗层级显示异常的问题;

■ fix(X-Pack,同步管理):修复SQL查询验证错误;

■ fix(X-Pack,同步管理):修复SQL Server表名含@符号时任务创建失败的问题;

■ fix(X-Pack,同步管理):修复下次执行时间计算错乱问题;

■ fix(X-Pack,同步管理):修复数据源异常时同步日志丢失的问题;

■ fix(X-Pack,同步管理):修复SQL Server uniqueidentifier字段导致定时任务创建失败的问题;

■ fix(X-Pack,同步管理):修复字段数量过多导致任务创建失败的问题;

■ fix(X-Pack):修复“权限配置”→“按用户配置”时,用户量过大导致页面卡顿的问题;

■ fix(X-Pack):修复移动仪表板后,后台缓存未即时刷新的问题;

■ fix(X-Pack):修复在“组织管理”模块中修改名称需刷新浏览器才能生效的问题(#16751);

■ fix(漏洞):修复DB2 LDAP存在的SSRF漏洞(CVE-2025-58045);

■ fix(漏洞):修复Impala数据源JDBC攻击漏洞(CVE-2025-58046);

■ fix(漏洞):修复Redshift JDBC绕过漏洞(CVE-2025-58748);

■ fix:修复模板市场地址提示错误(#16858);

■ fix:统一数据大屏与仪表板事件触发规则,编辑状态下禁止触发;

■ fix:修复存储单位判断脚本中非GB单位的处理问题。

教你如何用 JS 实现 Agent 系统(2)—— 开发 ReAct 版本的“深度搜索”

作者 唐某人丶
2025年9月16日 18:59

一、前言

大家好,我是唐某人~ 我非常乐意与大家分享我在AI领域的经验和心得。我的目标是帮助更多的前端工程师以及其他开发岗位的朋友,能够以一种轻松易懂的方式掌握AI应用开发的技能。希望我们能一起学习,共同进步!

1.1 前情回顾

在前文《教你如何用 JavaScript 实现 Agent 系统(一)—— Agentic System 概览》中,我详细介绍了Agent系统的基本概念,并探讨了构建一个基础的单体Agent的方法。

1.2 目标

最近一个月,我写了多篇关于人工智能的文章,并总结了一些经验。首先文章不宜过长,否则写作费劲,阅读也累,容易失去思路。其次,使用实际例子比过多理论更有效,这样不仅吸引人,还能带来成就感,内容也更生动具体、易于接受。
回顾和总结后,我计划将未来的内容分为四个部分来写,以提高效率并保证每部分都有深度。然后,我会通过实现一个“深度搜索”的应用来引导大家实践,希望能激发大家动手实践的兴趣。

未来的内容的核心是:学习四种主流的 Agent 设计模式,并且用这些模式实现四个不同版本的 “深度搜索”。

画板

所以本篇的目的:

  1. 理论:学习 ReAct 模式的运行机制
  2. 实战:用 ReAct 设计模式实现“深度搜索”Agent

二、深度搜索

2.1 什么是深度搜索

在实践之前,我们先来了解一下什么是深度搜索?这里给大家演示两个产品,你自己也可要去体验一下。

通义千问的 “分析研究”

千问会基于用户提出的问题,首先生成一个研究分析框架。随后,它会在整个互联网范围内自动搜集相关信息,并通过持续的信息检索、分析与综合处理,最终产出一份详尽且全面的问题分析报告。

豆包的“深入研究”

可以根据用户的问题,自动规划研究、全网搜集资料并进行综合分析,最终提供一份分析报告。

2.2 它是怎么玩的

它的本质是通过 LLM 与搜索工具结合来解决问题。具体步骤如下:

  1. LLM 分析解答问题还缺失哪些信息
  2. 利用搜索工具获取实时信息
  3. LLM 整合和分析信息后提供答案

画板

比起让大模型只用预训练时的数据来回答问题,这种“深度搜索”方式在时效性和准确性上表现更好。所以,在处理学术研究、时事新闻、金融分析等领域的问题时,它特别合适。

三、什么是 ReAct

官方的说,ReAct(由Yao在2023年提出)是一种让 LLM 理通过思考、行动、观察的循环来完成任务的框架。简单的说,它就是一种提示词工程和代码实现的设计模式。其工作流程如下:

  1. 思考:确定下一步需要采取什么行动。
  2. 行动:输出指令,使AI调用外部工具。
  3. 观察:分析工具执行的结果。
  4. 回答:如果结果已足够解答问题,则组织答案。
  5. 循环:如果信息不足,重复上述步骤。

为了让大家对这个流程有一个更直观的理解,我准备了一张流程图供大家参考。

画板

首先,我们需要明确两个关键角色以及它们的关键组成部分:

  1. 问题提出者:也就是发起对话的用户。
  2. Agent:由大型语言模型(LLM)、对话记忆以及工具调用功能组成。

简化后的内容如下:

  1. 用户提问。
  2. LLM(大语言模型)接收问题,分析并输出解决方案(思考阶段)。
  3. LLM 判断是否需要使用工具来辅助解决问题,该过程会发出调用指令(行动阶段)。
  4. 系统根据指示调用工具,并将结果反馈给用户。
  5. 该过程的**所有步骤(用户提问、LLM 思考、工具指令、工具结构)**都会记录在对话上下文中
  6. 对话上下文提交给 LLM 进行再次分析(观察阶段)。
  7. LLM 分析上下文,若需更多信息,则重复思考-行动-观察循环;否则直接提供答案

四、代码实现

好的,我们已经掌握了理论基础,接下来就进入实战阶段吧!我们将通过构建一个 ReAct 版本的“深度搜索”项目,来更直观地体验和理解ReAct模式的工作原理。希望这个过程能让你有更深的体会!

4.1 封装增强版LLM

首先还是老套路,构建 Agent 的三个基础是 LLM、对话记忆、工具调用,所以我们得封装一个增强版本的 LLM。核心是功能:

  1. 支持设定系统提示词
  2. 自动记录对话上下文
  3. 自动调用工具并触发下一轮对话

因为这些内容在上一篇详细讲过,下面只展示一下核心代码,完整代码请看 github.com/zixingtangm… 的实现。

export class Block {
  // 其他属性……

  // 对话内容
  private messages: Message[] = [];

  constructor(config: BlockConfig) {
    // 初始化
  }

  // 对话
  public async invoke(messages?: Message[]): Promise<AssistantMessage> {
    const { model, apiKey, baseUrl, response_format } = this.llmBaseConfig;

    // 记录用户对话
    if (messages) {
      this.messages.push(...messages);
    }

    // 调用 LLM API
    const res = await fetch();

    // LLM 回复的文本内容
    let assistantMessage = '';
    // 工具调用指令
    let tools: Record<number, ToolCall> = {};

    const reader = res.body?.getReader();
    const decoder = new TextDecoder();
    while (true) {
      // 流式的存储 LLM 的回复以及工具调用指令
    }

    // 最终回复
    let message: AssistantMessage;

    // 没有工具调用,会直接回复
    if (assistantMessage && Object.keys(tools).length === 0) {
      message = new AssistantMessage(assistantMessage);
      this.messages.push(message);
    }

    // 有工具调用,需要调用工具,自动触发一轮新的对话
    if (Object.keys(tools).length > 0) {
      // 提取参数
      const tool_calls = Object.values(tools).map((tool) => tool);
      // 记录 LLM 的工具调用指令
      this.messages.push(message);

      // 执行全部的工具
      const callToolTasks = Object.values(tools).map(async (tool) => {
        let result = '';
        try {
          result = await this.tools.call(tool.function.name, JSON.parse(tool.function.arguments));
        } catch (error) {
          result = `${tool.function.name} 执行异常`;
        }
        return JSON.stringify(result);
      });
      const toolResults = await Promise.all(callToolTasks);


      // 每个工具的结果,创建一个 tool message 存入对话上下文中
      const toolResultMessages = toolResults.map((result, index) => {
        console.table([{ node: this.name, type: 'tool', json: JSON.stringify(result) }]);
        return new ToolMessage(result, tools[index].id);
      });
      this.messages.push(...toolResultMessages);

      // 触发新一轮的对话
      return await this.invoke();
    }

    return message;
  }
}

4.2 设定提示词

有了 Block 基础类之后,接下来就可以开始设计提示词了。提示词的设计主要围绕以下三点展开:

  1. 明确角色:告诉 LLM 它扮演什么角色,以及它的任务是什么。
  2. 细化工具使用场景:补充核心工具在什么情况下使用,具体怎么用。
  3. 强调关键点:提醒 LLM 在执行任务前一定要先思考,并且要一步一步来,不能跳过任何环节。
const prompt = `
你是一个专业的搜索研究助理,你需要搜集到准确、实时的信息,然后通过总结和分析来解决用户的问题。

以下是可用的工具和使用场景:
1.Thought: 用于观察现有的上下文内容,思考是否具备回答问题的条件,如不满足则思考接下来还要搜集什么信息、分析什么信息
2.TavilySearch: 用于搜索互联网的相关内容
3.GetCurrentDate: 获取当前的时间,以保证搜集、分析、回答的内容是与用户期望的时间相差不远的

注意:
1. 你必须在调用 TavilySearch、GetCurrentDate 工具的前后调用 Thought 工具,用于观察现有上下文的内容,思考后续的步骤
2. 所有的工具不能并行调用,必须按顺序,逐一的调用
3. 你需要一步一步的思考
`;

声明一个 ReActAgent 类,在初始化的时候创建 Block 实例并设定系统提示词

export class ReActAgent {
  private agent: Block;
  constructor() {
    this.agent = new Block({
      name: 'agent',
      instruction: prompt,
    });
  }
}

4.3 工具设定

想让 Agent 解决特定领域的问题,就得给它准备好解决问题需要的工具。下面是我们必须提供的几个工具:

  1. Thought(思考工具):这个工具的核心作用是让大模型先好好思考,并且把思考的过程展示出来。
  2. TavilySearch(搜索工具):用来在互联网上查找相关信息,帮助解决需要外部资料的问题。
  3. GetCurrentDate(获取时间工具):在处理一些需要知道当前时间的问题时,可以用这个工具来获取最新的日期或时间信息。

简单来说,就是给 Agent 配备好“思考”、“查资料”和“看时间”的能力,让它能更高效地完成任务。

Thought

强制 LLM 把思考的过程,通过工具调用的形式输出到对话的上下文中

export const thoughtTool: Tool = {
  type: 'function',
  function: {
    name: 'Thought',
    description: '用于输出用于思考解决用户的问题,需要做什么以及这么做的原因',
    parameters: {
      type: 'object',
      properties: {
        thought: {
          type: 'string',
          description: '思考的内容',
        },
      },
      required: ['thought'],
    },
  },
  func: async (args: { [key: string]: any }) => {
    return `Thought: ${args.thought}`;
  },
};

TavilySearch

可以先去 www.tavily.com/ 申请一个 API KEY,会有一定的免费调用额度。

申请完 API KEY 以后,在项目中配置这个 KEY,并下载 @tavily/core 这包就可以使用了。

import { tavily } from '@tavily/core';

const tvly = tavily({ apiKey: process.env.TAVILY_API_KEY! });

export const tavilySearchTool: Tool = {
  type: 'function',
  function: {
    name: 'TavilySearch',
    description: '用于搜索互联网的相关内容',
    parameters: {
      type: 'object',
      properties: {
        query: {
          type: 'string',
          description: '搜索的查询内容',
        },
      },
      required: ['query'],
    },
  },
  func: async (args: { [key: string]: any }) => {
    try {
      const res = await tvly.search(args.query);
      const data = res.results.map(({ title, url, content }) => ({
        title,
        url,
        content,
      }));
      return JSON.stringify(data);
    } catch (error) {
      return 'Error: Tavily 搜索失败';
    }
  },
};

GetCurrentDate

获取当前的时间。核心是为了确保 LLM 回答问题的时效性,让 LLM 知道当前的时间。

export const getCurrentDateTool: Tool = {
  type: 'function',
  function: {
    name: 'GetCurrentDate',
    description: '获取当前日期',
  },
  func: async () => {
    return new Date().toLocaleDateString();
  },
};

配置工具

将相关的工具配置好。完成代码看这里:github.com/zixingtangm…

import { getCurrentDateTool } from '../tools/get-current-date';
import { tavilySearchTool } from '../tools/tavily-search';
import { thoughtTool } from '../tools/thought';

export class ReActAgent {
  private agent: Block;
  constructor() {
    this.agent = new Block({
      name: 'agent',
      instruction: prompt,
      tools: new Tools([thoughtTool, tavilySearchTool, getCurrentDateTool]),
    });
  }
}

4.4 测试效果

这里我们来测试一下,询问“今天关于 AI 的热点新闻有哪些?”

你可以看到,现在的 LLM 已经学会了一种“先想后做”的方式来一步步解决我们的问题。它会先用 Thought 工具把自己的思考过程写出来,然后调用 GetCurrentDate 来获取当前时间,接着再用 TavilySearch 去查找我们需要的信息……

其实这就是 ReAct 的运行流程,非常的简单。当然,你还可以尝试问更多有意思的问题 😏

五、最后

5.1 思考

现在你已经能够基于 ReAct 模式开发一个“深度搜索”的智能体应用了。但是不知道你是否发现,我们的设计存在一定的问题。你是否有想过,如果 LLM 一直认为搜索的信息还不够,然后不停的迭代搜索会出现哪些问题呢?

先思考,我们下一篇会详细讲解这个问题。

5.2 总结

最后我们再来总结一下本篇的内容。

  1. 什么是深度搜索:基于 LLM + 搜索工具实现的一种 Agent。它能够分析用户问题,发现缺失的信息,自动搜索信息并进行分析,最后总结回答。
  2. ReAct:是一种让 LLM 理通过思考、行动、观察的循环来完成任务的 Agent 设计模式。本质是让 LLM 模仿人类 “先思考后行动”的思维,一步步的解决问题。

5.3 后文

下一遍会继续分享 Agent 的设计模式——如何用 Plan And Execute 模式实现深度搜索。如果你觉得内容对你有帮助,请关注我,我会持续更新~

原创不易,转载请私信我。

深入剖析 useState产生的 setState的完整执行流程

作者 XXUZZWZ
2025年9月16日 18:57

我们结合上一篇文章React 执行阶段与渲染机制详解(基于 React 18+ 官方文档)React 执行阶段与渲染机制详解(基于 Rea - 掘金梳理的 React 执行阶段(Render Phase, Commit Phase, Passive Effect Phase)和状态管理机制,来深入剖析 useState 产生的 setState 的完整执行流程。

理解 setState 的行为是掌握 React 状态管理的核心。它远非简单的“设置一个值”,而是一个触发复杂协调和渲染流程的起点。


🧩 useStatesetState 的核心概念

首先,明确几个关键点:

  1. useState 是 Hook:在函数组件首次渲染时调用,返回一个状态变量和一个 setState 函数。
  2. setState 是调度器 (Scheduler):调用 setState 不会立即更新状态,也不会立即触发重新渲染。它只是向 React 的调度系统“请求”一次状态更新。
  3. 状态更新是异步且可能批量的:React 会为了性能,将多个 setState 调用合并成一次更新,尤其是在事件处理器中。
  4. 状态是不可变的快照:每次渲染,组件函数都会从头开始执行,拿到的是当前渲染周期的 state 快照。setState 会安排下一次渲染使用新的状态。

🔄 setState 的详细执行流程

当您在组件中调用 setState(newValue) 时,以下流程被触发:

第 1 步:触发更新请求 (Trigger Update Request)

  • 发生时机:在您的代码中调用 setScore(score + 1)setScore(prev => prev + 1)
  • 内部动作
    • React 将这个更新请求(包含新值或更新函数)放入一个更新队列 (Update Queue) 中,与当前 Fiber 节点(代表您的组件实例)关联。
    • React 标记该 Fiber 节点及其父路径上的节点为“需要更新”。
    • 关键点:此时,组件函数不会重新执行,DOM 不会更新,state 变量的值在当前函数作用域内依然保持旧值

第 2 步:进入 Render Phase(渲染/协调阶段)

  • 发生时机:React 的调度器(Scheduler)在合适的时机(通常是当前浏览器任务结束后,或根据并发模式的优先级)开始处理更新队列。
  • 内部动作
    1. 计算新状态
      • React 会处理该组件 Fiber 节点上的所有待定更新。
      • 如果 setState 传递的是函数(如 setScore(prev => prev + 1)),React 会按顺序调用这些函数,将上一个函数的返回值作为下一个函数的参数,最终得到一个新值。
      • 如果传递的是(如 setScore(5)),则直接使用该值(如果多次调用,后面的会覆盖前面的,除非是函数形式)。
    2. 触发组件重新渲染
      • React 使用计算出的新状态重新执行您的整个函数组件。
      • 在这次新的执行中,useState Hook 会返回这个新的状态值
      • 组件根据新的 stateprops 生成新的 JSX(Virtual DOM)。
    3. Reconciliation (协调)
      • React 将新生成的 Virtual DOM 树与上一次渲染的树进行比较(Diff 算法)。
      • 计算出最小的、必要的 DOM 变更列表。
    4. 可中断性:在 Concurrent Mode 下,这个阶段是可以被更高优先级的任务(如用户输入)中断的。React 可能会丢弃当前的渲染结果,稍后再重新开始。

第 3 步:进入 Commit Phase(提交阶段)

  • 发生时机:Render Phase 成功完成,计算出最终的变更列表后。
  • 内部动作
    1. Mutation (变更):React 根据 Diff 结果,同步地更新真实的 DOM。这是用户能看到界面变化的时刻。
    2. Layout Effects:执行所有 useLayoutEffect 的清理函数(来自上一次渲染),然后执行本次渲染的 useLayoutEffect 创建函数。这些是同步执行的,发生在浏览器绘制之前,可以用来读取或同步修改刚刚更新的 DOM。
    3. 浏览器绘制:浏览器根据更新后的 DOM 进行布局(Layout)和绘制(Paint),用户看到最终的视觉效果。

第 4 步:进入 Passive Effect Phase(被动副作用阶段)

  • 发生时机:在浏览器完成绘制之后,异步执行。
  • 内部动作
    • 执行所有 useEffect 的清理函数(来自上一次渲染)。
    • 执行本次渲染的 useEffect 创建函数。
    • 这是进行数据获取、设置订阅、记录日志等副作用操作的理想位置,因为它不会阻塞视觉更新。

🎯 关键行为与最佳实践

1. 状态更新的异步性与批量处理

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1); // 请求更新 1
    setCount(count + 1); // 请求更新 2 (基于旧的 count)
    // 最终 count 只会 +1,因为两次都基于同一个旧值
    // React 可能会将这两次更新合并,只触发一次重新渲染
  };

  const handleClickCorrect = () => {
    setCount(prev => prev + 1); // 请求更新 1 (基于上一次的状态)
    setCount(prev => prev + 1); // 请求更新 2 (基于更新 1 后的状态)
    // 最终 count 会 +2,因为每次更新都基于最新的状态
    // 即使合并,函数也会按顺序执行
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Bad Update</button>
      <button onClick={handleClickCorrect}>Good Update</button>
    </div>
  );
}
  • 推荐:当新状态依赖于旧状态时,务必使用函数式更新 (setState(prev => newValue))。这能确保你总是基于最新的状态进行计算,避免因异步和批量更新导致的竞态条件。

2. Render Phase 的纯函数性

在组件函数执行期间(Render Phase),你拿到的 state 是一个固定的快照。任何在 Render Phase 中对 state 的“修改”尝试都是徒劳的,因为下一次渲染会带来新的快照。

function MyComponent() {
  const [value, setValue] = useState(0);

  // ❌ 错误:在渲染过程中直接修改 state 是无意义的,并且违反了纯函数原则
  // setValue(value + 1); // 这会导致无限循环!

  // ✅ 正确:在事件处理器或 Effect 中更新 state
  const handleClick = () => {
    setValue(value + 1);
  };

  return <button onClick={handleClick}>Increment</button>;
}

3. 状态保留与组件身份

正如前面文章所述,React 通过组件在树中的位置来决定是否保留状态。setState 更新的是当前组件实例的状态。

function App() {
  const [show, setShow] = useState(true);

  return (
    <div>
      <Counter /> {/* 这个 Counter 的状态始终保留 */}
      {show && <Counter />} {/* 这个 Counter 在 show 为 false 时会被卸载,状态丢失;show 为 true 时重新挂载,状态重置 */}
      <button onClick={() => setShow(!show)}>
        Toggle Second Counter
      </button>
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0); // 每次挂载都会初始化为 0
  return (
    <div>
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>Add one</button>
    </div>
  );
}

调用第二个 CountersetScore 只会影响它自己的状态。当它被卸载时,其状态会被销毁。重新挂载时,useState(0) 会再次执行,状态重置为初始值。


📊 总结:setState 生命周期图谱

[用户点击按钮][调用 setState(newValue/UpdaterFn)] → (更新入队,标记 Fiber)
        ↓
[React Scheduler 触发 Render Phase]
        ├──> [计算新状态] (处理更新队列)
        ├──> [重新执行组件函数] (拿到新 state)
        ├──> [生成新 Virtual DOM]
        └──> [Reconciliation (Diff)][Commit Phase]
        ├──> [Mutation: 更新真实 DOM]
        ├──> [Layout: 执行 useLayoutEffect]
        └──> [浏览器绘制][Passive Effect Phase]
        └──> [执行 useEffect][等待下一次 setState 或事件...]

理解这个流程,能帮助你:

  • 预测行为:知道为什么状态不会立即更新,为什么需要函数式更新。
  • 调试问题:定位状态更新不生效、无限循环渲染等问题的根源。
  • 优化性能:理解批量更新和并发渲染的原理,写出更高效的代码。
  • 设计架构:合理规划状态提升、状态管理库的使用。

setState 是 React 响应式更新的引擎,掌握其背后的机制,是成为 React 高手的关键一步。

React 执行阶段与渲染机制详解(基于 React 18+ 官方文档)

作者 XXUZZWZ
2025年9月16日 18:42

React 执行阶段与渲染机制详解(基于 React 18+ 官方文档)

🌟 核心概念:React 的执行流程分层

React 的工作流程基于 Fiber 架构,将任务拆分为可中断的单元,核心分为 Render Phase(渲染阶段)Commit Phase(提交阶段),并辅以 副作用阶段卸载阶段。以下是详细解析:


一、Render Phase(渲染阶段 / 协调阶段)

1. 作用与触发

  • 目的:计算新的 Virtual DOM 树,并与旧树进行 Diff(协调算法),决定需要更新的部分。
  • 触发条件
    • 初始挂载(Mount)
    • 状态更新(State Update):useState/useReducer
    • Props 变化(父组件重渲染)
    • 强制更新(forceUpdate

2. 特性

  • 纯函数性:组件函数必须是纯函数,禁止修改外部状态或变量(如 propsstate、全局变量)。
  • 可中断性:React 18 的并发模式下,此阶段可被中断或重启,优先处理高优先级任务。
  • Strict Mode 行为:开发环境下,React 会双调用组件函数,用于检测非纯函数导致的副作用。

3. 代码示例

function Counter({ time }) {
  // 纯函数:相同输入返回相同 JSX
  return (
    <>
      <h1>{time}</h1>
      <input /> {/* 用户输入不会因父组件重渲染而丢失 */}
    </>
  );
}

二、Commit Phase(提交阶段)

1. 作用

将 Render Phase 计算出的变更同步应用到真实 DOM,确保用户界面即时更新。

2. 三个子阶段

  1. Before Mutation
    • 执行 getSnapshotBeforeUpdate(类组件)
    • 执行 useLayoutEffect清理函数
  2. Mutation
    • 实际插入、更新、删除 DOM 节点
  3. Layout
    • 执行 useLayoutEffect创建函数
    • 类组件的 componentDidMount/componentDidUpdate

3. 关键特性

  • 不可中断:此阶段必须一次性完成,确保 DOM 操作的原子性。
  • 避免闪烁:React 仅更新差异节点(Diff 结果),保证 input 值等用户交互状态不丢失。

三、Passive Effect Phase(被动副作用阶段)

1. 作用

在浏览器完成布局和绘制后,异步执行副作用(如网络请求、订阅)。

2. 执行内容

  • useEffect 的创建函数和清理函数
  • 类组件的 componentDidMount/componentDidUpdate/componentWillUnmount 中的异步操作

3. 与 useLayoutEffect 的区别

Hook 执行时机 是否阻塞渲染 适用场景
useEffect Passive Phase ❌ 否 数据获取、日志、非关键副作用
useLayoutEffect Commit Phase(Layout 阶段) ✅ 是 同步 DOM 操作(如测量尺寸)

四、Unmount Phase(卸载阶段)

1. 触发条件

组件从 DOM 中移除(如条件渲染切换、列表项删除)。

2. 执行内容

  • 清理 useEffect/useLayoutEffect 的副作用
  • 类组件的 componentWillUnmount

五、状态保留与重置机制

1. 状态隔离规则

  • 相同位置保留状态:组件在树中的位置不变时,React 保留其 state
  • 位置变化重置状态:组件位置变化时,React 会销毁旧组件并初始化新组件(即使类型相同)。

2. 控制状态保留的技巧

  • Key 属性:通过唯一 key 标识列表项,避免位置变化导致的状态重置。
  • 条件渲染:动态切换组件类型时,状态会被重置(即使 props 相同)。
function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}
    </div>
  );
}

上述示例中,<Counter /> 的位置不变,因此 score 状态会保留。


六、错误处理机制(Error Boundaries)

1. 执行阶段

  • getDerivedStateFromError:同步执行,用于渲染降级 UI。
  • componentDidCatch:提交阶段执行,用于记录错误日志。

2. 限制

  • 无法捕获的错误:事件处理器、异步代码(如 setTimeout)、服务端渲染(SSR)中的错误。

七、性能优化策略

1. 避免过早优化

  • 默认行为:React 的 Diff 算法已足够高效,优先保证代码可读性。
  • 优化时机:当性能瓶颈明显时,使用 React.memouseMemouseCallback 等工具。

2. 优化技巧

  • 减少不必要的 re-renders:使用 React.memo 包裹子组件。
  • 避免在 Render Phase 创建对象/函数:使用 useMemo/useCallback 缓存。
  • 并发模式下的优先级调度:React 自动优化任务优先级,无需手动干预。

八、官方文档与术语参考


九、总结:React 执行流程全貌

用户交互 / 状态更新
        ↓
   [Render Phase](协调)
        ↓
   [Commit Phase]
   ├── Before Mutation → useInsertionEffect / getSnapshotBeforeUpdate
   ├── Mutation        → DOM 更新
   └── Layout          → useLayoutEffect / componentDidMount/Update
        ↓
[Passive Effect Phase] → useEffect
        ↓
[Unmount Phase] → 清理副作用

掌握这一流程,能帮助你编写更符合 React 设计哲学的代码,避免常见陷阱(如非纯函数、滥用 useLayoutEffect),并充分利用 React 的并发能力提升应用性能。

Electron-Vite (一)快速构建桌面应用

作者 会豪
2025年9月16日 18:35

在前面的章节中,我们讲到了快速构建一个Electron的应用,但实际使用起来,对开发者来说,可以说是隔靴搔痒

下面我来将一个新型的构建工具 Electron-Vite

废话不多说,直接开始构建Electron应用

一、安装

1.1 我们随便找一个文件夹,开始创建我们的项目

1.2 然后打开终端,输入指令

npm create @quick-start/electron@latest

输入 y ,继续执行

这里是输入我们的项目名称,这里我就输入了 “study-electron"

这里是选择我们项目的响应式框架,我这里选择了vue框架

这里是选择是否加入TypeScript支持,我选择了是

后面的一些选项,直接选择是即可

1.3 最终会生成这样一个文件夹

进入文件夹,我们会发现文件结构是这个样子

二、运行项目

我们用VS Code打开这个项目

2.1 安装依赖

我们打开VS Code的终端,然后在终端里面输入

npm i

等待npm的安装,最后安装成功的界面如下

2.2 运行

我们打开项目里面package.json文件

可以看到electron-vite已经为我们配置了相当多的命令

然后我们在终端里面输入

npm run dev

我们的电脑桌面就会弹出来这样一个窗口,如下图所示

这个就是我们的Electron应用

相对于原生的Electron创建应用,我们使用的Electron-Vite构建工具要更加的方便,快捷

三、项目文件结构

下面我们来看一下我们的项目文件结构,并一一解释

四、主进程和渲染进程之间通信

我们先认识一下主进程和渲染进程是什么

在 Electron 中,主进程和渲染进程是两大核心概念,分工明确:

  • 主进程
    是应用的入口点(通常对应 main.js),负责管理整个应用的生命周期(启动、关闭等)、创建和控制窗口、处理系统级事件(如菜单、对话框),以及与操作系统交互。
    它运行在 Node.js 环境中,拥有完整的系统权限,可访问 Electron 提供的底层 API(如窗口操作、全局快捷键等)。
  • 渲染进程
    每个窗口对应一个独立的渲染进程,负责渲染网页内容(HTML/CSS/JavaScript),处理页面交互(如 DOM 操作、用户输入)。
    它运行在 Chromium 浏览器环境中,权限受限(类似普通网页),若需访问系统资源或主进程功能,需通过 IPC(进程间通信)与主进程交互。

打个比方:

在主进程中,我们可以随意的操作文件系统,比如说随意删除D盘的某个文件,获取系统信息,监听端口号等等

但是在渲染进程中,我们无法做到上面的事情,渲染进程就和网页端开发一模一样

如果我们需要在渲染进程里面去操作文件系统,就需要进行主进程渲染进程通信

在项目里面

main文件夹就相当于我们的主进程

renderer文件夹就相当于我们的渲染进程

4.1 清理我们Electron项目

我们先让我们的项目变得干净一些,找到我们的App.vue

删除多余内容,修改成如下内容

找到渲染进程下面的main.ts

删除多余代码,变成下面的样子

最后我们的项目页面,就会是这个样子,一片干净整洁

4.2 主进程向渲染进程发消息

第一步:修改我们主进程下面的index.ts文件

主要是将局部变量mainWindow提取出来,变成全局变量

import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'

export let mainWindow: BrowserWindow

function createWindow(): void {
  // Create the browser window.
  mainWindow = new BrowserWindow({
    width: 900,
    height: 670,
    show: false,
    autoHideMenuBar: true,
    ...(process.platform === 'linux' ? { icon } : {}),
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      sandbox: false
    }
  })

  mainWindow.on('ready-to-show', () => {
    mainWindow.show()
  })

  mainWindow.webContents.setWindowOpenHandler((details) => {
    shell.openExternal(details.url)
    return { action: 'deny' }
  })

  // HMR for renderer base on electron-vite cli.
  // Load the remote URL for development or the local html file for production.
  if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
    mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
  } else {
    mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
  }
}

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
  // Set app user model id for windows
  electronApp.setAppUserModelId('com.electron')

  // Default open or close DevTools by F12 in development
  // and ignore CommandOrControl + R in production.
  // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
  app.on('browser-window-created', (_, window) => {
    optimizer.watchWindowShortcuts(window)
  })

  // IPC test
  ipcMain.on('ping', () => console.log('pong'))

  createWindow()

  app.on('activate', function () {
    // On macOS it's common to re-create a window in the app when the
    // dock icon is clicked and there are no other windows open.
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

第二步:然后在我们的主进程文件夹下面创建一个文件 toRendererMsg.ts

文件内容如下

import { mainWindow } from '.'

export const toMsg = (): void => {
  setInterval(() => {
    //获取当前的标准时间
    const date = new Date()
    mainWindow.webContents.send('toMsg', date.toLocaleTimeString())
  }, 1000)
}

这个代码的意思是,每隔一秒钟,我就要向渲染进程发一次消息

第三步:然后我们将这个方法放在index.ts里面执行

第四步:回到我们的App.vue里面,编写下面的程序

<template>
  <span>主进程发过来的消息</span>
  <span>{{ msg }}</span>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'

const msg = ref('')

onMounted(() => {
  window.electron.ipcRenderer.on('toMsg', (e, data) => {
    msg.value = data
  })
})
</script>

在上面的程序里面,我们定义了一个msg的变量,用来接收主进程发过来的消息内容

然后我们在钩子函数里面,定义了监听方法

在主进程里面,我们是通过 toMsg 窗口来发消息的

所以在渲染进程里面,我们就需要通过 监听 toMsg 窗口来获取到消息

接下来我们来运行项目

在终端里面执行

npm run dev

效果如下,可以看到时间在变化

4.3 渲染进程向主进程通信(单向)

第一步:还是回到我们的App.vue文件里面,将程序替换成下面的内容

<template>
  <div @click="handleClick">向主进程发消息</div>
</template>
<script setup lang="ts">
const handleClick = (): void => {
  window.electron.ipcRenderer.send('toMain', 'hello world')
}
</script>

我们定义了一个div,然后定义了一个点击事件

通过这个点击事件,我们可以直接向主进程发消息

第二步:然后我们回到主进程,在主进程文件夹下面创建一个新的文件 monitorEvent.ts

文件内容如下

import { ipcMain } from 'electron'

export const monitorEvent = (): void => {
  ipcMain.on('toMain', (e, data) => {
    console.log(data)
  })
}

然后将这个方法放入到index.ts里面

第三步:运行项目

我们点击这个文字

在我们的vs code的终端里面就会出现我们预期的结果

4.4 渲染进程向主进程通信(双向)

在4.3 中,我们渲染进程的消息发给了主进程,但是这消息是单程消息,发出去就没有后续了,如果我们想发出消息,并获得处理的结果,就需要双向通信了

第一步:还是修改我们的App.vue

<template>
  <div @click="handleClick">获取D盘文件目录</div>

  <template v-for="(item, index) in fileList" :key="index">
    <p>{{ item }}</p>
  </template>
</template>
<script setup lang="ts">
import { ref } from 'vue'

const fileList = ref<string[]>([])
const handleClick = async (): Promise<void> => {
  const res = await window.electron.ipcRenderer.invoke('getFileList', 'D')
  fileList.value = res
}
</script>

第二步:完善一下我们的monitorEvent.ts

改成下面内容

import { ipcMain } from 'electron'
import fs from 'fs/promises'

export const monitorEvent = (): void => {
  ipcMain.on('toMain', (e, data) => {
    console.log(data)
  })

  ipcMain.handle('getFileList', async (e, data) => {
    if (data === 'D') {
      //获取D盘文件列表
      const rootPath = 'D:/' // D盘根目录路径

      try {
        // 读取目录,withFileTypes: true 可以获取文件类型信息
        const entries = await fs.readdir(rootPath, { withFileTypes: true })

        // 分离文件和目录
        const directories = [] as string[]
        const files = [] as string[]

        for (const entry of entries) {
          if (entry.isDirectory()) {
            directories.push(entry.name)
          } else if (entry.isFile()) {
            files.push(entry.name)
          }
        }

        console.log('D盘根目录下的目录:')
        console.log(directories)

        console.log('\nD盘根目录下的文件:')
        console.log(files)

        return [...directories, ...files]
      } catch (err) {
        console.error('读取目录失败:', err)
        throw err // 可以根据需要处理错误
      }
    }
  })
}

第三步:点击触发事件

当我们点击这个文字时

渲染进程就会向主进程发消息,然后主进程整理D盘下面所有文件及目录的名称,返回给渲染进程

4.5 渲染进程向主进程通信(双向-同步)

在 4.4 里面,渲染进程向主进程通信时,需要 async/await 处理,那假如说我现在不想要异步通信,想要同步通信,可以做到吗?

这是当然的,不过弊端就在于主进程在处理数据时,会阻碍渲染进程

第一步:这时我们就需要用到预加载(preload)里面的文件了

找到下面的文件

然后修改成下面的内容

import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'

// Custom APIs for renderer
export const api = {
  getFileList: (path: string): string[] => {
    return ipcRenderer.sendSync('getFileList', path)
  }
}

// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
  try {
    contextBridge.exposeInMainWorld('electron', electronAPI)
    contextBridge.exposeInMainWorld('api', api)
  } catch (error) {
    console.error(error)
  }
} else {
  // @ts-ignore (define in dts)
  window.electron = electronAPI
  // @ts-ignore (define in dts)
  window.api = api
}

第二步:然后再找到与index.ts相邻的index.d.ts,修改内容如下

第三步:我们回到monitorEvent.ts文件里面

将原有的handle改为on

第四步:回到App.vue里面,修改内容如下

<template>
  <div @click="handleClick">获取D盘文件目录</div>

  <template v-for="(item, index) in fileList" :key="index">
    <p>{{ item }}</p>
  </template>
</template>
<script setup lang="ts">
import { ref } from 'vue'

const fileList = ref<string[]>([])
const handleClick = (): void => {
  const res = window.api.getFileList('D')
  fileList.value = res
}
</script>

我们可以看到,handleClick方法原先的async/await 不见了,这表示我们可以进行同步通信

效果就不再展示,大家自行查验

4.6 主进程向渲染进程返回到数据暴露类型

如果大家自己做了一遍4.5的内容,就已经知道主进程向渲染进程返回数据时,怎么暴露数据类型了

如下图所示

我们在预加载ts里面,写了一个方法 getFileList,然后这个方法我们也暴露了返回值的类型,那之后我们在前端接收的时候,就会被告知返回类型是什么

如下图所示,我们可以看到我们用来接收数据的res变量,数据类型是一个string[]

如果我们不通过预加载TS,直接主进程和渲染进程通信,那么我们是无法获取返回值的类型,只能得到一个any类型的返回值

通过预加载TS,可以进行同步通信,也可以获取返回值的类型

五、总结

本章重点讲解electron-vite中,主进程和渲染进程之间的通信,后续我会给大家带来更多实用的小技巧

高德地图-地图选择经纬度问题【使用输入提示-使用Autocomplete进行联想输入】(复盘)

作者 Gracemark
2025年9月16日 18:22

一、问题一:打开第一次弹框这个可以监听,然后关闭。然后使用其他按钮打开弹框,搜索关键词,这个select监听不到

49b42bb2c5277467cbc96119b931b953.jpg

解决问题复盘:

我先打开并浏览 src/views/wlhy/baidumap/bf9-16-2.vue,梳理其与 Map.vue 的差异点和事件绑定、销毁、输入处理等实现,以便做对照复盘。

[1 tool called]

我先通读了 src/views/wlhy/baidumap/bf9-16-2.vue,对照今天在 Map.vue 的修复点,做一次聚焦性的复盘与改进建议。

换个模型--问题陈述

  • 同样使用高德 AMap 的抽屉式地图选点;带搜索框、搜索按钮、结果面板 panel、地图标记与定位等能力。
  • 该文件你之前说明“不需要同步更改”,因此它保留了与 Map.vue 不同的实现策略。

关键实现与优点

  • 使用 :id="mapContainerId":id="tipInputId" 的动态容器与输入框 ID,理论上可以避免 DOM 复用导致的实例绑定陈旧问题。
  • 抽屉打开/关闭使用 @afterVisibleChange 驱动初始化,配合 MutationObserver、反复 resize()、甚至“移除并重建地图容器”的兜底路径,增强了在复杂布局/动画场景下的渲染稳定性。
  • 显式的“搜索按钮 + 结果面板 panel”交互,用户心智简单,便于定位问题。
  • 插件封装清晰:initAMapPlugins() 内完成 AutoCompletePlaceSearchToolBar 初始化与事件绑定。

核心风险与不一致点

  • AutoComplete 未使用动态 ID,实际绑定仍然是硬编码 'tipinput'

    • 现状:
      • 模板:<input ... :id="tipInputId" ...>
      • 插件初始化:document.getElementById('tipinput')input: 'tipinput'
    • 风险:当抽屉反复开关或 tipInputId 变化时,AutoComplete 可能绑定到不存在或旧的节点,导致选择事件丢失。
    • 建议:统一改为 document.getElementById(this.tipInputId)input: this.tipInputId
  • 方法重复定义,存在覆盖与维护风险

    • getCurrentPosition 定义了两次(L193 与 L1090 附近),后者会覆盖前者。
    • handleKeywordInput 定义了两次(L242 与 L765 附近),后者会覆盖前者。
    • 影响:行为以“最后一次定义为准”,前面实现与日志都失效,增加排查难度。
    • 建议:保留一份,删除重复,整合日志。
  • 资源释放方式与兼容性

    • 多处直接调用 this.autoComplete.destroy()this.placeSearch.destroy()(L408、L409、L889、L890、L1295、L1303),在某些版本/构造器上可能没有 destroy(),导致报错(这正是 Map.vue 中出现过的警告来源之一)。
    • 建议:调用前加 typeof ...destroy === 'function' 守卫,或使用事件解绑 + 置空的方式收尾。
  • 事件模型混用

    • PlaceSearch 使用了 .on('complete')/.on('error')(L705、L706),也使用了 search(kw, callback) 的回调风格(L249)。API 版本不同事件模型不同,容易引发“双通道回调”或空回调。
    • 建议:二选一,统一事件模型,减少重复触发。
  • 过度复杂的渲染兜底路径

    • 包含 MutationObserver、多次 resize()、定时器兜底、甚至“删除并重建地图容器”(L443-L451、L589-L663、L878-L926)。虽然鲁棒,但复杂度较高,后续维护成本与副作用(例如插件状态丢失)上升。
    • 建议:仅保留必要路径;若真实环境不再复现“容器尺寸为 0/首帧白屏”,可收敛策略。
  • 细节一致性

    • visible 监听中会条件初始化地图或强制重绘(L169-L176),同时 afterDrawerVisibleChange 也负责初始化(L455-L545),实际存在两处入口的竞态可能。
    • 建议:二选一,建议继续由 afterVisibleChange 统一入口。

与 Map.vue 的对照要点

  • Map.vue 的修复重心:
    • 动态输入框 ID 全链路应用(模板、插件初始化一致);
    • 事件绑定收敛:只保留 AutoComplete.on('select', ...) + PlaceSearch Fallback;
    • 关闭时“解绑事件 + 置空实例”而非强依赖 destroy()
    • 每次进入清空 keyword(而你明确说明此文件无需同步这一点)。
  • bf9-16-2.vue 更偏“工程兜底型”:
    • 更强的容器可见性检测与重建;
    • 明确的“按钮搜索 + 结果面板”;
    • 但仍有“硬编码 ID / 重复方法 / destroy 兼容性 / 双模型事件”的技术债。

建议的最小修正(不改变你“不需要同步更改”的产品行为)

  • 统一 AutoComplete 绑定为动态 ID:
    • initAMapPlugins()
      • document.getElementById('tipinput')document.getElementById(this.tipInputId)
      • input: 'tipinput'input: this.tipInputId
  • 移除方法重复定义:
    • 删除重复的 getCurrentPositionhandleKeywordInput,只保留一份。
  • 守卫 destroy()
    • 所有 destroy() 改为先判断类型,或仅做 事件解绑 + 置空
  • 事件模型统一:
    • PlaceSearch 只保留一种回调方式(建议保留 .on('complete')/.on('error')search(kw, cb) 二选一)。

验证点(按现有实现)

  • 首次打开:能初始化地图、AutoCompletePlaceSearch,按钮搜索可见 panel
  • 再次打开:依赖 afterDrawerVisibleChange + 观察器重试能恢复地图;若不改动 ID 硬编码,select 有概率因节点不匹配而不触发。
  • 关闭:会尝试销毁地图与插件;在某版本无 destroy() 的情况下,可能产生控制台警告。

如果你愿意,我可以按“最小修正”帮你把上述四点直接在 bf9-16-2.vue 中落地,保持交互不变,仅提升稳定性与一致性。

问题陈述

  • 打开抽屉第一次可以监听到高德 AutoCompleteselect 事件;关闭后再次打开,输入关键词搜索,select 事件不再触发。
  • 新需求:每次打开组件时清空搜索框 keyword

根因分析

  • 静态 DOM ID 复用导致实例绑定到了已销毁的输入框:id="tipinput" 在抽屉反复打开/关闭后,旧 DOM 被销毁但 AutoComplete 仍引用旧节点。
  • 事件与实例清理不彻底:地图与插件实例未完全释放,重复初始化下出现“事件未触发/触发到旧实例”的不一致。
  • 事件绑定方式混乱:多次尝试 on/choose/complete/addListener 等并行方案,进一步引发重复绑定、误判、竞态。

关键改动

  • 动态 ID 防陈旧绑定
    • data: 新增 tipInputId: 'tipinput-' + Date.now()
    • template: 关键词输入框改为 :id="tipInputId"
    • show(): 每次打开生成新 ID:this.tipInputId = 'tipinput-' + Date.now();
  • 规范初始化与销毁
    • initMap():
      • 地图重复进入时先安全销毁旧 mapGD
      • AMap.plugin 内部初始化 AutoCompletePlaceSearch
      • AutoComplete 仅保留核心:this.autoComplete.on('select', this.onAutocompleteSelect)
      • 地图 complete 后标记 mapInitialized=true 并执行 checkAndLocate()
    • close():
      • 解绑 AutoCompleteselect 事件(off('select', ...)),将实例置空。
      • 清理 currentMarker,销毁 mapGD,重置状态变量。
      • 清空输入数据(keyword/location/inputLng/inputLat)。
  • 选择结果落地逻辑统一
    • onAutocompleteSelect(e): 优先直接用 e.poi.location 定位、打点、更新 addrInfo,无 location 时再走 placeSearch
    • 新增 handlePOISelection(poi): 封装“根据 POI 更新地图与地址信息”,兼容多种 location 结构。
  • 输入法/选择的兜底
    • 保留 handleManualSelection(selectedText) 以便在极端情况下根据文本触发 placeSearch
    • 将早先会造成重复和干扰的 DOM 事件绑定(keydown/click/input 全局监听)移出默认流程,避免二次进入时重复绑定。
  • 体验优化
    • show() 首行清空关键词:this.keyword = '',满足“每次进入清空”的需求。
    • visible 变化时触发 map.resize(),避免首次渲染尺寸为 0 导致白屏。

验证结果

  • 首次打开:select 正常触发。
  • 关闭后再次打开:因 tipInputId 更新,AutoComplete 绑定到全新 DOM,select 可持续触发。
  • 关键词每次进入自动清空;若通过参数传入 keyword,会在后续逻辑中覆盖为传入值(兼容业务场景)。
  • 地图加载完成后能回显已选坐标点;手动经纬度搜索、拖拽标记、逆地理地址回显均正常。

已规避的历史错误

  • 调用不存在的 destroy():所有销毁调用前均做了类型检查或改为安全置空。
  • 事件重复绑定与全局监听泄漏:通过在 close() 中集中清理,且默认不再附加额外 DOM 监听,避免重复与干扰。
  • 依赖 complete 事件做选择:统一以 select 为主,placeSearch 为 fallback。

可能的后续优化(可选)

  • 如业务需要跨城市联动:在城市输入变更时调用 autoComplete.setCity()placeSearch.setCity()
  • 若需保持键盘回车搜索体验,可单独加按钮触发 placeSearch.search(keyword),避免全局 DOM 监听。

结论

  • 根因是“静态 DOM ID 导致的陈旧引用 + 实例/事件清理不彻底”。通过“动态 ID + 严格初始化/销毁 + 事件收敛”彻底解决。
  • 新需求“每次进入清空关键词”已在 show() 中生效,不影响传参覆盖。
  • 当前 Map.vue 处于可复现、可维护、行为稳定的状态。

问题二、切换打开这个组件,控制台打印地图加载完成,但是页面地图区域空白。

f91e6e17a819164adbdfc80ca3a08223.png

79884a1550e03315d93f6a1583711c0a.jpg

我先复盘,再给出可执行的修复要点与自检清单。

复盘:为什么“加载完成”但页面空白

  • 地图实例在容器尺寸为 0(抽屉未完成展开)时初始化,AMap 内部 canvas 尺寸为 0,导致空白。
  • 二次打开时沿用上次实例或残留 DOM,或插件未重新绑定到新 map 实例,导致渲染异常。
  • 关键词输入框和搜索插件未对齐(动态 id/DOM 还没渲染就初始化),引发搜索正常但地图未刷新中心或未重绘。
  • 仅在 visible 改变时调用 resize,时机过早,抽屉动画未结束,仍为 0 尺寸。

已做的关键修复(Map.vue)

  • 延后初始化到抽屉动画完成后,并且只在容器尺寸 > 0 时创建地图:
    • 使用抽屉的 @afterVisibleChangeMutationObserver 监听容器尺寸,尺寸有效时再 initMap(),否则等待。
  • 强约束容器存在性:
    • 若地图容器被移除,重新创建 div#mapContainerId 并挂回父节点,再初始化地图。
  • 地图实例重建和强制重绘:
    • 旧实例存在则先销毁;完成后强制多次 mapGD.resize(),并在插件加载后再次 resize()
  • 插件安全重绑:
    • AutoCompletePlaceSearch 每次初始化都销毁旧实例,绑定到当前 mapGD,并保证输入框、面板 DOM 存在后再初始化。
  • 运行时防御与兜底:
    • 如果有初始经纬度,初始化后立即定位并打点;否则尝试浏览器定位。
    • 增加调试日志,便于定位是“容器未就绪”还是“插件未就绪”。

推荐落地做法(更稳)

  • 容器高度用固定 CSS 类而非运行时动态改样式,避免“动态样式”引发的新问题:
    • .map-gaode-container 配置固定高度(如 500px),抽屉内布局不要依赖内容自适应高度。
  • 初始化时序:
    • 抽屉 afterVisibleChange(true) → 容器尺寸检测通过 → new AMap.Map(...) → 插件加载 → resize() → 定位/打点。
  • 二次打开:
    • 关闭时销毁 mapGD 和插件、移除容器或清空容器;打开时按上面流程重建。

快速自检清单(排查空白的关键节点)

  • 抽屉刚打开时 #mapContainerIdoffsetWidth/offsetHeight 是否 > 0?
  • 初始化后 container.querySelector('canvas') 是否存在,canvas 宽高是否为 0?
  • mapGD.getSize() 是否非 0?mapGD.resize() 是否被调用过(并在抽屉动画后调用)?
  • 插件是否绑定到当前 mapGD,输入框/面板 DOM 是否已渲染后再初始化?
  • 关闭后是否有彻底 destroy(),二次打开是否复用旧 DOM/实例?

如果你更倾向“不用动态样式”,我可以把目前 watch 里设置容器内联样式的逻辑改成纯 CSS 类方式,并保留上述延后初始化与 resize 策略。你要我直接改成纯 CSS 版本吗?

uni-app vue3 也能使用 Echarts?Wot Starter 是这样做的!

2025年9月16日 18:12

大家好,我是不如摸鱼去,wot-ui的主要维护者,欢迎来到我的 uni-app 分享专栏。

在移动端跨平台开发中,数据可视化是一个常见需求。而 ECharts 作为百度开源的强大图表库,在 Web 端有着广泛的应用,我们在技术栈选择的时候往往倾向于选择这种应用广泛,解决方案完善的库。

但在 uni-app 中直接使用 ECharts 会遇到各种兼容性问题,特别是在小程序端。幸运的是,有很库可以帮助我们在 uni-app 中使用 Echarts,例如 uni-echartslime-echart 等插件,为我们提供了相应的解决方案。

今天我们将会在 wot-starter 中,探索 uni-app 接入 Echarts 的方案,并针对小程序,对其超级庞大的体积进行优化。

为什么选择 uni-echarts?

uni-echarts 是一个适用于 uni-app 的 Apache ECharts 组件(仅支持Vue 3),具有以下优势:

  • 🚀 快速上手:与 Vue ECharts 近乎一致的使用体验
  • 📱 多端兼容:支持 H5、小程序、APP 等多个平台
  • 📦 支持 easycom:无需手动导入,开箱即用
  • TypeScript 支持:完整的类型定义
  • 🍳 免费商用:基于 MIT 许可协议

基于以上,选择使用 uni-echarts 作为我们的图表库,当然也可以选择 lime-echart

安装和配置

1. 安装依赖

首先安装必要的依赖包:

pnpm add echarts uni-echarts
# 或者
npm install echarts uni-echarts

在我们的项目中,package.json 已经包含了这些依赖:

{
  "dependencies": {
    "echarts": "^6.0.0",
    "uni-echarts": "^1.1.2"
  }
}

2. Vite 配置

vite.config.ts 中添加必要的配置:

import { defineConfig } from 'vite'
import Uni from '@dcloudio/vite-plugin-uni'
import UniHelperComponents from '@uni-helper/vite-plugin-uni-components'
import { UniEchartsResolver } from 'uni-echarts/resolver'

export default defineConfig({
  optimizeDeps: {
    exclude: process.env.NODE_ENV === 'development' ? ['wot-design-uni', 'uni-echarts'] : [],
  },
  plugins: [
    // 组件自动导入
    UniHelperComponents({
      resolvers: [UniEchartsResolver()],
      dts: 'src/components.d.ts',
    }),
    Uni(),
  ],
})

这样配置后,uni-echarts 组件就可以在项目中自动导入使用了,更多信息参见 Uni ECharts 快速开始

基础使用示例

创建一个柱状图组件

让我们以项目中的 BarChart.vue 为例,看看如何创建一个基础的柱状图:

<script setup lang="ts">
import { BarChart } from 'echarts/charts'
import { DatasetComponent, GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import * as echarts from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { provideEcharts } from 'uni-echarts/shared'

// 🚨 重要:由于 npm 插件编译机制问题,需要手动提供 echarts 实例
provideEcharts(echarts)

// 注册需要的组件
echarts.use([
  GridComponent,
  LegendComponent,
  TooltipComponent,
  DatasetComponent,
  BarChart,
  CanvasRenderer,
])

// 图表配置
const option = ref({
  tooltip: {
    trigger: 'axis',
    axisPointer: {
      type: 'shadow',
    },
  },
  legend: {
    data: ['销售额', '利润'],
    top: 30,
  },
  grid: {
    left: '3%',
    right: '4%',
    bottom: '3%',
    containLabel: true,
  },
  xAxis: {
    type: 'category',
    data: ['1月', '2月', '3月', '4月', '5月', '6月'],
  },
  yAxis: {
    type: 'value',
  },
  series: [
    {
      name: '销售额',
      type: 'bar',
      data: [120, 200, 150, 80, 70, 110],
      itemStyle: {
        color: '#5470c6',
      },
    },
    {
      name: '利润',
      type: 'bar',
      data: [20, 40, 30, 15, 12, 22],
      itemStyle: {
        color: '#91cc75',
      },
    },
  ],
})
</script>

<template>
  <uni-echarts custom-class="h-300px" :option="option" />
</template>

关键要点说明

  1. provideEcharts(echarts):这是使用 uni-echarts 的关键步骤,必须在每个组件中调用
  2. 按需导入:只导入需要的图表类型和组件,减小打包体积
  3. echarts.use():注册导入的组件
  4. uni-echarts 组件:使用 <uni-echarts> 标签渲染图表

更多图表类型

更多图表类型见 EchartsUni ECharts,当然你也可以使用 AI 工具帮助你编写想要的图表配置,它非常善于处理这个事情。

高级功能:分包优化与异步加载

引入 Echarts 后,体积暴增 800KB ,怎么办?

有办法,我们曾在 Vue3 uni-app 主包 2 MB 危机?1 个插件 10 分钟瘦身 一文中介绍过 @uni-ku/bundle-optimizer,它是解决微信小程序超包的利器,我们现在使用它的分包优化和分包异步化能力,来优化引入 Echarts 后暴增的小程序体积。

这是我们的项目结构,在 subEcharts 分包中实现 Echarts 相关组件,在 subAsyncEcharts 分包中演示分包异步化效果:

src/
├── pages/           # 主包页面
├── subEcharts/      # ECharts 组件分包
│   └── echarts/
│       └── components/
├── subAsyncEcharts/ # 异步 ECharts 演示分包
│   └── asyncEcharts/
└── subPages/        # 其他功能分包

安装和配置

1. 安装依赖

pnpm add -D @uni-ku/bundle-optimizer
# 或者
npm install -D @uni-ku/bundle-optimizer

在我们的项目中,package.json 已经包含了这个依赖:

{
  "devDependencies": {
    "@uni-ku/bundle-optimizer": "1.3.15-beta.2"
  }
}

2. Vite 配置

vite.config.ts 中配置插件:

import { defineConfig } from 'vite'
import Uni from '@dcloudio/vite-plugin-uni'
import Optimization from '@uni-ku/bundle-optimizer'

export default defineConfig({
  plugins: [
    Uni(),
    // 分包优化插件
    Optimization({
      logger: true,  // 开启日志输出
    }),
  ],
})

3. 小程序分包配置

manifest.json 中开启分包优化:

{
  "mp-weixin": {
    "optimization": {
      "subPackages": true
    }
  }
}

如果你使用了 @uni-helper/vite-plugin-uni-manifest 插件,那么需要在 manifest.config.ts 中开启分包优化:

export default defineManifestConfig({
  'mp-weixin': {
    optimization: {
      subPackages: true,
    },
  },
})

配置完成后,重新构建,我们会发现主包少了 200+KB ,还剩 500KB 在主包中,可以期待 @uni-ku/bundle-optimizer 未来可以传送组件到分包中,到时会将把大部分构建产物都打包进入分包中。

这里配合 lime-echart 的话,应该可以将 echarts.min.js 完全放入分包,各位可以自行探索。

跨分包异步组件引用

在我们的项目中,subAsyncEcharts 分包可以异步引用 subEcharts 分包中的组件:

<!-- src/subAsyncEcharts/asyncEcharts/index.vue -->
<script setup lang="ts">
// 跨分包异步导入组件
import BarChart from '@/subEcharts/echarts/components/BarChart.vue?async'
import DonutChart from '@/subEcharts/echarts/components/DonutChart.vue?async'
import FunnelChart from '@/subEcharts/echarts/components/FunnelChart.vue?async'
import GaugeChart from '@/subEcharts/echarts/components/GaugeChart.vue?async'
import LineChart from '@/subEcharts/echarts/components/LineChart.vue?async'
import LiquidFillChart from '@/subEcharts/echarts/components/LiquidFillChart.vue?async'
import MiniLineChart from '@/subEcharts/echarts/components/MiniLineChart.vue?async'
import PieChart from '@/subEcharts/echarts/components/PieChart.vue?async'
import RadarChart from '@/subEcharts/echarts/components/RadarChart.vue?async'
import ScatterChart from '@/subEcharts/echarts/components/ScatterChart.vue?async'
import StackedBarChart from '@/subEcharts/echarts/components/StackedBarChart.vue?async'
</script>

分包异步化效果

前后2个页面,一个在 subEcharts 分包中,一个在 subAsyncEcharts 分包中,其中 subAsyncEcharts 中的页面打包后几乎不存在体积的增大。

更多信息参见 @uni-ku/bundle-optimizer: github.com/uni-ku/bund…

注意事项和最佳实践

1. 使用 npm 方式安装必须调用 provideEcharts

在每个使用 ECharts 的组件中,都必须调用 provideEcharts(echarts)

import * as echarts from 'echarts/core'
import { provideEcharts } from 'uni-echarts/shared'

// 🚨 这一行是必须的
provideEcharts(echarts)

2. 按需导入组件

为了减小打包体积,建议按需导入需要的图表类型和组件:

// 只导入需要的图表类型
import { BarChart, LineChart, PieChart } from 'echarts/charts'
// 只导入需要的组件
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
// 导入渲染器
import { CanvasRenderer } from 'echarts/renderers'

3. 设置图表尺寸

使用 custom-class 属性设置图表容器的尺寸:

<template>
  <!-- 使用 UnoCSS/Tailwind 类名 -->
  <uni-echarts custom-class="h-300px w-full" :option="option" />

  <!-- 或者使用自定义 CSS 类 -->
  <uni-echarts custom-class="chart-container" :option="option" />
</template>

<style>
.chart-container {
  width: 100%;
  height: 300px;
}
</style>

4. 响应式数据更新

当需要动态更新图表数据时,直接修改 option 对象即可:

const option = ref({
  // 初始配置
})

// 更新数据
function updateData() {
  option.value.series[0].data = [/* 新数据 */]
}

5. 主题定制

可以通过 provideEchartsTheme 来设置自定义主题:

import { provideEcharts, provideEchartsTheme } from 'uni-echarts/shared'
import * as echarts from 'echarts/core'

provideEcharts(echarts)

// 设置自定义主题
provideEchartsTheme({
  color: ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de'],
  backgroundColor: 'transparent',
  // 更多主题配置...
})

总结

我们在 wot-starter 中 使用 uni-echarts 结合 @uni-ku/bundle-optimizeruni-app 开发者提供了一个完整的高性能 ECharts 解决方案。通过合理的配置和规范的使用方式,我们可以在各个平台上实现丰富的数据可视化效果,同时保证应用的性能和用户体验。

关键要点回顾:

基础配置

  1. 安装 echartsuni-echarts@uni-ku/bundle-optimizer 依赖
  2. 在 vite.config.ts 中配置 UniEchartsResolver 和 Optimization 插件
  3. 在 manifest.json 中开启分包优化
  4. 在组件中调用 provideEcharts(echarts)
  5. 按需导入并注册 ECharts 组件
  6. 使用 <uni-echarts> 组件渲染图表

性能优化

  1. 使用 ?async 后缀实现组件异步加载
  2. 合理规划分包结构,避免主包体积过大
  3. 利用跨分包异步引用,实现代码分割

参考资源

往期精彩

当年偷偷玩小霸王,现在偷偷用 Trae Solo 复刻坦克大战

告别 HBuilderX,拥抱现代化!这个模板让 uni-app 开发体验起飞

uni-app 还在手写请求?alova 帮你全搞定!

uni-app 无法实现全局 Toast?这个方法做到了!

Vue3 uni-app 主包 2 MB 危机?1 个插件 10 分钟瘦身

欢迎评论区沟通、讨论👇👇

ts中的函数重载

作者 龙在天
2025年9月16日 17:59
message({
  mode: 'mode',
  text: 'text',
  onClose: function () {},
  duration: 3000,
});

message('text');
message('text', function () {});
message('text', 'mode');
message('text', 'mode', 3000);
message('text', 3000);
message('text', 3000, function () {});

export default {};

message 用法有很多。

// ts 的用法
function message(
  param1: string | object,
  param2?: number | Function | string 
): void {
  // Function implementation goes here
}

为什么会有ts函数重载?

因为如果没有重载,永远只是很宽泛的类型,any来any去。太多any要ts就没意思了。

function message(params1: string | object, param2?: any): void

有重载时,编辑器IDE就能提供更精确的提示:

message('text')
message('text', 'mode')

// 没有重载时,这些错误调用不会被检测到
message('text', 'invalid', 'wrong') // 不会报错
message(123, 'mode')                // 不会报错

// 有重载时,TypeScript 会准确报错
message('text', 'invalid', 'wrong') // 错误:第三个参数类型不匹配
message(123, 'mode')                // 错误:第一个参数必须是字符串或对象

完整代码:

function message(text: string): void;
function message(text: string, onClose: () => void): void;
function message(text: string, mode: string): void;
function message(text: string, mode: string, duration: number): void;
function message(text: string, duration: number): void;
function message(text: string, duration: number, onClose: () => void): void;
function message(options: { 
  mode: string; 
  text: string; 
  onClose?: () => void; 
  duration?: number 
}): void;

function message(
  param1: string | object,
  param2?: number | Function | string,
  param3?: number | Function
): void {
  // Function implementation goes here
  if (typeof param1 === 'object') {
    // 对象形式调用: message({ mode: 'mode', text: 'text', onClose: function () {}, duration: 3000 })
    const options = param1;
    // 处理选项
  } else {
    const text = param1;
    
    if (typeof param2 === 'function') {
      // message('text', function () {})
      const onClose = param2;
    } else if (typeof param2 === 'string') {
      // message('text', 'mode')
      const mode = param2;
      
      if (typeof param3 === 'number') {
        // message('text', 'mode', 3000)
        const duration = param3;
      }
    } else if (typeof param2 === 'number') {
      // message('text', 3000) 或 message('text', 3000, function () {})
      const duration = param2;
      
      if (typeof param3 === 'function') {
        // message('text', 3000, function () {})
        const onClose = param3;
      }
    }
  }
}

export default {};

非常经典的Android开发问题-mipmap图标目录和drawable图标目录的区别和适用场景实战举例-优雅草卓伊凡

作者 卓伊凡
2025年9月16日 17:30

非常经典的Android开发问题-mipmap图标目录和drawable图标目录的区别和适用场景实战举例-优雅草卓伊凡

核心结论(先看这个)

  • 对于应用图标(App Icon) :永远放在 mipmap 目录中。
  • 对于其他所有图标和图片资源:如图标按钮、标签页图标、背景图、占位图等,都放在 drawable 目录中。

这是Google自Android Studio 1.0版本后官方推荐的最佳实践。


详细区别与适用场景

1. mipmap 目录

  • 设计初衷:专门为应用启动器图标(Launcher Icon) 设计。
  • 关键特性系统永远不会对 ****mipmap ****目录下的资源进行缩放。无论你的应用安装在哪一种屏幕密度的设备上,Launcher(桌面)都会获取到为该设备分辨率精确匹配的 mipmap 资源,从而确保应用图标始终清晰、无锯齿。
  • 适用场景
    • 有且仅有:你的应用图标,即 ic_launcheric_launcher_round 等。这些图标会显示在手机桌面、应用列表、设置界面以及分享菜单中。
  • 为什么不放别的:如果把其他UI图标也放在 mipmap 中,系统不会缩放它们,你在布局中引用时可能需要自己处理尺寸,反而增加麻烦。而且这违背了该目录的设计初衷。

2. drawable 目录

  • 设计初衷:存放应用内所有其他的图片资源
  • 关键特性:系统可能会根据设备的屏幕密度(dpi)对 drawable 目录下的图片进行缩放,以适配不同屏幕。系统会选择最接近的密度版本,然后缩放至合适大小。
  • 适用场景除了应用启动图标之外的所有图片。包括但不限于:
    • UI图标(如返回箭头、菜单图标、心形收藏图标等)
    • 按钮背景(Button Backgrounds)
    • 界面背景(Backgrounds)
    • 位图(Bitmaps)
    • XML绘制的形状(Shapes)、选择器(Selectors)等

为什么会有这个区分?—— 历史与优化

在早期Android版本中,所有图片资源都放在 drawable 目录下。但这样做有一个问题:

当设备屏幕密度很高(比如xxhdpi)而你的应用只提供了低密度(比如mdpi)的应用图标时,系统会从 drawable 目录中获取这个低清图标,并放大它来显示在桌面上。放大必然导致图标模糊、失真,用户体验很差。

为了解决这个问题,Android引入了 mipmap 目录。mipmap 一词源自计算机图形学,指的是一组预先生成的、不同精度的纹理图片。系统可以精确地为设备选择最合适的那一个,而无需进行缩放。

因此,将应用图标单独放入 mipmap 目录,确保了桌面Launcher总能拿到最精确尺寸的图标,与设备DPI完美匹配。而应用内部的UI元素因为布局复杂,缩放是不可避免的,所以继续留在 drawable 目录中由系统处理。

目录结构示例

一个标准的项目资源目录结构看起来是这样的:

src/main/res/
├── drawable/          // 默认drawable目录(通常放xml资源,如selector, shape)
├── drawable-hdpi/     // 为hdpi屏幕准备的png/jpg图片
├── drawable-xhdpi/    // 为xhdpi屏幕准备的png/jpg图片
├── drawable-xxhdpi/   // 为xxhdpi屏幕准备的png/jpg图片
├── mipmap-hdpi/       // 只放应用图标 ic_launcher.png
├── mipmap-xhdpi/      // 只放应用图标 ic_launcher.png
├── mipmap-xxhdpi/     // 只放应用图标 ic_launcher.png
├── mipmap-xxxhdpi/    // 只放应用图标 ic_launcher.png (建议提供,作为高分辨率设备的基准)
├── layout/
└── values/

总结与最佳实践

特性 mipmap****目录 drawable****目录
主要用途 应用启动器图标(App Launcher Icon) 应用内所有其他UI元素和图片
系统缩放 永不缩放,保证图标清晰 会根据设备dpi进行缩放
包含内容 `ic_launcher(.webp .png .xml)` 按钮图标、背景、位图、选择器、形状等
最佳实践 所有密度版本都应提供该图标 为不同密度提供相应的图片以优化性能和效果

记住这个黄金法则:

mipmap ****for the app icon, ****drawable ****for everything else.
(mipmap放应用图标,drawable放其他所有东西。)

实际项目举例

看到这两个图标了没,一个是默认页面的背景图,另一个是被选中按钮激活后的选项背景图

结论先行

这两个资源都属于“应用内其他图片资源”,都应该放在 ****drawable ****目录中。

  • 按钮选中状态的背景 -> drawable 目录
  • 主页面整体背景 -> drawable 目录

详细解释

1. 按钮选中状态的背景

这个背景是应用内部UI交互的一部分。它可能是一个图片(.png, .jpg, .webp),也可能是一个用XML定义的选择器(Selector)形状(Shape)

  • 如果是图片文件(如 btn_selected_bg.png):
    你应该将它放入对应的密度文件夹中,例如 drawable-xhdpi/, drawable-xxhdpi/ 等。系统会根据设备屏幕密度自动选择最合适的一个,并在需要时进行缩放,以保证在不同设备上显示比例正确。
  • 如果是XML文件(如 btn_selected_bg.xml,里面可能是 <selector><shape>):
    你应该将它直接放入默认的 ****drawable/ ****目录(不带密度后缀)。因为XML是矢量性的,系统可以根据描述在任何密度下完美地绘制出图形,无需为不同密度提供多个版本。

放置路径示例:

  • res/drawable/btn_selected_bg.xml (XML选择器)
  • res/drawable-xhdpi/btn_selected_bg.png (XHDPI密度图片)
  • res/drawable-xxhdpi/btn_selected_bg.png (XXHDPI密度图片)

2. 主页面整体背景

这个背景同样是应用内部的装饰性图片,不属于应用图标。它通常是一张较大的图片(如 bg_home.jpg)。

  • 你应该根据它的尺寸和预期效果,为不同屏幕密度提供优化后的版本,并放入对应的 drawable-dpi 目录中。例如,为高密度设备提供更高分辨率的背景图,可以避免在大屏手机上被拉伸而模糊。

放置路径示例:

  • res/drawable-mdpi/bg_home.jpg (通常不需要,但遵循规范的话应该提供)
  • res/drawable-hdpi/bg_home.jpg
  • res/drawable-xhdpi/bg_home.jpg
  • res/drawable-xxhdpi/bg_home.jpg (这是目前最主流的基准尺寸)

为什么不能放 mipmap

回顾一下 mipmap 的核心特性:系统永不缩放

如果你把主页背景图 bg_home.jpg 放在 mipmap-xxhdpi/ 目录下,然后在一个 hdpi 的设备上运行:

  1. 系统会去 mipmap-hdpi/ 找,但你没提供这个版本。
  2. 系统不会像处理 drawable 那样自动缩放 mipmap-xxhdpi 里的图来适配。
  3. 它会直接使用 mipmap-xxhdpi/ 里的原图,这张图在 hdpi 设备上会显得非常大,可能超出屏幕,导致显示异常。

把按钮背景放在 mipmap 里也会有类似的问题,因为系统不会帮你做任何缩放适配,你需要自己写代码去控制大小,这完全违背了Android资源适配的初衷。

总结表格

资源类型 推荐目录 原因
应用图标 (ic_launcher) mipmap-*dpi/ 确保桌面启动器获取未缩放的清晰图标
按钮背景 (图片或XML) drawable/drawable-*dpi/ 属于应用内UI,需要系统进行密度适配
页面背景图 drawable-*dpi/ 属于应用内装饰性图片,需要系统进行密度适配
XML选择器/形状 drawable/(无密度后缀) XML可自适应不同密度,一份资源即可

简单记忆:只要不是那个会被手机桌面用到的图标,统统扔进 drawable

昨天 — 2025年9月16日掘金 前端

🔹🔹🔹 vue 通信方式 eventBus

2025年9月16日 17:22

没啥关系的两个组件,

找破天,都找不到啥关系的两个组件。

数据占比比较多,事件的话还要监听:
用vuex、用localstorage。


事件占比比较多

eventBus

一、使用方法:

1、在main.js中创建一个空的vue实例作为全局事件总线,同时,将其绑定在vue原型上。

//main.js
Vue.prototype.$eventBus = new Vue()

2、子组件或兄弟组件,通过$emit来触发事件。

sendFunc(){
    this.$eventBus.$emit( 'changeFunc' ,  123, 'abc' )
}

3、父组件或兄弟组件,通过$on来监听事件。

created(){
    //绑定前需先解绑,避免反复触发、内存泄漏的问题
    this.$eventBus.$off( 'changeFunc' );  
    this.$eventBus.$on( 'changeFunc' , (val1, val2)=>{
        this.msg = val1;
        this.msg2 = val2;
    } )
}

组件监听eventBus中的事件前,一定要记得先解绑。

created(){
    //this.$eventBus.$off( 'changeFunc' );  
    this.$eventBus.$on( 'changeFunc' , (val)=>{
        this.msg = val;
    } )
}

如上,绑定前不解绑的话,主要有两方面的问题:1、事件反复触发;2、内存泄漏;


很显然,上面是vue2的写法。

什么年代了还在vue2,

下面是vue3的写法:


Vue2 里常用:

// eventBus.js
import Vue from 'vue'
export const eventBus = new Vue()

// 组件A
eventBus.$emit('sayHello', '你好')

// 组件B
eventBus.$on('sayHello', (msg) => {
  console.log(msg) // 你好
})

那么vue3呢,vue3没有new Vue()了啊。

vue3

  1. vue3 用 mitt
npm i mitt
  1. 新建 eventBus.js
// eventBus.js
import mitt from 'mitt'

// 创建一个全局事件总线
const eventBus = mitt()

export default eventBus
  1. 组件A(发送事件)
<script setup>
import eventBus from '@/eventBus.js'

function sendMessage() {
  eventBus.emit('sayHello', '你好,我是组件A')
}
</script>

<template>
  <button @click="sendMessage">发送消息</button>
</template>
  1. 组件B(接收事件)
<script setup>
import { onMounted, onUnmounted } from 'vue'
import eventBus from '@/eventBus.js'

function handleMessage(msg) {
  console.log('组件B收到消息:', msg)
}

onMounted(() => {
  eventBus.on('sayHello', handleMessage)
})

onUnmounted(() => {
  eventBus.off('sayHello', handleMessage) // 记得销毁,避免内存泄漏
})
</script>

<template>
  <div>我是组件B</div>
</template>

  • 父子组件通信:props / emits
  • 跨层级组件通信:provide / inject
  • 全局状态管理:Pinia(推荐)
  • 非父子组件通信(解耦):eventBus(基于 mitt)

完事了。

Python采集tiktok视频详情数据,tiktok API系列

2025年9月16日 17:04

在Python中采集TikTok视频详情数据,可通过官方API、第三方API服务或非官方库实现,以下是一些主流方法及其技术细节与适用场景分析:

一、官方API:TikTok for Business API

适用场景:企业级应用,需合规获取广告、账号或创作者市场数据。
功能覆盖

  • Marketing API:管理广告活动(如创建、投放、优化广告),支持批量操作与实时数据查询。
  • Accounts API:监控企业账号活动(如视频发布、互动数据),提供深度分析报表。
  • Data Portability API:合规转移用户数据(如GDPR场景下的用户请求处理)。

技术实现

  1. 认证授权:通过OAuth 2.0获取access_token,需注册TikTok for Business开发者账号并申请权限。

  2. 接口调用:使用requests库发送HTTPS请求,示例代码:

    python
    import requests
    auth_url = "https://business-api.tiktok.com/api/v1/oauth/token/"
    params = {
        "client_key": "YOUR_APP_ID",
        "client_secret": "YOUR_SECRET",
        "grant_type": "client_credentials"
    }
    response = requests.post(auth_url, data=params)
    access_token = response.json()["access_token"]
    
  3. 数据获取:调用视频详情接口(需替换为实际API端点):

    python
    video_url = "https://api.tiktok.com/v2/video/detail/"
    headers = {"Authorization": f"Bearer {access_token}"}
    params = {"video_id": "YOUR_VIDEO_ID"}
    response = requests.get(video_url, headers=headers, params=params)
    video_data = response.json()
    

优势:数据权威、合规性强,支持高并发请求。
限制:需企业资质审核,个人开发者难以获取权限;功能聚焦于商业场景,普通视频数据获取受限。

二、第三方API服务:TikHub.io

适用场景:快速获取用户主页视频、热门视频等公开数据,无需逆向工程。
功能覆盖

  • 支持通过sec_user_idunique_id查询用户作品列表。
  • 返回字段包括视频标题、播放量、点赞数、封面URL等。

技术实现

  1. 注册获取API Key:访问TikHub用户后台完成注册。

  2. 调用接口:示例代码:

    python
    import requests
    url = "https://api.tikhub.io/api/v1/tiktok/app/v3/fetch_user_post_videos"
    headers = {
        "Authorization": "Bearer YOUR_API_KEY",
        "accept": "application/json"
    }
    params = {
        "sec_user_id": "MS4wLjABAAAA...",
        "count": 20,
        "sort_type": 0
    }
    response = requests.get(url, headers=headers, params=params)
    videos = response.json()["data"]["videos"]
    for video in videos:
        print(f"标题: {video['desc']}, 播放量: {video['play_count']}")
    

优势

  • 免爬虫、免逆向,接口稳定且支持大规模请求。
  • 提供中文文档与SDK,开发者友好。
  • 免费注册,每日签到可领取调用额度。

限制:部分高级功能需付费;数据延迟可能高于官方API。

三、非官方库:TikTok-API-Python

适用场景:开源社区支持,适合技术探索与个性化需求。
功能覆盖

  • 获取热门视频、用户帖子、话题帖子及音乐帖子数据。
  • 支持加密传输,确保数据安全。

技术实现

  1. 安装依赖

    bash
    git clone https://github.com/demon071/Tiktok-API-Python.git
    cd Tiktok-API-Python
    pip install -r requirements.txt
    
  2. 调用API:示例代码:

    python
    from tiktok import TikTok
    api = TikTok()
    videos = api.trending()  # 获取热门视频
    for video in videos[:5]:
        print(f"标题: {video['title']}, 视频URL: {video['video_url']}")
    

优势

  • 功能丰富,支持多种数据类型获取。
  • 社区活跃,持续更新修复bug。

限制

  • 非官方库,可能违反TikTok服务条款。
  • 依赖逆向工程,接口稳定性较差,易受平台风控策略影响。

四、对比与推荐

方案 适用场景 数据权威性 稳定性 开发成本
TikTok for Business API 企业级商业应用 高(需审核)
TikHub.io 快速获取公开数据
TikTok-API-Python 技术探索与个性化需求

推荐选择

  • 企业开发者:优先使用TikTok for Business API,确保合规性与数据权威性。
  • 个人开发者/研究者:选择TikHub.io,平衡效率与成本。
  • 技术爱好者:可尝试TikTok-API-Python,但需注意法律风险与接口稳定性。

【手写组件】 Vue3 + Uniapp 手写一个高颜值日历组件(含跨月补全+今日高亮+选中状态)

作者 天下无贼
2025年9月16日 16:59

文章目录

日历组件实现

一个基于 Vue3 + Composition API 的日历组件,功能包括:

功能概览

功能点 实现情况
月份切换 ✅ 通过 handleLastMonth / handleNestMonth 实现
今日高亮 ✅ 使用 isToday 判断并加样式
选中日期 ✅ 使用 SelectedDate 存储并高亮
跨月补全 ✅ 上月和下月的日期用 isOtherMonth 标记并灰显
响应式布局 ✅ 使用 grid + aspect-ratio 实现正方形格子
样式变量 ✅ 使用 CSS 变量(如 --primary, --gary_light

代码亮点

  1. 逻辑清晰:用 computed 生成 days 数组,结构干净。
  2. 日期处理封装得好getDateformatDate 方法复用性强。
  3. 样式语义化:类名如 .today, .selected, .other-month 非常直观。
  4. 组件化思维:使用 TagCop 作为子组件,符合 uni-app 风格。

接下来开始我们的代码之旅😄:在这里插入图片描述

基础功能搭建

首先创建模板结构:

<template>
  <div class="calendarCop">
    <!-- 日历顶部栏 -->
    <div class="calendarCop-header"></div>
    <!-- 日历星期栏 -->
    <div class="calendarCop-weekdays">
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
    </div>
    <!-- 日历 -->
    <div class="calendarCop-days"></div>
  </div>
</template>

<script setup></script>

<style scoped lang="scss">
.calendarCop {
  background-color: var(--gary_light);
  padding: 16rpx;
  border-radius: var(--radius);
  .calendarCop-header {
  }
  .calendarCop-weekdays {
  }
  .calendarCop-days {
  }
}
</style>

搭建日历顶部栏结构:

创建出顶部栏需要展示的空间,分别有分布于左侧的切换至上个月图标按钮和右侧的切换至下个月图标按钮,以及中间年月份展示区

<!-- 日历顶部栏 -->
<div class="calendarCop-header">
    <!-- 顶部栏月份切换区 -->
    <div class="changeMouth">
        <!-- 切换至上个月图标按钮 -->
        <span class="left">
            <uni-icons type="left" size="24" color="#23ad1e"> </uni-icons>
        </span>
        <!-- 年月份展示区 -->
        <p class="data">2025 年 9 月</p>
        <!-- 切换至下个月图标按钮 -->
        <span class="right">
            <uni-icons type="right" size="24" color="#23ad1e"></uni-icons>
        </span>
    </div>
</div>

接下来编写样式:

.calendarCop-header {
    .changeMouth {
        display: inline-flex;
        align-items: center;
        gap: 16rpx;
        height: 50rpx;
        .left,
        .right {
            font-weight: 900;
        }
        .data {
            font-size: 36rpx;
            line-height: 50rpx;
        }
    }
}

效果:

image-20250916100540390

现在结构已经搭建好了,逻辑交互等日历日期渲染出来了再做。

搭建日历星期栏样式:

直接使用网格布局将七个星期都渲染出来,然后再添加一些修饰就完成啦。

  .calendarCop-weekdays {
    color: var(--primary_dark);
    font-weight: 900;
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    text-align: center;
    padding-bottom: 8rpx;
    margin-bottom: 8rpx;
    border-bottom: 4rpx solid var(--gary_dark);
  }

效果:

image-20250916101310240

渲染日历日期

接下来就是重头戏了,要想渲染出时间日期,我们就要请出Date时间对象来。

先来获取到当前年|月|日数据:

  • 这里使用ref响应式创建了一个时间对象
  • 然后用计算属性分别计算出CurrentDate时间对象的年|月|日信息
/* 当前日期时间 */
// 获取当前时间对象
const CurrentDate = ref(new Date());
// 获取当前年份
const Year = computed(() => CurrentDate.value.getFullYear());
// 获取当前月份
const Month = computed(() => CurrentDate.value.getMonth());
// 获取当前日期
const Today = computed(() => CurrentDate.value.getDate());

拿到了日期时间后,就可以在日历顶部栏中替换掉之前写死的年月份

<!-- 年月份展示区 -->
<p class="data">{{ Year }} 年 {{ Month + 1 }} 月</p>

⚠注意:时间对象中拿到的月份是从0开始的,所以相比较于现实的月份会少1,所以要在渲染月份的时候加上1

写一个获取日期对象方法

const getDate = ({ year, month, day } = {}) =>
  new Date(year ?? Year.value, month ?? Month.value, day ?? Today.value);

生成日期数据:

/* 生成日期数据 */
const days = computed(() => {
  const result = [];
  // 获取每个月的第一天和最后一天
  const firstDay = getDate({ day: 0 });
  const lastDay = getDate({ month: Month.value + 1, day: 0 });
  // 通过遍历来渲染所有日期
  for (let i = 1; i <= lastDay.getDate(); i++) {
     const date = getDate({ year: Year.value, month: Month.value, day: i });
    result.push({
      date,
      text: i,
    });
  }
  return result;
});

整体逻辑就是先拿 lastDay 定出本月共有多少天,然后从 1 号循环到该天数,每天调用 getDate 生成一个 Date 对象塞进数组,最终得到“本月所有日期”列表。

我们可以打印一下days来观察数据长啥样:console.log("🚀:", days.value);

image-20250916110935784

接下来将日期数据渲染到模板上:

    <!-- 日历 -->
    <div class="calendarCop-days">
      <div class="item" v-for="day in days" :key="day.date">
        <div class="day">
          {{ day.text }}
        </div>
      </div>
    </div>

// 样式
  .calendarCop-days {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 8rpx;
    .item {
      font-size: 32rpx;
      aspect-ratio: 1; // 宽=高,正方形
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      .day {
      }
    }
  }

如今,日历已经有初步形态:

image-20250916113420544

接下来完成今日日期显示:

给日期格子添加上样式类名和并且准备好样式:

<div class="calendarCop-days">
    <div class="item" v-for="day in days" :key="day.date">
        <div
             :class="{
                     day: true,
                     base: true,
                     today: isToday(day.date),
                     }"
             >
            {{ day.text }}
        </div>
    </div>
</div>

// 样式:
.base {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--radius);
    font-weight: 900;
}
.today {
    color: var(--primary_dark);
    background: var(--primary_light);
}

判断是否为今天isToday方法:

// 格式化日期方法
const formatDate = (date) =>
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
    2,
    "0"
  )}-${String(date.getDate()).padStart(2, "0")}`;

/* 今日日期 */
const today_date = new Date();
const isToday = (date) => formatDate(today_date) === formatDate(date);

这里补充一下padStart方法:

padStart 只做一件事:把字符串“补长”到指定长度,不够就在前面补规定的字符。

语法:str.padStart(目标长度, 补什么)

例如:

'5'.padStart(2, '0')   // 长度=1,差1位 →补0 → '05'
'12'.padStart(2, '0')  // 长度已够 → 原样返回 '12'

效果:

image-20250916135515932

处理选中日期效果:

同样的,先添加上选中的类名和样式效果:

<div
     :class="{
             day: true,
             base: true,
             today: isToday(day.date),
             selected: isSelected(day.date),
             }"
     @click="selectDay(day.date)"
     >
    {{ day.text }}
</div>

// 样式:⚠注意selected类名要在today下方,这样选中效果才能覆盖掉today样式
.today {
    color: var(--primary_dark);
    background: var(--primary_light);
}
.selected {
color: #fff;
background: var(--primary);
}

编写逻辑:

/* 选择日期相关 */
// 选中日期
const SelectedDate = ref(null);
// 选中日期方法
const selectDay = (date) => {
  SelectedDate.value = formatDate(date);
};
const isSelected = (date) => SelectedDate.value === formatDate(date);
// 初始化选中今天
onMounted(() => {
  SelectedDate.value = formatDate(today_date);
});

现在选中效果也做好啦:

image-20250916140133877

回到今日:

<div class="calendarCop-header">
    <!-- 顶部栏月份切换区 -->
    <div class="changeMouth">
        <!-- ... -->
    </div>
    <TagCop
            class="selectToday"
            text="今日"
            backgroundColor="var(--primary_light)"
            @click="selectToday"
            />
</div>

⚠这个<TagCop>就是一个标签组件而已啦!

添加回到今日方法:

/* 今日日期 */
const today_date = new Date();
const isToday = (date) => formatDate(today_date) === formatDate(date);
const selectToday = () => {
  CurrentDate.value = today_date;
  selectDay(today_date);
};

效果:

image-20250916143412219

月份切换

现在来制作月份切换效果:

给图标绑定好切换方法:

<!-- 切换至上个月图标按钮 -->
<uni-icons
           class="left"
           type="left"
           size="24"
           color="#23ad1e"
           @click="handleLastMonth"添加
           >
</uni-icons>
<!-- 年月份展示区 -->
<p class="data">{{ Year }} 年 {{ Month + 1 }} 月</p>
<!-- 切换至下个月图标按钮 -->
<uni-icons
           class="right"
           type="right"
           size="24"
           color="#23ad1e"
           @click="handleNestMonth"添加
           >
</uni-icons>

编写方法:

/* 月份切换相关 */
const handleLastMonth = () => {
  CurrentDate.value = getDate({
    year: Year.value,
    month: Month.value - 1,
    day: 1,
  });
};
const handleNestMonth = () => {
  CurrentDate.value = getDate({
    year: Year.value,
    month: Month.value + 1,
    day: 1,
  });
};

现在月份可以切换了,但是每个日期对应的星期没有正确分布出来,接下来就需要引入上个月的日期,才能保证后面星期数是对的上的。

月份补充

为了方便理解,先记住 3 个前提:

  1. getDate({ year, month, day }) 内部就是 new Date(year, month, day) – 月份从 0 开始(0=1 月 … 11=12 月) – 如果 day=0 会得到“上个月的最后一天”,day=-n 会得到“上个月倒数第 n 天”——这是 JS Date 的天生能力。
  2. 组件要求日历从周一开头(模板里周一在第一个格子)。
  3. 一行 7 格,总共 5 行 = 35 格。 如果“上月补头 + 当月天数 + 下月补尾”不足 35,就再补 7 格,凑够 42 格(6 行)。

可视化说明:

gap = 5(周一到周五共 5 天)
头补:12728293031 日

当月:1 日 … 28 日
已用:5 + 28 = 33

remains = 35 - 33 = 2
尾补:312 日
最终数组长度:35

上月补充(补“头部”)

// 1. 当月 1 号
const firstDay = getDate({ day: 1 });

// 2. 当月 1 号是星期几? 0=周日 1=周一 ... 6=周六
const startDayOfWeek = firstDay.getDay(); // 例如 3 → 周三

// 3. 要补几个空位?
//    我们想让它从“周一”开始,所以:
//    周一 → 补 0 个
//    周二 → 补 1 个
//    ...
//    周日 → 补 6 个
const gap = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1;

举例:

  • 2025-09-01 是周一 → startDayOfWeek=1gap=0不补
  • 2025-10-01 是周三 → startDayOfWeek=3gap=2补 2 天
本月1号 周日 周一 周二 周三 周四 周五 周六
getDay() 0 1 2 3 4 5 6
需补几天 6 0 1 2 3 4 5

所以就可以通过这一特性,当作遍历次数:

/* 上月补充 */
// 获取第一天的星期数
const startDayOfWeek = firstDay.getDay(); // 0=周日
// 获取上个月最后一天(从周一开始算,所以要调整偏移)
const gap = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1;
for (let i = gap; i > 0; i--) {
    // 倒序生成日期对象
    const date = getDate({ year: Year.value, month: Month.value, day: -i });
    result.push({
        date,
        text: date.getDate(),
        isOtherMonth: true,
    });
}

下月补充(补“尾部”)

实现原理:

// 1. 已经装了几天?
const already = result.length; // 头补 + 当月天数

// 2. 一共想要 35 格(5 行),不够就再补 7 格,凑够 42 格
const remains = 5 * 7 - already; // 可能为 0 甚至负数

如果 remains ≤ 0 说明 35 格已够,就不会再进循环; 如果 remains > 0 就继续往后数数:

/* 下月补充 */
  const remains = 5 * 7 - result.length;
  for (let i = 1; i <= remains; i++) {
    const date = getDate({ year: Year.value, month: Month.value + 1, day: i });
    result.push({
      date,
      text: i,
      isOtherMonth: true,
    });
  }

技巧点

  • month: Month.value + 1 如果原来是 11(12 月),+1 变成 12,JS 会自动变成下一年 0 月(1 月),无需手写跨年逻辑。
  • 只补到 35 格,保证永远是完整 5 行;如果想固定 6 行,可把 5*7 改成 6*7

完成效果:

image-20250916155023574

完整代码:

<template>
  <div class="calendarCop">
    <!-- 日历顶部栏 -->
    <div class="calendarCop-header">
      <!-- 顶部栏月份切换区 -->
      <!-- 年月展示区 -->
      <div class="changeMouth">
        <!-- 切换至上个月图标按钮 -->
        <uni-icons
          class="left"
          type="left"
          size="24"
          color="#23ad1e"
          @click="handleLastMonth"
        >
        </uni-icons>
        <!-- 年月份展示区 -->
        <p class="data">{{ Year }} 年 {{ Month + 1 }} 月</p>
        <!-- 切换至下个月图标按钮 -->
        <uni-icons
          class="right"
          type="right"
          size="24"
          color="#23ad1e"
          @click="handleNestMonth"
        ></uni-icons>
      </div>
      <!-- 回到今日 -->
      <TagCop
        class="selectToday"
        text="今日"
        backgroundColor="var(--primary_light)"
        @click="selectToday"
      />
      <!-- 更多操作 -->
      <uni-icons
        v-show="false"
        type="more-filled"
        class="more"
        size="24"
        color="#23ad1e"
      ></uni-icons>
    </div>
    <!-- 日历星期栏 -->
    <div class="calendarCop-weekdays">
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
    </div>
    <!-- 日历 -->
    <div class="calendarCop-days">
      <div class="item" v-for="day in days" :key="day.date">
        <div
          :class="{
            day: true,
            base: true,
            today: isToday(day.date),
            selected: isSelected(day.date),
            'other-month': day.isOtherMonth,
          }"
          @click="selectDay(day.date)"
        >
          {{ day.text }}
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from "vue";
import TagCop from "@/components/base/tag-cop";

/* 当前日期时间 */
// 获取当前时间对象
const CurrentDate = ref(new Date());
// 获取当前年份
const Year = computed(() => CurrentDate.value.getFullYear());
// 获取当前月份
const Month = computed(() => CurrentDate.value.getMonth());
// 获取当前日期
const Today = computed(() => CurrentDate.value.getDate());

// 获取日期对象方法
const getDate = ({ year, month, day } = {}) =>
  new Date(year ?? Year.value, month ?? Month.value, day ?? Today.value);
// 格式化日期方法
const formatDate = (date) =>
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
    2,
    "0"
  )}-${String(date.getDate()).padStart(2, "0")}`;

/* 生成日期数据 */
const days = computed(() => {
  const result = [];
  // 获取每个月的第一天和最后一天
  const firstDay = getDate({ day: 1 });
  const lastDay = getDate({ month: Month.value + 1, day: 0 });
  /* 上月补充 */
  // 获取第一天的星期数
  const startDayOfWeek = firstDay.getDay(); // 0=周日
  // 获取上个月最后一天(从周一开始算,所以要调整偏移)
  const gap = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1;
  for (let i = gap; i > 0; i--) {
    // 倒序生成日期对象
    const date = getDate({ year: Year.value, month: Month.value, day: -i });
    result.push({
      date,
      text: date.getDate(),
      isOtherMonth: true,
    });
  }

  /* 本月日期 */
  // 通过遍历来渲染所有日期
  for (let i = 1; i <= lastDay.getDate(); i++) {
    const date = getDate({ year: Year.value, month: Month.value, day: i });
    result.push({
      date,
      text: i,
    });
  }

  /* 下月补充 */
  const remains = 5 * 7 - result.length;
  for (let i = 1; i <= remains; i++) {
    const date = getDate({ year: Year.value, month: Month.value + 1, day: i });
    result.push({
      date,
      text: i,
      isOtherMonth: true,
    });
  }

  return result;
});

/* 今日日期 */
const today_date = new Date();
const isToday = (date) => formatDate(today_date) === formatDate(date);
const selectToday = () => {
  CurrentDate.value = today_date;
  selectDay(today_date);
};

/* 选择日期相关 */
// 选中日期
const SelectedDate = ref(null);
// 选中日期方法
const selectDay = (date) => {
  SelectedDate.value = formatDate(date);
};
const isSelected = (date) => SelectedDate.value === formatDate(date);
// 初始化选中今天
onMounted(() => {
  SelectedDate.value = formatDate(today_date);
});

/* 月份切换相关 */
const handleLastMonth = () => {
  CurrentDate.value = getDate({
    year: Year.value,
    month: Month.value - 1,
    day: 1,
  });
};
const handleNestMonth = () => {
  CurrentDate.value = getDate({
    year: Year.value,
    month: Month.value + 1,
    day: 1,
  });
};
</script>

<style scoped lang="scss">
.calendarCop {
  background-color: var(--gary_light);
  padding: 16rpx;
  border-radius: var(--radius_big);
  .calendarCop-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    .more {
      transform: rotate(90deg);
    }
    .changeMouth {
      display: inline-flex;
      align-items: center;
      gap: 16rpx;
      height: 50rpx;
      .left,
      .right {
        font-weight: 900;
      }
      .data {
        font-size: 36rpx;
        line-height: 50rpx;
      }
    }
  }
  .calendarCop-weekdays {
    color: var(--primary_dark);
    font-weight: 900;
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    text-align: center;
    padding-bottom: 8rpx;
    margin: 8rpx 0;
    border-bottom: 4rpx solid var(--gary_dark);
  }
  .calendarCop-days {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 8rpx;
    .item {
      font-size: 32rpx;
      aspect-ratio: 1; // 宽=高,正方形
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      .day {
      }
      .other-month {
        color: var(--gary_dark);
      }
      .base {
        width: 100%;
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: var(--radius);
        font-weight: 900;
      }
      .today {
        color: var(--primary_dark);
        background: var(--primary_light);
        position: relative;
        &::after {
          content: "今";
          font-size: 18rpx;
          position: absolute;
          top: 4rpx;
          right: 8rpx;
        }
      }
      .selected {
        color: #fff;
        background: var(--primary);
      }
    }
  }
}
</style>

最终效果: image-20250916164921068

🧩 未来迭代方向

  1. 把“选中”改成 v-model 双向绑定 父组件 <CalendarCop v-model="date" /> 就能直接拿到日期。
  2. 加“事件点” 父组件传 events: Record<'yyyy-mm-dd', {dot?: boolean, text?: string, color?: string}>, 日历在对应格子画小圆点/小标签。
  3. 支持“范围选择”SelectedDate: Ref<string> 升级成 SelectedRange: Ref<{start?: string; end?: string}>, 点击逻辑改为:
    • 第一次点 → start
    • 第二次点 → end(若再点则清空重设) 样式层加 .in-range 做背景条。
  4. 支持“多选” SelectedDates: Set<string>,点击 toggle,样式加 .selected 即可。
  5. 加“ swipe 手势” 因为你在 uni-app,可以直接用 @touchstart/@touchend 算滑动距离, 或者引 uni-swiper-action 做整月滑动切换。
  6. 加“农历/节假日”text: number 拓展成 text: number | {solar: number; lunar: string; festival?: string}, 下面再画一行小字。

image.png

❌
❌