普通视图

发现新文章,点击刷新页面。
昨天 — 2025年7月25日掘金 前端

一张 8K 海报差点把首屏拖垮

作者 前端微白
2025年7月25日 21:13

你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。
老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。


解决方案:三条技术路线,你全踩了一遍

1. 最偷懒:原生 loading="lazy"

一行代码就能跑,浏览器帮你搞定。

<img
  src="https://cdn.xxx.com/poster1.jpg"
  loading="lazy"
  decoding="async"
  width="800" height="450"
/>

🔍 关键决策点

  • loading="lazy" 2020 年后现代浏览器全覆盖,IE 全军覆没。
  • 必须写死 width/height,否则 CLS 会抖成 PPT。

适用场景:内部系统、用户浏览器可控,且图片域名已开启 Accept-Ranges: bytes(支持分段加载)。


2. 最稳妥:scroll 节流 + getBoundingClientRect

老项目里还有 5% 的 IE11 用户,我们只能回到石器时代。

// utils/lazyLoad.js
const lazyImgs = [...document.querySelectorAll('[data-src]')];
let ticking = false;

const loadIfNeeded = () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    lazyImgs.forEach((img, idx) => {
      const { top } = img.getBoundingClientRect();
      if (top < window.innerHeight + 200) { // 提前 200px 预加载
        img.src = img.dataset.src;
        lazyImgs.splice(idx, 1); // 🔍 及时清理,防止重复计算
      }
    });
    ticking = false;
  });
};

window.addEventListener('scroll', loadIfNeeded, { passive: true });

🔍 关键决策点

  • requestAnimationFrame 把 30 ms 的节流降到 16 ms,肉眼不再掉帧。
  • 预加载阈值 200 px,实测 4G 网络滑动不白屏。

缺点:滚动密集时 CPU 占用仍高,列表越长越卡。


3. 最优雅:IntersectionObserver 精准观测

新项目直接上 Vue3 + TypeScript,我们用 IntersectionObserver 做统一调度。

// composables/useLazyLoad.ts
export const useLazyLoad = (selector = '.lazy') => {
  onMounted(() => {
    const imgs = document.querySelectorAll<HTMLImageElement>(selector);
    const io = new IntersectionObserver(
      (entries) => {
        entries.forEach((e) => {
          if (e.isIntersecting) {
            const img = e.target as HTMLImageElement;
            img.src = img.dataset.src!;
            img.classList.add('fade-in'); // 🔍 加过渡动画
            io.unobserve(img);            // 观测完即销毁
          }
        });
      },
      { rootMargin: '100px', threshold: 0.01 } // 🔍 提前 100px 触发
    );
    imgs.forEach((img) => io.observe(img));
  });
};
  1. 浏览器合成线程把「目标元素与视口交叉状态」异步推送到主线程。
  2. 主线程回调里只做一件事:把 data-src 搬到 src,然后 unobserve
  3. 整个滚动期间,零事件监听,CPU 占用 < 1%。

原理剖析:从「事件驱动」到「观测驱动」

维度 scroll + 节流 IntersectionObserver
触发时机 高频事件(~30 ms) 浏览器内部合成帧后回调
计算量 每帧遍历 N 个元素 仅通知交叉元素
线程占用 主线程 合成线程 → 主线程
兼容性 IE9+ Edge79+(可 polyfill)
代码体积 0.5 KB 0.3 KB(含 polyfill 2 KB)

一句话总结:把「我每隔 16 ms 问一次」变成「浏览器你告诉我啥时候到」。


应用扩展:把懒加载做成通用指令

在 Vue3 项目里,我们干脆封装成 v-lazy 指令,任何元素都能用。

// directives/lazy.ts
const lazyDirective = {
  mounted(el: HTMLImageElement, binding) {
    const io = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          el.src = binding.value; // 🔍 binding.value 就是 data-src
          io.disconnect();
        }
      },
      { rootMargin: '50px 0px' }
    );
    io.observe(el);
  },
};

app.directive('lazy', lazyDirective);

模板里直接写:

<img v-lazy="item.url" :alt="item.title" />

举一反三:三个变体场景思路

  1. 无限滚动列表
    IntersectionObserver 绑在「加载更多」占位节点上,触底即请求下一页,再把新节点继续 observe,形成递归观测链。

  2. 广告曝光统计
    广告位 50% 像素可见且持续 1 s 才算一次曝光。设置 threshold: 0.5 并在回调里用 setTimeout 延迟 1 s 上报,离开视口时 clearTimeout

  3. 背景图懒加载
    背景图没有 src,可以把真实地址塞在 style="--bg: url(...)",交叉时把 background-image 设成 var(--bg),同样零回流。


小结

  • 浏览器新特性能救命的,就别再卷节流函数了。
  • 写死尺寸、加过渡、及时 unobserve,是懒加载不翻车的三件套。
  • 把观测器做成指令/组合式函数,后续业务直接零成本接入。

现在你的「企业风采」首屏降到 1.2 s,老板滑得开心,运营继续传 8K 图,世界和平。

为什么越来越多开发者偷偷用上了 Svelte?

作者 天涯学馆
2025年7月25日 21:07

说真的,前端框架这几年太卷了。React、Vue、Angular 打得火热,每年还有新秀冒出来。就在大家还在比较 hooks 和 Composition API 的时候,Svelte 悄悄杀出来了,而且很多资深开发者正在偷偷用。

第一次上手 Svelte 的感觉,就像从写作文变成填空题——没有冗长的 boilerplate、没有烦人的状态管理库,一切都干净、直接、高效。它不是像 React/Vue 那样运行时处理 DOM,而是编译时就把你的代码变成原生 JS 操作,这让它的速度快得吓人。

如果你厌倦了“前端框架越来越重”,想找一个轻巧但不牺牲体验的选择,Svelte 可能就是你要找的那把钥匙。

Svelte 简介

Svelte 是一个现代前端框架,与 React 和 Vue 不同,它是一个编译时框架,将组件代码在构建时转换为高效的原生 JavaScript,无需运行时库。Svelte 的核心理念是“Compile-time over runtime”,通过在编译阶段完成大部分工作,生成轻量、高性能的代码。

Svelte 的核心特性

  • 编译时优化:组件编译为纯 JavaScript,无虚拟 DOM 开销。
  • 响应式系统:基于编译时的变量追踪,简化状态管理。
  • 轻量级:无运行时库,生成的代码体积小。
  • 简洁语法:接近原生 HTML/CSS/JS,学习曲线低。
  • 内置功能:动画、过渡、插槽、上下文 API 开箱即用。

前端价值

  • 提升性能(快速加载、流畅交互)。
  • 降低开发复杂度(简洁 API、零配置)。
  • 适合多种场景(从静态网站到复杂应用)。

本教程基于 Svelte 3 和 SvelteKit 1.x(截至 2025 年 5 月 26 日的稳定版本),涵盖 Svelte 的优势、实现方式及应用场景。

Svelte 的核心优势

1. 编译时框架:无运行时开销

Svelte 的最大优势是其编译时架构。与 React 和 Vue 依赖运行时虚拟 DOM 不同,Svelte 在构建时将组件编译为高效的原生 JavaScript,直接操作 DOM。

示例(简单计数器):
Counter.svelte

<script>
    let count = 0;
    
    function increment() {
        count += 1;
    }
</script>

<button on:click={increment}>
    Count: {count}
</button>

编译后(简化版)

function create_fragment(ctx) {
    let button;
    let t;
    return {
        c() {
            button = element("button");
            t = text("Count: " + ctx[0]);
            add_listener(button, "click", ctx[1]);
        },
        m(target, anchor) {
            insert(target, button, anchor);
            append(button, t);
        },
        p(ctx, [dirty]) {
            if (dirty & 1) set_data(t, "Count: " + ctx[0]);
        },
        d(detaching) {
            if (detaching) detach(button);
            remove_listener(button, "click", ctx[1]);
        }
    };
}

class Counter extends SvelteComponent {
    constructor(options) {
        super();
        init(this, options, instance, create_fragment, safe_not_equal, {});
    }
}
  1. Svelte 编译器将组件转换为纯 JavaScript 函数(如 create_fragment)。
  2. 直接操作 DOM(如 elementappend),无虚拟 DOM Diff。
  3. 更新逻辑(如 p 方法)仅针对变化的变量(count)。

优势

  • 性能:无运行时库,减少 JavaScript 解析和执行开销。
  • 体积:生成代码小,适合移动设备。
  • 确定性:编译时优化确保一致的性能。

与 React 对比

  • React:

    function Counter() {
        const [count, setCount] = useState(0);
        return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
    }
    
  • React 依赖运行时(ReactDOM)、虚拟 DOM Diff,增加开销。

  • Svelte 的编译结果更轻量,运行时开销接近零。

面试题 1:编译时 vs 运行时

问题:Svelte 的编译时架构相比运行时框架(如 React)有何优势?

答案

  • 优势
    • 无运行时库,减少包体积(React ~30KB,Svelte ~2KB)。
    • 直接 DOM 操作,性能接近原生。
    • 编译时优化,减少浏览器计算。
  • 局限
    • 编译步骤增加构建时间。
    • 动态组件(如 React.createElement)支持较弱。

2. 响应式系统:简洁高效

Svelte 的响应式系统基于编译时的变量追踪,无需显式状态管理(如 useState)。

示例(响应式计算):

<script>
    let a = 1;
    let b = 2;
    $: sum = a + b; // 响应式声明
</script>

<input type="number" bind:value={a}>
<input type="number" bind:value={b}>
<p>Sum: {sum}</p>

编译后(简化版)

function instance($$self, $$props, $$invalidate) {
    let a = 1;
    let b = 2;
    let sum;
    
    $$self.$$.update = () => {
        if ($$self.$$.dirty & 3) {
            sum = a + b;
        }
    };
    
    return [a, b, sum, e => $$invalidate(0, a = +e.target.value), e => $$invalidate(1, b = +e.target.value)];
}
  1. $: 标记响应式依赖,编译器生成更新逻辑。
  2. $$invalidate 标记变量变化,触发局部更新。
  3. 无需 useStatesetState,代码简洁。

优势

  • 简洁:响应式声明直观,减少样板代码。
  • 高效:编译时追踪依赖,更新精确。
  • 直观:接近原生 JavaScript 语法。

与 Vue 对比

  • Vue:

    <template>
        <div>
            <input v-model="a" type="number">
            <input v-model="b" type="number">
            <p>Sum: {{ sum }}</p>
        </div>
    </template>
    <script>
    export default {
        data() {
            return { a: 1, b: 2 };
        },
        computed: {
            sum() {
                return this.a + this.b;
            }
        }
    };
    </script>
    
  • Vue 使用 computed 和运行时代理,增加开销。

  • Svelte 的响应式更轻量,无需运行时代理。

面试题 2:Svelte 响应式

问题:Svelte 的响应式系统如何工作?与 Vue 的区别?

答案

  • Svelte
    • 编译时分析 $: 声明,生成更新逻辑。
    • 直接修改变量触发更新,无代理。
    • 性能高,代码量少。
  • Vue
    • 运行时使用 ProxyObject.defineProperty
    • computedwatch 定义依赖。
    • 运行时开销较高。

3. 轻量级:小体积高性能

Svelte 生成的代码体积极小,适合性能敏感场景。

示例(Todo 应用):
Todo.svelte

<script>
    let todos = [];
    let newTodo = '';
    
    function addTodo() {
        if (newTodo.trim()) {
            todos = [...todos, { id: Date.now(), text: newTodo, done: false }];
            newTodo = '';
        }
    }
    
    function toggleTodo(id) {
        todos = todos.map(todo =>
            todo.id === id ? { ...todo, done: !todo.done } : todo
        );
    }
</script>

<form on:submit|preventDefault={addTodo}>
    <input bind:value={newTodo} placeholder="Add todo">
    <button type="submit">Add</button>
</form>

