普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月21日技术

Next.js精通SEO第二章(robots.txt + sitemap.xml)

作者 小满zs
2026年4月20日 18:22

robots.txt

robots.txt是搜索引擎爬虫访问网站时遵循的规则,它告诉搜索引擎哪些页面可以抓取,哪些页面不能抓取。一般是存放在网站根目录下。

参数说明

User-agent

user-agent是搜索引擎爬虫的名称,例如GooglebotBaiduspiderBingbotYandexBotSogou spiderYahoo! SlurpBingPreview等,也可以直接使用*表示所有搜索引擎爬虫都可以访问。

Disallow

disallow是搜索引擎爬虫不能访问的页面,例如/admin/,/api/,/login/,/logout/等。

Allow

allow是搜索引擎爬虫可以访问的页面,例如/,/about/,/contact/等。

Crawl-delay

crawl-delay是搜索引擎爬虫访问网站的间隔时间,例如10,表示搜索引擎爬虫访问网站的间隔时间为10秒。

注意: Google机器人不支持该参数,其他部分爬虫机器人支持该参数

Sitemap

sitemap是网站地图的URL,例如https://www.某某某.com/sitemap.xml。

Host

host是网站的域名,例如https://www.某某某.com。

示例

掘金

image.png

掘金的User-agent是*,表示所有搜索引擎爬虫都可以访问。

以及配置了Disallow: /subscribe/subscribed,表示搜索引擎爬虫不能访问/subscribe/subscribe这个路由,等等其他路由也是同理。

配置了Sitemap: juejin.cn/sitemap/pos…,表示搜索引擎爬虫可以访问网站地图,网站地图会列出网站中的所有页面,方便搜索引擎爬虫抓取。

哔哩哔哩

image.png

若同一份 robots.txt 里既有通配符 *,又有具名爬虫(如 Googlebot),则对某只爬虫而言,会优先采用与其名称匹配的那一组规则;没有单独声明时再回退到 *

第一组规则
User-agent: *
Disallow: /medialist/detail/
Disallow: /index.html

通配符 * 段:声明不得抓取 /medialist/detail//index.html。下文已单独写出 User-agent 的爬虫(如 Googlebot)按各自分组执行,一般不适用本段这两条限制。

第二组规则
User-agent: Yisouspider
Allow: /

User-agent: Applebot
Allow: /

User-agent: bingbot
Allow: /

User-agent: Sogou inst spider
Allow: /

User-agent: Sogou web spider
Allow: /

User-agent: 360Spider
Allow: /

User-agent: Googlebot
Allow: /

User-agent: Baiduspider
Allow: /

User-agent: Bytespider
Allow: /

User-agent: PetalBot
Allow: /

为各主流搜索引擎爬虫单独声明 Allow: /,表示允许抓取整站路径。

第三组规则
User-agent: facebookexternalhit
Allow: /tbhx/hero

User-agent: Facebot
Allow: /tbhx/hero

User-agent: Twitterbot
Allow: /tbhx/hero

面向 Facebook、Twitter 等社交/预览类爬虫,声明允许 /tbhx/hero(多用于分享卡片、链接预览等)。

第四组规则
User-agent: *
Disallow: /

兜底:对未在上面单独列名的爬虫,禁止抓取全站(/)。若文件中出现多段 User-agent: *,路径规则如何合并以各搜索引擎实现为准。

Next.js中实现robots.txt

Next.js中实现robots.txt非常简单,我们是AppRouter,所以直接在app目录下创建一个robots.[ts | js]文件即可。

import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
    return {
        //如果是通用规则,可以这样写,就直接是一个对象类似于掘金
        // rules: {
        //    userAgent: '*',
        //    allow: '/',
        //    disallow: '/private/',
        //  },
        //自定义爬虫机器人规则可以用数组形式,就是一个数组类似于哔哩哔哩
        rules: [
            {
                userAgent: 'Googlebot', //搜索引擎爬虫的名称
                allow: '/', //允许访问的页面
                disallow: '/api/', //不允许访问的页面
                crawlDelay: 10, //访问间隔时间(Google机器人不支持该参数,其他部分爬虫机器人支持该参数)
            },
            {
                userAgent: 'Baiduspider',
                allow: '/',
                disallow: '/api/',
                crawlDelay: 10,
            },
            {
                userAgent: 'Bingbot',
                allow: '/',
                disallow: '/api/',
                crawlDelay: 10,
            },
            {
                userAgent: 'YandexBot',
                allow: '/',
                disallow: '/api/',
                crawlDelay: 10,
            },
            {
                userAgent: 'Sogou spider',
                allow: '/',
                disallow: '/api/',
                crawlDelay: 10,
            },
        ],
        sitemap: 'xxxx', //网站地图的URL
        //如果有多个可以写成一个数组
        //sitemaps: ['https://www.xxxxxx.com/sitemap.xml', 'https://www.xxxxxx.com/sitemap2.xml'],
    }
}

代码保存之后Next.js会自动生成一个robots.txt文件。

User-Agent: Googlebot
Allow: /
Disallow: /api/
Crawl-delay: 10

User-Agent: Baiduspider
Allow: /
Disallow: /api/
Crawl-delay: 10

User-Agent: Bingbot
Allow: /
Disallow: /api/
Crawl-delay: 10

User-Agent: YandexBot
Allow: /
Disallow: /api/
Crawl-delay: 10

User-Agent: Sogou spider
Allow: /
Disallow: /api/
Crawl-delay: 10

Sitemap: xxxx

sitemap.xml

sitemap.xml 是网站地图,用来向搜索引擎提供一批希望被发现的页面 URL(以及可选的更新时间、更新频率、优先级等提示信息),帮助爬虫更系统地遍历站点。哪些路径不允许抓取不希望被索引,通常由 robots.txtnoindex 等机制单独声明,而不是靠 sitemap 来“禁止”。

主要作用

  1. 帮助搜索引擎发现页面 如果你是新网站,并且有大量的路由是深层级的,爬虫很难发现你的页面,这时候你可以使用sitemap.xml来告诉搜索引擎你的页面有哪些,方便搜索引擎抓取。
  2. 利于被发现与纳入索引的考虑:例如掘金这类内容量很大的站点,会通过 sitemap(常按类型或分页拆成多个 XML)把文章 URL 结构化地提供给搜索引擎,提高被抓取、被纳入索引的机会

image.png 掘金通过 sitemap 列出文章 URL,方便搜索引擎发现并抓取,所以一部分原因是跟sitemap.xml有关,但最终是否收录还是取决于内容质量等其他因素。

image.png

常用字段与扩展

下面按 Sitemaps.org 协议 与常见的 Google 扩展(图片 / 视频)来说明。除 loc 外均为可选;扩展需在根节点声明对应 xmlns(见你文末示例)。

loc(必填)

页面 绝对地址http / https),需与站点实际可访问 URL 一致,并对 &< 等做 XML 转义(例如 & 写成 &amp;)。

示例:https://www.example.com/pagehttps://www.example.com/page/1

lastmod(可选)

最后修改时间,建议使用 W3C Datetime(与 协议说明 一致):

  • 仅日期:2026-04-20
  • 日期 + 时间(可带时区):2026-04-20T12:00:00+08:00

尽量与页面真实变更时间一致;胡填可能被搜索引擎忽略。

changefreq(可选)

相对本站该 URL 的“预期更新频率”,协议允许取值如下(英文为写入 XML 的值):

取值 含义
always 每次访问都可能不同
hourly 约每小时
daily 约每天
weekly 约每周
monthly 约每月
yearly 约每年
never 归档、基本不变

注意:这是协议里的提示字段。以 Google 为例,官方文档说明 不会用 changefreq(以及下面的 priority)来决定抓取频率或排序;其他爬虫是否参考也不统一。可填作兼容或自研爬虫的提示,但不要指望靠它“控制抓取周期”。

priority(可选)

仅相对同一站点内其他 URL 的重要程度,浮点数 0.01.0,默认 0.5。不是“全互联网排名优先级”,爬虫机器人会根据这个字段来决定抓取页面的优先级。(数字越大表示权重越高,爬虫机器人就会优先抓取)

图片扩展(可选)

某个 <url> 条目内 使用 Google 图片扩展:<image:image>,命名空间一般为 http://www.google.com/schemas/sitemap-image/1.1。常见子标签:

标签 说明
image:loc 图片 URL(核心字段)
image:caption 图片说明(可选)
image:title 图片标题(可选)

同一页面多张图可写多个 <image:image>

视频扩展(可选)

某个 <url> 条目内 使用 <video:video>,命名空间一般为 http://www.google.com/schemas/sitemap-video/1.1。面向 Google 视频索引 时,除标题、描述、封面外,通常还需要在 video:content_loc(媒体直链)与 video:player_loc(播放器页)至少填写其一(以 Google 视频站点地图说明 为准)。

常用子标签一览(是否必填随搜索引擎与场景略有差异,下表按常见用法归纳):

标签 常见要求 含义
video:title 必填 视频标题
video:thumbnail_loc 必填 封面图 URL
video:description 必填 视频文字描述
video:content_loc 常与 player_loc 二选一或并存 视频文件地址
video:player_loc 常与 content_loc 二选一或并存 可嵌入播放器的页面 URL
video:duration 可选 时长(秒)
video:expiration_date 可选 过期时间(W3C Datetime)
video:rating 可选 评分
video:view_count 可选 播放次数
video:publication_date 可选 首次发布时间
video:family_friendly 可选 yes / no
video:restriction 可选 允许 / 禁止的国家或地区代码
video:platform 可选 允许 / 禁止的平台(如 web / mobile)
video:requires_subscription 可选 yes / no
video:uploader 可选 上传者信息(可用属性 info 等,见官方文档)
video:live 可选 是否直播:yes / no
video:tag 可选 标签
生成的基本结构如下

在我们编写完sitemap.[ts | js]文件之后,Next.js会自动生成一个sitemap.xml文件。

<urlset
  xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
  xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
  xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"
>
  <url>
    <loc>https://example.com</loc>
    <image:image>
      <image:loc>http://localhost:3000/xxxxxxxxxx.jpg</image:loc>
    </image:image>
    <lastmod>2026-04-19T20:21:06.903Z</lastmod>
    <changefreq>yearly</changefreq>
    <priority>1</priority>
  </url>
  <url>
    <loc>https://example.com/about</loc>
    <video:video>
      <video:title>视频标题</video:title>
      <video:thumbnail_loc>http://localhost:3000/xxxxxxxxxx.jpg</video:thumbnail_loc>
      <video:description>视频描述</video:description>
      <video:duration>100</video:duration>
      <video:publication_date>Mon Apr 20 2026 04:21:06 GMT+0800 (中国标准时间)</video:publication_date>
    </video:video>
    <lastmod>2026-04-19T20:21:06.903Z</lastmod>
    <changefreq>monthly</changefreq>
    <priority>0.8</priority>
  </url>
  <url>
    <loc>https://example.com/blog</loc>
    <lastmod>2026-04-19T20:21:06.903Z</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.5</priority>
  </url>
</urlset>

Next.js中实现sitemap.xml

我们使用的是AppRouter,所以直接在app目录下创建一个sitemap.[ts | js]文件即可。

import type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
    return [
        {
            url: 'https://example.com',
            lastModified: new Date(),
            changeFrequency: 'yearly',
            priority: 1,
            images: ['http://localhost:3000/xxxxxxxxxx.jpg'],
        },
        {
            url: 'https://example.com/about',
            lastModified: new Date(),
            changeFrequency: 'monthly',
            priority: 0.8,
            videos: [
                {
                    thumbnail_loc: 'http://localhost:3000/xxxxxxxxxx.jpg',
                    title: '视频标题',
                    description: '视频描述',
                    duration: 100,
                    publication_date: new Date(),
                }
            ]
        },
        {
            url: 'https://example.com/blog',
            lastModified: new Date(),
            changeFrequency: 'weekly',
            priority: 0.5,
        },
    ]
}

进阶用法:拆成多个 sitemap(generateSitemaps

单文件 app/sitemap.ts 适合 URL 数量较少的情况。页面很多时(例如文章、商品各自成千上万条),更常见的做法是 拆成多个 sitemap 文件:既符合 Sitemap 协议 的实践,也便于控制单次生成的体积(例如 Google 建议 每个 sitemap 最多约 5 万条 URL)。

在 App Router 里可以这样做(摘自 Next.js 文档:Generating multiple sitemaps):

  1. 仍在 app 目录下使用 sitemap.ts / sitemap.js(或与 sitemap.xml 二选一,按项目约定)。
  2. 额外导出 generateSitemaps:返回一组带 id 的对象,每个 id 对应一份子 sitemap。
  3. 默认导出的 sitemap 函数会带上当前这份子图的 id,你根据 id 去查库或拼不同前缀的路径即可。

如何访问

  • 子站点地图地址形如:/sitemap/{id}.xml,其中 {id}generateSitemaps() 里返回的 id 一致(例如 id: '1' → 浏览器打开 http://localhost:3000/sitemap/1.xml,与下图一致)。
  • 若文件放在嵌套路由下(例如 app/products/sitemap.ts),则前缀会带上段路径,形如 /products/sitemap/{id}.xml(见 generateSitemaps 文档中的 URL 说明)。
  • 启用多份 sitemap 后,根路径一般还会提供 /sitemap.xml 作为 站点地图索引(sitemap index),里面列出各子 sitemap 的 URL,提交给搜索引擎时通常 优先提交这份索引

下面示例演示 三份子图(文章 / 用户 / 资讯),每份里临时生成 5 条 URL;真实项目里把 for 循环换成 调接口或查数据库 即可。

import type { MetadataRoute } from 'next'

/** 每个 id 对应一份子 sitemap */
export async function generateSitemaps() {
  return [{ id: '1' }, { id: '2' }, { id: '3' }]
}

const SEGMENT_BY_ID: Record<string, string> = {
  '1': 'post',
  '2': 'user',
  '3': 'new',
}

export default async function sitemap(props: {
  id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
  const id = await props.id
  const segment = SEGMENT_BY_ID[id] //根据id获取对应的segment
  if (!segment) return []

  const num = 5 //模拟生成5条URL
  const entries: MetadataRoute.Sitemap = []

  for (let i = 0; i < num; i++) {
    entries.push({
      url: `http://localhost:3000/demo/${segment}/${i}`,
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 0.6,
    })
  }

  return entries
}

image.png


你的首屏慢得像蜗牛?这6招让页面“秒开”

作者 kyriewen
2026年4月20日 17:51

用户打开你的网站,3秒了还是一片白。他走了,去了隔壁。你丢了一个客户,就因为首屏慢了几秒。今天我们来给页面“提速”,6个实战技巧,从网络请求到渲染,让你的首屏加载快得像闪电。

前言

你有没有等过一个加载超过5秒的网页?那种感觉就像在机场等一艘船。用户耐心有限:3秒内没打开,一半人会走。今天我们不谈虚的理论,直接上代码、上配置、上工具,从源头把首屏时间砍掉一半以上。

一、首屏慢的三大元凶

  • 请求太多:几十个JS、CSS、图片,每个都要握手、传输。
  • 资源太大:未压缩的图片、没Tree Shaking的依赖。
  • 渲染阻塞:CSS和JS阻塞了HTML解析,白屏时间拉长。

对症下药,我们一个个击破。

二、第1招:SSR或预渲染,让首屏“有内容”

纯SPA(单页应用)的HTML几乎是空的,需要等JS下载执行后才渲染。用户看到白屏的时间很长。

解决方案

  • SSR(服务端渲染):用Next.js(React)或Nuxt(Vue),在服务器生成完整HTML,用户直接看到内容,然后JS“水合”绑定事件。
  • 静态生成(SSG):像Gatsby、Astro,构建时生成HTML,适合内容不频繁变化的页面。
  • 预渲染(Prerendering):用prerender-spa-plugin在构建时把几个关键路由生成静态HTML。

如果你不想上SSR,至少做到骨架屏——在JS执行前先显示灰色占位块,让用户觉得“快了快了”。

三、第2招:代码分割,别一次加载所有

你只访问首页,结果整个后台管理系统的代码都下载了。浪费流量,也浪费时间。

Webpack/Vite内置代码分割

  • 动态导入(import()):路由级别的懒加载。
// 路由懒加载
const UserPage = () => import('./pages/UserPage');
  • 分割第三方库:把reactlodash等抽成单独的vendor文件,利用缓存。
// vite.config.js
build: {
  rollupOptions: {
    output: {
      manualChunks: {
        vendor: ['react', 'react-dom'],
        ui: ['antd']
      }
    }
  }
}

四、第3招:压缩与优化资源

图片:首屏最大杀手

  • 换成WebP:比JPEG小30%左右。用<picture>标签提供fallback。
  • 懒加载:首屏之外的图片先不加载,滚动到再加载。
<img loading="lazy" src="..." alt="...">
  • 响应式图片:用srcset给不同屏幕尺寸加载不同大小的图片。

字体:FOIT(无样式文本闪烁)

  • font-display: swap先显示系统字体,等自定义字体加载完再替换。
  • 只加载需要的字符集(比如只加载英文和数字)。

JS/CSS压缩

  • Vite/Webpack生产模式默认开启压缩。但可以手动配置Terser去掉console
  • compression-webpack-plugin生成gzip或brotli文件,让服务器直接返回压缩版本。

五、第4招:优化关键渲染路径

浏览器先解析HTML,遇到<link><script>会阻塞渲染。

内联关键CSS

把首屏需要的CSS直接内联到<style>里,其余CSS异步加载。

<style>/* 首屏CSS */</style>
<link rel="preload" href="main.css" as="style" onload="this.onload=null;this.rel='stylesheet'">

给JS加defer或async

  • defer:并行下载,但按顺序执行,在DOMContentLoaded之前执行。
  • async:并行下载,下载完立刻执行,执行顺序不定。
<script defer src="app.js"></script>

对于首屏不需要的JS,可以延迟到页面空闲时加载。

// 空闲时加载
requestIdleCallback(() => import('./analytics.js'));

六、第5招:使用CDN和HTTP/2

  • CDN:把静态资源放到离用户最近的服务器,减少物理距离导致的延迟。
  • HTTP/2:多路复用,一个连接并发传输多个文件,比HTTP/1.1的6个连接限制强很多。

七、第6招:缓存策略,二次访问秒开

  • 强缓存Cache-Control: max-age=31536000(一年),适用于不变的资源(带hash的JS/CSS)。
  • 协商缓存ETag + Last-Modified,服务器确认资源没变化则返回304。
  • Service Worker:离线缓存,甚至可以做到“骨架屏秒现”。

八、实战:用Lighthouse跑分并优化

Chrome DevTools → Lighthouse,生成报告,它会告诉你哪些资源浪费了时间、哪些图片可以优化、哪些请求阻塞渲染。

常见优化建议:

  • 移除阻塞渲染的脚本。
  • 压缩图片。
  • 减少未使用的CSS(用purgecss移除没用的样式)。
  • 启用文本压缩(gzip)。

九、总结:首屏优化清单

  • 开启Gzip/Brotli压缩。
  • 图片转WebP、懒加载、响应式。
  • 路由懒加载 + 第三方库分割。
  • 关键CSS内联,非关键异步加载。
  • JS加defer/async。
  • 使用CDN + HTTP/2。
  • 配置强缓存和协商缓存。
  • 用Lighthouse反复测量。

优化完,你的页面首屏时间可以从3秒降到1秒以内。用户开心,老板也开心。


如果你觉得今天的提速课够实战,点个赞让更多人看到。明天我们继续性能优化第二弹——运行时优化,让你的页面滚动、动画、输入都不掉帧。我们明天见!

VUE开发环境配置基础(构建工具→单文件组件SFC→css预处理器sass→eslint)及安装脚手架

作者 RONIN
2026年4月20日 17:44

VUE开发环境配置基础(构建工具→单文件组件SFC→css预处理器sass→eslint)

一、构建工具

作用:

打包压缩、转换(.vue文件转换成浏览器能识别的html、css、js)

内置了web服务器可进行热更新

  • webpack构建工具
  • vite构建工具

使用:

  1. npm init -y初始化项目,生成package.json文件
  2. npm i vite -D(npm install vite -D)安装vite构建工具(-S生产环境,-D局部安装/开发环境,-G全局安装)

安装之后可以使用vite命令启动内置web服务器

但开发中一般会在package.json文件中配置dev、build

"scripts": {
    "dev": "vite --host",
    "build": "vite build",
    "preview": "vite preview",
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
  },

 

  1. 执行npm run dev命令启动内置web服务器
  2. 执行npm run build打包项目,打包后会在根目录下生成一个dist文件夹,该文件夹下存放的就是打包压缩后的包

二、单文件组件SFC(.vue结尾的文件)包括<template><script><style>

  1. npm i @vitejs/plugin-vue -S 安装vite构建工具解析.vue文件的插件

根目录下创建vite.config.js文件,配置集成该插件

import vue from '@vitejs/plugin-vue' // vite构建工具解析 .vue文件的插件
import {defineConfig} from 'vite'//defineConfig方法,编写代码会有提示
export default defineConfig({
    plugins:[vue()] // 集成插件
})

2. npm i vue -S 安装vue框架

main.js

// import {createApp} from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
import { createApp } from 'vue'

// import App from './App.js'
import App from './App.vue'

createApp(App).mount('#app')

App.vue

<!-- 模板 -->
<template>
    <div class="g-wrapper">
        <h2>单文件组件 SFC</h2>
        <p>{{message}}</p>
        <table>
            <tr>
                <th>序号</th>
                <th>名称</th>
                <th>价格</th>
            </tr>

            <!--  绑定key目的: 虚拟dom diff算法能够快速找到列表项,对列表项进行高效操作 -->
            <tr v-for="item,index in list" :key="item.id">
                 <td>{{item.id}}</td>
                 <td>{{item.name}}</td>
                 <td>{{item.price}}</td>
            </tr>
        </table>
        <ComA></ComA>
    </div>
</template>

<!-- js代码 -->
<script>
import ComA from './components/ComA.vue'
export default {
    components:{
        ComA
    },
    data() {
        return {
            message:'根组件App.vue',
            list:[
                {id:1001,name:'javascript编程',price:99.89},
                {id:1002,name:'css编程',price:89.89},
                {id:1003,name:'vue编程',price:178.88},
            ]
        }
    }
}
</script>

<!-- css样式 -->
<style scoped>
.g-wrapper{
    width: 1200px;
    margin: 100px auto;
}

.g-wrapper table{
    width: 100%;
    text-align: center;
}

.g-wrapper table tr td,th{
    border-bottom: 1px dotted gray;
    line-height: 40px;
}
</style>

ComA.vue

<template>
  <div class="g-wrapper">
    <h2>组件ComA</h2>
  </div>
</template>

<script>
export default {};
</script>

<!-- scoped :样式作用域只在当前组件生效 -->
<style scoped>
h2 {
  color: red;
}
</style>

三、css预处理器less,sass,stylus

sass(两个版本:sass、scss。scss是sass的升级版,完全兼容css)

官网:www.sass.hk/

npm i sass -D 安装sass(vite构建工具内置了sass库,安装后不需要配置)

1. 导入样式可以在main.js入口文件中导入,也可以在某个模块中导入

scss文件导入总结:

  • main.js入口文件:import  ‘./text.scss’
  • .vue文件:@import  url(./text.scss)
  • .scss同级文件:@import  ‘./text.scss’

main.js

import { createApp } from 'vue'
// 模块化导入样式
import "./assets/scss/text.scss";

import App from './App.vue'
createApp(App).mount('#app')

.vue文件

<style scoped>
// 导入样式
@import url(../assets/sass/test.scss);
</style>

2. scss语法

变量$btn、混合器@mixin可单独封装一个文件,便于维护

1>.定义变量:

$变量名:值

$c: blue; // scss定义变量
$h: 200px;
$btnH: 40px;
2>.嵌套语法:
//css写法
// .g-container {
//   background-color: pink;
//   height: $h;
// }

// .g-container h2 {
//     font-size: 18px;
//     color: $c;
// }

// 嵌套语法
.g-container {
  background-color: pink;
  height: $h;
  h2 {
    font-size: 18px;
    color: $c;
  }
}
3>.混合器:样式封装

定义混合器@mixin 混合器名{}(相当于函数function 函数名)

@mixin btn1{
    display: inline-block;
    //封装样式
    width: 100px;
    height: $btnH;
    text-align: center;
    line-height: $btnH;
    border: none;
    outline: none;
    background-color: skyblue;
    border-radius: 5px;
}

使用封装的混合器(@include 混合器名)

.m-a1 {
    color: blue;
    margin: 10px;
    @include btn1;
  }
4>.鼠标悬停(伪类&)
//css写法
// .m-a1:hover{
//   background-color: #3eb8e9;
// }

.m-a1 {
  color: blue;
  margin: 10px;
  @include btn1;
  &:hover {
    background-color: #3eb8e9;
  }
}
5>.控制指令@if

@if 表达式返回值不是false或者null时,条件成立,输出(}内的代码。

@if 声明后面可以跟多个@else if 声明,或者一个@else 声明。

$type: monster;

P{
@if $type == ocean {color: blue;}
@else if $type == matador {color: red;}
@else if $type == monster {color: green;}
@else {color: black;}
}

更多语法参官网

3.单文件组件中使用scss(lang=”scss”)

<style lang="scss" scoped>
// 模块化导入样式
/*@import url(../assets/sass/test.scss); */

.g-container{
  background-color: pink;
  h2{
    color:red;
  }
  div{
    width: 100px;
    height: 40px;
    background-color: skyblue;
  }
}
</style>

四、eslint一个语法规则和代码风格的检查工具,保证写出语法正确、风格统一的代码

官网:eslint.nodejs.cn/

手动集成:

  1. npm i eslint -D(yarn add eslint -D) 安装eslint
  2. npx eslint --init 初始化项目eslint,生成.eslintrc.js配置文件
/* eslint-disable no-undef */

module.exports = {
    "env": {
        "browser"true,
        "es2021"true
    },

    "extends": [
        "eslint:recommended",
        "plugin:vue/vue3-essential"
    ],

    "overrides": [
    ],

    "parserOptions": {
        "ecmaVersion""latest",
        "sourceType""module"
    },

    "plugins": [
        "vue"
    ],

    "rules": {//自定义规则
        // semi: ['error', 'never'],  // 使用分号结束报错
        // quotes: ['error', 'single'],  // 使用单引号报错
        // eqeqeq: ['error', 'always'],// 使用===,不能使用==
        // 'vue/no-unused-vars': 'error',
    }
}

3. npm i eslint-plugin-vue 安装检查单文件组件的插件 4. vscode搜索安装ESLint插件,自动检测,不符合规则会报错

  1. npm i pretter eslint-config-prettier -D(yarn add pretter eslint-config-prettier -D)安装eslint格式化插件,格式化时自动改正
  2. 根目录下创建配置.prettierrc.json格式化规则文件
{
    "tabWidth": 4,
    "useTabs": false,
    "semi": false,
    "singleQuote": true,
    "TrailingComma": "all",
    "bracketSpacing": true,
    "jsxBracketSameLine": false,
    "arrowParens": "avoid"
}

脚手架(create-vite、vue-cli、create-vue、quasar-cli)

1. create-vite

npm create vite@latest(yarn create vite)安装脚手架命令

2. vue-cli

npm i -g @vue-cli安装脚手架命令

vue create project1创建项目

3. create-vue(vue官方的项目脚手架工具,内置了vite构建工具)项目开发中使用的脚手架

npm init vue@latest安装脚手架命令,根据预设生成相应的配置文件

npm install(npm i) 安装依赖

image.png npm run dev运行

目录结构介绍

image.png

4. quasar-cli项目开发中使用的脚手架

关于quasar要求:

  • Node 12+用于Quasar CLI与Webpack,Node 14+用于Quasar CLI与Vite。
  • Yarn v1(强烈推荐),PNPM,或NPM。

npm i -g @quasar/cli 安装脚手架命令

npm init quasar 初始化quasar根据预设生成相应的配置文件 image.png 此时回车,会生成项目文件和目录 image.png 提示安装项目依赖,选择yes回车 image.png quasar dev(npm run dev)运行

如何实现一个简化的响应式系统

作者 yogalin1993
2026年4月20日 17:38

下面是一个基于 Proxy 的简化响应式系统实现,涵盖依赖收集、触发更新、嵌套对象处理,以及一些常见边界处理(避免重复代理、Set/Delete 操作、数组索引等)。

let activeEffect = null;
const effectStack = [];

const targetMap = new WeakMap();
const reactiveMap = new WeakMap();

const ITERATE_KEY = Symbol('iterate');

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    try {
      return fn();
    } finally {
      effectStack.pop();
      activeEffect = effectStack[effectStack.length - 1] || null;
    }
  };
  effectFn.deps = [];
  effectFn();
  return effectFn;
}

function cleanup(effectFn) {
  for (const dep of effectFn.deps) {
    dep.delete(effectFn);
  }
  effectFn.deps.length = 0;
}

function track(target, key) {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));

  let dep = depsMap.get(key);
  if (!dep) depsMap.set(key, (dep = new Set()));

  if (!dep.has(activeEffect)) {
    dep.add(activeEffect);
    activeEffect.deps.push(dep);
  }
}

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const effectsToRun = new Set();
  const add = (dep) => {
    if (!dep) return;
    dep.forEach((fn) => {
      if (fn !== activeEffect) effectsToRun.add(fn);
    });
  };

  add(depsMap.get(key));
  add(depsMap.get(ITERATE_KEY));

  effectsToRun.forEach((fn) => fn());
}