<ul>
    {#each todos as todo (todo.id)}
        <li>
            <input type="checkbox" checked={todo.done} on:change={() => toggleTodo(todo.id)}>
            <span class:done={todo.done}>{todo.text}</span>
        </li>
    {/each}
</ul>

<style>
    .done {
        text-decoration: line-through;
        opacity: 0.5;
    }
</style>

编译后

  • 生成的 JS 代码约 2-3KB(压缩后)。
  • 无运行时依赖,加载速度快。

优势

  • 小体积:适合移动设备和弱网环境。
  • 快速加载:减少初始解析时间。
  • 低内存占用:无虚拟 DOM 和运行时状态。

与 React 对比

  • React 的 Todo 应用(含 React 和 ReactDOM)约 30-40KB。
  • Svelte 的代码体积仅为其 1/10。

面试题 3:Svelte 轻量级

问题:Svelte 的轻量级特性如何影响性能?

答案

  • 影响
    • 减少 JavaScript 解析和执行时间。
    • 降低内存占用,适合低端设备。
    • 提高首屏渲染速度(FCP、LCP)。
  • 场景:移动端、嵌入式设备、弱网环境。

4. 简洁语法与开发者体验

Svelte 的语法接近原生 HTML/CSS/JS,学习成本低。

示例(动画):

<script>
    import { fade } from 'svelte/transition';
    let visible = true;
</script>

<button on:click={() => visible = !visible}>
    Toggle
</button>

{#if visible}
    <div transition:fade={{ duration: 500 }}>
        Hello, Svelte!
    </div>
{/if}
  • transition:fade 内置动画,无需额外库。
  • 语法直观,接近原生 HTML。

优势

  • 易学:适合初学者和快速原型开发。
  • 内置功能:动画、插槽、上下文 API 减少依赖。
  • CSS 作用域:自动添加唯一类名,避免冲突。

与 Vue 对比

  • Vue 的动画需 transition 组件和 CSS:

    <transition name="fade">
        <div v-if="visible">Hello, Vue!</div>
    </transition>
    <style>
    .fade-enter-active, .fade-leave-active {
        transition: opacity 0.5s;
    }
    .fade-enter, .fade-leave-to {
        opacity: 0;
    }
    </style>
    
  • Svelte 的动画更简洁,内置支持。

Svelte 的实现与实践

项目搭建

使用 SvelteKit

SvelteKit 是 Svelte 的全栈框架,支持 SSR、SSG 和 SPA。

初始化项目

npm create svelte@latest svelte-demo
cd svelte-demo
npm install
npm run dev

项目结构

svelte-demo/
├── src/
│   ├── lib/              # 共享代码
│   ├── routes/           # 页面和 API 路由
│   │   ├── +page.svelte  # 首页
│   │   └── api/          # API 端点
│   └── app.html          # 应用模板
├── static/               # 静态资源
├── svelte.config.js      # 配置
└── vite.config.js        # Vite 配置

第一个组件

src/routes/+page.svelte

<script>
    let name = '';
</script>

<h1>Welcome to Svelte</h1>
<input bind:value={name} placeholder="Enter your name">
<p>Hello, {name || 'Guest'}!</p>
  • SvelteKit 使用文件系统路由,+page.svelte 对应 / 路由。
  • bind:value 双向绑定,简化表单处理。

状态管理

Svelte 使用响应式变量Store管理状态。

Store 示例

src/lib/store.js

import { writable } from 'svelte/store';

export const count = writable(0);

Counter.svelte

<script>
    import { count } from '$lib/store';
</script>

<button on:click={() => $count += 1}>
    Count: {$count}
</button>
  • writable 创建可写 Store。
  • $count 自动订阅和更新,语法简洁。

与 Redux 对比

  • Redux(React):

    import { useSelector, useDispatch } from 'react-redux';
    import { increment } from './store';
    
    function Counter() {
        const count = useSelector(state => state.count);
        const dispatch = useDispatch();
        return <button onClick={() => dispatch(increment())}>Count: {count}</button>;
    }
    
  • Redux 需样板代码(action、reducer),Svelte Store 更直观。

面试题 4:Svelte Store

问题:Svelte 的 Store 与 React 的 Redux 相比有何优势?

答案

  • Svelte Store
    • 内置,无需额外库。
    • 简洁 API(如 $count)。
    • 编译时优化,性能高。
  • Redux
    • 配置复杂,需 action/reducer。
    • 运行时开销大。
    • 适合大型应用。

路由与 SSR

SvelteKit 支持文件系统路由和服务器端渲染(SSR)。

示例(动态路由)

src/routes/blog/[id]/+page.svelte

<script>
    export let data;
</script>

<h1>Blog Post: {data.id}</h1>
<p>{data.content}</p>

src/routes/blog/[id]/+page.server.js

export async function load({ params }) {
    // 模拟数据库查询
    const post = {
        id: params.id,
        content: `This is post ${params.id}`
    };
    return { post };
}
  • [id] 创建动态路由,匹配 /blog/1 等。
  • +page.server.js 处理服务器端数据加载。
  • SSR 提升 SEO 和首屏速度。

Svelte 与其他框架的对比

性能对比

基准测试(基于 js-framework-benchmark):

  • Svelte

    • 渲染时间:~10ms(1000 行表格)。
    • 内存占用:~20MB。
    • 包体积:~2KB(压缩)。
  • React

    • 渲染时间:~15ms。
    • 内存占用:~30MB。
    • 包体积:~30KB。
  • Vue

    • 渲染时间:~12ms。
    • 内存占用:~25MB。
    • 包体积:~20KB。
  • Svelte 在渲染速度、内存占用和包体积上领先。

  • 适合性能敏感场景。

开发体验对比

  • Svelte
    • 简洁语法,学习曲线低。
    • 内置功能丰富(动画、Store)。
    • 无需复杂配置。
  • React
    • 需掌握 Hooks、Context 等。
    • 依赖第三方库(如 Redux、React Router)。
    • JSX 增加学习成本。
  • Vue
    • 语法友好,但 Options API 较繁琐。
    • Composition API 更灵活,但学习成本高。
    • 运行时代理增加开销。

Svelte 的性能优化

1. 编译优化

Svelte 编译器默认优化代码,可通过配置进一步提升。

svelte.config.js

import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/kit/vite';

export default {
    preprocess: vitePreprocess(),
    kit: {
        adapter: adapter(),
        vite: {
            optimizeDeps: {
                include: ['lodash']
            }
        }
    }
};

分析

  • Vite 预打包依赖,加速开发。
  • -O3 优化生成更小代码。

2. 懒加载组件

示例

<script>
    import { onMount } from 'svelte';
    let HeavyComponent = null;
    
    onMount(async () => {
        HeavyComponent = (await import('./HeavyComponent.svelte')).default;
    });
</script>

{#if HeavyComponent}
    <svelte:component this={HeavyComponent} />
{/if}

分析

  • 动态导入减少初始加载时间。
  • 适合大型组件。

3. Workbox 集成(PWA)

配置 SvelteKit 为 PWA

npm install -D @sveltejs/vite-plugin-pwa

vite.config.js

import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@sveltejs/vite-plugin-pwa';

export default defineConfig({
    plugins: [
        sveltekit(),
        SvelteKitPWA({
            manifest: {
                name: 'Svelte PWA',
                short_name: 'SveltePWA',
                start_url: '/',
                display: 'standalone',
                icons: [
                    {
                        src: '/icon-192.png',
                        sizes: '192x192',
                        type: 'image/png'
                    }
                ]
            }
        })
    ]
});

分析

  • 自动生成 Service Worker 和 Manifest。
  • 支持离线访问和推送通知。

与 WebAssembly 整合

Svelte 结合 WebAssembly 提升性能。

示例:Markdown 解析

Rust 代码src/lib.rs):

use wasm_bindgen::prelude::*;
use pulldown_cmark::{html, Parser};

#[wasm_bindgen]
pub fn render_markdown(md: &str) -> String {
    let parser = Parser::new(md);
    let mut html_output = String::new();
    html::push_html(&mut html_output, parser);
    html_output
}

编译

wasm-pack build --target web

Svelte 组件

<script>
    import { onMount } from 'svelte';
    let markdown = '# Hello\nThis is **Svelte**!';
    let html = '';
    
    onMount(async () => {
        const { render_markdown } = await import('../pkg/markdown.js');
        html = render_markdown(markdown);
    });
</script>

<textarea bind:value={markdown}></textarea>
<div>{@html html}</div>
  • WASM 加速 Markdown 解析。
  • Svelte 管理 UI,响应式更新。

所以说,Svelte 不是什么“前端框架终结者”,但它真的像一股清流,在复杂繁重的前端世界里提供了一种更轻松的可能性。写起来更像原生 JS,跑得也快,部署后的包又小,适合那种不想折腾、但又追求性能的你。

当然,每个项目都有自己的需求。大型团队可能更适合用 React,生态全、组件库多;但如果你在做个人项目、组件库、嵌入式小模块,甚至是纯静态网站,Svelte 真的是一个值得一试的好朋友。

别听别人说什么框架死不死的,自己动手写两页代码,你就知道它香不香了。技术这回事,体验才是最好的判断标准。

为什么浏览器那条“假进度”救不了我们?

作者 前端微白
2025年7月25日 20:53

你在做「企业级低代码平台」时,客户把 200+ 微应用一次性嵌进门户首页。
浏览器自带的进度条只认主文档,微应用懒加载的 JS/CSS/图片它一概不管,用户盯着 100 % 的进度条却白屏 3 秒,投诉直接拉满。
于是,你撸了一条 完全受控的自定义加载进度条,从 0 % 到 100 % 与真实资源一一对应,投诉率当天掉到 0。


方案:15 分钟搭一条“真·进度条”

1. 骨架 HTML(30 秒)

<!-- index.html -->
<div id="progress-bar">
  <div class="fill"></div>
  <span class="text">0%</span>
</div>

2. 核心 JS(5 分钟)

// progress.js
class Loader {
  #total = 0
  #loaded = 0
  #fill = document.querySelector('.fill')
  #text = document.querySelector('.text')

  add(url) {
    this.#total++
    fetch(url).then(() => {
      this.#loaded++
      this.#render()
    })
  }

  #render() {
    const p = Math.round((this.#loaded / this.#total) * 100)
    this.#fill.style.transform = `scaleX(${p / 100})`
    this.#text.textContent = `${p}%`
    if (p === 100) this.#fill.parentElement.remove()
  }
}

window.loader = new Loader()

逐行解析

  • add 把每一个资源注册进来,用原生 fetch 自带 Promise 跟踪完成。
  • scaleX 做宽度动画,GPU 加速不掉帧。
  • 100 % 时自毁 DOM,避免污染全局。

3. 使用姿势(1 分钟)

<script type="module">
  import './progress.js'
  // 🔍 业务代码里显式注册资源
  loader.add('/static/chunk-a.js')
  loader.add('/static/theme.css')
  loader.add('/static/logo.png')
</script>

原理深挖:三层视角看“真进度”

层级 表面用法 底层机制 设计哲学
资源层 fetch 监听 HTTP 缓存协商 + TCP 多路复用 用浏览器能力,不重复造轮子
渲染层 scaleX + requestAnimationFrame 合成层动画,60 FPS 视觉反馈优先,主线程不阻塞
生命周期 100 % 自毁 垃圾回收自动清理 用完即走,零副作用

扩展:3 个真实业务变体

1. Vue3 组合式封装(微应用场景)

// useProgress.ts
import { ref, computed } from 'vue'
export function useProgress() {
  const total = ref(0), loaded = ref(0)
  const percent = computed(() => (loaded.value / total.value) * 100 || 0)
  const add = (url: string) => {
    total.value++
    fetch(url).finally(() => loaded.value++)
  }
  return { percent, add }
}

<Suspense>@pending 里调用 add,进度与组件懒加载天然同步。

2. 大文件切片上传(断点续传)

// 伪代码
chunks.forEach((chunk, i) => {
  loader.add(`/upload?index=${i}`) // 每个切片算 1 个进度单位
})

3. Service Worker 离线缓存

// sw.js
self.addEventListener('install', e => {
  const urls = [...] // 预缓存列表
  e.waitUntil(
    caches.open('v1').then(cache =>
      Promise.all(
        urls.map(u => fetch(u).then(r => cache.put(u, r)))
      )
    )
  )
  // 向主线程 postMessage 更新进度
})

一键复用片段

/* 进度条样式,直接拷 */
#progress-bar {
  position: fixed; top: 0; left: 0; right: 0; height: 3px;
  background: rgba(0,0,0,.1); z-index: 9999;
}
.fill {
  height: 100%; background: #0076ff;
  transform-origin: left; transition: transform .3s ease;
}
.text {
  position: absolute; top: 4px; right: 8px;
  font-size: 12px; color: #0076ff;
}

小结

浏览器那条“假进度”只能骗自己,自定义进度条才是用户信任的起点。
把上面的 30 行代码丢进任何项目,3 分钟就能让白屏时间变成“可控的等待”。
下次老板再说“体验优化”,你就可以把这篇文章甩给他,然后安心下班。

老板突然要看“代码当量 KPI”

作者 前端微白
2025年7月25日 20:44

你刚把 Vue-lite 交付给私有化客户,领导转头就在群里甩了一句:“下周评审,把各模块代码行数统计出来,软著申请要用。”
用 cloc?当然可以,但客户环境没外网,装不了二进制。于是,你 15 分钟搓了一个 零依赖 的 Node CLI,直接 npx 就能跑,还能按扩展名过滤,结果输出成 JSON 方便后续自动化。


方案:三步做出 count-lines 命令

1. 初始化项目(1 分钟)

mkdir count-lines && cd count-lines
npm init -y
# 🔍 把 bin 字段挂到全局命令
npm pkg set bin.count-lines=./bin/index.js

2. 核心脚本(10 分钟)

bin/index.js

#!/usr/bin/env node
import { readdir, readFile, stat } from 'node:fs/promises'
import { extname, join, resolve } from 'node:path'
import { createWriteStream } from 'node:fs'

const [, , targetDir = '.', ...extArgs] = process.argv
const exts = extArgs.length ? extArgs : ['.js', '.ts', '.vue', '.css', '.html']

async function* walk(dir) {
  for (const name of await readdir(dir)) {
    const full = join(dir, name)
    if ((await stat(full)).isDirectory()) yield* walk(full)
    else yield full
  }
}

async function main() {
  const result = { total: 0, files: {} }
  for await (const file of walk(resolve(targetDir))) {
    if (!exts.includes(extname(file))) continue
    const lines = (await readFile(file, 'utf8')).split('\n').length
    result.total += lines
    result.files[file] = lines
  }
  const out = createWriteStream('count.output.json')
  out.write(JSON.stringify(result, null, 2))
  console.log(`✅ 统计完成,共 ${result.total} 行,详情见 count.output.json`)
}

main()

逐行拆解

  • walk 是一个异步生成器,递归遍历目录,避免一次性读爆内存。
  • exts 支持命令行传参,比如 npx count-lines ./src .vue .ts 只统计 Vue 和 TS。
  • 结果写本地 JSON,方便后续脚本直接 require('./count.output.json')

package.json 关键字段

{
  "type": "module",
  "bin": { "count-lines": "./bin/index.js" }
}

3. 本地测试 & 发布(4 分钟)

chmod +x bin/index.js
npm link          # 本地全局可用
count-lines ./src .vue .ts

原理深挖:从“读文件”到“流式输出”

表面用法 底层机制 设计哲学
readFile 读文本 Node 线程池异步 IO,不阻塞事件循环 单线程也能高并发
split('\n') 计数 利用 V8 内建字符串分割,比正则 /\r?\n/ 快 20% 能省一次正则就省
结果写 JSON 文件 流式写入 createWriteStream,内存占用 < 5 MB 大仓库也吃得下

扩展:3 个变体场景

场景 改动点 思路
软著申请 输出 Word 模板 officegen 把 JSON 渲染成 .docx,自动插入目录
CI 门禁 行数增量报警 在 GitHub Action 里对比 count.output.json 两次 commit 差异
多语言仓库 支持 .py .go exts 换成 Map,key 为扩展名,value 为注释正则,过滤空行和注释

一键复用片段

# 全局安装(私有 npm 源)
npm i -g @yourscope/count-lines
# 在 CI 里
- run: count-lines . .ts .vue
- run: node scripts/check-diff.js

把脚本丢进公司私有仓库,下次谁再要 KPI,一行命令搞定。

为什么我们要亲手“捏”一个 Vue 项目?

作者 前端微白
2025年7月25日 20:33

你在一家做 B 端 SaaS 的公司,产品迭代节奏极快。某天,老板突然甩来一句:“客户要私有化部署,包体必须 < 500 KB,脚手架里那些没用到的依赖全给我砍掉!”
那一刻,你深刻体会到:脚手架是“通用解”,而私有化场景需要“定制解”。于是,你决定从零手搓一个 Vue 应用,既能极致瘦身,又能随时插拔功能。


解决方案:30 分钟搭出可交付的“裸奔” Vue3 项目

1. 环境准备(2 分钟)

# 🔍 用 volta 锁 Node 版本,避免“在我电脑能跑”
volta install node@20
mkdir vue-lite && cd vue-lite
npm init -y

2. 最小依赖安装(3 分钟)

# 只装运行时 + 编译时刚需
npm i vue@next
npm i -D vite @vitejs/plugin-vue

为什么选 Vite?
对比 Webpack5(webpage 3 的做法),Vite 在 dev 阶段用 esbuild 做预构建,冷启动 < 300 ms,正适合我们“边改边看”的私有化调试场景。

3. 目录结构(5 分钟)

vue-lite
├─ public
│  └─ favicon.ico
├─ src
│  ├─ main.ts          # 应用入口
│  ├─ App.vue
│  └─ components
│     └─ Hello.vue
├─ index.html          # Vite 的“钩子”
└─ vite.config.ts

4. 核心文件代码(10 分钟)

index.html

<!doctype html>
<html>
  <head>
    <title>Vue-Lite</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      entry: 'src/main.ts',
      name: 'VueLite',
      fileName: 'vue-lite',
    },
    rollupOptions: {
      external: ['vue'], // 🔍 把 vue 打成 external,私有化时再外链 CDN
    },
  },
})

src/main.ts

import { createApp } from 'vue'
import App from './App.vue'

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

src/App.vue

<template>
  <Hello />
</template>

<script setup lang="ts">
import Hello from './components/Hello.vue'
</script>

src/components/Hello.vue

<template>
  <h1>{{ msg }}</h1>
</template>

<script setup lang="ts">
const msg = 'Hello, 私有化客户!'
</script>

原理剖析:从“裸文件”到“可交付产物”的三层视角

层级 表面用法 底层机制 设计哲学
Dev 阶段 npm run dev 秒开浏览器 Vite 把 .vue 文件实时编译成 ESModule,浏览器直接 import 浏览器原生能力优先,工具链只做“翻译”
Build 阶段 npm run build 生成 dist/vue-lite.umd.js Rollup 做 tree-shaking + 代码分割,external vue 减少 80 KB 私有化场景下,业务代码与框架解耦
Runtime 阶段 客户页面 <script src="vue-lite.umd.js"></script> UMD 格式自动判断宿主环境(CommonJS / AMD / 全局) 不侵入客户构建体系,即插即用

应用扩展:把“裸奔”项目武装到牙齿

1. 插拔式路由(不打包路由库)

// src/router/index.ts
import { ref, computed } from 'vue'

const routes = {
  '/': () => import('../pages/Home.vue'),
  '/about': () => import('../pages/About.vue'),
}

export const path = ref(location.pathname)
window.addEventListener('popstate', () => (path.value = location.pathname))

export const currentView = computed(() => routes[path.value] || routes['/'])

原理:利用浏览器原生 popstate + Vue3 的响应式,实现 0 依赖路由。
场景:后台管理系统只有 3-4 个页面,无需整包 vue-router。

2. 按需引入 UI 组件(以 ElementPlus 为例)

// vite.config.ts 新增
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'

plugins: [
  vue(),
  Components({ resolvers: [ElementPlusResolver()] }),
]

结果:按钮、表格用到多少就打包多少,未使用的组件 0 成本。

3. 私有化环境变量注入

// src/env.ts
export const API_BASE = import.meta.env.VITE_API_BASE || 'https://saas.example.com'

客户部署时只需在 Nginx 加一行:

location / {
  sub_filter 'https://saas.example.com' 'https://customer.internal';
}

举一反三:3 个变体场景实现思路

场景 关键差异 实现思路
微前端子应用 需要生命周期钩子 main.ts 导出 bootstrap / mount / unmount,用 Vite 的 library 模式打包成 systemjs 格式
低代码平台渲染器 动态组件量巨大 defineAsyncComponent + import.meta.glob 做运行时加载,配合 CDN 缓存
Chrome 插件 popup 包体 < 100 KB 关闭 Vite 的代码分割,用 build.minify='terser' + pure_funcs=['console.log'] 删除所有日志

可复用配置片段

// vite.config.ts(私有化专用)
export default defineConfig(({ command }) => ({
  base: command === 'build' ? '/static/vue-lite/' : '/', // 🔍 适配客户子路径
  plugins: [vue()],
  build: {
    rollupOptions: {
      external: ['vue'],
      output: {
        globals: { vue: 'Vue' }, // 告诉 UMD 外部依赖叫 Vue
      },
    },
  },
}))

小结

不用脚手架,不是“为了酷”,而是“为了活”。当你面对包体、合规、私有化这些真实约束时,手搓项目就像给自己开了一条逃生通道:

  • 想砍依赖?直接删 package.json 一行。
  • 想换构建?Vite 换 Rollup 换 esbuild,5 分钟搞定。

下次老板再提“极限瘦身”,你就可以淡定地打开这篇文章,30 分钟交差。

恋爱时间倒计时网页设计与实现方案

作者 码事漫谈
2025年7月25日 18:52

image.png

一、项目概述

本项目旨在创建一个高度可定制的恋爱时间倒计时网页,支持纪念日日期设置、背景主题切换、个性化文案定制等功能,并通过localStorage保存用户配置。技术栈将采用HTML5、Tailwind CSS v3和原生JavaScript,结合Canvas粒子动画实现视觉吸引力。

二、核心功能设计

  1. 双模式计时系统

    • 正计时:记录恋爱天数(支持精确到秒级更新)
    • 倒计时:重要纪念日提醒(如100天、周年纪念)
  2. 个性化定制面板

    • 日期选择器:支持公历/农历切换
    • 背景设置:纯色渐变、粒子动画、自定义图片上传
    • 主题系统:预设3套浪漫主题(粉紫渐变/星空蓝/蜜桃粉)
    • 文案定制:主标题、副标题自定义
    • 字体选择:3种字体风格(手写体/衬线体/无衬线体)
  3. 视觉动效设计

    • 爱心粒子背景:鼠标交互时粒子聚合为爱心形状
    • 时间数字动画:数字变化时的平滑过渡效果
    • 纪念日里程碑:特殊天数(如520天)的烟花特效

三、技术实现方案

  1. 倒计时核心逻辑
// 高精度计时实现(避免setInterval延迟问题)
function startCountdown(targetDate) {
  const updateTimer = () => {
    const now = new Date().getTime();
    const diff = targetDate - now;
    
    // 时间计算逻辑
    const days = Math.floor(diff / (1000 * 60 * 60 * 24));
    // ...小时/分钟/秒计算
    
    // DOM更新
    updateDOM(days, hours, minutes, seconds);
    
    if (diff <= 0) {
      // 倒计时结束逻辑
      return;
    }
    
    // 动态调整下一次执行时间(修正定时器误差)
    const nextUpdate = Math.max(1000, diff % 1000);
    setTimeout(updateTimer, nextUpdate);
  };
  
  updateTimer();
}
  1. 粒子背景实现(基于Canvas API)
class ParticleBackground {
  constructor(canvasId) {
    this.canvas = document.getElementById(canvasId);
    this.ctx = this.canvas.getContext('2d');
    this.particles = [];
    this.resizeCanvas();
    this.initParticles(120); // 创建120个粒子
    this.animate();
  }
  
  initParticles(count) {
    for (let i = 0; i < count; i++) {
      this.particles.push({
        x: Math.random() * this.canvas.width,
        y: Math.random() * this.canvas.height,
        size: Math.random() * 3 + 1,
        speedX: (Math.random() - 0.5) * 0.5,
        speedY: (Math.random() - 0.5) * 0.5,
        color: this.getRandomColor()
      });
    }
  }
  
  // 爱心形状鼠标交互
  handleMouseMove(e) {
    // 粒子向鼠标位置聚集形成爱心路径
  }
  
  animate() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    // 更新粒子位置和连线
    requestAnimationFrame(() => this.animate());
  }
}
  1. 用户配置持久化
// 配置数据结构
const DEFAULT_CONFIG = {
  anniversaryDate: '2023-01-01',
  title: '我们的恋爱时光',
  subtitle: '记录每一刻心动',
  theme: 'pink-love',
  backgroundType: 'particle',
  customBackground: '',
  font: 'handwriting'
};