function isObject(val) {
  return val !== null && typeof val === 'object';
}

function createReactive(obj) {
  if (!isObject(obj)) return obj;
  if (reactiveMap.has(obj)) return reactiveMap.get(obj);

  const proxy = new Proxy(obj, {
    get(target, key, receiver) {
      if (key === '__isReactive__') return true;

      const res = Reflect.get(target, key, receiver);
      track(target, key);

      return isObject(res) ? createReactive(res) : res;
    },

    set(target, key, value, receiver) {
      const oldValue = target[key];
      const hadKey = Object.prototype.hasOwnProperty.call(target, key);
      const result = Reflect.set(target, key, value, receiver);

      if (!hadKey) {
        trigger(target, ITERATE_KEY);
        trigger(target, key);
      } else if (oldValue !== value && !(Number.isNaN(oldValue) && Number.isNaN(value))) {
        trigger(target, key);
      }
      return result;
    },

    has(target, key) {
      track(target, key);
      return Reflect.has(target, key);
    },

    ownKeys(target) {
      track(target, ITERATE_KEY);
      return Reflect.ownKeys(target);
    },

    deleteProperty(target, key) {
      const hadKey = Object.prototype.hasOwnProperty.call(target, key);
      const result = Reflect.deleteProperty(target, key);
      if (hadKey && result) {
        trigger(target, key);
        trigger(target, ITERATE_KEY);
      }
      return result;
    },
  });

  reactiveMap.set(obj, proxy);
  return proxy;
}

设计要点

1. 依赖收集 (track)

  • WeakMap → Map → Set 三层结构存依赖:target → key → effects
  • WeakMap 避免内存泄漏(target 被回收时依赖一起回收)
  • 只有在 activeEffect 存在时才收集,避免在非 effect 环境下 get 也被记录

2. 触发更新 (trigger)

  • 取出对应 key 的依赖集合并执行
  • 用一个新的 Set 包裹要执行的 effects,避免遍历过程中 cleanup + 重新收集导致的死循环
  • fn !== activeEffect 防止 effect 内自更新触发自身的递归

3. 嵌套对象处理 — 懒代理

关键在 get 拦截器里:

return isObject(res) ? createReactive(res) : res;

只有真正访问到嵌套对象时才递归代理它,比一次性深度遍历更高效,也能正确处理后续动态新增的对象属性。

4. 其他增强

  • reactiveMap 缓存已代理对象,避免对同一对象重复创建 Proxy
  • effect 支持嵌套(用 effectStack 维护当前 activeEffect)
  • cleanup 每次执行前清理旧依赖,解决条件分支场景下的"过期依赖"问题(比如 state.show ? state.a : state.b
  • set 中区分新增 key修改 key:新增会触发 ITERATE_KEY(使 for...in / Object.keys 也能响应)
  • 处理 NaN !== NaN 的边界

使用示例

const state = createReactive({
  count: 0,
  user: { name: 'Tom', age: 18 },
});

effect(() => {
  console.log('count:', state.count);
});

effect(() => {
  console.log('user name:', state.user.name);
});

state.count++;
state.user.name = 'Jerry';
state.user.age = 20;

输出:

count: 0
user name: Tom
count: 1
user name: Jerry

注意 state.user.age = 20 没有触发任何 effect — 因为没有 effect 依赖 age,这正是依赖收集精度的体现。

如果还想扩展,可以在此基础上加 computed(用 dirty 标志位 + scheduler)和 watch(包装一个收集依赖的 effect + scheduler 回调),核心机制就是上面这套 effect / track / trigger

我用 Cloudflare Workers + GitHub Actions 做了个 2.5 刀/月的 AI 日报,代码开源了

作者 HashTang
2026年4月20日 17:36

每天 UTC 00:00,脚本自动抓 HN、GitHub Trending、Product Hunt、HuggingFace、Reddit(indiehackers / SideProject 等 12 个子版)、V2EX、Google Trends 关键词涨幅,300+ 条原始信号扔给 LLM,吐出中英双语结构化日报,commit 到公开仓库,再触发 Cloudflare Queue 推送订阅邮件。

站点:dailydawn.dev · 代码:github.com/TangSY/dail…

文章分成五个部分:

  1. 架构全景
  2. 三个关键决策:为什么不选 Vercel、为什么单 Worker 合并三种 handler、为什么把 GitHub 当 CMS
  3. 三个硬核踩坑:LLM 时间幻觉根治、邮件 RFC 8058 一键退订、Cloudflare Queues 批处理
  4. 成本账单:真实拆解
  5. 下一步 + 邀请你一起玩

全文干货,尽量给你可以直接偷的代码和配置。


1. 架构全景

整体流水:

flowchart LR
    A[GitHub Actions<br/>cron UTC 00:00] --> B[Python pipeline<br/>抓取 去重 LLM]
    B --> C[commit markdown<br/>到开源仓库]
    C --> D[GitHub raw CDN]
    C --> E[curl webhook]
    D --> W[Cloudflare Worker]
    E --> W
    W --> F[(D1 SQLite)]
    W --> Q[[EMAIL_QUEUE]]
    Q --> W
    W --> R[Resend API]
    W --> U[dailydawn.dev]

一个单 Cloudflare Worker 同时扛三种 handler:fetch(网站 + API)、queue(邮件批量消费)、scheduled(cron 兜底):

flowchart TB
    User([访客浏览]) --> H1
    Hook([内容 webhook]) --> H1
    Cron([CF Cron 00:30]) --> H3
    subgraph Worker [单个 Cloudflare Worker]
        H1[fetch handler<br/>Astro SSR + API]
        H2[queue handler<br/>批量发邮件]
        H3[scheduled handler<br/>失败兜底]
    end
    H1 --> Q[[EMAIL_QUEUE]]
    H3 --> Q
    Q --> H2
    H2 --> Resend([Resend API])
    H1 --> D[(D1)]
    H3 --> D

D1 做数据库,Queues 做消息队列,Static Assets 做静态资源。全家桶一个 Dashboard 管。

这套架构三件事合一:

  • 访问 dailydawn.dev 看日报,走的是 Worker 的 fetch handler。
  • Python pipeline push 新日报后 curl 打到这个 Worker 的 /api/webhook/new-report,它会给所有订阅者入队。
  • 队列消息被同一个 Worker 的 queue handler 消费,调 Resend 发邮件。
  • 如果 webhook 失败,cron 每天 00:30 兜底:查今日 report 若已入库且未通知则入队所有订阅者。

听起来简单,实际落地踩了不少坑。往下看。


2. 三个关键决策

2.1 为什么不选 Vercel 或 Next.js

做这种小项目,多数人第一反应是 Next.js + Vercel。我没选,原因三条:

成本。Vercel Pro 是 20/月起步,Hobby有限流。CloudflareWorkers免费额度是10万请求/天,到1000/月之后才20/月起步,Hobby 有限流。Cloudflare Workers 免费额度是 10 万请求/天,到 1000 万/月之后才 5,完全覆盖我的量级。

组件整合度。我需要的是:web server + 数据库 + 消息队列 + 定时任务 + 静态资源。Vercel 上这套要组合:Vercel + Neon/Planetscale + Upstash + Vercel Cron + Vercel Blob。五个 Dashboard、五份账单、五个 SDK。

Cloudflare 一个 wrangler.toml 全搞定:

name = "dailydawn"
main = "dist/_worker.js/index.js"
compatibility_date = "2024-11-06"

[[d1_databases]]
binding = "DB"
database_name = "daily-builder"
database_id = "xxxxx"

[[queues.producers]]
binding = "EMAIL_QUEUE"
queue = "daily-email-queue"

[[queues.consumers]]
queue = "daily-email-queue"
max_batch_size = 50

[triggers]
crons = ["30 0 * * *"]

[assets]
directory = "./dist"
binding = "ASSETS"

冷启动。Workers 跑在边缘节点,无冷启动。订阅确认邮件的点击链接是全球用户打开,任何一个 $29 VPS 都比不上 CF 边缘的延迟。

选 Cloudflare 的副作用:不能用 Node.js 专属 API(fschild_process 那些)。但做 web 层用不上,反而逼你写更简洁的代码。

2.2 为什么单 Worker 合并 fetch + queue + scheduled

默认 Astro Cloudflare adapter 只生成带 fetch handler 的 _worker.js

// dist/_worker.js/index.js(Astro 产出)
export default {
  async fetch(request, env, ctx) {
    // Astro SSR 逻辑
  }
};

Queue 和 scheduled handler 它不管。按官方文档的建议,应该拆成两个 Worker:

  • Worker A:Astro 产出,处理 fetch。
  • Worker B:独立 TypeScript 项目,处理 queue + scheduled。

两个 Worker 意味着两套 deploy、两份代码、两个 D1 binding(或者 service binding 互相调)、config 维护量翻倍。对一个想快速迭代的小项目来说,这税太贵。

我的解法:用 esbuild 把自己写的 worker-entry.ts 追加到 Astro 产出的 _worker.js/index.js 末尾,让自定义的 default export 覆盖 Astro 的。

// scripts/merge-worker.mjs
import esbuild from 'esbuild';
import fs from 'node:fs/promises';

const ASTRO_WORKER = 'dist/_worker.js/index.js';

// 1. 把 worker-entry.ts 编译成独立 bundle
const result = await esbuild.build({
  entryPoints: ['src/worker-entry.ts'],
  bundle: true,
  format: 'esm',
  platform: 'neutral',
  write: false,
  external: ['cloudflare:*'],
});

const extra = result.outputFiles[0].text;

// 2. 追加到 Astro 产出末尾,覆盖 default export
const original = await fs.readFile(ASTRO_WORKER, 'utf-8');
const merged = `
${original.replace('export default', 'const __astroWorker =')}

${extra}
`;

await fs.writeFile(ASTRO_WORKER, merged);

src/worker-entry.ts 长这样:

import { handleEmailQueue } from './lib/queue';
import { handleScheduled } from './lib/scheduled';

declare const __astroWorker: ExportedHandler<Env>;

export default {
  async fetch(request, env, ctx) {
    return __astroWorker.fetch!(request, env, ctx);
  },
  async queue(batch, env, ctx) {
    return handleEmailQueue(batch, env, ctx);
  },
  async scheduled(controller, env, ctx) {
    return handleScheduled(controller, env, ctx);
  },
} satisfies ExportedHandler<Env>;

package.json

{
  "scripts": {
    "build": "astro build && node scripts/merge-worker.mjs"
  }
}

这是 hack,不是官方方案。风险是 Astro 升级如果改了 _worker.js 导出结构可能默默坏掉。我的 CI 加了一个 smoke test:build 之后 grep __astroWorker 看有没有,没有就 fail。

坦白说,我也等官方某天暴露 adapter hook。在那之前这段 35 行代码帮我省了一整个独立 Worker 项目。

2.3 为什么把 GitHub 当 CMS

日报的内容存在哪?最常见的三条路:

  • 传统 CMS(Strapi / Ghost / Directus):要维护一个后台、数据库、鉴权。多一个服务就多一个月成本。
  • Notion API / Airtable 当数据源:免运维,但免费额度吃紧,延迟高,版本化弱。
  • Headless CMS SaaS(Contentful / Sanity):$9 起步,并且内容在别人手里。

我最后选的是把 GitHub 仓库本身当 CMS——每天生成的 markdown 直接 commit 到一个公开仓库,目录按 lang/YYYY/YYYY-MM-DD.md 组织。

.
├── zh/2026/2026-04-20.md
├── zh/2026/2026-04-19.md
├── en/2026/2026-04-20.md
└── en/2026/2026-04-19.md

这套方案免费,且带来四个意外的好处:

  1. 版本化免费git log zh/2026/2026-04-20.md 就是内容修订史。哪一天算法改了、哪一条信号被人肉修正过,git blame 一眼能看。
  2. 读者可以 PR 修正内容。看到事实错误直接提 PR,比任何「留言反馈」都更真实的参与感。
  3. 免费全球 CDNraw.githubusercontent.com 自带 GitHub 的全球边缘,再叠一层 Cloudflare cache,读取延迟低到忽略。
  4. 算法透明度。「AI 日报」这个 pitch 里,开源是信号——告诉读者内容是算法生成还是人肉塞的,算法长什么样,有没有夹带私货。仓库公开这个信任就立住了。

Web 层 SSR 时从 GitHub raw 拉 markdown:

// src/lib/content.ts
import { marked } from 'marked';
import matter from 'gray-matter';

export async function fetchReportMarkdown(lang: 'zh' | 'en', date: string) {
  const [y] = date.split('-');
  const url = `https://raw.githubusercontent.com/TangSY/dailydawn/main/${lang}/${y}/${date}.md`;
  const res = await fetch(url, {
    cf: { cacheEverything: true, cacheTtl: 3600 },
  });
  if (!res.ok) return null;
  const raw = await res.text();
  const { content } = matter(raw);
  return marked.parse(content);
}

cf.cacheEverything + cacheTtl: 3600,CF 边缘缓存 1 小时。命中后不碰 GitHub。

相比 git submodule 方案,它的好处是部署频率和内容更新完全解耦——内容每天变一次,Web 层不需要重新 build 部署;Web 层偶尔改 UI 重新部署,内容不受影响。

这是我觉得这个项目架构上最值得抄的一个决策。


3. 三个硬核踩坑

3.1 LLM 时间幻觉:三天前的事被说成「今天发布」

这是我调试了整整两晚的问题。

现象:LLM 生成的日报里经常有这种句子:

🔍 今天发布:Cursor Composer agents 支持 Vercel AI SDK

点开链接一看,github release 时间是 3 天前。但 LLM 写得信誓旦旦。一开始我以为是 prompt 没说清楚,加了一行「只引用今天发布的内容,如果日期不是今天请明确标注」。

没用。LLM 继续胡说。

第一层:prompt 工程不够

我试了各种 prompt 工程花招——thinking chain、explicit instruction、JSON schema 严格约束、few-shot 示例。效果是从「80% 错」降到「40% 错」。还是烂。

根本原因:LLM 根本不知道真实时间。它看到一条信号文本里写 "released xxx",它不知道这是什么时候的 released。它 fallback 到「听起来像新的 = 今天」。

第二层:数据源注入真实时间

我在 fetcher 层给每条 signal 加了 published_at 字段(ISO 8601 UTC)。以 HN 为例:

# scripts/fetchers/hackernews.py
signal = Signal(
    source="hackernews",
    title=hit["title"],
    url=hit["url"],
    score=hit["points"],
    published_at=hit["created_at"],  # 来自 Algolia API 的真实时间戳
)

Product Hunt 改 GraphQL query 加 featuredAt,Reddit 把 created_utc 转 ISO,V2EX、HuggingFace 各自找原生时间字段。GitHub Trending 的 HTML 没有精确时间字段,我保留 None 并在 prompt 里说明「该信号源无时间信息」。

然后给 expert prompt 注入当前 UTC 日期 + 每条信号的相对天数:

# scripts/pipeline/expert.py
today = datetime.now(timezone.utc).date()

def format_signal(s):
    if s.published_at:
        days_ago = (today - parse_iso(s.published_at).date()).days
        age_label = "今天" if days_ago == 0 else f"{days_ago} 天前"
    else:
        age_label = "时间未知"
    return f"[{age_label}] {s.title} ({s.url})"

效果:错误率从 40% 降到 ~10%。但还是有漏网的——LLM 偶尔会把「3 天前」描述成「今天发布」,因为它觉得说「今天」更有新闻感。

第三层:post-LLM 正则校验

我在 editor 输出 markdown 之后加了一轮 regex 校验:

# scripts/pipeline/editor.py
TIME_STUTTER_RE = re.compile(r"过去\s*(\d+)\s*天前")
INCONSISTENCY_RE = re.compile(
    r"(###\s+[^\n]*今天发布[^\n]*\n[\s\S]*?)"
    r"((\d+)\s*天前|(?:in|over)\s+the\s+past\s+(\d+)\s+days?)",
)

def _fix_time_stutter(md: str) -> str:
    # 「过去 3 天前」→「3 天前」
    return TIME_STUTTER_RE.sub(r"\1 天前", md)

def _validate_time_consistency(md: str) -> str:
    # 标题写「今天发布」但 body 有「N 天前」→ 降级到「最近发布」
    def replacer(m):
        return m.group(1).replace("今天发布", "最近发布") + m.group(2)
    return INCONSISTENCY_RE.sub(replacer, md)

跑完这层,错误率降到几乎为零。

这件事让我明确了一个原则:LLM 幻觉不能只靠 prompt 治,要数据层 + prompt + 后置校验三层。每层兜底一个概率。只靠 prompt 就像只靠 frontend 做表单校验——能跑,但随时会翻车。

对应代码在 scripts/pipeline/editor.py_fix_time_stutter_validate_time_consistency,有兴趣可以去 github.com/TangSY/dail… 自己看。

3.2 邮件 RFC 8058 一键退订

发邮件的坑比想象多。

Gmail 和 Yahoo 在 2024 年 2 月强推了一条规则:发件人必须支持 RFC 8058 的 One-Click Unsubscribe,否则批量邮件直接进垃圾箱

具体要求:

  1. 邮件 header 里要有 List-Unsubscribe,指向一个 mailto 和一个 URL。
  2. 还要有 List-Unsubscribe-Post: List-Unsubscribe=One-Click
  3. 收件人点退订时,邮件客户端会不带任何前置确认直接 POST 到你的 URL,你必须在这一次 POST 里处理掉退订,不能跳确认页。

我第一版用 GET 退订(点击链接 → 跳确认页 → 再点一次确认)。结果 Gmail 认为不合规,直接进垃圾箱。

正确做法:

// src/pages/api/unsubscribe.ts
import type { APIRoute } from 'astro';
import { unsubscribeByToken } from '@/lib/db';

export const POST: APIRoute = async ({ request, locals }) => {
  const env = locals.runtime.env;
  const formData = await request.formData();
  const listHeader = formData.get('List-Unsubscribe');
  if (listHeader !== 'One-Click') {
    return new Response('Bad Request', { status: 400 });
  }
  const token = new URL(request.url).searchParams.get('t');
  if (!token) return new Response('Missing token', { status: 400 });
  await unsubscribeByToken(env.DB, token);
  return new Response('Unsubscribed', { status: 200 });
};

// 同时保留 GET 给用户手动点击邮件里的链接
export const GET: APIRoute = async ({ url, locals }) => {
  const token = url.searchParams.get('t');
  if (!token) return new Response('Missing token', { status: 400 });
  await unsubscribeByToken(locals.runtime.env.DB, token);
  return Response.redirect(`${url.origin}/unsubscribed`, 302);
};

发邮件时带上 header:

// src/lib/email.ts
const unsubUrl = `https://dailydawn.dev/api/unsubscribe?t=${token}`;
await resend.emails.send({
  from: 'DailyDawn <daily@dailydawn.dev>',
  to,
  subject,
  html,
  headers: {
    'List-Unsubscribe': `<mailto:unsub@dailydawn.dev>, <${unsubUrl}>`,
    'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
  },
});

这一步漏掉,后面做 deliverability 调优都白搭。再好的 DKIM/SPF 配置也救不回 Gmail 对不合规发件人的拒绝。

3.3 Cloudflare Queues 批处理 + DLQ

第一版我用同步发邮件——收到 webhook 之后循环所有订阅者,挨个调 Resend。当订阅到 50 个的时候,Worker 直接超时(CF Worker 单次 fetch 最多 30 秒)。

换成 Queue:webhook 只负责入队,queue handler 批量消费。

// src/pages/api/webhook/new-report.ts
const subscribers = await listConfirmedSubscribers(env.DB);
for (const sub of subscribers) {
  await env.EMAIL_QUEUE.send({
    type: 'daily-report',
    email: sub.email,
    lang: sub.lang,
    date,
    token: sub.unsubscribe_token,
  });
}
// src/lib/queue.ts
export async function handleEmailQueue(
  batch: MessageBatch<EmailJob>,
  env: Env,
  ctx: ExecutionContext,
) {
  // 同一批里复用 markdown(同 date + lang)
  const cache = new Map<string, string>();
  for (const msg of batch.messages) {
    const { email, lang, date, token } = msg.body;
    const cacheKey = `${lang}:${date}`;
    let html = cache.get(cacheKey);
    if (!html) {
      html = await renderReportHtml(lang, date);
      cache.set(cacheKey, html);
    }
    try {
      await sendEmail(env, { to: email, html, token, lang, date });
      msg.ack();
    } catch (err) {
      msg.retry({ delaySeconds: 60 });
    }
  }
}

wrangler.toml 里配 DLQ(死信队列),重试 3 次后进 DLQ,我手动介入:

[[queues.consumers]]
queue = "daily-email-queue"
max_batch_size = 50
max_retries = 3
dead_letter_queue = "daily-email-dlq"

同 batch 内 date + lang 一致的 HTML 只渲染一次,50 条消息大概 2-3 次 markdown fetch + render。CPU 时间省 90%。


4. 成本账单

真实数字(基于当前量级):

项目 免费额度 实际用量 费用
Cloudflare Workers 10 万请求/天 <1k/天 $0
Cloudflare D1 500 万读/天 <1 万 $0
Cloudflare Queues 100 万/月 <1 万 $0
Cloudflare Cron 无限 1/天 $0
GitHub Actions 2000 min/月 ~150 min $0
Resend 3000 封/月 <3000 $0
LLM API - ~100 万 token/天 $1.5
域名 .dev - 年付 $12 ~$1
合计 ~$2.5/月

LLM 那一栏最大头。我选的是成本便宜一个量级的开源模型(GPT-4o 级别会翻 10 倍),写日报的质量跑过 200+ 天,对中文内容生成完全够用,英文版语感略弱但可接受。这一栏你想换模型自己替换 LLM_BASE_URLLLM_MODEL 两个环境变量即可,pipeline 用的是兼容 OpenAI chat completion 的接口。

订阅突破 3000 后 Resend 要升级 20/月,其他还是免费。预计订阅破万之前整体成本都压在20/月,其他还是免费。预计订阅破万之前整体成本都压在 25 以内。


不管你是独立开发者、AI 从业者、还是只想每天 5 分钟看完一天的 AI 信号:

  • 订阅dailydawn.dev,双重确认 + 一键退订 + 中英双语 + 永久免费
  • Star 仓库github.com/TangSY/dail…,代码完全开源。
  • 提 issue:信号源想加什么、日报格式想怎么改。

前端重生之 - 前端视角下的 Python

作者 老王以为
2026年4月20日 17:21

以前我认为 JavaScript 就是编程世界的全部。从 jQuery 时代的 DOM 操作,到 React/Vue 的组件化革命,再到 TypeScript 的类型安全,见证了前端技术的每一次跃迁。然而,AI 时代来临,人人都在喊转 “全栈“,所以我也开始真正深入 Python 的生态系统,才发现这不仅是两门语言的对话,更是两种编程哲学、两种技术文化的碰撞与融合。这篇文章,是我从前端视角重新审视 Python 的记录,也是我对技术本质的一次探索,接下来我还将从前端视角看 Java、Go、C# 等不同的后端的语言,可能会有错误的地方,欢迎指正,也欢迎关注我,后期还将有分析其他语言的文章,奥利给!


从 JS 的异步到 Python 的同步

1.1 事件循环的底层机制

前端对事件循环(Event Loop)的理解,往往始于浏览器中的 setTimeoutPromise。JavaScript 的单线程异步模型,是为了应对浏览器环境中用户交互、网络请求等 I/O 密集型场景而设计的。我们习惯了回调地狱的煎熬,也享受过 async/await 带来的语法糖甜蜜。但很少有人深入思考:为什么 JavaScript 必须是单线程的?这个设计选择背后的权衡是什么?

JavaScript 诞生于浏览器环境,而浏览器的核心职责是渲染页面和响应用户交互。如果 JavaScript 是多线程的,一个线程正在修改 DOM,另一个线程同时也在修改同一个 DOM 节点,就会产生竞争条件(Race Condition),导致不可预测的行为。为了避免这种复杂性,JavaScript 的设计者选择了单线程模型,并通过事件循环来实现异步非阻塞 I/O。

浏览器的事件循环可以简化为以下伪代码:

while (true) {
    // 1. 执行宏任务队列中的一个任务
    const macroTask = macroTaskQueue.shift();
    if (macroTask) execute(macroTask);
    
    // 2. 执行所有微任务
    while (microTaskQueue.length > 0) {
        const microTask = microTaskQueue.shift();
        execute(microTask);
    }
    
    // 3. 渲染(如果需要)
    if (shouldRender) render();
}

这个模型保证了 JavaScript 的执行顺序是可预测的:宏任务 → 微任务 → 渲染。Promise 的回调之所以比 setTimeout 先执行,就是因为它们被放入了微任务队列。

然而,当我第一次接触 Python 的 asyncio 时,一种奇妙的熟悉感与陌生感同时涌现。Python 的协程机制与 JavaScript 的 Promise 有着惊人的相似性,但底层哲学却截然不同。

1.2 Python asyncio 的设计哲学

Python 的 asyncio 是在 Python 3.4 中引入的,在 3.5 中通过 async/await 语法得到大幅改进。与 JavaScript 不同,Python 并不是天生单线程的——它有多线程(threading 模块)和多进程(multiprocessing 模块)的完整支持。asyncio 是 Python 对协程(Coroutine)这一并发模型的选择,而不是被迫的设计。

让我们深入对比两者的实现:

# Python asyncio 示例
import asyncio

async def fetch_data(url):
    print(f"开始请求: {url}")
    await asyncio.sleep(1)  # 模拟网络请求
    print(f"请求完成: {url}")
    return f"数据来自 {url}"

async def main():
    # 并发执行多个任务
    tasks = [
        fetch_data("https://api.example.com/1"),
        fetch_data("https://api.example.com/2"),
        fetch_data("https://api.example.com/3")
    ]
    results = await asyncio.gather(*tasks)
    print(results)

asyncio.run(main())
// JavaScript 对比实现
async function fetchData(url) {
    console.log(`开始请求: ${url}`);
    await new Promise(resolve => setTimeout(resolve, 1000));
    console.log(`请求完成: ${url}`);
    return `数据来自 ${url}`;
}

async function main() {
    const tasks = [
        fetchData("https://api.example.com/1"),
        fetchData("https://api.example.com/2"),
        fetchData("https://api.example.com/3")
    ];
    const results = await Promise.all(tasks);
    console.log(results);
}

main();

表面看两者几乎相同,但底层实现有本质区别:

特性 JavaScript Python
事件循环 浏览器/Node 内置,不可替换 asyncio 库实现,可自定义
协程实现 基于 Promise 和微任务队列 基于生成器(Generator)和事件循环
线程模型 单线程 + 事件循环 多线程/多进程 + 可选的协程
GIL 影响 无(天生单线程) 有(多线程受 GIL 限制)
并发性能 适合 I/O 密集型 适合 I/O 密集型,CPU 密集型需用多进程

1.3 GIL:Python 的"阿喀琉斯之踵"

谈到 Python 的并发,就不能不提 GIL(Global Interpreter Lock,全局解释器锁)。GIL 是 CPython 实现中的一个机制,它确保任何时候只有一个线程在执行 Python 字节码。这意味着,即使在多核 CPU 上,Python 的多线程也无法实现真正的并行计算。

import threading
import time

def cpu_bound_task(n):
    """CPU 密集型任务"""
    count = 0
    for i in range(n):
        count += i * i
    return count

# 多线程版本(受 GIL 限制)
def multi_threaded():
    threads = []
    for _ in range(4):
        t = threading.Thread(target=cpu_bound_task, args=(10_000_000,))
        threads.append(t)
        t.start()
    for t in threads:
        t.join()

# 多进程版本(绕过 GIL)
from multiprocessing import Process

def multi_process():
    processes = []
    for _ in range(4):
        p = Process(target=cpu_bound_task, args=(10_000_000,))
        processes.append(p)
        p.start()
    for p in processes:
        p.join()

# 性能对比
start = time.time()
multi_threaded()
print(f"多线程耗时: {time.time() - start:.2f}秒")

start = time.time()
multi_process()
print(f"多进程耗时: {time.time() - start:.2f}秒")

在我的测试环境中(4 核 CPU),多线程版本耗时约 12 秒,而多进程版本仅需 3 秒。这就是 GIL 的影响——多线程在 CPU 密集型任务上无法发挥多核优势。

JavaScript 没有 GIL 的问题,因为它天生就是单线程的。但这也意味着 JavaScript 无法利用多核 CPU 进行并行计算——除非使用 Worker Threads(Node.js)或 Web Workers(浏览器),但这些机制与主线程是隔离的,通信成本较高。

1.4 编程范式的思维转换

JavaScript 是一门多范式语言,但前端开发中函数式编程的影子无处不在:mapfilterreduce 成为日常,Immutable.js 和 Ramda 这样的库广受欢迎。我们追求纯函数、避免副作用、崇尚不可变性。这种趋势在 React 的函数组件和 Hooks 中达到顶峰。

// React 函数组件 + Hooks(函数式风格)
import React, { useState, useEffect } from 'react';

function UserList({ users }) {
    const [filteredUsers, setFilteredUsers] = useState([]);
    
    useEffect(() => {
        const activeUsers = users
            .filter(u => u.isActive)
            .map(u => ({ ...u, name: u.name.toUpperCase() }));
        setFilteredUsers(activeUsers);
    }, [users]);
    
    return (
        <ul>
            {filteredUsers.map(u => <li key={u.id}>{u.name}</li>)}
        </ul>
    );
}

Python 则是一门 "batteries included" 的语言,它拥抱多种范式却从不偏执。在 Python 中,你可以写出优雅的函数式代码:

# Python 函数式风格
users = [
    {"id": 1, "name": "Alice", "is_active": True},
    {"id": 2, "name": "Bob", "is_active": False},
    {"id": 3, "name": "Charlie", "is_active": True}
]

# 函数式写法
filtered_users = list(
    map(
        lambda u: {**u, "name": u["name"].upper()},
        filter(lambda u: u["is_active"], users)
    )
)

# 但更 Pythonic 的方式是列表推导式
filtered_users = [
    {**u, "name": u["name"].upper()} 
    for u in users 
    if u["is_active"]
]

这种"列表推导式"的语法,是 Python 对函数式编程的本土化改造。它既保留了函数式的表达能力,又符合 Python 简洁优雅的设计哲学。

Python 还支持面向对象和命令式编程:

# Python 面向对象风格
class User:
    def __init__(self, id, name, is_active):
        self.id = id
        self.name = name
        self.is_active = is_active
    
    def activate(self):
        self.is_active = True
    
    def __repr__(self):
        return f"User({self.name})"

# 使用类
users = [User(1, "Alice", True), User(2, "Bob", False)]
for user in users:
    if not user.is_active:
        user.activate()

这让我反思:前端开发中是否过度追求函数式的"纯粹",而忽略了实用主义的平衡?React 的类组件被函数组件取代,但类组件在某些场景下(如复杂的生命周期管理)仍然有其优势。Python 的多范式支持提醒我们:没有最好的范式,只有最适合场景的范式。


类型系统——从动态到静态的考虑

2.1 TypeScript 的革命

2012 年,TypeScript 的诞生改变了前端开发的格局。作为 JavaScript 的超集,TypeScript 为动态语言带来了静态类型的严谨。今天,几乎所有大型前端项目都采用 TypeScript,类型安全已成为行业标准。

TypeScript 的成功不是偶然的。它解决了 JavaScript 开发中的几个核心痛点:

  1. 运行时错误前置:在编译阶段发现类型错误,而不是在生产环境崩溃
  2. IDE 支持:智能提示、自动补全、重构支持
  3. 文档即代码:类型定义就是最好的 API 文档
  4. 团队协作:类型约束作为团队间的契约
// TypeScript 示例
interface User {
    id: number;
    name: string;
    email?: string;  // 可选属性
}

function greet(user: User): string {
    return `Hello, ${user.name}`;
}

// 编译错误:类型不匹配
const result = greet({ id: "1", name: "Alice" });  // Error: id 应该是 number

2.2 Python 类型注解的演进

有趣的是,Python 的类型注解(Type Hints)几乎是与 TypeScript 同期发展的。PEP 484 在 2014 年引入类型注解,PEP 526 在 2016 年完善变量注解。两条平行线,却走向了相似的终点。

from typing import Optional, List, Dict

class User:
    def __init__(self, id: int, name: str, email: Optional[str] = None):
        self.id = id
        self.name = name
        self.email = email

def greet(user: User) -> str:
    return f"Hello, {user.name}"

# 类型检查工具(如 mypy)会在静态分析时报告错误
user = User(id="1", name="Alice")  # mypy: Argument "id" has incompatible type "str"; expected "int"

然而,TypeScript 和 Python 类型系统的底层哲学存在本质差异:

2.2.1 编译时 vs 运行时

TypeScript 的类型在编译时完全擦除,编译后的 JavaScript 不包含任何类型信息:

// TypeScript 源码
function add(a: number, b: number): number {
    return a + b;
}

// 编译后的 JavaScript
function add(a, b) {
    return a + b;
}

Python 的类型注解在运行时保留,但解释器不做强制检查:

# Python 源码
def add(a: int, b: int) -> int:
    return a + b

# 运行时可以通过 __annotations__ 访问类型信息
print(add.__annotations__)  # {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

# 但解释器不会检查类型
result = add("hello", "world")  # 正常运行,返回 "helloworld"

2.2.2 结构类型 vs 名义类型

TypeScript 采用结构类型系统(Structural Typing),也称为"鸭子类型"(Duck Typing):

interface Point {
    x: number;
    y: number;
}

function printPoint(p: Point) {
    console.log(`${p.x}, ${p.y}`);
}

// 只要结构匹配,就可以传递
printPoint({ x: 1, y: 2 });  // OK
printPoint({ x: 1, y: 2, z: 3 });  // OK(多余属性允许)

Python 的类型检查器(如 mypy)同样支持结构类型,通过 Protocol(PEP 544):

from typing import Protocol

class Point(Protocol):
    x: int
    y: int

def print_point(p: Point) -> None:
    print(f"{p.x}, {p.y}")

class MyPoint:
    def __init__(self, x: int, y: int):
        self.x = x
        self.y = y

# MyPoint 没有显式继承 Point,但结构匹配即可
print_point(MyPoint(1, 2))  # OK

2.2.3 类型推断

TypeScript 的类型推断更为激进:

// TypeScript 能推断出 arr 是 number[]
const arr = [1, 2, 3];

// 能推断出 result 是 number
const result = arr.map(x => x * 2).filter(x => x > 2);

Python 的类型推断相对保守,需要显式注解:

from typing import List

# Python 需要显式类型注解
arr: List[int] = [1, 2, 3]

# 或者让 mypy 推断(有限支持)
result = [x * 2 for x in arr if x > 2]  # mypy 能推断为 List[int]

2.3 渐进式类型的价值

这种对比从侧面来说:类型系统的价值不在于"正确性"本身,而在于它如何帮助团队协作和代码演进。Python 的渐进式类型(Gradual Typing)策略——允许在需要时添加类型,在灵活时保持动态——或许比 TypeScript 的"全有或全无"更加务实。

# Python 渐进式类型示例
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from models import User  # 仅在类型检查时导入

def process_user(user):  # 动态类型,灵活
    return user.name.upper()

def process_user_typed(user: "User") -> str:  # 静态类型,安全
    return user.name.upper()

在实际的开发中,我通常采用以下策略:

  • 公共 API 和核心模块使用完整的类型注解
  • 脚本和原型代码保持动态类型,快速迭代
  • 使用 mypy 在 CI 中检查关键模块的类型安全

生态系统

3.1 npm 与 PyPI:包管理的两种哲学

前端对于 npm 生态的复杂情感,可以用一句话概括:"node_modules 是世界上最重的东西"。JavaScript 的微包文化(left-pad 事件)和依赖,是每个前端的心头痛。

让我们看看一个典型的 React 项目的依赖树:

$ npm list | wc -l
# 输出可能超过 1000 行

$ du -sh node_modules
# 输出可能超过 500MB

这种依赖膨胀的原因是多方面的:

  1. 微包文化:JavaScript 生态倾向于将功能拆分为极小的包,一个左填充函数(left-pad)也能成为一个包
  2. 重复依赖:不同版本的同一个库可能同时存在
  3. 开发依赖混杂:构建工具、测试框架、类型定义都混在一起

2016 年的微包事件是一个标志性案例。一个只有 11 行代码的包被作者从 npm 下架,导致全球数千个项目无法构建。这暴露了微包文化的脆弱性。

Python 的包管理生态则呈现出不同的面貌。pip、conda、poetry、pipenv …… 工具超级多,但核心理念一致:显式优于隐式

# Python 的 requirements.txt
requests==2.28.1
numpy>=1.21.0
pandas~=1.5.0

这个文件明确告诉我们:

  • requests 必须严格等于 2.28.1 版本
  • numpy 可以是 1.21.0 或更高版本
  • pandas 可以是 1.5.x 系列(补丁版本可以变)

这种显式依赖管理的文化,让 Python 项目的可重现性远超 JavaScript。当你克隆一个 Python 项目,你知道需要安装什么;而当你克隆一个 Node.js 项目,node_modules 的深渊往往让人望而却步。

3.2 虚拟环境:Python 的隔离艺术

Python 的虚拟环境(virtualenv/venv)是包管理的另一大特色。每个项目可以有独立的 Python 环境和依赖,互不干扰。

# 创建虚拟环境
python -m venv myproject-env

# 激活虚拟环境
source myproject-env/bin/activate  # Linux/Mac
myproject-env\Scripts\activate  # Windows

# 安装依赖
pip install -r requirements.txt

# 退出虚拟环境
deactivate

这与 Node.js 的 node_modules 本地安装有相似之处,但更加彻底——虚拟环境甚至隔离了 Python 解释器本身。

现在 Python 项目更倾向于使用 pyproject.toml(PEP 518)来管理依赖:

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "my-project"
version = "0.1.0"
description = "A sample project"

[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.28"
pydantic = "^1.10"

[tool.poetry.dev-dependencies]
pytest = "^7.0"
black = "^22.0"
mypy = "^0.991"

Poetry 不仅管理依赖,还管理虚拟环境、打包、发布,是一个完整的项目管理工具。这与 JavaScript 生态中 npm/yarn/pnpm 的竞争格局形成鲜明对比。

3.3 标准库的力量

Python 的 "batteries included" 哲学,在标准库中体现得淋漓尽致。从文件处理到网络编程,从正则表达式到 JSON 解析,从单元测试到并发编程——Python 标准库几乎覆盖了一个开发者 80% 的日常需求。

# Python 标准库示例
import json
import re
import urllib.request
from datetime import datetime, timedelta
from pathlib import Path
from unittest import TestCase, main

# JSON 处理
data = {"name": "Alice", "age": 30}
json_str = json.dumps(data, indent=2)

# 正则表达式
pattern = r"\b\w+@\w+\.\w+\b"
emails = re.findall(pattern, "Contact: alice@example.com, bob@test.org")

# 文件路径操作
config_path = Path.home() / ".config" / "myapp" / "settings.json"

# 日期时间
now = datetime.now()
future = now + timedelta(days=7)

# HTTP 请求
with urllib.request.urlopen("https://api.example.com/data") as response:
    data = json.loads(response.read())

相比之下,JavaScript 的标准库堪称贫瘠。直到 ES6 引入 Promise、fetch、模块化,JavaScript 才勉强跟上时代。但即便如此,lodash、axios、moment 依然是大多数项目的标配。

这种差异的根源在于语言的设计目标:

  • JavaScript 诞生于浏览器,被设计为轻量级脚本语言,依赖浏览器提供的 DOM API
  • Python 诞生于通用编程,被设计为"可执行的伪代码",需要在各种环境中独立运行

标准库的丰富程度,反映的是语言设计者对"开箱即用"的不同理解。

3.4 生态系统的成熟度对比

维度 JavaScript/npm Python/PyPI
包数量 200万+ 40万+
包平均大小 小(微包文化) 大(功能完整)
依赖管理 嵌套依赖(node_modules) 扁平依赖 + 虚拟环境
标准库 贫瘠 丰富("batteries included")
类型定义 @types/* 包 内置类型注解
安全审计 npm audit safety, pip-audit
私有仓库 Verdaccio, Nexus PyPI Enterprise, Devpi

数据科学的疆域的追赶

4.1 Python 的数据霸权

如果说前端是 JavaScript 的天下,那么数据科学就是 Python 的帝国。NumPy、Pandas、Matplotlib、Scikit-learn、TensorFlow、PyTorch——这些库构成了数据科学的完整工具链,而 Python 是它们的通用语言。

这种霸权不是偶然的。Python 的简洁语法、丰富的科学计算库、与 C/C++/Fortran 的良好互操作性,使其成为数据科学家的首选语言。

4.1.1 NumPy:向量化计算的威力

NumPy 是 Python 科学计算的基础。它提供了高效的多维数组对象和数学函数库,底层使用 C 实现,性能远超纯 Python。

import numpy as np

# 创建数组
arr = np.array([1, 2, 3, 4, 5])

# 向量化运算——比 Python 循环快 100 倍
result = arr * 2 + 1  # [3, 5, 7, 9, 11]

# 多维数组
matrix = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

# 矩阵运算
transposed = matrix.T
dot_product = matrix @ matrix.T

# 统计函数
mean = np.mean(arr)
std = np.std(arr)
max_val = np.max(matrix, axis=0)  # 每列的最大值

让我们做一个性能对比:

import time

# Python 原生列表
python_list = list(range(1_000_000))

start = time.time()
result = [x * 2 for x in python_list]
print(f"Python 列表推导式: {time.time() - start:.4f}秒")

# NumPy 数组
numpy_array = np.array(python_list)

start = time.time()
result = numpy_array * 2
print(f"NumPy 向量化运算: {time.time() - start:.4f}秒")

在我的电脑上,Python 列表推导式耗时约 0.08 秒,而 NumPy 仅需 0.001 秒——80 倍的性能差距!这是因为 NumPy 的运算是在 C 层面执行的,避免了 Python 的解释器开销。

4.1.2 Pandas:数据处理的艺术

如果说 NumPy 是数组计算的利器,Pandas 就是数据处理的瑞士军刀。它提供了 DataFrame 和 Series 两种数据结构,让数据清洗、转换、分析变得异常简单。

import pandas as pd

# 读取数据
df = pd.read_csv('sales_data.csv')

# 数据清洗
df = df.dropna()  # 删除缺失值
df = df[df['price'] > 0]  # 过滤异常值
df['date'] = pd.to_datetime(df['date'])  # 类型转换

# 数据转换
df['revenue'] = df['price'] * df['quantity']
df['month'] = df['date'].dt.month

# 分组聚合
monthly_sales = df.groupby('month').agg({
    'revenue': 'sum',
    'quantity': 'mean'
}).reset_index()

# 透视表
pivot = df.pivot_table(
    values='revenue',
    index='category',
    columns='month',
    aggfunc='sum'
)

# 合并数据
merged = pd.merge(df, customer_df, on='customer_id', how='left')

这段代码如果用 JavaScript 实现,需要多少行?lodash 可以处理数组,但没有原生的 DataFrame 概念。D3.js 可以做数据转换,但学习曲线陡峭。Pandas 的链式操作让复杂的数据处理变得可读、可维护。

4.1.3 数据可视化

Matplotlib 和 Seaborn 是 Python 数据可视化的主力军:

import matplotlib.pyplot as plt
import seaborn as sns

# 设置样式
sns.set_style("whitegrid")

# 创建图表
fig, axes = plt.subplots(2, 2, figsize=(12, 10))

# 折线图
df.groupby('date')['revenue'].sum().plot(ax=axes[0, 0], title='Daily Revenue')

# 柱状图
df['category'].value_counts().plot(kind='bar', ax=axes[0, 1], title='Category Distribution')

# 散点图
axes[1, 0].scatter(df['price'], df['quantity'], alpha=0.5)
axes[1, 0].set_title('Price vs Quantity')

# 热力图
corr = df[['price', 'quantity', 'revenue']].corr()
sns.heatmap(corr, annot=True, ax=axes[1, 1], title='Correlation Matrix')

plt.tight_layout()
plt.savefig('analysis.png', dpi=300)

前端可能会说:"这些用 D3.js 也能做,而且交互性更强。"没错,D3.js 的交互能力是 Matplotlib 无法比拟的。但 Matplotlib 的优势在于快速探索和静态报告——数据分析不需要为每个图表写 200 行 D3 代码。

4.2 前端的数据觉醒

幸运的是,前端世界正在觉醒。TensorFlow.js、ONNX.js、Apache Arrow JS——这些项目正在把数据科学的能力带入浏览器。

4.2.1 TensorFlow.js

TensorFlow.js 让机器学习模型可以在浏览器中运行:

import * as tf from '@tensorflow/tfjs';

// 创建一个简单的神经网络
const model = tf.sequential({
    layers: [
        tf.layers.dense({ inputShape: [784], units: 32, activation: 'relu' }),
        tf.layers.dense({ units: 10, activation: 'softmax' })
    ]
});

model.compile({
    optimizer: 'adam',
    loss: 'categoricalCrossentropy',
    metrics: ['accuracy']
});

// 训练模型(在浏览器中)
await model.fit(xs, ys, {
    epochs: 10,
    batchSize: 32,
    callbacks: {
        onEpochEnd: (epoch, logs) => {
            console.log(`Epoch ${epoch}: loss = ${logs.loss}`);
        }
    }
});

// 预测
const prediction = model.predict(newImage);

这意味着前端可以在用户设备上运行机器学习模型,无需服务器参与。隐私保护、低延迟、离线可用——这些是服务端推理无法比拟的优势。

4.2.2 Apache Arrow JS

Apache Arrow 是一种跨语言的列式内存格式,Arrow JS 让 JavaScript 可以高效地处理大规模数据:

import { Table, FloatVector } from 'apache-arrow';

// 创建 Arrow 表
const table = Table.new(
    [
        FloatVector.from([1, 2, 3, 4, 5]),
        FloatVector.from([10, 20, 30, 40, 50])
    ],
    ['x', 'y']
);

// 高效查询
const sum = table.getColumn('y').toArray().reduce((a, b) => a + b, 0);

Arrow 的列式存储格式让数据在 Python、JavaScript、R、Julia 之间零拷贝传输成为可能。这对于前后端数据交互是一个革命性的改进。

4.3 前端的数据科学学习路径

但更深层的思考是:前端是否应该掌握数据科学的能力?

我的答案是肯定的。现代前端不再是简单的页面展示,而是数据驱动的交互应用。理解数据处理、理解机器学习的基本原理,将成为高级前端的必备技能。

Web 开发的殊途同归

5.1 Django vs Express:两种架构哲学

Django 是 Python Web 开发的旗舰框架,它的哲学是"约定优于配置"。ORM、表单处理、认证系统、管理后台——Django 提供了一站式解决方案。这种"全功能框架"的思路,让开发者可以快速搭建复杂的 Web 应用。

# Django 模型定义
from django.db import models
from django.contrib.auth.models import User

class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        ordering = ['-created_at']

# Django 视图
from django.shortcuts import render, get_object_or_404
from rest_framework import viewsets
from rest_framework.decorators import action

class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    
    @action(detail=True, methods=['post'])
    def publish(self, request, pk=None):
        article = self.get_object()
        article.publish()
        return Response({'status': 'published'})

Django 的优势在于:

  • 快速开发:内置的 admin 界面让 CRUD 操作无需额外代码
  • 安全性:内置 CSRF 保护、SQL 注入防护、XSS 过滤
  • 可扩展性:丰富的第三方应用生态

但 Django 也有其局限性:

  • 灵活性不足:Django 的"全功能"意味着你必须按照它的方式做事
  • 学习曲线陡峭:需要理解 ORM、视图、模板、中间件等多个概念
  • 性能开销:大而全的框架必然带来性能损耗

Express.js 则是 Node.js 世界的微框架代表:

const express = require('express');
const mongoose = require('mongoose');

const app = express();
app.use(express.json());

// 模型定义(使用 Mongoose)
const articleSchema = new mongoose.Schema({
    title: String,
    content: String,
    author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
    createdAt: { type: Date, default: Date.now }
});

const Article = mongoose.model('Article', articleSchema);

// 路由
app.get('/api/articles', async (req, res) => {
    const articles = await Article.find().sort({ createdAt: -1 });
    res.json(articles);
});

app.post('/api/articles', async (req, res) => {
    const article = new Article(req.body);
    await article.save();
    res.status(201).json(article);
});

app.listen(3000);

Express 的优势在于:

  • 灵活性:只提供基础功能,其他由你选择
  • 学习曲线平缓:理解中间件概念后即可上手
  • 性能:轻量级框架,开销小

但 Express 的灵活性也带来了问题:

  • 选择困难症:ORM 用 Sequelize、TypeORM 还是 Prisma?验证用 Joi、Yup 还是 class-validator?
  • 项目结构不一致:每个 Express 项目的结构都可能不同
  • 重复造轮子:很多功能需要自己实现或选择第三方库
维度 Django Express.js
架构风格 全功能框架(" batteries included ") 微框架
ORM 内置 Django ORM 需额外选择(Sequelize/TypeORM/Prisma)
认证授权 内置 需额外实现(Passport.js 等)
管理后台 内置 admin
学习曲线 陡峭 平缓
灵活性 较低
适用场景 大型项目、快速原型 中小型项目、API 服务
性能 中等

5.2 FastAPI:Python 的现代答案

如果说 Django 是 Python 的 Spring,那么 FastAPI 就是 Python 的 NestJS。FastAPI 采用声明式编程、依赖注入、类型注解,它的设计哲学与 TypeScript 生态高度契合。

from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
from typing import List, Optional
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session

# 数据库设置
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

# 数据库模型
class UserDB(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True, index=True)
    name = Column(String, index=True)
    email = Column(String, unique=True, index=True)

# Pydantic 模型(用于 API 验证)
class UserBase(BaseModel):
    name: str
    email: str

class UserCreate(UserBase):
    pass

class User(UserBase):
    id: int
    
    class Config:
        orm_mode = True

# FastAPI 应用
app = FastAPI(title="User API", version="1.0.0")

# 依赖注入:数据库会话
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

# 路由
@app.post("/users/", response_model=User)
def create_user(user: UserCreate, db: Session = Depends(get_db)):
    db_user = UserDB(**user.dict())
    db.add(db_user)
    db.commit()
    db.refresh(db_user)
    return db_user

@app.get("/users/", response_model=List[User])
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
    users = db.query(UserDB).offset(skip).limit(limit).all()
    return users

@app.get("/users/{user_id}", response_model=User)
def read_user(user_id: int, db: Session = Depends(get_db)):
    user = db.query(UserDB).filter(UserDB.id == user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    return user

这段代码的优雅让我惊叹:

  1. 类型安全:Pydantic 模型自动验证请求和响应
  2. 自动文档:访问 /docs 即可获得 Swagger UI 文档
  3. 异步支持:原生支持 async/await
  4. 依赖注入Depends 让代码解耦、可测试

对比 TypeScript 的 NestJS:

// NestJS 对比
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

class CreateUserDto {
    name: string;
    email: string;
}

@Controller('users')
export class UserController {
    constructor(
        @InjectRepository(User)
        private userRepository: Repository<User>
    ) {}
    
    @Post()
    async create(@Body() createUserDto: CreateUserDto) {
        const user = this.userRepository.create(createUserDto);
        return this.userRepository.save(user);
    }
    
    @Get()
    async findAll() {
        return this.userRepository.find();
    }
}

FastAPI 和 NestJS 的设计如此相似——装饰器路由、依赖注入、DTO 验证、ORM 集成。这或许预示着 Web 开发的未来:语言的边界正在模糊,好的设计理念会被跨语言借鉴。

5.3 性能对比:Node.js vs Python

让我们做一个简单的性能测试,对比 Node.js 和 Python 处理 HTTP 请求的能力:

Node.js (Express)

const express = require('express');
const app = express();

app.get('/api/data', (req, res) => {
    // 模拟数据库查询
    const data = Array.from({ length: 1000 }, (_, i) => ({
        id: i,
        value: Math.random()
    }));
    res.json(data);
});

app.listen(3000);

Python (FastAPI)

from fastapi import FastAPI
import random

app = FastAPI()

@app.get("/api/data")
async def get_data():
    data = [
        {"id": i, "value": random.random()}
        for i in range(1000)
    ]
    return data

# 使用 uvicorn 运行:uvicorn main:app --workers 4

使用 wrk 进行压力测试:

# Node.js
wrk -t12 -c400 -d30s http://localhost:3000/api/data
# Requests/sec:  15000

# Python (单 worker)
wrk -t12 -c400 -d30s http://localhost:8000/api/data
# Requests/sec:   8000

# Python (4 workers)
wrk -t12 -c400 -d30s http://localhost:8000/api/data
# Requests/sec:  25000

结果挺让人惊讶的:在单 worker 模式下,Node.js 的性能是 Python 的 2 倍。但当 Python 使用多 worker(利用多核 CPU)时,性能反超 Node.js。这说明:

  1. 单线程性能:Node.js 的 V8 引擎优于 Python 的解释器
  2. 多核利用:Python 的多进程模型可以充分利用多核 CPU
  3. 场景选择:I/O 密集型任务两者差距不大,CPU 密集型任务需要多进程

融会贯通

6.1 语言只是工具,思维才是核心

深入 Python 之后,我越来越确信一个观点:编程语言的差异,远不如编程思维的差异重要。无论是 JavaScript 还是 Python,优秀的代码都遵循相同的原则:

6.1.1 SOLID 原则

单一职责原则(Single Responsibility Principle)

# 不好的设计:一个类做太多事
class UserManager:
    def create_user(self, data): ...
    def send_email(self, user): ...
    def generate_report(self): ...

# 好的设计:职责分离
class UserService:
    def create_user(self, data): ...

class EmailService:
    def send_email(self, user): ...

class ReportService:
    def generate_report(self): ...

开闭原则(Open/Closed Principle)

from abc import ABC, abstractmethod

# 抽象基类
class PaymentProcessor(ABC):
    @abstractmethod
    def process(self, amount: float) -> bool:
        pass

# 具体实现
class AlipayProcessor(PaymentProcessor):
    def process(self, amount: float) -> bool:
        # 支付宝支付逻辑
        return True

class WechatProcessor(PaymentProcessor):
    def process(self, amount: float) -> bool:
        # 微信支付逻辑
        return True

# 使用
class PaymentService:
    def __init__(self, processor: PaymentProcessor):
        self.processor = processor
    
    def pay(self, amount: float) -> bool:
        return self.processor.process(amount)

# 新增支付方式无需修改现有代码
class StripeProcessor(PaymentProcessor):
    def process(self, amount: float) -> bool:
        return True

6.1.2 设计模式

设计模式是跨语言的。无论是 JavaScript 还是 Python,观察者模式、工厂模式、策略模式等都有相似的实现:

# Python 观察者模式
from typing import List, Callable

class EventEmitter:
    def __init__(self):
        self._listeners: dict[str, List[Callable]] = {}
    
    def on(self, event: str, callback: Callable):
        if event not in self._listeners:
            self._listeners[event] = []
        self._listeners[event].append(callback)
    
    def emit(self, event: str, *args, **kwargs):
        for callback in self._listeners.get(event, []):
            callback(*args, **kwargs)

# 使用
emitter = EventEmitter()
emitter.on('user_created', lambda user: print(f"User created: {user}"))
emitter.emit('user_created', {'name': 'Alice'})
// JavaScript 观察者模式(几乎相同)
class EventEmitter {
    constructor() {
        this.listeners = {};
    }
    
    on(event, callback) {
        if (!this.listeners[event]) {
            this.listeners[event] = [];
        }
        this.listeners[event].push(callback);
    }
    
    emit(event, ...args) {
        (this.listeners[event] || []).forEach(cb => cb(...args));
    }
}

6.1.3 编程思维的培养

掌握多门语言的价值,不在于"技多不压身",而在于从不同视角理解这些原则,形成更全面的技术判断力。

  • JavaScript 教会我:异步编程、函数式思维、事件驱动
  • Python 教会我:简洁优雅、实用主义、科学计算
  • TypeScript 教会我:类型安全、接口设计、静态分析

6.2 未来的融合趋势

技术发展的趋势是融合而非对立。我们看到 Python 和 JavaScript 都在做的事情:

6.2.1 WebAssembly 的崛起

WebAssembly(Wasm)让 Python 可以在浏览器中运行:

# 使用 Pyodide 在浏览器中运行 Python
import micropip
await micropip.install('numpy')

import numpy as np
arr = np.array([1, 2, 3, 4, 5])
result = arr * 2

这意味着前端可以在浏览器中使用 Python 的数据处理能力,而无需服务器参与。

6.2.2 PyScript 的革命

PyScript 让 Python 可以直接嵌入 HTML:

<!DOCTYPE html>
<html>
<head>
    <link rel="stylesheet" href="https://pyscript.net/latest/pyscript.css" />
    <script defer src="https://pyscript.net/latest/pyscript.js"></script>
</head>
<body>
    <div id="output"></div>
    
    <py-script>
        from js import document
        import numpy as np
        
        arr = np.array([1, 2, 3, 4, 5])
        result = arr * 2
        
        output = document.getElementById('output')
        output.innerHTML = f"Result: {result.tolist()}"
    </py-script>
</body>
</html>

6.2.3 跨语言借鉴

  • Node.js 的 worker_threads 借鉴了 Python 的多进程模型
  • TypeScript 的类型系统影响了 Python 的类型注解设计
  • Rust 的所有权系统正在影响 JavaScript 和 Python 的内存管理思路

6.2.4 全栈工程师的新定义

未来的工程师,可能不再被"前端"或"后端"的标签所限制。他们会根据场景选择最合适的工具:

  • Python 处理数据、训练模型、编写自动化脚本
  • JavaScript/TypeScript 构建用户界面、实现交互逻辑
  • Rust 编写高性能模块、系统级工具
  • Go 构建微服务、高并发后端

这不是"全栈"的泛化,而是技术能力的深化。真正的技术专家,不是掌握最多语言的人,而是知道何时使用哪门语言的人。


结语

写完这篇文章,我想起了一个古老的比喻:

"如果你手里只有一把锤子,那么所有问题看起来都像钉子。"

JavaScript 是我手中的第一把锤子,它帮助我构建了无数精彩的 Web 应用。从简单的页面交互到复杂的单页应用,从 jQuery 到 React,从回调地狱到 async/await——JavaScript 陪伴我走过了前端技术的每一个阶段。

但 Python 让我看到了另一片天空:

  • 数据科学的深邃:NumPy、Pandas、Scikit-learn 让数据处理变得优雅而高效
  • 自动化的便捷:几行 Python 脚本可以替代 hours of manual work
  • 科学计算的严谨:从物理模拟到金融建模,Python 是科学家的首选语言
  • Web 开发的简洁:FastAPI 的设计哲学让我重新审视"好的代码"的定义

这两门语言不是竞争对手,而是互补的伙伴。

技术的深度,来自于对一门语言的精通;技术的广度,来自于对多门语言的理解。而技术的智慧,来自于知道何时使用哪一门语言,加油,奥利给!

vue2、vue3区别之混入mixins和过滤器filter

作者 RONIN
2026年4月20日 17:17

一、混入mixins

一个包含组件选项的对象数组(可复用),这些选项都将被混入到当前组件的实例中

属性相同时,原组件中的属性会覆盖混入的属性。

vue2多使用

作用:将组件公共的数据方法和生命周期函数提取出来,封装到一个独立对象中,被其它所有组件共享。

实现:

1.MyMixins.js定义混入对象(1.定义混入对象 2.在vue组件中通过mixins选项接收要混入的对象数组 3.使用)

export const mixins1 = {
  data() {
      return {
          message:'这是混入的message'
      }
  },

  methods: {
      plus(){
          console.log('这是混入的plus >>>>')
      }
  },
}

2.App.js引入接收使用

import { mixins1 } from "./mixins/MyMixins.js";

export default {
  mixins: [mixins1],

  data() {
    return {
      title"混入技术",
      vcolor"red",
      message:'这是组件app中message'
    };
  },

  methods: {
    bindUpdateColor() {
      this.vcolor = "blue";
    },
  },

  /*html*/

  template: `<div>
                <h2>{{title}}</h2>
                <p>{{ message }}</p>
                <button @click="plus">确定</button>
            </div>
            `,
};

二、过滤器filter

全局方法,本质是一个函数。

vue2中使用,vue3没有filter过滤器

注册:Vue.filter(过滤器名称,过滤器函数)

调用:  <p>{{  参数|过滤器名称 }}</p>

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>过滤器</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
    <!-- <script src="./vue.js"></script> -->
</head>

<body>
    <div id="app"></div>
    <script>
        const root = {
            el:'#app',
            data: {
                title'过滤器'
            },

            /*html 调用*/
            template:`<div>
                    {{title}}
                    <p>{{ title|msgFilter }}</p>  
                 </div>`
        }

         //注册
         Vue.filter('msgFilter',(t)=>{
            const data =  new Date()
            return data.toLocaleTimeString()
        })

        // 创建vue实例
        new Vue(root)
    </script>
</body>
</html>

TypeScript 类型导入详解:import type 与 import {type}

作者 五号厂房
2026年4月20日 17:11

一、引言

在 TypeScript 项目开发中,类型导入是日常高频操作。import typeimport { type } 是两种主流的类型导入语法,二者看似功能相似,均可实现类型引入,但在语义定义、编译产物、运行时表现以及工程化最佳实践上,存在明显区别。

本文将从语法特性、编译原理、使用场景多维度,全面拆解两种导入方式的差异,帮助开发者规范 TS 类型导入写法。

1.1 基础语法定义

表格

语法格式 核心说明
import type { Foo } from 'module' 全局类型导入,整行语句仅用于引入类型,隔离运行时代码
import { type Foo } from 'module' 局部类型标记,在解构导入中,单独指定某一项为类型

1.2 基础编译差异

TypeScript 仅在编译阶段做类型校验,类型本身不参与运行时。

  • import type:编译器会直接移除整行导入语句,零运行时代码
  • import { type }:支持一行代码混合导入类型与运行时值,仅删除标记为 type 的类型部分,普通值导入会完整保留。
// 纯类型导入,编译后整行删除
import type { Foo } from './types';

// 混合导入,编译后仅保留 Bar 导入
import { type Foo, Bar } from './types';

二、深入解析:编译逻辑与运行时特性

2.1 类型导入核心本质

TS 属于静态类型约束,interface、type、类型别名等仅作用于编译阶段。一旦完成代码编译转换为 JavaScript,所有类型信息都会被清除。因此,合理区分类型导入,能够剔除冗余代码,减少打包体积,规避模块冗余加载问题。

2.2 import type 完整特性

  1. 仅允许导入纯类型:接口、类型别名、枚举类型、类型化类;
  2. 编译后无任何残留代码,完全脱离运行时;
  3. 适用于仅做类型注解、类型约束的场景。
import type { User } from './user';

// 仅用作类型标注,无运行时依赖
function getUser(): User {
  return { id: 1, name: "Alice" };
}

2.3 import {type} 完整特性

  1. 支持类型 + 运行时值混合导入,语法灵活性更高;
  2. 同模块下,可同时引入变量、函数、类等可执行代码与类型;
  3. 编译时按需清除类型导入,保留业务运行依赖。
// 混合导入:User 为类型,fetchUser 为可执行函数
import { type User, fetchUser } from './user';

async function getUser(): Promise<User> {
  return await fetchUser();
}

三、场景划分与最佳实践

3.1 优先使用 import type

当只需要引入类型,无需依赖模块中任何运行时值时,统一使用该语法。单一类型导入语义明确,代码可读性更强,同时杜绝无用依赖。

import type { UserInfo, PageParams } from './typings';

3.2 优先使用 import {type}

当目标模块同时导出类型 + 业务逻辑代码,需要在同一文件同时引入时使用。无需拆分两次 import,精简代码结构。

import { type UserInfo, createUser, deleteUser } from './user';

3.3 解决类型与值命名冲突

部分模块会存在同名类型与常量 / 对象的导出场景,两种语法可精准消除歧义:

// 模块导出内容
export type User = { id: number; name: string };
export const User = { id: 0, name: "默认用户" };

// 精准导入类型
import type { User } from './user';
// 精准导入运行时值
import { User } from './user';

四、总结

  1. import type整行级别的纯类型导入,编译后彻底清空,轻量化、无冗余,适合单纯类型引用;
  2. import { type }细粒度局部类型标记,支持混合导入,适合类型与业务代码共存的模块;
  3. 大型项目中,规范类型导入语法,不仅能提升代码可维护性,还能优化打包性能、降低循环依赖风险;
  4. 合理运用两种语法,是中高级 TypeScript 开发者必备的编码规范,也是高质量工程化项目的基础要求。

属性透传attribute、vue实例对象方法$nextTick()、虚拟dom与浏览器渲染机制

作者 RONIN
2026年4月20日 17:10

一、属性透传attribute

  • 指的是传递给一个组件,但没有通过props或emits接收,常见的如class、style、id
  • 透传的样式会自动被添加到子组件根元素上,如果子组件已经有一个样式,透传的样式会和已有样式合并。
  • 如果透传的是一个点击事件,也会自动被添加到子组件根元素上,如果子组件自身也有点击事件,点击时透传过来的事件和其本身的事件都会触发。
  • 透传的属性可以通过{{$attrs}}拿到
  • 属性透传只会透传到根元素上,如果有多个根节点,vue不知道要透传到哪个节点,需要通过v-bind=”$attrs”绑定到要透传到的那个节点上,否则会抛出警告。
  • 属性透传是可以禁用的,通过inheritAttrs: false可以阻止透传

例:

目录

image.png

1.新建assets样式文件夹,样式文件style.css

.large{
    color: red;
}
.small{
    background-color: pink;
}

2.在index.html中引入样式文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>首页</title>
    <link rel="stylesheet" href="./assets/css/style.css">
</head>

<body>
    <div id="app"></div>
    <script type="module" src="./src/main.js"></script>
</body>
</html>

父组件App.js

import Child from "./Child.js";
export default {
  components: {
    Child,
  },

  data() {
    return {
      title: "attribute属性透传",
      count:0
    };
  },

  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>
                <p>{{count}}</p>
                 <Child class="large" @click="count++" ></Child>
            </div>`,
};

子组件Child.js

export default {
  data() {
    return {
      num10,
    };
  },

  inheritAttrs: true, // 阻止透传

  /*html*/
  template: `
              <button @click="num++" class="small">子按钮{{num}}</button>
              <main v-bind="$attrs">多个根节点</main>
          `,
};


二、vue实例对象方法$nextTick()

可以通过this.$nextTick(()=>{})访问,回调函数会在dom节点渲染完成后执行

如:正常操作dom节点是在mounted生命周期中操作,nextTick()方法可以实现在created生命周期中操作dom节点

export default {
  data() {
    return {
      title"vue实例对象的 $nextTick()",
      count:10
    };
  },

  created() {
      this.$nextTick(()=>{
        //回调函数,模板界面异步更新完成后执行
        const pEle = document.querySelector('#countP')
        console.log('bindPlus >> ',pEle.innerHTML);
      })
  },

  methods: {
    bindPlus(){
      this.count++
      // 验证, count数据变化,通知依赖更新界面是一个异步过程
      const pEle = document.querySelector('#countP')
      console.log('bindPlus >> ',pEle.innerHTML);//是更新之前的值,说明是异步更新的

      //模板界面异步更新完成后执行nextTick回调函数中代码
      this.$nextTick(()=>{
        const pEle = document.querySelector('#countP')
        console.log('bindPlus >> ',pEle.innerHTML); //是更新之后的值
      })
    }
  },

  /*html*/
  template: `<div>
                <h2>{{title}}</h2>
                <p id="countP">{{count}}</p>
                <button @click="bindPlus">加一 </button>
            </div>`,
};

三、虚拟dom、浏览器渲染机制

整个html文档是一个dom对象,整个html由dom节点对象构成。

浏览器渲染机制:

  1. 解析HTML生成dom树,同时解析CSS文档构建CSSOM树
  2. DOM树和CSSOM树关联起来生成渲染树(RenderTree)
  3. 浏览器按照渲染树进行重排重绘

重绘:CSS 样式改变(如:visibility,背景色的改变),使浏览器需要根据新的属性进行绘制

重排:对DOM的修改引发了DOM几何元素的变化(如:改变元素高度),渲染树需要重新计算,重新生成布局,重新排列元素。

重绘不一定导致重排,但重排一定会导致重绘

 

操作真实dom会引起重排重绘,vue框架操作的是虚拟dom(本质上就是一个普通的JS对象。是模拟真实dom得到的一个JS对象)

将真实dom多次操作在虚拟dom上完成,再将虚拟dom映射到真实dom,完成一次重排重绘,提高渲染效率。

我们在vue中写的template模板,vue编译过程中,会调用render()函数将其编译成虚拟dom树,后映射挂载成真实dom,然后重排重绘显示给用户

vue提供了一个h()方法,用于创建虚拟dom(vnode)

import { h } from 'vue'
render(){
  return[

        h('div')

        h('div', { id"foo" })

        h('div', { id"foo", class"bar", style: { color'red' }, onClick: () => { } },'标  题', [/*child*/])

  ]
}

零依赖、链式调用、可变对象:重新设计 JavaScript 颜色处理体验

作者 xpyjs
2026年4月20日 17:04

做前端这么多年,每次处理颜色都有种说不出的别扭。现有方案要么太重、要么 API 设计反直觉,有的追求不可变性导致代码繁琐,有的插件丰富但体积臃肿。

于是就有了 @xpyjs/color


痛点

  • chroma.js 太大,压缩后 30KB+
  • colord 走不可变路线,每次操作返回新对象,调试麻烦
  • tinycolor2 TS 支持弱
  • 没有一家把「可变对象 + 完整色彩空间 + 轻量」三者同时做到

核心理念

@xpyjs/color 的设计哲学只有三个词:可变、完整、轻量

可变对象

直接修改原对象,不需要频繁创建新实例:

const color = new Color('#ff6b6b')
color.lighten(0.1)
color.saturate(0.2)
console.log(color.hex()) // 输出调整后的颜色

当然,如果你想保留原对象的色值,还可以使用 .clone() 来复制一个新对象:

const color1 = new Color('#ff6b6b')
const color2 = color1.clone().lighten(0.1).saturate(0.2) // color1 对象被保留,并且生成一个新值赋给 color2

完整色彩空间

支持 8 种色彩空间,覆盖日常和专业场景:

  • HEX / RGB / HSL / HSV
  • XYZ / LAB / LCH / OKLAB
const color = new Color('#ff6b6b')

color.hex()   // '#ff6b6b'
color.rgb()   // { r: 255, g: 107, b: 107 }
color.hsl()   // { h: 0, s: 100, l: 71 }
color.lab()   // { l: 64.7, a: 59.4, b: 51.2 }

零依赖 + 轻量

  • 纯原生 JS,无任何外部依赖
  • 压缩后约 4KB(gzipped)
  • TypeScript-first,完整类型提示,开箱即用

链式调用

所有操作都支持链式,代码流畅可读:

import { Color } from '@xpyjs/color'

const result = new Color('#ff6b6b')
  .lighten(0.15)
  .saturate(0.3)
  .alpha(0.85)
  .hex() // '#ff9b9b'

插件系统

内置方法相当丰富,并且还内置 20+ 插件,按需扩展使用:

类别 插件
色彩空间 cmyk, lab, hwb, oklab, a98Rgb, displayP3, proPhotoRgb, rec2020, xyz, name
调整 harmony, scale
运算 blend, gradient, palette, temperature
转换 percentageRgb, simulate
辅助 a11y, theme
  • 全部都有完整的类型提示
  • 针对更现代的内容进行适配
  • 更丰富的插件配置

对比 colord

colord 是个好库,但它的不可变设计在高频调整场景下会很痛苦:

// colord(不可变)
const c = new Colord('#ff6b6b')
const adjusted = c.lighten(0.1).saturate(0.2) // 返回新对象,原对象不变

// @xpyjs/color(可变)
const c = new Color('#ff6b6b')
c.lighten(0.1).saturate(0.2) // 直接修改原对象

如果你的颜色频繁修改,或者你喜欢「所见即所得」的调试体验,也许可变方案更适合你。


安装

npm install @xpyjs/color

开源

项目地址:github.com/xpyjs/color

欢迎 star、issues 和 PR,一起让颜色处理变得更好用 🦋

为什么你的 PR 总是多出一堆奇怪的 commit?90% 的人都踩过这个 Git 坑

作者 果然_
2026年4月20日 17:01

为什么你的 PR 总是多出一堆奇怪的 commit?90% 的人都踩过这个 Git 坑

在日常开发中,功能分支开发周期较长时,主干分支往往已经有了新的提交。这时需要将主干的最新代码同步到自己的功能分支,常见的方式有两种:mergerebase。本文通过实际场景对比两种方式的区别与使用方法。


背景:为什么需要同步主干?

假设你从主干 main 切出了功能分支 feature/my-work,开发过程中主干有了新的提交,此时你的分支历史如下:

main:    A ── B ── C(新提交)

feature: A ── B ── D(你的提交)

如果不同步主干,提 PR 时可能产生冲突,或者遗漏主干的重要修改。


git pull 是什么?

git pullgit fetch + 合并操作的快捷命令,默认使用 merge 方式:

git pull origin main
# 等价于
git fetch origin
git merge origin/main

也可以指定使用 rebase 方式:

git pull --rebase origin main
# 等价于
git fetch origin
git rebase origin/main

两者的区别与下文 merge / rebase 章节一致。如果希望 git pull 始终使用 rebase,可以设置全局配置:

git config --global pull.rebase true

💡 建议:对于个人功能分支,推荐全局开启 pull.rebase true,保持提交历史线性整洁。


方式一:merge

原理

将主干的最新提交合并进你的分支,并生成一个新的 MergeCommit 记录这次合并。

main:    A ── B ── C
                    \
feature: A ── B ── D ── M(MergeCommit)

操作步骤

第一步:拉取远程最新代码

git fetch origin

第二步:切换到自己的功能分支

git checkout feature/my-work

第三步:合并主干

git merge origin/main

处理冲突(如有)

# 手动解决冲突后
git add .
git merge --continue

# 放弃本次合并
git merge --abort

第四步:推送到远程

git push origin feature/my-work

提 PR 后的 commit 记录

D  你的提交
M  Merge branch 'origin/main' into feature/my-work

方式二:rebase(推荐)

原理

将你的提交「移植」到主干最新提交的后面,历史记录保持线性,不会产生多余的 MergeCommit

main:    A ── B ── C
                    \
feature: A ── B ── C ── D'(你的提交被接在 C 后面)

操作步骤

第一步:拉取远程最新代码

git fetch origin

第二步:切换到自己的功能分支

git checkout feature/my-work

第三步:变基到主干

git rebase origin/main

处理冲突(如有)

rebase 会逐个回放你的每一个 commit,每个 commit 都可能产生冲突,需要逐一处理:

# 手动解决冲突后
git add .
git rebase --continue

# 放弃本次 rebase
git rebase --abort

第四步:force push 到远程

由于 rebase 改写了本地提交历史,必须使用 force push 同步到远程:

git push origin feature/my-work --force-with-lease

⚠️ 为什么用 --force-with-lease 而不是 --force --force 会无条件覆盖远程分支,存在覆盖他人提交的风险。 --force-with-lease 更安全——如果远程分支有你本地没有的新提交,会拒绝推送并提示,避免意外覆盖他人代码。

提 PR 后的 commit 记录

D'  你的提交(仅此一条,干净清晰)

两种方式对比

merge rebase
历史记录 非线性,含 MergeCommit 线性,简洁清晰
PR commit 数量 自己的 commit + MergeCommit 只有自己的 commit
推送方式 正常 git push git push --force-with-lease
冲突处理次数 只处理一次 每个 commit 都可能处理一次
适合场景 多人协作的公共分支 个人功能分支同步主干

常见问题

Q:rebase 时提示 hint: use --reapply-cherry-picks to include skipped commits

这是正常现象。rebase 检测到你分支上的某个 commit 内容已经存在于目标分支(例如之前被 cherry-pick 过),会自动跳过该 commit,避免重复提交。

可以关闭这条提示:

git config --global advice.skippedCherryPicks false

Q:rebase 之后提 PR,为什么还是有重复的 commit?

rebase 只改写了本地历史,如果没有执行 force push,远程分支仍然是旧的历史。PR 提的是远程分支,所以重复的 commit 还是会被带进去。

务必在 rebase 完成后执行:

git push origin feature/my-work --force-with-lease

Q:git pull 默认是 merge,能改成 rebase 吗?

可以,设置全局配置:

# git pull 默认使用 rebase
git config --global pull.rebase true

设置后,git pull 等价于 git pull --rebase,不再产生多余的 MergeCommit。


总结

  • 个人功能分支同步主干,推荐使用 rebase,PR 历史干净,只包含自己真正新增的 commit。
  • 公共分支之间的合并,推荐使用 merge,保留完整历史,不需要 force push,更安全。
  • 无论哪种方式,同步前都应先执行 git fetch origin 获取远程最新状态。

vue自定义指令与自定义插件

作者 RONIN
2026年4月20日 16:57

一、自定义指令

  • vue内置指令:指带有v-前缀的特殊属性
  • 自定义指令:指包含类似组件生命周期钩子函数的特殊对象(钩子函数会接收到指令所绑定元素作为其参数)

 

const mydirective = {
自定义指令钩子:

// 在绑定元素的 attribute 前,或事件监听器应用前调用
created(el,binding,vnode, prevvnode){},

// 在元素被插入到 DOM 前调用
beforeMount(el,binding, vnode, prevvnode){}

// 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
mounted(el,binding, vnode, prevvnode){},

// 绑定元素的父组件更新前调用
beforeupdate(el,binding, vnode, prevvnode){},

// 在绑定元素的父组件及他自己的所有子节点都更新后调用
updated(el,binding, vnode, prevvnode){},

// 绑定元素的父组件卸载前调用
beforeUnmount(el,binding,vnode,prevvnode){}

// 绑定元素的父组件卸载后调用
unmounted(el,binding, vnode, prevvnode){}
}

自定义指令钩子函数参数说明:

el :指令绑定到的元素。可以用于直接操作 DOM。

binding :一个对象,包含属性:

value :传递给指令的值。

oldvalue :之前的值,仅在 beforeupdate 和 updated 中可用。无论值是否更改,它都可用。

arg:传递给指令的参数(如果有的话)。

modifiers :一个包含修饰符的对象(如果有的话)。

instance :使用该指令的组件实例。

dir :指令的定义对象。

vnode:代表绑定元素的底层VNode。

prevNode :之前的渲染中代表指令所绑定元素的VNode。仅在 beforeupdate 和 updated 钩子中可用.。

实现:

1.App.js(1.定义指令对象 2.通过directive注册指令(全局注册,局部注册) 3.使用)

/**
 * 自定义指令
 *    指令: v-特殊属性
 *          vue内置指令:  v-html  v-text v-pre v-bind v-on v-if v-show
 *    自定义指令: 包含组件生命周期函数的特殊对象
 *               1. 特定对象
 *               2. 组件生命周期函数
 *  
 *   实现:
 *      1. 定义指令对象
 *        const foucs = {
 *              created(el,binding){},
 *              mounted(el,binding){},
 *              unmounted(el,binding){}
 *         }
 *      2. 注册指令
 *          全局注册
 *             指令可以整个应用所有标签使用
 *             const app = creatApp()
 *             app.directive('foucs',focus)
 *          局部注册
 *             只在当前注册的组件标签中使用
 *             const App = {
 *                  components:{}
 *                  directives:{
 *                      foucs:foucs
 *                  }
 *              }
 *
 */

// v-focus自动获取焦点
const focus = {
  mounted(el, binding) {
    el.focus();
  },
};

// v-red使作用的元素内容为红色

const red = {
  mounted(el, binding) {
    el.style.color = "red";
  },
};

// v-color根据指令值,设置指令作用元素内容颜色
const color = {
  mounted(el, binding) {
    el.style.color = binding.value;
  },

  updated (el,binding) {
    el.style.color = binding.value;
  }
}

export default {
//局部注册
  directives: {
    focus,
    red,
    color,
  },

  data() {
    return {
      title: "自定义指令",
      vcolor: "red",
    };
  },

  methods: {
    bindUpdateColor() {
      this.vcolor = "blue";
      console.log(this.vcolor);
    },
  },

  /*html*/
  template: `<div>
                <h2>{{title}}</h2>
                <input type="text" v-focus>
                
                <p v-red>内容</p>

                <p v-color="vcolor">v-color指令内容</p>

                <!--<p v-color="’blue’">v-color指令内容</p>-->

                <button @click="bindUpdateColor">确定</button>
            </div>
            `,
};

main.js

// 使用vue3 ES模块构建版本
import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
// 导入根组件
import App from './App.js'
// createApp(App).mount('#app')
const app = createApp(App)

// v-focus自动获取焦点
const focus = {
  mounted(el, binding) {
    el.focus();
  },
};

//全局注册
app.directive('focus',focus)
app.mount('#app')

二、自定义插件

  • 自定义插件指拥有install()方法的对象,是一种为vue添加全局功能的工具代码。可以在里面注册全局的组件或指令,然后集成到vue全局对象中,全局使用。

 

install(app, options){}方法参数说明:

app: vue应用实例

options: 可选参数对象

 

实现:

main.js导入集成插件

// 使用vue3 ES模块构建版本

import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
import { Myplugin } from "./plugins/MyPlugin.js";

// 导入根组件
import App from './App.js'
// createApp(App).mount('#app')
const app = createApp(App);

app.use(Myplugin); //集成插件
app.mount("#app")

MyPlugin.js定义插件(1.定义插件 2.通过app.use集成插件 3.使用)

export const Myplugin = {
    // app: vue应用实例
    // options: 可选参数对象

    install(app, options) {
      // 封装插件功能
      // 封装全局组件, 封装全局指令

      // 注册组件
      // const ButtonCouter = {
      //  data() {
      //     return {
      //       title: "按钮",
      //     };
      //   },
      //   template: `<button>{{title}}</button>`,
      // }
      // app.component("ButtonCouter",ButtonCouter)

      // 注册组件
      app.component("ButtonCouter", {
        data() {
          return {
            title"按钮",
          };
        },
        template`<button>{{title}}</button>`,
      });

      //注册指令
      app.directive("color", {
        mounted(el, binding) {
          el.style.color = binding.value;
        },

        updated(el, binding) {
          el.style.color = binding.value;
        },
      });
 
      //使一个资源可被注入整个应用app.provide()
    },
  };

App.js使用

export default {
  data() {
    return {
      title: "自定义指令",
    };
  },

  /*html*/
  template: `<div>
                <h2>{{title}}</h2>
                <button-couter></button-couter>
                <p v-color="'blue'">插件定义的指令内容</p>
            </div>
            `,
};

Node.js 全栈知识点详细整理(含代码示例 + 前端结合实战)

作者 WayneYang
2026年4月20日 16:52

本文从基础到进阶全面梳理 Node.js 核心知识点,并重点详解前端与 Node.js 的 6 种核心结合方式(前后端分离、SSR、静态服务、跨域代理、工程化、全栈实战),覆盖前端开发必备的 Node.js 全量知识。

一、Node.js 基础核心

1.1 Node.js 简介与运行

Node.js 是基于 Chrome V8 引擎的 JavaScript 运行时,让 JS 可以运行在服务端,核心特性:非阻塞 I/O、事件驱动、轻量高效

  • 安装:官网下载对应系统安装包(自带 npm 包管理器)
  • 运行:终端执行 node 文件名.js

1.2 全局对象 & 全局 API

Node.js 无浏览器的 window,替代为 global 全局对象,内置常用全局 API:

全局对象 / API 作用 代码示例
global 全局根对象 global.name = "Node.js"; console.log(global.name)
process 进程信息(环境变量、命令行参数) js // 打印环境变量 console.log(process.env); // 命令行参数 console.log(process.argv);
__dirname 当前文件所在绝对路径 console.log(__dirname)
__filename 当前文件的绝对路径 + 文件名 console.log(__filename)
定时器 setTimeout/setInterval/setImmediate setTimeout(()=>console.log("延时执行"),1000)

1.3 核心内置模块(必学)

Node.js 内置无需安装的核心模块,是服务端开发基础:

1. Buffer 模块(二进制数据处理)

处理文件、网络传输的二进制数据(Node.js 特有)

// 创建 Buffer
const buf = Buffer.from('Hello Node.js', 'utf8');
// 转字符串
console.log(buf.toString()); // Hello Node.js
// 查看二进制
console.log(buf); // <Buffer 48 65 6c 6c 6f 20 4e 6f 64 65 2e 6a 73>
2. fs 模块(文件系统)

文件读写、复制、删除,分同步 / 异步两种写法(推荐异步,非阻塞)

const fs = require('fs'); // 引入模块

// 1. 异步读取文件(推荐)
fs.readFile('test.txt', 'utf8', (err, data) => {
  if (err) throw err; // 错误处理
  console.log('文件内容:', data);
});

// 2. 同步读取文件(阻塞线程,慎用)
const data = fs.readFileSync('test.txt', 'utf8');

// 3. 写入文件(覆盖写入)
fs.writeFile('test.txt', '我是写入的内容', (err) => {
  if (!err) console.log('写入成功');
});
3. path 模块(路径处理)

解决跨平台路径兼容问题(Windows/macOS/Linux)

const path = require('path');

// 拼接绝对路径(最常用)
const fullPath = path.join(__dirname, 'test.txt');
console.log('完整路径:', fullPath);

// 获取文件名
console.log(path.basename(fullPath)); // test.txt
// 获取文件扩展名
console.log(path.extname(fullPath)); // .txt
4. http 模块(原生 Web 服务)

创建 HTTP 服务器,处理前端请求

const http = require('http');

// 创建服务器
const server = http.createServer((req, res) => {
  // 设置响应头
  res.writeHead(200, { 'Content-Type': 'text/plain;charset=utf-8' });
  // 返回响应内容
  res.end('Hello Node.js 原生服务器');
});

// 监听 3000 端口
server.listen(3000, () => {
  console.log('服务器运行在 http://localhost:3000');
});
5. events 模块(事件驱动)

Node.js 核心:基于事件订阅 / 发布

const EventEmitter = require('events');
const emitter = new EventEmitter();

// 1. 订阅事件(监听)
emitter.on('sayHi', (name) => {
  console.log(`你好,${name}`);
});

// 2. 触发事件
emitter.emit('sayHi', '前端开发者');

1.4 模块化系统

Node.js 采用模块化管理代码,避免全局污染,支持两种规范:

1. CommonJS 规范(默认)
  • 导出:module.exports / exports
  • 导入:require()
// 模块文件:utils.js
// 导出
module.exports = {
  sum: (a, b) => a + b,
  name: "工具模块"
};

// 主文件:index.js
const utils = require('./utils.js');
console.log(utils.sum(1,2)); // 3
2. ES Module 规范(ESM,现代推荐)

需在 package.json 中添加 "type": "module"

// 模块文件:utils.js
// 导出
export const sum = (a,b) => a+b;
export const name = "工具模块";

// 主文件:index.js
import { sum, name } from './utils.js';
console.log(sum(1,2)); // 3

1.5 异步编程(Node.js 核心)

Node.js 是异步非阻塞模型,解决回调地狱的三种方案:

1. 回调函数(原始写法,易产生回调地狱)
fs.readFile('a.txt', 'utf8', (err, data1) => {
  fs.readFile('b.txt', 'utf8', (err, data2) => {
    console.log(data1 + data2); // 嵌套层级过深 = 回调地狱
  });
});
2. Promise(优化嵌套)
const fs = require('fs/promises'); // Promise 版 fs

fs.readFile('a.txt', 'utf8')
  .then(data1 => fs.readFile('b.txt', 'utf8'))
  .then(data2 => console.log(data2))
  .catch(err => console.log(err));
3. async/await(终极方案,同步写法写异步)
const fs = require('fs/promises');

// 异步函数
async function readFiles() {
  try {
    const data1 = await fs.readFile('a.txt', 'utf8');
    const data2 = await fs.readFile('b.txt', 'utf8');
    console.log(data1 + data2);
  } catch (err) {
    console.log('读取失败:', err);
  }
}

readFiles(); // 执行

二、Node.js Web 开发(前端结合核心)

原生 http 模块开发效率低,Express 是 Node.js 最流行的 Web 框架(轻量、灵活)。

2.1 Express 快速上手

  1. 安装:npm install express
  2. 基础服务器:
const express = require('express');
const app = express();
const PORT = 3000;

// 1. 中间件:解析 JSON 请求体(处理前端 POST 数据)
app.use(express.json());
// 2. 中间件:解决跨域(后面前端结合会详解)
const cors = require('cors');
app.use(cors());

// 3. 路由:处理 GET 请求
app.get('/', (req, res) => {
  res.send('Hello Express 服务器');
});

// 4. 动态路由
app.get('/user/:id', (req, res) => {
  res.json({ id: req.params.id, name: '前端用户' });
});

// 启动服务
app.listen(PORT, () => {
  console.log(`Express 运行在 http://localhost:${PORT}`);
});