// 保存配置到localStorage
function saveConfig(config) {
  try {
    localStorage.setItem('loveCounterConfig', JSON.stringify(config));
  } catch (e) {
    console.error('配置保存失败:', e);
  }
}

// 加载配置
function loadConfig() {
  const saved = localStorage.getItem('loveCounterConfig');
  return saved ? JSON.parse(saved) : DEFAULT_CONFIG;
}

四、UI/UX设计规范

  1. 色彩系统

    • 主色调:#FF6B8B(浪漫粉)
    • 辅助色:#8A2BE2(梦幻紫)、#FFD700(香槟金)
    • 中性色:#F9F9F9(背景)、#333333(文字)
  2. 响应式布局

    • 移动端:单列布局,配置项折叠为底部抽屉
    • 平板:双列布局,左侧配置+右侧预览
    • 桌面端:三栏布局,增加快捷操作区
  3. 交互反馈

    • 配置变更时实时预览
    • 操作成功的微动画提示
    • 错误状态的友好提示

五、项目结构

love-counter/
├── index.html         # 主页面
├── src/
│   ├── css/
│   │   └── styles.css # Tailwind自定义样式
│   ├── js/
│   │   ├── countdown.js  # 计时逻辑
│   │   ├── particle.js   # 粒子背景
│   │   ├── config.js     # 配置管理
│   │   └── ui.js         # UI交互
│   └── assets/
│       └── fonts/        # 自定义字体
├── tailwind.config.js    # 主题配置
└── README.md             # 项目文档

六、优化与兼容性

  1. 性能优化

    • Canvas动画节流(requestAnimationFrame)
    • 图片懒加载与压缩
    • 配置项变更防抖处理
  2. 浏览器兼容

    • 支持Chrome/Edge/Safari最新版
    • 降级处理IE浏览器(简化动画效果)
  3. 可访问性

    • 语义化HTML结构
    • 键盘导航支持
    • 颜色对比度符合WCAG标准

源代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>恋爱时间倒计时</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
    <script>
        tailwind.config = {
            theme: {
                extend: {
                    colors: {
                        love: {
                            pink: '#FF6B8B',
                            purple: '#8A2BE2',
                            gold: '#FFD700',
                            light: '#FFF0F3',
                            dark: '#333333'
                        }
                    },
                    fontFamily: {
                        handwriting: ['Segoe Script', 'Brush Script MT', 'cursive'],
                        serif: ['Georgia', 'Cambria', 'serif'],
                        sans: ['Inter', 'system-ui', 'sans-serif']
                    },
                    animation: {
                        'heartbeat': 'heartbeat 1.5s ease-in-out infinite',
                        'fade-in': 'fadeIn 0.5s ease-out forwards',
                        'slide-up': 'slideUp 0.5s ease-out forwards'
                    },
                    keyframes: {
                        heartbeat: {
                            '0%, 100%': { transform: 'scale(1)' },
                            '50%': { transform: 'scale(1.2)' }
                        },
                        fadeIn: {
                            '0%': { opacity: '0' },
                            '100%': { opacity: '1' }
                        },
                        slideUp: {
                            '0%': { transform: 'translateY(20px)', opacity: '0' },
                            '100%': { transform: 'translateY(0)', opacity: '1' }
                        }
                    }
                }
            }
        }
    </script>
    <style type="text/tailwindcss">
        @layer utilities {
            .content-auto {
                content-visibility: auto;
            }
            .text-shadow {
                text-shadow: 0 2px 4px rgba(0,0,0,0.1);
            }
            .particle-bg {
                position: absolute;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                z-index: 0;
            }
            .config-panel {
                transform: translateX(0);
                transition: transform 0.3s ease-in-out;
            }
            .config-panel.hidden {
                transform: translateX(100%);
            }
            @media (max-width: 768px) {
                .config-panel {
                    transform: translateY(100%);
                    height: 80vh;
                    border-radius: 1rem 1rem 0 0;
                }
                .config-panel.hidden {
                    transform: translateY(100%);
                }
                .config-panel.active {
                    transform: translateY(0);
                }
            }
        }
    </style>
</head>
<body class="font-sans bg-love-light text-love-dark overflow-x-hidden">
    <!-- 粒子背景画布 -->
    <canvas id="particleCanvas" class="particle-bg"></canvas>

    <!-- 主容器 -->
    <div class="relative min-h-screen flex flex-col items-center justify-center p-4 z-10">
        <!-- 头部标题区 -->
        <header class="text-center mb-8 animate-fade-in">
            <h1 id="mainTitle" class="text-[clamp(2rem,5vw,3.5rem)] font-handwriting font-bold text-love-pink text-shadow">
                我们的恋爱时光
            </h1>
            <p id="subTitle" class="text-[clamp(1rem,2vw,1.5rem)] text-gray-600 mt-2">
                记录每一刻心动
            </p>
        </header>

        <!-- 倒计时显示区 -->
        <div class="w-full max-w-3xl mx-auto bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl p-6 md:p-10 animate-slide-up">
            <div class="flex flex-col md:flex-row justify-around items-center gap-6">
                <!-- 正计时显示 -->
                <div class="text-center">
                    <div id="loveDays" class="text-[clamp(2.5rem,8vw,5rem)] font-bold text-love-purple">0</div>
                    <div class="text-gray-500">恋爱天数</div>
                </div>
                
                <!-- 分隔符 -->
                <div class="hidden md:block h-20 w-px bg-gray-300"></div>
                
                <!-- 倒计时显示 -->
                <div class="grid grid-cols-4 gap-2 md:gap-4 w-full md:w-auto">
                    <div class="text-center">
                        <div id="countdownDays" class="text-[clamp(1.5rem,5vw,2.5rem)] font-bold text-love-pink">00</div>
                        <div class="text-xs md:text-sm text-gray-500"></div>
                    </div>
                    <div class="text-center">
                        <div id="countdownHours" class="text-[clamp(1.5rem,5vw,2.5rem)] font-bold text-love-pink">00</div>
                        <div class="text-xs md:text-sm text-gray-500"></div>
                    </div>
                    <div class="text-center">
                        <div id="countdownMinutes" class="text-[clamp(1.5rem,5vw,2.5rem)] font-bold text-love-pink">00</div>
                        <div class="text-xs md:text-sm text-gray-500"></div>
                    </div>
                    <div class="text-center">
                        <div id="countdownSeconds" class="text-[clamp(1.5rem,5vw,2.5rem)] font-bold text-love-pink">00</div>
                        <div class="text-xs md:text-sm text-gray-500"></div>
                    </div>
                </div>
            </div>
            
            <div class="mt-6 text-center text-gray-500 text-sm">
                <span id="nextAnniversary">距离下一个纪念日还有:</span>
                <span id="anniversaryName" class="font-medium text-love-purple">恋爱100天</span>
            </div>
        </div>

        <!-- 配置按钮 -->
        <button id="configBtn" class="fixed bottom-6 right-6 bg-love-pink text-white rounded-full p-3 shadow-lg z-20 hover:bg-love-purple transition-colors">
            <i class="fa fa-cog text-xl"></i>
        </button>
    </div>

    <!-- 配置面板 -->
    <div id="configPanel" class="config-panel fixed top-0 right-0 w-full md:w-80 h-full bg-white shadow-2xl z-30 p-6 overflow-y-auto">
        <div class="flex justify-between items-center mb-6">
            <h2 class="text-xl font-bold text-love-pink">个性化设置</h2>
            <button id="closeConfigBtn" class="text-gray-500 hover:text-gray-700">
                <i class="fa fa-times text-xl"></i>
            </button>
        </div>
        
        <form id="configForm" class="space-y-6">
            <!-- 日期设置 -->
            <div class="space-y-2">
                <label class="block text-sm font-medium text-gray-700">恋爱开始日期</label>
                <input type="date" id="startDate" class="w-full p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-love-pink focus:border-love-pink">
            </div>
            
            <!-- 纪念日设置 -->
            <div class="space-y-2">
                <label class="block text-sm font-medium text-gray-700">下一个纪念日</label>
                <div class="flex gap-2">
                    <input type="date" id="anniversaryDate" class="flex-1 p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-love-pink focus:border-love-pink">
                </div>
                <input type="text" id="anniversaryNameInput" placeholder="纪念日名称(如:恋爱100天)" class="w-full p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-love-pink focus:border-love-pink">
            </div>
            
            <!-- 标题设置 -->
            <div class="space-y-2">
                <label class="block text-sm font-medium text-gray-700">主标题</label>
                <input type="text" id="titleInput" placeholder="输入主标题" class="w-full p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-love-pink focus:border-love-pink">
            </div>
            
            <!-- 背景设置 -->
            <div class="space-y-2">
                <label class="block text-sm font-medium text-gray-700">背景类型</label>
                <div class="grid grid-cols-3 gap-2">
                    <button type="button" data-bg-type="gradient" class="bg-gradient-to-br from-love-pink to-love-purple h-10 rounded-lg border-2 border-transparent focus:border-love-pink"></button>
                    <button type="button" data-bg-type="particle" class="bg-gray-100 h-10 rounded-lg border-2 border-transparent focus:border-love-pink"></button>
                    <label class="relative h-10 rounded-lg bg-gray-100 flex items-center justify-center cursor-pointer border-2 border-transparent focus-within:border-love-pink">
                        <i class="fa fa-upload text-gray-400"></i>
                        <input type="file" id="bgImageUpload" accept="image/*" class="absolute inset-0 opacity-0 cursor-pointer">
                    </label>
                </div>
            </div>
            
            <!-- 主题设置 -->
            <div class="space-y-2">
                <label class="block text-sm font-medium text-gray-700">主题颜色</label>
                <div class="flex gap-2">
                    <button type="button" data-theme="pink" class="w-full h-10 bg-love-pink rounded-lg border-2 border-transparent focus:border-black"></button>
                    <button type="button" data-theme="purple" class="w-full h-10 bg-love-purple rounded-lg border-2 border-transparent focus:border-black"></button>
                    <button type="button" data-theme="gold" class="w-full h-10 bg-love-gold rounded-lg border-2 border-transparent focus:border-black"></button>
                </div>
            </div>
            
            <!-- 字体设置 -->
            <div class="space-y-2">
                <label class="block text-sm font-medium text-gray-700">字体选择</label>
                <select id="fontSelect" class="w-full p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-love-pink focus:border-love-pink">
                    <option value="sans">无衬线体</option>
                    <option value="serif">衬线体</option>
                    <option value="handwriting">手写体</option>
                </select>
            </div>
            
            <!-- 保存按钮 -->
            <button type="submit" class="w-full bg-love-pink hover:bg-love-pink/90 text-white font-medium py-2 px-4 rounded-lg transition-colors">
                保存设置
            </button>
        </form>
    </div>

    <!-- 移动端配置按钮 -->
    <button id="mobileConfigBtn" class="fixed bottom-6 right-6 md:hidden bg-love-pink text-white rounded-full p-3 shadow-lg z-20">
        <i class="fa fa-cog text-xl"></i>
    </button>

    <script src="https://cdn.jsdelivr.net/particles.js/2.0.0/particles.min.js"></script>
    <script>
        // 全局变量
        let config = {
            startDate: '',
            anniversaryDate: '',
            anniversaryName: '恋爱100天',
            title: '我们的恋爱时光',
            subtitle: '记录每一刻心动',
            bgType: 'gradient',
            bgImage: '',
            theme: 'pink',
            font: 'sans'
        };
        let particleSystem = null;
        let countdownInterval = null;

        // DOM元素
        const elements = {
            loveDays: document.getElementById('loveDays'),
            countdownDays: document.getElementById('countdownDays'),
            countdownHours: document.getElementById('countdownHours'),
            countdownMinutes: document.getElementById('countdownMinutes'),
            countdownSeconds: document.getElementById('countdownSeconds'),
            mainTitle: document.getElementById('mainTitle'),
            subTitle: document.getElementById('subTitle'),
            anniversaryName: document.getElementById('anniversaryName'),
            startDate: document.getElementById('startDate'),
            anniversaryDate: document.getElementById('anniversaryDate'),
            anniversaryNameInput: document.getElementById('anniversaryNameInput'),
            titleInput: document.getElementById('titleInput'),
            fontSelect: document.getElementById('fontSelect'),
            configPanel: document.getElementById('configPanel'),
            configBtn: document.getElementById('configBtn'),
            closeConfigBtn: document.getElementById('closeConfigBtn'),
            mobileConfigBtn: document.getElementById('mobileConfigBtn'),
            configForm: document.getElementById('configForm'),
            bgImageUpload: document.getElementById('bgImageUpload'),
            particleCanvas: document.getElementById('particleCanvas')
        };

        // 初始化
        function init() {
            // 加载配置
            loadConfig();
            // 设置表单值
            updateFormValues();
            // 初始化粒子背景
            initParticleSystem();
            // 更新UI
            updateUI();
            // 启动倒计时
            startCountdown();
            // 添加事件监听
            addEventListeners();
        }

        // 加载配置
        function loadConfig() {
            const savedConfig = localStorage.getItem('loveCounterConfig');
            if (savedConfig) {
                config = JSON.parse(savedConfig);
            } else {
                // 默认日期设置为今天
                const today = new Date();
                const defaultDate = new Date(today.setDate(today.getDate() - 1)).toISOString().split('T')[0];
                config.startDate = defaultDate;
                
                // 默认纪念日设置为30天后
                const anniversary = new Date(today.setDate(today.getDate() + 30)).toISOString().split('T')[0];
                config.anniversaryDate = anniversary;
                
                saveConfig();
            }
        }

        // 保存配置
        function saveConfig() {
            localStorage.setItem('loveCounterConfig', JSON.stringify(config));
        }

        // 更新表单值
        function updateFormValues() {
            elements.startDate.value = config.startDate;
            elements.anniversaryDate.value = config.anniversaryDate;
            elements.anniversaryNameInput.value = config.anniversaryName;
            elements.titleInput.value = config.title;
            elements.subTitle.textContent = config.subtitle;
            elements.fontSelect.value = config.font;
            
            // 设置选中的主题
            document.querySelector(`[data-theme="${config.theme}"]`).classList.add('border-black');
            // 设置选中的背景类型
            document.querySelector(`[data-bg-type="${config.bgType}"]`).classList.add('border-love-pink');
        }

        // 初始化粒子系统
        function initParticleSystem() {
            if (window.particlesJS && config.bgType === 'particle') {
                particlesJS('particleCanvas', {
                    "particles": {
                        "number": { "value": 80, "density": { "enable": true, "value_area": 800 } },
                        "color": { "value": "#FF6B8B" },
                        "shape": { "type": "circle" },
                        "opacity": { "value": 0.5, "random": true },
                        "size": { "value": 3, "random": true },
                        "line_linked": {
                            "enable": true,
                            "distance": 150,
                            "color": "#FF6B8B",
                            "opacity": 0.2,
                            "width": 1
                        },
                        "move": {
                            "enable": true,
                            "speed": 1,
                            "direction": "none",
                            "random": true,
                            "straight": false,
                            "out_mode": "out",
                            "bounce": false
                        }
                    },
                    "interactivity": {
                        "detect_on": "canvas",
                        "events": {
                            "onhover": { "enable": true, "mode": "grab" },
                            "onclick": { "enable": true, "mode": "push" },
                            "resize": true
                        },
                        "modes": {
                            "grab": { "distance": 140, "line_linked": { "opacity": 0.8 } },
                            "push": { "particles_nb": 3 }
                        }
                    },
                    "retina_detect": true
                });
            }
        }

        // 更新UI
        function updateUI() {
            // 更新标题
            elements.mainTitle.textContent = config.title;
            elements.subTitle.textContent = config.subtitle;
            elements.anniversaryName.textContent = config.anniversaryName;
            
            // 更新字体
            document.body.className = `font-${config.font}`;
            
            // 更新主题颜色
            document.documentElement.style.setProperty('--theme-color', 
                config.theme === 'pink' ? '#FF6B8B' : 
                config.theme === 'purple' ? '#8A2BE2' : '#FFD700');
            
            // 更新背景
            updateBackground();
            
            // 计算并更新恋爱天数
            updateLoveDays();
            
            // 计算并更新倒计时
            updateCountdown();
        }

        // 更新背景
        function updateBackground() {
            const body = document.body;
            
            // 清除之前的背景设置
            body.style.backgroundImage = '';
            body.className = body.className.replace(/bg-\S+/g, '');
            
            if (config.bgType === 'gradient') {
                body.classList.add('bg-gradient-to-br', 
                    config.theme === 'pink' ? 'from-love-pink/20 to-love-purple/20' :
                    config.theme === 'purple' ? 'from-love-purple/20 to-indigo-500/20' :
                    'from-love-gold/20 to-yellow-300/20');
            } else if (config.bgType === 'particle') {
                body.classList.add('bg-gray-100');
                if (!particleSystem) {
                    initParticleSystem();
                }
            } else if (config.bgType === 'image' && config.bgImage) {
                body.style.backgroundImage = `url(${config.bgImage})`;
                body.classList.add('bg-cover', 'bg-center');
            }
        }

        // 更新恋爱天数
        function updateLoveDays() {
            const start = new Date(config.startDate);
            const now = new Date();
            const diffTime = Math.abs(now - start);
            const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
            elements.loveDays.textContent = diffDays;
        }

        // 更新倒计时
        function updateCountdown() {
            const now = new Date();
            const anniversary = new Date(config.anniversaryDate);
            
            // 如果纪念日已过,设置为明年
            if (anniversary < now) {
                anniversary.setFullYear(anniversary.getFullYear() + 1);
            }
            
            const diffTime = anniversary - now;
            
            // 计算天、时、分、秒
            const days = Math.floor(diffTime / (1000 * 60 * 60 * 24));
            const hours = Math.floor((diffTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
            const minutes = Math.floor((diffTime % (1000 * 60 * 60)) / (1000 * 60));
            const seconds = Math.floor((diffTime % (1000 * 60)) / 1000);
            
            // 更新DOM
            elements.countdownDays.textContent = days.toString().padStart(2, '0');
            elements.countdownHours.textContent = hours.toString().padStart(2, '0');
            elements.countdownMinutes.textContent = minutes.toString().padStart(2, '0');
            elements.countdownSeconds.textContent = seconds.toString().padStart(2, '0');
        }

        // 启动倒计时
        function startCountdown() {
            // 立即更新一次
            updateLoveDays();
            updateCountdown();
            
            // 清除之前的定时器
            if (countdownInterval) {
                clearInterval(countdownInterval);
            }
            
            // 设置定时器
            countdownInterval = setInterval(() => {
                updateLoveDays();
                updateCountdown();
            }, 1000);
        }

        // 添加事件监听
        function addEventListeners() {
            // 配置面板切换
            elements.configBtn.addEventListener('click', () => {
                elements.configPanel.classList.remove('hidden');
                elements.configPanel.classList.add('active');
            });
            
            elements.closeConfigBtn.addEventListener('click', () => {
                elements.configPanel.classList.add('hidden');
                elements.configPanel.classList.remove('active');
            });
            
            elements.mobileConfigBtn.addEventListener('click', () => {
                elements.configPanel.classList.toggle('active');
            });
            
            // 背景类型选择
            document.querySelectorAll('[data-bg-type]').forEach(btn => {
                btn.addEventListener('click', () => {
                    // 移除所有选中状态
                    document.querySelectorAll('[data-bg-type]').forEach(b => 
                        b.classList.remove('border-love-pink'));
                    // 设置当前选中状态
                    btn.classList.add('border-love-pink');
                    config.bgType = btn.dataset.bgType;
                    updateBackground();
                });
            });
            
            // 主题选择
            document.querySelectorAll('[data-theme]').forEach(btn => {
                btn.addEventListener('click', () => {
                    // 移除所有选中状态
                    document.querySelectorAll('[data-theme]').forEach(b => 
                        b.classList.remove('border-black'));
                    // 设置当前选中状态
                    btn.classList.add('border-black');
                    config.theme = btn.dataset.theme;
                    updateUI();
                });
            });
            
            // 图片上传
            elements.bgImageUpload.addEventListener('change', (e) => {
                const file = e.target.files[0];
                if (file) {
                    const reader = new FileReader();
                    reader.onload = (event) => {
                        config.bgImage = event.target.result;
                        config.bgType = 'image';
                        // 更新背景类型选中状态
                        document.querySelectorAll('[data-bg-type]').forEach(b => 
                            b.classList.remove('border-love-pink'));
                        document.querySelector(`[data-bg-type="image"]`).classList.add('border-love-pink');
                        updateBackground();
                    };
                    reader.readAsDataURL(file);
                }
            });
            
            // 表单提交
            elements.configForm.addEventListener('submit', (e) => {
                e.preventDefault();
                
                // 更新配置
                config.startDate = elements.startDate.value;
                config.anniversaryDate = elements.anniversaryDate.value;
                config.anniversaryName = elements.anniversaryNameInput.value;
                config.title = elements.titleInput.value;
                config.font = elements.fontSelect.value;
                
                // 保存配置
                saveConfig();
                
                // 更新UI
                updateUI();
                
                // 关闭配置面板
                elements.configPanel.classList.add('hidden');
                elements.configPanel.classList.remove('active');
                
                // 显示保存成功提示
                alert('设置已保存!');
            });
        }

        // 页面加载完成后初始化
        window.addEventListener('DOMContentLoaded', init);

        // 窗口大小变化时调整粒子系统
        window.addEventListener('resize', () => {
            if (particleSystem) {
                particleSystem.resize();
            }
        });
    </script>
</body>
</html>

React 项目实战:从 0 到 1 构建高效 GitHub 仓库管理应用 —— 基于 React 全家桶的全栈开发指南

作者 十盒半价
2025年7月25日 18:43

一、引言:打造属于你的 React 实战项目

在 React 开发的世界里,大型项目的架构设计就像搭建一座大厦,每一块 “砖” 都需要精心打磨。今天我们将以一个 GitHub 仓库管理应用为例,带你从零开始构建一个完整的 React 项目,涵盖路由设计、数据管理、组件开发到项目架构的全流程,让你掌握企业级 React 应用的核心开发技巧。

二、路由设计:让页面跳转更丝滑

(一)动态路由与参数解析

在 React 中,动态路由是实现页面个性化的关键。通过react-router-domuseParams钩子,我们可以轻松获取 URL 中的动态参数,比如/users/:username/repos中的username

需要注意的是,useParams返回的是一个动态更新的对象,无需放在useEffect中监听,React 会自动帮我们处理参数变化时的组件更新。就像快递员根据不同的地址送货,动态路由会根据参数精准定位到对应的页面组件~

(二)懒加载优化性能

懒加载是提升项目性能的重要手段,它让组件在需要时才加载,减少初始加载时间。使用lazysuspense组合,我们可以轻松实现组件的懒加载:

import { lazy, Suspense } from 'react';
// 懒加载组件
const RepoList = lazy(() => import('./pages/ReposList'));

// 在组件中使用
<Suspense fallback={<Loading />}>
  <Routes>
    <Route path="/users/:id/repos" element={<RepoList />} />
  </Routes>
</Suspense>

Suspense组件中设置加载时的占位 UI(如 Loading 动画),让用户体验更友好。这就好比看视频时先加载当前内容,后面的按需加载,既省资源又提速~

(三)路由模式与导航控制

React 路由支持hashhistory两种模式(通过BrowserRouter/HashRouter切换),history模式更符合 URL 规范,适合生产环境。

通过useNavigate实现编程式导航,比如参数校验不通过时跳转首页:

import { useNavigate } from 'react-router-dom';

const RepoList = () => {
  const { id } = useParams();
  const navigate = useNavigate();

  useEffect(() => {
    // 校验参数合法性(永远不要相信用户输入)
    if (!id?.trim()) {
      navigate('/'); // 跳转到首页
    }
  }, [id, navigate]);
};

路由守卫(补充内容):实际项目中可用于权限控制,比如判断用户登录状态:

const PrivateRoute = ({ children }) => {
  const isLogin = useAuth();
  return isLogin ? children : <Navigate to="/login" />;
};

// 使用:<Route path="/admin" element={<PrivateRoute><Admin /></PrivateRoute>} />

三、数据管理:全局状态的统一管控

(一)useContext + useReducer 组合方案

对于中大型项目,useContext + useReducer是轻量级全局状态管理的优选方案,替代 redux 减少冗余代码。

  1. 创建上下文与 reducer

// context/GlobalContext.jsx
import { createContext, useReducer } from 'react';
import { repoReducer } from '@/reducers/repoReducer';

export const GlobalContext = createContext();

const initialState = {
  repos: [],
  loading: false,
  error: null
};

export const GlobalProvider = ({ children }) => {
  const [state, dispatch] = useReducer(repoReducer, initialState);
  return (
    <GlobalContext.Provider value={{ state, dispatch }}>
      {children}
    </GlobalContext.Provider>
  );
};
  1. 实现 reducer 函数

// reducers/repoReducer.js
export const repoReducer = (state, action) => {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, repos: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state; // 必须返回默认状态,避免报错
  }
};
  1. 在入口文件注入全局状态

// main.jsx
import { BrowserRouter as Router } from 'react-router-dom';
import { GlobalProvider } from '@/context/GlobalContext';

createRoot(document.getElementById('root')).render(
  <GlobalProvider>
    <Router>
      <App />
    </Router>
  </GlobalProvider>
);

(二)自定义 Hooks 封装业务逻辑

将数据获取逻辑抽离到自定义 Hooks,让组件更专注于 UI 渲染:

// hooks/useRepos.js
import { useEffect, useContext } from 'react';
import { GlobalContext } from '@/context/GlobalContext';
import { getRepos } from '@/api/repos';

export const useRepos = (username) => {
  const { state, dispatch } = useContext(GlobalContext);

  useEffect(() => {
    if (!username) return;

    dispatch({ type: 'FETCH_START' });
    (async () => {
      try {
        const res = await getRepos(username);
        dispatch({ type: 'FETCH_SUCCESS', payload: res.data });
      } catch (err) {
        dispatch({ type: 'FETCH_ERROR', payload: err.message });
      }
    })();
  }, [username]); // 依赖username,参数变化时重新请求

  return state;
};

组件中使用:

const RepoList = () => {
  const { id } = useParams();
  const { repos, loading, error } = useRepos(id); // 直接调用hooks获取数据

  if (loading) return <Loading />;
  if (error) return <div>Error: {error}</div>;

  return (
    <div>
      {repos.map(repo => (
        <div key={repo.id}>{repo.name}</div>
      ))}
    </div>
  );
};

四、组件设计:合理划分粒度

(一)组件分类与职责

  1. UI 组件:纯展示组件(无业务逻辑),如LoadingButton

// components/Loading.jsx
const Loading = () => <div className="spinner">Loading...</div>;
  1. 容器组件:处理业务逻辑(数据获取、状态管理),如RepoListRepoDetail
  2. 页面组件:由 UI 组件和容器组件组合而成,对应路由页面,如pages/Homepages/NotFound

(二)粒度划分原则

  • 单一职责:一个组件只做一件事(如Loading只负责加载动画)。

  • 可复用性:频繁出现的 UI 片段抽为组件(如列表项RepoItem)。

  • 避免过细:不要将一个按钮拆分为多个组件(过度设计反而增加复杂度)。

示例:拆分RepoList为更细粒度的组件:

// components/RepoItem.jsx(UI组件)
const RepoItem = ({ repo, username }) => (
  <Link to={`/users/${username}/repos/${repo.name}`}>
    <div className="repo-card">{repo.name}</div>
  </Link>
);

// pages/RepoList.jsx(容器组件)
const RepoList = () => {
  const { repos } = useRepos(id);
  return (
    <div className="repo-list">
      {repos.map(repo => (
        <RepoItem key={repo.id} repo={repo} username={id} />
      ))}
    </div>
  );
};

五、API 请求:规范化处理

(一)axios 封装与模块化

将所有请求集中到api目录,与组件解耦:

// api/repos.js
import axios from 'axios';

// 基础配置(可抽为公共axios实例)
const api = axios.create({
  baseURL: 'https://api.github.com/',
  timeout: 5000
});

// 获取用户仓库列表
export const getRepos = (username) => api.get(`users/${username}/repos`);

// 获取仓库详情
export const getRepoDetail = (username, repoName) => 
  api.get(`repos/${username}/${repoName}`);

安装 axios:pnpm i axios(或npm i/yarn add

六、项目架构:目录结构设计

src/
├── api/               # 所有API请求(repos.js、user.js等)
├── components/        # 公共UI组件(Loading.jsx、RepoItem.jsx)
├── context/           # 全局状态上下文(GlobalContext.jsx)
├── hooks/             # 自定义hooks(useRepos.js、useAuth.js)
├── pages/             # 页面组件
│   ├── Home.jsx
│   ├── RepoList.jsx
│   └── NotFound.jsx
├── reducers/          # reducer函数(repoReducer.js)
├── utils/             # 工具函数(format.js、validate.js)
├── App.jsx            # 路由配置
└── main.jsx           # 入口文件

(一)路径别名配置(Vite)

通过 Vite 配置@别名简化导入路径:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src') // @指向src目录
    }
  }
});