2.2 常用中间件

中间件是 Express 的核心,处理请求 / 响应的通用逻辑

中间件 作用 安装
cors 解决跨域 npm i cors
multer 文件上传 npm i multer
dotenv 环境变量管理 npm i dotenv
express.static 静态资源托管 内置

三、数据持久化(数据库)

Node.js 配合数据库实现数据存储,前端常用组合:

  • 非关系型:MongoDB + Mongoose
  • 关系型:MySQL + mysql2

示例:MongoDB + Mongoose

// 安装:npm i mongoose
const mongoose = require('mongoose');

// 1. 连接数据库
mongoose.connect('mongodb://localhost:27017/frontDB')
  .then(() => console.log('MongoDB 连接成功'));

// 2. 定义数据模型
const userSchema = new mongoose.Schema({
  username: String,
  age: Number
});
const User = mongoose.model('User', userSchema);

// 3. 新增数据(给前端接口用)
async function addUser() {
  const user = new User({ username: '前端小白', age: 22 });
  await user.save();
}
addUser();

🔥 重点:前端与 Node.js 结合实战(6 大场景)

这是前端开发最常用的 Node.js 应用场景,每个场景配前后端完整代码 + 详细阐述

场景 1:前后端分离(主流)

核心逻辑

  • Node.js(Express):写后端 API 接口(提供数据)
  • 前端:用 axios 请求接口,渲染页面(Vue/React/ 原生 JS 通用)

步骤 1:Node.js 编写 API 接口

// server.js(后端)
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json());

// 模拟数据
const users = [
  { id:1, name:'张三', job:'前端开发' },
  { id:2, name:'李四', job:'全栈开发' }
];

// 1. GET 接口:获取用户列表
app.get('/api/users', (req, res) => {
  res.json(users); // 返回 JSON 数据
});

// 2. POST 接口:新增用户
app.post('/api/user', (req, res) => {
  const newUser = req.body; // 接收前端传递的数据
  users.push(newUser);
  res.json({ msg:'新增成功', user:newUser });
});

app.listen(3000, () => console.log('API 服务运行在 3000 端口'));

步骤 2:前端请求接口(原生 HTML + Axios)

<!-- index.html(前端) -->
<!DOCTYPE html>
<html>
<body>
  <div id="userList"></div>
  <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
  <script>
    // 1. 请求后端接口获取用户
    async function getUsers() {
      const res = await axios.get('http://localhost:3000/api/users');
      // 渲染数据到页面
      const html = res.data.map(item => `<p>${item.name} - ${item.job}</p>`).join('');
      document.getElementById('userList').innerHTML = html;
    }

    // 2. 调用接口
    getUsers();
  </script>
</body>
</html>

前端结合阐述

  1. 解耦:前端专注页面渲染,后端专注数据接口,独立开发部署
  2. 技术栈:Vue/React + Axios + Node.js/Express 是前端主流全栈方案
  3. 跨域:后端用 cors 中间件解决前端跨域问题

场景 2:静态资源服务器

核心逻辑

Node.js 托管前端静态文件(HTML/CSS/JS/ 图片),直接访问页面。

后端代码

const express = require('express');
const app = express();

// 托管当前目录下的 public 文件夹(存放前端静态文件)
app.use(express.static('public'));

app.listen(3000, () => {
  console.log('静态服务器运行在 http://localhost:3000');
});

目录结构