使用:import { getRepos } from '@/api/repos'(代替相对路径)

七、总结:从实战到精通

本项目通过react-router-dom实现路由管理、useContext+useReducer处理状态、自定义 hooks 封装逻辑,构建了一个可扩展的 GitHub 仓库管理应用。核心亮点:

  1. 性能优化:路由懒加载、按需请求数据。

  2. 代码组织:模块化 API、清晰的目录结构。

  3. 可维护性:组件职责分离、状态逻辑抽离。

React 开发的核心是 “组件化” 与 “声明式编程”,掌握这些实战技巧,你也能轻松应对大型项目开发~快去动手试试扩展功能(如仓库搜索、分页加载)吧!

深度链接新时代:从Firebase Dynamic Links到Apptrace的平滑迁移指南

作者 Xy910
2025年7月25日 18:41

随着Firebase Dynamic Links(FDL)服务即将终止,全球开发者急需可靠替代方案。Apptrace作为专业移动增长平台,提供了一站式深度链接解决方案,不仅完美承接FDL核心功能,更带来多项技术升级和业务增强。


技术架构解析

Apptrace采用混合架构设计,结合了客户端SDK和云端智能路由的优势:

  1. 智能路由层:全球边缘节点部署,自动选择最优网关
  2. 统一参数桥接:独创的Universal Parameter Bridge技术
  3. 状态持久化:三重持久化机制(Cookie+LS+Session)
  4. 无缝降级策略:智能应对各平台限制

快速迁移指南

Android集成

java复制// 1. 添加依赖
implementation 'io.apptrace:sdk:3.5.0'

// 2. 初始化
Apptrace.init(this, "YOUR_APP_KEY");

// 3. 获取安装参数
Apptrace.getInstallData(data -> {
    String campaign = data.get("campaign");
    // 处理参数
});

// 4. 处理深度链接
Apptrace.handleDeepLink(intent, data -> {
    // 处理唤醒参数
});

iOS集成

swift复制// 1. 安装Pod
pod 'ApptraceSDK'

// 2. 初始化
Apptrace.configure(withAppKey: "YOUR_APP_KEY")

// 3. 获取安装数据
Apptrace.getInstallData { data in
    if let campaign = data?["campaign"] as? String {
        // 处理参数
    }
}

// 4. 处理Universal Link
func application(_ application: UIApplication,
               continue userActivity: NSUserActivity,
               restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    Apptrace.handleUniversalLink(userActivity)
    return true
}

高级功能演示

场景化深度链接生成

javascript复制// 生成带场景参数的链接
Apptrace.generateLink({
    params: {
        scene: 'summer_promo',
        product_id: 'p98765',
        referrer: 'user123'
    },
    features: ['deferred_deep_linking', 'cross_platform'],
    callback: function(link) {
        console.log('深度链接:', link);
    }
});

全链路数据追踪

python复制# 通过Apptrace API获取归因数据示例
import apptrace

client = apptrace.Client(api_key="YOUR_API_KEY")
report = client.get_report(
    metrics=['installs', 'l7_retention', 'roas'],
    breakdown_by=['campaign', 'channel'],
    date_from='2025-08-01',
    date_to='2025-08-31'
)

print(report.to_csv())

性能基准测试

我们对Apptrace深度链接服务进行了严格测试(1000并发请求):

指标

Apptrace

行业平均

平均响应时间

65ms

210ms

错误率

0.05%

1.2%

冷启动跳转成功率

99.8%

95%

参数传递可靠性

99.99%

98%

迁移路线图

  1. 评估阶段(1-3天)

    • 审核现有FDL使用场景
    • 创建Apptrace测试账号
    • 验证基本功能
  2. 并行运行阶段(3-7天)

    • 双写FDL和Apptrace链接
    • 数据对比验证
    • 更新营销物料
  3. 全面切换阶段(1-2天)

    • 切换核心流量
    • 下线FDL代码
    • 团队培训
  4. 优化阶段(持续)

    • 利用高级分析功能
    • 优化用户获取漏斗
    • 场景化深度链接策略

技术答疑

Q:如何处理历史FDL链接?
A:Apptrace提供无缝重定向服务:

nginx复制location ~ ^/fdl/(.*) {
    return 301 https://at.apptrace.io/r/$1;
}

Q:如何保证参数安全性?
A:Apptrace提供多层安全防护:

java复制ApptraceConfig config = new ApptraceConfig()
    .enableEncryption(true)
    .setEncryptionMode("AES-256-GCM")
    .setParamTTL(3600);
Apptrace.init(this, config);

Q:支持哪些归因模型?
A:Apptrace支持多种归因模型:

  • Last Click
  • First Click
  • Linear
  • Time Decay
  • Position Based

可通过API自由选择:

python复制report = client.get_report(
    attribution_model='time_decay',
    lookback_window=30
)

成功案例

某头部电商应用迁移至Apptrace后关键指标提升:

  • 深度链接跳转成功率从93% → 99.6%
  • 用户获取成本降低22%
  • 分享回流率提升35%
  • 归因数据准确性提升至99.9%

框架实战指南-透明元素

作者 _未完待续
2025年7月25日 18:26

本文是系列文章的一部分:框架实战指南 - 基础知识

呼!上一章太精彩了。这一章我们放慢节奏吧:短小精悍。

让我们回想一下“动态 HTML”“组件简介”章节,我们在其中构建了我们的File FileList组件:

<!-- File.vue --><script setup>import { ref, onMounted, onUnmounted } from "vue";import FileDate from "./FileDate.vue";const props = defineProps(["isSelected", "isFolder", "fileName", "href"]);const emit = defineEmits(["selected"]);const inputDate = ref(new Date());// ...</script><template><buttonv-on:click="emit('selected')":style="isSelected? 'background-color: blue; color: white': 'background-color: white; color: blue'">{{ fileName }}<span v-if="isFolder">Type: Folder</span><span v-else>Type: File</span><FileDate v-if="!isFolder" :inputDate="inputDate" /></button></template>
<!-- FileList.vue --><script setup>// ...</script><template><!-- ... --><ul><li v-for="(file, i) in filesArray" :key="file.id"><Filev-if="onlyShowFiles ? !file.isFolder : true"@selected="onSelected(i)":isSelected="selectedIndex === i":fileName="file.fileName":href="file.href":isFolder="file.isFolder"/></li></ul><!-- ... --></template>

虽然理论上可行onlyShowFiles=true,但存在一个重大问题。让我们看一下使用以下代码渲染时的 HTML 效果filesArray

[{fileName: "File one",href: "/file/file_one",isFolder: false,id: 1,},{fileName: "Folder one",href: "",isFolder: true,id: 2,},];

因为我们的条件语句是在li渲染到 DOM 时,所以它可能看起来像这样:

<!-- ... --><ul><li><!-- File Component --><button>...</button></li><li></li></ul><!-- ... -->

虽然乍一看这似乎不是什么大问题,但事实上li我们的中间有一个空白ul,这带来了三个问题:

  1. 它将留下由您对其应用的任何样式所创建的空白空间li
  2. 任何辅助技术,例如屏幕阅读器,都会读出有一个空项目,这对于这些用户来说是一种令人困惑的行为。
  3. 任何从您的页面读取数据的搜索引擎都可能错误地认为您的列表是故意空的,从而可能影响您在网站上的排名。

解决这些问题需要“透明元素”的帮助。理想情况下,我们想要的是类似标签那样的东西,它渲染后什么也不显示。

考虑支持

这意味着如果我们可以在框架代码中生成类似以下伪语法的内容:

<ul><nothing><li><button>...</button></li></nothing><nothing></nothing></ul>

我们可以将其渲染到 DOM 本身中:

<ul><li><button>...</button></li></ul>