├── server.js
└── public/        # 前端静态资源
    ├── index.html
    ├── css/style.css
    └── js/main.js

前端结合阐述

  • 无需 Nginx,Node.js 快速搭建前端静态服务
  • 适用于:前端项目本地预览、小型项目部署

场景 3:服务端渲染(SSR)

核心逻辑

Node.js 在服务端渲染页面,直接返回完整 HTML 给前端(利于 SEO,首屏快)。

步骤 1:Express + EJS 模板引擎

// 安装:npm i ejs
const express = require('express');
const app = express();

// 配置模板引擎
app.set('view engine', 'ejs');
// 模板文件目录
app.set('views', './views');

// 渲染页面
app.get('/', (req, res) => {
  // 向模板传递数据
  res.render('index', { title: 'Node.js SSR 页面', user: '前端开发者' });
});

app.listen(3000);

步骤 2:EJS 模板(前端页面)

<!-- views/index.ejs -->
<h1><%= title %></h1>
<p>欢迎:<%= user %></p>

前端结合阐述

  • 解决 SPA(单页应用)SEO 不友好、首屏加载慢问题
  • 适用场景:官网、博客、电商等需要 SEO 的页面

场景 4:开发环境跨域代理

核心问题

前端开发时请求第三方接口会跨域,用 Node.js 做代理服务器转发请求。

后端代理代码

// 安装:npm i http-proxy-middleware
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();

// 代理配置:将 /api 请求转发到第三方接口
app.use('/api', createProxyMiddleware({
  target: 'https://api.github.com', // 第三方接口地址
  changeOrigin: true // 解决跨域
}));

app.listen(3001, () => console.log('代理服务器运行在 3001 端口'));

前端请求

// 前端请求代理服务器,而非直接请求第三方接口
axios.get('http://localhost:3001/api/users')
  .then(res => console.log(res.data));

前端结合阐述

  • 替代 Webpack/Vite 内置代理,纯 Node.js 实现跨域代理
  • 适用于:无构建工具的原生前端项目开发

场景 5:前端工程化(Node.js 驱动)

核心逻辑

Node.js 是前端工程化的基石,所有构建工具都基于 Node.js 开发:

  • 包管理:npm/yarn/pnpm
  • 构建工具:Webpack/Vite/Rollup
  • 脚本命令:package.json 中的 scripts

示例:package.json 脚本

{
  "scripts": {
    "dev": "vite", // 启动开发服务(Node.js 驱动)
    "build": "vite build", // 打包项目
    "preview": "vite preview"
  }
}

前端结合阐述

  1. 前端的模块化、打包、压缩、热更新全靠 Node.js 实现
  2. Vue/React 项目的底层运行环境都是 Node.js

场景 6:全栈小案例(TODO 待办)

完整演示:前端页面 + Node.js 接口 + 数据存储

  1. 前端:增删改查待办事项
  2. Node.js:提供 CRUD 接口
  3. 数据:内存存储(可替换为数据库)

四、Node.js 进阶知识点

  1. 流(Stream) :处理大文件(视频 / 日志),避免内存溢出

    const fs = require('fs');
    // 读取流
    const readStream = fs.createReadStream('largeFile.txt');
    readStream.on('data', (chunk) => console.log('读取分片:', chunk));
    
  2. 进程管理child_process(创建子进程)、pm2(生产环境部署)

  3. 错误处理:全局异常捕获、接口错误统一返回

  4. 部署:服务器安装 Node.js,用 pm2 守护进程


总结

  1. Node.js 核心:非阻塞 I/O、事件驱动、模块化、异步编程(async/await)

  2. Web 开发:Express 是前端必备框架,核心是路由 + 中间件

  3. 前端结合

    • 主流:前后端分离(Node.js 写 API,前端请求)
    • 辅助:静态服务、跨域代理、SSR、工程化
  4. 全栈能力:前端掌握 Node.js,可独立完成「前端页面 + 后端接口 + 部署」全流程开发

CSS作用域穿透选择器

2026年4月20日 16:44

:deep

名称:CSS作用域穿透选择器

核心作用

  1. 穿透样式隔离 允许父组件样式影响子组件的深层元素,解决组件化开发中样式被隔离的问题。
  2. 覆盖第三方组件样式 当使用 UI 库(如 Element UI、Ant Design)时,直接修改子组件内部元素的样式。

vue3语法 新版语法 ::v-deep:deep

:deep称为CSS作用域穿透选择器,在vue中,默认有样式作用域隔离(通过<style scoped>)。若需要覆盖子组件样式,需用 ::v-deep(Vue 3 推荐,取代废弃的 /deep/>>>)。

/* Vue 3 语法 */
::v-deep(.child-class) {
  color: red;
}

/* 简写(某些预处理器如 SASS 可能需要括号) */
:deep(.child-class) {
  color: red;
}

vue2语法 旧版语法 /deep/>>>

/* Vue 2 或旧版语法 */
.parent-class /deep/ .child-class {
  color: red;
}

/* 或 */
.parent-class >>> .child-class {
  color: red;
}

注意事项

  1. 谨慎使用 过度使用会破坏组件独立性,应优先通过组件接口(如 Props)或 CSS 变量(--custom-color)修改样式。
  2. 替代方案 现代框架推荐通过 CSS 变量或 :global(如 CSS Modules)实现样式共享。
  3. 废弃语法 /deep/>>>、Vue 3 推荐 ::v-deep

属性透传attribute与性能优化组件(component、异步组件、keep-alive/Suspense/Teleport/Transition)

作者 RONIN
2026年4月20日 16:40

一、属性透传attribute

  • 指的是传递给一个组件,但没有通过props或emits接收,常见的如class、style、id
  • 透传的样式会自动被添加到子组件根元素上,如果子组件已经有一个样式,透传的样式会和已有样式合并。
  • 如果透传的是一个点击事件,也会自动被添加到子组件根元素上,如果子组件自身也有点击事件,点击时透传过来的事件和其本身的事件都会触发。
  • 透传的属性可以通过{{$attrs}}拿到
  • 属性透传只会透传到根元素上,如果有多个根节点,vue不知道要透传到哪个节点,需要通过v-bind=”$attrs”绑定到要透传到的那个节点上,否则会抛出警告。
  • 属性透传是可以禁用的,通过inheritAttrs: false可以阻止透传

例:

目录

image.png

1.新建assets样式文件夹,样式文件style.css

.large{
    color: red;
}
.small{
    background-color: pink;
}

2.在index.html中引入样式文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>首页</title>
    <link rel="stylesheet" href="./assets/css/style.css">
</head>

<body>
    <div id="app"></div>
    <script type="module" src="./src/main.js"></script>
</body>
</html>

父组件App.js

import Child from "./Child.js";
export default {
  components: {
    Child,
  },

  data() {
    return {
      title: "attribute属性透传",
      count:0
    };
  },

  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>
                <p>{{count}}</p>
                 <Child class="large" @click="count++" ></Child>
            </div>`,
};

子组件Child.js

export default {
  data() {
    return {
      num10,
    };
  },

  inheritAttrs: true, // 阻止透传

  /*html*/
  template: `
              <button @click="num++" class="small">子按钮{{num}}</button>
              <main v-bind="$attrs">多个根节点</main>
          `,
};

二、性能优化组件

详细介绍六个性能优化组件,其他组件官网自行学习

1.动态组件(<component :is=””></component>改变is属性绑定的值即可)

例如tab栏切换

父组件App.js

import Home from "./Home.js";
import Category from "./Category.js";
import Cart from "./Cart.js";
import My from "./My.js";
/*
 *  点击tab选项 内容区域切换为对应组件
 *    1. tab选项绑定点击事件
 *    2. 切换组件
 */

export default {
  components: {
    Home,
    Category,
    Cart,
    My,
  },

  data() {
    return {
      title: "动态组件",
      currentTab:'home',
      list:[
        {name:'home',title:'首页'},
        {name:'category',title:'分类'},
        {name:'cart',title:'购物车'},
        {name:'my',title:'我的'},
      ]
    };
  },

  methods: {
    onTabChange(tabName){
      this.currentTab = tabName
    }
  },

  /*html*/
  template: `<div class="g-container">
                 <div class="g-content">
                      <component :is="currentTab"></component>
                 </div>

                 <ul class="g-footer">
                    <li v-for="item in list" @click="onTabChange(item.name)" :class="{active:currentTab==item.name}">{{item.title}}</li>
                 </ul>
            </div>`,
};

样式文件style.css

*{padding: 0;margin: 0;}

ul,li{
    list-style: none;
}

.g-container{
    height: 100vh;
    width: 100%;
    display: flex;
    flex-direction: column;
}

.g-container .g-content{
    flex:1
}

.g-container .g-footer{
    height: 60px;
    background-color: skyblue;
    display: flex;
    justify-content: space-around;
    align-items: center;
}

.active{
   color: red;    
}

子组件Home.js(其他子组件同此组件)

export default {
  data() {
    return {
      title"首页",
    };
  },

  /*html*/
  template: `<div>
                  <h2>{{title}}</h2>
              </div>`,
};

2.异步组件(服务端定义的组件,通过网络异步获取到前端,再注册使用。通过defineAsyncComponent方法获取组件)

App.js

import { defineAsyncComponent } from "https://unpkg.com/vue@3/dist/vue.esm-browser.js";

/**
 *   异步组件: 服务端定义的组件,通过网络异步获取到前端,再注册使用
 *   同步组件: 前端客户端定义组件
 */

const AsyncChild = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    //模拟网络接口
    setTimeout(() => {
      //异步获取后端定义的异步组件
      const asyncComponent = {
        template`<p>我是异步组件</p>`,
      };

      resolve(asyncComponent);
    }, 2000);
  });
});

export default {
  components: {
    AsyncChild,
  },

  data() {
    return {
      title"异步组件",
    };
  },

  methods: {},

  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>

                <p>---异步组件----</p>
                <async-child></async-child>
            </div>`,
};

3.内置组件--缓存组件keep-alive/Suspense/传送门Teleport/过渡动画Transition

直接使用,无需注册

1>. 缓存组件,结合动态组件使用(<keep-alive include=””></keep-alive> 通过 include可以配置哪些组件需要缓存,需要注意的是,include中填写的并不是组件注册时的名称,是定义组件时name选项定义的组件名称)

注:存组件添加之后组件生命周期钩子函数也不会执行了,但是有另外两个钩子函数会执行:activated激活deactivated失活

使用示例:b组件,a组件会销毁,再次切换到a组件时组件会重新创建,但有时是不需要重新创建的,即切换回来原数据还存在。

父组件App.js

import Home from "./components/Home.js";
import Category from "./components/Category.js";
import Cart from "./components/Cart.js";
import My from "./components/My.js";
/**
 *  点击tab选项 内容区域切换为对应组件
 *    1. tab选项绑定点击事件
 *    2. 切换组件
 */
export default {
  name:'App',
  components: {
    Home,
    Category,
    Cart,
    My,
  },

  data() {
    return {
      title: "动态组件",
      currentTab: "home",
      list: [
        { name: "home", title: "首页" },
        { name: "category", title: "分类" },
        { name: "cart", title: "购物车" },
        { name: "my", title: "我的" },
      ],
    };
  },

  methods: {
    onTabChange(tabName) {
      this.currentTab = tabName;
    },
  },

  /*html*/
  template: `<div class="g-container">
                 <div class="g-content">
                      <!--数组写法-->
                      <!--<keep-alive :include="['Home','Category','Cart']">-->

                      <!--字符串写法-->
                      <keep-alive include="Home,Category,Cart">
                          <component :is="currentTab"></component>
                      </keep-alive>
                 </div>

                 <ul class="g-footer">
                    <li v-for="item in list" @click="onTabChange(item.name)" :class="{active:currentTab==item.name}">{{item.title}}</li>
                 </ul>
            </div>`,
};

子组件Home.js

export default {
  name:'Home',
  data() {
    return {
      title"首页",
    };
  },

  created() {
    console.log("home created ");
  },

  mounted() {
    console.log("home mounted ");
  },

  activated() {
    console.log("home activated ");
  },

  deactivated() {
    console.log("home deactivated ");
  },

  unmounted() {
    console.log("home unmounted ");
  },

  /*html*/

  template: `<div>
                  <h2>{{title}}</h2>
                  <input type="text" name="message">
              </div>`,

};

子组件Category.js

export default {
  name:'Category',
  data() {
    return {
      title"分类",
      list: [],
    };
  },
  
  created() {
    console.log("category created ");
  },

  mounted() {
    console.log("category mounted ");
  },

  activated() {
    console.log("category activated ");

    // 调用接口获取分类列表数据 fetch返回promise对象,

    fetch("https://api.yuguoxy.com/api/shop/list?pageSize=4")
      .then((response) => {
        return response.json();
      })
      .then((data) => {
        console.log(data);
        this.list = data.resultInfo.list;
      });
  },

  deactivated() {
    console.log("category deactivated ");
  },

  unmounted() {
    console.log("category unmounted ");
  },

  /*html*/
  template: `<div>
                  <h2>{{title}}</h2>
                  <ul>
                    <li v-for="item in list">
                       <img :src="item.picture" style="width:100px;"/>
                       <p>{{item.shop}}</p>
                    </li>
                  </ul>
              </div>`,
};

2>.Suspense组件,结合插槽使用。(当网络请求时间较长,请求的内容暂时没有获取到。等待时渲染一个加载状态,获取到之后展示获取到的内容)

App.js

import { defineAsyncComponent } from "https://unpkg.com/vue@3/dist/vue.esm-browser.js";

/**
 *   异步组件: 服务端定义的组件,通过网络异步获取到前端,再注册使用
 *   同步组件: 前端客户端定义组件
 */

const AsyncChild = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    //模拟网络接口
    setTimeout(() => {
      //异步获取后端定义的异步组件
      const asyncComponent = {
        template: `<p>我是异步组件</p>`,
      };
      resolve(asyncComponent);
    }, 2000);
  });
});
 
export default {
  components: {
    AsyncChild,
  },

  data() {
    return {
      title: "异步组件",
    };
  },

  methods: {},
  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>

                <p>---异步组件----</p>
                <!-- Suspense 作用: 首先显示 名为fallback的插槽内容,当异步组件加载完成后,显示异步组件  -->

                <Suspense>
                     <async-child></async-child>

                     <template #fallback>
                         <p>加载中...</p>
                     </template>
                </Suspense>
            </div>`,
};

3>.传送门Teleport

实际应用场景:没有传送门时嵌套的css样式太深(#app div box model),可以使用传送门减少嵌套层数(#app model)

父组件App.js

import Dialog from "./Dialog.js";

export default {
  components: {
    Dialog,
  },

  data() {
    return {
      title: "父组件",
      show: false, // 控制对话框隐藏显示
    };
  },

  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>
                <button @click="show=true">添加用户</button>

                <!--传送到body标签下面-->
                <Teleport to="body">
                    <Dialog v-if="show" @closeDialog="show=false"></Dialog>
                </Teleport>
            </div>
            `,
};

子组件Dialog.js

export default {
  emits: ["closeDialog"], // 接收事件
  data() {
    return {};
  },

  methods: {
    bindConfirm(){
       // 1. 获取表单输入框内容
       // 2. 调用添加用户接口,保存用户数据到服务端
       // 3. 保存用户成功,关闭对话框
          this.$emit('closeDialog')
    }
  },

  /*html*/
  template: `<div class="box">
              <div class="modal">
                  <!-- header -->
                  <div class="header">
                      <p class="title">标题</p>
                      <p class="close" @click="$emit('closeDialog')">x</p>
                  </div>

                  <!-- 内容区域 -->
                  <div class="content">
                      <form>
                          <input type="text" name="username" placeholder="请输入用户名">  
                          <input type="text" name="password" placeholder="请输入密码">  
                      </form>
                  </div>

                  <!-- 底部区域 -->
                  <div class="footer">
                      <button @click="bindConfirm">确定</button>
                  </div>
              </div>
            </div>`,
};

4>.过渡动画<Transition name=””>

没有定义name,样式默认v-;定义了name=”a”,样式为a-

.v-enter-active,

.v-leave-active {transition: opacity 0.5s ease;}

.v-enter-from,

.v-leave-to {opacity: 0;}

父组件App.js

import Dialog from "./Dialog.js";

export default {
  components: {
    Dialog,
  },

  data() {
    return {
      title: "父组件",
      show: true, // 控制对话框隐藏显示
    };
  },

  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>
                <button @click="show=!show">切换</button>

                <!--过渡动画效果-->
                <Transition> 
                   <p v-if="show">过度动画效果</p>
                </Transition> 

                <Transition name="fade"> 
                   <Dialog v-if="show" @closeDialog="show=false"></Dialog>
                </Transition> 
            </div>
            `,
};

子组件Dialog.js

export default {
  emits: ["closeDialog"], // 接收事件
  data() {
    return {};
  },

  methods: {
    bindConfirm(){
       // 1. 获取表单输入框内容
       // 2. 调用添加用户接口,保存用户数据到服务端
       // 3. 保存用户成功,关闭对话框
          this.$emit('closeDialog')
    }
  },

  /*html*/
  template: `<div class="box">
              <div class="modal">

                  <!-- header -->
                  <div class="header">
                      <p class="title">标题</p>
                      <p class="close" @click="$emit('closeDialog')">x</p>
                  </div>

                  <!-- 内容区域 -->

                  <div class="content">
                      <form>
                          <input type="text" name="username" placeholder="请输入用户名">  
                          <input type="text" name="password" placeholder="请输入密码">  
                      </form>

                  </div>

                  <!-- 底部区域 -->
                  <div class="footer">
                      <button @click="bindConfirm">确定</button>
                  </div>
              </div>
            </div>`,
};

样式style.css

/* 下面我们会解释这些 class 是做什么的 */

.v-enter-active,

.v-leave-active {
  transition: opacity 0.5s ease;
}

.v-enter-from,

.v-leave-to {
  opacity: 0;
}


.fade-enter-active,

.fade-leave-active {
  transition: opacity 1s ease;
}

.fade-enter-from,

.fade-leave-to {
  opacity: 0;
}

【Next.js】基础知识速查

作者 Explore
2026年4月20日 16:30

前言:本文把 Next.js App Router 的高频知识进行总结,给出简洁说明和示例,可直接当作速查清单与复盘资料。

1. SSR 和 CSR:先建立整体认知

CSR(Client Side Rendering)

  • 浏览器先拿到基础 HTML,再下载 JS 后在客户端渲染页面。
  • 首屏通常依赖 JS 执行,页面交互能力强,适合高交互后台系统。

SSR(Server Side Rendering)

  • 服务端先把页面渲染成 HTML 返回给浏览器,客户端再进行水合(Hydration)。
  • 首屏内容到达更早,对 SEO 和社交分享更友好。

SSR 的核心好处

  • SEO 友好,搜索引擎更容易拿到完整内容。
  • 可在服务端预处理数据,减少页面加载后的额外请求。
  • 能规避很多浏览器端跨域限制(由服务端统一请求外部 API)。
  • 便于聚合多后端服务(例如微服务 + GraphQL 网关),前端调用更简单。
  • 更好的社交平台预览(OG/meta tags 能在首个 HTML 中返回)。
  • 安全性更高:敏感逻辑和密钥可留在服务端执行。

2. App Router 文件层级:layout、template、page

App Router 的渲染层级通常是:

layout > template > page

layout

  • 支持根 layout 和嵌套 layout。
  • 路由切换时,layout 默认会复用,内部状态可保留。

template

  • 每次导航都会重新创建实例。
  • 路由切换后其局部状态不会保留。

简单理解:

  • 希望状态保留,用 layout
  • 希望切换即重置,用 template

3. 路由、动态路由、路由组

文件路由

  • 目录即路由,page.tsx 对应该层页面。

动态路由

  • [slug]:单段动态参数。
  • [...slug]:Catch-all,匹配多段。
  • [[...slug]]:可选 Catch-all,可匹配空路径。

在 Next.js 15+ 的服务端组件中,params 通常按 Promise 处理后再使用。

// app/blog/[slug]/page.tsx
export default async function BlogDetail({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return <div>slug: {slug}</div>;
}

路由组

  • 使用 (groupName) 目录分组。
  • 只做组织结构,不影响 URL。

最小目录示例:

app/
  (marketing)/
    about/page.tsx   -> /about
  (shop)/
    cart/page.tsx    -> /cart

4. 平行路由(Parallel Routes)

平行路由可以理解为“多插槽并行渲染”,和 Vue 插槽思路接近。

语法

  • @ 开头的目录,如 @modal@dashboard
  • 默认插槽相当于 @children

软导航 vs 硬导航

  • 软导航:通过 <Link> 跳转,会尽量复用已有 UI 状态。
  • 硬导航:浏览器刷新或直接输入 URL,会重新加载整页。

default.tsx 的作用

给某个平行路由槽位提供兜底内容,避免导航时因为缺少对应子路由而出现不一致或空白。

最小目录示例:

app/
  dashboard/
    layout.tsx
    @analytics/
      page.tsx
      default.tsx
    @team/
      page.tsx
      default.tsx

5. 拦截路由(Intercepting Routes)

语法:(..)

典型场景:

  • 在列表页点击图片后,以弹窗展示详情(仍保留列表上下文)。
  • 把详情页链接分享给别人时,对方可直接打开完整详情页。

这类体验通常会配合“平行路由 + 拦截路由”实现。

最小目录示例(列表页弹窗 + 独立详情页):

app/
  feed/
    page.tsx
    @modal/
      (..)photo/[id]/page.tsx   // 从 feed 拦截进入弹窗
  photo/[id]/page.tsx           // 直接访问时展示完整详情页

6. 在客户端获取路径信息

这一块最容易混淆,先记住一句话:

  • useParams 读“路径动态段”(如 /posts/[id])。
  • useSearchParams 读“查询参数”(如 ?tab=comment)。
  • usePathname 读“当前路径字符串”(不含查询串)。

假设当前 URL 是:/posts/123?tab=comment&from=home

  • useParams() -> { id: "123" }
  • useSearchParams().get("tab") -> "comment"
  • usePathname() -> "/posts/123"
"use client";

import { useParams, usePathname, useSearchParams } from "next/navigation";

export default function Demo() {
  const { id } = useParams<{ id: string }>();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const keyword = searchParams.get("keyword");
  const tab = searchParams.get("tab");

  return (
    <div>
      <p>id: {id}</p>
      <p>pathname: {pathname}</p>
      <p>keyword: {keyword}</p>
      <p>tab: {tab}</p>
    </div>
  );
}

7. 页面跳转方式

<Link>

  • 增强版 a 标签,支持预取(prefetch)。
  • 推荐用于常规导航。

useRouter()

  • 编程式导航:router.push()router.replace()router.back()router.refresh()
  • 适合事件回调中的跳转控制。
"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";

export default function NavDemo() {
  const router = useRouter();
  return (
    <>
      <Link href="/posts">去列表页</Link>
      <button onClick={() => router.push("/posts/1")}>查看详情</button>
      <button onClick={() => router.refresh()}>刷新当前路由数据</button>
    </>
  );
}

8. Metadata:静态与动态

App Router 支持通过 metadatagenerateMetadata 配置 SEO 信息。

  • 静态 metadata:页面级固定标题、描述。
  • 动态 metadata:可根据路由参数或接口数据动态生成。
// 静态 metadata
export const metadata = {
  title: "文章列表",
  description: "博客文章列表页",
};
// 动态 metadata
export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return {
    title: `文章-${id}`,
  };
}

9. 404 处理:全局与局部

全局 404

  • 根目录 not-found.tsx,兜底所有未匹配页面。

局部 404

  • 某个路由段下也可放置 not-found.tsx
  • 业务中手动触发:
import { notFound } from "next/navigation";

notFound();

10. 路由处理程序(Route Handlers)

文件约定:app/api/**/route.ts

可实现 RESTful 风格接口,也支持动态路由参数。

示例:

// app/api/posts/[id]/route.ts
export async function GET() {}
export async function PATCH() {}
export async function DELETE() {}

11. GET 缓存何时失效(常见误区)

以下场景通常不会走静态缓存(或会触发动态渲染):

  • 在 Route Handler 中读取 Request 里的动态信息(如 query、cookie、header)并参与响应计算。
  • 使用非 GET 方法(POST/PUT/PATCH/DELETE)。
  • 使用动态函数(如 cookies()headers())。
  • 显式通过 dynamicrevalidatecache 配置动态策略。

建议:不要过度依赖“默认缓存行为”,在关键接口上显式写清缓存策略。

12. Middleware(请求拦截层),现称 Proxy

常见写法:

export const config = {
  matcher: "/about/:path*",
};

应用场景:

  • 登录态校验、权限控制。
  • 登录/退出后的重定向策略。
  • 国际化前缀处理等。

它的职责更像应用入口处的“网关/代理层”,但工程上仍按 middleware.ts 约定使用。

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const token = request.cookies.get("token")?.value;
  if (!token) return NextResponse.redirect(new URL("/login", request.url));
  return NextResponse.next();
}

13. 客户端组件 vs 服务端组件

什么时候必须用客户端组件("use client"

  • 需要事件处理(点击、输入等)。
  • 需要 React Hooks(useStateuseEffect 等)。
  • 需要浏览器 API(windowlocalStorage)。
  • 使用依赖 state/effect/browser API 的自定义 Hook。
  • 使用类组件。

客户端组件执行过程

  • 服务端预渲染出初始 HTML(可选)。
  • 客户端进行水合并接管交互。
  • 后续状态变化在客户端重新渲染。

服务端组件(RSC)

  • 仅运行在服务端。
  • 可在构建时静态生成,也可在请求时动态渲染。
  • 更适合数据读取、拼装和安全逻辑处理。

组合注意事项

  • 服务端组件可以直接使用客户端组件。
  • 客户端组件不能直接 import 服务端组件;常见做法是通过 children 组合。
  • 第三方库若内部使用浏览器 API,需包一层客户端组件再在服务端树中使用。
  • Context Provider 放在根时,通常也需要客户端包装层。

14. 服务端组件的数据共享

在 RSC 中可直接 fetch 读取数据并复用缓存。

  • Next.js 14:fetch 默认更偏向可缓存策略(视场景而定)。
  • Next.js 15+:默认行为改为更偏动态(常见理解是默认不缓存),迁移时要显式声明缓存策略。

迁移建议:把缓存意图写清楚,不依赖“默认行为”。

15. 服务端组件渲染策略:静态与动态

静态渲染

  • 适合内容相对稳定页面。
  • 支持 revalidate 进行按时间增量更新(ISR)。

动态渲染

触发条件常见有:

  • 使用动态函数:cookies()headers()searchParams
  • 使用未缓存的 fetch

16. fetch

await fetch(url, {
  next: { revalidate: 3000, tags: ["post-list"] },
});

常见刷新策略

  • 基于时间:revalidate
  • 按路径:revalidatePath("/posts")
  • 按标签:revalidateTag("post-list")

revalidateTag 适合批量失效同类查询,通常比路径粒度更灵活。

17. 四类缓存机制

1)请求级缓存(Request Memoization)

  • React 层能力,同一次请求内去重相同 fetch。

2)数据缓存(Data Cache)

  • Next.js 层能力,跨请求复用数据。
  • cacherevalidatetags 影响。

3)全路由缓存(Full Route Cache)

  • 面向静态路由产物缓存。

4)客户端路由缓存(Router Cache)

  • 基于 App Router 在客户端缓存段数据,提升导航速度。
  • 页面刷新会清空这类缓存。

补充:

  • <Link> 会触发预取,常见默认缓存窗口:静态页更长、动态页更短。
  • router.refresh() 会请求新数据并更新当前路由树。

18. Server Action / Server Function

使用方式:"use server"

两种常见声明级别

  • 函数级别:在具体函数体前声明。
  • 模块级别:文件顶部声明,文件内导出函数都在服务端执行。

典型应用

  • 表单提交写库。
  • 数据变更后触发 revalidatePath / revalidateTag
  • 将客户端“上提请求”改为服务端执行,减少 API 样板代码。
// app/actions.ts
"use server";

import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const title = String(formData.get("title") || "");
  // await db.post.create({ data: { title } });
  revalidatePath("/posts");
}
// app/posts/new/page.tsx
import { createPost } from "@/app/actions";

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="请输入标题" />
      <button type="submit">提交</button>
    </form>
  );
}

19. 类型校验:推荐 Zod

在 Server Action 或 Route Handler 中,建议先做参数校验再执行业务逻辑:

import { z } from "zod";

const CreatePostSchema = z.object({
  title: z.string().min(1, "标题不能为空"),
  content: z.string().min(1, "内容不能为空"),
});

优势:

  • 运行时校验 + TS 类型推导统一。
  • 错误信息可控,便于前后端协作。

20. Turbopack 与 React Compiler

Turbopack 的价值

  • 更快的本地构建与热更新反馈。
  • 惰性打包,按需处理模块。
  • 增量计算与缓存,改动范围越小收益越明显。

React Compiler(关注趋势)

  • 目标是让 React 编译器自动优化部分渲染性能问题。
  • 在真实项目中仍需配合良好组件边界和状态设计,不能把性能完全交给“黑盒优化”。

实战建议:如何把这些知识真正用起来

  1. 先定渲染策略:页面是静态优先还是动态优先。
  2. 再定路由结构:普通路由、平行路由、拦截路由如何组合。
  3. 明确缓存策略:哪些数据走 revalidate,哪些走 tag 失效。
  4. 写操作优先 Server Action:并在动作后精确触发缓存失效。
  5. 最后补齐类型和异常处理:Zod + 404 + 边界兜底。

WebSkill —— 运行在浏览器的 Agent 技能

2026年4月20日 16:28

本文由华为前端技术专家莫春辉原创。

与运行在后端服务的传统技能(Skill)相比,WebSkill 是一种完全运行在 Web 前端的原生架构。它配合 WebMCP 和生成式 UI(Generative UI),共同构成了以大语言模型(LLM)为中心的三位一体 Web AI 架构。这三大核心部件通过紧密联动,实现了 AI 应用从“用户意图识别”到“Agent 任务执行”在浏览器端的全闭环。本文将基于这一架构,深入探讨 WebSkill 扮演的核心角色、独特价值、企业级应用场景、Web 标准化建议以及至关重要的安全防御边界。

一、 以 LLM 为中心的“智能体交互三角”

在前端 Web AI 应用的 Agent 对话框场景中,系统的运作可以被抽象为一个以大语言模型(LLM)为中心枢纽,由 WebSkill、WebMCP 和生成式 UI 共同构成的三角形架构。

web_ai.jpg

  1. 大语言模型(LLM) LLM 承担着语义推理与编排调度的核心职能。当用户在 AI 应用的对话框中输入自然语言意图时,LLM 首先负责解析该意图,并作为路由引擎,从 Web 前端的技能清单中检索并加载相匹配的 WebSkill 文档。

  2. 声明式技能(WebSkill) WebSkill 是连接 LLM、Agent 任务执行与用户界面的桥梁。它通过“渐进式披露”机制,仅在特定业务场景下,按需向 LLM 暴露相关的指令、前置条件和所需的 WebMCP 工具。此外,WebSkill 文档内详细定义了实现用户意图必须收集的参数规范(Schema)。当 LLM 发现用户提供的意图无法补全这些参数时,WebSkill 的逻辑将指示 Agent 暂停底层执行,转向用户发起信息收集。

  3. 生成式 UI(Generative UI) 在传统架构中,LLM 只能通过输出 Markdown 格式的文本选项来询问用户,交互方式非常僵化。而在本架构中,LLM 基于 WebSkill 定义的 Schema,流式输出结构化的 JSON 数据。Agent 对话框中的生成式 UI 渲染器会实时拦截这些数据,并自动渲染出包含文本框、下拉菜单、日期选择器等常规 Web 元素的可视化表单。用户在直观的表单中完成交互选择后,生成式 UI 确保了被收集参数的准确性。当 WebMCP 完成任务后,LLM 同样能够调用生成式 UI,将枯燥的数据结果渲染为柱状图、饼图或交互式表格,为用户提供可视化的成果展示。

  4. 前端执行工具(WebMCP) 当任务执行所需的参数通过生成式 UI 收集完毕后,系统将其传递给 WebMCP 工具进行执行。WebMCP 是模型上下文协议(MCP)在前端 Web 应用内的 TypeScript 版 SDK 实现。开发者可以通过网页脚本注册 MCP 工具,当工具的回调函数被触发时,WebMCP 可以直接操作当前页面的 DOM 节点,或携带用户现有的会话状态向后端服务发送请求。

二、 WebSkill 的核心价值与企业应用场景

探讨 WebSkill 的核心价值,必须将其与常规的 LLM 工具调用模式及传统的云端技能架构进行区分。

  1. 突破上下文爆炸瓶颈

    从技术原理上看,LLM 本身具备直接调用 WebMCP 工具的能力,前提是在发送给大模型的请求中附带上完整的 MCP Tools 声明。然而,在复杂的企业级 Web AI 应用中,底层工具的数量往往成百上千。如果将所有工具的 Schema 一次性全部塞入上下文,不仅会迅速耗尽 LLM 的上下文窗口(Context Window),引发“上下文爆炸”和高昂的 Token 成本,还会导致大模型注意力分散,严重降低意图识别的准确率。 web_skill.jpg

    WebSkill 的出现优雅地解决了这一难题。当用户输入自然语言时,LLM 首先进行轻量级的意图识别,匹配到特定的 WebSkill。由于每个 WebSkill 内部已经明确声明了完成该业务所需的 WebMCP 工具清单,系统只需将这几个特定工具的声明注入到后续的上下文中即可。这种“按需动态加载”机制,极大地节省了系统开销,确保了大型企业应用在复杂场景下的稳定运行。

  2. 前端原生闭环

    目前开源社区存在名为 Webskills 的命令行工具,它仅仅是将网页视为知识库语料,服务于浏览器外部的 CLI 智能体。相反,本文提出的 WebSkill 是真正的前端原生(Frontend-Native)闭环。WebSkill 的内容直接存在于浏览器端。在传统架构中,Skill 文档存储在云端并作为后端 API 运行,不仅要求处理复杂的跨端身份验证,还受制于执行超时。而 WebSkill 文档驻留在浏览器内,WebMCP 工具在前端运行,天然继承并复用了用户现有的 Cookies、LocalStorage 和登录状态。这使得 Agent 能够轻易绕过复杂的单点登录(SSO)或多因素认证(MFA),实现“零状态同步成本”的任务执行。

  3. 敏捷迭代与自我进化

    在传统模式下,赋予 Agent 某项业务能力的链路极其漫长:梳理文档 -> 编写代码 -> 后端部署 -> 上线运行 -> 发现偏差 -> 重新开发部署。而在 WebSkill 架构下,技能转变为前端可解析的轻量级声明式文档(如 Markdown)。业务人员甚至客户可以直接在可视化编辑器中调整 Skill 的前置条件和逻辑。由于技能存储在前端,修改后无需任何后端部署,Agent 下次执行即可即时加载最新规则,将迭代周期从数天压缩至数秒。 web_agent.jpg

    此外,随着 LLM 推理能力的增强,Agent 在该架构下甚至具备了自我进化的能力。当 Agent 观察到客户在复杂企业应用中存在重复性的提取或交互操作时,它可以自主归纳工作流,并将其固化为一个全新的 WebSkill。由于该技能与当前用户的浏览器强绑定,这不仅为用户带来了极致的定制化体验,更确保了核心的业务操作逻辑绝对不会泄露给其他租户。

三、 基于 OPFS 的 WebSkill 标准化建议

源私有文件系统(OPFS)是由 W3C 提出并逐步被主流浏览器实现的一项标准 API。它允许网页在一个隔离的私有目录中读写文件和目录结构,且这个目录仅对当前 Origin(协议 + 域名 + 端口)可见。

在基于 OPFS 的 WebSkill 实现中,技能文档一旦写入 OPFS,便会受到浏览器严格的同源策略隔离,从而确保恶意网站无法跨域访问企业的技能定义。同时,结合 AES-256-GCM 算法对本地存储的技能进行静态加密,可确保机密业务数据永远不会离开当前设备。

我们定义以下 Web IDL 接口规范,旨在将 WebSkill 技能标准化并安全地存储至 OPFS:


// =========================================================
// 1. 安全与边界约束 (WebSkillSecurityConstraints)
// =========================================================
dictionary WebSkillSecurityConstraints {
    // WebMCP 工具网络请求的严格白名单(物理切断数据外传)
    sequence<DOMString> domainAllowlist;
    // 高危操作强制触发人类在环(Generative UI 拦截弹窗)
    boolean requiresHumanConfirmation;
    // 禁用当前技能通过 WebMCP 访问 file:// 等本地文件资源
    boolean blockLocalFileAccess;
};

// =========================================================
// 2. 生成式 UI 契约 (GenerativeUIOptions)
// =========================================================
dictionary GenerativeUIOptions {
    // 必填:用于让 GenUI 实时拦截并渲染表单的 JSON Schema
    required object parameterSchema;
    // 可选:给渲染器的视觉提示(如:某字段推荐使用"DatePicker")
    object renderHints;
    // 当意图参数缺失时,LLM 抛给 UI 渲染组件的友好引导语
    DOMString defaultIntentPrompt;
};

// =========================================================
// 3. WebMCP 绑定契约 (WebMCPBinding)
// =========================================================
dictionary WebMCPBinding {
    // 当前 Skill 允许调用的前端原生 WebMCP 工具标识符
    required sequence<DOMString> toolNames;
    // 该技能执行后,期望 WebMCP 返回的数据格式约束
    object expectedOutputSchema;
};

// =========================================================
// 4. 核心 WebSkill 数据结构
// =========================================================
dictionary WebSkillOptions {
    // 基础信息与路由编排
    required DOMString name;
    required DOMString description; // LLM 意图路由的检索依据
    required DOMString content;     // YAML/Markdown 格式的业务逻辑或系统提示词

    // 架构强关联:UI 表现层约束
    GenerativeUIOptions uiSchema;

    // 架构强关联:底层执行器约束
    WebMCPBinding mcpBindings;

    // 架构强关联:意图碰撞防御配置
    WebSkillSecurityConstraints security;

    DOMString parentId;
};

// 完整的静态契约对象 (存入 OPFS 后的形态)
dictionary WebSkill : WebSkillOptions {
    required DOMString id;
    required unsigned long long createdAt;
    unsigned long long updatedAt;
};

// =========================================================
// 5. 核心接口定义
// =========================================================

// WebSkill 管理器 (负责基于 OPFS 的增删改查与校验)
[Exposed=(Window,Worker)]
interface WebSkillManager {
    Promise<WebSkill?> get(DOMString skillId);
    Promise<DOMString> create(WebSkillOptions options);
    Promise<boolean> update(DOMString skillId, WebSkillOptions options);
    Promise<boolean> remove(DOMString skillId);

    // 核心:校验 UI 约束和 MCP 约束是否符合安全底线
    Promise<boolean> validate(DOMString skillId);
    Promise<sequence<WebSkill>> query(DOMString? keyword);
};

// 【挂载全局属性】
partial interface Window {
    [SameObject] readonly attribute WebSkillManager skills;
};

通过声明式约束,我们将 WebSkill 严格定义为了一个安全沙箱(Sandbox):

  • 高度结构化的绑定: 有别于普通的本地存储,WebSkillOptions 强制拆分了 uiSchemamcpBindings。这意味着当 LLM 读取到这份 Skill 时,它不仅知道“要做什么”,还明确知道“缺参数时该用什么 Schema 让前端画表单(Generative UI)”,以及“收集完参数后只能调用哪几个声明过的底层工具(WebMCP)”。

  • 纵深防御内置化: WebSkillSecurityConstraints 被直接嵌入到 Skill 级别。如果一个 Skill 绑定了提取敏感数据的 WebMCP 工具,它必须在创建时就在 domainAllowlist 中锁死数据流向,防止因“意图碰撞”导致的恶意指令将数据暗中发送到第三方服务器。

  • 渐进式披露的基础: 这种结构允许系统在接收到用户意图后,先通过 description 进行轻量级的路由匹配。只有在成功匹配后,再按需加载具体的 mcpBindingsuiSchema,从而极大地节省了上下文 Token 的消耗。

以下是基于 OPFS 的参考实现代码,该代码遵循上述 IDL 规范,并重点实现了 validate 方法,以体现对 Generative UI 和 WebMCP 绑定的系统架构级校验:

/**
 * 模拟 AES-256-GCM 静态加密服务,确保本地 OPFS 存储的数据隐私
 */
const CryptoService = {
  async encrypt(dataObj) {
    return new TextEncoder().encode(JSON.stringify(dataObj));
  },

  async decrypt(buffer) {
    return JSON.parse(new TextDecoder().decode(buffer));
  }
};

class WebSkillManagerImpl {
  constructor() {
    this.dirName = 'webskills_vault';
  }

  async _getSkillDirectory() {
    const root = await navigator.storage.getDirectory();
    return await root.getDirectoryHandle(this.dirName, { create: true });
  }

  _generateId() {
    return crypto.randomUUID();
  }

  async get(skillId) {
    try {
      const dirHandle = await this._getSkillDirectory();
      const fileHandle = await dirHandle.getFileHandle(`${skillId}.json`, { create: false });
      const file = await fileHandle.getFile();
      const buffer = await file.arrayBuffer();
      return await CryptoService.decrypt(buffer);
    } catch (error) {
      return null; // 未找到
    }
  }

  async create(options) {
    const skillId = `skill_${this._generateId()}`;
    const skillData = { id: skillId, createdAt: Date.now(), ...options };

    const dirHandle = await this._getSkillDirectory();
    const fileHandle = await dirHandle.getFileHandle(`${skillId}.json`, { create: true });
    const writable = await fileHandle.createWritable();

    await writable.write(await CryptoService.encrypt(skillData));
    await writable.close();

    return skillId;
  }

  async update(skillId, options) {
    const existingData = await this.get(skillId);
    if (!existingData) return false;

    const updatedData = { ...existingData, ...options, updatedAt: Date.now() };

    try {
      const dirHandle = await this._getSkillDirectory();
      const fileHandle = await dirHandle.getFileHandle(`${skillId}.json`, { create: false });
      const writable = await fileHandle.createWritable();

      await writable.write(await CryptoService.encrypt(updatedData));
      await writable.close();
      return true;
    } catch (e) {
      return false;
    }
  }

  async remove(skillId) {
    try {
      const dirHandle = await this._getSkillDirectory();
      await dirHandle.removeEntry(`${skillId}.json`);
      return true;
    } catch (e) {
      return false;
    }
  }

  /**
   * 核心校验逻辑:验证 Skill 是否符合 "前端原生架构" 的系统性要求
   */

  async validate(skillId) {
    const skill = await this.get(skillId);
    if (!skill) return false;

    // 1. 基础元数据校验
    if (!skill.name || !skill.description || !skill.content) {
      console.error(`[验证失败] 缺失基础路由元数据: ${skillId}`);
      return false;
    }

    // 2. 生成式 UI (GenUI) 契约校验
    if (skill.uiSchema) {
      if (!skill.uiSchema.parameterSchema || typeof skill.uiSchema.parameterSchema !== 'object') {
        console.error(`[验证失败] 配置了 uiSchema 但未提供有效的 parameterSchema: ${skillId}`);
        return false;
      }
    }

    // 3. WebMCP 绑定与安全约束的联动校验 (防范意图碰撞)
    if (skill.mcpBindings && skill.mcpBindings.toolNames?.length > 0) {
      const security = skill.security || {};

      // 强制规则:如果绑定了底层操作工具,必须提供物理级的域名白名单
      if (!security.domainAllowlist || security.domainAllowlist.length === 0) {
        console.error(`[安全拦截] Skill 绑定了 WebMCP 工具,但未配置 domainAllowlist。拒绝通过校验。`);
        return false;
      }

      // 提示:高危工具建议开启人类在环
      if (!security.requiresHumanConfirmation) {
        console.warn(`[安全警告] Skill 调用了底层工具但未开启 requiresHumanConfirmation (人类在环)。`);
      }
    }

    return true;
  }

  async query(keyword = '') {
    const dirHandle = await this._getSkillDirectory();
    const results = [];

    for await (const [name, handle] of dirHandle.entries()) {
      if (handle.kind === 'file' && name.endsWith('.json')) {
        const file = await handle.getFile();
        const buffer = await file.arrayBuffer();
        const skillData = await CryptoService.decrypt(buffer);

        if (!keyword || skillData.description.includes(keyword) || skillData.name.includes(keyword)) {
          results.push(skillData);
        }
      }
    }
    return results;
  }
}

// 挂载至全局 Window

if (typeof window !== 'undefined') {
  Object.defineProperty(window, 'skills', {
    value: new WebSkillManagerImpl(),
    writable: false,
    enumerable: true,
    configurable: false
  });
}

这份参考实现代码为 Skill 技能管理器赋予了底层支撑:

  • 天然的沙箱隔离: 借助 navigator.storage.getDirectory(),这些 WebSkill 只有当前 Origin 的应用代码可以访问。即使用户误入恶意钓鱼网站,对方也无法跨域读取或篡改 webskills_vault 目录下的内容,奠定了“绝对隔离的隐私 AI 闭环”的物理基础。

  • 极低的 I/O 损耗与零状态同步: 数据直接存储在本地文件系统,Agent 读取技能规范的延迟近乎为零。这彻底消除了传统架构中 Agent 需不断向后端发送 REST API 拉取 Skill 描述所带来的网络超时瓶颈。

  • 加密(Auth Vault)集成预留: 通过 CryptoService 进行了 AES-256-GCM 静态加密拦截模拟。在实际商业应用中,本地不仅存储逻辑,还可能存储与此 Skill 相关的用户敏感凭证。加密机制确保了即便设备被物理攻破,没有正确的密钥也无法解析 OPFS 中的文件。

  • 架构守门员(validate 方法): 这是整个实现最核心的业务逻辑,充当了系统安全的第一道防线。如果业务侧试图写入一个调用了高危工具(如删除操作)却没有配置 domainAllowlist 的技能,validate 将直接拦截,从根本上阻断提示词注入(Prompt Injection)导致数据非法外传的可能性。

四、 WebSkill 的安全防御体系

赋予 Web AI 应用直接读取网页内容、加载 WebSkill 并通过 WebMCP 操作底层 DOM 的权限,不可避免地会引入安全盲区——特别是间接提示词注入与意图碰撞。

意图碰撞的威胁机理: 当 Agent 在前端运行时,它不仅会读取预设的 WebSkill,还会处理当前网页上大量不受信任的内容(如用户评论、第三方广告、日历邀请等)。由于 LLM 存在上下文推理的局限性,它无法绝对可靠地区分“合法的业务系统指导”与“网页注入的隐蔽恶意指令”。例如:攻击者可以利用“任务对齐注入”技术,将恶意指令巧妙伪装成有用的任务补充。例如,攻击者仅需通过向用户发送一个包含隐藏指令的会议邀请。当 Agent 协助用户执行“接受会议”这一初始意图时,恶意指令便与其发生了“意图碰撞”。Agent 可能会误以为“读取 WebSkill 文档并发送”是完成会议接受的必要步骤,进而利用 window.skills 越权读取敏感的业务技能数据,并将其静默拼接在 URL 中外传至攻击者服务器。

多层纵深的防御策略: 为了确保 WebSkill 架构的生存能力与系统级安全,开发者必须抛弃对 LLM “安全对齐”的盲目信任,转而在架构底层建立坚实的多层防御机制:

  1. 代码级硬边界与执行约束: 在 WebMCP SDK 底层实施绝对的权限阻断。强制引入严格的域名白名单机制,限制 WebMCP 工具只能向受信任的源发送网络请求,从物理层面上彻底切断数据外传通道。

  2. 人类在环(Human-in-the-Loop)强制确认: 针对任何涉及敏感 DOM 操作、本地文件读取、密码重置或跨域请求的高危 WebMCP 调用,系统必须通过生成式 UI 强制弹出不可绕过的原生授权弹窗。将最终决策权交还给人类用户,剥夺 Agent 在敏感链路上的自治权。

  3. 内容边界标记: 在将不可控的网页数据传入 LLM 之前,系统应通过包裹明确的定界符,帮助模型在语义层面区分“受信任的 WebSkill 指令”和“不受信任的 Web DOM 文本”,从而大幅降低提示词被语义劫持的概率。

结语

以内置 LLM 为中枢、WebSkill 为业务技能、生成式 UI 为交互桥梁、WebMCP 为底层执行工具的全前端闭环生态,代表了 Web AI 架构演进的必然方向。

该架构不仅优雅地化解了系统复杂性带来的“上下文窗口爆炸”难题,更通过前端本地化,为企业赋予了前所未有的敏捷迭代能力与高标准的数据隐私保障。在妥善构建抵御“意图碰撞”等新型攻击的安全边界前提下,前端原生的 WebSkill 将打破传统云端技能的运行桎梏,成为驱动下一代智能化、个性化 Web 应用的核心引擎。

关于 OpenTiny NEXT

OpenTiny NEXT 是一套企业智能前端开发解决方案,以生成式 UI 和 WebMCP 两大核心技术为基础,对现有传统的 TinyVue 组件库、TinyEngine 低代码引擎等产品进行智能化升级,构建出面向 Agent 应用的前端 NEXT-SDKs、AI Extension、TinyRobot智能助手、GenUI等新产品,实现AI理解用户意图自主完成任务,加速企业应用的智能化改造。

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
NEXT SDK 代码仓库:github.com/opentiny/we… (欢迎star ⭐)

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

昨天 — 2026年4月20日技术

Bash Strict Mode: set -euo pipefail Explained

By default, a Bash script will keep running after a command fails, treat unset variables as empty strings, and hide errors inside pipelines. That is convenient for one-off commands in an interactive shell, but inside a script it is a recipe for silent corruption: a failed backup step that still reports success, an unset path that expands to rm -rf /, a curl | sh pipeline that swallows a 500 error.

Bash strict mode is a short set of flags you put at the top of a script to make the shell fail loudly instead. This guide explains each flag in set -euo pipefail, shows what it changes with real examples, and covers the cases where you need to turn it off.

The Strict Mode Line

You will see this line at the top of many modern shell scripts:

script.shsh
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

Each flag addresses a different class of silent failure:

  • set -e exits immediately when a command returns a non-zero status.
  • set -u treats references to unset variables as an error.
  • set -o pipefail makes a pipeline fail if any command in it fails, not just the last.
  • IFS=$'\n\t' narrows word splitting so unquoted expansions do not split on spaces.

You can enable the options separately, but in practice they are almost always used together.

set -e: Exit on Error

Without set -e, a failing command in a script does not stop execution. The script happily continues to the next line:

no-strict.shsh
#!/usr/bin/env bash
cp /does/not/exist /tmp/dest
echo "Backup complete"

Running it prints both the error and the success message:

Terminal
./no-strict.sh
output
cp: cannot stat '/does/not/exist': No such file or directory
Backup complete

The script exits with status 0, so any caller thinks the backup worked. Adding set -e changes the behavior:

strict.shsh
#!/usr/bin/env bash
set -e
cp /does/not/exist /tmp/dest
echo "Backup complete"
Terminal
./strict.sh
echo $?
output
cp: cannot stat '/does/not/exist': No such file or directory
1

The script stops at the failing cp, never prints “Backup complete”, and exits with a non-zero status. Anyone scheduling this through cron or a CI pipeline now gets a real failure signal.

Opting Out for a Single Command

Sometimes you expect a command to fail and want to handle the result yourself. Append || true to tell set -e to ignore the exit status of that one command:

sh
grep "pattern" config.txt || true

You can also use if or &&, which set -e treats as deliberate checks:

sh
if ! grep -q "pattern" config.txt; then
 echo "pattern missing"
fi

This is the canonical way to check for something without exiting the script when it is not there.

set -u: Fail on Unset Variables

A classic shell footgun is a typo in a variable name. Without strict mode, Bash silently expands the misspelled variable to an empty string:

unset.shsh
#!/usr/bin/env bash
TARGET_DIR="/var/backups"
rm -rf "$TARGE_DIR/old"

The script deletes /old because $TARGE_DIR is empty. With set -u, the same script exits before the rm runs:

unset-strict.shsh
#!/usr/bin/env bash
set -u
TARGET_DIR="/var/backups"
rm -rf "$TARGE_DIR/old"
output
./unset-strict.sh: line 4: TARGE_DIR: unbound variable

Handling Optional Variables

Environment variables that may or may not be set need a default to play nicely with set -u. The ${VAR:-default} syntax provides one without touching the original:

sh
PORT="${PORT:-8080}"
echo "Listening on $PORT"

If $PORT is unset or empty, the script uses 8080. If it is set, the original value is kept.

set -o pipefail: Catch Failures Inside Pipelines

By default, the exit status of a pipeline is the exit status of the last command. Everything to the left can fail silently:

sh
curl https://bad.example.com/data | tee data.txt

If curl fails with a 404, the pipeline still exits 0 because tee wrote its (empty) input successfully. With set -o pipefail, the pipeline adopts the first non-zero exit status from any command in it:

pipefail.shsh
#!/usr/bin/env bash
set -o pipefail
curl https://bad.example.com/data | tee data.txt
echo "Exit: $?"
output
curl: (6) Could not resolve host: bad.example.com
Exit: 6

This is the one flag that fixes the most “my script said it succeeded but clearly did not” bugs, and it is the one most often forgotten when people add just set -e.

IFS: Safer Word Splitting

The Internal Field Separator (IFS) controls how Bash splits unquoted expansions into words. The default is space, tab, and newline, which means a filename with a space in it gets split into two arguments:

sh
files="one two.txt three.txt"
for f in $files; do
 echo "$f"
done
output
one
two.txt
three.txt

Setting IFS=$'\n\t' removes the space from the separator list. You still get clean splitting on newlines (useful for reading find or ls output) and on tabs (useful for TSV data), but spaces inside values are preserved.

Even with a narrower IFS, always quote your expansions ("$var", "${array[@]}"). IFS tightening is a safety net, not a replacement for quoting.

When Strict Mode Gets in the Way

Strict mode is opinionated, and a few situations push back. The most common one is reading a file line by line with a while loop and a counter:

sh
count=0
while read -r line; do
 count=$((count + 1))
done < input.txt

That works fine, but if you increment with ((count++)) inside set -e, the script exits on the first iteration because ((count++)) returns the pre-increment value (0), which Bash treats as a failure. Use count=$((count + 1)) or ((count++)) || true to stay compatible.

Another common case is probing for a command:

sh
if ! command -v jq >/dev/null; then
 echo "jq is not installed"
 exit 1
fi

Using command -v inside an if is safe even under set -e, because set -e does not trigger on commands in conditional contexts.

If you need to temporarily disable a flag for a specific block, turn it off and back on:

sh
set +e
some_flaky_command
result=$?
set -e

This is cleaner than sprinkling || true everywhere when you have a block of commands that need softer error handling.

A Minimal Strict Script Template

Use this as a starting point for new scripts:

template.shsh
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

# Script body goes here.

It is four lines, and it catches the majority of silent failures that cause shell scripts to misbehave in production.

Quick Reference

Flag What it does
set -e Exit when any command returns a non-zero status
set -u Treat unset variables as an error
set -o pipefail A pipeline fails if any command in it fails
IFS=$'\n\t' Word-split only on newline and tab, not on spaces
set +e / set +u Turn the matching flag back off for a block
command || true Ignore the exit status of a single command
${VAR:-default} Provide a default for optional variables

Troubleshooting

Script exits silently with no error message
A command is failing under set -e without printing anything. Run the script with bash -x script.sh to trace execution and see which line aborts.

unbound variable on a variable that sometimes is set
Replace the reference with ${VAR:-} to provide an empty default, or ${VAR:-fallback} to provide a meaningful one.

Pipeline fails on a command that is supposed to stop early
Commands like head can cause the producer to receive SIGPIPE, which pipefail treats as a failure. Either redesign the pipeline or wrap the producer in || true when the early exit is expected.

Strict mode breaks a sourced script
The set flags apply to the current shell. If you source a script that expects the old behavior, either fix the sourced script or wrap the source call between set +e and set -e.

FAQ

Should every Bash script use strict mode?
For scripts that run unattended (cron jobs, CI steps, deployment scripts), yes. For short interactive helpers, the flags are still useful, but the cost of a missed edge case is lower.

Does strict mode work in sh or dash?
set -e and set -u are POSIX, so they work in any compliant shell. set -o pipefail is a Bash extension that also works in Zsh and Ksh, but not in pure POSIX sh or dash.

Is set -e really that unreliable?
set -e has well-known edge cases, especially around functions and subshells. It is not a replacement for explicit error handling in critical paths, but combined with pipefail and -u it catches far more bugs than it causes.

How do I pass set -euo pipefail to a one-liner?
Use bash -euo pipefail -c 'your command' when invoking Bash from another program such as ssh or a Makefile.

Conclusion

set -euo pipefail and a tighter IFS catch many of the silent failures that make shell scripts hard to trust. Pair strict mode with clear error handling using Bash functions and a proper shebang line when you want scripts to fail early and predictably.

uni-app 全能日历组件,支持农历、酒店预订、打卡签到、价格日历多种场景

2026年4月20日 15:15

一、uView Pro 的 Calendar 组件

在 uni-app 开发中,日期选择是一个高频需求场景。无论是酒店预订的入住离店时间选择、电商平台的商品预约、还是日常应用的打卡签到,一个功能完善、体验优秀的日历组件都是必不可少的。

uView Pro 作为 uni-app 生态中备受关注的 Vue3 组件库,其 Calendar 日历组件 经过了多个版本的迭代优化,从最初的基础日期选择,逐步演进为支持农历显示、打卡签到、节假日标记、自定义价格日历等丰富功能的综合型组件。

本文将深入解析 uView Pro Calendar 组件的核心特性、实现原理以及实际应用场景,帮助你快速掌握这个强大的日期选择利器。

二、组件概览:功能特性总览

0.png

uView Pro 的 Calendar 日历组件具有以下核心特性:

基础功能

  • ✅ 支持单日期选择和日期范围选择两种模式
  • ✅ 底部弹窗和页面嵌入两种展示方式
  • ✅ 年月切换导航,支持自定义年份范围
  • ✅ 日期范围限制,防止选择无效日期

进阶功能

  • ✅ 农历显示支持,自动计算农历日期
  • ✅ 打卡签到模式,支持已打卡/未打卡状态展示
  • ✅ 节假日和加班日标记,显示"休"/"班"标识
  • ✅ 内置中国传统节日,支持自定义节日配置
  • ✅ 自定义日期内容插槽,适用于价格日历等场景

交互优化

  • ✅ 默认选中今天,支持指定默认日期
  • ✅ 只读模式,禁止日期选择
  • ✅ 选中效果可配置,适应不同视觉需求

三、基础使用:快速上手

3.1 单日期选择模式

单日期选择是最常用的场景,比如选择生日、预约日期等。

1.png

<template>
    <view>
        <u-calendar v-model="show" mode="date" @change="onChange"></u-calendar>
        <u-button @click="show = true">选择日期</u-button>
    </view>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { CalendarChangeDate } from 'uview-pro/types/global'

const show = ref(false)

function onChange(e: CalendarChangeDate) {
    console.log('选择的日期:', e.result)
    console.log('星期:', e.week)
    console.log('是否今天:', e.isToday)
}
</script>

回调参数说明:

属性 说明 类型
year 选择的年份 number
month 选择的月份 number
day 选择的日期 number
result 格式化的日期字符串,如 "2024-06-15" string
week 星期文字,如 "星期六" string
isToday 是否选择了今天 boolean

3.2 日期范围选择模式

范围选择适用于酒店预订、行程规划等需要起止时间的场景。

2.png

<template>
    <u-calendar 
        v-model="show" 
        mode="range" 
        start-text="入住"
        end-text="离店"
        @change="onRangeChange"
    >
        <template #tooltip>
            <view class="tip">请选择入住和离店时间</view>
        </template>
    </u-calendar>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { CalendarChangeRange } from 'uview-pro/types/global'

const show = ref(false)

function onRangeChange(e: CalendarChangeRange) {
    console.log('入住日期:', e.startDate)
    console.log('离店日期:', e.endDate)
    console.log('共', e.endDay - e.startDay + 1, '晚')
}
</script>

范围模式回调参数:

属性 说明
startDate / endDate 起始/结束日期字符串
startYear / endYear 起始/结束年份
startMonth / endMonth 起始/结束月份
startDay / endDay 起始/结束日期
startWeek / endWeek 起始/结束星期

四、进阶功能详解

4.1 农历显示

Calendar 组件内置了农历计算功能,开启后会自动显示农历日期。

6.png

<u-calendar 
    v-model="show" 
    mode="date" 
    :show-lunar="true"
    @change="onLunarChange"
></u-calendar>

开启农历后,回调参数会增加 lunar 对象:

{
    day: 15,
    month: 6,
    result: "2024-06-15",
    lunar: {
        dayCn: '初十',      // 农历日
        monthCn: '五月',    // 农历月
        year: 2024,         // 农历年
        weekCn: "星期六"    // 农历星期
    }
}

农历显示会自动处理闰月、大小月等复杂逻辑,无需开发者关心底层实现。

4.2 页面嵌入模式

除了弹窗模式,组件还支持直接嵌入页面显示,适用于需要常驻展示日历的场景。

<template>
    <view class="calendar-page">
        <u-calendar 
            :is-page="true" 
            mode="date"
            @change="onChange"
        ></u-calendar>
    </view>
</template>

页面模式的特点:

  • 不显示弹窗和确定按钮
  • 选择日期后自动触发 change 事件
  • 支持所有其他功能(农历、打卡、节假日等)

7.png

4.3 打卡签到模式

打卡签到日历也是近期咨询我比较多的功能,Calendar 组件专门为此设计了打卡模式。

3.png

<template>
    <u-calendar
        :is-page="true"
        :checkin-mode="true"
        :checked-dates="checkedDates"
        :today-checked="todayChecked"
    ></u-calendar>
</template>

<script setup>
import { ref } from 'vue'

// 已打卡日期列表
const checkedDates = ref([
    '2024-01-01', 
    '2024-01-02', 
    '2024-01-03',
    '2024-01-05'
])

// 今日打卡状态(优先级高于自动判断)
const todayChecked = ref(true)
</script>

打卡模式的显示规则:

  1. 今日已打卡:绿色圆形背景,显示白色对勾
  2. 其他已打卡日期:橙色圆形背景,显示日期
  3. 未打卡日期checkin-mode 为 true 时):灰色圆形背景

颜色自定义:

属性 说明 默认值
checked-bg-color 已打卡日期背景色 橙色(warning)
today-checked-bg-color 今日已打卡背景色 绿色(success)
unchecked-bg-color 未打卡日期背景色 灰色(light)

4.4 节假日与加班日标记

组件支持显示节假日和加班日标记,方便用户了解日期属性。

<template>
    <u-calendar
        :is-page="true"
        :holidays="holidays"
        :workdays="workdays"
    ></u-calendar>
</template>

<script setup>
import { ref } from 'vue'

// 节假日(元旦假期)
const holidays = ref(['2024-01-01', '2024-01-02'])

// 加班日(调休上班)
const workdays = ref(['2024-01-06', '2024-01-07'])
</script>

显示效果:

  • 节假日:日期右上角显示红色"休"字
  • 加班日:日期右上角显示蓝色"班"字
  • 选中状态下,"休"/"班"字变为白色

4.png

4.5 节日显示

组件内置了中国传统节日,同时支持自定义节日配置。

内置节日(show-festival 为 true 时自动显示):

  • 元旦(1月1日)
  • 情人节(2月14日)
  • 妇女节(3月8日)
  • 植树节(3月12日)
  • 愚人节(4月1日)
  • 劳动节(5月1日)
  • 青年节(5月4日)
  • 儿童节(6月1日)
  • 建党节(7月1日)
  • 建军节(8月1日)
  • 教师节(9月10日)
  • 国庆节(10月1日)
  • 光棍节(11月11日)
  • 圣诞节(12月25日)

自定义节日:

<template>
    <u-calendar
        :is-page="true"
        :show-festival="true"
        :festivals="customFestivals"
    ></u-calendar>
</template>

<script setup>
import { ref } from 'vue'

const customFestivals = ref({
    // 每年固定节日(MM-DD 格式)
    '04-04': '清明节',
    '05-05': '端午节',
    '08-15': '中秋节',
    
    // 特定年份节日(YYYY-MM-DD 格式)- 优先级更高
    '2025-04-04': '清明节(2025)',
    
    // 覆盖内置节日(传入空字符串不显示)
    '02-14': '',
})
</script>

优先级规则:

  1. 特定年份格式(YYYY-MM-DD)优先级最高
  2. 每年固定格式(MM-DD)次之
  3. 内置节日优先级最低

4.6 自定义日期内容:价格日历

通过 date 插槽,可以完全自定义每个日期的显示内容,常用于电商价格日历场景。

5.png

<template>
    <u-calendar 
        :is-page="true" 
        mode="date"
        :use-date-slot="true"
    >
        <template #date="{ date }">
            <text :class="getPriceClass(date)">
                {{ getPriceText(date) }}
            </text>
        </template>
    </u-calendar>
</template>

<script setup>
import { ref } from 'vue'

// 价格数据
const priceMap = ref({
    '2024-01-01': 299,
    '2024-01-02': 399,
    '2024-01-03': 359,
    // ...
})

function getPriceText(date) {
    if (date.isToday) return '今天'
    const price = priceMap.value[date.date]
    return price ? ${price}` : ''
}