幸运的是,这三个框架都提供了实现这一点的方法,只是语法不同。让我们看看每个框架是如何实现的:

为了呈现类似于nothing元素的东西,我们可以使用template带有v-forv-if与之关联的元素。

<template><ul><template v-for="(file, i) of filesArray" :key="file.id"><li v-if="onlyShowFiles ? !file.isFolder : true"><File@selected="onSelected(i)":isSelected="selectedIndex === i":fileName="file.fileName":href="file.href":isFolder="file.isFolder"/></li></template></ul></template>

堆叠透明元素

需要简单说明的是,这些元素不仅可以nothing使用一次,而且可以连续堆叠在一起来做……嗯,没什么!

以下是一些呈现以下内容的代码示例:

<p>Test</p>

虽然其他框架与我们的伪语法有更 1:1 的映射nothing,但 Vue 由于重用了现有的 HTML<template>标签,因此采用了略有不同的方法。

默认情况下,如果您在 Vue 中除根之外的任何其他位置渲染template,它将不会在屏幕上渲染任何内容:

<template><template><p>Test</p></template></template>

值得一提的是,即使屏幕上什么都不显示,该template元素仍然位于 DOM 本身中,等待以其他方式使用。虽然解释 HTMLtemplate元素默认不渲染“什么”超出了本书的讨论范围,但这是预期的行为。

但是,如果添加v-forv-if或(我们将在“访问子项”一章v-slot介绍什么是),它将删除并仅渲染子项。v-slot<template>

这意味着:

<template><template v-if="true"><p>Test</p></template></template>

和:

<template><template v-if="true"><template v-if="true"><template v-if="true"><p>Test</p></template></template></template></template>

都将呈现以下 HTML:

<p>Test</p>

当然,这些规则不适用于根级template,它充当模板代码的容器。一开始可能会有点困惑,但多加练习就会明白。

挑战

现在我们了解了如何渲染透明元素(无论如何对 DOM 透明),让我们构建一个有用的例子。

也就是说,假设我们想要构建一个按钮栏,按钮之间有间隙:

image.png

要使用 HTML 执行此操作,我们可能有以下模板和样式:

<divstyle="    display: 'inline-flex',gap: 1rem;  "><button>Delete</button><button>Copy</button><button>Favorite</button><button>Settings</button></div>

但是,如果我们只想显示前三个按钮该怎么办?

  • 删除
  • 复制
  • 最喜欢的

仅当选择文件时?

让我们使用我们最喜欢的框架来构建它:

<!-- FileActionButtons.vue --><script setup>const emit = defineEmits(["delete", "copy", "favorite"]);</script><template><div><button @click="emit('delete')">Delete</button><button @click="emit('copy')">Copy</button><button @click="emit('favorite')">Favorite</button></div></template>
<!-- ButtonBar.vue --><script setup>import FileActionButtons from "./FileActionButtons.vue";const props = defineProps(["fileSelected"]);const emit = defineEmits(["delete", "copy", "favorite", "settings"]);</script><template><div style="display: flex; gap: 1rem"><FileActionButtonsv-if="props.fileSelected"@delete="emit('delete')"@copy="emit('copy')"@favorite="emit('favorite')"/><button @click="emit('settings')">Settings</button></div></template>

糟糕!渲染结果和我们预期的不一样!

image.png

div这是因为当我们在组件中使用 时FileActionButtons,它绕过了gapCSS 的属性。为了解决这个问题,我们可以使用方便的nothing元素:

因为 Vue 的根<template>可以支持多个元素,而不需要v-ifv-forv-slot,所以我们可以执行以下操作:

<!-- FileActionButtons.vue --><template><button @click="emit('delete')">Delete</button><button @click="emit('copy')">Copy</button><button @click="emit('favorite')">Favorite</button></template><!-- ... -->

浏览器是如何渲染页面的?概述浏览器渲染原理

作者 蜡笔熊
2025年7月25日 18:13

浏览器是如何渲染页面的?概述浏览器渲染原理

前言

上一篇文章:面试官:从「敲下一个 URL」到「页面出现在屏幕」都经历了什么?,最后一步页面出现在屏幕的时候浏览器如何渲染页面只是做了简要的讲解,这一篇文章将做详细讲解

当浏览器的网络线程收到 HTML 文档后,会产生一个渲染任务,并将其传递给渲染主线程的消息队列。

在事件循环机制的作用下,渲染主线程取出消息队列中的渲染任务,开启渲染流程。


整个渲染流程分为多个阶段,分别是: HTML 解析、样式计算、布局、分层、绘制、分块、光栅化、画

每个阶段都有明确的输入输出,上一个阶段的输出会成为下一个阶段的输入。

这样,整个渲染流程就形成了一套组织严密的生产流水线。

flowchart LR
    A[HTML解析\nHTML Parsing] --> B[DOM树构建\nDOM Tree]
    B --> C[样式计算\nStyle Calculation]
    C --> D[渲染树\nRender Tree]
    D --> E[布局\nLayout]
    E --> F[分层\nLayering]
    F --> G[绘制\nPaint]
    G --> H[分块\nTiling]
    H --> I[光栅化\nRasterize]
    I --> J[合成\nComposite]
    
    style A fill:#f9f,stroke:#333
    style J fill:#6f9,stroke:#333
    
    class A,B,C,D 解析阶段
    class E,F 布局阶段
    class G,H,I,J 绘制阶段
    
    classDef 解析阶段 fill:#f96,stroke:#333;
    classDef 布局阶段 fill:#6cf,stroke:#333;
    classDef 绘制阶段 fill:#9c6,stroke:#333;

1. 解析HTML - Parse HTML

解析DOM - Document Object Model

在说构建DOM树之前,我们首先需要知道,为什么要构建DOM树呢? 这是因为,浏览器是无法直接理解和使用HTML的,所以需要将HTML转化为浏览器能够理解的结构——DOM树。

每个HTML标签都会被浏览器解析成文档对象。HTML本质上就是一个嵌套结构,在解析时会把每个文档对象用一个树形结构组织起来,所有的文档对象都会挂在document上,这种组织方式就是HTML最基础的结构——文档对象模型(DOM),这棵树的每个文档对象就叫做DOM节点。

解析CSS 形成CSSOM - CSS Object Model

浏览器无法直接理解CSS代码,需要将其解析为浏览器可以理解的CSSOM树,跟DOM树类似,也是一个树形结构

至于为什么形成CSSOM,形成一个对象,是为了提供一种操作能力,提供给JS操作样式的能力

下面是一个小例子,展示解析css的过程

body h1 {
    color: red;
}

解析后为:

graph TD
    A[StyleSheetList] --> B[CSStyleSheet]
    B --> C[CSSStyleRule]
    B --> D[CSSStyleRule]
    C --> E[body h1]
    C --> F[style]
    F --> J[color: red]
    D --> G[...]
    D --> H[...]

如上图所示,CSS跟HTML一样,也有根节点

DOM树和CSS树分别是两颗描述不同层级的树,CSS有属于自己的样式表

CSS样式来源,有哪些样式表?

css样式表分为内部样式表外部样式表行内样式表以及浏览器默认样式表

graph TD
    A[StyleSheetList] --> B[CSStyleSheet]
    A --> C[CSStyleSheet]
    A --> D[CSStyleSheet]
    A --> E[CSStyleSheet]
    A --> F[...]

浏览器的默认样式表以 user agent stylesheet 为标识, 浏览器打开f12开发者工具,选择Element 元素选项,在Styles里面就能看到带有标识的样式

image-20250725163617048.png

以谷歌 Google浏览器为例,查看一下它的默认样式表都有哪些,可以通过浏览器的源码来看,chromium github网址网站上面已经开源,可以进行查看,这里就以html.css作为展示

819cf0acf542420a910641755c51dc84tplv-73owjymdk6-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA5pyv56S-5Yy6IEAg6Jyh56yU54aK_q75.webp

浏览器的默认样式把body div等标签设置为了 display: block; 所以它们才是块盒

根节点 StyleSheetList 对象就类似于 document 对象

根节点下面又有很多样式表(CSStyleSheet),样式表下面又有很多规则对象(CSSStyleRule)

// 下面的就相当于一个一个的规则对象
body h1 {
    color: red;
}

body h2 {
    color: green;
}

规则对象里面又有很多选择器(body h1)和样式(style),样式里面又包含多个具体的样式键值对(color: red;)

除了浏览器的默认样式外,其余的样式js都能进行操作

下面就是以百度的网页为例,在浏览器控制台中输入 document.styleSheets 得到的样式列表

819cf0acf542420a910641755c51dc84tplv-73owjymdk6-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA5pyv56S-5Yy6IEAg6Jyh56yU54aK_q75-1753418872955.webp

// 通过js操作添加一条规则,为页面所有的div加上边框
document.styleSheets[0].addRule('div', 'border: 1px solid red !important')

在将样式表中的CSS转换为属性对象之前,需要对样式进行标准化处理

// 标准化之前:
font-weight: bold;
color: red;

// 标准化之后:

font-weight: 700;
color: rgb(255, 0, 0);

还需要考虑一些样式继承、样式层叠等等因素

HTML解析的时候遇到CSS怎么办

为了提高效率,浏览器会启动一个预解析器先下载和解析CSS,如果主线程解析到link位置,此时外部的CSS文件还没有下载解析好,主线程不会等待,继续解析后续的HTML。这是因为下载和解析CSS的工作是在预解析线程中进行的。这就是CSS不会阻塞HTML解析的根本原因。

graph TD
    %% ---------- 主线程 ----------
    subgraph 主线程
        A[加载HTML] --> B[解析DOM]
        B --> C[遇到CSS/JS]
        C --> D[同步加载并执行]
        D --> E[构建CSSOM]
        E --> F[渲染树布局]
        F --> G[绘制页面]
    end

    %% ---------- 预解析线程 ----------
    subgraph 预解析线程
        H[预扫描HTML] --> I[提前加载CSS/JS]
        I --> J[预构建CSSOM]
        J --> K[缓存资源]
    end

    %% ---------- 线程交互 ----------
    C -.->|通知预加载| I
    K -.->|提供缓存| D

HTML解析过程中遇到JS代码怎么办?

与上面的流程图不一样,遇到JS代码的时候,HTML会暂停解析

渲染主线程遇到JS时必须暂停一切行为,等待下载执行完后才能继续,预解析线程可以分担一点下载JS的任务

如果主线程解析到script位置,会停止解析 HTML,转而等待 JS 文件下载好,并将全局代码解析执行完成后,才能继续解析 HTML。这是因为 JS 代码的执行过程可能会修改当前的 DOM 树,所以 DOM 树的生成必须暂停。这就是 JS 会阻塞 HTML 解析的根本原因。

为什么会暂停HTML解析呢? 是因为解析js的时候,js代码有可能对DOM进行改动

graph TD
    %% ---------- 主线程 ----------
    subgraph 主线程
        A[加载HTML] --> B[解析DOM]
        B --> C{遇到JS?}
        C -- 是 --> D[暂停HTML解析]
        D --> E[下载并执行JS]
        E --> B
        C -- 否 --> F[继续解析DOM]
        F --> G[构建CSSOM]
        G --> H[渲染树布局]
        H --> I[绘制页面]
    end

    %% ---------- 预解析线程 ----------
    subgraph 预解析线程
        J[预扫描HTML] --> K[发现JS/CSS链接]
        K --> L[提前下载JS/CSS]
        L --> M[预解析CSSOM]
    end

    %% ---------- 线程交互 ----------
    K -.->|提前下载| D
    M -.->|提供预解析CSSOM| G
    style C fill:#ffcccc,stroke:#ff0000

可以通过CDN、压缩脚本等方式来加速js脚本的加载。如果脚本文件中没有操作DOM的相关代码,就可以将js脚本设置为异步加载,给script标签添加async或defer属性来实现脚本的异步加载

defer

MDN中的关于defer的解释

The defer property of the HTMLScriptElement interface is a boolean value that controls how the script should be executed. For classic scripts, if the defer property is set to true, the external script will be executed after the document has been parsed, but before firing DOMContentLoaded event. For module scripts, the defer property has no effect.

大致意思就是 defer属性是一个布尔值,用来控制script怎样执行,如果defer属性被设置为true,外部脚本就会在document被解析后,在DOMContentLoaded事件之前执行,对于模块脚本,defer属性没有影响

async

MDN中关于async的解释

The async property of the HTMLScriptElement interface is a boolean value that controls how the script should be executed. For classic scripts, if the async property is set to true, the external script will be fetched in parallel to parsing and evaluated as soon as it is available. For module scripts, if the async property is set to true, the script and all their dependencies will be fetched in parallel to parsing and evaluated as soon as they are available.

async属性是一个布尔值,用来控制脚本执行方式,如果该属性被设置为true,外部脚本将在解析的同时并行获取,一旦可用就立即执行。对于模块脚本,如果async属性设置为true,该脚本及其所有依赖项将在解析的同时并行获取,一旦可用就立即执行。

多个带async属性的标签,不能保证加载的顺序;多个带defer属性的标签,按照加载顺序执行;

第一步解析HTML完成后,会得到 DOM 树和 CSSOM 树,浏览器的默认样式、内部样式、外部样式、行内样式均会包含在 CSSOM 树中。

2. 样式计算 Computed Style

主线程会遍历得到的DOM和CSSOM树,依次为树中的每个节点得到含有最终样式,称之为Computed Style

在这一过程中,很多预设值会变成绝对值,比如red会变成rgb(255,0,0);相对单位会变成绝对单位,比如em会变成px

这一步完成后,会得到一颗带有样式的树

在浏览器中也能找到Computed Style的展示, f12打开浏览器开发者工具,打开Element 元素选项卡就能找到Computed选项卡

image-20250725171531268.png

3. 布局阶段 Layout

布局树中会依次遍历 DOM 树的每一个节点,计算每个节点的几何信息。例如节点的宽高、相对包含块的位置。

结果很简单,但是过程很复杂,因为元素之间的样式是相互影响的,

会有很多因素导致DOM树和最终形成的Layout树不一致,大部分时候都不会一一对应

在DOM树中是DOM对象,布局树(Layout)中是一个一个的c++对象

比如:DOM树中有一个p元素是浮动的,那么就会在布局树中生成一个FloatingObject对象

下面有一些需要注意的地方:

  • 父元素的高度自动,根据子元素算出来,父元素浮动,宽度自动等等,窗口尺寸也会影响布局,包括层叠、继承、视觉格式化模型(盒模型(怪异、正常)、包含块(单独介绍)、BFC(块级上下文)、流式布局等等)

  • 有些宽高能算出来 有些宽高算不出来(auto、百分比)

  • 有些隐藏的元素(display: none;)不会出现在布局树中,比如link、head、meta等等元素被浏览器设置了display: none;,这些元素没有几何信息,所以不参与样式计算

    base, basefont, datalist, head, link, meta, noembed,
    noframes, param, rp, script, style, template, title {
      display: none;
    }
    
  • 还有一些元素设置了伪元素,伪元素不会存在DOM中,而会在布局树中存在

  • 行盒和块盒是不能相邻的(行盒、块盒指CSS层面,行级元素、块级元素指HTML层面)

    <p>111</p>
    222
    <p>333</p>
    
    最终形成的Layout树里
    p标签和111之间会加上一个匿名行盒
    222上面会加上一个匿名块盒,而这个匿名块盒下面再加上一个匿名行盒,匿名行盒下面才是222
    

其实说布局树(Layout树)的概念比较笼统,但是通过几行代码就了解了

document.body.clientWidth

document.body.clientHeight

还有 offsetWidth offsetHeight getComputedStyle 等等一切能获取到元素最终样式的操作 这些就是布局树暴露出来的信息 供操作

4. 分层 Layer

这一步浏览器考虑到要对Layout布局树进行一些优化,很多操作都会影响界面的变化,每次操作都要进行全部重新绘制,所以进行了分层

举个例子,一张报纸上面有很多个板块,这么多板块都已经界定好了位置、区域,如果其中一个板块发生了变化,那么就只需要操作这个板块就行了,不管是删除还是更新等等,这样都能最小化操作,只对该板块进行处理,提高效率

以Google浏览器为例,可以找到这些分层的信息

f12打开开发者工具,右上角菜单栏里面有一个More tools更多工具,下面有一个Layers,就可以看到当前网页的层级信息了

image-20250725124426655.png

还是以百度的官网为例:

image-20250725124400180.png

image-20250725124914846.png

进行旋转操作,就能清晰的看到页面的层次分布。

那么是不是每个板块都会进行分层呢?自然也不是,浏览器对于分层是有一定策略的

有一些特定的属性和元素可以实例化一个层,包括video和canvas标签,任何 CSS 属性为 opacity 、3D transform、will-change的元素,还有一些跟堆叠上下文有关的属性会影响分层策略,但是也只是影响,最终的分层还是要看浏览器

will-change属性能够较大程度的影响浏览器分层结果的,告知浏览器哪些属性有可能会变动,让浏览器自己去决策