function getPriceClass(date) {
    if (date.isSelected) return 'price-selected'
    if (date.isToday) return 'price-today'
    return 'price-normal'
}
</script>

<style scoped>
.price-today {
    color: #19be6b;
    font-weight: bold;
}
.price-normal {
    color: #909399;
    font-size: 22rpx;
}
.price-selected {
    color: #ffffff;
}
</style>

插槽作用域参数:

属性 说明 类型
date.year 年份 number
date.month 月份 number
date.day 日期 number
date.date 完整日期字符串 string
date.week 星期文字 string
date.isToday 是否今天 boolean
date.isHoliday 是否节假日 boolean
date.isWorkday 是否加班日 boolean
date.isChecked 是否已打卡 boolean
date.isSelected 是否选中 boolean
date.lunar 农历信息 object

五、核心实现原理浅析

5.1 日历渲染逻辑

Calendar 组件的日历渲染基于以下核心算法:

// 获取某月天数
function getMonthDay(year: number, month: number) {
    return new Date(year, month, 0).getDate()
}

// 获取某月第一天星期几(0-6)
function getWeekday(year: number, month: number) {
    let date = new Date(`${year}/${month}/01 00:00:00`)
    return date.getDay()
}

渲染流程:

  1. 计算当月第一天是星期几,生成前置空白格子
  2. 计算当月总天数,生成日期格子
  3. 根据选中状态计算每个格子的样式
  4. 如果有农历,调用农历转换库计算农历日期

5.2 农历计算

组件使用了独立的农历计算工具 Calendar.solar2lunar,将公历日期转换为农历:

function getLunar(year: any, month: any, day: any) {
    const val = Calendar.solar2lunar(year, month, day)
    return {
        dayCn: val.IDayCn,      // 农历日(初十、廿三等)
        monthCn: val.IMonthCn,  // 农历月(正月、五月等)
        weekCn: val.ncWeek,     // 农历星期
        day: val.lDay,          // 农历日数字
        month: val.lMonth,      // 农历月数字
        year: val.lYear         // 农历年
    }
}

5.3 范围选择逻辑

范围选择采用两次点击确定起止时间的交互方式:

function dateClick(dayIdx: number) {
    const d = dayIdx + 1
    const date = `${year.value}-${month.value}-${d}`
    
    if (props.mode == 'range') {
        // 判断是设置开始日期还是结束日期
        const compare = new Date(date).getTime() < new Date(startDate.value).getTime()
        
        if (isStart.value || compare) {
            // 设置开始日期
            startDate.value = date
            isStart.value = false
        } else {
            // 设置结束日期
            endDate.value = date
            isStart.value = true
            // 触发回调
            if (props.isPage) btnFix(true)
        }
    }
}

六、实际应用场景

6.1 酒店预订日历

<u-calendar 
    v-model="show" 
    mode="range"
    start-text="入住"
    end-text="离店"
    :min-date="minDate"
    :max-date="maxDate"
    @change="onDateChange"
>
    <template #tooltip>
        <view class="hotel-tip">
            <text>请选择入住和离店日期</text>
            <text class="sub">入住时间14:00后,离店时间12:00前</text>
        </view>
    </template>
</u-calendar>

6.2 健身打卡应用

<u-calendar
    :is-page="true"
    :checkin-mode="true"
    :checked-dates="monthCheckins"
    :today-checked="todayChecked"
    :show-lunar="true"
    @change="onCheckin"
></u-calendar>

6.3 航班价格日历

<u-calendar 
    :is-page="true"
    mode="date"
    :use-date-slot="true"
    :default-select-today="false"
    :is-active-current="false"
>
    <template #date="{ date }">
        <view class="flight-price">
            <text class="day">{{ date.day }}</text>
            <text class="price" v-if="getPrice(date.date)">
                ¥{{ getPrice(date.date) }}
            </text>
        </view>
    </template>
</u-calendar>

6.4 日程管理应用

<u-calendar
    :is-page="true"
    :show-festival="true"
    :festivals="customFestivals"
    :holidays="holidays"
    :workdays="workdays"
    :default-date="selectedDate"
    @change="onSelectDate"
></u-calendar>

七、API 完整参考

Props 属性

参数 说明 类型 默认值
v-model 控制弹窗显示/隐藏 boolean false
mode 选择模式:date 单选 / range 范围 string date
is-page 是否在页面中直接显示 boolean false
show-lunar 是否显示农历 boolean false
readonly 是否只读 boolean false
default-date 默认选中日期(单选模式) string -
start-date 默认开始日期(范围模式) string -
end-date 默认结束日期(范围模式) string -
default-select-today 默认选中今天 boolean true
min-date 最小可选日期 string 1950-01-01
max-date 最大可选日期 string 今天
min-year 最小可选年份 number/string 1950
max-year 最大可选年份 number/string 2050
change-year 是否显示年份切换按钮 boolean true
change-month 是否显示月份切换按钮 boolean true
active-bg-color 选中日期背景色 string 主题色
active-color 选中日期文字颜色 string 白色
range-bg-color 范围内日期背景色 string 主题色浅
range-color 范围内日期文字颜色 string 主题色
start-text 开始日期提示文字 string 开始
end-text 结束日期提示文字 string 结束
tool-tip 顶部提示文字 string 选择日期
closeable 是否显示关闭图标 boolean true
mask-close-able 点击遮罩是否关闭 boolean true
safe-area-inset-bottom 底部安全区适配 boolean false
border-radius 弹窗圆角 number/string 20
z-index 弹窗层级 number/string 10075
is-active-current 选中日期是否高亮 boolean true
checkin-mode 是否启用打卡模式 boolean false
checked-dates 已打卡日期列表 array []
today-checked 今日是否已打卡 boolean false
checked-bg-color 已打卡背景色 string 橙色
today-checked-bg-color 今日已打卡背景色 string 绿色
unchecked-bg-color 未打卡背景色 string 灰色
holidays 节假日列表 array []
workdays 加班日列表 array []
holiday-color 节假日文字颜色 string 红色
workday-color 加班日文字颜色 string 蓝色
show-festival 是否显示内置节日 boolean false
festivals 自定义节日配置 object {}
festival-color 节日文字颜色 string 主题色
use-date-slot 是否启用日期插槽 boolean false

Events 事件

事件名 说明 回调参数
change 日期选择完成时触发 CalendarChangeDate / CalendarChangeRange

Slots 插槽

名称 说明
tooltip 自定义顶部提示内容
date 自定义日期内容(作用域插槽)

更多功能及用法参考 uView Pro 官方文档 uviewpro.cn

八、总结

uView Pro 的 Calendar 日历组件是一个功能全面、设计精良的日期选择解决方案。从基础的单日期选择到复杂的打卡签到、价格日历,这些都能轻松应对。

使用建议:

  1. 选择合适的展示模式:弹窗模式适合临时选择,页面模式适合常驻展示
  2. 合理利用默认选中:通过 default-datedefault-select-today 提升用户体验
  3. 注意日期格式:所有日期参数统一使用 YYYY-MM-DD 格式
  4. 自定义插槽优先级:使用 date 插槽时会覆盖农历、节日等默认显示
  5. 打卡模式注意today-checked 优先级高于 checkedDates 的自动判断

功能使用建议:

  • 如需农历功能,请确保使用支持该功能的版本
  • 如需打卡签到、节假日、自定义插槽等高级功能,请使用最新版本

如果你正在开发 uni-app 项目,需要一个功能强大、易于定制的日历组件,uView Pro 的 Calendar 值得一试,快来体验一下。

九、资源

  • 📚 uView Pro 官方文档:uviewpro.cn
  • 📦 开源地址:GithubGitee,欢迎 Star
  • 💬 技术交流:如有问题欢迎在评论区留言讨论

本文基于 uView Pro v0.5.17 版本编写,部分功能可能需要更新版本支持。

❌
❌