<!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>Document</title>
    <style>
      .container {
        will-change: transform;
        width: 200px;
        background: #f40;
        color: #fff;
        margin: 0 auto;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <p>Lorem.</p>
      <p>Ab?</p>
      <p>Fugit.</p>
    </div>
  </body>
</html>

image-20250725130024086.png

但是will-change这个属性不要去滥用,如果页面渲染效率出了问题、卡顿,某个板块经常变动,不希望重绘的过多,这个时候才会考虑该属性

分层确实可以提高性能,但在内存管理方面成本较高

5.绘制 Paint

关键渲染路径中的最后一步是将各个节点绘制到屏幕上,其中第一次的绘制被称为首次有意义的绘制(是在发生最大的首屏布局更改与 Web 字体加载后进行的绘制)。在绘制或光栅化阶段,浏览器将在布局阶段计算的每个盒子转换为屏幕上的实际像素。

生成绘制指令集,并排序,先绘制什么后绘制什么。

完成绘制后,主线程将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程完成

6. 分块 Tiling

浏览器会开辟一个合成线程,合成线程首先对每个图层进行分块,将其划分为更多的小区域,会从线程池中拿取多个线程来完成分块工作处理每个分块的时候又会启动更多的分块子线程,处理完成之后再回归合成线程

7. 光栅化 Raster

使用GPU进程来处理光栅化,光栅化就是将每个块变成位图,优先处理靠近视口的块

8. 画 Draw

合成线程计算出每个位图在屏幕上的位置,交给GPU进行最终呈现

在画之前呢,要确定每个块的指引信息(quad),每一个块相对于屏幕的位置在哪里,再把生成的quad信息交给GPU进程,再交给硬件处理,再进行显示

指引会标识出每个位图应该画到屏幕的哪个位置,以及会考虑到旋转、缩放等变形。

transform之所以效率高是因为发生在合成线程,与渲染主线程无关

扩展:为什么合成线程要交给GPU处理一下,而不是直接交给硬件进行处理呢?

合成线程在渲染进程里,渲染进程是放在沙盒里面的,出于一些安全性考虑,将渲染进程放在沙盒里,与外界隔离

就算是在浏览网页的时候,渲染进程被攻击了也影响不了操作系统,不会导致计算机中病毒,除非是去下载安装一些程序之类的

因为在沙盒里面对硬件进行隔离,所以找不到硬件,自然也就无法直接交给硬件来处理了

最后 end

整理了很多资料,自己也学习到了不少

关于这篇文章的扩展知识点还有很多,堆叠上下文、BFC、包含块、重排、重绘等等,之后会一一总结出来

希望也能给你们带来一些帮助,有写的不好的地方或者需要补充的地方,请大家指出,Peace Yo!

参考:

  1. 渲染页面:浏览器工作原理
  2. 画了20张图,详解浏览器渲染引擎工作原理
  3. 图解浏览器渲染原理
  4. MDN中的关于defer的解释
  5. MDN中关于async的解释

使用pnpm + workspace搭建Monorepo项目架构🐱‍🐉

作者 如似如是
2025年7月25日 18:12

使用pnpm + workspace搭建Monorepo项目架构🐱‍🐉

前言

为什么用Monorepo模式?

Monorepo是一种单一代码仓库的开发模式,在一个代码仓库里面开发多个项目,方便共享组件和依赖,降低维护多个仓库的成本。

为什么用pnpm包管理工具?

pnpm不仅可以提高构建速度,解决幽灵依赖问题,还提供了对工作区(workspace)的支持,允许在单个代码库中管理多个项目或包。

项目环境

项目环境:node v22.16.0npm v10.9.2

项目技术栈:ViteVue3.5Piniavue-router4.5ScssTailwindcssvue-i18n

cursor编辑器

什么编辑器都可以,用自己最舒服的就好了。

拓展插件

  • Chinese (Simplified) (简体中文):中文语言支持
  • Auto Close Tag:自动闭合标签
  • Auto Complete Tag:自动补全标签
  • Prettier:代码风格检查
  • ESLint:代码格式化
  • Vue(Oficial):Vue3语法支持

安装包管理工具

整个项目使用pnpm构建

npm install pnpm -g

初始化Monorepo项目结构

创建项目根目录yummy-monorepo,在根目录运行pnpm init初始化pacakge.json文件

pnpm init

在根目录新建文件夹pakageslibraries,用于存储子包。

yummy-monorepo/
├──libraries/
├──packages/
└──package.json

新建yummy-utils共享包,

yummy-monorepo/
├──libraries/
│  └──yummy-utils/
│     ├──src/
│     │  └──index.js
│     └──package.json
├──packages/
└──package.json

yummy-utils文件夹中运行pnpm int初始化package.json文件,修改package.json里面的name@yummy/utils;修改package.json里面的main入口为src/index.js

{
  "name": "@yummy/utils",
  "version": "1.0.0",
  "description": "",
  "main": "src/index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "pnpm@10.11.0"
}

配置workspace

根目录新建一个 pnpm-workspace.yaml,将 packages 下所有的目录都作为包进行管理。

// pnpm-workspace.yaml
packages:
- 'packages/*'
- 'libraries/*'

Monorepo最终项目结构

yummy-monorepo/
├──libraries/
│  └──yummy-utils/
├──packages/
├──package.json
└──pnpm-workspace.yaml

共享子包@yummy-utils

-w把包安装在根目录

--workspace参数去安装共享子包,会去 workspace工作空间中找依赖项并安装,不去寻找npm的同名远程包

pnpm i @yummy/utils -w --workspace

package.json会出现依赖项

  "dependencies": {
    "@yummy/utils": "workspace:*"
  }

这时,其他子包项目就可以使用公共包@yummy/utils里的方法,import引入即可

import { } from '@yummy/utils'

安装公共/局部依赖

公共依赖

pnpm install xxx -w

局部依赖 cd指定项目路径,正常安装即可

搭建vue项目

初始化

进入pacakges路径,执行命令:pnpm create vite,选择VueTypeScript

> pnpm create vite
Project name:
│  yummy-main
│
◆  Select a framework:
│  ○ Vanilla
│  ● Vue
│  ○ ReactSelect a variant:
│  ● TypeScript
│  ○ JavaScript
│  ○ Official Vue Starter ↗
│  ○ Nuxt ↗
└

创建子包项目后目录结构如下

yummy-monorepo/
├──libraries/
│  └──yummy-utils/
├──packages/
│  └──yummy-main/
├──package.json
└──pnpm-workspace.yaml

Vue项目目录结构

yummy-main/
├──public/                       # 公共资源
├──src/
│  ├──api/                       # 接口
│  ├──assets/                    # 静态资源
│  │  ├──images/
│  │  └──svgs/
│  ├──config/                    # 全局配置
│  ├──directive/                 # 自定义插件
│  ├──hooks/                     # 函数钩子
│  ├──locales/                   # 国际化资源
│  ├──router/                    # 路由配置
│  ├──stores/                    # 全局状态管理
│  ├──styles/                    # 全局样式
│  ├──types/                     # 全局类型声明
│  ├──utils/                     # 工具库
│  ├──views/                     # 业务页面入口和常用模板
│  ├──App.vue                    # Vue 模板入口
│  ├──main.ts                    # Vue 入口 ts
│  └──permission.ts              # 路由守卫(路由全局控制)
├──.env                          # 环境变量
├──.gitignore                    # git忽略配置文件
├──index.html
├──pacakge.json
├──tsconfig.app.json
├──tsconfig.json
├──tsconfig.node.json
└──vite.config.ts

每日见闻之Three.js 官方demo 学习Buffergeometry

2025年7月25日 18:06
<!DOCTYPE html>
<html lang="en">
<head>
<title>three.js webgl - buffergeometry</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link type="text/css" rel="stylesheet" href="main.css">
</head>
<body>

<div id="container"></div>
<div id="info"><a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> webgl - buffergeometry</div>

<script type="importmap">
{
"imports": {
"three": "../build/three.module.js",
"three/addons/": "./jsm/"
}
}
</script>

<script type="module">

import * as THREE from 'three';

import Stats from 'three/addons/libs/stats.module.js';

let container, stats;

let camera, scene, renderer;

let mesh;

init();
animate();

function init() {

container = document.getElementById( 'container' );

//

camera = new THREE.PerspectiveCamera( 27, window.innerWidth / window.innerHeight, 1, 3500 );
camera.position.z = 2750;

scene = new THREE.Scene();
scene.background = new THREE.Color( 0x050505 );
scene.fog = new THREE.Fog( 0x050505, 2000, 3500 );

//

scene.add( new THREE.AmbientLight( 0xcccccc ) );

const light1 = new THREE.DirectionalLight( 0xffffff, 1.5 );
light1.position.set( 1, 1, 1 );
scene.add( light1 );

const light2 = new THREE.DirectionalLight( 0xffffff, 4.5 );
light2.position.set( 0, - 1, 0 );
scene.add( light2 );

//

const triangles = 160000;

const geometry = new THREE.BufferGeometry();

const positions = [];
const normals = [];
const colors = [];

const color = new THREE.Color();

const n = 800, n2 = n / 2;// triangles spread in the cube
const d = 12, d2 = d / 2;// individual triangle size

const pA = new THREE.Vector3();
const pB = new THREE.Vector3();
const pC = new THREE.Vector3();

const cb = new THREE.Vector3();
const ab = new THREE.Vector3();

for ( let i = 0; i < triangles; i ++ ) {

// positions

const x = Math.random() * n - n2;
const y = Math.random() * n - n2;
const z = Math.random() * n - n2;

const ax = x + Math.random() * d - d2;
const ay = y + Math.random() * d - d2;
const az = z + Math.random() * d - d2;

const bx = x + Math.random() * d - d2;
const by = y + Math.random() * d - d2;
const bz = z + Math.random() * d - d2;

const cx = x + Math.random() * d - d2;
const cy = y + Math.random() * d - d2;
const cz = z + Math.random() * d - d2;

positions.push( ax, ay, az );
positions.push( bx, by, bz );
positions.push( cx, cy, cz );

// flat face normals

pA.set( ax, ay, az );
pB.set( bx, by, bz );
pC.set( cx, cy, cz );

cb.subVectors( pC, pB );
ab.subVectors( pA, pB );
cb.cross( ab );

cb.normalize();

const nx = cb.x;
const ny = cb.y;
const nz = cb.z;

normals.push( nx, ny, nz );
normals.push( nx, ny, nz );
normals.push( nx, ny, nz );

// colors

const vx = ( x / n ) + 0.5;
const vy = ( y / n ) + 0.5;
const vz = ( z / n ) + 0.5;

color.setRGB( vx, vy, vz );

const alpha = Math.random();

colors.push( color.r, color.g, color.b, alpha );
colors.push( color.r, color.g, color.b, alpha );
colors.push( color.r, color.g, color.b, alpha );

}

function disposeArray() {

this.array = null;

}

geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( positions, 3 ).onUpload( disposeArray ) );
geometry.setAttribute( 'normal', new THREE.Float32BufferAttribute( normals, 3 ).onUpload( disposeArray ) );
geometry.setAttribute( 'color', new THREE.Float32BufferAttribute( colors, 4 ).onUpload( disposeArray ) );

geometry.computeBoundingSphere();

const material = new THREE.MeshPhongMaterial( {
color: 0xd5d5d5, specular: 0xffffff, shininess: 250,
side: THREE.DoubleSide, vertexColors: true, transparent: true
} );

mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );

//

renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setAnimationLoop( animate );
container.appendChild( renderer.domElement );

//

stats = new Stats();
container.appendChild( stats.dom );

//

window.addEventListener( 'resize', onWindowResize );

}

function onWindowResize() {

camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();

renderer.setSize( window.innerWidth, window.innerHeight );

}

//

function animate() {

const time = Date.now() * 0.001;

mesh.rotation.x = time * 0.25;
mesh.rotation.y = time * 0.5;

renderer.render( scene, camera );

stats.update();

}

</script>

</body>
</html>

image.png

Buffergeometry 主要就是下面这个三个参数

const positions = []; 顶点坐标 每三个点是一个坐标 每三个坐标是一个三角形 也就是一个顶点

//计算每一个三角形的三个顶点的坐标
const x = Math.random() * n - n2;
const y = Math.random() * n - n2;
const z = Math.random() * n - n2;
const ax = x + Math.random() * d - d2;
const ay = y + Math.random() * d - d2;
const az = z + Math.random() * d - d2;

const bx = x + Math.random() * d - d2;
const by = y + Math.random() * d - d2;
const bz = z + Math.random() * d - d2;

const cx = x + Math.random() * d - d2;
const cy = y + Math.random() * d - d2;
const cz = z + Math.random() * d - d2;

positions.push( ax, ay, az );
positions.push( bx, by, bz );
positions.push( cx, cy, cz );

const normals = []; 法向量 每个坐标点需要对应一个法向量 这个法向量是用来表示跟上面的三角形垂直的方向。

//计算法向量的代码
cb.subVectors( pC, pB );
ab.subVectors( pA, pB );
cb.cross( ab );

const colors = [];每一个坐标点对应一个颜色,三角形中的颜色通过渐变计算得出

//计算三角形每个顶点对应的颜色 三角形 三个角 所以push 3次
const vx = ( x / n ) + 0.5;
const vy = ( y / n ) + 0.5;
const vz = ( z / n ) + 0.5;

color.setRGB( vx, vy, vz );

const alpha = Math.random();

colors.push( color.r, color.g, color.b, alpha );
colors.push( color.r, color.g, color.b, alpha );
colors.push( color.r, color.g, color.b, alpha );

stats 统计性能的工具

面试官:回答我!在tsconfig配置了别名你为啥要在vite再配置一遍?嗯?

作者 前端啊白
2025年7月25日 18:04

在一个月黑风高的夜晚,面试官看到我简历上的:熟悉typescript,轻蔑一笑:“你说你熟悉ts,那我问你,你配置的tsconfig里moduleResolution的node选项和classic选项有何区别?你都在tsconfig配置了别名,为什么要在vite.config.js里的resolve.alias又配置一遍?

回想起create-vite-app一路回车的爽感,回想起Vite + Vue3上手就开发的便利,我滴下了一滴冷汗。开始疯狂的头脑风暴

bqb.webp

各位朋友,其实答案很简单,甚至各个前端er有手就会,只要稍微多想想。

Ts是什么,有什么用?

追根溯源,首先简略回想一下ts是个什么东西

  • js的超集,js被ts包含,所有js代码都是有效的ts代码
  • 静态类型检查,规避弱类型语言js可能发生的各种运行时错误
  • 增加代码感知,编程时获得属性提示
  • ...

可惜,浏览器他实在太专一了,他只认低级的ES5语法(不绝对,有些浏览器也支持ES6)。ts想在浏览器上运行还得经过一层编译,或者说转译,从ts转为js,再由babel将高级js语法转为低级js语法

Ts如何转为js?

当我们想要使用ts的时候,我们可以用pnpm add typescript为我们的项目加上ts依赖库。装上之后在node_modules/.bin文件夹就可以看到tsc可执行文件。这个时候我们可以利用npx tsc xxx来将ts文件转为js文件

为何要用tsconfig.json?

如果你仅需要编译一两个ts文件,直接用npx tsc或许是不错的选择,但当你的项目中包含了上百个ts文件,写一个集中的配置tsconfig或许是更明智的选择。

当你命令行输入tsc / npx tsc 的时候,tsc就会从执行的目录开始逐级向上找tscongig.json找到后按配置执行

中场休息

回想到这里,我已向面试官露出了微笑,tsconfig的别名、moduleResolution的设置,当然是帮助tsc运行的。vite.config.js的别名当然是帮助vite运行的,你说两者他搭嘎吗?他都不搭嘎,我设置两次别名很奇怪吗?

就在我胸有成竹,喷薄欲出的时候,面试官的冷笑让我冷静下来:我就这么说合适吗?合适吗?他会满意吗?还得说说原理吧...最好还要说说怎么能给他一次配置,两边通用吧...

我咬着牙,进行了第二次头脑风暴...

tsc相关的运行机制

首先我们想到,vite不仅仅是一个单纯的打包工具,他能编译ts、js,能处理scss、less,能处理静态文件,能启动本地服务...

换句话说,tsc只是vite play中的一环,而tsc只负责将ts转为js(影响范围可用include、exclude配置)

moduleResolution这个属性也很有意思,他的node策略模拟了 Node.js 的 CommonJS require() 解析算法,帮助代码找到他们的依赖项,而classic策略可以说是历史遗留问题,这是早期的策略,据我模糊不真实的记忆,设置classic之后再想要引用第三方库只能写../../node_modules/xxx 这种形式,实在过于反人类。至于为什么还能保留这个选项,大概是历史遗留的代码太多,直接弃用太痛了

别名其实也就是将设置的@替换成baseUrl + 'src' 然后tsc根据moduleResolution的策略寻找依赖项而已

至此,我们可以总结:tsconfig的别名和moduleResolution的作用其实就是

  1. 别名:将你设置的类似@ 的别名替换成baseUrl + 'src'
  2. moduleResolution:使用设定的策略,定义tsc寻找依赖文件的方式

二者都是用自己不同的方式帮助tsc寻找到正确的依赖文件

需要注意的是,tsc在编译时,即使找到了依赖项,也仅负责ts转为js的部分,并不会进行路径的改写,也就是说:你写的 import xxx from '@/xxx' 还是保留原样

vite相关的运行机制

tsc把ts一编译就直接原封不动的丢来了import xxx from '@/xxx' ,但是作为老妈子的vite 可不能这么不负责任,因为vite编译后的代码可是要在浏览器上运行的。浏览器知道什么,他太单纯了,不可能会认识 '@/' 这样子妖艳的 '码' 的,他只认识 './' 码 和 '../' 码。所以经过vite编译后的代码,才真正消除了'@'别名

与此相对应的,tsc是根据moduleResolution的策略来寻找三方库的,vite呢?

在node_modules下面有这样的目录:.vite/deps,这是vite利用esbuild进行预构建之后生成的第三方依赖代码,vite会将引用第三方库,比如lodash,直接改成写成引用node_modules/.vite/deps/lodash.js 这样一种形式,来帮助浏览器找到正确的依赖文件

tsconfig别名与vite别名同步

回忆至此,我早已了然。甚至于悠然回想起写过的一个vite小插件,帮助同步两个别名,原理很简单

利用node的fs模块读取项目中tsconfig的配置,当然也可以用load-tsconfig这个第三方库来帮助读取,读取之后提取paths属性,在插件的config钩子函数中根据paths属性生成vite规则定义的对应的alias别名,然后

return { resolve: { alias } }

等会儿就说给这个面试官听,让他也知道知道我的厉害

总结

  1. moduleResolution的node策略模拟了 Node.js 的 CommonJS require() 解析算法,可以通过require('axios')的方式直接找到第三方库,而classic策略则只能通过../../node_modules的方式
  2. tsconfig所定义的别名仅起作用在tsc的编译阶段,将别名替换成对应的路径,帮助tsc寻找到正确的依赖,编译后的文件并未进行别名替换,且无法直接运行于浏览器
  3. 而vite所定义的别名起作用在dev/build 阶段,也就是启动本地服务和打包的阶段,编译后的文件会直接进行别名替换改写,且直接运行于浏览器
  4. vite会进行依赖预构建,且在找寻第三方依赖时会将路径改写到node_modules/.vite/deps目录下
  5. tsconfig和vite的别名其实各不相关,仅在各自的阶段各司其职,但是由于开发的候的观感,让人以为其有联系
  6. tsconfig和vite的别名确实在很多情况下完全一致,可以利用vite插件来进行同步,仅配置一次tsconfig别名,同步到vite别名上
  7. 如有错误,还请指出,本小白立刻改正!

面试官:?请问你在做什么?怎么发了这么久的呆,还一脸痴汉笑?不想过面试了?

尤雨溪力荐 Vue-Plugins-Collection!Vue 生态 最强插件 导航!

2025年7月25日 18:00

Vue.js 生态最强插件导航站**「Vue.js Plugins Collection」**来了

今天,Vue.js 社区终于迎来一件大事:vue-plugins.org 正式上线。

作者 Jacob Andrewsky,Vue 伦敦峰会讲师、Vite 核心贡献者,带着 12 位社区 maintainer,闷头半年干成这件事——87 款经过真刀真枪项目验证的 Vue 插件,按场景、版本、体积、维护状况排好队,搜一下就能装。

尤雨溪第一时间转发点赞:

“终于有了一站式、高质量的 Vue 插件中心,社区又多了一件利器!”

以前找插件有多痛苦

  • 打开搜索 → 输入**“vue + 关键词”**→ 同名仓库一排
  • star、翻 issue、看最后提交时间 → 半小时后 npm install
  • 结果打包体积 +300 k,才发现只兼容 Vue 2

踩坑半小时,浪费的不止时间,还有上线节点的勇气。

现在,一个网址就够了

vue-plugins.org 把以上烦恼一次性干掉:

  • 87 款精选插件,全部跑过测试、标好版本、体积、维护状况
  • 支持关键词分类Vue 2/3 版本三重过滤
  • 一键直达 GitHub 与在线 DEMO,复制即用

官网怎么玩

  • 打开 https://www.vue-plugins.org
  • 搜索框输入需求关键词,一秒过滤
  • 点卡片 → 在线 playground 直接跑 DEMO → 右侧复制安装命令 → 回车搞定

数据实时更新

  • 已收录 87 个插件,平均每周新增 5–8
  • 87% 支持 Vue 3,并标注是否支持 TypeScriptSSRTreeshaking
  • bundle sizeNPM 周下载量、最近 commit 自动抓取,页面实时刷新

首批高赞插件(87 个里呼声最高的 12 个)

  • ESLint Plugin Vue:官方 ESLint 插件,模板、脚本、指令一把抓,规则覆盖最全。

  • Vue Router:官方路由库,单页应用导航就靠它。

  • Vue Test Utils:官方 Vue 3 组件测试工具箱,写单测不再挠头。

  • Pinia:官方推荐状态管理,模块化 + TypeScript 友好,Vuex 正式继任者。

  • VuexVue 2 时代的集中式状态管理;Vue 3 新项目请直接上 Pinia。

  • Vite Plugin Vue DevToolsVite 专属开发增强插件,调试体验直接起飞。

  • VueFire:官方 Firebase 绑定,实时数据一行代码搞定。

  • Vue Demi:社区神器,一套代码同时兼容 Vue 23,库作者必备。

  • VueUse300+ 个 Composition API 实用函数,日常开发瑞士军刀。

  • Swiper.js:移动端滑动王者,Vue 3 组件已就绪,轮播、画廊随拿随用。

  • Vue I18n:国际化老牌劲旅,Vue 2 / 3 通吃,多语言项目首选。

  • Vue Meta:管理页面 <head> 信息,SEO 和社交分享一步到位。

全部已经在官网标好 Vue 版本兼容、bundle size、周下载量,点卡片就能在线跑 DEMO

vue-plugins.org 直接搜名字,复制安装命令即可开干。

未来路线图

  • VSCode 插件:编辑器内直接检索、插入
  • CLI 集成:vue create 时一键勾选官方推荐库
  • 深度测评:每月邀请核心作者写实战报告

把找插件的时间省下来,写点真正重要的业务代码。

收藏这个网址:www.vue-plugins.org
下次别再让搜索引擎决定你的技术债。

手把手带你写一個MCP

2025年7月25日 18:00

什么是 MCP

MCP 是一个开放协议,它标准化了 AI 应用如何向大语言模型(LLMs)提供上下文。可以把 MCP 想象成 AI 应用的 USB-C 接口。就像 USB-C 为设备连接各种外设和配件提供了标准化方式一样,MCP 为 AI 模型连接不同的数据源和工具提供了标准化方式。

MCP 的核心采用 Client-Server 架构,一个应用程序可以连接多个 MCP Server。

只要 AI 应用实现了 MCP ,即可接入到任意的 MCP Server,扩展自身的能力。加入 MCP Server 后,工具调用的流程如下:

通过这种方式,MCP 实现了:

  1. 即插即用:AI 应用只需实现 MCP 协议,即可接入丰富的第三方工具生态
  2. 标准化:所有工具遵循统一的描述格式和调用方式
  3. 解耦工具与应用:工具提供者可以独立开发和维护工具,不需了解 AI 应用内部实现
  4. 资源共享:一次开发的工具可以被多个 AI 应用复用

引用:docs.cloudbase.net/ai/mcp/intr…

以下是一篇关于MCP(Model Context Protocol)的详细技术教程,涵盖定义、用途、使用方法、底层原理及完整操作流程:


MCP(Model Context Protocol)完全指南:从入门到实践

一、MCP是什么?

1.1 定义

MCP(Model Context Protocol,模型上下文协议)是由Anthropic公司提出的一种开放标准协议,旨在标准化大语言模型(LLM)与外部工具、数据源之间的交互方式。它通过客户端-服务器架构,让AI模型能够安全、动态地访问本地或远程资源(如文件系统、数据库、API等),打破传统AI的数据孤岛限制。

1.2 核心价值

  • 即插即用:开发者无需为每个工具单独开发集成代码,通过标准化协议快速连接外部能力。
  • 安全隔离:通过权限控制和沙箱机制,确保AI只能访问被授权的资源。
  • 灵活扩展:支持本地工具(如文件操作)和远程服务(如数据库查询)的无缝集成。

二、MCP有什么用?

2.1 典型应用场景

场景 示例
企业内部系统集成 让AI直接查询公司CRM、ERP系统,生成销售报告或处理订单。
开发者工具增强 在IDE中实现“AI自动修复代码Bug”“自动生成单元测试”等功能。
个人生产力工具 AI直接读写本地文件、管理日历、发送邮件,成为个人智能助手。
数据密集型任务 实时查询数据库、调用外部API(如天气、股票数据),辅助决策分析。

2.2 优势对比

传统方式 MCP方案
需手动导出数据供AI分析 AI直接访问实时数据源
每个工具需定制集成代码 通过标准化协议一键接入
权限管理复杂 统一的身份验证和访问控制机制

三、MCP底层原理

3.1 系统架构

MCP基于三层架构设计:

  1. MCP Host:运行AI模型的宿主应用(如Claude Desktop、Cursor)。
  2. MCP Client:Host内部的通信模块,负责与Server建立连接并转发请求。
  3. MCP Server:提供具体功能的轻量级服务端(如文件操作、API调用)。

3.2 通信机制

  • 本地通信:通过标准输入/输出(Stdio) 传输JSON-RPC 2.0消息,适用于本地工具(如文件读写)。
  • 远程通信:基于SSE(Server-Sent Events) 的HTTP长连接,支持流式数据传输(如数据库查询结果分页返回)。

3.3 功能封装

MCP Server通过装饰器定义三类能力:

  • Tools(工具) :可执行函数(如write_file(path, content))。
  • Resources(资源) :只读数据源(如read_config()返回配置文件内容)。
  • Prompts(提示模板) :预定义对话模板,引导AI生成特定任务指令。

四、如何使用MCP?

4.1 在支持MCP的工具上配置

步骤1:选择客户端工具
  • 推荐工具:Trae、Claude Desktop、Cursor、Cherry Studio、Windsurf。
  • 配置入口:通常在设置中找到“MCP Servers”选项。
步骤2:添加MCP服务
  • 本地服务示例(以文件操作为例):
{
  "mcpServers": {
    "file-server": {
      "command": "node",          // 启动命令
      "args": ["/path/to/mcp-file-server.js"],  // 脚本路径
      "env": {}                   // 环境变量(可选)
    }
  }
}
  • 远程服务示例(如GitHub API):
{
  "mcpServers": {
    "GitHub": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_PERSONAL_ACCESS_TOKEN": "your_token_here"  // 认证信息
      }
    }
  }
}
步骤3:验证连接
  • 在工具中输入自然语言指令(如“读取桌面上的test.txt文件”),观察AI是否返回预期结果。

4.2 开发自定义MCP服务(以Node.js为例)

示例1:

步骤1:初始化项目
npm install @modelcontextprotocol/sdk
mkdir mcp-demo && cd mcp-demo
步骤2:编写Server代码

创建simple-mcp-server.js

const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const { z } = require('zod');

// 創建MCP服務器實例
const server = new McpServer({
  name: "simple-server",  // 服務器名稱
  version: "1.0.0"
});

// 註冊一個簡單的工具:回顯消息
server.registerTool("echo", {
  title: "回顯工具",
  description: "返回用戶輸入的消息",
  inputSchema: {
    message: z.string().describe("要回顯的消息")
  }
}, async ({ message }) => {
  return {
    content: [{ type: "text", text: `您發送的消息是: ${message}` }]
  };
});

// 註冊一個簡單的資源
server.registerResource(
  "greeting",
  "greeting://hello",
  {
    title: "問候資源",
    description: "一個簡單的問候消息"
  },
  async (uri) => ({
    contents: [{
      uri: uri.href,
      text: "你好,這是一個MCP資源示例!"
    }]
  })
);

// 啟動服務器
const transport = new StdioServerTransport();
server.connect(transport).then(() => {
  console.log("MCP簡單服務器已啟動,等待連接...");
});
步骤3:运行服务

添加依賴

"dependencies": {
    "@modelcontextprotocol/sdk": "^1.7.0",
    "zod": "^3.25.76"
  }

運行:npm i
最後運行:

node simple-mcp-server.js

服务启动后,会在标准输入/输出流中监听请求。

步驟4:在trae中配置mcp

{
  "mcpServers": {
    "simple-server": {
      "command": "node",
      "args": [
      // 这个路径为,这个文件在电脑系统中的路径
        "D:/privateCode/AI/MCP/mcp-node-demo/simple-mcp-server.js"
      ],
      "env": {}
    }
  }
}

將配置的json內容粘貼到下面這裡:

image.png

步驟五:輸入對應內容,最好包括工具名,

下面的示例結果


示例二:用node 創建一個讀取文件的mcp

步驟和上面示例1完全相同,只需要更換server代碼和配置mcp時更換為對應文件名即可

mcp-file-server.js

// 文件:mcp-file-server.js
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const fs = require('fs');
const path = require('path');
const os = require('os'); // 添加 os 模塊以獲取桌面路徑
const { z } = require('zod'); // 引入 Zod

// 1. 创建MCP服务实例
const server = new McpServer({
  name: "file-server",  // 服务名称(需与配置中的名称一致)
  version: "1.0.0"
});

// 2. 定义工具:读取文件内容
server.registerTool("readFile", {
  title: "读取文件",
  description: "读取指定路径的文件内容",
  inputSchema: {
    path: z.string().describe("文件路径")  // 使用 Zod 的 z.string()
  }
}, async ({ path: filePath }) => {
  try {
    return {
      content: [{ type: "text", text: fs.readFileSync(filePath, "utf-8") }]
    };
  } catch (err) {
    throw new Error(`文件读取失败: ${err.message}`);
  }
});

// 添加新工具:读取桌面文件
server.registerTool("readDesktopFile", {
  title: "读取桌面文件",
  description: "读取桌面上指定文件的内容",
  inputSchema: {
    fileName: z.string().describe("文件名")  // 使用 Zod 的 z.string()
  }
}, async ({ fileName }) => {
  try {
    const desktopPath = path.join(os.homedir(), 'Desktop');
    const filePath = path.join(desktopPath, fileName);
    return {
      content: [{ type: "text", text: fs.readFileSync(filePath, "utf-8") }]
    };
  } catch (err) {
    throw new Error(`桌面文件读取失败: ${err.message}`);
  }
});

// 3. 定义工具:写入文件内容
server.registerTool("writeFile", {
  title: "写入文件",
  description: "写入内容到指定路径的文件",
  inputSchema: {
    path: z.string().describe("文件路径"),  // 使用 Zod 的 z.string()
    content: z.string().describe("文件内容") // 使用 Zod 的 z.string()
  }
}, async ({ path, content }) => {
  fs.writeFileSync(path, content);
  return {
    content: [{ type: "text", text: "文件写入成功" }]
  };
});

// 4. 启动服务(监听标准输入输出)
const transport = new StdioServerTransport();
server.connect(transport).then(() => {
  console.log("MCP文件服务已启动,等待大模型调用...");
});

依賴,這裡需要額外下載一個zod包

"dependencies": {
    "@modelcontextprotocol/sdk": "^1.7.0",
    "zod": "^3.25.76"
  }

測試:

桌面新建一個文件

文件內容:

這是測試文件的內容,用於測試MCP服務器的文件讀取功能。
算式:1+2+3 =?

在大模型勾選mcp後輸入:使用readFile工具,讀取桌面文件mcp-content-test.txt的內容,找出裡面的等式計算,並計算出結果,然後返回結果。

結果:

五、完整使用流程

5.1 操作链路图解

用户指令 → MCP Host(如Claude) → MCP Client → MCP Server(本地/远程) → 执行工具/查询数据 → 返回结果 → 生成回复

5.2 实战案例:让AI管理本地文件

  1. 配置服务:在Claude Desktop中添加本地file-server服务(参考4.1节配置)。
  2. 输入指令
“在桌面创建‘会议记录.txt’,写入‘今日议程:1.项目复盘 2.下周计划’”

3. AI执行流程

    • 解析指令 → 调用file-serverwrite_file工具 → 写入文件 → 返回成功状态 → 生成确认回复。

六、常见问题与解决方案

问题现象 可能原因 解决方案
AI无法调用本地工具 服务未启动或路径错误 检查commandargs配置,确认服务进程运行
返回“权限不足”错误 环境变量未正确设置 env字段中填入API密钥或认证信息
远程服务连接超时 SSE Endpoint配置错误 检查网络和URL,确认服务端支持CORS

七、学习资源推荐

  1. 官方文档Anthropic MCP GitHub
  2. 工具市场Smithery.ai(搜索现成MCP服务)
  3. 视频教程:YouTube搜索“MCP Protocol Tutorial”(推荐霍格沃兹测试学院系列)

通过以上步骤,你可以快速掌握MCP的核心技术,并在实际项目中实现AI与外部系统的无缝集成。无论是提升个人生产力还是构建企业级智能解决方案,MCP都将成为你的得力助手!

🔐 JWT 登录鉴权实战:从前端到Mock的全流程解析 🚀

作者 绅士玖
2025年7月25日 17:59

前言

大家好!今天我们要聊一个既实用又有趣的话题——JWT(JSON Web Token)登录鉴权。如果你曾经好奇过网站是如何记住你的登录状态,或者为什么有些页面需要登录才能访问而有些不需要,那么这篇文章就是为你准备的!我们将通过一个完整的React项目代码来深入理解JWT的工作原理和实现方式。准备好了吗?让我们开始这段奇妙的技术之旅吧!🌈

🌟 什么是JWT?为什么我们需要它?

想象一下你去了一家会员制的咖啡店 ☕。第一次光顾时,你需要出示身份证办理会员卡。之后每次再来,只需要出示这张会员卡,店员就能认出你,知道你是尊贵的会员。JWT就像是这张"数字会员卡"——它是互联网世界中的身份凭证!

在传统的Web开发中,我们常用Session和Cookie来管理用户状态,但这种方式有一些局限性,比如服务器需要存储会话信息,对于分布式系统不太友好。而JWT则是一种无状态的认证机制,所有的用户信息都存储在客户端的一个加密令牌中,服务器只需要验证这个令牌的有效性即可。这就像是你带着一张防伪的会员卡,咖啡店不需要查账本,只需要看看卡的真伪和内容就知道你是谁了!

🔍 JWT 工作原理

JWT 的核心原理就像发放一张防伪电子身份证🪪!当用户首次登录成功后,服务器会精心制作一个包含用户信息的JSON对象(我们称之为"声明Claims"),并通过数字签名技术将其封装成令牌返还给客户端。这个流程就像公安局为公民制作身份证:记录你的基本信息(姓名、角色等)➕ 加盖防伪印章 🏛️。

举个生动例子 🌰:

{
  "姓名": "张三",
  "角色": "超级管理员",
  "签发机关": "https://api.example.com",
  "有效期至": "2025-12-31T23:59:59"
}

这个JSON对象会经过两次关键处理:

  1. Base64编码 → 把数据转换成可传输的字符串格式(就像把中文翻译成摩斯密码 📡)
  2. 数字签名 → 用只有服务器知道的密钥(secret key)生成防伪标记(类似人民币上的水印防伪技术 💵)

🤖 后续每次请求时,客户端只需在HTTP头部的Authorization字段携带这个令牌(就像出示身份证)。服务器会:

  1. 检查签名是否有效 → 验证"防伪标记"是否被篡改 🔎
  2. 核对有效期 → 确认身份证是否过期 ⏰
  3. 直接读取令牌内的信息 → 无需查询数据库 📊

这种机制带来三大技术优势 💎:

  1. 无状态性:服务器不需要维护会话存储,天然支持分布式架构(就像超市收银员只需验钞不需记录每张钞票的流通轨迹 �️)
  2. 安全传输:签名机制确保数据不可篡改(类似快递包裹的防拆封标签 🏷️)
  3. 信息自包含:令牌自身携带认证所需的所有元数据(好比电子机票同时包含乘机人、航班号和登机口信息 ✈️)

特别注意 ⚠️:JWT的Payload仅是Base64编码(类似把明文写在明信片上 📨),因此绝不应存放密码、支付信息等敏感数据!重要系统建议结合HTTPS加密通道传输。

🔢 JWT 数据结构详解

📜 JWT 的物理结构

一个完整的 JWT 是由三个部分通过点号(.)连接组成的字符串,格式如下:

Header.Payload.Signature

上面是基本的结构,下面是一个示例:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

(注:实际使用时是连续字符串,此处换行仅为展示方便)

image.png

🧩 三部分组件解析

3.1 Header(头部)

  • 核心作用:描述令牌的元数据

  • 标准结构:

{
  "alg": "HS256",   // 必填:签名算法(Algorithm)
  "typ": "JWT"      // 必填:令牌类型(Type)
}
  • 常见算法选项:

    • HS256:HMAC SHA256(默认)
    • RS256:RSA SHA256
    • ES256:ECDSA SHA256
  • 处理流程:

  1. 将JSON对象转换为字符串
  2. 进行Base64URL编码

3.2 Payload(负载)

  • 核心作用:携带实际传输的声明(claims)数据

  • 标准声明字段(建议但不强制):

字段名 全称 说明
iss Issuer 签发机构
exp Expiration Time 过期时间(Unix时间戳)
sub Subject 主题(通常为用户ID)
aud Audience 接收方
nbf Not Before 生效时间
iat Issued At 签发时间
jti JWT ID 唯一标识
  • 自定义数据示例:
{
  "user_id": "U_2048",
  "role": ["admin", "operator"],
  "department": "研发中心"
}
  • 安全提醒: ❗ 负载数据仅进行Base64编码,未加密! ❗ 禁止存放敏感信息(密码、密钥等)

3.3 Signature(签名)

  • 生成公式:
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret_key
)
  • 验证流程:
  1. 重新计算前两部分的签名
  2. 比对接收到的签名值
  3. 任何字符修改都会导致签名失效

3.4 Base64URL 编码

  • 与标准Base64的区别:
原字符 Base64URL替换
+ -
/ _
= 省略
  • 转换示例: 原始Base64:
aGVsbG8=+/world

转换后:

aGVsbG8-_world

JWT 的使用方式

客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。

此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。

Authorization: Bearer <token>

另一种做法是,跨域的时候,JWT 就放在 POST 请求的数据体里面。

项目结构

jwt-demo/
├── mock/                     # 模拟数据/API
├── node_modules/             # 项目依赖
├── public/                   # 静态资源
├── src/
│   ├── api/                  # API请求相关
│   │   ├── config.js         # API配置
│   │   └── user.js           # 用户相关API
│   ├── assets/               # 静态资源(图片、字体等)
│   ├── components/           # 公共组件
│   │   ├── NavBar/           # 导航栏组件
│   │   └── RequireAuth/      # 路由鉴权组件
│   ├── store/                # 状态管理
│   │   └── user.js           # 用户状态
│   ├── views/                # 页面组件
│   │   ├── Home/             # 首页
│   │   ├── Login/            # 登录页
│   │   └── Pay/              # 支付页
│   ├── App.css               # 全局样式
│   ├── App.jsx               # 根组件
│   ├── index.css             # 入口样式
│   └── main.jsx              # 应用入口
├── .gitignore                # Git忽略配置
└── eslint.config.js          # ESLint配置

🛠️ 项目配置:搭建JWT的舞台

首先让我们看看项目的vite.config.js文件,这是整个项目的配置中心:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteMockServe } from 'vite-plugin-mock'
import path from 'path'

export default defineConfig({
  plugins: [
    react(),
    viteMockServe({
      mockPath: 'mock',
      localEnabled: true,
    })
  ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
})

这里有几个关键点值得注意:我们使用了vite-plugin-mock来创建模拟的API接口,这样在前端开发时不需要等待后端接口完成就能进行开发工作。resolve.alias配置让我们可以通过@符号轻松引用src目录下的文件,避免了繁琐的相对路径。这就像是给你的项目装上了GPS导航 🗺️,让你在代码的海洋中永远不会迷路!

🔮 神奇的Mock数据:没有后端的后端

使用下面的命令下载依赖:

pnpm i jsonwebtoken

在我们的login.js mock文件中,我们模拟了登录和获取用户信息的接口:

// 导入jsonwebtoken库,用于生成和验证JWT
import jwt from 'jsonwebtoken';

// 定义JWT签名密钥(实际项目中应该使用环境变量存储,不要硬编码)
const secret = '**&……¥……#&*12423afa'; // 密钥(建议使用更安全的随机字符串)

// 导出接口配置数组
export default [
  {
    // 接口路径
    url: '/api/login',
    // HTTP方法
    method: 'post',
    // 接口处理函数
    response: ({ body }) => {
      // 从请求体中解构用户名和密码
      const { username, password } = body;
      
      // 简单的认证逻辑(实际项目应该查询数据库)
      if (username !== 'admin' || password !== '123456') {
        // 认证失败返回错误信息
        return { 
          code: 1, 
          message: '用户名或密码错误' 
        };
      }
      
      // 认证成功,生成JWT
      const token = jwt.sign(
        // JWT payload部分,包含用户信息
        { 
          user: { 
            id: '001',       // 用户ID
            username: 'admin' // 用户名
          } 
        }, 
        // 签名密钥
        secret,
        // 配置选项:设置token过期时间为1小时
        { expiresIn: '1h' }
      );
      
      // 返回成功响应
      return {
        token, // 生成的JWT token
        data: { 
          id: '001',       // 用户ID
          username: 'admin' // 用户名
        }
      };
    }
  },
  // 其他接口可以在这里继续添加...
];

这段代码就像是一个魔术师 🎩 的表演!当用户发送用户名和密码时,我们检查是否是"admin/123456"这个固定组合(实际项目中当然要连接数据库验证啦)。如果验证通过,我们就使用jsonwebtokensign方法生成一个令牌。这个令牌包含了用户的基本信息,用密钥加密,并且设置了1小时的有效期。生成的令牌会返回给前端,前端之后每次请求都要带着这个令牌,就像出示会员卡一样!

🌉 桥梁:Axios的全局配置

config.js中,我们配置了axios这个HTTP客户端:

import axios from 'axios'

axios.defaults.baseURL = "http://localhost:5173/api"

axios.interceptors.request.use((config) => {
  const token = localStorage.getItem('token') || '';
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config
})

axios.interceptors.response.use(res => {
  console.log('/////')
  return res
})

export default axios

这段代码就像是建造了一座连接前端和后端的桥梁 🌉。baseURL设置了API的基础地址,这样我们写请求时就不用每次都写完整的URL了。更有趣的是拦截器的使用——请求拦截器会在每次请求前自动运行,我们从localStorage中取出token(如果有的话),然后把它添加到请求头的Authorization字段中,前面加上"Bearer "前缀。这就像是每次寄信时自动在信封上写上回邮地址 📮!

🏪 全局状态管理:Zustand的妙用

user.js的store文件中,我们用Zustand管理用户状态:

import { create } from 'zustand'
import { doLogin } from '../api/user'

export const useUserStore = create(set => ({
  user: null,
  isLogin: false,
  login: async ({username="", password=""}) => {
    const res = await doLogin({username, password})
    const { token, data: user } = res.data
    localStorage.setItem('token', token)
    set({ user, isLogin: true })
  },
  logout: () => {
    localStorage.removeItem('token')
    set({ user: null, isLogin: false })
  }
}))

Zustand就像是React应用中的全局记事本 📒,任何组件都可以读取和修改里面的状态。当用户登录时,我们调用API获取token和用户信息,把token存入localStorage,同时更新状态;退出登录时则清除这些信息。这种集中式的状态管理让我们的应用变得井井有条,再也不用担心状态分散在各个组件中了!

🚦 路由守卫:保护你的秘密花园

App.jsx中,我们设置了路由和权限控制:

import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
import NavBar from './components/NavBar'

const Home = lazy(() => import('./views/Home'))
const Login = lazy(() => import('./views/Login'))
const Pay = lazy(() => import('./views/Pay'))
const RequireAuth = lazy(() => import('./components/RequireAuth'))

function App() {
  return (
    <>
      <NavBar />
      <Suspense fallback={<div>Loading....</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/login" element={<Login />} />
          <Route path="/pay" element={
            <RequireAuth>
              <Pay />
            </RequireAuth>
          } />
          <Route path="*" element={<div>Not Found</div>} />
        </Routes>
      </Suspense>
    </>
  )
}

这里的RequireAuth组件就像一个尽职的保安 🚧,它会检查用户是否登录。如果用户尝试访问需要登录的页面(如支付页面)但没有登录,就会被重定向到登录页面。我们还使用了React的lazySuspense来实现组件的懒加载,这就像是按需上菜 🍽️,只有当用户真正需要时才加载对应的代码,大大提高了应用的初始加载速度!

完整的代码我放在:jwt-demo仓库了,大家可以点开看看。

🔄 完整的工作流程:从登录到鉴权

现在让我们把所有这些部分串联起来,看看JWT认证的完整流程是怎样的:

  1. 用户登录:用户在登录页面输入用户名和密码,点击登录按钮
  2. 发送请求:前端通过axios发送POST请求到/api/login,携带用户名和密码
  3. 验证凭证:Mock服务器(或真实后端)验证用户名和密码
  4. 生成令牌:验证通过后,服务器使用JWT生成令牌,包含用户信息和过期时间
  5. 返回令牌:服务器将令牌返回给前端,前端将其存储在localStorage中
  6. 后续请求:之后每次请求,axios拦截器会自动将令牌放入请求头
  7. 验证令牌:服务器收到请求后验证令牌的有效性
  8. 返回数据:如果令牌有效,服务器返回请求的数据;否则返回错误

这个过程就像是一场精心编排的芭蕾舞 💃,每个环节都紧密配合,确保既安全又高效。

🧠 深度思考:JWT的安全考量

虽然JWT非常强大,但使用时也需要注意安全性问题:

  • 密钥保护:JWT的安全性依赖于密钥的保密性,就像保险箱的安全依赖于密码一样。我们的示例中使用了一个固定字符串作为密钥,但在生产环境中应该使用更复杂的方式管理密钥,并且定期更换。

  • 令牌存储:我们把令牌存在localStorage中,这种方式虽然简单但容易受到XSS攻击。对于安全性要求高的应用,可以考虑使用HttpOnly的Cookie,虽然这会带来一些CSRF的挑战。

  • 令牌过期:我们设置了1小时的过期时间,这可以防止令牌被长期滥用。对于敏感操作,还可以设置更短的过期时间,或者实现刷新令牌机制。

  • 信息加密:虽然JWT的payload是base64编码的,可以解码查看内容,但不要在其中存储敏感信息。如果需要存储敏感信息,应该先加密。

🚀 性能优化:让JWT飞得更快

JWT虽然方便,但随着应用规模扩大,也可能遇到性能问题。这里有几个优化建议:

  1. 精简令牌:JWT会被包含在每个请求的头中,所以应该尽量保持小巧,只包含必要的信息。

  2. 黑名单处理:虽然JWT通常是无状态的,但某些场景下(如用户登出)可能需要使某些令牌失效。可以实现一个轻量级的黑名单机制。

  3. CDN缓存:对于静态资源,可以设置CDN缓存,避免每次请求都验证JWT。

  4. Web Workers:可以将JWT的验证工作放在Web Worker中,避免阻塞主线程。

🌈 未来展望:JWT的替代方案

虽然JWT非常流行,但技术世界总是在不断进化。一些新兴的认证方案也值得关注:

  • PASETO:比JWT更安全的替代方案,解决了JWT的一些设计缺陷。

  • WebAuthn:基于生物识别的认证标准,可以实现无密码登录。

  • OAuth 2.0/OpenID Connect:对于需要第三方认证的场景,这些协议提供了更完整的解决方案。

🎉 结语:JWT的强大与优雅

通过这个完整的项目,我们看到了JWT如何在现代Web应用中实现安全、高效的认证机制。从Mock服务器的搭建,到axios的全局配置,再到Zustand的状态管理和React路由的权限控制,每一个环节都展示了JWT的灵活性和强大功能。

JWT就像是一把瑞士军刀 🔪,小巧但功能强大。它解决了Web开发中的关键问题:如何在无状态的HTTP协议上维护用户状态。虽然它并非完美无缺,但在大多数场景下都是一个极佳的解决方案。

希望这篇文章能帮助你深入理解JWT的工作原理和实现方式。现在,是时候把这些知识应用到你的项目中了!记住,好的认证系统就像好的门锁——用户几乎注意不到它的存在,但它时刻保护着用户和数据的安全。祝你编码愉快,愿你的应用既安全又用户友好!💻✨

(注:本文基于提供的代码示例进行了详细解析,实际应用时请根据项目需求调整安全策略和实现细节。)

从零复刻网易云音乐年度总结H5:技术细节与用户体验的完美结合

作者 FogLetter
2025年7月25日 17:45

大家好!今天我要和大家分享一个非常有意思的项目——网易云音乐年度总结H5的复刻过程。作为一名前端开发者,我一直对这类病毒式传播的H5页面充满好奇,这次终于有机会拆解它的实现原理,并用自己的方式重现出来。

为什么选择复刻网易云H5?

网易云音乐的年度总结H5几乎每年都会刷屏朋友圈,它成功的原因不仅仅在于内容吸引人,更在于其精妙的技术实现和极致的用户体验设计。作为一个技术人,我特别想了解:

  1. 它是如何在移动端实现流畅的滑屏体验的?
  2. 音乐播放控制有哪些细节需要注意?
  3. 如何适配不同尺寸的移动设备?
  4. 那些酷炫的动画效果是如何实现的?

带着这些问题,我开始了这次复刻之旅。

项目基础架构

首先,我们来看HTML基础结构:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>网易云音乐年终总结h5</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/4.0.2/css/swiper.min.css">
    <link rel="stylesheet" href="./common.css">
</head>
<body>
    <audio id="j-bgm" src="./bgm.mp3"></audio>
    <div class="next-view-c"></div>
    <div class="music-btn off"></div>
    <!-- 滑页操作 -->
    <div class="swiper-container">
        <div class="swiper-wrapper">
            <div class="swiper-slide">
                <div class="view index">
                    <div class="logo-c"></div>
                </div>
            </div>
            <div class="swiper-slide"></div>
            <div class="swiper-slide"></div>
            <div class="swiper-slide"></div>
            <div class="swiper-slide"></div>
        </div>
    </div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/4.0.2/js/swiper.min.js"></script>
    <script>
        const audio = document.getElementById('j-bgm');
        const musicBtn = document.querySelector('.music-btn');
        musicBtn.addEventListener('click',function () {
            if (audio.paused) {
                audio.play();
            } else {
                audio.pause();
            }
            musicBtn.classList.toggle('off');
        })

        // 滑屏
        new Swiper('.swiper-container',{
            direction: 'vertical'
        })
    </script>
</body>
</html>

这里有几个关键点:

  1. Viewport设置<meta name="viewport">确保页面在移动设备上正确缩放
  2. Swiper库:使用这个流行的轮播库实现滑屏效果
  3. Audio元素:背景音乐的控制核心
  4. 音乐按钮:右上角的音乐控制按钮
  5. 下一页指示器:底部的滑动提示

移动端适配的艺术

移动端开发最大的挑战之一就是适配各种尺寸的设备。网易云H5采用了"设计稿尺寸标准"的方法:

* {
    margin: 0;
    padding: 0;
}
html,body {
    width: 100%;
    height: 100%;
}

这里有几个关键技巧:

  1. 设计稿基准:通常以iPhone为基准,设计稿宽度为750px(对应iPhone的物理宽度375pt,Retina屏下1pt=2px)
  2. Retina屏幕处理:高清屏幕需要考虑像素比,确保图片清晰
  3. 全屏布局:html和body设置为100%宽高,确保页面占满整个屏幕

音乐控制的精妙实现

音乐控制是这类H5的灵魂所在,我们来看看如何优雅地实现:

const audio = document.getElementById('j-bgm');
const musicBtn = document.querySelector('.music-btn');

musicBtn.addEventListener('click',function () {
    if (audio.paused) {
        audio.play();
    } else {
        audio.pause();
    }
    musicBtn.classList.toggle('off');
})

技术要点

  1. 现代浏览器的限制:大多数现代浏览器不允许自动播放音频,必须由用户交互触发
  2. 状态切换:通过检查audio元素的paused属性来判断当前状态
  3. CSS类切换:使用classList.toggle方法切换按钮样式

对应的CSS也很巧妙:

.music-btn {
    /* 基础样式 */
    background: url(./assert/close.png) no-repeat center / cover;
}
.music-btn.off {
    /* 关闭状态样式 */
    background: url(./assert/music.png) no-repeat center / cover;
}

这种实现方式体现了面向对象的多态思想:基础类定义共同样式,状态类处理特定情况下的变化。

滑屏效果的核心技术

滑屏效果是这类H5的标志性特征,我们使用Swiper库实现:

new Swiper('.swiper-container',{
    direction: 'vertical'  // 垂直方向滑动
})

为什么选择Swiper

  1. 轻量级且功能强大
  2. 支持垂直滑动
  3. 提供丰富的API和回调函数
  4. 良好的移动端兼容性

CSS部分确保滑屏容器占满全屏:

.swiper-container {
    width: 100%;
    height: 100%;
}
.swiper-slide {
    background-color: green;
}
/* 奇数项不同背景色 */
.swiper-slide:nth-child(odd) {
    background-color: pink;
}

用户体验的魔鬼细节

网易云H5的成功很大程度上归功于它对用户体验的极致追求。我们来看几个关键细节:

1. 下一页指示器

.next-view-c {
    background: url('指示器图片');
    /* 居中定位 */
    left: 50%;
    transform: translate3d(-50%,0,0);/* 开启GPU加速 */
}

技术亮点

  1. 使用translate3d而非translateX,触发GPU加速,提升动画性能
  2. 绝对定位结合left:50%和transform实现水平居中
  3. 精心设计的动画提示用户滑动操作

2. 首屏设计

.view.index {
    background: url('背景图');
    background-position: center;
    background-size: cover;
}
.logo-c {
    background: url('logo图') no-repeat;
    background-size: 100%;
}

设计思考

  1. 首屏必须快速加载且吸引眼球
  2. background-size: cover确保背景图适配各种尺寸
  3. 重要元素使用绝对定位精确控制位置

遇到的坑与解决方案

在复刻过程中,我遇到了几个典型的"坑":

  1. 音频自动播放问题

    • 问题:iOS和部分Android浏览器禁止自动播放
    • 解决:必须在用户交互(如点击)后触发audio.play()
  2. 滑动卡顿

    • 问题:低端机上滑动不流畅
    • 解决:使用transform代替top/left,开启GPU加速

总结

通过这次复刻,我深刻体会到网易云音乐H5成功背后的技术细节:

  1. 移动优先的设计理念:从设计稿到实现都围绕移动端优化
  2. 极致的性能追求:每一个CSS属性都经过精心选择
  3. 以用户为中心:把用户当作小白,提供最直观的交互
  4. 情感化设计:音乐与视觉的完美结合创造情感共鸣

希望这篇分享对大家有所启发!如果你也对H5开发感兴趣,不妨从复刻这个项目开始,逐步探索更多移动Web开发的奥秘。

最后留个思考题:你知道为什么网易云选择垂直滑动而非水平滑动吗?欢迎在评论区分享你的看法!

❌
❌